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;
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)]
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)]
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)]
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)]
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)]
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)]
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 candid::types::{CandidType, Label, Type, TypeInner};
482
483    fn expect_record_fields(ty: Type) -> Vec<String> {
484        match ty.as_ref() {
485            TypeInner::Record(fields) => fields
486                .iter()
487                .map(|field| match field.id.as_ref() {
488                    Label::Named(name) => name.clone(),
489                    other => panic!("expected named record field, got {other:?}"),
490                })
491                .collect(),
492            other => panic!("expected candid record, got {other:?}"),
493        }
494    }
495
496    fn expect_variant_labels(ty: Type) -> Vec<String> {
497        match ty.as_ref() {
498            TypeInner::Variant(fields) => fields
499                .iter()
500                .map(|field| match field.id.as_ref() {
501                    Label::Named(name) => name.clone(),
502                    other => panic!("expected named variant label, got {other:?}"),
503                })
504                .collect(),
505            other => panic!("expected candid variant, got {other:?}"),
506        }
507    }
508
509    #[test]
510    fn entity_schema_description_candid_shape_is_stable() {
511        let fields = expect_record_fields(EntitySchemaDescription::ty());
512
513        for field in [
514            "entity_path",
515            "entity_name",
516            "primary_key",
517            "fields",
518            "indexes",
519            "relations",
520        ] {
521            assert!(
522                fields.iter().any(|candidate| candidate == field),
523                "EntitySchemaDescription must keep `{field}` field key",
524            );
525        }
526    }
527
528    #[test]
529    fn entity_field_description_candid_shape_is_stable() {
530        let fields = expect_record_fields(EntityFieldDescription::ty());
531
532        for field in ["name", "kind", "primary_key", "queryable"] {
533            assert!(
534                fields.iter().any(|candidate| candidate == field),
535                "EntityFieldDescription must keep `{field}` field key",
536            );
537        }
538    }
539
540    #[test]
541    fn entity_index_description_candid_shape_is_stable() {
542        let fields = expect_record_fields(EntityIndexDescription::ty());
543
544        for field in ["name", "unique", "fields"] {
545            assert!(
546                fields.iter().any(|candidate| candidate == field),
547                "EntityIndexDescription must keep `{field}` field key",
548            );
549        }
550    }
551
552    #[test]
553    fn entity_relation_description_candid_shape_is_stable() {
554        let fields = expect_record_fields(EntityRelationDescription::ty());
555
556        for field in [
557            "field",
558            "target_path",
559            "target_entity_name",
560            "target_store_path",
561            "strength",
562            "cardinality",
563        ] {
564            assert!(
565                fields.iter().any(|candidate| candidate == field),
566                "EntityRelationDescription must keep `{field}` field key",
567            );
568        }
569    }
570
571    #[test]
572    fn relation_enum_variant_labels_are_stable() {
573        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
574        strength_labels.sort_unstable();
575        assert_eq!(
576            strength_labels,
577            vec!["Strong".to_string(), "Weak".to_string()]
578        );
579
580        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
581        cardinality_labels.sort_unstable();
582        assert_eq!(
583            cardinality_labels,
584            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
585        );
586    }
587
588    #[test]
589    fn describe_fixture_constructors_stay_usable() {
590        let payload = EntitySchemaDescription::new(
591            "entities::User".to_string(),
592            "User".to_string(),
593            "id".to_string(),
594            vec![EntityFieldDescription::new(
595                "id".to_string(),
596                "ulid".to_string(),
597                true,
598                true,
599            )],
600            vec![EntityIndexDescription::new(
601                "idx_email".to_string(),
602                true,
603                vec!["email".to_string()],
604            )],
605            vec![EntityRelationDescription::new(
606                "account_id".to_string(),
607                "entities::Account".to_string(),
608                "Account".to_string(),
609                "accounts".to_string(),
610                EntityRelationStrength::Strong,
611                EntityRelationCardinality::Single,
612            )],
613        );
614
615        assert_eq!(payload.entity_name(), "User");
616        assert_eq!(payload.fields().len(), 1);
617        assert_eq!(payload.indexes().len(), 1);
618        assert_eq!(payload.relations().len(), 1);
619    }
620}