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