rustrails-model 0.1.2

Model layer (ActiveModel equivalent)
Documentation
use std::fmt;

use serde_json::Value;

use super::{Validator, ValidatorOptions};
use crate::errors::{ErrorType, Errors};

type UniquenessCheckFn = dyn Fn(&str, &Value) -> bool + Send + Sync;

/// Validates that a value is unique via an injected lookup function.
#[derive(Default)]
pub struct UniquenessValidator {
    /// Additional attributes that define uniqueness scope.
    pub scope: Vec<String>,
    /// Whether string comparisons should preserve case.
    pub case_sensitive: bool,
    /// Custom message used when the value is taken.
    pub message: Option<String>,
    /// Lookup function supplied by higher layers with storage access.
    pub check: Option<Box<UniquenessCheckFn>>,
    pub(crate) options: ValidatorOptions,
}

impl fmt::Debug for UniquenessValidator {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("UniquenessValidator")
            .field("has_check", &self.check.is_some())
            .field("message", &self.message)
            .field("options", &self.options)
            .finish()
    }
}

impl UniquenessValidator {
    /// Creates a new uniqueness validator.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    crate::validations::impl_common_validator_methods!();

    /// Registers a uniqueness check where `true` means the value is unique.
    #[must_use]
    pub fn with_check<F>(mut self, check: F) -> Self
    where
        F: Fn(&str, &Value) -> bool + Send + Sync + 'static,
    {
        self.check = Some(Box::new(check));
        self
    }

    /// Overrides the default taken message.
    #[must_use]
    pub fn message(mut self, message: impl Into<String>) -> Self {
        self.message = Some(message.into());
        self
    }

    fn error_message(&self) -> String {
        self.message
            .clone()
            .unwrap_or_else(|| String::from("has already been taken"))
    }
}

impl Validator for UniquenessValidator {
    fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
        let Some(check) = &self.check else {
            return;
        };
        let Some(value) = value else {
            return;
        };

