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 1,
1321 &DESCRIBE_RELATION_FIELDS[0],
1322 0,
1323 &DESCRIBE_RELATION_FIELDS,
1324 &DESCRIBE_RELATION_INDEXES,
1325 );
1326 static DESCRIBE_COMPOSITE_PK_FIELDS: [FieldModel; 3] = [
1327 FieldModel::generated("tenant_id", FieldKind::Nat64),
1328 FieldModel::generated("local_id", FieldKind::Nat64),
1329 FieldModel::generated("label", FieldKind::Text { max_len: None }),
1330 ];
1331 static DESCRIBE_COMPOSITE_PK_FIELD_REFS: [&FieldModel; 2] = [
1332 &DESCRIBE_COMPOSITE_PK_FIELDS[0],
1333 &DESCRIBE_COMPOSITE_PK_FIELDS[1],
1334 ];
1335 static DESCRIBE_COMPOSITE_PK_MODEL: EntityModel = EntityModel::generated_with_primary_key_model(
1336 "entities::Composite",
1337 "Composite",
1338 1,
1339 PrimaryKeyModel::ordered(&DESCRIBE_COMPOSITE_PK_FIELD_REFS),
1340 0,
1341 &DESCRIBE_COMPOSITE_PK_FIELDS,
1342 &DESCRIBE_RELATION_INDEXES,
1343 );
1344
1345 fn expect_record_fields(ty: Type) -> Vec<String> {
1346 match ty.as_ref() {
1347 TypeInner::Record(fields) => fields
1348 .iter()
1349 .map(|field| match field.id.as_ref() {
1350 Label::Named(name) => name.clone(),
1351 other => panic!("expected named record field, got {other:?}"),
1352 })
1353 .collect(),
1354 other => panic!("expected candid record, got {other:?}"),
1355 }
1356 }
1357
1358 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
1359 match ty.as_ref() {
1360 TypeInner::Record(fields) => fields
1361 .iter()
1362 .find_map(|field| match field.id.as_ref() {
1363 Label::Named(name) if name == field_name => Some(field.ty.clone()),
1364 _ => None,
1365 })
1366 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
1367 other => panic!("expected candid record, got {other:?}"),
1368 }
1369 }
1370
1371 fn expect_variant_labels(ty: Type) -> Vec<String> {
1372 match ty.as_ref() {
1373 TypeInner::Variant(fields) => fields
1374 .iter()
1375 .map(|field| match field.id.as_ref() {
1376 Label::Named(name) => name.clone(),
1377 other => panic!("expected named variant label, got {other:?}"),
1378 })
1379 .collect(),
1380 other => panic!("expected candid variant, got {other:?}"),
1381 }
1382 }
1383
1384 #[test]
1385 fn entity_schema_description_candid_shape_is_stable() {
1386 let fields = expect_record_fields(EntitySchemaDescription::ty());
1387
1388 for field in [
1389 "entity_path",
1390 "entity_name",
1391 "primary_key",
1392 "primary_key_fields",
1393 "fields",
1394 "indexes",
1395 "relations",
1396 ] {
1397 assert!(
1398 fields.iter().any(|candidate| candidate == field),
1399 "EntitySchemaDescription must keep `{field}` field key",
1400 );
1401 }
1402 }
1403
1404 #[test]
1405 fn entity_field_description_candid_shape_is_stable() {
1406 let fields = expect_record_fields(EntityFieldDescription::ty());
1407
1408 for field in ["name", "slot", "kind", "primary_key", "queryable", "origin"] {
1409 assert!(
1410 fields.iter().any(|candidate| candidate == field),
1411 "EntityFieldDescription must keep `{field}` field key",
1412 );
1413 }
1414
1415 assert!(
1416 matches!(
1417 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
1418 TypeInner::Nat16
1419 ),
1420 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
1421 );
1422 }
1423
1424 #[test]
1425 fn entity_index_description_candid_shape_is_stable() {
1426 let fields = expect_record_fields(EntityIndexDescription::ty());
1427
1428 for field in ["name", "unique", "fields", "origin"] {
1429 assert!(
1430 fields.iter().any(|candidate| candidate == field),
1431 "EntityIndexDescription must keep `{field}` field key",
1432 );
1433 }
1434 }
1435
1436 #[test]
1437 fn entity_relation_description_candid_shape_is_stable() {
1438 let fields = expect_record_fields(EntityRelationDescription::ty());
1439
1440 for field in [
1441 "field",
1442 "target_path",
1443 "target_entity_name",
1444 "target_store_path",
1445 "strength",
1446 "cardinality",
1447 ] {
1448 assert!(
1449 fields.iter().any(|candidate| candidate == field),
1450 "EntityRelationDescription must keep `{field}` field key",
1451 );
1452 }
1453 }
1454
1455 #[test]
1456 fn relation_enum_variant_labels_are_stable() {
1457 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
1458 strength_labels.sort_unstable();
1459 assert_eq!(
1460 strength_labels,
1461 vec!["Strong".to_string(), "Weak".to_string()]
1462 );
1463
1464 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
1465 cardinality_labels.sort_unstable();
1466 assert_eq!(
1467 cardinality_labels,
1468 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
1469 );
1470 }
1471
1472 #[test]
1473 fn describe_fixture_constructors_stay_usable() {
1474 let payload = EntitySchemaDescription::new(
1475 "entities::User".to_string(),
1476 "User".to_string(),
1477 "id".to_string(),
1478 vec![EntityFieldDescription::new(
1479 "id".to_string(),
1480 Some(0),
1481 "ulid".to_string(),
1482 false,
1483 true,
1484 true,
1485 "generated".to_string(),
1486 )],
1487 vec![EntityIndexDescription::new(
1488 "idx_email".to_string(),
1489 true,
1490 vec!["email".to_string()],
1491 "generated".to_string(),
1492 )],
1493 vec![EntityRelationDescription::new(
1494 "account_id".to_string(),
1495 "entities::Account".to_string(),
1496 "Account".to_string(),
1497 "accounts".to_string(),
1498 EntityRelationStrength::Strong,
1499 EntityRelationCardinality::Single,
1500 )],
1501 );
1502
1503 assert_eq!(payload.entity_name(), "User");
1504 assert_eq!(payload.primary_key(), "id");
1505 assert_eq!(payload.primary_key_fields(), ["id".to_string()].as_slice());
1506 assert_eq!(payload.fields().len(), 1);
1507 assert_eq!(payload.indexes().len(), 1);
1508 assert_eq!(payload.relations().len(), 1);
1509 }
1510
1511 #[test]
1512 fn describe_entity_model_marks_all_composite_primary_key_fields() {
1513 let described = describe_entity_model(&DESCRIBE_COMPOSITE_PK_MODEL);
1514 let primary_key_fields = described
1515 .fields()
1516 .iter()
1517 .filter(|field| field.primary_key())
1518 .map(EntityFieldDescription::name)
1519 .collect::<Vec<_>>();
1520
1521 assert_eq!(described.primary_key(), "tenant_id, local_id");
1522 assert_eq!(
1523 described.primary_key_fields(),
1524 ["tenant_id".to_string(), "local_id".to_string()].as_slice(),
1525 );
1526 assert_eq!(primary_key_fields, ["tenant_id", "local_id"]);
1527 }
1528
1529 #[test]
1530 fn schema_describe_relations_match_relation_field_metadata() {
1531 let metadata =
1532 relation_field_metadata_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1533 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1534 let relations = described.relations();
1535
1536 assert_eq!(metadata.len(), relations.len());
1537
1538 for (metadata, relation) in metadata.iter().zip(relations) {
1539 assert_eq!(relation.field(), metadata.field_name());
1540 assert_eq!(relation.target_path(), metadata.target_path());
1541 assert_eq!(relation.target_entity_name(), metadata.target_entity_name());
1542 assert_eq!(relation.target_store_path(), metadata.target_store_path());
1543 assert_eq!(
1544 relation.strength(),
1545 match metadata.strength() {
1546 RelationStrength::Strong => EntityRelationStrength::Strong,
1547 RelationStrength::Weak => EntityRelationStrength::Weak,
1548 }
1549 );
1550 assert_eq!(
1551 relation.cardinality(),
1552 match metadata.cardinality() {
1553 RelationFieldCardinality::Single => EntityRelationCardinality::Single,
1554 RelationFieldCardinality::List => EntityRelationCardinality::List,
1555 RelationFieldCardinality::Set => EntityRelationCardinality::Set,
1556 }
1557 );
1558 }
1559 }
1560
1561 #[test]
1562 fn accepted_schema_describe_relations_use_persisted_relation_authority() {
1563 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1564 SchemaVersion::initial(),
1565 "entities::AcceptedSource".to_string(),
1566 "AcceptedSource".to_string(),
1567 FieldId::new(1),
1568 SchemaRowLayout::new(
1569 SchemaVersion::initial(),
1570 vec![
1571 (FieldId::new(1), SchemaFieldSlot::new(0)),
1572 (FieldId::new(2), SchemaFieldSlot::new(1)),
1573 ],
1574 ),
1575 vec![
1576 PersistedFieldSnapshot::new(
1577 FieldId::new(1),
1578 "id".to_string(),
1579 SchemaFieldSlot::new(0),
1580 PersistedFieldKind::Ulid,
1581 Vec::new(),
1582 false,
1583 SchemaFieldDefault::None,
1584 FieldStorageDecode::ByKind,
1585 LeafCodec::StructuralFallback,
1586 ),
1587 PersistedFieldSnapshot::new(
1588 FieldId::new(2),
1589 "accepted_targets".to_string(),
1590 SchemaFieldSlot::new(1),
1591 PersistedFieldKind::Set(Box::new(PersistedFieldKind::Relation {
1592 target_path: "accepted::Target".to_string(),
1593 target_entity_name: "AcceptedTarget".to_string(),
1594 target_entity_tag: EntityTag::new(0xD0A1),
1595 target_store_path: "accepted::TargetStore".to_string(),
1596 key_kind: Box::new(PersistedFieldKind::Nat128),
1597 strength: PersistedRelationStrength::Strong,
1598 })),
1599 Vec::new(),
1600 false,
1601 SchemaFieldDefault::None,
1602 FieldStorageDecode::ByKind,
1603 LeafCodec::StructuralFallback,
1604 ),
1605 ],
1606 ));
1607
1608 let described =
1609 describe_entity_model_with_persisted_schema(&DESCRIBE_RELATION_MODEL, &snapshot);
1610
1611 assert_eq!(described.entity_path(), "entities::AcceptedSource");
1612 assert_eq!(described.entity_name(), "AcceptedSource");
1613 assert_eq!(
1614 described.primary_key_fields(),
1615 ["id".to_string()].as_slice()
1616 );
1617 assert_eq!(described.relations().len(), 1);
1618
1619 let relation = &described.relations()[0];
1620 assert_eq!(relation.field(), "accepted_targets");
1621 assert_eq!(relation.target_path(), "accepted::Target");
1622 assert_eq!(relation.target_entity_name(), "AcceptedTarget");
1623 assert_eq!(relation.target_store_path(), "accepted::TargetStore");
1624 assert_eq!(relation.strength(), EntityRelationStrength::Strong);
1625 assert_eq!(relation.cardinality(), EntityRelationCardinality::Set);
1626 }
1627
1628 #[test]
1629 fn schema_describe_includes_text_max_len_contract() {
1630 static FIELDS: [FieldModel; 2] = [
1631 FieldModel::generated("id", FieldKind::Ulid),
1632 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1633 ];
1634 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1635 static MODEL: EntityModel = EntityModel::generated(
1636 "entities::BoundedName",
1637 "BoundedName",
1638 1,
1639 &FIELDS[0],
1640 0,
1641 &FIELDS,
1642 &INDEXES,
1643 );
1644
1645 let described = describe_entity_model(&MODEL);
1646 let name_field = described
1647 .fields()
1648 .iter()
1649 .find(|field| field.name() == "name")
1650 .expect("bounded text field should be described");
1651
1652 assert_eq!(name_field.kind(), "text(max_len=16)");
1653 }
1654
1655 #[test]
1656 fn schema_describe_preserves_fixed_width_numeric_kind_labels() {
1657 static FIELDS: [FieldModel; 7] = [
1658 FieldModel::generated("id", FieldKind::Ulid),
1659 FieldModel::generated("small_signed", FieldKind::Int8),
1660 FieldModel::generated("cell_x", FieldKind::Nat16),
1661 FieldModel::generated("large_signed", FieldKind::Int64),
1662 FieldModel::generated("large_unsigned", FieldKind::Nat64),
1663 FieldModel::generated("huge_signed", FieldKind::IntBig { max_bytes: 384 }),
1664 FieldModel::generated("huge_unsigned", FieldKind::NatBig { max_bytes: 512 }),
1665 ];
1666 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1667 static MODEL: EntityModel = EntityModel::generated(
1668 "entities::FixedWidthNumbers",
1669 "FixedWidthNumbers",
1670 1,
1671 &FIELDS[0],
1672 0,
1673 &FIELDS,
1674 &INDEXES,
1675 );
1676
1677 let described = describe_entity_model(&MODEL)
1678 .fields()
1679 .iter()
1680 .map(|field| (field.name().to_string(), field.kind().to_string()))
1681 .collect::<Vec<_>>();
1682
1683 assert!(described.contains(&("small_signed".to_string(), "int8".to_string())));
1684 assert!(described.contains(&("cell_x".to_string(), "nat16".to_string())));
1685 assert!(described.contains(&("large_signed".to_string(), "int64".to_string())));
1686 assert!(described.contains(&("large_unsigned".to_string(), "nat64".to_string())));
1687 assert!(described.contains(&(
1688 "huge_signed".to_string(),
1689 "int_big(max_bytes=384)".to_string()
1690 )));
1691 assert!(described.contains(&(
1692 "huge_unsigned".to_string(),
1693 "nat_big(max_bytes=512)".to_string()
1694 )));
1695 }
1696
1697 #[test]
1698 fn schema_describe_includes_generated_database_default_metadata() {
1699 static DEFAULT_PAYLOAD: &[u8] = &[0xFF, 0x01, 7, 0, 0, 0, 0, 0, 0, 0];
1700 static FIELDS: [FieldModel; 2] = [
1701 FieldModel::generated("id", FieldKind::Ulid),
1702 FieldModel::generated_with_storage_decode_nullability_write_policies_database_default_and_nested_fields(
1703 "score",
1704 FieldKind::Nat64,
1705 FieldStorageDecode::ByKind,
1706 false,
1707 None,
1708 None,
1709 FieldDatabaseDefault::EncodedSlotPayload(DEFAULT_PAYLOAD),
1710 &[],
1711 ),
1712 ];
1713 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1714 static MODEL: EntityModel = EntityModel::generated(
1715 "entities::DefaultedScore",
1716 "DefaultedScore",
1717 1,
1718 &FIELDS[0],
1719 0,
1720 &FIELDS,
1721 &INDEXES,
1722 );
1723
1724 let described = describe_entity_model(&MODEL);
1725 let score_field = described
1726 .fields()
1727 .iter()
1728 .find(|field| field.name() == "score")
1729 .expect("database-defaulted score field should be described");
1730
1731 assert_eq!(
1732 score_field.kind(),
1733 "nat64 default=slot_payload(bytes=10, sha256=37746b8fe16bb6b4)"
1734 );
1735 }
1736
1737 #[test]
1738 fn schema_describe_uses_accepted_top_level_field_metadata() {
1739 let id_slot = SchemaFieldSlot::new(0);
1740 let payload_slot = SchemaFieldSlot::new(7);
1741 let stale_payload_field_slot = SchemaFieldSlot::new(3);
1744 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1745 SchemaVersion::initial(),
1746 "entities::BlobEvent".to_string(),
1747 "BlobEvent".to_string(),
1748 FieldId::new(1),
1749 SchemaRowLayout::new(
1750 SchemaVersion::initial(),
1751 vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1752 ),
1753 vec![
1754 PersistedFieldSnapshot::new(
1755 FieldId::new(1),
1756 "id".to_string(),
1757 id_slot,
1758 PersistedFieldKind::Ulid,
1759 Vec::new(),
1760 false,
1761 SchemaFieldDefault::None,
1762 FieldStorageDecode::ByKind,
1763 LeafCodec::StructuralFallback,
1764 ),
1765 PersistedFieldSnapshot::new(
1766 FieldId::new(2),
1767 "payload".to_string(),
1768 stale_payload_field_slot,
1769 PersistedFieldKind::Blob { max_len: None },
1770 Vec::new(),
1771 false,
1772 SchemaFieldDefault::SlotPayload(vec![0x10, 0x20, 0x30]),
1773 FieldStorageDecode::ByKind,
1774 LeafCodec::StructuralFallback,
1775 ),
1776 ],
1777 ));
1778
1779 let described = describe_entity_fields_with_persisted_schema(&snapshot)
1780 .into_iter()
1781 .map(|field| {
1782 (
1783 field.name().to_string(),
1784 field.slot(),
1785 field.kind().to_string(),
1786 )
1787 })
1788 .collect::<Vec<_>>();
1789
1790 assert_eq!(
1791 described,
1792 vec![
1793 ("id".to_string(), Some(0), "ulid".to_string()),
1794 (
1795 "payload".to_string(),
1796 Some(7),
1797 "blob(unbounded) default=slot_payload(bytes=3, sha256=8e1336ab78ebe687)"
1798 .to_string()
1799 ),
1800 ],
1801 );
1802 }
1803
1804 #[test]
1805 fn schema_describe_preserves_accepted_fixed_width_numeric_kind_labels() {
1806 let id_slot = SchemaFieldSlot::new(0);
1807 let x_slot = SchemaFieldSlot::new(1);
1808 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1809 SchemaVersion::initial(),
1810 "entities::Grid".to_string(),
1811 "Grid".to_string(),
1812 FieldId::new(1),
1813 SchemaRowLayout::new(
1814 SchemaVersion::initial(),
1815 vec![(FieldId::new(1), id_slot), (FieldId::new(2), x_slot)],
1816 ),
1817 vec![
1818 PersistedFieldSnapshot::new(
1819 FieldId::new(1),
1820 "id".to_string(),
1821 id_slot,
1822 PersistedFieldKind::Ulid,
1823 Vec::new(),
1824 false,
1825 SchemaFieldDefault::None,
1826 FieldStorageDecode::ByKind,
1827 LeafCodec::StructuralFallback,
1828 ),
1829 PersistedFieldSnapshot::new(
1830 FieldId::new(2),
1831 "x".to_string(),
1832 x_slot,
1833 PersistedFieldKind::Nat16,
1834 Vec::new(),
1835 false,
1836 SchemaFieldDefault::None,
1837 FieldStorageDecode::ByKind,
1838 LeafCodec::Scalar(ScalarCodec::Nat64),
1839 ),
1840 ],
1841 ));
1842
1843 let described = describe_entity_fields_with_persisted_schema(&snapshot);
1844 let x = described
1845 .iter()
1846 .find(|field| field.name() == "x")
1847 .expect("accepted fixed-width field should be described");
1848
1849 assert_eq!(x.kind(), "nat16");
1850 }
1851
1852 #[test]
1853 fn schema_describe_uses_accepted_nested_leaf_metadata() {
1854 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1855 SchemaVersion::initial(),
1856 "entities::AcceptedProfile".to_string(),
1857 "AcceptedProfile".to_string(),
1858 FieldId::new(1),
1859 SchemaRowLayout::new(
1860 SchemaVersion::initial(),
1861 vec![
1862 (FieldId::new(1), SchemaFieldSlot::new(0)),
1863 (FieldId::new(2), SchemaFieldSlot::new(1)),
1864 ],
1865 ),
1866 vec![
1867 PersistedFieldSnapshot::new(
1868 FieldId::new(1),
1869 "id".to_string(),
1870 SchemaFieldSlot::new(0),
1871 PersistedFieldKind::Ulid,
1872 Vec::new(),
1873 false,
1874 SchemaFieldDefault::None,
1875 FieldStorageDecode::ByKind,
1876 LeafCodec::StructuralFallback,
1877 ),
1878 PersistedFieldSnapshot::new(
1879 FieldId::new(2),
1880 "profile".to_string(),
1881 SchemaFieldSlot::new(1),
1882 PersistedFieldKind::Structured { queryable: true },
1883 vec![PersistedNestedLeafSnapshot::new(
1884 vec!["rank".to_string()],
1885 PersistedFieldKind::Blob { max_len: None },
1886 false,
1887 FieldStorageDecode::ByKind,
1888 LeafCodec::Scalar(ScalarCodec::Blob),
1889 )],
1890 false,
1891 SchemaFieldDefault::None,
1892 FieldStorageDecode::Value,
1893 LeafCodec::StructuralFallback,
1894 ),
1895 ],
1896 ));
1897
1898 let described = describe_entity_fields_with_persisted_schema(&snapshot);
1899 let rank = described
1900 .iter()
1901 .find(|field| field.name() == "└─ rank")
1902 .expect("accepted nested leaf should be described");
1903
1904 assert_eq!(rank.slot(), None);
1905 assert_eq!(rank.kind(), "blob(unbounded)");
1906 assert!(rank.queryable());
1907 }
1908
1909 #[test]
1910 fn schema_describe_expands_generated_structured_field_leaves() {
1911 static NESTED_FIELDS: [FieldModel; 3] = [
1912 FieldModel::generated("name", FieldKind::Text { max_len: None }),
1913 FieldModel::generated("level", FieldKind::Nat64),
1914 FieldModel::generated("pid", FieldKind::Principal),
1915 ];
1916 static FIELDS: [FieldModel; 2] = [
1917 FieldModel::generated("id", FieldKind::Ulid),
1918 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1919 "mentor",
1920 FieldKind::Structured { queryable: false },
1921 FieldStorageDecode::Value,
1922 false,
1923 None,
1924 None,
1925 &NESTED_FIELDS,
1926 ),
1927 ];
1928 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1929 static MODEL: EntityModel = EntityModel::generated(
1930 "entities::Character",
1931 "Character",
1932 1,
1933 &FIELDS[0],
1934 0,
1935 &FIELDS,
1936 &INDEXES,
1937 );
1938
1939 let described = describe_entity_model(&MODEL);
1940 let described_fields = described
1941 .fields()
1942 .iter()
1943 .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1944 .collect::<Vec<_>>();
1945
1946 assert_eq!(
1947 described_fields,
1948 vec![
1949 ("id", Some(0), "ulid", true),
1950 ("mentor", Some(1), "structured", false),
1951 ("├─ name", None, "text(unbounded)", true),
1952 ("├─ level", None, "nat64", true),
1953 ("└─ pid", None, "principal", true),
1954 ],
1955 );
1956 }
1957}