1use std::collections::HashSet;
4
5use anyhow::Result;
6use tracing::{debug, info};
7
8use super::{
9 sql_identifier::validate_sql_identifier,
10 types::{ErrorSeverity, ValidationError, ValidationReport},
11};
12use crate::schema::intermediate::IntermediateSchema;
13
14fn extract_base_type(type_str: &str) -> &str {
18 let s = type_str.trim();
19 let s = s.trim_start_matches('[').trim_end_matches(']');
20 let s = s.trim_end_matches('!').trim_start_matches('!');
21 let s = s.trim_start_matches('[').trim_end_matches(']');
22 let s = s.trim_end_matches('!');
23 s.trim()
24}
25
26pub struct SchemaValidator;
28
29impl SchemaValidator {
30 #[allow(clippy::cognitive_complexity)] pub fn validate(schema: &IntermediateSchema) -> Result<ValidationReport> {
39 info!("Validating schema structure");
40
41 let mut report = ValidationReport::default();
42
43 let mut type_names = HashSet::new();
45 for type_def in &schema.types {
46 if type_names.contains(&type_def.name) {
47 report.errors.push(ValidationError {
48 message: format!("Duplicate type name: '{}'", type_def.name),
49 path: format!("types[{}].name", type_names.len()),
50 severity: ErrorSeverity::Error,
51 suggestion: Some("Type names must be unique".to_string()),
52 });
53 }
54 type_names.insert(type_def.name.clone());
55 }
56
57 type_names.insert("Int".to_string());
59 type_names.insert("Float".to_string());
60 type_names.insert("String".to_string());
61 type_names.insert("Boolean".to_string());
62 type_names.insert("ID".to_string());
63
64 let mut query_names = HashSet::new();
66 for (idx, query) in schema.queries.iter().enumerate() {
67 debug!("Validating query: {}", query.name);
68
69 if query_names.contains(&query.name) {
71 report.errors.push(ValidationError {
72 message: format!("Duplicate query name: '{}'", query.name),
73 path: format!("queries[{idx}].name"),
74 severity: ErrorSeverity::Error,
75 suggestion: Some("Query names must be unique".to_string()),
76 });
77 }
78 query_names.insert(query.name.clone());
79
80 let base_return = extract_base_type(&query.return_type);
82 if !type_names.contains(base_return) {
83 report.errors.push(ValidationError {
84 message: format!(
85 "Query '{}' references unknown type '{}'",
86 query.name, base_return
87 ),
88 path: format!("queries[{idx}].return_type"),
89 severity: ErrorSeverity::Error,
90 suggestion: Some(format!(
91 "Available types: {}",
92 Self::suggest_similar_type(base_return, &type_names)
93 )),
94 });
95 }
96
97 for (arg_idx, arg) in query.arguments.iter().enumerate() {
99 let base_arg = extract_base_type(&arg.arg_type);
100 if !type_names.contains(base_arg) {
101 report.errors.push(ValidationError {
102 message: format!(
103 "Query '{}' argument '{}' references unknown type '{}'",
104 query.name, arg.name, base_arg
105 ),
106 path: format!("queries[{idx}].arguments[{arg_idx}].type"),
107 severity: ErrorSeverity::Error,
108 suggestion: Some(format!(
109 "Available types: {}",
110 Self::suggest_similar_type(base_arg, &type_names)
111 )),
112 });
113 }
114 }
115
116 if let Some(sql_source) = &query.sql_source {
118 if let Err(e) = validate_sql_identifier(
119 sql_source,
120 "sql_source",
121 &format!("Query.{}", query.name),
122 ) {
123 report.errors.push(e);
124 }
125 }
126
127 if query.sql_source.is_none() && query.returns_list {
129 report.errors.push(ValidationError {
130 message: format!(
131 "Query '{}' returns a list but has no sql_source",
132 query.name
133 ),
134 path: format!("queries[{idx}]"),
135 severity: ErrorSeverity::Warning,
136 suggestion: Some("Add sql_source for SQL-backed queries".to_string()),
137 });
138 }
139 }
140
141 let mut mutation_names = HashSet::new();
143 for (idx, mutation) in schema.mutations.iter().enumerate() {
144 debug!("Validating mutation: {}", mutation.name);
145
146 if mutation_names.contains(&mutation.name) {
148 report.errors.push(ValidationError {
149 message: format!("Duplicate mutation name: '{}'", mutation.name),
150 path: format!("mutations[{idx}].name"),
151 severity: ErrorSeverity::Error,
152 suggestion: Some("Mutation names must be unique".to_string()),
153 });
154 }
155 mutation_names.insert(mutation.name.clone());
156
157 let base_return = extract_base_type(&mutation.return_type);
159 if !type_names.contains(base_return) {
160 report.errors.push(ValidationError {
161 message: format!(
162 "Mutation '{}' references unknown type '{}'",
163 mutation.name, base_return
164 ),
165 path: format!("mutations[{idx}].return_type"),
166 severity: ErrorSeverity::Error,
167 suggestion: Some(format!(
168 "Available types: {}",
169 Self::suggest_similar_type(base_return, &type_names)
170 )),
171 });
172 }
173
174 for (arg_idx, arg) in mutation.arguments.iter().enumerate() {
176 let base_arg = extract_base_type(&arg.arg_type);
177 if !type_names.contains(base_arg) {
178 report.errors.push(ValidationError {
179 message: format!(
180 "Mutation '{}' argument '{}' references unknown type '{}'",
181 mutation.name, arg.name, base_arg
182 ),
183 path: format!("mutations[{idx}].arguments[{arg_idx}].type"),
184 severity: ErrorSeverity::Error,
185 suggestion: Some(format!(
186 "Available types: {}",
187 Self::suggest_similar_type(base_arg, &type_names)
188 )),
189 });
190 }
191 }
192
193 if let Some(sql_source) = &mutation.sql_source {
195 if let Err(e) = validate_sql_identifier(
196 sql_source,
197 "sql_source",
198 &format!("Mutation.{}", mutation.name),
199 ) {
200 report.errors.push(e);
201 }
202 }
203
204 if !mutation.inject.is_empty() {
206 let inject_names: Vec<&str> = mutation.inject.keys().map(String::as_str).collect();
207 let fn_name = mutation.sql_source.as_deref().unwrap_or("<unknown>");
208 report.errors.push(ValidationError {
209 message: format!(
210 "Mutation '{}' has inject params {:?}. \
211 These are appended as the LAST positional arguments to \
212 `{fn_name}`. Your SQL function MUST declare injected \
213 parameters last, after all client-provided arguments.",
214 mutation.name, inject_names,
215 ),
216 path: format!("Mutation.{}", mutation.name),
217 severity: ErrorSeverity::Warning,
218 suggestion: None,
219 });
220 }
221 }
222
223 if let Some(observers) = &schema.observers {
225 let mut observer_names = HashSet::new();
226 for (idx, observer) in observers.iter().enumerate() {
227 debug!("Validating observer: {}", observer.name);
228
229 if observer_names.contains(&observer.name) {
231 report.errors.push(ValidationError {
232 message: format!("Duplicate observer name: '{}'", observer.name),
233 path: format!("observers[{idx}].name"),
234 severity: ErrorSeverity::Error,
235 suggestion: Some("Observer names must be unique".to_string()),
236 });
237 }
238 observer_names.insert(observer.name.clone());
239
240 if !type_names.contains(&observer.entity) {
242 report.errors.push(ValidationError {
243 message: format!(
244 "Observer '{}' references unknown entity '{}'",
245 observer.name, observer.entity
246 ),
247 path: format!("observers[{idx}].entity"),
248 severity: ErrorSeverity::Error,
249 suggestion: Some(format!(
250 "Available types: {}",
251 Self::suggest_similar_type(&observer.entity, &type_names)
252 )),
253 });
254 }
255
256 let valid_events = ["INSERT", "UPDATE", "DELETE"];
258 if !valid_events.contains(&observer.event.as_str()) {
259 report.errors.push(ValidationError {
260 message: format!(
261 "Observer '{}' has invalid event '{}'. Must be INSERT, UPDATE, or DELETE",
262 observer.name, observer.event
263 ),
264 path: format!("observers[{idx}].event"),
265 severity: ErrorSeverity::Error,
266 suggestion: Some("Valid events: INSERT, UPDATE, DELETE".to_string()),
267 });
268 }
269
270 if observer.actions.is_empty() {
272 report.errors.push(ValidationError {
273 message: format!(
274 "Observer '{}' must have at least one action",
275 observer.name
276 ),
277 path: format!("observers[{idx}].actions"),
278 severity: ErrorSeverity::Error,
279 suggestion: Some("Add a webhook, slack, or email action".to_string()),
280 });
281 }
282
283 for (action_idx, action) in observer.actions.iter().enumerate() {
285 if let Some(obj) = action.as_object() {
286 if let Some(action_type) = obj.get("type").and_then(|v| v.as_str()) {
288 let valid_action_types = ["webhook", "slack", "email"];
289 if !valid_action_types.contains(&action_type) {
290 report.errors.push(ValidationError {
291 message: format!(
292 "Observer '{}' action {} has invalid type '{}'",
293 observer.name, action_idx, action_type
294 ),
295 path: format!(
296 "observers[{idx}].actions[{action_idx}].type"
297 ),
298 severity: ErrorSeverity::Error,
299 suggestion: Some(
300 "Valid action types: webhook, slack, email".to_string(),
301 ),
302 });
303 }
304
305 match action_type {
307 "webhook" => {
308 let has_url = obj.contains_key("url");
309 let has_url_env = obj.contains_key("url_env");
310 if !has_url && !has_url_env {
311 report.errors.push(ValidationError {
312 message: format!(
313 "Observer '{}' webhook action must have 'url' or 'url_env'",
314 observer.name
315 ),
316 path: format!("observers[{idx}].actions[{action_idx}]"),
317 severity: ErrorSeverity::Error,
318 suggestion: Some("Add 'url' or 'url_env' field".to_string()),
319 });
320 }
321 },
322 "slack" => {
323 if !obj.contains_key("channel") {
324 report.errors.push(ValidationError {
325 message: format!(
326 "Observer '{}' slack action must have 'channel' field",
327 observer.name
328 ),
329 path: format!("observers[{idx}].actions[{action_idx}]"),
330 severity: ErrorSeverity::Error,
331 suggestion: Some("Add 'channel' field (e.g., '#sales')".to_string()),
332 });
333 }
334 if !obj.contains_key("message") {
335 report.errors.push(ValidationError {
336 message: format!(
337 "Observer '{}' slack action must have 'message' field",
338 observer.name
339 ),
340 path: format!("observers[{idx}].actions[{action_idx}]"),
341 severity: ErrorSeverity::Error,
342 suggestion: Some("Add 'message' field".to_string()),
343 });
344 }
345 },
346 "email" => {
347 let required_fields = ["to", "subject", "body"];
348 for field in &required_fields {
349 if !obj.contains_key(*field) {
350 report.errors.push(ValidationError {
351 message: format!(
352 "Observer '{}' email action must have '{}' field",
353 observer.name, field
354 ),
355 path: format!("observers[{idx}].actions[{action_idx}]"),
356 severity: ErrorSeverity::Error,
357 suggestion: Some(format!("Add '{field}' field")),
358 });
359 }
360 }
361 },
362 _ => {},
363 }
364 } else {
365 report.errors.push(ValidationError {
366 message: format!(
367 "Observer '{}' action {} missing 'type' field",
368 observer.name, action_idx
369 ),
370 path: format!("observers[{idx}].actions[{action_idx}]"),
371 severity: ErrorSeverity::Error,
372 suggestion: Some(
373 "Add 'type' field (webhook, slack, or email)".to_string(),
374 ),
375 });
376 }
377 } else {
378 report.errors.push(ValidationError {
379 message: format!(
380 "Observer '{}' action {} must be an object",
381 observer.name, action_idx
382 ),
383 path: format!("observers[{idx}].actions[{action_idx}]"),
384 severity: ErrorSeverity::Error,
385 suggestion: None,
386 });
387 }
388 }
389
390 let valid_backoff_strategies = ["exponential", "linear", "fixed"];
392 if !valid_backoff_strategies.contains(&observer.retry.backoff_strategy.as_str()) {
393 report.errors.push(ValidationError {
394 message: format!(
395 "Observer '{}' has invalid backoff_strategy '{}'",
396 observer.name, observer.retry.backoff_strategy
397 ),
398 path: format!("observers[{idx}].retry.backoff_strategy"),
399 severity: ErrorSeverity::Error,
400 suggestion: Some(
401 "Valid strategies: exponential, linear, fixed".to_string(),
402 ),
403 });
404 }
405
406 if observer.retry.max_attempts == 0 {
407 report.errors.push(ValidationError {
408 message: format!(
409 "Observer '{}' has max_attempts=0, actions will never execute",
410 observer.name
411 ),
412 path: format!("observers[{idx}].retry.max_attempts"),
413 severity: ErrorSeverity::Warning,
414 suggestion: Some("Set max_attempts >= 1".to_string()),
415 });
416 }
417
418 if observer.retry.initial_delay_ms == 0 {
419 report.errors.push(ValidationError {
420 message: format!(
421 "Observer '{}' has initial_delay_ms=0, retries will be immediate",
422 observer.name
423 ),
424 path: format!("observers[{idx}].retry.initial_delay_ms"),
425 severity: ErrorSeverity::Warning,
426 suggestion: Some("Consider setting initial_delay_ms > 0".to_string()),
427 });
428 }
429
430 if observer.retry.max_delay_ms < observer.retry.initial_delay_ms {
431 report.errors.push(ValidationError {
432 message: format!(
433 "Observer '{}' has max_delay_ms < initial_delay_ms",
434 observer.name
435 ),
436 path: format!("observers[{idx}].retry.max_delay_ms"),
437 severity: ErrorSeverity::Error,
438 suggestion: Some("max_delay_ms must be >= initial_delay_ms".to_string()),
439 });
440 }
441 }
442 }
443
444 info!(
445 "Validation complete: {} errors, {} warnings",
446 report.error_count(),
447 report.warning_count()
448 );
449
450 Ok(report)
451 }
452
453 fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
459 let similar: Vec<&String> = available
461 .iter()
462 .filter(|name| {
463 name.to_lowercase().starts_with(&typo[0..1].to_lowercase())
464 || typo.to_lowercase().starts_with(&name[0..1].to_lowercase())
465 })
466 .take(3)
467 .collect();
468
469 if similar.is_empty() {
470 available.iter().take(5).cloned().collect::<Vec<_>>().join(", ")
471 } else {
472 similar.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
473 }
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 #![allow(missing_docs)]
480 #![allow(clippy::unwrap_used)] use super::*;
483 use crate::schema::intermediate::{
484 IntermediateSchema,
485 operations::{IntermediateArgument, IntermediateMutation, IntermediateQuery},
486 types::{IntermediateField, IntermediateType},
487 };
488
489 fn field(name: &str, ty: &str) -> IntermediateField {
490 IntermediateField {
491 name: name.to_string(),
492 field_type: ty.to_string(),
493 nullable: false,
494 description: None,
495 directives: None,
496 requires_scope: None,
497 on_deny: None,
498 }
499 }
500
501 fn arg(name: &str, ty: &str) -> IntermediateArgument {
502 IntermediateArgument {
503 name: name.to_string(),
504 arg_type: ty.to_string(),
505 nullable: false,
506 default: None,
507 deprecated: None,
508 }
509 }
510
511 fn minimal_schema() -> IntermediateSchema {
512 let mut schema = IntermediateSchema::default();
513 schema.types.push(IntermediateType {
514 name: "Item".to_string(),
515 fields: vec![field("id", "UUID")],
516 ..Default::default()
517 });
518 schema
519 }
520
521 #[test]
524 fn extract_base_type_strips_non_null_suffix() {
525 assert_eq!(extract_base_type("Item!"), "Item");
526 assert_eq!(extract_base_type("String!"), "String");
527 assert_eq!(extract_base_type("Json!"), "Json");
528 }
529
530 #[test]
531 fn extract_base_type_strips_list_brackets() {
532 assert_eq!(extract_base_type("[User]"), "User");
533 assert_eq!(extract_base_type("[User!]!"), "User");
534 assert_eq!(extract_base_type("[String!]"), "String");
535 }
536
537 #[test]
538 fn extract_base_type_passthrough() {
539 assert_eq!(extract_base_type("String"), "String");
540 assert_eq!(extract_base_type("Item"), "Item");
541 }
542
543 #[test]
546 fn query_with_bang_suffixed_return_type_is_valid() {
547 let mut schema = minimal_schema();
548 schema.queries.push(IntermediateQuery {
549 name: "item".to_string(),
550 return_type: "Item!".to_string(),
551 sql_source: Some("v_item".to_string()),
552 ..Default::default()
553 });
554
555 let report = SchemaValidator::validate(&schema).unwrap();
556 let errors: Vec<_> =
557 report.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).collect();
558 assert!(errors.is_empty(), "Item! should resolve to Item: {errors:?}");
559 }
560
561 #[test]
562 fn query_arg_with_bang_suffix_is_valid() {
563 let mut schema = minimal_schema();
564 schema.queries.push(IntermediateQuery {
565 name: "item".to_string(),
566 return_type: "Item".to_string(),
567 arguments: vec![arg("id", "String!")],
568 sql_source: Some("v_item".to_string()),
569 ..Default::default()
570 });
571
572 let report = SchemaValidator::validate(&schema).unwrap();
573 let errors: Vec<_> =
574 report.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).collect();
575 assert!(errors.is_empty(), "String! should resolve to String: {errors:?}");
576 }
577
578 #[test]
579 fn mutation_with_bang_suffixed_types_is_valid() {
580 let mut schema = minimal_schema();
581 schema.mutations.push(IntermediateMutation {
582 name: "createItem".to_string(),
583 return_type: "Item!".to_string(),
584 arguments: vec![arg("name", "String!")],
585 sql_source: Some("fn_create_item".to_string()),
586 ..Default::default()
587 });
588
589 let report = SchemaValidator::validate(&schema).unwrap();
590 let errors: Vec<_> =
591 report.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).collect();
592 assert!(errors.is_empty(), "Item! and String! should be valid: {errors:?}");
593 }
594
595 #[test]
596 fn list_type_with_bang_is_valid() {
597 let mut schema = minimal_schema();
598 schema.queries.push(IntermediateQuery {
599 name: "items".to_string(),
600 return_type: "[Item!]!".to_string(),
601 returns_list: true,
602 sql_source: Some("v_item".to_string()),
603 ..Default::default()
604 });
605
606 let report = SchemaValidator::validate(&schema).unwrap();
607 let errors: Vec<_> =
608 report.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).collect();
609 assert!(errors.is_empty(), "[Item!]! should resolve to Item: {errors:?}");
610 }
611
612 #[test]
615 fn truly_unknown_type_still_rejected() {
616 let mut schema = minimal_schema();
617 schema.queries.push(IntermediateQuery {
618 name: "item".to_string(),
619 return_type: "NonExistent!".to_string(),
620 sql_source: Some("v_item".to_string()),
621 ..Default::default()
622 });
623
624 let report = SchemaValidator::validate(&schema).unwrap();
625 let errors: Vec<_> =
626 report.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).collect();
627 assert!(!errors.is_empty(), "NonExistent should still be rejected");
628 assert!(
629 errors[0].message.contains("NonExistent"),
630 "error should name the base type, not 'NonExistent!': {}",
631 errors[0].message
632 );
633 assert!(
635 !errors[0].message.contains("NonExistent!"),
636 "error should strip ! from type name: {}",
637 errors[0].message
638 );
639 }
640}