use serde_json::Value;
use super::{Validator, ValidatorOptions};
use crate::errors::{ErrorType, Errors};
#[derive(Debug, Clone, Default)]
pub struct NumericalityValidator {
pub only_integer: bool,
pub greater_than: Option<f64>,
pub greater_than_or_equal_to: Option<f64>,
pub less_than: Option<f64>,
pub less_than_or_equal_to: Option<f64>,
pub equal_to: Option<f64>,
pub other_than: Option<f64>,
pub odd: bool,
pub even: bool,
pub allow_nil: bool,
pub message: Option<String>,
pub(crate) options: ValidatorOptions,
}
impl NumericalityValidator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn allow_blank(mut self) -> Self {
self.options.allow_blank = true;
self
}
#[must_use]
pub fn on(mut self, contexts: Vec<crate::validations::ValidationContext>) -> Self {
self.options.on = Some(contexts);
self
}
#[must_use]
pub fn if_cond<F>(mut self, cond: F) -> Self
where
F: Fn(&Value) -> bool + Send + Sync + 'static,
{
self.options.if_cond = Some(std::sync::Arc::new(cond));
self
}
#[must_use]
pub fn unless_cond<F>(mut self, cond: F) -> Self
where
F: Fn(&Value) -> bool + Send + Sync + 'static,
{
self.options.unless_cond = Some(std::sync::Arc::new(cond));
self
}
#[must_use]
pub fn only_integer(mut self) -> Self {
self.only_integer = true;
self
}
#[must_use]
pub fn greater_than(mut self, bound: f64) -> Self {
self.greater_than = Some(bound);
self
}
#[must_use]
pub fn greater_than_or_equal_to(mut self, bound: f64) -> Self {
self.greater_than_or_equal_to = Some(bound);
self
}
#[must_use]
pub fn less_than(mut self, bound: f64) -> Self {
self.less_than = Some(bound);
self
}
#[must_use]
pub fn less_than_or_equal_to(mut self, bound: f64) -> Self {
self.less_than_or_equal_to = Some(bound);
self
}
#[must_use]
pub fn equal_to(mut self, bound: f64) -> Self {
self.equal_to = Some(bound);
self
}
#[must_use]
pub fn other_than(mut self, bound: f64) -> Self {
self.other_than = Some(bound);
self
}
#[must_use]
pub fn odd(mut self) -> Self {
self.odd = true;
self
}
#[must_use]
pub fn even(mut self) -> Self {
self.even = true;
self
}
#[must_use]
pub fn allow_nil(mut self) -> Self {
self.allow_nil = true;
self.options.allow_nil = true;
self
}
#[must_use]
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
fn parse_number(value: &Value) -> Option<f64> {
match value {
Value::Number(number) => number.as_f64(),
Value::String(text) => text.trim().parse::<f64>().ok(),
_ => None,
}
}
fn parse_integer(value: &Value) -> Option<i64> {
match value {
Value::Number(number) => number
.as_i64()
.or_else(|| number.as_u64().and_then(|value| i64::try_from(value).ok())),
Value::String(text) => text.trim().parse::<i64>().ok(),
_ => None,
}
}
fn not_a_number_message(&self) -> String {
self.message
.clone()
.unwrap_or_else(|| "is not a number".to_string())
}
fn not_an_integer_message(&self) -> String {
self.message
.clone()
.unwrap_or_else(|| "must be an integer".to_string())
}
}
impl Validator for NumericalityValidator {
fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
let Some(value) = value else {
if !self.allow_nil {
errors.add(
attribute,
ErrorType::NotANumber,
self.not_a_number_message(),
);
}
return;
};
if matches!(value, Value::Null) {
if !self.allow_nil {
errors.add(
attribute,
ErrorType::NotANumber,
self.not_a_number_message(),
);
}
return;
}
let Some(number) = Self::parse_number(value) else {
errors.add(
attribute,
ErrorType::NotANumber,
self.not_a_number_message(),
);
return;
};
if self.only_integer || self.odd || self.even {
let Some(integer) = Self::parse_integer(value) else {
errors.add(
attribute,
ErrorType::NotAnInteger,
self.not_an_integer_message(),
);
return;
};
self.validate_integer_constraints(attribute, integer, errors);
}
if let Some(bound) = self.greater_than
&& number <= bound
{
errors.add(
attribute,
ErrorType::GreaterThan,
format!("must be greater than {bound}"),
);
}
if let Some(bound) = self.greater_than_or_equal_to
&& number < bound
{
errors.add(
attribute,
ErrorType::GreaterThanOrEqualTo,
format!("must be greater than or equal to {bound}"),
);
}
if let Some(bound) = self.less_than
&& number >= bound
{
errors.add(
attribute,
ErrorType::LessThan,
format!("must be less than {bound}"),
);
}
if let Some(bound) = self.less_than_or_equal_to
&& number > bound
{
errors.add(
attribute,
ErrorType::LessThanOrEqualTo,
format!("must be less than or equal to {bound}"),
);
}
if let Some(bound) = self.equal_to
&& (number - bound).abs() > f64::EPSILON
{
errors.add(
attribute,
ErrorType::EqualTo,
format!("must be equal to {bound}"),
);
}
if let Some(bound) = self.other_than
&& (number - bound).abs() <= f64::EPSILON
{
errors.add(
attribute,
ErrorType::OtherThan,
format!("must be other than {bound}"),
);
}
}
fn name(&self) -> &str {
"numericality"
}
fn options(&self) -> &ValidatorOptions {
&self.options
}
}
impl NumericalityValidator {
fn validate_integer_constraints(&self, attribute: &str, integer: i64, errors: &mut Errors) {
if self.odd && integer % 2 == 0 {
errors.add(attribute, ErrorType::Invalid, "must be odd");
}
if self.even && integer % 2 != 0 {
errors.add(attribute, ErrorType::Invalid, "must be even");
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::json;
use super::NumericalityValidator;
use crate::{
errors::{ErrorType, Errors},
validations::{ValidationContext, ValidationSet, Validator},
};
fn validate_number(
validator: NumericalityValidator,
value: Option<serde_json::Value>,
) -> Errors {
let mut errors = Errors::new();
validator.validate("field", value.as_ref(), &mut errors);
errors
}
#[test]
fn missing_value_fails_by_default() {
let validator = NumericalityValidator::new();
let mut errors = Errors::new();
validator.validate("age", None, &mut errors);
assert_eq!(errors.on("age")[0].error_type, ErrorType::NotANumber);
}
#[test]
fn nil_value_can_be_allowed() {
let validator = NumericalityValidator::new().allow_nil();
let mut errors = Errors::new();
validator.validate("age", Some(&json!(null)), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn string_number_passes() {
let validator = NumericalityValidator::new();
let mut errors = Errors::new();
validator.validate("age", Some(&json!("42.5")), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn non_numeric_string_fails() {
let validator = NumericalityValidator::new();
let mut errors = Errors::new();
validator.validate("age", Some(&json!("old")), &mut errors);
assert_eq!(errors.on("age")[0].error_type, ErrorType::NotANumber);
}
#[test]
fn only_integer_rejects_fractional_values() {
let validator = NumericalityValidator::new().only_integer();
let mut errors = Errors::new();
validator.validate("age", Some(&json!("4.2")), &mut errors);
assert_eq!(errors.on("age")[0].error_type, ErrorType::NotAnInteger);
}
#[test]
fn only_integer_accepts_whole_numbers() {
let validator = NumericalityValidator::new().only_integer();
let mut errors = Errors::new();
validator.validate("age", Some(&json!("4")), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn greater_than_failure_adds_error() {
let validator = NumericalityValidator::new().greater_than(0.0);
let mut errors = Errors::new();
validator.validate("score", Some(&json!(0)), &mut errors);
assert_eq!(errors.on("score")[0].error_type, ErrorType::GreaterThan);
}
#[test]
fn less_than_failure_adds_error() {
let validator = NumericalityValidator::new().less_than(10.0);
let mut errors = Errors::new();
validator.validate("score", Some(&json!(10)), &mut errors);
assert_eq!(errors.on("score")[0].error_type, ErrorType::LessThan);
}
#[test]
fn greater_than_or_equal_to_passes_on_boundary() {
let validator = NumericalityValidator::new().greater_than_or_equal_to(5.0);
let mut errors = Errors::new();
validator.validate("score", Some(&json!(5)), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn less_than_or_equal_to_passes_on_boundary() {
let validator = NumericalityValidator::new().less_than_or_equal_to(5.0);
let mut errors = Errors::new();
validator.validate("score", Some(&json!(5)), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn equal_to_failure_adds_error() {
let validator = NumericalityValidator::new().equal_to(7.0);
let mut errors = Errors::new();
validator.validate("score", Some(&json!(6)), &mut errors);
assert_eq!(errors.on("score")[0].error_type, ErrorType::EqualTo);
}
#[test]
fn other_than_failure_adds_error() {
let validator = NumericalityValidator::new().other_than(7.0);
let mut errors = Errors::new();
validator.validate("score", Some(&json!(7)), &mut errors);
assert_eq!(errors.on("score")[0].error_type, ErrorType::OtherThan);
}
#[test]
fn odd_constraint_rejects_even_values() {
let validator = NumericalityValidator::new().odd();
let mut errors = Errors::new();
validator.validate("lucky", Some(&json!(4)), &mut errors);
assert_eq!(errors.on("lucky")[0].message, "must be odd");
}
#[test]
fn even_constraint_rejects_odd_values() {
let validator = NumericalityValidator::new().even();
let mut errors = Errors::new();
validator.validate("count", Some(&json!(3)), &mut errors);
assert_eq!(errors.on("count")[0].message, "must be even");
}
#[test]
fn odd_constraint_requires_integer() {
let validator = NumericalityValidator::new().odd();
let mut errors = Errors::new();
validator.validate("count", Some(&json!(3.5)), &mut errors);
assert_eq!(errors.on("count")[0].error_type, ErrorType::NotAnInteger);
}
#[test]
fn custom_message_is_used_for_non_numeric_failures() {
let validator = NumericalityValidator::new().message("must be numeric");
let mut errors = Errors::new();
validator.validate("age", Some(&json!("NaN?")), &mut errors);
assert_eq!(errors.on("age")[0].message, "must be numeric");
}
#[test]
fn trimmed_string_number_passes() {
let errors = validate_number(NumericalityValidator::new(), Some(json!(" 42 ")));
assert!(errors.is_empty());
}
#[test]
fn integer_json_value_passes_only_integer() {
let errors = validate_number(NumericalityValidator::new().only_integer(), Some(json!(42)));
assert!(errors.is_empty());
}
#[test]
fn fractional_json_value_fails_only_integer() {
let errors = validate_number(
NumericalityValidator::new().only_integer(),
Some(json!(4.5)),
);
assert_eq!(errors.on("field")[0].error_type, ErrorType::NotAnInteger);
}
#[test]
fn greater_than_passes_for_larger_value() {
let errors = validate_number(
NumericalityValidator::new().greater_than(10.0),
Some(json!(11)),
);
assert!(errors.is_empty());
}
#[test]
fn greater_than_or_equal_to_fails_below_boundary() {
let errors = validate_number(
NumericalityValidator::new().greater_than_or_equal_to(5.0),
Some(json!(4)),
);
assert_eq!(
errors.on("field")[0].error_type,
ErrorType::GreaterThanOrEqualTo
);
}
#[test]
fn less_than_passes_for_smaller_value() {
let errors = validate_number(NumericalityValidator::new().less_than(10.0), Some(json!(9)));
assert!(errors.is_empty());
}
#[test]
fn less_than_or_equal_to_fails_above_boundary() {
let errors = validate_number(
NumericalityValidator::new().less_than_or_equal_to(5.0),
Some(json!(6)),
);
assert_eq!(
errors.on("field")[0].error_type,
ErrorType::LessThanOrEqualTo
);
}
#[test]
fn equal_to_passes_for_same_value() {
let errors = validate_number(NumericalityValidator::new().equal_to(7.0), Some(json!(7)));
assert!(errors.is_empty());
}
#[test]
fn other_than_passes_for_different_value() {
let errors = validate_number(NumericalityValidator::new().other_than(7.0), Some(json!(8)));
assert!(errors.is_empty());
}
#[test]
fn odd_constraint_accepts_odd_values() {
let errors = validate_number(NumericalityValidator::new().odd(), Some(json!(5)));
assert!(errors.is_empty());
}
#[test]
fn even_constraint_accepts_even_values() {
let errors = validate_number(NumericalityValidator::new().even(), Some(json!(6)));
assert!(errors.is_empty());
}
#[test]
fn allow_blank_skips_whitespace_in_validation_set() {
let mut set = ValidationSet::new();
set.add("age", NumericalityValidator::new().allow_blank());
let mut errors = Errors::new();
let _ = set.validate(&|_| Some(json!(" ")), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn on_context_runs_only_for_matching_context() {
let mut set = ValidationSet::new();
set.add(
"age",
NumericalityValidator::new()
.greater_than(18.0)
.on(vec![ValidationContext::Create]),
);
let attrs = HashMap::from([("age".to_string(), json!(18))]);
let mut errors = Errors::new();
let _ = set.validate_with_context(
&|name| attrs.get(name).cloned(),
&mut errors,
&ValidationContext::Update,
);
assert!(errors.is_empty());
let _ = set.validate_with_context(
&|name| attrs.get(name).cloned(),
&mut errors,
&ValidationContext::Create,
);
assert_eq!(errors.on("age")[0].error_type, ErrorType::GreaterThan);
}
#[test]
fn if_condition_false_skips_validation() {
let mut set = ValidationSet::new();
set.add(
"age",
NumericalityValidator::new()
.greater_than(18.0)
.if_cond(|value| value == &json!(21)),
);
let attrs = HashMap::from([("age".to_string(), json!(18))]);
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn unless_condition_true_skips_validation() {
let mut set = ValidationSet::new();
set.add(
"age",
NumericalityValidator::new()
.greater_than(18.0)
.unless_cond(|value| value == &json!(18)),
);
let attrs = HashMap::from([("age".to_string(), json!(18))]);
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert!(errors.is_empty());
}
#[test]
fn custom_message_is_reused_for_integer_failures() {
let errors = validate_number(
NumericalityValidator::new()
.only_integer()
.message("whole number only"),
Some(json!("4.5")),
);
assert_eq!(errors.on("field")[0].message, "whole number only");
}
#[test]
fn null_value_fails_without_allow_nil() {
let errors = validate_number(NumericalityValidator::new(), Some(json!(null)));
assert_eq!(errors.on("field")[0].message, "is not a number");
}
#[test]
fn combined_range_passes_inside_bounds() {
let errors = validate_number(
NumericalityValidator::new()
.greater_than(1.0)
.less_than_or_equal_to(3.0),
Some(json!(2)),
);
assert!(errors.is_empty());
}
#[test]
fn inconsistent_bounds_can_add_multiple_errors() {
let errors = validate_number(
NumericalityValidator::new()
.greater_than(10.0)
.less_than(5.0),
Some(json!(7)),
);
assert_eq!(errors.count(), 2);
assert_eq!(errors.on("field")[0].error_type, ErrorType::GreaterThan);
assert_eq!(errors.on("field")[1].error_type, ErrorType::LessThan);
}
#[test]
fn odd_constraint_uses_trimmed_integer_strings() {
let errors = validate_number(NumericalityValidator::new().odd(), Some(json!(" 7 ")));
assert!(errors.is_empty());
}
#[test]
fn negative_numbers_respect_upper_bounds() {
let errors = validate_number(
NumericalityValidator::new().less_than_or_equal_to(-2.0),
Some(json!(-3)),
);
assert!(errors.is_empty());
}
}