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 => out.push_str("text"),
406        FieldKind::Timestamp => out.push_str("timestamp"),
407        FieldKind::Uint => out.push_str("uint"),
408        FieldKind::Uint128 => out.push_str("uint128"),
409        FieldKind::UintBig => out.push_str("uint_big"),
410        FieldKind::Ulid => out.push_str("ulid"),
411        FieldKind::Unit => out.push_str("unit"),
412        FieldKind::Relation {
413            target_entity_name,
414            key_kind,
415            strength,
416            ..
417        } => {
418            out.push_str("relation(target=");
419            out.push_str(target_entity_name);
420            out.push_str(", key=");
421            write_field_kind_summary(out, key_kind);
422            out.push_str(", strength=");
423            out.push_str(summarize_relation_strength(*strength));
424            out.push(')');
425        }
426        FieldKind::List(inner) => {
427            out.push_str("list<");
428            write_field_kind_summary(out, inner);
429            out.push('>');
430        }
431        FieldKind::Set(inner) => {
432            out.push_str("set<");
433            write_field_kind_summary(out, inner);
434            out.push('>');
435        }
436        FieldKind::Map { key, value } => {
437            out.push_str("map<");
438            write_field_kind_summary(out, key);
439            out.push_str(", ");
440            write_field_kind_summary(out, value);
441            out.push('>');
442        }
443        FieldKind::Structured { queryable } => {
444            let _ = write!(out, "structured(queryable={queryable})");
445        }
446    }
447}
448
449#[cfg_attr(
450    doc,
451    doc = "Render one stable relation-strength label for field-kind summaries."
452)]
453const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
454    match strength {
455        RelationStrength::Strong => "strong",
456        RelationStrength::Weak => "weak",
457    }
458}
459
460//
461// TESTS
462//
463
464#[cfg(test)]
465mod tests {
466    use crate::{
467        db::{
468            EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
469            EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
470            relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
471            schema::describe::describe_entity_model,
472        },
473        model::{
474            entity::EntityModel,
475            field::{FieldKind, FieldModel, RelationStrength},
476        },
477        types::EntityTag,
478    };
479    use candid::types::{CandidType, Label, Type, TypeInner};
480
481    static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
482        target_path: "entities::Target",
483        target_entity_name: "Target",
484        target_entity_tag: EntityTag::new(0xD001),
485        target_store_path: "stores::Target",
486        key_kind: &FieldKind::Ulid,
487        strength: RelationStrength::Strong,
488    };
489    static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
490        target_path: "entities::Account",
491        target_entity_name: "Account",
492        target_entity_tag: EntityTag::new(0xD002),
493        target_store_path: "stores::Account",
494        key_kind: &FieldKind::Uint,
495        strength: RelationStrength::Weak,
496    };
497    static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
498        target_path: "entities::Team",
499        target_entity_name: "Team",
500        target_entity_tag: EntityTag::new(0xD003),
501        target_store_path: "stores::Team",
502        key_kind: &FieldKind::Text,
503        strength: RelationStrength::Strong,
504    };
505    static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
506        FieldModel::generated("id", FieldKind::Ulid),
507        FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
508        FieldModel::generated(
509            "accounts",
510            FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
511        ),
512        FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
513    ];
514    static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
515    static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
516        "entities::Source",
517        "Source",
518        &DESCRIBE_RELATION_FIELDS[0],
519        0,
520        &DESCRIBE_RELATION_FIELDS,
521        &DESCRIBE_RELATION_INDEXES,
522    );
523
524    fn expect_record_fields(ty: Type) -> Vec<String> {
525        match ty.as_ref() {
526            TypeInner::Record(fields) => fields
527                .iter()
528                .map(|field| match field.id.as_ref() {
529                    Label::Named(name) => name.clone(),
530                    other => panic!("expected named record field, got {other:?}"),
531                })
532                .collect(),
533            other => panic!("expected candid record, got {other:?}"),
534        }
535    }
536
537    fn expect_variant_labels(ty: Type) -> Vec<String> {
538        match ty.as_ref() {
539            TypeInner::Variant(fields) => fields
540                .iter()
541                .map(|field| match field.id.as_ref() {
542                    Label::Named(name) => name.clone(),
543                    other => panic!("expected named variant label, got {other:?}"),
544                })
545                .collect(),
546            other => panic!("expected candid variant, got {other:?}"),
547        }
548    }
549
550    #[test]
551    fn entity_schema_description_candid_shape_is_stable() {
552        let fields = expect_record_fields(EntitySchemaDescription::ty());
553
554        for field in [
555            "entity_path",
556            "entity_name",
557            "primary_key",
558            "fields",
559            "indexes",
560            "relations",
561        ] {
562            assert!(
563                fields.iter().any(|candidate| candidate == field),
564                "EntitySchemaDescription must keep `{field}` field key",
565            );
566        }
567    }
568
569    #[test]
570    fn entity_field_description_candid_shape_is_stable() {
571        let fields = expect_record_fields(EntityFieldDescription::ty());
572
573        for field in ["name", "kind", "primary_key", "queryable"] {
574            assert!(
575                fields.iter().any(|candidate| candidate == field),
576                "EntityFieldDescription must keep `{field}` field key",
577            );
578        }
579    }
580
581    #[test]
582    fn entity_index_description_candid_shape_is_stable() {
583        let fields = expect_record_fields(EntityIndexDescription::ty());
584
585        for field in ["name", "unique", "fields"] {
586            assert!(
587                fields.iter().any(|candidate| candidate == field),
588                "EntityIndexDescription must keep `{field}` field key",
589            );
590        }
591    }
592
593    #[test]
594    fn entity_relation_description_candid_shape_is_stable() {
595        let fields = expect_record_fields(EntityRelationDescription::ty());
596
597        for field in [
598            "field",
599            "target_path",
600            "target_entity_name",
601            "target_store_path",
602            "strength",
603            "cardinality",
604        ] {
605            assert!(
606                fields.iter().any(|candidate| candidate == field),
607                "EntityRelationDescription must keep `{field}` field key",
608            );
609        }
610    }
611
612    #[test]
613    fn relation_enum_variant_labels_are_stable() {
614        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
615        strength_labels.sort_unstable();
616        assert_eq!(
617            strength_labels,
618            vec!["Strong".to_string(), "Weak".to_string()]
619        );
620
621        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
622        cardinality_labels.sort_unstable();
623        assert_eq!(
624            cardinality_labels,
625            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
626        );
627    }
628
629    #[test]
630    fn describe_fixture_constructors_stay_usable() {
631        let payload = EntitySchemaDescription::new(
632            "entities::User".to_string(),
633            "User".to_string(),
634            "id".to_string(),
635            vec![EntityFieldDescription::new(
636                "id".to_string(),
637                "ulid".to_string(),
638                true,
639                true,
640            )],
641            vec![EntityIndexDescription::new(
642                "idx_email".to_string(),
643                true,
644                vec!["email".to_string()],
645            )],
646            vec![EntityRelationDescription::new(
647                "account_id".to_string(),
648                "entities::Account".to_string(),
649                "Account".to_string(),
650                "accounts".to_string(),
651                EntityRelationStrength::Strong,
652                EntityRelationCardinality::Single,
653            )],
654        );
655
656        assert_eq!(payload.entity_name(), "User");
657        assert_eq!(payload.fields().len(), 1);
658        assert_eq!(payload.indexes().len(), 1);
659        assert_eq!(payload.relations().len(), 1);
660    }
661
662    #[test]
663    fn schema_describe_relations_match_relation_descriptors() {
664        let descriptors =
665            relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
666        let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
667        let relations = described.relations();
668
669        assert_eq!(descriptors.len(), relations.len());
670
671        for (descriptor, relation) in descriptors.iter().zip(relations) {
672            assert_eq!(relation.field(), descriptor.field_name());
673            assert_eq!(relation.target_path(), descriptor.target_path());
674            assert_eq!(
675                relation.target_entity_name(),
676                descriptor.target_entity_name()
677            );
678            assert_eq!(relation.target_store_path(), descriptor.target_store_path());
679            assert_eq!(
680                relation.strength(),
681                match descriptor.strength() {
682                    RelationStrength::Strong => EntityRelationStrength::Strong,
683                    RelationStrength::Weak => EntityRelationStrength::Weak,
684                }
685            );
686            assert_eq!(
687                relation.cardinality(),
688                match descriptor.cardinality() {
689                    RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
690                    RelationDescriptorCardinality::List => EntityRelationCardinality::List,
691                    RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
692                }
693            );
694        }
695    }
696}