1use crate::model::{
7 entity::EntityModel,
8 field::{FieldKind, RelationStrength},
9};
10use candid::CandidType;
11use serde::{Deserialize, Serialize};
12
13#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
21pub struct EntitySchemaDescription {
22 pub(crate) entity_path: String,
23 pub(crate) entity_name: String,
24 pub(crate) primary_key: String,
25 pub(crate) fields: Vec<EntityFieldDescription>,
26 pub(crate) indexes: Vec<EntityIndexDescription>,
27 pub(crate) relations: Vec<EntityRelationDescription>,
28}
29
30impl EntitySchemaDescription {
31 #[must_use]
33 pub const fn new(
34 entity_path: String,
35 entity_name: String,
36 primary_key: String,
37 fields: Vec<EntityFieldDescription>,
38 indexes: Vec<EntityIndexDescription>,
39 relations: Vec<EntityRelationDescription>,
40 ) -> Self {
41 Self {
42 entity_path,
43 entity_name,
44 primary_key,
45 fields,
46 indexes,
47 relations,
48 }
49 }
50
51 #[must_use]
53 pub const fn entity_path(&self) -> &str {
54 self.entity_path.as_str()
55 }
56
57 #[must_use]
59 pub const fn entity_name(&self) -> &str {
60 self.entity_name.as_str()
61 }
62
63 #[must_use]
65 pub const fn primary_key(&self) -> &str {
66 self.primary_key.as_str()
67 }
68
69 #[must_use]
71 pub const fn fields(&self) -> &[EntityFieldDescription] {
72 self.fields.as_slice()
73 }
74
75 #[must_use]
77 pub const fn indexes(&self) -> &[EntityIndexDescription] {
78 self.indexes.as_slice()
79 }
80
81 #[must_use]
83 pub const fn relations(&self) -> &[EntityRelationDescription] {
84 self.relations.as_slice()
85 }
86}
87
88#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
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#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
148pub struct EntityIndexDescription {
149 pub(crate) name: String,
150 pub(crate) unique: bool,
151 pub(crate) fields: Vec<String>,
152}
153
154impl EntityIndexDescription {
155 #[must_use]
157 pub const fn new(name: String, unique: bool, fields: Vec<String>) -> Self {
158 Self {
159 name,
160 unique,
161 fields,
162 }
163 }
164
165 #[must_use]
167 pub const fn name(&self) -> &str {
168 self.name.as_str()
169 }
170
171 #[must_use]
173 pub const fn unique(&self) -> bool {
174 self.unique
175 }
176
177 #[must_use]
179 pub const fn fields(&self) -> &[String] {
180 self.fields.as_slice()
181 }
182}
183
184#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192pub struct EntityRelationDescription {
193 pub(crate) field: String,
194 pub(crate) target_path: String,
195 pub(crate) target_entity_name: String,
196 pub(crate) target_store_path: String,
197 pub(crate) strength: EntityRelationStrength,
198 pub(crate) cardinality: EntityRelationCardinality,
199}
200
201impl EntityRelationDescription {
202 #[must_use]
204 pub const fn new(
205 field: String,
206 target_path: String,
207 target_entity_name: String,
208 target_store_path: String,
209 strength: EntityRelationStrength,
210 cardinality: EntityRelationCardinality,
211 ) -> Self {
212 Self {
213 field,
214 target_path,
215 target_entity_name,
216 target_store_path,
217 strength,
218 cardinality,
219 }
220 }
221
222 #[must_use]
224 pub const fn field(&self) -> &str {
225 self.field.as_str()
226 }
227
228 #[must_use]
230 pub const fn target_path(&self) -> &str {
231 self.target_path.as_str()
232 }
233
234 #[must_use]
236 pub const fn target_entity_name(&self) -> &str {
237 self.target_entity_name.as_str()
238 }
239
240 #[must_use]
242 pub const fn target_store_path(&self) -> &str {
243 self.target_store_path.as_str()
244 }
245
246 #[must_use]
248 pub const fn strength(&self) -> EntityRelationStrength {
249 self.strength
250 }
251
252 #[must_use]
254 pub const fn cardinality(&self) -> EntityRelationCardinality {
255 self.cardinality
256 }
257}
258
259#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
266pub enum EntityRelationStrength {
267 Strong,
268 Weak,
269}
270
271#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
278pub enum EntityRelationCardinality {
279 Single,
280 List,
281 Set,
282}
283
284#[must_use]
286pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
287 let mut fields = Vec::with_capacity(model.fields.len());
288 let mut relations = Vec::new();
289 for field in model.fields {
290 let field_kind = summarize_field_kind(&field.kind);
291 let queryable = field.kind.value_kind().is_queryable();
292 let primary_key = field.name == model.primary_key.name;
293
294 fields.push(EntityFieldDescription::new(
295 field.name.to_string(),
296 field_kind,
297 primary_key,
298 queryable,
299 ));
300
301 if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
302 relations.push(relation);
303 }
304 }
305
306 let mut indexes = Vec::with_capacity(model.indexes.len());
307 for index in model.indexes {
308 indexes.push(EntityIndexDescription::new(
309 index.name().to_string(),
310 index.is_unique(),
311 index
312 .fields()
313 .iter()
314 .map(|field| (*field).to_string())
315 .collect(),
316 ));
317 }
318
319 EntitySchemaDescription::new(
320 model.path.to_string(),
321 model.entity_name.to_string(),
322 model.primary_key.name.to_string(),
323 fields,
324 indexes,
325 relations,
326 )
327}
328
329fn relation_from_field_kind(
331 field_name: &str,
332 kind: &FieldKind,
333) -> Option<EntityRelationDescription> {
334 match kind {
335 FieldKind::Relation {
336 target_path,
337 target_entity_name,
338 target_store_path,
339 strength,
340 ..
341 } => Some(EntityRelationDescription::new(
342 field_name.to_string(),
343 (*target_path).to_string(),
344 (*target_entity_name).to_string(),
345 (*target_store_path).to_string(),
346 relation_strength(*strength),
347 EntityRelationCardinality::Single,
348 )),
349 FieldKind::List(inner) => {
350 relation_from_collection_relation(field_name, inner, EntityRelationCardinality::List)
351 }
352 FieldKind::Set(inner) => {
353 relation_from_collection_relation(field_name, inner, EntityRelationCardinality::Set)
354 }
355 FieldKind::Account
356 | FieldKind::Blob
357 | FieldKind::Bool
358 | FieldKind::Date
359 | FieldKind::Decimal { .. }
360 | FieldKind::Duration
361 | FieldKind::Enum { .. }
362 | FieldKind::Float32
363 | FieldKind::Float64
364 | FieldKind::Int
365 | FieldKind::Int128
366 | FieldKind::IntBig
367 | FieldKind::Principal
368 | FieldKind::Subaccount
369 | FieldKind::Text
370 | FieldKind::Timestamp
371 | FieldKind::Uint
372 | FieldKind::Uint128
373 | FieldKind::UintBig
374 | FieldKind::Ulid
375 | FieldKind::Unit
376 | FieldKind::Map { .. }
377 | FieldKind::Structured { .. } => None,
378 }
379}
380
381fn relation_from_collection_relation(
383 field_name: &str,
384 inner: &FieldKind,
385 cardinality: EntityRelationCardinality,
386) -> Option<EntityRelationDescription> {
387 let FieldKind::Relation {
388 target_path,
389 target_entity_name,
390 target_store_path,
391 strength,
392 ..
393 } = inner
394 else {
395 return None;
396 };
397
398 Some(EntityRelationDescription::new(
399 field_name.to_string(),
400 (*target_path).to_string(),
401 (*target_entity_name).to_string(),
402 (*target_store_path).to_string(),
403 relation_strength(*strength),
404 cardinality,
405 ))
406}
407
408const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
410 match strength {
411 RelationStrength::Strong => EntityRelationStrength::Strong,
412 RelationStrength::Weak => EntityRelationStrength::Weak,
413 }
414}
415
416fn summarize_field_kind(kind: &FieldKind) -> String {
418 match kind {
419 FieldKind::Account => "account".to_string(),
420 FieldKind::Blob => "blob".to_string(),
421 FieldKind::Bool => "bool".to_string(),
422 FieldKind::Date => "date".to_string(),
423 FieldKind::Decimal { scale } => format!("decimal(scale={scale})"),
424 FieldKind::Duration => "duration".to_string(),
425 FieldKind::Enum { path } => format!("enum({path})"),
426 FieldKind::Float32 => "float32".to_string(),
427 FieldKind::Float64 => "float64".to_string(),
428 FieldKind::Int => "int".to_string(),
429 FieldKind::Int128 => "int128".to_string(),
430 FieldKind::IntBig => "int_big".to_string(),
431 FieldKind::Principal => "principal".to_string(),
432 FieldKind::Subaccount => "subaccount".to_string(),
433 FieldKind::Text => "text".to_string(),
434 FieldKind::Timestamp => "timestamp".to_string(),
435 FieldKind::Uint => "uint".to_string(),
436 FieldKind::Uint128 => "uint128".to_string(),
437 FieldKind::UintBig => "uint_big".to_string(),
438 FieldKind::Ulid => "ulid".to_string(),
439 FieldKind::Unit => "unit".to_string(),
440 FieldKind::Relation {
441 target_entity_name,
442 key_kind,
443 strength,
444 ..
445 } => format!(
446 "relation(target={target_entity_name}, key={}, strength={})",
447 summarize_field_kind(key_kind),
448 summarize_relation_strength(*strength),
449 ),
450 FieldKind::List(inner) => format!("list<{}>", summarize_field_kind(inner)),
451 FieldKind::Set(inner) => format!("set<{}>", summarize_field_kind(inner)),
452 FieldKind::Map { key, value } => {
453 format!(
454 "map<{}, {}>",
455 summarize_field_kind(key),
456 summarize_field_kind(value)
457 )
458 }
459 FieldKind::Structured { queryable } => format!("structured(queryable={queryable})"),
460 }
461}
462
463const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
465 match strength {
466 RelationStrength::Strong => "strong",
467 RelationStrength::Weak => "weak",
468 }
469}
470
471#[cfg(test)]
476mod tests {
477 use crate::db::{
478 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
479 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
480 };
481 use serde::Serialize;
482 use serde_cbor::Value as CborValue;
483 use std::collections::BTreeMap;
484
485 fn to_cbor_value<T: Serialize>(value: &T) -> CborValue {
486 let bytes =
487 serde_cbor::to_vec(value).expect("test fixtures must serialize into CBOR payloads");
488 serde_cbor::from_slice::<CborValue>(&bytes)
489 .expect("test fixtures must deserialize into CBOR value trees")
490 }
491
492 fn expect_cbor_map(value: &CborValue) -> &BTreeMap<CborValue, CborValue> {
493 match value {
494 CborValue::Map(map) => map,
495 other => panic!("expected CBOR map, got {other:?}"),
496 }
497 }
498
499 fn map_field<'a>(map: &'a BTreeMap<CborValue, CborValue>, key: &str) -> Option<&'a CborValue> {
500 map.get(&CborValue::Text(key.to_string()))
501 }
502
503 #[test]
504 fn entity_schema_description_serialization_shape_is_stable() {
505 let payload = EntitySchemaDescription::new(
506 "entities::User".to_string(),
507 "User".to_string(),
508 "id".to_string(),
509 vec![EntityFieldDescription::new(
510 "id".to_string(),
511 "ulid".to_string(),
512 true,
513 true,
514 )],
515 vec![EntityIndexDescription::new(
516 "idx_email".to_string(),
517 true,
518 vec!["email".to_string()],
519 )],
520 vec![EntityRelationDescription::new(
521 "account_id".to_string(),
522 "entities::Account".to_string(),
523 "Account".to_string(),
524 "accounts".to_string(),
525 EntityRelationStrength::Strong,
526 EntityRelationCardinality::Single,
527 )],
528 );
529 let encoded = to_cbor_value(&payload);
530 let root = expect_cbor_map(&encoded);
531
532 assert!(
533 map_field(root, "entity_path").is_some(),
534 "EntitySchemaDescription must keep `entity_path` field key",
535 );
536 assert!(
537 map_field(root, "entity_name").is_some(),
538 "EntitySchemaDescription must keep `entity_name` field key",
539 );
540 assert!(
541 map_field(root, "primary_key").is_some(),
542 "EntitySchemaDescription must keep `primary_key` field key",
543 );
544 assert!(
545 map_field(root, "fields").is_some(),
546 "EntitySchemaDescription must keep `fields` field key",
547 );
548 assert!(
549 map_field(root, "indexes").is_some(),
550 "EntitySchemaDescription must keep `indexes` field key",
551 );
552 assert!(
553 map_field(root, "relations").is_some(),
554 "EntitySchemaDescription must keep `relations` field key",
555 );
556 }
557
558 #[test]
559 fn entity_field_description_serialization_shape_is_stable() {
560 let encoded = to_cbor_value(&EntityFieldDescription::new(
561 "created_at".to_string(),
562 "timestamp".to_string(),
563 false,
564 true,
565 ));
566 let root = expect_cbor_map(&encoded);
567
568 assert!(
569 map_field(root, "name").is_some(),
570 "EntityFieldDescription must keep `name` field key",
571 );
572 assert!(
573 map_field(root, "kind").is_some(),
574 "EntityFieldDescription must keep `kind` field key",
575 );
576 assert!(
577 map_field(root, "primary_key").is_some(),
578 "EntityFieldDescription must keep `primary_key` field key",
579 );
580 assert!(
581 map_field(root, "queryable").is_some(),
582 "EntityFieldDescription must keep `queryable` field key",
583 );
584 }
585
586 #[test]
587 fn entity_index_description_serialization_shape_is_stable() {
588 let encoded = to_cbor_value(&EntityIndexDescription::new(
589 "idx_created_at".to_string(),
590 false,
591 vec!["created_at".to_string()],
592 ));
593 let root = expect_cbor_map(&encoded);
594
595 assert!(
596 map_field(root, "name").is_some(),
597 "EntityIndexDescription must keep `name` field key",
598 );
599 assert!(
600 map_field(root, "unique").is_some(),
601 "EntityIndexDescription must keep `unique` field key",
602 );
603 assert!(
604 map_field(root, "fields").is_some(),
605 "EntityIndexDescription must keep `fields` field key",
606 );
607 }
608
609 #[test]
610 fn entity_relation_description_serialization_shape_is_stable() {
611 let encoded = to_cbor_value(&EntityRelationDescription::new(
612 "owner_id".to_string(),
613 "entities::User".to_string(),
614 "User".to_string(),
615 "users".to_string(),
616 EntityRelationStrength::Weak,
617 EntityRelationCardinality::Set,
618 ));
619 let root = expect_cbor_map(&encoded);
620
621 assert!(
622 map_field(root, "field").is_some(),
623 "EntityRelationDescription must keep `field` field key",
624 );
625 assert!(
626 map_field(root, "target_path").is_some(),
627 "EntityRelationDescription must keep `target_path` field key",
628 );
629 assert!(
630 map_field(root, "target_entity_name").is_some(),
631 "EntityRelationDescription must keep `target_entity_name` field key",
632 );
633 assert!(
634 map_field(root, "target_store_path").is_some(),
635 "EntityRelationDescription must keep `target_store_path` field key",
636 );
637 assert!(
638 map_field(root, "strength").is_some(),
639 "EntityRelationDescription must keep `strength` field key",
640 );
641 assert!(
642 map_field(root, "cardinality").is_some(),
643 "EntityRelationDescription must keep `cardinality` field key",
644 );
645 }
646
647 #[test]
648 fn relation_enum_variant_labels_are_stable() {
649 assert_eq!(
650 to_cbor_value(&EntityRelationStrength::Strong),
651 CborValue::Text("Strong".to_string())
652 );
653 assert_eq!(
654 to_cbor_value(&EntityRelationStrength::Weak),
655 CborValue::Text("Weak".to_string())
656 );
657 assert_eq!(
658 to_cbor_value(&EntityRelationCardinality::Single),
659 CborValue::Text("Single".to_string())
660 );
661 assert_eq!(
662 to_cbor_value(&EntityRelationCardinality::List),
663 CborValue::Text("List".to_string())
664 );
665 assert_eq!(
666 to_cbor_value(&EntityRelationCardinality::Set),
667 CborValue::Text("Set".to_string())
668 );
669 }
670}