1use crate::{
7 db::{
8 relation::{
9 RelationDescriptor, RelationDescriptorCardinality, relation_descriptors_for_model_iter,
10 },
11 schema::AcceptedSchemaSnapshot,
12 },
13 model::{
14 entity::EntityModel,
15 field::{FieldKind, FieldModel, RelationStrength},
16 },
17};
18use candid::CandidType;
19use serde::Deserialize;
20use std::{collections::BTreeMap, fmt::Write};
21
22const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
23
24#[cfg_attr(
25 doc,
26 doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
27)]
28#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
29pub struct EntitySchemaDescription {
30 pub(crate) entity_path: String,
31 pub(crate) entity_name: String,
32 pub(crate) primary_key: String,
33 pub(crate) fields: Vec<EntityFieldDescription>,
34 pub(crate) indexes: Vec<EntityIndexDescription>,
35 pub(crate) relations: Vec<EntityRelationDescription>,
36}
37
38impl EntitySchemaDescription {
39 #[must_use]
41 pub const fn new(
42 entity_path: String,
43 entity_name: String,
44 primary_key: String,
45 fields: Vec<EntityFieldDescription>,
46 indexes: Vec<EntityIndexDescription>,
47 relations: Vec<EntityRelationDescription>,
48 ) -> Self {
49 Self {
50 entity_path,
51 entity_name,
52 primary_key,
53 fields,
54 indexes,
55 relations,
56 }
57 }
58
59 #[must_use]
61 pub const fn entity_path(&self) -> &str {
62 self.entity_path.as_str()
63 }
64
65 #[must_use]
67 pub const fn entity_name(&self) -> &str {
68 self.entity_name.as_str()
69 }
70
71 #[must_use]
73 pub const fn primary_key(&self) -> &str {
74 self.primary_key.as_str()
75 }
76
77 #[must_use]
79 pub const fn fields(&self) -> &[EntityFieldDescription] {
80 self.fields.as_slice()
81 }
82
83 #[must_use]
85 pub const fn indexes(&self) -> &[EntityIndexDescription] {
86 self.indexes.as_slice()
87 }
88
89 #[must_use]
91 pub const fn relations(&self) -> &[EntityRelationDescription] {
92 self.relations.as_slice()
93 }
94}
95
96#[cfg_attr(
97 doc,
98 doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
99)]
100#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
101pub struct EntityFieldDescription {
102 pub(crate) name: String,
103 pub(crate) slot: u16,
104 pub(crate) kind: String,
105 pub(crate) primary_key: bool,
106 pub(crate) queryable: bool,
107}
108
109impl EntityFieldDescription {
110 #[must_use]
112 pub const fn new(
113 name: String,
114 slot: Option<u16>,
115 kind: String,
116 primary_key: bool,
117 queryable: bool,
118 ) -> Self {
119 let slot = match slot {
120 Some(slot) => slot,
121 None => ENTITY_FIELD_DESCRIPTION_NO_SLOT,
122 };
123
124 Self {
125 name,
126 slot,
127 kind,
128 primary_key,
129 queryable,
130 }
131 }
132
133 #[must_use]
135 pub const fn name(&self) -> &str {
136 self.name.as_str()
137 }
138
139 #[must_use]
141 pub const fn slot(&self) -> Option<u16> {
142 if self.slot == ENTITY_FIELD_DESCRIPTION_NO_SLOT {
143 None
144 } else {
145 Some(self.slot)
146 }
147 }
148
149 #[must_use]
151 pub const fn kind(&self) -> &str {
152 self.kind.as_str()
153 }
154
155 #[must_use]
157 pub const fn primary_key(&self) -> bool {
158 self.primary_key
159 }
160
161 #[must_use]
163 pub const fn queryable(&self) -> bool {
164 self.queryable
165 }
166}
167
168#[cfg_attr(
169 doc,
170 doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
171)]
172#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
173pub struct EntityIndexDescription {
174 pub(crate) name: String,
175 pub(crate) unique: bool,
176 pub(crate) fields: Vec<String>,
177}
178
179impl EntityIndexDescription {
180 #[must_use]
182 pub const fn new(name: String, unique: bool, fields: Vec<String>) -> Self {
183 Self {
184 name,
185 unique,
186 fields,
187 }
188 }
189
190 #[must_use]
192 pub const fn name(&self) -> &str {
193 self.name.as_str()
194 }
195
196 #[must_use]
198 pub const fn unique(&self) -> bool {
199 self.unique
200 }
201
202 #[must_use]
204 pub const fn fields(&self) -> &[String] {
205 self.fields.as_slice()
206 }
207}
208
209#[cfg_attr(
210 doc,
211 doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
212)]
213#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
214pub struct EntityRelationDescription {
215 pub(crate) field: String,
216 pub(crate) target_path: String,
217 pub(crate) target_entity_name: String,
218 pub(crate) target_store_path: String,
219 pub(crate) strength: EntityRelationStrength,
220 pub(crate) cardinality: EntityRelationCardinality,
221}
222
223impl EntityRelationDescription {
224 #[must_use]
226 pub const fn new(
227 field: String,
228 target_path: String,
229 target_entity_name: String,
230 target_store_path: String,
231 strength: EntityRelationStrength,
232 cardinality: EntityRelationCardinality,
233 ) -> Self {
234 Self {
235 field,
236 target_path,
237 target_entity_name,
238 target_store_path,
239 strength,
240 cardinality,
241 }
242 }
243
244 #[must_use]
246 pub const fn field(&self) -> &str {
247 self.field.as_str()
248 }
249
250 #[must_use]
252 pub const fn target_path(&self) -> &str {
253 self.target_path.as_str()
254 }
255
256 #[must_use]
258 pub const fn target_entity_name(&self) -> &str {
259 self.target_entity_name.as_str()
260 }
261
262 #[must_use]
264 pub const fn target_store_path(&self) -> &str {
265 self.target_store_path.as_str()
266 }
267
268 #[must_use]
270 pub const fn strength(&self) -> EntityRelationStrength {
271 self.strength
272 }
273
274 #[must_use]
276 pub const fn cardinality(&self) -> EntityRelationCardinality {
277 self.cardinality
278 }
279}
280
281#[cfg_attr(doc, doc = "EntityRelationStrength\n\nDescribe relation strength.")]
282#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
283pub enum EntityRelationStrength {
284 Strong,
285 Weak,
286}
287
288#[cfg_attr(
289 doc,
290 doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
291)]
292#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
293pub enum EntityRelationCardinality {
294 Single,
295 List,
296 Set,
297}
298
299#[cfg_attr(
300 doc,
301 doc = "Build one stable entity-schema description from one runtime `EntityModel`."
302)]
303#[must_use]
304pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
305 let fields = describe_entity_fields(model);
306
307 describe_entity_model_with_fields(model, fields)
308}
309
310#[cfg_attr(
311 doc,
312 doc = "Build one entity-schema description using accepted persisted schema slot metadata."
313)]
314#[must_use]
315pub(in crate::db) fn describe_entity_model_with_persisted_schema(
316 model: &EntityModel,
317 schema: &AcceptedSchemaSnapshot,
318) -> EntitySchemaDescription {
319 let fields = describe_entity_fields_with_persisted_schema(model, schema);
320
321 describe_entity_model_with_fields(model, fields)
322}
323
324fn describe_entity_model_with_fields(
328 model: &EntityModel,
329 fields: Vec<EntityFieldDescription>,
330) -> EntitySchemaDescription {
331 let relations = relation_descriptors_for_model_iter(model)
332 .map(relation_description_from_descriptor)
333 .collect();
334
335 let mut indexes = Vec::with_capacity(model.indexes.len());
336 for index in model.indexes {
337 indexes.push(EntityIndexDescription::new(
338 index.name().to_string(),
339 index.is_unique(),
340 index
341 .fields()
342 .iter()
343 .map(|field| (*field).to_string())
344 .collect(),
345 ));
346 }
347
348 EntitySchemaDescription::new(
349 model.path.to_string(),
350 model.entity_name.to_string(),
351 model.primary_key.name.to_string(),
352 fields,
353 indexes,
354 relations,
355 )
356}
357
358#[must_use]
362pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
363 describe_entity_fields_with_slot_lookup(model, |slot, _field| {
364 Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
365 })
366}
367
368#[cfg_attr(
369 doc,
370 doc = "Build field descriptors using accepted persisted schema slot metadata."
371)]
372#[must_use]
373pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
374 model: &EntityModel,
375 schema: &AcceptedSchemaSnapshot,
376) -> Vec<EntityFieldDescription> {
377 let slots_by_name = schema
378 .snapshot()
379 .fields()
380 .iter()
381 .map(|field| (field.name(), field.slot().get()))
382 .collect::<BTreeMap<_, _>>();
383
384 describe_entity_fields_with_slot_lookup(model, |_slot, field| {
385 slots_by_name.get(field.name()).copied()
386 })
387}
388
389fn describe_entity_fields_with_slot_lookup(
393 model: &EntityModel,
394 mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
395) -> Vec<EntityFieldDescription> {
396 let mut fields = Vec::with_capacity(model.fields.len());
397
398 for (slot, field) in model.fields.iter().enumerate() {
399 let primary_key = field.name == model.primary_key.name;
400 describe_field_recursive(
401 &mut fields,
402 field.name,
403 slot_for_field(slot, field),
404 field,
405 primary_key,
406 None,
407 );
408 }
409
410 fields
411}
412
413fn describe_field_recursive(
417 fields: &mut Vec<EntityFieldDescription>,
418 name: &str,
419 slot: Option<u16>,
420 field: &FieldModel,
421 primary_key: bool,
422 tree_prefix: Option<&'static str>,
423) {
424 let field_kind = summarize_field_kind(&field.kind);
425 let queryable = field.kind.value_kind().is_queryable();
426
427 let display_name = if let Some(prefix) = tree_prefix {
430 format!("{prefix}{name}")
431 } else {
432 name.to_string()
433 };
434
435 fields.push(EntityFieldDescription::new(
436 display_name,
437 slot,
438 field_kind,
439 primary_key,
440 queryable,
441 ));
442
443 let nested_fields = field.nested_fields();
444 for (index, nested) in nested_fields.iter().enumerate() {
445 let prefix = if index + 1 == nested_fields.len() {
446 "└─ "
447 } else {
448 "├─ "
449 };
450 describe_field_recursive(fields, nested.name(), None, nested, false, Some(prefix));
451 }
452}
453
454fn relation_description_from_descriptor(
456 descriptor: RelationDescriptor<'_>,
457) -> EntityRelationDescription {
458 let strength = match descriptor.strength() {
459 RelationStrength::Strong => EntityRelationStrength::Strong,
460 RelationStrength::Weak => EntityRelationStrength::Weak,
461 };
462
463 let cardinality = match descriptor.cardinality() {
464 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
465 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
466 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
467 };
468
469 EntityRelationDescription::new(
470 descriptor.field_name().to_string(),
471 descriptor.target_path().to_string(),
472 descriptor.target_entity_name().to_string(),
473 descriptor.target_store_path().to_string(),
474 strength,
475 cardinality,
476 )
477}
478
479#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
480fn summarize_field_kind(kind: &FieldKind) -> String {
481 let mut out = String::new();
482 write_field_kind_summary(&mut out, kind);
483
484 out
485}
486
487fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
490 match kind {
491 FieldKind::Account => out.push_str("account"),
492 FieldKind::Blob => out.push_str("blob"),
493 FieldKind::Bool => out.push_str("bool"),
494 FieldKind::Date => out.push_str("date"),
495 FieldKind::Decimal { scale } => {
496 let _ = write!(out, "decimal(scale={scale})");
497 }
498 FieldKind::Duration => out.push_str("duration"),
499 FieldKind::Enum { path, .. } => {
500 out.push_str("enum(");
501 out.push_str(path);
502 out.push(')');
503 }
504 FieldKind::Float32 => out.push_str("float32"),
505 FieldKind::Float64 => out.push_str("float64"),
506 FieldKind::Int => out.push_str("int"),
507 FieldKind::Int128 => out.push_str("int128"),
508 FieldKind::IntBig => out.push_str("int_big"),
509 FieldKind::Principal => out.push_str("principal"),
510 FieldKind::Subaccount => out.push_str("subaccount"),
511 FieldKind::Text { max_len } => match max_len {
512 Some(max_len) => {
513 let _ = write!(out, "text(max_len={max_len})");
514 }
515 None => out.push_str("text"),
516 },
517 FieldKind::Timestamp => out.push_str("timestamp"),
518 FieldKind::Uint => out.push_str("uint"),
519 FieldKind::Uint128 => out.push_str("uint128"),
520 FieldKind::UintBig => out.push_str("uint_big"),
521 FieldKind::Ulid => out.push_str("ulid"),
522 FieldKind::Unit => out.push_str("unit"),
523 FieldKind::Relation {
524 target_entity_name,
525 key_kind,
526 strength,
527 ..
528 } => {
529 out.push_str("relation(target=");
530 out.push_str(target_entity_name);
531 out.push_str(", key=");
532 write_field_kind_summary(out, key_kind);
533 out.push_str(", strength=");
534 out.push_str(summarize_relation_strength(*strength));
535 out.push(')');
536 }
537 FieldKind::List(inner) => {
538 out.push_str("list<");
539 write_field_kind_summary(out, inner);
540 out.push('>');
541 }
542 FieldKind::Set(inner) => {
543 out.push_str("set<");
544 write_field_kind_summary(out, inner);
545 out.push('>');
546 }
547 FieldKind::Map { key, value } => {
548 out.push_str("map<");
549 write_field_kind_summary(out, key);
550 out.push_str(", ");
551 write_field_kind_summary(out, value);
552 out.push('>');
553 }
554 FieldKind::Structured { .. } => {
555 out.push_str("structured");
556 }
557 }
558}
559
560#[cfg_attr(
561 doc,
562 doc = "Render one stable relation-strength label for field-kind summaries."
563)]
564const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
565 match strength {
566 RelationStrength::Strong => "strong",
567 RelationStrength::Weak => "weak",
568 }
569}
570
571#[cfg(test)]
576mod tests {
577 use crate::{
578 db::{
579 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
580 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
581 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
582 schema::describe::describe_entity_model,
583 },
584 model::{
585 entity::EntityModel,
586 field::{FieldKind, FieldModel, FieldStorageDecode, RelationStrength},
587 },
588 types::EntityTag,
589 };
590 use candid::types::{CandidType, Label, Type, TypeInner};
591
592 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
593 target_path: "entities::Target",
594 target_entity_name: "Target",
595 target_entity_tag: EntityTag::new(0xD001),
596 target_store_path: "stores::Target",
597 key_kind: &FieldKind::Ulid,
598 strength: RelationStrength::Strong,
599 };
600 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
601 target_path: "entities::Account",
602 target_entity_name: "Account",
603 target_entity_tag: EntityTag::new(0xD002),
604 target_store_path: "stores::Account",
605 key_kind: &FieldKind::Uint,
606 strength: RelationStrength::Weak,
607 };
608 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
609 target_path: "entities::Team",
610 target_entity_name: "Team",
611 target_entity_tag: EntityTag::new(0xD003),
612 target_store_path: "stores::Team",
613 key_kind: &FieldKind::Text { max_len: None },
614 strength: RelationStrength::Strong,
615 };
616 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
617 FieldModel::generated("id", FieldKind::Ulid),
618 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
619 FieldModel::generated(
620 "accounts",
621 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
622 ),
623 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
624 ];
625 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
626 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
627 "entities::Source",
628 "Source",
629 &DESCRIBE_RELATION_FIELDS[0],
630 0,
631 &DESCRIBE_RELATION_FIELDS,
632 &DESCRIBE_RELATION_INDEXES,
633 );
634
635 fn expect_record_fields(ty: Type) -> Vec<String> {
636 match ty.as_ref() {
637 TypeInner::Record(fields) => fields
638 .iter()
639 .map(|field| match field.id.as_ref() {
640 Label::Named(name) => name.clone(),
641 other => panic!("expected named record field, got {other:?}"),
642 })
643 .collect(),
644 other => panic!("expected candid record, got {other:?}"),
645 }
646 }
647
648 fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
649 match ty.as_ref() {
650 TypeInner::Record(fields) => fields
651 .iter()
652 .find_map(|field| match field.id.as_ref() {
653 Label::Named(name) if name == field_name => Some(field.ty.clone()),
654 _ => None,
655 })
656 .unwrap_or_else(|| panic!("expected record field `{field_name}`")),
657 other => panic!("expected candid record, got {other:?}"),
658 }
659 }
660
661 fn expect_variant_labels(ty: Type) -> Vec<String> {
662 match ty.as_ref() {
663 TypeInner::Variant(fields) => fields
664 .iter()
665 .map(|field| match field.id.as_ref() {
666 Label::Named(name) => name.clone(),
667 other => panic!("expected named variant label, got {other:?}"),
668 })
669 .collect(),
670 other => panic!("expected candid variant, got {other:?}"),
671 }
672 }
673
674 #[test]
675 fn entity_schema_description_candid_shape_is_stable() {
676 let fields = expect_record_fields(EntitySchemaDescription::ty());
677
678 for field in [
679 "entity_path",
680 "entity_name",
681 "primary_key",
682 "fields",
683 "indexes",
684 "relations",
685 ] {
686 assert!(
687 fields.iter().any(|candidate| candidate == field),
688 "EntitySchemaDescription must keep `{field}` field key",
689 );
690 }
691 }
692
693 #[test]
694 fn entity_field_description_candid_shape_is_stable() {
695 let fields = expect_record_fields(EntityFieldDescription::ty());
696
697 for field in ["name", "slot", "kind", "primary_key", "queryable"] {
698 assert!(
699 fields.iter().any(|candidate| candidate == field),
700 "EntityFieldDescription must keep `{field}` field key",
701 );
702 }
703
704 assert!(
705 matches!(
706 expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
707 TypeInner::Nat16
708 ),
709 "EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
710 );
711 }
712
713 #[test]
714 fn entity_index_description_candid_shape_is_stable() {
715 let fields = expect_record_fields(EntityIndexDescription::ty());
716
717 for field in ["name", "unique", "fields"] {
718 assert!(
719 fields.iter().any(|candidate| candidate == field),
720 "EntityIndexDescription must keep `{field}` field key",
721 );
722 }
723 }
724
725 #[test]
726 fn entity_relation_description_candid_shape_is_stable() {
727 let fields = expect_record_fields(EntityRelationDescription::ty());
728
729 for field in [
730 "field",
731 "target_path",
732 "target_entity_name",
733 "target_store_path",
734 "strength",
735 "cardinality",
736 ] {
737 assert!(
738 fields.iter().any(|candidate| candidate == field),
739 "EntityRelationDescription must keep `{field}` field key",
740 );
741 }
742 }
743
744 #[test]
745 fn relation_enum_variant_labels_are_stable() {
746 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
747 strength_labels.sort_unstable();
748 assert_eq!(
749 strength_labels,
750 vec!["Strong".to_string(), "Weak".to_string()]
751 );
752
753 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
754 cardinality_labels.sort_unstable();
755 assert_eq!(
756 cardinality_labels,
757 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
758 );
759 }
760
761 #[test]
762 fn describe_fixture_constructors_stay_usable() {
763 let payload = EntitySchemaDescription::new(
764 "entities::User".to_string(),
765 "User".to_string(),
766 "id".to_string(),
767 vec![EntityFieldDescription::new(
768 "id".to_string(),
769 Some(0),
770 "ulid".to_string(),
771 true,
772 true,
773 )],
774 vec![EntityIndexDescription::new(
775 "idx_email".to_string(),
776 true,
777 vec!["email".to_string()],
778 )],
779 vec![EntityRelationDescription::new(
780 "account_id".to_string(),
781 "entities::Account".to_string(),
782 "Account".to_string(),
783 "accounts".to_string(),
784 EntityRelationStrength::Strong,
785 EntityRelationCardinality::Single,
786 )],
787 );
788
789 assert_eq!(payload.entity_name(), "User");
790 assert_eq!(payload.fields().len(), 1);
791 assert_eq!(payload.indexes().len(), 1);
792 assert_eq!(payload.relations().len(), 1);
793 }
794
795 #[test]
796 fn schema_describe_relations_match_relation_descriptors() {
797 let descriptors =
798 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
799 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
800 let relations = described.relations();
801
802 assert_eq!(descriptors.len(), relations.len());
803
804 for (descriptor, relation) in descriptors.iter().zip(relations) {
805 assert_eq!(relation.field(), descriptor.field_name());
806 assert_eq!(relation.target_path(), descriptor.target_path());
807 assert_eq!(
808 relation.target_entity_name(),
809 descriptor.target_entity_name()
810 );
811 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
812 assert_eq!(
813 relation.strength(),
814 match descriptor.strength() {
815 RelationStrength::Strong => EntityRelationStrength::Strong,
816 RelationStrength::Weak => EntityRelationStrength::Weak,
817 }
818 );
819 assert_eq!(
820 relation.cardinality(),
821 match descriptor.cardinality() {
822 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
823 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
824 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
825 }
826 );
827 }
828 }
829
830 #[test]
831 fn schema_describe_includes_text_max_len_contract() {
832 static FIELDS: [FieldModel; 2] = [
833 FieldModel::generated("id", FieldKind::Ulid),
834 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
835 ];
836 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
837 static MODEL: EntityModel = EntityModel::generated(
838 "entities::BoundedName",
839 "BoundedName",
840 &FIELDS[0],
841 0,
842 &FIELDS,
843 &INDEXES,
844 );
845
846 let described = describe_entity_model(&MODEL);
847 let name_field = described
848 .fields()
849 .iter()
850 .find(|field| field.name() == "name")
851 .expect("bounded text field should be described");
852
853 assert_eq!(name_field.kind(), "text(max_len=16)");
854 }
855
856 #[test]
857 fn schema_describe_expands_generated_structured_field_leaves() {
858 static NESTED_FIELDS: [FieldModel; 3] = [
859 FieldModel::generated("name", FieldKind::Text { max_len: None }),
860 FieldModel::generated("level", FieldKind::Uint),
861 FieldModel::generated("pid", FieldKind::Principal),
862 ];
863 static FIELDS: [FieldModel; 2] = [
864 FieldModel::generated("id", FieldKind::Ulid),
865 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
866 "mentor",
867 FieldKind::Structured { queryable: false },
868 FieldStorageDecode::Value,
869 false,
870 None,
871 None,
872 &NESTED_FIELDS,
873 ),
874 ];
875 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
876 static MODEL: EntityModel = EntityModel::generated(
877 "entities::Character",
878 "Character",
879 &FIELDS[0],
880 0,
881 &FIELDS,
882 &INDEXES,
883 );
884
885 let described = describe_entity_model(&MODEL);
886 let described_fields = described
887 .fields()
888 .iter()
889 .map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
890 .collect::<Vec<_>>();
891
892 assert_eq!(
893 described_fields,
894 vec![
895 ("id", Some(0), "ulid", true),
896 ("mentor", Some(1), "structured", false),
897 ("├─ name", None, "text", true),
898 ("├─ level", None, "uint", true),
899 ("└─ pid", None, "principal", true),
900 ],
901 );
902 }
903}