tablo_core/schema/lenses.rs
1//! Resolves typed Toasty paths to form keys and field metadata.
2//!
3//! A path resolves through the app schema in scope: the one the panel installs while it calls a
4//! resource's declarations at mount, or the one [`declare`] installs. Outside a scope a path
5//! resolves against its model alone, which binds a single field and refuses an embedded leaf.
6
7use std::{cell::RefCell, sync::Arc};
8
9use toasty::stmt::Path;
10use toasty_core::stmt::PathRoot;
11use topcoat::context::Cx;
12
13use crate::{DeclarationErrorKind, naming::capitalize};
14
15thread_local! {
16 static SCHEMA: RefCell<Option<Arc<toasty_core::Schema>>> = const { RefCell::new(None) };
17}
18
19/// Run `declarations` with `db`'s app schema in scope, so every path they bind resolves an
20/// embedded leaf to its flattened storage column.
21///
22/// Mounting a panel ([`RouterBuilderPanelExt::panel`](crate::RouterBuilderPanelExt::panel)) calls
23/// each resource's declarations inside such a scope. Call this to build a declaration outside a
24/// panel, such as a [`Schema`](crate::Schema) a custom page renders:
25///
26/// ```rust,no_run
27/// # #[derive(Debug, Clone, toasty::Model)]
28/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
29/// # #[derive(Debug, Clone, tablo_core::RecordForm)]
30/// # #[form(model = Post)]
31/// # struct PostForm { title: String }
32/// # use tablo_core::RecordForm;
33/// # let db: toasty::Db = todo!();
34/// let form = tablo_core::declare(&db, PostForm::schema);
35/// # let _ = form;
36/// ```
37pub fn declare<T>(db: &toasty::Db, declarations: impl FnOnce() -> T) -> T {
38 declare_with(Some(db.schema().clone()), declarations)
39}
40
41/// Run `declarations` with `schema` in scope, restoring the enclosing scope after, a panic
42/// included.
43pub(crate) fn declare_with<T>(
44 schema: Option<Arc<toasty_core::Schema>>,
45 declarations: impl FnOnce() -> T,
46) -> T {
47 struct Restore(Option<Arc<toasty_core::Schema>>);
48 impl Drop for Restore {
49 fn drop(&mut self) {
50 SCHEMA.with(|scope| *scope.borrow_mut() = self.0.take());
51 }
52 }
53 let _restore = Restore(SCHEMA.with(|scope| scope.replace(schema)));
54 declarations()
55}
56
57/// The app schema of the `Db` a request carries, if any.
58pub(crate) fn schema_of(cx: &Cx) -> Option<Arc<toasty_core::Schema>> {
59 topcoat::context::try_app_context::<toasty::Db>(cx).map(|db| db.schema().clone())
60}
61
62/// A path resolved to the form key it binds and the metadata a field defaults from.
63///
64/// A column defaults `required` from its nullability and `unique` from a single- or multi-field
65/// unique index it belongs to. An embedded leaf is never required or unique by default: only the
66/// matching enum variant writes a variant payload column, so the resolver reports every embedded
67/// leaf nullable. That is the binding default, not a storage fact — the flattened column of a
68/// required embedded struct is `NOT NULL` — so a field opts in with `.required()`.
69pub(crate) struct ResolvedLens<M, T> {
70 pub(crate) path: Path<M, T>,
71 pub(crate) name: String,
72 pub(crate) label: String,
73 pub(crate) nullable: bool,
74 pub(crate) unique: bool,
75 /// Why the path binds no column, when it does not: the builder records this, and
76 /// [`RouterBuilderPanelExt::panel`](crate::RouterBuilderPanelExt::panel) reports it.
77 pub(crate) misdeclared: Option<DeclarationErrorKind>,
78}
79
80impl<M, T> ResolvedLens<M, T>
81where
82 M: toasty::schema::Model,
83{
84 /// Resolve `path` through the app schema in scope.
85 ///
86 /// A refused path keeps a placeholder name spelling its steps, so it reads as no other
87 /// field's duplicate.
88 pub(crate) fn of(path: impl Into<Path<M, T>>) -> Self {
89 let path = path.into();
90 match FieldResolver::current().resolve(path.clone()) {
91 Ok(leaf) => Self {
92 path,
93 name: leaf.name,
94 label: leaf.label,
95 nullable: leaf.nullable,
96 unique: leaf.unique,
97 misdeclared: None,
98 },
99 Err(error) => {
100 let core_path: toasty_core::stmt::Path = path.clone().into();
101 Self {
102 path,
103 name: format!("{:?}", core_path.projection.as_slice()),
104 label: String::new(),
105 nullable: true,
106 unique: false,
107 misdeclared: Some(error),
108 }
109 }
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 ResolvedLens::of(path).name
121}
122
123/// The storage column a lens resolves to, plus the metadata field
124/// constructors need.
125#[derive(Debug, Clone)]
126pub(crate) struct LeafField {
127 /// The flattened storage column (`application_seo_title`), or the field's
128 /// own name for a single-segment path.
129 pub(crate) name: String,
130 pub(crate) label: String,
131 /// Whether the leaf is nullable. Every leaf the walk resolves reports
132 /// `true`: an embedded leaf is never required by default, because only the
133 /// matching enum variant writes a variant payload's column. That is this
134 /// walk's policy rather than the compiled column's own nullability — the
135 /// flattened column of a required embedded struct is `NOT NULL` — so it is
136 /// the *binding* default, not a storage fact.
137 pub(crate) nullable: bool,
138 /// Whether a unique index covers the column. Always `false` for an
139 /// embedded leaf, which declares no index of its own.
140 pub(crate) unique: bool,
141}
142
143/// Walks a lens path against the app schema, resolving embedded steps.
144pub(crate) struct FieldResolver {
145 /// The **compiled** schema: its `.app` half resolves the path, its
146 /// `.mapping` half names the column, its `.db` half holds the name.
147 ///
148 /// `Db::schema()` gives all three halves the walk needs: `.app` carries the embedded models
149 /// the owned `Model::schema()` cannot see, `.mapping` records which physical column each
150 /// field resolves to, and `.db` holds the physical table and column names. So an embedded
151 /// lens binds without opening a connection.
152 schema: Option<Arc<toasty_core::Schema>>,
153}
154
155impl FieldResolver {
156 pub(crate) fn new(schema: Option<Arc<toasty_core::Schema>>) -> Self {
157 Self { schema }
158 }
159
160 /// The resolver over the app schema in scope ([`declare`]).
161 pub(crate) fn current() -> Self {
162 Self::new(SCHEMA.with(|scope| scope.borrow().clone()))
163 }
164
165 /// Whether an app schema is in scope at all.
166 ///
167 /// A leaf resolution falls back to the single-segment rule without one; an
168 /// embedded enum has no fallback — its discriminant column and variants
169 /// come from the schema — so its entry point says so rather than reporting
170 /// a traversal-lens error.
171 pub(crate) fn has_schema(&self) -> bool {
172 self.schema.is_some()
173 }
174
175 /// Resolve a lens to its leaf field.
176 ///
177 /// With a schema this walks the whole projection, so an embedded step lands
178 /// on the flattened column. Without one it falls back to the single-segment
179 /// rule and refuses a traversal lens, because silently binding the first
180 /// segment misbinds in release.
181 ///
182 /// # Errors
183 ///
184 /// A lens that resolves to no single column.
185 pub(crate) fn resolve<M, T>(&self, path: Path<M, T>) -> Result<LeafField, DeclarationErrorKind>
186 where
187 M: toasty::schema::Model,
188 {
189 let model = M::schema();
190 let core_path: toasty_core::stmt::Path = path.into();
191 let segments = core_path.projection.as_slice().len();
192 // A variant root is an embedded-enum payload accessor *whatever* its
193 // projection length: the generated accessor rebases onto the variant, so
194 // the steps are variant-local and a single-step projection is the
195 // payload field. Only a model root with one step is a plain field.
196 let is_embedded_path = segments > 1 || matches!(core_path.root, PathRoot::Variant { .. });
197 if is_embedded_path && let Some(schema) = self.schema.as_deref() {
198 return Self::walk_embedded(schema, &core_path).ok_or_else(|| {
199 DeclarationErrorKind::UnresolvedLens {
200 model: std::any::type_name::<M>(),
201 steps: core_path.projection.as_slice().to_vec(),
202 }
203 });
204 }
205 // No schema: the single-segment rule is all an owned `app::Model` can
206 // answer, so resolve the first step against `ModelRoot.fields` and let
207 // `single_segment` refuse a traversal lens.
208 let idx = single_segment(&core_path)?;
209 let field = model
210 .as_root_unwrap()
211 .fields
212 .get(idx)
213 .cloned()
214 .unwrap_or_else(|| {
215 panic!(
216 "field index {idx} out of bounds for {}",
217 std::any::type_name::<M>()
218 )
219 });
220 Ok(LeafField {
221 name: field.name.app_unwrap().to_string(),
222 label: lens_label(&field),
223 nullable: field.nullable(),
224 unique: lens_field_unique(&field, model.as_root_unwrap()),
225 })
226 }
227
228 /// Walk a lens path to the physical column it names.
229 ///
230 /// Two sources, each authoritative for one thing:
231 ///
232 /// - the **app schema** drives the traversal, because it is the only side that knows a field is
233 /// a `#[document]` — a *primitive* whose storage is a model, so its inner fields collapse
234 /// into the one column named after it;
235 /// - the **compiled mapping** names the column. `Db::schema().mapping` records, per model,
236 /// which column every field resolves to — flattened embedded structs, enum discriminant and
237 /// payload columns, shared columns, and a document's single column alike — so this reads the
238 /// name off `db::Table` rather than re-deriving Toasty's naming rules.
239 ///
240 /// Two roots reach here: a **model** root for a plain path (`seo.title`), and
241 /// a **variant** root for an enum payload accessor
242 /// (`media.video().poster().url()`), where the generated accessor rebases
243 /// onto the variant and the projection's steps are variant-local.
244 ///
245 /// Only embedded steps are followed. A relation hop yields `None`: this
246 /// exists for embedded binding, and binding anything else would reintroduce
247 /// the misbind the single-segment rule guards against.
248 fn walk_embedded(
249 schema: &toasty_core::Schema,
250 path: &toasty_core::stmt::Path,
251 ) -> Option<LeafField> {
252 match &path.root {
253 PathRoot::Model(id) => {
254 let root = schema.app.get_model(*id)?.as_root()?;
255 let model = schema.mapping.models.get(&root.id)?;
256 descend(
257 schema,
258 &root.fields,
259 &model.fields,
260 path.projection.as_slice(),
261 )
262 }
263 // An enum payload: resolve the parent path (it ends at the enum
264 // field), then descend into the variant the accessor selected.
265 PathRoot::Variant { parent, variant_id } => {
266 let root = schema
267 .app
268 .get_model(parent.root.as_model_unwrap())?
269 .as_root()?;
270 let model = schema.mapping.models.get(&root.id)?;
271 let parent_steps = parent.projection.as_slice();
272 // The app side supplies the enum's payload field list, the
273 // mapping side its per-variant column mappings.
274 let app_field = app_field_at(schema, &root.fields, parent_steps)?;
275 let Some(toasty::schema::app::Model::EmbeddedEnum(e)) =
276 app_embedded(schema, app_field)
277 else {
278 return None;
279 };
280 let MappingField::Enum(me) = mapping_field_at(&model.fields, parent_steps)? else {
281 return None;
282 };
283 let variant = me.variants.get(variant_id.index)?;
284 let payloads: Vec<_> = e.variant_fields(variant_id.index).iter().collect();
285 descend(
286 schema,
287 &payloads,
288 &variant.fields,
289 path.projection.as_slice(),
290 )
291 }
292 }
293 }
294
295 /// The discriminant column and variants of the embedded **enum** at
296 /// `path`, or `None` when `path` names anything else.
297 ///
298 /// [`Self::resolve`] answers "which column does this one leaf occupy"; this
299 /// answers "which column carries the variant, and what does each variant
300 /// store there", which is what an embedded enum's variant control needs.
301 /// Its payload leaves resolve one at a time through [`Self::resolve`].
302 ///
303 /// `path` addresses the embedded field itself (`Post::fields().publication()`),
304 /// not one of its leaves: a variant-rooted path names a *variant* of a
305 /// value, not the value, and yields `None`.
306 pub(crate) fn resolve_enum<M, T>(&self, path: Path<M, T>) -> Option<EnumShape>
307 where
308 M: toasty::schema::Model,
309 {
310 let schema = self.schema.as_deref()?;
311 let core_path: toasty_core::stmt::Path = path.into();
312 let PathRoot::Model(id) = core_path.root else {
313 return None;
314 };
315 let root = schema.app.get_model(id)?.as_root()?;
316 let mapping = schema.mapping.models.get(&root.id)?;
317 let steps = core_path.projection.as_slice();
318 let app_field = app_field_at(schema, &root.fields, steps)?;
319 let (toasty::schema::app::Model::EmbeddedEnum(e), MappingField::Enum(me)) = (
320 app_embedded(schema, app_field)?,
321 mapping_field_at(&mapping.fields, steps)?,
322 ) else {
323 return None;
324 };
325 let discriminant =
326 column_of(schema, &MappingField::Primitive(me.discriminant.clone()))?.name;
327 let variants = e
328 .variants
329 .iter()
330 .map(|v| {
331 crate::toasty_compat::value_text(&v.discriminant)
332 .map(|value| (value, capitalize(&v.name.snake_case())))
333 })
334 .collect::<Option<Vec<_>>>()?;
335 Some(EnumShape {
336 discriminant,
337 variants,
338 })
339 }
340}
341
342/// An embedded enum's discriminant column and its variants.
343///
344/// A variant carries two things, and they are not interchangeable: the
345/// **value** its discriminant column stores (`2`, or a string discriminant's
346/// own text), which is the form's transport, and the **name** a person reads
347/// (`Published`), which is the variant control's label. The name is humanized
348/// into sentence case (`In progress`), as a derived field label is; it is a
349/// label, never a handle, since the normalization is lossy (`OK` reads `Ok`).
350/// Code addresses variants by **declaration index**, the handle the schema
351/// itself uses (`VariantId { index }`).
352#[derive(Debug, Clone)]
353pub(crate) struct EnumShape {
354 /// The discriminant column the form carries (`publication`).
355 pub(crate) discriminant: String,
356 /// Each variant's stored value and name, in declaration order.
357 pub(crate) variants: Vec<(String, String)>,
358}
359
360/// A `mapping::Field`, aliased so the traversal signatures stay readable.
361type MappingField = toasty_core::schema::mapping::Field;
362
363/// Walk the path down to the column that stores it.
364///
365/// `app_fields` and `mapping_fields` are indexed by the same field index, so
366/// every step reads both: the app side decides *whether to descend*, the mapping
367/// side names *the column*. Neither alone is enough — the mapping cannot say a
368/// field is a `#[document]`, and the app schema cannot say which column a leaf
369/// occupies without re-deriving Toasty's naming.
370fn descend<F>(
371 schema: &toasty_core::Schema,
372 app_fields: &[F],
373 mapping_fields: &[MappingField],
374 steps: &[usize],
375) -> Option<LeafField>
376where
377 F: std::borrow::Borrow<toasty::schema::app::Field>,
378{
379 let (first, rest) = steps.split_first()?;
380 let app_field: &toasty::schema::app::Field = app_fields.get(*first)?.borrow();
381 let mapping_field = mapping_fields.get(*first)?;
382 // A `#[document]`'s inner fields share its one column, so reaching the
383 // document is reaching the leaf, however many steps remain.
384 if rest.is_empty() || is_document(app_field) {
385 return column_of(schema, mapping_field);
386 }
387 let toasty::schema::app::FieldTy::Embedded(embedded) = &app_field.ty else {
388 // A relation hop, or a primitive with steps left over: not an embedded
389 // leaf either way.
390 return None;
391 };
392 match schema.app.get_model(embedded.target)? {
393 toasty::schema::app::Model::EmbeddedStruct(e) => {
394 let MappingField::Struct(ms) = mapping_field else {
395 return None;
396 };
397 descend(schema, &e.fields, &ms.fields, rest)
398 }
399 toasty::schema::app::Model::EmbeddedEnum(e) => {
400 let MappingField::Enum(me) = mapping_field else {
401 return None;
402 };
403 // A variant consumes two steps: the variant index, then the field.
404 let (variant_index, tail) = rest.split_first()?;
405 let variant = me.variants.get(*variant_index)?;
406 let payloads: Vec<_> = e.variant_fields(*variant_index).iter().collect();
407 descend(schema, &payloads, &variant.fields, tail)
408 }
409 _ => None,
410 }
411}
412
413/// The column a single mapping field occupies, if it is a leaf.
414fn column_of(schema: &toasty_core::Schema, field: &MappingField) -> Option<LeafField> {
415 let MappingField::Primitive(p) = field else {
416 // A path stopping on a struct or enum names no single column, and a
417 // relation stores none.
418 return None;
419 };
420 let column = &schema.db.tables[p.column.table.0].columns[p.column.index];
421 Some(LeafField {
422 name: column.name.clone(),
423 label: capitalize(&column.name.replace('_', " ")),
424 // Reported nullable by policy, not read off the column: an embedded
425 // leaf is never required by default, because only the matching enum
426 // variant writes a variant payload's column. The compiled column can be
427 // `NOT NULL` — an embedded struct's flattened column is.
428 nullable: true,
429 unique: false,
430 })
431}
432
433/// Whether `field` is a `#[document]`: a *primitive* whose storage is a model,
434/// so its inner fields collapse into the one column named after it.
435fn is_document(field: &toasty::schema::app::Field) -> bool {
436 matches!(
437 &field.ty,
438 toasty::schema::app::FieldTy::Primitive(p)
439 if matches!(p.ty, toasty_core::stmt::Type::Model(_))
440 )
441}
442
443/// The embedded model an app field targets, if it is embedded.
444fn app_embedded<'a>(
445 schema: &'a toasty_core::Schema,
446 field: &toasty::schema::app::Field,
447) -> Option<&'a toasty::schema::app::Model> {
448 let toasty::schema::app::FieldTy::Embedded(embedded) = &field.ty else {
449 return None;
450 };
451 schema.app.get_model(embedded.target)
452}
453
454/// The app-level field `steps` reaches, used by the variant root to find the
455/// enum's payload list before descending.
456///
457/// Recurses through embedded structs: the variant root's parent path can walk
458/// through them (an embedded struct holding the enum), not just one step.
459fn app_field_at<'a>(
460 schema: &'a toasty_core::Schema,
461 fields: &'a [toasty::schema::app::Field],
462 steps: &[usize],
463) -> Option<&'a toasty::schema::app::Field> {
464 let (first, rest) = steps.split_first()?;
465 let field = fields.get(*first)?;
466 if rest.is_empty() {
467 return Some(field);
468 }
469 match app_embedded(schema, field)? {
470 toasty::schema::app::Model::EmbeddedStruct(e) => app_field_at(schema, &e.fields, rest),
471 // An enum nested in the parent path consumes two steps per level: the
472 // variant index, then a variant-local field index. `variant_fields`
473 // returns the variant's own slice of the enum's global field list
474 // (upstream 7ff180db), so the second step indexes it directly — the
475 // generated accessor's index is variant-local, which is the same field
476 // either way.
477 //
478 // The variant step is bounds-checked here first: `variant_fields`
479 // indexes `variants[i]` and panics out of range, and this walk answers
480 // `None` for a path it cannot resolve (a wrong lens must not abort a
481 // request).
482 toasty::schema::app::Model::EmbeddedEnum(e) => {
483 let (variant, tail) = rest.split_first()?;
484 e.variants.get(*variant)?;
485 let field = e.variant_fields(*variant).get(*tail.first()?)?;
486 if tail.len() == 1 {
487 Some(field)
488 } else {
489 app_field_at(schema, std::slice::from_ref(field), &tail[1..])
490 }
491 }
492 toasty::schema::app::Model::Root(_) => None,
493 }
494}
495
496/// The mapping field `steps` reaches, used by the variant root to find the
497/// enum's per-variant mappings before descending.
498fn mapping_field_at<'a>(fields: &'a [MappingField], steps: &[usize]) -> Option<&'a MappingField> {
499 let (first, rest) = steps.split_first()?;
500 let field = fields.get(*first)?;
501 if rest.is_empty() {
502 return Some(field);
503 }
504 match field {
505 MappingField::Struct(s) => mapping_field_at(&s.fields, rest),
506 MappingField::Enum(e) => {
507 let (variant, tail) = rest.split_first()?;
508 mapping_field_at(&e.variants.get(*variant)?.fields, tail)
509 }
510 _ => None,
511 }
512}
513
514/// Resolve a typed lens to the app-level [`Field`] behind it.
515///
516/// The single walk over `Path → toasty_core::stmt::Path → projection`, reading
517/// off the model's built schema (upstream issues #114/#183). Callers read
518/// name, label, nullability, storage name, `FieldTy`, `auto`, and `constraints`
519/// off the result; uniqueness is the one property a `Field` cannot answer, so
520/// it has [`lens_field_unique`] of its own.
521///
522/// `model` is the owning `Model::schema()`, passed in so one form-input
523/// construction resolves the schema once (never per row) and shares it with
524/// [`lens_field_unique`]. The returned `Field` is owned because
525/// `Model::schema()` builds the model by value with no cache, so no borrow of
526/// it can escape — but only the one field is cloned.
527///
528/// Traversal lenses are refused: a multi-step path has no single
529/// field name, and silently binding its first segment misbinds in release.
530/// Use [`FieldResolver`] to bind an embedded path instead.
531///
532/// # Errors
533///
534/// A traversal lens.
535pub(crate) fn lens_field<M, T>(
536 path: Path<M, T>,
537 model: &toasty::schema::app::Model,
538) -> Result<toasty::schema::app::Field, DeclarationErrorKind>
539where
540 M: toasty::schema::Model,
541{
542 let core_path: toasty_core::stmt::Path = path.into();
543 let idx = single_segment(&core_path)?;
544 Ok(model
545 .as_root_unwrap()
546 .fields
547 .get(idx)
548 .cloned()
549 .unwrap_or_else(|| {
550 panic!(
551 "field index {idx} out of bounds for {}",
552 std::any::type_name::<M>()
553 )
554 }))
555}
556
557/// The capitalized label for a field's app-level name.
558pub(crate) fn lens_label(field: &toasty::schema::app::Field) -> String {
559 capitalize(field.name.app_unwrap())
560}
561
562/// Whether `field` is backed by a unique index it participates in.
563///
564/// Uniqueness is not a property of a `Field`: Toasty stores it on the model's
565/// index list, so this needs the owning `ModelRoot` too. The primary key is
566/// excluded — a key column is unique by construction, not by a declared
567/// constraint.
568///
569/// A **composite** unique index counts too: `#[unique(tenant_id, email)]` is how
570/// a tenant-scoped resource expresses "unique within the tenant", and the
571/// app-side pre-check has to recognise it or the field's `unique()` declaration
572/// is silently dead. Recognizing the index is not checking it exactly: the
573/// pre-check probes inside the tenant-scoped query's scope, so it enforces the
574/// constraint only when that scope matches the index's remaining components.
575/// An index with components outside the scope stays a gap, and it is not
576/// checkable here because a query's filters are not introspectable; see
577/// `Resource::query`'s note and upstream #117.
578///
579/// This reports declared schema uniqueness, not a global guarantee: SQL permits
580/// multiple `NULL`s in a unique index, and enum-variant columns are
581/// storage-nullable, so a nullable unique field can still repeat.
582pub(crate) fn lens_field_unique(
583 field: &toasty::schema::app::Field,
584 model: &toasty::schema::app::ModelRoot,
585) -> bool {
586 model.indices.iter().any(|index| {
587 index.unique && !index.primary_key && index.fields.iter().any(|f| f.field == field.id)
588 })
589}
590
591/// The one field a lens path addresses.
592///
593/// A traversal lens (relation hops, embedded steps) has no single field name,
594/// nullability, or uniqueness — silently binding its first segment misbinds in
595/// release, so every lens helper refuses a multi-segment path instead.
596///
597/// # Errors
598///
599/// A path of any other length than one.
600pub(crate) fn single_segment(
601 path: &toasty_core::stmt::Path,
602) -> Result<usize, DeclarationErrorKind> {
603 match path.projection.as_slice() {
604 [index] => Ok(*index),
605 steps => Err(DeclarationErrorKind::TraversalLens { steps: steps.len() }),
606 }
607}
608
609#[cfg(test)]
610mod tests;