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 => out.push_str("text"),
406 FieldKind::Timestamp => out.push_str("timestamp"),
407 FieldKind::Uint => out.push_str("uint"),
408 FieldKind::Uint128 => out.push_str("uint128"),
409 FieldKind::UintBig => out.push_str("uint_big"),
410 FieldKind::Ulid => out.push_str("ulid"),
411 FieldKind::Unit => out.push_str("unit"),
412 FieldKind::Relation {
413 target_entity_name,
414 key_kind,
415 strength,
416 ..
417 } => {
418 out.push_str("relation(target=");
419 out.push_str(target_entity_name);
420 out.push_str(", key=");
421 write_field_kind_summary(out, key_kind);
422 out.push_str(", strength=");
423 out.push_str(summarize_relation_strength(*strength));
424 out.push(')');
425 }
426 FieldKind::List(inner) => {
427 out.push_str("list<");
428 write_field_kind_summary(out, inner);
429 out.push('>');
430 }
431 FieldKind::Set(inner) => {
432 out.push_str("set<");
433 write_field_kind_summary(out, inner);
434 out.push('>');
435 }
436 FieldKind::Map { key, value } => {
437 out.push_str("map<");
438 write_field_kind_summary(out, key);
439 out.push_str(", ");
440 write_field_kind_summary(out, value);
441 out.push('>');
442 }
443 FieldKind::Structured { queryable } => {
444 let _ = write!(out, "structured(queryable={queryable})");
445 }
446 }
447}
448
449#[cfg_attr(
450 doc,
451 doc = "Render one stable relation-strength label for field-kind summaries."
452)]
453const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
454 match strength {
455 RelationStrength::Strong => "strong",
456 RelationStrength::Weak => "weak",
457 }
458}
459
460#[cfg(test)]
465mod tests {
466 use crate::{
467 db::{
468 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
469 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
470 relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
471 schema::describe::describe_entity_model,
472 },
473 model::{
474 entity::EntityModel,
475 field::{FieldKind, FieldModel, RelationStrength},
476 },
477 types::EntityTag,
478 };
479 use candid::types::{CandidType, Label, Type, TypeInner};
480
481 static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
482 target_path: "entities::Target",
483 target_entity_name: "Target",
484 target_entity_tag: EntityTag::new(0xD001),
485 target_store_path: "stores::Target",
486 key_kind: &FieldKind::Ulid,
487 strength: RelationStrength::Strong,
488 };
489 static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
490 target_path: "entities::Account",
491 target_entity_name: "Account",
492 target_entity_tag: EntityTag::new(0xD002),
493 target_store_path: "stores::Account",
494 key_kind: &FieldKind::Uint,
495 strength: RelationStrength::Weak,
496 };
497 static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
498 target_path: "entities::Team",
499 target_entity_name: "Team",
500 target_entity_tag: EntityTag::new(0xD003),
501 target_store_path: "stores::Team",
502 key_kind: &FieldKind::Text,
503 strength: RelationStrength::Strong,
504 };
505 static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
506 FieldModel::generated("id", FieldKind::Ulid),
507 FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
508 FieldModel::generated(
509 "accounts",
510 FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
511 ),
512 FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
513 ];
514 static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
515 static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
516 "entities::Source",
517 "Source",
518 &DESCRIBE_RELATION_FIELDS[0],
519 0,
520 &DESCRIBE_RELATION_FIELDS,
521 &DESCRIBE_RELATION_INDEXES,
522 );
523
524 fn expect_record_fields(ty: Type) -> Vec<String> {
525 match ty.as_ref() {
526 TypeInner::Record(fields) => fields
527 .iter()
528 .map(|field| match field.id.as_ref() {
529 Label::Named(name) => name.clone(),
530 other => panic!("expected named record field, got {other:?}"),
531 })
532 .collect(),
533 other => panic!("expected candid record, got {other:?}"),
534 }
535 }
536
537 fn expect_variant_labels(ty: Type) -> Vec<String> {
538 match ty.as_ref() {
539 TypeInner::Variant(fields) => fields
540 .iter()
541 .map(|field| match field.id.as_ref() {
542 Label::Named(name) => name.clone(),
543 other => panic!("expected named variant label, got {other:?}"),
544 })
545 .collect(),
546 other => panic!("expected candid variant, got {other:?}"),
547 }
548 }
549
550 #[test]
551 fn entity_schema_description_candid_shape_is_stable() {
552 let fields = expect_record_fields(EntitySchemaDescription::ty());
553
554 for field in [
555 "entity_path",
556 "entity_name",
557 "primary_key",
558 "fields",
559 "indexes",
560 "relations",
561 ] {
562 assert!(
563 fields.iter().any(|candidate| candidate == field),
564 "EntitySchemaDescription must keep `{field}` field key",
565 );
566 }
567 }
568
569 #[test]
570 fn entity_field_description_candid_shape_is_stable() {
571 let fields = expect_record_fields(EntityFieldDescription::ty());
572
573 for field in ["name", "kind", "primary_key", "queryable"] {
574 assert!(
575 fields.iter().any(|candidate| candidate == field),
576 "EntityFieldDescription must keep `{field}` field key",
577 );
578 }
579 }
580
581 #[test]
582 fn entity_index_description_candid_shape_is_stable() {
583 let fields = expect_record_fields(EntityIndexDescription::ty());
584
585 for field in ["name", "unique", "fields"] {
586 assert!(
587 fields.iter().any(|candidate| candidate == field),
588 "EntityIndexDescription must keep `{field}` field key",
589 );
590 }
591 }
592
593 #[test]
594 fn entity_relation_description_candid_shape_is_stable() {
595 let fields = expect_record_fields(EntityRelationDescription::ty());
596
597 for field in [
598 "field",
599 "target_path",
600 "target_entity_name",
601 "target_store_path",
602 "strength",
603 "cardinality",
604 ] {
605 assert!(
606 fields.iter().any(|candidate| candidate == field),
607 "EntityRelationDescription must keep `{field}` field key",
608 );
609 }
610 }
611
612 #[test]
613 fn relation_enum_variant_labels_are_stable() {
614 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
615 strength_labels.sort_unstable();
616 assert_eq!(
617 strength_labels,
618 vec!["Strong".to_string(), "Weak".to_string()]
619 );
620
621 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
622 cardinality_labels.sort_unstable();
623 assert_eq!(
624 cardinality_labels,
625 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
626 );
627 }
628
629 #[test]
630 fn describe_fixture_constructors_stay_usable() {
631 let payload = EntitySchemaDescription::new(
632 "entities::User".to_string(),
633 "User".to_string(),
634 "id".to_string(),
635 vec![EntityFieldDescription::new(
636 "id".to_string(),
637 "ulid".to_string(),
638 true,
639 true,
640 )],
641 vec![EntityIndexDescription::new(
642 "idx_email".to_string(),
643 true,
644 vec!["email".to_string()],
645 )],
646 vec![EntityRelationDescription::new(
647 "account_id".to_string(),
648 "entities::Account".to_string(),
649 "Account".to_string(),
650 "accounts".to_string(),
651 EntityRelationStrength::Strong,
652 EntityRelationCardinality::Single,
653 )],
654 );
655
656 assert_eq!(payload.entity_name(), "User");
657 assert_eq!(payload.fields().len(), 1);
658 assert_eq!(payload.indexes().len(), 1);
659 assert_eq!(payload.relations().len(), 1);
660 }
661
662 #[test]
663 fn schema_describe_relations_match_relation_descriptors() {
664 let descriptors =
665 relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
666 let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
667 let relations = described.relations();
668
669 assert_eq!(descriptors.len(), relations.len());
670
671 for (descriptor, relation) in descriptors.iter().zip(relations) {
672 assert_eq!(relation.field(), descriptor.field_name());
673 assert_eq!(relation.target_path(), descriptor.target_path());
674 assert_eq!(
675 relation.target_entity_name(),
676 descriptor.target_entity_name()
677 );
678 assert_eq!(relation.target_store_path(), descriptor.target_store_path());
679 assert_eq!(
680 relation.strength(),
681 match descriptor.strength() {
682 RelationStrength::Strong => EntityRelationStrength::Strong,
683 RelationStrength::Weak => EntityRelationStrength::Weak,
684 }
685 );
686 assert_eq!(
687 relation.cardinality(),
688 match descriptor.cardinality() {
689 RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
690 RelationDescriptorCardinality::List => EntityRelationCardinality::List,
691 RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
692 }
693 );
694 }
695 }
696}