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