1use crate::model::{
7 entity::EntityModel,
8 field::{FieldKind, RelationStrength},
9};
10use candid::CandidType;
11use serde::Deserialize;
12
13#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
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)]
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)]
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)]
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)]
266pub enum EntityRelationStrength {
267 Strong,
268 Weak,
269}
270
271#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
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 candid::types::{CandidType, Label, Type, TypeInner};
482
483 fn expect_record_fields(ty: Type) -> Vec<String> {
484 match ty.as_ref() {
485 TypeInner::Record(fields) => fields
486 .iter()
487 .map(|field| match field.id.as_ref() {
488 Label::Named(name) => name.clone(),
489 other => panic!("expected named record field, got {other:?}"),
490 })
491 .collect(),
492 other => panic!("expected candid record, got {other:?}"),
493 }
494 }
495
496 fn expect_variant_labels(ty: Type) -> Vec<String> {
497 match ty.as_ref() {
498 TypeInner::Variant(fields) => fields
499 .iter()
500 .map(|field| match field.id.as_ref() {
501 Label::Named(name) => name.clone(),
502 other => panic!("expected named variant label, got {other:?}"),
503 })
504 .collect(),
505 other => panic!("expected candid variant, got {other:?}"),
506 }
507 }
508
509 #[test]
510 fn entity_schema_description_candid_shape_is_stable() {
511 let fields = expect_record_fields(EntitySchemaDescription::ty());
512
513 for field in [
514 "entity_path",
515 "entity_name",
516 "primary_key",
517 "fields",
518 "indexes",
519 "relations",
520 ] {
521 assert!(
522 fields.iter().any(|candidate| candidate == field),
523 "EntitySchemaDescription must keep `{field}` field key",
524 );
525 }
526 }
527
528 #[test]
529 fn entity_field_description_candid_shape_is_stable() {
530 let fields = expect_record_fields(EntityFieldDescription::ty());
531
532 for field in ["name", "kind", "primary_key", "queryable"] {
533 assert!(
534 fields.iter().any(|candidate| candidate == field),
535 "EntityFieldDescription must keep `{field}` field key",
536 );
537 }
538 }
539
540 #[test]
541 fn entity_index_description_candid_shape_is_stable() {
542 let fields = expect_record_fields(EntityIndexDescription::ty());
543
544 for field in ["name", "unique", "fields"] {
545 assert!(
546 fields.iter().any(|candidate| candidate == field),
547 "EntityIndexDescription must keep `{field}` field key",
548 );
549 }
550 }
551
552 #[test]
553 fn entity_relation_description_candid_shape_is_stable() {
554 let fields = expect_record_fields(EntityRelationDescription::ty());
555
556 for field in [
557 "field",
558 "target_path",
559 "target_entity_name",
560 "target_store_path",
561 "strength",
562 "cardinality",
563 ] {
564 assert!(
565 fields.iter().any(|candidate| candidate == field),
566 "EntityRelationDescription must keep `{field}` field key",
567 );
568 }
569 }
570
571 #[test]
572 fn relation_enum_variant_labels_are_stable() {
573 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
574 strength_labels.sort_unstable();
575 assert_eq!(
576 strength_labels,
577 vec!["Strong".to_string(), "Weak".to_string()]
578 );
579
580 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
581 cardinality_labels.sort_unstable();
582 assert_eq!(
583 cardinality_labels,
584 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
585 );
586 }
587
588 #[test]
589 fn describe_fixture_constructors_stay_usable() {
590 let payload = EntitySchemaDescription::new(
591 "entities::User".to_string(),
592 "User".to_string(),
593 "id".to_string(),
594 vec![EntityFieldDescription::new(
595 "id".to_string(),
596 "ulid".to_string(),
597 true,
598 true,
599 )],
600 vec![EntityIndexDescription::new(
601 "idx_email".to_string(),
602 true,
603 vec!["email".to_string()],
604 )],
605 vec![EntityRelationDescription::new(
606 "account_id".to_string(),
607 "entities::Account".to_string(),
608 "Account".to_string(),
609 "accounts".to_string(),
610 EntityRelationStrength::Strong,
611 EntityRelationCardinality::Single,
612 )],
613 );
614
615 assert_eq!(payload.entity_name(), "User");
616 assert_eq!(payload.fields().len(), 1);
617 assert_eq!(payload.indexes().len(), 1);
618 assert_eq!(payload.relations().len(), 1);
619 }
620}