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::{
7    db::{
8        relation::{
9            RelationDescriptor, RelationDescriptorCardinality, relation_descriptors_for_model_iter,
10        },
11        schema::{
12            AcceptedSchemaSnapshot, PersistedFieldKind, PersistedFieldSnapshot,
13            PersistedNestedLeafSnapshot, PersistedRelationStrength, SchemaFieldSlot,
14            field_type_from_persisted_kind,
15        },
16    },
17    model::{
18        entity::EntityModel,
19        field::{FieldKind, FieldModel, RelationStrength},
20    },
21};
22use candid::CandidType;
23use serde::Deserialize;
24use std::fmt::Write;
25
26const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
27
28#[cfg_attr(
29    doc,
30    doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
31)]
32#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
33pub struct EntitySchemaDescription {
34    pub(crate) entity_path: String,
35    pub(crate) entity_name: String,
36    pub(crate) primary_key: String,
37    pub(crate) fields: Vec<EntityFieldDescription>,
38    pub(crate) indexes: Vec<EntityIndexDescription>,
39    pub(crate) relations: Vec<EntityRelationDescription>,
40}
41
42impl EntitySchemaDescription {
43    /// Construct one entity schema description payload.
44    #[must_use]
45    pub const fn new(
46        entity_path: String,
47        entity_name: String,
48        primary_key: String,
49        fields: Vec<EntityFieldDescription>,
50        indexes: Vec<EntityIndexDescription>,
51        relations: Vec<EntityRelationDescription>,
52    ) -> Self {
53        Self {
54            entity_path,
55            entity_name,
56            primary_key,
57            fields,
58            indexes,
59            relations,
60        }
61    }
62
63    /// Borrow the entity module path.
64    #[must_use]
65    pub const fn entity_path(&self) -> &str {
66        self.entity_path.as_str()
67    }
68
69    /// Borrow the entity display name.
70    #[must_use]
71    pub const fn entity_name(&self) -> &str {
72        self.entity_name.as_str()
73    }
74
75    /// Borrow the primary-key field name.
76    #[must_use]
77    pub const fn primary_key(&self) -> &str {
78        self.primary_key.as_str()
79    }
80
81    /// Borrow field description entries.
82    #[must_use]
83    pub const fn fields(&self) -> &[EntityFieldDescription] {
84        self.fields.as_slice()
85    }
86
87    /// Borrow index description entries.
88    #[must_use]
89    pub const fn indexes(&self) -> &[EntityIndexDescription] {
90        self.indexes.as_slice()
91    }
92
93    /// Borrow relation description entries.
94    #[must_use]
95    pub const fn relations(&self) -> &[EntityRelationDescription] {
96        self.relations.as_slice()
97    }
98}
99
100#[cfg_attr(
101    doc,
102    doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
103)]
104#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
105pub struct EntityFieldDescription {
106    pub(crate) name: String,
107    pub(crate) slot: u16,
108    pub(crate) kind: String,
109    pub(crate) primary_key: bool,
110    pub(crate) queryable: bool,
111}
112
113impl EntityFieldDescription {
114    /// Construct one field description entry.
115    #[must_use]
116    pub const fn new(
117        name: String,
118        slot: Option<u16>,
119        kind: String,
120        primary_key: bool,
121        queryable: bool,
122    ) -> Self {
123        let slot = match slot {
124            Some(slot) => slot,
125            None => ENTITY_FIELD_DESCRIPTION_NO_SLOT,
126        };
127
128        Self {
129            name,
130            slot,
131            kind,
132            primary_key,
133            queryable,
134        }
135    }
136
137    /// Borrow the field name.
138    #[must_use]
139    pub const fn name(&self) -> &str {
140        self.name.as_str()
141    }
142
143    /// Return the physical row slot for top-level fields.
144    #[must_use]
145    pub const fn slot(&self) -> Option<u16> {
146        if self.slot == ENTITY_FIELD_DESCRIPTION_NO_SLOT {
147            None
148        } else {
149            Some(self.slot)
150        }
151    }
152
153    /// Borrow the rendered field kind label.
154    #[must_use]
155    pub const fn kind(&self) -> &str {
156        self.kind.as_str()
157    }
158
159    /// Return whether this field is the primary key.
160    #[must_use]
161    pub const fn primary_key(&self) -> bool {
162        self.primary_key
163    }
164
165    /// Return whether this field is queryable.
166    #[must_use]
167    pub const fn queryable(&self) -> bool {
168        self.queryable
169    }
170}
171
172#[cfg_attr(
173    doc,
174    doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
175)]
176#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
177pub struct EntityIndexDescription {
178    pub(crate) name: String,
179    pub(crate) unique: bool,
180    pub(crate) fields: Vec<String>,
181}
182
183impl EntityIndexDescription {
184    /// Construct one index description entry.
185    #[must_use]
186    pub const fn new(name: String, unique: bool, fields: Vec<String>) -> Self {
187        Self {
188            name,
189            unique,
190            fields,
191        }
192    }
193
194    /// Borrow the index name.
195    #[must_use]
196    pub const fn name(&self) -> &str {
197        self.name.as_str()
198    }
199
200    /// Return whether the index enforces uniqueness.
201    #[must_use]
202    pub const fn unique(&self) -> bool {
203        self.unique
204    }
205
206    /// Borrow ordered index field names.
207    #[must_use]
208    pub const fn fields(&self) -> &[String] {
209        self.fields.as_slice()
210    }
211}
212
213#[cfg_attr(
214    doc,
215    doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
216)]
217#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
218pub struct EntityRelationDescription {
219    pub(crate) field: String,
220    pub(crate) target_path: String,
221    pub(crate) target_entity_name: String,
222    pub(crate) target_store_path: String,
223    pub(crate) strength: EntityRelationStrength,
224    pub(crate) cardinality: EntityRelationCardinality,
225}
226
227impl EntityRelationDescription {
228    /// Construct one relation description entry.
229    #[must_use]
230    pub const fn new(
231        field: String,
232        target_path: String,
233        target_entity_name: String,
234        target_store_path: String,
235        strength: EntityRelationStrength,
236        cardinality: EntityRelationCardinality,
237    ) -> Self {
238        Self {
239            field,
240            target_path,
241            target_entity_name,
242            target_store_path,
243            strength,
244            cardinality,
245        }
246    }
247
248    /// Borrow the source relation field name.
249    #[must_use]
250    pub const fn field(&self) -> &str {
251        self.field.as_str()
252    }
253
254    /// Borrow the relation target path.
255    #[must_use]
256    pub const fn target_path(&self) -> &str {
257        self.target_path.as_str()
258    }
259
260    /// Borrow the relation target entity name.
261    #[must_use]
262    pub const fn target_entity_name(&self) -> &str {
263        self.target_entity_name.as_str()
264    }
265
266    /// Borrow the relation target store path.
267    #[must_use]
268    pub const fn target_store_path(&self) -> &str {
269        self.target_store_path.as_str()
270    }
271
272    /// Return relation strength.
273    #[must_use]
274    pub const fn strength(&self) -> EntityRelationStrength {
275        self.strength
276    }
277
278    /// Return relation cardinality.
279    #[must_use]
280    pub const fn cardinality(&self) -> EntityRelationCardinality {
281        self.cardinality
282    }
283}
284
285#[cfg_attr(doc, doc = "EntityRelationStrength\n\nDescribe relation strength.")]
286#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
287pub enum EntityRelationStrength {
288    Strong,
289    Weak,
290}
291
292#[cfg_attr(
293    doc,
294    doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
295)]
296#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
297pub enum EntityRelationCardinality {
298    Single,
299    List,
300    Set,
301}
302
303#[cfg_attr(
304    doc,
305    doc = "Build one stable entity-schema description from one runtime `EntityModel`."
306)]
307#[must_use]
308pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
309    let fields = describe_entity_fields(model);
310
311    describe_entity_model_with_parts(
312        model.path,
313        model.entity_name,
314        model.primary_key.name,
315        fields,
316        model,
317    )
318}
319
320#[cfg_attr(
321    doc,
322    doc = "Build one entity-schema description using accepted persisted schema slot metadata."
323)]
324#[must_use]
325pub(in crate::db) fn describe_entity_model_with_persisted_schema(
326    model: &EntityModel,
327    schema: &AcceptedSchemaSnapshot,
328) -> EntitySchemaDescription {
329    let fields = describe_entity_fields_with_persisted_schema(model, schema);
330    let primary_key = schema
331        .primary_key_field_name()
332        .unwrap_or(model.primary_key.name);
333
334    describe_entity_model_with_parts(
335        schema.entity_path(),
336        schema.entity_name(),
337        primary_key,
338        fields,
339        model,
340    )
341}
342
343// Assemble the common DESCRIBE payload once field rows have already been built.
344// This lets accepted-schema metadata own the entity header while index and
345// relation description remain generated-model owned for this phase.
346fn describe_entity_model_with_parts(
347    entity_path: &str,
348    entity_name: &str,
349    primary_key: &str,
350    fields: Vec<EntityFieldDescription>,
351    model: &EntityModel,
352) -> EntitySchemaDescription {
353    let relations = relation_descriptors_for_model_iter(model)
354        .map(relation_description_from_descriptor)
355        .collect();
356
357    let mut indexes = Vec::with_capacity(model.indexes.len());
358    for index in model.indexes {
359        indexes.push(EntityIndexDescription::new(
360            index.name().to_string(),
361            index.is_unique(),
362            index
363                .fields()
364                .iter()
365                .map(|field| (*field).to_string())
366                .collect(),
367        ));
368    }
369
370    EntitySchemaDescription::new(
371        entity_path.to_string(),
372        entity_name.to_string(),
373        primary_key.to_string(),
374        fields,
375        indexes,
376        relations,
377    )
378}
379
380// Build the stable field-description subset once from one runtime model so
381// metadata surfaces that only need columns do not rebuild indexes and
382// relations through the heavier DESCRIBE payload path.
383#[must_use]
384pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
385    describe_entity_fields_with_slot_lookup(model, |slot, _field| {
386        Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
387    })
388}
389
390#[cfg_attr(
391    doc,
392    doc = "Build field descriptors using accepted persisted schema slot metadata."
393)]
394#[must_use]
395pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
396    model: &EntityModel,
397    schema: &AcceptedSchemaSnapshot,
398) -> Vec<EntityFieldDescription> {
399    let mut fields = Vec::with_capacity(model.fields.len());
400
401    for field in model.fields {
402        let primary_key = field.name == model.primary_key.name;
403        let accepted_field = schema.field_by_name(field.name());
404        let slot = accepted_field
405            .map(PersistedFieldSnapshot::slot)
406            .map(SchemaFieldSlot::get);
407        let metadata = accepted_field.map(|field| {
408            DescribeFieldMetadata::new(
409                summarize_persisted_field_kind(field.kind()),
410                field_type_from_persisted_kind(field.kind())
411                    .value_kind()
412                    .is_queryable(),
413            )
414        });
415
416        push_described_field_row(
417            &mut fields,
418            field.name,
419            slot,
420            primary_key,
421            None,
422            metadata.unwrap_or_else(|| {
423                DescribeFieldMetadata::new(
424                    summarize_field_kind(&field.kind),
425                    field.kind.value_kind().is_queryable(),
426                )
427            }),
428        );
429
430        if let Some(accepted_field) = accepted_field
431            && !accepted_field.nested_leaves().is_empty()
432        {
433            describe_persisted_nested_leaves(&mut fields, accepted_field.nested_leaves());
434        } else {
435            describe_generated_nested_fields(&mut fields, field.nested_fields());
436        }
437    }
438
439    fields
440}
441
442// Build field descriptors with an injected top-level slot lookup. Generated
443// model introspection uses generated positions; live-schema introspection uses
444// accepted persisted row layout metadata while preserving nested-field behavior.
445fn describe_entity_fields_with_slot_lookup(
446    model: &EntityModel,
447    mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
448) -> Vec<EntityFieldDescription> {
449    let mut fields = Vec::with_capacity(model.fields.len());
450
451    for (slot, field) in model.fields.iter().enumerate() {
452        let primary_key = field.name == model.primary_key.name;
453        describe_field_recursive(
454            &mut fields,
455            field.name,
456            slot_for_field(slot, field),
457            field,
458            primary_key,
459            None,
460            None,
461        );
462    }
463
464    fields
465}
466
467///
468/// DescribeFieldMetadata
469///
470/// Field-description metadata selected before recursive field rendering.
471/// Accepted-schema metadata can override generated model facts for top-level
472/// fields and, when available, nested leaf rows.
473///
474
475struct DescribeFieldMetadata {
476    kind: String,
477    queryable: bool,
478}
479
480impl DescribeFieldMetadata {
481    // Build one metadata bundle from already-rendered field facts.
482    const fn new(kind: String, queryable: bool) -> Self {
483        Self { kind, queryable }
484    }
485}
486
487// Add one generated field and any generated structured-record leaves so
488// DESCRIBE/SHOW COLUMNS expose the same nested rows SQL can project and filter.
489fn describe_field_recursive(
490    fields: &mut Vec<EntityFieldDescription>,
491    name: &str,
492    slot: Option<u16>,
493    field: &FieldModel,
494    primary_key: bool,
495    tree_prefix: Option<&'static str>,
496    metadata_override: Option<DescribeFieldMetadata>,
497) {
498    let metadata = metadata_override.unwrap_or_else(|| {
499        DescribeFieldMetadata::new(
500            summarize_field_kind(&field.kind),
501            field.kind.value_kind().is_queryable(),
502        )
503    });
504
505    push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
506    describe_generated_nested_fields(fields, field.nested_fields());
507}
508
509// Add one already-resolved field row to the stable describe DTO list. The
510// caller owns where metadata came from: generated model or accepted schema.
511fn push_described_field_row(
512    fields: &mut Vec<EntityFieldDescription>,
513    name: &str,
514    slot: Option<u16>,
515    primary_key: bool,
516    tree_prefix: Option<&'static str>,
517    metadata: DescribeFieldMetadata,
518) {
519    // Nested field rows keep a compact tree marker so table-oriented describe
520    // output scans as a hierarchy without assigning nested leaves row slots.
521    let display_name = if let Some(prefix) = tree_prefix {
522        format!("{prefix}{name}")
523    } else {
524        name.to_string()
525    };
526
527    fields.push(EntityFieldDescription::new(
528        display_name,
529        slot,
530        metadata.kind,
531        primary_key,
532        metadata.queryable,
533    ));
534}
535
536// Render generated nested field metadata recursively. Generated and accepted
537// top-level describe paths both use this fallback when no accepted nested leaf
538// descriptors are available yet.
539fn describe_generated_nested_fields(
540    fields: &mut Vec<EntityFieldDescription>,
541    nested_fields: &[FieldModel],
542) {
543    for (index, nested) in nested_fields.iter().enumerate() {
544        let prefix = if index + 1 == nested_fields.len() {
545            "└─ "
546        } else {
547            "├─ "
548        };
549        describe_field_recursive(
550            fields,
551            nested.name(),
552            None,
553            nested,
554            false,
555            Some(prefix),
556            None,
557        );
558    }
559}
560
561// Render accepted nested leaf descriptors. Nested leaves do not own physical
562// row slots, so they always appear with the no-slot sentinel in the Candid DTO.
563fn describe_persisted_nested_leaves(
564    fields: &mut Vec<EntityFieldDescription>,
565    nested_leaves: &[PersistedNestedLeafSnapshot],
566) {
567    for (index, leaf) in nested_leaves.iter().enumerate() {
568        let prefix = if index + 1 == nested_leaves.len() {
569            "└─ "
570        } else {
571            "├─ "
572        };
573        let name = leaf.path().last().map_or("", String::as_str);
574        let metadata = DescribeFieldMetadata::new(
575            summarize_persisted_field_kind(leaf.kind()),
576            field_type_from_persisted_kind(leaf.kind())
577                .value_kind()
578                .is_queryable(),
579        );
580
581        push_described_field_row(fields, name, None, false, Some(prefix), metadata);
582    }
583}
584
585// Project the relation-owned descriptor into the stable describe DTO surface.
586fn relation_description_from_descriptor(
587    descriptor: RelationDescriptor<'_>,
588) -> EntityRelationDescription {
589    let strength = match descriptor.strength() {
590        RelationStrength::Strong => EntityRelationStrength::Strong,
591        RelationStrength::Weak => EntityRelationStrength::Weak,
592    };
593
594    let cardinality = match descriptor.cardinality() {
595        RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
596        RelationDescriptorCardinality::List => EntityRelationCardinality::List,
597        RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
598    };
599
600    EntityRelationDescription::new(
601        descriptor.field_name().to_string(),
602        descriptor.target_path().to_string(),
603        descriptor.target_entity_name().to_string(),
604        descriptor.target_store_path().to_string(),
605        strength,
606        cardinality,
607    )
608}
609
610#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
611fn summarize_field_kind(kind: &FieldKind) -> String {
612    let mut out = String::new();
613    write_field_kind_summary(&mut out, kind);
614
615    out
616}
617
618// Stream one stable field-kind label directly into the output buffer so
619// describe/sql surfaces do not retain a large recursive `format!` family.
620fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
621    match kind {
622        FieldKind::Account => out.push_str("account"),
623        FieldKind::Blob => out.push_str("blob"),
624        FieldKind::Bool => out.push_str("bool"),
625        FieldKind::Date => out.push_str("date"),
626        FieldKind::Decimal { scale } => {
627            let _ = write!(out, "decimal(scale={scale})");
628        }
629        FieldKind::Duration => out.push_str("duration"),
630        FieldKind::Enum { path, .. } => {
631            out.push_str("enum(");
632            out.push_str(path);
633            out.push(')');
634        }
635        FieldKind::Float32 => out.push_str("float32"),
636        FieldKind::Float64 => out.push_str("float64"),
637        FieldKind::Int => out.push_str("int"),
638        FieldKind::Int128 => out.push_str("int128"),
639        FieldKind::IntBig => out.push_str("int_big"),
640        FieldKind::Principal => out.push_str("principal"),
641        FieldKind::Subaccount => out.push_str("subaccount"),
642        FieldKind::Text { max_len } => match max_len {
643            Some(max_len) => {
644                let _ = write!(out, "text(max_len={max_len})");
645            }
646            None => out.push_str("text"),
647        },
648        FieldKind::Timestamp => out.push_str("timestamp"),
649        FieldKind::Uint => out.push_str("uint"),
650        FieldKind::Uint128 => out.push_str("uint128"),
651        FieldKind::UintBig => out.push_str("uint_big"),
652        FieldKind::Ulid => out.push_str("ulid"),
653        FieldKind::Unit => out.push_str("unit"),
654        FieldKind::Relation {
655            target_entity_name,
656            key_kind,
657            strength,
658            ..
659        } => {
660            out.push_str("relation(target=");
661            out.push_str(target_entity_name);
662            out.push_str(", key=");
663            write_field_kind_summary(out, key_kind);
664            out.push_str(", strength=");
665            out.push_str(summarize_relation_strength(*strength));
666            out.push(')');
667        }
668        FieldKind::List(inner) => {
669            out.push_str("list<");
670            write_field_kind_summary(out, inner);
671            out.push('>');
672        }
673        FieldKind::Set(inner) => {
674            out.push_str("set<");
675            write_field_kind_summary(out, inner);
676            out.push('>');
677        }
678        FieldKind::Map { key, value } => {
679            out.push_str("map<");
680            write_field_kind_summary(out, key);
681            out.push_str(", ");
682            write_field_kind_summary(out, value);
683            out.push('>');
684        }
685        FieldKind::Structured { .. } => {
686            out.push_str("structured");
687        }
688    }
689}
690
691#[cfg_attr(
692    doc,
693    doc = "Render one stable field-kind label from accepted persisted schema metadata."
694)]
695fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
696    let mut out = String::new();
697    write_persisted_field_kind_summary(&mut out, kind);
698
699    out
700}
701
702// Stream the accepted persisted field-kind label in the same public format as
703// generated `FieldKind` summaries. Top-level live-schema metadata can then
704// drive DESCRIBE output without converting back into generated static types.
705fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
706    match kind {
707        PersistedFieldKind::Account => out.push_str("account"),
708        PersistedFieldKind::Blob => out.push_str("blob"),
709        PersistedFieldKind::Bool => out.push_str("bool"),
710        PersistedFieldKind::Date => out.push_str("date"),
711        PersistedFieldKind::Decimal { scale } => {
712            let _ = write!(out, "decimal(scale={scale})");
713        }
714        PersistedFieldKind::Duration => out.push_str("duration"),
715        PersistedFieldKind::Enum { path, .. } => {
716            out.push_str("enum(");
717            out.push_str(path);
718            out.push(')');
719        }
720        PersistedFieldKind::Float32 => out.push_str("float32"),
721        PersistedFieldKind::Float64 => out.push_str("float64"),
722        PersistedFieldKind::Int => out.push_str("int"),
723        PersistedFieldKind::Int128 => out.push_str("int128"),
724        PersistedFieldKind::IntBig => out.push_str("int_big"),
725        PersistedFieldKind::Principal => out.push_str("principal"),
726        PersistedFieldKind::Subaccount => out.push_str("subaccount"),
727        PersistedFieldKind::Text { max_len } => match max_len {
728            Some(max_len) => {
729                let _ = write!(out, "text(max_len={max_len})");
730            }
731            None => out.push_str("text"),
732        },
733        PersistedFieldKind::Timestamp => out.push_str("timestamp"),
734        PersistedFieldKind::Uint => out.push_str("uint"),
735        PersistedFieldKind::Uint128 => out.push_str("uint128"),
736        PersistedFieldKind::UintBig => out.push_str("uint_big"),
737        PersistedFieldKind::Ulid => out.push_str("ulid"),
738        PersistedFieldKind::Unit => out.push_str("unit"),
739        PersistedFieldKind::Relation {
740            target_entity_name,
741            key_kind,
742            strength,
743            ..
744        } => {
745            out.push_str("relation(target=");
746            out.push_str(target_entity_name);
747            out.push_str(", key=");
748            write_persisted_field_kind_summary(out, key_kind);
749            out.push_str(", strength=");
750            out.push_str(summarize_persisted_relation_strength(*strength));
751            out.push(')');
752        }
753        PersistedFieldKind::List(inner) => {
754            out.push_str("list<");
755            write_persisted_field_kind_summary(out, inner);
756            out.push('>');
757        }
758        PersistedFieldKind::Set(inner) => {
759            out.push_str("set<");
760            write_persisted_field_kind_summary(out, inner);
761            out.push('>');
762        }
763        PersistedFieldKind::Map { key, value } => {
764            out.push_str("map<");
765            write_persisted_field_kind_summary(out, key);
766            out.push_str(", ");
767            write_persisted_field_kind_summary(out, value);
768            out.push('>');
769        }
770        PersistedFieldKind::Structured { .. } => {
771            out.push_str("structured");
772        }
773    }
774}
775
776#[cfg_attr(
777    doc,
778    doc = "Render one stable relation-strength label from persisted schema metadata."
779)]
780const fn summarize_persisted_relation_strength(
781    strength: PersistedRelationStrength,
782) -> &'static str {
783    match strength {
784        PersistedRelationStrength::Strong => "strong",
785        PersistedRelationStrength::Weak => "weak",
786    }
787}
788
789#[cfg_attr(
790    doc,
791    doc = "Render one stable relation-strength label for field-kind summaries."
792)]
793const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
794    match strength {
795        RelationStrength::Strong => "strong",
796        RelationStrength::Weak => "weak",
797    }
798}
799
800//
801// TESTS
802//
803
804#[cfg(test)]
805mod tests {
806    use crate::{
807        db::{
808            EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
809            EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
810            relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
811            schema::{
812                AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
813                PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
814                SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
815                describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
816            },
817        },
818        model::{
819            entity::EntityModel,
820            field::{
821                FieldKind, FieldModel, FieldStorageDecode, LeafCodec, RelationStrength, ScalarCodec,
822            },
823        },
824        types::EntityTag,
825    };
826    use candid::types::{CandidType, Label, Type, TypeInner};
827
828    static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
829        target_path: "entities::Target",
830        target_entity_name: "Target",
831        target_entity_tag: EntityTag::new(0xD001),
832        target_store_path: "stores::Target",
833        key_kind: &FieldKind::Ulid,
834        strength: RelationStrength::Strong,
835    };
836    static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
837        target_path: "entities::Account",
838        target_entity_name: "Account",
839        target_entity_tag: EntityTag::new(0xD002),
840        target_store_path: "stores::Account",
841        key_kind: &FieldKind::Uint,
842        strength: RelationStrength::Weak,
843    };
844    static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
845        target_path: "entities::Team",
846        target_entity_name: "Team",
847        target_entity_tag: EntityTag::new(0xD003),
848        target_store_path: "stores::Team",
849        key_kind: &FieldKind::Text { max_len: None },
850        strength: RelationStrength::Strong,
851    };
852    static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
853        FieldModel::generated("id", FieldKind::Ulid),
854        FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
855        FieldModel::generated(
856            "accounts",
857            FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
858        ),
859        FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
860    ];
861    static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
862    static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
863        "entities::Source",
864        "Source",
865        &DESCRIBE_RELATION_FIELDS[0],
866        0,
867        &DESCRIBE_RELATION_FIELDS,
868        &DESCRIBE_RELATION_INDEXES,
869    );
870
871    fn expect_record_fields(ty: Type) -> Vec<String> {
872        match ty.as_ref() {
873            TypeInner::Record(fields) => fields
874                .iter()
875                .map(|field| match field.id.as_ref() {
876                    Label::Named(name) => name.clone(),
877                    other => panic!("expected named record field, got {other:?}"),
878                })
879                .collect(),
880            other => panic!("expected candid record, got {other:?}"),
881        }
882    }
883
884    fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
885        match ty.as_ref() {
886            TypeInner::Record(fields) => fields
887                .iter()
888                .find_map(|field| match field.id.as_ref() {
889                    Label::Named(name) if name == field_name => Some(field.ty.clone()),
890                    _ => None,
891                })
892                .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
893            other => panic!("expected candid record, got {other:?}"),
894        }
895    }
896
897    fn expect_variant_labels(ty: Type) -> Vec<String> {
898        match ty.as_ref() {
899            TypeInner::Variant(fields) => fields
900                .iter()
901                .map(|field| match field.id.as_ref() {
902                    Label::Named(name) => name.clone(),
903                    other => panic!("expected named variant label, got {other:?}"),
904                })
905                .collect(),
906            other => panic!("expected candid variant, got {other:?}"),
907        }
908    }
909
910    #[test]
911    fn entity_schema_description_candid_shape_is_stable() {
912        let fields = expect_record_fields(EntitySchemaDescription::ty());
913
914        for field in [
915            "entity_path",
916            "entity_name",
917            "primary_key",
918            "fields",
919            "indexes",
920            "relations",
921        ] {
922            assert!(
923                fields.iter().any(|candidate| candidate == field),
924                "EntitySchemaDescription must keep `{field}` field key",
925            );
926        }
927    }
928
929    #[test]
930    fn entity_field_description_candid_shape_is_stable() {
931        let fields = expect_record_fields(EntityFieldDescription::ty());
932
933        for field in ["name", "slot", "kind", "primary_key", "queryable"] {
934            assert!(
935                fields.iter().any(|candidate| candidate == field),
936                "EntityFieldDescription must keep `{field}` field key",
937            );
938        }
939
940        assert!(
941            matches!(
942                expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
943                TypeInner::Nat16
944            ),
945            "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
946        );
947    }
948
949    #[test]
950    fn entity_index_description_candid_shape_is_stable() {
951        let fields = expect_record_fields(EntityIndexDescription::ty());
952
953        for field in ["name", "unique", "fields"] {
954            assert!(
955                fields.iter().any(|candidate| candidate == field),
956                "EntityIndexDescription must keep `{field}` field key",
957            );
958        }
959    }
960
961    #[test]
962    fn entity_relation_description_candid_shape_is_stable() {
963        let fields = expect_record_fields(EntityRelationDescription::ty());
964
965        for field in [
966            "field",
967            "target_path",
968            "target_entity_name",
969            "target_store_path",
970            "strength",
971            "cardinality",
972        ] {
973            assert!(
974                fields.iter().any(|candidate| candidate == field),
975                "EntityRelationDescription must keep `{field}` field key",
976            );
977        }
978    }
979
980    #[test]
981    fn relation_enum_variant_labels_are_stable() {
982        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
983        strength_labels.sort_unstable();
984        assert_eq!(
985            strength_labels,
986            vec!["Strong".to_string(), "Weak".to_string()]
987        );
988
989        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
990        cardinality_labels.sort_unstable();
991        assert_eq!(
992            cardinality_labels,
993            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
994        );
995    }
996
997    #[test]
998    fn describe_fixture_constructors_stay_usable() {
999        let payload = EntitySchemaDescription::new(
1000            "entities::User".to_string(),
1001            "User".to_string(),
1002            "id".to_string(),
1003            vec![EntityFieldDescription::new(
1004                "id".to_string(),
1005                Some(0),
1006                "ulid".to_string(),
1007                true,
1008                true,
1009            )],
1010            vec![EntityIndexDescription::new(
1011                "idx_email".to_string(),
1012                true,
1013                vec!["email".to_string()],
1014            )],
1015            vec![EntityRelationDescription::new(
1016                "account_id".to_string(),
1017                "entities::Account".to_string(),
1018                "Account".to_string(),
1019                "accounts".to_string(),
1020                EntityRelationStrength::Strong,
1021                EntityRelationCardinality::Single,
1022            )],
1023        );
1024
1025        assert_eq!(payload.entity_name(), "User");
1026        assert_eq!(payload.fields().len(), 1);
1027        assert_eq!(payload.indexes().len(), 1);
1028        assert_eq!(payload.relations().len(), 1);
1029    }
1030
1031    #[test]
1032    fn schema_describe_relations_match_relation_descriptors() {
1033        let descriptors =
1034            relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1035        let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1036        let relations = described.relations();
1037
1038        assert_eq!(descriptors.len(), relations.len());
1039
1040        for (descriptor, relation) in descriptors.iter().zip(relations) {
1041            assert_eq!(relation.field(), descriptor.field_name());
1042            assert_eq!(relation.target_path(), descriptor.target_path());
1043            assert_eq!(
1044                relation.target_entity_name(),
1045                descriptor.target_entity_name()
1046            );
1047            assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1048            assert_eq!(
1049                relation.strength(),
1050                match descriptor.strength() {
1051                    RelationStrength::Strong => EntityRelationStrength::Strong,
1052                    RelationStrength::Weak => EntityRelationStrength::Weak,
1053                }
1054            );
1055            assert_eq!(
1056                relation.cardinality(),
1057                match descriptor.cardinality() {
1058                    RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1059                    RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1060                    RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1061                }
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn schema_describe_includes_text_max_len_contract() {
1068        static FIELDS: [FieldModel; 2] = [
1069            FieldModel::generated("id", FieldKind::Ulid),
1070            FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1071        ];
1072        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1073        static MODEL: EntityModel = EntityModel::generated(
1074            "entities::BoundedName",
1075            "BoundedName",
1076            &FIELDS[0],
1077            0,
1078            &FIELDS,
1079            &INDEXES,
1080        );
1081
1082        let described = describe_entity_model(&MODEL);
1083        let name_field = described
1084            .fields()
1085            .iter()
1086            .find(|field| field.name() == "name")
1087            .expect("bounded text field should be described");
1088
1089        assert_eq!(name_field.kind(), "text(max_len=16)");
1090    }
1091
1092    #[test]
1093    fn schema_describe_uses_accepted_top_level_field_metadata() {
1094        static FIELDS: [FieldModel; 2] = [
1095            FieldModel::generated("id", FieldKind::Ulid),
1096            FieldModel::generated("payload", FieldKind::Text { max_len: None }),
1097        ];
1098        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1099        static MODEL: EntityModel = EntityModel::generated(
1100            "entities::BlobEvent",
1101            "BlobEvent",
1102            &FIELDS[0],
1103            0,
1104            &FIELDS,
1105            &INDEXES,
1106        );
1107        let id_slot = SchemaFieldSlot::new(0);
1108        let payload_slot = SchemaFieldSlot::new(7);
1109        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1110            SchemaVersion::initial(),
1111            "entities::BlobEvent".to_string(),
1112            "BlobEvent".to_string(),
1113            FieldId::new(1),
1114            SchemaRowLayout::new(
1115                SchemaVersion::initial(),
1116                vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1117            ),
1118            vec![
1119                PersistedFieldSnapshot::new(
1120                    FieldId::new(1),
1121                    "id".to_string(),
1122                    id_slot,
1123                    PersistedFieldKind::Ulid,
1124                    Vec::new(),
1125                    false,
1126                    SchemaFieldDefault::None,
1127                    FieldStorageDecode::ByKind,
1128                    LeafCodec::StructuralFallback,
1129                ),
1130                PersistedFieldSnapshot::new(
1131                    FieldId::new(2),
1132                    "payload".to_string(),
1133                    payload_slot,
1134                    PersistedFieldKind::Blob,
1135                    Vec::new(),
1136                    false,
1137                    SchemaFieldDefault::None,
1138                    FieldStorageDecode::ByKind,
1139                    LeafCodec::StructuralFallback,
1140                ),
1141            ],
1142        ));
1143
1144        let described = describe_entity_fields_with_persisted_schema(&MODEL, &snapshot)
1145            .into_iter()
1146            .map(|field| {
1147                (
1148                    field.name().to_string(),
1149                    field.slot(),
1150                    field.kind().to_string(),
1151                )
1152            })
1153            .collect::<Vec<_>>();
1154
1155        assert_eq!(
1156            described,
1157            vec![
1158                ("id".to_string(), Some(0), "ulid".to_string()),
1159                ("payload".to_string(), Some(7), "blob".to_string()),
1160            ],
1161        );
1162    }
1163
1164    #[test]
1165    fn schema_describe_uses_accepted_nested_leaf_metadata() {
1166        static NESTED_FIELDS: [FieldModel; 1] = [FieldModel::generated("rank", FieldKind::Uint)];
1167        static FIELDS: [FieldModel; 2] = [
1168            FieldModel::generated("id", FieldKind::Ulid),
1169            FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1170                "profile",
1171                FieldKind::Structured { queryable: true },
1172                FieldStorageDecode::Value,
1173                false,
1174                None,
1175                None,
1176                &NESTED_FIELDS,
1177            ),
1178        ];
1179        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1180        static MODEL: EntityModel = EntityModel::generated(
1181            "entities::AcceptedProfile",
1182            "AcceptedProfile",
1183            &FIELDS[0],
1184            0,
1185            &FIELDS,
1186            &INDEXES,
1187        );
1188        let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1189            SchemaVersion::initial(),
1190            "entities::AcceptedProfile".to_string(),
1191            "AcceptedProfile".to_string(),
1192            FieldId::new(1),
1193            SchemaRowLayout::new(
1194                SchemaVersion::initial(),
1195                vec![
1196                    (FieldId::new(1), SchemaFieldSlot::new(0)),
1197                    (FieldId::new(2), SchemaFieldSlot::new(1)),
1198                ],
1199            ),
1200            vec![
1201                PersistedFieldSnapshot::new(
1202                    FieldId::new(1),
1203                    "id".to_string(),
1204                    SchemaFieldSlot::new(0),
1205                    PersistedFieldKind::Ulid,
1206                    Vec::new(),
1207                    false,
1208                    SchemaFieldDefault::None,
1209                    FieldStorageDecode::ByKind,
1210                    LeafCodec::StructuralFallback,
1211                ),
1212                PersistedFieldSnapshot::new(
1213                    FieldId::new(2),
1214                    "profile".to_string(),
1215                    SchemaFieldSlot::new(1),
1216                    PersistedFieldKind::Structured { queryable: true },
1217                    vec![PersistedNestedLeafSnapshot::new(
1218                        vec!["rank".to_string()],
1219                        PersistedFieldKind::Blob,
1220                        false,
1221                        FieldStorageDecode::ByKind,
1222                        LeafCodec::Scalar(ScalarCodec::Blob),
1223                    )],
1224                    false,
1225                    SchemaFieldDefault::None,
1226                    FieldStorageDecode::Value,
1227                    LeafCodec::StructuralFallback,
1228                ),
1229            ],
1230        ));
1231
1232        let described = describe_entity_fields_with_persisted_schema(&MODEL, &snapshot);
1233        let rank = described
1234            .iter()
1235            .find(|field| field.name() == "└─ rank")
1236            .expect("accepted nested leaf should be described");
1237
1238        assert_eq!(rank.slot(), None);
1239        assert_eq!(rank.kind(), "blob");
1240        assert!(rank.queryable());
1241    }
1242
1243    #[test]
1244    fn schema_describe_expands_generated_structured_field_leaves() {
1245        static NESTED_FIELDS: [FieldModel; 3] = [
1246            FieldModel::generated("name", FieldKind::Text { max_len: None }),
1247            FieldModel::generated("level", FieldKind::Uint),
1248            FieldModel::generated("pid", FieldKind::Principal),
1249        ];
1250        static FIELDS: [FieldModel; 2] = [
1251            FieldModel::generated("id", FieldKind::Ulid),
1252            FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1253                "mentor",
1254                FieldKind::Structured { queryable: false },
1255                FieldStorageDecode::Value,
1256                false,
1257                None,
1258                None,
1259                &NESTED_FIELDS,
1260            ),
1261        ];
1262        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1263        static MODEL: EntityModel = EntityModel::generated(
1264            "entities::Character",
1265            "Character",
1266            &FIELDS[0],
1267            0,
1268            &FIELDS,
1269            &INDEXES,
1270        );
1271
1272        let described = describe_entity_model(&MODEL);
1273        let described_fields = described
1274            .fields()
1275            .iter()
1276            .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1277            .collect::<Vec<_>>();
1278
1279        assert_eq!(
1280            described_fields,
1281            vec![
1282                ("id", Some(0), "ulid", true),
1283                ("mentor", Some(1), "structured", false),
1284                ("├─ name", None, "text", true),
1285                ("├─ level", None, "uint", true),
1286                ("└─ pid", None, "principal", true),
1287            ],
1288        );
1289    }
1290}