autumn-web 0.9.0

An opinionated, convention-over-configuration web framework for Rust
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
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! Field normalization primitives (issue #1379).
//!
//! See the [forms, validation and normalization guide](https://github.com/autumn-foundation/autumn/blob/trunk/docs/guide/forms.md)
//! for where normalization sits relative to validation and hooks.
//!
//! Canonicalizes `#[model]` `String` columns declared with the
//! `#[normalize(...)]` attribute. Normalizers are the small, composable
//! building blocks the macro chains left-to-right; the traits are the
//! runtime hooks the generated code and the derived `#[repository]` finders
//! call.
//!
//! # Built-in normalizers
//!
//! - [`trim`] — strip leading/trailing whitespace.
//! - [`downcase`] — lowercase (str casing; simple ASCII/Unicode `to_lowercase`).
//! - [`upcase`] — uppercase (str casing).
//! - [`squish`] — trim **and** collapse internal runs of whitespace to a single
//!   space.
//! - [`strip_nul`] — remove every NUL (`U+0000`), which a Postgres
//!   `TEXT`/`VARCHAR` column cannot store (#2423).
//!
//! All built-ins are idempotent: normalizing an already-normalized value is a
//! no-op, so applying them on both the write path and on lookups converges on
//! the same canonical value.
//!
//! # Ordering (write path)
//!
//! `#[model]` runs normalization at the head of the repository insert flow,
//! before the model's `#[validate(...)]` rules, before the hooks, and before the
//! row is written. Normalizers apply left-to-right in the order written in the
//! attribute.
//!
//! **Insert** (`save`, `save_many`, `save_many_skip_invalid`, and the create
//! half of `find_or_create_by_*`) normalizes the `New*` input, so the model's
//! rules *and the database* observe the canonical value (#2586).
//!
//! **Update is not symmetric with insert, and there are three cases, not two.**
//!
//! | Repository | Merged draft | Persisted |
//! |---|---|---|
//! | no hooks, no knob (**blind**) | none built — `from_patch` is not emitted | raw patch |
//! | `validate_on_update = fetch` | built and normalized, **validation only** | raw patch |
//! | has hooks | built and normalized | the normalized draft |
//!
//! The middle row is the one that surprises: `from_patch` normalizes the merged
//! model so validators see the canonical value, but the generated code keeps
//! only its 422 and persists `changes.__to_changeset()` unchanged. So on either
//! of the first two, an `update` that sets a `#[normalize(trim, downcase)]`
//! column to `"  FOO@X.com "` stores it verbatim, and
//! [`normalize_lookup_value`]-based finders will not match that row. Only the
//! hooked path writes the canonical value.
//!
//! If a normalized column is ever written through `update`, give the repository
//! hooks or normalize before building the patch — and consider a `CITEXT`
//! column or a functional unique index as the durable backstop. See
//! `docs/guide/forms.md`.

/// Strip leading and trailing whitespace.
#[must_use]
pub fn trim(s: &str) -> String {
    s.trim().to_owned()
}

/// Lowercase the value (str casing).
#[must_use]
pub fn downcase(s: &str) -> String {
    s.to_lowercase()
}

/// Uppercase the value (str casing).
#[must_use]
pub fn upcase(s: &str) -> String {
    s.to_uppercase()
}

/// Trim and collapse every internal run of whitespace to a single ASCII space.
#[must_use]
pub fn squish(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// Remove every NUL (`U+0000`) character (issue #2423).
///
/// A Postgres `TEXT`/`VARCHAR` column cannot hold `0x00`, so a value carrying
/// one is rejected by the server at `INSERT`/`UPDATE` time. Reach for this on
/// a column fed by input that may legitimately arrive dirty (a paste from a
/// binary source, a misbehaving input method) and where silently dropping the
/// byte is preferable to rejecting the write.
///
/// Only NUL is removed: every other control character is storable and is left
/// exactly as written.
///
/// Prefer rejecting over cleaning where the author can see and fix the value —
/// [`crate::form::ChangesetForm`] already surfaces a NUL as an inline field
/// error without any attribute.
#[must_use]
pub fn strip_nul(s: &str) -> String {
    s.replace('\u{0}', "")
}

/// A type whose `#[normalize]` columns can be canonicalized in place.
///
/// `#[model]` implements this for the model and for every generated `New*`
/// insert struct. You can use a `T: Normalize` bound for all of them (#2692).
/// `normalize` applies the normalizer chain of each field. If the model has no
/// `#[normalize]` columns, `normalize` does nothing.
pub trait Normalize {
    /// Canonicalize every `#[normalize]` field in place.
    fn normalize(&mut self);
}

/// Marks a `Normalize` impl that does nothing.
///
/// `#[model]` emits this for a `New*` with no `#[normalize]` columns. The
/// repository insert path then skips the clone (#2634). Do not implement it
/// by hand: the insert path does not call `normalize` on a marked type.
#[doc(hidden)]
pub trait NormalizeNoop: Normalize {}

/// A model that knows how to canonicalize a lookup argument for one of its
/// columns, so derived `#[repository]` `find_by_`/`count_by_` finders match the
/// stored (already-normalized) value.
///
/// Implemented by `#[model]` for every model — `normalize_lookup` returns
/// `None` for a column with no `#[normalize]` attribute (the finder then uses
/// the raw argument), and `Some(canonical)` for a normalized column.
pub trait NormalizedModel {
    /// The canonical form of `value` for `column`, or `None` if `column` is not
    /// a `#[normalize]` column on this model.
    fn normalize_lookup(column: &str, value: &str) -> Option<String>;
}

/// Normalize a derived-finder argument for `column` on model `M`.
///
/// Falls back to the raw value when the column is not normalized. Called by the
/// generated `#[repository]` finder chain so `find_by_email("  FOO@X.com ")`
/// matches the stored `foo@x.com` row.
#[must_use]
pub fn normalize_lookup_value<M: NormalizedModel>(column: &str, value: &str) -> String {
    M::normalize_lookup(column, value).unwrap_or_else(|| value.to_owned())
}

// ── Autoref specialization for the `#[repository]` call sites ─────────────
//
// A `#[repository]` can target a hand-written `New*` or model. Such a type
// need not implement `Normalize` / `NormalizedModel`. So the generated code
// calls these probe wrappers. Method resolution picks the first arm whose
// bound the concrete type satisfies. Each arm takes one more `&` than the
// next arm. This sets the order. This works only at a concrete call site, not
// through a generic boundary. Thus the macro builds the wrappers in the
// generated code. The method names differ from 0.8.0 (two arms). Thus 0.8.0
// macro output does not compile against this probe and cannot skip
// normalization silently.

/// Probe wrapper for write-path normalization.
///
/// Holds a borrow of the caller's input. Call it as
/// `(&&SpezNormalize(x)).spez_normalize_insert()`. The arms, in order:
///
/// 1. `Noop` (`T: NormalizeNoop`): returns the borrow. No clone (#2634).
/// 2. `Yes` (`T: Normalize + Clone`): clones, normalizes, returns the value.
/// 3. `No` (any `T`, e.g. a hand-written `New*`): returns the borrow.
///
/// The generated code unifies the arms with `Borrow`. See `#[repository]`
/// `save`, `save_many`, `save_many_skip_invalid` and `find_or_create_by_*`.
#[doc(hidden)]
pub struct SpezNormalize<'a, T: ?Sized>(pub &'a T);

// The `No` arm takes `self` by value after autoderef. Thus `SpezNormalize`
// must be `Copy`. A derive adds a `T: Copy` bound, so the impls are manual.
impl<T: ?Sized> Clone for SpezNormalize<'_, T> {
    fn clone(&self) -> Self {
        *self
    }
}
impl<T: ?Sized> Copy for SpezNormalize<'_, T> {}

