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#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
265pub enum EntityRelationStrength {
266    Strong,
267    Weak,
268}
269
270///
271/// EntityRelationCardinality
272///
273/// Describe-surface relation cardinality projection.
274///
275#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
276pub enum EntityRelationCardinality {
277    Single,
278    List,
279    Set,
280}
281
282/// Build one stable entity-schema description from one runtime `EntityModel`.
283#[must_use]
284pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
285    let mut fields = Vec::with_capacity(model.fields.len());
286    let mut relations = Vec::new();
287    for field in model.fields {
288        let field_kind = summarize_field_kind(&field.kind);
289        let queryable = field.kind.value_kind().is_queryable();
290        let primary_key = field.name == model.primary_key.name;
291
292        fields.push(EntityFieldDescription::new(
293            field.name.to_string(),
294            field_kind,
295            primary_key,
296            queryable,
297        ));
298
299        if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
300            relations.push(relation);
301        }
302    }
303
304    let mut indexes = Vec::with_capacity(model.indexes.len());
305    for index in model.indexes {
306        indexes.push(EntityIndexDescription::new(
307            index.name.to_string(),
308            index.unique,
309            index
310                .fields
311                .iter()
312                .map(|field| (*field).to_string())
313                .collect(),
314        ));
315    }
316
317    EntitySchemaDescription::new(
318        model.path.to_string(),
319        model.entity_name.to_string(),
320        model.primary_key.name.to_string(),
321        fields,
322        indexes,
323        relations,
324    )
325}
326
327// Resolve relation metadata from one field kind, including list/set relation forms.
328fn relation_from_field_kind(
329    field_name: &str,
330    kind: &FieldKind,
331) -> Option<EntityRelationDescription> {
332    match kind {
333        FieldKind::Relation {
334            target_path,
335            target_entity_name,
336            target_store_path,
337            strength,
338            ..
339        } => Some(EntityRelationDescription::new(
340            field_name.to_string(),
341            (*target_path).to_string(),
342            (*target_entity_name).to_string(),
343            (*target_store_path).to_string(),
344            relation_strength(*strength),
345            EntityRelationCardinality::Single,
346        )),
347        FieldKind::List(inner) => {
348            relation_from_collection_relation(field_name, inner, EntityRelationCardinality::List)
349        }
350        FieldKind::Set(inner) => {
351            relation_from_collection_relation(field_name, inner, EntityRelationCardinality::Set)
352        }
353        FieldKind::Account
354        | FieldKind::Blob
355        | FieldKind::Bool
356        | FieldKind::Date
357        | FieldKind::Decimal { .. }
358        | FieldKind::Duration
359        | FieldKind::Enum { .. }
360        | FieldKind::Float32
361        | FieldKind::Float64
362        | FieldKind::Int
363        | FieldKind::Int128
364        | FieldKind::IntBig
365        | FieldKind::Principal
366        | FieldKind::Subaccount
367        | FieldKind::Text
368        | FieldKind::Timestamp
369        | FieldKind::Uint
370        | FieldKind::Uint128
371        | FieldKind::UintBig
372        | FieldKind::Ulid
373        | FieldKind::Unit
374        | FieldKind::Map { .. }
375        | FieldKind::Structured { .. } => None,
376    }
377}
378
379// Resolve list/set relation metadata only when the collection inner shape is relation.
380fn relation_from_collection_relation(
381    field_name: &str,
382    inner: &FieldKind,
383    cardinality: EntityRelationCardinality,
384) -> Option<EntityRelationDescription> {
385    let FieldKind::Relation {
386        target_path,
387        target_entity_name,
388        target_store_path,
389        strength,
390        ..
391    } = inner
392    else {
393        return None;
394    };
395
396    Some(EntityRelationDescription::new(
397        field_name.to_string(),
398        (*target_path).to_string(),
399        (*target_entity_name).to_string(),
400        (*target_store_path).to_string(),
401        relation_strength(*strength),
402        cardinality,
403    ))
404}
405
406// Project runtime relation strength into the describe DTO surface.
407const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
408    match strength {
409        RelationStrength::Strong => EntityRelationStrength::Strong,
410        RelationStrength::Weak => EntityRelationStrength::Weak,
411    }
412}
413
414// Render one stable field-kind label for describe output.
415fn summarize_field_kind(kind: &FieldKind) -> String {
416    match kind {
417        FieldKind::Account => "account".to_string(),
418        FieldKind::Blob => "blob".to_string(),
419        FieldKind::Bool => "bool".to_string(),
420        FieldKind::Date => "date".to_string(),
421        FieldKind::Decimal { scale } => format!("decimal(scale={scale})"),
422        FieldKind::Duration => "duration".to_string(),
423        FieldKind::Enum { path } => format!("enum({path})"),
424        FieldKind::Float32 => "float32".to_string(),
425        FieldKind::Float64 => "float64".to_string(),
426        FieldKind::Int => "int".to_string(),
427        FieldKind::Int128 => "int128".to_string(),
428        FieldKind::IntBig => "int_big".to_string(),
429        FieldKind::Principal => "principal".to_string(),
430        FieldKind::Subaccount => "subaccount".to_string(),
431        FieldKind::Text => "text".to_string(),
432        FieldKind::Timestamp => "timestamp".to_string(),
433        FieldKind::Uint => "uint".to_string(),
434        FieldKind::Uint128 => "uint128".to_string(),
435        FieldKind::UintBig => "uint_big".to_string(),
436        FieldKind::Ulid => "ulid".to_string(),
437        FieldKind::Unit => "unit".to_string(),
438        FieldKind::Relation {
439            target_entity_name,
440            key_kind,
441            strength,
442            ..
443        } => format!(
444            "relation(target={target_entity_name}, key={}, strength={})",
445            summarize_field_kind(key_kind),
446            summarize_relation_strength(*strength),
447        ),
448        FieldKind::List(inner) => format!("list<{}>", summarize_field_kind(inner)),
449        FieldKind::Set(inner) => format!("set<{}>", summarize_field_kind(inner)),
450        FieldKind::Map { key, value } => {
451            format!(
452                "map<{}, {}>",
453                summarize_field_kind(key),
454                summarize_field_kind(value)
455            )
456        }
457        FieldKind::Structured { queryable } => format!("structured(queryable={queryable})"),
458    }
459}
460
461// Render one stable relation-strength label for field-kind summaries.
462const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
463    match strength {
464        RelationStrength::Strong => "strong",
465        RelationStrength::Weak => "weak",
466    }
467}
468
469///
470/// TESTS
471///
472
473#[cfg(test)]
474mod tests {
475    use crate::db::{
476        EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
477        EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
478    };
479    use serde::Serialize;
480    use serde_cbor::Value as CborValue;
481    use std::collections::BTreeMap;
482
483    fn to_cbor_value<T: Serialize>(value: &T) -> CborValue {
484        let bytes =
485            serde_cbor::to_vec(value).expect("test fixtures must serialize into CBOR payloads");
486        serde_cbor::from_slice::<CborValue>(&bytes)
487            .expect("test fixtures must deserialize into CBOR value trees")
488    }
489
490    fn expect_cbor_map(value: &CborValue) -> &BTreeMap<CborValue, CborValue> {
491        match value {
492            CborValue::Map(map) => map,
493            other => panic!("expected CBOR map, got {other:?}"),
494        }
495    }
496
497    fn map_field<'a>(map: &'a BTreeMap<CborValue, CborValue>, key: &str) -> Option<&'a CborValue> {
498        map.get(&CborValue::Text(key.to_string()))
499    }
500
501    #[test]
502    fn entity_schema_description_serialization_shape_is_stable() {
503        let payload = EntitySchemaDescription::new(
504            "entities::User".to_string(),
505            "User".to_string(),
506            "id".to_string(),
507            vec![EntityFieldDescription::new(
508                "id".to_string(),
509                "ulid".to_string(),
510                true,
511                true,
512            )],
513            vec![EntityIndexDescription::new(
514                "idx_email".to_string(),
515                true,
516                vec!["email".to_string()],
517            )],
518            vec![EntityRelationDescription::new(
519                "account_id".to_string(),
520                "entities::Account".to_string(),
521                "Account".to_string(),
522                "accounts".to_string(),
523                EntityRelationStrength::Strong,
524                EntityRelationCardinality::Single,
525            )],
526        );
527        let encoded = to_cbor_value(&payload);
528        let root = expect_cbor_map(&encoded);
529
530        assert!(
531            map_field(root, "entity_path").is_some(),
532            "EntitySchemaDescription must keep `entity_path` field key",
533        );
534        assert!(
535            map_field(root, "entity_name").is_some(),
536            "EntitySchemaDescription must keep `entity_name` field key",
537        );
538        assert!(
539            map_field(root, "primary_key").is_some(),
540            "EntitySchemaDescription must keep `primary_key` field key",
541        );
542        assert!(
543            map_field(root, "fields").is_some(),
544            "EntitySchemaDescription must keep `fields` field key",
545        );
546        assert!(
547            map_field(root, "indexes").is_some(),
548            "EntitySchemaDescription must keep `indexes` field key",
549        );
550        assert!(
551            map_field(root, "relations").is_some(),
552            "EntitySchemaDescription must keep `relations` field key",
553        );
554    }
555
556    #[test]
557    fn entity_relation_description_serialization_shape_is_stable() {
558        let encoded = to_cbor_value(&EntityRelationDescription::new(
559            "owner_id".to_string(),
560            "entities::User".to_string(),
561            "User".to_string(),
562            "users".to_string(),
563            EntityRelationStrength::Weak,
564            EntityRelationCardinality::Set,
565        ));
566        let root = expect_cbor_map(&encoded);
567
568        assert!(
569            map_field(root, "field").is_some(),
570            "EntityRelationDescription must keep `field` field key",
571        );
572        assert!(
573            map_field(root, "target_path").is_some(),
574            "EntityRelationDescription must keep `target_path` field key",
575        );
576        assert!(
577            map_field(root, "target_entity_name").is_some(),
578            "EntityRelationDescription must keep `target_entity_name` field key",
579        );
580        assert!(
581            map_field(root, "target_store_path").is_some(),
582            "EntityRelationDescription must keep `target_store_path` field key",
583        );
584        assert!(
585            map_field(root, "strength").is_some(),
586            "EntityRelationDescription must keep `strength` field key",
587        );
588        assert!(
589            map_field(root, "cardinality").is_some(),
590            "EntityRelationDescription must keep `cardinality` field key",
591        );
592    }
593
594    #[test]
595    fn relation_enum_variant_labels_are_stable() {
596        assert_eq!(
597            to_cbor_value(&EntityRelationStrength::Strong),
598            CborValue::Text("Strong".to_string())
599        );
600        assert_eq!(
601            to_cbor_value(&EntityRelationStrength::Weak),
602            CborValue::Text("Weak".to_string())
603        );
604        assert_eq!(
605            to_cbor_value(&EntityRelationCardinality::Single),
606            CborValue::Text("Single".to_string())
607        );
608        assert_eq!(
609            to_cbor_value(&EntityRelationCardinality::List),
610            CborValue::Text("List".to_string())
611        );
612        assert_eq!(
613            to_cbor_value(&EntityRelationCardinality::Set),
614            CborValue::Text("Set".to_string())
615        );
616    }
617}