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