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)]
40#[serde(rename_all = "snake_case")]
41pub enum Rule {
42 Required,
44 RequiredOn(Mode),
46 MinLength(usize),
49 MaxLength(usize),
51 Email,
53 Numeric,
55 Url,
57 Unique,
60}
61
62#[derive(Debug, Clone)]
64pub struct FieldRules {
65 pub field: String,
66 pub label: String,
67 pub rules: Vec<Rule>,
68}
69
70impl FieldRules {
71 pub fn new(field: impl Into<String>, label: impl Into<String>, rules: Vec<Rule>) -> Self {
72 Self {
73 field: field.into(),
74 label: label.into(),
75 rules,
76 }
77 }
78}
79
80#[derive(Debug, Default, Clone)]
84pub struct ErrorBag {
85 fields: BTreeMap<String, Vec<Text>>,
86}
87
88impl ErrorBag {
89 pub fn is_empty(&self) -> bool {
91 self.fields.is_empty()
92 }
93
94 pub fn add(&mut self, field: &str, message: Text) {
96 self.fields
97 .entry(field.to_string())
98 .or_default()
99 .push(message);
100 }
101
102 pub fn messages(&self, field: &str) -> &[Text] {
104 self.fields.get(field).map(Vec::as_slice).unwrap_or(&[])
105 }
106}
107
108fn field_msg(source: &'static str, label: &str) -> Text {
111 Text::new(source).arg("label", Text::dynamic(label))
112}
113
114fn field_plural_msg(one: &'static str, other: &'static str, n: usize, label: &str) -> Text {
116 let n = n as i64;
117 Text::new(one)
118 .with_plural(other, n)
119 .arg("n", n)
120 .arg("label", Text::dynamic(label))
121}
122
123pub fn validate_fields(
128 fields: &[FieldRules],
129 data: &HashMap<String, String>,
130 mode: Mode,
131) -> ErrorBag {
132 let mut bag = ErrorBag::default();
133 for f in fields {
134 let value = data.get(&f.field).map(String::as_str).unwrap_or("");
135 let trimmed = value.trim();
136 let len = trimmed.chars().count();
137 for rule in &f.rules {
138 match rule {
139 Rule::Required if trimmed.is_empty() => {
140 bag.add(&f.field, field_msg("{label} is required.", &f.label));
141 }
142 Rule::RequiredOn(m) if *m == mode && trimmed.is_empty() => {
143 bag.add(&f.field, field_msg("{label} is required.", &f.label));
144 }
145 Rule::MinLength(n) if !trimmed.is_empty() && len < *n => {
146 bag.add(
147 &f.field,
148 field_plural_msg(
149 "{label} must be at least {n} character.",
150 "{label} must be at least {n} characters.",
151 *n,
152 &f.label,
153 ),
154 );
155 }
156 Rule::MaxLength(n) if len > *n => {
157 bag.add(
158 &f.field,
159 field_plural_msg(
160 "{label} must be at most {n} character.",
161 "{label} must be at most {n} characters.",
162 *n,
163 &f.label,
164 ),
165 );
166 }
167 Rule::Email if !trimmed.is_empty() && !is_email(trimmed) => {
168 bag.add(
169 &f.field,
170 field_msg("{label} must be a valid email address.", &f.label),
171 );
172 }
173 Rule::Numeric if !trimmed.is_empty() && trimmed.parse::<f64>().is_err() => {
174 bag.add(&f.field, field_msg("{label} must be a number.", &f.label));
175 }
176 Rule::Url if !trimmed.is_empty() && !is_url(trimmed) => {
177 bag.add(
178 &f.field,
179 field_msg("{label} must be a valid URL.", &f.label),
180 );
181 }
182 _ => {}
183 }
184 }
185 }
186 bag
187}
188
189pub async fn validate(
193 db: &Db,
194 table: &str,
195 id_field: &str,
196 fields: &[FieldRules],
197 data: &HashMap<String, String>,
198 mode: Mode,
199 edited_id: Option<&str>,
200) -> CoreResult<ErrorBag> {
201 let mut bag = validate_fields(fields, data, mode);
202 for f in fields {
203 let wants_unique = f.rules.iter().any(|r| matches!(r, Rule::Unique));
204 if !wants_unique || !bag.messages(&f.field).is_empty() {
205 continue;
206 }
207 let value = data.get(&f.field).map(|s| s.trim()).unwrap_or("");
208 if value.is_empty() {
209 continue;
210 }
211 if unique_conflict(db, table, &f.field, value, id_field, mode, edited_id).await? {
212 bag.add(&f.field, field_msg("{label} is already taken.", &f.label));
213 }
214 }
215 Ok(bag)
216}
217
218async fn unique_conflict(
221 db: &Db,
222 table: &str,
223 column: &str,
224 value: &str,
225 id_field: &str,
226 mode: Mode,
227 edited_id: Option<&str>,
228) -> CoreResult<bool> {
229 let (sql, values) = {
230 let mut stmt = Query::select();
231 stmt.expr(Expr::val(1))
232 .from(Alias::new(table))
233 .and_where(Expr::col(Alias::new(column)).eq(value))
234 .limit(1);
235 if mode == Mode::Update {
236 if let Some(id) = edited_id {
237 stmt.and_where(
238 Expr::col(Alias::new(id_field))
239 .cast_as(Alias::new(text_cast(db.backend)))
240 .ne(id),
241 );
242 }
243 }
244 build(db.backend, stmt.to_owned())
245 };
246 let row = bind_values(sqlx::query(&sql), values)
247 .fetch_optional(&db.pool)
248 .await?;
249 Ok(row.is_some())
250}
251
252fn is_email(s: &str) -> bool {
256 email_address::EmailAddress::is_valid(s)
257}
258
259fn is_url(s: &str) -> bool {
262 url::Url::parse(s).is_ok_and(|u| u.has_host())
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::i18n::Translator;
269
270 fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
271 pairs
272 .iter()
273 .map(|(k, v)| (k.to_string(), v.to_string()))
274 .collect()
275 }
276
277 #[test]
278 fn cheap_rules_collect_per_field_messages() {
279 let fields = vec![
280 FieldRules::new("name", "Name", vec![Rule::Required, Rule::MaxLength(5)]),
281 FieldRules::new("email", "Email", vec![Rule::Email]),
282 ];
283 let bag = validate_fields(
284 &fields,
285 &data(&[("name", "toolong"), ("email", "nope")]),
286 Mode::Create,
287 );
288 let tr = Translator::new("en");
289 assert!(!bag.is_empty());
290 assert_eq!(bag.messages("name").len(), 1); assert!(tr.t(&bag.messages("name")[0]).contains("at most 5"));
292 assert_eq!(bag.messages("email").len(), 1);
293 assert!(tr.t(&bag.messages("email")[0]).contains("valid email"));
294 }
295
296 #[test]
297 fn numeric_rejects_non_numbers_and_skips_blanks() {
298 let fields = [FieldRules::new("qty", "Qty", vec![Rule::Numeric])];
299 assert!(validate_fields(&fields, &data(&[("qty", "12.5")]), Mode::Create).is_empty());
300 assert!(validate_fields(&fields, &data(&[("qty", "")]), Mode::Create).is_empty());
301 let bag = validate_fields(&fields, &data(&[("qty", "abc")]), Mode::Create);
302 assert_eq!(bag.messages("qty").len(), 1);
303 }
304
305 #[test]
306 fn url_rejects_non_urls_and_skips_blanks() {
307 let fields = [FieldRules::new("site", "Site", vec![Rule::Url])];
308 assert!(validate_fields(
309 &fields,
310 &data(&[("site", "https://example.com/x")]),
311 Mode::Create
312 )
313 .is_empty());
314 assert!(validate_fields(&fields, &data(&[("site", "")]), Mode::Create).is_empty());
316 let bag = validate_fields(&fields, &data(&[("site", "example.com")]), Mode::Create);
318 assert_eq!(bag.messages("site").len(), 1);
319 }
320
321 #[test]
322 fn required_on_fires_only_in_its_mode() {
323 let fields = vec![FieldRules::new(
324 "password",
325 "Password",
326 vec![Rule::RequiredOn(Mode::Create)],
327 )];
328 let blank = data(&[("password", "")]);
329 assert!(!validate_fields(&fields, &blank, Mode::Create).is_empty());
330 assert!(validate_fields(&fields, &blank, Mode::Update).is_empty());
331 }
332
333 #[test]
334 fn valid_input_produces_an_empty_bag() {
335 let fields = vec![FieldRules::new(
336 "email",
337 "Email",
338 vec![Rule::Required, Rule::Email],
339 )];
340 assert!(validate_fields(&fields, &data(&[("email", "a@b.com")]), Mode::Create).is_empty());
341 }
342
343 #[test]
344 fn error_bag_records_messages_localized_at_the_sink() {
345 let mut bag = ErrorBag::default();
346 bag.add("name", field_msg("{label} is required.", "Name"));
347 assert_eq!(bag.messages("name").len(), 1);
348 let tr = Translator::new("en");
350 assert_eq!(tr.t(&bag.messages("name")[0]), "Name is required.");
351 }
352
353 #[test]
354 fn min_length_message_pluralizes_on_the_count() {
355 let tr = Translator::new("en");
356 assert_eq!(
357 tr.t(&field_plural_msg(
358 "{label} must be at least {n} character.",
359 "{label} must be at least {n} characters.",
360 1,
361 "Pin",
362 )),
363 "Pin must be at least 1 character."
364 );
365 assert_eq!(
366 tr.t(&field_plural_msg(
367 "{label} must be at least {n} character.",
368 "{label} must be at least {n} characters.",
369 8,
370 "Pin",
371 )),
372 "Pin must be at least 8 characters."
373 );
374 }
375
376 #[test]
377 fn email_shape_check_is_wired_to_the_crate() {
378 assert!(is_email("a@b.com"));
380 assert!(is_email("user.name+tag@sub.example.co"));
381 assert!(!is_email("no-at"));
382 assert!(!is_email("@b.com"));
383 assert!(!is_email("a@@b.com"));
384 }
385}