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#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct UniquenessValidator {
17 pub scope: Vec<String>,
22 pub case_sensitive: bool,
24 pub message: Option<String>,
26}
27
28impl UniquenessValidator {
29 #[must_use]
31 pub fn new() -> Self {
32 Self {
33 scope: Vec::new(),
34 case_sensitive: true,
35 message: None,
36 }
37 }
38
39 #[must_use]
41 pub fn scope(mut self, fields: Vec<String>) -> Self {
42 self.scope = fields;
43 self
44 }
45
46 #[must_use]
48 pub fn case_insensitive(mut self) -> Self {
49 self.case_sensitive = false;
50 self
51 }
52
53 #[must_use]
55 pub fn message(mut self, message: impl Into<String>) -> Self {
56 self.message = Some(message.into());
57 self
58 }
59
60 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 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}