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, 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 accepted_facts = schema.field_facts_by_name(field.name());
403        let slot = accepted_facts.map(|(_, accepted_slot, _)| accepted_slot.get());
404        let metadata = accepted_facts.map(|(kind, _, _)| {
405            DescribeFieldMetadata::new(
406                summarize_persisted_field_kind(kind),
407                field_type_from_persisted_kind(kind)
408                    .value_kind()
409                    .is_queryable(),
410            )
411        });
412
413        push_described_field_row(
414            &mut fields,
415            field.name,
416            slot,
417            primary_key,
418            None,
419            metadata.unwrap_or_else(|| {
420                DescribeFieldMetadata::new(
421                    summarize_field_kind(&field.kind),
422                    field.kind.value_kind().is_queryable(),
423                )
424            }),
425        );
426
427        if let Some((_, _, nested_leaves)) = accepted_facts
428            && !nested_leaves.is_empty()
429        {
430            describe_persisted_nested_leaves(&mut fields, nested_leaves);
431        } else {
432            describe_generated_nested_fields(&mut fields, field.nested_fields());
433        }
434    }
435
436    fields
437}
438
439// Build field descriptors with an injected top-level slot lookup. Generated
440// model introspection uses generated positions; live-schema introspection uses
441// accepted persisted row layout metadata while preserving nested-field behavior.
442fn describe_entity_fields_with_slot_lookup(
443    model: &EntityModel,
444    mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
445) -> Vec<EntityFieldDescription> {
446    let mut fields = Vec::with_capacity(model.fields.len());
447
448    for (slot, field) in model.fields.iter().enumerate() {
449        let primary_key = field.name == model.primary_key.name;
450        describe_field_recursive(
451            &mut fields,
452            field.name,
453            slot_for_field(slot, field),
454            field,
455            primary_key,
456            None,
457            None,
458        );
459    }
460
461    fields
462}
463
464///
465/// DescribeFieldMetadata
466///
467/// Field-description metadata selected before recursive field rendering.
468/// Accepted-schema metadata can override generated model facts for top-level
469/// fields and, when available, nested leaf rows.
470///
471
472struct DescribeFieldMetadata {
473    kind: String,
474    queryable: bool,
475}
476
477impl DescribeFieldMetadata {
478    // Build one metadata bundle from already-rendered field facts.
479    const fn new(kind: String, queryable: bool) -> Self {
480        Self { kind, queryable }
481    }
482}
483
484// Add one generated field and any generated structured-record leaves so
485// DESCRIBE/SHOW COLUMNS expose the same nested rows SQL can project and filter.
486fn describe_field_recursive(
487    fields: &mut Vec<EntityFieldDescription>,
488    name: &str,
489    slot: Option<u16>,
490    field: &FieldModel,
491    primary_key: bool,
492    tree_prefix: Option<&'static str>,
493    metadata_override: Option<DescribeFieldMetadata>,
494) {
495    let metadata = metadata_override.unwrap_or_else(|| {
496        DescribeFieldMetadata::new(
497            summarize_field_kind(&field.kind),
498            field.kind.value_kind().is_queryable(),
499        )
500    });
501
502    push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
503    describe_generated_nested_fields(fields, field.nested_fields());
504}
505
506// Add one already-resolved field row to the stable describe DTO list. The
507// caller owns where metadata came from: generated model or accepted schema.
508fn push_described_field_row(
509    fields: &mut Vec<EntityFieldDescription>,
510    name: &str,
511    slot: Option<u16>,
512    primary_key: bool,
513    tree_prefix: Option<&'static str>,
514    metadata: DescribeFieldMetadata,
515) {
516    // Nested field rows keep a compact tree marker so table-oriented describe
517    // output scans as a hierarchy without assigning nested leaves row slots.
518    let display_name = if let Some(prefix) = tree_prefix {
519        format!("{prefix}{name}")
520    } else {
521        name.to_string()
522    };
523
524    fields.push(EntityFieldDescription::new(
525        display_name,
526        slot,
527        metadata.kind,
528        primary_key,
529        metadata.queryable,
530    ));
531}
532
533// Render generated nested field metadata recursively. Generated and accepted
534// top-level describe paths both use this fallback when no accepted nested leaf
535// descriptors are available yet.
536fn describe_generated_nested_fields(
537    fields: &mut Vec<EntityFieldDescription>,
538    nested_fields: &[FieldModel],
539) {
540    for (index, nested) in nested_fields.iter().enumerate() {
541        let prefix = if index + 1 == nested_fields.len() {
542            "└─ "
543        } else {
544            "├─ "
545        };
546        describe_field_recursive(
547            fields,
548            nested.name(),
549            None,
550            nested,
551            false,
552            Some(prefix),
553            None,
554        );
555    }
556}
557
558// Render accepted nested leaf descriptors. Nested leaves do not own physical
559// row slots, so they always appear with the no-slot sentinel in the Candid DTO.
560fn describe_persisted_nested_leaves(
561    fields: &mut Vec<EntityFieldDescription>,
562    nested_leaves: &[PersistedNestedLeafSnapshot],
563) {
564    for (index, leaf) in nested_leaves.iter().enumerate() {
565        let prefix = if index + 1 == nested_leaves.len() {
566            "└─ "
567        } else {
568            "├─ "
569        };
570        let name = leaf.path().last().map_or("", String::as_str);
571        let metadata = DescribeFieldMetadata::new(
572            summarize_persisted_field_kind(leaf.kind()),
573            field_type_from_persisted_kind(leaf.kind())
574                .value_kind()
575                .is_queryable(),
576        );
577
578        push_described_field_row(fields, name, None, false, Some(prefix), metadata);
579    }
580}
581
582// Project the relation-owned descriptor into the stable describe DTO surface.
583fn relation_description_from_descriptor(
584    descriptor: RelationDescriptor<'_>,
585) -> EntityRelationDescription {
586    let strength = match descriptor.strength() {
587        RelationStrength::Strong => EntityRelationStrength::Strong,
588        RelationStrength::Weak => EntityRelationStrength::Weak,
589    };
590
591    let cardinality = match descriptor.cardinality() {
592        RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
593        RelationDescriptorCardinality::List => EntityRelationCardinality::List,
594        RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
595    };
596
597    EntityRelationDescription::new(
598        descriptor.field_name().to_string(),
599        descriptor.target_path().to_string(),
600        descriptor.target_entity_name().to_string(),
601        descriptor.target_store_path().to_string(),
602        strength,
603        cardinality,
604    )
605}
606
607#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
608fn summarize_field_kind(kind: &FieldKind) -> String {
609    let mut out = String::new();
610    write_field_kind_summary(&mut out, kind);
611
612    out
613}
614
615// Stream one stable field-kind label directly into the output buffer so
616// describe/sql surfaces do not retain a large recursive `format!` family.
617fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
618    match kind {
619        FieldKind::Account => out.push_str("account"),
620        FieldKind::Blob => out.push_str("blob"),
621        FieldKind::Bool => out.push_str("bool"),
622        FieldKind::Date => out.push_str("date"),
623        FieldKind::Decimal { scale } => {
624            let _ = write!(out, "decimal(scale={scale})");
625        }
626        FieldKind::Duration => out.push_str("duration"),
627        FieldKind::Enum { path, .. } => {
628            out.push_str("enum(");
629            out.push_str(path);
630            out.push(')');
631        }
632        FieldKind::Float32 => out.push_str("float32"),
633        FieldKind::Float64 => out.push_str("float64"),
634        FieldKind::Int => out.push_str("int"),
635        FieldKind::Int128 => out.push_str("int128"),
636        FieldKind::IntBig => out.push_str("int_big"),
637        FieldKind::Principal => out.push_str("principal"),
638        FieldKind::Subaccount => out.push_str("subaccount"),
639        FieldKind::Text { max_len } => match max_len {
640            Some(max_len) => {
641                let _ = write!(out, "text(max_len={max_len})");
642            }
643            None => out.push_str("text"),
644        },
645        FieldKind::Timestamp => out.push_str("timestamp"),
646        FieldKind::Uint => out.push_str("uint"),
647        FieldKind::Uint128 => out.push_str("uint128"),
648        FieldKind::UintBig => out.push_str("uint_big"),
649        FieldKind::Ulid => out.push_str("ulid"),
650        FieldKind::Unit => out.push_str("unit"),
651        FieldKind::Relation {
652            target_entity_name,
653            key_kind,
654            strength,
655            ..
656        } => {
657            out.push_str("relation(target=");
658            out.push_str(target_entity_name);
659            out.push_str(", key=");
660            write_field_kind_summary(out, key_kind);
661            out.push_str(", strength=");
662            out.push_str(summarize_relation_strength(*strength));
663            out.push(')');
664        }
665        FieldKind::List(inner) => {
666            out.push_str("list<");
667            write_field_kind_summary(out, inner);
668            out.push('>');
669        }
670        FieldKind::Set(inner) => {
671            out.push_str("set<");
672            write_field_kind_summary(out, inner);
673            out.push('>');
674        }
675        FieldKind::Map { key, value } => {
676            out.push_str("map<");
677            write_field_kind_summary(out, key);
678            out.push_str(", ");
679            write_field_kind_summary(out, value);
680            out.push('>');
681        }
682        FieldKind::Structured { .. } => {
683            out.push_str("structured");
684        }
685    }
686}
687
688#[cfg_attr(
689    doc,
690    doc = "Render one stable field-kind label from accepted persisted schema metadata."
691)]
692fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
693    let mut out = String::new();
694    write_persisted_field_kind_summary(&mut out, kind);
695
696    out
697}
698
699// Stream the accepted persisted field-kind label in the same public format as
700// generated `FieldKind` summaries. Top-level live-schema metadata can then
701// drive DESCRIBE output without converting back into generated static types.
702fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
703    match kind {
704        PersistedFieldKind::Account => out.push_str("account"),
705        PersistedFieldKind::Blob => out.push_str("blob"),
706        PersistedFieldKind::Bool => out.push_str("bool"),
707        PersistedFieldKind::Date => out.push_str("date"),
708        PersistedFieldKind::Decimal { scale } => {
709            let _ = write!(out, "decimal(scale={scale})");
710        }
711        PersistedFieldKind::Duration => out.push_str("duration"),
712        PersistedFieldKind::Enum { path, .. } => {
713            out.push_str("enum(");
714            out.push_str(path);
715            out.push(')');
716        }
717        PersistedFieldKind::Float32 => out.push_str("float32"),
718        PersistedFieldKind::Float64 => out.push_str("float64"),
719        PersistedFieldKind::Int => out.push_str("int"),
720        PersistedFieldKind::Int128 => out.push_str("int128"),
721        PersistedFieldKind::IntBig => out.push_str("int_big"),
722        PersistedFieldKind::Principal => out.push_str("principal"),
723        PersistedFieldKind::Subaccount => out.push_str("subaccount"),
724        PersistedFieldKind::Text { max_len } => match max_len {
725            Some(max_len) => {
726                let _ = write!(out, "text(max_len={max_len})");
727            }
728            None => out.push_str("text"),
729        },
730        PersistedFieldKind::Timestamp => out.push_str("timestamp"),
731        PersistedFieldKind::Uint => out.push_str("uint"),
732        PersistedFieldKind::Uint128 => out.push_str("uint128"),
733        PersistedFieldKind::UintBig => out.push_str("uint_big"),
734        PersistedFieldKind::Ulid => out.push_str("ulid"),
735        PersistedFieldKind::Unit => out.push_str("unit"),
736        PersistedFieldKind::Relation {
737            target_entity_name,
738            key_kind,
739            strength,
740            ..
741        } => {
742            out.push_str("relation(target=");
743            out.push_str(target_entity_name);
744            out.push_str(", key=");
745            write_persisted_field_kind_summary(out, key_kind);
746            out.push_str(", strength=");
747            out.push_str(summarize_persisted_relation_strength(*strength));
748            out.push(')');
749        }
750        PersistedFieldKind::List(inner) => {
751            out.push_str("list<");
752            write_persisted_field_kind_summary(out, inner);
753            out.push('>');
754        }
755        PersistedFieldKind::Set(inner) => {
756            out.push_str("set<");
757            write_persisted_field_kind_summary(out, inner);
758            out.push('>');
759        }
760        PersistedFieldKind::Map { key, value } => {
761            out.push_str("map<");
762            write_persisted_field_kind_summary(out, key);
763            out.push_str(", ");
764            write_persisted_field_kind_summary(out, value);
765            out.push('>');
766        }
767        PersistedFieldKind::Structured { .. } => {
768            out.push_str("structured");
769        }
770    }
771}
772
773#[cfg_attr(
774    doc,
775    doc = "Render one stable relation-strength label from persisted schema metadata."
776)]
777const fn summarize_persisted_relation_strength(
778    strength: PersistedRelationStrength,
779) -> &'static str {
780    match strength {
781        PersistedRelationStrength::Strong => "strong",
782        PersistedRelationStrength::Weak => "weak",
783    }
784}
785
786#[cfg_attr(
787    doc,
788    doc = "Render one stable relation-strength label for field-kind summaries."
789)]
790const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
791    match strength {
792        RelationStrength::Strong => "strong",
793        RelationStrength::Weak => "weak",
794    }
795}
796
797//
798// TESTS
799//
800
801#[cfg(test)]
802mod tests {
803    use crate::{
804        db::{
805            EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
806            EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
807            relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
808            schema::{
809                AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
810                PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
811                SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
812                describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
813            },
814        },
815        model::{
816            entity::EntityModel,
817            field::{
818                FieldKind, FieldModel, FieldStorageDecode, LeafCodec, RelationStrength, ScalarCodec,
819            },
820        },
821        types::EntityTag,
822    };
823    use candid::types::{CandidType, Label, Type, TypeInner};
824
825    static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
826        target_path: "entities::Target",
827        target_entity_name: "Target",
828        target_entity_tag: EntityTag::new(0xD001),
829        target_store_path: "stores::Target",
830        key_kind: &FieldKind::Ulid,
831        strength: RelationStrength::Strong,
832    };
833    static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
834        target_path: "entities::Account",
835        target_entity_name: "Account",
836        target_entity_tag: EntityTag::new(0xD002),
837        target_store_path: "stores::Account",
838        key_kind: &FieldKind::Uint,
839        strength: RelationStrength::Weak,
840    };
841    static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
842        target_path: "entities::Team",
843        target_entity_name: "Team",
844        target_entity_tag: EntityTag::new(0xD003),
845        target_store_path: "stores::Team",
846        key_kind: &FieldKind::Text { max_len: None },
847        strength: RelationStrength::Strong,
848    };
849    static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
850        FieldModel::generated("id", FieldKind::Ulid),
851        FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
852        FieldModel::generated(
853            "accounts",
854            FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
855        ),
856        FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
857    ];
858    static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
859    static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
860        "entities::Source",
861        "Source",
862        &DESCRIBE_RELATION_FIELDS[0],
863        0,
864        &DESCRIBE_RELATION_FIELDS,
865        &DESCRIBE_RELATION_INDEXES,
866    );
867
868    fn expect_record_fields(ty: Type) -> Vec<String> {
869        match ty.as_ref() {
870            TypeInner::Record(fields) => fields
871                .iter()
872                .map(|field| match field.id.as_ref() {
873                    Label::Named(name) => name.clone(),
874                    other => panic!("expected named record field, got {other:?}"),
875                })
876                .collect(),
877            other => panic!("expected candid record, got {other:?}"),
878        }
879    }
880
881    fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
882        match ty.as_ref() {
883            TypeInner::Record(fields) => fields
884                .iter()
885                .find_map(|field| match field.id.as_ref() {
886                    Label::Named(name) if name == field_name => Some(field.ty.clone()),
887                    _ => None,
888                })
889                .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
890            other => panic!("expected candid record, got {other:?}"),
891        }
892    }
893
894    fn expect_variant_labels(ty: Type) -> Vec<String> {
895        match ty.as_ref() {
896            TypeInner::Variant(fields) => fields
897                .iter()
898                .map(|field| match field.id.as_ref() {
899                    Label::Named(name) => name.clone(),
900                    other => panic!("expected named variant label, got {other:?}"),
901                })
902                .collect(),
903            other => panic!("expected candid variant, got {other:?}"),
904        }
905    }
906
907    #[test]
908    fn entity_schema_description_candid_shape_is_stable() {
909        let fields = expect_record_fields(EntitySchemaDescription::ty());
910
911        for field in [
912            "entity_path",
913            "entity_name",
914            "primary_key",
915            "fields",
916            "indexes",
917            "relations",
918        ] {
919            assert!(
920                fields.iter().any(|candidate| candidate == field),
921                "EntitySchemaDescription must keep `{field}` field key",
922            );
923        }
924    }
925
926    #[test]
927    fn entity_field_description_candid_shape_is_stable() {
928        let fields = expect_record_fields(EntityFieldDescription::ty());
929
930        for field in ["name", "slot", "kind", "primary_key", "queryable"] {
931            assert!(
932                fields.iter().any(|candidate| candidate == field),
933                "EntityFieldDescription must keep `{field}` field key",
934            );
935        }
936
937        assert!(
938            matches!(
939                expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
940                TypeInner::Nat16
941            ),
942            "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
943        );
944    }
945
946    #[test]
947    fn entity_index_description_candid_shape_is_stable() {
948        let fields = expect_record_fields(EntityIndexDescription::ty());
949
950        for field in ["name", "unique", "fields"] {
951            assert!(
952                fields.iter().any(|candidate| candidate == field),
953                "EntityIndexDescription must keep `{field}` field key",
954            );
955        }
956    }
957
958    #[test]
959    fn entity_relation_description_candid_shape_is_stable() {
960        let fields = expect_record_fields(EntityRelationDescription::ty());
961
962        for field in [
963            "field",
964            "target_path",
965            "target_entity_name",
966            "target_store_path",
967            "strength",
968            "cardinality",
969        ] {
970            assert!(
971                fields.iter().any(|candidate| candidate == field),
972                "EntityRelationDescription must keep `{field}` field key",
973            );
974        }
975    }
976
977    #[test]
978    fn relation_enum_variant_labels_are_stable() {
979        let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
980        strength_labels.sort_unstable();
981        assert_eq!(
982            strength_labels,
983            vec!["Strong".to_string(), "Weak".to_string()]
984        );
985
986        let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
987        cardinality_labels.sort_unstable();
988        assert_eq!(
989            cardinality_labels,
990            vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
991        );
992    }
993
994    #[test]
995    fn describe_fixture_constructors_stay_usable() {
996        let payload = EntitySchemaDescription::new(
997            "entities::User".to_string(),
998            "User".to_string(),
999            "id".to_string(),
1000            vec![EntityFieldDescription::new(
1001                "id".to_string(),
1002                Some(0),
1003                "ulid".to_string(),
1004                true,
1005                true,
1006            )],
1007            vec![EntityIndexDescription::new(
1008                "idx_email".to_string(),
1009                true,
1010                vec!["email".to_string()],
1011            )],
1012            vec![EntityRelationDescription::new(
1013                "account_id".to_string(),
1014                "entities::Account".to_string(),
1015                "Account".to_string(),
1016                "accounts".to_string(),
1017                EntityRelationStrength::Strong,
1018                EntityRelationCardinality::Single,
1019            )],
1020        );
1021
1022        assert_eq!(payload.entity_name(), "User");
1023        assert_eq!(payload.fields().len(), 1);
1024        assert_eq!(payload.indexes().len(), 1);
1025        assert_eq!(payload.relations().len(), 1);
1026    }
1027
1028    #[test]
1029    fn schema_describe_relations_match_relation_descriptors() {
1030        let descriptors =
1031            relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1032        let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1033        let relations = described.relations();
1034
1035        assert_eq!(descriptors.len(), relations.len());
1036
1037        for (descriptor, relation) in descriptors.iter().zip(relations) {
1038            assert_eq!(relation.field(), descriptor.field_name());
1039            assert_eq!(relation.target_path(), descriptor.target_path());
1040            assert_eq!(
1041                relation.target_entity_name(),
1042                descriptor.target_entity_name()
1043            );
1044            assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1045            assert_eq!(
1046                relation.strength(),
1047                match descriptor.strength() {
1048                    RelationStrength::Strong => EntityRelationStrength::Strong,
1049                    RelationStrength::Weak => EntityRelationStrength::Weak,
1050                }
1051            );
1052            assert_eq!(
1053                relation.cardinality(),
1054                match descriptor.cardinality() {
1055                    RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1056                    RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1057                    RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1058                }
1059            );
1060        }
1061    }
1062
1063    #[test]
1064    fn schema_describe_includes_text_max_len_contract() {
1065        static FIELDS: [FieldModel; 2] = [
1066            FieldModel::generated("id", FieldKind::Ulid),
1067            FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1068        ];
1069        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1070        static MODEL: EntityModel = EntityModel::generated(
1071            "entities::BoundedName",
1072            "BoundedName",
1073            &FIELDS[0],
1074            0,
1075            &FIELDS,
1076            &INDEXES,
1077        );
1078
1079        let described = describe_entity_model(&MODEL);
1080        let name_field = described
1081            .fields()
1082            .iter()
1083            .find(|field| field.name() == "name")
1084            .expect("bounded text field should be described");
1085
1086        assert_eq!(name_field.kind(), "text(max_len=16)");
1087    }
1088
1089    #[test]
1090    fn schema_describe_uses_accepted_top_level_field_metadata() {
1091        static FIELDS: [FieldModel; 2] = [
1092            FieldModel::generated("id", FieldKind::Ulid),
1093            FieldModel::generated("payload", FieldKind::Text { max_len: None }),
1094        ];
1095        static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1096        static MODEL: EntityModel = EntityModel::generated(
1097            "entities::BlobEvent",
1098            "BlobEvent",
1099            &FIELDS[0],
1100            0,
1101            &FIELDS,
1102            &INDEXES,
1103        );
1104        let id_slot = SchemaFieldSlot::new(0);
1105        let payload_slot = SchemaFieldSlot::new(7);
1106        // The accepted wrapper below is intentionally inconsistent so this
1107        // metadata boundary proves row-layout authority owns slot answers.
1108        let stale_payload_field_slot = SchemaFieldSlot::new(3);
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                    stale_payload_field_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}