laterite-core 0.9.13

Laterite CMF application kernel: config, errors, database, migrations, modules
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! One validation engine for admin forms and the API.
//!
//! Typed [`Rule`]s run over a field's submitted value into an [`ErrorBag`] of
//! locale-free [`Text`] messages: the admin form reads it field by field and
//! localizes each inline, the API localizes it into a `422` body. Cheap rules
//! (required, length, format) run without touching the database; the one DB-backed
//! rule, [`Rule::Unique`], runs a single `EXISTS`-style probe and only for a field
//! that already passed its cheap rules, so a blank or malformed value is never also
//! probed.
//!
//! The engine is bespoke because Laterite's rules are runtime and
//! descriptor-driven (a `FormConfig` or YAML over a `HashMap`), which the
//! derive-macro crates (`validator`, `garde`) do not fit, and because `Unique`
//! is inherently database-specific. The one genuinely-standard primitive, email
//! shape, is delegated to the RFC-compliant `email_address` crate.

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};

/// Whether a submission creates a new record or updates an existing one. A
/// [`Rule::Unique`] check ignores the edited row on update, and
/// [`Rule::RequiredOn`] fires for one mode only.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
    Create,
    Update,
}

/// A single rule applied to one field's submitted value. Serde-serialisable so
/// a descriptor (later YAML) can author rules by name (`required`, `email`,
/// `max_length`, ...).
///
/// Non-exhaustive: rules are added as field types need them, so match with a `_`
/// arm.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Rule {
    /// Non-empty after trimming, in every mode.
    Required,
    /// Non-empty after trimming, in the given mode only.
    RequiredOn(Mode),
    /// At least `n` characters. Skipped when empty, so pair it with `Required`
    /// to forbid blanks.
    MinLength(usize),
    /// At most `n` characters.
    MaxLength(usize),
    /// A syntactically plausible email address. Skipped when empty.
    Email,
    /// Parses as a number (integer or decimal). Skipped when empty.
    Numeric,
    /// A syntactically valid absolute URL with a host. Skipped when empty.
    Url,
    /// A calendar date, `YYYY-MM-DD`. Skipped when empty.
    Date,
    /// The value must not already exist in this field's column of the target
    /// table. On update, the edited row is ignored.
    Unique,
}

/// The rules for one field, with the label used in its messages.
#[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,
        }
    }
}

/// Per-field validation messages, each a locale-free [`Text`]. Empty means valid.
/// Whoever renders the bag localizes each message at its sink: the admin form
/// through the request translator, the API into a localized `422` body.
#[derive(Debug, Default, Clone)]
pub struct ErrorBag {
    fields: BTreeMap<String, Vec<Text>>,
}

impl ErrorBag {
    /// Whether every field validated (no messages).
    pub fn is_empty(&self) -> bool {
        self.fields.is_empty()
    }

    /// Records a message against a field.
    pub fn add(&mut self, field: &str, message: Text) {
        self.fields
            .entry(field.to_string())
            .or_default()
            .push(message);
    }

    /// The messages recorded for `field` (empty if it validated).
    pub fn messages(&self, field: &str) -> &[Text] {
        self.fields.get(field).map(Vec::as_slice).unwrap_or(&[])
    }

    /// Every field with messages, in field order, each with its messages.
    pub fn iter(&self) -> impl Iterator<Item = (&str, &[Text])> {
        self.fields
            .iter()
            .map(|(field, messages)| (field.as_str(), messages.as_slice()))
    }
}

/// Whether the value is a calendar date in the one format a date control sends
/// and the framework stores.
fn is_date(value: &str) -> bool {
    chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
}

/// A validation message carrying the field `label` as a nested [`Text`] argument,
/// so the label localizes too once descriptor labels become `Text`.
fn field_msg(source: &'static str, label: &str) -> Text {
    Text::new(source).arg("label", Text::dynamic(label))
}

/// A count-pluralized validation message carrying the field `label` and count `n`.
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))
}

/// Runs the cheap, non-database rules over `data` for `mode`, returning the error
/// bag. [`Rule::Unique`] needs the database and is ignored here; use [`validate`]
/// to include it. Exposed on its own for callers that want a DB-free check (for
/// example an API validating a payload before any query).
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
}

/// Runs [`validate_fields`], then the DB-backed [`Rule::Unique`] probe for each
/// field that carries it, has a value, and passed its cheap rules. `edited_id` is
/// the row being updated (ignored by the probe); pass `None` on create.
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)
}

/// Whether `value` already exists in `table.column`, ignoring the edited row on
/// update. Identifiers go through sea-query (quoted); the value and id are bound.
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())
}

/// Whether `s` is a syntactically valid email address, delegated to the
/// RFC-compliant `email_address` crate. Deliverability is confirmed by a
/// confirmation email, not by this shape check.
fn is_email(s: &str) -> bool {
    email_address::EmailAddress::is_valid(s)
}

/// Whether `s` is a syntactically valid absolute URL with a host, delegated to
/// the `url` crate. Scheme-agnostic: an input type may narrow to http(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); // MaxLength only (Required passed)
        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());
        // Blank is skipped (pair with Required to forbid a missing value).
        assert!(validate_fields(&fields, &data(&[("site", "")]), Mode::Create).is_empty());
        // Schemeless / relative values fail.
        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);
        // The bag holds a locale-free Text; the sink localizes it.
        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() {
        // Unambiguous cases the email_address crate agrees on either way.
        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"));
    }
}