1use 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 #[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_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
439fn 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
464struct DescribeFieldMetadata {
473 kind: String,
474 queryable: bool,
475}
476
477impl DescribeFieldMetadata {
478 const fn new(kind: String, queryable: bool) -> Self {
480 Self { kind, queryable }
481 }
482}
483
484fn 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
506fn 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 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
533fn 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
558fn 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
582fn 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
615fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
618 match kind {
619 FieldKind::Account => out.push_str("account"),
620 FieldKind::Blob { max_len } => match max_len {
621 Some(max_len) => {
622 out.push_str("blob(max_len=");
623 out.push_str(&max_len.to_string());
624 out.push(')');
625 }
626 None => out.push_str("blob"),
627 },
628 FieldKind::Bool => out.push_str("bool"),
629 FieldKind::Date => out.push_str("date"),
630 FieldKind::Decimal { scale } => {
631 let _ = write!(out, "decimal(scale={scale})");
632 }
633 FieldKind::Duration => out.push_str("duration"),
634 FieldKind::Enum { path, .. } => {
635 out.push_str("enum(");
636 out.push_str(path);
637 out.push(')');
638 }
639 FieldKind::Float32 => out.push_str("float32"),
640 FieldKind::Float64 => out.push_str("float64"),
641 FieldKind::Int => out.push_str("int"),
642 FieldKind::Int128 => out.push_str("int128"),
643 FieldKind::IntBig => out.push_str("int_big"),
644 FieldKind::Principal => out.push_str("principal"),
645 FieldKind::Subaccount => out.push_str("subaccount"),
646 FieldKind::Text { max_len } => match max_len {
647 Some(max_len) => {
648 let _ = write!(out, "text(max_len={max_len})");
649 }
650 None => out.push_str("text"),
651 },
652 FieldKind::Timestamp => out.push_str("timestamp"),
653 FieldKind::Uint => out.push_str("uint"),
654 FieldKind::Uint128 => out.push_str("uint128"),
655 FieldKind::UintBig => out.push_str("uint_big"),
656 FieldKind::Ulid => out.push_str("ulid"),
657 FieldKind::Unit => out.push_str("unit"),
658 FieldKind::Relation {
659 target_entity_name,
660 key_kind,
661 strength,
662 ..
663 } => {
664 out.push_str("relation(target=");
665 out.push_str(target_entity_name);
666 out.push_str(", key=");
667 write_field_kind_summary(out, key_kind);
668 out.push_str(", strength=");
669 out.push_str(summarize_relation_strength(*strength));
670 out.push(')');
671 }
672 FieldKind::List(inner) => {
673 out.push_str("list<");
674 write_field_kind_summary(out, inner);
675 out.push('>');
676 }
677 FieldKind::Set(inner) => {
678 out.push_str("set<");
679 write_field_kind_summary(out, inner);
680 out.push('>');
681 }
682 FieldKind::Map { key, value } => {
683 out.push_str("map<");
684 write_field_kind_summary(out, key);
685 out.push_str(", ");
686 write_field_kind_summary(out, value);
687 out.push('>');
688 }
689 FieldKind::Structured { .. } => {
690 out.push_str("structured");
691 }
692 }
693}
694
695#[cfg_attr(
696 doc,
697 doc = "Render one stable field-kind label from accepted persisted schema metadata."
698)]
699fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
700 let mut out = String::new();
701 write_persisted_field_kind_summary(&mut out, kind);
702
703 out
704}
705
706fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
710 match kind {
711 PersistedFieldKind::Account => out.push_str("account"),
712 PersistedFieldKind::Blob { max_len } => match max_len {
713 Some(max_len) => {
714 out.push_str("blob(max_len=");
715 out.push_str(&max_len.to_string());
716 out.push(')');
717 }
718 None => out.push_str("blob"),
719 },
720 PersistedFieldKind::Bool => out.push_str("bool"),
721 PersistedFieldKind::Date => out.push_str("date"),
722 PersistedFieldKind::Decimal { scale } => {
723 let _ = write!(out, "decimal(scale={scale})");
724 }
725 PersistedFieldKind::Duration => out.push_str("duration"),
726 PersistedFieldKind::Enum { path, .. } => {
727 out.push_str("enum(");
728 out.push_str(path);
729 out.push(')');
730 }
731 PersistedFieldKind::Float32 => out.push_str("float32"),
732 PersistedFieldKind::Float64 => out.push_str("float64"),
733 PersistedFieldKind::Int => out.push_str("int"),
734 PersistedFieldKind::Int128 => out.push_str("int128"),
735 PersistedFieldKind::IntBig => out.push_str("int_big"),
736 PersistedFieldKind::Principal => out.push_str("principal"),
737 PersistedFieldKind::Subaccount => out.push_str("subaccount"),
738 PersistedFieldKind::Text { max_len } => match max_len {
739 Some(max_len) => {
740 let _ = write!(out, "text(max_len={max_len})");
741 }
742 None => out.push_str("text"),
743 },
744 PersistedFieldKind::Timestamp => out.push_str("timestamp"),
745 PersistedFieldKind::Uint => out.push_str("uint"),
746 PersistedFieldKind::Uint128 => out.push_str("uint128"),
747 PersistedFieldKind::UintBig => out.push_str("uint_big"),
748 PersistedFieldKind::Ulid => out.push_str("ulid"),
749 PersistedFieldKind::Unit => out.push_str("unit"),
750 PersistedFieldKind::Relation {
751 target_entity_name,
752 key_kind,
753 strength,
754 ..
755 } => {
756 out.push_str("relation(target=");
757 out.push_str(target_entity_name);
758 out.push_str(", key=");
759 write_persisted_field_kind_summary(out, key_kind);
760 out.push_str(", strength=");
761 out.push_str(summarize_persisted_relation_strength(*strength));
762 out.push(')');
763 }
764 PersistedFieldKind::List(inner) => {
765 out.push_str("list<");
766 write_persisted_field_kind_summary(out, inner);
767 out.push('>');
768 }
769 PersistedFieldKind::Set(inner) => {
770 out.push_str("set<");
771 write_persisted_field_kind_summary(out, inner);
772 out.push('>');
773 }
774 PersistedFieldKind::Map { key, value } => {
775 out.push_str("map<");
776 write_persisted_field_kind_summary(out, key);
777 out.push_str(", ");
778 write_persisted_field_kind_summary(out, value);
779 out.push('>');
780 }
781 PersistedFieldKind::Structured { .. } => {
782 out.push_str("structured");
783 }
784 }
785}
786
787#[cfg_attr(
788 doc,
789 doc = "Render one stable relation-strength label from persisted schema metadata."
790)]
791const fn summarize_persisted_relation_strength(
792 strength: PersistedRelationStrength,
793) -> &'static str {
794 match strength {
795 PersistedRelationStrength::Strong => "strong",
796 PersistedRelationStrength::Weak => "weak",
797 }
798}
799
800#[cfg_attr(
801 doc,
802 doc = "Render one stable relation-strength label for field-kind summaries."
803)]
804const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
805 match strength {
806 RelationStrength::Strong => "strong",
807 RelationStrength::Weak => "weak",
808 }
809}
810
811#[cfg(test)]
816mod tests {
817 use crate::{
818 db::{
819 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
820 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
821 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
822 schema::{
823 AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
824 PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
825 SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
826 describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
827 },
828 },
829 model::{
830 entity::EntityModel,
831 field::{
832 FieldKind, FieldModel, FieldStorageDecode, LeafCodec, RelationStrength, ScalarCodec,
833 },
834 },
835 types::EntityTag,
836 };
837 use candid::types::{CandidType, Label, Type, TypeInner};
838
839 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
840 target_path: "entities::Target",
841 target_entity_name: "Target",
842 target_entity_tag: EntityTag::new(0xD001),
843 target_store_path: "stores::Target",
844 key_kind: &FieldKind::Ulid,
845 strength: RelationStrength::Strong,
846 };
847 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
848 target_path: "entities::Account",
849 target_entity_name: "Account",
850 target_entity_tag: EntityTag::new(0xD002),
851 target_store_path: "stores::Account",
852 key_kind: &FieldKind::Uint,
853 strength: RelationStrength::Weak,
854 };
855 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
856 target_path: "entities::Team",
857 target_entity_name: "Team",
858 target_entity_tag: EntityTag::new(0xD003),
859 target_store_path: "stores::Team",
860 key_kind: &FieldKind::Text { max_len: None },
861 strength: RelationStrength::Strong,
862 };
863 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
864 FieldModel::generated("id", FieldKind::Ulid),
865 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
866 FieldModel::generated(
867 "accounts",
868 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
869 ),
870 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
871 ];
872 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
873 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
874 "entities::Source",
875 "Source",
876 &DESCRIBE_RELATION_FIELDS[0],
877 0,
878 &DESCRIBE_RELATION_FIELDS,
879 &DESCRIBE_RELATION_INDEXES,
880 );
881
882 fn expect_record_fields(ty: Type) -> Vec<String> {
883 match ty.as_ref() {
884 TypeInner::Record(fields) => fields
885 .iter()
886 .map(|field| match field.id.as_ref() {
887 Label::Named(name) => name.clone(),
888 other => panic!("expected named record field, got {other:?}"),
889 })
890 .collect(),
891 other => panic!("expected candid record, got {other:?}"),
892 }
893 }
894
895 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
896 match ty.as_ref() {
897 TypeInner::Record(fields) => fields
898 .iter()
899 .find_map(|field| match field.id.as_ref() {
900 Label::Named(name) if name == field_name => Some(field.ty.clone()),
901 _ => None,
902 })
903 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
904 other => panic!("expected candid record, got {other:?}"),
905 }
906 }
907
908 fn expect_variant_labels(ty: Type) -> Vec<String> {
909 match ty.as_ref() {
910 TypeInner::Variant(fields) => fields
911 .iter()
912 .map(|field| match field.id.as_ref() {
913 Label::Named(name) => name.clone(),
914 other => panic!("expected named variant label, got {other:?}"),
915 })
916 .collect(),
917 other => panic!("expected candid variant, got {other:?}"),
918 }
919 }
920
921 #[test]
922 fn entity_schema_description_candid_shape_is_stable() {
923 let fields = expect_record_fields(EntitySchemaDescription::ty());
924
925 for field in [
926 "entity_path",
927 "entity_name",
928 "primary_key",
929 "fields",
930 "indexes",
931 "relations",
932 ] {
933 assert!(
934 fields.iter().any(|candidate| candidate == field),
935 "EntitySchemaDescription must keep `{field}` field key",
936 );
937 }
938 }
939
940 #[test]
941 fn entity_field_description_candid_shape_is_stable() {
942 let fields = expect_record_fields(EntityFieldDescription::ty());
943
944 for field in ["name", "slot", "kind", "primary_key", "queryable"] {
945 assert!(
946 fields.iter().any(|candidate| candidate == field),
947 "EntityFieldDescription must keep `{field}` field key",
948 );
949 }
950
951 assert!(
952 matches!(
953 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
954 TypeInner::Nat16
955 ),
956 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
957 );
958 }
959
960 #[test]
961 fn entity_index_description_candid_shape_is_stable() {
962 let fields = expect_record_fields(EntityIndexDescription::ty());
963
964 for field in ["name", "unique", "fields"] {
965 assert!(
966 fields.iter().any(|candidate| candidate == field),
967 "EntityIndexDescription must keep `{field}` field key",
968 );
969 }
970 }
971
972 #[test]
973 fn entity_relation_description_candid_shape_is_stable() {
974 let fields = expect_record_fields(EntityRelationDescription::ty());
975
976 for field in [
977 "field",
978 "target_path",
979 "target_entity_name",
980 "target_store_path",
981 "strength",
982 "cardinality",
983 ] {
984 assert!(
985 fields.iter().any(|candidate| candidate == field),
986 "EntityRelationDescription must keep `{field}` field key",
987 );
988 }
989 }
990
991 #[test]
992 fn relation_enum_variant_labels_are_stable() {
993 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
994 strength_labels.sort_unstable();
995 assert_eq!(
996 strength_labels,
997 vec!["Strong".to_string(), "Weak".to_string()]
998 );
999
1000 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
1001 cardinality_labels.sort_unstable();
1002 assert_eq!(
1003 cardinality_labels,
1004 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
1005 );
1006 }
1007
1008 #[test]
1009 fn describe_fixture_constructors_stay_usable() {
1010 let payload = EntitySchemaDescription::new(
1011 "entities::User".to_string(),
1012 "User".to_string(),
1013 "id".to_string(),
1014 vec![EntityFieldDescription::new(
1015 "id".to_string(),
1016 Some(0),
1017 "ulid".to_string(),
1018 true,
1019 true,
1020 )],
1021 vec![EntityIndexDescription::new(
1022 "idx_email".to_string(),
1023 true,
1024 vec!["email".to_string()],
1025 )],
1026 vec![EntityRelationDescription::new(
1027 "account_id".to_string(),
1028 "entities::Account".to_string(),
1029 "Account".to_string(),
1030 "accounts".to_string(),
1031 EntityRelationStrength::Strong,
1032 EntityRelationCardinality::Single,
1033 )],
1034 );
1035
1036 assert_eq!(payload.entity_name(), "User");
1037 assert_eq!(payload.fields().len(), 1);
1038 assert_eq!(payload.indexes().len(), 1);
1039 assert_eq!(payload.relations().len(), 1);
1040 }
1041
1042 #[test]
1043 fn schema_describe_relations_match_relation_descriptors() {
1044 let descriptors =
1045 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1046 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1047 let relations = described.relations();
1048
1049 assert_eq!(descriptors.len(), relations.len());
1050
1051 for (descriptor, relation) in descriptors.iter().zip(relations) {
1052 assert_eq!(relation.field(), descriptor.field_name());
1053 assert_eq!(relation.target_path(), descriptor.target_path());
1054 assert_eq!(
1055 relation.target_entity_name(),
1056 descriptor.target_entity_name()
1057 );
1058 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1059 assert_eq!(
1060 relation.strength(),
1061 match descriptor.strength() {
1062 RelationStrength::Strong => EntityRelationStrength::Strong,
1063 RelationStrength::Weak => EntityRelationStrength::Weak,
1064 }
1065 );
1066 assert_eq!(
1067 relation.cardinality(),
1068 match descriptor.cardinality() {
1069 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1070 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1071 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1072 }
1073 );
1074 }
1075 }
1076
1077 #[test]
1078 fn schema_describe_includes_text_max_len_contract() {
1079 static FIELDS: [FieldModel; 2] = [
1080 FieldModel::generated("id", FieldKind::Ulid),
1081 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1082 ];
1083 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1084 static MODEL: EntityModel = EntityModel::generated(
1085 "entities::BoundedName",
1086 "BoundedName",
1087 &FIELDS[0],
1088 0,
1089 &FIELDS,
1090 &INDEXES,
1091 );
1092
1093 let described = describe_entity_model(&MODEL);
1094 let name_field = described
1095 .fields()
1096 .iter()
1097 .find(|field| field.name() == "name")
1098 .expect("bounded text field should be described");
1099
1100 assert_eq!(name_field.kind(), "text(max_len=16)");
1101 }
1102
1103 #[test]
1104 fn schema_describe_uses_accepted_top_level_field_metadata() {
1105 static FIELDS: [FieldModel; 2] = [
1106 FieldModel::generated("id", FieldKind::Ulid),
1107 FieldModel::generated("payload", FieldKind::Text { max_len: None }),
1108 ];
1109 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1110 static MODEL: EntityModel = EntityModel::generated(
1111 "entities::BlobEvent",
1112 "BlobEvent",
1113 &FIELDS[0],
1114 0,
1115 &FIELDS,
1116 &INDEXES,
1117 );
1118 let id_slot = SchemaFieldSlot::new(0);
1119 let payload_slot = SchemaFieldSlot::new(7);
1120 let stale_payload_field_slot = SchemaFieldSlot::new(3);
1123 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1124 SchemaVersion::initial(),
1125 "entities::BlobEvent".to_string(),
1126 "BlobEvent".to_string(),
1127 FieldId::new(1),
1128 SchemaRowLayout::new(
1129 SchemaVersion::initial(),
1130 vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1131 ),
1132 vec![
1133 PersistedFieldSnapshot::new(
1134 FieldId::new(1),
1135 "id".to_string(),
1136 id_slot,
1137 PersistedFieldKind::Ulid,
1138 Vec::new(),
1139 false,
1140 SchemaFieldDefault::None,
1141 FieldStorageDecode::ByKind,
1142 LeafCodec::StructuralFallback,
1143 ),
1144 PersistedFieldSnapshot::new(
1145 FieldId::new(2),
1146 "payload".to_string(),
1147 stale_payload_field_slot,
1148 PersistedFieldKind::Blob { max_len: None },
1149 Vec::new(),
1150 false,
1151 SchemaFieldDefault::None,
1152 FieldStorageDecode::ByKind,
1153 LeafCodec::StructuralFallback,
1154 ),
1155 ],
1156 ));
1157
1158 let described = describe_entity_fields_with_persisted_schema(&MODEL, &snapshot)
1159 .into_iter()
1160 .map(|field| {
1161 (
1162 field.name().to_string(),
1163 field.slot(),
1164 field.kind().to_string(),
1165 )
1166 })
1167 .collect::<Vec<_>>();
1168
1169 assert_eq!(
1170 described,
1171 vec![
1172 ("id".to_string(), Some(0), "ulid".to_string()),
1173 ("payload".to_string(), Some(7), "blob".to_string()),
1174 ],
1175 );
1176 }
1177
1178 #[test]
1179 fn schema_describe_uses_accepted_nested_leaf_metadata() {
1180 static NESTED_FIELDS: [FieldModel; 1] = [FieldModel::generated("rank", FieldKind::Uint)];
1181 static FIELDS: [FieldModel; 2] = [
1182 FieldModel::generated("id", FieldKind::Ulid),
1183 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1184 "profile",
1185 FieldKind::Structured { queryable: true },
1186 FieldStorageDecode::Value,
1187 false,
1188 None,
1189 None,
1190 &NESTED_FIELDS,
1191 ),
1192 ];
1193 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1194 static MODEL: EntityModel = EntityModel::generated(
1195 "entities::AcceptedProfile",
1196 "AcceptedProfile",
1197 &FIELDS[0],
1198 0,
1199 &FIELDS,
1200 &INDEXES,
1201 );
1202 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1203 SchemaVersion::initial(),
1204 "entities::AcceptedProfile".to_string(),
1205 "AcceptedProfile".to_string(),
1206 FieldId::new(1),
1207 SchemaRowLayout::new(
1208 SchemaVersion::initial(),
1209 vec![
1210 (FieldId::new(1), SchemaFieldSlot::new(0)),
1211 (FieldId::new(2), SchemaFieldSlot::new(1)),
1212 ],
1213 ),
1214 vec![
1215 PersistedFieldSnapshot::new(
1216 FieldId::new(1),
1217 "id".to_string(),
1218 SchemaFieldSlot::new(0),
1219 PersistedFieldKind::Ulid,
1220 Vec::new(),
1221 false,
1222 SchemaFieldDefault::None,
1223 FieldStorageDecode::ByKind,
1224 LeafCodec::StructuralFallback,
1225 ),
1226 PersistedFieldSnapshot::new(
1227 FieldId::new(2),
1228 "profile".to_string(),
1229 SchemaFieldSlot::new(1),
1230 PersistedFieldKind::Structured { queryable: true },
1231 vec![PersistedNestedLeafSnapshot::new(
1232 vec!["rank".to_string()],
1233 PersistedFieldKind::Blob { max_len: None },
1234 false,
1235 FieldStorageDecode::ByKind,
1236 LeafCodec::Scalar(ScalarCodec::Blob),
1237 )],
1238 false,
1239 SchemaFieldDefault::None,
1240 FieldStorageDecode::Value,
1241 LeafCodec::StructuralFallback,
1242 ),
1243 ],
1244 ));
1245
1246 let described = describe_entity_fields_with_persisted_schema(&MODEL, &snapshot);
1247 let rank = described
1248 .iter()
1249 .find(|field| field.name() == "└─ rank")
1250 .expect("accepted nested leaf should be described");
1251
1252 assert_eq!(rank.slot(), None);
1253 assert_eq!(rank.kind(), "blob");
1254 assert!(rank.queryable());
1255 }
1256
1257 #[test]
1258 fn schema_describe_expands_generated_structured_field_leaves() {
1259 static NESTED_FIELDS: [FieldModel; 3] = [
1260 FieldModel::generated("name", FieldKind::Text { max_len: None }),
1261 FieldModel::generated("level", FieldKind::Uint),
1262 FieldModel::generated("pid", FieldKind::Principal),
1263 ];
1264 static FIELDS: [FieldModel; 2] = [
1265 FieldModel::generated("id", FieldKind::Ulid),
1266 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1267 "mentor",
1268 FieldKind::Structured { queryable: false },
1269 FieldStorageDecode::Value,
1270 false,
1271 None,
1272 None,
1273 &NESTED_FIELDS,
1274 ),
1275 ];
1276 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1277 static MODEL: EntityModel = EntityModel::generated(
1278 "entities::Character",
1279 "Character",
1280 &FIELDS[0],
1281 0,
1282 &FIELDS,
1283 &INDEXES,
1284 );
1285
1286 let described = describe_entity_model(&MODEL);
1287 let described_fields = described
1288 .fields()
1289 .iter()
1290 .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1291 .collect::<Vec<_>>();
1292
1293 assert_eq!(
1294 described_fields,
1295 vec![
1296 ("id", Some(0), "ulid", true),
1297 ("mentor", Some(1), "structured", false),
1298 ("├─ name", None, "text", true),
1299 ("├─ level", None, "uint", true),
1300 ("└─ pid", None, "principal", true),
1301 ],
1302 );
1303 }
1304}