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