Skip to main content

icydb_core/db/
describe.rs

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