Skip to main content

rustrails_record/
validations.rs

1use rustrails_support::{database, runtime};
2use sea_orm::{
3    ColumnTrait, DatabaseConnection, EntityTrait, ExprTrait, FromQueryResult, Iterable,
4    PaginatorTrait, QueryFilter,
5    sea_query::{Expr, Func},
6};
7use serde_json::Value;
8
9use crate::{
10    Record,
11    relation::{json_to_sea_value, resolve_column},
12};
13
14/// Record-level uniqueness validation metadata and database lookup behavior.
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct UniquenessValidator {
17    /// Additional attributes intended to narrow the uniqueness scope.
18    ///
19    /// Scope metadata is retained even though the current generic record API cannot yet project
20    /// sibling attribute values for query construction.
21    pub scope: Vec<String>,
22    /// Whether string comparisons preserve case.
23    pub case_sensitive: bool,
24    /// Optional custom validation message for higher layers.
25    pub message: Option<String>,
26}
27
28impl UniquenessValidator {
29    /// Creates a new uniqueness validator.
30    #[must_use]
31    pub fn new() -> Self {
32        Self {
33            scope: Vec::new(),
34            case_sensitive: true,
35            message: None,
36        }
37    }
38
39    /// Sets additional scope fields for the uniqueness rule.
40    #[must_use]
41    pub fn scope(mut self, fields: Vec<String>) -> Self {
42        self.scope = fields;
43        self
44    }
45
46    /// Marks the validator as case-insensitive for string comparisons.
47    #[must_use]
48    pub fn case_insensitive(mut self) -> Self {
49        self.case_sensitive = false;
50        self
51    }
52
53    /// Overrides the default error message recorded by higher layers.
54    #[must_use]
55    pub fn message(mut self, message: impl Into<String>) -> Self {
56        self.message = Some(message.into());
57        self
58    }
59
60    /// Checks whether the candidate value is unique for the target attribute.
61    ///
62    /// The current generic implementation validates the requested attribute and excludes the
63    /// current record by primary key when present. `scope` metadata is stored for future use once
64    /// generic sibling-attribute access is available across all record types.
65    pub async fn validate_unique<R: Record>(
66        &self,
67        attribute: &str,
68        value: &Value,
69        record: &R,
70        db: &DatabaseConnection,
71    ) -> bool
72    where
73        <R::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
74        <R::Entity as EntityTrait>::Model: FromQueryResult + Send + Sync,
75    {
76        let _scope = &self.scope;
77
78        let attribute_column = match resolve_column::<R>(attribute) {
79            Ok(column) => column,
80            Err(_) => return false,
81        };
82
83        let mut query = R::Entity::find();
84        query = if !self.case_sensitive {
85            match value {
86                Value::String(text) => query
87                    .filter(Func::lower(Expr::col(attribute_column)).eq(text.to_ascii_lowercase())),
88                _ => match json_to_sea_value(value) {
89                    Ok(candidate) => query.filter(Expr::col(attribute_column).eq(candidate)),
90                    Err(_) => return false,
91                },
92            }
93        } else {
94            match json_to_sea_value(value) {
95                Ok(candidate) => query.filter(Expr::col(attribute_column).eq(candidate)),
96                Err(_) => return false,
97            }
98        };
99
100        if let Some(id) = record.id() {
101            let primary_key_column = match resolve_column::<R>(R::primary_key_name()) {
102                Ok(column) => column,
103                Err(_) => return false,
104            };
105            query = query.filter(Expr::col(primary_key_column).ne(id));
106        }
107
108        match query.paginate(db, 1).num_items().await {
109            Ok(count) => count == 0,
110            Err(_) => false,
111        }
112    }
113
114    /// Synchronous wrapper for [`Self::validate_unique`].
115    pub fn validate_unique_sync<R: Record>(
116        &self,
117        attribute: &str,
118        value: &Value,
119        record: &R,
120    ) -> bool
121    where
122        <R::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
123        <R::Entity as EntityTrait>::Model: FromQueryResult + Send + Sync,
124    {
125        database::with_db(|db| {
126            runtime::block_on(self.validate_unique(attribute, value, record, db))
127        })
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use std::collections::HashMap;
134
135    use rustrails_model::{
136        errors::{ErrorType, Errors},
137        validations::{
138            CustomValidator, FormatValidator, LengthValidator, NumericalityValidator,
139            PresenceValidator, ValidationSet,
140        },
141    };
142    use sea_orm::{ConnectionTrait, Schema};
143    use serde_json::{Value, json};
144
145    use super::UniquenessValidator;
146    use crate::{
147        RecordState,
148        base::test_support::{TestUser, seed_users, setup_db, test_user},
149    };
150    use rustrails_support::{database, runtime};
151
152    fn run_sync_validation_test(seed: bool, test: impl FnOnce() + Send + 'static) {
153        std::thread::spawn(move || {
154            let _rt = runtime::init_runtime();
155            database::establish("sqlite::memory:")
156                .expect("sqlite in-memory connection should succeed");
157            runtime::block_on(async {
158                let db = database::db();
159                let schema = Schema::new(db.get_database_backend());
160                db.execute(&schema.create_table_from_entity(test_user::Entity))
161                    .await
162                    .expect("test_users table should be created");
163                if seed {
164                    seed_users(&db).await;
165                }
166            });
167            test();
168        })
169        .join()
170        .unwrap();
171    }
172
173    fn run_seeded_sync_validation_test(test: impl FnOnce() + Send + 'static) {
174        run_sync_validation_test(true, test);
175    }
176
177    fn run_validations(
178        set: &ValidationSet,
179        attrs: impl IntoIterator<Item = (&'static str, Value)>,
180    ) -> Errors {
181        let attrs = attrs
182            .into_iter()
183            .map(|(name, value)| (name.to_owned(), value))
184            .collect::<HashMap<_, _>>();
185        let mut errors = Errors::new();
186        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
187        errors
188    }
189
190    fn run_validations_without_attrs(set: &ValidationSet) -> Errors {
191        let mut errors = Errors::new();
192        let _ = set.validate(&|_| None, &mut errors);
193        errors
194    }
195
196    #[test]
197    fn builder_methods_update_validator_configuration() {
198        let validator = UniquenessValidator::new()
199            .scope(vec!["account_id".to_owned()])
200            .case_insensitive()
201            .message("already used");
202
203        assert_eq!(validator.scope, vec!["account_id"]);
204        assert!(!validator.case_sensitive);
205        assert_eq!(validator.message.as_deref(), Some("already used"));
206    }
207
208    #[test]
209    fn presence_validation_rejects_missing_values() {
210        let mut set = ValidationSet::new();
211        set.add("name", PresenceValidator::new());
212
213        let errors = run_validations_without_attrs(&set);
214
215        assert_eq!(errors.on("name")[0].error_type, ErrorType::Blank);
216        assert_eq!(errors.messages_for("name"), vec!["can't be blank"]);
217    }
218
219    #[test]
220    fn presence_validation_rejects_blank_strings() {
221        let mut set = ValidationSet::new();
222        set.add("name", PresenceValidator::new());
223
224        let errors = run_validations(&set, [("name", json!("   "))]);
225
226        assert_eq!(errors.on("name")[0].error_type, ErrorType::Blank);
227    }
228
229    #[test]
230    fn presence_validation_accepts_present_strings() {
231        let mut set = ValidationSet::new();
232        set.add("name", PresenceValidator::new());
233
234        let errors = run_validations(&set, [("name", json!("Alice"))]);
235
236        assert!(errors.is_empty());
237    }
238
239    #[test]
240    fn length_validation_rejects_values_shorter_than_minimum() {
241        let mut set = ValidationSet::new();
242        set.add("name", LengthValidator::new().minimum(3));
243
244        let errors = run_validations(&set, [("name", json!("Al"))]);
245
246        assert_eq!(errors.on("name")[0].error_type, ErrorType::TooShort);
247        assert_eq!(errors.on("name")[0].details.get("count"), Some(&json!(3)));
248    }
249
250    #[test]
251    fn length_validation_accepts_values_at_minimum_boundary() {
252        let mut set = ValidationSet::new();
253        set.add("name", LengthValidator::new().minimum(3));
254
255        let errors = run_validations(&set, [("name", json!("Ada"))]);
256
257        assert!(errors.is_empty());
258    }
259
260    #[test]
261    fn length_validation_rejects_values_longer_than_maximum() {
262        let mut set = ValidationSet::new();
263        set.add("name", LengthValidator::new().maximum(5));
264
265        let errors = run_validations(&set, [("name", json!("Roberto"))]);
266
267        assert_eq!(errors.on("name")[0].error_type, ErrorType::TooLong);
268        assert_eq!(errors.on("name")[0].details.get("count"), Some(&json!(5)));
269    }
270
271    #[test]
272    fn length_validation_accepts_values_at_maximum_boundary() {
273        let mut set = ValidationSet::new();
274        set.add("name", LengthValidator::new().maximum(5));
275
276        let errors = run_validations(&set, [("name", json!("Alice"))]);
277
278        assert!(errors.is_empty());
279    }
280
281    #[test]
282    fn length_validation_rejects_values_with_wrong_exact_length() {
283        let mut set = ValidationSet::new();
284        set.add("code", LengthValidator::new().is(4));
285
286        let errors = run_validations(&set, [("code", json!("abc"))]);
287
288        assert_eq!(errors.on("code")[0].error_type, ErrorType::WrongLength);
289        assert_eq!(
290            errors.messages_for("code"),
291            vec!["is the wrong length (should be 4 characters)"]
292        );
293    }
294
295    #[test]
296    fn length_validation_accepts_values_with_exact_length() {
297        let mut set = ValidationSet::new();
298        set.add("code", LengthValidator::new().is(4));
299
300        let errors = run_validations(&set, [("code", json!("ABCD"))]);
301
302        assert!(errors.is_empty());
303    }
304
305    #[test]
306    fn length_validation_counts_unicode_scalars() {
307        let mut set = ValidationSet::new();
308        set.add("nickname", LengthValidator::new().is(5));
309
310        let errors = run_validations(&set, [("nickname", json!("あいうえお"))]);
311
312        assert!(errors.is_empty());
313    }
314
315    #[test]
316    fn format_validation_accepts_matching_patterns() {
317        let mut set = ValidationSet::new();
318        set.add(
319            "email",
320            FormatValidator::with_pattern(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
321        );
322
323        let errors = run_validations(&set, [("email", json!("alice@example.com"))]);
324
325        assert!(errors.is_empty());
326    }
327
328    #[test]
329    fn format_validation_rejects_non_matching_patterns() {
330        let mut set = ValidationSet::new();
331        set.add(
332            "email",
333            FormatValidator::with_pattern(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
334        );
335
336        let errors = run_validations(&set, [("email", json!("alice-at-example"))]);
337
338        assert_eq!(errors.on("email")[0].error_type, ErrorType::Invalid);
339    }
340
341    #[test]
342    fn format_validation_uses_custom_messages() {
343        let mut set = ValidationSet::new();
344        set.add(
345            "pin",
346            FormatValidator::with_pattern(r"^\d+$").message("digits only"),
347        );
348
349        let errors = run_validations(&set, [("pin", json!("12ab"))]);
350
351        assert_eq!(errors.messages_for("pin"), vec!["digits only"]);
352    }
353
354    #[test]
355    fn format_validation_can_reject_forbidden_matches() {
356        let mut set = ValidationSet::new();
357        set.add("body", FormatValidator::new().without("spam"));
358
359        let errors = run_validations(&set, [("body", json!("contains spam"))]);
360
361        assert_eq!(errors.on("body")[0].error_type, ErrorType::Invalid);
362    }
363
364    #[test]
365    fn numericality_validation_rejects_non_numeric_strings() {
366        let mut set = ValidationSet::new();
367        set.add("age", NumericalityValidator::new());
368
369        let errors = run_validations(&set, [("age", json!("old enough"))]);
370
371        assert_eq!(errors.on("age")[0].error_type, ErrorType::NotANumber);
372    }
373
374    #[test]
375    fn numericality_validation_accepts_numeric_strings() {
376        let mut set = ValidationSet::new();
377        set.add("age", NumericalityValidator::new());
378
379        let errors = run_validations(&set, [("age", json!("42"))]);
380
381        assert!(errors.is_empty());
382    }
383
384    #[test]
385    fn numericality_validation_rejects_non_integer_values_when_integer_required() {
386        let mut set = ValidationSet::new();
387        set.add("age", NumericalityValidator::new().only_integer());
388
389        let errors = run_validations(&set, [("age", json!("12.5"))]);
390
391        assert_eq!(errors.on("age")[0].error_type, ErrorType::NotAnInteger);
392    }
393
394    #[test]
395    fn numericality_validation_rejects_values_below_greater_than_bound() {
396        let mut set = ValidationSet::new();
397        set.add("score", NumericalityValidator::new().greater_than(10.0));
398
399        let errors = run_validations(&set, [("score", json!(10))]);
400
401        assert_eq!(errors.on("score")[0].error_type, ErrorType::GreaterThan);
402    }
403
404    #[test]
405    fn numericality_validation_accepts_values_that_satisfy_multiple_constraints() {
406        let mut set = ValidationSet::new();
407        set.add(
408            "score",
409            NumericalityValidator::new()
410                .greater_than(10.0)
411                .less_than_or_equal_to(20.0)
412                .even(),
413        );
414
415        let errors = run_validations(&set, [("score", json!(18))]);
416
417        assert!(errors.is_empty());
418    }
419
420    #[test]
421    fn numericality_validation_allow_nil_skips_missing_values() {
422        let mut set = ValidationSet::new();
423        set.add("score", NumericalityValidator::new().allow_nil());
424
425        let errors = run_validations_without_attrs(&set);
426
427        assert!(errors.is_empty());
428    }
429
430    #[test]
431    fn custom_validation_can_add_errors() {
432        let mut set = ValidationSet::new();
433        set.add(
434            "slug",
435            CustomValidator::new(|attribute, value, errors| {
436                if value.and_then(Value::as_str) == Some("reserved") {
437                    errors.add(
438                        attribute,
439                        ErrorType::Custom("reserved".to_owned()),
440                        "is reserved",
441                    );
442                }
443            }),
444        );
445
446        let errors = run_validations(&set, [("slug", json!("reserved"))]);
447
448        assert_eq!(
449            errors.on("slug")[0].error_type,
450            ErrorType::Custom("reserved".to_owned())
451        );
452        assert_eq!(errors.messages_for("slug"), vec!["is reserved"]);
453    }
454
455    #[test]
456    fn custom_validation_receives_candidate_values() {
457        let mut set = ValidationSet::new();
458        set.add(
459            "slug",
460            CustomValidator::new(|attribute, value, errors| {
461                if value.and_then(Value::as_str) != Some("rustrails") {
462                    errors.add(
463                        attribute,
464                        ErrorType::Custom("slug".to_owned()),
465                        "must equal rustrails",
466                    );
467                }
468            }),
469        );
470
471        let errors = run_validations(&set, [("slug", json!("rustrails"))]);
472
473        assert!(errors.is_empty());
474    }
475
476    #[test]
477    fn error_collection_returns_messages_by_attribute() {
478        let mut set = ValidationSet::new();
479        set.add("name", PresenceValidator::new());
480        set.add(
481            "email",
482            FormatValidator::with_pattern(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
483        );
484
485        let errors = run_validations(
486            &set,
487            [("name", json!("")), ("email", json!("not-an-email"))],
488        );
489
490        assert_eq!(errors.attributes(), vec!["name", "email"]);
491        assert_eq!(errors.messages_for("name"), vec!["can't be blank"]);
492        assert_eq!(errors.messages_for("email"), vec!["is invalid"]);
493    }
494
495    #[test]
496    fn error_collection_builds_full_messages_in_insertion_order() {
497        let mut set = ValidationSet::new();
498        set.add("name", PresenceValidator::new());
499        set.add(
500            "email",
501            FormatValidator::with_pattern(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
502        );
503
504        let errors = run_validations(
505            &set,
506            [("name", json!("")), ("email", json!("not-an-email"))],
507        );
508
509        assert_eq!(
510            errors.full_messages(),
511            vec!["Name can't be blank", "Email is invalid"]
512        );
513    }
514
515    #[test]
516    fn multiple_validations_on_same_field_collect_multiple_errors() {
517        let mut set = ValidationSet::new();
518        set.add("name", PresenceValidator::new());
519        set.add("name", LengthValidator::new().minimum(3));
520
521        let errors = run_validations(&set, [("name", json!(""))]);
522
523        assert_eq!(errors.on("name").len(), 2);
524        assert_eq!(
525            errors.messages_for("name"),
526            vec!["can't be blank", "is too short (minimum is 3 characters)",]
527        );
528    }
529
530    #[test]
531    fn multiple_validations_on_same_field_preserve_error_type_order() {
532        let mut set = ValidationSet::new();
533        set.add("name", PresenceValidator::new());
534        set.add("name", LengthValidator::new().minimum(3));
535
536        let errors = run_validations(&set, [("name", json!(""))]);
537
538        let error_types = errors
539            .on("name")
540            .into_iter()
541            .map(|error| error.error_type.clone())
542            .collect::<Vec<_>>();
543
544        assert_eq!(error_types, vec![ErrorType::Blank, ErrorType::TooShort]);
545    }
546
547    #[tokio::test]
548    async fn validate_unique_returns_false_for_duplicate_values() {
549        let db = setup_db().await;
550        seed_users(&db).await;
551
552        let candidate = TestUser {
553            name: "Alice Clone".to_owned(),
554            email: "alice@example.com".to_owned(),
555            state: RecordState::New,
556            ..Default::default()
557        };
558
559        let is_unique = UniquenessValidator::new()
560            .validate_unique("email", &json!("alice@example.com"), &candidate, &db)
561            .await;
562
563        assert!(!is_unique);
564    }
565
566    #[tokio::test]
567    async fn validate_unique_excludes_the_current_record() {
568        let db = setup_db().await;
569        let mut users = seed_users(&db).await;
570        let alice = users.remove(0);
571
572        let is_unique = UniquenessValidator::new()
573            .validate_unique("email", &json!("alice@example.com"), &alice, &db)
574            .await;
575
576        assert!(is_unique);
577    }
578
579    #[tokio::test]
580    async fn validate_unique_supports_case_insensitive_string_checks() {
581        let db = setup_db().await;
582        seed_users(&db).await;
583
584        let candidate = TestUser {
585            name: "Alice Clone".to_owned(),
586            email: "ALICE@EXAMPLE.COM".to_owned(),
587            state: RecordState::New,
588            ..Default::default()
589        };
590
591        let is_unique = UniquenessValidator::new()
592            .case_insensitive()
593            .validate_unique("email", &json!("ALICE@EXAMPLE.COM"), &candidate, &db)
594            .await;
595
596        assert!(!is_unique);
597    }
598
599    #[test]
600    fn validate_unique_sync_returns_false_for_duplicate_values() {
601        run_seeded_sync_validation_test(|| {
602            let candidate = TestUser {
603                name: "Alice Clone".to_owned(),
604                email: "alice@example.com".to_owned(),
605                state: RecordState::New,
606                ..Default::default()
607            };
608
609            let is_unique = UniquenessValidator::new().validate_unique_sync(
610                "email",
611                &json!("alice@example.com"),
612                &candidate,
613            );
614
615            assert!(!is_unique);
616        });
617    }
618}