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