1use crate::{
7 db::{
8 relation::{
9 RelationDescriptor, RelationDescriptorCardinality, relation_descriptors_for_model_iter,
10 },
11 schema::{
12 AcceptedSchemaSnapshot, PersistedFieldKind, PersistedIndexKeyItemSnapshot,
13 PersistedIndexKeySnapshot, PersistedNestedLeafSnapshot, PersistedRelationStrength,
14 SchemaFieldDefault, SchemaFieldSlot, field_type_from_persisted_kind,
15 },
16 },
17 model::{
18 entity::EntityModel,
19 field::{FieldDatabaseDefault, 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 describe_entity_indexes_from_model(model),
317 model,
318 )
319}
320
321#[cfg_attr(
322 doc,
323 doc = "Build one entity-schema description using accepted persisted schema slot metadata."
324)]
325#[must_use]
326pub(in crate::db) fn describe_entity_model_with_persisted_schema(
327 model: &EntityModel,
328 schema: &AcceptedSchemaSnapshot,
329) -> EntitySchemaDescription {
330 let fields = describe_entity_fields_with_persisted_schema(schema);
331 let primary_key = schema
332 .primary_key_field_name()
333 .unwrap_or(model.primary_key.name);
334
335 describe_entity_model_with_parts(
336 schema.entity_path(),
337 schema.entity_name(),
338 primary_key,
339 fields,
340 describe_entity_indexes_with_persisted_schema(schema),
341 model,
342 )
343}
344
345fn describe_entity_model_with_parts(
349 entity_path: &str,
350 entity_name: &str,
351 primary_key: &str,
352 fields: Vec<EntityFieldDescription>,
353 indexes: Vec<EntityIndexDescription>,
354 model: &EntityModel,
355) -> EntitySchemaDescription {
356 let relations = relation_descriptors_for_model_iter(model)
357 .map(relation_description_from_descriptor)
358 .collect();
359
360 EntitySchemaDescription::new(
361 entity_path.to_string(),
362 entity_name.to_string(),
363 primary_key.to_string(),
364 fields,
365 indexes,
366 relations,
367 )
368}
369
370fn describe_entity_indexes_from_model(model: &EntityModel) -> Vec<EntityIndexDescription> {
371 let mut indexes = Vec::with_capacity(model.indexes.len());
372 for index in model.indexes {
373 indexes.push(EntityIndexDescription::new(
374 index.name().to_string(),
375 index.is_unique(),
376 index
377 .fields()
378 .iter()
379 .map(|field| (*field).to_string())
380 .collect(),
381 ));
382 }
383
384 indexes
385}
386
387fn describe_entity_indexes_with_persisted_schema(
388 schema: &AcceptedSchemaSnapshot,
389) -> Vec<EntityIndexDescription> {
390 schema
391 .persisted_snapshot()
392 .indexes()
393 .iter()
394 .map(|index| {
395 EntityIndexDescription::new(
396 index.name().to_string(),
397 index.unique(),
398 describe_persisted_index_fields(index.key()),
399 )
400 })
401 .collect()
402}
403
404fn describe_persisted_index_fields(key: &PersistedIndexKeySnapshot) -> Vec<String> {
405 match key {
406 PersistedIndexKeySnapshot::FieldPath(paths) => paths
407 .iter()
408 .map(|field_path| field_path.path().join("."))
409 .collect(),
410 PersistedIndexKeySnapshot::Items(items) => items
411 .iter()
412 .map(|item| match item {
413 PersistedIndexKeyItemSnapshot::FieldPath(field_path) => field_path.path().join("."),
414 PersistedIndexKeyItemSnapshot::Expression(expression) => {
415 expression.canonical_text().to_string()
416 }
417 })
418 .collect(),
419 }
420}
421
422#[must_use]
426pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
427 describe_entity_fields_with_slot_lookup(model, |slot, _field| {
428 Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
429 })
430}
431
432#[cfg_attr(
433 doc,
434 doc = "Build field descriptors using accepted persisted schema slot metadata."
435)]
436#[must_use]
437pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
438 schema: &AcceptedSchemaSnapshot,
439) -> Vec<EntityFieldDescription> {
440 let snapshot = schema.persisted_snapshot();
441 let mut fields = Vec::with_capacity(snapshot.fields().len());
442
443 for field in snapshot.fields() {
446 let primary_key = field.id() == snapshot.primary_key_field_id();
447 let slot = snapshot
448 .row_layout()
449 .slot_for_field(field.id())
450 .map(SchemaFieldSlot::get);
451 let mut kind = summarize_persisted_field_kind(field.kind());
452 write_schema_default_summary(&mut kind, field.default());
453 let metadata = DescribeFieldMetadata::new(
454 kind,
455 field_type_from_persisted_kind(field.kind())
456 .value_kind()
457 .is_queryable(),
458 );
459
460 push_described_field_row(&mut fields, field.name(), slot, primary_key, None, metadata);
461
462 if !field.nested_leaves().is_empty() {
463 describe_persisted_nested_leaves(&mut fields, field.nested_leaves());
464 }
465 }
466
467 fields
468}
469
470fn describe_entity_fields_with_slot_lookup(
474 model: &EntityModel,
475 mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
476) -> Vec<EntityFieldDescription> {
477 let mut fields = Vec::with_capacity(model.fields.len());
478
479 for (slot, field) in model.fields.iter().enumerate() {
480 let primary_key = field.name == model.primary_key.name;
481 describe_field_recursive(
482 &mut fields,
483 field.name,
484 slot_for_field(slot, field),
485 field,
486 primary_key,
487 None,
488 None,
489 );
490 }
491
492 fields
493}
494
495struct DescribeFieldMetadata {
504 kind: String,
505 queryable: bool,
506}
507
508impl DescribeFieldMetadata {
509 const fn new(kind: String, queryable: bool) -> Self {
511 Self { kind, queryable }
512 }
513}
514
515fn describe_field_recursive(
518 fields: &mut Vec<EntityFieldDescription>,
519 name: &str,
520 slot: Option<u16>,
521 field: &FieldModel,
522 primary_key: bool,
523 tree_prefix: Option<&'static str>,
524 metadata_override: Option<DescribeFieldMetadata>,
525) {
526 let metadata = metadata_override.unwrap_or_else(|| {
527 let mut kind = summarize_field_kind(&field.kind);
528 write_model_default_summary(&mut kind, field.database_default());
529
530 DescribeFieldMetadata::new(kind, field.kind.value_kind().is_queryable())
531 });
532
533 push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
534 describe_generated_nested_fields(fields, field.nested_fields());
535}
536
537fn push_described_field_row(
540 fields: &mut Vec<EntityFieldDescription>,
541 name: &str,
542 slot: Option<u16>,
543 primary_key: bool,
544 tree_prefix: Option<&'static str>,
545 metadata: DescribeFieldMetadata,
546) {
547 let display_name = if let Some(prefix) = tree_prefix {
550 format!("{prefix}{name}")
551 } else {
552 name.to_string()
553 };
554
555 fields.push(EntityFieldDescription::new(
556 display_name,
557 slot,
558 metadata.kind,
559 primary_key,
560 metadata.queryable,
561 ));
562}
563
564fn describe_generated_nested_fields(
568 fields: &mut Vec<EntityFieldDescription>,
569 nested_fields: &[FieldModel],
570) {
571 for (index, nested) in nested_fields.iter().enumerate() {
572 let prefix = if index + 1 == nested_fields.len() {
573 "└─ "
574 } else {
575 "├─ "
576 };
577 describe_field_recursive(
578 fields,
579 nested.name(),
580 None,
581 nested,
582 false,
583 Some(prefix),
584 None,
585 );
586 }
587}
588
589fn describe_persisted_nested_leaves(
592 fields: &mut Vec<EntityFieldDescription>,
593 nested_leaves: &[PersistedNestedLeafSnapshot],
594) {
595 for (index, leaf) in nested_leaves.iter().enumerate() {
596 let prefix = if index + 1 == nested_leaves.len() {
597 "└─ "
598 } else {
599 "├─ "
600 };
601 let name = leaf.path().last().map_or("", String::as_str);
602 let metadata = DescribeFieldMetadata::new(
603 summarize_persisted_field_kind(leaf.kind()),
604 field_type_from_persisted_kind(leaf.kind())
605 .value_kind()
606 .is_queryable(),
607 );
608
609 push_described_field_row(fields, name, None, false, Some(prefix), metadata);
610 }
611}
612
613fn relation_description_from_descriptor(
615 descriptor: RelationDescriptor,
616) -> EntityRelationDescription {
617 let strength = match descriptor.strength() {
618 RelationStrength::Strong => EntityRelationStrength::Strong,
619 RelationStrength::Weak => EntityRelationStrength::Weak,
620 };
621
622 let cardinality = match descriptor.cardinality() {
623 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
624 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
625 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
626 };
627
628 EntityRelationDescription::new(
629 descriptor.field_name().to_string(),
630 descriptor.target_path().to_string(),
631 descriptor.target_entity_name().to_string(),
632 descriptor.target_store_path().to_string(),
633 strength,
634 cardinality,
635 )
636}
637
638#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
639fn summarize_field_kind(kind: &FieldKind) -> String {
640 let mut out = String::new();
641 write_field_kind_summary(&mut out, kind);
642
643 out
644}
645
646fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
649 match kind {
650 FieldKind::Account => out.push_str("account"),
651 FieldKind::Blob { max_len } => {
652 write_length_bounded_field_kind_summary(out, "blob", *max_len);
653 }
654 FieldKind::Bool => out.push_str("bool"),
655 FieldKind::Date => out.push_str("date"),
656 FieldKind::Decimal { scale } => {
657 let _ = write!(out, "decimal(scale={scale})");
658 }
659 FieldKind::Duration => out.push_str("duration"),
660 FieldKind::Enum { path, .. } => {
661 out.push_str("enum(");
662 out.push_str(path);
663 out.push(')');
664 }
665 FieldKind::Float32 => out.push_str("float32"),
666 FieldKind::Float64 => out.push_str("float64"),
667 FieldKind::Int => out.push_str("int"),
668 FieldKind::Int128 => out.push_str("int128"),
669 FieldKind::IntBig => out.push_str("int_big"),
670 FieldKind::Principal => out.push_str("principal"),
671 FieldKind::Subaccount => out.push_str("subaccount"),
672 FieldKind::Text { max_len } => {
673 write_length_bounded_field_kind_summary(out, "text", *max_len);
674 }
675 FieldKind::Timestamp => out.push_str("timestamp"),
676 FieldKind::Nat => out.push_str("nat"),
677 FieldKind::Nat128 => out.push_str("nat128"),
678 FieldKind::NatBig => out.push_str("nat_big"),
679 FieldKind::Ulid => out.push_str("ulid"),
680 FieldKind::Unit => out.push_str("unit"),
681 FieldKind::Relation {
682 target_entity_name,
683 key_kind,
684 strength,
685 ..
686 } => {
687 out.push_str("relation(target=");
688 out.push_str(target_entity_name);
689 out.push_str(", key=");
690 write_field_kind_summary(out, key_kind);
691 out.push_str(", strength=");
692 out.push_str(summarize_relation_strength(*strength));
693 out.push(')');
694 }
695 FieldKind::List(inner) => {
696 out.push_str("list<");
697 write_field_kind_summary(out, inner);
698 out.push('>');
699 }
700 FieldKind::Set(inner) => {
701 out.push_str("set<");
702 write_field_kind_summary(out, inner);
703 out.push('>');
704 }
705 FieldKind::Map { key, value } => {
706 out.push_str("map<");
707 write_field_kind_summary(out, key);
708 out.push_str(", ");
709 write_field_kind_summary(out, value);
710 out.push('>');
711 }
712 FieldKind::Structured { .. } => {
713 out.push_str("structured");
714 }
715 }
716}
717
718fn write_length_bounded_field_kind_summary(
722 out: &mut String,
723 kind_name: &str,
724 max_len: Option<u32>,
725) {
726 out.push_str(kind_name);
727 if let Some(max_len) = max_len {
728 out.push_str("(max_len=");
729 out.push_str(&max_len.to_string());
730 out.push(')');
731 } else {
732 out.push_str("(unbounded)");
733 }
734}
735
736fn write_model_default_summary(out: &mut String, default: FieldDatabaseDefault) {
740 match default {
741 FieldDatabaseDefault::None => {}
742 FieldDatabaseDefault::EncodedSlotPayload(payload) => {
743 write_encoded_default_payload_summary(out, payload);
744 }
745 }
746}
747
748fn write_schema_default_summary(out: &mut String, default: &SchemaFieldDefault) {
751 if let Some(payload) = default.slot_payload() {
752 write_encoded_default_payload_summary(out, payload);
753 }
754}
755
756fn write_encoded_default_payload_summary(out: &mut String, payload: &[u8]) {
760 let _ = write!(out, " default=slot_payload(bytes={})", payload.len());
761}
762
763#[cfg_attr(
764 doc,
765 doc = "Render one stable field-kind label from accepted persisted schema metadata."
766)]
767fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
768 let mut out = String::new();
769 write_persisted_field_kind_summary(&mut out, kind);
770
771 out
772}
773
774fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
778 match kind {
779 PersistedFieldKind::Account => out.push_str("account"),
780 PersistedFieldKind::Blob { max_len } => {
781 write_length_bounded_field_kind_summary(out, "blob", *max_len);
782 }
783 PersistedFieldKind::Bool => out.push_str("bool"),
784 PersistedFieldKind::Date => out.push_str("date"),
785 PersistedFieldKind::Decimal { scale } => {
786 let _ = write!(out, "decimal(scale={scale})");
787 }
788 PersistedFieldKind::Duration => out.push_str("duration"),
789 PersistedFieldKind::Enum { path, .. } => {
790 out.push_str("enum(");
791 out.push_str(path);
792 out.push(')');
793 }
794 PersistedFieldKind::Float32 => out.push_str("float32"),
795 PersistedFieldKind::Float64 => out.push_str("float64"),
796 PersistedFieldKind::Int => out.push_str("int"),
797 PersistedFieldKind::Int128 => out.push_str("int128"),
798 PersistedFieldKind::IntBig => out.push_str("int_big"),
799 PersistedFieldKind::Principal => out.push_str("principal"),
800 PersistedFieldKind::Subaccount => out.push_str("subaccount"),
801 PersistedFieldKind::Text { max_len } => {
802 write_length_bounded_field_kind_summary(out, "text", *max_len);
803 }
804 PersistedFieldKind::Timestamp => out.push_str("timestamp"),
805 PersistedFieldKind::Nat => out.push_str("nat"),
806 PersistedFieldKind::Nat128 => out.push_str("nat128"),
807 PersistedFieldKind::NatBig => out.push_str("nat_big"),
808 PersistedFieldKind::Ulid => out.push_str("ulid"),
809 PersistedFieldKind::Unit => out.push_str("unit"),
810 PersistedFieldKind::Relation {
811 target_entity_name,
812 key_kind,
813 strength,
814 ..
815 } => {
816 out.push_str("relation(target=");
817 out.push_str(target_entity_name);
818 out.push_str(", key=");
819 write_persisted_field_kind_summary(out, key_kind);
820 out.push_str(", strength=");
821 out.push_str(summarize_persisted_relation_strength(*strength));
822 out.push(')');
823 }
824 PersistedFieldKind::List(inner) => {
825 out.push_str("list<");
826 write_persisted_field_kind_summary(out, inner);
827 out.push('>');
828 }
829 PersistedFieldKind::Set(inner) => {
830 out.push_str("set<");
831 write_persisted_field_kind_summary(out, inner);
832 out.push('>');
833 }
834 PersistedFieldKind::Map { key, value } => {
835 out.push_str("map<");
836 write_persisted_field_kind_summary(out, key);
837 out.push_str(", ");
838 write_persisted_field_kind_summary(out, value);
839 out.push('>');
840 }
841 PersistedFieldKind::Structured { .. } => {
842 out.push_str("structured");
843 }
844 }
845}
846
847#[cfg_attr(
848 doc,
849 doc = "Render one stable relation-strength label from persisted schema metadata."
850)]
851const fn summarize_persisted_relation_strength(
852 strength: PersistedRelationStrength,
853) -> &'static str {
854 match strength {
855 PersistedRelationStrength::Strong => "strong",
856 PersistedRelationStrength::Weak => "weak",
857 }
858}
859
860#[cfg_attr(
861 doc,
862 doc = "Render one stable relation-strength label for field-kind summaries."
863)]
864const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
865 match strength {
866 RelationStrength::Strong => "strong",
867 RelationStrength::Weak => "weak",
868 }
869}
870
871#[cfg(test)]
876mod tests {
877 use crate::{
878 db::{
879 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
880 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
881 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
882 schema::{
883 AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
884 PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
885 SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
886 describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
887 },
888 },
889 model::{
890 entity::EntityModel,
891 field::{
892 FieldDatabaseDefault, FieldKind, FieldModel, FieldStorageDecode, LeafCodec,
893 RelationStrength, ScalarCodec,
894 },
895 },
896 types::EntityTag,
897 };
898 use candid::types::{CandidType, Label, Type, TypeInner};
899
900 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
901 target_path: "entities::Target",
902 target_entity_name: "Target",
903 target_entity_tag: EntityTag::new(0xD001),
904 target_store_path: "stores::Target",
905 key_kind: &FieldKind::Ulid,
906 strength: RelationStrength::Strong,
907 };
908 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
909 target_path: "entities::Account",
910 target_entity_name: "Account",
911 target_entity_tag: EntityTag::new(0xD002),
912 target_store_path: "stores::Account",
913 key_kind: &FieldKind::Nat,
914 strength: RelationStrength::Weak,
915 };
916 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
917 target_path: "entities::Team",
918 target_entity_name: "Team",
919 target_entity_tag: EntityTag::new(0xD003),
920 target_store_path: "stores::Team",
921 key_kind: &FieldKind::Text { max_len: None },
922 strength: RelationStrength::Strong,
923 };
924 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
925 FieldModel::generated("id", FieldKind::Ulid),
926 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
927 FieldModel::generated(
928 "accounts",
929 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
930 ),
931 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
932 ];
933 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
934 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
935 "entities::Source",
936 "Source",
937 &DESCRIBE_RELATION_FIELDS[0],
938 0,
939 &DESCRIBE_RELATION_FIELDS,
940 &DESCRIBE_RELATION_INDEXES,
941 );
942
943 fn expect_record_fields(ty: Type) -> Vec<String> {
944 match ty.as_ref() {
945 TypeInner::Record(fields) => fields
946 .iter()
947 .map(|field| match field.id.as_ref() {
948 Label::Named(name) => name.clone(),
949 other => panic!("expected named record field, got {other:?}"),
950 })
951 .collect(),
952 other => panic!("expected candid record, got {other:?}"),
953 }
954 }
955
956 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
957 match ty.as_ref() {
958 TypeInner::Record(fields) => fields
959 .iter()
960 .find_map(|field| match field.id.as_ref() {
961 Label::Named(name) if name == field_name => Some(field.ty.clone()),
962 _ => None,
963 })
964 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
965 other => panic!("expected candid record, got {other:?}"),
966 }
967 }
968
969 fn expect_variant_labels(ty: Type) -> Vec<String> {
970 match ty.as_ref() {
971 TypeInner::Variant(fields) => fields
972 .iter()
973 .map(|field| match field.id.as_ref() {
974 Label::Named(name) => name.clone(),
975 other => panic!("expected named variant label, got {other:?}"),
976 })
977 .collect(),
978 other => panic!("expected candid variant, got {other:?}"),
979 }
980 }
981
982 #[test]
983 fn entity_schema_description_candid_shape_is_stable() {
984 let fields = expect_record_fields(EntitySchemaDescription::ty());
985
986 for field in [
987 "entity_path",
988 "entity_name",
989 "primary_key",
990 "fields",
991 "indexes",
992 "relations",
993 ] {
994 assert!(
995 fields.iter().any(|candidate| candidate == field),
996 "EntitySchemaDescription must keep `{field}` field key",
997 );
998 }
999 }
1000
1001 #[test]
1002 fn entity_field_description_candid_shape_is_stable() {
1003 let fields = expect_record_fields(EntityFieldDescription::ty());
1004
1005 for field in ["name", "slot", "kind", "primary_key", "queryable"] {
1006 assert!(
1007 fields.iter().any(|candidate| candidate == field),
1008 "EntityFieldDescription must keep `{field}` field key",
1009 );
1010 }
1011
1012 assert!(
1013 matches!(
1014 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
1015 TypeInner::Nat16
1016 ),
1017 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
1018 );
1019 }
1020
1021 #[test]
1022 fn entity_index_description_candid_shape_is_stable() {
1023 let fields = expect_record_fields(EntityIndexDescription::ty());
1024
1025 for field in ["name", "unique", "fields"] {
1026 assert!(
1027 fields.iter().any(|candidate| candidate == field),
1028 "EntityIndexDescription must keep `{field}` field key",
1029 );
1030 }
1031 }
1032
1033 #[test]
1034 fn entity_relation_description_candid_shape_is_stable() {
1035 let fields = expect_record_fields(EntityRelationDescription::ty());
1036
1037 for field in [
1038 "field",
1039 "target_path",
1040 "target_entity_name",
1041 "target_store_path",
1042 "strength",
1043 "cardinality",
1044 ] {
1045 assert!(
1046 fields.iter().any(|candidate| candidate == field),
1047 "EntityRelationDescription must keep `{field}` field key",
1048 );
1049 }
1050 }
1051
1052 #[test]
1053 fn relation_enum_variant_labels_are_stable() {
1054 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
1055 strength_labels.sort_unstable();
1056 assert_eq!(
1057 strength_labels,
1058 vec!["Strong".to_string(), "Weak".to_string()]
1059 );
1060
1061 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
1062 cardinality_labels.sort_unstable();
1063 assert_eq!(
1064 cardinality_labels,
1065 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
1066 );
1067 }
1068
1069 #[test]
1070 fn describe_fixture_constructors_stay_usable() {
1071 let payload = EntitySchemaDescription::new(
1072 "entities::User".to_string(),
1073 "User".to_string(),
1074 "id".to_string(),
1075 vec![EntityFieldDescription::new(
1076 "id".to_string(),
1077 Some(0),
1078 "ulid".to_string(),
1079 true,
1080 true,
1081 )],
1082 vec![EntityIndexDescription::new(
1083 "idx_email".to_string(),
1084 true,
1085 vec!["email".to_string()],
1086 )],
1087 vec![EntityRelationDescription::new(
1088 "account_id".to_string(),
1089 "entities::Account".to_string(),
1090 "Account".to_string(),
1091 "accounts".to_string(),
1092 EntityRelationStrength::Strong,
1093 EntityRelationCardinality::Single,
1094 )],
1095 );
1096
1097 assert_eq!(payload.entity_name(), "User");
1098 assert_eq!(payload.fields().len(), 1);
1099 assert_eq!(payload.indexes().len(), 1);
1100 assert_eq!(payload.relations().len(), 1);
1101 }
1102
1103 #[test]
1104 fn schema_describe_relations_match_relation_descriptors() {
1105 let descriptors =
1106 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1107 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1108 let relations = described.relations();
1109
1110 assert_eq!(descriptors.len(), relations.len());
1111
1112 for (descriptor, relation) in descriptors.iter().zip(relations) {
1113 assert_eq!(relation.field(), descriptor.field_name());
1114 assert_eq!(relation.target_path(), descriptor.target_path());
1115 assert_eq!(
1116 relation.target_entity_name(),
1117 descriptor.target_entity_name()
1118 );
1119 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1120 assert_eq!(
1121 relation.strength(),
1122 match descriptor.strength() {
1123 RelationStrength::Strong => EntityRelationStrength::Strong,
1124 RelationStrength::Weak => EntityRelationStrength::Weak,
1125 }
1126 );
1127 assert_eq!(
1128 relation.cardinality(),
1129 match descriptor.cardinality() {
1130 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1131 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1132 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1133 }
1134 );
1135 }
1136 }
1137
1138 #[test]
1139 fn schema_describe_includes_text_max_len_contract() {
1140 static FIELDS: [FieldModel; 2] = [
1141 FieldModel::generated("id", FieldKind::Ulid),
1142 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1143 ];
1144 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1145 static MODEL: EntityModel = EntityModel::generated(
1146 "entities::BoundedName",
1147 "BoundedName",
1148 &FIELDS[0],
1149 0,
1150 &FIELDS,
1151 &INDEXES,
1152 );
1153
1154 let described = describe_entity_model(&MODEL);
1155 let name_field = described
1156 .fields()
1157 .iter()
1158 .find(|field| field.name() == "name")
1159 .expect("bounded text field should be described");
1160
1161 assert_eq!(name_field.kind(), "text(max_len=16)");
1162 }
1163
1164 #[test]
1165 fn schema_describe_includes_generated_database_default_metadata() {
1166 static DEFAULT_PAYLOAD: &[u8] = &[0xFF, 0x01, 7, 0, 0, 0, 0, 0, 0, 0];
1167 static FIELDS: [FieldModel; 2] = [
1168 FieldModel::generated("id", FieldKind::Ulid),
1169 FieldModel::generated_with_storage_decode_nullability_write_policies_database_default_and_nested_fields(
1170 "score",
1171 FieldKind::Nat,
1172 FieldStorageDecode::ByKind,
1173 false,
1174 None,
1175 None,
1176 FieldDatabaseDefault::EncodedSlotPayload(DEFAULT_PAYLOAD),
1177 &[],
1178 ),
1179 ];
1180 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1181 static MODEL: EntityModel = EntityModel::generated(
1182 "entities::DefaultedScore",
1183 "DefaultedScore",
1184 &FIELDS[0],
1185 0,
1186 &FIELDS,
1187 &INDEXES,
1188 );
1189
1190 let described = describe_entity_model(&MODEL);
1191 let score_field = described
1192 .fields()
1193 .iter()
1194 .find(|field| field.name() == "score")
1195 .expect("database-defaulted score field should be described");
1196
1197 assert_eq!(score_field.kind(), "nat default=slot_payload(bytes=10)");
1198 }
1199
1200 #[test]
1201 fn schema_describe_uses_accepted_top_level_field_metadata() {
1202 let id_slot = SchemaFieldSlot::new(0);
1203 let payload_slot = SchemaFieldSlot::new(7);
1204 let stale_payload_field_slot = SchemaFieldSlot::new(3);
1207 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1208 SchemaVersion::initial(),
1209 "entities::BlobEvent".to_string(),
1210 "BlobEvent".to_string(),
1211 FieldId::new(1),
1212 SchemaRowLayout::new(
1213 SchemaVersion::initial(),
1214 vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1215 ),
1216 vec![
1217 PersistedFieldSnapshot::new(
1218 FieldId::new(1),
1219 "id".to_string(),
1220 id_slot,
1221 PersistedFieldKind::Ulid,
1222 Vec::new(),
1223 false,
1224 SchemaFieldDefault::None,
1225 FieldStorageDecode::ByKind,
1226 LeafCodec::StructuralFallback,
1227 ),
1228 PersistedFieldSnapshot::new(
1229 FieldId::new(2),
1230 "payload".to_string(),
1231 stale_payload_field_slot,
1232 PersistedFieldKind::Blob { max_len: None },
1233 Vec::new(),
1234 false,
1235 SchemaFieldDefault::SlotPayload(vec![0x10, 0x20, 0x30]),
1236 FieldStorageDecode::ByKind,
1237 LeafCodec::StructuralFallback,
1238 ),
1239 ],
1240 ));
1241
1242 let described = describe_entity_fields_with_persisted_schema(&snapshot)
1243 .into_iter()
1244 .map(|field| {
1245 (
1246 field.name().to_string(),
1247 field.slot(),
1248 field.kind().to_string(),
1249 )
1250 })
1251 .collect::<Vec<_>>();
1252
1253 assert_eq!(
1254 described,
1255 vec![
1256 ("id".to_string(), Some(0), "ulid".to_string()),
1257 (
1258 "payload".to_string(),
1259 Some(7),
1260 "blob(unbounded) default=slot_payload(bytes=3)".to_string()
1261 ),
1262 ],
1263 );
1264 }
1265
1266 #[test]
1267 fn schema_describe_uses_accepted_nested_leaf_metadata() {
1268 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1269 SchemaVersion::initial(),
1270 "entities::AcceptedProfile".to_string(),
1271 "AcceptedProfile".to_string(),
1272 FieldId::new(1),
1273 SchemaRowLayout::new(
1274 SchemaVersion::initial(),
1275 vec![
1276 (FieldId::new(1), SchemaFieldSlot::new(0)),
1277 (FieldId::new(2), SchemaFieldSlot::new(1)),
1278 ],
1279 ),
1280 vec![
1281 PersistedFieldSnapshot::new(
1282 FieldId::new(1),
1283 "id".to_string(),
1284 SchemaFieldSlot::new(0),
1285 PersistedFieldKind::Ulid,
1286 Vec::new(),
1287 false,
1288 SchemaFieldDefault::None,
1289 FieldStorageDecode::ByKind,
1290 LeafCodec::StructuralFallback,
1291 ),
1292 PersistedFieldSnapshot::new(
1293 FieldId::new(2),
1294 "profile".to_string(),
1295 SchemaFieldSlot::new(1),
1296 PersistedFieldKind::Structured { queryable: true },
1297 vec![PersistedNestedLeafSnapshot::new(
1298 vec!["rank".to_string()],
1299 PersistedFieldKind::Blob { max_len: None },
1300 false,
1301 FieldStorageDecode::ByKind,
1302 LeafCodec::Scalar(ScalarCodec::Blob),
1303 )],
1304 false,
1305 SchemaFieldDefault::None,
1306 FieldStorageDecode::Value,
1307 LeafCodec::StructuralFallback,
1308 ),
1309 ],
1310 ));
1311
1312 let described = describe_entity_fields_with_persisted_schema(&snapshot);
1313 let rank = described
1314 .iter()
1315 .find(|field| field.name() == "└─ rank")
1316 .expect("accepted nested leaf should be described");
1317
1318 assert_eq!(rank.slot(), None);
1319 assert_eq!(rank.kind(), "blob(unbounded)");
1320 assert!(rank.queryable());
1321 }
1322
1323 #[test]
1324 fn schema_describe_expands_generated_structured_field_leaves() {
1325 static NESTED_FIELDS: [FieldModel; 3] = [
1326 FieldModel::generated("name", FieldKind::Text { max_len: None }),
1327 FieldModel::generated("level", FieldKind::Nat),
1328 FieldModel::generated("pid", FieldKind::Principal),
1329 ];
1330 static FIELDS: [FieldModel; 2] = [
1331 FieldModel::generated("id", FieldKind::Ulid),
1332 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1333 "mentor",
1334 FieldKind::Structured { queryable: false },
1335 FieldStorageDecode::Value,
1336 false,
1337 None,
1338 None,
1339 &NESTED_FIELDS,
1340 ),
1341 ];
1342 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1343 static MODEL: EntityModel = EntityModel::generated(
1344 "entities::Character",
1345 "Character",
1346 &FIELDS[0],
1347 0,
1348 &FIELDS,
1349 &INDEXES,
1350 );
1351
1352 let described = describe_entity_model(&MODEL);
1353 let described_fields = described
1354 .fields()
1355 .iter()
1356 .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1357 .collect::<Vec<_>>();
1358
1359 assert_eq!(
1360 described_fields,
1361 vec![
1362 ("id", Some(0), "ulid", true),
1363 ("mentor", Some(1), "structured", false),
1364 ("├─ name", None, "text(unbounded)", true),
1365 ("├─ level", None, "nat", true),
1366 ("└─ pid", None, "principal", true),
1367 ],
1368 );
1369 }
1370}