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