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
87pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
88    // Load and parse schema
89    let schema_content = fs::read_to_string(input)?;
90    let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
91
92    // Build dependency graph
93    let graph = SchemaDependencyGraph::build(&schema);
94
95    let mut errors: Vec<String> = Vec::new();
96    let mut warnings: Vec<String> = Vec::new();
97    let mut cycles: Vec<CycleError> = Vec::new();
98    let mut unused_types: Vec<String> = Vec::new();
99
100    // Check for circular dependencies
101    if opts.check_cycles {
102        let detected_cycles = graph.find_cycles();
103        for cycle in detected_cycles {
104            let cycle_error = CycleError {
105                types: cycle.nodes.clone(),
106                path:  cycle.path_string(),
107            };
108            errors.push(format!("Circular dependency: {}", cycle.path_string()));
109            cycles.push(cycle_error);
110        }
111    }
112
113    // Check for unused types
114    if opts.check_unused {
115        let detected_unused = graph.find_unused();
116        for type_name in detected_unused {
117            if opts.strict {
118                errors.push(format!("Unused type: '{type_name}' has no incoming references"));
119            } else {
120                warnings.push(format!("Unused type: '{type_name}' has no incoming references"));
121            }
122            unused_types.push(type_name);
123        }
124    }
125
126    // Type-specific analysis
127    let type_analysis = if opts.filter_types.is_empty() {
128        None
129    } else {
130        let mut analyses = Vec::new();
131        for type_name in &opts.filter_types {
132            if graph.has_type(type_name) {
133                let deps = graph.dependencies_of(type_name);
134                let refs = graph.dependents_of(type_name);
135                let transitive = graph.transitive_dependencies(type_name);
136
137                analyses.push(TypeAnalysis {
138                    name:                    type_name.clone(),
139                    dependencies:            deps,
140                    dependents:              refs,
141                    transitive_dependencies: transitive.into_iter().collect(),
142                });
143            } else {
144                warnings.push(format!("Type '{type_name}' not found in schema"));
145            }
146        }
147        Some(analyses)
148    };
149
150    // Build result
151    let result = ValidationResult {
152        schema_path: input.to_string(),
153        valid: errors.is_empty(),
154        type_count: schema.types.len(),
155        query_count: schema.queries.len(),
156        mutation_count: schema.mutations.len(),
157        cycles,
158        unused_types,
159        type_analysis,
160    };
161
162    let data = serde_json::to_value(&result)?;
163
164    if !errors.is_empty() {
165        Ok(CommandResult {
166            status: "validation-failed".to_string(),
167            command: "validate".to_string(),
168            data: Some(data),
169            message: Some(format!("{} validation error(s) found", errors.len())),
170            code: Some("VALIDATION_FAILED".to_string()),
171            errors,
172            warnings,
173        })
174    } else if !warnings.is_empty() {
175        Ok(CommandResult::success_with_warnings("validate", data, warnings))
176    } else {
177        Ok(CommandResult::success("validate", data))
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use std::io::Write;
184
185    use tempfile::NamedTempFile;
186
187    use super::*;
188
189    fn create_valid_schema() -> String {
190        serde_json::json!({
191            "types": [
192                {
193                    "name": "User",
194                    "sql_source": "v_user",
195                    "jsonb_column": "data",
196                    "fields": [
197                        {"name": "id", "field_type": "ID"},
198                        {"name": "profile", "field_type": {"Object": "Profile"}, "nullable": true}
199                    ],
200                    "implements": []
201                },
202                {
203                    "name": "Profile",
204                    "sql_source": "v_profile",
205                    "jsonb_column": "data",
206                    "fields": [
207                        {"name": "bio", "field_type": "String", "nullable": true}
208                    ],
209                    "implements": []
210                }
211            ],
212            "queries": [
213                {
214                    "name": "users",
215                    "sql_source": "v_user",
216                    "return_type": "[User]",
217                    "arguments": [],
218                    "max_results": 1000
219                }
220            ],
221            "mutations": [],
222            "subscriptions": [],
223            "enums": [],
224            "input_types": [],
225            "interfaces": [],
226            "unions": [],
227            "directives": [],
228            "observers": []
229        })
230        .to_string()
231    }
232
233    fn create_schema_with_cycle() -> String {
234        serde_json::json!({
235            "types": [
236                {
237                    "name": "A",
238                    "sql_source": "v_a",
239                    "jsonb_column": "data",
240                    "fields": [
241                        {"name": "id", "field_type": "ID"},
242                        {"name": "b", "field_type": {"Object": "B"}}
243                    ],
244                    "implements": []
245                },
246                {
247                    "name": "B",
248                    "sql_source": "v_b",
249                    "jsonb_column": "data",
250                    "fields": [
251                        {"name": "id", "field_type": "ID"},
252                        {"name": "a", "field_type": {"Object": "A"}}
253                    ],
254                    "implements": []
255                }
256            ],
257            "queries": [
258                {
259                    "name": "items",
260                    "sql_source": "v_a",
261                    "return_type": "[A]",
262                    "arguments": [],
263                    "max_results": 1000
264                }
265            ],
266            "mutations": [],
267            "subscriptions": [],
268            "enums": [],
269            "input_types": [],
270            "interfaces": [],
271            "unions": [],
272            "directives": [],
273            "observers": []
274        })
275        .to_string()
276    }
277
278    fn create_schema_with_unused() -> String {
279        serde_json::json!({
280            "types": [
281                {
282                    "name": "User",
283                    "sql_source": "v_user",
284                    "jsonb_column": "data",
285                    "fields": [
286                        {"name": "id", "field_type": "ID"}
287                    ],
288                    "implements": []
289                },
290                {
291                    "name": "OrphanType",
292                    "sql_source": "v_orphan",
293                    "jsonb_column": "data",
294                    "fields": [
295                        {"name": "data", "field_type": "String"}
296                    ],
297                    "implements": []
298                }
299            ],
300            "queries": [
301                {
302                    "name": "users",
303                    "sql_source": "v_user",
304                    "return_type": "[User]",
305                    "arguments": [],
306                    "max_results": 1000
307                }
308            ],
309            "mutations": [],
310            "subscriptions": [],
311            "enums": [],
312            "input_types": [],
313            "interfaces": [],
314            "unions": [],
315            "directives": [],
316            "observers": []
317        })
318        .to_string()
319    }
320
321    #[test]
322    fn test_validate_valid_schema() {
323        let schema = create_valid_schema();
324        let mut temp_file = NamedTempFile::new().unwrap();
325        temp_file.write_all(schema.as_bytes()).unwrap();
326
327        let opts = ValidateOptions {
328            check_cycles: true,
329            check_unused: true,
330            strict:       false,
331            filter_types: vec![],
332        };
333
334        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
335
336        assert_eq!(result.status, "success");
337    }
338
339    #[test]
340    fn test_validate_detects_cycles() {
341        let schema = create_schema_with_cycle();
342        let mut temp_file = NamedTempFile::new().unwrap();
343        temp_file.write_all(schema.as_bytes()).unwrap();
344
345        let opts = ValidateOptions {
346            check_cycles: true,
347            check_unused: false,
348            strict:       false,
349            filter_types: vec![],
350        };
351
352        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
353
354        assert_eq!(result.status, "validation-failed");
355        assert!(result.errors.iter().any(|e| e.contains("Circular")));
356    }
357
358    #[test]
359    fn test_validate_cycles_disabled() {
360        let schema = create_schema_with_cycle();
361        let mut temp_file = NamedTempFile::new().unwrap();
362        temp_file.write_all(schema.as_bytes()).unwrap();
363
364        let opts = ValidateOptions {
365            check_cycles: false,
366            check_unused: false,
367            strict:       false,
368            filter_types: vec![],
369        };
370
371        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
372
373        // Should pass because cycle checking is disabled
374        assert_eq!(result.status, "success");
375    }
376
377    #[test]
378    fn test_validate_unused_as_warning() {
379        let schema = create_schema_with_unused();
380        let mut temp_file = NamedTempFile::new().unwrap();
381        temp_file.write_all(schema.as_bytes()).unwrap();
382
383        let opts = ValidateOptions {
384            check_cycles: true,
385            check_unused: true,
386            strict:       false,
387            filter_types: vec![],
388        };
389
390        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
391
392        // Should succeed with warnings
393        assert_eq!(result.status, "success");
394        assert!(!result.warnings.is_empty());
395        assert!(result.warnings.iter().any(|w| w.contains("OrphanType")));
396    }
397
398    #[test]
399    fn test_validate_strict_mode() {
400        let schema = create_schema_with_unused();
401        let mut temp_file = NamedTempFile::new().unwrap();
402        temp_file.write_all(schema.as_bytes()).unwrap();
403
404        let opts = ValidateOptions {
405            check_cycles: true,
406            check_unused: true,
407            strict:       true,
408            filter_types: vec![],
409        };
410
411        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
412
413        // Should fail in strict mode
414        assert_eq!(result.status, "validation-failed");
415        assert!(result.errors.iter().any(|e| e.contains("OrphanType")));
416    }
417
418    #[test]
419    fn test_validate_type_filter() {
420        let schema = create_valid_schema();
421        let mut temp_file = NamedTempFile::new().unwrap();
422        temp_file.write_all(schema.as_bytes()).unwrap();
423
424        let opts = ValidateOptions {
425            check_cycles: true,
426            check_unused: false,
427            strict:       false,
428            filter_types: vec!["User".to_string()],
429        };
430
431        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
432
433        assert_eq!(result.status, "success");
434        let data = result.data.unwrap();
435        let type_analysis = data.get("type_analysis").unwrap().as_array().unwrap();
436        assert_eq!(type_analysis.len(), 1);
437        assert_eq!(type_analysis[0]["name"], "User");
438    }
439
440    #[test]
441    fn test_validate_type_filter_not_found() {
442        let schema = create_valid_schema();
443        let mut temp_file = NamedTempFile::new().unwrap();
444        temp_file.write_all(schema.as_bytes()).unwrap();
445
446        let opts = ValidateOptions {
447            check_cycles: true,
448            check_unused: false,
449            strict:       false,
450            filter_types: vec!["NonExistent".to_string()],
451        };
452
453        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
454
455        // Should succeed with warning about missing type
456        assert_eq!(result.status, "success");
457        assert!(result.warnings.iter().any(|w| w.contains("NonExistent")));
458    }
459
460    #[test]
461    fn test_validate_result_structure() {
462        let schema = create_valid_schema();
463        let mut temp_file = NamedTempFile::new().unwrap();
464        temp_file.write_all(schema.as_bytes()).unwrap();
465
466        let opts = ValidateOptions::default();
467
468        let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
469
470        let data = result.data.unwrap();
471        assert!(data.get("schema_path").is_some());
472        assert!(data.get("valid").is_some());
473        assert!(data.get("type_count").is_some());
474        assert!(data.get("query_count").is_some());
475        assert!(data.get("mutation_count").is_some());
476    }
477}