1use crate::{
7 db::{
8 relation::{
9 RelationFieldCardinality, RelationFieldMetadata, relation_field_metadata_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) primary_key_fields: Vec<String>,
39 pub(crate) fields: Vec<EntityFieldDescription>,
40 pub(crate) indexes: Vec<EntityIndexDescription>,
41 pub(crate) relations: Vec<EntityRelationDescription>,
42}
43
44#[cfg_attr(
45 doc,
46 doc = "EntitySchemaCheckDescription\n\nGenerated-vs-accepted schema description payload for one entity."
47)]
48#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
49pub struct EntitySchemaCheckDescription {
50 pub(crate) generated: EntitySchemaDescription,
51 pub(crate) accepted: EntitySchemaDescription,
52}
53
54impl EntitySchemaCheckDescription {
55 #[must_use]
57 pub const fn new(
58 generated: EntitySchemaDescription,
59 accepted: EntitySchemaDescription,
60 ) -> Self {
61 Self {
62 generated,
63 accepted,
64 }
65 }
66
67 #[must_use]
69 pub const fn generated(&self) -> &EntitySchemaDescription {
70 &self.generated
71 }
72
73 #[must_use]
75 pub const fn accepted(&self) -> &EntitySchemaDescription {
76 &self.accepted
77 }
78}
79
80impl EntitySchemaDescription {
81 #[must_use]
83 pub fn new(
84 entity_path: String,
85 entity_name: String,
86 primary_key: String,
87 fields: Vec<EntityFieldDescription>,
88 indexes: Vec<EntityIndexDescription>,
89 relations: Vec<EntityRelationDescription>,
90 ) -> Self {
91 Self::new_with_primary_key_fields(
92 entity_path,
93 entity_name,
94 primary_key.clone(),
95 vec![primary_key],
96 fields,
97 indexes,
98 relations,
99 )
100 }
101
102 #[must_use]
105 pub const fn new_with_primary_key_fields(
106 entity_path: String,
107 entity_name: String,
108 primary_key: String,
109 primary_key_fields: Vec<String>,
110 fields: Vec<EntityFieldDescription>,
111 indexes: Vec<EntityIndexDescription>,
112 relations: Vec<EntityRelationDescription>,
113 ) -> Self {
114 Self {
115 entity_path,
116 entity_name,
117 primary_key,
118 primary_key_fields,
119 fields,
120 indexes,
121 relations,
122 }
123 }
124
125 #[must_use]
127 pub const fn entity_path(&self) -> &str {
128 self.entity_path.as_str()
129 }
130
131 #[must_use]
133 pub const fn entity_name(&self) -> &str {
134 self.entity_name.as_str()
135 }
136
137 #[must_use]
139 pub const fn primary_key(&self) -> &str {
140 self.primary_key.as_str()
141 }
142
143 #[must_use]
145 pub const fn primary_key_fields(&self) -> &[String] {
146 self.primary_key_fields.as_slice()
147 }
148
149 #[must_use]
151 pub const fn fields(&self) -> &[EntityFieldDescription] {
152 self.fields.as_slice()
153 }
154
155 #[must_use]
157 pub const fn indexes(&self) -> &[EntityIndexDescription] {
158 self.indexes.as_slice()
159 }
160
161 #[must_use]
163 pub const fn relations(&self) -> &[EntityRelationDescription] {
164 self.relations.as_slice()
165 }
166}
167
168#[cfg_attr(
169 doc,
170 doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
171)]
172#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
173pub struct EntityFieldDescription {
174 pub(crate) name: String,
175 pub(crate) slot: u16,
176 pub(crate) kind: String,
177 pub(crate) nullable: bool,
178 pub(crate) primary_key: bool,
179 pub(crate) queryable: bool,
180 pub(crate) origin: String,
181}
182
183impl EntityFieldDescription {
184 #[must_use]
186 pub const fn new(
187 name: String,
188 slot: Option<u16>,
189 kind: String,
190 nullable: bool,
191 primary_key: bool,
192 queryable: bool,
193 origin: String,
194 ) -> Self {
195 let slot = match slot {
196 Some(slot) => slot,
197 None => ENTITY_FIELD_DESCRIPTION_NO_SLOT,
198 };
199
200 Self {
201 name,
202 slot,
203 kind,
204 nullable,
205 primary_key,
206 queryable,
207 origin,
208 }
209 }
210
211 #[must_use]
213 pub const fn name(&self) -> &str {
214 self.name.as_str()
215 }
216
217 #[must_use]
219 pub const fn slot(&self) -> Option<u16> {
220 if self.slot == ENTITY_FIELD_DESCRIPTION_NO_SLOT {
221 None
222 } else {
223 Some(self.slot)
224 }
225 }
226
227 #[must_use]
229 pub const fn kind(&self) -> &str {
230 self.kind.as_str()
231 }
232
233 #[must_use]
235 pub const fn nullable(&self) -> bool {
236 self.nullable
237 }
238
239 #[must_use]
241 pub const fn primary_key(&self) -> bool {
242 self.primary_key
243 }
244
245 #[must_use]
247 pub const fn queryable(&self) -> bool {
248 self.queryable
249 }
250
251 #[must_use]
253 pub const fn origin(&self) -> &str {
254 self.origin.as_str()
255 }
256}
257
258#[cfg_attr(
259 doc,
260 doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
261)]
262#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
263pub struct EntityIndexDescription {
264 pub(crate) name: String,
265 pub(crate) unique: bool,
266 pub(crate) fields: Vec<String>,
267 pub(crate) origin: String,
268}
269
270impl EntityIndexDescription {
271 #[must_use]
273 pub const fn new(name: String, unique: bool, fields: Vec<String>, origin: String) -> Self {
274 Self {
275 name,
276 unique,
277 fields,
278 origin,
279 }
280 }
281
282 #[must_use]
284 pub const fn name(&self) -> &str {
285 self.name.as_str()
286 }
287
288 #[must_use]
290 pub const fn unique(&self) -> bool {
291 self.unique
292 }
293
294 #[must_use]
296 pub const fn fields(&self) -> &[String] {
297 self.fields.as_slice()
298 }
299
300 #[must_use]
302 pub const fn origin(&self) -> &str {
303 self.origin.as_str()
304 }
305}
306
307#[cfg_attr(
308 doc,
309 doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
310)]
311#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
312pub struct EntityRelationDescription {
313 pub(crate) field: String,
314 pub(crate) target_path: String,
315 pub(crate) target_entity_name: String,
316 pub(crate) target_store_path: String,
317 pub(crate) strength: EntityRelationStrength,
318 pub(crate) cardinality: EntityRelationCardinality,
319}
320
321impl EntityRelationDescription {
322 #[must_use]
324 pub const fn new(
325 field: String,
326 target_path: String,
327 target_entity_name: String,
328 target_store_path: String,
329 strength: EntityRelationStrength,
330 cardinality: EntityRelationCardinality,
331 ) -> Self {
332 Self {
333 field,
334 target_path,
335 target_entity_name,
336 target_store_path,
337 strength,
338 cardinality,
339 }
340 }
341
342 #[must_use]
344 pub const fn field(&self) -> &str {
345 self.field.as_str()
346 }
347
348 #[must_use]
350 pub const fn target_path(&self) -> &str {
351 self.target_path.as_str()
352 }
353
354 #[must_use]
356 pub const fn target_entity_name(&self) -> &str {
357 self.target_entity_name.as_str()
358 }
359
360 #[must_use]
362 pub const fn target_store_path(&self) -> &str {
363 self.target_store_path.as_str()
364 }
365
366 #[must_use]
368 pub const fn strength(&self) -> EntityRelationStrength {
369 self.strength
370 }
371
372 #[must_use]
374 pub const fn cardinality(&self) -> EntityRelationCardinality {
375 self.cardinality
376 }
377}
378
379#[cfg_attr(doc, doc = "EntityRelationStrength\n\nDescribe relation strength.")]
380#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
381pub enum EntityRelationStrength {
382 Strong,
383 Weak,
384}
385
386#[cfg_attr(
387 doc,
388 doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
389)]
390#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
391pub enum EntityRelationCardinality {
392 Single,
393 List,
394 Set,
395}
396
397#[cfg_attr(
398 doc,
399 doc = "Build one stable entity-schema description from one runtime `EntityModel`."
400)]
401#[must_use]
402pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
403 let fields = describe_entity_fields(model);
404 let primary_key_fields = primary_key_field_names_from_model(model);
405 let primary_key = render_primary_key_fields(primary_key_fields.as_slice());
406
407 describe_entity_model_from_description_rows(
408 model.path,
409 model.entity_name,
410 primary_key.as_str(),
411 primary_key_fields,
412 fields,
413 describe_entity_indexes_from_model(model),
414 describe_entity_relations_from_model(model),
415 )
416}
417
418#[cfg_attr(
419 doc,
420 doc = "Build one entity-schema description using accepted persisted schema slot metadata."
421)]
422#[must_use]
423pub(in crate::db) fn describe_entity_model_with_persisted_schema(
424 model: &EntityModel,
425 schema: &AcceptedSchemaSnapshot,
426) -> EntitySchemaDescription {
427 let fields = describe_entity_fields_with_persisted_schema(schema);
428 let primary_key_fields = schema.primary_key_field_names();
429 let primary_key_fields = if primary_key_fields.is_empty() {
430 vec![model.primary_key.name.to_string()]
431 } else {
432 primary_key_fields
433 .into_iter()
434 .map(str::to_string)
435 .collect::<Vec<_>>()
436 };
437 let primary_key = render_primary_key_fields(primary_key_fields.as_slice());
438
439 describe_entity_model_from_description_rows(
440 schema.entity_path(),
441 schema.entity_name(),
442 primary_key.as_str(),
443 primary_key_fields,
444 fields,
445 describe_entity_indexes_with_persisted_schema(schema),
446 describe_entity_relations_with_persisted_schema(schema),
447 )
448}
449
450fn describe_entity_model_from_description_rows(
455 entity_path: &str,
456 entity_name: &str,
457 primary_key: &str,
458 primary_key_fields: Vec<String>,
459 fields: Vec<EntityFieldDescription>,
460 indexes: Vec<EntityIndexDescription>,
461 relations: Vec<EntityRelationDescription>,
462) -> EntitySchemaDescription {
463 EntitySchemaDescription::new_with_primary_key_fields(
464 entity_path.to_string(),
465 entity_name.to_string(),
466 primary_key.to_string(),
467 primary_key_fields,
468 fields,
469 indexes,
470 relations,
471 )
472}
473
474fn describe_entity_relations_from_model(model: &EntityModel) -> Vec<EntityRelationDescription> {
475 relation_field_metadata_for_model_iter(model)
476 .map(relation_description_from_metadata)
477 .collect()
478}
479
480fn primary_key_field_names_from_model(model: &EntityModel) -> Vec<String> {
481 model
482 .primary_key_model()
483 .fields()
484 .iter()
485 .map(|field| field.name.to_string())
486 .collect()
487}
488
489fn render_primary_key_fields(fields: &[String]) -> String {
490 fields.join(", ")
491}
492
493fn describe_entity_indexes_from_model(model: &EntityModel) -> Vec<EntityIndexDescription> {
494 let mut indexes = Vec::with_capacity(model.indexes.len());
495 for index in model.indexes {
496 indexes.push(EntityIndexDescription::new(
497 index.name().to_string(),
498 index.is_unique(),
499 index
500 .fields()
501 .iter()
502 .map(|field| (*field).to_string())
503 .collect(),
504 "generated".to_string(),
505 ));
506 }
507
508 indexes
509}
510
511fn describe_entity_indexes_with_persisted_schema(
512 schema: &AcceptedSchemaSnapshot,
513) -> Vec<EntityIndexDescription> {
514 schema
515 .persisted_snapshot()
516 .indexes()
517 .iter()
518 .map(|index| {
519 EntityIndexDescription::new(
520 index.name().to_string(),
521 index.unique(),
522 describe_persisted_index_fields(index.key()),
523 if index.generated() {
524 "generated".to_string()
525 } else {
526 "ddl".to_string()
527 },
528 )
529 })
530 .collect()
531}
532
533fn describe_persisted_index_fields(key: &PersistedIndexKeySnapshot) -> Vec<String> {
534 match key {
535 PersistedIndexKeySnapshot::FieldPath(paths) => paths
536 .iter()
537 .map(|field_path| field_path.path().join("."))
538 .collect(),
539 PersistedIndexKeySnapshot::Items(items) => items
540 .iter()
541 .map(|item| match item {
542 PersistedIndexKeyItemSnapshot::FieldPath(field_path) => field_path.path().join("."),
543 PersistedIndexKeyItemSnapshot::Expression(expression) => {
544 expression.canonical_text().to_string()
545 }
546 })
547 .collect(),
548 }
549}
550
551#[must_use]
555pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
556 describe_entity_fields_with_slot_lookup(model, |slot, _field| {
557 Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
558 })
559}
560
561#[cfg_attr(
562 doc,
563 doc = "Build field descriptors using accepted persisted schema slot metadata."
564)]
565#[must_use]
566pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
567 schema: &AcceptedSchemaSnapshot,
568) -> Vec<EntityFieldDescription> {
569 let snapshot = schema.persisted_snapshot();
570 let mut fields = Vec::with_capacity(snapshot.fields().len());
571
572 for field in snapshot.fields() {
575 let primary_key = snapshot.primary_key_field_ids().contains(&field.id());
576 let slot = snapshot
577 .row_layout()
578 .slot_for_field(field.id())
579 .map(SchemaFieldSlot::get);
580 let mut kind = summarize_persisted_field_kind(field.kind());
581 write_schema_default_summary(&mut kind, field.default());
582 let metadata = DescribeFieldMetadata::new(
583 kind,
584 field.nullable(),
585 field_type_from_persisted_kind(field.kind())
586 .value_kind()
587 .is_queryable(),
588 field_origin_label(field.generated()),
589 );
590
591 push_described_field_row(&mut fields, field.name(), slot, primary_key, None, metadata);
592
593 if !field.nested_leaves().is_empty() {
594 describe_persisted_nested_leaves(
595 &mut fields,
596 field.nested_leaves(),
597 field_origin_label(field.generated()),
598 );
599 }
600 }
601
602 fields
603}
604
605fn describe_entity_fields_with_slot_lookup(
609 model: &EntityModel,
610 mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
611) -> Vec<EntityFieldDescription> {
612 let mut fields = Vec::with_capacity(model.fields.len());
613 let primary_key_fields = primary_key_field_names_from_model(model);
614
615 for (slot, field) in model.fields.iter().enumerate() {
616 let primary_key = primary_key_fields
617 .iter()
618 .any(|primary_key_field| primary_key_field == field.name);
619 describe_field_recursive(
620 &mut fields,
621 field.name,
622 slot_for_field(slot, field),
623 field,
624 primary_key,
625 None,
626 None,
627 );
628 }
629
630 fields
631}
632
633struct DescribeFieldMetadata {
642 kind: String,
643 nullable: bool,
644 queryable: bool,
645 origin: String,
646}
647
648impl DescribeFieldMetadata {
649 const fn new(kind: String, nullable: bool, queryable: bool, origin: String) -> Self {
651 Self {
652 kind,
653 nullable,
654 queryable,
655 origin,
656 }
657 }
658}
659
660fn describe_field_recursive(
663 fields: &mut Vec<EntityFieldDescription>,
664 name: &str,
665 slot: Option<u16>,
666 field: &FieldModel,
667 primary_key: bool,
668 tree_prefix: Option<&'static str>,
669 metadata_override: Option<DescribeFieldMetadata>,
670) {
671 let metadata = metadata_override.unwrap_or_else(|| {
672 let mut kind = summarize_field_kind(&field.kind);
673 write_model_default_summary(&mut kind, field.database_default());
674
675 DescribeFieldMetadata::new(
676 kind,
677 field.nullable(),
678 field.kind.value_kind().is_queryable(),
679 "generated".to_string(),
680 )
681 });
682
683 push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
684 describe_generated_nested_fields(fields, field.nested_fields());
685}
686
687fn push_described_field_row(
690 fields: &mut Vec<EntityFieldDescription>,
691 name: &str,
692 slot: Option<u16>,
693 primary_key: bool,
694 tree_prefix: Option<&'static str>,
695 metadata: DescribeFieldMetadata,
696) {
697 let display_name = if let Some(prefix) = tree_prefix {
700 format!("{prefix}{name}")
701 } else {
702 name.to_string()
703 };
704
705 fields.push(EntityFieldDescription::new(
706 display_name,
707 slot,
708 metadata.kind,
709 metadata.nullable,
710 primary_key,
711 metadata.queryable,
712 metadata.origin,
713 ));
714}
715
716fn describe_generated_nested_fields(
720 fields: &mut Vec<EntityFieldDescription>,
721 nested_fields: &[FieldModel],
722) {
723 for (index, nested) in nested_fields.iter().enumerate() {
724 let prefix = if index + 1 == nested_fields.len() {
725 "└─ "
726 } else {
727 "├─ "
728 };
729 describe_field_recursive(
730 fields,
731 nested.name(),
732 None,
733 nested,
734 false,
735 Some(prefix),
736 None,
737 );
738 }
739}
740
741fn describe_persisted_nested_leaves(
744 fields: &mut Vec<EntityFieldDescription>,
745 nested_leaves: &[PersistedNestedLeafSnapshot],
746 origin: String,
747) {
748 for (index, leaf) in nested_leaves.iter().enumerate() {
749 let prefix = if index + 1 == nested_leaves.len() {
750 "└─ "
751 } else {
752 "├─ "
753 };
754 let name = leaf.path().last().map_or("", String::as_str);
755 let metadata = DescribeFieldMetadata::new(
756 summarize_persisted_field_kind(leaf.kind()),
757 leaf.nullable(),
758 field_type_from_persisted_kind(leaf.kind())
759 .value_kind()
760 .is_queryable(),
761 origin.clone(),
762 );
763
764 push_described_field_row(fields, name, None, false, Some(prefix), metadata);
765 }
766}
767
768fn field_origin_label(generated: bool) -> String {
769 if generated {
770 "generated".to_string()
771 } else {
772 "ddl".to_string()
773 }
774}
775
776fn describe_entity_relations_with_persisted_schema(
777 schema: &AcceptedSchemaSnapshot,
778) -> Vec<EntityRelationDescription> {
779 schema
780 .persisted_snapshot()
781 .fields()
782 .iter()
783 .filter_map(relation_description_from_persisted_field)
784 .collect()
785}
786
787fn relation_description_from_persisted_field(
788 field: &crate::db::schema::PersistedFieldSnapshot,
789) -> Option<EntityRelationDescription> {
790 let relation = persisted_relation_description_metadata(field.kind())?;
791
792 Some(EntityRelationDescription::new(
793 field.name().to_string(),
794 relation.target_path.to_string(),
795 relation.target_entity_name.to_string(),
796 relation.target_store_path.to_string(),
797 relation.strength,
798 relation.cardinality,
799 ))
800}
801
802struct PersistedRelationDescriptionMetadata<'a> {
803 target_path: &'a str,
804 target_entity_name: &'a str,
805 target_store_path: &'a str,
806 strength: EntityRelationStrength,
807 cardinality: EntityRelationCardinality,
808}
809
810fn persisted_relation_description_metadata(
811 kind: &PersistedFieldKind,
812) -> Option<PersistedRelationDescriptionMetadata<'_>> {
813 const fn from_relation_kind(
814 kind: &PersistedFieldKind,
815 cardinality: EntityRelationCardinality,
816 ) -> Option<PersistedRelationDescriptionMetadata<'_>> {
817 let PersistedFieldKind::Relation {
818 target_path,
819 target_entity_name,
820 target_store_path,
821 strength,
822 ..
823 } = kind
824 else {
825 return None;
826 };
827
828 Some(PersistedRelationDescriptionMetadata {
829 target_path: target_path.as_str(),
830 target_entity_name: target_entity_name.as_str(),
831 target_store_path: target_store_path.as_str(),
832 strength: entity_relation_strength_from_persisted(*strength),
833 cardinality,
834 })
835 }
836
837 match kind {
838 PersistedFieldKind::Relation { .. } => {
839 from_relation_kind(kind, EntityRelationCardinality::Single)
840 }
841 PersistedFieldKind::List(inner) => {
842 from_relation_kind(inner, EntityRelationCardinality::List)
843 }
844 PersistedFieldKind::Set(inner) => from_relation_kind(inner, EntityRelationCardinality::Set),
845 _ => None,
846 }
847}
848
849const fn entity_relation_strength_from_persisted(
850 strength: PersistedRelationStrength,
851) -> EntityRelationStrength {
852 match strength {
853 PersistedRelationStrength::Strong => EntityRelationStrength::Strong,
854 PersistedRelationStrength::Weak => EntityRelationStrength::Weak,
855 }
856}
857
858fn relation_description_from_metadata(
860 metadata: RelationFieldMetadata,
861) -> EntityRelationDescription {
862 let strength = match metadata.strength() {
863 RelationStrength::Strong => EntityRelationStrength::Strong,
864 RelationStrength::Weak => EntityRelationStrength::Weak,
865 };
866
867 let cardinality = match metadata.cardinality() {
868 RelationFieldCardinality::Single => EntityRelationCardinality::Single,
869 RelationFieldCardinality::List => EntityRelationCardinality::List,
870 RelationFieldCardinality::Set => EntityRelationCardinality::Set,
871 };
872
873 EntityRelationDescription::new(
874 metadata.field_name().to_string(),
875 metadata.target_path().to_string(),
876 metadata.target_entity_name().to_string(),
877 metadata.target_store_path().to_string(),
878 strength,
879 cardinality,
880 )
881}
882
883#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
884fn summarize_field_kind(kind: &FieldKind) -> String {
885 let mut out = String::new();
886 write_field_kind_summary(&mut out, kind);
887
888 out
889}
890
891fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
894 if let Some(name) = kind.describe_kind_name() {
895 out.push_str(name);
896 return;
897 }
898
899 match kind {
900 FieldKind::Blob { max_len } => {
901 write_length_bounded_field_kind_summary(out, "blob", *max_len);
902 }
903 FieldKind::Decimal { scale } => {
904 let _ = write!(out, "decimal(scale={scale})");
905 }
906 FieldKind::IntBig { max_bytes } => {
907 write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
908 }
909 FieldKind::Enum { path, .. } => {
910 out.push_str("enum(");
911 out.push_str(path);
912 out.push(')');
913 }
914 FieldKind::Text { max_len } => {
915 write_length_bounded_field_kind_summary(out, "text", *max_len);
916 }
917 FieldKind::Relation {
918 target_entity_name,
919 key_kind,
920 strength,
921 ..
922 } => {
923 out.push_str("relation(target=");
924 out.push_str(target_entity_name);
925 out.push_str(", key=");
926 write_field_kind_summary(out, key_kind);
927 out.push_str(", strength=");
928 out.push_str(summarize_relation_strength(*strength));
929 out.push(')');
930 }
931 FieldKind::List(inner) => {
932 out.push_str("list<");
933 write_field_kind_summary(out, inner);
934 out.push('>');
935 }
936 FieldKind::Set(inner) => {
937 out.push_str("set<");
938 write_field_kind_summary(out, inner);
939 out.push('>');
940 }
941 FieldKind::Map { key, value } => {
942 out.push_str("map<");
943 write_field_kind_summary(out, key);
944 out.push_str(", ");
945 write_field_kind_summary(out, value);
946 out.push('>');
947 }
948 FieldKind::Structured { .. } => {
949 out.push_str("structured");
950 }
951 FieldKind::Account
952 | FieldKind::Bool
953 | FieldKind::Date
954 | FieldKind::Duration
955 | FieldKind::Float32
956 | FieldKind::Float64
957 | FieldKind::Int8
958 | FieldKind::Int16
959 | FieldKind::Int32
960 | FieldKind::Int64
961 | FieldKind::Int128
962 | FieldKind::Principal
963 | FieldKind::Subaccount
964 | FieldKind::Timestamp
965 | FieldKind::Nat8
966 | FieldKind::Nat16
967 | FieldKind::Nat32
968 | FieldKind::Nat64
969 | FieldKind::Nat128
970 | FieldKind::Ulid
971 | FieldKind::Unit => unreachable!("plain field kind labels return before recursive render"),
972 FieldKind::NatBig { max_bytes } => {
973 write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
974 }
975 }
976}
977
978trait DescribeKindName {
979 fn describe_kind_name(&self) -> Option<&'static str>;
980}
981
982impl DescribeKindName for FieldKind {
983 fn describe_kind_name(&self) -> Option<&'static str> {
984 Some(match self {
985 Self::Account => "account",
986 Self::Bool => "bool",
987 Self::Date => "date",
988 Self::Duration => "duration",
989 Self::Float32 => "float32",
990 Self::Float64 => "float64",
991 Self::Int8 => "int8",
992 Self::Int16 => "int16",
993 Self::Int32 => "int32",
994 Self::Int64 => "int64",
995 Self::Int128 => "int128",
996 Self::Principal => "principal",
997 Self::Subaccount => "subaccount",
998 Self::Timestamp => "timestamp",
999 Self::Nat8 => "nat8",
1000 Self::Nat16 => "nat16",
1001 Self::Nat32 => "nat32",
1002 Self::Nat64 => "nat64",
1003 Self::Nat128 => "nat128",
1004 Self::Ulid => "ulid",
1005 Self::Unit => "unit",
1006 Self::Blob { .. }
1007 | Self::Decimal { .. }
1008 | Self::Enum { .. }
1009 | Self::IntBig { .. }
1010 | Self::NatBig { .. }
1011 | Self::Text { .. }
1012 | Self::Relation { .. }
1013 | Self::List(_)
1014 | Self::Set(_)
1015 | Self::Map { .. }
1016 | Self::Structured { .. } => return None,
1017 })
1018 }
1019}
1020
1021fn write_length_bounded_field_kind_summary(
1025 out: &mut String,
1026 kind_name: &str,
1027 max_len: Option<u32>,
1028) {
1029 out.push_str(kind_name);
1030 if let Some(max_len) = max_len {
1031 out.push_str("(max_len=");
1032 out.push_str(&max_len.to_string());
1033 out.push(')');
1034 } else {
1035 out.push_str("(unbounded)");
1036 }
1037}
1038
1039fn write_byte_bounded_field_kind_summary(out: &mut String, kind_name: &str, max_bytes: u32) {
1040 out.push_str(kind_name);
1041 out.push_str("(max_bytes=");
1042 out.push_str(&max_bytes.to_string());
1043 out.push(')');
1044}
1045
1046fn write_model_default_summary(out: &mut String, default: FieldDatabaseDefault) {
1050 match default {
1051 FieldDatabaseDefault::None => {}
1052 FieldDatabaseDefault::EncodedSlotPayload(payload) => {
1053 write_encoded_default_payload_summary(out, payload);
1054 }
1055 }
1056}
1057
1058fn write_schema_default_summary(out: &mut String, default: &SchemaFieldDefault) {
1061 if let Some(payload) = default.slot_payload() {
1062 write_encoded_default_payload_summary(out, payload);
1063 }
1064}
1065
1066fn write_encoded_default_payload_summary(out: &mut String, payload: &[u8]) {
1070 let _ = write!(
1071 out,
1072 " default=slot_payload(bytes={}, sha256={})",
1073 payload.len(),
1074 short_default_payload_fingerprint(payload),
1075 );
1076}
1077
1078fn short_default_payload_fingerprint(payload: &[u8]) -> String {
1079 let digest = Sha256::digest(payload);
1080 let mut out = String::with_capacity(16);
1081 for byte in &digest[..8] {
1082 let _ = write!(out, "{byte:02x}");
1083 }
1084 out
1085}
1086
1087#[cfg_attr(
1088 doc,
1089 doc = "Render one stable field-kind label from accepted persisted schema metadata."
1090)]
1091fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
1092 let mut out = String::new();
1093 write_persisted_field_kind_summary(&mut out, kind);
1094
1095 out
1096}
1097
1098fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
1102 if let Some(name) = kind.describe_kind_name() {
1103 out.push_str(name);
1104 return;
1105 }
1106
1107 match kind {
1108 PersistedFieldKind::Blob { max_len } => {
1109 write_length_bounded_field_kind_summary(out, "blob", *max_len);
1110 }
1111 PersistedFieldKind::Decimal { scale } => {
1112 let _ = write!(out, "decimal(scale={scale})");
1113 }
1114 PersistedFieldKind::IntBig { max_bytes } => {
1115 write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
1116 }
1117 PersistedFieldKind::Enum { path, .. } => {
1118 out.push_str("enum(");
1119 out.push_str(path);
1120 out.push(')');
1121 }
1122 PersistedFieldKind::Text { max_len } => {
1123 write_length_bounded_field_kind_summary(out, "text", *max_len);
1124 }
1125 PersistedFieldKind::Relation {
1126 target_entity_name,
1127 key_kind,
1128 strength,
1129 ..
1130 } => {
1131 out.push_str("relation(target=");
1132 out.push_str(target_entity_name);
1133 out.push_str(", key=");
1134 write_persisted_field_kind_summary(out, key_kind);
1135 out.push_str(", strength=");
1136 out.push_str(summarize_persisted_relation_strength(*strength));
1137 out.push(')');
1138 }
1139 PersistedFieldKind::List(inner) => {
1140 out.push_str("list<");
1141 write_persisted_field_kind_summary(out, inner);
1142 out.push('>');
1143 }
1144 PersistedFieldKind::Set(inner) => {
1145 out.push_str("set<");
1146 write_persisted_field_kind_summary(out, inner);
1147 out.push('>');
1148 }
1149 PersistedFieldKind::Map { key, value } => {
1150 out.push_str("map<");
1151 write_persisted_field_kind_summary(out, key);
1152 out.push_str(", ");
1153 write_persisted_field_kind_summary(out, value);
1154 out.push('>');
1155 }
1156 PersistedFieldKind::Structured { .. } => {
1157 out.push_str("structured");
1158 }
1159 PersistedFieldKind::Account
1160 | PersistedFieldKind::Bool
1161 | PersistedFieldKind::Date
1162 | PersistedFieldKind::Duration
1163 | PersistedFieldKind::Float32
1164 | PersistedFieldKind::Float64
1165 | PersistedFieldKind::Int8
1166 | PersistedFieldKind::Int16
1167 | PersistedFieldKind::Int32
1168 | PersistedFieldKind::Int64
1169 | PersistedFieldKind::Int128
1170 | PersistedFieldKind::Principal
1171 | PersistedFieldKind::Subaccount
1172 | PersistedFieldKind::Timestamp
1173 | PersistedFieldKind::Nat8
1174 | PersistedFieldKind::Nat16
1175 | PersistedFieldKind::Nat32
1176 | PersistedFieldKind::Nat64
1177 | PersistedFieldKind::Nat128
1178 | PersistedFieldKind::Ulid
1179 | PersistedFieldKind::Unit => {
1180 unreachable!("plain persisted field kind labels return before recursive render")
1181 }
1182 PersistedFieldKind::NatBig { max_bytes } => {
1183 write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
1184 }
1185 }
1186}
1187
1188impl DescribeKindName for PersistedFieldKind {
1189 fn describe_kind_name(&self) -> Option<&'static str> {
1190 Some(match self {
1191 Self::Account => "account",
1192 Self::Bool => "bool",
1193 Self::Date => "date",
1194 Self::Duration => "duration",
1195 Self::Float32 => "float32",
1196 Self::Float64 => "float64",
1197 Self::Int8 => "int8",
1198 Self::Int16 => "int16",
1199 Self::Int32 => "int32",
1200 Self::Int64 => "int64",
1201 Self::Int128 => "int128",
1202 Self::Principal => "principal",
1203 Self::Subaccount => "subaccount",
1204 Self::Timestamp => "timestamp",
1205 Self::Nat8 => "nat8",
1206 Self::Nat16 => "nat16",
1207 Self::Nat32 => "nat32",
1208 Self::Nat64 => "nat64",
1209 Self::Nat128 => "nat128",
1210 Self::Ulid => "ulid",
1211 Self::Unit => "unit",
1212 Self::Blob { .. }
1213 | Self::Decimal { .. }
1214 | Self::Enum { .. }
1215 | Self::IntBig { .. }
1216 | Self::NatBig { .. }
1217 | Self::Text { .. }
1218 | Self::Relation { .. }
1219 | Self::List(_)
1220 | Self::Set(_)
1221 | Self::Map { .. }
1222 | Self::Structured { .. } => return None,
1223 })
1224 }
1225}
1226
1227#[cfg_attr(
1228 doc,
1229 doc = "Render one stable relation-strength label from persisted schema metadata."
1230)]
1231const fn summarize_persisted_relation_strength(
1232 strength: PersistedRelationStrength,
1233) -> &'static str {
1234 match strength {
1235 PersistedRelationStrength::Strong => "strong",
1236 PersistedRelationStrength::Weak => "weak",
1237 }
1238}
1239
1240#[cfg_attr(
1241 doc,
1242 doc = "Render one stable relation-strength label for field-kind summaries."
1243)]
1244const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
1245 match strength {
1246 RelationStrength::Strong => "strong",
1247 RelationStrength::Weak => "weak",
1248 }
1249}
1250
1251#[cfg(test)]
1256mod tests {
1257 use crate::{
1258 db::{
1259 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
1260 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
1261 relation::{RelationFieldCardinality, relation_field_metadata_for_model_iter},
1262 schema::{
1263 AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
1264 PersistedNestedLeafSnapshot, PersistedRelationStrength, PersistedSchemaSnapshot,
1265 SchemaFieldDefault, SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
1266 describe::{
1267 describe_entity_fields_with_persisted_schema, describe_entity_model,
1268 describe_entity_model_with_persisted_schema,
1269 },
1270 },
1271 },
1272 model::{
1273 entity::{EntityModel, PrimaryKeyModel},
1274 field::{
1275 FieldDatabaseDefault, FieldKind, FieldModel, FieldStorageDecode, LeafCodec,
1276 RelationStrength, ScalarCodec,
1277 },
1278 },
1279 types::EntityTag,
1280 };
1281 use candid::types::{CandidType, Label, Type, TypeInner};
1282
1283 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
1284 target_path: "entities::Target",
1285 target_entity_name: "Target",
1286 target_entity_tag: EntityTag::new(0xD001),
1287 target_store_path: "stores::Target",
1288 key_kind: &FieldKind::Ulid,
1289 strength: RelationStrength::Strong,
1290 };
1291 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1292 target_path: "entities::Account",
1293 target_entity_name: "Account",
1294 target_entity_tag: EntityTag::new(0xD002),
1295 target_store_path: "stores::Account",
1296 key_kind: &FieldKind::Nat64,
1297 strength: RelationStrength::Weak,
1298 };
1299 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1300 target_path: "entities::Team",
1301 target_entity_name: "Team",
1302 target_entity_tag: EntityTag::new(0xD003),
1303 target_store_path: "stores::Team",
1304 key_kind: &FieldKind::Text { max_len: None },
1305 strength: RelationStrength::Strong,
1306 };
1307 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
1308 FieldModel::generated("id", FieldKind::Ulid),
1309 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
1310 FieldModel::generated(
1311 "accounts",
1312 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
1313 ),
1314 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
1315 ];
1316 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
1317 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
1318 "entities::Source",
1319 "Source",
1320 &DESCRIBE_RELATION_FIELDS[0],
1321 0,
1322 &DESCRIBE_RELATION_FIELDS,
1323 &DESCRIBE_RELATION_INDEXES,
1324 );
1325 static DESCRIBE_COMPOSITE_PK_FIELDS: [FieldModel; 3] = [
1326 FieldModel::generated("tenant_id", FieldKind::Nat64),
1327 FieldModel::generated("local_id", FieldKind::Nat64),
1328 FieldModel::generated("label", FieldKind::Text { max_len: None }),
1329 ];
1330 static DESCRIBE_COMPOSITE_PK_FIELD_REFS: [&FieldModel; 2] = [
1331 &DESCRIBE_COMPOSITE_PK_FIELDS[0],
1332 &DESCRIBE_COMPOSITE_PK_FIELDS[1],
1333 ];
1334 static DESCRIBE_COMPOSITE_PK_MODEL: EntityModel = EntityModel::generated_with_primary_key_model(
1335 "entities::Composite",
1336 "Composite",
1337 PrimaryKeyModel::ordered(&DESCRIBE_COMPOSITE_PK_FIELD_REFS),
1338 0,
1339 &DESCRIBE_COMPOSITE_PK_FIELDS,
1340 &DESCRIBE_RELATION_INDEXES,
1341 );
1342
1343 fn expect_record_fields(ty: Type) -> Vec<String> {
1344 match ty.as_ref() {
1345 TypeInner::Record(fields) => fields
1346 .iter()
1347 .map(|field| match field.id.as_ref() {
1348 Label::Named(name) => name.clone(),
1349 other => panic!("expected named record field, got {other:?}"),
1350 })
1351 .collect(),
1352 other => panic!("expected candid record, got {other:?}"),
1353 }
1354 }
1355
1356 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
1357 match ty.as_ref() {
1358 TypeInner::Record(fields) => fields
1359 .iter()
1360 .find_map(|field| match field.id.as_ref() {
1361 Label::Named(name) if name == field_name => Some(field.ty.clone()),
1362 _ => None,
1363 })
1364 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
1365 other => panic!("expected candid record, got {other:?}"),
1366 }
1367 }
1368
1369 fn expect_variant_labels(ty: Type) -> Vec<String> {
1370 match ty.as_ref() {
1371 TypeInner::Variant(fields) => fields
1372 .iter()
1373 .map(|field| match field.id.as_ref() {
1374 Label::Named(name) => name.clone(),
1375 other => panic!("expected named variant label, got {other:?}"),
1376 })
1377 .collect(),
1378 other => panic!("expected candid variant, got {other:?}"),
1379 }
1380 }
1381
1382 #[test]
1383 fn entity_schema_description_candid_shape_is_stable() {
1384 let fields = expect_record_fields(EntitySchemaDescription::ty());
1385
1386 for field in [
1387 "entity_path",
1388 "entity_name",
1389 "primary_key",
1390 "primary_key_fields",
1391 "fields",
1392 "indexes",
1393 "relations",
1394 ] {
1395 assert!(
1396 fields.iter().any(|candidate| candidate == field),
1397 "EntitySchemaDescription must keep `{field}` field key",
1398 );
1399 }
1400 }
1401
1402 #[test]
1403 fn entity_field_description_candid_shape_is_stable() {
1404 let fields = expect_record_fields(EntityFieldDescription::ty());
1405
1406 for field in ["name", "slot", "kind", "primary_key", "queryable", "origin"] {
1407 assert!(
1408 fields.iter().any(|candidate| candidate == field),
1409 "EntityFieldDescription must keep `{field}` field key",
1410 );
1411 }
1412
1413 assert!(
1414 matches!(
1415 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
1416 TypeInner::Nat16
1417 ),
1418 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
1419 );
1420 }
1421
1422 #[test]
1423 fn entity_index_description_candid_shape_is_stable() {
1424 let fields = expect_record_fields(EntityIndexDescription::ty());
1425
1426 for field in ["name", "unique", "fields", "origin"] {
1427 assert!(
1428 fields.iter().any(|candidate| candidate == field),
1429 "EntityIndexDescription must keep `{field}` field key",
1430 );
1431 }
1432 }
1433
1434 #[test]
1435 fn entity_relation_description_candid_shape_is_stable() {
1436 let fields = expect_record_fields(EntityRelationDescription::ty());
1437
1438 for field in [
1439 "field",
1440 "target_path",
1441 "target_entity_name",
1442 "target_store_path",
1443 "strength",
1444 "cardinality",
1445 ] {
1446 assert!(
1447 fields.iter().any(|candidate| candidate == field),
1448 "EntityRelationDescription must keep `{field}` field key",
1449 );
1450 }
1451 }
1452
1453 #[test]
1454 fn relation_enum_variant_labels_are_stable() {
1455 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
1456 strength_labels.sort_unstable();
1457 assert_eq!(
1458 strength_labels,
1459 vec!["Strong".to_string(), "Weak".to_string()]
1460 );
1461
1462 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
1463 cardinality_labels.sort_unstable();
1464 assert_eq!(
1465 cardinality_labels,
1466 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
1467 );
1468 }
1469
1470 #[test]
1471 fn describe_fixture_constructors_stay_usable() {
1472 let payload = EntitySchemaDescription::new(
1473 "entities::User".to_string(),
1474 "User".to_string(),
1475 "id".to_string(),
1476 vec![EntityFieldDescription::new(
1477 "id".to_string(),
1478 Some(0),
1479 "ulid".to_string(),
1480 false,
1481 true,
1482 true,
1483 "generated".to_string(),
1484 )],
1485 vec![EntityIndexDescription::new(
1486 "idx_email".to_string(),
1487 true,
1488 vec!["email".to_string()],
1489 "generated".to_string(),
1490 )],
1491 vec![EntityRelationDescription::new(
1492 "account_id".to_string(),
1493 "entities::Account".to_string(),
1494 "Account".to_string(),
1495 "accounts".to_string(),
1496 EntityRelationStrength::Strong,
1497 EntityRelationCardinality::Single,
1498 )],
1499 );
1500
1501 assert_eq!(payload.entity_name(), "User");
1502 assert_eq!(payload.primary_key(), "id");
1503 assert_eq!(payload.primary_key_fields(), ["id".to_string()].as_slice());
1504 assert_eq!(payload.fields().len(), 1);
1505 assert_eq!(payload.indexes().len(), 1);
1506 assert_eq!(payload.relations().len(), 1);
1507 }
1508
1509 #[test]
1510 fn describe_entity_model_marks_all_composite_primary_key_fields() {
1511 let described = describe_entity_model(&DESCRIBE_COMPOSITE_PK_MODEL);
1512 let primary_key_fields = described
1513 .fields()
1514 .iter()
1515 .filter(|field| field.primary_key())
1516 .map(EntityFieldDescription::name)
1517 .collect::<Vec<_>>();
1518
1519 assert_eq!(described.primary_key(), "tenant_id, local_id");
1520 assert_eq!(
1521 described.primary_key_fields(),
1522 ["tenant_id".to_string(), "local_id".to_string()].as_slice(),
1523 );
1524 assert_eq!(primary_key_fields, ["tenant_id", "local_id"]);
1525 }
1526
1527 #[test]
1528 fn schema_describe_relations_match_relation_field_metadata() {
1529 let metadata =
1530 relation_field_metadata_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1531 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1532 let relations = described.relations();
1533
1534 assert_eq!(metadata.len(), relations.len());
1535
1536 for (metadata, relation) in metadata.iter().zip(relations) {
1537 assert_eq!(relation.field(), metadata.field_name());
1538 assert_eq!(relation.target_path(), metadata.target_path());
1539 assert_eq!(relation.target_entity_name(), metadata.target_entity_name());
1540 assert_eq!(relation.target_store_path(), metadata.target_store_path());
1541 assert_eq!(
1542 relation.strength(),
1543 match metadata.strength() {
1544 RelationStrength::Strong => EntityRelationStrength::Strong,
1545 RelationStrength::Weak => EntityRelationStrength::Weak,
1546 }
1547 );
1548 assert_eq!(
1549 relation.cardinality(),
1550 match metadata.cardinality() {
1551 RelationFieldCardinality::Single => EntityRelationCardinality::Single,
1552 RelationFieldCardinality::List => EntityRelationCardinality::List,
1553 RelationFieldCardinality::Set => EntityRelationCardinality::Set,
1554 }
1555 );
1556 }
1557 }
1558
1559 #[test]
1560 fn accepted_schema_describe_relations_use_persisted_relation_authority() {
1561 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1562 SchemaVersion::initial(),
1563 "entities::AcceptedSource".to_string(),
1564 "AcceptedSource".to_string(),
1565 FieldId::new(1),
1566 SchemaRowLayout::new(
1567 SchemaVersion::initial(),
1568 vec![
1569 (FieldId::new(1), SchemaFieldSlot::new(0)),
1570 (FieldId::new(2), SchemaFieldSlot::new(1)),
1571 ],
1572 ),
1573 vec![
1574 PersistedFieldSnapshot::new(
1575 FieldId::new(1),
1576 "id".to_string(),
1577 SchemaFieldSlot::new(0),
1578 PersistedFieldKind::Ulid,
1579 Vec::new(),
1580 false,
1581 SchemaFieldDefault::None,
1582 FieldStorageDecode::ByKind,
1583 LeafCodec::StructuralFallback,
1584 ),
1585 PersistedFieldSnapshot::new(
1586 FieldId::new(2),
1587 "accepted_targets".to_string(),
1588 SchemaFieldSlot::new(1),
1589 PersistedFieldKind::Set(Box::new(PersistedFieldKind::Relation {
1590 target_path: "accepted::Target".to_string(),
1591 target_entity_name: "AcceptedTarget".to_string(),
1592 target_entity_tag: EntityTag::new(0xD0A1),
1593 target_store_path: "accepted::TargetStore".to_string(),
1594 key_kind: Box::new(PersistedFieldKind::Nat128),
1595 strength: PersistedRelationStrength::Strong,
1596 })),
1597 Vec::new(),
1598 false,
1599 SchemaFieldDefault::None,
1600 FieldStorageDecode::ByKind,
1601 LeafCodec::StructuralFallback,
1602 ),
1603 ],
1604 ));
1605
1606 let described =
1607 describe_entity_model_with_persisted_schema(&DESCRIBE_RELATION_MODEL, &snapshot);
1608
1609 assert_eq!(described.entity_path(), "entities::AcceptedSource");
1610 assert_eq!(described.entity_name(), "AcceptedSource");
1611 assert_eq!(
1612 described.primary_key_fields(),
1613 ["id".to_string()].as_slice()
1614 );
1615 assert_eq!(described.relations().len(), 1);
1616
1617 let relation = &described.relations()[0];
1618 assert_eq!(relation.field(), "accepted_targets");
1619 assert_eq!(relation.target_path(), "accepted::Target");
1620 assert_eq!(relation.target_entity_name(), "AcceptedTarget");
1621 assert_eq!(relation.target_store_path(), "accepted::TargetStore");
1622 assert_eq!(relation.strength(), EntityRelationStrength::Strong);
1623 assert_eq!(relation.cardinality(), EntityRelationCardinality::Set);
1624 }
1625
1626 #[test]
1627 fn schema_describe_includes_text_max_len_contract() {
1628 static FIELDS: [FieldModel; 2] = [
1629 FieldModel::generated("id", FieldKind::Ulid),
1630 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1631 ];
1632 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1633 static MODEL: EntityModel = EntityModel::generated(
1634 "entities::BoundedName",
1635 "BoundedName",
1636 &FIELDS[0],
1637 0,
1638 &FIELDS,
1639 &INDEXES,
1640 );
1641
1642 let described = describe_entity_model(&MODEL);
1643 let name_field = described
1644 .fields()
1645 .iter()
1646 .find(|field| field.name() == "name")
1647 .expect("bounded text field should be described");
1648
1649 assert_eq!(name_field.kind(), "text(max_len=16)");
1650 }
1651
1652 #[test]
1653 fn schema_describe_preserves_fixed_width_numeric_kind_labels() {
1654 static FIELDS: [FieldModel; 7] = [
1655 FieldModel::generated("id", FieldKind::Ulid),
1656 FieldModel::generated("small_signed", FieldKind::Int8),
1657 FieldModel::generated("cell_x", FieldKind::Nat16),
1658 FieldModel::generated("large_signed", FieldKind::Int64),
1659 FieldModel::generated("large_unsigned", FieldKind::Nat64),
1660 FieldModel::generated("huge_signed", FieldKind::IntBig { max_bytes: 384 }),
1661 FieldModel::generated("huge_unsigned", FieldKind::NatBig { max_bytes: 512 }),
1662 ];
1663 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1664 static MODEL: EntityModel = EntityModel::generated(
1665 "entities::FixedWidthNumbers",
1666 "FixedWidthNumbers",
1667 &FIELDS[0],
1668 0,
1669 &FIELDS,
1670 &INDEXES,
1671 );
1672
1673 let described = describe_entity_model(&MODEL)
1674 .fields()
1675 .iter()
1676 .map(|field| (field.name().to_string(), field.kind().to_string()))
1677 .collect::<Vec<_>>();
1678
1679 assert!(described.contains(&("small_signed".to_string(), "int8".to_string())));
1680 assert!(described.contains(&("cell_x".to_string(), "nat16".to_string())));
1681 assert!(described.contains(&("large_signed".to_string(), "int64".to_string())));
1682 assert!(described.contains(&("large_unsigned".to_string(), "nat64".to_string())));
1683 assert!(described.contains(&(
1684 "huge_signed".to_string(),
1685 "int_big(max_bytes=384)".to_string()
1686 )));
1687 assert!(described.contains(&(
1688 "huge_unsigned".to_string(),
1689 "nat_big(max_bytes=512)".to_string()
1690 )));
1691 }
1692
1693 #[test]
1694 fn schema_describe_includes_generated_database_default_metadata() {
1695 static DEFAULT_PAYLOAD: &[u8] = &[0xFF, 0x01, 7, 0, 0, 0, 0, 0, 0, 0];
1696 static FIELDS: [FieldModel; 2] = [
1697 FieldModel::generated("id", FieldKind::Ulid),
1698 FieldModel::generated_with_storage_decode_nullability_write_policies_database_default_and_nested_fields(
1699 "score",
1700 FieldKind::Nat64,
1701 FieldStorageDecode::ByKind,
1702 false,
1703 None,
1704 None,
1705 FieldDatabaseDefault::EncodedSlotPayload(DEFAULT_PAYLOAD),
1706 &[],
1707 ),
1708 ];
1709 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1710 static MODEL: EntityModel = EntityModel::generated(
1711 "entities::DefaultedScore",
1712 "DefaultedScore",
1713 &FIELDS[0],
1714 0,
1715 &FIELDS,
1716 &INDEXES,
1717 );
1718
1719 let described = describe_entity_model(&MODEL);
1720 let score_field = described
1721 .fields()
1722 .iter()
1723 .find(|field| field.name() == "score")
1724 .expect("database-defaulted score field should be described");
1725
1726 assert_eq!(
1727 score_field.kind(),
1728 "nat64 default=slot_payload(bytes=10, sha256=37746b8fe16bb6b4)"
1729 );
1730 }
1731
1732 #[test]
1733 fn schema_describe_uses_accepted_top_level_field_metadata() {
1734 let id_slot = SchemaFieldSlot::new(0);
1735 let payload_slot = SchemaFieldSlot::new(7);
1736 let stale_payload_field_slot = SchemaFieldSlot::new(3);
1739 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1740 SchemaVersion::initial(),
1741 "entities::BlobEvent".to_string(),
1742 "BlobEvent".to_string(),
1743 FieldId::new(1),
1744 SchemaRowLayout::new(
1745 SchemaVersion::initial(),
1746 vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1747 ),
1748 vec![
1749 PersistedFieldSnapshot::new(
1750 FieldId::new(1),
1751 "id".to_string(),
1752 id_slot,
1753 PersistedFieldKind::Ulid,
1754 Vec::new(),
1755 false,
1756 SchemaFieldDefault::None,
1757 FieldStorageDecode::ByKind,
1758 LeafCodec::StructuralFallback,
1759 ),
1760 PersistedFieldSnapshot::new(
1761 FieldId::new(2),
1762 "payload".to_string(),
1763 stale_payload_field_slot,
1764 PersistedFieldKind::Blob { max_len: None },
1765 Vec::new(),
1766 false,
1767 SchemaFieldDefault::SlotPayload(vec![0x10, 0x20, 0x30]),
1768 FieldStorageDecode::ByKind,
1769 LeafCodec::StructuralFallback,
1770 ),
1771 ],
1772 ));
1773
1774 let described = describe_entity_fields_with_persisted_schema(&snapshot)
1775 .into_iter()
1776 .map(|field| {
1777 (
1778 field.name().to_string(),
1779 field.slot(),
1780 field.kind().to_string(),
1781 )
1782 })
1783 .collect::<Vec<_>>();
1784
1785 assert_eq!(
1786 described,
1787 vec![
1788 ("id".to_string(), Some(0), "ulid".to_string()),
1789 (
1790 "payload".to_string(),
1791 Some(7),
1792 "blob(unbounded) default=slot_payload(bytes=3, sha256=8e1336ab78ebe687)"
1793 .to_string()
1794 ),
1795 ],
1796 );
1797 }
1798
1799 #[test]
1800 fn schema_describe_preserves_accepted_fixed_width_numeric_kind_labels() {
1801 let id_slot = SchemaFieldSlot::new(0);
1802 let x_slot = SchemaFieldSlot::new(1);
1803 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1804 SchemaVersion::initial(),
1805 "entities::Grid".to_string(),
1806 "Grid".to_string(),
1807 FieldId::new(1),
1808 SchemaRowLayout::new(
1809 SchemaVersion::initial(),
1810 vec![(FieldId::new(1), id_slot), (FieldId::new(2), x_slot)],
1811 ),
1812 vec![
1813 PersistedFieldSnapshot::new(
1814 FieldId::new(1),
1815 "id".to_string(),
1816 id_slot,
1817 PersistedFieldKind::Ulid,
1818 Vec::new(),
1819 false,
1820 SchemaFieldDefault::None,
1821 FieldStorageDecode::ByKind,
1822 LeafCodec::StructuralFallback,
1823 ),
1824 PersistedFieldSnapshot::new(
1825 FieldId::new(2),
1826 "x".to_string(),
1827 x_slot,
1828 PersistedFieldKind::Nat16,
1829 Vec::new(),
1830 false,
1831 SchemaFieldDefault::None,
1832 FieldStorageDecode::ByKind,
1833 LeafCodec::Scalar(ScalarCodec::Nat64),
1834 ),
1835 ],
1836 ));
1837
1838 let described = describe_entity_fields_with_persisted_schema(&snapshot);
1839 let x = described
1840 .iter()
1841 .find(|field| field.name() == "x")
1842 .expect("accepted fixed-width field should be described");
1843
1844 assert_eq!(x.kind(), "nat16");
1845 }
1846
1847 #[test]
1848 fn schema_describe_uses_accepted_nested_leaf_metadata() {
1849 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1850 SchemaVersion::initial(),
1851 "entities::AcceptedProfile".to_string(),
1852 "AcceptedProfile".to_string(),
1853 FieldId::new(1),
1854 SchemaRowLayout::new(
1855 SchemaVersion::initial(),
1856 vec![
1857 (FieldId::new(1), SchemaFieldSlot::new(0)),
1858 (FieldId::new(2), SchemaFieldSlot::new(1)),
1859 ],
1860 ),
1861 vec![
1862 PersistedFieldSnapshot::new(
1863 FieldId::new(1),
1864 "id".to_string(),
1865 SchemaFieldSlot::new(0),
1866 PersistedFieldKind::Ulid,
1867 Vec::new(),
1868 false,
1869 SchemaFieldDefault::None,
1870 FieldStorageDecode::ByKind,
1871 LeafCodec::StructuralFallback,
1872 ),
1873 PersistedFieldSnapshot::new(
1874 FieldId::new(2),
1875 "profile".to_string(),
1876 SchemaFieldSlot::new(1),
1877 PersistedFieldKind::Structured { queryable: true },
1878 vec![PersistedNestedLeafSnapshot::new(
1879 vec!["rank".to_string()],
1880 PersistedFieldKind::Blob { max_len: None },
1881 false,
1882 FieldStorageDecode::ByKind,
1883 LeafCodec::Scalar(ScalarCodec::Blob),
1884 )],
1885 false,
1886 SchemaFieldDefault::None,
1887 FieldStorageDecode::Value,
1888 LeafCodec::StructuralFallback,
1889 ),
1890 ],
1891 ));
1892
1893 let described = describe_entity_fields_with_persisted_schema(&snapshot);
1894 let rank = described
1895 .iter()
1896 .find(|field| field.name() == "└─ rank")
1897 .expect("accepted nested leaf should be described");
1898
1899 assert_eq!(rank.slot(), None);
1900 assert_eq!(rank.kind(), "blob(unbounded)");
1901 assert!(rank.queryable());
1902 }
1903
1904 #[test]
1905 fn schema_describe_expands_generated_structured_field_leaves() {
1906 static NESTED_FIELDS: [FieldModel; 3] = [
1907 FieldModel::generated("name", FieldKind::Text { max_len: None }),
1908 FieldModel::generated("level", FieldKind::Nat64),
1909 FieldModel::generated("pid", FieldKind::Principal),
1910 ];
1911 static FIELDS: [FieldModel; 2] = [
1912 FieldModel::generated("id", FieldKind::Ulid),
1913 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1914 "mentor",
1915 FieldKind::Structured { queryable: false },
1916 FieldStorageDecode::Value,
1917 false,
1918 None,
1919 None,
1920 &NESTED_FIELDS,
1921 ),
1922 ];
1923 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1924 static MODEL: EntityModel = EntityModel::generated(
1925 "entities::Character",
1926 "Character",
1927 &FIELDS[0],
1928 0,
1929 &FIELDS,
1930 &INDEXES,
1931 );
1932
1933 let described = describe_entity_model(&MODEL);
1934 let described_fields = described
1935 .fields()
1936 .iter()
1937 .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1938 .collect::<Vec<_>>();
1939
1940 assert_eq!(
1941 described_fields,
1942 vec![
1943 ("id", Some(0), "ulid", true),
1944 ("mentor", Some(1), "structured", false),
1945 ("├─ name", None, "text(unbounded)", true),
1946 ("├─ level", None, "nat64", true),
1947 ("└─ pid", None, "principal", true),
1948 ],
1949 );
1950 }
1951}