Skip to main content

fraiseql_cli/commands/
validate.rs

1//! Schema validation command
2//!
3//! Validates schema.json with comprehensive checks including:
4//! - JSON structure validation
5//! - Type reference validation
6//! - Circular dependency detection
7//! - Unused type detection
8
9use std::fs;
10
11use anyhow::Result;
12use fraiseql_core::schema::{CompiledSchema, SchemaDependencyGraph};
13use serde::Serialize;
14
15use crate::output::CommandResult;
16
17/// Options for schema validation
18#[derive(Debug, Clone, Default)]
19pub struct ValidateOptions {
20    /// Check for circular dependencies between types
21    pub check_cycles: bool,
22
23    /// Check for unused types (types with no incoming references)
24    pub check_unused: bool,
25
26    /// Strict mode: treat warnings as errors
27    pub strict: bool,
28
29    /// Filter to specific types (empty = all types)
30    pub filter_types: Vec<String>,
31}
32
33/// Detailed validation result
34#[derive(Debug, Serialize)]
35pub struct ValidationResult {
36    /// Schema file path
37    pub schema_path: String,
38
39    /// Whether validation passed
40    pub valid: bool,
41
42    /// Number of types in schema
43    pub type_count: usize,
44
45    /// Number of queries
46    pub query_count: usize,
47
48    /// Number of mutations
49    pub mutation_count: usize,
50
51    /// Circular dependencies found (errors)
52    #[serde(skip_serializing_if = "Vec::is_empty")]
53    pub cycles: Vec<CycleError>,
54
55    /// Unused types found (warnings or errors in strict mode)
56    #[serde(skip_serializing_if = "Vec::is_empty")]
57    pub unused_types: Vec<String>,
58
59    /// Type-specific analysis (when --types filter is used)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub type_analysis: Option<Vec<TypeAnalysis>>,
62}
63
64/// Information about a circular dependency
65#[derive(Debug, Serialize)]
66pub struct CycleError {
67    /// Types involved in the cycle
68    pub types: Vec<String>,
69    /// Human-readable path
70    pub path:  String,
71}
72
73/// Analysis of a specific type
74#[derive(Debug, Serialize)]
75pub struct TypeAnalysis {
76    /// Type name
77    pub name:                    String,
78    /// Types this type depends on
79    pub dependencies:            Vec<String>,
80    /// Types that depend on this type
81    pub dependents:              Vec<String>,
82    /// Transitive dependencies (all types reachable)
83    pub transitive_dependencies: Vec<String>,
84}
85
86/// Run validation with options and return structured result
87///
88/// # Errors
89///
90/// Returns an error if the schema file cannot be read, cannot be deserialized as
91/// a `CompiledSchema`, or if JSON serialization of the result fails.
92pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
93    // Load and parse schema
94    let schema_content = fs::read_to_string(input)?;
95    let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
96
97    // Build dependency graph
98    let graph = SchemaDependencyGraph::build(&schema);
99
100    let mut errors: Vec<String> = Vec::new();
101    let mut warnings: Vec<String> = Vec::new();
102    let mut cycles: Vec<CycleError> = Vec::new();
103    let mut unused_types: Vec<String> = Vec::new();
104
105    // Check for circular dependencies
106    if opts.check_cycles {
107        let detected_cycles = graph.find_cycles();
108        for cycle in detected_cycles {
109            let cycle_error = CycleError {
110                types: cycle.nodes.clone(),
111                path:  cycle.path_string(),
112            };
113            errors.push(format!("Circular dependency: {}", cycle.path_string()));
114            cycles.push(cycle_error);
115        }
116    }
117
118    // Check for unused types
119    if opts.check_unused {
120        let detected_unused = graph.find_unused();
121        for type_name in detected_unused {
122            if opts.strict {
123                errors.push(format!("Unused type: '{type_name}' has no incoming references"));
124            } else {
125                warnings.push(format!("Unused type: '{type_name}' has no incoming references"));
126            }
127            unused_types.push(type_name);
128        }
129    }
130
131    // Type-specific analysis
132    let type_analysis = if opts.filter_types.is_empty() {
133        None
134    } else {
135        let mut analyses = Vec::new();
136        for type_name in &opts.filter_types {
137            if graph.has_type(type_name) {
138                let deps = graph.dependencies_of(type_name);
139                let refs = graph.dependents_of(type_name);
140                let transitive = graph.transitive_dependencies(type_name);
141
142                analyses.push(TypeAnalysis {
143                    name:                    type_name.clone(),
144                    dependencies:            deps,
145                    dependents:              refs,
146                    transitive_dependencies: transitive.into_iter().collect(),
147                });
148            } else {
149                warnings.push(format!("Type '{type_name}' not found in schema"));
150            }
151        }
152        Some(analyses)
153    };
154
155    // Build result
156    let result = ValidationResult {
157        schema_path: input.to_string(),
158        valid: errors.is_empty(),
159        type_count: schema.types.len(),
160        query_count: schema.queries.len(),
161        mutation_count: schema.mutations.len(),
162        cycles,
163        unused_types,
164        type_analysis,
165    };
166
167    let data = serde_json::to_value(&result)?;
168
169    if !errors.is_empty() {
170        Ok(CommandResult {
171            status: "validation-failed".to_string(),
172            command: "validate".to_string(),
173            data: Some(data),
174            message: Some(format!("{} validation error(s) found", errors.len())),
175            code: Some("VALIDATION_FAILED".to_string()),
176            errors,
177            warnings,
178        })
179    } else if !warnings.is_empty() {
180        Ok(CommandResult::success_with_warnings("validate", data, warnings))
181    } else {
182        Ok(CommandResult::success("validate", data))
183    }
184}
185
186#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
187#[cfg(test)]
188mod tests {
189    use std::io::Write;
190
191    use tempfile::NamedTempFile;
192
193    use super::*;
194
195    fn create_valid_schema() -> String {
196        serde_json::json!({
197            "types": [
198                {
199                    "name": "User",
200                    "sql_source": "v_user",
201                    "jsonb_column": "data",
202                    "fields": [
203                        {"name": "id", "field_type": "ID"},
204                        {"name": "profile", "field_type": {"Object": "Profile"}, "nullable": true}
205                    ],
206                    "implements": []
207                },
208                {
209                    "name": "Profile",
210                    "sql_source": "v_profile",
211                    "jsonb_column": "data",
212                    "fields": [
213                        {"name": "bio", "field_type": "String", "nullable": true}
214                    ],
215                    "implements": []
216                }
217            ],
218            "queries": [
219                {
220                    "name": "users",
221                    "sql_source": "v_user",
222                    "return_type": "[User]",
223                    "arguments": [],
224                    "max_results": 1000
225                }
226            ],
227            "mutations": [],
228            "subscriptions": [],
229            "enums": [],
230            "input_types": [],
231            "interfaces": [],
232            "unions": [],
233            "directives": [],
234            "observers": []
235        })
236        .to_string()
237    }
238
239    fn create_schema_with_cycle() -> String {
240        serde_json::json!({
241            "types": [
242                {
243                    "name": "A",
244                    "sql_source": "v_a",
245                    "jsonb_column": "data",
246                    "fields": [
247                        {"name": "id", "field_type": "ID"},
248                        {"name": "b", "field_type": {"Object": "B"}}
249                    ],
250                    "implements": []
251                },
252                {
253                    "name": "B",
254                    "sql_source": "v_b",
255                    "jsonb_column": "data",
256                    "fields": [
257                        {"name": "id", "field_type": "ID"},
258                        {"name": "a", "field_type": {"Object": "A"}}
259                    ],
260                    "implements": []
261                }
262            ],
263            "queries": [
264                {
265                    "name": "items",
266                    "sql_source": "v_a",
267                    "return_type": "[A]",
268                    "arguments": [],
269                    "max_results": 1000
270                }
271            ],
272            "mutations": [],
273            "subscriptions": [],
274            "enums": [],
275            "input_types": [],
276            "interfaces": [],
277            "unions": [],
278            "directives": [],
279            "observers": []
280        })
281        .to_string()
282    }
283
284    fn create_schema_with_unused() -> String {
285        serde_json::json!({
286            "types": [
287                {
288                    "name": "User",
289                    "sql_source": "v_user",
290                    "jsonb_column": "data",
291                    "fields": [
292                        {"name": "id", "field_type": "ID"}
293                    ],
294                    "implements": []
295                },
296                {
297                    "name": "OrphanType",
298                    "sql_source": "v_orphan",
299                    "jsonb_column": "data",
300                    "fields": [
301                        {"name": "data", "field_type": "String"}
302                    ],
303                    "implements": []
304                }
305            ],
306            "queries": [
307                {
308                    "name": "users",
309                    "sql_source": "v_user",
310                    "return_type": "[User]",
311                    "arguments": [],
312                    "max_results": 1000
313                }
314            ],
315            "mutations": [],
316            "subscriptions": [],
317            "enums": [],
318            "input_types": [],
319            "interfaces": [],
320            "unions": [],
321            "directives": [],
322            "observers": []
323        })
324        .to_string()
325    }
326
327    #[test]
328    fn test_validate_valid_schema() {
329        let schema = create_valid_schema();
330        let mut temp_file = NamedTempFile::new().unwrap();
331        temp_file.write_all(schema.as_bytes()).unwrap();
332
333        let opts = ValidateOptions {
334            check_cycles: true,
335            check_unused: true,
336            strict:       false,
337            filter_types: vec![],
338        };
339
340        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
341
342        assert_eq!(result.status, "success");
343    }
344
345    #[test]
346    fn test_validate_detects_cycles() {
347        let schema = create_schema_with_cycle();
348        let mut temp_file = NamedTempFile::new().unwrap();
349        temp_file.write_all(schema.as_bytes()).unwrap();
350
351        let opts = ValidateOptions {
352            check_cycles: true,
353            check_unused: false,
354            strict:       false,
355            filter_types: vec![],
356        };
357
358        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
359
360        assert_eq!(result.status, "validation-failed");
361        assert!(result.errors.iter().any(|e| e.contains("Circular")));
362    }
363
364    #[test]
365    fn test_validate_cycles_disabled() {
366        let schema = create_schema_with_cycle();
367        let mut temp_file = NamedTempFile::new().unwrap();
368        temp_file.write_all(schema.as_bytes()).unwrap();
369
370        let opts = ValidateOptions {
371            check_cycles: false,
372            check_unused: false,
373            strict:       false,
374            filter_types: vec![],
375        };
376
377        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
378
379        // Should pass because cycle checking is disabled
380        assert_eq!(result.status, "success");
381    }
382
383    #[test]
384    fn test_validate_unused_as_warning() {
385        let schema = create_schema_with_unused();
386        let mut temp_file = NamedTempFile::new().unwrap();
387        temp_file.write_all(schema.as_bytes()).unwrap();
388
389        let opts = ValidateOptions {
390            check_cycles: true,
391            check_unused: true,
392            strict:       false,
393            filter_types: vec![],
394        };
395
396        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
397
398        // Should succeed with warnings
399        assert_eq!(result.status, "success");
400        assert!(!result.warnings.is_empty());
401        assert!(result.warnings.iter().any(|w| w.contains("OrphanType")));
402    }
403
404    #[test]
405    fn test_validate_strict_mode() {
406        let schema = create_schema_with_unused();
407        let mut temp_file = NamedTempFile::new().unwrap();
408        temp_file.write_all(schema.as_bytes()).unwrap();
409
410        let opts = ValidateOptions {
411            check_cycles: true,
412            check_unused: true,
413            strict:       true,
414            filter_types: vec![],
415        };
416
417        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
418
419        // Should fail in strict mode
420        assert_eq!(result.status, "validation-failed");
421        assert!(result.errors.iter().any(|e| e.contains("OrphanType")));
422    }
423
424    #[test]
425    fn test_validate_type_filter() {
426        let schema = create_valid_schema();
427        let mut temp_file = NamedTempFile::new().unwrap();
428        temp_file.write_all(schema.as_bytes()).unwrap();
429
430        let opts = ValidateOptions {
431            check_cycles: true,
432            check_unused: false,
433            strict:       false,
434            filter_types: vec!["User".to_string()],
435        };
436
437        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
438
439        assert_eq!(result.status, "success");
440        let data = result.data.unwrap();
441        let type_analysis = data.get("type_analysis").unwrap().as_array().unwrap();
442        assert_eq!(type_analysis.len(), 1);
443        assert_eq!(type_analysis[0]["name"], "User");
444    }
445
446    #[test]
447    fn test_validate_type_filter_not_found() {
448        let schema = create_valid_schema();
449        let mut temp_file = NamedTempFile::new().unwrap();
450        temp_file.write_all(schema.as_bytes()).unwrap();
451
452        let opts = ValidateOptions {
453            check_cycles: true,
454            check_unused: false,
455            strict:       false,
456            filter_types: vec!["NonExistent".to_string()],
457        };
458
459        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
460
461        // Should succeed with warning about missing type
462        assert_eq!(result.status, "success");
463        assert!(result.warnings.iter().any(|w| w.contains("NonExistent")));
464    }
465
466    #[test]
467    fn test_validate_result_structure() {
468        let schema = create_valid_schema();
469        let mut temp_file = NamedTempFile::new().unwrap();
470        temp_file.write_all(schema.as_bytes()).unwrap();
471
472        let opts = ValidateOptions::default();
473
474        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
475
476        let data = result.data.unwrap();
477        assert!(data.get("schema_path").is_some());
478        assert!(data.get("valid").is_some());
479        assert!(data.get("type_count").is_some());
480        assert!(data.get("query_count").is_some());
481        assert!(data.get("mutation_count").is_some());
482    }
483}