1use crate::{
7 db::{
8 relation::{
9 RelationDescriptor, RelationDescriptorCardinality, relation_descriptors_for_model_iter,
10 },
11 schema::{
12 AcceptedSchemaSnapshot, PersistedFieldKind, PersistedFieldSnapshot,
13 PersistedNestedLeafSnapshot, PersistedRelationStrength, SchemaFieldSlot,
14 field_type_from_persisted_kind,
15 },
16 },
17 model::{
18 entity::EntityModel,
19 field::{FieldKind, FieldModel, RelationStrength},
20 },
21};
22use candid::CandidType;
23use serde::Deserialize;
24use std::fmt::Write;
25
26const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
27
28#[cfg_attr(
29 doc,
30 doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
31)]
32#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
33pub struct EntitySchemaDescription {
34 pub(crate) entity_path: String,
35 pub(crate) entity_name: String,
36 pub(crate) primary_key: String,
37 pub(crate) fields: Vec<EntityFieldDescription>,
38 pub(crate) indexes: Vec<EntityIndexDescription>,
39 pub(crate) relations: Vec<EntityRelationDescription>,
40}
41
42impl EntitySchemaDescription {
43 #[must_use]
45 pub const fn new(
46 entity_path: String,
47 entity_name: String,
48 primary_key: String,
49 fields: Vec<EntityFieldDescription>,
50 indexes: Vec<EntityIndexDescription>,
51 relations: Vec<EntityRelationDescription>,
52 ) -> Self {
53 Self {
54 entity_path,
55 entity_name,
56 primary_key,
57 fields,
58 indexes,
59 relations,
60 }
61 }
62
63 #[must_use]
65 pub const fn entity_path(&self) -> &str {
66 self.entity_path.as_str()
67 }
68
69 #[must_use]
71 pub const fn entity_name(&self) -> &str {
72 self.entity_name.as_str()
73 }
74
75 #[must_use]
77 pub const fn primary_key(&self) -> &str {
78 self.primary_key.as_str()
79 }
80
81 #[must_use]
83 pub const fn fields(&self) -> &[EntityFieldDescription] {
84 self.fields.as_slice()
85 }
86
87 #[must_use]
89 pub const fn indexes(&self) -> &[EntityIndexDescription] {
90 self.indexes.as_slice()
91 }
92
93 #[must_use]
95 pub const fn relations(&self) -> &[EntityRelationDescription] {
96 self.relations.as_slice()
97 }
98}
99
100#[cfg_attr(
101 doc,
102 doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
103)]
104#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
105pub struct EntityFieldDescription {
106 pub(crate) name: String,
107 pub(crate) slot: u16,
108 pub(crate) kind: String,
109 pub(crate) primary_key: bool,
110 pub(crate) queryable: bool,
111}
112
113impl EntityFieldDescription {
114 #[must_use]
116 pub const fn new(
117 name: String,
118 slot: Option<u16>,
119 kind: String,
120 primary_key: bool,
121 queryable: bool,
122 ) -> Self {
123 let slot = match slot {
124 Some(slot) => slot,
125 None => ENTITY_FIELD_DESCRIPTION_NO_SLOT,
126 };
127
128 Self {
129 name,
130 slot,
131 kind,
132 primary_key,
133 queryable,
134 }
135 }
136
137 #[must_use]
139 pub const fn name(&self) -> &str {
140 self.name.as_str()
141 }
142
143 #[must_use]
145 pub const fn slot(&self) -> Option<u16> {
146 if self.slot == ENTITY_FIELD_DESCRIPTION_NO_SLOT {
147 None
148 } else {
149 Some(self.slot)
150 }
151 }
152
153 #[must_use]
155 pub const fn kind(&self) -> &str {
156 self.kind.as_str()
157 }
158
159 #[must_use]
161 pub const fn primary_key(&self) -> bool {
162 self.primary_key
163 }
164
165 #[must_use]
167 pub const fn queryable(&self) -> bool {
168 self.queryable
169 }
170}
171
172#[cfg_attr(
173 doc,
174 doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
175)]
176#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
177pub struct EntityIndexDescription {
178 pub(crate) name: String,
179 pub(crate) unique: bool,
180 pub(crate) fields: Vec<String>,
181}
182
183impl EntityIndexDescription {
184 #[must_use]
186 pub const fn new(name: String, unique: bool, fields: Vec<String>) -> Self {
187 Self {
188 name,
189 unique,
190 fields,
191 }
192 }
193
194 #[must_use]
196 pub const fn name(&self) -> &str {
197 self.name.as_str()
198 }
199
200 #[must_use]
202 pub const fn unique(&self) -> bool {
203 self.unique
204 }
205
206 #[must_use]
208 pub const fn fields(&self) -> &[String] {
209 self.fields.as_slice()
210 }
211}
212
213#[cfg_attr(
214 doc,
215 doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
216)]
217#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
218pub struct EntityRelationDescription {
219 pub(crate) field: String,
220 pub(crate) target_path: String,
221 pub(crate) target_entity_name: String,
222 pub(crate) target_store_path: String,
223 pub(crate) strength: EntityRelationStrength,
224 pub(crate) cardinality: EntityRelationCardinality,
225}
226
227impl EntityRelationDescription {
228 #[must_use]
230 pub const fn new(
231 field: String,
232 target_path: String,
233 target_entity_name: String,
234 target_store_path: String,
235 strength: EntityRelationStrength,
236 cardinality: EntityRelationCardinality,
237 ) -> Self {
238 Self {
239 field,
240 target_path,
241 target_entity_name,
242 target_store_path,
243 strength,
244 cardinality,
245 }
246 }
247
248 #[must_use]
250 pub const fn field(&self) -> &str {
251 self.field.as_str()
252 }
253
254 #[must_use]
256 pub const fn target_path(&self) -> &str {
257 self.target_path.as_str()
258 }
259
260 #[must_use]
262 pub const fn target_entity_name(&self) -> &str {
263 self.target_entity_name.as_str()
264 }
265
266 #[must_use]
268 pub const fn target_store_path(&self) -> &str {
269 self.target_store_path.as_str()
270 }
271
272 #[must_use]
274 pub const fn strength(&self) -> EntityRelationStrength {
275 self.strength
276 }
277
278 #[must_use]
280 pub const fn cardinality(&self) -> EntityRelationCardinality {
281 self.cardinality
282 }
283}
284
285#[cfg_attr(doc, doc = "EntityRelationStrength\n\nDescribe relation strength.")]
286#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
287pub enum EntityRelationStrength {
288 Strong,
289 Weak,
290}
291
292#[cfg_attr(
293 doc,
294 doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
295)]
296#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
297pub enum EntityRelationCardinality {
298 Single,
299 List,
300 Set,
301}
302
303#[cfg_attr(
304 doc,
305 doc = "Build one stable entity-schema description from one runtime `EntityModel`."
306)]
307#[must_use]
308pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
309 let fields = describe_entity_fields(model);
310
311 describe_entity_model_with_parts(
312 model.path,
313 model.entity_name,
314 model.primary_key.name,
315 fields,
316 model,
317 )
318}
319
320#[cfg_attr(
321 doc,
322 doc = "Build one entity-schema description using accepted persisted schema slot metadata."
323)]
324#[must_use]
325pub(in crate::db) fn describe_entity_model_with_persisted_schema(
326 model: &EntityModel,
327 schema: &AcceptedSchemaSnapshot,
328) -> EntitySchemaDescription {
329 let fields = describe_entity_fields_with_persisted_schema(model, schema);
330 let primary_key = schema
331 .primary_key_field_name()
332 .unwrap_or(model.primary_key.name);
333
334 describe_entity_model_with_parts(
335 schema.entity_path(),
336 schema.entity_name(),
337 primary_key,
338 fields,
339 model,
340 )
341}
342
343fn describe_entity_model_with_parts(
347 entity_path: &str,
348 entity_name: &str,
349 primary_key: &str,
350 fields: Vec<EntityFieldDescription>,
351 model: &EntityModel,
352) -> EntitySchemaDescription {
353 let relations = relation_descriptors_for_model_iter(model)
354 .map(relation_description_from_descriptor)
355 .collect();
356
357 let mut indexes = Vec::with_capacity(model.indexes.len());
358 for index in model.indexes {
359 indexes.push(EntityIndexDescription::new(
360 index.name().to_string(),
361 index.is_unique(),
362 index
363 .fields()
364 .iter()
365 .map(|field| (*field).to_string())
366 .collect(),
367 ));
368 }
369
370 EntitySchemaDescription::new(
371 entity_path.to_string(),
372 entity_name.to_string(),
373 primary_key.to_string(),
374 fields,
375 indexes,
376 relations,
377 )
378}
379
380#[must_use]
384pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
385 describe_entity_fields_with_slot_lookup(model, |slot, _field| {
386 Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
387 })
388}
389
390#[cfg_attr(
391 doc,
392 doc = "Build field descriptors using accepted persisted schema slot metadata."
393)]
394#[must_use]
395pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
396 model: &EntityModel,
397 schema: &AcceptedSchemaSnapshot,
398) -> Vec<EntityFieldDescription> {
399 let mut fields = Vec::with_capacity(model.fields.len());
400
401 for field in model.fields {
402 let primary_key = field.name == model.primary_key.name;
403 let accepted_field = schema.field_by_name(field.name());
404 let slot = accepted_field
405 .map(PersistedFieldSnapshot::slot)
406 .map(SchemaFieldSlot::get);
407 let metadata = accepted_field.map(|field| {
408 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
416 push_described_field_row(
417 &mut fields,
418 field.name,
419 slot,
420 primary_key,
421 None,
422 metadata.unwrap_or_else(|| {
423 DescribeFieldMetadata::new(
424 summarize_field_kind(&field.kind),
425 field.kind.value_kind().is_queryable(),
426 )
427 }),
428 );
429
430 if let Some(accepted_field) = accepted_field
431 && !accepted_field.nested_leaves().is_empty()
432 {
433 describe_persisted_nested_leaves(&mut fields, accepted_field.nested_leaves());
434 } else {
435 describe_generated_nested_fields(&mut fields, field.nested_fields());
436 }
437 }
438
439 fields
440}
441
442fn describe_entity_fields_with_slot_lookup(
446 model: &EntityModel,
447 mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
448) -> Vec<EntityFieldDescription> {
449 let mut fields = Vec::with_capacity(model.fields.len());
450
451 for (slot, field) in model.fields.iter().enumerate() {
452 let primary_key = field.name == model.primary_key.name;
453 describe_field_recursive(
454 &mut fields,
455 field.name,
456 slot_for_field(slot, field),
457 field,
458 primary_key,
459 None,
460 None,
461 );
462 }
463
464 fields
465}
466
467struct DescribeFieldMetadata {
476 kind: String,
477 queryable: bool,
478}
479
480impl DescribeFieldMetadata {
481 const fn new(kind: String, queryable: bool) -> Self {
483 Self { kind, queryable }
484 }
485}
486
487fn describe_field_recursive(
490 fields: &mut Vec<EntityFieldDescription>,
491 name: &str,
492 slot: Option<u16>,
493 field: &FieldModel,
494 primary_key: bool,
495 tree_prefix: Option<&'static str>,
496 metadata_override: Option<DescribeFieldMetadata>,
497) {
498 let metadata = metadata_override.unwrap_or_else(|| {
499 DescribeFieldMetadata::new(
500 summarize_field_kind(&field.kind),
501 field.kind.value_kind().is_queryable(),
502 )
503 });
504
505 push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
506 describe_generated_nested_fields(fields, field.nested_fields());
507}
508
509fn push_described_field_row(
512 fields: &mut Vec<EntityFieldDescription>,
513 name: &str,
514 slot: Option<u16>,
515 primary_key: bool,
516 tree_prefix: Option<&'static str>,
517 metadata: DescribeFieldMetadata,
518) {
519 let display_name = if let Some(prefix) = tree_prefix {
522 format!("{prefix}{name}")
523 } else {
524 name.to_string()
525 };
526
527 fields.push(EntityFieldDescription::new(
528 display_name,
529 slot,
530 metadata.kind,
531 primary_key,
532 metadata.queryable,
533 ));
534}
535
536fn describe_generated_nested_fields(
540 fields: &mut Vec<EntityFieldDescription>,
541 nested_fields: &[FieldModel],
542) {
543 for (index, nested) in nested_fields.iter().enumerate() {
544 let prefix = if index + 1 == nested_fields.len() {
545 "└─ "
546 } else {
547 "├─ "
548 };
549 describe_field_recursive(
550 fields,
551 nested.name(),
552 None,
553 nested,
554 false,
555 Some(prefix),
556 None,
557 );
558 }
559}
560
561fn describe_persisted_nested_leaves(
564 fields: &mut Vec<EntityFieldDescription>,
565 nested_leaves: &[PersistedNestedLeafSnapshot],
566) {
567 for (index, leaf) in nested_leaves.iter().enumerate() {
568 let prefix = if index + 1 == nested_leaves.len() {
569 "└─ "
570 } else {
571 "├─ "
572 };
573 let name = leaf.path().last().map_or("", String::as_str);
574 let metadata = DescribeFieldMetadata::new(
575 summarize_persisted_field_kind(leaf.kind()),
576 field_type_from_persisted_kind(leaf.kind())
577 .value_kind()
578 .is_queryable(),
579 );
580
581 push_described_field_row(fields, name, None, false, Some(prefix), metadata);
582 }
583}
584
585fn relation_description_from_descriptor(
587 descriptor: RelationDescriptor<'_>,
588) -> EntityRelationDescription {
589 let strength = match descriptor.strength() {
590 RelationStrength::Strong => EntityRelationStrength::Strong,
591 RelationStrength::Weak => EntityRelationStrength::Weak,
592 };
593
594 let cardinality = match descriptor.cardinality() {
595 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
596 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
597 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
598 };
599
600 EntityRelationDescription::new(
601 descriptor.field_name().to_string(),
602 descriptor.target_path().to_string(),
603 descriptor.target_entity_name().to_string(),
604 descriptor.target_store_path().to_string(),
605 strength,
606 cardinality,
607 )
608}
609
610#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
611fn summarize_field_kind(kind: &FieldKind) -> String {
612 let mut out = String::new();
613 write_field_kind_summary(&mut out, kind);
614
615 out
616}
617
618fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
621 match kind {
622 FieldKind::Account => out.push_str("account"),
623 FieldKind::Blob => out.push_str("blob"),
624 FieldKind::Bool => out.push_str("bool"),
625 FieldKind::Date => out.push_str("date"),
626 FieldKind::Decimal { scale } => {
627 let _ = write!(out, "decimal(scale={scale})");
628 }
629 FieldKind::Duration => out.push_str("duration"),
630 FieldKind::Enum { path, .. } => {
631 out.push_str("enum(");
632 out.push_str(path);
633 out.push(')');
634 }
635 FieldKind::Float32 => out.push_str("float32"),
636 FieldKind::Float64 => out.push_str("float64"),
637 FieldKind::Int => out.push_str("int"),
638 FieldKind::Int128 => out.push_str("int128"),
639 FieldKind::IntBig => out.push_str("int_big"),
640 FieldKind::Principal => out.push_str("principal"),
641 FieldKind::Subaccount => out.push_str("subaccount"),
642 FieldKind::Text { max_len } => match max_len {
643 Some(max_len) => {
644 let _ = write!(out, "text(max_len={max_len})");
645 }
646 None => out.push_str("text"),
647 },
648 FieldKind::Timestamp => out.push_str("timestamp"),
649 FieldKind::Uint => out.push_str("uint"),
650 FieldKind::Uint128 => out.push_str("uint128"),
651 FieldKind::UintBig => out.push_str("uint_big"),
652 FieldKind::Ulid => out.push_str("ulid"),
653 FieldKind::Unit => out.push_str("unit"),
654 FieldKind::Relation {
655 target_entity_name,
656 key_kind,
657 strength,
658 ..
659 } => {
660 out.push_str("relation(target=");
661 out.push_str(target_entity_name);
662 out.push_str(", key=");
663 write_field_kind_summary(out, key_kind);
664 out.push_str(", strength=");
665 out.push_str(summarize_relation_strength(*strength));
666 out.push(')');
667 }
668 FieldKind::List(inner) => {
669 out.push_str("list<");
670 write_field_kind_summary(out, inner);
671 out.push('>');
672 }
673 FieldKind::Set(inner) => {
674 out.push_str("set<");
675 write_field_kind_summary(out, inner);
676 out.push('>');
677 }
678 FieldKind::Map { key, value } => {
679 out.push_str("map<");
680 write_field_kind_summary(out, key);
681 out.push_str(", ");
682 write_field_kind_summary(out, value);
683 out.push('>');
684 }
685 FieldKind::Structured { .. } => {
686 out.push_str("structured");
687 }
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 => out.push_str("blob"),
709 PersistedFieldKind::Bool => out.push_str("bool"),
710 PersistedFieldKind::Date => out.push_str("date"),
711 PersistedFieldKind::Decimal { scale } => {
712 let _ = write!(out, "decimal(scale={scale})");
713 }
714 PersistedFieldKind::Duration => out.push_str("duration"),
715 PersistedFieldKind::Enum { path, .. } => {
716 out.push_str("enum(");
717 out.push_str(path);
718 out.push(')');
719 }
720 PersistedFieldKind::Float32 => out.push_str("float32"),
721 PersistedFieldKind::Float64 => out.push_str("float64"),
722 PersistedFieldKind::Int => out.push_str("int"),
723 PersistedFieldKind::Int128 => out.push_str("int128"),
724 PersistedFieldKind::IntBig => out.push_str("int_big"),
725 PersistedFieldKind::Principal => out.push_str("principal"),
726 PersistedFieldKind::Subaccount => out.push_str("subaccount"),
727 PersistedFieldKind::Text { max_len } => match max_len {
728 Some(max_len) => {
729 let _ = write!(out, "text(max_len={max_len})");
730 }
731 None => out.push_str("text"),
732 },
733 PersistedFieldKind::Timestamp => out.push_str("timestamp"),
734 PersistedFieldKind::Uint => out.push_str("uint"),
735 PersistedFieldKind::Uint128 => out.push_str("uint128"),
736 PersistedFieldKind::UintBig => out.push_str("uint_big"),
737 PersistedFieldKind::Ulid => out.push_str("ulid"),
738 PersistedFieldKind::Unit => out.push_str("unit"),
739 PersistedFieldKind::Relation {
740 target_entity_name,
741 key_kind,
742 strength,
743 ..
744 } => {
745 out.push_str("relation(target=");
746 out.push_str(target_entity_name);
747 out.push_str(", key=");
748 write_persisted_field_kind_summary(out, key_kind);
749 out.push_str(", strength=");
750 out.push_str(summarize_persisted_relation_strength(*strength));
751 out.push(')');
752 }
753 PersistedFieldKind::List(inner) => {
754 out.push_str("list<");
755 write_persisted_field_kind_summary(out, inner);
756 out.push('>');
757 }
758 PersistedFieldKind::Set(inner) => {
759 out.push_str("set<");
760 write_persisted_field_kind_summary(out, inner);
761 out.push('>');
762 }
763 PersistedFieldKind::Map { key, value } => {
764 out.push_str("map<");
765 write_persisted_field_kind_summary(out, key);
766 out.push_str(", ");
767 write_persisted_field_kind_summary(out, value);
768 out.push('>');
769 }
770 PersistedFieldKind::Structured { .. } => {
771 out.push_str("structured");
772 }
773 }
774}
775
776#[cfg_attr(
777 doc,
778 doc = "Render one stable relation-strength label from persisted schema metadata."
779)]
780const fn summarize_persisted_relation_strength(
781 strength: PersistedRelationStrength,
782) -> &'static str {
783 match strength {
784 PersistedRelationStrength::Strong => "strong",
785 PersistedRelationStrength::Weak => "weak",
786 }
787}
788
789#[cfg_attr(
790 doc,
791 doc = "Render one stable relation-strength label for field-kind summaries."
792)]
793const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
794 match strength {
795 RelationStrength::Strong => "strong",
796 RelationStrength::Weak => "weak",
797 }
798}
799
800#[cfg(test)]
805mod tests {
806 use crate::{
807 db::{
808 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
809 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
810 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
811 schema::{
812 AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
813 PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
814 SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
815 describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
816 },
817 },
818 model::{
819 entity::EntityModel,
820 field::{
821 FieldKind, FieldModel, FieldStorageDecode, LeafCodec, RelationStrength, ScalarCodec,
822 },
823 },
824 types::EntityTag,
825 };
826 use candid::types::{CandidType, Label, Type, TypeInner};
827
828 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
829 target_path: "entities::Target",
830 target_entity_name: "Target",
831 target_entity_tag: EntityTag::new(0xD001),
832 target_store_path: "stores::Target",
833 key_kind: &FieldKind::Ulid,
834 strength: RelationStrength::Strong,
835 };
836 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
837 target_path: "entities::Account",
838 target_entity_name: "Account",
839 target_entity_tag: EntityTag::new(0xD002),
840 target_store_path: "stores::Account",
841 key_kind: &FieldKind::Uint,
842 strength: RelationStrength::Weak,
843 };
844 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
845 target_path: "entities::Team",
846 target_entity_name: "Team",
847 target_entity_tag: EntityTag::new(0xD003),
848 target_store_path: "stores::Team",
849 key_kind: &FieldKind::Text { max_len: None },
850 strength: RelationStrength::Strong,
851 };
852 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
853 FieldModel::generated("id", FieldKind::Ulid),
854 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
855 FieldModel::generated(
856 "accounts",
857 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
858 ),
859 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
860 ];
861 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
862 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
863 "entities::Source",
864 "Source",
865 &DESCRIBE_RELATION_FIELDS[0],
866 0,
867 &DESCRIBE_RELATION_FIELDS,
868 &DESCRIBE_RELATION_INDEXES,
869 );
870
871 fn expect_record_fields(ty: Type) -> Vec<String> {
872 match ty.as_ref() {
873 TypeInner::Record(fields) => fields
874 .iter()
875 .map(|field| match field.id.as_ref() {
876 Label::Named(name) => name.clone(),
877 other => panic!("expected named record field, got {other:?}"),
878 })
879 .collect(),
880 other => panic!("expected candid record, got {other:?}"),
881 }
882 }
883
884 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
885 match ty.as_ref() {
886 TypeInner::Record(fields) => fields
887 .iter()
888 .find_map(|field| match field.id.as_ref() {
889 Label::Named(name) if name == field_name => Some(field.ty.clone()),
890 _ => None,
891 })
892 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
893 other => panic!("expected candid record, got {other:?}"),
894 }
895 }
896
897 fn expect_variant_labels(ty: Type) -> Vec<String> {
898 match ty.as_ref() {
899 TypeInner::Variant(fields) => fields
900 .iter()
901 .map(|field| match field.id.as_ref() {
902 Label::Named(name) => name.clone(),
903 other => panic!("expected named variant label, got {other:?}"),
904 })
905 .collect(),
906 other => panic!("expected candid variant, got {other:?}"),
907 }
908 }
909
910 #[test]
911 fn entity_schema_description_candid_shape_is_stable() {
912 let fields = expect_record_fields(EntitySchemaDescription::ty());
913
914 for field in [
915 "entity_path",
916 "entity_name",
917 "primary_key",
918 "fields",
919 "indexes",
920 "relations",
921 ] {
922 assert!(
923 fields.iter().any(|candidate| candidate == field),
924 "EntitySchemaDescription must keep `{field}` field key",
925 );
926 }
927 }
928
929 #[test]
930 fn entity_field_description_candid_shape_is_stable() {
931 let fields = expect_record_fields(EntityFieldDescription::ty());
932
933 for field in ["name", "slot", "kind", "primary_key", "queryable"] {
934 assert!(
935 fields.iter().any(|candidate| candidate == field),
936 "EntityFieldDescription must keep `{field}` field key",
937 );
938 }
939
940 assert!(
941 matches!(
942 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
943 TypeInner::Nat16
944 ),
945 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
946 );
947 }
948
949 #[test]
950 fn entity_index_description_candid_shape_is_stable() {
951 let fields = expect_record_fields(EntityIndexDescription::ty());
952
953 for field in ["name", "unique", "fields"] {
954 assert!(
955 fields.iter().any(|candidate| candidate == field),
956 "EntityIndexDescription must keep `{field}` field key",
957 );
958 }
959 }
960
961 #[test]
962 fn entity_relation_description_candid_shape_is_stable() {
963 let fields = expect_record_fields(EntityRelationDescription::ty());
964
965 for field in [
966 "field",
967 "target_path",
968 "target_entity_name",
969 "target_store_path",
970 "strength",
971 "cardinality",
972 ] {
973 assert!(
974 fields.iter().any(|candidate| candidate == field),
975 "EntityRelationDescription must keep `{field}` field key",
976 );
977 }
978 }
979
980 #[test]
981 fn relation_enum_variant_labels_are_stable() {
982 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
983 strength_labels.sort_unstable();
984 assert_eq!(
985 strength_labels,
986 vec!["Strong".to_string(), "Weak".to_string()]
987 );
988
989 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
990 cardinality_labels.sort_unstable();
991 assert_eq!(
992 cardinality_labels,
993 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
994 );
995 }
996
997 #[test]
998 fn describe_fixture_constructors_stay_usable() {
999 let payload = EntitySchemaDescription::new(
1000 "entities::User".to_string(),
1001 "User".to_string(),
1002 "id".to_string(),
1003 vec![EntityFieldDescription::new(
1004 "id".to_string(),
1005 Some(0),
1006 "ulid".to_string(),
1007 true,
1008 true,
1009 )],
1010 vec![EntityIndexDescription::new(
1011 "idx_email".to_string(),
1012 true,
1013 vec!["email".to_string()],
1014 )],
1015 vec![EntityRelationDescription::new(
1016 "account_id".to_string(),
1017 "entities::Account".to_string(),
1018 "Account".to_string(),
1019 "accounts".to_string(),
1020 EntityRelationStrength::Strong,
1021 EntityRelationCardinality::Single,
1022 )],
1023 );
1024
1025 assert_eq!(payload.entity_name(), "User");
1026 assert_eq!(payload.fields().len(), 1);
1027 assert_eq!(payload.indexes().len(), 1);
1028 assert_eq!(payload.relations().len(), 1);
1029 }
1030
1031 #[test]
1032 fn schema_describe_relations_match_relation_descriptors() {
1033 let descriptors =
1034 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1035 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1036 let relations = described.relations();
1037
1038 assert_eq!(descriptors.len(), relations.len());
1039
1040 for (descriptor, relation) in descriptors.iter().zip(relations) {
1041 assert_eq!(relation.field(), descriptor.field_name());
1042 assert_eq!(relation.target_path(), descriptor.target_path());
1043 assert_eq!(
1044 relation.target_entity_name(),
1045 descriptor.target_entity_name()
1046 );
1047 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1048 assert_eq!(
1049 relation.strength(),
1050 match descriptor.strength() {
1051 RelationStrength::Strong => EntityRelationStrength::Strong,
1052 RelationStrength::Weak => EntityRelationStrength::Weak,
1053 }
1054 );
1055 assert_eq!(
1056 relation.cardinality(),
1057 match descriptor.cardinality() {
1058 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1059 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1060 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1061 }
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn schema_describe_includes_text_max_len_contract() {
1068 static FIELDS: [FieldModel; 2] = [
1069 FieldModel::generated("id", FieldKind::Ulid),
1070 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1071 ];
1072 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1073 static MODEL: EntityModel = EntityModel::generated(
1074 "entities::BoundedName",
1075 "BoundedName",
1076 &FIELDS[0],
1077 0,
1078 &FIELDS,
1079 &INDEXES,
1080 );
1081
1082 let described = describe_entity_model(&MODEL);
1083 let name_field = described
1084 .fields()
1085 .iter()
1086 .find(|field| field.name() == "name")
1087 .expect("bounded text field should be described");
1088
1089 assert_eq!(name_field.kind(), "text(max_len=16)");
1090 }
1091
1092 #[test]
1093 fn schema_describe_uses_accepted_top_level_field_metadata() {
1094 static FIELDS: [FieldModel; 2] = [
1095 FieldModel::generated("id", FieldKind::Ulid),
1096 FieldModel::generated("payload", FieldKind::Text { max_len: None }),
1097 ];
1098 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1099 static MODEL: EntityModel = EntityModel::generated(
1100 "entities::BlobEvent",
1101 "BlobEvent",
1102 &FIELDS[0],
1103 0,
1104 &FIELDS,
1105 &INDEXES,
1106 );
1107 let id_slot = SchemaFieldSlot::new(0);
1108 let payload_slot = SchemaFieldSlot::new(7);
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 payload_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}