1use crate::prelude::*;
2use std::ops::Not;
3
4#[derive(Clone, Debug, Serialize)]
12pub struct Item {
13 target: ItemTarget,
14
15 #[serde(skip_serializing_if = "Option::is_none")]
16 relation: Option<&'static str>,
17
18 #[serde(skip_serializing_if = "Option::is_none")]
19 scale: Option<u32>,
20
21 #[serde(skip_serializing_if = "Option::is_none")]
22 max_len: Option<u32>,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
25 max_bytes: Option<u32>,
26
27 #[serde(skip_serializing_if = "<[_]>::is_empty")]
28 validators: &'static [TypeValidator],
29
30 #[serde(skip_serializing_if = "<[_]>::is_empty")]
31 sanitizers: &'static [TypeSanitizer],
32
33 #[serde(skip_serializing_if = "Not::not")]
34 indirect: bool,
35}
36
37impl Item {
38 #[must_use]
39 #[expect(
40 clippy::too_many_arguments,
41 reason = "schema item construction keeps generated scalar, relation, and validation metadata explicit"
42 )]
43 pub const fn new(
44 target: ItemTarget,
45 relation: Option<&'static str>,
46 scale: Option<u32>,
47 max_len: Option<u32>,
48 max_bytes: Option<u32>,
49 validators: &'static [TypeValidator],
50 sanitizers: &'static [TypeSanitizer],
51 indirect: bool,
52 ) -> Self {
53 Self {
54 target,
55 relation,
56 scale,
57 max_len,
58 max_bytes,
59 validators,
60 sanitizers,
61 indirect,
62 }
63 }
64
65 #[must_use]
66 pub const fn target(&self) -> &ItemTarget {
67 &self.target
68 }
69
70 #[must_use]
71 pub const fn relation(&self) -> Option<&'static str> {
72 self.relation
73 }
74
75 #[must_use]
76 pub const fn scale(&self) -> Option<u32> {
77 self.scale
78 }
79
80 #[must_use]
81 pub const fn max_len(&self) -> Option<u32> {
82 self.max_len
83 }
84
85 #[must_use]
86 pub const fn max_bytes(&self) -> Option<u32> {
87 self.max_bytes
88 }
89
90 #[must_use]
91 pub const fn validators(&self) -> &'static [TypeValidator] {
92 self.validators
93 }
94
95 #[must_use]
96 pub const fn sanitizers(&self) -> &'static [TypeSanitizer] {
97 self.sanitizers
98 }
99
100 #[must_use]
101 pub const fn indirect(&self) -> bool {
102 self.indirect
103 }
104
105 #[must_use]
106 pub const fn is_relation(&self) -> bool {
107 self.relation().is_some()
108 }
109}
110
111impl ValidateNode for Item {
112 fn validate(&self) -> Result<(), ErrorTree> {
113 let mut errs = ErrorTree::new();
114 let schema = schema_read();
115
116 match self.target() {
118 ItemTarget::Is(path) => {
119 if schema.check_node_as::<Entity>(path).is_ok() {
121 err!(errs, "a non-relation Item cannot reference an Entity");
122 }
123 }
124
125 ItemTarget::Primitive(_) => {}
126 }
127
128 if let Some(relation) = self.relation() {
130 match schema.cast_node::<Entity>(relation) {
131 Ok(entity) => {
132 if entity.primary_key().fields().len() != 1 {
133 err!(
134 errs,
135 "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",
136 entity.primary_key().fields()
137 );
138 } else if let Some(primary_field) = entity.scalar_primary_key_field() {
139 let relation_target = primary_field.value().item().target();
140
141 let relation_scale = primary_field.value().item().scale();
142 let relation_max_len = primary_field.value().item().max_len();
143 let relation_max_bytes = primary_field.value().item().max_bytes();
144 if self.target() != relation_target
145 || self.scale() != relation_scale
146 || self.max_len() != relation_max_len
147 || self.max_bytes() != relation_max_bytes
148 {
149 err!(
150 errs,
151 "relation target type mismatch: expected ({:?}, scale={:?}, max_len={:?}, max_bytes={:?}), found ({:?}, scale={:?}, max_len={:?}, max_bytes={:?})",
152 relation_target,
153 relation_scale,
154 relation_max_len,
155 relation_max_bytes,
156 self.target(),
157 self.scale(),
158 self.max_len(),
159 self.max_bytes()
160 );
161 }
162 } else {
163 let primary_key_field =
164 entity.primary_key().scalar_field().unwrap_or("<composite>");
165 err!(
166 errs,
167 "relation entity '{relation}' missing primary key field '{0}'",
168 primary_key_field
169 );
170 }
171 }
172 Err(_) => {
173 err!(errs, "relation entity '{relation}' not found");
174 }
175 }
176 }
177
178 errs.result()
179 }
180}
181
182impl VisitableNode for Item {
183 fn drive<V: Visitor>(&self, v: &mut V) {
184 for node in self.validators() {
185 node.accept(v);
186 }
187 }
188}
189
190#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
198pub enum ItemTarget {
199 Is(&'static str),
200 Primitive(Primitive),
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::build::schema_write;
207
208 fn primitive_item(primitive: Primitive) -> Item {
209 Item::new(
210 ItemTarget::Primitive(primitive),
211 None,
212 None,
213 None,
214 None,
215 &[],
216 &[],
217 false,
218 )
219 }
220
221 fn relation_item(target_path: &'static str, primitive: Primitive) -> Item {
222 Item::new(
223 ItemTarget::Primitive(primitive),
224 Some(target_path),
225 None,
226 None,
227 None,
228 &[],
229 &[],
230 false,
231 )
232 }
233
234 fn field(ident: &'static str, primitive: Primitive) -> Field {
235 Field::new(
236 ident,
237 Value::new(Cardinality::One, primitive_item(primitive)),
238 None,
239 None,
240 None,
241 )
242 }
243
244 fn insert_entity(
245 module: &'static str,
246 ident: &'static str,
247 pk_fields: &'static [&'static str],
248 fields: &'static [Field],
249 ) -> &'static str {
250 let path = Box::leak(format!("{module}::{ident}").into_boxed_str());
251 schema_write().insert_node(SchemaNode::Entity(Entity::new(
252 Def::new(module, ident),
253 "SchemaItemRelationStore",
254 PrimaryKey::new(pk_fields, PrimaryKeySource::External),
255 None,
256 &[],
257 FieldList::new(fields),
258 Type::new(&[], &[]),
259 )));
260 path
261 }
262
263 #[test]
264 fn relation_to_composite_target_rejects_even_when_first_component_matches() {
265 let fields = Box::leak(
266 vec![
267 field("tenant_id", Primitive::Nat64),
268 field("local_id", Primitive::Nat64),
269 ]
270 .into_boxed_slice(),
271 );
272 let target_path = insert_entity(
273 "schema_item_relation_composite_target",
274 "CompositeTarget",
275 &["tenant_id", "local_id"],
276 fields,
277 );
278
279 let err = relation_item(target_path, Primitive::Nat64)
280 .validate()
281 .expect_err("relation to composite target must fail before first-field matching");
282
283 assert!(
284 err.messages().iter().any(|message| {
285 message.contains("uses composite primary key fields")
286 && message
287 .contains("single-field relation targets require a scalar primary key")
288 }),
289 "unexpected relation validation errors: {err}",
290 );
291 }
292
293 #[test]
294 fn scalar_128_bit_relation_targets_validate_at_schema_node_boundary() {
295 for (module, ident, primitive) in [
296 (
297 "schema_item_relation_int128_target",
298 "Int128Target",
299 Primitive::Int128,
300 ),
301 (
302 "schema_item_relation_nat128_target",
303 "Nat128Target",
304 Primitive::Nat128,
305 ),
306 ] {
307 let fields = Box::leak(vec![field("id", primitive)].into_boxed_slice());
308 let target_path = insert_entity(module, ident, &["id"], fields);
309
310 relation_item(target_path, primitive)
311 .validate()
312 .expect("scalar 128-bit relation target should validate");
313 }
314 }
315}