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 schema-introspection payload for one runtime entity model.
18/// This mirrors SQL-style `DESCRIBE` intent for fields, indexes, and relations.
19///
20
21#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
22pub struct EntitySchemaDescription {
23    pub(crate) entity_path: String,
24    pub(crate) entity_name: String,
25    pub(crate) primary_key: String,
26    pub(crate) fields: Vec<EntityFieldDescription>,
27    pub(crate) indexes: Vec<EntityIndexDescription>,
28    pub(crate) relations: Vec<EntityRelationDescription>,
29}
30
31impl EntitySchemaDescription {
32    /// Construct one entity schema description payload.
33    #[must_use]
34    pub const fn new(
35        entity_path: String,
36        entity_name: String,
37        primary_key: String,
38        fields: Vec<EntityFieldDescription>,
39        indexes: Vec<EntityIndexDescription>,
40        relations: Vec<EntityRelationDescription>,
41    ) -> Self {
42        Self {
43            entity_path,
44            entity_name,
45            primary_key,
46            fields,
47            indexes,
48            relations,
49        }
50    }
51
52    /// Borrow the entity module path.
53    #[must_use]
54    pub const fn entity_path(&self) -> &str {
55        self.entity_path.as_str()
56    }
57
58    /// Borrow the entity display name.
59    #[must_use]
60    pub const fn entity_name(&self) -> &str {
61        self.entity_name.as_str()
62    }
63
64    /// Borrow the primary-key field name.
65    #[must_use]
66    pub const fn primary_key(&self) -> &str {
67        self.primary_key.as_str()
68    }
69
70    /// Borrow field description entries.
71    #[must_use]
72    pub const fn fields(&self) -> &[EntityFieldDescription] {
73        self.fields.as_slice()
74    }
75
76    /// Borrow index description entries.
77    #[must_use]
78    pub const fn indexes(&self) -> &[EntityIndexDescription] {
79        self.indexes.as_slice()
80    }
81
82    /// Borrow relation description entries.
83    #[must_use]
84    pub const fn relations(&self) -> &[EntityRelationDescription] {
85        self.relations.as_slice()
86    }
87}
88
89///
90/// EntityFieldDescription
91///
92/// One field-level projection inside `EntitySchemaDescription`.
93/// Keeps field type and queryability metadata explicit for diagnostics.
94///
95
96#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
97pub struct EntityFieldDescription {
98    pub(crate) name: String,
99    pub(crate) kind: String,
100    pub(crate) primary_key: bool,
101    pub(crate) queryable: bool,
102}
103
104impl EntityFieldDescription {
105    /// Construct one field description entry.
106    #[must_use]
107    pub const fn new(name: String, kind: String, primary_key: bool, queryable: bool) -> Self {
108        Self {
109            name,
110            kind,
111            primary_key,
112            queryable,
113        }
114    }
115
116    /// Borrow the field name.
117    #[must_use]
118    pub const fn name(&self) -> &str {
119        self.name.as_str()
120    }
121
122    /// Borrow the rendered field kind label.
123    #[must_use]
124    pub const fn kind(&self) -> &str {
125        self.kind.as_str()
126    }
127
128    /// Return whether this field is the primary key.
129    #[must_use]
130    pub const fn primary_key(&self) -> bool {
131        self.primary_key
132    }
133
134    /// Return whether this field is queryable.
135    #[must_use]
136    pub const fn queryable(&self) -> bool {
137        self.queryable
138    }
139}
140
141///
142/// EntityIndexDescription
143///
144/// One secondary-index projection inside `EntitySchemaDescription`.
145/// Includes uniqueness and ordered field list.
146///
147
148#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
149pub struct EntityIndexDescription {
150    pub(crate) name: String,
151    pub(crate) unique: bool,
152    pub(crate) fields: Vec<String>,
153}
154
155impl EntityIndexDescription {
156    /// Construct one index description entry.
157    #[must_use]
158    pub const fn new(name: String, unique: bool, fields: Vec<String>) -> Self {
159        Self {
160            name,
161            unique,
162            fields,
163        }
164    }
165
166    /// Borrow the index name.
167    #[must_use]
168    pub const fn name(&self) -> &str {
169        self.name.as_str()
170    }
171
172    /// Return whether the index enforces uniqueness.
173    #[must_use]
174    pub const fn unique(&self) -> bool {
175        self.unique
176    }
177
178    /// Borrow ordered index field names.
179    #[must_use]
180    pub const fn fields(&self) -> &[String] {
181        self.fields.as_slice()
182    }
183}
184
185///
186/// EntityRelationDescription
187///
188/// One relation-field projection inside `EntitySchemaDescription`.
189/// Captures relation target identity plus strength/cardinality metadata.
190///
191
192#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
193pub struct EntityRelationDescription {
194    pub(crate) field: String,
195    pub(crate) target_path: String,
196    pub(crate) target_entity_name: String,
197    pub(crate) target_store_path: String,
198    pub(crate) strength: EntityRelationStrength,
199    pub(crate) cardinality: EntityRelationCardinality,
200}
201
202impl EntityRelationDescription {
203    /// Construct one relation description entry.
204    #[must_use]
205    pub const fn new(
206        field: String,
207        target_path: String,
208        target_entity_name: String,
209        target_store_path: String,
210        strength: EntityRelationStrength,
211        cardinality: EntityRelationCardinality,
212    ) -> Self {
213        Self {
214            field,
215            target_path,
216            target_entity_name,
217            target_store_path,
218            strength,
219            cardinality,
220        }
221    }
222
223    /// Borrow the source relation field name.
224    #[must_use]
225    pub const fn field(&self) -> &str {
226        self.field.as_str()
227    }
228
229    /// Borrow the relation target path.
230    #[must_use]
231    pub const fn target_path(&self) -> &str {
232        self.target_path.as_str()
233    }
234
235    /// Borrow the relation target entity name.
236    #[must_use]
237    pub const fn target_entity_name(&self) -> &str {
238        self.target_entity_name.as_str()
239    }
240
241    /// Borrow the relation target store path.
242    #[must_use]
243    pub const fn target_store_path(&self) -> &str {
244        self.target_store_path.as_str()
245    }
246
247    /// Return relation strength.
248    #[must_use]
249    pub const fn strength(&self) -> EntityRelationStrength {
250        self.strength
251    }
252
253    /// Return relation cardinality.
254    #[must_use]
255    pub const fn cardinality(&self) -> EntityRelationCardinality {
256        self.cardinality
257    }
258}
259
260///
261/// EntityRelationStrength
262///
263/// Describe-surface relation strength projection.
264///
265
266#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
267pub enum EntityRelationStrength {
268    Strong,
269    Weak,
270}
271
272///
273/// EntityRelationCardinality
274///
275/// Describe-surface relation cardinality projection.
276///
277
278#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
279pub enum EntityRelationCardinality {
280    Single,
281    List,
282    Set,
283}
284
285/// Build one stable entity-schema description from one runtime `EntityModel`.
286#[must_use]
287pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
288    let mut fields = Vec::with_capacity(model.fields.len());
289    let mut relations = Vec::new();
290    for field in model.fields {
291        let field_kind = summarize_field_kind(&field.kind);
292        let queryable = field.kind.value_kind().is_queryable();
293        let primary_key = field.name == model.primary_key.name;
294
295        fields.push(EntityFieldDescription::new(
296            field.name.to_string(),
297            field_kind,
298            primary_key,
299            queryable,
300        ));
301
302        if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
303            relations.push(relation);
304        }
305    }
306
307    let mut indexes = Vec::with_capacity(model.indexes.len());
308    for index in model.indexes {
309        indexes.push(EntityIndexDescription::new(
310            index.name().to_string(),
311            index.is_unique(),
312            index
313                .fields()
314                .iter()
315                .map(|field| (*field).to_string())
316                .collect(),
317        ));
318    }
319
320    EntitySchemaDescription::new(
321        model.path.to_string(),
322        model.entity_name.to_string(),
323        model.primary_key.name.to_string(),
324        fields,
325        indexes,
326        relations,
327    )
328}
329
330/// Resolve relation metadata from one field kind, including list/set relation forms.
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/// Resolve list/set relation metadata only when the collection inner shape is relation.
383fn relation_from_collection_relation(
384    field_name: &str,
385    inner: &FieldKind,
386    cardinality: EntityRelationCardinality,
387) -> Option<EntityRelationDescription> {
388    let FieldKind::Relation {
389        target_path,
390        target_entity_name,
391        target_store_path,
392        strength,
393        ..
394    } = inner
395    else {
396        return None;
397    };
398
399    Some(EntityRelationDescription::new(
400        field_name.to_string(),
401        (*target_path).to_string(),
402        (*target_entity_name).to_string(),
403        (*target_store_path).to_string(),
404        relation_strength(*strength),
405        cardinality,
406    ))
407}
408
409/// Project runtime relation strength into the describe DTO surface.
410const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
411    match strength {
412        RelationStrength::Strong => EntityRelationStrength::Strong,
413        RelationStrength::Weak => EntityRelationStrength::Weak,
414    }
415}
416
417/// Render one stable field-kind label for describe output.
418fn summarize_field_kind(kind: &FieldKind) -> String {
419    let mut out = String::new();
420    write_field_kind_summary(&mut out, kind);
421
422    out
423}
424
425// Stream one stable field-kind label directly into the output buffer so
426// describe/sql surfaces do not retain a large recursive `format!` family.
427fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
428    match kind {
429        FieldKind::Account => out.push_str("account"),
430        FieldKind::Blob => out.push_str("blob"),
431        FieldKind::Bool => out.push_str("bool"),
432        FieldKind::Date => out.push_str("date"),
433        FieldKind::Decimal { scale } => {
434            let _ = write!(out, "decimal(scale={scale})");
435        }
436        FieldKind::Duration => out.push_str("duration"),
437        FieldKind::Enum { path, .. } => {
438            out.push_str("enum(");
439            out.push_str(path);
440            out.push(')');
441        }
442        FieldKind::Float32 => out.push_str("float32"),
443        FieldKind::Float64 => out.push_str("float64"),
444        FieldKind::Int => out.push_str("int"),
445        FieldKind::Int128 => out.push_str("int128"),
446        FieldKind::IntBig => out.push_str("int_big"),
447        FieldKind::Principal => out.push_str("principal"),
448        FieldKind::Subaccount => out.push_str("subaccount"),
449        FieldKind::Text => out.push_str("text"),
450        FieldKind::Timestamp => out.push_str("timestamp"),
451        FieldKind::Uint => out.push_str("uint"),
452        FieldKind::Uint128 => out.push_str("uint128"),
453        FieldKind::UintBig => out.push_str("uint_big"),
454        FieldKind::Ulid => out.push_str("ulid"),
455        FieldKind::Unit => out.push_str("unit"),
456        FieldKind::Relation {
457            target_entity_name,
458            key_kind,
459            strength,
460            ..
461        } => {
462            out.push_str("relation(target=");
463            out.push_str(target_entity_name);
464            out.push_str(", key=");
465            write_field_kind_summary(out, key_kind);
466            out.push_str(", strength=");
467            out.push_str(summarize_relation_strength(*strength));
468            out.push(')');
469        }
470        FieldKind::List(inner) => {
471            out.push_str("list<");
472            write_field_kind_summary(out, inner);
473            out.push('>');
474        }
475        FieldKind::Set(inner) => {
476            out.push_str("set<");
477            write_field_kind_summary(out, inner);
478            out.push('>');
479        }
480        FieldKind::Map { key, value } => {
481            out.push_str("map<");
482            write_field_kind_summary(out, key);
483            out.push_str(", ");
484            write_field_kind_summary(out, value);
485            out.push('>');
486        }
487        FieldKind::Structured { queryable } => {
488            let _ = write!(out, "structured(queryable={queryable})");
489        }
490    }
491}
492
493/// Render one stable relation-strength label for field-kind summaries.
494const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
495    match strength {
496        RelationStrength::Strong => "strong",
497        RelationStrength::Weak => "weak",
498    }
499}
500
501///
502/// TESTS
503///
504
505#[cfg(test)]
506mod tests {
507    use crate::db::{
508        EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
509        EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
510    };
511    use candid::types::{CandidType, Label, Type, TypeInner};
512
513    fn expect_record_fields(ty: Type) -> Vec<String> {
514        match ty.as_ref() {
515            TypeInner::Record(fields) => fields
516                .iter()
517                .map(|field| match field.id.as_ref() {
518                    Label::Named(name) => name.clone(),
519                    other => panic!("expected named record field, got {other:?}"),
520                })
521                .collect(),
522            other => panic!("expected candid record, got {other:?}"),
523        }
524    }
525
526    fn expect_variant_labels(ty: Type) -> Vec<String> {
527        match ty.as_ref() {
528            TypeInner::Variant(fields) => fields
529                .iter()
530                .map(|field| match field.id.as_ref() {
531                    Label::Named(name) => name.clone(),
532                    other => panic!("expected named variant label, got {other:?}"),
533                })
534                .collect(),
535            other => panic!("expected candid variant, got {other:?}"),
536        }
537    }
538
539    #[test]
540    fn entity_schema_description_candid_shape_is_stable() {
541        let fields = expect_record_fields(EntitySchemaDescription::ty());
542
543        for field in [
544            "entity_path",
545            "entity_name",
546            "primary_key",
547            "fields",
548            "indexes",
549            "relations",
550        ] {
551            assert!(
552                fields.iter().any(|candidate| candidate == field),
553                "EntitySchemaDescription must keep `{field}` field key",
554            );
555        }
556    }
557
558    #[test]
559    fn entity_field_description_candid_shape_is_stable() {
560        let fields = expect_record_fields(EntityFieldDescription::ty());
561
562        for field in ["name", "kind", "primary_key", "queryable"] {
563            assert!(
564                fields.iter().any(|candidate| candidate == field),
565                "EntityFieldDescription must keep `{field}` field key",
566            );
567        }
568    }
569
570    #[test]
571    fn entity_index_description_candid_shape_is_stable() {
572        let fields = expect_record_fields(EntityIndexDescription::ty());
573
574        for field in ["name", "unique", "fields"] {
575            assert!(
576                fields.iter().any(|candidate| candidate == field),
577                "EntityIndexDescription must keep `{field}` field key",
578            );
579        }
580    }
581
582    #[test]
583    fn entity_relation_description_candid_shape_is_stable() {
584        let fields = expect_record_fields(EntityRelationDescription::ty());
585
586        for field in [
587            "field",
588            "target_path",
589            "target_entity_name",
590            "target_store_path",
591            "strength",
592            "cardinality",
593        ] {
594            assert!(
595                fields.iter().any(|candidate| candidate == field),
596                "EntityRelationDescription must keep `{field}` field key",
597            );
598        }
599    }
600
601    #[test]
602    fn relation_enum_variant_labels_are_stable() {
603        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
604        strength_labels.sort_unstable();
605        assert_eq!(
606            strength_labels,
607            vec!["Strong".to_string(), "Weak".to_string()]
608        );
609
610        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
611        cardinality_labels.sort_unstable();
612        assert_eq!(
613            cardinality_labels,
614            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
615        );
616    }
617
618    #[test]
619    fn describe_fixture_constructors_stay_usable() {
620        let payload = EntitySchemaDescription::new(
621            "entities::User".to_string(),
622            "User".to_string(),
623            "id".to_string(),
624            vec![EntityFieldDescription::new(
625                "id".to_string(),
626                "ulid".to_string(),
627                true,
628                true,
629            )],
630            vec![EntityIndexDescription::new(
631                "idx_email".to_string(),
632                true,
633                vec!["email".to_string()],
634            )],
635            vec![EntityRelationDescription::new(
636                "account_id".to_string(),
637                "entities::Account".to_string(),
638                "Account".to_string(),
639                "accounts".to_string(),
640                EntityRelationStrength::Strong,
641                EntityRelationCardinality::Single,
642            )],
643        );
644
645        assert_eq!(payload.entity_name(), "User");
646        assert_eq!(payload.fields().len(), 1);
647        assert_eq!(payload.indexes().len(), 1);
648        assert_eq!(payload.relations().len(), 1);
649    }
650}