Skip to main content

fraiseql_cli/schema/validator/
schema_validator.rs

1//! `SchemaValidator` — validates an `IntermediateSchema` with detailed error reporting.
2
3use 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
14/// Strip GraphQL type modifiers (`!`, `[]`) to extract the base type name.
15///
16/// Examples: `"UUID!"` → `"UUID"`, `"[User!]!"` → `"User"`, `"String"` → `"String"`
17fn 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
26/// Enhanced schema validator
27pub struct SchemaValidator;
28
29impl SchemaValidator {
30    /// Validate an intermediate schema with detailed error reporting
31    ///
32    /// # Errors
33    ///
34    /// Currently infallible; always returns `Ok` containing the report.
35    /// The `Result` return type is reserved for future validation that may
36    /// require fallible I/O.
37    #[allow(clippy::cognitive_complexity)] // Reason: comprehensive schema validation with many cross-field constraint checks
38    pub fn validate(schema: &IntermediateSchema) -> Result<ValidationReport> {
39        info!("Validating schema structure");
40
41        let mut report = ValidationReport::default();
42
43        // Build type registry
44        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        // Add input types — valid as mutation argument types (fraiseql/fraiseql#190)
58        for input_type in &schema.input_types {
59            type_names.insert(input_type.name.clone());
60        }
61
62        // Add built-in scalars
63        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        // Validate queries
70        let mut query_names = HashSet::new();
71        for (idx, query) in schema.queries.iter().enumerate() {
72            debug!("Validating query: {}", query.name);
73
74            // Check for duplicate query names
75            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            // Validate return type exists (strip ! and [] modifiers)
86            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            // Validate argument types
103            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            // Validate sql_source is a safe SQL identifier
122            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            // Warning for queries without SQL source
133            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        // Validate mutations
147        let mut mutation_names = HashSet::new();
148        for (idx, mutation) in schema.mutations.iter().enumerate() {
149            debug!("Validating mutation: {}", mutation.name);
150
151            // Check for duplicate mutation names
152            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            // Validate return type exists (strip ! and [] modifiers)
163            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            // Validate argument types
180            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            // Validate sql_source is a safe SQL identifier
199            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            // Warn about inject_params ordering contract
210            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        // Validate observers
229        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                // Check for duplicate observer names
235                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                // Validate entity type exists
246                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                // Validate event type
262                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                // Validate at least one action exists
276                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                // Validate each action
289                for (action_idx, action) in observer.actions.iter().enumerate() {
290                    if let Some(obj) = action.as_object() {
291                        // Check action has a type field
292                        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                            // Validate action-specific required fields
311                            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                // Validate retry config
396                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    /// Suggest similar type names for typos.
459    ///
460    /// # Panics
461    ///
462    /// Panics if `typo` is empty (cannot slice first character).
463    fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
464        // Simple Levenshtein-style similarity (first letter match)
465        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)] // Reason: test code, panics are acceptable
486
487    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    // ── extract_base_type unit tests ────────────────────────────────
527
528    #[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    // ── Issue #151: ! suffix accepted in queries ────────────────────
549
550    #[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    // ── Truly unknown types are still rejected ──────────────────────
618
619    #[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        // Error message should show the base type, not the raw "NonExistent!"
639        assert!(
640            !errors[0].message.contains("NonExistent!"),
641            "error should strip ! from type name: {}",
642            errors[0].message
643        );
644    }
645}