Skip to main content

fraiseql_cli/commands/
validate.rs

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