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 = relation_descriptors_for_model_iter(model)
279 .map(relation_description_from_descriptor)
280 .collect();
281
282 let mut indexes = Vec::with_capacity(model.indexes.len());
283 for index in model.indexes {
284 indexes.push(EntityIndexDescription::new(
285 index.name().to_string(),
286 index.is_unique(),
287 index
288 .fields()
289 .iter()
290 .map(|field| (*field).to_string())
291 .collect(),
292 ));
293 }
294
295 EntitySchemaDescription::new(
296 model.path.to_string(),
297 model.entity_name.to_string(),
298 model.primary_key.name.to_string(),
299 fields,
300 indexes,
301 relations,
302 )
303}
304
305#[must_use]
309pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
310 let mut fields = Vec::with_capacity(model.fields.len());
311
312 for field in model.fields {
313 let primary_key = field.name == model.primary_key.name;
314 describe_field_recursive(&mut fields, field.name, field, primary_key, None);
315 }
316
317 fields
318}
319
320fn describe_field_recursive(
324 fields: &mut Vec<EntityFieldDescription>,
325 name: &str,
326 field: &FieldModel,
327 primary_key: bool,
328 tree_prefix: Option<&'static str>,
329) {
330 let field_kind = summarize_field_kind(&field.kind);
331 let queryable = field.kind.value_kind().is_queryable();
332
333 let display_name = if let Some(prefix) = tree_prefix {
336 format!("{prefix}{name}")
337 } else {
338 name.to_string()
339 };
340
341 fields.push(EntityFieldDescription::new(
342 display_name,
343 field_kind,
344 primary_key,
345 queryable,
346 ));
347
348 let nested_fields = field.nested_fields();
349 for (index, nested) in nested_fields.iter().enumerate() {
350 let prefix = if index + 1 == nested_fields.len() {
351 "└─ "
352 } else {
353 "├─ "
354 };
355 describe_field_recursive(fields, nested.name(), nested, false, Some(prefix));
356 }
357}
358
359fn relation_description_from_descriptor(
361 descriptor: RelationDescriptor<'_>,
362) -> EntityRelationDescription {
363 let strength = match descriptor.strength() {
364 RelationStrength::Strong => EntityRelationStrength::Strong,
365 RelationStrength::Weak => EntityRelationStrength::Weak,
366 };
367
368 let cardinality = match descriptor.cardinality() {
369 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
370 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
371 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
372 };
373
374 EntityRelationDescription::new(
375 descriptor.field_name().to_string(),
376 descriptor.target_path().to_string(),
377 descriptor.target_entity_name().to_string(),
378 descriptor.target_store_path().to_string(),
379 strength,
380 cardinality,
381 )
382}
383
384#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
385fn summarize_field_kind(kind: &FieldKind) -> String {
386 let mut out = String::new();
387 write_field_kind_summary(&mut out, kind);
388
389 out
390}
391
392fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
395 match kind {
396 FieldKind::Account => out.push_str("account"),
397 FieldKind::Blob => out.push_str("blob"),
398 FieldKind::Bool => out.push_str("bool"),
399 FieldKind::Date => out.push_str("date"),
400 FieldKind::Decimal { scale } => {
401 let _ = write!(out, "decimal(scale={scale})");
402 }
403 FieldKind::Duration => out.push_str("duration"),
404 FieldKind::Enum { path, .. } => {
405 out.push_str("enum(");
406 out.push_str(path);
407 out.push(')');
408 }
409 FieldKind::Float32 => out.push_str("float32"),
410 FieldKind::Float64 => out.push_str("float64"),
411 FieldKind::Int => out.push_str("int"),
412 FieldKind::Int128 => out.push_str("int128"),
413 FieldKind::IntBig => out.push_str("int_big"),
414 FieldKind::Principal => out.push_str("principal"),
415 FieldKind::Subaccount => out.push_str("subaccount"),
416 FieldKind::Text { max_len } => match max_len {
417 Some(max_len) => {
418 let _ = write!(out, "text(max_len={max_len})");
419 }
420 None => out.push_str("text"),
421 },
422 FieldKind::Timestamp => out.push_str("timestamp"),
423 FieldKind::Uint => out.push_str("uint"),
424 FieldKind::Uint128 => out.push_str("uint128"),
425 FieldKind::UintBig => out.push_str("uint_big"),
426 FieldKind::Ulid => out.push_str("ulid"),
427 FieldKind::Unit => out.push_str("unit"),
428 FieldKind::Relation {
429 target_entity_name,
430 key_kind,
431 strength,
432 ..
433 } => {
434 out.push_str("relation(target=");
435 out.push_str(target_entity_name);
436 out.push_str(", key=");
437 write_field_kind_summary(out, key_kind);
438 out.push_str(", strength=");
439 out.push_str(summarize_relation_strength(*strength));
440 out.push(')');
441 }
442 FieldKind::List(inner) => {
443 out.push_str("list<");
444 write_field_kind_summary(out, inner);
445 out.push('>');
446 }
447 FieldKind::Set(inner) => {
448 out.push_str("set<");
449 write_field_kind_summary(out, inner);
450 out.push('>');
451 }
452 FieldKind::Map { key, value } => {
453 out.push_str("map<");
454 write_field_kind_summary(out, key);
455 out.push_str(", ");
456 write_field_kind_summary(out, value);
457 out.push('>');
458 }
459 FieldKind::Structured { .. } => {
460 out.push_str("structured");
461 }
462 }
463}
464
465#[cfg_attr(
466 doc,
467 doc = "Render one stable relation-strength label for field-kind summaries."
468)]
469const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
470 match strength {
471 RelationStrength::Strong => "strong",
472 RelationStrength::Weak => "weak",
473 }
474}
475
476#[cfg(test)]
481mod tests {
482 use crate::{
483 db::{
484 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
485 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
486 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
487 schema::describe::describe_entity_model,
488 },
489 model::{
490 entity::EntityModel,
491 field::{FieldKind, FieldModel, FieldStorageDecode, RelationStrength},
492 },
493 types::EntityTag,
494 };
495 use candid::types::{CandidType, Label, Type, TypeInner};
496
497 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
498 target_path: "entities::Target",
499 target_entity_name: "Target",
500 target_entity_tag: EntityTag::new(0xD001),
501 target_store_path: "stores::Target",
502 key_kind: &FieldKind::Ulid,
503 strength: RelationStrength::Strong,
504 };
505 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
506 target_path: "entities::Account",
507 target_entity_name: "Account",
508 target_entity_tag: EntityTag::new(0xD002),
509 target_store_path: "stores::Account",
510 key_kind: &FieldKind::Uint,
511 strength: RelationStrength::Weak,
512 };
513 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
514 target_path: "entities::Team",
515 target_entity_name: "Team",
516 target_entity_tag: EntityTag::new(0xD003),
517 target_store_path: "stores::Team",
518 key_kind: &FieldKind::Text { max_len: None },
519 strength: RelationStrength::Strong,
520 };
521 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
522 FieldModel::generated("id", FieldKind::Ulid),
523 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
524 FieldModel::generated(
525 "accounts",
526 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
527 ),
528 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
529 ];
530 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
531 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
532 "entities::Source",
533 "Source",
534 &DESCRIBE_RELATION_FIELDS[0],
535 0,
536 &DESCRIBE_RELATION_FIELDS,
537 &DESCRIBE_RELATION_INDEXES,
538 );
539
540 fn expect_record_fields(ty: Type) -> Vec<String> {
541 match ty.as_ref() {
542 TypeInner::Record(fields) => fields
543 .iter()
544 .map(|field| match field.id.as_ref() {
545 Label::Named(name) => name.clone(),
546 other => panic!("expected named record field, got {other:?}"),
547 })
548 .collect(),
549 other => panic!("expected candid record, got {other:?}"),
550 }
551 }
552
553 fn expect_variant_labels(ty: Type) -> Vec<String> {
554 match ty.as_ref() {
555 TypeInner::Variant(fields) => fields
556 .iter()
557 .map(|field| match field.id.as_ref() {
558 Label::Named(name) => name.clone(),
559 other => panic!("expected named variant label, got {other:?}"),
560 })
561 .collect(),
562 other => panic!("expected candid variant, got {other:?}"),
563 }
564 }
565
566 #[test]
567 fn entity_schema_description_candid_shape_is_stable() {
568 let fields = expect_record_fields(EntitySchemaDescription::ty());
569
570 for field in [
571 "entity_path",
572 "entity_name",
573 "primary_key",
574 "fields",
575 "indexes",
576 "relations",
577 ] {
578 assert!(
579 fields.iter().any(|candidate| candidate == field),
580 "EntitySchemaDescription must keep `{field}` field key",
581 );
582 }
583 }
584
585 #[test]
586 fn entity_field_description_candid_shape_is_stable() {
587 let fields = expect_record_fields(EntityFieldDescription::ty());
588
589 for field in ["name", "kind", "primary_key", "queryable"] {
590 assert!(
591 fields.iter().any(|candidate| candidate == field),
592 "EntityFieldDescription must keep `{field}` field key",
593 );
594 }
595 }
596
597 #[test]
598 fn entity_index_description_candid_shape_is_stable() {
599 let fields = expect_record_fields(EntityIndexDescription::ty());
600
601 for field in ["name", "unique", "fields"] {
602 assert!(
603 fields.iter().any(|candidate| candidate == field),
604 "EntityIndexDescription must keep `{field}` field key",
605 );
606 }
607 }
608
609 #[test]
610 fn entity_relation_description_candid_shape_is_stable() {
611 let fields = expect_record_fields(EntityRelationDescription::ty());
612
613 for field in [
614 "field",
615 "target_path",
616 "target_entity_name",
617 "target_store_path",
618 "strength",
619 "cardinality",
620 ] {
621 assert!(
622 fields.iter().any(|candidate| candidate == field),
623 "EntityRelationDescription must keep `{field}` field key",
624 );
625 }
626 }
627
628 #[test]
629 fn relation_enum_variant_labels_are_stable() {
630 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
631 strength_labels.sort_unstable();
632 assert_eq!(
633 strength_labels,
634 vec!["Strong".to_string(), "Weak".to_string()]
635 );
636
637 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
638 cardinality_labels.sort_unstable();
639 assert_eq!(
640 cardinality_labels,
641 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
642 );
643 }
644
645 #[test]
646 fn describe_fixture_constructors_stay_usable() {
647 let payload = EntitySchemaDescription::new(
648 "entities::User".to_string(),
649 "User".to_string(),
650 "id".to_string(),
651 vec![EntityFieldDescription::new(
652 "id".to_string(),
653 "ulid".to_string(),
654 true,
655 true,
656 )],
657 vec![EntityIndexDescription::new(
658 "idx_email".to_string(),
659 true,
660 vec!["email".to_string()],
661 )],
662 vec![EntityRelationDescription::new(
663 "account_id".to_string(),
664 "entities::Account".to_string(),
665 "Account".to_string(),
666 "accounts".to_string(),
667 EntityRelationStrength::Strong,
668 EntityRelationCardinality::Single,
669 )],
670 );
671
672 assert_eq!(payload.entity_name(), "User");
673 assert_eq!(payload.fields().len(), 1);
674 assert_eq!(payload.indexes().len(), 1);
675 assert_eq!(payload.relations().len(), 1);
676 }
677
678 #[test]
679 fn schema_describe_relations_match_relation_descriptors() {
680 let descriptors =
681 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
682 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
683 let relations = described.relations();
684
685 assert_eq!(descriptors.len(), relations.len());
686
687 for (descriptor, relation) in descriptors.iter().zip(relations) {
688 assert_eq!(relation.field(), descriptor.field_name());
689 assert_eq!(relation.target_path(), descriptor.target_path());
690 assert_eq!(
691 relation.target_entity_name(),
692 descriptor.target_entity_name()
693 );
694 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
695 assert_eq!(
696 relation.strength(),
697 match descriptor.strength() {
698 RelationStrength::Strong => EntityRelationStrength::Strong,
699 RelationStrength::Weak => EntityRelationStrength::Weak,
700 }
701 );
702 assert_eq!(
703 relation.cardinality(),
704 match descriptor.cardinality() {
705 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
706 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
707 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
708 }
709 );
710 }
711 }
712
713 #[test]
714 fn schema_describe_includes_text_max_len_contract() {
715 static FIELDS: [FieldModel; 2] = [
716 FieldModel::generated("id", FieldKind::Ulid),
717 FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
718 ];
719 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
720 static MODEL: EntityModel = EntityModel::generated(
721 "entities::BoundedName",
722 "BoundedName",
723 &FIELDS[0],
724 0,
725 &FIELDS,
726 &INDEXES,
727 );
728
729 let described = describe_entity_model(&MODEL);
730 let name_field = described
731 .fields()
732 .iter()
733 .find(|field| field.name() == "name")
734 .expect("bounded text field should be described");
735
736 assert_eq!(name_field.kind(), "text(max_len=16)");
737 }
738
739 #[test]
740 fn schema_describe_expands_generated_structured_field_leaves() {
741 static NESTED_FIELDS: [FieldModel; 3] = [
742 FieldModel::generated("name", FieldKind::Text { max_len: None }),
743 FieldModel::generated("level", FieldKind::Uint),
744 FieldModel::generated("pid", FieldKind::Principal),
745 ];
746 static FIELDS: [FieldModel; 2] = [
747 FieldModel::generated("id", FieldKind::Ulid),
748 FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
749 "mentor",
750 FieldKind::Structured { queryable: false },
751 FieldStorageDecode::Value,
752 false,
753 None,
754 None,
755 &NESTED_FIELDS,
756 ),
757 ];
758 static INDEXES: [&crate::model::index::IndexModel; 0] = [];
759 static MODEL: EntityModel = EntityModel::generated(
760 "entities::Character",
761 "Character",
762 &FIELDS[0],
763 0,
764 &FIELDS,
765 &INDEXES,
766 );
767
768 let described = describe_entity_model(&MODEL);
769 let described_fields = described
770 .fields()
771 .iter()
772 .map(|field| (field.name(), field.kind(), field.queryable()))
773 .collect::<Vec<_>>();
774
775 assert_eq!(
776 described_fields,
777 vec![
778 ("id", "ulid", true),
779 ("mentor", "structured", false),
780 ("├─ name", "text", true),
781 ("├─ level", "uint", true),
782 ("└─ pid", "principal", true),
783 ],
784 );
785 }
786}