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