1use crate::schema::{lookup_crdt, Entity, SchemaFile};
2use std::collections::HashSet;
3use std::fmt;
4
5#[derive(Debug, Clone)]
7pub struct ValidationError {
8 pub entity: Option<String>,
9 pub version: Option<u32>,
10 pub field: Option<String>,
11 pub message: String,
12}
13
14impl fmt::Display for ValidationError {
15 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16 let mut ctx = Vec::new();
17 if let Some(e) = &self.entity {
18 ctx.push(format!("entity={e}"));
19 }
20 if let Some(v) = self.version {
21 ctx.push(format!("v{v}"));
22 }
23 if let Some(field) = &self.field {
24 ctx.push(format!("field={field}"));
25 }
26 if ctx.is_empty() {
27 write!(f, "{}", self.message)
28 } else {
29 write!(f, "[{}] {}", ctx.join(", "), self.message)
30 }
31 }
32}
33
34const SUPPORTED_PRIMITIVES: &[&str] = &[
36 "String", "bool", "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "f32", "f64",
37];
38
39pub fn validate_schema(schema: &SchemaFile) -> Result<(), Vec<ValidationError>> {
41 let mut errors = Vec::new();
42
43 if schema.config.output.is_empty() {
44 errors.push(ValidationError {
45 entity: None,
46 version: None,
47 field: None,
48 message: "config.output must not be empty".into(),
49 });
50 }
51
52 if schema.entities.is_empty() {
53 errors.push(ValidationError {
54 entity: None,
55 version: None,
56 field: None,
57 message: "schema must define at least one entity".into(),
58 });
59 }
60
61 if let Some(events) = &schema.config.events {
63 if events.enabled && events.snapshot_threshold == 0 {
64 errors.push(ValidationError {
65 entity: None,
66 version: None,
67 field: None,
68 message: "config.events.snapshot_threshold must be > 0".into(),
69 });
70 }
71 }
72
73 if let Some(sync) = &schema.config.sync {
75 if sync.enabled {
76 let any_crdt = schema.entities.iter().any(|e| {
77 e.versions
78 .iter()
79 .any(|v| v.fields.iter().any(|f| f.crdt.is_some()))
80 });
81 if !any_crdt {
82 errors.push(ValidationError {
83 entity: None,
84 version: None,
85 field: None,
86 message: "config.sync.enabled requires at least one entity with CRDT fields"
87 .into(),
88 });
89 }
90 }
91 }
92
93 let all_entity_names: HashSet<&str> = schema.entities.iter().map(|e| e.name.as_str()).collect();
94
95 let mut entity_names = HashSet::new();
96 for entity in &schema.entities {
97 if !entity_names.insert(&entity.name) {
98 errors.push(ValidationError {
99 entity: Some(entity.name.clone()),
100 version: None,
101 field: None,
102 message: "duplicate entity name".into(),
103 });
104 }
105 validate_entity(entity, &all_entity_names, &mut errors);
106 }
107
108 if errors.is_empty() {
109 Ok(())
110 } else {
111 Err(errors)
112 }
113}
114
115fn validate_entity(
116 entity: &Entity,
117 all_entity_names: &HashSet<&str>,
118 errors: &mut Vec<ValidationError>,
119) {
120 if entity.name.is_empty()
122 || !entity
123 .name
124 .chars()
125 .next()
126 .unwrap_or('a')
127 .is_ascii_uppercase()
128 {
129 errors.push(ValidationError {
130 entity: Some(entity.name.clone()),
131 version: None,
132 field: None,
133 message: "entity name must be PascalCase (start with uppercase)".into(),
134 });
135 }
136
137 if entity.table.is_empty() {
138 errors.push(ValidationError {
139 entity: Some(entity.name.clone()),
140 version: None,
141 field: None,
142 message: "table name must not be empty".into(),
143 });
144 }
145
146 if entity.versions.is_empty() {
147 errors.push(ValidationError {
148 entity: Some(entity.name.clone()),
149 version: None,
150 field: None,
151 message: "entity must have at least one version".into(),
152 });
153 return;
154 }
155
156 for (i, ver) in entity.versions.iter().enumerate() {
158 let expected = (i as u32) + 1;
159 if ver.version != expected {
160 errors.push(ValidationError {
161 entity: Some(entity.name.clone()),
162 version: Some(ver.version),
163 field: None,
164 message: format!("expected version {expected}, got {}", ver.version),
165 });
166 }
167 }
168
169 let mut prev_fields: Option<HashSet<String>> = None;
171 for ver in &entity.versions {
172 let mut field_names = HashSet::new();
173 for field in &ver.fields {
174 if !field_names.insert(field.name.clone()) {
175 errors.push(ValidationError {
176 entity: Some(entity.name.clone()),
177 version: Some(ver.version),
178 field: Some(field.name.clone()),
179 message: "duplicate field name".into(),
180 });
181 }
182
183 if field.name.is_empty()
185 || field
186 .name
187 .chars()
188 .next()
189 .unwrap_or('A')
190 .is_ascii_uppercase()
191 {
192 errors.push(ValidationError {
193 entity: Some(entity.name.clone()),
194 version: Some(ver.version),
195 field: Some(field.name.clone()),
196 message: "field name must be snake_case (start with lowercase)".into(),
197 });
198 }
199
200 if !is_supported_type(&field.field_type) {
202 errors.push(ValidationError {
203 entity: Some(entity.name.clone()),
204 version: Some(ver.version),
205 field: Some(field.name.clone()),
206 message: format!("unsupported type `{}`", field.field_type),
207 });
208 }
209
210 if let Some(crdt_name) = &field.crdt {
212 if lookup_crdt(crdt_name).is_none() {
213 errors.push(ValidationError {
214 entity: Some(entity.name.clone()),
215 version: Some(ver.version),
216 field: Some(field.name.clone()),
217 message: format!(
218 "unsupported CRDT type `{crdt_name}` (supported: GCounter, PNCounter, LWWRegister, MVRegister, GSet, TwoPSet, ORSet)"
219 ),
220 });
221 }
222 }
223
224 if let Some(rel) = &field.relation {
226 if !all_entity_names.contains(rel.as_str()) {
227 errors.push(ValidationError {
228 entity: Some(entity.name.clone()),
229 version: Some(ver.version),
230 field: Some(field.name.clone()),
231 message: format!("relation references unknown entity `{rel}`"),
232 });
233 }
234 }
235
236 if let Some(prev) = &prev_fields {
239 let has_auto_default = field.crdt.is_some();
240 if !prev.contains(&field.name) && field.default.is_none() && !has_auto_default {
241 errors.push(ValidationError {
242 entity: Some(entity.name.clone()),
243 version: Some(ver.version),
244 field: Some(field.name.clone()),
245 message: "field added in a later version must have a `default` value"
246 .into(),
247 });
248 }
249 }
250 }
251 prev_fields = Some(field_names);
252 }
253}
254
255fn is_supported_type(ty: &str) -> bool {
256 if SUPPORTED_PRIMITIVES.contains(&ty) {
258 return true;
259 }
260 if let Some(inner) = ty.strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) {
262 return is_supported_type(inner.trim());
263 }
264 if let Some(inner) = ty.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) {
266 return is_supported_type(inner.trim());
267 }
268 false
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::schema::*;
275
276 fn make_schema(entities: Vec<Entity>) -> SchemaFile {
277 SchemaFile {
278 config: SchemaConfig {
279 output: "src/generated".into(),
280 events: None,
281 sync: None,
282 },
283 entities,
284 }
285 }
286
287 fn make_entity(name: &str, table: &str, versions: Vec<EntityVersion>) -> Entity {
288 Entity {
289 name: name.into(),
290 table: table.into(),
291 versions,
292 }
293 }
294
295 fn make_version(version: u32, fields: Vec<Field>) -> EntityVersion {
296 EntityVersion { version, fields }
297 }
298
299 fn make_field(name: &str, field_type: &str, default: Option<&str>) -> Field {
300 Field {
301 name: name.into(),
302 field_type: field_type.into(),
303 default: default.map(|s| s.into()),
304 crdt: None,
305 relation: None,
306 }
307 }
308
309 #[test]
310 fn valid_minimal_schema() {
311 let schema = make_schema(vec![make_entity(
312 "Task",
313 "tasks",
314 vec![make_version(1, vec![make_field("title", "String", None)])],
315 )]);
316 assert!(validate_schema(&schema).is_ok());
317 }
318
319 #[test]
320 fn empty_output_fails() {
321 let mut schema = make_schema(vec![make_entity(
322 "Task",
323 "tasks",
324 vec![make_version(1, vec![make_field("title", "String", None)])],
325 )]);
326 schema.config.output = String::new();
327 let errs = validate_schema(&schema).unwrap_err();
328 assert!(errs.iter().any(|e| e.message.contains("output")));
329 }
330
331 #[test]
332 fn non_contiguous_versions_fail() {
333 let schema = make_schema(vec![make_entity(
334 "Task",
335 "tasks",
336 vec![
337 make_version(1, vec![make_field("title", "String", None)]),
338 make_version(3, vec![make_field("title", "String", None)]),
339 ],
340 )]);
341 let errs = validate_schema(&schema).unwrap_err();
342 assert!(errs
343 .iter()
344 .any(|e| e.message.contains("expected version 2")));
345 }
346
347 #[test]
348 fn new_field_without_default_fails() {
349 let schema = make_schema(vec![make_entity(
350 "Task",
351 "tasks",
352 vec![
353 make_version(1, vec![make_field("title", "String", None)]),
354 make_version(
355 2,
356 vec![
357 make_field("title", "String", None),
358 make_field("priority", "Option<u8>", None), ],
360 ),
361 ],
362 )]);
363 let errs = validate_schema(&schema).unwrap_err();
364 assert!(errs.iter().any(|e| e.message.contains("default")));
365 }
366
367 #[test]
368 fn new_field_with_default_passes() {
369 let schema = make_schema(vec![make_entity(
370 "Task",
371 "tasks",
372 vec![
373 make_version(1, vec![make_field("title", "String", None)]),
374 make_version(
375 2,
376 vec![
377 make_field("title", "String", None),
378 make_field("priority", "Option<u8>", Some("None")),
379 ],
380 ),
381 ],
382 )]);
383 assert!(validate_schema(&schema).is_ok());
384 }
385
386 #[test]
387 fn unsupported_type_fails() {
388 let schema = make_schema(vec![make_entity(
389 "Task",
390 "tasks",
391 vec![make_version(
392 1,
393 vec![make_field("data", "HashMap<String, String>", None)],
394 )],
395 )]);
396 let errs = validate_schema(&schema).unwrap_err();
397 assert!(errs.iter().any(|e| e.message.contains("unsupported type")));
398 }
399
400 #[test]
401 fn supported_types_pass() {
402 let fields = vec![
403 make_field("a", "String", None),
404 make_field("b", "bool", None),
405 make_field("c", "u8", None),
406 make_field("d", "u64", None),
407 make_field("e", "f32", None),
408 make_field("f", "Option<String>", None),
409 make_field("g", "Vec<u8>", None),
410 make_field("h", "Option<Vec<String>>", None),
411 ];
412 let schema = make_schema(vec![make_entity(
413 "Task",
414 "tasks",
415 vec![make_version(1, fields)],
416 )]);
417 assert!(validate_schema(&schema).is_ok());
418 }
419
420 #[test]
421 fn duplicate_entity_names_fail() {
422 let schema = make_schema(vec![
423 make_entity(
424 "Task",
425 "tasks",
426 vec![make_version(1, vec![make_field("title", "String", None)])],
427 ),
428 make_entity(
429 "Task",
430 "other",
431 vec![make_version(1, vec![make_field("name", "String", None)])],
432 ),
433 ]);
434 let errs = validate_schema(&schema).unwrap_err();
435 assert!(errs.iter().any(|e| e.message.contains("duplicate entity")));
436 }
437
438 #[test]
439 fn lowercase_entity_name_fails() {
440 let schema = make_schema(vec![make_entity(
441 "task",
442 "tasks",
443 vec![make_version(1, vec![make_field("title", "String", None)])],
444 )]);
445 let errs = validate_schema(&schema).unwrap_err();
446 assert!(errs.iter().any(|e| e.message.contains("PascalCase")));
447 }
448}