1use crate::model::{
7 entity::EntityModel,
8 field::{FieldKind, RelationStrength},
9};
10use candid::CandidType;
11use serde::Deserialize;
12use std::fmt::Write;
13
14#[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)]
95pub struct EntityFieldDescription {
96 pub(crate) name: String,
97 pub(crate) kind: String,
98 pub(crate) primary_key: bool,
99 pub(crate) queryable: bool,
100}
101
102impl EntityFieldDescription {
103 #[must_use]
105 pub const fn new(name: String, kind: String, primary_key: bool, queryable: bool) -> Self {
106 Self {
107 name,
108 kind,
109 primary_key,
110 queryable,
111 }
112 }
113
114 #[must_use]
116 pub const fn name(&self) -> &str {
117 self.name.as_str()
118 }
119
120 #[must_use]
122 pub const fn kind(&self) -> &str {
123 self.kind.as_str()
124 }
125
126 #[must_use]
128 pub const fn primary_key(&self) -> bool {
129 self.primary_key
130 }
131
132 #[must_use]
134 pub const fn queryable(&self) -> bool {
135 self.queryable
136 }
137}
138
139#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
146pub struct EntityIndexDescription {
147 pub(crate) name: String,
148 pub(crate) unique: bool,
149 pub(crate) fields: Vec<String>,
150}
151
152impl EntityIndexDescription {
153 #[must_use]
155 pub const fn new(name: String, unique: bool, fields: Vec<String>) -> Self {
156 Self {
157 name,
158 unique,
159 fields,
160 }
161 }
162
163 #[must_use]
165 pub const fn name(&self) -> &str {
166 self.name.as_str()
167 }
168
169 #[must_use]
171 pub const fn unique(&self) -> bool {
172 self.unique
173 }
174
175 #[must_use]
177 pub const fn fields(&self) -> &[String] {
178 self.fields.as_slice()
179 }
180}
181
182#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
189pub struct EntityRelationDescription {
190 pub(crate) field: String,
191 pub(crate) target_path: String,
192 pub(crate) target_entity_name: String,
193 pub(crate) target_store_path: String,
194 pub(crate) strength: EntityRelationStrength,
195 pub(crate) cardinality: EntityRelationCardinality,
196}
197
198impl EntityRelationDescription {
199 #[must_use]
201 pub const fn new(
202 field: String,
203 target_path: String,
204 target_entity_name: String,
205 target_store_path: String,
206 strength: EntityRelationStrength,
207 cardinality: EntityRelationCardinality,
208 ) -> Self {
209 Self {
210 field,
211 target_path,
212 target_entity_name,
213 target_store_path,
214 strength,
215 cardinality,
216 }
217 }
218
219 #[must_use]
221 pub const fn field(&self) -> &str {
222 self.field.as_str()
223 }
224
225 #[must_use]
227 pub const fn target_path(&self) -> &str {
228 self.target_path.as_str()
229 }
230
231 #[must_use]
233 pub const fn target_entity_name(&self) -> &str {
234 self.target_entity_name.as_str()
235 }
236
237 #[must_use]
239 pub const fn target_store_path(&self) -> &str {
240 self.target_store_path.as_str()
241 }
242
243 #[must_use]
245 pub const fn strength(&self) -> EntityRelationStrength {
246 self.strength
247 }
248
249 #[must_use]
251 pub const fn cardinality(&self) -> EntityRelationCardinality {
252 self.cardinality
253 }
254}
255
256#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
263pub enum EntityRelationStrength {
264 Strong,
265 Weak,
266}
267
268#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
275pub enum EntityRelationCardinality {
276 Single,
277 List,
278 Set,
279}
280
281#[must_use]
283pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
284 let mut fields = Vec::with_capacity(model.fields.len());
285 let mut relations = Vec::new();
286 for field in model.fields {
287 let field_kind = summarize_field_kind(&field.kind);
288 let queryable = field.kind.value_kind().is_queryable();
289 let primary_key = field.name == model.primary_key.name;
290
291 fields.push(EntityFieldDescription::new(
292 field.name.to_string(),
293 field_kind,
294 primary_key,
295 queryable,
296 ));
297
298 if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
299 relations.push(relation);
300 }
301 }
302
303 let mut indexes = Vec::with_capacity(model.indexes.len());
304 for index in model.indexes {
305 indexes.push(EntityIndexDescription::new(
306 index.name().to_string(),
307 index.is_unique(),
308 index
309 .fields()
310 .iter()
311 .map(|field| (*field).to_string())
312 .collect(),
313 ));
314 }
315
316 EntitySchemaDescription::new(
317 model.path.to_string(),
318 model.entity_name.to_string(),
319 model.primary_key.name.to_string(),
320 fields,
321 indexes,
322 relations,
323 )
324}
325
326fn relation_from_field_kind(
328 field_name: &str,
329 kind: &FieldKind,
330) -> Option<EntityRelationDescription> {
331 match kind {
332 FieldKind::Relation {
333 target_path,
334 target_entity_name,
335 target_store_path,
336 strength,
337 ..
338 } => Some(EntityRelationDescription::new(
339 field_name.to_string(),
340 (*target_path).to_string(),
341 (*target_entity_name).to_string(),
342 (*target_store_path).to_string(),
343 relation_strength(*strength),
344 EntityRelationCardinality::Single,
345 )),
346 FieldKind::List(inner) => {
347 relation_from_collection_relation(field_name, inner, EntityRelationCardinality::List)
348 }
349 FieldKind::Set(inner) => {
350 relation_from_collection_relation(field_name, inner, EntityRelationCardinality::Set)
351 }
352 FieldKind::Account
353 | FieldKind::Blob
354 | FieldKind::Bool
355 | FieldKind::Date
356 | FieldKind::Decimal { .. }
357 | FieldKind::Duration
358 | FieldKind::Enum { .. }
359 | FieldKind::Float32
360 | FieldKind::Float64
361 | FieldKind::Int
362 | FieldKind::Int128
363 | FieldKind::IntBig
364 | FieldKind::Principal
365 | FieldKind::Subaccount
366 | FieldKind::Text
367 | FieldKind::Timestamp
368 | FieldKind::Uint
369 | FieldKind::Uint128
370 | FieldKind::UintBig
371 | FieldKind::Ulid
372 | FieldKind::Unit
373 | FieldKind::Map { .. }
374 | FieldKind::Structured { .. } => None,
375 }
376}
377
378fn relation_from_collection_relation(
380 field_name: &str,
381 inner: &FieldKind,
382 cardinality: EntityRelationCardinality,
383) -> Option<EntityRelationDescription> {
384 let FieldKind::Relation {
385 target_path,
386 target_entity_name,
387 target_store_path,
388 strength,
389 ..
390 } = inner
391 else {
392 return None;
393 };
394
395 Some(EntityRelationDescription::new(
396 field_name.to_string(),
397 (*target_path).to_string(),
398 (*target_entity_name).to_string(),
399 (*target_store_path).to_string(),
400 relation_strength(*strength),
401 cardinality,
402 ))
403}
404
405const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
407 match strength {
408 RelationStrength::Strong => EntityRelationStrength::Strong,
409 RelationStrength::Weak => EntityRelationStrength::Weak,
410 }
411}
412
413fn summarize_field_kind(kind: &FieldKind) -> String {
415 let mut out = String::new();
416 write_field_kind_summary(&mut out, kind);
417
418 out
419}
420
421fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
424 match kind {
425 FieldKind::Account => out.push_str("account"),
426 FieldKind::Blob => out.push_str("blob"),
427 FieldKind::Bool => out.push_str("bool"),
428 FieldKind::Date => out.push_str("date"),
429 FieldKind::Decimal { scale } => {
430 let _ = write!(out, "decimal(scale={scale})");
431 }
432 FieldKind::Duration => out.push_str("duration"),
433 FieldKind::Enum { path, .. } => {
434 out.push_str("enum(");
435 out.push_str(path);
436 out.push(')');
437 }
438 FieldKind::Float32 => out.push_str("float32"),
439 FieldKind::Float64 => out.push_str("float64"),
440 FieldKind::Int => out.push_str("int"),
441 FieldKind::Int128 => out.push_str("int128"),
442 FieldKind::IntBig => out.push_str("int_big"),
443 FieldKind::Principal => out.push_str("principal"),
444 FieldKind::Subaccount => out.push_str("subaccount"),
445 FieldKind::Text => out.push_str("text"),
446 FieldKind::Timestamp => out.push_str("timestamp"),
447 FieldKind::Uint => out.push_str("uint"),
448 FieldKind::Uint128 => out.push_str("uint128"),
449 FieldKind::UintBig => out.push_str("uint_big"),
450 FieldKind::Ulid => out.push_str("ulid"),
451 FieldKind::Unit => out.push_str("unit"),
452 FieldKind::Relation {
453 target_entity_name,
454 key_kind,
455 strength,
456 ..
457 } => {
458 out.push_str("relation(target=");
459 out.push_str(target_entity_name);
460 out.push_str(", key=");
461 write_field_kind_summary(out, key_kind);
462 out.push_str(", strength=");
463 out.push_str(summarize_relation_strength(*strength));
464 out.push(')');
465 }
466 FieldKind::List(inner) => {
467 out.push_str("list<");
468 write_field_kind_summary(out, inner);
469 out.push('>');
470 }
471 FieldKind::Set(inner) => {
472 out.push_str("set<");
473 write_field_kind_summary(out, inner);
474 out.push('>');
475 }
476 FieldKind::Map { key, value } => {
477 out.push_str("map<");
478 write_field_kind_summary(out, key);
479 out.push_str(", ");
480 write_field_kind_summary(out, value);
481 out.push('>');
482 }
483 FieldKind::Structured { queryable } => {
484 let _ = write!(out, "structured(queryable={queryable})");
485 }
486 }
487}
488
489const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
491 match strength {
492 RelationStrength::Strong => "strong",
493 RelationStrength::Weak => "weak",
494 }
495}
496
497#[cfg(test)]
502mod tests {
503 use crate::db::{
504 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
505 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
506 };
507 use candid::types::{CandidType, Label, Type, TypeInner};
508
509 fn expect_record_fields(ty: Type) -> Vec<String> {
510 match ty.as_ref() {
511 TypeInner::Record(fields) => fields
512 .iter()
513 .map(|field| match field.id.as_ref() {
514 Label::Named(name) => name.clone(),
515 other => panic!("expected named record field, got {other:?}"),
516 })
517 .collect(),
518 other => panic!("expected candid record, got {other:?}"),
519 }
520 }
521
522 fn expect_variant_labels(ty: Type) -> Vec<String> {
523 match ty.as_ref() {
524 TypeInner::Variant(fields) => fields
525 .iter()
526 .map(|field| match field.id.as_ref() {
527 Label::Named(name) => name.clone(),
528 other => panic!("expected named variant label, got {other:?}"),
529 })
530 .collect(),
531 other => panic!("expected candid variant, got {other:?}"),
532 }
533 }
534
535 #[test]
536 fn entity_schema_description_candid_shape_is_stable() {
537 let fields = expect_record_fields(EntitySchemaDescription::ty());
538
539 for field in [
540 "entity_path",
541 "entity_name",
542 "primary_key",
543 "fields",
544 "indexes",
545 "relations",
546 ] {
547 assert!(
548 fields.iter().any(|candidate| candidate == field),
549 "EntitySchemaDescription must keep `{field}` field key",
550 );
551 }
552 }
553
554 #[test]
555 fn entity_field_description_candid_shape_is_stable() {
556 let fields = expect_record_fields(EntityFieldDescription::ty());
557
558 for field in ["name", "kind", "primary_key", "queryable"] {
559 assert!(
560 fields.iter().any(|candidate| candidate == field),
561 "EntityFieldDescription must keep `{field}` field key",
562 );
563 }
564 }
565
566 #[test]
567 fn entity_index_description_candid_shape_is_stable() {
568 let fields = expect_record_fields(EntityIndexDescription::ty());
569
570 for field in ["name", "unique", "fields"] {
571 assert!(
572 fields.iter().any(|candidate| candidate == field),
573 "EntityIndexDescription must keep `{field}` field key",
574 );
575 }
576 }
577
578 #[test]
579 fn entity_relation_description_candid_shape_is_stable() {
580 let fields = expect_record_fields(EntityRelationDescription::ty());
581
582 for field in [
583 "field",
584 "target_path",
585 "target_entity_name",
586 "target_store_path",
587 "strength",
588 "cardinality",
589 ] {
590 assert!(
591 fields.iter().any(|candidate| candidate == field),
592 "EntityRelationDescription must keep `{field}` field key",
593 );
594 }
595 }
596
597 #[test]
598 fn relation_enum_variant_labels_are_stable() {
599 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
600 strength_labels.sort_unstable();
601 assert_eq!(
602 strength_labels,
603 vec!["Strong".to_string(), "Weak".to_string()]
604 );
605
606 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
607 cardinality_labels.sort_unstable();
608 assert_eq!(
609 cardinality_labels,
610 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
611 );
612 }
613
614 #[test]
615 fn describe_fixture_constructors_stay_usable() {
616 let payload = EntitySchemaDescription::new(
617 "entities::User".to_string(),
618 "User".to_string(),
619 "id".to_string(),
620 vec![EntityFieldDescription::new(
621 "id".to_string(),
622 "ulid".to_string(),
623 true,
624 true,
625 )],
626 vec![EntityIndexDescription::new(
627 "idx_email".to_string(),
628 true,
629 vec!["email".to_string()],
630 )],
631 vec![EntityRelationDescription::new(
632 "account_id".to_string(),
633 "entities::Account".to_string(),
634 "Account".to_string(),
635 "accounts".to_string(),
636 EntityRelationStrength::Strong,
637 EntityRelationCardinality::Single,
638 )],
639 );
640
641 assert_eq!(payload.entity_name(), "User");
642 assert_eq!(payload.fields().len(), 1);
643 assert_eq!(payload.indexes().len(), 1);
644 assert_eq!(payload.relations().len(), 1);
645 }
646}