1use crate::{
7 db::{
8 relation::{
9 RelationDescriptor, RelationDescriptorCardinality, relation_descriptors_for_model_iter,
10 },
11 schema::{
12 AcceptedSchemaSnapshot, PersistedFieldKind, PersistedFieldSnapshot,
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 #[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 #[must_use]
64 pub const fn entity_path(&self) -> &str {
65 self.entity_path.as_str()
66 }
67
68 #[must_use]
70 pub const fn entity_name(&self) -> &str {
71 self.entity_name.as_str()
72 }
73
74 #[must_use]
76 pub const fn primary_key(&self) -> &str {
77 self.primary_key.as_str()
78 }
79
80 #[must_use]
82 pub const fn fields(&self) -> &[EntityFieldDescription] {
83 self.fields.as_slice()
84 }
85
86 #[must_use]
88 pub const fn indexes(&self) -> &[EntityIndexDescription] {
89 self.indexes.as_slice()
90 }
91
92 #[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 #[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 #[must_use]
138 pub const fn name(&self) -> &str {
139 self.name.as_str()
140 }
141
142 #[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 #[must_use]
154 pub const fn kind(&self) -> &str {
155 self.kind.as_str()
156 }
157
158 #[must_use]
160 pub const fn primary_key(&self) -> bool {
161 self.primary_key
162 }
163
164 #[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 #[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 #[must_use]
195 pub const fn name(&self) -> &str {
196 self.name.as_str()
197 }
198
199 #[must_use]
201 pub const fn unique(&self) -> bool {
202 self.unique
203 }
204
205 #[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 #[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 #[must_use]
249 pub const fn field(&self) -> &str {
250 self.field.as_str()
251 }
252
253 #[must_use]
255 pub const fn target_path(&self) -> &str {
256 self.target_path.as_str()
257 }
258
259 #[must_use]
261 pub const fn target_entity_name(&self) -> &str {
262 self.target_entity_name.as_str()
263 }
264
265 #[must_use]
267 pub const fn target_store_path(&self) -> &str {
268 self.target_store_path.as_str()
269 }
270
271 #[must_use]
273 pub const fn strength(&self) -> EntityRelationStrength {
274 self.strength
275 }
276
277 #[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
342fn 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#[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_field = schema.field_by_name(field.name());
403 let slot = accepted_field
404 .map(PersistedFieldSnapshot::slot)
405 .map(SchemaFieldSlot::get);
406 let metadata = accepted_field.map(|field| {
407 DescribeFieldMetadata::new(
408 summarize_persisted_field_kind(field.kind()),
409 field_type_from_persisted_kind(field.kind())
410 .value_kind()
411 .is_queryable(),
412 )
413 });
414 describe_field_recursive(
415 &mut fields,
416 field.name,
417 slot,
418 field,
419 primary_key,
420 None,
421 metadata,
422 );
423 }
424
425 fields
426}
427
428fn describe_entity_fields_with_slot_lookup(
432 model: &EntityModel,
433 mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
434) -> Vec<EntityFieldDescription> {
435 let mut fields = Vec::with_capacity(model.fields.len());
436
437 for (slot, field) in model.fields.iter().enumerate() {
438 let primary_key = field.name == model.primary_key.name;
439 describe_field_recursive(
440 &mut fields,
441 field.name,
442 slot_for_field(slot, field),
443 field,
444 primary_key,
445 None,
446 None,
447 );
448 }
449
450 fields
451}
452
453struct DescribeFieldMetadata {
462 kind: String,
463 queryable: bool,
464}
465
466impl DescribeFieldMetadata {
467 const fn new(kind: String, queryable: bool) -> Self {
469 Self { kind, queryable }
470 }
471}
472
473fn describe_field_recursive(
477 fields: &mut Vec<EntityFieldDescription>,
478 name: &str,
479 slot: Option<u16>,
480 field: &FieldModel,
481 primary_key: bool,
482 tree_prefix: Option<&'static str>,
483 metadata_override: Option<DescribeFieldMetadata>,
484) {
485 let metadata = metadata_override.unwrap_or_else(|| {
486 DescribeFieldMetadata::new(
487 summarize_field_kind(&field.kind),
488 field.kind.value_kind().is_queryable(),
489 )
490 });
491
492 let display_name = if let Some(prefix) = tree_prefix {
495 format!("{prefix}{name}")
496 } else {
497 name.to_string()
498 };
499
500 fields.push(EntityFieldDescription::new(
501 display_name,
502 slot,
503 metadata.kind,
504 primary_key,
505 metadata.queryable,
506 ));
507
508 let nested_fields = field.nested_fields();
509 for (index, nested) in nested_fields.iter().enumerate() {
510 let prefix = if index + 1 == nested_fields.len() {
511 "└─ "
512 } else {
513 "├─ "
514 };
515 describe_field_recursive(
516 fields,
517 nested.name(),
518 None,
519 nested,
520 false,
521 Some(prefix),
522 None,
523 );
524 }
525}
526
527fn relation_description_from_descriptor(
529 descriptor: RelationDescriptor<'_>,
530) -> EntityRelationDescription {
531 let strength = match descriptor.strength() {
532 RelationStrength::Strong => EntityRelationStrength::Strong,
533 RelationStrength::Weak => EntityRelationStrength::Weak,
534 };
535
536 let cardinality = match descriptor.cardinality() {
537 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
538 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
539 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
540 };
541
542 EntityRelationDescription::new(
543 descriptor.field_name().to_string(),
544 descriptor.target_path().to_string(),
545 descriptor.target_entity_name().to_string(),
546 descriptor.target_store_path().to_string(),
547 strength,
548 cardinality,
549 )
550}
551
552#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
553fn summarize_field_kind(kind: &FieldKind) -> String {
554 let mut out = String::new();
555 write_field_kind_summary(&mut out, kind);
556
557 out
558}
559
560fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
563 match kind {
564 FieldKind::Account => out.push_str("account"),
565 FieldKind::Blob => out.push_str("blob"),
566 FieldKind::Bool => out.push_str("bool"),
567 FieldKind::Date => out.push_str("date"),
568 FieldKind::Decimal { scale } => {
569 let _ = write!(out, "decimal(scale={scale})");
570 }
571 FieldKind::Duration => out.push_str("duration"),
572 FieldKind::Enum { path, .. } => {
573 out.push_str("enum(");
574 out.push_str(path);
575 out.push(')');
576 }
577 FieldKind::Float32 => out.push_str("float32"),
578 FieldKind::Float64 => out.push_str("float64"),
579 FieldKind::Int => out.push_str("int"),
580 FieldKind::Int128 => out.push_str("int128"),
581 FieldKind::IntBig => out.push_str("int_big"),
582 FieldKind::Principal => out.push_str("principal"),
583 FieldKind::Subaccount => out.push_str("subaccount"),
584 FieldKind::Text { max_len } => match max_len {
585 Some(max_len) => {
586 let _ = write!(out, "text(max_len={max_len})");
587 }
588 None => out.push_str("text"),
589 },
590 FieldKind::Timestamp => out.push_str("timestamp"),
591 FieldKind::Uint => out.push_str("uint"),
592 FieldKind::Uint128 => out.push_str("uint128"),
593 FieldKind::UintBig => out.push_str("uint_big"),
594 FieldKind::Ulid => out.push_str("ulid"),
595 FieldKind::Unit => out.push_str("unit"),
596 FieldKind::Relation {
597 target_entity_name,
598 key_kind,
599 strength,
600 ..
601 } => {
602 out.push_str("relation(target=");
603 out.push_str(target_entity_name);
604 out.push_str(", key=");
605 write_field_kind_summary(out, key_kind);
606 out.push_str(", strength=");
607 out.push_str(summarize_relation_strength(*strength));
608 out.push(')');
609 }
610 FieldKind::List(inner) => {
611 out.push_str("list<");
612 write_field_kind_summary(out, inner);
613 out.push('>');
614 }
615 FieldKind::Set(inner) => {
616 out.push_str("set<");
617 write_field_kind_summary(out, inner);
618 out.push('>');
619 }
620 FieldKind::Map { key, value } => {
621 out.push_str("map<");
622 write_field_kind_summary(out, key);
623 out.push_str(", ");
624 write_field_kind_summary(out, value);
625 out.push('>');
626 }
627 FieldKind::Structured { .. } => {
628 out.push_str("structured");
629 }
630 }
631}
632
633#[cfg_attr(
634 doc,
635 doc = "Render one stable field-kind label from accepted persisted schema metadata."
636)]
637fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
638 let mut out = String::new();
639 write_persisted_field_kind_summary(&mut out, kind);
640
641 out
642}
643
644fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
648 match kind {
649 PersistedFieldKind::Account => out.push_str("account"),
650 PersistedFieldKind::Blob => out.push_str("blob"),
651 PersistedFieldKind::Bool => out.push_str("bool"),
652 PersistedFieldKind::Date => out.push_str("date"),
653 PersistedFieldKind::Decimal { scale } => {
654 let _ = write!(out, "decimal(scale={scale})");
655 }
656 PersistedFieldKind::Duration => out.push_str("duration"),
657 PersistedFieldKind::Enum { path, .. } => {
658 out.push_str("enum(");
659 out.push_str(path);
660 out.push(')');
661 }
662 PersistedFieldKind::Float32 => out.push_str("float32"),
663 PersistedFieldKind::Float64 => out.push_str("float64"),
664 PersistedFieldKind::Int => out.push_str("int"),
665 PersistedFieldKind::Int128 => out.push_str("int128"),
666 PersistedFieldKind::IntBig => out.push_str("int_big"),
667 PersistedFieldKind::Principal => out.push_str("principal"),
668 PersistedFieldKind::Subaccount => out.push_str("subaccount"),
669 PersistedFieldKind::Text { max_len } => match max_len {
670 Some(max_len) => {
671 let _ = write!(out, "text(max_len={max_len})");
672 }
673 None => out.push_str("text"),
674 },
675 PersistedFieldKind::Timestamp => out.push_str("timestamp"),
676 PersistedFieldKind::Uint => out.push_str("uint"),
677 PersistedFieldKind::Uint128 => out.push_str("uint128"),
678 PersistedFieldKind::UintBig => out.push_str("uint_big"),
679 PersistedFieldKind::Ulid => out.push_str("ulid"),
680 PersistedFieldKind::Unit => out.push_str("unit"),
681 PersistedFieldKind::Relation {
682 target_entity_name,
683 key_kind,
684 strength,
685 ..
686 } => {
687 out.push_str("relation(target=");
688 out.push_str(target_entity_name);
689 out.push_str(", key=");
690 write_persisted_field_kind_summary(out, key_kind);
691 out.push_str(", strength=");
692 out.push_str(summarize_persisted_relation_strength(*strength));
693 out.push(')');
694 }
695 PersistedFieldKind::List(inner) => {
696 out.push_str("list<");
697 write_persisted_field_kind_summary(out, inner);
698 out.push('>');
699 }
700 PersistedFieldKind::Set(inner) => {
701 out.push_str("set<");
702 write_persisted_field_kind_summary(out, inner);
703 out.push('>');
704 }
705 PersistedFieldKind::Map { key, value } => {
706 out.push_str("map<");
707 write_persisted_field_kind_summary(out, key);
708 out.push_str(", ");
709 write_persisted_field_kind_summary(out, value);
710 out.push('>');
711 }
712 PersistedFieldKind::Structured { .. } => {
713 out.push_str("structured");
714 }
715 }
716}
717
718#[cfg_attr(
719 doc,
720 doc = "Render one stable relation-strength label from persisted schema metadata."
721)]
722const fn summarize_persisted_relation_strength(
723 strength: PersistedRelationStrength,
724) -> &'static str {
725 match strength {
726 PersistedRelationStrength::Strong => "strong",
727 PersistedRelationStrength::Weak => "weak",
728 }
729}
730
731#[cfg_attr(
732 doc,
733 doc = "Render one stable relation-strength label for field-kind summaries."
734)]
735const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
736 match strength {
737 RelationStrength::Strong => "strong",
738 RelationStrength::Weak => "weak",
739 }
740}
741
742#[cfg(test)]
747mod tests {
748 use crate::{
749 db::{
750 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
751 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
752 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
753 schema::{
754 AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
755 PersistedSchemaSnapshot, SchemaFieldDefault, SchemaFieldSlot, SchemaRowLayout,
756 SchemaVersion,
757 describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
758 },
759 },
760 model::{
761 entity::EntityModel,
762 field::{FieldKind, FieldModel, FieldStorageDecode, LeafCodec, RelationStrength},
763 },
764 types::EntityTag,
765 };
766 use candid::types::{CandidType, Label, Type, TypeInner};
767
768 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
769 target_path: "entities::Target",
770 target_entity_name: "Target",
771 target_entity_tag: EntityTag::new(0xD001),
772 target_store_path: "stores::Target",
773 key_kind: &FieldKind::Ulid,
774 strength: RelationStrength::Strong,
775 };
776 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
777 target_path: "entities::Account",
778 target_entity_name: "Account",
779 target_entity_tag: EntityTag::new(0xD002),
780 target_store_path: "stores::Account",
781 key_kind: &FieldKind::Uint,
782 strength: RelationStrength::Weak,
783 };
784 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
785 target_path: "entities::Team",
786 target_entity_name: "Team",
787 target_entity_tag: EntityTag::new(0xD003),
788 target_store_path: "stores::Team",
789 key_kind: &FieldKind::Text { max_len: None },
790 strength: RelationStrength::Strong,
791 };
792 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
793 FieldModel::generated("id", FieldKind::Ulid),
794 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
795 FieldModel::generated(
796 "accounts",
797 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
798 ),
799 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
800 ];
801 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
802 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
803 "entities::Source",
804 "Source",
805 &DESCRIBE_RELATION_FIELDS[0],
806 0,
807 &DESCRIBE_RELATION_FIELDS,
808 &DESCRIBE_RELATION_INDEXES,
809 );
810
811 fn expect_record_fields(ty: Type) -> Vec<String> {
812 match ty.as_ref() {
813 TypeInner::Record(fields) => fields
814 .iter()
815 .map(|field| match field.id.as_ref() {
816 Label::Named(name) => name.clone(),
817 other => panic!("expected named record field, got {other:?}"),
818 })
819 .collect(),
820 other => panic!("expected candid record, got {other:?}"),
821 }
822 }
823
824 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
825 match ty.as_ref() {
826 TypeInner::Record(fields) => fields
827 .iter()
828 .find_map(|field| match field.id.as_ref() {
829 Label::Named(name) if name == field_name => Some(field.ty.clone()),
830 _ => None,
831 })
832 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
833 other => panic!("expected candid record, got {other:?}"),
834 }
835 }
836
837 fn expect_variant_labels(ty: Type) -> Vec<String> {
838 match ty.as_ref() {
839 TypeInner::Variant(fields) => fields
840 .iter()
841 .map(|field| match field.id.as_ref() {
842 Label::Named(name) => name.clone(),
843 other => panic!("expected named variant label, got {other:?}"),
844 })
845 .collect(),
846 other => panic!("expected candid variant, got {other:?}"),
847 }
848 }
849
850 #[test]
851 fn entity_schema_description_candid_shape_is_stable() {
852 let fields = expect_record_fields(EntitySchemaDescription::ty());
853
854 for field in [
855 "entity_path",
856 "entity_name",
857 "primary_key",
858 "fields",
859 "indexes",
860 "relations",
861 ] {
862 assert!(
863 fields.iter().any(|candidate| candidate == field),
864 "EntitySchemaDescription must keep `{field}` field key",
865 );
866 }
867 }
868
869 #[test]
870 fn entity_field_description_candid_shape_is_stable() {
871 let fields = expect_record_fields(EntityFieldDescription::ty());
872
873 for field in ["name", "slot", "kind", "primary_key", "queryable"] {
874 assert!(
875 fields.iter().any(|candidate| candidate == field),
876 "EntityFieldDescription must keep `{field}` field key",
877 );
878 }
879
880 assert!(
881 matches!(
882 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
883 TypeInner::Nat16
884 ),
885 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
886 );
887 }
888
889 #[test]
890 fn entity_index_description_candid_shape_is_stable() {
891 let fields = expect_record_fields(EntityIndexDescription::ty());
892
893 for field in ["name", "unique", "fields"] {
894 assert!(
895 fields.iter().any(|candidate| candidate == field),
896 "EntityIndexDescription must keep `{field}` field key",
897 );
898 }
899 }
900
901 #[test]
902 fn entity_relation_description_candid_shape_is_stable() {
903 let fields = expect_record_fields(EntityRelationDescription::ty());
904
905 for field in [
906 "field",
907 "target_path",
908 "target_entity_name",
909 "target_store_path",
910 "strength",
911 "cardinality",
912 ] {
913 assert!(
914 fields.iter().any(|candidate| candidate == field),
915 "EntityRelationDescription must keep `{field}` field key",
916 );
917 }
918 }
919
920 #[test]
921 fn relation_enum_variant_labels_are_stable() {
922 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
923 strength_labels.sort_unstable();
924 assert_eq!(
925 strength_labels,
926 vec!["Strong".to_string(), "Weak".to_string()]
927 );
928
929 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
930 cardinality_labels.sort_unstable();
931 assert_eq!(
932 cardinality_labels,
933 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
934 );
935 }
936
937 #[test]
938 fn describe_fixture_constructors_stay_usable() {
939 let payload = EntitySchemaDescription::new(
940 "entities::User".to_string(),
941 "User".to_string(),
942 "id".to_string(),
943 vec![EntityFieldDescription::new(
944 "id".to_string(),
945 Some(0),
946 "ulid".to_string(),
947 true,
948 true,
949 )],
950 vec![EntityIndexDescription::new(
951 "idx_email".to_string(),
952 true,
953 vec!["email".to_string()],
954 )],
955 vec![EntityRelationDescription::new(
956 "account_id".to_string(),
957 "entities::Account".to_string(),
958 "Account".to_string(),
959 "accounts".to_string(),
960 EntityRelationStrength::Strong,
961 EntityRelationCardinality::Single,
962 )],
963 );
964
965 assert_eq!(payload.entity_name(), "User");
966 assert_eq!(payload.fields().len(), 1);
967 assert_eq!(payload.indexes().len(), 1);
968 assert_eq!(payload.relations().len(), 1);
969 }
970
971 #[test]
972 fn schema_describe_relations_match_relation_descriptors() {
973 let descriptors =
974 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
975 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
976 let relations = described.relations();
977
978 assert_eq!(descriptors.len(), relations.len());
979
980 for (descriptor, relation) in descriptors.iter().zip(relations) {
981 assert_eq!(relation.field(), descriptor.field_name());
982 assert_eq!(relation.target_path(), descriptor.target_path());
983 assert_eq!(
984 relation.target_entity_name(),
985 descriptor.target_entity_name()
986 );
987 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
988 assert_eq!(
989 relation.strength(),
990 match descriptor.strength() {
991 RelationStrength::Strong => EntityRelationStrength::Strong,
992 RelationStrength::Weak => EntityRelationStrength::Weak,
993 }
994 );
995 assert_eq!(
996 relation.cardinality(),
997 match descriptor.cardinality() {
998 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
999 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1000 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1001 }
1002 );
1003 }
1004 }
1005
1006 #[test]
1007 fn schema_describe_includes_text_max_len_contract() {
1008 static FIELDS: [FieldModel; 2] = [
1009 FieldModel::generated("id", FieldKind::Ulid),
1010 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1011 ];
1012 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1013 static MODEL: EntityModel = EntityModel::generated(
1014 "entities::BoundedName",
1015 "BoundedName",
1016 &FIELDS[0],
1017 0,
1018 &FIELDS,
1019 &INDEXES,
1020 );
1021
1022 let described = describe_entity_model(&MODEL);
1023 let name_field = described
1024 .fields()
1025 .iter()
1026 .find(|field| field.name() == "name")
1027 .expect("bounded text field should be described");
1028
1029 assert_eq!(name_field.kind(), "text(max_len=16)");
1030 }
1031
1032 #[test]
1033 fn schema_describe_uses_accepted_top_level_field_metadata() {
1034 static FIELDS: [FieldModel; 2] = [
1035 FieldModel::generated("id", FieldKind::Ulid),
1036 FieldModel::generated("payload", FieldKind::Text { max_len: None }),
1037 ];
1038 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1039 static MODEL: EntityModel = EntityModel::generated(
1040 "entities::BlobEvent",
1041 "BlobEvent",
1042 &FIELDS[0],
1043 0,
1044 &FIELDS,
1045 &INDEXES,
1046 );
1047 let id_slot = SchemaFieldSlot::new(0);
1048 let payload_slot = SchemaFieldSlot::new(7);
1049 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1050 SchemaVersion::initial(),
1051 "entities::BlobEvent".to_string(),
1052 "BlobEvent".to_string(),
1053 FieldId::new(1),
1054 SchemaRowLayout::new(
1055 SchemaVersion::initial(),
1056 vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1057 ),
1058 vec![
1059 PersistedFieldSnapshot::new(
1060 FieldId::new(1),
1061 "id".to_string(),
1062 id_slot,
1063 PersistedFieldKind::Ulid,
1064 false,
1065 SchemaFieldDefault::None,
1066 FieldStorageDecode::ByKind,
1067 LeafCodec::StructuralFallback,
1068 ),
1069 PersistedFieldSnapshot::new(
1070 FieldId::new(2),
1071 "payload".to_string(),
1072 payload_slot,
1073 PersistedFieldKind::Blob,
1074 false,
1075 SchemaFieldDefault::None,
1076 FieldStorageDecode::ByKind,
1077 LeafCodec::StructuralFallback,
1078 ),
1079 ],
1080 ));
1081
1082 let described = describe_entity_fields_with_persisted_schema(&MODEL, &snapshot)
1083 .into_iter()
1084 .map(|field| {
1085 (
1086 field.name().to_string(),
1087 field.slot(),
1088 field.kind().to_string(),
1089 )
1090 })
1091 .collect::<Vec<_>>();
1092
1093 assert_eq!(
1094 described,
1095 vec![
1096 ("id".to_string(), Some(0), "ulid".to_string()),
1097 ("payload".to_string(), Some(7), "blob".to_string()),
1098 ],
1099 );
1100 }
1101
1102 #[test]
1103 fn schema_describe_expands_generated_structured_field_leaves() {
1104 static NESTED_FIELDS: [FieldModel; 3] = [
1105 FieldModel::generated("name", FieldKind::Text { max_len: None }),
1106 FieldModel::generated("level", FieldKind::Uint),
1107 FieldModel::generated("pid", FieldKind::Principal),
1108 ];
1109 static FIELDS: [FieldModel; 2] = [
1110 FieldModel::generated("id", FieldKind::Ulid),
1111 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1112 "mentor",
1113 FieldKind::Structured { queryable: false },
1114 FieldStorageDecode::Value,
1115 false,
1116 None,
1117 None,
1118 &NESTED_FIELDS,
1119 ),
1120 ];
1121 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1122 static MODEL: EntityModel = EntityModel::generated(
1123 "entities::Character",
1124 "Character",
1125 &FIELDS[0],
1126 0,
1127 &FIELDS,
1128 &INDEXES,
1129 );
1130
1131 let described = describe_entity_model(&MODEL);
1132 let described_fields = described
1133 .fields()
1134 .iter()
1135 .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1136 .collect::<Vec<_>>();
1137
1138 assert_eq!(
1139 described_fields,
1140 vec![
1141 ("id", Some(0), "ulid", true),
1142 ("mentor", Some(1), "structured", false),
1143 ("├─ name", None, "text", true),
1144 ("├─ level", None, "uint", true),
1145 ("└─ pid", None, "principal", true),
1146 ],
1147 );
1148 }
1149}