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            RelationFieldCardinality, RelationFieldMetadata, relation_field_metadata_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_from_description_rows(
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        describe_entity_relations_from_model(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_from_description_rows(
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        describe_entity_relations_with_persisted_schema(schema),
447    )
448}
449
450// Assemble the common DESCRIBE payload once field rows have already been built.
451// Callers project relation descriptions from the same authority as their field
452// and index rows, so accepted DESCRIBE output does not fall back to generated
453// relation metadata.
454fn describe_entity_model_from_description_rows(
455    entity_path: &str,
456    entity_name: &str,
457    primary_key: &str,
458    primary_key_fields: Vec<String>,
459    fields: Vec<EntityFieldDescription>,
460    indexes: Vec<EntityIndexDescription>,
461    relations: Vec<EntityRelationDescription>,
462) -> EntitySchemaDescription {
463    EntitySchemaDescription::new_with_primary_key_fields(
464        entity_path.to_string(),
465        entity_name.to_string(),
466        primary_key.to_string(),
467        primary_key_fields,
468        fields,
469        indexes,
470        relations,
471    )
472}
473
474fn describe_entity_relations_from_model(model: &EntityModel) -> Vec<EntityRelationDescription> {
475    relation_field_metadata_for_model_iter(model)
476        .map(relation_description_from_metadata)
477        .collect()
478}
479
480fn primary_key_field_names_from_model(model: &EntityModel) -> Vec<String> {
481    model
482        .primary_key_model()
483        .fields()
484        .iter()
485        .map(|field| field.name.to_string())
486        .collect()
487}
488
489fn render_primary_key_fields(fields: &[String]) -> String {
490    fields.join(", ")
491}
492
493fn describe_entity_indexes_from_model(model: &EntityModel) -> Vec<EntityIndexDescription> {
494    let mut indexes = Vec::with_capacity(model.indexes.len());
495    for index in model.indexes {
496        indexes.push(EntityIndexDescription::new(
497            index.name().to_string(),
498            index.is_unique(),
499            index
500                .fields()
501                .iter()
502                .map(|field| (*field).to_string())
503                .collect(),
504            "generated".to_string(),
505        ));
506    }
507
508    indexes
509}
510
511fn describe_entity_indexes_with_persisted_schema(
512    schema: &AcceptedSchemaSnapshot,
513) -> Vec<EntityIndexDescription> {
514    schema
515        .persisted_snapshot()
516        .indexes()
517        .iter()
518        .map(|index| {
519            EntityIndexDescription::new(
520                index.name().to_string(),
521                index.unique(),
522                describe_persisted_index_fields(index.key()),
523                if index.generated() {
524                    "generated".to_string()
525                } else {
526                    "ddl".to_string()
527                },
528            )
529        })
530        .collect()
531}
532
533fn describe_persisted_index_fields(key: &PersistedIndexKeySnapshot) -> Vec<String> {
534    match key {
535        PersistedIndexKeySnapshot::FieldPath(paths) => paths
536            .iter()
537            .map(|field_path| field_path.path().join("."))
538            .collect(),
539        PersistedIndexKeySnapshot::Items(items) => items
540            .iter()
541            .map(|item| match item {
542                PersistedIndexKeyItemSnapshot::FieldPath(field_path) => field_path.path().join("."),
543                PersistedIndexKeyItemSnapshot::Expression(expression) => {
544                    expression.canonical_text().to_string()
545                }
546            })
547            .collect(),
548    }
549}
550
551// Build the stable field-description subset once from one runtime model so
552// metadata surfaces that only need columns do not rebuild indexes and
553// relations through the heavier DESCRIBE payload path.
554#[must_use]
555pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
556    describe_entity_fields_with_slot_lookup(model, |slot, _field| {
557        Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
558    })
559}
560
561#[cfg_attr(
562    doc,
563    doc = "Build field descriptors using accepted persisted schema slot metadata."
564)]
565#[must_use]
566pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
567    schema: &AcceptedSchemaSnapshot,
568) -> Vec<EntityFieldDescription> {
569    let snapshot = schema.persisted_snapshot();
570    let mut fields = Vec::with_capacity(snapshot.fields().len());
571
572    // Accepted-schema describe surfaces must follow the stored schema payload,
573    // not the generated model's current field order.
574    for field in snapshot.fields() {
575        let primary_key = snapshot.primary_key_field_ids().contains(&field.id());
576        let slot = snapshot
577            .row_layout()
578            .slot_for_field(field.id())
579            .map(SchemaFieldSlot::get);
580        let mut kind = summarize_persisted_field_kind(field.kind());
581        write_schema_default_summary(&mut kind, field.default());
582        let metadata = DescribeFieldMetadata::new(
583            kind,
584            field.nullable(),
585            field_type_from_persisted_kind(field.kind())
586                .value_kind()
587                .is_queryable(),
588            field_origin_label(field.generated()),
589        );
590
591        push_described_field_row(&mut fields, field.name(), slot, primary_key, None, metadata);
592
593        if !field.nested_leaves().is_empty() {
594            describe_persisted_nested_leaves(
595                &mut fields,
596                field.nested_leaves(),
597                field_origin_label(field.generated()),
598            );
599        }
600    }
601
602    fields
603}
604
605// Build field descriptors with an injected top-level slot lookup. Generated
606// model introspection uses generated positions; live-schema introspection uses
607// accepted persisted row layout metadata while preserving nested-field behavior.
608fn describe_entity_fields_with_slot_lookup(
609    model: &EntityModel,
610    mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
611) -> Vec<EntityFieldDescription> {
612    let mut fields = Vec::with_capacity(model.fields.len());
613    let primary_key_fields = primary_key_field_names_from_model(model);
614
615    for (slot, field) in model.fields.iter().enumerate() {
616        let primary_key = primary_key_fields
617            .iter()
618            .any(|primary_key_field| primary_key_field == field.name);
619        describe_field_recursive(
620            &mut fields,
621            field.name,
622            slot_for_field(slot, field),
623            field,
624            primary_key,
625            None,
626            None,
627        );
628    }
629
630    fields
631}
632
633///
634/// DescribeFieldMetadata
635///
636/// Field-description metadata selected before recursive field rendering.
637/// Accepted-schema metadata can override generated model facts for top-level
638/// fields and, when available, nested leaf rows.
639///
640
641struct DescribeFieldMetadata {
642    kind: String,
643    nullable: bool,
644    queryable: bool,
645    origin: String,
646}
647
648impl DescribeFieldMetadata {
649    // Build one metadata bundle from already-rendered field facts.
650    const fn new(kind: String, nullable: bool, queryable: bool, origin: String) -> Self {
651        Self {
652            kind,
653            nullable,
654            queryable,
655            origin,
656        }
657    }
658}
659
660// Add one generated field and any generated structured-record leaves so
661// DESCRIBE/SHOW COLUMNS expose the same nested rows SQL can project and filter.
662fn describe_field_recursive(
663    fields: &mut Vec<EntityFieldDescription>,
664    name: &str,
665    slot: Option<u16>,
666    field: &FieldModel,
667    primary_key: bool,
668    tree_prefix: Option<&'static str>,
669    metadata_override: Option<DescribeFieldMetadata>,
670) {
671    let metadata = metadata_override.unwrap_or_else(|| {
672        let mut kind = summarize_field_kind(&field.kind);
673        write_model_default_summary(&mut kind, field.database_default());
674
675        DescribeFieldMetadata::new(
676            kind,
677            field.nullable(),
678            field.kind.value_kind().is_queryable(),
679            "generated".to_string(),
680        )
681    });
682
683    push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
684    describe_generated_nested_fields(fields, field.nested_fields());
685}
686
687// Add one already-resolved field row to the stable describe DTO list. The
688// caller owns where metadata came from: generated model or accepted schema.
689fn push_described_field_row(
690    fields: &mut Vec<EntityFieldDescription>,
691    name: &str,
692    slot: Option<u16>,
693    primary_key: bool,
694    tree_prefix: Option<&'static str>,
695    metadata: DescribeFieldMetadata,
696) {
697    // Nested field rows keep a compact tree marker so table-oriented describe
698    // output scans as a hierarchy without assigning nested leaves row slots.
699    let display_name = if let Some(prefix) = tree_prefix {
700        format!("{prefix}{name}")
701    } else {
702        name.to_string()
703    };
704
705    fields.push(EntityFieldDescription::new(
706        display_name,
707        slot,
708        metadata.kind,
709        metadata.nullable,
710        primary_key,
711        metadata.queryable,
712        metadata.origin,
713    ));
714}
715
716// Render generated nested field metadata recursively. Generated and accepted
717// top-level describe paths both use this fallback when no accepted nested leaf
718// descriptors are available yet.
719fn describe_generated_nested_fields(
720    fields: &mut Vec<EntityFieldDescription>,
721    nested_fields: &[FieldModel],
722) {
723    for (index, nested) in nested_fields.iter().enumerate() {
724        let prefix = if index + 1 == nested_fields.len() {
725            "└─ "
726        } else {
727            "├─ "
728        };
729        describe_field_recursive(
730            fields,
731            nested.name(),
732            None,
733            nested,
734            false,
735            Some(prefix),
736            None,
737        );
738    }
739}
740
741// Render accepted nested leaf descriptors. Nested leaves do not own physical
742// row slots, so they always appear with the no-slot sentinel in the Candid DTO.
743fn describe_persisted_nested_leaves(
744    fields: &mut Vec<EntityFieldDescription>,
745    nested_leaves: &[PersistedNestedLeafSnapshot],
746    origin: String,
747) {
748    for (index, leaf) in nested_leaves.iter().enumerate() {
749        let prefix = if index + 1 == nested_leaves.len() {
750            "└─ "
751        } else {
752            "├─ "
753        };
754        let name = leaf.path().last().map_or("", String::as_str);
755        let metadata = DescribeFieldMetadata::new(
756            summarize_persisted_field_kind(leaf.kind()),
757            leaf.nullable(),
758            field_type_from_persisted_kind(leaf.kind())
759                .value_kind()
760                .is_queryable(),
761            origin.clone(),
762        );
763
764        push_described_field_row(fields, name, None, false, Some(prefix), metadata);
765    }
766}
767
768fn field_origin_label(generated: bool) -> String {
769    if generated {
770        "generated".to_string()
771    } else {
772        "ddl".to_string()
773    }
774}
775
776fn describe_entity_relations_with_persisted_schema(
777    schema: &AcceptedSchemaSnapshot,
778) -> Vec<EntityRelationDescription> {
779    schema
780        .persisted_snapshot()
781        .fields()
782        .iter()
783        .filter_map(relation_description_from_persisted_field)
784        .collect()
785}
786
787fn relation_description_from_persisted_field(
788    field: &crate::db::schema::PersistedFieldSnapshot,
789) -> Option<EntityRelationDescription> {
790    let relation = persisted_relation_description_metadata(field.kind())?;
791
792    Some(EntityRelationDescription::new(
793        field.name().to_string(),
794        relation.target_path.to_string(),
795        relation.target_entity_name.to_string(),
796        relation.target_store_path.to_string(),
797        relation.strength,
798        relation.cardinality,
799    ))
800}
801
802struct PersistedRelationDescriptionMetadata<'a> {
803    target_path: &'a str,
804    target_entity_name: &'a str,
805    target_store_path: &'a str,
806    strength: EntityRelationStrength,
807    cardinality: EntityRelationCardinality,
808}
809
810fn persisted_relation_description_metadata(
811    kind: &PersistedFieldKind,
812) -> Option<PersistedRelationDescriptionMetadata<'_>> {
813    const fn from_relation_kind(
814        kind: &PersistedFieldKind,
815        cardinality: EntityRelationCardinality,
816    ) -> Option<PersistedRelationDescriptionMetadata<'_>> {
817        let PersistedFieldKind::Relation {
818            target_path,
819            target_entity_name,
820            target_store_path,
821            strength,
822            ..
823        } = kind
824        else {
825            return None;
826        };
827
828        Some(PersistedRelationDescriptionMetadata {
829            target_path: target_path.as_str(),
830            target_entity_name: target_entity_name.as_str(),
831            target_store_path: target_store_path.as_str(),
832            strength: entity_relation_strength_from_persisted(*strength),
833            cardinality,
834        })
835    }
836
837    match kind {
838        PersistedFieldKind::Relation { .. } => {
839            from_relation_kind(kind, EntityRelationCardinality::Single)
840        }
841        PersistedFieldKind::List(inner) => {
842            from_relation_kind(inner, EntityRelationCardinality::List)
843        }
844        PersistedFieldKind::Set(inner) => from_relation_kind(inner, EntityRelationCardinality::Set),
845        _ => None,
846    }
847}
848
849const fn entity_relation_strength_from_persisted(
850    strength: PersistedRelationStrength,
851) -> EntityRelationStrength {
852    match strength {
853        PersistedRelationStrength::Strong => EntityRelationStrength::Strong,
854        PersistedRelationStrength::Weak => EntityRelationStrength::Weak,
855    }
856}
857
858// Project relation-owned metadata into the stable describe DTO surface.
859fn relation_description_from_metadata(
860    metadata: RelationFieldMetadata,
861) -> EntityRelationDescription {
862    let strength = match metadata.strength() {
863        RelationStrength::Strong => EntityRelationStrength::Strong,
864        RelationStrength::Weak => EntityRelationStrength::Weak,
865    };
866
867    let cardinality = match metadata.cardinality() {
868        RelationFieldCardinality::Single => EntityRelationCardinality::Single,
869        RelationFieldCardinality::List => EntityRelationCardinality::List,
870        RelationFieldCardinality::Set => EntityRelationCardinality::Set,
871    };
872
873    EntityRelationDescription::new(
874        metadata.field_name().to_string(),
875        metadata.target_path().to_string(),
876        metadata.target_entity_name().to_string(),
877        metadata.target_store_path().to_string(),
878        strength,
879        cardinality,
880    )
881}
882
883#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
884fn summarize_field_kind(kind: &FieldKind) -> String {
885    let mut out = String::new();
886    write_field_kind_summary(&mut out, kind);
887
888    out
889}
890
891// Stream one stable field-kind label directly into the output buffer so
892// describe/sql surfaces do not retain a large recursive `format!` family.
893fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
894    if let Some(name) = kind.describe_kind_name() {
895        out.push_str(name);
896        return;
897    }
898
899    match kind {
900        FieldKind::Blob { max_len } => {
901            write_length_bounded_field_kind_summary(out, "blob", *max_len);
902        }
903        FieldKind::Decimal { scale } => {
904            let _ = write!(out, "decimal(scale={scale})");
905        }
906        FieldKind::IntBig { max_bytes } => {
907            write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
908        }
909        FieldKind::Enum { path, .. } => {
910            out.push_str("enum(");
911            out.push_str(path);
912            out.push(')');
913        }
914        FieldKind::Text { max_len } => {
915            write_length_bounded_field_kind_summary(out, "text", *max_len);
916        }
917        FieldKind::Relation {
918            target_entity_name,
919            key_kind,
920            strength,
921            ..
922        } => {
923            out.push_str("relation(target=");
924            out.push_str(target_entity_name);
925            out.push_str(", key=");
926            write_field_kind_summary(out, key_kind);
927            out.push_str(", strength=");
928            out.push_str(summarize_relation_strength(*strength));
929            out.push(')');
930        }
931        FieldKind::List(inner) => {
932            out.push_str("list<");
933            write_field_kind_summary(out, inner);
934            out.push('>');
935        }
936        FieldKind::Set(inner) => {
937            out.push_str("set<");
938            write_field_kind_summary(out, inner);
939            out.push('>');
940        }
941        FieldKind::Map { key, value } => {
942            out.push_str("map<");
943            write_field_kind_summary(out, key);
944            out.push_str(", ");
945            write_field_kind_summary(out, value);
946            out.push('>');
947        }
948        FieldKind::Structured { .. } => {
949            out.push_str("structured");
950        }
951        FieldKind::Account
952        | FieldKind::Bool
953        | FieldKind::Date
954        | FieldKind::Duration
955        | FieldKind::Float32
956        | FieldKind::Float64
957        | FieldKind::Int8
958        | FieldKind::Int16
959        | FieldKind::Int32
960        | FieldKind::Int64
961        | FieldKind::Int128
962        | FieldKind::Principal
963        | FieldKind::Subaccount
964        | FieldKind::Timestamp
965        | FieldKind::Nat8
966        | FieldKind::Nat16
967        | FieldKind::Nat32
968        | FieldKind::Nat64
969        | FieldKind::Nat128
970        | FieldKind::Ulid
971        | FieldKind::Unit => unreachable!("plain field kind labels return before recursive render"),
972        FieldKind::NatBig { max_bytes } => {
973            write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
974        }
975    }
976}
977
978trait DescribeKindName {
979    fn describe_kind_name(&self) -> Option<&'static str>;
980}
981
982impl DescribeKindName for FieldKind {
983    fn describe_kind_name(&self) -> Option<&'static str> {
984        Some(match self {
985            Self::Account => "account",
986            Self::Bool => "bool",
987            Self::Date => "date",
988            Self::Duration => "duration",
989            Self::Float32 => "float32",
990            Self::Float64 => "float64",
991            Self::Int8 => "int8",
992            Self::Int16 => "int16",
993            Self::Int32 => "int32",
994            Self::Int64 => "int64",
995            Self::Int128 => "int128",
996            Self::Principal => "principal",
997            Self::Subaccount => "subaccount",
998            Self::Timestamp => "timestamp",
999            Self::Nat8 => "nat8",
1000            Self::Nat16 => "nat16",
1001            Self::Nat32 => "nat32",
1002            Self::Nat64 => "nat64",
1003            Self::Nat128 => "nat128",
1004            Self::Ulid => "ulid",
1005            Self::Unit => "unit",
1006            Self::Blob { .. }
1007            | Self::Decimal { .. }
1008            | Self::Enum { .. }
1009            | Self::IntBig { .. }
1010            | Self::NatBig { .. }
1011            | Self::Text { .. }
1012            | Self::Relation { .. }
1013            | Self::List(_)
1014            | Self::Set(_)
1015            | Self::Map { .. }
1016            | Self::Structured { .. } => return None,
1017        })
1018    }
1019}
1020
1021// Write the common text/blob describe label. Both generated and accepted schema
1022// summaries use this path so bounded and explicitly unbounded contracts stay
1023// visibly identical across `DESCRIBE` and `SHOW COLUMNS`.
1024fn write_length_bounded_field_kind_summary(
1025    out: &mut String,
1026    kind_name: &str,
1027    max_len: Option<u32>,
1028) {
1029    out.push_str(kind_name);
1030    if let Some(max_len) = max_len {
1031        out.push_str("(max_len=");
1032        out.push_str(&max_len.to_string());
1033        out.push(')');
1034    } else {
1035        out.push_str("(unbounded)");
1036    }
1037}
1038
1039fn write_byte_bounded_field_kind_summary(out: &mut String, kind_name: &str, max_bytes: u32) {
1040    out.push_str(kind_name);
1041    out.push_str("(max_bytes=");
1042    out.push_str(&max_bytes.to_string());
1043    out.push(')');
1044}
1045
1046// Append database-default metadata without decoding the stored payload back
1047// into a runtime value. Schema describe owns metadata projection, while field
1048// codecs own payload interpretation.
1049fn write_model_default_summary(out: &mut String, default: FieldDatabaseDefault) {
1050    match default {
1051        FieldDatabaseDefault::None => {}
1052        FieldDatabaseDefault::EncodedSlotPayload(payload) => {
1053            write_encoded_default_payload_summary(out, payload);
1054        }
1055    }
1056}
1057
1058// Append accepted-schema database-default metadata in the same format as the
1059// generated-model path so DESCRIBE and SHOW COLUMNS remain visually aligned.
1060fn write_schema_default_summary(out: &mut String, default: &SchemaFieldDefault) {
1061    if let Some(payload) = default.slot_payload() {
1062        write_encoded_default_payload_summary(out, payload);
1063    }
1064}
1065
1066// Keep default rendering compact and byte-oriented. Persisted schema defaults
1067// are field-codec payloads, not SQL literals, so the describe surface reports
1068// their presence and a stable fingerprint rather than inventing a lossy value.
1069fn write_encoded_default_payload_summary(out: &mut String, payload: &[u8]) {
1070    let _ = write!(
1071        out,
1072        " default=slot_payload(bytes={}, sha256={})",
1073        payload.len(),
1074        short_default_payload_fingerprint(payload),
1075    );
1076}
1077
1078fn short_default_payload_fingerprint(payload: &[u8]) -> String {
1079    let digest = Sha256::digest(payload);
1080    let mut out = String::with_capacity(16);
1081    for byte in &digest[..8] {
1082        let _ = write!(out, "{byte:02x}");
1083    }
1084    out
1085}
1086
1087#[cfg_attr(
1088    doc,
1089    doc = "Render one stable field-kind label from accepted persisted schema metadata."
1090)]
1091fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
1092    let mut out = String::new();
1093    write_persisted_field_kind_summary(&mut out, kind);
1094
1095    out
1096}
1097
1098// Stream the accepted persisted field-kind label in the same public format as
1099// generated `FieldKind` summaries. Top-level live-schema metadata can then
1100// drive DESCRIBE output without converting back into generated static types.
1101fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
1102    if let Some(name) = kind.describe_kind_name() {
1103        out.push_str(name);
1104        return;
1105    }
1106
1107    match kind {
1108        PersistedFieldKind::Blob { max_len } => {
1109            write_length_bounded_field_kind_summary(out, "blob", *max_len);
1110        }
1111        PersistedFieldKind::Decimal { scale } => {
1112            let _ = write!(out, "decimal(scale={scale})");
1113        }
1114        PersistedFieldKind::IntBig { max_bytes } => {
1115            write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
1116        }
1117        PersistedFieldKind::Enum { path, .. } => {
1118            out.push_str("enum(");
1119            out.push_str(path);
1120            out.push(')');
1121        }
1122        PersistedFieldKind::Text { max_len } => {
1123            write_length_bounded_field_kind_summary(out, "text", *max_len);
1124        }
1125        PersistedFieldKind::Relation {
1126            target_entity_name,
1127            key_kind,
1128            strength,
1129            ..
1130        } => {
1131            out.push_str("relation(target=");
1132            out.push_str(target_entity_name);
1133            out.push_str(", key=");
1134            write_persisted_field_kind_summary(out, key_kind);
1135            out.push_str(", strength=");
1136            out.push_str(summarize_persisted_relation_strength(*strength));
1137            out.push(')');
1138        }
1139        PersistedFieldKind::List(inner) => {
1140            out.push_str("list<");
1141            write_persisted_field_kind_summary(out, inner);
1142            out.push('>');
1143        }
1144        PersistedFieldKind::Set(inner) => {
1145            out.push_str("set<");
1146            write_persisted_field_kind_summary(out, inner);
1147            out.push('>');
1148        }
1149        PersistedFieldKind::Map { key, value } => {
1150            out.push_str("map<");
1151            write_persisted_field_kind_summary(out, key);
1152            out.push_str(", ");
1153            write_persisted_field_kind_summary(out, value);
1154            out.push('>');
1155        }
1156        PersistedFieldKind::Structured { .. } => {
1157            out.push_str("structured");
1158        }
1159        PersistedFieldKind::Account
1160        | PersistedFieldKind::Bool
1161        | PersistedFieldKind::Date
1162        | PersistedFieldKind::Duration
1163        | PersistedFieldKind::Float32
1164        | PersistedFieldKind::Float64
1165        | PersistedFieldKind::Int8
1166        | PersistedFieldKind::Int16
1167        | PersistedFieldKind::Int32
1168        | PersistedFieldKind::Int64
1169        | PersistedFieldKind::Int128
1170        | PersistedFieldKind::Principal
1171        | PersistedFieldKind::Subaccount
1172        | PersistedFieldKind::Timestamp
1173        | PersistedFieldKind::Nat8
1174        | PersistedFieldKind::Nat16
1175        | PersistedFieldKind::Nat32
1176        | PersistedFieldKind::Nat64
1177        | PersistedFieldKind::Nat128
1178        | PersistedFieldKind::Ulid
1179        | PersistedFieldKind::Unit => {
1180            unreachable!("plain persisted field kind labels return before recursive render")
1181        }
1182        PersistedFieldKind::NatBig { max_bytes } => {
1183            write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
1184        }
1185    }
1186}
1187
1188impl DescribeKindName for PersistedFieldKind {
1189    fn describe_kind_name(&self) -> Option<&'static str> {
1190        Some(match self {
1191            Self::Account => "account",
1192            Self::Bool => "bool",
1193            Self::Date => "date",
1194            Self::Duration => "duration",
1195            Self::Float32 => "float32",
1196            Self::Float64 => "float64",
1197            Self::Int8 => "int8",
1198            Self::Int16 => "int16",
1199            Self::Int32 => "int32",
1200            Self::Int64 => "int64",
1201            Self::Int128 => "int128",
1202            Self::Principal => "principal",
1203            Self::Subaccount => "subaccount",
1204            Self::Timestamp => "timestamp",
1205            Self::Nat8 => "nat8",
1206            Self::Nat16 => "nat16",
1207            Self::Nat32 => "nat32",
1208            Self::Nat64 => "nat64",
1209            Self::Nat128 => "nat128",
1210            Self::Ulid => "ulid",
1211            Self::Unit => "unit",
1212            Self::Blob { .. }
1213            | Self::Decimal { .. }
1214            | Self::Enum { .. }
1215            | Self::IntBig { .. }
1216            | Self::NatBig { .. }
1217            | Self::Text { .. }
1218            | Self::Relation { .. }
1219            | Self::List(_)
1220            | Self::Set(_)
1221            | Self::Map { .. }
1222            | Self::Structured { .. } => return None,
1223        })
1224    }
1225}
1226
1227#[cfg_attr(
1228    doc,
1229    doc = "Render one stable relation-strength label from persisted schema metadata."
1230)]
1231const fn summarize_persisted_relation_strength(
1232    strength: PersistedRelationStrength,
1233) -> &'static str {
1234    match strength {
1235        PersistedRelationStrength::Strong => "strong",
1236        PersistedRelationStrength::Weak => "weak",
1237    }
1238}
1239
1240#[cfg_attr(
1241    doc,
1242    doc = "Render one stable relation-strength label for field-kind summaries."
1243)]
1244const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
1245    match strength {
1246        RelationStrength::Strong => "strong",
1247        RelationStrength::Weak => "weak",
1248    }
1249}
1250
1251//
1252// TESTS
1253//
1254
1255#[cfg(test)]
1256mod tests {
1257    use crate::{
1258        db::{
1259            EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
1260            EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
1261            relation::{RelationFieldCardinality, relation_field_metadata_for_model_iter},
1262            schema::{
1263                AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
1264                PersistedNestedLeafSnapshot, PersistedRelationStrength, PersistedSchemaSnapshot,
1265                SchemaFieldDefault, SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
1266                describe::{
1267                    describe_entity_fields_with_persisted_schema, describe_entity_model,
1268                    describe_entity_model_with_persisted_schema,
1269                },
1270            },
1271        },
1272        model::{
1273            entity::{EntityModel, PrimaryKeyModel},
1274            field::{
1275                FieldDatabaseDefault, FieldKind, FieldModel, FieldStorageDecode, LeafCodec,
1276                RelationStrength, ScalarCodec,
1277            },
1278        },
1279        types::EntityTag,
1280    };
1281    use candid::types::{CandidType, Label, Type, TypeInner};
1282
1283    static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
1284        target_path: "entities::Target",
1285        target_entity_name: "Target",
1286        target_entity_tag: EntityTag::new(0xD001),
1287        target_store_path: "stores::Target",
1288        key_kind: &FieldKind::Ulid,
1289        strength: RelationStrength::Strong,
1290    };
1291    static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1292        target_path: "entities::Account",
1293        target_entity_name: "Account",
1294        target_entity_tag: EntityTag::new(0xD002),
1295        target_store_path: "stores::Account",
1296        key_kind: &FieldKind::Nat64,
1297        strength: RelationStrength::Weak,
1298    };
1299    static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1300        target_path: "entities::Team",
1301        target_entity_name: "Team",
1302        target_entity_tag: EntityTag::new(0xD003),
1303        target_store_path: "stores::Team",
1304        key_kind: &FieldKind::Text { max_len: None },
1305        strength: RelationStrength::Strong,
1306    };
1307    static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
1308        FieldModel::generated("id", FieldKind::Ulid),
1309        FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
1310        FieldModel::generated(
1311            "accounts",
1312            FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
1313        ),
1314        FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
1315    ];
1316    static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
1317    static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
1318        "entities::Source",
1319        "Source",
1320        &DESCRIBE_RELATION_FIELDS[0],
1321        0,
1322        &DESCRIBE_RELATION_FIELDS,
1323        &DESCRIBE_RELATION_INDEXES,
1324    );
1325    static DESCRIBE_COMPOSITE_PK_FIELDS: [FieldModel; 3] = [
1326        FieldModel::generated("tenant_id", FieldKind::Nat64),
1327        FieldModel::generated("local_id", FieldKind::Nat64),
1328        FieldModel::generated("label", FieldKind::Text { max_len: None }),
1329    ];
1330    static DESCRIBE_COMPOSITE_PK_FIELD_REFS: [&FieldModel; 2] = [
1331        &DESCRIBE_COMPOSITE_PK_FIELDS[0],
1332        &DESCRIBE_COMPOSITE_PK_FIELDS[1],
1333    ];
1334    static DESCRIBE_COMPOSITE_PK_MODEL: EntityModel = EntityModel::generated_with_primary_key_model(
1335        "entities::Composite",
1336        "Composite",
1337        PrimaryKeyModel::ordered(&DESCRIBE_COMPOSITE_PK_FIELD_REFS),
1338        0,
1339        &DESCRIBE_COMPOSITE_PK_FIELDS,
1340        &DESCRIBE_RELATION_INDEXES,
1341    );
1342
1343    fn expect_record_fields(ty: Type) -> Vec<String> {
1344        match ty.as_ref() {
1345            TypeInner::Record(fields) => fields
1346                .iter()
1347                .map(|field| match field.id.as_ref() {
1348                    Label::Named(name) => name.clone(),
1349                    other => panic!("expected named record field, got {other:?}"),
1350                })
1351                .collect(),
1352            other => panic!("expected candid record, got {other:?}"),
1353        }
1354    }
1355
1356    fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
1357        match ty.as_ref() {
1358            TypeInner::Record(fields) => fields
1359                .iter()
1360                .find_map(|field| match field.id.as_ref() {
1361                    Label::Named(name) if name == field_name => Some(field.ty.clone()),
1362                    _ => None,
1363                })
1364                .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
1365            other => panic!("expected candid record, got {other:?}"),
1366        }
1367    }
1368
1369    fn expect_variant_labels(ty: Type) -> Vec<String> {
1370        match ty.as_ref() {
1371            TypeInner::Variant(fields) => fields
1372                .iter()
1373                .map(|field| match field.id.as_ref() {
1374                    Label::Named(name) => name.clone(),
1375                    other => panic!("expected named variant label, got {other:?}"),
1376                })
1377                .collect(),
1378            other => panic!("expected candid variant, got {other:?}"),
1379        }
1380    }
1381
1382    #[test]
1383    fn entity_schema_description_candid_shape_is_stable() {
1384        let fields = expect_record_fields(EntitySchemaDescription::ty());
1385
1386        for field in [
1387            "entity_path",
1388            "entity_name",
1389            "primary_key",
1390            "primary_key_fields",
1391            "fields",
1392            "indexes",
1393            "relations",
1394        ] {
1395            assert!(
1396                fields.iter().any(|candidate| candidate == field),
1397                "EntitySchemaDescription must keep `{field}` field key",
1398            );
1399        }
1400    }
1401
1402    #[test]
1403    fn entity_field_description_candid_shape_is_stable() {
1404        let fields = expect_record_fields(EntityFieldDescription::ty());
1405
1406        for field in ["name", "slot", "kind", "primary_key", "queryable", "origin"] {
1407            assert!(
1408                fields.iter().any(|candidate| candidate == field),
1409                "EntityFieldDescription must keep `{field}` field key",
1410            );
1411        }
1412
1413        assert!(
1414            matches!(
1415                expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
1416                TypeInner::Nat16
1417            ),
1418            "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
1419        );
1420    }
1421
1422    #[test]
1423    fn entity_index_description_candid_shape_is_stable() {
1424        let fields = expect_record_fields(EntityIndexDescription::ty());
1425
1426        for field in ["name", "unique", "fields", "origin"] {
1427            assert!(
1428                fields.iter().any(|candidate| candidate == field),
1429                "EntityIndexDescription must keep `{field}` field key",
1430            );
1431        }
1432    }
1433
1434    #[test]
1435    fn entity_relation_description_candid_shape_is_stable() {
1436        let fields = expect_record_fields(EntityRelationDescription::ty());
1437
1438        for field in [
1439            "field",
1440            "target_path",
1441            "target_entity_name",
1442            "target_store_path",
1443            "strength",
1444            "cardinality",
1445        ] {
1446            assert!(
1447                fields.iter().any(|candidate| candidate == field),
1448                "EntityRelationDescription must keep `{field}` field key",
1449            );
1450        }
1451    }
1452
1453    #[test]
1454    fn relation_enum_variant_labels_are_stable() {
1455        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
1456        strength_labels.sort_unstable();
1457        assert_eq!(
1458            strength_labels,
1459            vec!["Strong".to_string(), "Weak".to_string()]
1460        );
1461
1462        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
1463        cardinality_labels.sort_unstable();
1464        assert_eq!(
1465            cardinality_labels,
1466            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
1467        );
1468    }
1469
1470    #[test]
1471    fn describe_fixture_constructors_stay_usable() {
1472        let payload = EntitySchemaDescription::new(
1473            "entities::User".to_string(),
1474            "User".to_string(),
1475            "id".to_string(),
1476            vec![EntityFieldDescription::new(
1477                "id".to_string(),
1478                Some(0),
1479                "ulid".to_string(),
1480                false,
1481                true,
1482                true,
1483                "generated".to_string(),
1484            )],
1485            vec![EntityIndexDescription::new(
1486                "idx_email".to_string(),
1487                true,
1488                vec!["email".to_string()],
1489                "generated".to_string(),
1490            )],
1491            vec![EntityRelationDescription::new(
1492                "account_id".to_string(),
1493                "entities::Account".to_string(),
1494                "Account".to_string(),
1495                "accounts".to_string(),
1496                EntityRelationStrength::Strong,
1497                EntityRelationCardinality::Single,
1498            )],
1499        );
1500
1501        assert_eq!(payload.entity_name(), "User");
1502        assert_eq!(payload.primary_key(), "id");
1503        assert_eq!(payload.primary_key_fields(), ["id".to_string()].as_slice());
1504        assert_eq!(payload.fields().len(), 1);
1505        assert_eq!(payload.indexes().len(), 1);
1506        assert_eq!(payload.relations().len(), 1);
1507    }
1508
1509    #[test]
1510    fn describe_entity_model_marks_all_composite_primary_key_fields() {
1511        let described = describe_entity_model(&DESCRIBE_COMPOSITE_PK_MODEL);
1512        let primary_key_fields = described
1513            .fields()
1514            .iter()
1515            .filter(|field| field.primary_key())
1516            .map(EntityFieldDescription::name)
1517            .collect::<Vec<_>>();
1518
1519        assert_eq!(described.primary_key(), "tenant_id, local_id");
1520        assert_eq!(
1521            described.primary_key_fields(),
1522            ["tenant_id".to_string(), "local_id".to_string()].as_slice(),
1523        );
1524        assert_eq!(primary_key_fields, ["tenant_id", "local_id"]);
1525    }
1526
1527    #[test]
1528    fn schema_describe_relations_match_relation_field_metadata() {
1529        let metadata =
1530            relation_field_metadata_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1531        let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1532        let relations = described.relations();
1533
1534        assert_eq!(metadata.len(), relations.len());
1535
1536        for (metadata, relation) in metadata.iter().zip(relations) {
1537            assert_eq!(relation.field(), metadata.field_name());
1538            assert_eq!(relation.target_path(), metadata.target_path());
1539            assert_eq!(relation.target_entity_name(), metadata.target_entity_name());
1540            assert_eq!(relation.target_store_path(), metadata.target_store_path());
1541            assert_eq!(
1542                relation.strength(),
1543                match metadata.strength() {
1544                    RelationStrength::Strong => EntityRelationStrength::Strong,
1545                    RelationStrength::Weak => EntityRelationStrength::Weak,
1546                }
1547            );
1548            assert_eq!(
1549                relation.cardinality(),
1550                match metadata.cardinality() {
1551                    RelationFieldCardinality::Single => EntityRelationCardinality::Single,
1552                    RelationFieldCardinality::List => EntityRelationCardinality::List,
1553                    RelationFieldCardinality::Set => EntityRelationCardinality::Set,
1554                }
1555            );
1556        }
1557    }
1558
1559    #[test]
1560    fn accepted_schema_describe_relations_use_persisted_relation_authority() {
1561        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1562            SchemaVersion::initial(),
1563            "entities::AcceptedSource".to_string(),
1564            "AcceptedSource".to_string(),
1565            FieldId::new(1),
1566            SchemaRowLayout::new(
1567                SchemaVersion::initial(),
1568                vec![
1569                    (FieldId::new(1), SchemaFieldSlot::new(0)),
1570                    (FieldId::new(2), SchemaFieldSlot::new(1)),
1571                ],
1572            ),
1573            vec![
1574                PersistedFieldSnapshot::new(
1575                    FieldId::new(1),
1576                    "id".to_string(),
1577                    SchemaFieldSlot::new(0),
1578                    PersistedFieldKind::Ulid,
1579                    Vec::new(),
1580                    false,
1581                    SchemaFieldDefault::None,
1582                    FieldStorageDecode::ByKind,
1583                    LeafCodec::StructuralFallback,
1584                ),
1585                PersistedFieldSnapshot::new(
1586                    FieldId::new(2),
1587                    "accepted_targets".to_string(),
1588                    SchemaFieldSlot::new(1),
1589                    PersistedFieldKind::Set(Box::new(PersistedFieldKind::Relation {
1590                        target_path: "accepted::Target".to_string(),
1591                        target_entity_name: "AcceptedTarget".to_string(),
1592                        target_entity_tag: EntityTag::new(0xD0A1),
1593                        target_store_path: "accepted::TargetStore".to_string(),
1594                        key_kind: Box::new(PersistedFieldKind::Nat128),
1595                        strength: PersistedRelationStrength::Strong,
1596                    })),
1597                    Vec::new(),
1598                    false,
1599                    SchemaFieldDefault::None,
1600                    FieldStorageDecode::ByKind,
1601                    LeafCodec::StructuralFallback,
1602                ),
1603            ],
1604        ));
1605
1606        let described =
1607            describe_entity_model_with_persisted_schema(&DESCRIBE_RELATION_MODEL, &snapshot);
1608
1609        assert_eq!(described.entity_path(), "entities::AcceptedSource");
1610        assert_eq!(described.entity_name(), "AcceptedSource");
1611        assert_eq!(
1612            described.primary_key_fields(),
1613            ["id".to_string()].as_slice()
1614        );
1615        assert_eq!(described.relations().len(), 1);
1616
1617        let relation = &described.relations()[0];
1618        assert_eq!(relation.field(), "accepted_targets");
1619        assert_eq!(relation.target_path(), "accepted::Target");
1620        assert_eq!(relation.target_entity_name(), "AcceptedTarget");
1621        assert_eq!(relation.target_store_path(), "accepted::TargetStore");
1622        assert_eq!(relation.strength(), EntityRelationStrength::Strong);
1623        assert_eq!(relation.cardinality(), EntityRelationCardinality::Set);
1624    }
1625
1626    #[test]
1627    fn schema_describe_includes_text_max_len_contract() {
1628        static FIELDS: [FieldModel; 2] = [
1629            FieldModel::generated("id", FieldKind::Ulid),
1630            FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1631        ];
1632        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1633        static MODEL: EntityModel = EntityModel::generated(
1634            "entities::BoundedName",
1635            "BoundedName",
1636            &FIELDS[0],
1637            0,
1638            &FIELDS,
1639            &INDEXES,
1640        );
1641
1642        let described = describe_entity_model(&MODEL);
1643        let name_field = described
1644            .fields()
1645            .iter()
1646            .find(|field| field.name() == "name")
1647            .expect("bounded text field should be described");
1648
1649        assert_eq!(name_field.kind(), "text(max_len=16)");
1650    }
1651
1652    #[test]
1653    fn schema_describe_preserves_fixed_width_numeric_kind_labels() {
1654        static FIELDS: [FieldModel; 7] = [
1655            FieldModel::generated("id", FieldKind::Ulid),
1656            FieldModel::generated("small_signed", FieldKind::Int8),
1657            FieldModel::generated("cell_x", FieldKind::Nat16),
1658            FieldModel::generated("large_signed", FieldKind::Int64),
1659            FieldModel::generated("large_unsigned", FieldKind::Nat64),
1660            FieldModel::generated("huge_signed", FieldKind::IntBig { max_bytes: 384 }),
1661            FieldModel::generated("huge_unsigned", FieldKind::NatBig { max_bytes: 512 }),
1662        ];
1663        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1664        static MODEL: EntityModel = EntityModel::generated(
1665            "entities::FixedWidthNumbers",
1666            "FixedWidthNumbers",
1667            &FIELDS[0],
1668            0,
1669            &FIELDS,
1670            &INDEXES,
1671        );
1672
1673        let described = describe_entity_model(&MODEL)
1674            .fields()
1675            .iter()
1676            .map(|field| (field.name().to_string(), field.kind().to_string()))
1677            .collect::<Vec<_>>();
1678
1679        assert!(described.contains(&("small_signed".to_string(), "int8".to_string())));
1680        assert!(described.contains(&("cell_x".to_string(), "nat16".to_string())));
1681        assert!(described.contains(&("large_signed".to_string(), "int64".to_string())));
1682        assert!(described.contains(&("large_unsigned".to_string(), "nat64".to_string())));
1683        assert!(described.contains(&(
1684            "huge_signed".to_string(),
1685            "int_big(max_bytes=384)".to_string()
1686        )));
1687        assert!(described.contains(&(
1688            "huge_unsigned".to_string(),
1689            "nat_big(max_bytes=512)".to_string()
1690        )));
1691    }
1692
1693    #[test]
1694    fn schema_describe_includes_generated_database_default_metadata() {
1695        static DEFAULT_PAYLOAD: &[u8] = &[0xFF, 0x01, 7, 0, 0, 0, 0, 0, 0, 0];
1696        static FIELDS: [FieldModel; 2] = [
1697            FieldModel::generated("id", FieldKind::Ulid),
1698            FieldModel::generated_with_storage_decode_nullability_write_policies_database_default_and_nested_fields(
1699                "score",
1700                FieldKind::Nat64,
1701                FieldStorageDecode::ByKind,
1702                false,
1703                None,
1704                None,
1705                FieldDatabaseDefault::EncodedSlotPayload(DEFAULT_PAYLOAD),
1706                &[],
1707            ),
1708        ];
1709        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1710        static MODEL: EntityModel = EntityModel::generated(
1711            "entities::DefaultedScore",
1712            "DefaultedScore",
1713            &FIELDS[0],
1714            0,
1715            &FIELDS,
1716            &INDEXES,
1717        );
1718
1719        let described = describe_entity_model(&MODEL);
1720        let score_field = described
1721            .fields()
1722            .iter()
1723            .find(|field| field.name() == "score")
1724            .expect("database-defaulted score field should be described");
1725
1726        assert_eq!(
1727            score_field.kind(),
1728            "nat64 default=slot_payload(bytes=10, sha256=37746b8fe16bb6b4)"
1729        );
1730    }
1731
1732    #[test]
1733    fn schema_describe_uses_accepted_top_level_field_metadata() {
1734        let id_slot = SchemaFieldSlot::new(0);
1735        let payload_slot = SchemaFieldSlot::new(7);
1736        // The accepted wrapper below is intentionally inconsistent so this
1737        // metadata boundary proves row-layout authority owns slot answers.
1738        let stale_payload_field_slot = SchemaFieldSlot::new(3);
1739        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1740            SchemaVersion::initial(),
1741            "entities::BlobEvent".to_string(),
1742            "BlobEvent".to_string(),
1743            FieldId::new(1),
1744            SchemaRowLayout::new(
1745                SchemaVersion::initial(),
1746                vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1747            ),
1748            vec![
1749                PersistedFieldSnapshot::new(
1750                    FieldId::new(1),
1751                    "id".to_string(),
1752                    id_slot,
1753                    PersistedFieldKind::Ulid,
1754                    Vec::new(),
1755                    false,
1756                    SchemaFieldDefault::None,
1757                    FieldStorageDecode::ByKind,
1758                    LeafCodec::StructuralFallback,
1759                ),
1760                PersistedFieldSnapshot::new(
1761                    FieldId::new(2),
1762                    "payload".to_string(),
1763                    stale_payload_field_slot,
1764                    PersistedFieldKind::Blob { max_len: None },
1765                    Vec::new(),
1766                    false,
1767                    SchemaFieldDefault::SlotPayload(vec![0x10, 0x20, 0x30]),
1768                    FieldStorageDecode::ByKind,
1769                    LeafCodec::StructuralFallback,
1770                ),
1771            ],
1772        ));
1773
1774        let described = describe_entity_fields_with_persisted_schema(&snapshot)
1775            .into_iter()
1776            .map(|field| {
1777                (
1778                    field.name().to_string(),
1779                    field.slot(),
1780                    field.kind().to_string(),
1781                )
1782            })
1783            .collect::<Vec<_>>();
1784
1785        assert_eq!(
1786            described,
1787            vec![
1788                ("id".to_string(), Some(0), "ulid".to_string()),
1789                (
1790                    "payload".to_string(),
1791                    Some(7),
1792                    "blob(unbounded) default=slot_payload(bytes=3, sha256=8e1336ab78ebe687)"
1793                        .to_string()
1794                ),
1795            ],
1796        );
1797    }
1798
1799    #[test]
1800    fn schema_describe_preserves_accepted_fixed_width_numeric_kind_labels() {
1801        let id_slot = SchemaFieldSlot::new(0);
1802        let x_slot = SchemaFieldSlot::new(1);
1803        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1804            SchemaVersion::initial(),
1805            "entities::Grid".to_string(),
1806            "Grid".to_string(),
1807            FieldId::new(1),
1808            SchemaRowLayout::new(
1809                SchemaVersion::initial(),
1810                vec![(FieldId::new(1), id_slot), (FieldId::new(2), x_slot)],
1811            ),
1812            vec![
1813                PersistedFieldSnapshot::new(
1814                    FieldId::new(1),
1815                    "id".to_string(),
1816                    id_slot,
1817                    PersistedFieldKind::Ulid,
1818                    Vec::new(),
1819                    false,
1820                    SchemaFieldDefault::None,
1821                    FieldStorageDecode::ByKind,
1822                    LeafCodec::StructuralFallback,
1823                ),
1824                PersistedFieldSnapshot::new(
1825                    FieldId::new(2),
1826                    "x".to_string(),
1827                    x_slot,
1828                    PersistedFieldKind::Nat16,
1829                    Vec::new(),
1830                    false,
1831                    SchemaFieldDefault::None,
1832                    FieldStorageDecode::ByKind,
1833                    LeafCodec::Scalar(ScalarCodec::Nat64),
1834                ),
1835            ],
1836        ));
1837
1838        let described = describe_entity_fields_with_persisted_schema(&snapshot);
1839        let x = described
1840            .iter()
1841            .find(|field| field.name() == "x")
1842            .expect("accepted fixed-width field should be described");
1843
1844        assert_eq!(x.kind(), "nat16");
1845    }
1846
1847    #[test]
1848    fn schema_describe_uses_accepted_nested_leaf_metadata() {
1849        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1850            SchemaVersion::initial(),
1851            "entities::AcceptedProfile".to_string(),
1852            "AcceptedProfile".to_string(),
1853            FieldId::new(1),
1854            SchemaRowLayout::new(
1855                SchemaVersion::initial(),
1856                vec![
1857                    (FieldId::new(1), SchemaFieldSlot::new(0)),
1858                    (FieldId::new(2), SchemaFieldSlot::new(1)),
1859                ],
1860            ),
1861            vec![
1862                PersistedFieldSnapshot::new(
1863                    FieldId::new(1),
1864                    "id".to_string(),
1865                    SchemaFieldSlot::new(0),
1866                    PersistedFieldKind::Ulid,
1867                    Vec::new(),
1868                    false,
1869                    SchemaFieldDefault::None,
1870                    FieldStorageDecode::ByKind,
1871                    LeafCodec::StructuralFallback,
1872                ),
1873                PersistedFieldSnapshot::new(
1874                    FieldId::new(2),
1875                    "profile".to_string(),
1876                    SchemaFieldSlot::new(1),
1877                    PersistedFieldKind::Structured { queryable: true },
1878                    vec![PersistedNestedLeafSnapshot::new(
1879                        vec!["rank".to_string()],
1880                        PersistedFieldKind::Blob { max_len: None },
1881                        false,
1882                        FieldStorageDecode::ByKind,
1883                        LeafCodec::Scalar(ScalarCodec::Blob),
1884                    )],
1885                    false,
1886                    SchemaFieldDefault::None,
1887                    FieldStorageDecode::Value,
1888                    LeafCodec::StructuralFallback,
1889                ),
1890            ],
1891        ));
1892
1893        let described = describe_entity_fields_with_persisted_schema(&snapshot);
1894        let rank = described
1895            .iter()
1896            .find(|field| field.name() == "└─ rank")
1897            .expect("accepted nested leaf should be described");
1898
1899        assert_eq!(rank.slot(), None);
1900        assert_eq!(rank.kind(), "blob(unbounded)");
1901        assert!(rank.queryable());
1902    }
1903
1904    #[test]
1905    fn schema_describe_expands_generated_structured_field_leaves() {
1906        static NESTED_FIELDS: [FieldModel; 3] = [
1907            FieldModel::generated("name", FieldKind::Text { max_len: None }),
1908            FieldModel::generated("level", FieldKind::Nat64),
1909            FieldModel::generated("pid", FieldKind::Principal),
1910        ];
1911        static FIELDS: [FieldModel; 2] = [
1912            FieldModel::generated("id", FieldKind::Ulid),
1913            FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1914                "mentor",
1915                FieldKind::Structured { queryable: false },
1916                FieldStorageDecode::Value,
1917                false,
1918                None,
1919                None,
1920                &NESTED_FIELDS,
1921            ),
1922        ];
1923        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1924        static MODEL: EntityModel = EntityModel::generated(
1925            "entities::Character",
1926            "Character",
1927            &FIELDS[0],
1928            0,
1929            &FIELDS,
1930            &INDEXES,
1931        );
1932
1933        let described = describe_entity_model(&MODEL);
1934        let described_fields = described
1935            .fields()
1936            .iter()
1937            .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1938            .collect::<Vec<_>>();
1939
1940        assert_eq!(
1941            described_fields,
1942            vec![
1943                ("id", Some(0), "ulid", true),
1944                ("mentor", Some(1), "structured", false),
1945                ("├─ name", None, "text(unbounded)", true),
1946                ("├─ level", None, "nat64", true),
1947                ("└─ pid", None, "principal", true),
1948            ],
1949        );
1950    }
1951}