Skip to content

Commit

Permalink
Feature: Add exclusive_min and exclusive_max to range validation (
Browse files Browse the repository at this point in the history
#246)

* feat(range): add exclusive minimum and exclusive maximum for `range` validation

* test(range): add tests for `exc_min` and `exc_max` range validation

* docs: add docs for `exc_min` and `exc_max` for `range` validation

* chore: rename `exc_min`, `exc_max` to `exclusive_min`, `exclusive_max`

* chore(validation.rs): get rid of `collide` function
  • Loading branch information
ivan-gj authored Apr 23, 2023
1 parent 4830561 commit 6471bae
Show file tree
Hide file tree
Showing 9 changed files with 263 additions and 52 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ struct SignupData {
first_name: String,
#[validate(range(min = 18, max = 20))]
age: u32,
#[validate(range(exclusive_min = 0.0, max = 100.0))]
height: f32,
}

fn validate_unique_username(username: &str) -> Result<(), ValidationError> {
Expand Down Expand Up @@ -197,7 +199,8 @@ const MAX_CONST: u64 = 10;
```

### range
Tests whether a number is in the given range. `range` takes 1 or 2 arguments `min` and `max` that can be a number or a value path.
Tests whether a number is in the given range. `range` takes 1 or 2 arguments, and they can be normal (`min` and `max`) or exclusive (`exclusive_min`, `exclusive_max`, unreachable limits).
These can be a number or a value path.

Examples:

Expand All @@ -212,6 +215,8 @@ const MIN_CONSTANT: i32 = 0;
#[validate(range(max = 10.8))]
#[validate(range(min = "MAX_CONSTANT"))]
#[validate(range(min = "crate::MAX_CONSTANT"))]
#[validate(range(exclusive_min = 0.0, max = 100.0))]
#[validate(range(exclusive_max = 10))]
```

### must_match
Expand Down
78 changes: 63 additions & 15 deletions validator/src/validation/range.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
/// Validates that the given `value` is inside the defined range. The `max` and `min` parameters are
/// Validates that the given `value` is inside the defined range.
/// The `max`, `min`, `exclusive_max` and `exclusive_min` parameters are
/// optional and will only be validated if they are not `None`
///
#[must_use]
pub fn validate_range<T: ValidateRange<T>>(value: T, min: Option<T>, max: Option<T>) -> bool {
value.validate_range(min, max)
pub fn validate_range<T: ValidateRange<T>>(
value: T,
min: Option<T>,
max: Option<T>,
exclusive_min: Option<T>,
exclusive_max: Option<T>,
) -> bool {
value.validate_range(min, max, exclusive_min, exclusive_max)
}

pub trait ValidateRange<T> {
fn validate_range(&self, min: Option<T>, max: Option<T>) -> bool {
fn validate_range(
&self,
min: Option<T>,
max: Option<T>,
exclusive_min: Option<T>,
exclusive_max: Option<T>,
) -> bool {
if let Some(max) = max {
if self.greater_than(max) {
return false;
Expand All @@ -20,6 +33,18 @@ pub trait ValidateRange<T> {
}
}

if let Some(exclusive_max) = exclusive_max {
if !self.less_than(exclusive_max) {
return false;
}
}

if let Some(exclusive_min) = exclusive_min {
if !self.greater_than(exclusive_min) {
return false;
}
}

true
}

Expand Down Expand Up @@ -55,30 +80,53 @@ mod tests {
#[test]
fn test_validate_range_generic_ok() {
// Unspecified generic type:
assert!(validate_range(10, Some(-10), Some(10)));
assert!(validate_range(0.0, Some(0.0), Some(10.0)));
assert!(validate_range(10, Some(-10), Some(10), None, None));
assert!(validate_range(0.0, Some(0.0), Some(10.0), None, None));

// Specified type:
assert!(validate_range(5u8, Some(0), Some(255)));
assert!(validate_range(4u16, Some(0), Some(16)));
assert!(validate_range(6u32, Some(0), Some(23)));
assert!(validate_range(5u8, Some(0), Some(255), None, None));
assert!(validate_range(4u16, Some(0), Some(16), None, None));
assert!(validate_range(6u32, Some(0), Some(23), None, None));
}

#[test]
fn test_validate_range_generic_fail() {
assert!(!validate_range(5, Some(17), Some(19)));
assert!(!validate_range(-1.0, Some(0.0), Some(10.0)));
assert!(!validate_range(5, Some(17), Some(19), None, None));
assert!(!validate_range(-1.0, Some(0.0), Some(10.0), None, None));
}

#[test]
fn test_validate_range_generic_min_only() {
assert!(!validate_range(5, Some(10), None));
assert!(validate_range(15, Some(10), None));
assert!(!validate_range(5, Some(10), None, None, None));
assert!(validate_range(15, Some(10), None, None, None));
}

#[test]
fn test_validate_range_generic_max_only() {
assert!(validate_range(5, None, Some(10)));
assert!(!validate_range(15, None, Some(10)));
assert!(validate_range(5, None, Some(10), None, None));
assert!(!validate_range(15, None, Some(10), None, None));
}

#[test]
fn test_validate_range_generic_exc_ok() {
assert!(validate_range(6, None, None, Some(5), Some(7)));
assert!(validate_range(0.0001, None, None, Some(0.0), Some(1.0)));
}

#[test]
fn test_validate_range_generic_exc_fail() {
assert!(!validate_range(5, None, None, Some(5), None));
}

#[test]
fn test_validate_range_generic_exclusive_max_only() {
assert!(!validate_range(10, None, None, None, Some(10)));
assert!(validate_range(9, None, None, None, Some(10)));
}

#[test]
fn test_validate_range_generic_exclusive_min_only() {
assert!(!validate_range(10, None, None, Some(10), None));
assert!(validate_range(9, None, None, Some(8), None));
}
}
59 changes: 37 additions & 22 deletions validator_derive/src/quoting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use if_chain::if_chain;
use proc_macro2::{self, Span};
use quote::quote;

use validator_types::Validator;
use validator_types::{Validator, ValueOrPath};

use crate::asserts::{COW_TYPE, NUMBER_TYPES};
use crate::lit::{option_to_tokens, value_or_path_to_tokens};
Expand Down Expand Up @@ -230,39 +230,34 @@ pub fn quote_range_validation(
let field_name = &field_quoter.name;
let quoted_ident = field_quoter.quote_validator_param();

if let Validator::Range { ref min, ref max } = validation.validator {
let min_err_param_quoted = if let Some(v) = min {
let v = value_or_path_to_tokens(v);
quote!(err.add_param(::std::borrow::Cow::from("min"), &#v);)
} else {
quote!()
};
let max_err_param_quoted = if let Some(v) = max {
let v = value_or_path_to_tokens(v);
quote!(err.add_param(::std::borrow::Cow::from("max"), &#v);)
} else {
quote!()
};
if let Validator::Range { ref min, ref max, ref exclusive_min, ref exclusive_max } =
validation.validator
{
let min_err_param_quoted = err_param_quoted(min, "min");
let max_err_param_quoted = err_param_quoted(max, "max");
let exclusive_min_err_param_quoted = err_param_quoted(exclusive_min, "exclusive_min");
let exclusive_max_err_param_quoted = err_param_quoted(exclusive_max, "exclusive_max");

// Can't interpolate None
let min_tokens =
min.clone().map(|x| value_or_path_to_tokens(&x)).map(|x| quote!(#x as f64));
let min_tokens = option_to_tokens(&min_tokens);

let max_tokens =
max.clone().map(|x| value_or_path_to_tokens(&x)).map(|x| quote!(#x as f64));
let max_tokens = option_to_tokens(&max_tokens);
let min_tokens = generate_tokens(min);
let max_tokens = generate_tokens(max);
let exclusive_min_tokens = generate_tokens(exclusive_min);
let exclusive_max_tokens = generate_tokens(exclusive_max);

let quoted_error = quote_error(validation);
let quoted = quote!(
if !::validator::validate_range(
#quoted_ident as f64,
#min_tokens,
#max_tokens
#max_tokens,
#exclusive_min_tokens,
#exclusive_max_tokens,
) {
#quoted_error
#min_err_param_quoted
#max_err_param_quoted
#exclusive_min_err_param_quoted
#exclusive_max_err_param_quoted
err.add_param(::std::borrow::Cow::from("value"), &#quoted_ident);
errors.add(#field_name, err);
}
Expand All @@ -274,6 +269,26 @@ pub fn quote_range_validation(
unreachable!()
}

fn err_param_quoted<T>(option: &Option<ValueOrPath<T>>, name: &str) -> proc_macro2::TokenStream
where
T: std::fmt::Debug + std::clone::Clone + std::cmp::PartialEq + quote::ToTokens,
{
if let Some(v) = option {
let v = value_or_path_to_tokens(v);
quote!(err.add_param(::std::borrow::Cow::from(#name), &#v);)
} else {
quote!()
}
}

fn generate_tokens<T>(value: &Option<ValueOrPath<T>>) -> proc_macro2::TokenStream
where
T: std::fmt::Debug + std::clone::Clone + std::cmp::PartialEq + quote::ToTokens,
{
let tokens = value.clone().map(|x| value_or_path_to_tokens(&x)).map(|x| quote!(#x as f64));
option_to_tokens(&tokens)
}

#[cfg(feature = "card")]
pub fn quote_credit_card_validation(
field_quoter: &FieldQuoter,
Expand Down
52 changes: 45 additions & 7 deletions validator_derive/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,20 @@ pub fn extract_length_validation(
}
}

const RANGE_MIN_KEY: &str = "min";
const RANGE_EXCLUSIVE_MIN_KEY: &str = "exclusive_min";
const RANGE_MAX_KEY: &str = "max";
const RANGE_EXCLUSIVE_MAX_KEY: &str = "exclusive_max";

pub fn extract_range_validation(
field: String,
attr: &syn::Attribute,
meta_items: &[syn::NestedMeta],
) -> FieldValidation {
let mut min = None;
let mut max = None;
let mut exclusive_min = None;
let mut exclusive_max = None;

let (message, code) = extract_message_and_code("range", &field, meta_items);

Expand All @@ -145,16 +152,28 @@ pub fn extract_range_validation(
let ident = path.get_ident().unwrap();
match ident.to_string().as_ref() {
"message" | "code" => continue,
"min" => {
RANGE_MIN_KEY => {
min = match lit_to_f64_or_path(lit) {
Some(s) => Some(s),
None => error(lit.span(), "invalid argument type for `min` of `range` validator: only number literals or value paths are allowed")
None => error(lit.span(), &lit_to_f64_error_message(RANGE_MIN_KEY))
};
}
RANGE_EXCLUSIVE_MIN_KEY => {
exclusive_min = match lit_to_f64_or_path(lit) {
Some(s) => Some(s),
None => error(lit.span(), &lit_to_f64_error_message(RANGE_EXCLUSIVE_MIN_KEY))
};
}
"max" => {
RANGE_MAX_KEY => {
max = match lit_to_f64_or_path(lit) {
Some(s) => Some(s),
None => error(lit.span(), "invalid argument type for `max` of `range` validator: only number literals or value paths are allowed")
None => error(lit.span(), &lit_to_f64_error_message(RANGE_MAX_KEY))
};
}
RANGE_EXCLUSIVE_MAX_KEY => {
exclusive_max = match lit_to_f64_or_path(lit) {
Some(s) => Some(s),
None => error(lit.span(), &lit_to_f64_error_message(RANGE_EXCLUSIVE_MAX_KEY))
};
}
v => error(path.span(), &format!(
Expand All @@ -173,18 +192,37 @@ pub fn extract_range_validation(
}
}

if min.is_none() && max.is_none() {
error(attr.span(), "Validator `range` requires at least 1 argument out of `min` and `max`");
if [&min, &max, &exclusive_min, &exclusive_max].iter().all(|x| x.is_none()) {
error(
attr.span(),
&format!(
"Validator `range` requires at least 1 argument out of `{}`, `{}`, `{}` and `{}`",
RANGE_MIN_KEY, RANGE_MAX_KEY, RANGE_EXCLUSIVE_MIN_KEY, RANGE_EXCLUSIVE_MAX_KEY
),
);
}

let validator = Validator::Range { min, max };
if min.is_some() && exclusive_min.is_some() || max.is_some() && exclusive_max.is_some() {
error(
attr.span(),
&format!(
"Validator `range` cannot contain one of its limits (`{}`, `{}`) and its exclusive counterpart",
RANGE_MIN_KEY, RANGE_MAX_KEY
)
)
}
let validator = Validator::Range { min, max, exclusive_min, exclusive_max };
FieldValidation {
message,
code: code.unwrap_or_else(|| validator.code().to_string()),
validator,
}
}

fn lit_to_f64_error_message(val_name: &str) -> String {
format!("invalid argument type for `{}` of `range` validator: only number literals or value paths are allowed", val_name)
}

pub fn extract_custom_validation(
field: String,
attr: &syn::Attribute,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
error: Invalid attribute #[validate] on field `s`: Validator `range` requires at least 1 argument out of `min` and `max`
error: Invalid attribute #[validate] on field `s`: Validator `range` requires at least 1 argument out of `min`, `max`, `exclusive_min` and `exclusive_max`
--> $DIR/no_args.rs:5:5
|
5 | #[validate(range())]
Expand Down
12 changes: 6 additions & 6 deletions validator_derive_tests/tests/complex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@ fn test_can_validate_option_fields_with_lifetime() {
name: Option<&'a str>,
#[validate(length(min = 1, max = 10))]
address: Option<Option<&'a str>>,
#[validate(range(min = 1, max = 100))]
#[validate(range(exclusive_min = 0, max = 100))]
age: Option<Option<usize>>,
#[validate(range(min = 1, max = 10))]
#[validate(range(min = 1, exclusive_max = 10))]
range: Option<usize>,
#[validate(email)]
email: Option<&'a str>,
Expand Down Expand Up @@ -217,9 +217,9 @@ fn test_can_validate_option_fields_without_lifetime() {
ids: Option<Vec<usize>>,
#[validate(length(min = 1, max = 10))]
opt_ids: Option<Option<Vec<usize>>>,
#[validate(range(min = 1, max = 100))]
#[validate(range(exclusive_min = 0, max = 100))]
age: Option<Option<usize>>,
#[validate(range(min = 1, max = 10))]
#[validate(range(min = 1, exclusive_max = 10))]
range: Option<usize>,
#[validate(email)]
email: Option<String>,
Expand Down Expand Up @@ -281,9 +281,9 @@ fn test_works_with_none_values() {
name: Option<String>,
#[validate(length(min = 1, max = 10))]
address: Option<Option<String>>,
#[validate(range(min = 1, max = 100))]
#[validate(range(exclusive_min = 0, max = 100))]
age: Option<Option<usize>>,
#[validate(range(min = 1, max = 10))]
#[validate(range(min = 1, exclusive_max = 10))]
range: Option<usize>,
}

Expand Down
Loading

0 comments on commit 6471bae

Please sign in to comment.