1use crate::prelude::*;
2
3#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub(crate) struct RelationComponentContract<'a> {
11 target: &'a ItemTarget,
12 scale: Option<u32>,
13 max_len: Option<u32>,
14 max_bytes: Option<u32>,
15}
16
17impl<'a> RelationComponentContract<'a> {
18 pub(crate) const fn from_field(field: &'a Field) -> Self {
19 Self::from_item(field.value().item())
20 }
21
22 pub(crate) const fn from_item(item: &'a Item) -> Self {
23 Self {
24 target: item.target(),
25 scale: item.scale(),
26 max_len: item.max_len(),
27 max_bytes: item.max_bytes(),
28 }
29 }
30
31 pub(crate) const fn target(&self) -> &'a ItemTarget {
32 self.target
33 }
34
35 pub(crate) const fn scale(&self) -> Option<u32> {
36 self.scale
37 }
38
39 pub(crate) const fn max_len(&self) -> Option<u32> {
40 self.max_len
41 }
42
43 pub(crate) const fn max_bytes(&self) -> Option<u32> {
44 self.max_bytes
45 }
46
47 pub(crate) fn mismatches(self, other: Self) -> bool {
48 self != other
49 }
50}
51
52#[derive(Clone, Debug, Serialize)]
62pub struct RelationEdge {
63 ident: &'static str,
64 target: &'static str,
65 local_fields: &'static [&'static str],
66}
67
68impl RelationEdge {
69 #[must_use]
72 pub const fn new(
73 ident: &'static str,
74 target: &'static str,
75 local_fields: &'static [&'static str],
76 ) -> Self {
77 Self {
78 ident,
79 target,
80 local_fields,
81 }
82 }
83
84 #[must_use]
86 pub const fn ident(&self) -> &'static str {
87 self.ident
88 }
89
90 #[must_use]
92 pub const fn target(&self) -> &'static str {
93 self.target
94 }
95
96 #[must_use]
98 pub const fn local_fields(&self) -> &'static [&'static str] {
99 self.local_fields
100 }
101
102 pub fn validate_for_source(&self, source: &Entity) -> Result<(), ErrorTree> {
105 let schema = schema_read();
106
107 match schema.cast_node::<Entity>(self.target()) {
108 Ok(target) => self.validate_against_entities(source, target),
109 Err(_) => Err(ErrorTree::from(format!(
110 "relation edge '{}' target entity '{}' not found",
111 self.ident(),
112 self.target()
113 ))),
114 }
115 }
116
117 pub fn validate_against_entities(
119 &self,
120 source: &Entity,
121 target: &Entity,
122 ) -> Result<(), ErrorTree> {
123 let mut errs = ErrorTree::new();
124 let target_fields = target.primary_key().fields();
125
126 if self.local_fields().is_empty() {
127 err!(
128 errs,
129 "relation edge '{}' must declare at least one local field",
130 self.ident()
131 );
132 }
133
134 if self.local_fields().len() != target_fields.len() {
135 err!(
136 errs,
137 "relation edge '{}' arity mismatch: local fields {:?} target primary key fields {:?}",
138 self.ident(),
139 self.local_fields(),
140 target_fields,
141 );
142 return errs.result();
143 }
144
145 let mut local_component_cardinality = None;
146 for (index, (local_name, target_name)) in self
147 .local_fields()
148 .iter()
149 .zip(target_fields.iter())
150 .enumerate()
151 {
152 let Some(local_field) = source.fields().get(local_name) else {
153 err!(
154 errs,
155 "relation edge '{}' local field '{}' not found",
156 self.ident(),
157 local_name
158 );
159 continue;
160 };
161 let Some(target_field) = target.fields().get(target_name) else {
162 err!(
163 errs,
164 "relation edge '{}' target primary key field '{}' not found",
165 self.ident(),
166 target_name
167 );
168 continue;
169 };
170
171 if !self.validate_local_component_shape(
172 &mut errs,
173 local_name,
174 local_field,
175 &mut local_component_cardinality,
176 ) {
177 continue;
178 }
179
180 self.validate_component_contract(
181 &mut errs,
182 index,
183 local_name,
184 local_field,
185 target_name,
186 target_field,
187 );
188 }
189
190 errs.result()
191 }
192
193 fn validate_local_component_shape(
194 &self,
195 errs: &mut ErrorTree,
196 local_name: &str,
197 local_field: &Field,
198 local_component_cardinality: &mut Option<Cardinality>,
199 ) -> bool {
200 let local_cardinality = local_field.value().cardinality();
201 if local_cardinality == Cardinality::Many {
202 err!(
203 errs,
204 "relation edge '{}' local field '{}' cannot have many cardinality",
205 self.ident(),
206 local_name
207 );
208 return false;
209 }
210 match *local_component_cardinality {
211 Some(expected) if expected != local_cardinality => {
212 err!(
213 errs,
214 "relation edge '{}' local field '{}' cardinality mismatch: all local component fields must be required or all optional",
215 self.ident(),
216 local_name
217 );
218 return false;
219 }
220 Some(_) => {}
221 None => *local_component_cardinality = Some(local_cardinality),
222 }
223
224 if local_field.generated().is_some() {
225 err!(
226 errs,
227 "relation edge '{}' local field '{}' is generated and cannot be a relation component",
228 self.ident(),
229 local_name
230 );
231 return false;
232 }
233
234 true
235 }
236
237 fn validate_component_contract(
238 &self,
239 errs: &mut ErrorTree,
240 index: usize,
241 local_name: &str,
242 local_field: &Field,
243 target_name: &str,
244 target_field: &Field,
245 ) {
246 let expected = RelationComponentContract::from_field(target_field);
247 if !target_primary_key_component_is_admissible(expected) {
248 err!(
249 errs,
250 "relation edge '{}' target primary key field '{}' uses non-admissible component {:?}",
251 self.ident(),
252 target_name,
253 expected.target(),
254 );
255 return;
256 }
257
258 let actual = RelationComponentContract::from_field(local_field);
259 if expected.mismatches(actual) {
260 err!(
261 errs,
262 "relation edge '{}' component {index} type mismatch: local field '{}' has ({:?}, scale={:?}, max_len={:?}, max_bytes={:?}); target field '{}' requires ({:?}, scale={:?}, max_len={:?}, max_bytes={:?})",
263 self.ident(),
264 local_name,
265 actual.target(),
266 actual.scale(),
267 actual.max_len(),
268 actual.max_bytes(),
269 target_name,
270 expected.target(),
271 expected.scale(),
272 expected.max_len(),
273 expected.max_bytes(),
274 );
275 }
276 }
277}
278
279const fn target_primary_key_component_is_admissible(
280 contract: RelationComponentContract<'_>,
281) -> bool {
282 match contract.target() {
283 ItemTarget::Primitive(primitive) => primitive.is_primary_key_component_encodable(),
284 ItemTarget::Is(_) => false,
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::build::schema_write;
292
293 fn primitive_item(primitive: Primitive) -> Item {
294 Item::new(
295 ItemTarget::Primitive(primitive),
296 None,
297 None,
298 None,
299 None,
300 &[],
301 &[],
302 false,
303 )
304 }
305
306 fn item_with_metadata(
307 primitive: Primitive,
308 scale: Option<u32>,
309 max_len: Option<u32>,
310 max_bytes: Option<u32>,
311 ) -> Item {
312 Item::new(
313 ItemTarget::Primitive(primitive),
314 None,
315 scale,
316 max_len,
317 max_bytes,
318 &[],
319 &[],
320 false,
321 )
322 }
323
324 fn field(ident: &'static str, primitive: Primitive) -> Field {
325 field_with_item(ident, primitive_item(primitive))
326 }
327
328 fn generated_field(ident: &'static str, primitive: Primitive) -> Field {
329 Field::new(
330 ident,
331 Value::new(Cardinality::One, primitive_item(primitive)),
332 None,
333 Some(FieldGeneration::Insert(Arg::FuncPath(
334 "generate_relation_component",
335 ))),
336 None,
337 )
338 }
339
340 fn field_with_item(ident: &'static str, item: Item) -> Field {
341 Field::new(ident, Value::new(Cardinality::One, item), None, None, None)
342 }
343
344 fn optional_field(ident: &'static str, primitive: Primitive) -> Field {
345 Field::new(
346 ident,
347 Value::new(Cardinality::Opt, primitive_item(primitive)),
348 None,
349 None,
350 None,
351 )
352 }
353
354 fn entity(
355 module: &'static str,
356 ident: &'static str,
357 pk_fields: &'static [&'static str],
358 fields: &'static [Field],
359 ) -> Entity {
360 Entity::new(
361 Def::new(module, ident),
362 "RelationEdgeStore",
363 1,
364 PrimaryKey::new(pk_fields, PrimaryKeySource::External),
365 None,
366 &[],
367 &[],
368 FieldList::new(fields),
369 Type::new(&[], &[]),
370 )
371 }
372
373 fn insert_entity(
374 module: &'static str,
375 ident: &'static str,
376 pk_fields: &'static [&'static str],
377 fields: &'static [Field],
378 ) -> (&'static str, Entity) {
379 let path = Box::leak(format!("{module}::{ident}").into_boxed_str());
380 let entity = entity(module, ident, pk_fields, fields);
381 schema_write().insert_node(SchemaNode::Entity(entity.clone()));
382 (path, entity)
383 }
384
385 #[test]
386 fn relation_edge_accepts_ordered_composite_target_tuple() {
387 let source_fields = Box::leak(
388 vec![
389 field("author_tenant_id", Primitive::Nat64),
390 field("author_user_id", Primitive::Ulid),
391 ]
392 .into_boxed_slice(),
393 );
394 let target_fields = Box::leak(
395 vec![
396 field("tenant_id", Primitive::Nat64),
397 field("user_id", Primitive::Ulid),
398 ]
399 .into_boxed_slice(),
400 );
401 let source = entity(
402 "schema_relation_edge_accepts_tuple",
403 "Post",
404 &["author_user_id"],
405 source_fields,
406 );
407 let target = entity(
408 "schema_relation_edge_accepts_tuple",
409 "User",
410 &["tenant_id", "user_id"],
411 target_fields,
412 );
413
414 RelationEdge::new(
415 "author",
416 "schema_relation_edge_accepts_tuple::User",
417 &["author_tenant_id", "author_user_id"],
418 )
419 .validate_against_entities(&source, &target)
420 .expect("matching ordered composite relation tuple should validate");
421 }
422
423 #[test]
424 fn relation_edge_rejects_scalar_local_field_for_composite_target() {
425 let source_fields =
426 Box::leak(vec![field("author_user_id", Primitive::Ulid)].into_boxed_slice());
427 let target_fields = Box::leak(
428 vec![
429 field("tenant_id", Primitive::Nat64),
430 field("user_id", Primitive::Ulid),
431 ]
432 .into_boxed_slice(),
433 );
434 let source = entity(
435 "schema_relation_edge_rejects_scalar_for_composite",
436 "Post",
437 &["author_user_id"],
438 source_fields,
439 );
440 let target = entity(
441 "schema_relation_edge_rejects_scalar_for_composite",
442 "User",
443 &["tenant_id", "user_id"],
444 target_fields,
445 );
446
447 let err = RelationEdge::new(
448 "author",
449 "schema_relation_edge_rejects_scalar_for_composite::User",
450 &["author_user_id"],
451 )
452 .validate_against_entities(&source, &target)
453 .expect_err("scalar local component must not validate as composite target tuple");
454
455 assert!(
456 err.messages()
457 .iter()
458 .any(|message| message.contains("arity mismatch")),
459 "unexpected relation edge validation errors: {err}",
460 );
461 }
462
463 #[test]
464 fn relation_edge_rejects_wrong_component_order() {
465 let source_fields = Box::leak(
466 vec![
467 field("author_tenant_id", Primitive::Nat64),
468 field("author_user_id", Primitive::Ulid),
469 ]
470 .into_boxed_slice(),
471 );
472 let target_fields = Box::leak(
473 vec![
474 field("tenant_id", Primitive::Nat64),
475 field("user_id", Primitive::Ulid),
476 ]
477 .into_boxed_slice(),
478 );
479 let source = entity(
480 "schema_relation_edge_rejects_order",
481 "Post",
482 &["author_user_id"],
483 source_fields,
484 );
485 let target = entity(
486 "schema_relation_edge_rejects_order",
487 "User",
488 &["tenant_id", "user_id"],
489 target_fields,
490 );
491
492 let err = RelationEdge::new(
493 "author",
494 "schema_relation_edge_rejects_order::User",
495 &["author_user_id", "author_tenant_id"],
496 )
497 .validate_against_entities(&source, &target)
498 .expect_err("local tuple order must match target primary-key order");
499
500 assert!(
501 err.messages()
502 .iter()
503 .any(|message| message.contains("component 0 type mismatch")),
504 "unexpected relation edge validation errors: {err}",
505 );
506 }
507
508 #[test]
509 fn relation_edge_rejects_missing_local_component_field() {
510 let source_fields =
511 Box::leak(vec![field("author_tenant_id", Primitive::Nat64)].into_boxed_slice());
512 let target_fields = Box::leak(
513 vec![
514 field("tenant_id", Primitive::Nat64),
515 field("user_id", Primitive::Ulid),
516 ]
517 .into_boxed_slice(),
518 );
519 let source = entity(
520 "schema_relation_edge_rejects_missing_local",
521 "Post",
522 &["author_tenant_id"],
523 source_fields,
524 );
525 let target = entity(
526 "schema_relation_edge_rejects_missing_local",
527 "User",
528 &["tenant_id", "user_id"],
529 target_fields,
530 );
531
532 let err = RelationEdge::new(
533 "author",
534 "schema_relation_edge_rejects_missing_local::User",
535 &["author_tenant_id", "author_user_id"],
536 )
537 .validate_against_entities(&source, &target)
538 .expect_err("missing local tuple component should reject");
539
540 assert!(
541 err.messages()
542 .iter()
543 .any(|message| message.contains("local field 'author_user_id' not found")),
544 "unexpected relation edge validation errors: {err}",
545 );
546 }
547
548 #[test]
549 fn relation_edge_rejects_non_admissible_target_primary_key_component() {
550 let source_fields =
551 Box::leak(vec![field("author_score", Primitive::IntBig)].into_boxed_slice());
552 let target_fields = Box::leak(vec![field("score", Primitive::IntBig)].into_boxed_slice());
553 let source = entity(
554 "schema_relation_edge_rejects_int_big_target",
555 "Post",
556 &["author_score"],
557 source_fields,
558 );
559 let target = entity(
560 "schema_relation_edge_rejects_int_big_target",
561 "User",
562 &["score"],
563 target_fields,
564 );
565
566 let err = RelationEdge::new(
567 "author",
568 "schema_relation_edge_rejects_int_big_target::User",
569 &["author_score"],
570 )
571 .validate_against_entities(&source, &target)
572 .expect_err("int_big target primary key component should reject");
573
574 assert!(
575 err.messages()
576 .iter()
577 .any(|message| message.contains("non-admissible component")),
578 "unexpected relation edge validation errors: {err}",
579 );
580 }
581
582 #[test]
583 fn relation_edge_rejects_generated_local_component_field() {
584 let source_fields =
585 Box::leak(vec![generated_field("author_id", Primitive::Ulid)].into_boxed_slice());
586 let target_fields = Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice());
587 let source = entity(
588 "schema_relation_edge_rejects_generated_local",
589 "Post",
590 &["author_id"],
591 source_fields,
592 );
593 let target = entity(
594 "schema_relation_edge_rejects_generated_local",
595 "User",
596 &["id"],
597 target_fields,
598 );
599
600 let err = RelationEdge::new(
601 "author",
602 "schema_relation_edge_rejects_generated_local::User",
603 &["author_id"],
604 )
605 .validate_against_entities(&source, &target)
606 .expect_err("generated local component field should reject");
607
608 assert!(
609 err.messages()
610 .iter()
611 .any(|message| message.contains("is generated")),
612 "unexpected relation edge validation errors: {err}",
613 );
614 }
615
616 #[test]
617 fn relation_edge_rejects_mixed_local_component_cardinality() {
618 let source_fields = Box::leak(
619 vec![
620 field("author_tenant_id", Primitive::Nat64),
621 optional_field("author_user_id", Primitive::Ulid),
622 ]
623 .into_boxed_slice(),
624 );
625 let target_fields = Box::leak(
626 vec![
627 field("tenant_id", Primitive::Nat64),
628 field("user_id", Primitive::Ulid),
629 ]
630 .into_boxed_slice(),
631 );
632 let source = entity(
633 "schema_relation_edge_rejects_mixed_cardinality",
634 "Post",
635 &["author_tenant_id"],
636 source_fields,
637 );
638 let target = entity(
639 "schema_relation_edge_rejects_mixed_cardinality",
640 "User",
641 &["tenant_id", "user_id"],
642 target_fields,
643 );
644
645 let err = RelationEdge::new(
646 "author",
647 "schema_relation_edge_rejects_mixed_cardinality::User",
648 &["author_tenant_id", "author_user_id"],
649 )
650 .validate_against_entities(&source, &target)
651 .expect_err("mixed local tuple cardinality should reject");
652
653 assert!(
654 err.messages()
655 .iter()
656 .any(|message| message.contains("cardinality mismatch")),
657 "unexpected relation edge validation errors: {err}",
658 );
659 }
660
661 #[test]
662 fn relation_edge_validate_for_source_uses_schema_target_lookup() {
663 let source_fields = Box::leak(vec![field("author_id", Primitive::Ulid)].into_boxed_slice());
664 let target_fields = Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice());
665 let source = entity(
666 "schema_relation_edge_lookup",
667 "Post",
668 &["author_id"],
669 source_fields,
670 );
671 let (target_path, _) = insert_entity(
672 "schema_relation_edge_lookup",
673 "User",
674 &["id"],
675 target_fields,
676 );
677
678 RelationEdge::new("author", target_path, &["author_id"])
679 .validate_for_source(&source)
680 .expect("schema target lookup should validate matching scalar edge");
681 }
682
683 #[test]
684 fn relation_edge_component_contract_preserves_bounds() {
685 let expected = field_with_item(
686 "body",
687 item_with_metadata(Primitive::Text, None, Some(64), None),
688 );
689 let same = field_with_item(
690 "body_copy",
691 item_with_metadata(Primitive::Text, None, Some(64), None),
692 );
693 let wrong = field_with_item(
694 "body_short",
695 item_with_metadata(Primitive::Text, None, Some(32), None),
696 );
697
698 let expected = RelationComponentContract::from_field(&expected);
699 assert!(!expected.mismatches(RelationComponentContract::from_field(&same)));
700 assert!(expected.mismatches(RelationComponentContract::from_field(&wrong)));
701 }
702}