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    if let Some(name) = kind.describe_kind_name() {
810        out.push_str(name);
811        return;
812    }
813
814    match kind {
815        FieldKind::Blob { max_len } => {
816            write_length_bounded_field_kind_summary(out, "blob", *max_len);
817        }
818        FieldKind::Decimal { scale } => {
819            let _ = write!(out, "decimal(scale={scale})");
820        }
821        FieldKind::IntBig { max_bytes } => {
822            write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
823        }
824        FieldKind::Enum { path, .. } => {
825            out.push_str("enum(");
826            out.push_str(path);
827            out.push(')');
828        }
829        FieldKind::Text { max_len } => {
830            write_length_bounded_field_kind_summary(out, "text", *max_len);
831        }
832        FieldKind::Relation {
833            target_entity_name,
834            key_kind,
835            strength,
836            ..
837        } => {
838            out.push_str("relation(target=");
839            out.push_str(target_entity_name);
840            out.push_str(", key=");
841            write_field_kind_summary(out, key_kind);
842            out.push_str(", strength=");
843            out.push_str(summarize_relation_strength(*strength));
844            out.push(')');
845        }
846        FieldKind::List(inner) => {
847            out.push_str("list<");
848            write_field_kind_summary(out, inner);
849            out.push('>');
850        }
851        FieldKind::Set(inner) => {
852            out.push_str("set<");
853            write_field_kind_summary(out, inner);
854            out.push('>');
855        }
856        FieldKind::Map { key, value } => {
857            out.push_str("map<");
858            write_field_kind_summary(out, key);
859            out.push_str(", ");
860            write_field_kind_summary(out, value);
861            out.push('>');
862        }
863        FieldKind::Structured { .. } => {
864            out.push_str("structured");
865        }
866        FieldKind::Account
867        | FieldKind::Bool
868        | FieldKind::Date
869        | FieldKind::Duration
870        | FieldKind::Float32
871        | FieldKind::Float64
872        | FieldKind::Int8
873        | FieldKind::Int16
874        | FieldKind::Int32
875        | FieldKind::Int64
876        | FieldKind::Int128
877        | FieldKind::Principal
878        | FieldKind::Subaccount
879        | FieldKind::Timestamp
880        | FieldKind::Nat8
881        | FieldKind::Nat16
882        | FieldKind::Nat32
883        | FieldKind::Nat64
884        | FieldKind::Nat128
885        | FieldKind::Ulid
886        | FieldKind::Unit => unreachable!("plain field kind labels return before recursive render"),
887        FieldKind::NatBig { max_bytes } => {
888            write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
889        }
890    }
891}
892
893trait DescribeKindName {
894    fn describe_kind_name(&self) -> Option<&'static str>;
895}
896
897impl DescribeKindName for FieldKind {
898    fn describe_kind_name(&self) -> Option<&'static str> {
899        Some(match self {
900            Self::Account => "account",
901            Self::Bool => "bool",
902            Self::Date => "date",
903            Self::Duration => "duration",
904            Self::Float32 => "float32",
905            Self::Float64 => "float64",
906            Self::Int8 => "int8",
907            Self::Int16 => "int16",
908            Self::Int32 => "int32",
909            Self::Int64 => "int64",
910            Self::Int128 => "int128",
911            Self::Principal => "principal",
912            Self::Subaccount => "subaccount",
913            Self::Timestamp => "timestamp",
914            Self::Nat8 => "nat8",
915            Self::Nat16 => "nat16",
916            Self::Nat32 => "nat32",
917            Self::Nat64 => "nat64",
918            Self::Nat128 => "nat128",
919            Self::Ulid => "ulid",
920            Self::Unit => "unit",
921            Self::Blob { .. }
922            | Self::Decimal { .. }
923            | Self::Enum { .. }
924            | Self::IntBig { .. }
925            | Self::NatBig { .. }
926            | Self::Text { .. }
927            | Self::Relation { .. }
928            | Self::List(_)
929            | Self::Set(_)
930            | Self::Map { .. }
931            | Self::Structured { .. } => return None,
932        })
933    }
934}
935
936// Write the common text/blob describe label. Both generated and accepted schema
937// summaries use this path so bounded and explicitly unbounded contracts stay
938// visibly identical across `DESCRIBE` and `SHOW COLUMNS`.
939fn write_length_bounded_field_kind_summary(
940    out: &mut String,
941    kind_name: &str,
942    max_len: Option<u32>,
943) {
944    out.push_str(kind_name);
945    if let Some(max_len) = max_len {
946        out.push_str("(max_len=");
947        out.push_str(&max_len.to_string());
948        out.push(')');
949    } else {
950        out.push_str("(unbounded)");
951    }
952}
953
954fn write_byte_bounded_field_kind_summary(out: &mut String, kind_name: &str, max_bytes: u32) {
955    out.push_str(kind_name);
956    out.push_str("(max_bytes=");
957    out.push_str(&max_bytes.to_string());
958    out.push(')');
959}
960
961// Append database-default metadata without decoding the stored payload back
962// into a runtime value. Schema describe owns metadata projection, while field
963// codecs own payload interpretation.
964fn write_model_default_summary(out: &mut String, default: FieldDatabaseDefault) {
965    match default {
966        FieldDatabaseDefault::None => {}
967        FieldDatabaseDefault::EncodedSlotPayload(payload) => {
968            write_encoded_default_payload_summary(out, payload);
969        }
970    }
971}
972
973// Append accepted-schema database-default metadata in the same format as the
974// generated-model path so DESCRIBE and SHOW COLUMNS remain visually aligned.
975fn write_schema_default_summary(out: &mut String, default: &SchemaFieldDefault) {
976    if let Some(payload) = default.slot_payload() {
977        write_encoded_default_payload_summary(out, payload);
978    }
979}
980
981// Keep default rendering compact and byte-oriented. Persisted schema defaults
982// are field-codec payloads, not SQL literals, so the describe surface reports
983// their presence and a stable fingerprint rather than inventing a lossy value.
984fn write_encoded_default_payload_summary(out: &mut String, payload: &[u8]) {
985    let _ = write!(
986        out,
987        " default=slot_payload(bytes={}, sha256={})",
988        payload.len(),
989        short_default_payload_fingerprint(payload),
990    );
991}
992
993fn short_default_payload_fingerprint(payload: &[u8]) -> String {
994    let digest = Sha256::digest(payload);
995    let mut out = String::with_capacity(16);
996    for byte in &digest[..8] {
997        let _ = write!(out, "{byte:02x}");
998    }
999    out
1000}
1001
1002#[cfg_attr(
1003    doc,
1004    doc = "Render one stable field-kind label from accepted persisted schema metadata."
1005)]
1006fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
1007    let mut out = String::new();
1008    write_persisted_field_kind_summary(&mut out, kind);
1009
1010    out
1011}
1012
1013// Stream the accepted persisted field-kind label in the same public format as
1014// generated `FieldKind` summaries. Top-level live-schema metadata can then
1015// drive DESCRIBE output without converting back into generated static types.
1016fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
1017    if let Some(name) = kind.describe_kind_name() {
1018        out.push_str(name);
1019        return;
1020    }
1021
1022    match kind {
1023        PersistedFieldKind::Blob { max_len } => {
1024            write_length_bounded_field_kind_summary(out, "blob", *max_len);
1025        }
1026        PersistedFieldKind::Decimal { scale } => {
1027            let _ = write!(out, "decimal(scale={scale})");
1028        }
1029        PersistedFieldKind::IntBig { max_bytes } => {
1030            write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
1031        }
1032        PersistedFieldKind::Enum { path, .. } => {
1033            out.push_str("enum(");
1034            out.push_str(path);
1035            out.push(')');
1036        }
1037        PersistedFieldKind::Text { max_len } => {
1038            write_length_bounded_field_kind_summary(out, "text", *max_len);
1039        }
1040        PersistedFieldKind::Relation {
1041            target_entity_name,
1042            key_kind,
1043            strength,
1044            ..
1045        } => {
1046            out.push_str("relation(target=");
1047            out.push_str(target_entity_name);
1048            out.push_str(", key=");
1049            write_persisted_field_kind_summary(out, key_kind);
1050            out.push_str(", strength=");
1051            out.push_str(summarize_persisted_relation_strength(*strength));
1052            out.push(')');
1053        }
1054        PersistedFieldKind::List(inner) => {
1055            out.push_str("list<");
1056            write_persisted_field_kind_summary(out, inner);
1057            out.push('>');
1058        }
1059        PersistedFieldKind::Set(inner) => {
1060            out.push_str("set<");
1061            write_persisted_field_kind_summary(out, inner);
1062            out.push('>');
1063        }
1064        PersistedFieldKind::Map { key, value } => {
1065            out.push_str("map<");
1066            write_persisted_field_kind_summary(out, key);
1067            out.push_str(", ");
1068            write_persisted_field_kind_summary(out, value);
1069            out.push('>');
1070        }
1071        PersistedFieldKind::Structured { .. } => {
1072            out.push_str("structured");
1073        }
1074        PersistedFieldKind::Account
1075        | PersistedFieldKind::Bool
1076        | PersistedFieldKind::Date
1077        | PersistedFieldKind::Duration
1078        | PersistedFieldKind::Float32
1079        | PersistedFieldKind::Float64
1080        | PersistedFieldKind::Int8
1081        | PersistedFieldKind::Int16
1082        | PersistedFieldKind::Int32
1083        | PersistedFieldKind::Int64
1084        | PersistedFieldKind::Int128
1085        | PersistedFieldKind::Principal
1086        | PersistedFieldKind::Subaccount
1087        | PersistedFieldKind::Timestamp
1088        | PersistedFieldKind::Nat8
1089        | PersistedFieldKind::Nat16
1090        | PersistedFieldKind::Nat32
1091        | PersistedFieldKind::Nat64
1092        | PersistedFieldKind::Nat128
1093        | PersistedFieldKind::Ulid
1094        | PersistedFieldKind::Unit => {
1095            unreachable!("plain persisted field kind labels return before recursive render")
1096        }
1097        PersistedFieldKind::NatBig { max_bytes } => {
1098            write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
1099        }
1100    }
1101}
1102
1103impl DescribeKindName for PersistedFieldKind {
1104    fn describe_kind_name(&self) -> Option<&'static str> {
1105        Some(match self {
1106            Self::Account => "account",
1107            Self::Bool => "bool",
1108            Self::Date => "date",
1109            Self::Duration => "duration",
1110            Self::Float32 => "float32",
1111            Self::Float64 => "float64",
1112            Self::Int8 => "int8",
1113            Self::Int16 => "int16",
1114            Self::Int32 => "int32",
1115            Self::Int64 => "int64",
1116            Self::Int128 => "int128",
1117            Self::Principal => "principal",
1118            Self::Subaccount => "subaccount",
1119            Self::Timestamp => "timestamp",
1120            Self::Nat8 => "nat8",
1121            Self::Nat16 => "nat16",
1122            Self::Nat32 => "nat32",
1123            Self::Nat64 => "nat64",
1124            Self::Nat128 => "nat128",
1125            Self::Ulid => "ulid",
1126            Self::Unit => "unit",
1127            Self::Blob { .. }
1128            | Self::Decimal { .. }
1129            | Self::Enum { .. }
1130            | Self::IntBig { .. }
1131            | Self::NatBig { .. }
1132            | Self::Text { .. }
1133            | Self::Relation { .. }
1134            | Self::List(_)
1135            | Self::Set(_)
1136            | Self::Map { .. }
1137            | Self::Structured { .. } => return None,
1138        })
1139    }
1140}
1141
1142#[cfg_attr(
1143    doc,
1144    doc = "Render one stable relation-strength label from persisted schema metadata."
1145)]
1146const fn summarize_persisted_relation_strength(
1147    strength: PersistedRelationStrength,
1148) -> &'static str {
1149    match strength {
1150        PersistedRelationStrength::Strong => "strong",
1151        PersistedRelationStrength::Weak => "weak",
1152    }
1153}
1154
1155#[cfg_attr(
1156    doc,
1157    doc = "Render one stable relation-strength label for field-kind summaries."
1158)]
1159const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
1160    match strength {
1161        RelationStrength::Strong => "strong",
1162        RelationStrength::Weak => "weak",
1163    }
1164}
1165
1166//
1167// TESTS
1168//
1169
1170#[cfg(test)]
1171mod tests {
1172    use crate::{
1173        db::{
1174            EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
1175            EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
1176            relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
1177            schema::{
1178                AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
1179                PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
1180                SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
1181                describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
1182            },
1183        },
1184        model::{
1185            entity::{EntityModel, PrimaryKeyModel},
1186            field::{
1187                FieldDatabaseDefault, FieldKind, FieldModel, FieldStorageDecode, LeafCodec,
1188                RelationStrength, ScalarCodec,
1189            },
1190        },
1191        types::EntityTag,
1192    };
1193    use candid::types::{CandidType, Label, Type, TypeInner};
1194
1195    static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
1196        target_path: "entities::Target",
1197        target_entity_name: "Target",
1198        target_entity_tag: EntityTag::new(0xD001),
1199        target_store_path: "stores::Target",
1200        key_kind: &FieldKind::Ulid,
1201        strength: RelationStrength::Strong,
1202    };
1203    static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1204        target_path: "entities::Account",
1205        target_entity_name: "Account",
1206        target_entity_tag: EntityTag::new(0xD002),
1207        target_store_path: "stores::Account",
1208        key_kind: &FieldKind::Nat64,
1209        strength: RelationStrength::Weak,
1210    };
1211    static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1212        target_path: "entities::Team",
1213        target_entity_name: "Team",
1214        target_entity_tag: EntityTag::new(0xD003),
1215        target_store_path: "stores::Team",
1216        key_kind: &FieldKind::Text { max_len: None },
1217        strength: RelationStrength::Strong,
1218    };
1219    static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
1220        FieldModel::generated("id", FieldKind::Ulid),
1221        FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
1222        FieldModel::generated(
1223            "accounts",
1224            FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
1225        ),
1226        FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
1227    ];
1228    static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
1229    static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
1230        "entities::Source",
1231        "Source",
1232        &DESCRIBE_RELATION_FIELDS[0],
1233        0,
1234        &DESCRIBE_RELATION_FIELDS,
1235        &DESCRIBE_RELATION_INDEXES,
1236    );
1237    static DESCRIBE_COMPOSITE_PK_FIELDS: [FieldModel; 3] = [
1238        FieldModel::generated("tenant_id", FieldKind::Nat64),
1239        FieldModel::generated("local_id", FieldKind::Nat64),
1240        FieldModel::generated("label", FieldKind::Text { max_len: None }),
1241    ];
1242    static DESCRIBE_COMPOSITE_PK_FIELD_REFS: [&FieldModel; 2] = [
1243        &DESCRIBE_COMPOSITE_PK_FIELDS[0],
1244        &DESCRIBE_COMPOSITE_PK_FIELDS[1],
1245    ];
1246    static DESCRIBE_COMPOSITE_PK_MODEL: EntityModel = EntityModel::generated_with_primary_key_model(
1247        "entities::Composite",
1248        "Composite",
1249        PrimaryKeyModel::ordered(&DESCRIBE_COMPOSITE_PK_FIELD_REFS),
1250        0,
1251        &DESCRIBE_COMPOSITE_PK_FIELDS,
1252        &DESCRIBE_RELATION_INDEXES,
1253    );
1254
1255    fn expect_record_fields(ty: Type) -> Vec<String> {
1256        match ty.as_ref() {
1257            TypeInner::Record(fields) => fields
1258                .iter()
1259                .map(|field| match field.id.as_ref() {
1260                    Label::Named(name) => name.clone(),
1261                    other => panic!("expected named record field, got {other:?}"),
1262                })
1263                .collect(),
1264            other => panic!("expected candid record, got {other:?}"),
1265        }
1266    }
1267
1268    fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
1269        match ty.as_ref() {
1270            TypeInner::Record(fields) => fields
1271                .iter()
1272                .find_map(|field| match field.id.as_ref() {
1273                    Label::Named(name) if name == field_name => Some(field.ty.clone()),
1274                    _ => None,
1275                })
1276                .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
1277            other => panic!("expected candid record, got {other:?}"),
1278        }
1279    }
1280
1281    fn expect_variant_labels(ty: Type) -> Vec<String> {
1282        match ty.as_ref() {
1283            TypeInner::Variant(fields) => fields
1284                .iter()
1285                .map(|field| match field.id.as_ref() {
1286                    Label::Named(name) => name.clone(),
1287                    other => panic!("expected named variant label, got {other:?}"),
1288                })
1289                .collect(),
1290            other => panic!("expected candid variant, got {other:?}"),
1291        }
1292    }
1293
1294    #[test]
1295    fn entity_schema_description_candid_shape_is_stable() {
1296        let fields = expect_record_fields(EntitySchemaDescription::ty());
1297
1298        for field in [
1299            "entity_path",
1300            "entity_name",
1301            "primary_key",
1302            "primary_key_fields",
1303            "fields",
1304            "indexes",
1305            "relations",
1306        ] {
1307            assert!(
1308                fields.iter().any(|candidate| candidate == field),
1309                "EntitySchemaDescription must keep `{field}` field key",
1310            );
1311        }
1312    }
1313
1314    #[test]
1315    fn entity_field_description_candid_shape_is_stable() {
1316        let fields = expect_record_fields(EntityFieldDescription::ty());
1317
1318        for field in ["name", "slot", "kind", "primary_key", "queryable", "origin"] {
1319            assert!(
1320                fields.iter().any(|candidate| candidate == field),
1321                "EntityFieldDescription must keep `{field}` field key",
1322            );
1323        }
1324
1325        assert!(
1326            matches!(
1327                expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
1328                TypeInner::Nat16
1329            ),
1330            "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
1331        );
1332    }
1333
1334    #[test]
1335    fn entity_index_description_candid_shape_is_stable() {
1336        let fields = expect_record_fields(EntityIndexDescription::ty());
1337
1338        for field in ["name", "unique", "fields", "origin"] {
1339            assert!(
1340                fields.iter().any(|candidate| candidate == field),
1341                "EntityIndexDescription must keep `{field}` field key",
1342            );
1343        }
1344    }
1345
1346    #[test]
1347    fn entity_relation_description_candid_shape_is_stable() {
1348        let fields = expect_record_fields(EntityRelationDescription::ty());
1349
1350        for field in [
1351            "field",
1352            "target_path",
1353            "target_entity_name",
1354            "target_store_path",
1355            "strength",
1356            "cardinality",
1357        ] {
1358            assert!(
1359                fields.iter().any(|candidate| candidate == field),
1360                "EntityRelationDescription must keep `{field}` field key",
1361            );
1362        }
1363    }
1364
1365    #[test]
1366    fn relation_enum_variant_labels_are_stable() {
1367        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
1368        strength_labels.sort_unstable();
1369        assert_eq!(
1370            strength_labels,
1371            vec!["Strong".to_string(), "Weak".to_string()]
1372        );
1373
1374        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
1375        cardinality_labels.sort_unstable();
1376        assert_eq!(
1377            cardinality_labels,
1378            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
1379        );
1380    }
1381
1382    #[test]
1383    fn describe_fixture_constructors_stay_usable() {
1384        let payload = EntitySchemaDescription::new(
1385            "entities::User".to_string(),
1386            "User".to_string(),
1387            "id".to_string(),
1388            vec![EntityFieldDescription::new(
1389                "id".to_string(),
1390                Some(0),
1391                "ulid".to_string(),
1392                false,
1393                true,
1394                true,
1395                "generated".to_string(),
1396            )],
1397            vec![EntityIndexDescription::new(
1398                "idx_email".to_string(),
1399                true,
1400                vec!["email".to_string()],
1401                "generated".to_string(),
1402            )],
1403            vec![EntityRelationDescription::new(
1404                "account_id".to_string(),
1405                "entities::Account".to_string(),
1406                "Account".to_string(),
1407                "accounts".to_string(),
1408                EntityRelationStrength::Strong,
1409                EntityRelationCardinality::Single,
1410            )],
1411        );
1412
1413        assert_eq!(payload.entity_name(), "User");
1414        assert_eq!(payload.primary_key(), "id");
1415        assert_eq!(payload.primary_key_fields(), ["id".to_string()].as_slice());
1416        assert_eq!(payload.fields().len(), 1);
1417        assert_eq!(payload.indexes().len(), 1);
1418        assert_eq!(payload.relations().len(), 1);
1419    }
1420
1421    #[test]
1422    fn describe_entity_model_marks_all_composite_primary_key_fields() {
1423        let described = describe_entity_model(&DESCRIBE_COMPOSITE_PK_MODEL);
1424        let primary_key_fields = described
1425            .fields()
1426            .iter()
1427            .filter(|field| field.primary_key())
1428            .map(EntityFieldDescription::name)
1429            .collect::<Vec<_>>();
1430
1431        assert_eq!(described.primary_key(), "tenant_id, local_id");
1432        assert_eq!(
1433            described.primary_key_fields(),
1434            ["tenant_id".to_string(), "local_id".to_string()].as_slice(),
1435        );
1436        assert_eq!(primary_key_fields, ["tenant_id", "local_id"]);
1437    }
1438
1439    #[test]
1440    fn schema_describe_relations_match_relation_descriptors() {
1441        let descriptors =
1442            relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1443        let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1444        let relations = described.relations();
1445
1446        assert_eq!(descriptors.len(), relations.len());
1447
1448        for (descriptor, relation) in descriptors.iter().zip(relations) {
1449            assert_eq!(relation.field(), descriptor.field_name());
1450            assert_eq!(relation.target_path(), descriptor.target_path());
1451            assert_eq!(
1452                relation.target_entity_name(),
1453                descriptor.target_entity_name()
1454            );
1455            assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1456            assert_eq!(
1457                relation.strength(),
1458                match descriptor.strength() {
1459                    RelationStrength::Strong => EntityRelationStrength::Strong,
1460                    RelationStrength::Weak => EntityRelationStrength::Weak,
1461                }
1462            );
1463            assert_eq!(
1464                relation.cardinality(),
1465                match descriptor.cardinality() {
1466                    RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1467                    RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1468                    RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1469                }
1470            );
1471        }
1472    }
1473
1474    #[test]
1475    fn schema_describe_includes_text_max_len_contract() {
1476        static FIELDS: [FieldModel; 2] = [
1477            FieldModel::generated("id", FieldKind::Ulid),
1478            FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1479        ];
1480        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1481        static MODEL: EntityModel = EntityModel::generated(
1482            "entities::BoundedName",
1483            "BoundedName",
1484            &FIELDS[0],
1485            0,
1486            &FIELDS,
1487            &INDEXES,
1488        );
1489
1490        let described = describe_entity_model(&MODEL);
1491        let name_field = described
1492            .fields()
1493            .iter()
1494            .find(|field| field.name() == "name")
1495            .expect("bounded text field should be described");
1496
1497        assert_eq!(name_field.kind(), "text(max_len=16)");
1498    }
1499
1500    #[test]
1501    fn schema_describe_preserves_fixed_width_numeric_kind_labels() {
1502        static FIELDS: [FieldModel; 7] = [
1503            FieldModel::generated("id", FieldKind::Ulid),
1504            FieldModel::generated("small_signed", FieldKind::Int8),
1505            FieldModel::generated("cell_x", FieldKind::Nat16),
1506            FieldModel::generated("large_signed", FieldKind::Int64),
1507            FieldModel::generated("large_unsigned", FieldKind::Nat64),
1508            FieldModel::generated("huge_signed", FieldKind::IntBig { max_bytes: 384 }),
1509            FieldModel::generated("huge_unsigned", FieldKind::NatBig { max_bytes: 512 }),
1510        ];
1511        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1512        static MODEL: EntityModel = EntityModel::generated(
1513            "entities::FixedWidthNumbers",
1514            "FixedWidthNumbers",
1515            &FIELDS[0],
1516            0,
1517            &FIELDS,
1518            &INDEXES,
1519        );
1520
1521        let described = describe_entity_model(&MODEL)
1522            .fields()
1523            .iter()
1524            .map(|field| (field.name().to_string(), field.kind().to_string()))
1525            .collect::<Vec<_>>();
1526
1527        assert!(described.contains(&("small_signed".to_string(), "int8".to_string())));
1528        assert!(described.contains(&("cell_x".to_string(), "nat16".to_string())));
1529        assert!(described.contains(&("large_signed".to_string(), "int64".to_string())));
1530        assert!(described.contains(&("large_unsigned".to_string(), "nat64".to_string())));
1531        assert!(described.contains(&(
1532            "huge_signed".to_string(),
1533            "int_big(max_bytes=384)".to_string()
1534        )));
1535        assert!(described.contains(&(
1536            "huge_unsigned".to_string(),
1537            "nat_big(max_bytes=512)".to_string()
1538        )));
1539    }
1540
1541    #[test]
1542    fn schema_describe_includes_generated_database_default_metadata() {
1543        static DEFAULT_PAYLOAD: &[u8] = &[0xFF, 0x01, 7, 0, 0, 0, 0, 0, 0, 0];
1544        static FIELDS: [FieldModel; 2] = [
1545            FieldModel::generated("id", FieldKind::Ulid),
1546            FieldModel::generated_with_storage_decode_nullability_write_policies_database_default_and_nested_fields(
1547                "score",
1548                FieldKind::Nat64,
1549                FieldStorageDecode::ByKind,
1550                false,
1551                None,
1552                None,
1553                FieldDatabaseDefault::EncodedSlotPayload(DEFAULT_PAYLOAD),
1554                &[],
1555            ),
1556        ];
1557        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1558        static MODEL: EntityModel = EntityModel::generated(
1559            "entities::DefaultedScore",
1560            "DefaultedScore",
1561            &FIELDS[0],
1562            0,
1563            &FIELDS,
1564            &INDEXES,
1565        );
1566
1567        let described = describe_entity_model(&MODEL);
1568        let score_field = described
1569            .fields()
1570            .iter()
1571            .find(|field| field.name() == "score")
1572            .expect("database-defaulted score field should be described");
1573
1574        assert_eq!(
1575            score_field.kind(),
1576            "nat64 default=slot_payload(bytes=10, sha256=37746b8fe16bb6b4)"
1577        );
1578    }
1579
1580    #[test]
1581    fn schema_describe_uses_accepted_top_level_field_metadata() {
1582        let id_slot = SchemaFieldSlot::new(0);
1583        let payload_slot = SchemaFieldSlot::new(7);
1584        // The accepted wrapper below is intentionally inconsistent so this
1585        // metadata boundary proves row-layout authority owns slot answers.
1586        let stale_payload_field_slot = SchemaFieldSlot::new(3);
1587        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1588            SchemaVersion::initial(),
1589            "entities::BlobEvent".to_string(),
1590            "BlobEvent".to_string(),
1591            FieldId::new(1),
1592            SchemaRowLayout::new(
1593                SchemaVersion::initial(),
1594                vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1595            ),
1596            vec![
1597                PersistedFieldSnapshot::new(
1598                    FieldId::new(1),
1599                    "id".to_string(),
1600                    id_slot,
1601                    PersistedFieldKind::Ulid,
1602                    Vec::new(),
1603                    false,
1604                    SchemaFieldDefault::None,
1605                    FieldStorageDecode::ByKind,
1606                    LeafCodec::StructuralFallback,
1607                ),
1608                PersistedFieldSnapshot::new(
1609                    FieldId::new(2),
1610                    "payload".to_string(),
1611                    stale_payload_field_slot,
1612                    PersistedFieldKind::Blob { max_len: None },
1613                    Vec::new(),
1614                    false,
1615                    SchemaFieldDefault::SlotPayload(vec![0x10, 0x20, 0x30]),
1616                    FieldStorageDecode::ByKind,
1617                    LeafCodec::StructuralFallback,
1618                ),
1619            ],
1620        ));
1621
1622        let described = describe_entity_fields_with_persisted_schema(&snapshot)
1623            .into_iter()
1624            .map(|field| {
1625                (
1626                    field.name().to_string(),
1627                    field.slot(),
1628                    field.kind().to_string(),
1629                )
1630            })
1631            .collect::<Vec<_>>();
1632
1633        assert_eq!(
1634            described,
1635            vec![
1636                ("id".to_string(), Some(0), "ulid".to_string()),
1637                (
1638                    "payload".to_string(),
1639                    Some(7),
1640                    "blob(unbounded) default=slot_payload(bytes=3, sha256=8e1336ab78ebe687)"
1641                        .to_string()
1642                ),
1643            ],
1644        );
1645    }
1646
1647    #[test]
1648    fn schema_describe_preserves_accepted_fixed_width_numeric_kind_labels() {
1649        let id_slot = SchemaFieldSlot::new(0);
1650        let x_slot = SchemaFieldSlot::new(1);
1651        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1652            SchemaVersion::initial(),
1653            "entities::Grid".to_string(),
1654            "Grid".to_string(),
1655            FieldId::new(1),
1656            SchemaRowLayout::new(
1657                SchemaVersion::initial(),
1658                vec![(FieldId::new(1), id_slot), (FieldId::new(2), x_slot)],
1659            ),
1660            vec![
1661                PersistedFieldSnapshot::new(
1662                    FieldId::new(1),
1663                    "id".to_string(),
1664                    id_slot,
1665                    PersistedFieldKind::Ulid,
1666                    Vec::new(),
1667                    false,
1668                    SchemaFieldDefault::None,
1669                    FieldStorageDecode::ByKind,
1670                    LeafCodec::StructuralFallback,
1671                ),
1672                PersistedFieldSnapshot::new(
1673                    FieldId::new(2),
1674                    "x".to_string(),
1675                    x_slot,
1676                    PersistedFieldKind::Nat16,
1677                    Vec::new(),
1678                    false,
1679                    SchemaFieldDefault::None,
1680                    FieldStorageDecode::ByKind,
1681                    LeafCodec::Scalar(ScalarCodec::Nat64),
1682                ),
1683            ],
1684        ));
1685
1686        let described = describe_entity_fields_with_persisted_schema(&snapshot);
1687        let x = described
1688            .iter()
1689            .find(|field| field.name() == "x")
1690            .expect("accepted fixed-width field should be described");
1691
1692        assert_eq!(x.kind(), "nat16");
1693    }
1694
1695    #[test]
1696    fn schema_describe_uses_accepted_nested_leaf_metadata() {
1697        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1698            SchemaVersion::initial(),
1699            "entities::AcceptedProfile".to_string(),
1700            "AcceptedProfile".to_string(),
1701            FieldId::new(1),
1702            SchemaRowLayout::new(
1703                SchemaVersion::initial(),
1704                vec![
1705                    (FieldId::new(1), SchemaFieldSlot::new(0)),
1706                    (FieldId::new(2), SchemaFieldSlot::new(1)),
1707                ],
1708            ),
1709            vec![
1710                PersistedFieldSnapshot::new(
1711                    FieldId::new(1),
1712                    "id".to_string(),
1713                    SchemaFieldSlot::new(0),
1714                    PersistedFieldKind::Ulid,
1715                    Vec::new(),
1716                    false,
1717                    SchemaFieldDefault::None,
1718                    FieldStorageDecode::ByKind,
1719                    LeafCodec::StructuralFallback,
1720                ),
1721                PersistedFieldSnapshot::new(
1722                    FieldId::new(2),
1723                    "profile".to_string(),
1724                    SchemaFieldSlot::new(1),
1725                    PersistedFieldKind::Structured { queryable: true },
1726                    vec![PersistedNestedLeafSnapshot::new(
1727                        vec!["rank".to_string()],
1728                        PersistedFieldKind::Blob { max_len: None },
1729                        false,
1730                        FieldStorageDecode::ByKind,
1731                        LeafCodec::Scalar(ScalarCodec::Blob),
1732                    )],
1733                    false,
1734                    SchemaFieldDefault::None,
1735                    FieldStorageDecode::Value,
1736                    LeafCodec::StructuralFallback,
1737                ),
1738            ],
1739        ));
1740
1741        let described = describe_entity_fields_with_persisted_schema(&snapshot);
1742        let rank = described
1743            .iter()
1744            .find(|field| field.name() == "└─ rank")
1745            .expect("accepted nested leaf should be described");
1746
1747        assert_eq!(rank.slot(), None);
1748        assert_eq!(rank.kind(), "blob(unbounded)");
1749        assert!(rank.queryable());
1750    }
1751
1752    #[test]
1753    fn schema_describe_expands_generated_structured_field_leaves() {
1754        static NESTED_FIELDS: [FieldModel; 3] = [
1755            FieldModel::generated("name", FieldKind::Text { max_len: None }),
1756            FieldModel::generated("level", FieldKind::Nat64),
1757            FieldModel::generated("pid", FieldKind::Principal),
1758        ];
1759        static FIELDS: [FieldModel; 2] = [
1760            FieldModel::generated("id", FieldKind::Ulid),
1761            FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1762                "mentor",
1763                FieldKind::Structured { queryable: false },
1764                FieldStorageDecode::Value,
1765                false,
1766                None,
1767                None,
1768                &NESTED_FIELDS,
1769            ),
1770        ];
1771        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1772        static MODEL: EntityModel = EntityModel::generated(
1773            "entities::Character",
1774            "Character",
1775            &FIELDS[0],
1776            0,
1777            &FIELDS,
1778            &INDEXES,
1779        );
1780
1781        let described = describe_entity_model(&MODEL);
1782        let described_fields = described
1783            .fields()
1784            .iter()
1785            .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1786            .collect::<Vec<_>>();
1787
1788        assert_eq!(
1789            described_fields,
1790            vec![
1791                ("id", Some(0), "ulid", true),
1792                ("mentor", Some(1), "structured", false),
1793                ("├─ name", None, "text(unbounded)", true),
1794                ("├─ level", None, "nat64", true),
1795                ("└─ pid", None, "principal", true),
1796            ],
1797        );
1798    }
1799}