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 built-in scalars
58        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        // Validate queries
65        let mut query_names = HashSet::new();
66        for (idx, query) in schema.queries.iter().enumerate() {
67            debug!("Validating query: {}", query.name);
68
69            // Check for duplicate query names
70            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            // Validate return type exists (strip ! and [] modifiers)
81            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            // Validate argument types
98            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            // Validate sql_source is a safe SQL identifier
117            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            // Warning for queries without SQL source
128            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        // Validate mutations
142        let mut mutation_names = HashSet::new();
143        for (idx, mutation) in schema.mutations.iter().enumerate() {
144            debug!("Validating mutation: {}", mutation.name);
145
146            // Check for duplicate mutation names
147            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            // Validate return type exists (strip ! and [] modifiers)
158            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            // Validate argument types
175            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            // Validate sql_source is a safe SQL identifier
194            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            // Warn about inject_params ordering contract
205            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        // Validate observers
224        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                // Check for duplicate observer names
230                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                // Validate entity type exists
241                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                // Validate event type
257                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                // Validate at least one action exists
271                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                // Validate each action
284                for (action_idx, action) in observer.actions.iter().enumerate() {
285                    if let Some(obj) = action.as_object() {
286                        // Check action has a type field
287                        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                            // Validate action-specific required fields
306                            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                // Validate retry config
391                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    /// Suggest similar type names for typos.
454    ///
455    /// # Panics
456    ///
457    /// Panics if `typo` is empty (cannot slice first character).
458    fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
459        // Simple Levenshtein-style similarity (first letter match)
460        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)] // Reason: test code, panics are acceptable
481
482    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    // ── extract_base_type unit tests ────────────────────────────────
522
523    #[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    // ── Issue #151: ! suffix accepted in queries ────────────────────
544
545    #[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    // ── Truly unknown types are still rejected ──────────────────────
613
614    #[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        // Error message should show the base type, not the raw "NonExistent!"
634        assert!(
635            !errors[0].message.contains("NonExistent!"),
636            "error should strip ! from type name: {}",
637            errors[0].message
638        );
639    }
640}