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            AcceptedFieldKind, AcceptedSchemaSnapshot, PersistedIndexKeyItemSnapshot,
13            PersistedIndexKeySnapshot, PersistedNestedLeafSnapshot, SchemaFieldDefault,
14            SchemaFieldSlot, field_type_from_persisted_kind,
15        },
16    },
17    model::{
18        entity::EntityModel,
19        field::{FieldDatabaseDefault, FieldKind, FieldModel},
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) cardinality: EntityRelationCardinality,
318}
319
320impl EntityRelationDescription {
321    /// Construct one relation description entry.
322    #[must_use]
323    pub const fn new(
324        field: String,
325        target_path: String,
326        target_entity_name: String,
327        target_store_path: String,
328        cardinality: EntityRelationCardinality,
329    ) -> Self {
330        Self {
331            field,
332            target_path,
333            target_entity_name,
334            target_store_path,
335            cardinality,
336        }
337    }
338
339    /// Borrow the source relation field name.
340    #[must_use]
341    pub const fn field(&self) -> &str {
342        self.field.as_str()
343    }
344
345    /// Borrow the relation target path.
346    #[must_use]
347    pub const fn target_path(&self) -> &str {
348        self.target_path.as_str()
349    }
350
351    /// Borrow the relation target entity name.
352    #[must_use]
353    pub const fn target_entity_name(&self) -> &str {
354        self.target_entity_name.as_str()
355    }
356
357    /// Borrow the relation target store path.
358    #[must_use]
359    pub const fn target_store_path(&self) -> &str {
360        self.target_store_path.as_str()
361    }
362
363    /// Return relation cardinality.
364    #[must_use]
365    pub const fn cardinality(&self) -> EntityRelationCardinality {
366        self.cardinality
367    }
368}
369
370#[cfg_attr(
371    doc,
372    doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
373)]
374#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
375pub enum EntityRelationCardinality {
376    Single,
377    List,
378    Set,
379}
380
381#[cfg_attr(
382    doc,
383    doc = "Build one stable entity-schema description from one runtime `EntityModel`."
384)]
385#[must_use]
386pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
387    let fields = describe_entity_fields(model);
388    let primary_key_fields = primary_key_field_names_from_model(model);
389    let primary_key = render_primary_key_fields(primary_key_fields.as_slice());
390
391    describe_entity_model_from_description_rows(
392        model.path,
393        model.entity_name,
394        primary_key.as_str(),
395        primary_key_fields,
396        fields,
397        describe_entity_indexes_from_model(model),
398        describe_entity_relations_from_model(model),
399    )
400}
401
402#[cfg_attr(
403    doc,
404    doc = "Build one entity-schema description using accepted persisted schema slot metadata."
405)]
406#[must_use]
407pub(in crate::db) fn describe_entity_model_with_persisted_schema(
408    model: &EntityModel,
409    schema: &AcceptedSchemaSnapshot,
410) -> EntitySchemaDescription {
411    let fields = describe_entity_fields_with_persisted_schema(schema);
412    let primary_key_fields = schema.primary_key_field_names();
413    let primary_key_fields = if primary_key_fields.is_empty() {
414        vec![model.primary_key.name.to_string()]
415    } else {
416        primary_key_fields
417            .into_iter()
418            .map(str::to_string)
419            .collect::<Vec<_>>()
420    };
421    let primary_key = render_primary_key_fields(primary_key_fields.as_slice());
422
423    describe_entity_model_from_description_rows(
424        schema.entity_path(),
425        schema.entity_name(),
426        primary_key.as_str(),
427        primary_key_fields,
428        fields,
429        describe_entity_indexes_with_persisted_schema(schema),
430        describe_entity_relations_with_persisted_schema(schema),
431    )
432}
433
434// Assemble the common DESCRIBE payload once field rows have already been built.
435// Callers project relation descriptions from the same authority as their field
436// and index rows, so accepted DESCRIBE output does not fall back to generated
437// relation metadata.
438fn describe_entity_model_from_description_rows(
439    entity_path: &str,
440    entity_name: &str,
441    primary_key: &str,
442    primary_key_fields: Vec<String>,
443    fields: Vec<EntityFieldDescription>,
444    indexes: Vec<EntityIndexDescription>,
445    relations: Vec<EntityRelationDescription>,
446) -> EntitySchemaDescription {
447    EntitySchemaDescription::new_with_primary_key_fields(
448        entity_path.to_string(),
449        entity_name.to_string(),
450        primary_key.to_string(),
451        primary_key_fields,
452        fields,
453        indexes,
454        relations,
455    )
456}
457
458fn describe_entity_relations_from_model(model: &EntityModel) -> Vec<EntityRelationDescription> {
459    relation_field_metadata_for_model_iter(model)
460        .map(relation_description_from_metadata)
461        .collect()
462}
463
464fn primary_key_field_names_from_model(model: &EntityModel) -> Vec<String> {
465    model
466        .primary_key_model()
467        .fields()
468        .iter()
469        .map(|field| field.name.to_string())
470        .collect()
471}
472
473fn render_primary_key_fields(fields: &[String]) -> String {
474    fields.join(", ")
475}
476
477fn describe_entity_indexes_from_model(model: &EntityModel) -> Vec<EntityIndexDescription> {
478    let mut indexes = Vec::with_capacity(model.indexes.len());
479    for index in model.indexes {
480        indexes.push(EntityIndexDescription::new(
481            index.name().to_string(),
482            index.is_unique(),
483            index
484                .fields()
485                .iter()
486                .map(|field| (*field).to_string())
487                .collect(),
488            "generated".to_string(),
489        ));
490    }
491
492    indexes
493}
494
495fn describe_entity_indexes_with_persisted_schema(
496    schema: &AcceptedSchemaSnapshot,
497) -> Vec<EntityIndexDescription> {
498    schema
499        .persisted_snapshot()
500        .indexes()
501        .iter()
502        .map(|index| {
503            EntityIndexDescription::new(
504                index.name().to_string(),
505                index.unique(),
506                describe_persisted_index_fields(index.key()),
507                if index.generated() {
508                    "generated".to_string()
509                } else {
510                    "ddl".to_string()
511                },
512            )
513        })
514        .collect()
515}
516
517fn describe_persisted_index_fields(key: &PersistedIndexKeySnapshot) -> Vec<String> {
518    match key {
519        PersistedIndexKeySnapshot::FieldPath(paths) => paths
520            .iter()
521            .map(|field_path| field_path.path().join("."))
522            .collect(),
523        PersistedIndexKeySnapshot::Items(items) => items
524            .iter()
525            .map(|item| match item {
526                PersistedIndexKeyItemSnapshot::FieldPath(field_path) => field_path.path().join("."),
527                PersistedIndexKeyItemSnapshot::Expression(expression) => {
528                    expression.canonical_text().to_string()
529                }
530            })
531            .collect(),
532    }
533}
534
535// Build the stable field-description subset once from one runtime model so
536// metadata surfaces that only need columns do not rebuild indexes and
537// relations through the heavier DESCRIBE payload path.
538#[must_use]
539pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
540    describe_entity_fields_with_slot_lookup(model, |slot, _field| {
541        Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
542    })
543}
544
545#[cfg_attr(
546    doc,
547    doc = "Build field descriptors using accepted persisted schema slot metadata."
548)]
549#[must_use]
550pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
551    schema: &AcceptedSchemaSnapshot,
552) -> Vec<EntityFieldDescription> {
553    let snapshot = schema.persisted_snapshot();
554    let mut fields = Vec::with_capacity(snapshot.fields().len());
555
556    // Accepted-schema describe surfaces must follow the stored schema payload,
557    // not the generated model's current field order.
558    for field in snapshot.fields() {
559        let primary_key = snapshot.primary_key_field_ids().contains(&field.id());
560        let slot = snapshot
561            .row_layout()
562            .slot_for_field(field.id())
563            .map(SchemaFieldSlot::get);
564        let mut kind = summarize_persisted_field_kind(field.kind());
565        write_schema_default_summary(&mut kind, field.default());
566        let metadata = DescribeFieldMetadata::new(
567            kind,
568            field.nullable(),
569            field_type_from_persisted_kind(field.kind())
570                .value_kind()
571                .is_queryable(),
572            field_origin_label(field.generated()),
573        );
574
575        push_described_field_row(&mut fields, field.name(), slot, primary_key, None, metadata);
576
577        if !field.nested_leaves().is_empty() {
578            describe_persisted_nested_leaves(
579                &mut fields,
580                field.nested_leaves(),
581                field_origin_label(field.generated()),
582            );
583        }
584    }
585
586    fields
587}
588
589// Build field descriptors with an injected top-level slot lookup. Generated
590// model introspection uses generated positions; live-schema introspection uses
591// accepted persisted row layout metadata while preserving nested-field behavior.
592fn describe_entity_fields_with_slot_lookup(
593    model: &EntityModel,
594    mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
595) -> Vec<EntityFieldDescription> {
596    let mut fields = Vec::with_capacity(model.fields.len());
597    let primary_key_fields = primary_key_field_names_from_model(model);
598
599    for (slot, field) in model.fields.iter().enumerate() {
600        let primary_key = primary_key_fields
601            .iter()
602            .any(|primary_key_field| primary_key_field == field.name);
603        describe_field_recursive(
604            &mut fields,
605            field.name,
606            slot_for_field(slot, field),
607            field,
608            primary_key,
609            None,
610            None,
611        );
612    }
613
614    fields
615}
616
617///
618/// DescribeFieldMetadata
619///
620/// Field-description metadata selected before recursive field rendering.
621/// Accepted-schema metadata can override generated model facts for top-level
622/// fields and, when available, nested leaf rows.
623///
624
625struct DescribeFieldMetadata {
626    kind: String,
627    nullable: bool,
628    queryable: bool,
629    origin: String,
630}
631
632impl DescribeFieldMetadata {
633    // Build one metadata bundle from already-rendered field facts.
634    const fn new(kind: String, nullable: bool, queryable: bool, origin: String) -> Self {
635        Self {
636            kind,
637            nullable,
638            queryable,
639            origin,
640        }
641    }
642}
643
644// Add one generated field and any generated structured-record leaves so
645// DESCRIBE/SHOW COLUMNS expose the same nested rows SQL can project and filter.
646fn describe_field_recursive(
647    fields: &mut Vec<EntityFieldDescription>,
648    name: &str,
649    slot: Option<u16>,
650    field: &FieldModel,
651    primary_key: bool,
652    tree_prefix: Option<&'static str>,
653    metadata_override: Option<DescribeFieldMetadata>,
654) {
655    let metadata = metadata_override.unwrap_or_else(|| {
656        let mut kind = summarize_field_kind(&field.kind);
657        write_model_default_summary(&mut kind, field.database_default());
658
659        DescribeFieldMetadata::new(
660            kind,
661            field.nullable(),
662            field.kind.value_kind().is_queryable(),
663            "generated".to_string(),
664        )
665    });
666
667    push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
668    describe_generated_nested_fields(fields, field.nested_fields());
669}
670
671// Add one already-resolved field row to the stable describe DTO list. The
672// caller owns where metadata came from: generated model or accepted schema.
673fn push_described_field_row(
674    fields: &mut Vec<EntityFieldDescription>,
675    name: &str,
676    slot: Option<u16>,
677    primary_key: bool,
678    tree_prefix: Option<&'static str>,
679    metadata: DescribeFieldMetadata,
680) {
681    // Nested field rows keep a compact tree marker so table-oriented describe
682    // output scans as a hierarchy without assigning nested leaves row slots.
683    let display_name = if let Some(prefix) = tree_prefix {
684        format!("{prefix}{name}")
685    } else {
686        name.to_string()
687    };
688
689    fields.push(EntityFieldDescription::new(
690        display_name,
691        slot,
692        metadata.kind,
693        metadata.nullable,
694        primary_key,
695        metadata.queryable,
696        metadata.origin,
697    ));
698}
699
700// Render generated nested field metadata recursively. Generated and accepted
701// top-level describe paths both use this fallback when no accepted nested leaf
702// descriptors are available yet.
703fn describe_generated_nested_fields(
704    fields: &mut Vec<EntityFieldDescription>,
705    nested_fields: &[FieldModel],
706) {
707    for (index, nested) in nested_fields.iter().enumerate() {
708        let prefix = if index + 1 == nested_fields.len() {
709            "└─ "
710        } else {
711            "├─ "
712        };
713        describe_field_recursive(
714            fields,
715            nested.name(),
716            None,
717            nested,
718            false,
719            Some(prefix),
720            None,
721        );
722    }
723}
724
725// Render accepted nested leaf descriptors. Nested leaves do not own physical
726// row slots, so they always appear with the no-slot sentinel in the Candid DTO.
727fn describe_persisted_nested_leaves(
728    fields: &mut Vec<EntityFieldDescription>,
729    nested_leaves: &[PersistedNestedLeafSnapshot],
730    origin: String,
731) {
732    for (index, leaf) in nested_leaves.iter().enumerate() {
733        let prefix = if index + 1 == nested_leaves.len() {
734            "└─ "
735        } else {
736            "├─ "
737        };
738        let name = leaf.path().last().map_or("", String::as_str);
739        let metadata = DescribeFieldMetadata::new(
740            summarize_persisted_field_kind(leaf.kind()),
741            leaf.nullable(),
742            field_type_from_persisted_kind(leaf.kind())
743                .value_kind()
744                .is_queryable(),
745            origin.clone(),
746        );
747
748        push_described_field_row(fields, name, None, false, Some(prefix), metadata);
749    }
750}
751
752fn field_origin_label(generated: bool) -> String {
753    if generated {
754        "generated".to_string()
755    } else {
756        "ddl".to_string()
757    }
758}
759
760fn describe_entity_relations_with_persisted_schema(
761    schema: &AcceptedSchemaSnapshot,
762) -> Vec<EntityRelationDescription> {
763    schema
764        .persisted_snapshot()
765        .fields()
766        .iter()
767        .filter_map(relation_description_from_persisted_field)
768        .collect()
769}
770
771fn relation_description_from_persisted_field(
772    field: &crate::db::schema::PersistedFieldSnapshot,
773) -> Option<EntityRelationDescription> {
774    let relation = persisted_relation_description_metadata(field.kind())?;
775
776    Some(EntityRelationDescription::new(
777        field.name().to_string(),
778        relation.target_path.to_string(),
779        relation.target_entity_name.to_string(),
780        relation.target_store_path.to_string(),
781        relation.cardinality,
782    ))
783}
784
785struct PersistedRelationDescriptionMetadata<'a> {
786    target_path: &'a str,
787    target_entity_name: &'a str,
788    target_store_path: &'a str,
789    cardinality: EntityRelationCardinality,
790}
791
792fn persisted_relation_description_metadata(
793    kind: &AcceptedFieldKind,
794) -> Option<PersistedRelationDescriptionMetadata<'_>> {
795    const fn from_relation_kind(
796        kind: &AcceptedFieldKind,
797        cardinality: EntityRelationCardinality,
798    ) -> Option<PersistedRelationDescriptionMetadata<'_>> {
799        let AcceptedFieldKind::Relation {
800            target_path,
801            target_entity_name,
802            target_store_path,
803            ..
804        } = kind
805        else {
806            return None;
807        };
808
809        Some(PersistedRelationDescriptionMetadata {
810            target_path: target_path.as_str(),
811            target_entity_name: target_entity_name.as_str(),
812            target_store_path: target_store_path.as_str(),
813            cardinality,
814        })
815    }
816
817    match kind {
818        AcceptedFieldKind::Relation { .. } => {
819            from_relation_kind(kind, EntityRelationCardinality::Single)
820        }
821        AcceptedFieldKind::List(inner) => {
822            from_relation_kind(inner, EntityRelationCardinality::List)
823        }
824        AcceptedFieldKind::Set(inner) => from_relation_kind(inner, EntityRelationCardinality::Set),
825        _ => None,
826    }
827}
828
829// Project relation-owned metadata into the stable describe DTO surface.
830fn relation_description_from_metadata(
831    metadata: RelationFieldMetadata,
832) -> EntityRelationDescription {
833    let cardinality = match metadata.cardinality() {
834        RelationFieldCardinality::Single => EntityRelationCardinality::Single,
835        RelationFieldCardinality::List => EntityRelationCardinality::List,
836        RelationFieldCardinality::Set => EntityRelationCardinality::Set,
837    };
838
839    EntityRelationDescription::new(
840        metadata.field_name().to_string(),
841        metadata.target_path().to_string(),
842        metadata.target_entity_name().to_string(),
843        metadata.target_store_path().to_string(),
844        cardinality,
845    )
846}
847
848#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
849fn summarize_field_kind(kind: &FieldKind) -> String {
850    let mut out = String::new();
851    write_field_kind_summary(&mut out, kind);
852
853    out
854}
855
856// Stream one stable field-kind label directly into the output buffer so
857// describe/sql surfaces do not retain a large recursive `format!` family.
858fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
859    if let Some(name) = kind.describe_kind_name() {
860        out.push_str(name);
861        return;
862    }
863
864    match kind {
865        FieldKind::Blob { max_len } => {
866            write_length_bounded_field_kind_summary(out, "blob", *max_len);
867        }
868        FieldKind::Decimal { scale } => {
869            let _ = write!(out, "decimal(scale={scale})");
870        }
871        FieldKind::IntBig { max_bytes } => {
872            write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
873        }
874        FieldKind::Enum { path, .. } => {
875            out.push_str("enum(");
876            out.push_str(path);
877            out.push(')');
878        }
879        FieldKind::Text { max_len } => {
880            write_length_bounded_field_kind_summary(out, "text", *max_len);
881        }
882        FieldKind::Relation {
883            target_entity_name,
884            key_kind,
885            ..
886        } => {
887            out.push_str("relation(target=");
888            out.push_str(target_entity_name);
889            out.push_str(", key=");
890            write_field_kind_summary(out, key_kind);
891            out.push(')');
892        }
893        FieldKind::List(inner) => {
894            out.push_str("list<");
895            write_field_kind_summary(out, inner);
896            out.push('>');
897        }
898        FieldKind::Set(inner) => {
899            out.push_str("set<");
900            write_field_kind_summary(out, inner);
901            out.push('>');
902        }
903        FieldKind::Map { key, value } => {
904            out.push_str("map<");
905            write_field_kind_summary(out, key);
906            out.push_str(", ");
907            write_field_kind_summary(out, value);
908            out.push('>');
909        }
910        FieldKind::Structured { .. } => {
911            out.push_str("structured");
912        }
913        FieldKind::Account
914        | FieldKind::Bool
915        | FieldKind::Date
916        | FieldKind::Duration
917        | FieldKind::Float32
918        | FieldKind::Float64
919        | FieldKind::Int8
920        | FieldKind::Int16
921        | FieldKind::Int32
922        | FieldKind::Int64
923        | FieldKind::Int128
924        | FieldKind::Principal
925        | FieldKind::Subaccount
926        | FieldKind::Timestamp
927        | FieldKind::Nat8
928        | FieldKind::Nat16
929        | FieldKind::Nat32
930        | FieldKind::Nat64
931        | FieldKind::Nat128
932        | FieldKind::Ulid
933        | FieldKind::Unit => unreachable!("schema describe invariant"),
934        FieldKind::NatBig { max_bytes } => {
935            write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
936        }
937    }
938}
939
940trait DescribeKindName {
941    fn describe_kind_name(&self) -> Option<&'static str>;
942}
943
944impl DescribeKindName for FieldKind {
945    fn describe_kind_name(&self) -> Option<&'static str> {
946        Some(match self {
947            Self::Account => "account",
948            Self::Bool => "bool",
949            Self::Date => "date",
950            Self::Duration => "duration",
951            Self::Float32 => "float32",
952            Self::Float64 => "float64",
953            Self::Int8 => "int8",
954            Self::Int16 => "int16",
955            Self::Int32 => "int32",
956            Self::Int64 => "int64",
957            Self::Int128 => "int128",
958            Self::Principal => "principal",
959            Self::Subaccount => "subaccount",
960            Self::Timestamp => "timestamp",
961            Self::Nat8 => "nat8",
962            Self::Nat16 => "nat16",
963            Self::Nat32 => "nat32",
964            Self::Nat64 => "nat64",
965            Self::Nat128 => "nat128",
966            Self::Ulid => "ulid",
967            Self::Unit => "unit",
968            Self::Blob { .. }
969            | Self::Decimal { .. }
970            | Self::Enum { .. }
971            | Self::IntBig { .. }
972            | Self::NatBig { .. }
973            | Self::Text { .. }
974            | Self::Relation { .. }
975            | Self::List(_)
976            | Self::Set(_)
977            | Self::Map { .. }
978            | Self::Structured { .. } => return None,
979        })
980    }
981}
982
983// Write the common text/blob describe label. Both generated and accepted schema
984// summaries use this path so bounded and explicitly unbounded contracts stay
985// visibly identical across `DESCRIBE` and `SHOW COLUMNS`.
986fn write_length_bounded_field_kind_summary(
987    out: &mut String,
988    kind_name: &str,
989    max_len: Option<u32>,
990) {
991    out.push_str(kind_name);
992    if let Some(max_len) = max_len {
993        out.push_str("(max_len=");
994        out.push_str(&max_len.to_string());
995        out.push(')');
996    } else {
997        out.push_str("(unbounded)");
998    }
999}
1000
1001fn write_byte_bounded_field_kind_summary(out: &mut String, kind_name: &str, max_bytes: u32) {
1002    out.push_str(kind_name);
1003    out.push_str("(max_bytes=");
1004    out.push_str(&max_bytes.to_string());
1005    out.push(')');
1006}
1007
1008// Append database-default metadata without decoding the stored payload back
1009// into a runtime value. Schema describe owns metadata projection, while field
1010// codecs own payload interpretation.
1011fn write_model_default_summary(out: &mut String, default: FieldDatabaseDefault) {
1012    match default {
1013        FieldDatabaseDefault::None => {}
1014        FieldDatabaseDefault::EncodedSlotPayload(payload) => {
1015            write_encoded_default_payload_summary(out, payload);
1016        }
1017        FieldDatabaseDefault::AuthoredEnumUnit { enum_path, variant } => {
1018            out.push_str(" default=enum_unit(");
1019            out.push_str(enum_path);
1020            out.push_str("::");
1021            out.push_str(variant);
1022            out.push(')');
1023        }
1024    }
1025}
1026
1027// Append accepted-schema database-default metadata from its persisted payload.
1028// Generated authored enum defaults remain name-based proposal metadata until
1029// catalog admission, so only accepted schema exposes their durable byte shape.
1030fn write_schema_default_summary(out: &mut String, default: &SchemaFieldDefault) {
1031    if let Some(payload) = default.slot_payload() {
1032        write_encoded_default_payload_summary(out, payload);
1033    }
1034}
1035
1036// Keep default rendering compact and byte-oriented. Persisted schema defaults
1037// are field-codec payloads, not SQL literals, so the describe surface reports
1038// their presence and a stable fingerprint rather than inventing a lossy value.
1039fn write_encoded_default_payload_summary(out: &mut String, payload: &[u8]) {
1040    let _ = write!(
1041        out,
1042        " default=slot_payload(bytes={}, sha256={})",
1043        payload.len(),
1044        short_default_payload_fingerprint(payload),
1045    );
1046}
1047
1048fn short_default_payload_fingerprint(payload: &[u8]) -> String {
1049    let digest = Sha256::digest(payload);
1050    let mut out = String::with_capacity(16);
1051    for byte in &digest[..8] {
1052        let _ = write!(out, "{byte:02x}");
1053    }
1054    out
1055}
1056
1057#[cfg_attr(
1058    doc,
1059    doc = "Render one stable field-kind label from accepted persisted schema metadata."
1060)]
1061fn summarize_persisted_field_kind(kind: &AcceptedFieldKind) -> String {
1062    let mut out = String::new();
1063    write_persisted_field_kind_summary(&mut out, kind);
1064
1065    out
1066}
1067
1068// Stream the accepted persisted field-kind label in the same public format as
1069// generated `FieldKind` summaries. Top-level live-schema metadata can then
1070// drive DESCRIBE output without converting back into generated static types.
1071fn write_persisted_field_kind_summary(out: &mut String, kind: &AcceptedFieldKind) {
1072    if let Some(name) = kind.describe_kind_name() {
1073        out.push_str(name);
1074        return;
1075    }
1076
1077    match kind {
1078        AcceptedFieldKind::Blob { max_len } => {
1079            write_length_bounded_field_kind_summary(out, "blob", *max_len);
1080        }
1081        AcceptedFieldKind::Decimal { scale } => {
1082            let _ = write!(out, "decimal(scale={scale})");
1083        }
1084        AcceptedFieldKind::IntBig { max_bytes } => {
1085            write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
1086        }
1087        AcceptedFieldKind::Enum { type_id } => {
1088            let _ = write!(out, "enum(type_id={})", type_id.get());
1089        }
1090        AcceptedFieldKind::Text { max_len } => {
1091            write_length_bounded_field_kind_summary(out, "text", *max_len);
1092        }
1093        AcceptedFieldKind::Relation {
1094            target_entity_name,
1095            key_kind,
1096            ..
1097        } => {
1098            out.push_str("relation(target=");
1099            out.push_str(target_entity_name);
1100            out.push_str(", key=");
1101            write_persisted_field_kind_summary(out, key_kind);
1102            out.push(')');
1103        }
1104        AcceptedFieldKind::List(inner) => {
1105            out.push_str("list<");
1106            write_persisted_field_kind_summary(out, inner);
1107            out.push('>');
1108        }
1109        AcceptedFieldKind::Set(inner) => {
1110            out.push_str("set<");
1111            write_persisted_field_kind_summary(out, inner);
1112            out.push('>');
1113        }
1114        AcceptedFieldKind::Map { key, value } => {
1115            out.push_str("map<");
1116            write_persisted_field_kind_summary(out, key);
1117            out.push_str(", ");
1118            write_persisted_field_kind_summary(out, value);
1119            out.push('>');
1120        }
1121        AcceptedFieldKind::Structured { .. } => {
1122            out.push_str("structured");
1123        }
1124        AcceptedFieldKind::Account
1125        | AcceptedFieldKind::Bool
1126        | AcceptedFieldKind::Date
1127        | AcceptedFieldKind::Duration
1128        | AcceptedFieldKind::Float32
1129        | AcceptedFieldKind::Float64
1130        | AcceptedFieldKind::Int8
1131        | AcceptedFieldKind::Int16
1132        | AcceptedFieldKind::Int32
1133        | AcceptedFieldKind::Int64
1134        | AcceptedFieldKind::Int128
1135        | AcceptedFieldKind::Principal
1136        | AcceptedFieldKind::Subaccount
1137        | AcceptedFieldKind::Timestamp
1138        | AcceptedFieldKind::Nat8
1139        | AcceptedFieldKind::Nat16
1140        | AcceptedFieldKind::Nat32
1141        | AcceptedFieldKind::Nat64
1142        | AcceptedFieldKind::Nat128
1143        | AcceptedFieldKind::Ulid
1144        | AcceptedFieldKind::Unit => unreachable!("schema describe invariant"),
1145        AcceptedFieldKind::NatBig { max_bytes } => {
1146            write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
1147        }
1148    }
1149}
1150
1151impl DescribeKindName for AcceptedFieldKind {
1152    fn describe_kind_name(&self) -> Option<&'static str> {
1153        Some(match self {
1154            Self::Account => "account",
1155            Self::Bool => "bool",
1156            Self::Date => "date",
1157            Self::Duration => "duration",
1158            Self::Float32 => "float32",
1159            Self::Float64 => "float64",
1160            Self::Int8 => "int8",
1161            Self::Int16 => "int16",
1162            Self::Int32 => "int32",
1163            Self::Int64 => "int64",
1164            Self::Int128 => "int128",
1165            Self::Principal => "principal",
1166            Self::Subaccount => "subaccount",
1167            Self::Timestamp => "timestamp",
1168            Self::Nat8 => "nat8",
1169            Self::Nat16 => "nat16",
1170            Self::Nat32 => "nat32",
1171            Self::Nat64 => "nat64",
1172            Self::Nat128 => "nat128",
1173            Self::Ulid => "ulid",
1174            Self::Unit => "unit",
1175            Self::Blob { .. }
1176            | Self::Decimal { .. }
1177            | Self::Enum { .. }
1178            | Self::IntBig { .. }
1179            | Self::NatBig { .. }
1180            | Self::Text { .. }
1181            | Self::Relation { .. }
1182            | Self::List(_)
1183            | Self::Set(_)
1184            | Self::Map { .. }
1185            | Self::Structured { .. } => return None,
1186        })
1187    }
1188}
1189
1190//
1191// TESTS
1192//
1193
1194#[cfg(test)]
1195mod tests;