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 sha2::{Digest, Sha256};
25use std::fmt::Write;
26
27const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
28
29#[cfg_attr(
30 doc,
31 doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
32)]
33#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
34pub struct EntitySchemaDescription {
35 pub(crate) entity_path: String,
36 pub(crate) entity_name: String,
37 pub(crate) primary_key: String,
38 pub(crate) fields: Vec<EntityFieldDescription>,
39 pub(crate) indexes: Vec<EntityIndexDescription>,
40 pub(crate) relations: Vec<EntityRelationDescription>,
41}
42
43#[cfg_attr(
44 doc,
45 doc = "EntitySchemaCheckDescription\n\nGenerated-vs-accepted schema description payload for one entity."
46)]
47#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
48pub struct EntitySchemaCheckDescription {
49 pub(crate) generated: EntitySchemaDescription,
50 pub(crate) accepted: EntitySchemaDescription,
51}
52
53impl EntitySchemaCheckDescription {
54 #[must_use]
56 pub const fn new(
57 generated: EntitySchemaDescription,
58 accepted: EntitySchemaDescription,
59 ) -> Self {
60 Self {
61 generated,
62 accepted,
63 }
64 }
65
66 #[must_use]
68 pub const fn generated(&self) -> &EntitySchemaDescription {
69 &self.generated
70 }
71
72 #[must_use]
74 pub const fn accepted(&self) -> &EntitySchemaDescription {
75 &self.accepted
76 }
77}
78
79impl EntitySchemaDescription {
80 #[must_use]
82 pub const fn new(
83 entity_path: String,
84 entity_name: String,
85 primary_key: String,
86 fields: Vec<EntityFieldDescription>,
87 indexes: Vec<EntityIndexDescription>,
88 relations: Vec<EntityRelationDescription>,
89 ) -> Self {
90 Self {
91 entity_path,
92 entity_name,
93 primary_key,
94 fields,
95 indexes,
96 relations,
97 }
98 }
99
100 #[must_use]
102 pub const fn entity_path(&self) -> &str {
103 self.entity_path.as_str()
104 }
105
106 #[must_use]
108 pub const fn entity_name(&self) -> &str {
109 self.entity_name.as_str()
110 }
111
112 #[must_use]
114 pub const fn primary_key(&self) -> &str {
115 self.primary_key.as_str()
116 }
117
118 #[must_use]
120 pub const fn fields(&self) -> &[EntityFieldDescription] {
121 self.fields.as_slice()
122 }
123
124 #[must_use]
126 pub const fn indexes(&self) -> &[EntityIndexDescription] {
127 self.indexes.as_slice()
128 }
129
130 #[must_use]
132 pub const fn relations(&self) -> &[EntityRelationDescription] {
133 self.relations.as_slice()
134 }
135}
136
137#[cfg_attr(
138 doc,
139 doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
140)]
141#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
142pub struct EntityFieldDescription {
143 pub(crate) name: String,
144 pub(crate) slot: u16,
145 pub(crate) kind: String,
146 pub(crate) primary_key: bool,
147 pub(crate) queryable: bool,
148 pub(crate) origin: String,
149}
150
151impl EntityFieldDescription {
152 #[must_use]
154 pub const fn new(
155 name: String,
156 slot: Option<u16>,
157 kind: String,
158 primary_key: bool,
159 queryable: bool,
160 origin: String,
161 ) -> Self {
162 let slot = match slot {
163 Some(slot) => slot,
164 None => ENTITY_FIELD_DESCRIPTION_NO_SLOT,
165 };
166
167 Self {
168 name,
169 slot,
170 kind,
171 primary_key,
172 queryable,
173 origin,
174 }
175 }
176
177 #[must_use]
179 pub const fn name(&self) -> &str {
180 self.name.as_str()
181 }
182
183 #[must_use]
185 pub const fn slot(&self) -> Option<u16> {
186 if self.slot == ENTITY_FIELD_DESCRIPTION_NO_SLOT {
187 None
188 } else {
189 Some(self.slot)
190 }
191 }
192
193 #[must_use]
195 pub const fn kind(&self) -> &str {
196 self.kind.as_str()
197 }
198
199 #[must_use]
201 pub const fn primary_key(&self) -> bool {
202 self.primary_key
203 }
204
205 #[must_use]
207 pub const fn queryable(&self) -> bool {
208 self.queryable
209 }
210
211 #[must_use]
213 pub const fn origin(&self) -> &str {
214 self.origin.as_str()
215 }
216}
217
218#[cfg_attr(
219 doc,
220 doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
221)]
222#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
223pub struct EntityIndexDescription {
224 pub(crate) name: String,
225 pub(crate) unique: bool,
226 pub(crate) fields: Vec<String>,
227 pub(crate) origin: String,
228}
229
230impl EntityIndexDescription {
231 #[must_use]
233 pub const fn new(name: String, unique: bool, fields: Vec<String>, origin: String) -> Self {
234 Self {
235 name,
236 unique,
237 fields,
238 origin,
239 }
240 }
241
242 #[must_use]
244 pub const fn name(&self) -> &str {
245 self.name.as_str()
246 }
247
248 #[must_use]
250 pub const fn unique(&self) -> bool {
251 self.unique
252 }
253
254 #[must_use]
256 pub const fn fields(&self) -> &[String] {
257 self.fields.as_slice()
258 }
259
260 #[must_use]
262 pub const fn origin(&self) -> &str {
263 self.origin.as_str()
264 }
265}
266
267#[cfg_attr(
268 doc,
269 doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
270)]
271#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
272pub struct EntityRelationDescription {
273 pub(crate) field: String,
274 pub(crate) target_path: String,
275 pub(crate) target_entity_name: String,
276 pub(crate) target_store_path: String,
277 pub(crate) strength: EntityRelationStrength,
278 pub(crate) cardinality: EntityRelationCardinality,
279}
280
281impl EntityRelationDescription {
282 #[must_use]
284 pub const fn new(
285 field: String,
286 target_path: String,
287 target_entity_name: String,
288 target_store_path: String,
289 strength: EntityRelationStrength,
290 cardinality: EntityRelationCardinality,
291 ) -> Self {
292 Self {
293 field,
294 target_path,
295 target_entity_name,
296 target_store_path,
297 strength,
298 cardinality,
299 }
300 }
301
302 #[must_use]
304 pub const fn field(&self) -> &str {
305 self.field.as_str()
306 }
307
308 #[must_use]
310 pub const fn target_path(&self) -> &str {
311 self.target_path.as_str()
312 }
313
314 #[must_use]
316 pub const fn target_entity_name(&self) -> &str {
317 self.target_entity_name.as_str()
318 }
319
320 #[must_use]
322 pub const fn target_store_path(&self) -> &str {
323 self.target_store_path.as_str()
324 }
325
326 #[must_use]
328 pub const fn strength(&self) -> EntityRelationStrength {
329 self.strength
330 }
331
332 #[must_use]
334 pub const fn cardinality(&self) -> EntityRelationCardinality {
335 self.cardinality
336 }
337}
338
339#[cfg_attr(doc, doc = "EntityRelationStrength\n\nDescribe relation strength.")]
340#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
341pub enum EntityRelationStrength {
342 Strong,
343 Weak,
344}
345
346#[cfg_attr(
347 doc,
348 doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
349)]
350#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
351pub enum EntityRelationCardinality {
352 Single,
353 List,
354 Set,
355}
356
357#[cfg_attr(
358 doc,
359 doc = "Build one stable entity-schema description from one runtime `EntityModel`."
360)]
361#[must_use]
362pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
363 let fields = describe_entity_fields(model);
364
365 describe_entity_model_with_parts(
366 model.path,
367 model.entity_name,
368 model.primary_key.name,
369 fields,
370 describe_entity_indexes_from_model(model),
371 model,
372 )
373}
374
375#[cfg_attr(
376 doc,
377 doc = "Build one entity-schema description using accepted persisted schema slot metadata."
378)]
379#[must_use]
380pub(in crate::db) fn describe_entity_model_with_persisted_schema(
381 model: &EntityModel,
382 schema: &AcceptedSchemaSnapshot,
383) -> EntitySchemaDescription {
384 let fields = describe_entity_fields_with_persisted_schema(schema);
385 let primary_key = schema
386 .primary_key_field_name()
387 .unwrap_or(model.primary_key.name);
388
389 describe_entity_model_with_parts(
390 schema.entity_path(),
391 schema.entity_name(),
392 primary_key,
393 fields,
394 describe_entity_indexes_with_persisted_schema(schema),
395 model,
396 )
397}
398
399fn describe_entity_model_with_parts(
403 entity_path: &str,
404 entity_name: &str,
405 primary_key: &str,
406 fields: Vec<EntityFieldDescription>,
407 indexes: Vec<EntityIndexDescription>,
408 model: &EntityModel,
409) -> EntitySchemaDescription {
410 let relations = relation_descriptors_for_model_iter(model)
411 .map(relation_description_from_descriptor)
412 .collect();
413
414 EntitySchemaDescription::new(
415 entity_path.to_string(),
416 entity_name.to_string(),
417 primary_key.to_string(),
418 fields,
419 indexes,
420 relations,
421 )
422}
423
424fn describe_entity_indexes_from_model(model: &EntityModel) -> Vec<EntityIndexDescription> {
425 let mut indexes = Vec::with_capacity(model.indexes.len());
426 for index in model.indexes {
427 indexes.push(EntityIndexDescription::new(
428 index.name().to_string(),
429 index.is_unique(),
430 index
431 .fields()
432 .iter()
433 .map(|field| (*field).to_string())
434 .collect(),
435 "generated".to_string(),
436 ));
437 }
438
439 indexes
440}
441
442fn describe_entity_indexes_with_persisted_schema(
443 schema: &AcceptedSchemaSnapshot,
444) -> Vec<EntityIndexDescription> {
445 schema
446 .persisted_snapshot()
447 .indexes()
448 .iter()
449 .map(|index| {
450 EntityIndexDescription::new(
451 index.name().to_string(),
452 index.unique(),
453 describe_persisted_index_fields(index.key()),
454 if index.generated() {
455 "generated".to_string()
456 } else {
457 "ddl".to_string()
458 },
459 )
460 })
461 .collect()
462}
463
464fn describe_persisted_index_fields(key: &PersistedIndexKeySnapshot) -> Vec<String> {
465 match key {
466 PersistedIndexKeySnapshot::FieldPath(paths) => paths
467 .iter()
468 .map(|field_path| field_path.path().join("."))
469 .collect(),
470 PersistedIndexKeySnapshot::Items(items) => items
471 .iter()
472 .map(|item| match item {
473 PersistedIndexKeyItemSnapshot::FieldPath(field_path) => field_path.path().join("."),
474 PersistedIndexKeyItemSnapshot::Expression(expression) => {
475 expression.canonical_text().to_string()
476 }
477 })
478 .collect(),
479 }
480}
481
482#[must_use]
486pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
487 describe_entity_fields_with_slot_lookup(model, |slot, _field| {
488 Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
489 })
490}
491
492#[cfg_attr(
493 doc,
494 doc = "Build field descriptors using accepted persisted schema slot metadata."
495)]
496#[must_use]
497pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
498 schema: &AcceptedSchemaSnapshot,
499) -> Vec<EntityFieldDescription> {
500 let snapshot = schema.persisted_snapshot();
501 let mut fields = Vec::with_capacity(snapshot.fields().len());
502
503 for field in snapshot.fields() {
506 let primary_key = field.id() == snapshot.primary_key_field_id();
507 let slot = snapshot
508 .row_layout()
509 .slot_for_field(field.id())
510 .map(SchemaFieldSlot::get);
511 let mut kind = summarize_persisted_field_kind(field.kind());
512 write_schema_default_summary(&mut kind, field.default());
513 let metadata = DescribeFieldMetadata::new(
514 kind,
515 field_type_from_persisted_kind(field.kind())
516 .value_kind()
517 .is_queryable(),
518 field_origin_label(field.generated()),
519 );
520
521 push_described_field_row(&mut fields, field.name(), slot, primary_key, None, metadata);
522
523 if !field.nested_leaves().is_empty() {
524 describe_persisted_nested_leaves(
525 &mut fields,
526 field.nested_leaves(),
527 field_origin_label(field.generated()),
528 );
529 }
530 }
531
532 fields
533}
534
535fn describe_entity_fields_with_slot_lookup(
539 model: &EntityModel,
540 mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
541) -> Vec<EntityFieldDescription> {
542 let mut fields = Vec::with_capacity(model.fields.len());
543
544 for (slot, field) in model.fields.iter().enumerate() {
545 let primary_key = field.name == model.primary_key.name;
546 describe_field_recursive(
547 &mut fields,
548 field.name,
549 slot_for_field(slot, field),
550 field,
551 primary_key,
552 None,
553 None,
554 );
555 }
556
557 fields
558}
559
560struct DescribeFieldMetadata {
569 kind: String,
570 queryable: bool,
571 origin: String,
572}
573
574impl DescribeFieldMetadata {
575 const fn new(kind: String, queryable: bool, origin: String) -> Self {
577 Self {
578 kind,
579 queryable,
580 origin,
581 }
582 }
583}
584
585fn describe_field_recursive(
588 fields: &mut Vec<EntityFieldDescription>,
589 name: &str,
590 slot: Option<u16>,
591 field: &FieldModel,
592 primary_key: bool,
593 tree_prefix: Option<&'static str>,
594 metadata_override: Option<DescribeFieldMetadata>,
595) {
596 let metadata = metadata_override.unwrap_or_else(|| {
597 let mut kind = summarize_field_kind(&field.kind);
598 write_model_default_summary(&mut kind, field.database_default());
599
600 DescribeFieldMetadata::new(
601 kind,
602 field.kind.value_kind().is_queryable(),
603 "generated".to_string(),
604 )
605 });
606
607 push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
608 describe_generated_nested_fields(fields, field.nested_fields());
609}
610
611fn push_described_field_row(
614 fields: &mut Vec<EntityFieldDescription>,
615 name: &str,
616 slot: Option<u16>,
617 primary_key: bool,
618 tree_prefix: Option<&'static str>,
619 metadata: DescribeFieldMetadata,
620) {
621 let display_name = if let Some(prefix) = tree_prefix {
624 format!("{prefix}{name}")
625 } else {
626 name.to_string()
627 };
628
629 fields.push(EntityFieldDescription::new(
630 display_name,
631 slot,
632 metadata.kind,
633 primary_key,
634 metadata.queryable,
635 metadata.origin,
636 ));
637}
638
639fn describe_generated_nested_fields(
643 fields: &mut Vec<EntityFieldDescription>,
644 nested_fields: &[FieldModel],
645) {
646 for (index, nested) in nested_fields.iter().enumerate() {
647 let prefix = if index + 1 == nested_fields.len() {
648 "└─ "
649 } else {
650 "├─ "
651 };
652 describe_field_recursive(
653 fields,
654 nested.name(),
655 None,
656 nested,
657 false,
658 Some(prefix),
659 None,
660 );
661 }
662}
663
664fn describe_persisted_nested_leaves(
667 fields: &mut Vec<EntityFieldDescription>,
668 nested_leaves: &[PersistedNestedLeafSnapshot],
669 origin: String,
670) {
671 for (index, leaf) in nested_leaves.iter().enumerate() {
672 let prefix = if index + 1 == nested_leaves.len() {
673 "└─ "
674 } else {
675 "├─ "
676 };
677 let name = leaf.path().last().map_or("", String::as_str);
678 let metadata = DescribeFieldMetadata::new(
679 summarize_persisted_field_kind(leaf.kind()),
680 field_type_from_persisted_kind(leaf.kind())
681 .value_kind()
682 .is_queryable(),
683 origin.clone(),
684 );
685
686 push_described_field_row(fields, name, None, false, Some(prefix), metadata);
687 }
688}
689
690fn field_origin_label(generated: bool) -> String {
691 if generated {
692 "generated".to_string()
693 } else {
694 "ddl".to_string()
695 }
696}
697
698fn relation_description_from_descriptor(
700 descriptor: RelationDescriptor,
701) -> EntityRelationDescription {
702 let strength = match descriptor.strength() {
703 RelationStrength::Strong => EntityRelationStrength::Strong,
704 RelationStrength::Weak => EntityRelationStrength::Weak,
705 };
706
707 let cardinality = match descriptor.cardinality() {
708 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
709 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
710 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
711 };
712
713 EntityRelationDescription::new(
714 descriptor.field_name().to_string(),
715 descriptor.target_path().to_string(),
716 descriptor.target_entity_name().to_string(),
717 descriptor.target_store_path().to_string(),
718 strength,
719 cardinality,
720 )
721}
722
723#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
724fn summarize_field_kind(kind: &FieldKind) -> String {
725 let mut out = String::new();
726 write_field_kind_summary(&mut out, kind);
727
728 out
729}
730
731fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
734 match kind {
735 FieldKind::Account => out.push_str("account"),
736 FieldKind::Blob { max_len } => {
737 write_length_bounded_field_kind_summary(out, "blob", *max_len);
738 }
739 FieldKind::Bool => out.push_str("bool"),
740 FieldKind::Date => out.push_str("date"),
741 FieldKind::Decimal { scale } => {
742 let _ = write!(out, "decimal(scale={scale})");
743 }
744 FieldKind::Duration => out.push_str("duration"),
745 FieldKind::Enum { path, .. } => {
746 out.push_str("enum(");
747 out.push_str(path);
748 out.push(')');
749 }
750 FieldKind::Float32 => out.push_str("float32"),
751 FieldKind::Float64 => out.push_str("float64"),
752 FieldKind::Int => out.push_str("int"),
753 FieldKind::Int128 => out.push_str("int128"),
754 FieldKind::IntBig => out.push_str("int_big"),
755 FieldKind::Principal => out.push_str("principal"),
756 FieldKind::Subaccount => out.push_str("subaccount"),
757 FieldKind::Text { max_len } => {
758 write_length_bounded_field_kind_summary(out, "text", *max_len);
759 }
760 FieldKind::Timestamp => out.push_str("timestamp"),
761 FieldKind::Nat => out.push_str("nat"),
762 FieldKind::Nat128 => out.push_str("nat128"),
763 FieldKind::NatBig => out.push_str("nat_big"),
764 FieldKind::Ulid => out.push_str("ulid"),
765 FieldKind::Unit => out.push_str("unit"),
766 FieldKind::Relation {
767 target_entity_name,
768 key_kind,
769 strength,
770 ..
771 } => {
772 out.push_str("relation(target=");
773 out.push_str(target_entity_name);
774 out.push_str(", key=");
775 write_field_kind_summary(out, key_kind);
776 out.push_str(", strength=");
777 out.push_str(summarize_relation_strength(*strength));
778 out.push(')');
779 }
780 FieldKind::List(inner) => {
781 out.push_str("list<");
782 write_field_kind_summary(out, inner);
783 out.push('>');
784 }
785 FieldKind::Set(inner) => {
786 out.push_str("set<");
787 write_field_kind_summary(out, inner);
788 out.push('>');
789 }
790 FieldKind::Map { key, value } => {
791 out.push_str("map<");
792 write_field_kind_summary(out, key);
793 out.push_str(", ");
794 write_field_kind_summary(out, value);
795 out.push('>');
796 }
797 FieldKind::Structured { .. } => {
798 out.push_str("structured");
799 }
800 }
801}
802
803fn write_length_bounded_field_kind_summary(
807 out: &mut String,
808 kind_name: &str,
809 max_len: Option<u32>,
810) {
811 out.push_str(kind_name);
812 if let Some(max_len) = max_len {
813 out.push_str("(max_len=");
814 out.push_str(&max_len.to_string());
815 out.push(')');
816 } else {
817 out.push_str("(unbounded)");
818 }
819}
820
821fn write_model_default_summary(out: &mut String, default: FieldDatabaseDefault) {
825 match default {
826 FieldDatabaseDefault::None => {}
827 FieldDatabaseDefault::EncodedSlotPayload(payload) => {
828 write_encoded_default_payload_summary(out, payload);
829 }
830 }
831}
832
833fn write_schema_default_summary(out: &mut String, default: &SchemaFieldDefault) {
836 if let Some(payload) = default.slot_payload() {
837 write_encoded_default_payload_summary(out, payload);
838 }
839}
840
841fn write_encoded_default_payload_summary(out: &mut String, payload: &[u8]) {
845 let _ = write!(
846 out,
847 " default=slot_payload(bytes={}, sha256={})",
848 payload.len(),
849 short_default_payload_fingerprint(payload),
850 );
851}
852
853fn short_default_payload_fingerprint(payload: &[u8]) -> String {
854 let digest = Sha256::digest(payload);
855 let mut out = String::with_capacity(16);
856 for byte in &digest[..8] {
857 let _ = write!(out, "{byte:02x}");
858 }
859 out
860}
861
862#[cfg_attr(
863 doc,
864 doc = "Render one stable field-kind label from accepted persisted schema metadata."
865)]
866fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
867 let mut out = String::new();
868 write_persisted_field_kind_summary(&mut out, kind);
869
870 out
871}
872
873fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
877 match kind {
878 PersistedFieldKind::Account => out.push_str("account"),
879 PersistedFieldKind::Blob { max_len } => {
880 write_length_bounded_field_kind_summary(out, "blob", *max_len);
881 }
882 PersistedFieldKind::Bool => out.push_str("bool"),
883 PersistedFieldKind::Date => out.push_str("date"),
884 PersistedFieldKind::Decimal { scale } => {
885 let _ = write!(out, "decimal(scale={scale})");
886 }
887 PersistedFieldKind::Duration => out.push_str("duration"),
888 PersistedFieldKind::Enum { path, .. } => {
889 out.push_str("enum(");
890 out.push_str(path);
891 out.push(')');
892 }
893 PersistedFieldKind::Float32 => out.push_str("float32"),
894 PersistedFieldKind::Float64 => out.push_str("float64"),
895 PersistedFieldKind::Int => out.push_str("int"),
896 PersistedFieldKind::Int128 => out.push_str("int128"),
897 PersistedFieldKind::IntBig => out.push_str("int_big"),
898 PersistedFieldKind::Principal => out.push_str("principal"),
899 PersistedFieldKind::Subaccount => out.push_str("subaccount"),
900 PersistedFieldKind::Text { max_len } => {
901 write_length_bounded_field_kind_summary(out, "text", *max_len);
902 }
903 PersistedFieldKind::Timestamp => out.push_str("timestamp"),
904 PersistedFieldKind::Nat => out.push_str("nat"),
905 PersistedFieldKind::Nat128 => out.push_str("nat128"),
906 PersistedFieldKind::NatBig => out.push_str("nat_big"),
907 PersistedFieldKind::Ulid => out.push_str("ulid"),
908 PersistedFieldKind::Unit => out.push_str("unit"),
909 PersistedFieldKind::Relation {
910 target_entity_name,
911 key_kind,
912 strength,
913 ..
914 } => {
915 out.push_str("relation(target=");
916 out.push_str(target_entity_name);
917 out.push_str(", key=");
918 write_persisted_field_kind_summary(out, key_kind);
919 out.push_str(", strength=");
920 out.push_str(summarize_persisted_relation_strength(*strength));
921 out.push(')');
922 }
923 PersistedFieldKind::List(inner) => {
924 out.push_str("list<");
925 write_persisted_field_kind_summary(out, inner);
926 out.push('>');
927 }
928 PersistedFieldKind::Set(inner) => {
929 out.push_str("set<");
930 write_persisted_field_kind_summary(out, inner);
931 out.push('>');
932 }
933 PersistedFieldKind::Map { key, value } => {
934 out.push_str("map<");
935 write_persisted_field_kind_summary(out, key);
936 out.push_str(", ");
937 write_persisted_field_kind_summary(out, value);
938 out.push('>');
939 }
940 PersistedFieldKind::Structured { .. } => {
941 out.push_str("structured");
942 }
943 }
944}
945
946#[cfg_attr(
947 doc,
948 doc = "Render one stable relation-strength label from persisted schema metadata."
949)]
950const fn summarize_persisted_relation_strength(
951 strength: PersistedRelationStrength,
952) -> &'static str {
953 match strength {
954 PersistedRelationStrength::Strong => "strong",
955 PersistedRelationStrength::Weak => "weak",
956 }
957}
958
959#[cfg_attr(
960 doc,
961 doc = "Render one stable relation-strength label for field-kind summaries."
962)]
963const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
964 match strength {
965 RelationStrength::Strong => "strong",
966 RelationStrength::Weak => "weak",
967 }
968}
969
970#[cfg(test)]
975mod tests {
976 use crate::{
977 db::{
978 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
979 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
980 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
981 schema::{
982 AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
983 PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
984 SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
985 describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
986 },
987 },
988 model::{
989 entity::EntityModel,
990 field::{
991 FieldDatabaseDefault, FieldKind, FieldModel, FieldStorageDecode, LeafCodec,
992 RelationStrength, ScalarCodec,
993 },
994 },
995 types::EntityTag,
996 };
997 use candid::types::{CandidType, Label, Type, TypeInner};
998
999 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
1000 target_path: "entities::Target",
1001 target_entity_name: "Target",
1002 target_entity_tag: EntityTag::new(0xD001),
1003 target_store_path: "stores::Target",
1004 key_kind: &FieldKind::Ulid,
1005 strength: RelationStrength::Strong,
1006 };
1007 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1008 target_path: "entities::Account",
1009 target_entity_name: "Account",
1010 target_entity_tag: EntityTag::new(0xD002),
1011 target_store_path: "stores::Account",
1012 key_kind: &FieldKind::Nat,
1013 strength: RelationStrength::Weak,
1014 };
1015 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1016 target_path: "entities::Team",
1017 target_entity_name: "Team",
1018 target_entity_tag: EntityTag::new(0xD003),
1019 target_store_path: "stores::Team",
1020 key_kind: &FieldKind::Text { max_len: None },
1021 strength: RelationStrength::Strong,
1022 };
1023 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
1024 FieldModel::generated("id", FieldKind::Ulid),
1025 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
1026 FieldModel::generated(
1027 "accounts",
1028 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
1029 ),
1030 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
1031 ];
1032 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
1033 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
1034 "entities::Source",
1035 "Source",
1036 &DESCRIBE_RELATION_FIELDS[0],
1037 0,
1038 &DESCRIBE_RELATION_FIELDS,
1039 &DESCRIBE_RELATION_INDEXES,
1040 );
1041
1042 fn expect_record_fields(ty: Type) -> Vec<String> {
1043 match ty.as_ref() {
1044 TypeInner::Record(fields) => fields
1045 .iter()
1046 .map(|field| match field.id.as_ref() {
1047 Label::Named(name) => name.clone(),
1048 other => panic!("expected named record field, got {other:?}"),
1049 })
1050 .collect(),
1051 other => panic!("expected candid record, got {other:?}"),
1052 }
1053 }
1054
1055 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
1056 match ty.as_ref() {
1057 TypeInner::Record(fields) => fields
1058 .iter()
1059 .find_map(|field| match field.id.as_ref() {
1060 Label::Named(name) if name == field_name => Some(field.ty.clone()),
1061 _ => None,
1062 })
1063 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
1064 other => panic!("expected candid record, got {other:?}"),
1065 }
1066 }
1067
1068 fn expect_variant_labels(ty: Type) -> Vec<String> {
1069 match ty.as_ref() {
1070 TypeInner::Variant(fields) => fields
1071 .iter()
1072 .map(|field| match field.id.as_ref() {
1073 Label::Named(name) => name.clone(),
1074 other => panic!("expected named variant label, got {other:?}"),
1075 })
1076 .collect(),
1077 other => panic!("expected candid variant, got {other:?}"),
1078 }
1079 }
1080
1081 #[test]
1082 fn entity_schema_description_candid_shape_is_stable() {
1083 let fields = expect_record_fields(EntitySchemaDescription::ty());
1084
1085 for field in [
1086 "entity_path",
1087 "entity_name",
1088 "primary_key",
1089 "fields",
1090 "indexes",
1091 "relations",
1092 ] {
1093 assert!(
1094 fields.iter().any(|candidate| candidate == field),
1095 "EntitySchemaDescription must keep `{field}` field key",
1096 );
1097 }
1098 }
1099
1100 #[test]
1101 fn entity_field_description_candid_shape_is_stable() {
1102 let fields = expect_record_fields(EntityFieldDescription::ty());
1103
1104 for field in ["name", "slot", "kind", "primary_key", "queryable", "origin"] {
1105 assert!(
1106 fields.iter().any(|candidate| candidate == field),
1107 "EntityFieldDescription must keep `{field}` field key",
1108 );
1109 }
1110
1111 assert!(
1112 matches!(
1113 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
1114 TypeInner::Nat16
1115 ),
1116 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
1117 );
1118 }
1119
1120 #[test]
1121 fn entity_index_description_candid_shape_is_stable() {
1122 let fields = expect_record_fields(EntityIndexDescription::ty());
1123
1124 for field in ["name", "unique", "fields", "origin"] {
1125 assert!(
1126 fields.iter().any(|candidate| candidate == field),
1127 "EntityIndexDescription must keep `{field}` field key",
1128 );
1129 }
1130 }
1131
1132 #[test]
1133 fn entity_relation_description_candid_shape_is_stable() {
1134 let fields = expect_record_fields(EntityRelationDescription::ty());
1135
1136 for field in [
1137 "field",
1138 "target_path",
1139 "target_entity_name",
1140 "target_store_path",
1141 "strength",
1142 "cardinality",
1143 ] {
1144 assert!(
1145 fields.iter().any(|candidate| candidate == field),
1146 "EntityRelationDescription must keep `{field}` field key",
1147 );
1148 }
1149 }
1150
1151 #[test]
1152 fn relation_enum_variant_labels_are_stable() {
1153 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
1154 strength_labels.sort_unstable();
1155 assert_eq!(
1156 strength_labels,
1157 vec!["Strong".to_string(), "Weak".to_string()]
1158 );
1159
1160 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
1161 cardinality_labels.sort_unstable();
1162 assert_eq!(
1163 cardinality_labels,
1164 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
1165 );
1166 }
1167
1168 #[test]
1169 fn describe_fixture_constructors_stay_usable() {
1170 let payload = EntitySchemaDescription::new(
1171 "entities::User".to_string(),
1172 "User".to_string(),
1173 "id".to_string(),
1174 vec![EntityFieldDescription::new(
1175 "id".to_string(),
1176 Some(0),
1177 "ulid".to_string(),
1178 true,
1179 true,
1180 "generated".to_string(),
1181 )],
1182 vec![EntityIndexDescription::new(
1183 "idx_email".to_string(),
1184 true,
1185 vec!["email".to_string()],
1186 "generated".to_string(),
1187 )],
1188 vec![EntityRelationDescription::new(
1189 "account_id".to_string(),
1190 "entities::Account".to_string(),
1191 "Account".to_string(),
1192 "accounts".to_string(),
1193 EntityRelationStrength::Strong,
1194 EntityRelationCardinality::Single,
1195 )],
1196 );
1197
1198 assert_eq!(payload.entity_name(), "User");
1199 assert_eq!(payload.fields().len(), 1);
1200 assert_eq!(payload.indexes().len(), 1);
1201 assert_eq!(payload.relations().len(), 1);
1202 }
1203
1204 #[test]
1205 fn schema_describe_relations_match_relation_descriptors() {
1206 let descriptors =
1207 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1208 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1209 let relations = described.relations();
1210
1211 assert_eq!(descriptors.len(), relations.len());
1212
1213 for (descriptor, relation) in descriptors.iter().zip(relations) {
1214 assert_eq!(relation.field(), descriptor.field_name());
1215 assert_eq!(relation.target_path(), descriptor.target_path());
1216 assert_eq!(
1217 relation.target_entity_name(),
1218 descriptor.target_entity_name()
1219 );
1220 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1221 assert_eq!(
1222 relation.strength(),
1223 match descriptor.strength() {
1224 RelationStrength::Strong => EntityRelationStrength::Strong,
1225 RelationStrength::Weak => EntityRelationStrength::Weak,
1226 }
1227 );
1228 assert_eq!(
1229 relation.cardinality(),
1230 match descriptor.cardinality() {
1231 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1232 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1233 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1234 }
1235 );
1236 }
1237 }
1238
1239 #[test]
1240 fn schema_describe_includes_text_max_len_contract() {
1241 static FIELDS: [FieldModel; 2] = [
1242 FieldModel::generated("id", FieldKind::Ulid),
1243 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1244 ];
1245 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1246 static MODEL: EntityModel = EntityModel::generated(
1247 "entities::BoundedName",
1248 "BoundedName",
1249 &FIELDS[0],
1250 0,
1251 &FIELDS,
1252 &INDEXES,
1253 );
1254
1255 let described = describe_entity_model(&MODEL);
1256 let name_field = described
1257 .fields()
1258 .iter()
1259 .find(|field| field.name() == "name")
1260 .expect("bounded text field should be described");
1261
1262 assert_eq!(name_field.kind(), "text(max_len=16)");
1263 }
1264
1265 #[test]
1266 fn schema_describe_includes_generated_database_default_metadata() {
1267 static DEFAULT_PAYLOAD: &[u8] = &[0xFF, 0x01, 7, 0, 0, 0, 0, 0, 0, 0];
1268 static FIELDS: [FieldModel; 2] = [
1269 FieldModel::generated("id", FieldKind::Ulid),
1270 FieldModel::generated_with_storage_decode_nullability_write_policies_database_default_and_nested_fields(
1271 "score",
1272 FieldKind::Nat,
1273 FieldStorageDecode::ByKind,
1274 false,
1275 None,
1276 None,
1277 FieldDatabaseDefault::EncodedSlotPayload(DEFAULT_PAYLOAD),
1278 &[],
1279 ),
1280 ];
1281 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1282 static MODEL: EntityModel = EntityModel::generated(
1283 "entities::DefaultedScore",
1284 "DefaultedScore",
1285 &FIELDS[0],
1286 0,
1287 &FIELDS,
1288 &INDEXES,
1289 );
1290
1291 let described = describe_entity_model(&MODEL);
1292 let score_field = described
1293 .fields()
1294 .iter()
1295 .find(|field| field.name() == "score")
1296 .expect("database-defaulted score field should be described");
1297
1298 assert_eq!(
1299 score_field.kind(),
1300 "nat default=slot_payload(bytes=10, sha256=37746b8fe16bb6b4)"
1301 );
1302 }
1303
1304 #[test]
1305 fn schema_describe_uses_accepted_top_level_field_metadata() {
1306 let id_slot = SchemaFieldSlot::new(0);
1307 let payload_slot = SchemaFieldSlot::new(7);
1308 let stale_payload_field_slot = SchemaFieldSlot::new(3);
1311 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1312 SchemaVersion::initial(),
1313 "entities::BlobEvent".to_string(),
1314 "BlobEvent".to_string(),
1315 FieldId::new(1),
1316 SchemaRowLayout::new(
1317 SchemaVersion::initial(),
1318 vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1319 ),
1320 vec![
1321 PersistedFieldSnapshot::new(
1322 FieldId::new(1),
1323 "id".to_string(),
1324 id_slot,
1325 PersistedFieldKind::Ulid,
1326 Vec::new(),
1327 false,
1328 SchemaFieldDefault::None,
1329 FieldStorageDecode::ByKind,
1330 LeafCodec::StructuralFallback,
1331 ),
1332 PersistedFieldSnapshot::new(
1333 FieldId::new(2),
1334 "payload".to_string(),
1335 stale_payload_field_slot,
1336 PersistedFieldKind::Blob { max_len: None },
1337 Vec::new(),
1338 false,
1339 SchemaFieldDefault::SlotPayload(vec![0x10, 0x20, 0x30]),
1340 FieldStorageDecode::ByKind,
1341 LeafCodec::StructuralFallback,
1342 ),
1343 ],
1344 ));
1345
1346 let described = describe_entity_fields_with_persisted_schema(&snapshot)
1347 .into_iter()
1348 .map(|field| {
1349 (
1350 field.name().to_string(),
1351 field.slot(),
1352 field.kind().to_string(),
1353 )
1354 })
1355 .collect::<Vec<_>>();
1356
1357 assert_eq!(
1358 described,
1359 vec![
1360 ("id".to_string(), Some(0), "ulid".to_string()),
1361 (
1362 "payload".to_string(),
1363 Some(7),
1364 "blob(unbounded) default=slot_payload(bytes=3, sha256=8e1336ab78ebe687)"
1365 .to_string()
1366 ),
1367 ],
1368 );
1369 }
1370
1371 #[test]
1372 fn schema_describe_uses_accepted_nested_leaf_metadata() {
1373 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1374 SchemaVersion::initial(),
1375 "entities::AcceptedProfile".to_string(),
1376 "AcceptedProfile".to_string(),
1377 FieldId::new(1),
1378 SchemaRowLayout::new(
1379 SchemaVersion::initial(),
1380 vec![
1381 (FieldId::new(1), SchemaFieldSlot::new(0)),
1382 (FieldId::new(2), SchemaFieldSlot::new(1)),
1383 ],
1384 ),
1385 vec![
1386 PersistedFieldSnapshot::new(
1387 FieldId::new(1),
1388 "id".to_string(),
1389 SchemaFieldSlot::new(0),
1390 PersistedFieldKind::Ulid,
1391 Vec::new(),
1392 false,
1393 SchemaFieldDefault::None,
1394 FieldStorageDecode::ByKind,
1395 LeafCodec::StructuralFallback,
1396 ),
1397 PersistedFieldSnapshot::new(
1398 FieldId::new(2),
1399 "profile".to_string(),
1400 SchemaFieldSlot::new(1),
1401 PersistedFieldKind::Structured { queryable: true },
1402 vec![PersistedNestedLeafSnapshot::new(
1403 vec!["rank".to_string()],
1404 PersistedFieldKind::Blob { max_len: None },
1405 false,
1406 FieldStorageDecode::ByKind,
1407 LeafCodec::Scalar(ScalarCodec::Blob),
1408 )],
1409 false,
1410 SchemaFieldDefault::None,
1411 FieldStorageDecode::Value,
1412 LeafCodec::StructuralFallback,
1413 ),
1414 ],
1415 ));
1416
1417 let described = describe_entity_fields_with_persisted_schema(&snapshot);
1418 let rank = described
1419 .iter()
1420 .find(|field| field.name() == "└─ rank")
1421 .expect("accepted nested leaf should be described");
1422
1423 assert_eq!(rank.slot(), None);
1424 assert_eq!(rank.kind(), "blob(unbounded)");
1425 assert!(rank.queryable());
1426 }
1427
1428 #[test]
1429 fn schema_describe_expands_generated_structured_field_leaves() {
1430 static NESTED_FIELDS: [FieldModel; 3] = [
1431 FieldModel::generated("name", FieldKind::Text { max_len: None }),
1432 FieldModel::generated("level", FieldKind::Nat),
1433 FieldModel::generated("pid", FieldKind::Principal),
1434 ];
1435 static FIELDS: [FieldModel; 2] = [
1436 FieldModel::generated("id", FieldKind::Ulid),
1437 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1438 "mentor",
1439 FieldKind::Structured { queryable: false },
1440 FieldStorageDecode::Value,
1441 false,
1442 None,
1443 None,
1444 &NESTED_FIELDS,
1445 ),
1446 ];
1447 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1448 static MODEL: EntityModel = EntityModel::generated(
1449 "entities::Character",
1450 "Character",
1451 &FIELDS[0],
1452 0,
1453 &FIELDS,
1454 &INDEXES,
1455 );
1456
1457 let described = describe_entity_model(&MODEL);
1458 let described_fields = described
1459 .fields()
1460 .iter()
1461 .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1462 .collect::<Vec<_>>();
1463
1464 assert_eq!(
1465 described_fields,
1466 vec![
1467 ("id", Some(0), "ulid", true),
1468 ("mentor", Some(1), "structured", false),
1469 ("├─ name", None, "text(unbounded)", true),
1470 ("├─ level", None, "nat", true),
1471 ("└─ pid", None, "principal", true),
1472 ],
1473 );
1474 }
1475}