Skip to main content

icydb_core/db/schema/
describe.rs

1//! Module: db::schema::describe
2//! Responsibility: deterministic entity-schema introspection DTOs for runtime consumers.
3//! Does not own: query planning, execution routing, or relation enforcement semantics.
4//! Boundary: projects generated or accepted schema metadata into stable describe surfaces.
5
6use crate::{
7    db::{
8        relation::{
9            RelationDescriptor, RelationDescriptorCardinality, relation_descriptors_for_model_iter,
10        },
11        schema::{
12            AcceptedSchemaSnapshot, PersistedFieldKind, PersistedIndexKeyItemSnapshot,
13            PersistedIndexKeySnapshot, PersistedNestedLeafSnapshot, PersistedRelationStrength,
14            SchemaFieldDefault, SchemaFieldSlot, field_type_from_persisted_kind,
15        },
16    },
17    model::{
18        entity::EntityModel,
19        field::{FieldDatabaseDefault, FieldKind, FieldModel, RelationStrength},
20    },
21};
22use candid::CandidType;
23use serde::Deserialize;
24use sha2::{Digest, Sha256};
25use std::fmt::Write;
26
27const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
28
29#[cfg_attr(
30    doc,
31    doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
32)]
33#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
34pub struct EntitySchemaDescription {
35    pub(crate) entity_path: String,
36    pub(crate) entity_name: String,
37    pub(crate) primary_key: String,
38    pub(crate) fields: Vec<EntityFieldDescription>,
39    pub(crate) indexes: Vec<EntityIndexDescription>,
40    pub(crate) relations: Vec<EntityRelationDescription>,
41}
42
43#[cfg_attr(
44    doc,
45    doc = "EntitySchemaCheckDescription\n\nGenerated-vs-accepted schema description payload for one entity."
46)]
47#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
48pub struct EntitySchemaCheckDescription {
49    pub(crate) generated: EntitySchemaDescription,
50    pub(crate) accepted: EntitySchemaDescription,
51}
52
53impl EntitySchemaCheckDescription {
54    /// Construct one generated-vs-accepted schema check payload.
55    #[must_use]
56    pub const fn new(
57        generated: EntitySchemaDescription,
58        accepted: EntitySchemaDescription,
59    ) -> Self {
60        Self {
61            generated,
62            accepted,
63        }
64    }
65
66    /// Borrow the generated schema proposal description.
67    #[must_use]
68    pub const fn generated(&self) -> &EntitySchemaDescription {
69        &self.generated
70    }
71
72    /// Borrow the accepted live-schema description.
73    #[must_use]
74    pub const fn accepted(&self) -> &EntitySchemaDescription {
75        &self.accepted
76    }
77}
78
79impl EntitySchemaDescription {
80    /// Construct one entity schema description payload.
81    #[must_use]
82    pub const fn new(
83        entity_path: String,
84        entity_name: String,
85        primary_key: String,
86        fields: Vec<EntityFieldDescription>,
87        indexes: Vec<EntityIndexDescription>,
88        relations: Vec<EntityRelationDescription>,
89    ) -> Self {
90        Self {
91            entity_path,
92            entity_name,
93            primary_key,
94            fields,
95            indexes,
96            relations,
97        }
98    }
99
100    /// Borrow the entity module path.
101    #[must_use]
102    pub const fn entity_path(&self) -> &str {
103        self.entity_path.as_str()
104    }
105
106    /// Borrow the entity display name.
107    #[must_use]
108    pub const fn entity_name(&self) -> &str {
109        self.entity_name.as_str()
110    }
111
112    /// Borrow the primary-key field name.
113    #[must_use]
114    pub const fn primary_key(&self) -> &str {
115        self.primary_key.as_str()
116    }
117
118    /// Borrow field description entries.
119    #[must_use]
120    pub const fn fields(&self) -> &[EntityFieldDescription] {
121        self.fields.as_slice()
122    }
123
124    /// Borrow index description entries.
125    #[must_use]
126    pub const fn indexes(&self) -> &[EntityIndexDescription] {
127        self.indexes.as_slice()
128    }
129
130    /// Borrow relation description entries.
131    #[must_use]
132    pub const fn relations(&self) -> &[EntityRelationDescription] {
133        self.relations.as_slice()
134    }
135}
136
137#[cfg_attr(
138    doc,
139    doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
140)]
141#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
142pub struct EntityFieldDescription {
143    pub(crate) name: String,
144    pub(crate) slot: u16,
145    pub(crate) kind: String,
146    pub(crate) primary_key: bool,
147    pub(crate) queryable: bool,
148    pub(crate) origin: String,
149}
150
151impl EntityFieldDescription {
152    /// Construct one field description entry.
153    #[must_use]
154    pub const fn new(
155        name: String,
156        slot: Option<u16>,
157        kind: String,
158        primary_key: bool,
159        queryable: bool,
160        origin: String,
161    ) -> Self {
162        let slot = match slot {
163            Some(slot) => slot,
164            None => ENTITY_FIELD_DESCRIPTION_NO_SLOT,
165        };
166
167        Self {
168            name,
169            slot,
170            kind,
171            primary_key,
172            queryable,
173            origin,
174        }
175    }
176
177    /// Borrow the field name.
178    #[must_use]
179    pub const fn name(&self) -> &str {
180        self.name.as_str()
181    }
182
183    /// Return the physical row slot for top-level fields.
184    #[must_use]
185    pub const fn slot(&self) -> Option<u16> {
186        if self.slot == ENTITY_FIELD_DESCRIPTION_NO_SLOT {
187            None
188        } else {
189            Some(self.slot)
190        }
191    }
192
193    /// Borrow the rendered field kind label.
194    #[must_use]
195    pub const fn kind(&self) -> &str {
196        self.kind.as_str()
197    }
198
199    /// Return whether this field is the primary key.
200    #[must_use]
201    pub const fn primary_key(&self) -> bool {
202        self.primary_key
203    }
204
205    /// Return whether this field is queryable.
206    #[must_use]
207    pub const fn queryable(&self) -> bool {
208        self.queryable
209    }
210
211    /// Borrow the accepted/generated field origin label.
212    #[must_use]
213    pub const fn origin(&self) -> &str {
214        self.origin.as_str()
215    }
216}
217
218#[cfg_attr(
219    doc,
220    doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
221)]
222#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
223pub struct EntityIndexDescription {
224    pub(crate) name: String,
225    pub(crate) unique: bool,
226    pub(crate) fields: Vec<String>,
227    pub(crate) origin: String,
228}
229
230impl EntityIndexDescription {
231    /// Construct one index description entry.
232    #[must_use]
233    pub const fn new(name: String, unique: bool, fields: Vec<String>, origin: String) -> Self {
234        Self {
235            name,
236            unique,
237            fields,
238            origin,
239        }
240    }
241
242    /// Borrow the index name.
243    #[must_use]
244    pub const fn name(&self) -> &str {
245        self.name.as_str()
246    }
247
248    /// Return whether the index enforces uniqueness.
249    #[must_use]
250    pub const fn unique(&self) -> bool {
251        self.unique
252    }
253
254    /// Borrow ordered index field names.
255    #[must_use]
256    pub const fn fields(&self) -> &[String] {
257        self.fields.as_slice()
258    }
259
260    /// Borrow the accepted index origin label.
261    #[must_use]
262    pub const fn origin(&self) -> &str {
263        self.origin.as_str()
264    }
265}
266
267#[cfg_attr(
268    doc,
269    doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
270)]
271#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
272pub struct EntityRelationDescription {
273    pub(crate) field: String,
274    pub(crate) target_path: String,
275    pub(crate) target_entity_name: String,
276    pub(crate) target_store_path: String,
277    pub(crate) strength: EntityRelationStrength,
278    pub(crate) cardinality: EntityRelationCardinality,
279}
280
281impl EntityRelationDescription {
282    /// Construct one relation description entry.
283    #[must_use]
284    pub const fn new(
285        field: String,
286        target_path: String,
287        target_entity_name: String,
288        target_store_path: String,
289        strength: EntityRelationStrength,
290        cardinality: EntityRelationCardinality,
291    ) -> Self {
292        Self {
293            field,
294            target_path,
295            target_entity_name,
296            target_store_path,
297            strength,
298            cardinality,
299        }
300    }
301
302    /// Borrow the source relation field name.
303    #[must_use]
304    pub const fn field(&self) -> &str {
305        self.field.as_str()
306    }
307
308    /// Borrow the relation target path.
309    #[must_use]
310    pub const fn target_path(&self) -> &str {
311        self.target_path.as_str()
312    }
313
314    /// Borrow the relation target entity name.
315    #[must_use]
316    pub const fn target_entity_name(&self) -> &str {
317        self.target_entity_name.as_str()
318    }
319
320    /// Borrow the relation target store path.
321    #[must_use]
322    pub const fn target_store_path(&self) -> &str {
323        self.target_store_path.as_str()
324    }
325
326    /// Return relation strength.
327    #[must_use]
328    pub const fn strength(&self) -> EntityRelationStrength {
329        self.strength
330    }
331
332    /// Return relation cardinality.
333    #[must_use]
334    pub const fn cardinality(&self) -> EntityRelationCardinality {
335        self.cardinality
336    }
337}
338
339#[cfg_attr(doc, doc = "EntityRelationStrength\n\nDescribe relation strength.")]
340#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
341pub enum EntityRelationStrength {
342    Strong,
343    Weak,
344}
345
346#[cfg_attr(
347    doc,
348    doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
349)]
350#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
351pub enum EntityRelationCardinality {
352    Single,
353    List,
354    Set,
355}
356
357#[cfg_attr(
358    doc,
359    doc = "Build one stable entity-schema description from one runtime `EntityModel`."
360)]
361#[must_use]
362pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
363    let fields = describe_entity_fields(model);
364
365    describe_entity_model_with_parts(
366        model.path,
367        model.entity_name,
368        model.primary_key.name,
369        fields,
370        describe_entity_indexes_from_model(model),
371        model,
372    )
373}
374
375#[cfg_attr(
376    doc,
377    doc = "Build one entity-schema description using accepted persisted schema slot metadata."
378)]
379#[must_use]
380pub(in crate::db) fn describe_entity_model_with_persisted_schema(
381    model: &EntityModel,
382    schema: &AcceptedSchemaSnapshot,
383) -> EntitySchemaDescription {
384    let fields = describe_entity_fields_with_persisted_schema(schema);
385    let primary_key = schema
386        .primary_key_field_name()
387        .unwrap_or(model.primary_key.name);
388
389    describe_entity_model_with_parts(
390        schema.entity_path(),
391        schema.entity_name(),
392        primary_key,
393        fields,
394        describe_entity_indexes_with_persisted_schema(schema),
395        model,
396    )
397}
398
399// Assemble the common DESCRIBE payload once field rows have already been built.
400// This lets accepted-schema callers supply persisted field and index metadata
401// while relation descriptions remain generated-model owned for this phase.
402fn describe_entity_model_with_parts(
403    entity_path: &str,
404    entity_name: &str,
405    primary_key: &str,
406    fields: Vec<EntityFieldDescription>,
407    indexes: Vec<EntityIndexDescription>,
408    model: &EntityModel,
409) -> EntitySchemaDescription {
410    let relations = relation_descriptors_for_model_iter(model)
411        .map(relation_description_from_descriptor)
412        .collect();
413
414    EntitySchemaDescription::new(
415        entity_path.to_string(),
416        entity_name.to_string(),
417        primary_key.to_string(),
418        fields,
419        indexes,
420        relations,
421    )
422}
423
424fn describe_entity_indexes_from_model(model: &EntityModel) -> Vec<EntityIndexDescription> {
425    let mut indexes = Vec::with_capacity(model.indexes.len());
426    for index in model.indexes {
427        indexes.push(EntityIndexDescription::new(
428            index.name().to_string(),
429            index.is_unique(),
430            index
431                .fields()
432                .iter()
433                .map(|field| (*field).to_string())
434                .collect(),
435            "generated".to_string(),
436        ));
437    }
438
439    indexes
440}
441
442fn describe_entity_indexes_with_persisted_schema(
443    schema: &AcceptedSchemaSnapshot,
444) -> Vec<EntityIndexDescription> {
445    schema
446        .persisted_snapshot()
447        .indexes()
448        .iter()
449        .map(|index| {
450            EntityIndexDescription::new(
451                index.name().to_string(),
452                index.unique(),
453                describe_persisted_index_fields(index.key()),
454                if index.generated() {
455                    "generated".to_string()
456                } else {
457                    "ddl".to_string()
458                },
459            )
460        })
461        .collect()
462}
463
464fn describe_persisted_index_fields(key: &PersistedIndexKeySnapshot) -> Vec<String> {
465    match key {
466        PersistedIndexKeySnapshot::FieldPath(paths) => paths
467            .iter()
468            .map(|field_path| field_path.path().join("."))
469            .collect(),
470        PersistedIndexKeySnapshot::Items(items) => items
471            .iter()
472            .map(|item| match item {
473                PersistedIndexKeyItemSnapshot::FieldPath(field_path) => field_path.path().join("."),
474                PersistedIndexKeyItemSnapshot::Expression(expression) => {
475                    expression.canonical_text().to_string()
476                }
477            })
478            .collect(),
479    }
480}
481
482// Build the stable field-description subset once from one runtime model so
483// metadata surfaces that only need columns do not rebuild indexes and
484// relations through the heavier DESCRIBE payload path.
485#[must_use]
486pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
487    describe_entity_fields_with_slot_lookup(model, |slot, _field| {
488        Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
489    })
490}
491
492#[cfg_attr(
493    doc,
494    doc = "Build field descriptors using accepted persisted schema slot metadata."
495)]
496#[must_use]
497pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
498    schema: &AcceptedSchemaSnapshot,
499) -> Vec<EntityFieldDescription> {
500    let snapshot = schema.persisted_snapshot();
501    let mut fields = Vec::with_capacity(snapshot.fields().len());
502
503    // Accepted-schema describe surfaces must follow the stored schema payload,
504    // not the generated model's current field order.
505    for field in snapshot.fields() {
506        let primary_key = field.id() == snapshot.primary_key_field_id();
507        let slot = snapshot
508            .row_layout()
509            .slot_for_field(field.id())
510            .map(SchemaFieldSlot::get);
511        let mut kind = summarize_persisted_field_kind(field.kind());
512        write_schema_default_summary(&mut kind, field.default());
513        let metadata = DescribeFieldMetadata::new(
514            kind,
515            field_type_from_persisted_kind(field.kind())
516                .value_kind()
517                .is_queryable(),
518            field_origin_label(field.generated()),
519        );
520
521        push_described_field_row(&mut fields, field.name(), slot, primary_key, None, metadata);
522
523        if !field.nested_leaves().is_empty() {
524            describe_persisted_nested_leaves(
525                &mut fields,
526                field.nested_leaves(),
527                field_origin_label(field.generated()),
528            );
529        }
530    }
531
532    fields
533}
534
535// Build field descriptors with an injected top-level slot lookup. Generated
536// model introspection uses generated positions; live-schema introspection uses
537// accepted persisted row layout metadata while preserving nested-field behavior.
538fn describe_entity_fields_with_slot_lookup(
539    model: &EntityModel,
540    mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
541) -> Vec<EntityFieldDescription> {
542    let mut fields = Vec::with_capacity(model.fields.len());
543
544    for (slot, field) in model.fields.iter().enumerate() {
545        let primary_key = field.name == model.primary_key.name;
546        describe_field_recursive(
547            &mut fields,
548            field.name,
549            slot_for_field(slot, field),
550            field,
551            primary_key,
552            None,
553            None,
554        );
555    }
556
557    fields
558}
559
560///
561/// DescribeFieldMetadata
562///
563/// Field-description metadata selected before recursive field rendering.
564/// Accepted-schema metadata can override generated model facts for top-level
565/// fields and, when available, nested leaf rows.
566///
567
568struct DescribeFieldMetadata {
569    kind: String,
570    queryable: bool,
571    origin: String,
572}
573
574impl DescribeFieldMetadata {
575    // Build one metadata bundle from already-rendered field facts.
576    const fn new(kind: String, queryable: bool, origin: String) -> Self {
577        Self {
578            kind,
579            queryable,
580            origin,
581        }
582    }
583}
584
585// Add one generated field and any generated structured-record leaves so
586// DESCRIBE/SHOW COLUMNS expose the same nested rows SQL can project and filter.
587fn describe_field_recursive(
588    fields: &mut Vec<EntityFieldDescription>,
589    name: &str,
590    slot: Option<u16>,
591    field: &FieldModel,
592    primary_key: bool,
593    tree_prefix: Option<&'static str>,
594    metadata_override: Option<DescribeFieldMetadata>,
595) {
596    let metadata = metadata_override.unwrap_or_else(|| {
597        let mut kind = summarize_field_kind(&field.kind);
598        write_model_default_summary(&mut kind, field.database_default());
599
600        DescribeFieldMetadata::new(
601            kind,
602            field.kind.value_kind().is_queryable(),
603            "generated".to_string(),
604        )
605    });
606
607    push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
608    describe_generated_nested_fields(fields, field.nested_fields());
609}
610
611// Add one already-resolved field row to the stable describe DTO list. The
612// caller owns where metadata came from: generated model or accepted schema.
613fn push_described_field_row(
614    fields: &mut Vec<EntityFieldDescription>,
615    name: &str,
616    slot: Option<u16>,
617    primary_key: bool,
618    tree_prefix: Option<&'static str>,
619    metadata: DescribeFieldMetadata,
620) {
621    // Nested field rows keep a compact tree marker so table-oriented describe
622    // output scans as a hierarchy without assigning nested leaves row slots.
623    let display_name = if let Some(prefix) = tree_prefix {
624        format!("{prefix}{name}")
625    } else {
626        name.to_string()
627    };
628
629    fields.push(EntityFieldDescription::new(
630        display_name,
631        slot,
632        metadata.kind,
633        primary_key,
634        metadata.queryable,
635        metadata.origin,
636    ));
637}
638
639// Render generated nested field metadata recursively. Generated and accepted
640// top-level describe paths both use this fallback when no accepted nested leaf
641// descriptors are available yet.
642fn describe_generated_nested_fields(
643    fields: &mut Vec<EntityFieldDescription>,
644    nested_fields: &[FieldModel],
645) {
646    for (index, nested) in nested_fields.iter().enumerate() {
647        let prefix = if index + 1 == nested_fields.len() {
648            "└─ "
649        } else {
650            "├─ "
651        };
652        describe_field_recursive(
653            fields,
654            nested.name(),
655            None,
656            nested,
657            false,
658            Some(prefix),
659            None,
660        );
661    }
662}
663
664// Render accepted nested leaf descriptors. Nested leaves do not own physical
665// row slots, so they always appear with the no-slot sentinel in the Candid DTO.
666fn describe_persisted_nested_leaves(
667    fields: &mut Vec<EntityFieldDescription>,
668    nested_leaves: &[PersistedNestedLeafSnapshot],
669    origin: String,
670) {
671    for (index, leaf) in nested_leaves.iter().enumerate() {
672        let prefix = if index + 1 == nested_leaves.len() {
673            "└─ "
674        } else {
675            "├─ "
676        };
677        let name = leaf.path().last().map_or("", String::as_str);
678        let metadata = DescribeFieldMetadata::new(
679            summarize_persisted_field_kind(leaf.kind()),
680            field_type_from_persisted_kind(leaf.kind())
681                .value_kind()
682                .is_queryable(),
683            origin.clone(),
684        );
685
686        push_described_field_row(fields, name, None, false, Some(prefix), metadata);
687    }
688}
689
690fn field_origin_label(generated: bool) -> String {
691    if generated {
692        "generated".to_string()
693    } else {
694        "ddl".to_string()
695    }
696}
697
698// Project the relation-owned descriptor into the stable describe DTO surface.
699fn relation_description_from_descriptor(
700    descriptor: RelationDescriptor,
701) -> EntityRelationDescription {
702    let strength = match descriptor.strength() {
703        RelationStrength::Strong => EntityRelationStrength::Strong,
704        RelationStrength::Weak => EntityRelationStrength::Weak,
705    };
706
707    let cardinality = match descriptor.cardinality() {
708        RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
709        RelationDescriptorCardinality::List => EntityRelationCardinality::List,
710        RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
711    };
712
713    EntityRelationDescription::new(
714        descriptor.field_name().to_string(),
715        descriptor.target_path().to_string(),
716        descriptor.target_entity_name().to_string(),
717        descriptor.target_store_path().to_string(),
718        strength,
719        cardinality,
720    )
721}
722
723#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
724fn summarize_field_kind(kind: &FieldKind) -> String {
725    let mut out = String::new();
726    write_field_kind_summary(&mut out, kind);
727
728    out
729}
730
731// Stream one stable field-kind label directly into the output buffer so
732// describe/sql surfaces do not retain a large recursive `format!` family.
733fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
734    match kind {
735        FieldKind::Account => out.push_str("account"),
736        FieldKind::Blob { max_len } => {
737            write_length_bounded_field_kind_summary(out, "blob", *max_len);
738        }
739        FieldKind::Bool => out.push_str("bool"),
740        FieldKind::Date => out.push_str("date"),
741        FieldKind::Decimal { scale } => {
742            let _ = write!(out, "decimal(scale={scale})");
743        }
744        FieldKind::Duration => out.push_str("duration"),
745        FieldKind::Enum { path, .. } => {
746            out.push_str("enum(");
747            out.push_str(path);
748            out.push(')');
749        }
750        FieldKind::Float32 => out.push_str("float32"),
751        FieldKind::Float64 => out.push_str("float64"),
752        FieldKind::Int => out.push_str("int"),
753        FieldKind::Int128 => out.push_str("int128"),
754        FieldKind::IntBig => out.push_str("int_big"),
755        FieldKind::Principal => out.push_str("principal"),
756        FieldKind::Subaccount => out.push_str("subaccount"),
757        FieldKind::Text { max_len } => {
758            write_length_bounded_field_kind_summary(out, "text", *max_len);
759        }
760        FieldKind::Timestamp => out.push_str("timestamp"),
761        FieldKind::Nat => out.push_str("nat"),
762        FieldKind::Nat128 => out.push_str("nat128"),
763        FieldKind::NatBig => out.push_str("nat_big"),
764        FieldKind::Ulid => out.push_str("ulid"),
765        FieldKind::Unit => out.push_str("unit"),
766        FieldKind::Relation {
767            target_entity_name,
768            key_kind,
769            strength,
770            ..
771        } => {
772            out.push_str("relation(target=");
773            out.push_str(target_entity_name);
774            out.push_str(", key=");
775            write_field_kind_summary(out, key_kind);
776            out.push_str(", strength=");
777            out.push_str(summarize_relation_strength(*strength));
778            out.push(')');
779        }
780        FieldKind::List(inner) => {
781            out.push_str("list<");
782            write_field_kind_summary(out, inner);
783            out.push('>');
784        }
785        FieldKind::Set(inner) => {
786            out.push_str("set<");
787            write_field_kind_summary(out, inner);
788            out.push('>');
789        }
790        FieldKind::Map { key, value } => {
791            out.push_str("map<");
792            write_field_kind_summary(out, key);
793            out.push_str(", ");
794            write_field_kind_summary(out, value);
795            out.push('>');
796        }
797        FieldKind::Structured { .. } => {
798            out.push_str("structured");
799        }
800    }
801}
802
803// Write the common text/blob describe label. Both generated and accepted schema
804// summaries use this path so bounded and explicitly unbounded contracts stay
805// visibly identical across `DESCRIBE` and `SHOW COLUMNS`.
806fn write_length_bounded_field_kind_summary(
807    out: &mut String,
808    kind_name: &str,
809    max_len: Option<u32>,
810) {
811    out.push_str(kind_name);
812    if let Some(max_len) = max_len {
813        out.push_str("(max_len=");
814        out.push_str(&max_len.to_string());
815        out.push(')');
816    } else {
817        out.push_str("(unbounded)");
818    }
819}
820
821// Append database-default metadata without decoding the stored payload back
822// into a runtime value. Schema describe owns metadata projection, while field
823// codecs own payload interpretation.
824fn write_model_default_summary(out: &mut String, default: FieldDatabaseDefault) {
825    match default {
826        FieldDatabaseDefault::None => {}
827        FieldDatabaseDefault::EncodedSlotPayload(payload) => {
828            write_encoded_default_payload_summary(out, payload);
829        }
830    }
831}
832
833// Append accepted-schema database-default metadata in the same format as the
834// generated-model path so DESCRIBE and SHOW COLUMNS remain visually aligned.
835fn write_schema_default_summary(out: &mut String, default: &SchemaFieldDefault) {
836    if let Some(payload) = default.slot_payload() {
837        write_encoded_default_payload_summary(out, payload);
838    }
839}
840
841// Keep default rendering compact and byte-oriented. Persisted schema defaults
842// are field-codec payloads, not SQL literals, so the describe surface reports
843// their presence and a stable fingerprint rather than inventing a lossy value.
844fn write_encoded_default_payload_summary(out: &mut String, payload: &[u8]) {
845    let _ = write!(
846        out,
847        " default=slot_payload(bytes={}, sha256={})",
848        payload.len(),
849        short_default_payload_fingerprint(payload),
850    );
851}
852
853fn short_default_payload_fingerprint(payload: &[u8]) -> String {
854    let digest = Sha256::digest(payload);
855    let mut out = String::with_capacity(16);
856    for byte in &digest[..8] {
857        let _ = write!(out, "{byte:02x}");
858    }
859    out
860}
861
862#[cfg_attr(
863    doc,
864    doc = "Render one stable field-kind label from accepted persisted schema metadata."
865)]
866fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
867    let mut out = String::new();
868    write_persisted_field_kind_summary(&mut out, kind);
869
870    out
871}
872
873// Stream the accepted persisted field-kind label in the same public format as
874// generated `FieldKind` summaries. Top-level live-schema metadata can then
875// drive DESCRIBE output without converting back into generated static types.
876fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
877    match kind {
878        PersistedFieldKind::Account => out.push_str("account"),
879        PersistedFieldKind::Blob { max_len } => {
880            write_length_bounded_field_kind_summary(out, "blob", *max_len);
881        }
882        PersistedFieldKind::Bool => out.push_str("bool"),
883        PersistedFieldKind::Date => out.push_str("date"),
884        PersistedFieldKind::Decimal { scale } => {
885            let _ = write!(out, "decimal(scale={scale})");
886        }
887        PersistedFieldKind::Duration => out.push_str("duration"),
888        PersistedFieldKind::Enum { path, .. } => {
889            out.push_str("enum(");
890            out.push_str(path);
891            out.push(')');
892        }
893        PersistedFieldKind::Float32 => out.push_str("float32"),
894        PersistedFieldKind::Float64 => out.push_str("float64"),
895        PersistedFieldKind::Int => out.push_str("int"),
896        PersistedFieldKind::Int128 => out.push_str("int128"),
897        PersistedFieldKind::IntBig => out.push_str("int_big"),
898        PersistedFieldKind::Principal => out.push_str("principal"),
899        PersistedFieldKind::Subaccount => out.push_str("subaccount"),
900        PersistedFieldKind::Text { max_len } => {
901            write_length_bounded_field_kind_summary(out, "text", *max_len);
902        }
903        PersistedFieldKind::Timestamp => out.push_str("timestamp"),
904        PersistedFieldKind::Nat => out.push_str("nat"),
905        PersistedFieldKind::Nat128 => out.push_str("nat128"),
906        PersistedFieldKind::NatBig => out.push_str("nat_big"),
907        PersistedFieldKind::Ulid => out.push_str("ulid"),
908        PersistedFieldKind::Unit => out.push_str("unit"),
909        PersistedFieldKind::Relation {
910            target_entity_name,
911            key_kind,
912            strength,
913            ..
914        } => {
915            out.push_str("relation(target=");
916            out.push_str(target_entity_name);
917            out.push_str(", key=");
918            write_persisted_field_kind_summary(out, key_kind);
919            out.push_str(", strength=");
920            out.push_str(summarize_persisted_relation_strength(*strength));
921            out.push(')');
922        }
923        PersistedFieldKind::List(inner) => {
924            out.push_str("list<");
925            write_persisted_field_kind_summary(out, inner);
926            out.push('>');
927        }
928        PersistedFieldKind::Set(inner) => {
929            out.push_str("set<");
930            write_persisted_field_kind_summary(out, inner);
931            out.push('>');
932        }
933        PersistedFieldKind::Map { key, value } => {
934            out.push_str("map<");
935            write_persisted_field_kind_summary(out, key);
936            out.push_str(", ");
937            write_persisted_field_kind_summary(out, value);
938            out.push('>');
939        }
940        PersistedFieldKind::Structured { .. } => {
941            out.push_str("structured");
942        }
943    }
944}
945
946#[cfg_attr(
947    doc,
948    doc = "Render one stable relation-strength label from persisted schema metadata."
949)]
950const fn summarize_persisted_relation_strength(
951    strength: PersistedRelationStrength,
952) -> &'static str {
953    match strength {
954        PersistedRelationStrength::Strong => "strong",
955        PersistedRelationStrength::Weak => "weak",
956    }
957}
958
959#[cfg_attr(
960    doc,
961    doc = "Render one stable relation-strength label for field-kind summaries."
962)]
963const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
964    match strength {
965        RelationStrength::Strong => "strong",
966        RelationStrength::Weak => "weak",
967    }
968}
969
970//
971// TESTS
972//
973
974#[cfg(test)]
975mod tests {
976    use crate::{
977        db::{
978            EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
979            EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
980            relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
981            schema::{
982                AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
983                PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
984                SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
985                describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
986            },
987        },
988        model::{
989            entity::EntityModel,
990            field::{
991                FieldDatabaseDefault, FieldKind, FieldModel, FieldStorageDecode, LeafCodec,
992                RelationStrength, ScalarCodec,
993            },
994        },
995        types::EntityTag,
996    };
997    use candid::types::{CandidType, Label, Type, TypeInner};
998
999    static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
1000        target_path: "entities::Target",
1001        target_entity_name: "Target",
1002        target_entity_tag: EntityTag::new(0xD001),
1003        target_store_path: "stores::Target",
1004        key_kind: &FieldKind::Ulid,
1005        strength: RelationStrength::Strong,
1006    };
1007    static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1008        target_path: "entities::Account",
1009        target_entity_name: "Account",
1010        target_entity_tag: EntityTag::new(0xD002),
1011        target_store_path: "stores::Account",
1012        key_kind: &FieldKind::Nat,
1013        strength: RelationStrength::Weak,
1014    };
1015    static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1016        target_path: "entities::Team",
1017        target_entity_name: "Team",
1018        target_entity_tag: EntityTag::new(0xD003),
1019        target_store_path: "stores::Team",
1020        key_kind: &FieldKind::Text { max_len: None },
1021        strength: RelationStrength::Strong,
1022    };
1023    static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
1024        FieldModel::generated("id", FieldKind::Ulid),
1025        FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
1026        FieldModel::generated(
1027            "accounts",
1028            FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
1029        ),
1030        FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
1031    ];
1032    static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
1033    static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
1034        "entities::Source",
1035        "Source",
1036        &DESCRIBE_RELATION_FIELDS[0],
1037        0,
1038        &DESCRIBE_RELATION_FIELDS,
1039        &DESCRIBE_RELATION_INDEXES,
1040    );
1041
1042    fn expect_record_fields(ty: Type) -> Vec<String> {
1043        match ty.as_ref() {
1044            TypeInner::Record(fields) => fields
1045                .iter()
1046                .map(|field| match field.id.as_ref() {
1047                    Label::Named(name) => name.clone(),
1048                    other => panic!("expected named record field, got {other:?}"),
1049                })
1050                .collect(),
1051            other => panic!("expected candid record, got {other:?}"),
1052        }
1053    }
1054
1055    fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
1056        match ty.as_ref() {
1057            TypeInner::Record(fields) => fields
1058                .iter()
1059                .find_map(|field| match field.id.as_ref() {
1060                    Label::Named(name) if name == field_name => Some(field.ty.clone()),
1061                    _ => None,
1062                })
1063                .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
1064            other => panic!("expected candid record, got {other:?}"),
1065        }
1066    }
1067
1068    fn expect_variant_labels(ty: Type) -> Vec<String> {
1069        match ty.as_ref() {
1070            TypeInner::Variant(fields) => fields
1071                .iter()
1072                .map(|field| match field.id.as_ref() {
1073                    Label::Named(name) => name.clone(),
1074                    other => panic!("expected named variant label, got {other:?}"),
1075                })
1076                .collect(),
1077            other => panic!("expected candid variant, got {other:?}"),
1078        }
1079    }
1080
1081    #[test]
1082    fn entity_schema_description_candid_shape_is_stable() {
1083        let fields = expect_record_fields(EntitySchemaDescription::ty());
1084
1085        for field in [
1086            "entity_path",
1087            "entity_name",
1088            "primary_key",
1089            "fields",
1090            "indexes",
1091            "relations",
1092        ] {
1093            assert!(
1094                fields.iter().any(|candidate| candidate == field),
1095                "EntitySchemaDescription must keep `{field}` field key",
1096            );
1097        }
1098    }
1099
1100    #[test]
1101    fn entity_field_description_candid_shape_is_stable() {
1102        let fields = expect_record_fields(EntityFieldDescription::ty());
1103
1104        for field in ["name", "slot", "kind", "primary_key", "queryable", "origin"] {
1105            assert!(
1106                fields.iter().any(|candidate| candidate == field),
1107                "EntityFieldDescription must keep `{field}` field key",
1108            );
1109        }
1110
1111        assert!(
1112            matches!(
1113                expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
1114                TypeInner::Nat16
1115            ),
1116            "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
1117        );
1118    }
1119
1120    #[test]
1121    fn entity_index_description_candid_shape_is_stable() {
1122        let fields = expect_record_fields(EntityIndexDescription::ty());
1123
1124        for field in ["name", "unique", "fields", "origin"] {
1125            assert!(
1126                fields.iter().any(|candidate| candidate == field),
1127                "EntityIndexDescription must keep `{field}` field key",
1128            );
1129        }
1130    }
1131
1132    #[test]
1133    fn entity_relation_description_candid_shape_is_stable() {
1134        let fields = expect_record_fields(EntityRelationDescription::ty());
1135
1136        for field in [
1137            "field",
1138            "target_path",
1139            "target_entity_name",
1140            "target_store_path",
1141            "strength",
1142            "cardinality",
1143        ] {
1144            assert!(
1145                fields.iter().any(|candidate| candidate == field),
1146                "EntityRelationDescription must keep `{field}` field key",
1147            );
1148        }
1149    }
1150
1151    #[test]
1152    fn relation_enum_variant_labels_are_stable() {
1153        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
1154        strength_labels.sort_unstable();
1155        assert_eq!(
1156            strength_labels,
1157            vec!["Strong".to_string(), "Weak".to_string()]
1158        );
1159
1160        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
1161        cardinality_labels.sort_unstable();
1162        assert_eq!(
1163            cardinality_labels,
1164            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
1165        );
1166    }
1167
1168    #[test]
1169    fn describe_fixture_constructors_stay_usable() {
1170        let payload = EntitySchemaDescription::new(
1171            "entities::User".to_string(),
1172            "User".to_string(),
1173            "id".to_string(),
1174            vec![EntityFieldDescription::new(
1175                "id".to_string(),
1176                Some(0),
1177                "ulid".to_string(),
1178                true,
1179                true,
1180                "generated".to_string(),
1181            )],
1182            vec![EntityIndexDescription::new(
1183                "idx_email".to_string(),
1184                true,
1185                vec!["email".to_string()],
1186                "generated".to_string(),
1187            )],
1188            vec![EntityRelationDescription::new(
1189                "account_id".to_string(),
1190                "entities::Account".to_string(),
1191                "Account".to_string(),
1192                "accounts".to_string(),
1193                EntityRelationStrength::Strong,
1194                EntityRelationCardinality::Single,
1195            )],
1196        );
1197
1198        assert_eq!(payload.entity_name(), "User");
1199        assert_eq!(payload.fields().len(), 1);
1200        assert_eq!(payload.indexes().len(), 1);
1201        assert_eq!(payload.relations().len(), 1);
1202    }
1203
1204    #[test]
1205    fn schema_describe_relations_match_relation_descriptors() {
1206        let descriptors =
1207            relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1208        let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1209        let relations = described.relations();
1210
1211        assert_eq!(descriptors.len(), relations.len());
1212
1213        for (descriptor, relation) in descriptors.iter().zip(relations) {
1214            assert_eq!(relation.field(), descriptor.field_name());
1215            assert_eq!(relation.target_path(), descriptor.target_path());
1216            assert_eq!(
1217                relation.target_entity_name(),
1218                descriptor.target_entity_name()
1219            );
1220            assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1221            assert_eq!(
1222                relation.strength(),
1223                match descriptor.strength() {
1224                    RelationStrength::Strong => EntityRelationStrength::Strong,
1225                    RelationStrength::Weak => EntityRelationStrength::Weak,
1226                }
1227            );
1228            assert_eq!(
1229                relation.cardinality(),
1230                match descriptor.cardinality() {
1231                    RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1232                    RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1233                    RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1234                }
1235            );
1236        }
1237    }
1238
1239    #[test]
1240    fn schema_describe_includes_text_max_len_contract() {
1241        static FIELDS: [FieldModel; 2] = [
1242            FieldModel::generated("id", FieldKind::Ulid),
1243            FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1244        ];
1245        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1246        static MODEL: EntityModel = EntityModel::generated(
1247            "entities::BoundedName",
1248            "BoundedName",
1249            &FIELDS[0],
1250            0,
1251            &FIELDS,
1252            &INDEXES,
1253        );
1254
1255        let described = describe_entity_model(&MODEL);
1256        let name_field = described
1257            .fields()
1258            .iter()
1259            .find(|field| field.name() == "name")
1260            .expect("bounded text field should be described");
1261
1262        assert_eq!(name_field.kind(), "text(max_len=16)");
1263    }
1264
1265    #[test]
1266    fn schema_describe_includes_generated_database_default_metadata() {
1267        static DEFAULT_PAYLOAD: &[u8] = &[0xFF, 0x01, 7, 0, 0, 0, 0, 0, 0, 0];
1268        static FIELDS: [FieldModel; 2] = [
1269            FieldModel::generated("id", FieldKind::Ulid),
1270            FieldModel::generated_with_storage_decode_nullability_write_policies_database_default_and_nested_fields(
1271                "score",
1272                FieldKind::Nat,
1273                FieldStorageDecode::ByKind,
1274                false,
1275                None,
1276                None,
1277                FieldDatabaseDefault::EncodedSlotPayload(DEFAULT_PAYLOAD),
1278                &[],
1279            ),
1280        ];
1281        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1282        static MODEL: EntityModel = EntityModel::generated(
1283            "entities::DefaultedScore",
1284            "DefaultedScore",
1285            &FIELDS[0],
1286            0,
1287            &FIELDS,
1288            &INDEXES,
1289        );
1290
1291        let described = describe_entity_model(&MODEL);
1292        let score_field = described
1293            .fields()
1294            .iter()
1295            .find(|field| field.name() == "score")
1296            .expect("database-defaulted score field should be described");
1297
1298        assert_eq!(
1299            score_field.kind(),
1300            "nat default=slot_payload(bytes=10, sha256=37746b8fe16bb6b4)"
1301        );
1302    }
1303
1304    #[test]
1305    fn schema_describe_uses_accepted_top_level_field_metadata() {
1306        let id_slot = SchemaFieldSlot::new(0);
1307        let payload_slot = SchemaFieldSlot::new(7);
1308        // The accepted wrapper below is intentionally inconsistent so this
1309        // metadata boundary proves row-layout authority owns slot answers.
1310        let stale_payload_field_slot = SchemaFieldSlot::new(3);
1311        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1312            SchemaVersion::initial(),
1313            "entities::BlobEvent".to_string(),
1314            "BlobEvent".to_string(),
1315            FieldId::new(1),
1316            SchemaRowLayout::new(
1317                SchemaVersion::initial(),
1318                vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1319            ),
1320            vec![
1321                PersistedFieldSnapshot::new(
1322                    FieldId::new(1),
1323                    "id".to_string(),
1324                    id_slot,
1325                    PersistedFieldKind::Ulid,
1326                    Vec::new(),
1327                    false,
1328                    SchemaFieldDefault::None,
1329                    FieldStorageDecode::ByKind,
1330                    LeafCodec::StructuralFallback,
1331                ),
1332                PersistedFieldSnapshot::new(
1333                    FieldId::new(2),
1334                    "payload".to_string(),
1335                    stale_payload_field_slot,
1336                    PersistedFieldKind::Blob { max_len: None },
1337                    Vec::new(),
1338                    false,
1339                    SchemaFieldDefault::SlotPayload(vec![0x10, 0x20, 0x30]),
1340                    FieldStorageDecode::ByKind,
1341                    LeafCodec::StructuralFallback,
1342                ),
1343            ],
1344        ));
1345
1346        let described = describe_entity_fields_with_persisted_schema(&snapshot)
1347            .into_iter()
1348            .map(|field| {
1349                (
1350                    field.name().to_string(),
1351                    field.slot(),
1352                    field.kind().to_string(),
1353                )
1354            })
1355            .collect::<Vec<_>>();
1356
1357        assert_eq!(
1358            described,
1359            vec![
1360                ("id".to_string(), Some(0), "ulid".to_string()),
1361                (
1362                    "payload".to_string(),
1363                    Some(7),
1364                    "blob(unbounded) default=slot_payload(bytes=3, sha256=8e1336ab78ebe687)"
1365                        .to_string()
1366                ),
1367            ],
1368        );
1369    }
1370
1371    #[test]
1372    fn schema_describe_uses_accepted_nested_leaf_metadata() {
1373        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1374            SchemaVersion::initial(),
1375            "entities::AcceptedProfile".to_string(),
1376            "AcceptedProfile".to_string(),
1377            FieldId::new(1),
1378            SchemaRowLayout::new(
1379                SchemaVersion::initial(),
1380                vec![
1381                    (FieldId::new(1), SchemaFieldSlot::new(0)),
1382                    (FieldId::new(2), SchemaFieldSlot::new(1)),
1383                ],
1384            ),
1385            vec![
1386                PersistedFieldSnapshot::new(
1387                    FieldId::new(1),
1388                    "id".to_string(),
1389                    SchemaFieldSlot::new(0),
1390                    PersistedFieldKind::Ulid,
1391                    Vec::new(),
1392                    false,
1393                    SchemaFieldDefault::None,
1394                    FieldStorageDecode::ByKind,
1395                    LeafCodec::StructuralFallback,
1396                ),
1397                PersistedFieldSnapshot::new(
1398                    FieldId::new(2),
1399                    "profile".to_string(),
1400                    SchemaFieldSlot::new(1),
1401                    PersistedFieldKind::Structured { queryable: true },
1402                    vec![PersistedNestedLeafSnapshot::new(
1403                        vec!["rank".to_string()],
1404                        PersistedFieldKind::Blob { max_len: None },
1405                        false,
1406                        FieldStorageDecode::ByKind,
1407                        LeafCodec::Scalar(ScalarCodec::Blob),
1408                    )],
1409                    false,
1410                    SchemaFieldDefault::None,
1411                    FieldStorageDecode::Value,
1412                    LeafCodec::StructuralFallback,
1413                ),
1414            ],
1415        ));
1416
1417        let described = describe_entity_fields_with_persisted_schema(&snapshot);
1418        let rank = described
1419            .iter()
1420            .find(|field| field.name() == "└─ rank")
1421            .expect("accepted nested leaf should be described");
1422
1423        assert_eq!(rank.slot(), None);
1424        assert_eq!(rank.kind(), "blob(unbounded)");
1425        assert!(rank.queryable());
1426    }
1427
1428    #[test]
1429    fn schema_describe_expands_generated_structured_field_leaves() {
1430        static NESTED_FIELDS: [FieldModel; 3] = [
1431            FieldModel::generated("name", FieldKind::Text { max_len: None }),
1432            FieldModel::generated("level", FieldKind::Nat),
1433            FieldModel::generated("pid", FieldKind::Principal),
1434        ];
1435        static FIELDS: [FieldModel; 2] = [
1436            FieldModel::generated("id", FieldKind::Ulid),
1437            FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1438                "mentor",
1439                FieldKind::Structured { queryable: false },
1440                FieldStorageDecode::Value,
1441                false,
1442                None,
1443                None,
1444                &NESTED_FIELDS,
1445            ),
1446        ];
1447        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1448        static MODEL: EntityModel = EntityModel::generated(
1449            "entities::Character",
1450            "Character",
1451            &FIELDS[0],
1452            0,
1453            &FIELDS,
1454            &INDEXES,
1455        );
1456
1457        let described = describe_entity_model(&MODEL);
1458        let described_fields = described
1459            .fields()
1460            .iter()
1461            .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1462            .collect::<Vec<_>>();
1463
1464        assert_eq!(
1465            described_fields,
1466            vec![
1467                ("id", Some(0), "ulid", true),
1468                ("mentor", Some(1), "structured", false),
1469                ("├─ name", None, "text(unbounded)", true),
1470                ("├─ level", None, "nat", true),
1471                ("└─ pid", None, "principal", true),
1472            ],
1473        );
1474    }
1475}