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 mut fields = Vec::with_capacity(model.fields.len());
273 let mut relations = Vec::new();
274 for field in model.fields {
275 let field_kind = summarize_field_kind(&field.kind);
276 let queryable = field.kind.value_kind().is_queryable();
277 let primary_key = field.name == model.primary_key.name;
278
279 fields.push(EntityFieldDescription::new(
280 field.name.to_string(),
281 field_kind,
282 primary_key,
283 queryable,
284 ));
285
286 if let Some(relation) = relation_from_field_kind(field.name, &field.kind) {
287 relations.push(relation);
288 }
289 }
290
291 let mut indexes = Vec::with_capacity(model.indexes.len());
292 for index in model.indexes {
293 indexes.push(EntityIndexDescription::new(
294 index.name().to_string(),
295 index.is_unique(),
296 index
297 .fields()
298 .iter()
299 .map(|field| (*field).to_string())
300 .collect(),
301 ));
302 }
303
304 EntitySchemaDescription::new(
305 model.path.to_string(),
306 model.entity_name.to_string(),
307 model.primary_key.name.to_string(),
308 fields,
309 indexes,
310 relations,
311 )
312}
313
314#[cfg_attr(
315 doc,
316 doc = "Resolve relation metadata from one field kind, including list/set relation forms."
317)]
318fn relation_from_field_kind(
319 field_name: &str,
320 kind: &FieldKind,
321) -> Option<EntityRelationDescription> {
322 match kind {
323 FieldKind::Relation {
324 target_path,
325 target_entity_name,
326 target_store_path,
327 strength,
328 ..
329 } => Some(EntityRelationDescription::new(
330 field_name.to_string(),
331 (*target_path).to_string(),
332 (*target_entity_name).to_string(),
333 (*target_store_path).to_string(),
334 relation_strength(*strength),
335 EntityRelationCardinality::Single,
336 )),
337 FieldKind::List(inner) => {
338 relation_from_collection_relation(field_name, inner, EntityRelationCardinality::List)
339 }
340 FieldKind::Set(inner) => {
341 relation_from_collection_relation(field_name, inner, EntityRelationCardinality::Set)
342 }
343 FieldKind::Account
344 | FieldKind::Blob
345 | FieldKind::Bool
346 | FieldKind::Date
347 | FieldKind::Decimal { .. }
348 | FieldKind::Duration
349 | FieldKind::Enum { .. }
350 | FieldKind::Float32
351 | FieldKind::Float64
352 | FieldKind::Int
353 | FieldKind::Int128
354 | FieldKind::IntBig
355 | FieldKind::Principal
356 | FieldKind::Subaccount
357 | FieldKind::Text
358 | FieldKind::Timestamp
359 | FieldKind::Uint
360 | FieldKind::Uint128
361 | FieldKind::UintBig
362 | FieldKind::Ulid
363 | FieldKind::Unit
364 | FieldKind::Map { .. }
365 | FieldKind::Structured { .. } => None,
366 }
367}
368
369#[cfg_attr(
370 doc,
371 doc = "Resolve list/set relation metadata only when the collection inner shape is relation."
372)]
373fn relation_from_collection_relation(
374 field_name: &str,
375 inner: &FieldKind,
376 cardinality: EntityRelationCardinality,
377) -> Option<EntityRelationDescription> {
378 let FieldKind::Relation {
379 target_path,
380 target_entity_name,
381 target_store_path,
382 strength,
383 ..
384 } = inner
385 else {
386 return None;
387 };
388
389 Some(EntityRelationDescription::new(
390 field_name.to_string(),
391 (*target_path).to_string(),
392 (*target_entity_name).to_string(),
393 (*target_store_path).to_string(),
394 relation_strength(*strength),
395 cardinality,
396 ))
397}
398
399#[cfg_attr(
400 doc,
401 doc = "Project runtime relation strength into the describe DTO surface."
402)]
403const fn relation_strength(strength: RelationStrength) -> EntityRelationStrength {
404 match strength {
405 RelationStrength::Strong => EntityRelationStrength::Strong,
406 RelationStrength::Weak => EntityRelationStrength::Weak,
407 }
408}
409
410#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
411fn summarize_field_kind(kind: &FieldKind) -> String {
412 let mut out = String::new();
413 write_field_kind_summary(&mut out, kind);
414
415 out
416}
417
418fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
421 match kind {
422 FieldKind::Account => out.push_str("account"),
423 FieldKind::Blob => out.push_str("blob"),
424 FieldKind::Bool => out.push_str("bool"),
425 FieldKind::Date => out.push_str("date"),
426 FieldKind::Decimal { scale } => {
427 let _ = write!(out, "decimal(scale={scale})");
428 }
429 FieldKind::Duration => out.push_str("duration"),
430 FieldKind::Enum { path, .. } => {
431 out.push_str("enum(");
432 out.push_str(path);
433 out.push(')');
434 }
435 FieldKind::Float32 => out.push_str("float32"),
436 FieldKind::Float64 => out.push_str("float64"),
437 FieldKind::Int => out.push_str("int"),
438 FieldKind::Int128 => out.push_str("int128"),
439 FieldKind::IntBig => out.push_str("int_big"),
440 FieldKind::Principal => out.push_str("principal"),
441 FieldKind::Subaccount => out.push_str("subaccount"),
442 FieldKind::Text => out.push_str("text"),
443 FieldKind::Timestamp => out.push_str("timestamp"),
444 FieldKind::Uint => out.push_str("uint"),
445 FieldKind::Uint128 => out.push_str("uint128"),
446 FieldKind::UintBig => out.push_str("uint_big"),
447 FieldKind::Ulid => out.push_str("ulid"),
448 FieldKind::Unit => out.push_str("unit"),
449 FieldKind::Relation {
450 target_entity_name,
451 key_kind,
452 strength,
453 ..
454 } => {
455 out.push_str("relation(target=");
456 out.push_str(target_entity_name);
457 out.push_str(", key=");
458 write_field_kind_summary(out, key_kind);
459 out.push_str(", strength=");
460 out.push_str(summarize_relation_strength(*strength));
461 out.push(')');
462 }
463 FieldKind::List(inner) => {
464 out.push_str("list<");
465 write_field_kind_summary(out, inner);
466 out.push('>');
467 }
468 FieldKind::Set(inner) => {
469 out.push_str("set<");
470 write_field_kind_summary(out, inner);
471 out.push('>');
472 }
473 FieldKind::Map { key, value } => {
474 out.push_str("map<");
475 write_field_kind_summary(out, key);
476 out.push_str(", ");
477 write_field_kind_summary(out, value);
478 out.push('>');
479 }
480 FieldKind::Structured { queryable } => {
481 let _ = write!(out, "structured(queryable={queryable})");
482 }
483 }
484}
485
486#[cfg_attr(
487 doc,
488 doc = "Render one stable relation-strength label for field-kind summaries."
489)]
490const 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}