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 = describe_entity_fields(model);
273    let mut relations = Vec::new();
274
275    for field in model.fields {
276        if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
277            relations.push(relation);
278        }
279    }
280
281    let mut indexes = Vec::with_capacity(model.indexes.len());
282    for index in model.indexes {
283        indexes.push(EntityIndexDescription::new(
284            index.name().to_string(),
285            index.is_unique(),
286            index
287                .fields()
288                .iter()
289                .map(|field| (*field).to_string())
290                .collect(),
291        ));
292    }
293
294    EntitySchemaDescription::new(
295        model.path.to_string(),
296        model.entity_name.to_string(),
297        model.primary_key.name.to_string(),
298        fields,
299        indexes,
300        relations,
301    )
302}
303
304// Build the stable field-description subset once from one runtime model so
305// metadata surfaces that only need columns do not rebuild indexes and
306// relations through the heavier DESCRIBE payload path.
307#[must_use]
308pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
309    let mut fields = Vec::with_capacity(model.fields.len());
310
311    for field in model.fields {
312        let field_kind = summarize_field_kind(&field.kind);
313        let queryable = field.kind.value_kind().is_queryable();
314        let primary_key = field.name == model.primary_key.name;
315
316        fields.push(EntityFieldDescription::new(
317            field.name.to_string(),
318            field_kind,
319            primary_key,
320            queryable,
321        ));
322    }
323
324    fields
325}
326
327#[cfg_attr(
328    doc,
329    doc = "Resolve relation metadata from one field kind, including list/set relation forms."
330)]
331fn relation_from_field_kind(
332    field_name: &str,
333    kind: &FieldKind,
334) -> Option<EntityRelationDescription> {
335    match kind {
336        FieldKind::Relation {
337            target_path,
338            target_entity_name,
339            target_store_path,
340            strength,
341            ..
342        } => Some(EntityRelationDescription::new(
343            field_name.to_string(),
344            (*target_path).to_string(),
345            (*target_entity_name).to_string(),
346            (*target_store_path).to_string(),
347            relation_strength(*strength),
348            EntityRelationCardinality::Single,
349        )),
350        FieldKind::List(inner) => {
351            relation_from_collection_relation(field_name, inner, EntityRelationCardinality::List)
352        }
353        FieldKind::Set(inner) => {
354            relation_from_collection_relation(field_name, inner, EntityRelationCardinality::Set)
355        }
356        FieldKind::Account
357        | FieldKind::Blob
358        | FieldKind::Bool
359        | FieldKind::Date
360        | FieldKind::Decimal { .. }
361        | FieldKind::Duration
362        | FieldKind::Enum { .. }
363        | FieldKind::Float32
364        | FieldKind::Float64
365        | FieldKind::Int
366        | FieldKind::Int128
367        | FieldKind::IntBig
368        | FieldKind::Principal
369        | FieldKind::Subaccount
370        | FieldKind::Text
371        | FieldKind::Timestamp
372        | FieldKind::Uint
373        | FieldKind::Uint128
374        | FieldKind::UintBig
375        | FieldKind::Ulid
376        | FieldKind::Unit
377        | FieldKind::Map { .. }
378        | FieldKind::Structured { .. } => None,
379    }
380}
381
382#[cfg_attr(
383    doc,
384    doc = "Resolve list/set relation metadata only when the collection inner shape is relation."
385)]
386fn relation_from_collection_relation(
387    field_name: &str,
388    inner: &FieldKind,
389    cardinality: EntityRelationCardinality,
390) -> Option<EntityRelationDescription> {
391    let FieldKind::Relation {
392        target_path,
393        target_entity_name,
394        target_store_path,
395        strength,
396        ..
397    } = inner
398    else {
399        return None;
400    };
401
402    Some(EntityRelationDescription::new(
403        field_name.to_string(),
404        (*target_path).to_string(),
405        (*target_entity_name).to_string(),
406        (*target_store_path).to_string(),
407        relation_strength(*strength),
408        cardinality,
409    ))
410}
411
412#[cfg_attr(
413    doc,
414    doc = "Project runtime relation strength into the describe DTO surface."
415)]
416const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
417    match strength {
418        RelationStrength::Strong => EntityRelationStrength::Strong,
419        RelationStrength::Weak => EntityRelationStrength::Weak,
420    }
421}
422
423#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
424fn summarize_field_kind(kind: &FieldKind) -> String {
425    let mut out = String::new();
426    write_field_kind_summary(&mut out, kind);
427
428    out
429}
430
431// Stream one stable field-kind label directly into the output buffer so
432// describe/sql surfaces do not retain a large recursive `format!` family.
433fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
434    match kind {
435        FieldKind::Account => out.push_str("account"),
436        FieldKind::Blob => out.push_str("blob"),
437        FieldKind::Bool => out.push_str("bool"),
438        FieldKind::Date => out.push_str("date"),
439        FieldKind::Decimal { scale } => {
440            let _ = write!(out, "decimal(scale={scale})");
441        }
442        FieldKind::Duration => out.push_str("duration"),
443        FieldKind::Enum { path, .. } => {
444            out.push_str("enum(");
445            out.push_str(path);
446            out.push(')');
447        }
448        FieldKind::Float32 => out.push_str("float32"),
449        FieldKind::Float64 => out.push_str("float64"),
450        FieldKind::Int => out.push_str("int"),
451        FieldKind::Int128 => out.push_str("int128"),
452        FieldKind::IntBig => out.push_str("int_big"),
453        FieldKind::Principal => out.push_str("principal"),
454        FieldKind::Subaccount => out.push_str("subaccount"),
455        FieldKind::Text => out.push_str("text"),
456        FieldKind::Timestamp => out.push_str("timestamp"),
457        FieldKind::Uint => out.push_str("uint"),
458        FieldKind::Uint128 => out.push_str("uint128"),
459        FieldKind::UintBig => out.push_str("uint_big"),
460        FieldKind::Ulid => out.push_str("ulid"),
461        FieldKind::Unit => out.push_str("unit"),
462        FieldKind::Relation {
463            target_entity_name,
464            key_kind,
465            strength,
466            ..
467        } => {
468            out.push_str("relation(target=");
469            out.push_str(target_entity_name);
470            out.push_str(", key=");
471            write_field_kind_summary(out, key_kind);
472            out.push_str(", strength=");
473            out.push_str(summarize_relation_strength(*strength));
474            out.push(')');
475        }
476        FieldKind::List(inner) => {
477            out.push_str("list<");
478            write_field_kind_summary(out, inner);
479            out.push('>');
480        }
481        FieldKind::Set(inner) => {
482            out.push_str("set<");
483            write_field_kind_summary(out, inner);
484            out.push('>');
485        }
486        FieldKind::Map { key, value } => {
487            out.push_str("map<");
488            write_field_kind_summary(out, key);
489            out.push_str(", ");
490            write_field_kind_summary(out, value);
491            out.push('>');
492        }
493        FieldKind::Structured { queryable } => {
494            let _ = write!(out, "structured(queryable={queryable})");
495        }
496    }
497}
498
499#[cfg_attr(
500    doc,
501    doc = "Render one stable relation-strength label for field-kind summaries."
502)]
503const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
504    match strength {
505        RelationStrength::Strong => "strong",
506        RelationStrength::Weak => "weak",
507    }
508}
509
510//
511// TESTS
512//
513
514#[cfg(test)]
515mod tests {
516    use crate::db::{
517        EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
518        EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
519    };
520    use candid::types::{CandidType, Label, Type, TypeInner};
521
522    fn expect_record_fields(ty: Type) -> Vec<String> {
523        match ty.as_ref() {
524            TypeInner::Record(fields) => fields
525                .iter()
526                .map(|field| match field.id.as_ref() {
527                    Label::Named(name) => name.clone(),
528                    other => panic!("expected named record field, got {other:?}"),
529                })
530                .collect(),
531            other => panic!("expected candid record, got {other:?}"),
532        }
533    }
534
535    fn expect_variant_labels(ty: Type) -> Vec<String> {
536        match ty.as_ref() {
537            TypeInner::Variant(fields) => fields
538                .iter()
539                .map(|field| match field.id.as_ref() {
540                    Label::Named(name) => name.clone(),
541                    other => panic!("expected named variant label, got {other:?}"),
542                })
543                .collect(),
544            other => panic!("expected candid variant, got {other:?}"),
545        }
546    }
547
548    #[test]
549    fn entity_schema_description_candid_shape_is_stable() {
550        let fields = expect_record_fields(EntitySchemaDescription::ty());
551
552        for field in [
553            "entity_path",
554            "entity_name",
555            "primary_key",
556            "fields",
557            "indexes",
558            "relations",
559        ] {
560            assert!(
561                fields.iter().any(|candidate| candidate == field),
562                "EntitySchemaDescription must keep `{field}` field key",
563            );
564        }
565    }
566
567    #[test]
568    fn entity_field_description_candid_shape_is_stable() {
569        let fields = expect_record_fields(EntityFieldDescription::ty());
570
571        for field in ["name", "kind", "primary_key", "queryable"] {
572            assert!(
573                fields.iter().any(|candidate| candidate == field),
574                "EntityFieldDescription must keep `{field}` field key",
575            );
576        }
577    }
578
579    #[test]
580    fn entity_index_description_candid_shape_is_stable() {
581        let fields = expect_record_fields(EntityIndexDescription::ty());
582
583        for field in ["name", "unique", "fields"] {
584            assert!(
585                fields.iter().any(|candidate| candidate == field),
586                "EntityIndexDescription must keep `{field}` field key",
587            );
588        }
589    }
590
591    #[test]
592    fn entity_relation_description_candid_shape_is_stable() {
593        let fields = expect_record_fields(EntityRelationDescription::ty());
594
595        for field in [
596            "field",
597            "target_path",
598            "target_entity_name",
599            "target_store_path",
600            "strength",
601            "cardinality",
602        ] {
603            assert!(
604                fields.iter().any(|candidate| candidate == field),
605                "EntityRelationDescription must keep `{field}` field key",
606            );
607        }
608    }
609
610    #[test]
611    fn relation_enum_variant_labels_are_stable() {
612        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
613        strength_labels.sort_unstable();
614        assert_eq!(
615            strength_labels,
616            vec!["Strong".to_string(), "Weak".to_string()]
617        );
618
619        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
620        cardinality_labels.sort_unstable();
621        assert_eq!(
622            cardinality_labels,
623            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
624        );
625    }
626
627    #[test]
628    fn describe_fixture_constructors_stay_usable() {
629        let payload = EntitySchemaDescription::new(
630            "entities::User".to_string(),
631            "User".to_string(),
632            "id".to_string(),
633            vec![EntityFieldDescription::new(
634                "id".to_string(),
635                "ulid".to_string(),
636                true,
637                true,
638            )],
639            vec![EntityIndexDescription::new(
640                "idx_email".to_string(),
641                true,
642                vec!["email".to_string()],
643            )],
644            vec![EntityRelationDescription::new(
645                "account_id".to_string(),
646                "entities::Account".to_string(),
647                "Account".to_string(),
648                "accounts".to_string(),
649                EntityRelationStrength::Strong,
650                EntityRelationCardinality::Single,
651            )],
652        );
653
654        assert_eq!(payload.entity_name(), "User");
655        assert_eq!(payload.fields().len(), 1);
656        assert_eq!(payload.indexes().len(), 1);
657        assert_eq!(payload.relations().len(), 1);
658    }
659}