1use super::relation::RelationComponentContract;
2use crate::prelude::*;
3use std::ops::Not;
4
5#[derive(Clone, Debug, Serialize)]
13pub struct Item {
14 target: ItemTarget,
15
16 #[serde(skip_serializing_if = "Option::is_none")]
17 relation: Option<&'static str>,
18
19 #[serde(skip_serializing_if = "Option::is_none")]
20 scale: Option<u32>,
21
22 #[serde(skip_serializing_if = "Option::is_none")]
23 max_len: Option<u32>,
24
25 #[serde(skip_serializing_if = "Option::is_none")]
26 max_bytes: Option<u32>,
27
28 #[serde(skip_serializing_if = "<[_]>::is_empty")]
29 validators: &'static [TypeValidator],
30
31 #[serde(skip_serializing_if = "<[_]>::is_empty")]
32 sanitizers: &'static [TypeSanitizer],
33
34 #[serde(skip_serializing_if = "Not::not")]
35 indirect: bool,
36}
37
38impl Item {
39 #[must_use]
40 #[expect(
41 clippy::too_many_arguments,
42 reason = "schema item construction keeps generated scalar, relation, and validation metadata explicit"
43 )]
44 pub const fn new(
45 target: ItemTarget,
46 relation: Option<&'static str>,
47 scale: Option<u32>,
48 max_len: Option<u32>,
49 max_bytes: Option<u32>,
50 validators: &'static [TypeValidator],
51 sanitizers: &'static [TypeSanitizer],
52 indirect: bool,
53 ) -> Self {
54 Self {
55 target,
56 relation,
57 scale,
58 max_len,
59 max_bytes,
60 validators,
61 sanitizers,
62 indirect,
63 }
64 }
65
66 #[must_use]
67 pub const fn target(&self) -> &ItemTarget {
68 &self.target
69 }
70
71 #[must_use]
72 pub const fn relation(&self) -> Option<&'static str> {
73 self.relation
74 }
75
76 #[must_use]
77 pub const fn scale(&self) -> Option<u32> {
78 self.scale
79 }
80
81 #[must_use]
82 pub const fn max_len(&self) -> Option<u32> {
83 self.max_len
84 }
85
86 #[must_use]
87 pub const fn max_bytes(&self) -> Option<u32> {
88 self.max_bytes
89 }
90
91 #[must_use]
92 pub const fn validators(&self) -> &'static [TypeValidator] {
93 self.validators
94 }
95
96 #[must_use]
97 pub const fn sanitizers(&self) -> &'static [TypeSanitizer] {
98 self.sanitizers
99 }
100
101 #[must_use]
102 pub const fn indirect(&self) -> bool {
103 self.indirect
104 }
105
106 #[must_use]
107 pub const fn is_relation(&self) -> bool {
108 self.relation().is_some()
109 }
110}
111
112impl ValidateNode for Item {
113 fn validate(&self) -> Result<(), ErrorTree> {
114 let mut errs = ErrorTree::new();
115 let schema = schema_read();
116
117 match self.target() {
119 ItemTarget::Is(path) => {
120 if schema.check_node_as::<Entity>(path).is_ok() {
122 err!(errs, "a non-relation Item cannot reference an Entity");
123 }
124 }
125
126 ItemTarget::Primitive(_) => {}
127 }
128
129 if let Some(relation) = self.relation() {
131 match schema.cast_node::<Entity>(relation) {
132 Ok(entity) => {
133 if entity.primary_key().fields().len() != 1 {
134 err!(
135 errs,
136 "relation entity '{relation}' uses composite primary key fields {:?}; single-field relation targets require a scalar primary key; use ordered relation tuple metadata for composite targets",
137 entity.primary_key().fields()
138 );
139 } else if let Some(primary_field) = entity.scalar_primary_key_field() {
140 let expected = RelationComponentContract::from_field(primary_field);
141 let actual = RelationComponentContract::from_item(self);
142 if expected.mismatches(actual) {
143 err!(
144 errs,
145 "relation target type mismatch: expected ({:?}, scale={:?}, max_len={:?}, max_bytes={:?}), found ({:?}, scale={:?}, max_len={:?}, max_bytes={:?})",
146 expected.target(),
147 expected.scale(),
148 expected.max_len(),
149 expected.max_bytes(),
150 actual.target(),
151 actual.scale(),
152 actual.max_len(),
153 actual.max_bytes(),
154 );
155 }
156 } else {
157 let primary_key_field =
158 entity.primary_key().scalar_field().unwrap_or("<composite>");
159 err!(
160 errs,
161 "relation entity '{relation}' missing primary key field '{0}'",
162 primary_key_field
163 );
164 }
165 }
166 Err(_) => {
167 err!(errs, "relation entity '{relation}' not found");
168 }
169 }
170 }
171
172 errs.result()
173 }
174}
175
176impl VisitableNode for Item {
177 fn drive<V: Visitor>(&self, v: &mut V) {
178 for node in self.validators() {
179 node.accept(v);
180 }
181 }
182}
183
184#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
192pub enum ItemTarget {
193 Is(&'static str),
194 Primitive(Primitive),
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::build::schema_write;
201
202 fn primitive_item(primitive: Primitive) -> Item {
203 Item::new(
204 ItemTarget::Primitive(primitive),
205 None,
206 None,
207 None,
208 None,
209 &[],
210 &[],
211 false,
212 )
213 }
214
215 fn relation_item(target_path: &'static str, primitive: Primitive) -> Item {
216 Item::new(
217 ItemTarget::Primitive(primitive),
218 Some(target_path),
219 None,
220 None,
221 None,
222 &[],
223 &[],
224 false,
225 )
226 }
227
228 fn field(ident: &'static str, primitive: Primitive) -> Field {
229 Field::new(
230 ident,
231 Value::new(Cardinality::One, primitive_item(primitive)),
232 None,
233 None,
234 None,
235 )
236 }
237
238 fn item_with_metadata(
239 primitive: Primitive,
240 scale: Option<u32>,
241 max_len: Option<u32>,
242 max_bytes: Option<u32>,
243 ) -> Item {
244 Item::new(
245 ItemTarget::Primitive(primitive),
246 None,
247 scale,
248 max_len,
249 max_bytes,
250 &[],
251 &[],
252 false,
253 )
254 }
255
256 fn insert_entity(
257 module: &'static str,
258 ident: &'static str,
259 pk_fields: &'static [&'static str],
260 fields: &'static [Field],
261 ) -> &'static str {
262 let path = Box::leak(format!("{module}::{ident}").into_boxed_str());
263 schema_write().insert_node(SchemaNode::Entity(Entity::new(
264 Def::new(module, ident),
265 "SchemaItemRelationStore",
266 1,
267 PrimaryKey::new(pk_fields, PrimaryKeySource::External),
268 None,
269 &[],
270 &[],
271 FieldList::new(fields),
272 Type::new(&[], &[]),
273 )));
274 path
275 }
276
277 #[test]
278 fn relation_to_composite_target_rejects_even_when_first_component_matches() {
279 let fields = Box::leak(
280 vec![
281 field("tenant_id", Primitive::Nat64),
282 field("local_id", Primitive::Nat64),
283 ]
284 .into_boxed_slice(),
285 );
286 let target_path = insert_entity(
287 "schema_item_relation_composite_target",
288 "CompositeTarget",
289 &["tenant_id", "local_id"],
290 fields,
291 );
292
293 let err = relation_item(target_path, Primitive::Nat64)
294 .validate()
295 .expect_err("relation to composite target must fail before first-field matching");
296
297 assert!(
298 err.messages().iter().any(|message| {
299 message.contains("uses composite primary key fields")
300 && message
301 .contains("single-field relation targets require a scalar primary key")
302 }),
303 "unexpected relation validation errors: {err}",
304 );
305 }
306
307 #[test]
308 fn scalar_128_bit_relation_targets_validate_at_schema_node_boundary() {
309 for (module, ident, primitive) in [
310 (
311 "schema_item_relation_int128_target",
312 "Int128Target",
313 Primitive::Int128,
314 ),
315 (
316 "schema_item_relation_nat128_target",
317 "Nat128Target",
318 Primitive::Nat128,
319 ),
320 ] {
321 let fields = Box::leak(vec![field("id", primitive)].into_boxed_slice());
322 let target_path = insert_entity(module, ident, &["id"], fields);
323
324 relation_item(target_path, primitive)
325 .validate()
326 .expect("scalar 128-bit relation target should validate");
327 }
328 }
329
330 #[test]
331 fn scalar_relation_target_descriptor_compares_type_and_bounds() {
332 for (primitive, expected_metadata, wrong_metadata) in [
333 (
334 Primitive::Decimal,
335 (Some(4), None, None),
336 (Some(2), None, None),
337 ),
338 (
339 Primitive::Text,
340 (None, Some(64), None),
341 (None, Some(32), None),
342 ),
343 (
344 Primitive::IntBig,
345 (None, None, Some(32)),
346 (None, None, Some(16)),
347 ),
348 ] {
349 let expected = item_with_metadata(
350 primitive,
351 expected_metadata.0,
352 expected_metadata.1,
353 expected_metadata.2,
354 );
355 let same = item_with_metadata(
356 primitive,
357 expected_metadata.0,
358 expected_metadata.1,
359 expected_metadata.2,
360 );
361 let wrong_bounds = item_with_metadata(
362 primitive,
363 wrong_metadata.0,
364 wrong_metadata.1,
365 wrong_metadata.2,
366 );
367 let wrong_target = item_with_metadata(
368 Primitive::Nat64,
369 expected_metadata.0,
370 expected_metadata.1,
371 expected_metadata.2,
372 );
373
374 let expected = RelationComponentContract::from_item(&expected);
375 assert!(!expected.mismatches(RelationComponentContract::from_item(&same)));
376 assert!(expected.mismatches(RelationComponentContract::from_item(&wrong_bounds)));
377 assert!(expected.mismatches(RelationComponentContract::from_item(&wrong_target)));
378 }
379 }
380
381 #[test]
382 fn scalar_relation_target_validation_rejects_mismatched_scalar_kind() {
383 let fields = Box::leak(vec![field("id", Primitive::Nat64)].into_boxed_slice());
384 let target_path = insert_entity(
385 "schema_item_relation_scalar_target_mismatch",
386 "Nat64Target",
387 &["id"],
388 fields,
389 );
390
391 let err = relation_item(target_path, Primitive::Int64)
392 .validate()
393 .expect_err("mismatched scalar relation target should reject");
394
395 assert!(
396 err.messages()
397 .iter()
398 .any(|message| message.contains("relation target type mismatch")),
399 "unexpected relation validation errors: {err}",
400 );
401 }
402
403 #[test]
404 fn scalar_relation_target_validation_accepts_matching_scalar_kind() {
405 let fields = Box::leak(vec![field("id", Primitive::Nat64)].into_boxed_slice());
406 let target_path = insert_entity(
407 "schema_item_relation_scalar_target_match",
408 "Nat64Target",
409 &["id"],
410 fields,
411 );
412
413 relation_item(target_path, Primitive::Nat64)
414 .validate()
415 .expect("matching scalar relation target should validate");
416 }
417
418 #[test]
419 fn scalar_relation_target_from_field_preserves_metadata_descriptor() {
420 let field = Field::new(
421 "id",
422 Value::new(
423 Cardinality::One,
424 item_with_metadata(Primitive::Text, None, Some(64), None),
425 ),
426 None,
427 None,
428 None,
429 );
430
431 let descriptor = RelationComponentContract::from_field(&field);
432 assert_eq!(descriptor.target(), &ItemTarget::Primitive(Primitive::Text));
433 assert_eq!(descriptor.scale(), None);
434 assert_eq!(descriptor.max_len(), Some(64));
435 assert_eq!(descriptor.max_bytes(), None);
436 }
437}