1use crate::{
7 db::{
8 relation::{
9 RelationDescriptor, RelationDescriptorCardinality, relation_descriptors_for_model_iter,
10 },
11 schema::{
12 AcceptedSchemaSnapshot, PersistedFieldKind, PersistedNestedLeafSnapshot,
13 PersistedRelationStrength, SchemaFieldSlot, field_type_from_persisted_kind,
14 },
15 },
16 model::{
17 entity::EntityModel,
18 field::{FieldKind, FieldModel, RelationStrength},
19 },
20};
21use candid::CandidType;
22use serde::Deserialize;
23use std::fmt::Write;
24
25const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
26
27#[cfg_attr(
28 doc,
29 doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
30)]
31#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
32pub struct EntitySchemaDescription {
33 pub(crate) entity_path: String,
34 pub(crate) entity_name: String,
35 pub(crate) primary_key: String,
36 pub(crate) fields: Vec<EntityFieldDescription>,
37 pub(crate) indexes: Vec<EntityIndexDescription>,
38 pub(crate) relations: Vec<EntityRelationDescription>,
39}
40
41impl EntitySchemaDescription {
42 #[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(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 schema: &AcceptedSchemaSnapshot,
396) -> Vec<EntityFieldDescription> {
397 let snapshot = schema.persisted_snapshot();
398 let mut fields = Vec::with_capacity(snapshot.fields().len());
399
400 for field in snapshot.fields() {
403 let primary_key = field.id() == snapshot.primary_key_field_id();
404 let slot = snapshot
405 .row_layout()
406 .slot_for_field(field.id())
407 .map(SchemaFieldSlot::get);
408 let metadata = DescribeFieldMetadata::new(
409 summarize_persisted_field_kind(field.kind()),
410 field_type_from_persisted_kind(field.kind())
411 .value_kind()
412 .is_queryable(),
413 );
414
415 push_described_field_row(&mut fields, field.name(), slot, primary_key, None, metadata);
416
417 if !field.nested_leaves().is_empty() {
418 describe_persisted_nested_leaves(&mut fields, field.nested_leaves());
419 }
420 }
421
422 fields
423}
424
425fn describe_entity_fields_with_slot_lookup(
429 model: &EntityModel,
430 mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
431) -> Vec<EntityFieldDescription> {
432 let mut fields = Vec::with_capacity(model.fields.len());
433
434 for (slot, field) in model.fields.iter().enumerate() {
435 let primary_key = field.name == model.primary_key.name;
436 describe_field_recursive(
437 &mut fields,
438 field.name,
439 slot_for_field(slot, field),
440 field,
441 primary_key,
442 None,
443 None,
444 );
445 }
446
447 fields
448}
449
450struct DescribeFieldMetadata {
459 kind: String,
460 queryable: bool,
461}
462
463impl DescribeFieldMetadata {
464 const fn new(kind: String, queryable: bool) -> Self {
466 Self { kind, queryable }
467 }
468}
469
470fn describe_field_recursive(
473 fields: &mut Vec<EntityFieldDescription>,
474 name: &str,
475 slot: Option<u16>,
476 field: &FieldModel,
477 primary_key: bool,
478 tree_prefix: Option<&'static str>,
479 metadata_override: Option<DescribeFieldMetadata>,
480) {
481 let metadata = metadata_override.unwrap_or_else(|| {
482 DescribeFieldMetadata::new(
483 summarize_field_kind(&field.kind),
484 field.kind.value_kind().is_queryable(),
485 )
486 });
487
488 push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
489 describe_generated_nested_fields(fields, field.nested_fields());
490}
491
492fn push_described_field_row(
495 fields: &mut Vec<EntityFieldDescription>,
496 name: &str,
497 slot: Option<u16>,
498 primary_key: bool,
499 tree_prefix: Option<&'static str>,
500 metadata: DescribeFieldMetadata,
501) {
502 let display_name = if let Some(prefix) = tree_prefix {
505 format!("{prefix}{name}")
506 } else {
507 name.to_string()
508 };
509
510 fields.push(EntityFieldDescription::new(
511 display_name,
512 slot,
513 metadata.kind,
514 primary_key,
515 metadata.queryable,
516 ));
517}
518
519fn describe_generated_nested_fields(
523 fields: &mut Vec<EntityFieldDescription>,
524 nested_fields: &[FieldModel],
525) {
526 for (index, nested) in nested_fields.iter().enumerate() {
527 let prefix = if index + 1 == nested_fields.len() {
528 "└─ "
529 } else {
530 "├─ "
531 };
532 describe_field_recursive(
533 fields,
534 nested.name(),
535 None,
536 nested,
537 false,
538 Some(prefix),
539 None,
540 );
541 }
542}
543
544fn describe_persisted_nested_leaves(
547 fields: &mut Vec<EntityFieldDescription>,
548 nested_leaves: &[PersistedNestedLeafSnapshot],
549) {
550 for (index, leaf) in nested_leaves.iter().enumerate() {
551 let prefix = if index + 1 == nested_leaves.len() {
552 "└─ "
553 } else {
554 "├─ "
555 };
556 let name = leaf.path().last().map_or("", String::as_str);
557 let metadata = DescribeFieldMetadata::new(
558 summarize_persisted_field_kind(leaf.kind()),
559 field_type_from_persisted_kind(leaf.kind())
560 .value_kind()
561 .is_queryable(),
562 );
563
564 push_described_field_row(fields, name, None, false, Some(prefix), metadata);
565 }
566}
567
568fn relation_description_from_descriptor(
570 descriptor: RelationDescriptor<'_>,
571) -> EntityRelationDescription {
572 let strength = match descriptor.strength() {
573 RelationStrength::Strong => EntityRelationStrength::Strong,
574 RelationStrength::Weak => EntityRelationStrength::Weak,
575 };
576
577 let cardinality = match descriptor.cardinality() {
578 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
579 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
580 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
581 };
582
583 EntityRelationDescription::new(
584 descriptor.field_name().to_string(),
585 descriptor.target_path().to_string(),
586 descriptor.target_entity_name().to_string(),
587 descriptor.target_store_path().to_string(),
588 strength,
589 cardinality,
590 )
591}
592
593#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
594fn summarize_field_kind(kind: &FieldKind) -> String {
595 let mut out = String::new();
596 write_field_kind_summary(&mut out, kind);
597
598 out
599}
600
601fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
604 match kind {
605 FieldKind::Account => out.push_str("account"),
606 FieldKind::Blob { max_len } => {
607 write_length_bounded_field_kind_summary(out, "blob", *max_len);
608 }
609 FieldKind::Bool => out.push_str("bool"),
610 FieldKind::Date => out.push_str("date"),
611 FieldKind::Decimal { scale } => {
612 let _ = write!(out, "decimal(scale={scale})");
613 }
614 FieldKind::Duration => out.push_str("duration"),
615 FieldKind::Enum { path, .. } => {
616 out.push_str("enum(");
617 out.push_str(path);
618 out.push(')');
619 }
620 FieldKind::Float32 => out.push_str("float32"),
621 FieldKind::Float64 => out.push_str("float64"),
622 FieldKind::Int => out.push_str("int"),
623 FieldKind::Int128 => out.push_str("int128"),
624 FieldKind::IntBig => out.push_str("int_big"),
625 FieldKind::Principal => out.push_str("principal"),
626 FieldKind::Subaccount => out.push_str("subaccount"),
627 FieldKind::Text { max_len } => {
628 write_length_bounded_field_kind_summary(out, "text", *max_len);
629 }
630 FieldKind::Timestamp => out.push_str("timestamp"),
631 FieldKind::Uint => out.push_str("uint"),
632 FieldKind::Uint128 => out.push_str("uint128"),
633 FieldKind::UintBig => out.push_str("uint_big"),
634 FieldKind::Ulid => out.push_str("ulid"),
635 FieldKind::Unit => out.push_str("unit"),
636 FieldKind::Relation {
637 target_entity_name,
638 key_kind,
639 strength,
640 ..
641 } => {
642 out.push_str("relation(target=");
643 out.push_str(target_entity_name);
644 out.push_str(", key=");
645 write_field_kind_summary(out, key_kind);
646 out.push_str(", strength=");
647 out.push_str(summarize_relation_strength(*strength));
648 out.push(')');
649 }
650 FieldKind::List(inner) => {
651 out.push_str("list<");
652 write_field_kind_summary(out, inner);
653 out.push('>');
654 }
655 FieldKind::Set(inner) => {
656 out.push_str("set<");
657 write_field_kind_summary(out, inner);
658 out.push('>');
659 }
660 FieldKind::Map { key, value } => {
661 out.push_str("map<");
662 write_field_kind_summary(out, key);
663 out.push_str(", ");
664 write_field_kind_summary(out, value);
665 out.push('>');
666 }
667 FieldKind::Structured { .. } => {
668 out.push_str("structured");
669 }
670 }
671}
672
673fn write_length_bounded_field_kind_summary(
677 out: &mut String,
678 kind_name: &str,
679 max_len: Option<u32>,
680) {
681 out.push_str(kind_name);
682 if let Some(max_len) = max_len {
683 out.push_str("(max_len=");
684 out.push_str(&max_len.to_string());
685 out.push(')');
686 } else {
687 out.push_str("(unbounded)");
688 }
689}
690
691#[cfg_attr(
692 doc,
693 doc = "Render one stable field-kind label from accepted persisted schema metadata."
694)]
695fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
696 let mut out = String::new();
697 write_persisted_field_kind_summary(&mut out, kind);
698
699 out
700}
701
702fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
706 match kind {
707 PersistedFieldKind::Account => out.push_str("account"),
708 PersistedFieldKind::Blob { max_len } => {
709 write_length_bounded_field_kind_summary(out, "blob", *max_len);
710 }
711 PersistedFieldKind::Bool => out.push_str("bool"),
712 PersistedFieldKind::Date => out.push_str("date"),
713 PersistedFieldKind::Decimal { scale } => {
714 let _ = write!(out, "decimal(scale={scale})");
715 }
716 PersistedFieldKind::Duration => out.push_str("duration"),
717 PersistedFieldKind::Enum { path, .. } => {
718 out.push_str("enum(");
719 out.push_str(path);
720 out.push(')');
721 }
722 PersistedFieldKind::Float32 => out.push_str("float32"),
723 PersistedFieldKind::Float64 => out.push_str("float64"),
724 PersistedFieldKind::Int => out.push_str("int"),
725 PersistedFieldKind::Int128 => out.push_str("int128"),
726 PersistedFieldKind::IntBig => out.push_str("int_big"),
727 PersistedFieldKind::Principal => out.push_str("principal"),
728 PersistedFieldKind::Subaccount => out.push_str("subaccount"),
729 PersistedFieldKind::Text { max_len } => {
730 write_length_bounded_field_kind_summary(out, "text", *max_len);
731 }
732 PersistedFieldKind::Timestamp => out.push_str("timestamp"),
733 PersistedFieldKind::Uint => out.push_str("uint"),
734 PersistedFieldKind::Uint128 => out.push_str("uint128"),
735 PersistedFieldKind::UintBig => out.push_str("uint_big"),
736 PersistedFieldKind::Ulid => out.push_str("ulid"),
737 PersistedFieldKind::Unit => out.push_str("unit"),
738 PersistedFieldKind::Relation {
739 target_entity_name,
740 key_kind,
741 strength,
742 ..
743 } => {
744 out.push_str("relation(target=");
745 out.push_str(target_entity_name);
746 out.push_str(", key=");
747 write_persisted_field_kind_summary(out, key_kind);
748 out.push_str(", strength=");
749 out.push_str(summarize_persisted_relation_strength(*strength));
750 out.push(')');
751 }
752 PersistedFieldKind::List(inner) => {
753 out.push_str("list<");
754 write_persisted_field_kind_summary(out, inner);
755 out.push('>');
756 }
757 PersistedFieldKind::Set(inner) => {
758 out.push_str("set<");
759 write_persisted_field_kind_summary(out, inner);
760 out.push('>');
761 }
762 PersistedFieldKind::Map { key, value } => {
763 out.push_str("map<");
764 write_persisted_field_kind_summary(out, key);
765 out.push_str(", ");
766 write_persisted_field_kind_summary(out, value);
767 out.push('>');
768 }
769 PersistedFieldKind::Structured { .. } => {
770 out.push_str("structured");
771 }
772 }
773}
774
775#[cfg_attr(
776 doc,
777 doc = "Render one stable relation-strength label from persisted schema metadata."
778)]
779const fn summarize_persisted_relation_strength(
780 strength: PersistedRelationStrength,
781) -> &'static str {
782 match strength {
783 PersistedRelationStrength::Strong => "strong",
784 PersistedRelationStrength::Weak => "weak",
785 }
786}
787
788#[cfg_attr(
789 doc,
790 doc = "Render one stable relation-strength label for field-kind summaries."
791)]
792const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
793 match strength {
794 RelationStrength::Strong => "strong",
795 RelationStrength::Weak => "weak",
796 }
797}
798
799#[cfg(test)]
804mod tests {
805 use crate::{
806 db::{
807 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
808 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
809 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
810 schema::{
811 AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
812 PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
813 SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
814 describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
815 },
816 },
817 model::{
818 entity::EntityModel,
819 field::{
820 FieldKind, FieldModel, FieldStorageDecode, LeafCodec, RelationStrength, ScalarCodec,
821 },
822 },
823 types::EntityTag,
824 };
825 use candid::types::{CandidType, Label, Type, TypeInner};
826
827 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
828 target_path: "entities::Target",
829 target_entity_name: "Target",
830 target_entity_tag: EntityTag::new(0xD001),
831 target_store_path: "stores::Target",
832 key_kind: &FieldKind::Ulid,
833 strength: RelationStrength::Strong,
834 };
835 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
836 target_path: "entities::Account",
837 target_entity_name: "Account",
838 target_entity_tag: EntityTag::new(0xD002),
839 target_store_path: "stores::Account",
840 key_kind: &FieldKind::Uint,
841 strength: RelationStrength::Weak,
842 };
843 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
844 target_path: "entities::Team",
845 target_entity_name: "Team",
846 target_entity_tag: EntityTag::new(0xD003),
847 target_store_path: "stores::Team",
848 key_kind: &FieldKind::Text { max_len: None },
849 strength: RelationStrength::Strong,
850 };
851 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
852 FieldModel::generated("id", FieldKind::Ulid),
853 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
854 FieldModel::generated(
855 "accounts",
856 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
857 ),
858 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
859 ];
860 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
861 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
862 "entities::Source",
863 "Source",
864 &DESCRIBE_RELATION_FIELDS[0],
865 0,
866 &DESCRIBE_RELATION_FIELDS,
867 &DESCRIBE_RELATION_INDEXES,
868 );
869
870 fn expect_record_fields(ty: Type) -> Vec<String> {
871 match ty.as_ref() {
872 TypeInner::Record(fields) => fields
873 .iter()
874 .map(|field| match field.id.as_ref() {
875 Label::Named(name) => name.clone(),
876 other => panic!("expected named record field, got {other:?}"),
877 })
878 .collect(),
879 other => panic!("expected candid record, got {other:?}"),
880 }
881 }
882
883 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
884 match ty.as_ref() {
885 TypeInner::Record(fields) => fields
886 .iter()
887 .find_map(|field| match field.id.as_ref() {
888 Label::Named(name) if name == field_name => Some(field.ty.clone()),
889 _ => None,
890 })
891 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
892 other => panic!("expected candid record, got {other:?}"),
893 }
894 }
895
896 fn expect_variant_labels(ty: Type) -> Vec<String> {
897 match ty.as_ref() {
898 TypeInner::Variant(fields) => fields
899 .iter()
900 .map(|field| match field.id.as_ref() {
901 Label::Named(name) => name.clone(),
902 other => panic!("expected named variant label, got {other:?}"),
903 })
904 .collect(),
905 other => panic!("expected candid variant, got {other:?}"),
906 }
907 }
908
909 #[test]
910 fn entity_schema_description_candid_shape_is_stable() {
911 let fields = expect_record_fields(EntitySchemaDescription::ty());
912
913 for field in [
914 "entity_path",
915 "entity_name",
916 "primary_key",
917 "fields",
918 "indexes",
919 "relations",
920 ] {
921 assert!(
922 fields.iter().any(|candidate| candidate == field),
923 "EntitySchemaDescription must keep `{field}` field key",
924 );
925 }
926 }
927
928 #[test]
929 fn entity_field_description_candid_shape_is_stable() {
930 let fields = expect_record_fields(EntityFieldDescription::ty());
931
932 for field in ["name", "slot", "kind", "primary_key", "queryable"] {
933 assert!(
934 fields.iter().any(|candidate| candidate == field),
935 "EntityFieldDescription must keep `{field}` field key",
936 );
937 }
938
939 assert!(
940 matches!(
941 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
942 TypeInner::Nat16
943 ),
944 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
945 );
946 }
947
948 #[test]
949 fn entity_index_description_candid_shape_is_stable() {
950 let fields = expect_record_fields(EntityIndexDescription::ty());
951
952 for field in ["name", "unique", "fields"] {
953 assert!(
954 fields.iter().any(|candidate| candidate == field),
955 "EntityIndexDescription must keep `{field}` field key",
956 );
957 }
958 }
959
960 #[test]
961 fn entity_relation_description_candid_shape_is_stable() {
962 let fields = expect_record_fields(EntityRelationDescription::ty());
963
964 for field in [
965 "field",
966 "target_path",
967 "target_entity_name",
968 "target_store_path",
969 "strength",
970 "cardinality",
971 ] {
972 assert!(
973 fields.iter().any(|candidate| candidate == field),
974 "EntityRelationDescription must keep `{field}` field key",
975 );
976 }
977 }
978
979 #[test]
980 fn relation_enum_variant_labels_are_stable() {
981 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
982 strength_labels.sort_unstable();
983 assert_eq!(
984 strength_labels,
985 vec!["Strong".to_string(), "Weak".to_string()]
986 );
987
988 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
989 cardinality_labels.sort_unstable();
990 assert_eq!(
991 cardinality_labels,
992 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
993 );
994 }
995
996 #[test]
997 fn describe_fixture_constructors_stay_usable() {
998 let payload = EntitySchemaDescription::new(
999 "entities::User".to_string(),
1000 "User".to_string(),
1001 "id".to_string(),
1002 vec![EntityFieldDescription::new(
1003 "id".to_string(),
1004 Some(0),
1005 "ulid".to_string(),
1006 true,
1007 true,
1008 )],
1009 vec![EntityIndexDescription::new(
1010 "idx_email".to_string(),
1011 true,
1012 vec!["email".to_string()],
1013 )],
1014 vec![EntityRelationDescription::new(
1015 "account_id".to_string(),
1016 "entities::Account".to_string(),
1017 "Account".to_string(),
1018 "accounts".to_string(),
1019 EntityRelationStrength::Strong,
1020 EntityRelationCardinality::Single,
1021 )],
1022 );
1023
1024 assert_eq!(payload.entity_name(), "User");
1025 assert_eq!(payload.fields().len(), 1);
1026 assert_eq!(payload.indexes().len(), 1);
1027 assert_eq!(payload.relations().len(), 1);
1028 }
1029
1030 #[test]
1031 fn schema_describe_relations_match_relation_descriptors() {
1032 let descriptors =
1033 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1034 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1035 let relations = described.relations();
1036
1037 assert_eq!(descriptors.len(), relations.len());
1038
1039 for (descriptor, relation) in descriptors.iter().zip(relations) {
1040 assert_eq!(relation.field(), descriptor.field_name());
1041 assert_eq!(relation.target_path(), descriptor.target_path());
1042 assert_eq!(
1043 relation.target_entity_name(),
1044 descriptor.target_entity_name()
1045 );
1046 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1047 assert_eq!(
1048 relation.strength(),
1049 match descriptor.strength() {
1050 RelationStrength::Strong => EntityRelationStrength::Strong,
1051 RelationStrength::Weak => EntityRelationStrength::Weak,
1052 }
1053 );
1054 assert_eq!(
1055 relation.cardinality(),
1056 match descriptor.cardinality() {
1057 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1058 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1059 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1060 }
1061 );
1062 }
1063 }
1064
1065 #[test]
1066 fn schema_describe_includes_text_max_len_contract() {
1067 static FIELDS: [FieldModel; 2] = [
1068 FieldModel::generated("id", FieldKind::Ulid),
1069 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1070 ];
1071 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1072 static MODEL: EntityModel = EntityModel::generated(
1073 "entities::BoundedName",
1074 "BoundedName",
1075 &FIELDS[0],
1076 0,
1077 &FIELDS,
1078 &INDEXES,
1079 );
1080
1081 let described = describe_entity_model(&MODEL);
1082 let name_field = described
1083 .fields()
1084 .iter()
1085 .find(|field| field.name() == "name")
1086 .expect("bounded text field should be described");
1087
1088 assert_eq!(name_field.kind(), "text(max_len=16)");
1089 }
1090
1091 #[test]
1092 fn schema_describe_uses_accepted_top_level_field_metadata() {
1093 let id_slot = SchemaFieldSlot::new(0);
1094 let payload_slot = SchemaFieldSlot::new(7);
1095 let stale_payload_field_slot = SchemaFieldSlot::new(3);
1098 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1099 SchemaVersion::initial(),
1100 "entities::BlobEvent".to_string(),
1101 "BlobEvent".to_string(),
1102 FieldId::new(1),
1103 SchemaRowLayout::new(
1104 SchemaVersion::initial(),
1105 vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1106 ),
1107 vec![
1108 PersistedFieldSnapshot::new(
1109 FieldId::new(1),
1110 "id".to_string(),
1111 id_slot,
1112 PersistedFieldKind::Ulid,
1113 Vec::new(),
1114 false,
1115 SchemaFieldDefault::None,
1116 FieldStorageDecode::ByKind,
1117 LeafCodec::StructuralFallback,
1118 ),
1119 PersistedFieldSnapshot::new(
1120 FieldId::new(2),
1121 "payload".to_string(),
1122 stale_payload_field_slot,
1123 PersistedFieldKind::Blob { max_len: None },
1124 Vec::new(),
1125 false,
1126 SchemaFieldDefault::None,
1127 FieldStorageDecode::ByKind,
1128 LeafCodec::StructuralFallback,
1129 ),
1130 ],
1131 ));
1132
1133 let described = describe_entity_fields_with_persisted_schema(&snapshot)
1134 .into_iter()
1135 .map(|field| {
1136 (
1137 field.name().to_string(),
1138 field.slot(),
1139 field.kind().to_string(),
1140 )
1141 })
1142 .collect::<Vec<_>>();
1143
1144 assert_eq!(
1145 described,
1146 vec![
1147 ("id".to_string(), Some(0), "ulid".to_string()),
1148 (
1149 "payload".to_string(),
1150 Some(7),
1151 "blob(unbounded)".to_string()
1152 ),
1153 ],
1154 );
1155 }
1156
1157 #[test]
1158 fn schema_describe_uses_accepted_nested_leaf_metadata() {
1159 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1160 SchemaVersion::initial(),
1161 "entities::AcceptedProfile".to_string(),
1162 "AcceptedProfile".to_string(),
1163 FieldId::new(1),
1164 SchemaRowLayout::new(
1165 SchemaVersion::initial(),
1166 vec![
1167 (FieldId::new(1), SchemaFieldSlot::new(0)),
1168 (FieldId::new(2), SchemaFieldSlot::new(1)),
1169 ],
1170 ),
1171 vec![
1172 PersistedFieldSnapshot::new(
1173 FieldId::new(1),
1174 "id".to_string(),
1175 SchemaFieldSlot::new(0),
1176 PersistedFieldKind::Ulid,
1177 Vec::new(),
1178 false,
1179 SchemaFieldDefault::None,
1180 FieldStorageDecode::ByKind,
1181 LeafCodec::StructuralFallback,
1182 ),
1183 PersistedFieldSnapshot::new(
1184 FieldId::new(2),
1185 "profile".to_string(),
1186 SchemaFieldSlot::new(1),
1187 PersistedFieldKind::Structured { queryable: true },
1188 vec![PersistedNestedLeafSnapshot::new(
1189 vec!["rank".to_string()],
1190 PersistedFieldKind::Blob { max_len: None },
1191 false,
1192 FieldStorageDecode::ByKind,
1193 LeafCodec::Scalar(ScalarCodec::Blob),
1194 )],
1195 false,
1196 SchemaFieldDefault::None,
1197 FieldStorageDecode::Value,
1198 LeafCodec::StructuralFallback,
1199 ),
1200 ],
1201 ));
1202
1203 let described = describe_entity_fields_with_persisted_schema(&snapshot);
1204 let rank = described
1205 .iter()
1206 .find(|field| field.name() == "└─ rank")
1207 .expect("accepted nested leaf should be described");
1208
1209 assert_eq!(rank.slot(), None);
1210 assert_eq!(rank.kind(), "blob(unbounded)");
1211 assert!(rank.queryable());
1212 }
1213
1214 #[test]
1215 fn schema_describe_expands_generated_structured_field_leaves() {
1216 static NESTED_FIELDS: [FieldModel; 3] = [
1217 FieldModel::generated("name", FieldKind::Text { max_len: None }),
1218 FieldModel::generated("level", FieldKind::Uint),
1219 FieldModel::generated("pid", FieldKind::Principal),
1220 ];
1221 static FIELDS: [FieldModel; 2] = [
1222 FieldModel::generated("id", FieldKind::Ulid),
1223 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1224 "mentor",
1225 FieldKind::Structured { queryable: false },
1226 FieldStorageDecode::Value,
1227 false,
1228 None,
1229 None,
1230 &NESTED_FIELDS,
1231 ),
1232 ];
1233 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1234 static MODEL: EntityModel = EntityModel::generated(
1235 "entities::Character",
1236 "Character",
1237 &FIELDS[0],
1238 0,
1239 &FIELDS,
1240 &INDEXES,
1241 );
1242
1243 let described = describe_entity_model(&MODEL);
1244 let described_fields = described
1245 .fields()
1246 .iter()
1247 .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1248 .collect::<Vec<_>>();
1249
1250 assert_eq!(
1251 described_fields,
1252 vec![
1253 ("id", Some(0), "ulid", true),
1254 ("mentor", Some(1), "structured", false),
1255 ("├─ name", None, "text(unbounded)", true),
1256 ("├─ level", None, "uint", true),
1257 ("└─ pid", None, "principal", true),
1258 ],
1259 );
1260 }
1261}