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 => 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
699fn 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#[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 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}