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)]
265pub enum EntityRelationStrength {
266 Strong,
267 Weak,
268}
269
270#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
276pub enum EntityRelationCardinality {
277 Single,
278 List,
279 Set,
280}
281
282#[must_use]
284pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
285 let mut fields = Vec::with_capacity(model.fields.len());
286 let mut relations = Vec::new();
287 for field in model.fields {
288 let field_kind = summarize_field_kind(&field.kind);
289 let queryable = field.kind.value_kind().is_queryable();
290 let primary_key = field.name == model.primary_key.name;
291
292 fields.push(EntityFieldDescription::new(
293 field.name.to_string(),
294 field_kind,
295 primary_key,
296 queryable,
297 ));
298
299 if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
300 relations.push(relation);
301 }
302 }
303
304 let mut indexes = Vec::with_capacity(model.indexes.len());
305 for index in model.indexes {
306 indexes.push(EntityIndexDescription::new(
307 index.name.to_string(),
308 index.unique,
309 index
310 .fields
311 .iter()
312 .map(|field| (*field).to_string())
313 .collect(),
314 ));
315 }
316
317 EntitySchemaDescription::new(
318 model.path.to_string(),
319 model.entity_name.to_string(),
320 model.primary_key.name.to_string(),
321 fields,
322 indexes,
323 relations,
324 )
325}
326
327fn relation_from_field_kind(
329 field_name: &str,
330 kind: &FieldKind,
331) -> Option<EntityRelationDescription> {
332 match kind {
333 FieldKind::Relation {
334 target_path,
335 target_entity_name,
336 target_store_path,
337 strength,
338 ..
339 } => Some(EntityRelationDescription::new(
340 field_name.to_string(),
341 (*target_path).to_string(),
342 (*target_entity_name).to_string(),
343 (*target_store_path).to_string(),
344 relation_strength(*strength),
345 EntityRelationCardinality::Single,
346 )),
347 FieldKind::List(inner) => {
348 relation_from_collection_relation(field_name, inner, EntityRelationCardinality::List)
349 }
350 FieldKind::Set(inner) => {
351 relation_from_collection_relation(field_name, inner, EntityRelationCardinality::Set)
352 }
353 FieldKind::Account
354 | FieldKind::Blob
355 | FieldKind::Bool
356 | FieldKind::Date
357 | FieldKind::Decimal { .. }
358 | FieldKind::Duration
359 | FieldKind::Enum { .. }
360 | FieldKind::Float32
361 | FieldKind::Float64
362 | FieldKind::Int
363 | FieldKind::Int128
364 | FieldKind::IntBig
365 | FieldKind::Principal
366 | FieldKind::Subaccount
367 | FieldKind::Text
368 | FieldKind::Timestamp
369 | FieldKind::Uint
370 | FieldKind::Uint128
371 | FieldKind::UintBig
372 | FieldKind::Ulid
373 | FieldKind::Unit
374 | FieldKind::Map { .. }
375 | FieldKind::Structured { .. } => None,
376 }
377}
378
379fn relation_from_collection_relation(
381 field_name: &str,
382 inner: &FieldKind,
383 cardinality: EntityRelationCardinality,
384) -> Option<EntityRelationDescription> {
385 let FieldKind::Relation {
386 target_path,
387 target_entity_name,
388 target_store_path,
389 strength,
390 ..
391 } = inner
392 else {
393 return None;
394 };
395
396 Some(EntityRelationDescription::new(
397 field_name.to_string(),
398 (*target_path).to_string(),
399 (*target_entity_name).to_string(),
400 (*target_store_path).to_string(),
401 relation_strength(*strength),
402 cardinality,
403 ))
404}
405
406const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
408 match strength {
409 RelationStrength::Strong => EntityRelationStrength::Strong,
410 RelationStrength::Weak => EntityRelationStrength::Weak,
411 }
412}
413
414fn summarize_field_kind(kind: &FieldKind) -> String {
416 match kind {
417 FieldKind::Account => "account".to_string(),
418 FieldKind::Blob => "blob".to_string(),
419 FieldKind::Bool => "bool".to_string(),
420 FieldKind::Date => "date".to_string(),
421 FieldKind::Decimal { scale } => format!("decimal(scale={scale})"),
422 FieldKind::Duration => "duration".to_string(),
423 FieldKind::Enum { path } => format!("enum({path})"),
424 FieldKind::Float32 => "float32".to_string(),
425 FieldKind::Float64 => "float64".to_string(),
426 FieldKind::Int => "int".to_string(),
427 FieldKind::Int128 => "int128".to_string(),
428 FieldKind::IntBig => "int_big".to_string(),
429 FieldKind::Principal => "principal".to_string(),
430 FieldKind::Subaccount => "subaccount".to_string(),
431 FieldKind::Text => "text".to_string(),
432 FieldKind::Timestamp => "timestamp".to_string(),
433 FieldKind::Uint => "uint".to_string(),
434 FieldKind::Uint128 => "uint128".to_string(),
435 FieldKind::UintBig => "uint_big".to_string(),
436 FieldKind::Ulid => "ulid".to_string(),
437 FieldKind::Unit => "unit".to_string(),
438 FieldKind::Relation {
439 target_entity_name,
440 key_kind,
441 strength,
442 ..
443 } => format!(
444 "relation(target={target_entity_name}, key={}, strength={})",
445 summarize_field_kind(key_kind),
446 summarize_relation_strength(*strength),
447 ),
448 FieldKind::List(inner) => format!("list<{}>", summarize_field_kind(inner)),
449 FieldKind::Set(inner) => format!("set<{}>", summarize_field_kind(inner)),
450 FieldKind::Map { key, value } => {
451 format!(
452 "map<{}, {}>",
453 summarize_field_kind(key),
454 summarize_field_kind(value)
455 )
456 }
457 FieldKind::Structured { queryable } => format!("structured(queryable={queryable})"),
458 }
459}
460
461const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
463 match strength {
464 RelationStrength::Strong => "strong",
465 RelationStrength::Weak => "weak",
466 }
467}
468
469#[cfg(test)]
474mod tests {
475 use crate::db::{
476 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
477 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
478 };
479 use serde::Serialize;
480 use serde_cbor::Value as CborValue;
481 use std::collections::BTreeMap;
482
483 fn to_cbor_value<T: Serialize>(value: &T) -> CborValue {
484 let bytes =
485 serde_cbor::to_vec(value).expect("test fixtures must serialize into CBOR payloads");
486 serde_cbor::from_slice::<CborValue>(&bytes)
487 .expect("test fixtures must deserialize into CBOR value trees")
488 }
489
490 fn expect_cbor_map(value: &CborValue) -> &BTreeMap<CborValue, CborValue> {
491 match value {
492 CborValue::Map(map) => map,
493 other => panic!("expected CBOR map, got {other:?}"),
494 }
495 }
496
497 fn map_field<'a>(map: &'a BTreeMap<CborValue, CborValue>, key: &str) -> Option<&'a CborValue> {
498 map.get(&CborValue::Text(key.to_string()))
499 }
500
501 #[test]
502 fn entity_schema_description_serialization_shape_is_stable() {
503 let payload = EntitySchemaDescription::new(
504 "entities::User".to_string(),
505 "User".to_string(),
506 "id".to_string(),
507 vec![EntityFieldDescription::new(
508 "id".to_string(),
509 "ulid".to_string(),
510 true,
511 true,
512 )],
513 vec![EntityIndexDescription::new(
514 "idx_email".to_string(),
515 true,
516 vec!["email".to_string()],
517 )],
518 vec![EntityRelationDescription::new(
519 "account_id".to_string(),
520 "entities::Account".to_string(),
521 "Account".to_string(),
522 "accounts".to_string(),
523 EntityRelationStrength::Strong,
524 EntityRelationCardinality::Single,
525 )],
526 );
527 let encoded = to_cbor_value(&payload);
528 let root = expect_cbor_map(&encoded);
529
530 assert!(
531 map_field(root, "entity_path").is_some(),
532 "EntitySchemaDescription must keep `entity_path` field key",
533 );
534 assert!(
535 map_field(root, "entity_name").is_some(),
536 "EntitySchemaDescription must keep `entity_name` field key",
537 );
538 assert!(
539 map_field(root, "primary_key").is_some(),
540 "EntitySchemaDescription must keep `primary_key` field key",
541 );
542 assert!(
543 map_field(root, "fields").is_some(),
544 "EntitySchemaDescription must keep `fields` field key",
545 );
546 assert!(
547 map_field(root, "indexes").is_some(),
548 "EntitySchemaDescription must keep `indexes` field key",
549 );
550 assert!(
551 map_field(root, "relations").is_some(),
552 "EntitySchemaDescription must keep `relations` field key",
553 );
554 }
555
556 #[test]
557 fn entity_relation_description_serialization_shape_is_stable() {
558 let encoded = to_cbor_value(&EntityRelationDescription::new(
559 "owner_id".to_string(),
560 "entities::User".to_string(),
561 "User".to_string(),
562 "users".to_string(),
563 EntityRelationStrength::Weak,
564 EntityRelationCardinality::Set,
565 ));
566 let root = expect_cbor_map(&encoded);
567
568 assert!(
569 map_field(root, "field").is_some(),
570 "EntityRelationDescription must keep `field` field key",
571 );
572 assert!(
573 map_field(root, "target_path").is_some(),
574 "EntityRelationDescription must keep `target_path` field key",
575 );
576 assert!(
577 map_field(root, "target_entity_name").is_some(),
578 "EntityRelationDescription must keep `target_entity_name` field key",
579 );
580 assert!(
581 map_field(root, "target_store_path").is_some(),
582 "EntityRelationDescription must keep `target_store_path` field key",
583 );
584 assert!(
585 map_field(root, "strength").is_some(),
586 "EntityRelationDescription must keep `strength` field key",
587 );
588 assert!(
589 map_field(root, "cardinality").is_some(),
590 "EntityRelationDescription must keep `cardinality` field key",
591 );
592 }
593
594 #[test]
595 fn relation_enum_variant_labels_are_stable() {
596 assert_eq!(
597 to_cbor_value(&EntityRelationStrength::Strong),
598 CborValue::Text("Strong".to_string())
599 );
600 assert_eq!(
601 to_cbor_value(&EntityRelationStrength::Weak),
602 CborValue::Text("Weak".to_string())
603 );
604 assert_eq!(
605 to_cbor_value(&EntityRelationCardinality::Single),
606 CborValue::Text("Single".to_string())
607 );
608 assert_eq!(
609 to_cbor_value(&EntityRelationCardinality::List),
610 CborValue::Text("List".to_string())
611 );
612 assert_eq!(
613 to_cbor_value(&EntityRelationCardinality::Set),
614 CborValue::Text("Set".to_string())
615 );
616 }
617}