use std::collections::{BTreeMap, HashMap};
use sea_query::{Alias, Expr, Query};
use serde::{Deserialize, Serialize};
use crate::i18n::Text;
use crate::query::{bind_values, build, text_cast};
use crate::{CoreResult, Db};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
Create,
Update,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Rule {
Required,
RequiredOn(Mode),
MinLength(usize),
MaxLength(usize),
Email,
Numeric,
Url,
Date,
Unique,
}
#[derive(Debug, Clone)]
pub struct FieldRules {
pub field: String,
pub label: String,
pub rules: Vec<Rule>,
}
impl FieldRules {
pub fn new(field: impl Into<String>, label: impl Into<String>, rules: Vec<Rule>) -> Self {
Self {
field: field.into(),
label: label.into(),
rules,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct ErrorBag {
fields: BTreeMap<String, Vec<Text>>,
}
impl ErrorBag {
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
pub fn add(&mut self, field: &str, message: Text) {
self.fields
.entry(field.to_string())
.or_default()
.push(message);
}
pub fn messages(&self, field: &str) -> &[Text] {
self.fields.get(field).map(Vec::as_slice).unwrap_or(&[])
}
}
fn is_date(value: &str) -> bool {
chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
}
fn field_msg(source: &'static str, label: &str) -> Text {
Text::new(source).arg("label", Text::dynamic(label))
}
fn field_plural_msg(one: &'static str, other: &'static str, n: usize, label: &str) -> Text {
let n = n as i64;
Text::new(one)
.with_plural(other, n)
.arg("n", n)
.arg("label", Text::dynamic(label))
}
pub fn validate_fields(
fields: &[FieldRules],
data: &HashMap<String, String>,
mode: Mode,
) -> ErrorBag {
let mut bag = ErrorBag::default();
for f in fields {
let value = data.get(&f.field).map(String::as_str).unwrap_or("");
let trimmed = value.trim();
let len = trimmed.chars().count();
for rule in &f.rules {
match rule {
Rule::Required if trimmed.is_empty() => {
bag.add(&f.field, field_msg("{label} is required.", &f.label));
}
Rule::RequiredOn(m) if *m == mode && trimmed.is_empty() => {
bag.add(&f.field, field_msg("{label} is required.", &f.label));
}
Rule::MinLength(n) if !trimmed.is_empty() && len < *n => {
bag.add(
&f.field,
field_plural_msg(
"{label} must be at least {n} character.",
"{label} must be at least {n} characters.",
*n,
&f.label,
),
);
}
Rule::MaxLength(n) if len > *n => {
bag.add(
&f.field,
field_plural_msg(
"{label} must be at most {n} character.",
"{label} must be at most {n} characters.",
*n,
&f.label,
),
);
}
Rule::Email if !trimmed.is_empty() && !is_email(trimmed) => {
bag.add(
&f.field,
field_msg("{label} must be a valid email address.", &f.label),
);
}
Rule::Numeric if !trimmed.is_empty() && trimmed.parse::<f64>().is_err() => {
bag.add(&f.field, field_msg("{label} must be a number.", &f.label));
}
Rule::Url if !trimmed.is_empty() && !is_url(trimmed) => {
bag.add(
&f.field,
field_msg("{label} must be a valid URL.", &f.label),
);
}
Rule::Date if !trimmed.is_empty() && !is_date(trimmed) => {
bag.add(&f.field, field_msg("{label} must be a date.", &f.label));
}
_ => {}
}
}
}
bag
}
pub async fn validate(
db: &Db,
table: &str,
id_field: &str,
fields: &[FieldRules],
data: &HashMap<String, String>,
mode: Mode,
edited_id: Option<&str>,
) -> CoreResult<ErrorBag> {
let mut bag = validate_fields(fields, data, mode);
for f in fields {
let wants_unique = f.rules.iter().any(|r| matches!(r, Rule::Unique));
if !wants_unique || !bag.messages(&f.field).is_empty() {
continue;
}
let value = data.get(&f.field).map(|s| s.trim()).unwrap_or("");
if value.is_empty() {
continue;
}
if unique_conflict(db, table, &f.field, value, id_field, mode, edited_id).await? {
bag.add(&f.field, field_msg("{label} is already taken.", &f.label));
}
}
Ok(bag)
}
async fn unique_conflict(
db: &Db,
table: &str,
column: &str,
value: &str,
id_field: &str,
mode: Mode,
edited_id: Option<&str>,
) -> CoreResult<bool> {
let (sql, values) = {
let mut stmt = Query::select();
stmt.expr(Expr::val(1))
.from(Alias::new(table))
.and_where(Expr::col(Alias::new(column)).eq(value))
.limit(1);
if mode == Mode::Update {
if let Some(id) = edited_id {
stmt.and_where(
Expr::col(Alias::new(id_field))
.cast_as(Alias::new(text_cast(db.backend)))
.ne(id),
);
}
}
build(db.backend, stmt.to_owned())
};
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
Ok(row.is_some())
}
fn is_email(s: &str) -> bool {
email_address::EmailAddress::is_valid(s)
}
fn is_url(s: &str) -> bool {
url::Url::parse(s).is_ok_and(|u| u.has_host())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::Translator;
fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn cheap_rules_collect_per_field_messages() {
let fields = vec![
FieldRules::new("name", "Name", vec![Rule::Required, Rule::MaxLength(5)]),
FieldRules::new("email", "Email", vec![Rule::Email]),
];
let bag = validate_fields(
&fields,
&data(&[("name", "toolong"), ("email", "nope")]),
Mode::Create,
);
let tr = Translator::new("en");
assert!(!bag.is_empty());
assert_eq!(bag.messages("name").len(), 1); assert!(tr.t(&bag.messages("name")[0]).contains("at most 5"));
assert_eq!(bag.messages("email").len(), 1);
assert!(tr.t(&bag.messages("email")[0]).contains("valid email"));
}
#[test]
fn numeric_rejects_non_numbers_and_skips_blanks() {
let fields = [FieldRules::new("qty", "Qty", vec![Rule::Numeric])];
assert!(validate_fields(&fields, &data(&[("qty", "12.5")]), Mode::Create).is_empty());
assert!(validate_fields(&fields, &data(&[("qty", "")]), Mode::Create).is_empty());
let bag = validate_fields(&fields, &data(&[("qty", "abc")]), Mode::Create);
assert_eq!(bag.messages("qty").len(), 1);
}
#[test]
fn url_rejects_non_urls_and_skips_blanks() {
let fields = [FieldRules::new("site", "Site", vec![Rule::Url])];
assert!(validate_fields(
&fields,
&data(&[("site", "https://example.com/x")]),
Mode::Create
)
.is_empty());
assert!(validate_fields(&fields, &data(&[("site", "")]), Mode::Create).is_empty());
let bag = validate_fields(&fields, &data(&[("site", "example.com")]), Mode::Create);
assert_eq!(bag.messages("site").len(), 1);
}
#[test]
fn required_on_fires_only_in_its_mode() {
let fields = vec![FieldRules::new(
"password",
"Password",
vec![Rule::RequiredOn(Mode::Create)],
)];
let blank = data(&[("password", "")]);
assert!(!validate_fields(&fields, &blank, Mode::Create).is_empty());
assert!(validate_fields(&fields, &blank, Mode::Update).is_empty());
}
#[test]
fn valid_input_produces_an_empty_bag() {
let fields = vec![FieldRules::new(
"email",
"Email",
vec![Rule::Required, Rule::Email],
)];
assert!(validate_fields(&fields, &data(&[("email", "a@b.com")]), Mode::Create).is_empty());
}
#[test]
fn error_bag_records_messages_localized_at_the_sink() {
let mut bag = ErrorBag::default();
bag.add("name", field_msg("{label} is required.", "Name"));
assert_eq!(bag.messages("name").len(), 1);
let tr = Translator::new("en");
assert_eq!(tr.t(&bag.messages("name")[0]), "Name is required.");
}
#[test]
fn min_length_message_pluralizes_on_the_count() {
let tr = Translator::new("en");
assert_eq!(
tr.t(&field_plural_msg(
"{label} must be at least {n} character.",
"{label} must be at least {n} characters.",
1,
"Pin",
)),
"Pin must be at least 1 character."
);
assert_eq!(
tr.t(&field_plural_msg(
"{label} must be at least {n} character.",
"{label} must be at least {n} characters.",
8,
"Pin",
)),
"Pin must be at least 8 characters."
);
}
#[test]
fn email_shape_check_is_wired_to_the_crate() {
assert!(is_email("a@b.com"));
assert!(is_email("user.name+tag@sub.example.co"));
assert!(!is_email("no-at"));
assert!(!is_email("@b.com"));
assert!(!is_email("a@@b.com"));
}
}