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;