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