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