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