1use std::collections::{BTreeMap, HashMap};
18
19use sea_query::{Alias, Expr, Query};
20use serde::{Deserialize, Serialize};
21
22use crate::i18n::Text;
23use crate::query::{bind_values, build, text_cast};
24use crate::{CoreResult, Db};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum Mode {
32 Create,
33 Update,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum Rule {
46 Required,
48 RequiredOn(Mode),
50 MinLength(usize),
53 MaxLength(usize),
55 Email,
57 Numeric,
59 Url,
61 Date,
63 Unique,
66}
67
68#[derive(Debug, Clone)]
70pub struct FieldRules {
71 pub field: String,
72 pub label: String,
73 pub rules: Vec<Rule>,
74}
75
76impl FieldRules {
77 pub fn new(field: impl Into<String>, label: impl Into<String>, rules: Vec<Rule>) -> Self {
78 Self {
79 field: field.into(),
80 label: label.into(),
81 rules,
82 }
83 }
84}
85
86#[derive(Debug, Default, Clone)]
90pub struct ErrorBag {
91 fields: BTreeMap<String, Vec<Text>>,
92}
93
94impl ErrorBag {
95 pub fn is_empty(&self) -> bool {
97 self.fields.is_empty()
98 }
99
100 pub fn add(&mut self, field: &str, message: Text) {
102 self.fields
103 .entry(field.to_string())
104 .or_default()
105 .push(message);
106 }
107
108 pub fn messages(&self, field: &str) -> &[Text] {
110 self.fields.get(field).map(Vec::as_slice).unwrap_or(&[])
111 }
112
113 pub fn iter(&self) -> impl Iterator<Item = (&str, &[Text])> {
115 self.fields
116 .iter()
117 .map(|(field, messages)| (field.as_str(), messages.as_slice()))
118 }
119}
120
121fn is_date(value: &str) -> bool {
124 chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
125}
126
127fn field_msg(source: &'static str, label: &str) -> Text {
130 Text::new(source).arg("label", Text::dynamic(label))
131}
132
133fn field_plural_msg(one: &'static str, other: &'static str, n: usize, label: &str) -> Text {
135 let n = n as i64;
136 Text::new(one)
137 .with_plural(other, n)
138 .arg("n", n)
139 .arg("label", Text::dynamic(label))
140}
141
142pub fn validate_fields(
147 fields: &[FieldRules],
148 data: &HashMap<String, String>,
149 mode: Mode,
150) -> ErrorBag {
151 let mut bag = ErrorBag::default();
152 for f in fields {
153 let value = data.get(&f.field).map(String::as_str).unwrap_or("");
154 let trimmed = value.trim();
155 let len = trimmed.chars().count();
156 for rule in &f.rules {
157 match rule {
158 Rule::Required if trimmed.is_empty() => {
159 bag.add(&f.field, field_msg("{label} is required.", &f.label));
160 }
161 Rule::RequiredOn(m) if *m == mode && trimmed.is_empty() => {
162 bag.add(&f.field, field_msg("{label} is required.", &f.label));
163 }
164 Rule::MinLength(n) if !trimmed.is_empty() && len < *n => {
165 bag.add(
166 &f.field,
167 field_plural_msg(
168 "{label} must be at least {n} character.",
169 "{label} must be at least {n} characters.",
170 *n,
171 &f.label,
172 ),
173 );
174 }
175 Rule::MaxLength(n) if len > *n => {
176 bag.add(
177 &f.field,
178 field_plural_msg(
179 "{label} must be at most {n} character.",
180 "{label} must be at most {n} characters.",
181 *n,
182 &f.label,
183 ),
184 );
185 }
186 Rule::Email if !trimmed.is_empty() && !is_email(trimmed) => {
187 bag.add(
188 &f.field,
189 field_msg("{label} must be a valid email address.", &f.label),
190 );
191 }
192 Rule::Numeric if !trimmed.is_empty() && trimmed.parse::<f64>().is_err() => {
193 bag.add(&f.field, field_msg("{label} must be a number.", &f.label));
194 }
195 Rule::Url if !trimmed.is_empty() && !is_url(trimmed) => {
196 bag.add(
197 &f.field,
198 field_msg("{label} must be a valid URL.", &f.label),
199 );
200 }
201 Rule::Date if !trimmed.is_empty() && !is_date(trimmed) => {
202 bag.add(&f.field, field_msg("{label} must be a date.", &f.label));
203 }
204 _ => {}
205 }
206 }
207 }
208 bag
209}
210
211pub async fn validate(
215 db: &Db,
216 table: &str,
217 id_field: &str,
218 fields: &[FieldRules],
219 data: &HashMap<String, String>,
220 mode: Mode,
221 edited_id: Option<&str>,
222) -> CoreResult<ErrorBag> {
223 let mut bag = validate_fields(fields, data, mode);
224 for f in fields {
225 let wants_unique = f.rules.iter().any(|r| matches!(r, Rule::Unique));
226 if !wants_unique || !bag.messages(&f.field).is_empty() {
227 continue;
228 }
229 let value = data.get(&f.field).map(|s| s.trim()).unwrap_or("");
230 if value.is_empty() {
231 continue;
232 }
233 if unique_conflict(db, table, &f.field, value, id_field, mode, edited_id).await? {
234 bag.add(&f.field, field_msg("{label} is already taken.", &f.label));
235 }
236 }
237 Ok(bag)
238}
239
240async fn unique_conflict(
243 db: &Db,
244 table: &str,
245 column: &str,
246 value: &str,
247 id_field: &str,
248 mode: Mode,
249 edited_id: Option<&str>,
250) -> CoreResult<bool> {
251 let (sql, values) = {
252 let mut stmt = Query::select();
253 stmt.expr(Expr::val(1))
254 .from(Alias::new(table))
255 .and_where(Expr::col(Alias::new(column)).eq(value))
256 .limit(1);
257 if mode == Mode::Update {
258 if let Some(id) = edited_id {
259 stmt.and_where(
260 Expr::col(Alias::new(id_field))
261 .cast_as(Alias::new(text_cast(db.backend)))
262 .ne(id),
263 );
264 }
265 }
266 build(db.backend, stmt.to_owned())
267 };
268 let row = bind_values(sqlx::query(&sql), values)
269 .fetch_optional(&db.pool)
270 .await?;
271 Ok(row.is_some())
272}
273
274fn is_email(s: &str) -> bool {
278 email_address::EmailAddress::is_valid(s)
279}
280
281fn is_url(s: &str) -> bool {
284 url::Url::parse(s).is_ok_and(|u| u.has_host())
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use crate::i18n::Translator;
291
292 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
293 pairs
294 .iter()
295 .map(|(k, v)| (k.to_string(), v.to_string()))
296 .collect()
297 }
298
299 #[test]
300 fn cheap_rules_collect_per_field_messages() {
301 let fields = vec![
302 FieldRules::new("name", "Name", vec![Rule::Required, Rule::MaxLength(5)]),
303 FieldRules::new("email", "Email", vec![Rule::Email]),
304 ];
305 let bag = validate_fields(
306 &fields,
307 &data(&[("name", "toolong"), ("email", "nope")]),
308 Mode::Create,
309 );
310 let tr = Translator::new("en");
311 assert!(!bag.is_empty());
312 assert_eq!(bag.messages("name").len(), 1); assert!(tr.t(&bag.messages("name")[0]).contains("at most 5"));
314 assert_eq!(bag.messages("email").len(), 1);
315 assert!(tr.t(&bag.messages("email")[0]).contains("valid email"));
316 }
317
318 #[test]
319 fn numeric_rejects_non_numbers_and_skips_blanks() {
320 let fields = [FieldRules::new("qty", "Qty", vec![Rule::Numeric])];
321 assert!(validate_fields(&fields, &data(&[("qty", "12.5")]), Mode::Create).is_empty());
322 assert!(validate_fields(&fields, &data(&[("qty", "")]), Mode::Create).is_empty());
323 let bag = validate_fields(&fields, &data(&[("qty", "abc")]), Mode::Create);
324 assert_eq!(bag.messages("qty").len(), 1);
325 }
326
327 #[test]
328 fn url_rejects_non_urls_and_skips_blanks() {
329 let fields = [FieldRules::new("site", "Site", vec![Rule::Url])];
330 assert!(validate_fields(
331 &fields,
332 &data(&[("site", "https://example.com/x")]),
333 Mode::Create
334 )
335 .is_empty());
336 assert!(validate_fields(&fields, &data(&[("site", "")]), Mode::Create).is_empty());
338 let bag = validate_fields(&fields, &data(&[("site", "example.com")]), Mode::Create);
340 assert_eq!(bag.messages("site").len(), 1);
341 }
342
343 #[test]
344 fn required_on_fires_only_in_its_mode() {
345 let fields = vec![FieldRules::new(
346 "password",
347 "Password",
348 vec![Rule::RequiredOn(Mode::Create)],
349 )];
350 let blank = data(&[("password", "")]);
351 assert!(!validate_fields(&fields, &blank, Mode::Create).is_empty());
352 assert!(validate_fields(&fields, &blank, Mode::Update).is_empty());
353 }
354
355 #[test]
356 fn valid_input_produces_an_empty_bag() {
357 let fields = vec![FieldRules::new(
358 "email",
359 "Email",
360 vec![Rule::Required, Rule::Email],
361 )];
362 assert!(validate_fields(&fields, &data(&[("email", "a@b.com")]), Mode::Create).is_empty());
363 }
364
365 #[test]
366 fn error_bag_records_messages_localized_at_the_sink() {
367 let mut bag = ErrorBag::default();
368 bag.add("name", field_msg("{label} is required.", "Name"));
369 assert_eq!(bag.messages("name").len(), 1);
370 let tr = Translator::new("en");
372 assert_eq!(tr.t(&bag.messages("name")[0]), "Name is required.");
373 }
374
375 #[test]
376 fn min_length_message_pluralizes_on_the_count() {
377 let tr = Translator::new("en");
378 assert_eq!(
379 tr.t(&field_plural_msg(
380 "{label} must be at least {n} character.",
381 "{label} must be at least {n} characters.",
382 1,
383 "Pin",
384 )),
385 "Pin must be at least 1 character."
386 );
387 assert_eq!(
388 tr.t(&field_plural_msg(
389 "{label} must be at least {n} character.",
390 "{label} must be at least {n} characters.",
391 8,
392 "Pin",
393 )),
394 "Pin must be at least 8 characters."
395 );
396 }
397
398 #[test]
399 fn email_shape_check_is_wired_to_the_crate() {
400 assert!(is_email("a@b.com"));
402 assert!(is_email("user.name+tag@sub.example.co"));
403 assert!(!is_email("no-at"));
404 assert!(!is_email("@b.com"));
405 assert!(!is_email("a@@b.com"));
406 }
407}