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