use anyhow::{Result, bail};
use fraiseql_core::schema::CompiledSchema;
use fraiseql_functions::{
FunctionsConfig, RuntimeType,
triggers::registry::{ParsedTrigger, QueryFunctionBinding, TriggerRegistry},
};
pub fn validate_against_schema(
functions: Option<&FunctionsConfig>,
schema: &CompiledSchema,
) -> Result<()> {
let definitions: &[fraiseql_functions::FunctionDefinition] =
functions.map_or(&[], |f| f.definitions.as_slice());
if let Err(error) = TriggerRegistry::validate_definitions(definitions) {
bail!("{}", error.message);
}
let bindings: Vec<QueryFunctionBinding<'_>> = query_function_bindings(schema);
if let Err(error) = TriggerRegistry::validate_query_bindings(&bindings, definitions) {
bail!("{}", error.message);
}
let mut failures: Vec<String> = Vec::new();
for definition in definitions {
let Ok(parsed) = ParsedTrigger::parse(&definition.trigger) else {
continue;
};
match parsed {
ParsedTrigger::BeforeMutation { mutation_name }
if !schema.mutations.iter().any(|m| m.name == mutation_name) =>
{
failures.push(format!(
" function `{}`: trigger `{}` names no declared mutation. The chain runs \
keyed on the mutation's name; declared mutations are: {}",
definition.name,
definition.trigger,
name_list(schema.mutations.iter().map(|m| m.name.as_str())),
));
},
ParsedTrigger::BeforeMutation { .. } => {},
ParsedTrigger::AfterMutation { entity_type, .. } => {
let written: Vec<String> = schema
.mutations
.iter()
.flat_map(|m| fraiseql_core::runtime::StampContract::of(schema, m).success)
.collect();
if !written.contains(&entity_type) {
failures.push(format!(
" function `{}`: trigger `{}` names `{entity_type}`, which no declared \
mutation writes — the trigger would never fire. after:mutation matches \
the entity a mutation writes (its return type, or the success member of \
its result union), not its name; entities written by a mutation are: {}",
definition.name,
definition.trigger,
name_list(written.iter().map(String::as_str)),
));
} else {
failures.extend(unknown_predicate_fields(definition, &entity_type, schema));
}
},
_ => {},
}
}
if let Some(functions) = functions {
if let Err(module_failures) = modules_are_present(functions) {
failures.extend(module_failures);
}
}
failures.extend(unloadable_connectors(functions, schema));
if !failures.is_empty() {
bail!(
"the compiled schema declares {} function(s) or source connector(s) that cannot run \
as written:\n{}",
failures.len(),
failures.join("\n")
);
}
Ok(())
}
fn unknown_predicate_fields(
definition: &fraiseql_functions::FunctionDefinition,
entity_type: &str,
schema: &CompiledSchema,
) -> Vec<String> {
let Some(type_def) = schema.types.iter().find(|t| t.name == entity_type) else {
return Vec::new();
};
definition
.when
.iter()
.filter(|predicate| !type_def.fields.iter().any(|f| f.name == predicate.field))
.map(|predicate| {
format!(
" function `{}`: `when` predicate names field `{}`, which `{entity_type}` does \
not have — the predicate can never match, so the function would never fire. \
`{entity_type}` has: {}",
definition.name,
predicate.field,
name_list(type_def.fields.iter().map(|f| f.name.as_str())),
)
})
.collect()
}
fn modules_are_present(functions: &FunctionsConfig) -> Result<(), Vec<String>> {
if !functions.module_dir.is_dir() {
tracing::warn!(
module_dir = %functions.module_dir.display(),
"functions module_dir does not exist at compile time — module presence and \
runtime/extension agreement are unverified; the server checks them at boot"
);
return Ok(());
}
let failures: Vec<String> = functions
.definitions
.iter()
.filter(|definition| definition.resolve_module_path(&functions.module_dir).is_none())
.map(|definition| {
format!(
" function `{}`: no module for the {:?} runtime at {} — the server loads \
`<module_dir>/<name>.<ext>`, so the file name must match the declared function \
name and the extension must match the declared runtime",
definition.name,
definition.runtime,
definition.module_path_pattern(&functions.module_dir),
)
})
.collect();
if failures.is_empty() {
Ok(())
} else {
Err(failures)
}
}
fn unloadable_connectors(
functions: Option<&FunctionsConfig>,
schema: &CompiledSchema,
) -> Vec<String> {
let enabled = schema.sources.iter().filter(|source| source.enabled);
let Some(functions) = functions else {
return enabled
.map(|source| {
format!(
" source `{}`: runs the connector `{}`, but there is no `functions` \
section to load it from — declare `[functions] module_dir` with `{}.ts` \
in it, or disable the source",
source.name, source.function, source.function,
)
})
.collect();
};
if !functions.module_dir.is_dir() {
return Vec::new();
}
let runtime = RuntimeType::Deno;
enabled
.filter(|source| !functions.definitions.iter().any(|d| d.name == source.function))
.filter(|source| {
runtime.resolve_module_path(&functions.module_dir, &source.function).is_none()
})
.map(|source| {
format!(
" source `{}`: no connector module at {} — the server loads a source's \
connector from `<module_dir>/<function>.<ext>`, so the file name must match \
the source's `function`",
source.name,
runtime.module_path_pattern(&functions.module_dir, &source.function),
)
})
.collect()
}
fn query_function_bindings(schema: &CompiledSchema) -> Vec<QueryFunctionBinding<'_>> {
schema
.queries
.iter()
.filter_map(|query| {
query.function.as_deref().map(|function| QueryFunctionBinding {
query: query.name.as_str(),
function,
})
})
.collect()
}
fn name_list<'a>(names: impl Iterator<Item = &'a str>) -> String {
let mut names: Vec<&str> = names.collect();
names.sort_unstable();
names.dedup();
if names.is_empty() {
"(none declared)".to_string()
} else {
names.join(", ")
}
}