        if !check(attribute, value) {
            errors.add(attribute, ErrorType::Taken, self.error_message());
        }
    }

    fn name(&self) -> &str {
        "uniqueness"
    }

    fn options(&self) -> &ValidatorOptions {
        &self.options
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;
    use std::sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    };

    use serde_json::json;

    use super::UniquenessValidator;
    use crate::{
        errors::{ErrorType, Errors},
        validations::{ValidationContext, ValidationSet, Validator},
    };

    #[test]
    fn passes_without_check_function() {
        let validator = UniquenessValidator::new();
        let mut errors = Errors::new();

        validator.validate("email", Some(&json!("alice@example.com")), &mut errors);

        assert!(errors.is_empty());
    }

    #[test]
    fn failing_check_marks_value_as_taken() {
        let validator = UniquenessValidator::new().with_check(|_, value| value != &json!("taken"));
        let mut errors = Errors::new();

        validator.validate("slug", Some(&json!("taken")), &mut errors);

        assert_eq!(errors.on("slug")[0].error_type, ErrorType::Taken);
    }

    #[test]
    fn successful_check_passes() {
        let validator = UniquenessValidator::new().with_check(|_, value| value == &json!("free"));
        let mut errors = Errors::new();

        validator.validate("slug", Some(&json!("free")), &mut errors);

        assert!(errors.is_empty());
    }

    #[test]
    fn custom_message_is_used() {
        let validator = UniquenessValidator::new()
            .with_check(|_, _| false)
            .message("already used");
        let mut errors = Errors::new();

        validator.validate("email", Some(&json!("alice@example.com")), &mut errors);

        assert_eq!(errors.on("email")[0].message, "already used");
    }

    #[test]
    fn missing_values_are_ignored_even_with_a_check() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let validator = UniquenessValidator::new().with_check(move |_, _| {
            called_clone.store(true, Ordering::Relaxed);
            true
        });
        let mut errors = Errors::new();

        validator.validate("email", None, &mut errors);

        assert!(errors.is_empty());
        assert!(!called.load(Ordering::Relaxed));
    }

    #[test]
    fn check_receives_attribute_name() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let validator = UniquenessValidator::new().with_check(move |attribute, _| {
            called_clone.store(attribute == "email", Ordering::Relaxed);
            true
        });
        let mut errors = Errors::new();

        validator.validate("email", Some(&json!("alice@example.com")), &mut errors);

        assert!(called.load(Ordering::Relaxed));
    }

    #[test]
    fn check_receives_attribute_value() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let validator = UniquenessValidator::new().with_check(move |_, value| {
            called_clone.store(value == &json!("alice@example.com"), Ordering::Relaxed);
            true
        });
        let mut errors = Errors::new();

        validator.validate("email", Some(&json!("alice@example.com")), &mut errors);

        assert!(called.load(Ordering::Relaxed));
    }

    #[test]
    fn allow_nil_skips_null_values_in_validation_set() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "email",
            UniquenessValidator::new()
                .with_check(move |_, _| {
                    called_clone.store(true, Ordering::Relaxed);
                    false
                })
                .allow_nil(),
        );
        let attrs = HashMap::from([("email".to_string(), json!(null))]);
        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 allow_blank_skips_blank_values_in_validation_set() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "email",
            UniquenessValidator::new()
                .with_check(move |_, _| {
                    called_clone.store(true, Ordering::Relaxed);
                    false
                })
                .allow_blank(),
        );
        let attrs = HashMap::from([("email".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 allow_blank_does_not_skip_present_values_in_validation_set() {
        let mut set = ValidationSet::new();
        set.add(
            "email",
            UniquenessValidator::new()
                .with_check(|_, _| false)
                .allow_blank(),
        );
        let attrs = HashMap::from([("email".to_string(), json!("alice@example.com"))]);
        let mut errors = Errors::new();

        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);

        assert_eq!(errors.on("email")[0].error_type, ErrorType::Taken);
    }

    #[test]
    fn on_context_filters_validation_set_execution() {
        let mut set = ValidationSet::new();
        set.add(
            "email",
            UniquenessValidator::new()
                .with_check(|_, _| false)
                .on(vec![ValidationContext::Update]),
        );
        let attrs = HashMap::from([("email".to_string(), json!("alice@example.com"))]);
        let mut errors = Errors::new();

        let _ = set.validate_with_context(
            &|name| attrs.get(name).cloned(),
            &mut errors,
            &ValidationContext::Create,
        );
        assert!(errors.is_empty());

        let _ = set.validate_with_context(
            &|name| attrs.get(name).cloned(),
            &mut errors,
            &ValidationContext::Update,
        );
        assert_eq!(errors.on("email")[0].error_type, ErrorType::Taken);
    }

    #[test]
    fn if_condition_false_skips_uniqueness_check() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "email",
            UniquenessValidator::new()
                .with_check(move |_, _| {
                    called_clone.store(true, Ordering::Relaxed);
                    false
                })
                .if_cond(|value| value == &json!("other@example.com")),
        );
        let attrs = HashMap::from([("email".to_string(), json!("alice@example.com"))]);
        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 unless_condition_true_skips_uniqueness_check() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "email",
            UniquenessValidator::new()
                .with_check(move |_, _| {
                    called_clone.store(true, Ordering::Relaxed);
                    false
                })
                .unless_cond(|value| value == &json!("alice@example.com")),
        );
        let attrs = HashMap::from([("email".to_string(), json!("alice@example.com"))]);
        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 full_message_humanizes_attribute_name() {
        let validator = UniquenessValidator::new().with_check(|_, _| false);
        let mut errors = Errors::new();

        validator.validate(
            "email_address",
            Some(&json!("alice@example.com")),
            &mut errors,
        );

        assert_eq!(
            errors.full_messages(),
            vec!["Email address has already been taken".to_string()],
        );
    }

    #[test]
    fn debug_output_reports_check_presence_without_closure_details() {
        let debug = format!("{:?}", UniquenessValidator::new().with_check(|_, _| true));

        assert!(debug.contains("has_check"));
        assert!(!debug.contains("0x"));
    }
}