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