1use crate::{
7 db::{
8 relation::{
9 RelationDescriptor, RelationDescriptorCardinality, relation_descriptors_for_model_iter,
10 },
11 schema::{
12 AcceptedSchemaSnapshot, PersistedFieldKind, PersistedIndexKeyItemSnapshot,
13 PersistedIndexKeySnapshot, PersistedNestedLeafSnapshot, PersistedRelationStrength,
14 SchemaFieldDefault, SchemaFieldSlot, field_type_from_persisted_kind,
15 },
16 },
17 model::{
18 entity::EntityModel,
19 field::{FieldDatabaseDefault, FieldKind, FieldModel, RelationStrength},
20 },
21};
22use candid::CandidType;
23use serde::Deserialize;
24use sha2::{Digest, Sha256};
25use std::fmt::Write;
26
27const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
28
29#[cfg_attr(
30 doc,
31 doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
32)]
33#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
34pub struct EntitySchemaDescription {
35 pub(crate) entity_path: String,
36 pub(crate) entity_name: String,
37 pub(crate) primary_key: String,
38 pub(crate) 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_with_parts(
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 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_with_parts(
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 model,
447 )
448}
449
450fn describe_entity_model_with_parts(
454 entity_path: &str,
455 entity_name: &str,
456 primary_key: &str,
457 primary_key_fields: Vec<String>,
458 fields: Vec<EntityFieldDescription>,
459 indexes: Vec<EntityIndexDescription>,
460 model: &EntityModel,
461) -> EntitySchemaDescription {
462 let relations = relation_descriptors_for_model_iter(model)
463 .map(relation_description_from_descriptor)
464 .collect();
465
466 EntitySchemaDescription::new_with_primary_key_fields(
467 entity_path.to_string(),
468 entity_name.to_string(),
469 primary_key.to_string(),
470 primary_key_fields,
471 fields,
472 indexes,
473 relations,
474 )
475}
476
477fn primary_key_field_names_from_model(model: &EntityModel) -> Vec<String> {
478 model
479 .primary_key_model()
480 .fields()
481 .iter()
482 .map(|field| field.name.to_string())
483 .collect()
484}
485
486fn render_primary_key_fields(fields: &[String]) -> String {
487 fields.join(", ")
488}
489
490fn describe_entity_indexes_from_model(model: &EntityModel) -> Vec<EntityIndexDescription> {
491 let mut indexes = Vec::with_capacity(model.indexes.len());
492 for index in model.indexes {
493 indexes.push(EntityIndexDescription::new(
494 index.name().to_string(),
495 index.is_unique(),
496 index
497 .fields()
498 .iter()
499 .map(|field| (*field).to_string())
500 .collect(),
501 "generated".to_string(),
502 ));
503 }
504
505 indexes
506}
507
508fn describe_entity_indexes_with_persisted_schema(
509 schema: &AcceptedSchemaSnapshot,
510) -> Vec<EntityIndexDescription> {
511 schema
512 .persisted_snapshot()
513 .indexes()
514 .iter()
515 .map(|index| {
516 EntityIndexDescription::new(
517 index.name().to_string(),
518 index.unique(),
519 describe_persisted_index_fields(index.key()),
520 if index.generated() {
521 "generated".to_string()
522 } else {
523 "ddl".to_string()
524 },
525 )
526 })
527 .collect()
528}
529
530fn describe_persisted_index_fields(key: &PersistedIndexKeySnapshot) -> Vec<String> {
531 match key {
532 PersistedIndexKeySnapshot::FieldPath(paths) => paths
533 .iter()
534 .map(|field_path| field_path.path().join("."))
535 .collect(),
536 PersistedIndexKeySnapshot::Items(items) => items
537 .iter()
538 .map(|item| match item {
539 PersistedIndexKeyItemSnapshot::FieldPath(field_path) => field_path.path().join("."),
540 PersistedIndexKeyItemSnapshot::Expression(expression) => {
541 expression.canonical_text().to_string()
542 }
543 })
544 .collect(),
545 }
546}
547
548#[must_use]
552pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
553 describe_entity_fields_with_slot_lookup(model, |slot, _field| {
554 Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
555 })
556}
557
558#[cfg_attr(
559 doc,
560 doc = "Build field descriptors using accepted persisted schema slot metadata."
561)]
562#[must_use]
563pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
564 schema: &AcceptedSchemaSnapshot,
565) -> Vec<EntityFieldDescription> {
566 let snapshot = schema.persisted_snapshot();
567 let mut fields = Vec::with_capacity(snapshot.fields().len());
568
569 for field in snapshot.fields() {
572 let primary_key = snapshot.primary_key_field_ids().contains(&field.id());
573 let slot = snapshot
574 .row_layout()
575 .slot_for_field(field.id())
576 .map(SchemaFieldSlot::get);
577 let mut kind = summarize_persisted_field_kind(field.kind());
578 write_schema_default_summary(&mut kind, field.default());
579 let metadata = DescribeFieldMetadata::new(
580 kind,
581 field.nullable(),
582 field_type_from_persisted_kind(field.kind())
583 .value_kind()
584 .is_queryable(),
585 field_origin_label(field.generated()),
586 );
587
588 push_described_field_row(&mut fields, field.name(), slot, primary_key, None, metadata);
589
590 if !field.nested_leaves().is_empty() {
591 describe_persisted_nested_leaves(
592 &mut fields,
593 field.nested_leaves(),
594 field_origin_label(field.generated()),
595 );
596 }
597 }
598
599 fields
600}
601
602fn describe_entity_fields_with_slot_lookup(
606 model: &EntityModel,
607 mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
608) -> Vec<EntityFieldDescription> {
609 let mut fields = Vec::with_capacity(model.fields.len());
610 let primary_key_fields = primary_key_field_names_from_model(model);
611
612 for (slot, field) in model.fields.iter().enumerate() {
613 let primary_key = primary_key_fields
614 .iter()
615 .any(|primary_key_field| primary_key_field == field.name);
616 describe_field_recursive(
617 &mut fields,
618 field.name,
619 slot_for_field(slot, field),
620 field,
621 primary_key,
622 None,
623 None,
624 );
625 }
626
627 fields
628}
629
630struct DescribeFieldMetadata {
639 kind: String,
640 nullable: bool,
641 queryable: bool,
642 origin: String,
643}
644
645impl DescribeFieldMetadata {
646 const fn new(kind: String, nullable: bool, queryable: bool, origin: String) -> Self {
648 Self {
649 kind,
650 nullable,
651 queryable,
652 origin,
653 }
654 }
655}
656
657fn describe_field_recursive(
660 fields: &mut Vec<EntityFieldDescription>,
661 name: &str,
662 slot: Option<u16>,
663 field: &FieldModel,
664 primary_key: bool,
665 tree_prefix: Option<&'static str>,
666 metadata_override: Option<DescribeFieldMetadata>,
667) {
668 let metadata = metadata_override.unwrap_or_else(|| {
669 let mut kind = summarize_field_kind(&field.kind);
670 write_model_default_summary(&mut kind, field.database_default());
671
672 DescribeFieldMetadata::new(
673 kind,
674 field.nullable(),
675 field.kind.value_kind().is_queryable(),
676 "generated".to_string(),
677 )
678 });
679
680 push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
681 describe_generated_nested_fields(fields, field.nested_fields());
682}
683
684fn push_described_field_row(
687 fields: &mut Vec<EntityFieldDescription>,
688 name: &str,
689 slot: Option<u16>,
690 primary_key: bool,
691 tree_prefix: Option<&'static str>,
692 metadata: DescribeFieldMetadata,
693) {
694 let display_name = if let Some(prefix) = tree_prefix {
697 format!("{prefix}{name}")
698 } else {
699 name.to_string()
700 };
701
702 fields.push(EntityFieldDescription::new(
703 display_name,
704 slot,
705 metadata.kind,
706 metadata.nullable,
707 primary_key,
708 metadata.queryable,
709 metadata.origin,
710 ));
711}
712
713fn describe_generated_nested_fields(
717 fields: &mut Vec<EntityFieldDescription>,
718 nested_fields: &[FieldModel],
719) {
720 for (index, nested) in nested_fields.iter().enumerate() {
721 let prefix = if index + 1 == nested_fields.len() {
722 "└─ "
723 } else {
724 "├─ "
725 };
726 describe_field_recursive(
727 fields,
728 nested.name(),
729 None,
730 nested,
731 false,
732 Some(prefix),
733 None,
734 );
735 }
736}
737
738fn describe_persisted_nested_leaves(
741 fields: &mut Vec<EntityFieldDescription>,
742 nested_leaves: &[PersistedNestedLeafSnapshot],
743 origin: String,
744) {
745 for (index, leaf) in nested_leaves.iter().enumerate() {
746 let prefix = if index + 1 == nested_leaves.len() {
747 "└─ "
748 } else {
749 "├─ "
750 };
751 let name = leaf.path().last().map_or("", String::as_str);
752 let metadata = DescribeFieldMetadata::new(
753 summarize_persisted_field_kind(leaf.kind()),
754 leaf.nullable(),
755 field_type_from_persisted_kind(leaf.kind())
756 .value_kind()
757 .is_queryable(),
758 origin.clone(),
759 );
760
761 push_described_field_row(fields, name, None, false, Some(prefix), metadata);
762 }
763}
764
765fn field_origin_label(generated: bool) -> String {
766 if generated {
767 "generated".to_string()
768 } else {
769 "ddl".to_string()
770 }
771}
772
773fn relation_description_from_descriptor(
775 descriptor: RelationDescriptor,
776) -> EntityRelationDescription {
777 let strength = match descriptor.strength() {
778 RelationStrength::Strong => EntityRelationStrength::Strong,
779 RelationStrength::Weak => EntityRelationStrength::Weak,
780 };
781
782 let cardinality = match descriptor.cardinality() {
783 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
784 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
785 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
786 };
787
788 EntityRelationDescription::new(
789 descriptor.field_name().to_string(),
790 descriptor.target_path().to_string(),
791 descriptor.target_entity_name().to_string(),
792 descriptor.target_store_path().to_string(),
793 strength,
794 cardinality,
795 )
796}
797
798#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
799fn summarize_field_kind(kind: &FieldKind) -> String {
800 let mut out = String::new();
801 write_field_kind_summary(&mut out, kind);
802
803 out
804}
805
806fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
809 if let Some(name) = kind.describe_kind_name() {
810 out.push_str(name);
811 return;
812 }
813
814 match kind {
815 FieldKind::Blob { max_len } => {
816 write_length_bounded_field_kind_summary(out, "blob", *max_len);
817 }
818 FieldKind::Decimal { scale } => {
819 let _ = write!(out, "decimal(scale={scale})");
820 }
821 FieldKind::IntBig { max_bytes } => {
822 write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
823 }
824 FieldKind::Enum { path, .. } => {
825 out.push_str("enum(");
826 out.push_str(path);
827 out.push(')');
828 }
829 FieldKind::Text { max_len } => {
830 write_length_bounded_field_kind_summary(out, "text", *max_len);
831 }
832 FieldKind::Relation {
833 target_entity_name,
834 key_kind,
835 strength,
836 ..
837 } => {
838 out.push_str("relation(target=");
839 out.push_str(target_entity_name);
840 out.push_str(", key=");
841 write_field_kind_summary(out, key_kind);
842 out.push_str(", strength=");
843 out.push_str(summarize_relation_strength(*strength));
844 out.push(')');
845 }
846 FieldKind::List(inner) => {
847 out.push_str("list<");
848 write_field_kind_summary(out, inner);
849 out.push('>');
850 }
851 FieldKind::Set(inner) => {
852 out.push_str("set<");
853 write_field_kind_summary(out, inner);
854 out.push('>');
855 }
856 FieldKind::Map { key, value } => {
857 out.push_str("map<");
858 write_field_kind_summary(out, key);
859 out.push_str(", ");
860 write_field_kind_summary(out, value);
861 out.push('>');
862 }
863 FieldKind::Structured { .. } => {
864 out.push_str("structured");
865 }
866 FieldKind::Account
867 | FieldKind::Bool
868 | FieldKind::Date
869 | FieldKind::Duration
870 | FieldKind::Float32
871 | FieldKind::Float64
872 | FieldKind::Int8
873 | FieldKind::Int16
874 | FieldKind::Int32
875 | FieldKind::Int64
876 | FieldKind::Int128
877 | FieldKind::Principal
878 | FieldKind::Subaccount
879 | FieldKind::Timestamp
880 | FieldKind::Nat8
881 | FieldKind::Nat16
882 | FieldKind::Nat32
883 | FieldKind::Nat64
884 | FieldKind::Nat128
885 | FieldKind::Ulid
886 | FieldKind::Unit => unreachable!("plain field kind labels return before recursive render"),
887 FieldKind::NatBig { max_bytes } => {
888 write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
889 }
890 }
891}
892
893trait DescribeKindName {
894 fn describe_kind_name(&self) -> Option<&'static str>;
895}
896
897impl DescribeKindName for FieldKind {
898 fn describe_kind_name(&self) -> Option<&'static str> {
899 Some(match self {
900 Self::Account => "account",
901 Self::Bool => "bool",
902 Self::Date => "date",
903 Self::Duration => "duration",
904 Self::Float32 => "float32",
905 Self::Float64 => "float64",
906 Self::Int8 => "int8",
907 Self::Int16 => "int16",
908 Self::Int32 => "int32",
909 Self::Int64 => "int64",
910 Self::Int128 => "int128",
911 Self::Principal => "principal",
912 Self::Subaccount => "subaccount",
913 Self::Timestamp => "timestamp",
914 Self::Nat8 => "nat8",
915 Self::Nat16 => "nat16",
916 Self::Nat32 => "nat32",
917 Self::Nat64 => "nat64",
918 Self::Nat128 => "nat128",
919 Self::Ulid => "ulid",
920 Self::Unit => "unit",
921 Self::Blob { .. }
922 | Self::Decimal { .. }
923 | Self::Enum { .. }
924 | Self::IntBig { .. }
925 | Self::NatBig { .. }
926 | Self::Text { .. }
927 | Self::Relation { .. }
928 | Self::List(_)
929 | Self::Set(_)
930 | Self::Map { .. }
931 | Self::Structured { .. } => return None,
932 })
933 }
934}
935
936fn write_length_bounded_field_kind_summary(
940 out: &mut String,
941 kind_name: &str,
942 max_len: Option<u32>,
943) {
944 out.push_str(kind_name);
945 if let Some(max_len) = max_len {
946 out.push_str("(max_len=");
947 out.push_str(&max_len.to_string());
948 out.push(')');
949 } else {
950 out.push_str("(unbounded)");
951 }
952}
953
954fn write_byte_bounded_field_kind_summary(out: &mut String, kind_name: &str, max_bytes: u32) {
955 out.push_str(kind_name);
956 out.push_str("(max_bytes=");
957 out.push_str(&max_bytes.to_string());
958 out.push(')');
959}
960
961fn write_model_default_summary(out: &mut String, default: FieldDatabaseDefault) {
965 match default {
966 FieldDatabaseDefault::None => {}
967 FieldDatabaseDefault::EncodedSlotPayload(payload) => {
968 write_encoded_default_payload_summary(out, payload);
969 }
970 }
971}
972
973fn write_schema_default_summary(out: &mut String, default: &SchemaFieldDefault) {
976 if let Some(payload) = default.slot_payload() {
977 write_encoded_default_payload_summary(out, payload);
978 }
979}
980
981fn write_encoded_default_payload_summary(out: &mut String, payload: &[u8]) {
985 let _ = write!(
986 out,
987 " default=slot_payload(bytes={}, sha256={})",
988 payload.len(),
989 short_default_payload_fingerprint(payload),
990 );
991}
992
993fn short_default_payload_fingerprint(payload: &[u8]) -> String {
994 let digest = Sha256::digest(payload);
995 let mut out = String::with_capacity(16);
996 for byte in &digest[..8] {
997 let _ = write!(out, "{byte:02x}");
998 }
999 out
1000}
1001
1002#[cfg_attr(
1003 doc,
1004 doc = "Render one stable field-kind label from accepted persisted schema metadata."
1005)]
1006fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
1007 let mut out = String::new();
1008 write_persisted_field_kind_summary(&mut out, kind);
1009
1010 out
1011}
1012
1013fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
1017 if let Some(name) = kind.describe_kind_name() {
1018 out.push_str(name);
1019 return;
1020 }
1021
1022 match kind {
1023 PersistedFieldKind::Blob { max_len } => {
1024 write_length_bounded_field_kind_summary(out, "blob", *max_len);
1025 }
1026 PersistedFieldKind::Decimal { scale } => {
1027 let _ = write!(out, "decimal(scale={scale})");
1028 }
1029 PersistedFieldKind::IntBig { max_bytes } => {
1030 write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
1031 }
1032 PersistedFieldKind::Enum { path, .. } => {
1033 out.push_str("enum(");
1034 out.push_str(path);
1035 out.push(')');
1036 }
1037 PersistedFieldKind::Text { max_len } => {
1038 write_length_bounded_field_kind_summary(out, "text", *max_len);
1039 }
1040 PersistedFieldKind::Relation {
1041 target_entity_name,
1042 key_kind,
1043 strength,
1044 ..
1045 } => {
1046 out.push_str("relation(target=");
1047 out.push_str(target_entity_name);
1048 out.push_str(", key=");
1049 write_persisted_field_kind_summary(out, key_kind);
1050 out.push_str(", strength=");
1051 out.push_str(summarize_persisted_relation_strength(*strength));
1052 out.push(')');
1053 }
1054 PersistedFieldKind::List(inner) => {
1055 out.push_str("list<");
1056 write_persisted_field_kind_summary(out, inner);
1057 out.push('>');
1058 }
1059 PersistedFieldKind::Set(inner) => {
1060 out.push_str("set<");
1061 write_persisted_field_kind_summary(out, inner);
1062 out.push('>');
1063 }
1064 PersistedFieldKind::Map { key, value } => {
1065 out.push_str("map<");
1066 write_persisted_field_kind_summary(out, key);
1067 out.push_str(", ");
1068 write_persisted_field_kind_summary(out, value);
1069 out.push('>');
1070 }
1071 PersistedFieldKind::Structured { .. } => {
1072 out.push_str("structured");
1073 }
1074 PersistedFieldKind::Account
1075 | PersistedFieldKind::Bool
1076 | PersistedFieldKind::Date
1077 | PersistedFieldKind::Duration
1078 | PersistedFieldKind::Float32
1079 | PersistedFieldKind::Float64
1080 | PersistedFieldKind::Int8
1081 | PersistedFieldKind::Int16
1082 | PersistedFieldKind::Int32
1083 | PersistedFieldKind::Int64
1084 | PersistedFieldKind::Int128
1085 | PersistedFieldKind::Principal
1086 | PersistedFieldKind::Subaccount
1087 | PersistedFieldKind::Timestamp
1088 | PersistedFieldKind::Nat8
1089 | PersistedFieldKind::Nat16
1090 | PersistedFieldKind::Nat32
1091 | PersistedFieldKind::Nat64
1092 | PersistedFieldKind::Nat128
1093 | PersistedFieldKind::Ulid
1094 | PersistedFieldKind::Unit => {
1095 unreachable!("plain persisted field kind labels return before recursive render")
1096 }
1097 PersistedFieldKind::NatBig { max_bytes } => {
1098 write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
1099 }
1100 }
1101}
1102
1103impl DescribeKindName for PersistedFieldKind {
1104 fn describe_kind_name(&self) -> Option<&'static str> {
1105 Some(match self {
1106 Self::Account => "account",
1107 Self::Bool => "bool",
1108 Self::Date => "date",
1109 Self::Duration => "duration",
1110 Self::Float32 => "float32",
1111 Self::Float64 => "float64",
1112 Self::Int8 => "int8",
1113 Self::Int16 => "int16",
1114 Self::Int32 => "int32",
1115 Self::Int64 => "int64",
1116 Self::Int128 => "int128",
1117 Self::Principal => "principal",
1118 Self::Subaccount => "subaccount",
1119 Self::Timestamp => "timestamp",
1120 Self::Nat8 => "nat8",
1121 Self::Nat16 => "nat16",
1122 Self::Nat32 => "nat32",
1123 Self::Nat64 => "nat64",
1124 Self::Nat128 => "nat128",
1125 Self::Ulid => "ulid",
1126 Self::Unit => "unit",
1127 Self::Blob { .. }
1128 | Self::Decimal { .. }
1129 | Self::Enum { .. }
1130 | Self::IntBig { .. }
1131 | Self::NatBig { .. }
1132 | Self::Text { .. }
1133 | Self::Relation { .. }
1134 | Self::List(_)
1135 | Self::Set(_)
1136 | Self::Map { .. }
1137 | Self::Structured { .. } => return None,
1138 })
1139 }
1140}
1141
1142#[cfg_attr(
1143 doc,
1144 doc = "Render one stable relation-strength label from persisted schema metadata."
1145)]
1146const fn summarize_persisted_relation_strength(
1147 strength: PersistedRelationStrength,
1148) -> &'static str {
1149 match strength {
1150 PersistedRelationStrength::Strong => "strong",
1151 PersistedRelationStrength::Weak => "weak",
1152 }
1153}
1154
1155#[cfg_attr(
1156 doc,
1157 doc = "Render one stable relation-strength label for field-kind summaries."
1158)]
1159const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
1160 match strength {
1161 RelationStrength::Strong => "strong",
1162 RelationStrength::Weak => "weak",
1163 }
1164}
1165
1166#[cfg(test)]
1171mod tests {
1172 use crate::{
1173 db::{
1174 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
1175 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
1176 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
1177 schema::{
1178 AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
1179 PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldDefault,
1180 SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
1181 describe::{describe_entity_fields_with_persisted_schema, describe_entity_model},
1182 },
1183 },
1184 model::{
1185 entity::{EntityModel, PrimaryKeyModel},
1186 field::{
1187 FieldDatabaseDefault, FieldKind, FieldModel, FieldStorageDecode, LeafCodec,
1188 RelationStrength, ScalarCodec,
1189 },
1190 },
1191 types::EntityTag,
1192 };
1193 use candid::types::{CandidType, Label, Type, TypeInner};
1194
1195 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
1196 target_path: "entities::Target",
1197 target_entity_name: "Target",
1198 target_entity_tag: EntityTag::new(0xD001),
1199 target_store_path: "stores::Target",
1200 key_kind: &FieldKind::Ulid,
1201 strength: RelationStrength::Strong,
1202 };
1203 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1204 target_path: "entities::Account",
1205 target_entity_name: "Account",
1206 target_entity_tag: EntityTag::new(0xD002),
1207 target_store_path: "stores::Account",
1208 key_kind: &FieldKind::Nat64,
1209 strength: RelationStrength::Weak,
1210 };
1211 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
1212 target_path: "entities::Team",
1213 target_entity_name: "Team",
1214 target_entity_tag: EntityTag::new(0xD003),
1215 target_store_path: "stores::Team",
1216 key_kind: &FieldKind::Text { max_len: None },
1217 strength: RelationStrength::Strong,
1218 };
1219 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
1220 FieldModel::generated("id", FieldKind::Ulid),
1221 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
1222 FieldModel::generated(
1223 "accounts",
1224 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
1225 ),
1226 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
1227 ];
1228 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
1229 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
1230 "entities::Source",
1231 "Source",
1232 &DESCRIBE_RELATION_FIELDS[0],
1233 0,
1234 &DESCRIBE_RELATION_FIELDS,
1235 &DESCRIBE_RELATION_INDEXES,
1236 );
1237 static DESCRIBE_COMPOSITE_PK_FIELDS: [FieldModel; 3] = [
1238 FieldModel::generated("tenant_id", FieldKind::Nat64),
1239 FieldModel::generated("local_id", FieldKind::Nat64),
1240 FieldModel::generated("label", FieldKind::Text { max_len: None }),
1241 ];
1242 static DESCRIBE_COMPOSITE_PK_FIELD_REFS: [&FieldModel; 2] = [
1243 &DESCRIBE_COMPOSITE_PK_FIELDS[0],
1244 &DESCRIBE_COMPOSITE_PK_FIELDS[1],
1245 ];
1246 static DESCRIBE_COMPOSITE_PK_MODEL: EntityModel = EntityModel::generated_with_primary_key_model(
1247 "entities::Composite",
1248 "Composite",
1249 PrimaryKeyModel::ordered(&DESCRIBE_COMPOSITE_PK_FIELD_REFS),
1250 0,
1251 &DESCRIBE_COMPOSITE_PK_FIELDS,
1252 &DESCRIBE_RELATION_INDEXES,
1253 );
1254
1255 fn expect_record_fields(ty: Type) -> Vec<String> {
1256 match ty.as_ref() {
1257 TypeInner::Record(fields) => fields
1258 .iter()
1259 .map(|field| match field.id.as_ref() {
1260 Label::Named(name) => name.clone(),
1261 other => panic!("expected named record field, got {other:?}"),
1262 })
1263 .collect(),
1264 other => panic!("expected candid record, got {other:?}"),
1265 }
1266 }
1267
1268 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
1269 match ty.as_ref() {
1270 TypeInner::Record(fields) => fields
1271 .iter()
1272 .find_map(|field| match field.id.as_ref() {
1273 Label::Named(name) if name == field_name => Some(field.ty.clone()),
1274 _ => None,
1275 })
1276 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
1277 other => panic!("expected candid record, got {other:?}"),
1278 }
1279 }
1280
1281 fn expect_variant_labels(ty: Type) -> Vec<String> {
1282 match ty.as_ref() {
1283 TypeInner::Variant(fields) => fields
1284 .iter()
1285 .map(|field| match field.id.as_ref() {
1286 Label::Named(name) => name.clone(),
1287 other => panic!("expected named variant label, got {other:?}"),
1288 })
1289 .collect(),
1290 other => panic!("expected candid variant, got {other:?}"),
1291 }
1292 }
1293
1294 #[test]
1295 fn entity_schema_description_candid_shape_is_stable() {
1296 let fields = expect_record_fields(EntitySchemaDescription::ty());
1297
1298 for field in [
1299 "entity_path",
1300 "entity_name",
1301 "primary_key",
1302 "primary_key_fields",
1303 "fields",
1304 "indexes",
1305 "relations",
1306 ] {
1307 assert!(
1308 fields.iter().any(|candidate| candidate == field),
1309 "EntitySchemaDescription must keep `{field}` field key",
1310 );
1311 }
1312 }
1313
1314 #[test]
1315 fn entity_field_description_candid_shape_is_stable() {
1316 let fields = expect_record_fields(EntityFieldDescription::ty());
1317
1318 for field in ["name", "slot", "kind", "primary_key", "queryable", "origin"] {
1319 assert!(
1320 fields.iter().any(|candidate| candidate == field),
1321 "EntityFieldDescription must keep `{field}` field key",
1322 );
1323 }
1324
1325 assert!(
1326 matches!(
1327 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
1328 TypeInner::Nat16
1329 ),
1330 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
1331 );
1332 }
1333
1334 #[test]
1335 fn entity_index_description_candid_shape_is_stable() {
1336 let fields = expect_record_fields(EntityIndexDescription::ty());
1337
1338 for field in ["name", "unique", "fields", "origin"] {
1339 assert!(
1340 fields.iter().any(|candidate| candidate == field),
1341 "EntityIndexDescription must keep `{field}` field key",
1342 );
1343 }
1344 }
1345
1346 #[test]
1347 fn entity_relation_description_candid_shape_is_stable() {
1348 let fields = expect_record_fields(EntityRelationDescription::ty());
1349
1350 for field in [
1351 "field",
1352 "target_path",
1353 "target_entity_name",
1354 "target_store_path",
1355 "strength",
1356 "cardinality",
1357 ] {
1358 assert!(
1359 fields.iter().any(|candidate| candidate == field),
1360 "EntityRelationDescription must keep `{field}` field key",
1361 );
1362 }
1363 }
1364
1365 #[test]
1366 fn relation_enum_variant_labels_are_stable() {
1367 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
1368 strength_labels.sort_unstable();
1369 assert_eq!(
1370 strength_labels,
1371 vec!["Strong".to_string(), "Weak".to_string()]
1372 );
1373
1374 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
1375 cardinality_labels.sort_unstable();
1376 assert_eq!(
1377 cardinality_labels,
1378 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
1379 );
1380 }
1381
1382 #[test]
1383 fn describe_fixture_constructors_stay_usable() {
1384 let payload = EntitySchemaDescription::new(
1385 "entities::User".to_string(),
1386 "User".to_string(),
1387 "id".to_string(),
1388 vec![EntityFieldDescription::new(
1389 "id".to_string(),
1390 Some(0),
1391 "ulid".to_string(),
1392 false,
1393 true,
1394 true,
1395 "generated".to_string(),
1396 )],
1397 vec![EntityIndexDescription::new(
1398 "idx_email".to_string(),
1399 true,
1400 vec!["email".to_string()],
1401 "generated".to_string(),
1402 )],
1403 vec![EntityRelationDescription::new(
1404 "account_id".to_string(),
1405 "entities::Account".to_string(),
1406 "Account".to_string(),
1407 "accounts".to_string(),
1408 EntityRelationStrength::Strong,
1409 EntityRelationCardinality::Single,
1410 )],
1411 );
1412
1413 assert_eq!(payload.entity_name(), "User");
1414 assert_eq!(payload.primary_key(), "id");
1415 assert_eq!(payload.primary_key_fields(), ["id".to_string()].as_slice());
1416 assert_eq!(payload.fields().len(), 1);
1417 assert_eq!(payload.indexes().len(), 1);
1418 assert_eq!(payload.relations().len(), 1);
1419 }
1420
1421 #[test]
1422 fn describe_entity_model_marks_all_composite_primary_key_fields() {
1423 let described = describe_entity_model(&DESCRIBE_COMPOSITE_PK_MODEL);
1424 let primary_key_fields = described
1425 .fields()
1426 .iter()
1427 .filter(|field| field.primary_key())
1428 .map(EntityFieldDescription::name)
1429 .collect::<Vec<_>>();
1430
1431 assert_eq!(described.primary_key(), "tenant_id, local_id");
1432 assert_eq!(
1433 described.primary_key_fields(),
1434 ["tenant_id".to_string(), "local_id".to_string()].as_slice(),
1435 );
1436 assert_eq!(primary_key_fields, ["tenant_id", "local_id"]);
1437 }
1438
1439 #[test]
1440 fn schema_describe_relations_match_relation_descriptors() {
1441 let descriptors =
1442 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
1443 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
1444 let relations = described.relations();
1445
1446 assert_eq!(descriptors.len(), relations.len());
1447
1448 for (descriptor, relation) in descriptors.iter().zip(relations) {
1449 assert_eq!(relation.field(), descriptor.field_name());
1450 assert_eq!(relation.target_path(), descriptor.target_path());
1451 assert_eq!(
1452 relation.target_entity_name(),
1453 descriptor.target_entity_name()
1454 );
1455 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
1456 assert_eq!(
1457 relation.strength(),
1458 match descriptor.strength() {
1459 RelationStrength::Strong => EntityRelationStrength::Strong,
1460 RelationStrength::Weak => EntityRelationStrength::Weak,
1461 }
1462 );
1463 assert_eq!(
1464 relation.cardinality(),
1465 match descriptor.cardinality() {
1466 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
1467 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
1468 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
1469 }
1470 );
1471 }
1472 }
1473
1474 #[test]
1475 fn schema_describe_includes_text_max_len_contract() {
1476 static FIELDS: [FieldModel; 2] = [
1477 FieldModel::generated("id", FieldKind::Ulid),
1478 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
1479 ];
1480 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1481 static MODEL: EntityModel = EntityModel::generated(
1482 "entities::BoundedName",
1483 "BoundedName",
1484 &FIELDS[0],
1485 0,
1486 &FIELDS,
1487 &INDEXES,
1488 );
1489
1490 let described = describe_entity_model(&MODEL);
1491 let name_field = described
1492 .fields()
1493 .iter()
1494 .find(|field| field.name() == "name")
1495 .expect("bounded text field should be described");
1496
1497 assert_eq!(name_field.kind(), "text(max_len=16)");
1498 }
1499
1500 #[test]
1501 fn schema_describe_preserves_fixed_width_numeric_kind_labels() {
1502 static FIELDS: [FieldModel; 7] = [
1503 FieldModel::generated("id", FieldKind::Ulid),
1504 FieldModel::generated("small_signed", FieldKind::Int8),
1505 FieldModel::generated("cell_x", FieldKind::Nat16),
1506 FieldModel::generated("large_signed", FieldKind::Int64),
1507 FieldModel::generated("large_unsigned", FieldKind::Nat64),
1508 FieldModel::generated("huge_signed", FieldKind::IntBig { max_bytes: 384 }),
1509 FieldModel::generated("huge_unsigned", FieldKind::NatBig { max_bytes: 512 }),
1510 ];
1511 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1512 static MODEL: EntityModel = EntityModel::generated(
1513 "entities::FixedWidthNumbers",
1514 "FixedWidthNumbers",
1515 &FIELDS[0],
1516 0,
1517 &FIELDS,
1518 &INDEXES,
1519 );
1520
1521 let described = describe_entity_model(&MODEL)
1522 .fields()
1523 .iter()
1524 .map(|field| (field.name().to_string(), field.kind().to_string()))
1525 .collect::<Vec<_>>();
1526
1527 assert!(described.contains(&("small_signed".to_string(), "int8".to_string())));
1528 assert!(described.contains(&("cell_x".to_string(), "nat16".to_string())));
1529 assert!(described.contains(&("large_signed".to_string(), "int64".to_string())));
1530 assert!(described.contains(&("large_unsigned".to_string(), "nat64".to_string())));
1531 assert!(described.contains(&(
1532 "huge_signed".to_string(),
1533 "int_big(max_bytes=384)".to_string()
1534 )));
1535 assert!(described.contains(&(
1536 "huge_unsigned".to_string(),
1537 "nat_big(max_bytes=512)".to_string()
1538 )));
1539 }
1540
1541 #[test]
1542 fn schema_describe_includes_generated_database_default_metadata() {
1543 static DEFAULT_PAYLOAD: &[u8] = &[0xFF, 0x01, 7, 0, 0, 0, 0, 0, 0, 0];
1544 static FIELDS: [FieldModel; 2] = [
1545 FieldModel::generated("id", FieldKind::Ulid),
1546 FieldModel::generated_with_storage_decode_nullability_write_policies_database_default_and_nested_fields(
1547 "score",
1548 FieldKind::Nat64,
1549 FieldStorageDecode::ByKind,
1550 false,
1551 None,
1552 None,
1553 FieldDatabaseDefault::EncodedSlotPayload(DEFAULT_PAYLOAD),
1554 &[],
1555 ),
1556 ];
1557 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1558 static MODEL: EntityModel = EntityModel::generated(
1559 "entities::DefaultedScore",
1560 "DefaultedScore",
1561 &FIELDS[0],
1562 0,
1563 &FIELDS,
1564 &INDEXES,
1565 );
1566
1567 let described = describe_entity_model(&MODEL);
1568 let score_field = described
1569 .fields()
1570 .iter()
1571 .find(|field| field.name() == "score")
1572 .expect("database-defaulted score field should be described");
1573
1574 assert_eq!(
1575 score_field.kind(),
1576 "nat64 default=slot_payload(bytes=10, sha256=37746b8fe16bb6b4)"
1577 );
1578 }
1579
1580 #[test]
1581 fn schema_describe_uses_accepted_top_level_field_metadata() {
1582 let id_slot = SchemaFieldSlot::new(0);
1583 let payload_slot = SchemaFieldSlot::new(7);
1584 let stale_payload_field_slot = SchemaFieldSlot::new(3);
1587 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1588 SchemaVersion::initial(),
1589 "entities::BlobEvent".to_string(),
1590 "BlobEvent".to_string(),
1591 FieldId::new(1),
1592 SchemaRowLayout::new(
1593 SchemaVersion::initial(),
1594 vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
1595 ),
1596 vec![
1597 PersistedFieldSnapshot::new(
1598 FieldId::new(1),
1599 "id".to_string(),
1600 id_slot,
1601 PersistedFieldKind::Ulid,
1602 Vec::new(),
1603 false,
1604 SchemaFieldDefault::None,
1605 FieldStorageDecode::ByKind,
1606 LeafCodec::StructuralFallback,
1607 ),
1608 PersistedFieldSnapshot::new(
1609 FieldId::new(2),
1610 "payload".to_string(),
1611 stale_payload_field_slot,
1612 PersistedFieldKind::Blob { max_len: None },
1613 Vec::new(),
1614 false,
1615 SchemaFieldDefault::SlotPayload(vec![0x10, 0x20, 0x30]),
1616 FieldStorageDecode::ByKind,
1617 LeafCodec::StructuralFallback,
1618 ),
1619 ],
1620 ));
1621
1622 let described = describe_entity_fields_with_persisted_schema(&snapshot)
1623 .into_iter()
1624 .map(|field| {
1625 (
1626 field.name().to_string(),
1627 field.slot(),
1628 field.kind().to_string(),
1629 )
1630 })
1631 .collect::<Vec<_>>();
1632
1633 assert_eq!(
1634 described,
1635 vec![
1636 ("id".to_string(), Some(0), "ulid".to_string()),
1637 (
1638 "payload".to_string(),
1639 Some(7),
1640 "blob(unbounded) default=slot_payload(bytes=3, sha256=8e1336ab78ebe687)"
1641 .to_string()
1642 ),
1643 ],
1644 );
1645 }
1646
1647 #[test]
1648 fn schema_describe_preserves_accepted_fixed_width_numeric_kind_labels() {
1649 let id_slot = SchemaFieldSlot::new(0);
1650 let x_slot = SchemaFieldSlot::new(1);
1651 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1652 SchemaVersion::initial(),
1653 "entities::Grid".to_string(),
1654 "Grid".to_string(),
1655 FieldId::new(1),
1656 SchemaRowLayout::new(
1657 SchemaVersion::initial(),
1658 vec![(FieldId::new(1), id_slot), (FieldId::new(2), x_slot)],
1659 ),
1660 vec![
1661 PersistedFieldSnapshot::new(
1662 FieldId::new(1),
1663 "id".to_string(),
1664 id_slot,
1665 PersistedFieldKind::Ulid,
1666 Vec::new(),
1667 false,
1668 SchemaFieldDefault::None,
1669 FieldStorageDecode::ByKind,
1670 LeafCodec::StructuralFallback,
1671 ),
1672 PersistedFieldSnapshot::new(
1673 FieldId::new(2),
1674 "x".to_string(),
1675 x_slot,
1676 PersistedFieldKind::Nat16,
1677 Vec::new(),
1678 false,
1679 SchemaFieldDefault::None,
1680 FieldStorageDecode::ByKind,
1681 LeafCodec::Scalar(ScalarCodec::Nat64),
1682 ),
1683 ],
1684 ));
1685
1686 let described = describe_entity_fields_with_persisted_schema(&snapshot);
1687 let x = described
1688 .iter()
1689 .find(|field| field.name() == "x")
1690 .expect("accepted fixed-width field should be described");
1691
1692 assert_eq!(x.kind(), "nat16");
1693 }
1694
1695 #[test]
1696 fn schema_describe_uses_accepted_nested_leaf_metadata() {
1697 let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
1698 SchemaVersion::initial(),
1699 "entities::AcceptedProfile".to_string(),
1700 "AcceptedProfile".to_string(),
1701 FieldId::new(1),
1702 SchemaRowLayout::new(
1703 SchemaVersion::initial(),
1704 vec![
1705 (FieldId::new(1), SchemaFieldSlot::new(0)),
1706 (FieldId::new(2), SchemaFieldSlot::new(1)),
1707 ],
1708 ),
1709 vec![
1710 PersistedFieldSnapshot::new(
1711 FieldId::new(1),
1712 "id".to_string(),
1713 SchemaFieldSlot::new(0),
1714 PersistedFieldKind::Ulid,
1715 Vec::new(),
1716 false,
1717 SchemaFieldDefault::None,
1718 FieldStorageDecode::ByKind,
1719 LeafCodec::StructuralFallback,
1720 ),
1721 PersistedFieldSnapshot::new(
1722 FieldId::new(2),
1723 "profile".to_string(),
1724 SchemaFieldSlot::new(1),
1725 PersistedFieldKind::Structured { queryable: true },
1726 vec![PersistedNestedLeafSnapshot::new(
1727 vec!["rank".to_string()],
1728 PersistedFieldKind::Blob { max_len: None },
1729 false,
1730 FieldStorageDecode::ByKind,
1731 LeafCodec::Scalar(ScalarCodec::Blob),
1732 )],
1733 false,
1734 SchemaFieldDefault::None,
1735 FieldStorageDecode::Value,
1736 LeafCodec::StructuralFallback,
1737 ),
1738 ],
1739 ));
1740
1741 let described = describe_entity_fields_with_persisted_schema(&snapshot);
1742 let rank = described
1743 .iter()
1744 .find(|field| field.name() == "└─ rank")
1745 .expect("accepted nested leaf should be described");
1746
1747 assert_eq!(rank.slot(), None);
1748 assert_eq!(rank.kind(), "blob(unbounded)");
1749 assert!(rank.queryable());
1750 }
1751
1752 #[test]
1753 fn schema_describe_expands_generated_structured_field_leaves() {
1754 static NESTED_FIELDS: [FieldModel; 3] = [
1755 FieldModel::generated("name", FieldKind::Text { max_len: None }),
1756 FieldModel::generated("level", FieldKind::Nat64),
1757 FieldModel::generated("pid", FieldKind::Principal),
1758 ];
1759 static FIELDS: [FieldModel; 2] = [
1760 FieldModel::generated("id", FieldKind::Ulid),
1761 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
1762 "mentor",
1763 FieldKind::Structured { queryable: false },
1764 FieldStorageDecode::Value,
1765 false,
1766 None,
1767 None,
1768 &NESTED_FIELDS,
1769 ),
1770 ];
1771 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
1772 static MODEL: EntityModel = EntityModel::generated(
1773 "entities::Character",
1774 "Character",
1775 &FIELDS[0],
1776 0,
1777 &FIELDS,
1778 &INDEXES,
1779 );
1780
1781 let described = describe_entity_model(&MODEL);
1782 let described_fields = described
1783 .fields()
1784 .iter()
1785 .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
1786 .collect::<Vec<_>>();
1787
1788 assert_eq!(
1789 described_fields,
1790 vec![
1791 ("id", Some(0), "ulid", true),
1792 ("mentor", Some(1), "structured", false),
1793 ("├─ name", None, "text(unbounded)", true),
1794 ("├─ level", None, "nat64", true),
1795 ("└─ pid", None, "principal", true),
1796 ],
1797 );
1798 }
1799}