Skip to main content

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;