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 = relation_descriptors_for_model_iter(model)
279        .map(relation_description_from_descriptor)
280        .collect();
281
282    let mut indexes = Vec::with_capacity(model.indexes.len());
283    for index in model.indexes {
284        indexes.push(EntityIndexDescription::new(
285            index.name().to_string(),
286            index.is_unique(),
287            index
288                .fields()
289                .iter()
290                .map(|field| (*field).to_string())
291                .collect(),
292        ));
293    }
294
295    EntitySchemaDescription::new(
296        model.path.to_string(),
297        model.entity_name.to_string(),
298        model.primary_key.name.to_string(),
299        fields,
300        indexes,
301        relations,
302    )
303}
304
305// Build the stable field-description subset once from one runtime model so
306// metadata surfaces that only need columns do not rebuild indexes and
307// relations through the heavier DESCRIBE payload path.
308#[must_use]
309pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
310    let mut fields = Vec::with_capacity(model.fields.len());
311
312    for field in model.fields {
313        let primary_key = field.name == model.primary_key.name;
314        describe_field_recursive(&mut fields, field.name, field, primary_key, None);
315    }
316
317    fields
318}
319
320// Add one top-level field and any generated structured-record leaves under
321// dotted names so DESCRIBE/SHOW COLUMNS expose the same field paths SQL can
322// project and filter.
323fn describe_field_recursive(
324    fields: &mut Vec<EntityFieldDescription>,
325    name: &str,
326    field: &FieldModel,
327    primary_key: bool,
328    tree_prefix: Option<&'static str>,
329) {
330    let field_kind = summarize_field_kind(&field.kind);
331    let queryable = field.kind.value_kind().is_queryable();
332
333    // Generated nested field rows keep a compact tree marker so
334    // table-oriented describe output scans as a hierarchy.
335    let display_name = if let Some(prefix) = tree_prefix {
336        format!("{prefix}{name}")
337    } else {
338        name.to_string()
339    };
340
341    fields.push(EntityFieldDescription::new(
342        display_name,
343        field_kind,
344        primary_key,
345        queryable,
346    ));
347
348    let nested_fields = field.nested_fields();
349    for (index, nested) in nested_fields.iter().enumerate() {
350        let prefix = if index + 1 == nested_fields.len() {
351            "└─ "
352        } else {
353            "├─ "
354        };
355        describe_field_recursive(fields, nested.name(), nested, false, Some(prefix));
356    }
357}
358
359// Project the relation-owned descriptor into the stable describe DTO surface.
360fn relation_description_from_descriptor(
361    descriptor: RelationDescriptor<'_>,
362) -> EntityRelationDescription {
363    let strength = match descriptor.strength() {
364        RelationStrength::Strong => EntityRelationStrength::Strong,
365        RelationStrength::Weak => EntityRelationStrength::Weak,
366    };
367
368    let cardinality = match descriptor.cardinality() {
369        RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
370        RelationDescriptorCardinality::List => EntityRelationCardinality::List,
371        RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
372    };
373
374    EntityRelationDescription::new(
375        descriptor.field_name().to_string(),
376        descriptor.target_path().to_string(),
377        descriptor.target_entity_name().to_string(),
378        descriptor.target_store_path().to_string(),
379        strength,
380        cardinality,
381    )
382}
383
384#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
385fn summarize_field_kind(kind: &FieldKind) -> String {
386    let mut out = String::new();
387    write_field_kind_summary(&mut out, kind);
388
389    out
390}
391
392// Stream one stable field-kind label directly into the output buffer so
393// describe/sql surfaces do not retain a large recursive `format!` family.
394fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
395    match kind {
396        FieldKind::Account => out.push_str("account"),
397        FieldKind::Blob => out.push_str("blob"),
398        FieldKind::Bool => out.push_str("bool"),
399        FieldKind::Date => out.push_str("date"),
400        FieldKind::Decimal { scale } => {
401            let _ = write!(out, "decimal(scale={scale})");
402        }
403        FieldKind::Duration => out.push_str("duration"),
404        FieldKind::Enum { path, .. } => {
405            out.push_str("enum(");
406            out.push_str(path);
407            out.push(')');
408        }
409        FieldKind::Float32 => out.push_str("float32"),
410        FieldKind::Float64 => out.push_str("float64"),
411        FieldKind::Int => out.push_str("int"),
412        FieldKind::Int128 => out.push_str("int128"),
413        FieldKind::IntBig => out.push_str("int_big"),
414        FieldKind::Principal => out.push_str("principal"),
415        FieldKind::Subaccount => out.push_str("subaccount"),
416        FieldKind::Text { max_len } => match max_len {
417            Some(max_len) => {
418                let _ = write!(out, "text(max_len={max_len})");
419            }
420            None => out.push_str("text"),
421        },
422        FieldKind::Timestamp => out.push_str("timestamp"),
423        FieldKind::Uint => out.push_str("uint"),
424        FieldKind::Uint128 => out.push_str("uint128"),
425        FieldKind::UintBig => out.push_str("uint_big"),
426        FieldKind::Ulid => out.push_str("ulid"),
427        FieldKind::Unit => out.push_str("unit"),
428        FieldKind::Relation {
429            target_entity_name,
430            key_kind,
431            strength,
432            ..
433        } => {
434            out.push_str("relation(target=");
435            out.push_str(target_entity_name);
436            out.push_str(", key=");
437            write_field_kind_summary(out, key_kind);
438            out.push_str(", strength=");
439            out.push_str(summarize_relation_strength(*strength));
440            out.push(')');
441        }
442        FieldKind::List(inner) => {
443            out.push_str("list<");
444            write_field_kind_summary(out, inner);
445            out.push('>');
446        }
447        FieldKind::Set(inner) => {
448            out.push_str("set<");
449            write_field_kind_summary(out, inner);
450            out.push('>');
451        }
452        FieldKind::Map { key, value } => {
453            out.push_str("map<");
454            write_field_kind_summary(out, key);
455            out.push_str(", ");
456            write_field_kind_summary(out, value);
457            out.push('>');
458        }
459        FieldKind::Structured { .. } => {
460            out.push_str("structured");
461        }
462    }
463}
464
465#[cfg_attr(
466    doc,
467    doc = "Render one stable relation-strength label for field-kind summaries."
468)]
469const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
470    match strength {
471        RelationStrength::Strong => "strong",
472        RelationStrength::Weak => "weak",
473    }
474}
475
476//
477// TESTS
478//
479
480#[cfg(test)]
481mod tests {
482    use crate::{
483        db::{
484            EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
485            EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
486            relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
487            schema::describe::describe_entity_model,
488        },
489        model::{
490            entity::EntityModel,
491            field::{FieldKind, FieldModel, FieldStorageDecode, RelationStrength},
492        },
493        types::EntityTag,
494    };
495    use candid::types::{CandidType, Label, Type, TypeInner};
496
497    static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
498        target_path: "entities::Target",
499        target_entity_name: "Target",
500        target_entity_tag: EntityTag::new(0xD001),
501        target_store_path: "stores::Target",
502        key_kind: &FieldKind::Ulid,
503        strength: RelationStrength::Strong,
504    };
505    static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
506        target_path: "entities::Account",
507        target_entity_name: "Account",
508        target_entity_tag: EntityTag::new(0xD002),
509        target_store_path: "stores::Account",
510        key_kind: &FieldKind::Uint,
511        strength: RelationStrength::Weak,
512    };
513    static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
514        target_path: "entities::Team",
515        target_entity_name: "Team",
516        target_entity_tag: EntityTag::new(0xD003),
517        target_store_path: "stores::Team",
518        key_kind: &FieldKind::Text { max_len: None },
519        strength: RelationStrength::Strong,
520    };
521    static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
522        FieldModel::generated("id", FieldKind::Ulid),
523        FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
524        FieldModel::generated(
525            "accounts",
526            FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
527        ),
528        FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
529    ];
530    static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
531    static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
532        "entities::Source",
533        "Source",
534        &DESCRIBE_RELATION_FIELDS[0],
535        0,
536        &DESCRIBE_RELATION_FIELDS,
537        &DESCRIBE_RELATION_INDEXES,
538    );
539
540    fn expect_record_fields(ty: Type) -> Vec<String> {
541        match ty.as_ref() {
542            TypeInner::Record(fields) => fields
543                .iter()
544                .map(|field| match field.id.as_ref() {
545                    Label::Named(name) => name.clone(),
546                    other => panic!("expected named record field, got {other:?}"),
547                })
548                .collect(),
549            other => panic!("expected candid record, got {other:?}"),
550        }
551    }
552
553    fn expect_variant_labels(ty: Type) -> Vec<String> {
554        match ty.as_ref() {
555            TypeInner::Variant(fields) => fields
556                .iter()
557                .map(|field| match field.id.as_ref() {
558                    Label::Named(name) => name.clone(),
559                    other => panic!("expected named variant label, got {other:?}"),
560                })
561                .collect(),
562            other => panic!("expected candid variant, got {other:?}"),
563        }
564    }
565
566    #[test]
567    fn entity_schema_description_candid_shape_is_stable() {
568        let fields = expect_record_fields(EntitySchemaDescription::ty());
569
570        for field in [
571            "entity_path",
572            "entity_name",
573            "primary_key",
574            "fields",
575            "indexes",
576            "relations",
577        ] {
578            assert!(
579                fields.iter().any(|candidate| candidate == field),
580                "EntitySchemaDescription must keep `{field}` field key",
581            );
582        }
583    }
584
585    #[test]
586    fn entity_field_description_candid_shape_is_stable() {
587        let fields = expect_record_fields(EntityFieldDescription::ty());
588
589        for field in ["name", "kind", "primary_key", "queryable"] {
590            assert!(
591                fields.iter().any(|candidate| candidate == field),
592                "EntityFieldDescription must keep `{field}` field key",
593            );
594        }
595    }
596
597    #[test]
598    fn entity_index_description_candid_shape_is_stable() {
599        let fields = expect_record_fields(EntityIndexDescription::ty());
600
601        for field in ["name", "unique", "fields"] {
602            assert!(
603                fields.iter().any(|candidate| candidate == field),
604                "EntityIndexDescription must keep `{field}` field key",
605            );
606        }
607    }
608
609    #[test]
610    fn entity_relation_description_candid_shape_is_stable() {
611        let fields = expect_record_fields(EntityRelationDescription::ty());
612
613        for field in [
614            "field",
615            "target_path",
616            "target_entity_name",
617            "target_store_path",
618            "strength",
619            "cardinality",
620        ] {
621            assert!(
622                fields.iter().any(|candidate| candidate == field),
623                "EntityRelationDescription must keep `{field}` field key",
624            );
625        }
626    }
627
628    #[test]
629    fn relation_enum_variant_labels_are_stable() {
630        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
631        strength_labels.sort_unstable();
632        assert_eq!(
633            strength_labels,
634            vec!["Strong".to_string(), "Weak".to_string()]
635        );
636
637        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
638        cardinality_labels.sort_unstable();
639        assert_eq!(
640            cardinality_labels,
641            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
642        );
643    }
644
645    #[test]
646    fn describe_fixture_constructors_stay_usable() {
647        let payload = EntitySchemaDescription::new(
648            "entities::User".to_string(),
649            "User".to_string(),
650            "id".to_string(),
651            vec![EntityFieldDescription::new(
652                "id".to_string(),
653                "ulid".to_string(),
654                true,
655                true,
656            )],
657            vec![EntityIndexDescription::new(
658                "idx_email".to_string(),
659                true,
660                vec!["email".to_string()],
661            )],
662            vec![EntityRelationDescription::new(
663                "account_id".to_string(),
664                "entities::Account".to_string(),
665                "Account".to_string(),
666                "accounts".to_string(),
667                EntityRelationStrength::Strong,
668                EntityRelationCardinality::Single,
669            )],
670        );
671
672        assert_eq!(payload.entity_name(), "User");
673        assert_eq!(payload.fields().len(), 1);
674        assert_eq!(payload.indexes().len(), 1);
675        assert_eq!(payload.relations().len(), 1);
676    }
677
678    #[test]
679    fn schema_describe_relations_match_relation_descriptors() {
680        let descriptors =
681            relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
682        let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
683        let relations = described.relations();
684
685        assert_eq!(descriptors.len(), relations.len());
686
687        for (descriptor, relation) in descriptors.iter().zip(relations) {
688            assert_eq!(relation.field(), descriptor.field_name());
689            assert_eq!(relation.target_path(), descriptor.target_path());
690            assert_eq!(
691                relation.target_entity_name(),
692                descriptor.target_entity_name()
693            );
694            assert_eq!(relation.target_store_path(), descriptor.target_store_path());
695            assert_eq!(
696                relation.strength(),
697                match descriptor.strength() {
698                    RelationStrength::Strong => EntityRelationStrength::Strong,
699                    RelationStrength::Weak => EntityRelationStrength::Weak,
700                }
701            );
702            assert_eq!(
703                relation.cardinality(),
704                match descriptor.cardinality() {
705                    RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
706                    RelationDescriptorCardinality::List => EntityRelationCardinality::List,
707                    RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
708                }
709            );
710        }
711    }
712
713    #[test]
714    fn schema_describe_includes_text_max_len_contract() {
715        static FIELDS: [FieldModel; 2] = [
716            FieldModel::generated("id", FieldKind::Ulid),
717            FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
718        ];
719        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
720        static MODEL: EntityModel = EntityModel::generated(
721            "entities::BoundedName",
722            "BoundedName",
723            &FIELDS[0],
724            0,
725            &FIELDS,
726            &INDEXES,
727        );
728
729        let described = describe_entity_model(&MODEL);
730        let name_field = described
731            .fields()
732            .iter()
733            .find(|field| field.name() == "name")
734            .expect("bounded text field should be described");
735
736        assert_eq!(name_field.kind(), "text(max_len=16)");
737    }
738
739    #[test]
740    fn schema_describe_expands_generated_structured_field_leaves() {
741        static NESTED_FIELDS: [FieldModel; 3] = [
742            FieldModel::generated("name", FieldKind::Text { max_len: None }),
743            FieldModel::generated("level", FieldKind::Uint),
744            FieldModel::generated("pid", FieldKind::Principal),
745        ];
746        static FIELDS: [FieldModel; 2] = [
747            FieldModel::generated("id", FieldKind::Ulid),
748            FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
749                "mentor",
750                FieldKind::Structured { queryable: false },
751                FieldStorageDecode::Value,
752                false,
753                None,
754                None,
755                &NESTED_FIELDS,
756            ),
757        ];
758        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
759        static MODEL: EntityModel = EntityModel::generated(
760            "entities::Character",
761            "Character",
762            &FIELDS[0],
763            0,
764            &FIELDS,
765            &INDEXES,
766        );
767
768        let described = describe_entity_model(&MODEL);
769        let described_fields = described
770            .fields()
771            .iter()
772            .map(|field| (field.name(), field.kind(), field.queryable()))
773            .collect::<Vec<_>>();
774
775        assert_eq!(
776            described_fields,
777            vec![
778                ("id", "ulid", true),
779                ("mentor", "structured", false),
780                ("├─ name", "text", true),
781                ("├─ level", "uint", true),
782                ("└─ pid", "principal", true),
783            ],
784        );
785    }
786}