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::model::{
7    entity::EntityModel,
8    field::{FieldKind, RelationStrength},
9};
10use candid::CandidType;
11use serde::Deserialize;
12use std::fmt::Write;
13
14#[cfg_attr(
15    doc,
16    doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
17)]
18#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
19pub struct EntitySchemaDescription {
20    pub(crate) entity_path: String,
21    pub(crate) entity_name: String,
22    pub(crate) primary_key: String,
23    pub(crate) fields: Vec<EntityFieldDescription>,
24    pub(crate) indexes: Vec<EntityIndexDescription>,
25    pub(crate) relations: Vec<EntityRelationDescription>,
26}
27
28impl EntitySchemaDescription {
29    /// Construct one entity schema description payload.
30    #[must_use]
31    pub const fn new(
32        entity_path: String,
33        entity_name: String,
34        primary_key: String,
35        fields: Vec<EntityFieldDescription>,
36        indexes: Vec<EntityIndexDescription>,
37        relations: Vec<EntityRelationDescription>,
38    ) -> Self {
39        Self {
40            entity_path,
41            entity_name,
42            primary_key,
43            fields,
44            indexes,
45            relations,
46        }
47    }
48
49    /// Borrow the entity module path.
50    #[must_use]
51    pub const fn entity_path(&self) -> &str {
52        self.entity_path.as_str()
53    }
54
55    /// Borrow the entity display name.
56    #[must_use]
57    pub const fn entity_name(&self) -> &str {
58        self.entity_name.as_str()
59    }
60
61    /// Borrow the primary-key field name.
62    #[must_use]
63    pub const fn primary_key(&self) -> &str {
64        self.primary_key.as_str()
65    }
66
67    /// Borrow field description entries.
68    #[must_use]
69    pub const fn fields(&self) -> &[EntityFieldDescription] {
70        self.fields.as_slice()
71    }
72
73    /// Borrow index description entries.
74    #[must_use]
75    pub const fn indexes(&self) -> &[EntityIndexDescription] {
76        self.indexes.as_slice()
77    }
78
79    /// Borrow relation description entries.
80    #[must_use]
81    pub const fn relations(&self) -> &[EntityRelationDescription] {
82        self.relations.as_slice()
83    }
84}
85
86#[cfg_attr(
87    doc,
88    doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
89)]
90#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
91pub struct EntityFieldDescription {
92    pub(crate) name: String,
93    pub(crate) kind: String,
94    pub(crate) primary_key: bool,
95    pub(crate) queryable: bool,
96}
97
98impl EntityFieldDescription {
99    /// Construct one field description entry.
100    #[must_use]
101    pub const fn new(name: String, kind: String, primary_key: bool, queryable: bool) -> Self {
102        Self {
103            name,
104            kind,
105            primary_key,
106            queryable,
107        }
108    }
109
110    /// Borrow the field name.
111    #[must_use]
112    pub const fn name(&self) -> &str {
113        self.name.as_str()
114    }
115
116    /// Borrow the rendered field kind label.
117    #[must_use]
118    pub const fn kind(&self) -> &str {
119        self.kind.as_str()
120    }
121
122    /// Return whether this field is the primary key.
123    #[must_use]
124    pub const fn primary_key(&self) -> bool {
125        self.primary_key
126    }
127
128    /// Return whether this field is queryable.
129    #[must_use]
130    pub const fn queryable(&self) -> bool {
131        self.queryable
132    }
133}
134
135#[cfg_attr(
136    doc,
137    doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
138)]
139#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
140pub struct EntityIndexDescription {
141    pub(crate) name: String,
142    pub(crate) unique: bool,
143    pub(crate) fields: Vec<String>,
144}
145
146impl EntityIndexDescription {
147    /// Construct one index description entry.
148    #[must_use]
149    pub const fn new(name: String, unique: bool, fields: Vec<String>) -> Self {
150        Self {
151            name,
152            unique,
153            fields,
154        }
155    }
156
157    /// Borrow the index name.
158    #[must_use]
159    pub const fn name(&self) -> &str {
160        self.name.as_str()
161    }
162
163    /// Return whether the index enforces uniqueness.
164    #[must_use]
165    pub const fn unique(&self) -> bool {
166        self.unique
167    }
168
169    /// Borrow ordered index field names.
170    #[must_use]
171    pub const fn fields(&self) -> &[String] {
172        self.fields.as_slice()
173    }
174}
175
176#[cfg_attr(
177    doc,
178    doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
179)]
180#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
181pub struct EntityRelationDescription {
182    pub(crate) field: String,
183    pub(crate) target_path: String,
184    pub(crate) target_entity_name: String,
185    pub(crate) target_store_path: String,
186    pub(crate) strength: EntityRelationStrength,
187    pub(crate) cardinality: EntityRelationCardinality,
188}
189
190impl EntityRelationDescription {
191    /// Construct one relation description entry.
192    #[must_use]
193    pub const fn new(
194        field: String,
195        target_path: String,
196        target_entity_name: String,
197        target_store_path: String,
198        strength: EntityRelationStrength,
199        cardinality: EntityRelationCardinality,
200    ) -> Self {
201        Self {
202            field,
203            target_path,
204            target_entity_name,
205            target_store_path,
206            strength,
207            cardinality,
208        }
209    }
210
211    /// Borrow the source relation field name.
212    #[must_use]
213    pub const fn field(&self) -> &str {
214        self.field.as_str()
215    }
216
217    /// Borrow the relation target path.
218    #[must_use]
219    pub const fn target_path(&self) -> &str {
220        self.target_path.as_str()
221    }
222
223    /// Borrow the relation target entity name.
224    #[must_use]
225    pub const fn target_entity_name(&self) -> &str {
226        self.target_entity_name.as_str()
227    }
228
229    /// Borrow the relation target store path.
230    #[must_use]
231    pub const fn target_store_path(&self) -> &str {
232        self.target_store_path.as_str()
233    }
234
235    /// Return relation strength.
236    #[must_use]
237    pub const fn strength(&self) -> EntityRelationStrength {
238        self.strength
239    }
240
241    /// Return relation cardinality.
242    #[must_use]
243    pub const fn cardinality(&self) -> EntityRelationCardinality {
244        self.cardinality
245    }
246}
247
248#[cfg_attr(doc, doc = "EntityRelationStrength\n\nDescribe relation strength.")]
249#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
250pub enum EntityRelationStrength {
251    Strong,
252    Weak,
253}
254
255#[cfg_attr(
256    doc,
257    doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
258)]
259#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
260pub enum EntityRelationCardinality {
261    Single,
262    List,
263    Set,
264}
265
266#[cfg_attr(
267    doc,
268    doc = "Build one stable entity-schema description from one runtime `EntityModel`."
269)]
270#[must_use]
271pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
272    let (fields, relations) = describe_entity_fields_and_relations(model);
273
274    let mut indexes = Vec::with_capacity(model.indexes.len());
275    for index in model.indexes {
276        indexes.push(EntityIndexDescription::new(
277            index.name().to_string(),
278            index.is_unique(),
279            index
280                .fields()
281                .iter()
282                .map(|field| (*field).to_string())
283                .collect(),
284        ));
285    }
286
287    EntitySchemaDescription::new(
288        model.path.to_string(),
289        model.entity_name.to_string(),
290        model.primary_key.name.to_string(),
291        fields,
292        indexes,
293        relations,
294    )
295}
296
297// Build the stable field-description subset once from one runtime model so
298// metadata surfaces that only need columns do not rebuild indexes and
299// relations through the heavier DESCRIBE payload path.
300#[must_use]
301pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
302    describe_entity_fields_and_relations(model).0
303}
304
305// Build the field and relation describe payloads together so the full
306// DESCRIBE surface does not walk the entity field table twice.
307fn describe_entity_fields_and_relations(
308    model: &EntityModel,
309) -> (Vec<EntityFieldDescription>, Vec<EntityRelationDescription>) {
310    let mut fields = Vec::with_capacity(model.fields.len());
311    let mut relations = Vec::new();
312
313    for field in model.fields {
314        let field_kind = summarize_field_kind(&field.kind);
315        let queryable = field.kind.value_kind().is_queryable();
316        let primary_key = field.name == model.primary_key.name;
317
318        fields.push(EntityFieldDescription::new(
319            field.name.to_string(),
320            field_kind,
321            primary_key,
322            queryable,
323        ));
324
325        if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
326            relations.push(relation);
327        }
328    }
329
330    (fields, relations)
331}
332
333#[cfg_attr(
334    doc,
335    doc = "Resolve relation metadata from one field kind, including list/set relation forms."
336)]
337fn relation_from_field_kind(
338    field_name: &str,
339    kind: &FieldKind,
340) -> Option<EntityRelationDescription> {
341    match kind {
342        FieldKind::Relation {
343            target_path,
344            target_entity_name,
345            target_store_path,
346            strength,
347            ..
348        } => Some(EntityRelationDescription::new(
349            field_name.to_string(),
350            (*target_path).to_string(),
351            (*target_entity_name).to_string(),
352            (*target_store_path).to_string(),
353            relation_strength(*strength),
354            EntityRelationCardinality::Single,
355        )),
356        FieldKind::List(inner) => {
357            relation_from_collection_relation(field_name, inner, EntityRelationCardinality::List)
358        }
359        FieldKind::Set(inner) => {
360            relation_from_collection_relation(field_name, inner, EntityRelationCardinality::Set)
361        }
362        FieldKind::Account
363        | FieldKind::Blob
364        | FieldKind::Bool
365        | FieldKind::Date
366        | FieldKind::Decimal { .. }
367        | FieldKind::Duration
368        | FieldKind::Enum { .. }
369        | FieldKind::Float32
370        | FieldKind::Float64
371        | FieldKind::Int
372        | FieldKind::Int128
373        | FieldKind::IntBig
374        | FieldKind::Principal
375        | FieldKind::Subaccount
376        | FieldKind::Text
377        | FieldKind::Timestamp
378        | FieldKind::Uint
379        | FieldKind::Uint128
380        | FieldKind::UintBig
381        | FieldKind::Ulid
382        | FieldKind::Unit
383        | FieldKind::Map { .. }
384        | FieldKind::Structured { .. } => None,
385    }
386}
387
388#[cfg_attr(
389    doc,
390    doc = "Resolve list/set relation metadata only when the collection inner shape is relation."
391)]
392fn relation_from_collection_relation(
393    field_name: &str,
394    inner: &FieldKind,
395    cardinality: EntityRelationCardinality,
396) -> Option<EntityRelationDescription> {
397    let FieldKind::Relation {
398        target_path,
399        target_entity_name,
400        target_store_path,
401        strength,
402        ..
403    } = inner
404    else {
405        return None;
406    };
407
408    Some(EntityRelationDescription::new(
409        field_name.to_string(),
410        (*target_path).to_string(),
411        (*target_entity_name).to_string(),
412        (*target_store_path).to_string(),
413        relation_strength(*strength),
414        cardinality,
415    ))
416}
417
418#[cfg_attr(
419    doc,
420    doc = "Project runtime relation strength into the describe DTO surface."
421)]
422const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
423    match strength {
424        RelationStrength::Strong => EntityRelationStrength::Strong,
425        RelationStrength::Weak => EntityRelationStrength::Weak,
426    }
427}
428
429#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
430fn summarize_field_kind(kind: &FieldKind) -> String {
431    let mut out = String::new();
432    write_field_kind_summary(&mut out, kind);
433
434    out
435}
436
437// Stream one stable field-kind label directly into the output buffer so
438// describe/sql surfaces do not retain a large recursive `format!` family.
439fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
440    match kind {
441        FieldKind::Account => out.push_str("account"),
442        FieldKind::Blob => out.push_str("blob"),
443        FieldKind::Bool => out.push_str("bool"),
444        FieldKind::Date => out.push_str("date"),
445        FieldKind::Decimal { scale } => {
446            let _ = write!(out, "decimal(scale={scale})");
447        }
448        FieldKind::Duration => out.push_str("duration"),
449        FieldKind::Enum { path, .. } => {
450            out.push_str("enum(");
451            out.push_str(path);
452            out.push(')');
453        }
454        FieldKind::Float32 => out.push_str("float32"),
455        FieldKind::Float64 => out.push_str("float64"),
456        FieldKind::Int => out.push_str("int"),
457        FieldKind::Int128 => out.push_str("int128"),
458        FieldKind::IntBig => out.push_str("int_big"),
459        FieldKind::Principal => out.push_str("principal"),
460        FieldKind::Subaccount => out.push_str("subaccount"),
461        FieldKind::Text => out.push_str("text"),
462        FieldKind::Timestamp => out.push_str("timestamp"),
463        FieldKind::Uint => out.push_str("uint"),
464        FieldKind::Uint128 => out.push_str("uint128"),
465        FieldKind::UintBig => out.push_str("uint_big"),
466        FieldKind::Ulid => out.push_str("ulid"),
467        FieldKind::Unit => out.push_str("unit"),
468        FieldKind::Relation {
469            target_entity_name,
470            key_kind,
471            strength,
472            ..
473        } => {
474            out.push_str("relation(target=");
475            out.push_str(target_entity_name);
476            out.push_str(", key=");
477            write_field_kind_summary(out, key_kind);
478            out.push_str(", strength=");
479            out.push_str(summarize_relation_strength(*strength));
480            out.push(')');
481        }
482        FieldKind::List(inner) => {
483            out.push_str("list<");
484            write_field_kind_summary(out, inner);
485            out.push('>');
486        }
487        FieldKind::Set(inner) => {
488            out.push_str("set<");
489            write_field_kind_summary(out, inner);
490            out.push('>');
491        }
492        FieldKind::Map { key, value } => {
493            out.push_str("map<");
494            write_field_kind_summary(out, key);
495            out.push_str(", ");
496            write_field_kind_summary(out, value);
497            out.push('>');
498        }
499        FieldKind::Structured { queryable } => {
500            let _ = write!(out, "structured(queryable={queryable})");
501        }
502    }
503}
504
505#[cfg_attr(
506    doc,
507    doc = "Render one stable relation-strength label for field-kind summaries."
508)]
509const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
510    match strength {
511        RelationStrength::Strong => "strong",
512        RelationStrength::Weak => "weak",
513    }
514}
515
516//
517// TESTS
518//
519
520#[cfg(test)]
521mod tests {
522    use crate::db::{
523        EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
524        EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
525    };
526    use candid::types::{CandidType, Label, Type, TypeInner};
527
528    fn expect_record_fields(ty: Type) -> Vec<String> {
529        match ty.as_ref() {
530            TypeInner::Record(fields) => fields
531                .iter()
532                .map(|field| match field.id.as_ref() {
533                    Label::Named(name) => name.clone(),
534                    other => panic!("expected named record field, got {other:?}"),
535                })
536                .collect(),
537            other => panic!("expected candid record, got {other:?}"),
538        }
539    }
540
541    fn expect_variant_labels(ty: Type) -> Vec<String> {
542        match ty.as_ref() {
543            TypeInner::Variant(fields) => fields
544                .iter()
545                .map(|field| match field.id.as_ref() {
546                    Label::Named(name) => name.clone(),
547                    other => panic!("expected named variant label, got {other:?}"),
548                })
549                .collect(),
550            other => panic!("expected candid variant, got {other:?}"),
551        }
552    }
553
554    #[test]
555    fn entity_schema_description_candid_shape_is_stable() {
556        let fields = expect_record_fields(EntitySchemaDescription::ty());
557
558        for field in [
559            "entity_path",
560            "entity_name",
561            "primary_key",
562            "fields",
563            "indexes",
564            "relations",
565        ] {
566            assert!(
567                fields.iter().any(|candidate| candidate == field),
568                "EntitySchemaDescription must keep `{field}` field key",
569            );
570        }
571    }
572
573    #[test]
574    fn entity_field_description_candid_shape_is_stable() {
575        let fields = expect_record_fields(EntityFieldDescription::ty());
576
577        for field in ["name", "kind", "primary_key", "queryable"] {
578            assert!(
579                fields.iter().any(|candidate| candidate == field),
580                "EntityFieldDescription must keep `{field}` field key",
581            );
582        }
583    }
584
585    #[test]
586    fn entity_index_description_candid_shape_is_stable() {
587        let fields = expect_record_fields(EntityIndexDescription::ty());
588
589        for field in ["name", "unique", "fields"] {
590            assert!(
591                fields.iter().any(|candidate| candidate == field),
592                "EntityIndexDescription must keep `{field}` field key",
593            );
594        }
595    }
596
597    #[test]
598    fn entity_relation_description_candid_shape_is_stable() {
599        let fields = expect_record_fields(EntityRelationDescription::ty());
600
601        for field in [
602            "field",
603            "target_path",
604            "target_entity_name",
605            "target_store_path",
606            "strength",
607            "cardinality",
608        ] {
609            assert!(
610                fields.iter().any(|candidate| candidate == field),
611                "EntityRelationDescription must keep `{field}` field key",
612            );
613        }
614    }
615
616    #[test]
617    fn relation_enum_variant_labels_are_stable() {
618        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
619        strength_labels.sort_unstable();
620        assert_eq!(
621            strength_labels,
622            vec!["Strong".to_string(), "Weak".to_string()]
623        );
624
625        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
626        cardinality_labels.sort_unstable();
627        assert_eq!(
628            cardinality_labels,
629            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
630        );
631    }
632
633    #[test]
634    fn describe_fixture_constructors_stay_usable() {
635        let payload = EntitySchemaDescription::new(
636            "entities::User".to_string(),
637            "User".to_string(),
638            "id".to_string(),
639            vec![EntityFieldDescription::new(
640                "id".to_string(),
641                "ulid".to_string(),
642                true,
643                true,
644            )],
645            vec![EntityIndexDescription::new(
646                "idx_email".to_string(),
647                true,
648                vec!["email".to_string()],
649            )],
650            vec![EntityRelationDescription::new(
651                "account_id".to_string(),
652                "entities::Account".to_string(),
653                "Account".to_string(),
654                "accounts".to_string(),
655                EntityRelationStrength::Strong,
656                EntityRelationCardinality::Single,
657            )],
658        );
659
660        assert_eq!(payload.entity_name(), "User");
661        assert_eq!(payload.fields().len(), 1);
662        assert_eq!(payload.indexes().len(), 1);
663        assert_eq!(payload.relations().len(), 1);
664    }
665}