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