use std::fs;
use anyhow::{Context, Result};
use fraiseql_core::schema::{CompiledSchema, SchemaDependencyGraph, SourceKind};
use serde::Serialize;
use crate::{
output::CommandResult,
schema::{
database_validator::{create_introspector, find_unbacked_sources},
function_root_contract::validate_function_roots,
mutation_contract::validate_mutation_contract,
pg_catalog::PgCatalog,
},
};
#[derive(Debug, Clone, Default)]
pub struct ValidateOptions {
pub check_cycles: bool,
pub check_unused: bool,
pub strict: bool,
pub filter_types: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct ValidationResult {
pub schema_path: String,
pub valid: bool,
pub type_count: usize,
pub query_count: usize,
pub mutation_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cycles: Vec<CycleError>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub unused_types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub type_analysis: Option<Vec<TypeAnalysis>>,
}
#[derive(Debug, Serialize)]
pub struct CycleError {
pub types: Vec<String>,
pub path: String,
}
#[derive(Debug, Serialize)]
pub struct TypeAnalysis {
pub name: String,
pub dependencies: Vec<String>,
pub dependents: Vec<String>,
pub transitive_dependencies: Vec<String>,
}
pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
let schema_content = fs::read_to_string(input)?;
let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
let graph = SchemaDependencyGraph::build(&schema);
let mut errors: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut cycles: Vec<CycleError> = Vec::new();
let mut unused_types: Vec<String> = Vec::new();
if opts.check_cycles {
let detected_cycles = graph.find_cycles();
for cycle in detected_cycles {
let cycle_error = CycleError {
types: cycle.nodes.clone(),
path: cycle.path_string(),
};
errors.push(format!("Circular dependency: {}", cycle.path_string()));
cycles.push(cycle_error);
}
}
if opts.check_unused {
let detected_unused = graph.find_unused();
for type_name in detected_unused {
if opts.strict {
errors.push(format!("Unused type: '{type_name}' has no incoming references"));
} else {
warnings.push(format!("Unused type: '{type_name}' has no incoming references"));
}
unused_types.push(type_name);
}
}
let type_analysis = if opts.filter_types.is_empty() {
None
} else {
let mut analyses = Vec::new();
for type_name in &opts.filter_types {
if graph.has_type(type_name) {
let deps = graph.dependencies_of(type_name);
let refs = graph.dependents_of(type_name);
let transitive = graph.transitive_dependencies(type_name);
analyses.push(TypeAnalysis {
name: type_name.clone(),
dependencies: deps,
dependents: refs,
transitive_dependencies: transitive.into_iter().collect(),
});
} else {
warnings.push(format!("Type '{type_name}' not found in schema"));
}
}
Some(analyses)
};
let result = ValidationResult {
schema_path: input.to_string(),
valid: errors.is_empty(),
type_count: schema.types.len(),
query_count: schema.queries.len(),
mutation_count: schema.mutations.len(),
cycles,
unused_types,
type_analysis,
};
let data = serde_json::to_value(&result)?;
if !errors.is_empty() {
Ok(CommandResult {
status: "validation-failed".to_string(),
command: "validate".to_string(),
data: Some(data),
message: Some(format!("{} validation error(s) found", errors.len())),
code: Some("VALIDATION_FAILED".to_string()),
errors,
warnings,
})
} else if !warnings.is_empty() {
Ok(CommandResult::success_with_warnings("validate", data, warnings))
} else {
Ok(CommandResult::success("validate", data))
}
}
pub async fn run_against_db(input: &str, db_url: &str, json: bool) -> Result<()> {
let schema_content = fs::read_to_string(input)
.with_context(|| format!("failed to read compiled schema `{input}`"))?;
let schema: CompiledSchema = serde_json::from_str(&schema_content).with_context(|| {
format!("`{input}` is not a valid compiled schema (run `fraiseql compile` first)")
})?;
let catalog =
PgCatalog::connect(db_url, &fraiseql_db::postgres::PostgresTlsConfig::default()).await?;
let report = validate_mutation_contract(&schema, &catalog).await?;
if json {
println!("{}", serde_json::to_string_pretty(&report.to_json())?);
} else {
report.print_text();
}
let introspector =
create_introspector(db_url, &fraiseql_db::postgres::PostgresTlsConfig::default()).await?;
let unbacked = find_unbacked_sources(&schema, &introspector).await?;
if !unbacked.is_empty() {
if json {
let list: Vec<_> = unbacked
.iter()
.map(|p| {
serde_json::json!({
"source": p.display_name(),
"kind": match p.kind {
SourceKind::Relation => "relation",
SourceKind::Function => "function",
},
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"unbackedSources": list
}))?
);
} else {
eprintln!("\nUnbacked sql_source(s) — these will 500 at runtime:");
for probe in &unbacked {
let kind = match probe.kind {
SourceKind::Relation => "relation",
SourceKind::Function => "function",
};
eprintln!(" - {} ({kind}) does not exist", probe.display_name());
}
}
}
let function_roots = validate_function_roots(&schema, &catalog).await?;
if !function_roots.is_empty() {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"functionRootErrors": function_roots
}))?
);
} else {
eprintln!("\nFunction root(s) whose function cannot serve the read:");
for problem in &function_roots {
eprintln!(" - {problem}");
}
}
}
let mutation_errors = report.error_count();
if mutation_errors > 0 || !unbacked.is_empty() || !function_roots.is_empty() {
anyhow::bail!(
"validate --against-db failed: {mutation_errors} mutation-contract error(s), \
{} unbacked sql_source(s), {} function-root error(s)",
unbacked.len(),
function_roots.len()
);
}
Ok(())
}