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 union type names — valid as mutation/query return types
63        for union_def in &schema.unions {
64            type_names.insert(union_def.name.clone());
65        }
66
67        // Add built-in scalars
68        for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
69            type_names.insert((*scalar).to_string());
70        }
71
72        // Collect custom scalars implicitly: any type name used in object field definitions
73        // that isn't already registered (e.g. IPAddress, Hostname, MACAddress, CIDR).
74        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        // Validate queries
82        let mut query_names = HashSet::new();
83        for (idx, query) in schema.queries.iter().enumerate() {
84            debug!("Validating query: {}", query.name);
85
86            // Check for duplicate query names
87            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            // Validate return type exists (strip ! and [] modifiers)
98            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            // Validate argument types
115            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            // Validate sql_source is a safe SQL identifier
134            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            // Warning for queries without SQL source
145            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        // Validate mutations
159        let mut mutation_names = HashSet::new();
160        for (idx, mutation) in schema.mutations.iter().enumerate() {
161            debug!("Validating mutation: {}", mutation.name);
162
163            // Check for duplicate mutation names
164            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            // Validate return type exists (strip ! and [] modifiers)
175            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            // Validate argument types
192            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            // Validate sql_source is a safe SQL identifier
211            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            // Warn about inject_params ordering contract
222            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        // Validate observers
241        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                // Check for duplicate observer names
247                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                // Validate entity type exists
258                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                // Validate event type
274                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                // Validate at least one action exists
288                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                // Validate each action
301                for (action_idx, action) in observer.actions.iter().enumerate() {
302                    if let Some(obj) = action.as_object() {
303                        // Check action has a type field
304                        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                            // Validate action-specific required fields
323                            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                // Validate retry config
408                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    /// Suggest similar type names for typos.
471    ///
472    /// # Panics
473    ///
474    /// Panics if `typo` is empty (cannot slice first character).
475    fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
476        // Simple Levenshtein-style similarity (first letter match)
477        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}
493
494#[cfg(test)]
495mod tests {
496    #![allow(missing_docs)]
497    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
498
499    use super::*;
500    use crate::schema::intermediate::{
501        IntermediateSchema,
502        operations::{IntermediateArgument, IntermediateMutation, IntermediateQuery},
503        types::{IntermediateField, IntermediateType},
504    };
505
506    fn field(name: &str, ty: &str) -> IntermediateField {
507        IntermediateField {
508            name:           name.to_string(),
509            field_type:     ty.to_string(),
510            nullable:       false,
511            description:    None,
512            directives:     None,
513            requires_scope: None,
514            on_deny:        None,
515        }
516    }
517
518    fn arg(name: &str, ty: &str) -> IntermediateArgument {
519        IntermediateArgument {
520            name:       name.to_string(),
521            arg_type:   ty.to_string(),
522            nullable:   false,
523            default:    None,
524            deprecated: None,
525        }
526    }
527
528    fn minimal_schema() -> IntermediateSchema {
529        let mut schema = IntermediateSchema::default();
530        schema.types.push(IntermediateType {
531            name: "Item".to_string(),
532            fields: vec![field("id", "UUID")],
533            ..Default::default()
534        });
535        schema
536    }
537
538    // ── extract_base_type unit tests ────────────────────────────────
539
540    #[test]
541    fn extract_base_type_strips_non_null_suffix() {
542        assert_eq!(extract_base_type("Item!"), "Item");
543        assert_eq!(extract_base_type("String!"), "String");
544        assert_eq!(extract_base_type("Json!"), "Json");
545    }
546
547    #[test]
548    fn extract_base_type_strips_list_brackets() {
549        assert_eq!(extract_base_type("[User]"), "User");
550        assert_eq!(extract_base_type("[User!]!"), "User");
551        assert_eq!(extract_base_type("[String!]"), "String");
552    }
553
554    #[test]
555    fn extract_base_type_passthrough() {
556        assert_eq!(extract_base_type("String"), "String");
557        assert_eq!(extract_base_type("Item"), "Item");
558    }
559
560    // ── Issue #151: ! suffix accepted in queries ────────────────────
561
562    #[test]
563    fn query_with_bang_suffixed_return_type_is_valid() {
564        let mut schema = minimal_schema();
565        schema.queries.push(IntermediateQuery {
566            name: "item".to_string(),
567            return_type: "Item!".to_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(), "Item! should resolve to Item: {errors:?}");
576    }
577
578    #[test]
579    fn query_arg_with_bang_suffix_is_valid() {
580        let mut schema = minimal_schema();
581        schema.queries.push(IntermediateQuery {
582            name: "item".to_string(),
583            return_type: "Item".to_string(),
584            arguments: vec![arg("id", "String!")],
585            sql_source: Some("v_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(), "String! should resolve to String: {errors:?}");
593    }
594
595    #[test]
596    fn mutation_with_bang_suffixed_types_is_valid() {
597        let mut schema = minimal_schema();
598        schema.mutations.push(IntermediateMutation {
599            name: "createItem".to_string(),
600            return_type: "Item!".to_string(),
601            arguments: vec![arg("name", "String!")],
602            sql_source: Some("fn_create_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! and String! should be valid: {errors:?}");
610    }
611
612    #[test]
613    fn list_type_with_bang_is_valid() {
614        let mut schema = minimal_schema();
615        schema.queries.push(IntermediateQuery {
616            name: "items".to_string(),
617            return_type: "[Item!]!".to_string(),
618            returns_list: true,
619            sql_source: Some("v_item".to_string()),
620            ..Default::default()
621        });
622
623        let report = SchemaValidator::validate(&schema).unwrap();
624        let errors: Vec<_> =
625            report.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).collect();
626        assert!(errors.is_empty(), "[Item!]! should resolve to Item: {errors:?}");
627    }
628
629    // ── Truly unknown types are still rejected ──────────────────────
630
631    #[test]
632    fn truly_unknown_type_still_rejected() {
633        let mut schema = minimal_schema();
634        schema.queries.push(IntermediateQuery {
635            name: "item".to_string(),
636            return_type: "NonExistent!".to_string(),
637            sql_source: Some("v_item".to_string()),
638            ..Default::default()
639        });
640
641        let report = SchemaValidator::validate(&schema).unwrap();
642        let errors: Vec<_> =
643            report.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).collect();
644        assert!(!errors.is_empty(), "NonExistent should still be rejected");
645        assert!(
646            errors[0].message.contains("NonExistent"),
647            "error should name the base type, not 'NonExistent!': {}",
648            errors[0].message
649        );
650        // Error message should show the base type, not the raw "NonExistent!"
651        assert!(
652            !errors[0].message.contains("NonExistent!"),
653            "error should strip ! from type name: {}",
654            errors[0].message
655        );
656    }
657}