Skip to main content

cratestack_axum/
projection.rs

1//! Format-preserving intermediate representation for generated
2//! `list`/`get` model-response projection (cratestack#430).
3//!
4//! Before this type existed, `project_<model>_model_value`
5//! (`cratestack-macros`' `axum/model/serializers.rs`) routed every row
6//! through `serde_json::to_value`. `serde_json::Value` always reports
7//! itself human-readable (`Serializer::is_human_readable` — see
8//! `serde_json::value::Serializer`), so any field whose own `Serialize`
9//! impl branches on that hint (`uuid::Uuid`, `chrono::DateTime`, …) took
10//! its *string* branch right there, permanently — before the real wire
11//! codec ever ran. Re-encoding that already-a-string value through CBOR
12//! (`minicbor-serde`, which correctly reports itself non-human-readable)
13//! produced a CBOR text string; the generated client decodes straight
14//! into `uuid::Uuid`, whose `Deserialize` takes the *bytes* branch under
15//! a non-human-readable format — a decode error on every `Uuid` column,
16//! every time, over the default (CBOR) wire format.
17//!
18//! `ProjectedValue` defers that branch to the real target `Serializer`
19//! instead of baking it in early: each scalar leaf keeps the record
20//! field's *original* value behind a type-erased `erased_serde::Serialize`
21//! trait object (`erased-serde` exists specifically to make `Serialize`
22//! object-safe; see its crate docs) rather than a pre-serialized
23//! `serde_json::Value`. When the response is finally encoded — by
24//! `JsonCodec` or `CborCodec`, chosen per-request via content
25//! negotiation, long after projection ran — `erased_serde::serialize`
26//! drives the leaf's *original* `Serialize::serialize` against the real
27//! serializer, so `is_human_readable()` reports the truth and the right
28//! branch runs. `Null` gets its own variant that always calls
29//! `serialize_none()` (the same primitive `Option::<T>::None` uses),
30//! rather than piggybacking on `serde_json::Value::Null`'s
31//! `serialize_unit()` — which is what the old code additionally had to
32//! special-case (a documented, separate `minicbor-serde` quirk: unit
33//! encodes as a CBOR empty array, not null). That workaround — stripping
34//! `Null` map entries out of the top-level projection before the codec
35//! ever saw them — is gone: it's no longer needed for scalar columns,
36//! and it was never applied to nullable to-one relation includes in the
37//! first place (a latent, separate CBOR-null bug on that path, fixed as
38//! a natural side effect of routing both through the same correct
39//! `Null` variant).
40
41use std::collections::BTreeMap;
42
43use serde::ser::{SerializeMap, SerializeSeq};
44use serde::{Serialize, Serializer};
45
46/// One projected model field, or a nested included relation. See the
47/// module doc for why this replaces `serde_json::Value` on the
48/// projection path.
49pub enum ProjectedValue {
50    /// Absent value — a `None` scalar, or a missing nullable to-one
51    /// relation. Always serializes via `serialize_none()`, matching
52    /// `Option::<T>::None`'s own wire encoding under every codec.
53    Null,
54    /// A single scalar field, holding the record field's *original*
55    /// value so its own `Serialize` impl runs against the real target
56    /// serializer at encode time. Construct via [`ProjectedValue::leaf`].
57    Leaf(Box<dyn erased_serde::Serialize + Send + Sync>),
58    /// A projected model (the detail shape, or one list element).
59    /// `BTreeMap` mirrors `serde_json::Map`'s own default (non
60    /// `preserve_order`) key ordering, which the rest of the generated
61    /// projection code already relies on being alphabetical.
62    Object(BTreeMap<String, ProjectedValue>),
63    /// A to-many included relation.
64    Array(Vec<ProjectedValue>),
65}
66
67impl ProjectedValue {
68    /// Wrap a single field's value without collapsing its type-specific
69    /// `Serialize` behavior. `T` is kept — not pre-serialized — so
70    /// `is_human_readable()`-sensitive impls (`Uuid`, `chrono::DateTime`,
71    /// `Option<T>`, …) see the *real* target serializer later.
72    pub fn leaf<T>(value: T) -> Self
73    where
74        T: Serialize + Send + Sync + 'static,
75    {
76        ProjectedValue::Leaf(Box::new(value))
77    }
78}
79
80impl Serialize for ProjectedValue {
81    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
82    where
83        S: Serializer,
84    {
85        match self {
86            ProjectedValue::Null => serializer.serialize_none(),
87            // Bridges the erased trait object back into the *concrete*
88            // target serializer — this, not any special-casing here, is
89            // what makes `is_human_readable()` report the real wire
90            // format to the leaf's own `Serialize` impl.
91            ProjectedValue::Leaf(value) => erased_serde::serialize(value.as_ref(), serializer),
92            ProjectedValue::Object(object) => {
93                let mut map = serializer.serialize_map(Some(object.len()))?;
94                for (key, value) in object {
95                    map.serialize_entry(key, value)?;
96                }
97                map.end()
98            }
99            ProjectedValue::Array(items) => {
100                let mut seq = serializer.serialize_seq(Some(items.len()))?;
101                for item in items {
102                    seq.serialize_element(item)?;
103                }
104                seq.end()
105            }
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests;