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