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