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
114fn is_date(value: &str) -> bool {
117 chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
118}
119
120fn field_msg(source: &'static str, label: &str) -> Text {
123 Text::new(source).arg("label", Text::dynamic(label))
124}
125
126fn field_plural_msg(one: &'static str, other: &'static str, n: usize, label: &str) -> Text {
128 let n = n as i64;
129 Text::new(one)
130 .with_plural(other, n)
131 .arg("n", n)
132 .arg("label", Text::dynamic(label))
133}
134
135pub fn validate_fields(
140 fields: &[FieldRules],
141 data: &HashMap<String, String>,
142 mode: Mode,
143) -> ErrorBag {
144 let mut bag = ErrorBag::default();
145 for f in fields {
146 let value = data.get(&f.field).map(String::as_str).unwrap_or("");
147 let trimmed = value.trim();
148 let len = trimmed.chars().count();
149 for rule in &f.rules {
150 match rule {
151 Rule::Required if trimmed.is_empty() => {
152 bag.add(&f.field, field_msg("{label} is required.", &f.label));
153 }
154 Rule::RequiredOn(m) if *m == mode && trimmed.is_empty() => {
155 bag.add(&f.field, field_msg("{label} is required.", &f.label));
156 }
157 Rule::MinLength(n) if !trimmed.is_empty() && len < *n => {
158 bag.add(
159 &f.field,
160 field_plural_msg(
161 "{label} must be at least {n} character.",
162 "{label} must be at least {n} characters.",
163 *n,
164 &f.label,
165 ),
166 );
167 }
168 Rule::MaxLength(n) if len > *n => {
169 bag.add(
170 &f.field,
171 field_plural_msg(
172 "{label} must be at most {n} character.",
173 "{label} must be at most {n} characters.",
174 *n,
175 &f.label,
176 ),
177 );
178 }
179 Rule::Email if !trimmed.is_empty() && !is_email(trimmed) => {
180 bag.add(
181 &f.field,
182 field_msg("{label} must be a valid email address.", &f.label),
183 );
184 }
185 Rule::Numeric if !trimmed.is_empty() && trimmed.parse::<f64>().is_err() => {
186 bag.add(&f.field, field_msg("{label} must be a number.", &f.label));
187 }
188 Rule::Url if !trimmed.is_empty() && !is_url(trimmed) => {
189 bag.add(
190 &f.field,
191 field_msg("{label} must be a valid URL.", &f.label),
192 );
193 }
194 Rule::Date if !trimmed.is_empty() && !is_date(trimmed) => {
195 bag.add(&f.field, field_msg("{label} must be a date.", &f.label));
196 }
197 _ => {}
198 }
199 }
200 }
201 bag
202}
203
204pub async fn validate(
208 db: &Db,
209 table: &str,
210 id_field: &str,
211 fields: &[FieldRules],
212 data: &HashMap<String, String>,
213 mode: Mode,
214 edited_id: Option<&str>,
215) -> CoreResult<ErrorBag> {
216 let mut bag = validate_fields(fields, data, mode);
217 for f in fields {
218 let wants_unique = f.rules.iter().any(|r| matches!(r, Rule::Unique));
219 if !wants_unique || !bag.messages(&f.field).is_empty() {
220 continue;
221 }
222 let value = data.get(&f.field).map(|s| s.trim()).unwrap_or("");
223 if value.is_empty() {
224 continue;
225 }
226 if unique_conflict(db, table, &f.field, value, id_field, mode, edited_id).await? {
227 bag.add(&f.field, field_msg("{label} is already taken.", &f.label));
228 }
229 }
230 Ok(bag)
231}
232
233async fn unique_conflict(
236 db: &Db,
237 table: &str,
238 column: &str,
239 value: &str,
240 id_field: &str,
241 mode: Mode,
242 edited_id: Option<&str>,
243) -> CoreResult<bool> {
244 let (sql, values) = {
245 let mut stmt = Query::select();
246 stmt.expr(Expr::val(1))
247 .from(Alias::new(table))
248 .and_where(Expr::col(Alias::new(column)).eq(value))
249 .limit(1);
250 if mode == Mode::Update {
251 if let Some(id) = edited_id {
252 stmt.and_where(
253 Expr::col(Alias::new(id_field))
254 .cast_as(Alias::new(text_cast(db.backend)))
255 .ne(id),
256 );
257 }
258 }
259 build(db.backend, stmt.to_owned())
260 };
261 let row = bind_values(sqlx::query(&sql), values)
262 .fetch_optional(&db.pool)
263 .await?;
264 Ok(row.is_some())
265}
266
267fn is_email(s: &str) -> bool {
271 email_address::EmailAddress::is_valid(s)
272}
273
274fn is_url(s: &str) -> bool {
277 url::Url::parse(s).is_ok_and(|u| u.has_host())
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::i18n::Translator;
284
285 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
286 pairs
287 .iter()
288 .map(|(k, v)| (k.to_string(), v.to_string()))
289 .collect()
290 }
291
292 #[test]
293 fn cheap_rules_collect_per_field_messages() {
294 let fields = vec![
295 FieldRules::new("name", "Name", vec![Rule::Required, Rule::MaxLength(5)]),
296 FieldRules::new("email", "Email", vec![Rule::Email]),
297 ];
298 let bag = validate_fields(
299 &fields,
300 &data(&[("name", "toolong"), ("email", "nope")]),
301 Mode::Create,
302 );
303 let tr = Translator::new("en");
304 assert!(!bag.is_empty());
305 assert_eq!(bag.messages("name").len(), 1); assert!(tr.t(&bag.messages("name")[0]).contains("at most 5"));
307 assert_eq!(bag.messages("email").len(), 1);
308 assert!(tr.t(&bag.messages("email")[0]).contains("valid email"));
309 }
310
311 #[test]
312 fn numeric_rejects_non_numbers_and_skips_blanks() {
313 let fields = [FieldRules::new("qty", "Qty", vec![Rule::Numeric])];
314 assert!(validate_fields(&fields, &data(&[("qty", "12.5")]), Mode::Create).is_empty());
315 assert!(validate_fields(&fields, &data(&[("qty", "")]), Mode::Create).is_empty());
316 let bag = validate_fields(&fields, &data(&[("qty", "abc")]), Mode::Create);
317 assert_eq!(bag.messages("qty").len(), 1);
318 }
319
320 #[test]
321 fn url_rejects_non_urls_and_skips_blanks() {
322 let fields = [FieldRules::new("site", "Site", vec![Rule::Url])];
323 assert!(validate_fields(
324 &fields,
325 &data(&[("site", "https://example.com/x")]),
326 Mode::Create
327 )
328 .is_empty());
329 assert!(validate_fields(&fields, &data(&[("site", "")]), Mode::Create).is_empty());
331 let bag = validate_fields(&fields, &data(&[("site", "example.com")]), Mode::Create);
333 assert_eq!(bag.messages("site").len(), 1);
334 }
335
336 #[test]
337 fn required_on_fires_only_in_its_mode() {
338 let fields = vec![FieldRules::new(
339 "password",
340 "Password",
341 vec![Rule::RequiredOn(Mode::Create)],
342 )];
343 let blank = data(&[("password", "")]);
344 assert!(!validate_fields(&fields, &blank, Mode::Create).is_empty());
345 assert!(validate_fields(&fields, &blank, Mode::Update).is_empty());
346 }
347
348 #[test]
349 fn valid_input_produces_an_empty_bag() {
350 let fields = vec![FieldRules::new(
351 "email",
352 "Email",
353 vec![Rule::Required, Rule::Email],
354 )];
355 assert!(validate_fields(&fields, &data(&[("email", "a@b.com")]), Mode::Create).is_empty());
356 }
357
358 #[test]
359 fn error_bag_records_messages_localized_at_the_sink() {
360 let mut bag = ErrorBag::default();
361 bag.add("name", field_msg("{label} is required.", "Name"));
362 assert_eq!(bag.messages("name").len(), 1);
363 let tr = Translator::new("en");
365 assert_eq!(tr.t(&bag.messages("name")[0]), "Name is required.");
366 }
367
368 #[test]
369 fn min_length_message_pluralizes_on_the_count() {
370 let tr = Translator::new("en");
371 assert_eq!(
372 tr.t(&field_plural_msg(
373 "{label} must be at least {n} character.",
374 "{label} must be at least {n} characters.",
375 1,
376 "Pin",
377 )),
378 "Pin must be at least 1 character."
379 );
380 assert_eq!(
381 tr.t(&field_plural_msg(
382 "{label} must be at least {n} character.",
383 "{label} must be at least {n} characters.",
384 8,
385 "Pin",
386 )),
387 "Pin must be at least 8 characters."
388 );
389 }
390
391 #[test]
392 fn email_shape_check_is_wired_to_the_crate() {
393 assert!(is_email("a@b.com"));
395 assert!(is_email("user.name+tag@sub.example.co"));
396 assert!(!is_email("no-at"));
397 assert!(!is_email("@b.com"));
398 assert!(!is_email("a@@b.com"));
399 }
400}