use rustrails_support::{database, runtime};
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, ExprTrait, FromQueryResult, Iterable,
PaginatorTrait, QueryFilter,
sea_query::{Expr, Func},
};
use serde_json::Value;
use crate::{
Record,
relation::{json_to_sea_value, resolve_column},
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UniquenessValidator {
pub scope: Vec<String>,
pub case_sensitive: bool,
pub message: Option<String>,
}
impl UniquenessValidator {
#[must_use]
pub fn new() -> Self {
Self {
scope: Vec::new(),
case_sensitive: true,
message: None,
}
}
#[must_use]
pub fn scope(mut self, fields: Vec<String>) -> Self {
self.scope = fields;
self
}
#[must_use]
pub fn case_insensitive(mut self) -> Self {
self.case_sensitive = false;
self
}
#[must_use]
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
pub async fn validate_unique<R: Record>(
&self,
attribute: &str,
value: &Value,
record: &R,
db: &DatabaseConnection,
) -> bool
where
<R::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
<R::Entity as EntityTrait>::Model: FromQueryResult + Send + Sync,
{
let _scope = &self.scope;
let attribute_column = match resolve_column::<R>(attribute) {
Ok(column) => column,
Err(_) => return false,
};
let mut query = R::Entity::find();
query = if !self.case_sensitive {
match value {
Value::String(text) => query
.filter(Func::lower(Expr::col(attribute_column)).eq(text.to_ascii_lowercase())),
_ => match json_to_sea_value(value) {
Ok(candidate) => query.filter(Expr::col(attribute_column).eq(candidate)),
Err(_) => return false,
},
}
} else {
match json_to_sea_value(value) {
Ok(candidate) => query.filter(Expr::col(attribute_column).eq(candidate)),
Err(_) => return false,
}
};
if let Some(id) = record.id() {
let primary_key_column = match resolve_column::<R>(R::primary_key_name()) {
Ok(column) => column,
Err(_) => return false,
};
query = query.filter(Expr::col(primary_key_column).ne(id));
}
match query.paginate(db, 1).num_items().await {
Ok(count) => count == 0,
Err(_) => false,
}
}
pub fn validate_unique_sync<R: Record>(
&self,
attribute: &str,
value: &Value,
record: &R,
) -> bool
where
<R::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
<R::Entity as EntityTrait>::Model: FromQueryResult + Send + Sync,
{
database::with_db(|db| {
runtime::block_on(self.validate_unique(attribute, value, record, db))
})
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use rustrails_model::{
errors::{ErrorType, Errors},
validations::{
CustomValidator, FormatValidator, LengthValidator, NumericalityValidator,
PresenceValidator, ValidationSet,
},
};
use sea_orm::{ConnectionTrait, Schema};
use serde_json::{Value, json};
use super::UniquenessValidator;
use crate::{
RecordState,
base::test_support::{TestUser, seed_users, setup_db, test_user},
};
use rustrails_support::{database, runtime};
fn run_sync_validation_test(seed: bool, test: impl FnOnce() + Send + 'static) {
std::thread::spawn(move || {
let _rt = runtime::init_runtime();
database::establish("sqlite::memory:")
.expect("sqlite in-memory connection should succeed");
runtime::block_on(async {
let db = database::db();
let schema = Schema::new(db.get_database_backend());
db.execute(&schema.create_table_from_entity(test_user::Entity))
.await
.expect("test_users table should be created");
if seed {
seed_users(&db).await;
}
});
test();
})
.join()
.unwrap();
}
fn run_seeded_sync_validation_test(test: impl FnOnce() + Send + 'static) {
run_sync_validation_test(true, test);
}
fn run_validations(
set: &ValidationSet,
attrs: impl IntoIterator<Item = (&'static str, Value)>,
) -> Errors {
let attrs = attrs
.into_iter()
.map(|(name, value)| (name.to_owned(), value))
.collect::<HashMap<_, _>>();
let mut errors = Errors::new();
let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
errors
}
fn run_validations_without_attrs(set: &ValidationSet) -> Errors {
let mut errors = Errors::new();
let _ = set.validate(&|_| None, &mut errors);
errors
}
#[test]
fn builder_methods_update_validator_configuration() {
let validator = UniquenessValidator::new()
.scope(vec!["account_id".to_owned()])
.case_insensitive()
.message("already used");
assert_eq!(validator.scope, vec!["account_id"]);
assert!(!validator.case_sensitive);
assert_eq!(validator.message.as_deref(), Some("already used"));
}
#[test]
fn presence_validation_rejects_missing_values() {
let mut set = ValidationSet::new();
set.add("name", PresenceValidator::new());
let errors = run_validations_without_attrs(&set);
assert_eq!(errors.on("name")[0].error_type, ErrorType::Blank);
assert_eq!(errors.messages_for("name"), vec!["can't be blank"]);
}
#[test]
fn presence_validation_rejects_blank_strings() {
let mut set = ValidationSet::new();
set.add("name", PresenceValidator::new());
let errors = run_validations(&set, [("name", json!(" "))]);
assert_eq!(errors.on("name")[0].error_type, ErrorType::Blank);
}
#[test]
fn presence_validation_accepts_present_strings() {
let mut set = ValidationSet::new();
set.add("name", PresenceValidator::new());
let errors = run_validations(&set, [("name", json!("Alice"))]);
assert!(errors.is_empty());
}
#[test]
fn length_validation_rejects_values_shorter_than_minimum() {
let mut set = ValidationSet::new();
set.add("name", LengthValidator::new().minimum(3));
let errors = run_validations(&set, [("name", json!("Al"))]);
assert_eq!(errors.on("name")[0].error_type, ErrorType::TooShort);
assert_eq!(errors.on("name")[0].details.get("count"), Some(&json!(3)));
}
#[test]
fn length_validation_accepts_values_at_minimum_boundary() {
let mut set = ValidationSet::new();
set.add("name", LengthValidator::new().minimum(3));
let errors = run_validations(&set, [("name", json!("Ada"))]);
assert!(errors.is_empty());
}
#[test]
fn length_validation_rejects_values_longer_than_maximum() {
let mut set = ValidationSet::new();
set.add("name", LengthValidator::new().maximum(5));
let errors = run_validations(&set, [("name", json!("Roberto"))]);
assert_eq!(errors.on("name")[0].error_type, ErrorType::TooLong);
assert_eq!(errors.on("name")[0].details.get("count"), Some(&json!(5)));
}
#[test]
fn length_validation_accepts_values_at_maximum_boundary() {
let mut set = ValidationSet::new();
set.add("name", LengthValidator::new().maximum(5));
let errors = run_validations(&set, [("name", json!("Alice"))]);
assert!(errors.is_empty());
}
#[test]
fn length_validation_rejects_values_with_wrong_exact_length() {
let mut set = ValidationSet::new();
set.add("code", LengthValidator::new().is(4));
let errors = run_validations(&set, [("code", json!("abc"))]);
assert_eq!(errors.on("code")[0].error_type, ErrorType::WrongLength);
assert_eq!(
errors.messages_for("code"),
vec!["is the wrong length (should be 4 characters)"]
);
}
#[test]
fn length_validation_accepts_values_with_exact_length() {
let mut set = ValidationSet::new();
set.add("code", LengthValidator::new().is(4));
let errors = run_validations(&set, [("code", json!("ABCD"))]);
assert!(errors.is_empty());
}
#[test]
fn length_validation_counts_unicode_scalars() {
let mut set = ValidationSet::new();
set.add("nickname", LengthValidator::new().is(5));
let errors = run_validations(&set, [("nickname", json!("あいうえお"))]);
assert!(errors.is_empty());
}
#[test]
fn format_validation_accepts_matching_patterns() {
let mut set = ValidationSet::new();
set.add(
"email",
FormatValidator::with_pattern(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
);
let errors = run_validations(&set, [("email", json!("alice@example.com"))]);
assert!(errors.is_empty());
}
#[test]
fn format_validation_rejects_non_matching_patterns() {
let mut set = ValidationSet::new();
set.add(
"email",
FormatValidator::with_pattern(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
);
let errors = run_validations(&set, [("email", json!("alice-at-example"))]);
assert_eq!(errors.on("email")[0].error_type, ErrorType::Invalid);
}
#[test]
fn format_validation_uses_custom_messages() {
let mut set = ValidationSet::new();
set.add(
"pin",
FormatValidator::with_pattern(r"^\d+$").message("digits only"),
);
let errors = run_validations(&set, [("pin", json!("12ab"))]);
assert_eq!(errors.messages_for("pin"), vec!["digits only"]);
}
#[test]
fn format_validation_can_reject_forbidden_matches() {
let mut set = ValidationSet::new();
set.add("body", FormatValidator::new().without("spam"));
let errors = run_validations(&set, [("body", json!("contains spam"))]);
assert_eq!(errors.on("body")[0].error_type, ErrorType::Invalid);
}
#[test]
fn numericality_validation_rejects_non_numeric_strings() {
let mut set = ValidationSet::new();
set.add("age", NumericalityValidator::new());
let errors = run_validations(&set, [("age", json!("old enough"))]);
assert_eq!(errors.on("age")[0].error_type, ErrorType::NotANumber);
}
#[test]
fn numericality_validation_accepts_numeric_strings() {
let mut set = ValidationSet::new();
set.add("age", NumericalityValidator::new());
let errors = run_validations(&set, [("age", json!("42"))]);
assert!(errors.is_empty());
}
#[test]
fn numericality_validation_rejects_non_integer_values_when_integer_required() {
let mut set = ValidationSet::new();
set.add("age", NumericalityValidator::new().only_integer());
let errors = run_validations(&set, [("age", json!("12.5"))]);
assert_eq!(errors.on("age")[0].error_type, ErrorType::NotAnInteger);
}
#[test]
fn numericality_validation_rejects_values_below_greater_than_bound() {
let mut set = ValidationSet::new();
set.add("score", NumericalityValidator::new().greater_than(10.0));
let errors = run_validations(&set, [("score", json!(10))]);
assert_eq!(errors.on("score")[0].error_type, ErrorType::GreaterThan);
}
#[test]
fn numericality_validation_accepts_values_that_satisfy_multiple_constraints() {
let mut set = ValidationSet::new();
set.add(
"score",
NumericalityValidator::new()
.greater_than(10.0)
.less_than_or_equal_to(20.0)
.even(),
);
let errors = run_validations(&set, [("score", json!(18))]);
assert!(errors.is_empty());
}
#[test]
fn numericality_validation_allow_nil_skips_missing_values() {
let mut set = ValidationSet::new();
set.add("score", NumericalityValidator::new().allow_nil());
let errors = run_validations_without_attrs(&set);
assert!(errors.is_empty());
}
#[test]
fn custom_validation_can_add_errors() {
let mut set = ValidationSet::new();
set.add(
"slug",
CustomValidator::new(|attribute, value, errors| {
if value.and_then(Value::as_str) == Some("reserved") {
errors.add(
attribute,
ErrorType::Custom("reserved".to_owned()),
"is reserved",
);
}
}),
);
let errors = run_validations(&set, [("slug", json!("reserved"))]);
assert_eq!(
errors.on("slug")[0].error_type,
ErrorType::Custom("reserved".to_owned())
);
assert_eq!(errors.messages_for("slug"), vec!["is reserved"]);
}
#[test]
fn custom_validation_receives_candidate_values() {
let mut set = ValidationSet::new();
set.add(
"slug",
CustomValidator::new(|attribute, value, errors| {
if value.and_then(Value::as_str) != Some("rustrails") {
errors.add(
attribute,
ErrorType::Custom("slug".to_owned()),
"must equal rustrails",
);
}
}),
);
let errors = run_validations(&set, [("slug", json!("rustrails"))]);
assert!(errors.is_empty());
}
#[test]
fn error_collection_returns_messages_by_attribute() {
let mut set = ValidationSet::new();
set.add("name", PresenceValidator::new());
set.add(
"email",
FormatValidator::with_pattern(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
);
let errors = run_validations(
&set,
[("name", json!("")), ("email", json!("not-an-email"))],
);
assert_eq!(errors.attributes(), vec!["name", "email"]);
assert_eq!(errors.messages_for("name"), vec!["can't be blank"]);
assert_eq!(errors.messages_for("email"), vec!["is invalid"]);
}
#[test]
fn error_collection_builds_full_messages_in_insertion_order() {
let mut set = ValidationSet::new();
set.add("name", PresenceValidator::new());
set.add(
"email",
FormatValidator::with_pattern(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
);
let errors = run_validations(
&set,
[("name", json!("")), ("email", json!("not-an-email"))],
);
assert_eq!(
errors.full_messages(),
vec!["Name can't be blank", "Email is invalid"]
);
}
#[test]
fn multiple_validations_on_same_field_collect_multiple_errors() {
let mut set = ValidationSet::new();
set.add("name", PresenceValidator::new());
set.add("name", LengthValidator::new().minimum(3));
let errors = run_validations(&set, [("name", json!(""))]);
assert_eq!(errors.on("name").len(), 2);
assert_eq!(
errors.messages_for("name"),
vec!["can't be blank", "is too short (minimum is 3 characters)",]
);
}
#[test]
fn multiple_validations_on_same_field_preserve_error_type_order() {
let mut set = ValidationSet::new();
set.add("name", PresenceValidator::new());
set.add("name", LengthValidator::new().minimum(3));
let errors = run_validations(&set, [("name", json!(""))]);
let error_types = errors
.on("name")
.into_iter()
.map(|error| error.error_type.clone())
.collect::<Vec<_>>();
assert_eq!(error_types, vec![ErrorType::Blank, ErrorType::TooShort]);
}
#[tokio::test]
async fn validate_unique_returns_false_for_duplicate_values() {
let db = setup_db().await;
seed_users(&db).await;
let candidate = TestUser {
name: "Alice Clone".to_owned(),
email: "alice@example.com".to_owned(),
state: RecordState::New,
..Default::default()
};
let is_unique = UniquenessValidator::new()
.validate_unique("email", &json!("alice@example.com"), &candidate, &db)
.await;
assert!(!is_unique);
}
#[tokio::test]
async fn validate_unique_excludes_the_current_record() {
let db = setup_db().await;
let mut users = seed_users(&db).await;
let alice = users.remove(0);
let is_unique = UniquenessValidator::new()
.validate_unique("email", &json!("alice@example.com"), &alice, &db)
.await;
assert!(is_unique);
}
#[tokio::test]
async fn validate_unique_supports_case_insensitive_string_checks() {
let db = setup_db().await;
seed_users(&db).await;
let candidate = TestUser {
name: "Alice Clone".to_owned(),
email: "ALICE@EXAMPLE.COM".to_owned(),
state: RecordState::New,
..Default::default()
};
let is_unique = UniquenessValidator::new()
.case_insensitive()
.validate_unique("email", &json!("ALICE@EXAMPLE.COM"), &candidate, &db)
.await;
assert!(!is_unique);
}
#[test]
fn validate_unique_sync_returns_false_for_duplicate_values() {
run_seeded_sync_validation_test(|| {
let candidate = TestUser {
name: "Alice Clone".to_owned(),
email: "alice@example.com".to_owned(),
state: RecordState::New,
..Default::default()
};
let is_unique = UniquenessValidator::new().validate_unique_sync(
"email",
&json!("alice@example.com"),
&candidate,
);
assert!(!is_unique);
});
}
}