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