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 = describe_entity_fields(model);
273 let mut relations = Vec::new();
274
275 for field in model.fields {
276 if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
277 relations.push(relation);
278 }
279 }
280
281 let mut indexes = Vec::with_capacity(model.indexes.len());
282 for index in model.indexes {
283 indexes.push(EntityIndexDescription::new(
284 index.name().to_string(),
285 index.is_unique(),
286 index
287 .fields()
288 .iter()
289 .map(|field| (*field).to_string())
290 .collect(),
291 ));
292 }
293
294 EntitySchemaDescription::new(
295 model.path.to_string(),
296 model.entity_name.to_string(),
297 model.primary_key.name.to_string(),
298 fields,
299 indexes,
300 relations,
301 )
302}
303
304#[must_use]
308pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
309 let mut fields = Vec::with_capacity(model.fields.len());
310
311 for field in model.fields {
312 let field_kind = summarize_field_kind(&field.kind);
313 let queryable = field.kind.value_kind().is_queryable();
314 let primary_key = field.name == model.primary_key.name;
315
316 fields.push(EntityFieldDescription::new(
317 field.name.to_string(),
318 field_kind,
319 primary_key,
320 queryable,
321 ));
322 }
323
324 fields
325}
326
327#[cfg_attr(
328 doc,
329 doc = "Resolve relation metadata from one field kind, including list/set relation forms."
330)]
331fn 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
382#[cfg_attr(
383 doc,
384 doc = "Resolve list/set relation metadata only when the collection inner shape is relation."
385)]
386fn relation_from_collection_relation(
387 field_name: &str,
388 inner: &FieldKind,
389 cardinality: EntityRelationCardinality,
390) -> Option<EntityRelationDescription> {
391 let FieldKind::Relation {
392 target_path,
393 target_entity_name,
394 target_store_path,
395 strength,
396 ..
397 } = inner
398 else {
399 return None;
400 };
401
402 Some(EntityRelationDescription::new(
403 field_name.to_string(),
404 (*target_path).to_string(),
405 (*target_entity_name).to_string(),
406 (*target_store_path).to_string(),
407 relation_strength(*strength),
408 cardinality,
409 ))
410}
411
412#[cfg_attr(
413 doc,
414 doc = "Project runtime relation strength into the describe DTO surface."
415)]
416const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
417 match strength {
418 RelationStrength::Strong => EntityRelationStrength::Strong,
419 RelationStrength::Weak => EntityRelationStrength::Weak,
420 }
421}
422
423#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
424fn summarize_field_kind(kind: &FieldKind) -> String {
425 let mut out = String::new();
426 write_field_kind_summary(&mut out, kind);
427
428 out
429}
430
431fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
434 match kind {
435 FieldKind::Account => out.push_str("account"),
436 FieldKind::Blob => out.push_str("blob"),
437 FieldKind::Bool => out.push_str("bool"),
438 FieldKind::Date => out.push_str("date"),
439 FieldKind::Decimal { scale } => {
440 let _ = write!(out, "decimal(scale={scale})");
441 }
442 FieldKind::Duration => out.push_str("duration"),
443 FieldKind::Enum { path, .. } => {
444 out.push_str("enum(");
445 out.push_str(path);
446 out.push(')');
447 }
448 FieldKind::Float32 => out.push_str("float32"),
449 FieldKind::Float64 => out.push_str("float64"),
450 FieldKind::Int => out.push_str("int"),
451 FieldKind::Int128 => out.push_str("int128"),
452 FieldKind::IntBig => out.push_str("int_big"),
453 FieldKind::Principal => out.push_str("principal"),
454 FieldKind::Subaccount => out.push_str("subaccount"),
455 FieldKind::Text => out.push_str("text"),
456 FieldKind::Timestamp => out.push_str("timestamp"),
457 FieldKind::Uint => out.push_str("uint"),
458 FieldKind::Uint128 => out.push_str("uint128"),
459 FieldKind::UintBig => out.push_str("uint_big"),
460 FieldKind::Ulid => out.push_str("ulid"),
461 FieldKind::Unit => out.push_str("unit"),
462 FieldKind::Relation {
463 target_entity_name,
464 key_kind,
465 strength,
466 ..
467 } => {
468 out.push_str("relation(target=");
469 out.push_str(target_entity_name);
470 out.push_str(", key=");
471 write_field_kind_summary(out, key_kind);
472 out.push_str(", strength=");
473 out.push_str(summarize_relation_strength(*strength));
474 out.push(')');
475 }
476 FieldKind::List(inner) => {
477 out.push_str("list<");
478 write_field_kind_summary(out, inner);
479 out.push('>');
480 }
481 FieldKind::Set(inner) => {
482 out.push_str("set<");
483 write_field_kind_summary(out, inner);
484 out.push('>');
485 }
486 FieldKind::Map { key, value } => {
487 out.push_str("map<");
488 write_field_kind_summary(out, key);
489 out.push_str(", ");
490 write_field_kind_summary(out, value);
491 out.push('>');
492 }
493 FieldKind::Structured { queryable } => {
494 let _ = write!(out, "structured(queryable={queryable})");
495 }
496 }
497}
498
499#[cfg_attr(
500 doc,
501 doc = "Render one stable relation-strength label for field-kind summaries."
502)]
503const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
504 match strength {
505 RelationStrength::Strong => "strong",
506 RelationStrength::Weak => "weak",
507 }
508}
509
510#[cfg(test)]
515mod tests {
516 use crate::db::{
517 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
518 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
519 };
520 use candid::types::{CandidType, Label, Type, TypeInner};
521
522 fn expect_record_fields(ty: Type) -> Vec<String> {
523 match ty.as_ref() {
524 TypeInner::Record(fields) => fields
525 .iter()
526 .map(|field| match field.id.as_ref() {
527 Label::Named(name) => name.clone(),
528 other => panic!("expected named record field, got {other:?}"),
529 })
530 .collect(),
531 other => panic!("expected candid record, got {other:?}"),
532 }
533 }
534
535 fn expect_variant_labels(ty: Type) -> Vec<String> {
536 match ty.as_ref() {
537 TypeInner::Variant(fields) => fields
538 .iter()
539 .map(|field| match field.id.as_ref() {
540 Label::Named(name) => name.clone(),
541 other => panic!("expected named variant label, got {other:?}"),
542 })
543 .collect(),
544 other => panic!("expected candid variant, got {other:?}"),
545 }
546 }
547
548 #[test]
549 fn entity_schema_description_candid_shape_is_stable() {
550 let fields = expect_record_fields(EntitySchemaDescription::ty());
551
552 for field in [
553 "entity_path",
554 "entity_name",
555 "primary_key",
556 "fields",
557 "indexes",
558 "relations",
559 ] {
560 assert!(
561 fields.iter().any(|candidate| candidate == field),
562 "EntitySchemaDescription must keep `{field}` field key",
563 );
564 }
565 }
566
567 #[test]
568 fn entity_field_description_candid_shape_is_stable() {
569 let fields = expect_record_fields(EntityFieldDescription::ty());
570
571 for field in ["name", "kind", "primary_key", "queryable"] {
572 assert!(
573 fields.iter().any(|candidate| candidate == field),
574 "EntityFieldDescription must keep `{field}` field key",
575 );
576 }
577 }
578
579 #[test]
580 fn entity_index_description_candid_shape_is_stable() {
581 let fields = expect_record_fields(EntityIndexDescription::ty());
582
583 for field in ["name", "unique", "fields"] {
584 assert!(
585 fields.iter().any(|candidate| candidate == field),
586 "EntityIndexDescription must keep `{field}` field key",
587 );
588 }
589 }
590
591 #[test]
592 fn entity_relation_description_candid_shape_is_stable() {
593 let fields = expect_record_fields(EntityRelationDescription::ty());
594
595 for field in [
596 "field",
597 "target_path",
598 "target_entity_name",
599 "target_store_path",
600 "strength",
601 "cardinality",
602 ] {
603 assert!(
604 fields.iter().any(|candidate| candidate == field),
605 "EntityRelationDescription must keep `{field}` field key",
606 );
607 }
608 }
609
610 #[test]
611 fn relation_enum_variant_labels_are_stable() {
612 let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
613 strength_labels.sort_unstable();
614 assert_eq!(
615 strength_labels,
616 vec!["Strong".to_string(), "Weak".to_string()]
617 );
618
619 let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
620 cardinality_labels.sort_unstable();
621 assert_eq!(
622 cardinality_labels,
623 vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
624 );
625 }
626
627 #[test]
628 fn describe_fixture_constructors_stay_usable() {
629 let payload = EntitySchemaDescription::new(
630 "entities::User".to_string(),
631 "User".to_string(),
632 "id".to_string(),
633 vec![EntityFieldDescription::new(
634 "id".to_string(),
635 "ulid".to_string(),
636 true,
637 true,
638 )],
639 vec![EntityIndexDescription::new(
640 "idx_email".to_string(),
641 true,
642 vec!["email".to_string()],
643 )],
644 vec![EntityRelationDescription::new(
645 "account_id".to_string(),
646 "entities::Account".to_string(),
647 "Account".to_string(),
648 "accounts".to_string(),
649 EntityRelationStrength::Strong,
650 EntityRelationCardinality::Single,
651 )],
652 );
653
654 assert_eq!(payload.entity_name(), "User");
655 assert_eq!(payload.fields().len(), 1);
656 assert_eq!(payload.indexes().len(), 1);
657 assert_eq!(payload.relations().len(), 1);
658 }
659}