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 function_root_contract::validate_function_roots,
20 mutation_contract::validate_mutation_contract,
21 pg_catalog::PgCatalog,
22 },
23};
24
25#[derive(Debug, Clone, Default)]
27pub struct ValidateOptions {
28 pub check_cycles: bool,
30
31 pub check_unused: bool,
33
34 pub strict: bool,
36
37 pub filter_types: Vec<String>,
39}
40
41#[derive(Debug, Serialize)]
43pub struct ValidationResult {
44 pub schema_path: String,
46
47 pub valid: bool,
49
50 pub type_count: usize,
52
53 pub query_count: usize,
55
56 pub mutation_count: usize,
58
59 #[serde(skip_serializing_if = "Vec::is_empty")]
61 pub cycles: Vec<CycleError>,
62
63 #[serde(skip_serializing_if = "Vec::is_empty")]
65 pub unused_types: Vec<String>,
66
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub type_analysis: Option<Vec<TypeAnalysis>>,
70}
71
72#[derive(Debug, Serialize)]
74pub struct CycleError {
75 pub types: Vec<String>,
77 pub path: String,
79}
80
81#[derive(Debug, Serialize)]
83pub struct TypeAnalysis {
84 pub name: String,
86 pub dependencies: Vec<String>,
88 pub dependents: Vec<String>,
90 pub transitive_dependencies: Vec<String>,
92}
93
94pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
101 let schema_content = fs::read_to_string(input)?;
103 let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
104
105 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 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 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 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 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
194pub 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 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 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}