#[doc(hidden)]
pub trait SpezNormalizeNoop<'a, T> {
    fn spez_normalize_insert(self) -> &'a T;
}
impl<'a, T: NormalizeNoop> SpezNormalizeNoop<'a, T> for &&SpezNormalize<'a, T> {
    #[inline]
    fn spez_normalize_insert(self) -> &'a T {
        self.0
    }
}

#[doc(hidden)]
pub trait SpezNormalizeYes<'a, T> {
    fn spez_normalize_insert(self) -> T;
}
impl<'a, T: Normalize + Clone> SpezNormalizeYes<'a, T> for &SpezNormalize<'a, T> {
    #[inline]
    fn spez_normalize_insert(self) -> T {
        let mut owned = self.0.clone();
        owned.normalize();
        owned
    }
}

#[doc(hidden)]
pub trait SpezNormalizeNo<'a, T: ?Sized> {
    fn spez_normalize_insert(self) -> &'a T;
}
impl<'a, T: ?Sized> SpezNormalizeNo<'a, T> for SpezNormalize<'a, T> {
    #[inline]
    fn spez_normalize_insert(self) -> &'a T {
        self.0
    }
}

#[doc(hidden)]
pub trait SpezNormalizeManyNoop<'a, T> {
    fn spez_normalize_insert_many(self) -> &'a [T];
}
impl<'a, T: NormalizeNoop> SpezNormalizeManyNoop<'a, T> for &&SpezNormalize<'a, [T]> {
    #[inline]
    fn spez_normalize_insert_many(self) -> &'a [T] {
        self.0
    }
}

