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