1use serde_json::Value;
2
3use super::{Validator, ValidatorOptions};
4use crate::errors::{ErrorType, Errors};
5
6#[derive(Debug, Clone, Default)]
8pub struct NumericalityValidator {
9 pub only_integer: bool,
11 pub greater_than: Option<f64>,
13 pub greater_than_or_equal_to: Option<f64>,
15 pub less_than: Option<f64>,
17 pub less_than_or_equal_to: Option<f64>,
19 pub equal_to: Option<f64>,
21 pub other_than: Option<f64>,
23 pub odd: bool,
25 pub even: bool,
27 pub allow_nil: bool,
29 pub message: Option<String>,
31 pub(crate) options: ValidatorOptions,
32}
33
34impl NumericalityValidator {
35 #[must_use]
37 pub fn new() -> Self {
38 Self::default()
39 }
40
41 #[must_use]
43 pub fn allow_blank(mut self) -> Self {
44 self.options.allow_blank = true;
45 self
46 }
47
48 #[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 #[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 #[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 #[must_use]
77 pub fn only_integer(mut self) -> Self {
78 self.only_integer = true;
79 self
80 }
81
82 #[must_use]
84 pub fn greater_than(mut self, bound: f64) -> Self {
85 self.greater_than = Some(bound);
86 self
87 }
88
89 #[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 #[must_use]
98 pub fn less_than(mut self, bound: f64) -> Self {
99 self.less_than = Some(bound);
100 self
101 }
102
103 #[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 #[must_use]
112 pub fn equal_to(mut self, bound: f64) -> Self {
113 self.equal_to = Some(bound);
114 self
115 }
116
117 #[must_use]
119 pub fn other_than(mut self, bound: f64) -> Self {
120 self.other_than = Some(bound);
121 self
122 }
123
124 #[must_use]
126 pub fn odd(mut self) -> Self {
127 self.odd = true;
128 self
129 }
130
131 #[must_use]
133 pub fn even(mut self) -> Self {
134 self.even = true;
135 self
136 }
137
138 #[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 #[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}