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