fraiseql_cli/commands/
validate.rs1use 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#[derive(Debug, Clone, Default)]
26pub struct ValidateOptions {
27 pub check_cycles: bool,
29
30 pub check_unused: bool,
32
33 pub strict: bool,
35
36 pub filter_types: Vec<String>,
38}
39
40#[derive(Debug, Serialize)]
42pub struct ValidationResult {
43 pub schema_path: String,
45
46 pub valid: bool,
48
49 pub type_count: usize,
51
52 pub query_count: usize,
54
55 pub mutation_count: usize,
57
58 #[serde(skip_serializing_if = "Vec::is_empty")]
60 pub cycles: Vec<CycleError>,
61
62 #[serde(skip_serializing_if = "Vec::is_empty")]
64 pub unused_types: Vec<String>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub type_analysis: Option<Vec<TypeAnalysis>>,
69}
70
71#[derive(Debug, Serialize)]
73pub struct CycleError {
74 pub types: Vec<String>,
76 pub path: String,
78}
79
80#[derive(Debug, Serialize)]
82pub struct TypeAnalysis {
83 pub name: String,
85 pub dependencies: Vec<String>,
87 pub dependents: Vec<String>,
89 pub transitive_dependencies: Vec<String>,
91}
92
93pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
100 let schema_content = fs::read_to_string(input)?;
102 let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
103
104 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 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 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 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 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
193pub 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 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}