#[doc(hidden)]
pub trait SpezNormalizeManyYes<'a, T> {
    fn spez_normalize_insert_many(self) -> Vec<T>;
}
impl<'a, T: Normalize + Clone> SpezNormalizeManyYes<'a, T> for &SpezNormalize<'a, [T]> {
    #[inline]
    fn spez_normalize_insert_many(self) -> Vec<T> {
        self.0
            .iter()
            .map(|item| {
                let mut owned = item.clone();
                owned.normalize();
                owned
            })
            .collect()
    }
}

#[doc(hidden)]
pub trait SpezNormalizeManyNo<'a, T> {
    fn spez_normalize_insert_many(self) -> &'a [T];
}
impl<'a, T> SpezNormalizeManyNo<'a, T> for SpezNormalize<'a, [T]> {
    #[inline]
    fn spez_normalize_insert_many(self) -> &'a [T] {
        self.0
    }
}

/// Probe wrapper for finder-argument normalization on lookups.
#[doc(hidden)]
pub struct SpezLookup<'a, M>(pub core::marker::PhantomData<M>, pub &'a str, pub &'a str);

#[doc(hidden)]
pub trait SpezLookupYes {
    fn spez_lookup(self) -> String;
}
impl<M: NormalizedModel> SpezLookupYes for SpezLookup<'_, M> {
    #[inline]
    fn spez_lookup(self) -> String {
        normalize_lookup_value::<M>(self.1, self.2)
    }
}

