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;