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