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 intermediate::IntermediateSchema,
21 mutation_contract::validate_mutation_contract,
22 pg_catalog::PgCatalog,
23 },
24};
25
26fn 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#[derive(Debug, Clone, Default)]
51pub struct ValidateOptions {
52 pub check_cycles: bool,
54
55 pub check_unused: bool,
57
58 pub strict: bool,
60
61 pub filter_types: Vec<String>,
63}
64
65#[derive(Debug, Serialize)]
67pub struct ValidationResult {
68 pub schema_path: String,
70
71 pub valid: bool,
73
74 pub type_count: usize,
76
77 pub query_count: usize,
79
80 pub mutation_count: usize,
82
83 #[serde(skip_serializing_if = "Vec::is_empty")]
85 pub cycles: Vec<CycleError>,
86
87 #[serde(skip_serializing_if = "Vec::is_empty")]
89 pub unused_types: Vec<String>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub type_analysis: Option<Vec<TypeAnalysis>>,
94}
95
96#[derive(Debug, Serialize)]
98pub struct CycleError {
99 pub types: Vec<String>,
101 pub path: String,
103}
104
105#[derive(Debug, Serialize)]
107pub struct TypeAnalysis {
108 pub name: String,
110 pub dependencies: Vec<String>,
112 pub dependents: Vec<String>,
114 pub transitive_dependencies: Vec<String>,
116}
117
118pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
125 let schema = load_compiled_schema(input)?;
126
127 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 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 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 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 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
216pub 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 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 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}