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