Skip to main content

icydb_core/db/schema/
describe.rs

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