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
14pub(crate) fn 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 for union_def in &schema.unions {
64 type_names.insert(union_def.name.clone());
65 }
66
67 for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
69 type_names.insert((*scalar).to_string());
70 }
71
72 for type_def in &schema.types {
75 for field in &type_def.fields {
76 let base = extract_base_type(&field.field_type);
77 type_names.insert(base.to_string());
78 }
79 }
80
81 let mut query_names = HashSet::new();
83 for (idx, query) in schema.queries.iter().enumerate() {
84 debug!("Validating query: {}", query.name);
85
86 if query_names.contains(&query.name) {
88 report.errors.push(ValidationError {
89 message: format!("Duplicate query name: '{}'", query.name),
90 path: format!("queries[{idx}].name"),
91 severity: ErrorSeverity::Error,
92 suggestion: Some("Query names must be unique".to_string()),
93 });
94 }
95 query_names.insert(query.name.clone());
96
97 let base_return = extract_base_type(&query.return_type);
99 if !type_names.contains(base_return) {
100 report.errors.push(ValidationError {
101 message: format!(
102 "Query '{}' references unknown type '{}'",
103 query.name, base_return
104 ),
105 path: format!("queries[{idx}].return_type"),
106 severity: ErrorSeverity::Error,
107 suggestion: Some(format!(
108 "Available types: {}",
109 Self::suggest_similar_type(base_return, &type_names)
110 )),
111 });
112 }
113
114 for (arg_idx, arg) in query.arguments.iter().enumerate() {
116 let base_arg = extract_base_type(&arg.arg_type);
117 if !type_names.contains(base_arg) {
118 report.errors.push(ValidationError {
119 message: format!(
120 "Query '{}' argument '{}' references unknown type '{}'",
121 query.name, arg.name, base_arg
122 ),
123 path: format!("queries[{idx}].arguments[{arg_idx}].type"),
124 severity: ErrorSeverity::Error,
125 suggestion: Some(format!(
126 "Available types: {}",
127 Self::suggest_similar_type(base_arg, &type_names)
128 )),
129 });
130 }
131 }
132
133 if let Some(sql_source) = &query.sql_source {
135 if let Err(e) = validate_sql_identifier(
136 sql_source,
137 "sql_source",
138 &format!("Query.{}", query.name),
139 ) {
140 report.errors.push(e);
141 }
142 }
143
144 if query.sql_source.is_none() && query.returns_list {
146 report.errors.push(ValidationError {
147 message: format!(
148 "Query '{}' returns a list but has no sql_source",
149 query.name
150 ),
151 path: format!("queries[{idx}]"),
152 severity: ErrorSeverity::Warning,
153 suggestion: Some("Add sql_source for SQL-backed queries".to_string()),
154 });
155 }
156 }
157
158 let mut mutation_names = HashSet::new();
160 for (idx, mutation) in schema.mutations.iter().enumerate() {
161 debug!("Validating mutation: {}", mutation.name);
162
163 if mutation_names.contains(&mutation.name) {
165 report.errors.push(ValidationError {
166 message: format!("Duplicate mutation name: '{}'", mutation.name),
167 path: format!("mutations[{idx}].name"),
168 severity: ErrorSeverity::Error,
169 suggestion: Some("Mutation names must be unique".to_string()),
170 });
171 }
172 mutation_names.insert(mutation.name.clone());
173
174 let base_return = extract_base_type(&mutation.return_type);
176 if !type_names.contains(base_return) {
177 report.errors.push(ValidationError {
178 message: format!(
179 "Mutation '{}' references unknown type '{}'",
180 mutation.name, base_return
181 ),
182 path: format!("mutations[{idx}].return_type"),
183 severity: ErrorSeverity::Error,
184 suggestion: Some(format!(
185 "Available types: {}",
186 Self::suggest_similar_type(base_return, &type_names)
187 )),
188 });
189 }
190
191 for (arg_idx, arg) in mutation.arguments.iter().enumerate() {
193 let base_arg = extract_base_type(&arg.arg_type);
194 if !type_names.contains(base_arg) {
195 report.errors.push(ValidationError {
196 message: format!(
197 "Mutation '{}' argument '{}' references unknown type '{}'",
198 mutation.name, arg.name, base_arg
199 ),
200 path: format!("mutations[{idx}].arguments[{arg_idx}].type"),
201 severity: ErrorSeverity::Error,
202 suggestion: Some(format!(
203 "Available types: {}",
204 Self::suggest_similar_type(base_arg, &type_names)
205 )),
206 });
207 }
208 }
209
210 if let Some(sql_source) = &mutation.sql_source {
212 if let Err(e) = validate_sql_identifier(
213 sql_source,
214 "sql_source",
215 &format!("Mutation.{}", mutation.name),
216 ) {
217 report.errors.push(e);
218 }
219 }
220
221 if !mutation.inject.is_empty() {
223 let inject_names: Vec<&str> = mutation.inject.keys().map(String::as_str).collect();
224 let fn_name = mutation.sql_source.as_deref().unwrap_or("<unknown>");
225 report.errors.push(ValidationError {
226 message: format!(
227 "Mutation '{}' has inject params {:?}. \
228 These are appended as the LAST positional arguments to \
229 `{fn_name}`. Your SQL function MUST declare injected \
230 parameters last, after all client-provided arguments.",
231 mutation.name, inject_names,
232 ),
233 path: format!("Mutation.{}", mutation.name),
234 severity: ErrorSeverity::Warning,
235 suggestion: None,
236 });
237 }
238 }
239
240 if let Some(observers) = &schema.observers {
242 let mut observer_names = HashSet::new();
243 for (idx, observer) in observers.iter().enumerate() {
244 debug!("Validating observer: {}", observer.name);
245
246 if observer_names.contains(&observer.name) {
248 report.errors.push(ValidationError {
249 message: format!("Duplicate observer name: '{}'", observer.name),
250 path: format!("observers[{idx}].name"),
251 severity: ErrorSeverity::Error,
252 suggestion: Some("Observer names must be unique".to_string()),
253 });
254 }
255 observer_names.insert(observer.name.clone());
256
257 if !type_names.contains(&observer.entity) {
259 report.errors.push(ValidationError {
260 message: format!(
261 "Observer '{}' references unknown entity '{}'",
262 observer.name, observer.entity
263 ),
264 path: format!("observers[{idx}].entity"),
265 severity: ErrorSeverity::Error,
266 suggestion: Some(format!(
267 "Available types: {}",
268 Self::suggest_similar_type(&observer.entity, &type_names)
269 )),
270 });
271 }
272
273 let valid_events = ["INSERT", "UPDATE", "DELETE"];
275 if !valid_events.contains(&observer.event.as_str()) {
276 report.errors.push(ValidationError {
277 message: format!(
278 "Observer '{}' has invalid event '{}'. Must be INSERT, UPDATE, or DELETE",
279 observer.name, observer.event
280 ),
281 path: format!("observers[{idx}].event"),
282 severity: ErrorSeverity::Error,
283 suggestion: Some("Valid events: INSERT, UPDATE, DELETE".to_string()),
284 });
285 }
286
287 if observer.actions.is_empty() {
289 report.errors.push(ValidationError {
290 message: format!(
291 "Observer '{}' must have at least one action",
292 observer.name
293 ),
294 path: format!("observers[{idx}].actions"),
295 severity: ErrorSeverity::Error,
296 suggestion: Some("Add a webhook, slack, or email action".to_string()),
297 });
298 }
299
300 for (action_idx, action) in observer.actions.iter().enumerate() {
302 if let Some(obj) = action.as_object() {
303 if let Some(action_type) = obj.get("type").and_then(|v| v.as_str()) {
305 let valid_action_types = ["webhook", "slack", "email"];
306 if !valid_action_types.contains(&action_type) {
307 report.errors.push(ValidationError {
308 message: format!(
309 "Observer '{}' action {} has invalid type '{}'",
310 observer.name, action_idx, action_type
311 ),
312 path: format!(
313 "observers[{idx}].actions[{action_idx}].type"
314 ),
315 severity: ErrorSeverity::Error,
316 suggestion: Some(
317 "Valid action types: webhook, slack, email".to_string(),
318 ),
319 });
320 }
321
322 match action_type {
324 "webhook" => {
325 let has_url = obj.contains_key("url");
326 let has_url_env = obj.contains_key("url_env");
327 if !has_url && !has_url_env {
328 report.errors.push(ValidationError {
329 message: format!(
330 "Observer '{}' webhook action must have 'url' or 'url_env'",
331 observer.name
332 ),
333 path: format!("observers[{idx}].actions[{action_idx}]"),
334 severity: ErrorSeverity::Error,
335 suggestion: Some("Add 'url' or 'url_env' field".to_string()),
336 });
337 }
338 },
339 "slack" => {
340 if !obj.contains_key("channel") {
341 report.errors.push(ValidationError {
342 message: format!(
343 "Observer '{}' slack action must have 'channel' field",
344 observer.name
345 ),
346 path: format!("observers[{idx}].actions[{action_idx}]"),
347 severity: ErrorSeverity::Error,
348 suggestion: Some("Add 'channel' field (e.g., '#sales')".to_string()),
349 });
350 }
351 if !obj.contains_key("message") {
352 report.errors.push(ValidationError {
353 message: format!(
354 "Observer '{}' slack action must have 'message' field",
355 observer.name
356 ),
357 path: format!("observers[{idx}].actions[{action_idx}]"),
358 severity: ErrorSeverity::Error,
359 suggestion: Some("Add 'message' field".to_string()),
360 });
361 }
362 },
363 "email" => {
364 let required_fields = ["to", "subject", "body"];
365 for field in &required_fields {
366 if !obj.contains_key(*field) {
367 report.errors.push(ValidationError {
368 message: format!(
369 "Observer '{}' email action must have '{}' field",
370 observer.name, field
371 ),
372 path: format!("observers[{idx}].actions[{action_idx}]"),
373 severity: ErrorSeverity::Error,
374 suggestion: Some(format!("Add '{field}' field")),
375 });
376 }
377 }
378 },
379 _ => {},
380 }
381 } else {
382 report.errors.push(ValidationError {
383 message: format!(
384 "Observer '{}' action {} missing 'type' field",
385 observer.name, action_idx
386 ),
387 path: format!("observers[{idx}].actions[{action_idx}]"),
388 severity: ErrorSeverity::Error,
389 suggestion: Some(
390 "Add 'type' field (webhook, slack, or email)".to_string(),
391 ),
392 });
393 }
394 } else {
395 report.errors.push(ValidationError {
396 message: format!(
397 "Observer '{}' action {} must be an object",
398 observer.name, action_idx
399 ),
400 path: format!("observers[{idx}].actions[{action_idx}]"),
401 severity: ErrorSeverity::Error,
402 suggestion: None,
403 });
404 }
405 }
406
407 let valid_backoff_strategies = ["exponential", "linear", "fixed"];
409 if !valid_backoff_strategies.contains(&observer.retry.backoff_strategy.as_str()) {
410 report.errors.push(ValidationError {
411 message: format!(
412 "Observer '{}' has invalid backoff_strategy '{}'",
413 observer.name, observer.retry.backoff_strategy
414 ),
415 path: format!("observers[{idx}].retry.backoff_strategy"),
416 severity: ErrorSeverity::Error,
417 suggestion: Some(
418 "Valid strategies: exponential, linear, fixed".to_string(),
419 ),
420 });
421 }
422
423 if observer.retry.max_attempts == 0 {
424 report.errors.push(ValidationError {
425 message: format!(
426 "Observer '{}' has max_attempts=0, actions will never execute",
427 observer.name
428 ),
429 path: format!("observers[{idx}].retry.max_attempts"),
430 severity: ErrorSeverity::Warning,
431 suggestion: Some("Set max_attempts >= 1".to_string()),
432 });
433 }
434
435 if observer.retry.initial_delay_ms == 0 {
436 report.errors.push(ValidationError {
437 message: format!(
438 "Observer '{}' has initial_delay_ms=0, retries will be immediate",
439 observer.name
440 ),
441 path: format!("observers[{idx}].retry.initial_delay_ms"),
442 severity: ErrorSeverity::Warning,
443 suggestion: Some("Consider setting initial_delay_ms > 0".to_string()),
444 });
445 }
446
447 if observer.retry.max_delay_ms < observer.retry.initial_delay_ms {
448 report.errors.push(ValidationError {
449 message: format!(
450 "Observer '{}' has max_delay_ms < initial_delay_ms",
451 observer.name
452 ),
453 path: format!("observers[{idx}].retry.max_delay_ms"),
454 severity: ErrorSeverity::Error,
455 suggestion: Some("max_delay_ms must be >= initial_delay_ms".to_string()),
456 });
457 }
458 }
459 }
460
461 info!(
462 "Validation complete: {} errors, {} warnings",
463 report.error_count(),
464 report.warning_count()
465 );
466
467 Ok(report)
468 }
469
470 fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
476 let similar: Vec<&String> = available
478 .iter()
479 .filter(|name| {
480 name.to_lowercase().starts_with(&typo[0..1].to_lowercase())
481 || typo.to_lowercase().starts_with(&name[0..1].to_lowercase())
482 })
483 .take(3)
484 .collect();
485
486 if similar.is_empty() {
487 available.iter().take(5).cloned().collect::<Vec<_>>().join(", ")
488 } else {
489 similar.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
490 }
491 }
492}