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