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