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