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