#[doc(hidden)]
pub trait SpezLookupNo {
    fn spez_lookup(self) -> String;
}
impl<M> SpezLookupNo for &SpezLookup<'_, M> {
    #[inline]
    fn spez_lookup(self) -> String {
        self.2.to_owned()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn trim_strips_edges() {
        assert_eq!(trim("  hi  "), "hi");
        assert_eq!(trim("hi"), "hi");
    }

    #[test]
    fn strip_nul_removes_embedded_nuls() {
        assert_eq!(strip_nul("before\u{0}after"), "beforeafter");
        assert_eq!(strip_nul("\u{0}\u{0}a\u{0}"), "a");
    }

    #[test]
    fn strip_nul_leaves_clean_text_untouched() {
        assert_eq!(strip_nul("plain text"), "plain text");
        // Other control characters are storable in a Postgres TEXT column and
        // are deliberately left alone.
        assert_eq!(strip_nul("tab\there"), "tab\there");
    }

    #[test]
    fn strip_nul_is_idempotent() {
        let once = strip_nul("a\u{0}b");
        assert_eq!(strip_nul(&once), once);
    }

    #[test]
    fn downcase_and_upcase() {
        assert_eq!(downcase("Foo@X.COM"), "foo@x.com");
        assert_eq!(upcase("abc"), "ABC");
    }

    #[test]
    fn squish_collapses_internal_whitespace() {
        assert_eq!(squish("  a   b\tc\n d "), "a b c d");
    }

    #[test]
    fn builtins_are_idempotent() {
        // Round-trip: normalizing an already-normalized value is a no-op
        // (issue #1379 AC: idempotency).
        for input in ["  Foo@X.COM ", "a  b   c", "MixedCase", ""] {
            let once = squish(&downcase(&trim(input)));
            let twice = squish(&downcase(&trim(&once)));
            assert_eq!(once, twice, "not idempotent for {input:?}");
        }
    }

    struct Account;
    impl NormalizedModel for Account {
        fn normalize_lookup(column: &str, value: &str) -> Option<String> {
            match column {
                "email" => Some(downcase(&trim(value))),
                _ => None,
            }
        }
    }

    #[test]
    fn normalize_lookup_value_canonicalizes_known_column() {
        assert_eq!(
            normalize_lookup_value::<Account>("email", "  FOO@X.com "),
            "foo@x.com"
        );
        // Unknown column passes through unchanged.
        assert_eq!(
            normalize_lookup_value::<Account>("nickname", "  Kept "),
            "  Kept "
        );
    }

    // ── Autoref specialization dispatch (repository call sites) ────────────

    #[derive(Clone)]
    struct Norms(String);
    impl Normalize for Norms {
        fn normalize(&mut self) {
            self.0 = downcase(&trim(&self.0));
        }
    }
    struct Plain(#[allow(dead_code)] String); // no Normalize impl

    #[derive(Clone)]
    struct Noop(#[allow(dead_code)] String);
    impl Normalize for Noop {
        fn normalize(&mut self) {}
    }
    impl NormalizeNoop for Noop {}

    #[test]
    // The `&&` receiver is the codegen call shape. Keep it for every type.
    #[allow(clippy::needless_borrow)]
    fn spez_normalize_picks_noop_then_yes_then_no() {
        use super::{SpezNormalizeNo as _, SpezNormalizeNoop as _, SpezNormalizeYes as _};
        use std::borrow::Borrow as _;

        // `Normalize + Clone`: cloned and canonicalized; input untouched.
        let n = Norms("  Foo ".into());
        let normalized = (&&SpezNormalize(&n)).spez_normalize_insert();
        let n_ref: &Norms = normalized.borrow();
        assert_eq!(n_ref.0, "foo", "Normalize type must be canonicalized");
        assert_eq!(n.0, "  Foo ", "input must not be mutated in place");

        // `NormalizeNoop`: the caller's borrow comes back. No clone (#2634).
        let z = Noop("  Foo ".into());
        let z_ref: &Noop = (&&SpezNormalize(&z)).spez_normalize_insert();
        assert!(
            std::ptr::eq(z_ref, std::ptr::from_ref(&z)),
            "no-op type must not be cloned"
        );

        // No `Normalize` impl: the borrow comes back untouched.
        let p = Plain("  Foo ".into());
        let p_ref: &Plain = (&&SpezNormalize(&p)).spez_normalize_insert();
        assert!(
            std::ptr::eq(p_ref, std::ptr::from_ref(&p)),
            "non-Normalize type must be untouched"
        );
    }

    #[test]
    // The `&&` receiver is the codegen call shape. Keep it for every type.
    #[allow(clippy::needless_borrow)]
    fn spez_normalize_insert_many_picks_noop_then_yes_then_no() {
        use super::{
            SpezNormalizeManyNo as _, SpezNormalizeManyNoop as _, SpezNormalizeManyYes as _,
        };
        use std::borrow::Borrow as _;

        let n = vec![Norms("  Foo ".into())];
        let normalized = (&&SpezNormalize(n.as_slice())).spez_normalize_insert_many();
        let n_ref: &[Norms] = normalized.borrow();
        assert_eq!(n_ref[0].0, "foo", "each row must be canonicalized");

        let z = vec![Noop("  Foo ".into())];
        let z_ref: &[Noop] = (&&SpezNormalize(z.as_slice())).spez_normalize_insert_many();
        assert!(
            std::ptr::eq(z_ref, z.as_slice()),
            "no-op batch must not be cloned"
        );

        let p = vec![Plain("  Foo ".into())];
        let p_ref: &[Plain] = (&&SpezNormalize(p.as_slice())).spez_normalize_insert_many();
        assert!(
            std::ptr::eq(p_ref, p.as_slice()),
            "non-Normalize batch must be untouched"
        );
    }

    #[test]
    fn normalize_stays_dyn_compatible() {
        // #2692: `Normalize` is public API. Keep it usable as a trait object.
        let mut boxed: Box<dyn Normalize> = Box::new(Norms("  Foo ".into()));
        boxed.normalize();
    }

    #[test]
    fn spez_lookup_uses_normalized_model_or_falls_back() {
        use super::{SpezLookupNo as _, SpezLookupYes as _};

        // NormalizedModel type: argument is canonicalized.
        let got =
            SpezLookup::<Account>(std::marker::PhantomData, "email", "  FOO@X.com ").spez_lookup();
        assert_eq!(got, "foo@x.com");

        // A type without NormalizedModel falls back to the raw value.
        let got = SpezLookup::<Plain>(std::marker::PhantomData, "email", "  Kept ").spez_lookup();
        assert_eq!(got, "  Kept ");
    }
}