use std::fmt;
use std::sync::Arc;
use serde_json::Value;
use crate::errors::Errors;
pub type ValidationPredicate = Arc<dyn Fn(&Value) -> bool + Send + Sync>;
type ValidateFn = dyn Fn(&str, Option<&Value>, &mut Errors) + Send + Sync;
pub type ModelValidationFn = dyn Fn(&dyn Fn(&str) -> Option<Value>, &mut Errors) + Send + Sync;
macro_rules! impl_common_validator_methods {
() => {
#[must_use]
pub fn allow_nil(mut self) -> Self {
self.options.allow_nil = true;
self
}
#[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 strict(mut self) -> Self {
self.options.strict = true;
self
}
#[must_use]
pub fn if_cond<F>(mut self, cond: F) -> Self
where
F: Fn(&serde_json::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(&serde_json::Value) -> bool + Send + Sync + 'static,
{
self.options.unless_cond = Some(std::sync::Arc::new(cond));
self
}
};
}
pub(crate) use impl_common_validator_methods;
pub mod acceptance;
pub mod confirmation;
pub mod custom;
pub mod exclusion;
pub mod format;
pub mod inclusion;
pub mod length;
pub mod numericality;
pub mod presence;
pub mod uniqueness;
pub use acceptance::AcceptanceValidator;
pub use confirmation::ConfirmationValidator;
pub use custom::CustomValidator;
pub use exclusion::ExclusionValidator;
pub use format::FormatValidator;
pub use inclusion::InclusionValidator;
pub use length::LengthValidator;
pub use numericality::NumericalityValidator;
pub use presence::PresenceValidator;
pub use uniqueness::UniquenessValidator;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationContext {
Create,
Update,
Save,
Custom(String),
}
#[derive(Clone, Default)]
pub struct ValidatorOptions {
pub allow_nil: bool,
pub allow_blank: bool,
pub on: Option<Vec<ValidationContext>>,
pub strict: bool,
pub if_cond: Option<ValidationPredicate>,
pub unless_cond: Option<ValidationPredicate>,
}
impl fmt::Debug for ValidatorOptions {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ValidatorOptions")
.field("allow_nil", &self.allow_nil)
.field("allow_blank", &self.allow_blank)
.field("on", &self.on)
.field("strict", &self.strict)
.field("if_cond", &self.if_cond.as_ref().map(|_| "<predicate>"))
.field(
"unless_cond",
&self.unless_cond.as_ref().map(|_| "<predicate>"),
)
.finish()
}
}
pub trait Validator: Send + Sync {
fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors);
fn validate_with_attrs(
&self,
attribute: &str,
value: Option<&Value>,
_attrs: &dyn Fn(&str) -> Option<Value>,
errors: &mut Errors,
) {
self.validate(attribute, value, errors);
}
fn name(&self) -> &str;
fn options(&self) -> &ValidatorOptions;
}
pub struct ValidationRule {
pub attribute: String,
pub validator: Box<dyn Validator>,
}
#[derive(Default)]
pub struct ValidationSet {
rules: Vec<ValidationRule>,
}
impl ValidationSet {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, attribute: impl Into<String>, validator: impl Validator + 'static) {
self.rules.push(ValidationRule {
attribute: attribute.into(),
validator: Box::new(validator),
});
}
#[must_use]
pub fn validators_on(&self, attribute: &str) -> Vec<&dyn Validator> {
self.rules
.iter()
.filter(|rule| rule.attribute == attribute)
.map(|rule| rule.validator.as_ref())
.collect()
}
pub fn validate(
&self,
attrs: &dyn Fn(&str) -> Option<Value>,
errors: &mut Errors,
) -> Result<(), String> {
self.validate_internal(attrs, errors, None)
}
pub fn validate_with_context(
&self,
attrs: &dyn Fn(&str) -> Option<Value>,
errors: &mut Errors,
context: &ValidationContext,
) -> Result<(), String> {
self.validate_internal(attrs, errors, Some(context))
}
fn validate_internal(
&self,
attrs: &dyn Fn(&str) -> Option<Value>,
errors: &mut Errors,
context: Option<&ValidationContext>,
) -> Result<(), String> {
for rule in &self.rules {
let value = attrs(&rule.attribute);
if should_skip(rule.validator.options(), value.as_ref(), context) {
continue;
}
let mut produced = Errors::new();
rule.validator.validate_with_attrs(
&rule.attribute,
value.as_ref(),
attrs,
&mut produced,
);
if produced.is_empty() {
continue;
}
if rule.validator.options().strict {
return Err(strict_error_message(&produced));
}
merge_errors(errors, &produced);
}
Ok(())
}
}
pub trait ValidationDsl {
fn validates_each<I, S, F>(&mut self, attributes: I, validate_fn: F)
where
I: IntoIterator<Item = S>,
S: Into<String>,
F: Fn(&str, Option<&Value>, &mut Errors) + Send + Sync + 'static;
fn validate<F>(&mut self, validate_fn: F)
where
F: Fn(&dyn Fn(&str) -> Option<Value>, &mut Errors) + Send + Sync + 'static;
}
impl ValidationDsl for ValidationSet {
fn validates_each<I, S, F>(&mut self, attributes: I, validate_fn: F)
where
I: IntoIterator<Item = S>,
S: Into<String>,
F: Fn(&str, Option<&Value>, &mut Errors) + Send + Sync + 'static,
{
let validate_fn: Arc<ValidateFn> = Arc::new(validate_fn);
for attribute in attributes {
self.add(
attribute.into(),
EachValidator::new(Arc::clone(&validate_fn)),
);
}
}
fn validate<F>(&mut self, validate_fn: F)
where
F: Fn(&dyn Fn(&str) -> Option<Value>, &mut Errors) + Send + Sync + 'static,
{
self.add("base", ModelValidator::new(validate_fn));
}
}
#[derive(Clone)]
struct EachValidator {
validate_fn: Arc<ValidateFn>,
options: ValidatorOptions,
}
impl EachValidator {
fn new(validate_fn: Arc<ValidateFn>) -> Self {
Self {
validate_fn,
options: ValidatorOptions::default(),
}
}
}
impl Validator for EachValidator {
fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
(self.validate_fn)(attribute, value, errors);
}
fn name(&self) -> &str {
"each"
}
fn options(&self) -> &ValidatorOptions {
&self.options
}
}
struct ModelValidator {
validate_fn: Box<ModelValidationFn>,
options: ValidatorOptions,
}
impl ModelValidator {
fn new<F>(validate_fn: F) -> Self
where
F: Fn(&dyn Fn(&str) -> Option<Value>, &mut Errors) + Send + Sync + 'static,
{
Self {
validate_fn: Box::new(validate_fn),
options: ValidatorOptions::default(),
}
}
}
impl Validator for ModelValidator {
fn validate(&self, _attribute: &str, _value: Option<&Value>, errors: &mut Errors) {
(self.validate_fn)(&|_| None, errors);
}
fn validate_with_attrs(
&self,
_attribute: &str,
_value: Option<&Value>,
attrs: &dyn Fn(&str) -> Option<Value>,
errors: &mut Errors,
) {
(self.validate_fn)(attrs, errors);
}
fn name(&self) -> &str {
"validate"
}
fn options(&self) -> &ValidatorOptions {
&self.options
}
}
fn merge_errors(target: &mut Errors, produced: &Errors) {
for error in produced.details() {
target.add_with_details(
&error.attribute,
error.error_type.clone(),
error.message.clone(),
error.details.clone(),
);
}
}
fn strict_error_message(errors: &Errors) -> String {
errors
.full_messages()
.into_iter()
.next()
.unwrap_or_else(|| String::from("validation failed"))
}
pub(crate) fn should_skip(
options: &ValidatorOptions,
value: Option<&Value>,
context: Option<&ValidationContext>,
) -> bool {
if !context_matches(options.on.as_deref(), context) {
return true;
}
if options.allow_nil && value_is_nil(value) {
return true;
}
if options.allow_blank && value_is_blank(value) {
return true;
}
let null = Value::Null;
let candidate = value.unwrap_or(&null);
if let Some(predicate) = &options.if_cond
&& !predicate(candidate)
{
return true;
}
if let Some(predicate) = &options.unless_cond
&& predicate(candidate)
{
return true;
}
false
}
pub(crate) fn context_matches(
allowed: Option<&[ValidationContext]>,
current: Option<&ValidationContext>,
) -> bool {
let Some(allowed) = allowed else {
return true;
};
let Some(current) = current else {
return true;
};
allowed.iter().any(|candidate| match (candidate, current) {
(ValidationContext::Save, _) => true,
(ValidationContext::Create, ValidationContext::Create)
| (ValidationContext::Update, ValidationContext::Update)
| (ValidationContext::Custom(_), ValidationContext::Custom(_)) => candidate == current,
_ => false,
})
}
pub(crate) fn value_is_nil(value: Option<&Value>) -> bool {
matches!(value, None | Some(Value::Null))
}
pub(crate) fn value_is_blank(value: Option<&Value>) -> bool {
match value {
None | Some(Value::Null) => true,
Some(Value::String(text)) => text.trim().is_empty(),
Some(Value::Array(values)) => values.is_empty(),
Some(Value::Object(values)) => values.is_empty(),
Some(Value::Bool(flag)) => !flag,
Some(Value::Number(_)) => false,
}
}
#[must_use]
pub fn presence() -> PresenceValidator {
PresenceValidator::new()
}
#[must_use]
pub fn length() -> LengthValidator {
LengthValidator::new()
}
#[must_use]
pub fn numericality() -> NumericalityValidator {
NumericalityValidator::new()
}
#[must_use]
pub fn format_with(pattern: &str) -> FormatValidator {
FormatValidator::with_pattern(pattern)
}
#[must_use]
pub fn inclusion<T>(values: T) -> InclusionValidator
where
T: Into<Vec<Value>>,
{
InclusionValidator::new(values)
}
#[must_use]
pub fn exclusion<T>(values: T) -> ExclusionValidator
where
T: Into<Vec<Value>>,
{
ExclusionValidator::new(values)
}
#[must_use]
pub fn acceptance() -> AcceptanceValidator {
AcceptanceValidator::new()
}
#[must_use]
pub fn confirmation(confirmation_field: &str) -> ConfirmationValidator {
ConfirmationValidator::new(confirmation_field)
}
#[must_use]
pub fn uniqueness() -> UniquenessValidator {
UniquenessValidator::new()
}
#[must_use]
pub fn custom<F>(f: F) -> CustomValidator
where
F: Fn(&str, Option<&Value>, &mut Errors) + Send + Sync + 'static,
{
CustomValidator::new(f)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use serde_json::{Value, json};
use super::{
ValidationContext, ValidationDsl, ValidationSet, Validator, ValidatorOptions,
context_matches, custom, length, presence, should_skip, value_is_blank, value_is_nil,
};
use crate::errors::{ErrorType, Errors};
#[derive(Default)]
struct MarkerValidator {
options: ValidatorOptions,
}
impl MarkerValidator {
fn new() -> Self {
Self::default()
}
fn allow_nil(mut self) -> Self {
self.options.allow_nil = true;
self
}
fn allow_blank(mut self) -> Self {
self.options.allow_blank = true;
self
}
fn on(mut self, contexts: Vec<ValidationContext>) -> Self {
self.options.on = Some(contexts);
self
}
fn if_cond<F>(mut self, cond: F) -> Self
where
F: Fn(&Value) -> bool + Send + Sync + 'static,
{
self.options.if_cond = Some(Arc::new(cond));
self
}
fn unless_cond<F>(mut self, cond: F) -> Self
where
F: Fn(&Value) -> bool + Send + Sync + 'static,
{
self.options.unless_cond = Some(Arc::new(cond));
self
}
}
impl Validator for MarkerValidator {
fn validate(&self, attribute: &str, _value: Option<&Value>, errors: &mut Errors) {
errors.add(attribute, ErrorType::Custom("marker".to_string()), "ran");
}
fn name(&self) -> &str {
"marker"
}
fn options(&self) -> &ValidatorOptions {
&self.options
}
}
#[test]
fn blank_detection_matches_validation_needs() {
assert!(value_is_blank(None));
assert!(value_is_blank(Some(&Value::Null)));
assert!(value_is_blank(Some(&json!(" "))));
assert!(value_is_blank(Some(&json!([]))));
assert!(value_is_blank(Some(&json!({}))));
assert!(value_is_blank(Some(&json!(false))));
assert!(!value_is_blank(Some(&json!(0))));
assert!(!value_is_blank(Some(&json!("Alice"))));
}
#[test]
fn context_matching_treats_save_as_global() {
let allowed = vec![ValidationContext::Save];
assert!(context_matches(
Some(&allowed),
Some(&ValidationContext::Create)
));
assert!(context_matches(
Some(&allowed),
Some(&ValidationContext::Update)
));
assert!(context_matches(
Some(&allowed),
Some(&ValidationContext::Save)
));
}
#[test]
fn nil_context_matches_every_restricted_validator() {
let allowed = vec![ValidationContext::Create, ValidationContext::Update];
assert!(context_matches(Some(&allowed), None));
}
#[test]
fn should_skip_honors_allow_nil_and_allow_blank() {
let nil_validator = MarkerValidator::new().allow_nil();
let blank_validator = MarkerValidator::new().allow_blank();
assert!(should_skip(
nil_validator.options(),
Some(&Value::Null),
Some(&ValidationContext::Save),
));
assert!(should_skip(
blank_validator.options(),
Some(&json!(" ")),
Some(&ValidationContext::Save),
));
}
#[test]
fn should_skip_honors_context_and_predicates() {
let validator = MarkerValidator::new()
.on(vec![ValidationContext::Create])
.if_cond(|value| value == &json!("run"))
.unless_cond(|value| value == &json!("stop"));
assert!(should_skip(
validator.options(),
Some(&json!("run")),
Some(&ValidationContext::Update),
));
assert!(should_skip(
validator.options(),
Some(&json!("miss")),
Some(&ValidationContext::Create),
));
assert!(should_skip(
validator.options(),
Some(&json!("stop")),
Some(&ValidationContext::Create),
));
assert!(!should_skip(
validator.options(),
Some(&json!("run")),
Some(&ValidationContext::Create),
));
}
#[test]
fn validation_set_runs_matching_validators() {
let mut set = ValidationSet::new();
set.add("name", MarkerValidator::new());
let attrs = HashMap::from([("name".to_string(), json!("Alice"))]);
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert_eq!(errors.count(), 1);
assert_eq!(errors.on("name")[0].message, "ran");
}
#[test]
fn validation_set_filters_by_context() {
let mut set = ValidationSet::new();
set.add(
"name",
MarkerValidator::new().on(vec![ValidationContext::Create]),
);
let attrs = HashMap::from([("name".to_string(), json!("Alice"))]);
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.count(), 1);
}
#[test]
fn validation_set_skips_custom_validator_when_blank_is_allowed() {
let called = Arc::new(AtomicBool::new(false));
let called_clone = Arc::clone(&called);
let validator = custom(move |_attribute, _value, _errors| {
called_clone.store(true, Ordering::Relaxed);
})
.allow_blank();
let mut set = ValidationSet::new();
set.add("nickname", validator);
let attrs = HashMap::from([("nickname".to_string(), json!(" "))]);
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert!(errors.is_empty());
assert!(!called.load(Ordering::Relaxed));
}
#[test]
fn custom_context_matches_same_name() {
let allowed = vec![ValidationContext::Custom("import".to_string())];
assert!(context_matches(
Some(&allowed),
Some(&ValidationContext::Custom("import".to_string())),
));
}
#[test]
fn custom_context_does_not_match_different_name() {
let allowed = vec![ValidationContext::Custom("import".to_string())];
assert!(!context_matches(
Some(&allowed),
Some(&ValidationContext::Custom("export".to_string())),
));
}
#[test]
fn save_context_matches_custom_context() {
let allowed = vec![ValidationContext::Save];
assert!(context_matches(
Some(&allowed),
Some(&ValidationContext::Custom("import".to_string())),
));
}
#[test]
fn value_is_nil_treats_missing_as_nil() {
assert!(value_is_nil(None));
}
#[test]
fn value_is_nil_treats_null_as_nil() {
assert!(value_is_nil(Some(&Value::Null)));
}
#[test]
fn value_is_nil_rejects_present_value() {
assert!(!value_is_nil(Some(&json!(0))));
}
#[test]
fn should_skip_uses_null_for_if_predicates_when_value_is_missing() {
let validator = MarkerValidator::new().if_cond(Value::is_null);
assert!(!should_skip(
validator.options(),
None,
Some(&ValidationContext::Save),
));
}
#[test]
fn should_skip_uses_null_for_unless_predicates_when_value_is_missing() {
let validator = MarkerValidator::new().unless_cond(Value::is_null);
assert!(should_skip(
validator.options(),
None,
Some(&ValidationContext::Save),
));
}
#[test]
fn should_not_skip_present_values_without_options() {
let validator = MarkerValidator::new();
assert!(!should_skip(
validator.options(),
Some(&json!("Alice")),
Some(&ValidationContext::Save),
));
}
#[test]
fn allow_blank_skips_empty_arrays() {
let validator = MarkerValidator::new().allow_blank();
assert!(should_skip(
validator.options(),
Some(&json!([])),
Some(&ValidationContext::Save),
));
}
#[test]
fn allow_blank_skips_empty_objects() {
let validator = MarkerValidator::new().allow_blank();
assert!(should_skip(
validator.options(),
Some(&json!({})),
Some(&ValidationContext::Save),
));
}
#[test]
fn validation_set_preserves_rule_order_for_same_attribute() {
let mut set = ValidationSet::new();
set.add(
"name",
custom(|attribute, _value, errors| {
errors.add(
attribute,
ErrorType::Custom("global-first".to_string()),
"global-first",
);
}),
);
set.add(
"name",
custom(|attribute, _value, errors| {
errors.add(
attribute,
ErrorType::Custom("create-only".to_string()),
"create-only",
);
})
.on(vec![ValidationContext::Create]),
);
set.add(
"name",
custom(|attribute, _value, errors| {
errors.add(
attribute,
ErrorType::Custom("update-only".to_string()),
"update-only",
);
})
.on(vec![ValidationContext::Update]),
);
set.add(
"name",
custom(|attribute, _value, errors| {
errors.add(
attribute,
ErrorType::Custom("global-last".to_string()),
"global-last",
);
}),
);
let attrs = HashMap::from([("name".to_string(), json!("Alice"))]);
let mut errors = Errors::new();
let _ = set.validate_with_context(
&|name| attrs.get(name).cloned(),
&mut errors,
&ValidationContext::Create,
);
assert_eq!(
errors.messages_for("name"),
vec![
"global-first".to_string(),
"create-only".to_string(),
"global-last".to_string(),
]
);
}
#[test]
fn validation_set_skips_allow_nil_rule_but_runs_required_rule_for_missing_value() {
let mut set = ValidationSet::new();
set.add(
"nickname",
custom(|attribute, _value, errors| {
errors.add(
attribute,
ErrorType::Custom("optional".to_string()),
"optional",
);
})
.allow_nil(),
);
set.add(
"nickname",
custom(|attribute, _value, errors| {
errors.add(
attribute,
ErrorType::Custom("required".to_string()),
"required",
);
}),
);
let attrs: HashMap<String, Value> = HashMap::new();
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert_eq!(
errors.messages_for("nickname"),
vec!["required".to_string()]
);
}
fn validate_errors(
set: &ValidationSet,
attrs: HashMap<String, Value>,
context: Option<ValidationContext>,
) -> Errors {
let mut errors = Errors::new();
match context {
Some(context) => {
let _ = set.validate_with_context(
&|name| attrs.get(name).cloned(),
&mut errors,
&context,
);
}
None => {
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
}
}
errors
}
struct ProcMessageValidator {
include_attribute: bool,
options: ValidatorOptions,
}
impl ProcMessageValidator {
fn from_record() -> Self {
Self {
include_attribute: false,
options: ValidatorOptions::default(),
}
}
fn from_record_and_data() -> Self {
Self {
include_attribute: true,
options: ValidatorOptions::default(),
}
}
fn build_message(&self, attribute: &str, attrs: &dyn Fn(&str) -> Option<Value>) -> String {
let author_name = attrs("author_name")
.and_then(|value| value.as_str().map(str::to_owned))
.unwrap_or_default();
if self.include_attribute {
format!(
"{} is missing. You have failed me for the last time, {}.",
rustrails_support::inflector::humanize(attribute),
author_name,
)
} else {
format!("You have failed me for the last time, {}.", author_name,)
}
}
}
impl Validator for ProcMessageValidator {
fn validate(&self, attribute: &str, _value: Option<&Value>, errors: &mut Errors) {
errors.add(
attribute,
ErrorType::Custom("proc_message".to_string()),
self.build_message(attribute, &|_| None),
);
}
fn validate_with_attrs(
&self,
attribute: &str,
_value: Option<&Value>,
attrs: &dyn Fn(&str) -> Option<Value>,
errors: &mut Errors,
) {
errors.add(
attribute,
ErrorType::Custom("proc_message".to_string()),
self.build_message(attribute, attrs),
);
}
fn name(&self) -> &str {
"proc_message"
}
fn options(&self) -> &ValidatorOptions {
&self.options
}
}
#[test]
fn test_single_field_validation() {
let mut set = ValidationSet::new();
set.add("content", presence());
let invalid_errors = validate_errors(
&set,
HashMap::from([("title".to_string(), json!("There's no content!"))]),
None,
);
assert!(invalid_errors.any());
assert_eq!(
invalid_errors.messages_for("content"),
vec!["can't be blank".to_string()]
);
let valid_errors = validate_errors(
&set,
HashMap::from([
("title".to_string(), json!("There's no content!")),
("content".to_string(), json!("Messa content!")),
]),
None,
);
assert!(valid_errors.is_empty());
}
#[test]
fn test_single_attr_validation_and_error_msg() {
let mut set = ValidationSet::new();
set.add("content", presence());
let errors = validate_errors(
&set,
HashMap::from([("title".to_string(), json!("There's no content!"))]),
None,
);
assert_eq!(errors.count(), 1);
assert_eq!(
errors.messages_for("content"),
vec!["can't be blank".to_string()]
);
}
#[test]
fn test_double_attr_validation_and_error_msg() {
let mut set = ValidationSet::new();
set.add("title", presence());
set.add("content", presence());
let errors = validate_errors(&set, HashMap::new(), None);
assert_eq!(errors.count(), 2);
assert_eq!(
errors.messages_for("title"),
vec!["can't be blank".to_string()]
);
assert_eq!(
errors.messages_for("content"),
vec!["can't be blank".to_string()]
);
}
#[test]
fn test_multiple_errors_per_attr_iteration_with_full_error_composition() {
let mut set = ValidationSet::new();
set.add("content", presence());
set.add("title", presence());
let errors = validate_errors(
&set,
HashMap::from([
("title".to_string(), json!("")),
("content".to_string(), json!("")),
]),
None,
);
assert_eq!(
errors.full_messages(),
vec![
"Content can't be blank".to_string(),
"Title can't be blank".to_string(),
]
);
assert_eq!(errors.count(), 2);
}
#[test]
fn test_errors_on_nested_attributes_expands_name() {
let mut errors = Errors::new();
errors.add("replies.name", ErrorType::Blank, "can't be blank");
assert_eq!(
errors.full_messages(),
vec!["Replies name can't be blank".to_string()]
);
}
#[test]
fn test_errors_on_base() {
let mut errors = Errors::new();
errors.add("title", ErrorType::Blank, "can't be blank");
errors.add("base", ErrorType::Invalid, "Reply is not dignifying");
assert_eq!(
errors.messages_for("base"),
vec!["Reply is not dignifying".to_string()]
);
assert_eq!(
errors.full_messages(),
vec![
"Title can't be blank".to_string(),
"Reply is not dignifying".to_string(),
]
);
assert_eq!(errors.count(), 2);
}
#[test]
fn test_errors_on_base_with_symbol_message() {
let mut errors = Errors::new();
errors.add("title", ErrorType::Blank, "can't be blank");
errors.add("base", ErrorType::Invalid, "is invalid");
assert_eq!(errors.messages_for("base"), vec!["is invalid".to_string()]);
assert_eq!(
errors.full_messages(),
vec!["Title can't be blank".to_string(), "is invalid".to_string()]
);
assert_eq!(errors.count(), 2);
}
#[test]
fn test_errors_on_custom_attribute() {
let mut errors = Errors::new();
errors.add("foo_bar", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.full_messages(),
vec!["Foo bar is invalid".to_string()]
);
}
#[test]
fn test_errors_on_custom_attribute_with_symbol_message() {
let mut errors = Errors::new();
errors.add("foo_bar", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.full_messages(),
vec!["Foo bar is invalid".to_string()]
);
}
#[test]
fn test_errors_empty_after_errors_on_check() {
let errors = Errors::new();
assert!(errors.messages_for("id").is_empty());
assert!(errors.is_empty());
}
#[test]
fn test_validates_each() {
let mut set = ValidationSet::new();
set.validates_each(["first_name", "last_name"], |attribute, value, errors| {
if value
.and_then(Value::as_str)
.is_some_and(|text| text.starts_with('z'))
{
errors.add(attribute, ErrorType::Invalid, "starts with z");
}
});
let errors = validate_errors(
&set,
HashMap::from([
("first_name".to_string(), json!("zed")),
("last_name".to_string(), json!("alpha")),
]),
None,
);
assert_eq!(
errors.messages_for("first_name"),
vec!["starts with z".to_string()]
);
assert!(errors.messages_for("last_name").is_empty());
}
#[test]
fn test_validate_block() {
let mut set = ValidationSet::new();
<ValidationSet as ValidationDsl>::validate(&mut set, |attrs, errors| {
if attrs("admin")
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
errors.add("base", ErrorType::Invalid, "admins are not allowed");
}
});
let errors = validate_errors(
&set,
HashMap::from([("admin".to_string(), json!(true))]),
None,
);
assert_eq!(
errors.messages_for("base"),
vec!["admins are not allowed".to_string()]
);
}
#[test]
fn test_validate_block_with_params() {
let mut set = ValidationSet::new();
<ValidationSet as ValidationDsl>::validate(&mut set, |attrs, errors| {
let title = attrs("title").and_then(|value| value.as_str().map(str::to_owned));
let author = attrs("author_name").and_then(|value| value.as_str().map(str::to_owned));
if title.as_deref() == Some("Forbidden") && author.as_deref() == Some("Robot") {
errors.add("title", ErrorType::Invalid, "cannot be assigned to Robot");
}
});
let errors = validate_errors(
&set,
HashMap::from([
("title".to_string(), json!("Forbidden")),
("author_name".to_string(), json!("Robot")),
]),
None,
);
assert_eq!(
errors.messages_for("title"),
vec!["cannot be assigned to Robot".to_string()]
);
}
#[test]
fn test_callback_options_to_validate() {
let sequence = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut set = ValidationSet::new();
let sequence_b = Arc::clone(&sequence);
set.add(
"title",
custom(move |_attribute, _value, _errors| {
sequence_b.lock().unwrap().push("b");
}),
);
let sequence_a = Arc::clone(&sequence);
set.add(
"title",
custom(move |_attribute, _value, _errors| {
sequence_a.lock().unwrap().push("a");
})
.if_cond(|_| true),
);
let sequence_c = Arc::clone(&sequence);
set.add(
"title",
custom(move |_attribute, _value, _errors| {
sequence_c.lock().unwrap().push("c");
})
.unless_cond(|_| true),
);
let errors = validate_errors(
&set,
HashMap::from([("title".to_string(), json!("whatever"))]),
None,
);
assert!(errors.is_empty());
let recorded = sequence.lock().unwrap().clone();
assert_eq!(recorded, vec!["b", "a"]);
}
#[test]
fn test_errors_to_json() {
let mut set = ValidationSet::new();
set.add("title", presence());
set.add("content", presence());
let errors = validate_errors(&set, HashMap::new(), None);
assert_eq!(
errors.as_json(),
json!({
"title": ["can't be blank"],
"content": ["can't be blank"],
})
);
}
#[test]
fn test_validation_order() {
let mut set = ValidationSet::new();
set.add("title", presence());
set.add("title", length().minimum(2));
set.add("author_name", presence());
set.add(
"author_email_address",
custom(|attribute, _value, errors| {
errors.add(
attribute,
ErrorType::Custom("manual".to_string()),
"will never be valid",
);
}),
);
set.add("content", length().minimum(10));
let errors = validate_errors(
&set,
HashMap::from([("title".to_string(), json!(""))]),
None,
);
assert_eq!(
errors.attributes(),
vec!["title", "author_name", "author_email_address", "content"]
);
assert_eq!(
errors.messages_for("title"),
vec![
"can't be blank".to_string(),
"is too short (minimum is 2 characters)".to_string(),
]
);
assert_eq!(
errors.messages_for("author_name"),
vec!["can't be blank".to_string()]
);
assert_eq!(
errors.messages_for("author_email_address"),
vec!["will never be valid".to_string()]
);
assert_eq!(
errors.messages_for("content"),
vec!["is too short (minimum is 10 characters)".to_string()]
);
}
#[test]
fn test_validation_with_if_and_on() {
let called = Arc::new(AtomicBool::new(false));
let called_on_update = Arc::clone(&called);
let mut set = ValidationSet::new();
set.add(
"title",
presence()
.if_cond(move |_| {
called_on_update.store(true, Ordering::Relaxed);
true
})
.on(vec![ValidationContext::Update]),
);
let no_context_errors = validate_errors(&set, HashMap::new(), None);
assert!(no_context_errors.any());
let create_errors = validate_errors(&set, HashMap::new(), Some(ValidationContext::Create));
assert!(create_errors.is_empty());
let update_errors = validate_errors(&set, HashMap::new(), Some(ValidationContext::Update));
assert!(update_errors.any());
assert!(called.load(Ordering::Relaxed));
}
#[test]
fn test_invalid_should_be_the_opposite_of_valid() {
let mut set = ValidationSet::new();
set.add("title", presence());
let invalid_errors = validate_errors(&set, HashMap::new(), None);
assert!(invalid_errors.any());
let valid_errors = validate_errors(
&set,
HashMap::from([("title".to_string(), json!("Things are going to change"))]),
None,
);
assert!(valid_errors.is_empty());
}
#[test]
fn test_validation_with_message_as_proc() {
let mut set = ValidationSet::new();
set.add(
"title",
custom(|attribute, _value, errors| {
errors.add(
attribute,
ErrorType::Custom("proc_message".to_string()),
"NO BLANKS HERE",
);
}),
);
let errors = validate_errors(&set, HashMap::new(), None);
assert_eq!(
errors.messages_for("title"),
vec!["NO BLANKS HERE".to_string()]
);
}
#[test]
fn test_list_of_validators_for_model() {
let mut set = ValidationSet::new();
set.add("title", presence());
set.add("title", length().minimum(5));
let validators = set.validators_on("title");
let names = validators
.iter()
.map(|validator| validator.name())
.collect::<Vec<_>>();
assert_eq!(names, vec!["presence", "length"]);
}
#[test]
fn test_list_of_validators_on_an_attribute() {
let mut set = ValidationSet::new();
set.add("title", presence());
let validators = set.validators_on("title");
assert_eq!(validators.len(), 1);
assert_eq!(validators[0].name(), "presence");
}
#[test]
fn test_list_of_validators_on_multiple_attributes() {
let mut set = ValidationSet::new();
set.add("title", presence());
set.add("author_name", length().minimum(3));
assert_eq!(set.validators_on("title").len(), 1);
assert_eq!(set.validators_on("author_name").len(), 1);
assert!(
set.validators_on("title")
.iter()
.all(|validator| validator.name() == "presence")
);
assert!(
set.validators_on("author_name")
.iter()
.all(|validator| validator.name() == "length")
);
}
#[test]
fn test_list_of_validators_will_be_empty_when_empty() {
let set = ValidationSet::new();
assert!(set.validators_on("missing").is_empty());
}
#[test]
fn test_validations_on_the_instance_level() {
let mut set = ValidationSet::new();
set.add("title", presence());
set.add("author_name", presence());
set.add("content", length().minimum(10));
let invalid_errors = validate_errors(&set, HashMap::new(), None);
assert_eq!(invalid_errors.count(), 3);
let valid_errors = validate_errors(
&set,
HashMap::from([
("title".to_string(), json!("Some Title")),
("author_name".to_string(), json!("Some Author")),
(
"content".to_string(),
json!("Some Content Whose Length is more than 10."),
),
]),
None,
);
assert!(valid_errors.is_empty());
}
#[test]
fn test_validate() {
let mut set = ValidationSet::new();
set.add("title", presence());
set.add("author_name", presence());
set.add("content", length().minimum(10));
let mut errors = Errors::new();
assert!(errors.is_empty());
let attrs: HashMap<String, Value> = HashMap::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
assert!(!errors.is_empty());
}
#[test]
fn test_strict_validation_in_validates() {
let mut set = ValidationSet::new();
set.add("title", presence().strict());
let mut errors = Errors::new();
let result = set.validate(&|_| None, &mut errors);
assert_eq!(result, Err("Title can't be blank".to_string()));
assert!(errors.is_empty());
}
#[test]
fn test_strict_validation_not_fails() {
let mut set = ValidationSet::new();
set.add("title", presence().strict());
let mut errors = Errors::new();
let result = set.validate(
&|name| (name == "title").then(|| json!("Present")),
&mut errors,
);
assert_eq!(result, Ok(()));
assert!(errors.is_empty());
}
#[test]
fn test_strict_validation_particular_validator() {
let mut set = ValidationSet::new();
set.add("title", presence());
set.add("title", length().minimum(5).strict());
let mut errors = Errors::new();
let result = set.validate(&|name| (name == "title").then(|| json!("abc")), &mut errors);
assert_eq!(
result,
Err("Title is too short (minimum is 5 characters)".to_string())
);
assert!(errors.is_empty());
}
#[test]
fn test_strict_validation_in_custom_validator_helper() {
let mut set = ValidationSet::new();
set.add(
"title",
custom(|attribute, _value, errors| {
errors.add(attribute, ErrorType::Invalid, "is forbidden");
})
.strict(),
);
let mut errors = Errors::new();
let result = set.validate(&|_| None, &mut errors);
assert_eq!(result, Err("Title is forbidden".to_string()));
assert!(errors.is_empty());
}
#[test]
fn test_strict_validation_error_message() {
let mut set = ValidationSet::new();
set.add(
"base",
custom(|attribute, _value, errors| {
errors.add(attribute, ErrorType::Invalid, "record is invalid");
})
.strict(),
);
let mut errors = Errors::new();
let result = set.validate(&|_| None, &mut errors);
assert_eq!(result, Err("record is invalid".to_string()));
assert!(errors.is_empty());
}
#[test]
fn test_does_not_modify_options_argument() {
let contexts = vec![ValidationContext::Create];
let snapshot = contexts.clone();
let validator = presence().on(contexts.clone());
assert_eq!(contexts, snapshot);
assert_eq!(validator.options().on.as_deref(), Some(snapshot.as_slice()));
}
#[test]
fn test_dup_validity_is_independent() {
let mut set = ValidationSet::new();
set.add("title", presence());
let original_errors = validate_errors(
&set,
HashMap::from([("title".to_string(), json!("Literature"))]),
None,
);
let duped_errors = validate_errors(&set, HashMap::new(), None);
assert!(original_errors.is_empty());
assert_eq!(
duped_errors.messages_for("title"),
vec!["can't be blank".to_string()]
);
}
#[test]
fn test_validation_with_message_as_proc_that_takes_a_record_as_a_parameter() {
let mut set = ValidationSet::new();
set.add("title", ProcMessageValidator::from_record());
let errors = validate_errors(
&set,
HashMap::from([("author_name".to_string(), json!("Admiral"))]),
None,
);
assert_eq!(
errors.messages_for("title"),
vec!["You have failed me for the last time, Admiral.".to_string()]
);
}
#[test]
fn test_validation_with_message_as_proc_that_takes_record_and_data_as_a_parameters() {
let mut set = ValidationSet::new();
set.add("title", ProcMessageValidator::from_record_and_data());
let errors = validate_errors(
&set,
HashMap::from([("author_name".to_string(), json!("Admiral"))]),
None,
);
assert_eq!(
errors.messages_for("title"),
vec!["Title is missing. You have failed me for the last time, Admiral.".to_string()]
);
}
#[test]
fn strict_validation_preserves_earlier_non_strict_errors_before_returning() {
let mut set = ValidationSet::new();
set.add("title", presence());
set.add("title", length().minimum(5).strict());
let mut errors = Errors::new();
let result = set.validate(&|name| (name == "title").then(|| json!("")), &mut errors);
assert_eq!(
result,
Err("Title is too short (minimum is 5 characters)".to_string())
);
assert_eq!(
errors.messages_for("title"),
vec!["can't be blank".to_string()]
);
}
#[test]
#[ignore = "Rails-specific: strict validator error message format depends on full ActiveModel error pipeline"]
fn test_invalid_validator() {}
#[test]
#[ignore = "Rails-specific: validate options hash parsing depends on Ruby metaprogramming"]
fn test_invalid_options_to_validate() {}
#[test]
#[ignore = "Rails-specific: frozen model validation depends on Ruby freeze semantics"]
fn test_frozen_models_can_be_validated() {}
#[test]
#[ignore = "Rails-specific: :except_on context filtering is not implemented"]
fn test_validate_with_except_on() {}
#[test]
#[ignore = "Rails-specific: :except_on context filtering is not implemented"]
fn test_validations_some_with_except() {}
#[test]
#[ignore = "Rails-specific: custom attribute readers are not supported in ValidationSet"]
fn test_validates_each_custom_reader() {}
#[test]
#[ignore = "Rails-specific: array condition mutation testing depends on Ruby array semantics"]
fn test_validates_with_array_condition_does_not_mutate_the_array() {}
#[test]
#[ignore = "Rails-specific: validator instance introspection depends on Ruby reflection"]
fn test_accessing_instance_of_validator_on_an_attribute() {}
#[test]
#[ignore = "Rails-specific: validators_for_model multi-attribute introspection not implemented"]
fn test_list_of_validators_for_model_exposes_all_attributes_at_once() {}
#[test]
#[ignore = "Rails-specific: validators_on varargs interface not implemented"]
fn test_list_of_validators_on_multiple_attributes_accepts_varargs() {}
}