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///
15/// EntitySchemaDescription
16///
17/// Stable describe payload for one entity model.
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 entry in a describe payload.
92///
93
94#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
95pub struct EntityFieldDescription {
96    pub(crate) name: String,
97    pub(crate) kind: String,
98    pub(crate) primary_key: bool,
99    pub(crate) queryable: bool,
100}
101
102impl EntityFieldDescription {
103    /// Construct one field description entry.
104    #[must_use]
105    pub const fn new(name: String, kind: String, primary_key: bool, queryable: bool) -> Self {
106        Self {
107            name,
108            kind,
109            primary_key,
110            queryable,
111        }
112    }
113
114    /// Borrow the field name.
115    #[must_use]
116    pub const fn name(&self) -> &str {
117        self.name.as_str()
118    }
119
120    /// Borrow the rendered field kind label.
121    #[must_use]
122    pub const fn kind(&self) -> &str {
123        self.kind.as_str()
124    }
125
126    /// Return whether this field is the primary key.
127    #[must_use]
128    pub const fn primary_key(&self) -> bool {
129        self.primary_key
130    }
131
132    /// Return whether this field is queryable.
133    #[must_use]
134    pub const fn queryable(&self) -> bool {
135        self.queryable
136    }
137}
138
139///
140/// EntityIndexDescription
141///
142/// One index entry in a describe payload.
143///
144
145#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
146pub struct EntityIndexDescription {
147    pub(crate) name: String,
148    pub(crate) unique: bool,
149    pub(crate) fields: Vec<String>,
150}
151
152impl EntityIndexDescription {
153    /// Construct one index description entry.
154    #[must_use]
155    pub const fn new(name: String, unique: bool, fields: Vec<String>) -> Self {
156        Self {
157            name,
158            unique,
159            fields,
160        }
161    }
162
163    /// Borrow the index name.
164    #[must_use]
165    pub const fn name(&self) -> &str {
166        self.name.as_str()
167    }
168
169    /// Return whether the index enforces uniqueness.
170    #[must_use]
171    pub const fn unique(&self) -> bool {
172        self.unique
173    }
174
175    /// Borrow ordered index field names.
176    #[must_use]
177    pub const fn fields(&self) -> &[String] {
178        self.fields.as_slice()
179    }
180}
181
182///
183/// EntityRelationDescription
184///
185/// One relation entry in a describe payload.
186///
187
188#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
189pub struct EntityRelationDescription {
190    pub(crate) field: String,
191    pub(crate) target_path: String,
192    pub(crate) target_entity_name: String,
193    pub(crate) target_store_path: String,
194    pub(crate) strength: EntityRelationStrength,
195    pub(crate) cardinality: EntityRelationCardinality,
196}
197
198impl EntityRelationDescription {
199    /// Construct one relation description entry.
200    #[must_use]
201    pub const fn new(
202        field: String,
203        target_path: String,
204        target_entity_name: String,
205        target_store_path: String,
206        strength: EntityRelationStrength,
207        cardinality: EntityRelationCardinality,
208    ) -> Self {
209        Self {
210            field,
211            target_path,
212            target_entity_name,
213            target_store_path,
214            strength,
215            cardinality,
216        }
217    }
218
219    /// Borrow the source relation field name.
220    #[must_use]
221    pub const fn field(&self) -> &str {
222        self.field.as_str()
223    }
224
225    /// Borrow the relation target path.
226    #[must_use]
227    pub const fn target_path(&self) -> &str {
228        self.target_path.as_str()
229    }
230
231    /// Borrow the relation target entity name.
232    #[must_use]
233    pub const fn target_entity_name(&self) -> &str {
234        self.target_entity_name.as_str()
235    }
236
237    /// Borrow the relation target store path.
238    #[must_use]
239    pub const fn target_store_path(&self) -> &str {
240        self.target_store_path.as_str()
241    }
242
243    /// Return relation strength.
244    #[must_use]
245    pub const fn strength(&self) -> EntityRelationStrength {
246        self.strength
247    }
248
249    /// Return relation cardinality.
250    #[must_use]
251    pub const fn cardinality(&self) -> EntityRelationCardinality {
252        self.cardinality
253    }
254}
255
256///
257/// EntityRelationStrength
258///
259/// Describe relation strength.
260///
261
262#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
263pub enum EntityRelationStrength {
264    Strong,
265    Weak,
266}
267
268///
269/// EntityRelationCardinality
270///
271/// Describe relation cardinality.
272///
273
274#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
275pub enum EntityRelationCardinality {
276    Single,
277    List,
278    Set,
279}
280
281/// Build one stable entity-schema description from one runtime `EntityModel`.
282#[must_use]
283pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
284    let mut fields = Vec::with_capacity(model.fields.len());
285    let mut relations = Vec::new();
286    for field in model.fields {
287        let field_kind = summarize_field_kind(&field.kind);
288        let queryable = field.kind.value_kind().is_queryable();
289        let primary_key = field.name == model.primary_key.name;
290
291        fields.push(EntityFieldDescription::new(
292            field.name.to_string(),
293            field_kind,
294            primary_key,
295            queryable,
296        ));
297
298        if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
299            relations.push(relation);
300        }
301    }
302
303    let mut indexes = Vec::with_capacity(model.indexes.len());
304    for index in model.indexes {
305        indexes.push(EntityIndexDescription::new(
306            index.name().to_string(),
307            index.is_unique(),
308            index
309                .fields()
310                .iter()
311                .map(|field| (*field).to_string())
312                .collect(),
313        ));
314    }
315
316    EntitySchemaDescription::new(
317        model.path.to_string(),
318        model.entity_name.to_string(),
319        model.primary_key.name.to_string(),
320        fields,
321        indexes,
322        relations,
323    )
324}
325
326/// Resolve relation metadata from one field kind, including list/set relation forms.
327fn relation_from_field_kind(
328    field_name: &str,
329    kind: &FieldKind,
330) -> Option<EntityRelationDescription> {
331    match kind {
332        FieldKind::Relation {
333            target_path,
334            target_entity_name,
335            target_store_path,
336            strength,
337            ..
338        } => Some(EntityRelationDescription::new(
339            field_name.to_string(),
340            (*target_path).to_string(),
341            (*target_entity_name).to_string(),
342            (*target_store_path).to_string(),
343            relation_strength(*strength),
344            EntityRelationCardinality::Single,
345        )),
346        FieldKind::List(inner) => {
347            relation_from_collection_relation(field_name, inner, EntityRelationCardinality::List)
348        }
349        FieldKind::Set(inner) => {
350            relation_from_collection_relation(field_name, inner, EntityRelationCardinality::Set)
351        }
352        FieldKind::Account
353        | FieldKind::Blob
354        | FieldKind::Bool
355        | FieldKind::Date
356        | FieldKind::Decimal { .. }
357        | FieldKind::Duration
358        | FieldKind::Enum { .. }
359        | FieldKind::Float32
360        | FieldKind::Float64
361        | FieldKind::Int
362        | FieldKind::Int128
363        | FieldKind::IntBig
364        | FieldKind::Principal
365        | FieldKind::Subaccount
366        | FieldKind::Text
367        | FieldKind::Timestamp
368        | FieldKind::Uint
369        | FieldKind::Uint128
370        | FieldKind::UintBig
371        | FieldKind::Ulid
372        | FieldKind::Unit
373        | FieldKind::Map { .. }
374        | FieldKind::Structured { .. } => None,
375    }
376}
377
378/// Resolve list/set relation metadata only when the collection inner shape is relation.
379fn relation_from_collection_relation(
380    field_name: &str,
381    inner: &FieldKind,
382    cardinality: EntityRelationCardinality,
383) -> Option<EntityRelationDescription> {
384    let FieldKind::Relation {
385        target_path,
386        target_entity_name,
387        target_store_path,
388        strength,
389        ..
390    } = inner
391    else {
392        return None;
393    };
394
395    Some(EntityRelationDescription::new(
396        field_name.to_string(),
397        (*target_path).to_string(),
398        (*target_entity_name).to_string(),
399        (*target_store_path).to_string(),
400        relation_strength(*strength),
401        cardinality,
402    ))
403}
404
405/// Project runtime relation strength into the describe DTO surface.
406const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
407    match strength {
408        RelationStrength::Strong => EntityRelationStrength::Strong,
409        RelationStrength::Weak => EntityRelationStrength::Weak,
410    }
411}
412
413/// Render one stable field-kind label for describe output.
414fn summarize_field_kind(kind: &FieldKind) -> String {
415    let mut out = String::new();
416    write_field_kind_summary(&mut out, kind);
417
418    out
419}
420
421// Stream one stable field-kind label directly into the output buffer so
422// describe/sql surfaces do not retain a large recursive `format!` family.
423fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
424    match kind {
425        FieldKind::Account => out.push_str("account"),
426        FieldKind::Blob => out.push_str("blob"),
427        FieldKind::Bool => out.push_str("bool"),
428        FieldKind::Date => out.push_str("date"),
429        FieldKind::Decimal { scale } => {
430            let _ = write!(out, "decimal(scale={scale})");
431        }
432        FieldKind::Duration => out.push_str("duration"),
433        FieldKind::Enum { path, .. } => {
434            out.push_str("enum(");
435            out.push_str(path);
436            out.push(')');
437        }
438        FieldKind::Float32 => out.push_str("float32"),
439        FieldKind::Float64 => out.push_str("float64"),
440        FieldKind::Int => out.push_str("int"),
441        FieldKind::Int128 => out.push_str("int128"),
442        FieldKind::IntBig => out.push_str("int_big"),
443        FieldKind::Principal => out.push_str("principal"),
444        FieldKind::Subaccount => out.push_str("subaccount"),
445        FieldKind::Text => out.push_str("text"),
446        FieldKind::Timestamp => out.push_str("timestamp"),
447        FieldKind::Uint => out.push_str("uint"),
448        FieldKind::Uint128 => out.push_str("uint128"),
449        FieldKind::UintBig => out.push_str("uint_big"),
450        FieldKind::Ulid => out.push_str("ulid"),
451        FieldKind::Unit => out.push_str("unit"),
452        FieldKind::Relation {
453            target_entity_name,
454            key_kind,
455            strength,
456            ..
457        } => {
458            out.push_str("relation(target=");
459            out.push_str(target_entity_name);
460            out.push_str(", key=");
461            write_field_kind_summary(out, key_kind);
462            out.push_str(", strength=");
463            out.push_str(summarize_relation_strength(*strength));
464            out.push(')');
465        }
466        FieldKind::List(inner) => {
467            out.push_str("list<");
468            write_field_kind_summary(out, inner);
469            out.push('>');
470        }
471        FieldKind::Set(inner) => {
472            out.push_str("set<");
473            write_field_kind_summary(out, inner);
474            out.push('>');
475        }
476        FieldKind::Map { key, value } => {
477            out.push_str("map<");
478            write_field_kind_summary(out, key);
479            out.push_str(", ");
480            write_field_kind_summary(out, value);
481            out.push('>');
482        }
483        FieldKind::Structured { queryable } => {
484            let _ = write!(out, "structured(queryable={queryable})");
485        }
486    }
487}
488
489/// Render one stable relation-strength label for field-kind summaries.
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}