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;