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::{Context, Result};
12use fraiseql_core::schema::{CompiledSchema, SchemaDependencyGraph};
13use serde::Serialize;
14
15use crate::{
16    output::CommandResult,
17    schema::{mutation_contract::validate_mutation_contract, pg_catalog::PgCatalog},
18};
19
20/// Options for schema validation
21#[derive(Debug, Clone, Default)]
22pub struct ValidateOptions {
23    /// Check for circular dependencies between types
24    pub check_cycles: bool,
25
26    /// Check for unused types (types with no incoming references)
27    pub check_unused: bool,
28
29    /// Strict mode: treat warnings as errors
30    pub strict: bool,
31
32    /// Filter to specific types (empty = all types)
33    pub filter_types: Vec<String>,
34}
35
36/// Detailed validation result
37#[derive(Debug, Serialize)]
38pub struct ValidationResult {
39    /// Schema file path
40    pub schema_path: String,
41
42    /// Whether validation passed
43    pub valid: bool,
44
45    /// Number of types in schema
46    pub type_count: usize,
47
48    /// Number of queries
49    pub query_count: usize,
50
51    /// Number of mutations
52    pub mutation_count: usize,
53
54    /// Circular dependencies found (errors)
55    #[serde(skip_serializing_if = "Vec::is_empty")]
56    pub cycles: Vec<CycleError>,
57
58    /// Unused types found (warnings or errors in strict mode)
59    #[serde(skip_serializing_if = "Vec::is_empty")]
60    pub unused_types: Vec<String>,
61
62    /// Type-specific analysis (when --types filter is used)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub type_analysis: Option<Vec<TypeAnalysis>>,
65}
66
67/// Information about a circular dependency
68#[derive(Debug, Serialize)]
69pub struct CycleError {
70    /// Types involved in the cycle
71    pub types: Vec<String>,
72    /// Human-readable path
73    pub path:  String,
74}
75
76/// Analysis of a specific type
77#[derive(Debug, Serialize)]
78pub struct TypeAnalysis {
79    /// Type name
80    pub name:                    String,
81    /// Types this type depends on
82    pub dependencies:            Vec<String>,
83    /// Types that depend on this type
84    pub dependents:              Vec<String>,
85    /// Transitive dependencies (all types reachable)
86    pub transitive_dependencies: Vec<String>,
87}
88
89/// Run validation with options and return structured result
90///
91/// # Errors
92///
93/// Returns an error if the schema file cannot be read, cannot be deserialized as
94/// a `CompiledSchema`, or if JSON serialization of the result fails.
95pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
96    // Load and parse schema
97    let schema_content = fs::read_to_string(input)?;
98    let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
99
100    // Build dependency graph
101    let graph = SchemaDependencyGraph::build(&schema);
102
103    let mut errors: Vec<String> = Vec::new();
104    let mut warnings: Vec<String> = Vec::new();
105    let mut cycles: Vec<CycleError> = Vec::new();
106    let mut unused_types: Vec<String> = Vec::new();
107
108    // Check for circular dependencies
109    if opts.check_cycles {
110        let detected_cycles = graph.find_cycles();
111        for cycle in detected_cycles {
112            let cycle_error = CycleError {
113                types: cycle.nodes.clone(),
114                path:  cycle.path_string(),
115            };
116            errors.push(format!("Circular dependency: {}", cycle.path_string()));
117            cycles.push(cycle_error);
118        }
119    }
120
121    // Check for unused types
122    if opts.check_unused {
123        let detected_unused = graph.find_unused();
124        for type_name in detected_unused {
125            if opts.strict {
126                errors.push(format!("Unused type: '{type_name}' has no incoming references"));
127            } else {
128                warnings.push(format!("Unused type: '{type_name}' has no incoming references"));
129            }
130            unused_types.push(type_name);
131        }
132    }
133
134    // Type-specific analysis
135    let type_analysis = if opts.filter_types.is_empty() {
136        None
137    } else {
138        let mut analyses = Vec::new();
139        for type_name in &opts.filter_types {
140            if graph.has_type(type_name) {
141                let deps = graph.dependencies_of(type_name);
142                let refs = graph.dependents_of(type_name);
143                let transitive = graph.transitive_dependencies(type_name);
144
145                analyses.push(TypeAnalysis {
146                    name:                    type_name.clone(),
147                    dependencies:            deps,
148                    dependents:              refs,
149                    transitive_dependencies: transitive.into_iter().collect(),
150                });
151            } else {
152                warnings.push(format!("Type '{type_name}' not found in schema"));
153            }
154        }
155        Some(analyses)
156    };
157
158    // Build result
159    let result = ValidationResult {
160        schema_path: input.to_string(),
161        valid: errors.is_empty(),
162        type_count: schema.types.len(),
163        query_count: schema.queries.len(),
164        mutation_count: schema.mutations.len(),
165        cycles,
166        unused_types,
167        type_analysis,
168    };
169
170    let data = serde_json::to_value(&result)?;
171
172    if !errors.is_empty() {
173        Ok(CommandResult {
174            status: "validation-failed".to_string(),
175            command: "validate".to_string(),
176            data: Some(data),
177            message: Some(format!("{} validation error(s) found", errors.len())),
178            code: Some("VALIDATION_FAILED".to_string()),
179            errors,
180            warnings,
181        })
182    } else if !warnings.is_empty() {
183        Ok(CommandResult::success_with_warnings("validate", data, warnings))
184    } else {
185        Ok(CommandResult::success("validate", data))
186    }
187}
188
189/// Validate the mutation contract against a live PostgreSQL database (#397).
190///
191/// Loads the compiled schema at `input`, checks every DB-backed mutation's call
192/// binding and response shape against the database, prints a report, and fails
193/// when any error-severity violation is found.
194///
195/// # Errors
196///
197/// Returns an error if the schema cannot be read or parsed, the database cannot
198/// be reached, or any mutation violates the contract.
199pub async fn run_against_db(input: &str, db_url: &str, json: bool) -> Result<()> {
200    let schema_content = fs::read_to_string(input)
201        .with_context(|| format!("failed to read compiled schema `{input}`"))?;
202    let schema: CompiledSchema = serde_json::from_str(&schema_content).with_context(|| {
203        format!("`{input}` is not a valid compiled schema (run `fraiseql compile` first)")
204    })?;
205
206    let catalog = PgCatalog::connect(db_url)?;
207    let report = validate_mutation_contract(&schema, &catalog).await?;
208
209    if json {
210        println!("{}", serde_json::to_string_pretty(&report.to_json())?);
211    } else {
212        report.print_text();
213    }
214
215    if report.error_count() > 0 {
216        anyhow::bail!("mutation contract validation failed: {} error(s)", report.error_count());
217    }
218    Ok(())
219}