Skip to main content

rustrails_model/validations/
numericality.rs

1use serde_json::Value;
2
3use super::{Validator, ValidatorOptions};
4use crate::errors::{ErrorType, Errors};
5
6/// Validates that an attribute contains a numeric value and optional comparisons.
7#[derive(Debug, Clone, Default)]
8pub struct NumericalityValidator {
9    /// Restricts the value to integers.
10    pub only_integer: bool,
11    /// Requires the value to be greater than the provided bound.
12    pub greater_than: Option<f64>,
13    /// Requires the value to be greater than or equal to the provided bound.
14    pub greater_than_or_equal_to: Option<f64>,
15    /// Requires the value to be less than the provided bound.
16    pub less_than: Option<f64>,
17    /// Requires the value to be less than or equal to the provided bound.
18    pub less_than_or_equal_to: Option<f64>,
19    /// Requires the value to equal the provided bound.
20    pub equal_to: Option<f64>,
21    /// Requires the value to differ from the provided bound.
22    pub other_than: Option<f64>,
23    /// Requires the value to be odd.
24    pub odd: bool,
25    /// Requires the value to be even.
26    pub even: bool,
27    /// Skips validation when the attribute value is missing or `null`.
28    pub allow_nil: bool,
29    /// Custom message used for non-numeric input.
30    pub message: Option<String>,
31    pub(crate) options: ValidatorOptions,
32}
33
34impl NumericalityValidator {
35    /// Creates a new numericality validator.
36    #[must_use]
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Skips validation when the attribute value is blank.
42    #[must_use]
43    pub fn allow_blank(mut self) -> Self {
44        self.options.allow_blank = true;
45        self
46    }
47
48    /// Restricts the validator to the provided contexts.
49    #[must_use]
50    pub fn on(mut self, contexts: Vec<crate::validations::ValidationContext>) -> Self {
51        self.options.on = Some(contexts);
52        self
53    }
54
55    /// Runs the validator only when the predicate returns `true`.
56    #[must_use]
57    pub fn if_cond<F>(mut self, cond: F) -> Self
58    where
59        F: Fn(&Value) -> bool + Send + Sync + 'static,
60    {
61        self.options.if_cond = Some(std::sync::Arc::new(cond));
62        self
63    }
64
65    /// Skips the validator when the predicate returns `true`.
66    #[must_use]
67    pub fn unless_cond<F>(mut self, cond: F) -> Self
68    where
69        F: Fn(&Value) -> bool + Send + Sync + 'static,
70    {
71        self.options.unless_cond = Some(std::sync::Arc::new(cond));
72        self
73    }
74
75    /// Restricts values to integers.
76    #[must_use]
77    pub fn only_integer(mut self) -> Self {
78        self.only_integer = true;
79        self
80    }
81
82    /// Requires the value to be greater than `bound`.
83    #[must_use]
84    pub fn greater_than(mut self, bound: f64) -> Self {
85        self.greater_than = Some(bound);
86        self
87    }
88
89    /// Requires the value to be greater than or equal to `bound`.
90    #[must_use]
91    pub fn greater_than_or_equal_to(mut self, bound: f64) -> Self {
92        self.greater_than_or_equal_to = Some(bound);
93        self
94    }
95
96    /// Requires the value to be less than `bound`.
97    #[must_use]
98    pub fn less_than(mut self, bound: f64) -> Self {
99        self.less_than = Some(bound);
100        self
101    }
102
103    /// Requires the value to be less than or equal to `bound`.
104    #[must_use]
105    pub fn less_than_or_equal_to(mut self, bound: f64) -> Self {
106        self.less_than_or_equal_to = Some(bound);
107        self
108    }
109
110    /// Requires the value to equal `bound`.
111    #[must_use]
112    pub fn equal_to(mut self, bound: f64) -> Self {
113        self.equal_to = Some(bound);
114        self
115    }
116
117    /// Requires the value to differ from `bound`.
118    #[must_use]
119    pub fn other_than(mut self, bound: f64) -> Self {
120        self.other_than = Some(bound);
121        self
122    }
123
124    /// Requires the value to be odd.
125    #[must_use]
126    pub fn odd(mut self) -> Self {
127        self.odd = true;
128        self
129    }
130
131    /// Requires the value to be even.
132    #[must_use]
133    pub fn even(mut self) -> Self {
134        self.even = true;
135        self
136    }
137
138    /// Skips validation for missing or `null` values.
139    #[must_use]
140    pub fn allow_nil(mut self) -> Self {
141        self.allow_nil = true;
142        self.options.allow_nil = true;
143        self
144    }
145
146    /// Overrides the default non-numeric failure message.
147    #[must_use]
148    pub fn message(mut self, message: impl Into<String>) -> Self {
149        self.message = Some(message.into());
150        self
151    }
152
153    fn parse_number(value: &Value) -> Option<f64> {
154        match value {
155            Value::Number(number) => number.as_f64(),
156            Value::String(text) => text.trim().parse::<f64>().ok(),
157            _ => None,
158        }
159    }
160
161    fn parse_integer(value: &Value) -> Option<i64> {
162        match value {
163            Value::Number(number) => number
164                .as_i64()
165                .or_else(|| number.as_u64().and_then(|value| i64::try_from(value).ok())),
166            Value::String(text) => text.trim().parse::<i64>().ok(),
167            _ => None,
168        }
169    }
170
171    fn not_a_number_message(&self) -> String {
172        self.message
173            .clone()
174            .unwrap_or_else(|| "is not a number".to_string())
175    }
176
177    fn not_an_integer_message(&self) -> String {
178        self.message
179            .clone()
180            .unwrap_or_else(|| "must be an integer".to_string())
181    }
182}
183
184impl Validator for NumericalityValidator {
185    fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
186        let Some(value) = value else {
187            if !self.allow_nil {
188                errors.add(
189                    attribute,
190                    ErrorType::NotANumber,
191                    self.not_a_number_message(),
192                );
193            }
194            return;
195        };
196
197        if matches!(value, Value::Null) {
198            if !self.allow_nil {
199                errors.add(
200                    attribute,
201                    ErrorType::NotANumber,
202                    self.not_a_number_message(),
203                );
204            }
205            return;
206        }
207
208        let Some(number) = Self::parse_number(value) else {
209            errors.add(
210                attribute,
211                ErrorType::NotANumber,
212                self.not_a_number_message(),
213            );
214            return;
215        };
216
217        if self.only_integer || self.odd || self.even {
218            let Some(integer) = Self::parse_integer(value) else {
219                errors.add(
220                    attribute,
221                    ErrorType::NotAnInteger,
222                    self.not_an_integer_message(),
223                );
224                return;
225            };
226
227            self.validate_integer_constraints(attribute, integer, errors);
228        }
229
230        if let Some(bound) = self.greater_than
231            && number <= bound
232        {
233            errors.add(
234                attribute,
235                ErrorType::GreaterThan,
236                format!("must be greater than {bound}"),
237            );
238        }
239
240        if let Some(bound) = self.greater_than_or_equal_to
241            && number < bound
242        {
243            errors.add(
244                attribute,
245                ErrorType::GreaterThanOrEqualTo,
246                format!("must be greater than or equal to {bound}"),
247            );
248        }
249
250        if let Some(bound) = self.less_than
251            && number >= bound
252        {
253            errors.add(
254                attribute,
255                ErrorType::LessThan,
256                format!("must be less than {bound}"),
257            );
258        }
259
260        if let Some(bound) = self.less_than_or_equal_to
261            && number > bound
262        {
263            errors.add(
264                attribute,
265                ErrorType::LessThanOrEqualTo,
266                format!("must be less than or equal to {bound}"),
267            );
268        }
269
270        if let Some(bound) = self.equal_to
271            && (number - bound).abs() > f64::EPSILON
272        {
273            errors.add(
274                attribute,
275                ErrorType::EqualTo,
276                format!("must be equal to {bound}"),
277            );
278        }
279
280        if let Some(bound) = self.other_than
281            && (number - bound).abs() <= f64::EPSILON
282        {
283            errors.add(
284                attribute,
285                ErrorType::OtherThan,
286                format!("must be other than {bound}"),
287            );
288        }
289    }
290
291    fn name(&self) -> &str {
292        "numericality"
293    }
294
295    fn options(&self) -> &ValidatorOptions {
296        &self.options
297    }
298}
299
300impl NumericalityValidator {
301    fn validate_integer_constraints(&self, attribute: &str, integer: i64, errors: &mut Errors) {
302        if self.odd && integer % 2 == 0 {
303            errors.add(attribute, ErrorType::Invalid, "must be odd");
304        }
305
306        if self.even && integer % 2 != 0 {
307            errors.add(attribute, ErrorType::Invalid, "must be even");
308        }
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use std::collections::HashMap;
315
316    use serde_json::json;
317
318    use super::NumericalityValidator;
319    use crate::{
320        errors::{ErrorType, Errors},
321        validations::{ValidationContext, ValidationSet, Validator},
322    };
323
324    fn validate_number(
325        validator: NumericalityValidator,
326        value: Option<serde_json::Value>,
327    ) -> Errors {
328        let mut errors = Errors::new();
329        validator.validate("field", value.as_ref(), &mut errors);
330        errors
331    }
332
333    #[test]
334    fn missing_value_fails_by_default() {
335        let validator = NumericalityValidator::new();
336        let mut errors = Errors::new();
337
338        validator.validate("age", None, &mut errors);
339
340        assert_eq!(errors.on("age")[0].error_type, ErrorType::NotANumber);
341    }
342
343    #[test]
344    fn nil_value_can_be_allowed() {
345        let validator = NumericalityValidator::new().allow_nil();
346        let mut errors = Errors::new();
347
348        validator.validate("age", Some(&json!(null)), &mut errors);
349
350        assert!(errors.is_empty());
351    }
352
353    #[test]
354    fn string_number_passes() {
355        let validator = NumericalityValidator::new();
356        let mut errors = Errors::new();
357
358        validator.validate("age", Some(&json!("42.5")), &mut errors);
359
360        assert!(errors.is_empty());
361    }
362
363    #[test]
364    fn non_numeric_string_fails() {
365        let validator = NumericalityValidator::new();
366        let mut errors = Errors::new();
367
368        validator.validate("age", Some(&json!("old")), &mut errors);
369
370        assert_eq!(errors.on("age")[0].error_type, ErrorType::NotANumber);
371    }
372
373    #[test]
374    fn only_integer_rejects_fractional_values() {
375        let validator = NumericalityValidator::new().only_integer();
376        let mut errors = Errors::new();
377
378        validator.validate("age", Some(&json!("4.2")), &mut errors);
379
380        assert_eq!(errors.on("age")[0].error_type, ErrorType::NotAnInteger);
381    }
382
383    #[test]
384    fn only_integer_accepts_whole_numbers() {
385        let validator = NumericalityValidator::new().only_integer();
386        let mut errors = Errors::new();
387
388        validator.validate("age", Some(&json!("4")), &mut errors);
389
390        assert!(errors.is_empty());
391    }
392
393    #[test]
394    fn greater_than_failure_adds_error() {
395        let validator = NumericalityValidator::new().greater_than(0.0);
396        let mut errors = Errors::new();
397
398        validator.validate("score", Some(&json!(0)), &mut errors);
399
400        assert_eq!(errors.on("score")[0].error_type, ErrorType::GreaterThan);
401    }
402
403    #[test]
404    fn less_than_failure_adds_error() {
405        let validator = NumericalityValidator::new().less_than(10.0);
406        let mut errors = Errors::new();
407
408        validator.validate("score", Some(&json!(10)), &mut errors);
409
410        assert_eq!(errors.on("score")[0].error_type, ErrorType::LessThan);
411    }
412
413    #[test]
414    fn greater_than_or_equal_to_passes_on_boundary() {
415        let validator = NumericalityValidator::new().greater_than_or_equal_to(5.0);
416        let mut errors = Errors::new();
417
418        validator.validate("score", Some(&json!(5)), &mut errors);
419
420        assert!(errors.is_empty());
421    }
422
423    #[test]
424    fn less_than_or_equal_to_passes_on_boundary() {
425        let validator = NumericalityValidator::new().less_than_or_equal_to(5.0);
426        let mut errors = Errors::new();
427
428        validator.validate("score", Some(&json!(5)), &mut errors);
429
430        assert!(errors.is_empty());
431    }
432
433    #[test]
434    fn equal_to_failure_adds_error() {
435        let validator = NumericalityValidator::new().equal_to(7.0);
436        let mut errors = Errors::new();
437
438        validator.validate("score", Some(&json!(6)), &mut errors);
439
440        assert_eq!(errors.on("score")[0].error_type, ErrorType::EqualTo);
441    }
442
443    #[test]
444    fn other_than_failure_adds_error() {
445        let validator = NumericalityValidator::new().other_than(7.0);
446        let mut errors = Errors::new();
447
448        validator.validate("score", Some(&json!(7)), &mut errors);
449
450        assert_eq!(errors.on("score")[0].error_type, ErrorType::OtherThan);
451    }
452
453    #[test]
454    fn odd_constraint_rejects_even_values() {
455        let validator = NumericalityValidator::new().odd();
456        let mut errors = Errors::new();
457
458        validator.validate("lucky", Some(&json!(4)), &mut errors);
459
460        assert_eq!(errors.on("lucky")[0].message, "must be odd");
461    }
462
463    #[test]
464    fn even_constraint_rejects_odd_values() {
465        let validator = NumericalityValidator::new().even();
466        let mut errors = Errors::new();
467
468        validator.validate("count", Some(&json!(3)), &mut errors);
469
470        assert_eq!(errors.on("count")[0].message, "must be even");
471    }
472
473    #[test]
474    fn odd_constraint_requires_integer() {
475        let validator = NumericalityValidator::new().odd();
476        let mut errors = Errors::new();
477
478        validator.validate("count", Some(&json!(3.5)), &mut errors);
479
480        assert_eq!(errors.on("count")[0].error_type, ErrorType::NotAnInteger);
481    }
482
483    #[test]
484    fn custom_message_is_used_for_non_numeric_failures() {
485        let validator = NumericalityValidator::new().message("must be numeric");
486        let mut errors = Errors::new();
487
488        validator.validate("age", Some(&json!("NaN?")), &mut errors);
489
490        assert_eq!(errors.on("age")[0].message, "must be numeric");
491    }
492
493    #[test]
494    fn trimmed_string_number_passes() {
495        let errors = validate_number(NumericalityValidator::new(), Some(json!(" 42 ")));
496
497        assert!(errors.is_empty());
498    }
499
500    #[test]
501    fn integer_json_value_passes_only_integer() {
502        let errors = validate_number(NumericalityValidator::new().only_integer(), Some(json!(42)));
503
504        assert!(errors.is_empty());
505    }
506
507    #[test]
508    fn fractional_json_value_fails_only_integer() {
509        let errors = validate_number(
510            NumericalityValidator::new().only_integer(),
511            Some(json!(4.5)),
512        );
513
514        assert_eq!(errors.on("field")[0].error_type, ErrorType::NotAnInteger);
515    }
516
517    #[test]
518    fn greater_than_passes_for_larger_value() {
519        let errors = validate_number(
520            NumericalityValidator::new().greater_than(10.0),
521            Some(json!(11)),
522        );
523
524        assert!(errors.is_empty());
525    }
526
527    #[test]
528    fn greater_than_or_equal_to_fails_below_boundary() {
529        let errors = validate_number(
530            NumericalityValidator::new().greater_than_or_equal_to(5.0),
531            Some(json!(4)),
532        );
533
534        assert_eq!(
535            errors.on("field")[0].error_type,
536            ErrorType::GreaterThanOrEqualTo
537        );
538    }
539
540    #[test]
541    fn less_than_passes_for_smaller_value() {
542        let errors = validate_number(NumericalityValidator::new().less_than(10.0), Some(json!(9)));
543
544        assert!(errors.is_empty());
545    }
546
547    #[test]
548    fn less_than_or_equal_to_fails_above_boundary() {
549        let errors = validate_number(
550            NumericalityValidator::new().less_than_or_equal_to(5.0),
551            Some(json!(6)),
552        );
553
554        assert_eq!(
555            errors.on("field")[0].error_type,
556            ErrorType::LessThanOrEqualTo
557        );
558    }
559
560    #[test]
561    fn equal_to_passes_for_same_value() {
562        let errors = validate_number(NumericalityValidator::new().equal_to(7.0), Some(json!(7)));
563
564        assert!(errors.is_empty());
565    }
566
567    #[test]
568    fn other_than_passes_for_different_value() {
569        let errors = validate_number(NumericalityValidator::new().other_than(7.0), Some(json!(8)));
570
571        assert!(errors.is_empty());
572    }
573
574    #[test]
575    fn odd_constraint_accepts_odd_values() {
576        let errors = validate_number(NumericalityValidator::new().odd(), Some(json!(5)));
577
578        assert!(errors.is_empty());
579    }
580
581    #[test]
582    fn even_constraint_accepts_even_values() {
583        let errors = validate_number(NumericalityValidator::new().even(), Some(json!(6)));
584
585        assert!(errors.is_empty());
586    }
587
588    #[test]
589    fn allow_blank_skips_whitespace_in_validation_set() {
590        let mut set = ValidationSet::new();
591        set.add("age", NumericalityValidator::new().allow_blank());
592        let mut errors = Errors::new();
593
594        let _ = set.validate(&|_| Some(json!("   ")), &mut errors);
595
596        assert!(errors.is_empty());
597    }
598
599    #[test]
600    fn on_context_runs_only_for_matching_context() {
601        let mut set = ValidationSet::new();
602        set.add(
603            "age",
604            NumericalityValidator::new()
605                .greater_than(18.0)
606                .on(vec![ValidationContext::Create]),
607        );
608        let attrs = HashMap::from([("age".to_string(), json!(18))]);
609        let mut errors = Errors::new();
610
611        let _ = set.validate_with_context(
612            &|name| attrs.get(name).cloned(),
613            &mut errors,
614            &ValidationContext::Update,
615        );
616        assert!(errors.is_empty());
617
618        let _ = set.validate_with_context(
619            &|name| attrs.get(name).cloned(),
620            &mut errors,
621            &ValidationContext::Create,
622        );
623        assert_eq!(errors.on("age")[0].error_type, ErrorType::GreaterThan);
624    }
625
626    #[test]
627    fn if_condition_false_skips_validation() {
628        let mut set = ValidationSet::new();
629        set.add(
630            "age",
631            NumericalityValidator::new()
632                .greater_than(18.0)
633                .if_cond(|value| value == &json!(21)),
634        );
635        let attrs = HashMap::from([("age".to_string(), json!(18))]);
636        let mut errors = Errors::new();
637
638        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
639
640        assert!(errors.is_empty());
641    }
642
643    #[test]
644    fn unless_condition_true_skips_validation() {
645        let mut set = ValidationSet::new();
646        set.add(
647            "age",
648            NumericalityValidator::new()
649                .greater_than(18.0)
650                .unless_cond(|value| value == &json!(18)),
651        );
652        let attrs = HashMap::from([("age".to_string(), json!(18))]);
653        let mut errors = Errors::new();
654
655        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
656
657        assert!(errors.is_empty());
658    }
659
660    #[test]
661    fn custom_message_is_reused_for_integer_failures() {
662        let errors = validate_number(
663            NumericalityValidator::new()
664                .only_integer()
665                .message("whole number only"),
666            Some(json!("4.5")),
667        );
668
669        assert_eq!(errors.on("field")[0].message, "whole number only");
670    }
671
672    #[test]
673    fn null_value_fails_without_allow_nil() {
674        let errors = validate_number(NumericalityValidator::new(), Some(json!(null)));
675
676        assert_eq!(errors.on("field")[0].message, "is not a number");
677    }
678
679    #[test]
680    fn combined_range_passes_inside_bounds() {
681        let errors = validate_number(
682            NumericalityValidator::new()
683                .greater_than(1.0)
684                .less_than_or_equal_to(3.0),
685            Some(json!(2)),
686        );
687
688        assert!(errors.is_empty());
689    }
690
691    #[test]
692    fn inconsistent_bounds_can_add_multiple_errors() {
693        let errors = validate_number(
694            NumericalityValidator::new()
695                .greater_than(10.0)
696                .less_than(5.0),
697            Some(json!(7)),
698        );
699
700        assert_eq!(errors.count(), 2);
701        assert_eq!(errors.on("field")[0].error_type, ErrorType::GreaterThan);
702        assert_eq!(errors.on("field")[1].error_type, ErrorType::LessThan);
703    }
704
705    #[test]
706    fn odd_constraint_uses_trimmed_integer_strings() {
707        let errors = validate_number(NumericalityValidator::new().odd(), Some(json!(" 7 ")));
708
709        assert!(errors.is_empty());
710    }
711
712    #[test]
713    fn negative_numbers_respect_upper_bounds() {
714        let errors = validate_number(
715            NumericalityValidator::new().less_than_or_equal_to(-2.0),
716            Some(json!(-3)),
717        );
718
719        assert!(errors.is_empty());
720    }
721}