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::AcceptedSchemaSnapshot,
12    },
13    model::{
14        entity::EntityModel,
15        field::{FieldKind, FieldModel, RelationStrength},
16    },
17};
18use candid::CandidType;
19use serde::Deserialize;
20use std::{collections::BTreeMap, fmt::Write};
21
22const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
23
24#[cfg_attr(
25    doc,
26    doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
27)]
28#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
29pub struct EntitySchemaDescription {
30    pub(crate) entity_path: String,
31    pub(crate) entity_name: String,
32    pub(crate) primary_key: String,
33    pub(crate) fields: Vec<EntityFieldDescription>,
34    pub(crate) indexes: Vec<EntityIndexDescription>,
35    pub(crate) relations: Vec<EntityRelationDescription>,
36}
37
38impl EntitySchemaDescription {
39    /// Construct one entity schema description payload.
40    #[must_use]
41    pub const fn new(
42        entity_path: String,
43        entity_name: String,
44        primary_key: String,
45        fields: Vec<EntityFieldDescription>,
46        indexes: Vec<EntityIndexDescription>,
47        relations: Vec<EntityRelationDescription>,
48    ) -> Self {
49        Self {
50            entity_path,
51            entity_name,
52            primary_key,
53            fields,
54            indexes,
55            relations,
56        }
57    }
58
59    /// Borrow the entity module path.
60    #[must_use]
61    pub const fn entity_path(&self) -> &str {
62        self.entity_path.as_str()
63    }
64
65    /// Borrow the entity display name.
66    #[must_use]
67    pub const fn entity_name(&self) -> &str {
68        self.entity_name.as_str()
69    }
70
71    /// Borrow the primary-key field name.
72    #[must_use]
73    pub const fn primary_key(&self) -> &str {
74        self.primary_key.as_str()
75    }
76
77    /// Borrow field description entries.
78    #[must_use]
79    pub const fn fields(&self) -> &[EntityFieldDescription] {
80        self.fields.as_slice()
81    }
82
83    /// Borrow index description entries.
84    #[must_use]
85    pub const fn indexes(&self) -> &[EntityIndexDescription] {
86        self.indexes.as_slice()
87    }
88
89    /// Borrow relation description entries.
90    #[must_use]
91    pub const fn relations(&self) -> &[EntityRelationDescription] {
92        self.relations.as_slice()
93    }
94}
95
96#[cfg_attr(
97    doc,
98    doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
99)]
100#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
101pub struct EntityFieldDescription {
102    pub(crate) name: String,
103    pub(crate) slot: u16,
104    pub(crate) kind: String,
105    pub(crate) primary_key: bool,
106    pub(crate) queryable: bool,
107}
108
109impl EntityFieldDescription {
110    /// Construct one field description entry.
111    #[must_use]
112    pub const fn new(
113        name: String,
114        slot: Option<u16>,
115        kind: String,
116        primary_key: bool,
117        queryable: bool,
118    ) -> Self {
119        let slot = match slot {
120            Some(slot) => slot,
121            None => ENTITY_FIELD_DESCRIPTION_NO_SLOT,
122        };
123
124        Self {
125            name,
126            slot,
127            kind,
128            primary_key,
129            queryable,
130        }
131    }
132
133    /// Borrow the field name.
134    #[must_use]
135    pub const fn name(&self) -> &str {
136        self.name.as_str()
137    }
138
139    /// Return the physical row slot for top-level fields.
140    #[must_use]
141    pub const fn slot(&self) -> Option<u16> {
142        if self.slot == ENTITY_FIELD_DESCRIPTION_NO_SLOT {
143            None
144        } else {
145            Some(self.slot)
146        }
147    }
148
149    /// Borrow the rendered field kind label.
150    #[must_use]
151    pub const fn kind(&self) -> &str {
152        self.kind.as_str()
153    }
154
155    /// Return whether this field is the primary key.
156    #[must_use]
157    pub const fn primary_key(&self) -> bool {
158        self.primary_key
159    }
160
161    /// Return whether this field is queryable.
162    #[must_use]
163    pub const fn queryable(&self) -> bool {
164        self.queryable
165    }
166}
167
168#[cfg_attr(
169    doc,
170    doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
171)]
172#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
173pub struct EntityIndexDescription {
174    pub(crate) name: String,
175    pub(crate) unique: bool,
176    pub(crate) fields: Vec<String>,
177}
178
179impl EntityIndexDescription {
180    /// Construct one index description entry.
181    #[must_use]
182    pub const fn new(name: String, unique: bool, fields: Vec<String>) -> Self {
183        Self {
184            name,
185            unique,
186            fields,
187        }
188    }
189
190    /// Borrow the index name.
191    #[must_use]
192    pub const fn name(&self) -> &str {
193        self.name.as_str()
194    }
195
196    /// Return whether the index enforces uniqueness.
197    #[must_use]
198    pub const fn unique(&self) -> bool {
199        self.unique
200    }
201
202    /// Borrow ordered index field names.
203    #[must_use]
204    pub const fn fields(&self) -> &[String] {
205        self.fields.as_slice()
206    }
207}
208
209#[cfg_attr(
210    doc,
211    doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
212)]
213#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
214pub struct EntityRelationDescription {
215    pub(crate) field: String,
216    pub(crate) target_path: String,
217    pub(crate) target_entity_name: String,
218    pub(crate) target_store_path: String,
219    pub(crate) strength: EntityRelationStrength,
220    pub(crate) cardinality: EntityRelationCardinality,
221}
222
223impl EntityRelationDescription {
224    /// Construct one relation description entry.
225    #[must_use]
226    pub const fn new(
227        field: String,
228        target_path: String,
229        target_entity_name: String,
230        target_store_path: String,
231        strength: EntityRelationStrength,
232        cardinality: EntityRelationCardinality,
233    ) -> Self {
234        Self {
235            field,
236            target_path,
237            target_entity_name,
238            target_store_path,
239            strength,
240            cardinality,
241        }
242    }
243
244    /// Borrow the source relation field name.
245    #[must_use]
246    pub const fn field(&self) -> &str {
247        self.field.as_str()
248    }
249
250    /// Borrow the relation target path.
251    #[must_use]
252    pub const fn target_path(&self) -> &str {
253        self.target_path.as_str()
254    }
255
256    /// Borrow the relation target entity name.
257    #[must_use]
258    pub const fn target_entity_name(&self) -> &str {
259        self.target_entity_name.as_str()
260    }
261
262    /// Borrow the relation target store path.
263    #[must_use]
264    pub const fn target_store_path(&self) -> &str {
265        self.target_store_path.as_str()
266    }
267
268    /// Return relation strength.
269    #[must_use]
270    pub const fn strength(&self) -> EntityRelationStrength {
271        self.strength
272    }
273
274    /// Return relation cardinality.
275    #[must_use]
276    pub const fn cardinality(&self) -> EntityRelationCardinality {
277        self.cardinality
278    }
279}
280
281#[cfg_attr(doc, doc = "EntityRelationStrength\n\nDescribe relation strength.")]
282#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
283pub enum EntityRelationStrength {
284    Strong,
285    Weak,
286}
287
288#[cfg_attr(
289    doc,
290    doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
291)]
292#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
293pub enum EntityRelationCardinality {
294    Single,
295    List,
296    Set,
297}
298
299#[cfg_attr(
300    doc,
301    doc = "Build one stable entity-schema description from one runtime `EntityModel`."
302)]
303#[must_use]
304pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
305    let fields = describe_entity_fields(model);
306
307    describe_entity_model_with_fields(model, fields)
308}
309
310#[cfg_attr(
311    doc,
312    doc = "Build one entity-schema description using accepted persisted schema slot metadata."
313)]
314#[must_use]
315pub(in crate::db) fn describe_entity_model_with_persisted_schema(
316    model: &EntityModel,
317    schema: &AcceptedSchemaSnapshot,
318) -> EntitySchemaDescription {
319    let fields = describe_entity_fields_with_persisted_schema(model, schema);
320
321    describe_entity_model_with_fields(model, fields)
322}
323
324// Assemble the common DESCRIBE payload once field rows have already been built.
325// This keeps live-schema slot overlays local to field description while index
326// and relation description remain generated-model owned for this phase.
327fn describe_entity_model_with_fields(
328    model: &EntityModel,
329    fields: Vec<EntityFieldDescription>,
330) -> EntitySchemaDescription {
331    let relations = relation_descriptors_for_model_iter(model)
332        .map(relation_description_from_descriptor)
333        .collect();
334
335    let mut indexes = Vec::with_capacity(model.indexes.len());
336    for index in model.indexes {
337        indexes.push(EntityIndexDescription::new(
338            index.name().to_string(),
339            index.is_unique(),
340            index
341                .fields()
342                .iter()
343                .map(|field| (*field).to_string())
344                .collect(),
345        ));
346    }
347
348    EntitySchemaDescription::new(
349        model.path.to_string(),
350        model.entity_name.to_string(),
351        model.primary_key.name.to_string(),
352        fields,
353        indexes,
354        relations,
355    )
356}
357
358// Build the stable field-description subset once from one runtime model so
359// metadata surfaces that only need columns do not rebuild indexes and
360// relations through the heavier DESCRIBE payload path.
361#[must_use]
362pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
363    describe_entity_fields_with_slot_lookup(model, |slot, _field| {
364        Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
365    })
366}
367
368#[cfg_attr(
369    doc,
370    doc = "Build field descriptors using accepted persisted schema slot metadata."
371)]
372#[must_use]
373pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
374    model: &EntityModel,
375    schema: &AcceptedSchemaSnapshot,
376) -> Vec<EntityFieldDescription> {
377    let slots_by_name = schema
378        .snapshot()
379        .fields()
380        .iter()
381        .map(|field| (field.name(), field.slot().get()))
382        .collect::<BTreeMap<_, _>>();
383
384    describe_entity_fields_with_slot_lookup(model, |_slot, field| {
385        slots_by_name.get(field.name()).copied()
386    })
387}
388
389// Build field descriptors with an injected top-level slot lookup. Generated
390// model introspection uses generated positions; live-schema introspection uses
391// accepted persisted row layout metadata while preserving nested-field behavior.
392fn describe_entity_fields_with_slot_lookup(
393    model: &EntityModel,
394    mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
395) -> Vec<EntityFieldDescription> {
396    let mut fields = Vec::with_capacity(model.fields.len());
397
398    for (slot, field) in model.fields.iter().enumerate() {
399        let primary_key = field.name == model.primary_key.name;
400        describe_field_recursive(
401            &mut fields,
402            field.name,
403            slot_for_field(slot, field),
404            field,
405            primary_key,
406            None,
407        );
408    }
409
410    fields
411}
412
413// Add one top-level field and any generated structured-record leaves under
414// dotted names so DESCRIBE/SHOW COLUMNS expose the same field paths SQL can
415// project and filter.
416fn describe_field_recursive(
417    fields: &mut Vec<EntityFieldDescription>,
418    name: &str,
419    slot: Option<u16>,
420    field: &FieldModel,
421    primary_key: bool,
422    tree_prefix: Option<&'static str>,
423) {
424    let field_kind = summarize_field_kind(&field.kind);
425    let queryable = field.kind.value_kind().is_queryable();
426
427    // Generated nested field rows keep a compact tree marker so
428    // table-oriented describe output scans as a hierarchy.
429    let display_name = if let Some(prefix) = tree_prefix {
430        format!("{prefix}{name}")
431    } else {
432        name.to_string()
433    };
434
435    fields.push(EntityFieldDescription::new(
436        display_name,
437        slot,
438        field_kind,
439        primary_key,
440        queryable,
441    ));
442
443    let nested_fields = field.nested_fields();
444    for (index, nested) in nested_fields.iter().enumerate() {
445        let prefix = if index + 1 == nested_fields.len() {
446            "└─ "
447        } else {
448            "├─ "
449        };
450        describe_field_recursive(fields, nested.name(), None, nested, false, Some(prefix));
451    }
452}
453
454// Project the relation-owned descriptor into the stable describe DTO surface.
455fn relation_description_from_descriptor(
456    descriptor: RelationDescriptor<'_>,
457) -> EntityRelationDescription {
458    let strength = match descriptor.strength() {
459        RelationStrength::Strong => EntityRelationStrength::Strong,
460        RelationStrength::Weak => EntityRelationStrength::Weak,
461    };
462
463    let cardinality = match descriptor.cardinality() {
464        RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
465        RelationDescriptorCardinality::List => EntityRelationCardinality::List,
466        RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
467    };
468
469    EntityRelationDescription::new(
470        descriptor.field_name().to_string(),
471        descriptor.target_path().to_string(),
472        descriptor.target_entity_name().to_string(),
473        descriptor.target_store_path().to_string(),
474        strength,
475        cardinality,
476    )
477}
478
479#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
480fn summarize_field_kind(kind: &FieldKind) -> String {
481    let mut out = String::new();
482    write_field_kind_summary(&mut out, kind);
483
484    out
485}
486
487// Stream one stable field-kind label directly into the output buffer so
488// describe/sql surfaces do not retain a large recursive `format!` family.
489fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
490    match kind {
491        FieldKind::Account => out.push_str("account"),
492        FieldKind::Blob => out.push_str("blob"),
493        FieldKind::Bool => out.push_str("bool"),
494        FieldKind::Date => out.push_str("date"),
495        FieldKind::Decimal { scale } => {
496            let _ = write!(out, "decimal(scale={scale})");
497        }
498        FieldKind::Duration => out.push_str("duration"),
499        FieldKind::Enum { path, .. } => {
500            out.push_str("enum(");
501            out.push_str(path);
502            out.push(')');
503        }
504        FieldKind::Float32 => out.push_str("float32"),
505        FieldKind::Float64 => out.push_str("float64"),
506        FieldKind::Int => out.push_str("int"),
507        FieldKind::Int128 => out.push_str("int128"),
508        FieldKind::IntBig => out.push_str("int_big"),
509        FieldKind::Principal => out.push_str("principal"),
510        FieldKind::Subaccount => out.push_str("subaccount"),
511        FieldKind::Text { max_len } => match max_len {
512            Some(max_len) => {
513                let _ = write!(out, "text(max_len={max_len})");
514            }
515            None => out.push_str("text"),
516        },
517        FieldKind::Timestamp => out.push_str("timestamp"),
518        FieldKind::Uint => out.push_str("uint"),
519        FieldKind::Uint128 => out.push_str("uint128"),
520        FieldKind::UintBig => out.push_str("uint_big"),
521        FieldKind::Ulid => out.push_str("ulid"),
522        FieldKind::Unit => out.push_str("unit"),
523        FieldKind::Relation {
524            target_entity_name,
525            key_kind,
526            strength,
527            ..
528        } => {
529            out.push_str("relation(target=");
530            out.push_str(target_entity_name);
531            out.push_str(", key=");
532            write_field_kind_summary(out, key_kind);
533            out.push_str(", strength=");
534            out.push_str(summarize_relation_strength(*strength));
535            out.push(')');
536        }
537        FieldKind::List(inner) => {
538            out.push_str("list<");
539            write_field_kind_summary(out, inner);
540            out.push('>');
541        }
542        FieldKind::Set(inner) => {
543            out.push_str("set<");
544            write_field_kind_summary(out, inner);
545            out.push('>');
546        }
547        FieldKind::Map { key, value } => {
548            out.push_str("map<");
549            write_field_kind_summary(out, key);
550            out.push_str(", ");
551            write_field_kind_summary(out, value);
552            out.push('>');
553        }
554        FieldKind::Structured { .. } => {
555            out.push_str("structured");
556        }
557    }
558}
559
560#[cfg_attr(
561    doc,
562    doc = "Render one stable relation-strength label for field-kind summaries."
563)]
564const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
565    match strength {
566        RelationStrength::Strong => "strong",
567        RelationStrength::Weak => "weak",
568    }
569}
570
571//
572// TESTS
573//
574
575#[cfg(test)]
576mod tests {
577    use crate::{
578        db::{
579            EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
580            EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
581            relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
582            schema::describe::describe_entity_model,
583        },
584        model::{
585            entity::EntityModel,
586            field::{FieldKind, FieldModel, FieldStorageDecode, RelationStrength},
587        },
588        types::EntityTag,
589    };
590    use candid::types::{CandidType, Label, Type, TypeInner};
591
592    static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
593        target_path: "entities::Target",
594        target_entity_name: "Target",
595        target_entity_tag: EntityTag::new(0xD001),
596        target_store_path: "stores::Target",
597        key_kind: &FieldKind::Ulid,
598        strength: RelationStrength::Strong,
599    };
600    static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
601        target_path: "entities::Account",
602        target_entity_name: "Account",
603        target_entity_tag: EntityTag::new(0xD002),
604        target_store_path: "stores::Account",
605        key_kind: &FieldKind::Uint,
606        strength: RelationStrength::Weak,
607    };
608    static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
609        target_path: "entities::Team",
610        target_entity_name: "Team",
611        target_entity_tag: EntityTag::new(0xD003),
612        target_store_path: "stores::Team",
613        key_kind: &FieldKind::Text { max_len: None },
614        strength: RelationStrength::Strong,
615    };
616    static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
617        FieldModel::generated("id", FieldKind::Ulid),
618        FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
619        FieldModel::generated(
620            "accounts",
621            FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
622        ),
623        FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
624    ];
625    static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
626    static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
627        "entities::Source",
628        "Source",
629        &DESCRIBE_RELATION_FIELDS[0],
630        0,
631        &DESCRIBE_RELATION_FIELDS,
632        &DESCRIBE_RELATION_INDEXES,
633    );
634
635    fn expect_record_fields(ty: Type) -> Vec<String> {
636        match ty.as_ref() {
637            TypeInner::Record(fields) => fields
638                .iter()
639                .map(|field| match field.id.as_ref() {
640                    Label::Named(name) => name.clone(),
641                    other => panic!("expected named record field, got {other:?}"),
642                })
643                .collect(),
644            other => panic!("expected candid record, got {other:?}"),
645        }
646    }
647
648    fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
649        match ty.as_ref() {
650            TypeInner::Record(fields) => fields
651                .iter()
652                .find_map(|field| match field.id.as_ref() {
653                    Label::Named(name) if name == field_name => Some(field.ty.clone()),
654                    _ => None,
655                })
656                .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
657            other => panic!("expected candid record, got {other:?}"),
658        }
659    }
660
661    fn expect_variant_labels(ty: Type) -> Vec<String> {
662        match ty.as_ref() {
663            TypeInner::Variant(fields) => fields
664                .iter()
665                .map(|field| match field.id.as_ref() {
666                    Label::Named(name) => name.clone(),
667                    other => panic!("expected named variant label, got {other:?}"),
668                })
669                .collect(),
670            other => panic!("expected candid variant, got {other:?}"),
671        }
672    }
673
674    #[test]
675    fn entity_schema_description_candid_shape_is_stable() {
676        let fields = expect_record_fields(EntitySchemaDescription::ty());
677
678        for field in [
679            "entity_path",
680            "entity_name",
681            "primary_key",
682            "fields",
683            "indexes",
684            "relations",
685        ] {
686            assert!(
687                fields.iter().any(|candidate| candidate == field),
688                "EntitySchemaDescription must keep `{field}` field key",
689            );
690        }
691    }
692
693    #[test]
694    fn entity_field_description_candid_shape_is_stable() {
695        let fields = expect_record_fields(EntityFieldDescription::ty());
696
697        for field in ["name", "slot", "kind", "primary_key", "queryable"] {
698            assert!(
699                fields.iter().any(|candidate| candidate == field),
700                "EntityFieldDescription must keep `{field}` field key",
701            );
702        }
703
704        assert!(
705            matches!(
706                expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
707                TypeInner::Nat16
708            ),
709            "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
710        );
711    }
712
713    #[test]
714    fn entity_index_description_candid_shape_is_stable() {
715        let fields = expect_record_fields(EntityIndexDescription::ty());
716
717        for field in ["name", "unique", "fields"] {
718            assert!(
719                fields.iter().any(|candidate| candidate == field),
720                "EntityIndexDescription must keep `{field}` field key",
721            );
722        }
723    }
724
725    #[test]
726    fn entity_relation_description_candid_shape_is_stable() {
727        let fields = expect_record_fields(EntityRelationDescription::ty());
728
729        for field in [
730            "field",
731            "target_path",
732            "target_entity_name",
733            "target_store_path",
734            "strength",
735            "cardinality",
736        ] {
737            assert!(
738                fields.iter().any(|candidate| candidate == field),
739                "EntityRelationDescription must keep `{field}` field key",
740            );
741        }
742    }
743
744    #[test]
745    fn relation_enum_variant_labels_are_stable() {
746        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
747        strength_labels.sort_unstable();
748        assert_eq!(
749            strength_labels,
750            vec!["Strong".to_string(), "Weak".to_string()]
751        );
752
753        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
754        cardinality_labels.sort_unstable();
755        assert_eq!(
756            cardinality_labels,
757            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
758        );
759    }
760
761    #[test]
762    fn describe_fixture_constructors_stay_usable() {
763        let payload = EntitySchemaDescription::new(
764            "entities::User".to_string(),
765            "User".to_string(),
766            "id".to_string(),
767            vec![EntityFieldDescription::new(
768                "id".to_string(),
769                Some(0),
770                "ulid".to_string(),
771                true,
772                true,
773            )],
774            vec![EntityIndexDescription::new(
775                "idx_email".to_string(),
776                true,
777                vec!["email".to_string()],
778            )],
779            vec![EntityRelationDescription::new(
780                "account_id".to_string(),
781                "entities::Account".to_string(),
782                "Account".to_string(),
783                "accounts".to_string(),
784                EntityRelationStrength::Strong,
785                EntityRelationCardinality::Single,
786            )],
787        );
788
789        assert_eq!(payload.entity_name(), "User");
790        assert_eq!(payload.fields().len(), 1);
791        assert_eq!(payload.indexes().len(), 1);
792        assert_eq!(payload.relations().len(), 1);
793    }
794
795    #[test]
796    fn schema_describe_relations_match_relation_descriptors() {
797        let descriptors =
798            relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
799        let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
800        let relations = described.relations();
801
802        assert_eq!(descriptors.len(), relations.len());
803
804        for (descriptor, relation) in descriptors.iter().zip(relations) {
805            assert_eq!(relation.field(), descriptor.field_name());
806            assert_eq!(relation.target_path(), descriptor.target_path());
807            assert_eq!(
808                relation.target_entity_name(),
809                descriptor.target_entity_name()
810            );
811            assert_eq!(relation.target_store_path(), descriptor.target_store_path());
812            assert_eq!(
813                relation.strength(),
814                match descriptor.strength() {
815                    RelationStrength::Strong => EntityRelationStrength::Strong,
816                    RelationStrength::Weak => EntityRelationStrength::Weak,
817                }
818            );
819            assert_eq!(
820                relation.cardinality(),
821                match descriptor.cardinality() {
822                    RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
823                    RelationDescriptorCardinality::List => EntityRelationCardinality::List,
824                    RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
825                }
826            );
827        }
828    }
829
830    #[test]
831    fn schema_describe_includes_text_max_len_contract() {
832        static FIELDS: [FieldModel; 2] = [
833            FieldModel::generated("id", FieldKind::Ulid),
834            FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
835        ];
836        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
837        static MODEL: EntityModel = EntityModel::generated(
838            "entities::BoundedName",
839            "BoundedName",
840            &FIELDS[0],
841            0,
842            &FIELDS,
843            &INDEXES,
844        );
845
846        let described = describe_entity_model(&MODEL);
847        let name_field = described
848            .fields()
849            .iter()
850            .find(|field| field.name() == "name")
851            .expect("bounded text field should be described");
852
853        assert_eq!(name_field.kind(), "text(max_len=16)");
854    }
855
856    #[test]
857    fn schema_describe_expands_generated_structured_field_leaves() {
858        static NESTED_FIELDS: [FieldModel; 3] = [
859            FieldModel::generated("name", FieldKind::Text { max_len: None }),
860            FieldModel::generated("level", FieldKind::Uint),
861            FieldModel::generated("pid", FieldKind::Principal),
862        ];
863        static FIELDS: [FieldModel; 2] = [
864            FieldModel::generated("id", FieldKind::Ulid),
865            FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
866                "mentor",
867                FieldKind::Structured { queryable: false },
868                FieldStorageDecode::Value,
869                false,
870                None,
871                None,
872                &NESTED_FIELDS,
873            ),
874        ];
875        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
876        static MODEL: EntityModel = EntityModel::generated(
877            "entities::Character",
878            "Character",
879            &FIELDS[0],
880            0,
881            &FIELDS,
882            &INDEXES,
883        );
884
885        let described = describe_entity_model(&MODEL);
886        let described_fields = described
887            .fields()
888            .iter()
889            .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
890            .collect::<Vec<_>>();
891
892        assert_eq!(
893            described_fields,
894            vec![
895                ("id", Some(0), "ulid", true),
896                ("mentor", Some(1), "structured", false),
897                ("├─ name", None, "text", true),
898                ("├─ level", None, "uint", true),
899                ("└─ pid", None, "principal", true),
900            ],
901        );
902    }
903}