Skip to main content

tablo_core/schema/
lenses.rs

1//! Resolves typed Toasty paths to form keys and field metadata.
2//!
3//! A single-field path resolves against its own model when its declaration is built. An embedded
4//! path names a flattened storage column only the app schema knows, so it stays unbound until its
5//! declaration binds to a [`FieldResolver`]: the panel binds every declaration it mounts, and
6//! [`Schema::bind`](crate::Schema::bind) and [`Table::bind`](crate::Table::bind) bind one built
7//! outside a panel.
8
9use std::sync::OnceLock;
10
11use toasty::stmt::Path;
12use topcoat::context::Cx;
13
14pub(crate) use crate::toasty_compat::model::{EnumShape, LeafField};
15use crate::{
16    DeclarationErrorKind,
17    toasty_compat::model::{self, AppSchema, ModelPath},
18};
19
20/// A path bound to the column it names, with the metadata a declaration defaults from.
21///
22/// A single-field path binds when it is built. An embedded path binds when its declaration binds
23/// ([`Self::bind`]); until then it names a placeholder spelling its steps, which reads as no other
24/// field's duplicate, and reports [`DeclarationErrorKind::Unbound`].
25///
26/// A column reports its nullability, and defaults `unique` from a single- or multi-field unique
27/// index it belongs to. An embedded leaf is never unique by default and reports nullable: only the
28/// matching enum variant writes a variant payload column. That is the binding default, not a
29/// storage fact — the flattened column of a required embedded struct is `NOT NULL`.
30#[derive(Debug, Clone)]
31pub(crate) struct Binding {
32    /// The path, or `None` for a key no path names (an embedded enum's discriminant).
33    path: Option<ModelPath>,
34    model: &'static str,
35    placeholder: String,
36    leaf: OnceLock<Result<LeafField, DeclarationErrorKind>>,
37}
38
39impl Binding {
40    /// Bind `path`, now when it names one field of its model and at [`Self::bind`] when it is
41    /// embedded.
42    pub(crate) fn of<M, T>(path: &Path<M, T>) -> Self
43    where
44        M: toasty::schema::Model,
45    {
46        let path = ModelPath::of(path);
47        let leaf = OnceLock::new();
48        if !path.is_embedded() {
49            let _ = leaf.set(model::single_field::<M>(&path));
50        }
51        Self {
52            placeholder: format!("{:?}", path.steps()),
53            path: Some(path),
54            model: std::any::type_name::<M>(),
55            leaf,
56        }
57    }
58
59    /// A key no path names, bound as given.
60    pub(crate) fn named(name: String, label: String) -> Self {
61        Self {
62            path: None,
63            model: "",
64            placeholder: String::new(),
65            leaf: OnceLock::from(Ok(LeafField {
66                name,
67                label,
68                nullable: true,
69                unique: false,
70            })),
71        }
72    }
73
74    /// Bind an embedded path through `resolver`'s app schema; a bound path stays as it is.
75    pub(crate) fn bind(&self, resolver: &FieldResolver) {
76        if let Some(path) = &self.path {
77            self.leaf
78                .get_or_init(|| resolver.resolve_embedded(path, self.model));
79        }
80    }
81
82    fn leaf(&self) -> Option<&LeafField> {
83        self.leaf.get().and_then(|leaf| leaf.as_ref().ok())
84    }
85
86    /// The form key: the storage column the path names.
87    pub(crate) fn name(&self) -> &str {
88        self.leaf().map_or(&self.placeholder, |leaf| &leaf.name)
89    }
90
91    /// The label a declaration defaults to.
92    pub(crate) fn label(&self) -> &str {
93        self.leaf().map_or("", |leaf| &leaf.label)
94    }
95
96    pub(crate) fn nullable(&self) -> bool {
97        self.leaf().is_none_or(|leaf| leaf.nullable)
98    }
99
100    pub(crate) fn unique(&self) -> bool {
101        self.leaf().is_some_and(|leaf| leaf.unique)
102    }
103
104    /// Why the path binds no column, when it does not.
105    pub(crate) fn misdeclared(&self) -> Option<DeclarationErrorKind> {
106        match self.leaf.get() {
107            Some(Ok(_)) => None,
108            Some(Err(error)) => Some(error.clone()),
109            None => Some(DeclarationErrorKind::Unbound { item: self.model }),
110        }
111    }
112}
113
114/// The form key a record-form field binds: the column `path` resolves to.
115#[doc(hidden)]
116pub fn form_key<M, T>(path: Path<M, T>) -> String
117where
118    M: toasty::schema::Model,
119{
120    Binding::of(&path).name().to_string()
121}
122
123/// The app schema embedded paths resolve through: the compiled schema of the app's `Db`.
124///
125/// A panel binds the declarations it mounts through its `Db`'s, and hands one to
126/// [`RecordForm::fields`](crate::RecordForm::fields) and
127/// [`EmbeddedForm`](crate::EmbeddedForm)'s schema builder. A hand-written impl passes it on to
128/// the embedded values it builds.
129#[derive(Clone, Default)]
130pub struct FieldResolver {
131    schema: Option<AppSchema>,
132}
133
134impl FieldResolver {
135    pub(crate) fn new(schema: Option<AppSchema>) -> Self {
136        Self { schema }
137    }
138
139    /// The resolver over `db`'s app schema.
140    pub(crate) fn of_db(db: &toasty::Db) -> Self {
141        Self::new(Some(AppSchema::of_db(db)))
142    }
143
144    /// The resolver over the app schema of the `Db` the request carries, if any.
145    pub(crate) fn of(cx: &Cx) -> Self {
146        Self::new(AppSchema::of(cx))
147    }
148
149    /// Whether an app schema backs this resolver at all.
150    ///
151    /// An embedded enum has no fallback without one — its discriminant column and variants come
152    /// from the schema — so its entry point says so rather than reporting a traversal-lens error.
153    pub(crate) fn has_schema(&self) -> bool {
154        self.schema.is_some()
155    }
156
157    /// Resolve a lens to its leaf field as a declaration binding it would: a single field against
158    /// its model, an embedded path through the app schema.
159    ///
160    /// # Errors
161    ///
162    /// A lens that resolves to no single column.
163    #[cfg(test)]
164    pub(crate) fn resolve<M, T>(&self, path: Path<M, T>) -> Result<LeafField, DeclarationErrorKind>
165    where
166        M: toasty::schema::Model,
167    {
168        let path = ModelPath::of(&path);
169        if path.is_embedded() {
170            self.resolve_embedded(&path, std::any::type_name::<M>())
171        } else {
172            model::single_field::<M>(&path)
173        }
174    }
175
176    /// Resolve an embedded path through the app schema; without one, the path is a traversal the
177    /// single-field rule refuses, since binding its first segment misbinds in release.
178    fn resolve_embedded(
179        &self,
180        path: &ModelPath,
181        model: &'static str,
182    ) -> Result<LeafField, DeclarationErrorKind> {
183        model::embedded_leaf(self.schema.as_ref(), path, model)
184    }
185
186    /// The discriminant column and variants of the embedded **enum** at `path`, or `None` when
187    /// `path` names anything else or no app schema backs the resolver.
188    ///
189    /// `path` addresses the embedded field itself (`Post::fields().publication()`), not one of
190    /// its leaves.
191    pub(crate) fn resolve_enum<M, T>(&self, path: Path<M, T>) -> Option<EnumShape> {
192        model::embedded_enum(self.schema.as_ref()?, &ModelPath::of(&path))
193    }
194}
195
196#[cfg(test)]
197mod tests;