fraiseql_cli/schema/
function_root_contract.rs1use anyhow::Result;
14use fraiseql_core::schema::{CompiledSchema, QueryDefinition};
15
16use crate::schema::pg_catalog::{PgCatalog, PgFunction};
17
18pub fn function_roots(schema: &CompiledSchema) -> impl Iterator<Item = &QueryDefinition> {
20 schema.queries.iter().filter(|q| !q.sql_source_kind.is_relation())
21}
22
23#[must_use]
26pub fn check_function_root(query: &QueryDefinition, candidates: &[PgFunction]) -> Vec<String> {
27 let source = query.sql_source.as_deref().unwrap_or_default();
28 if candidates.is_empty() {
29 return vec![format!(
30 "no function `{source}` is visible to the connection"
31 )];
32 }
33 let bound: Vec<&str> = query
34 .function_arguments()
35 .map(|arg| arg.name.as_str())
36 .chain(query.inject_params.keys().map(String::as_str))
37 .collect();
38 let matching: Vec<&PgFunction> =
39 candidates.iter().filter(|f| f.in_types.len() == bound.len()).collect();
40 let function = match matching.as_slice() {
41 [function] => *function,
42 [] => {
43 let mut arities: Vec<usize> = candidates.iter().map(|f| f.in_types.len()).collect();
44 arities.sort_unstable();
45 arities.dedup();
46 let takes = arities.iter().map(ToString::to_string).collect::<Vec<_>>().join(" or ");
47 return vec![format!(
48 "the query binds {} parameter(s) ({}), and `{source}` takes {takes}",
49 bound.len(),
50 if bound.is_empty() {
51 "none".to_string()
52 } else {
53 bound.join(", ")
54 },
55 )];
56 },
57 several => {
58 return vec![format!(
59 "{} overloads of `{source}` take {} parameter(s); the query's parameters are bound \
60 untyped, so the call would be ambiguous. Keep one.",
61 several.len(),
62 bound.len()
63 )];
64 },
65 };
66
67 let mut problems = Vec::new();
68 if !function.returns_set {
69 problems.push(format!(
70 "`{source}` returns one value; a query reads from a function that returns a set \
71 (RETURNS TABLE(…) or RETURNS SETOF …)"
72 ));
73 }
74 if function.volatility == "v" {
75 problems.push(format!(
76 "`{source}` is VOLATILE; a read may be cached, retried or served from a replica. \
77 Declare it STABLE, or make it a mutation if it writes"
78 ));
79 }
80 let column = query.jsonb_column.as_str();
81 match function.out_columns.iter().find(|c| c.name == column) {
82 Some(c) if matches!(c.type_name.as_str(), "json" | "jsonb") => {},
83 Some(c) => problems.push(format!(
84 "`{source}` returns column `{column}` as {}; the query reads it as the row's \
85 json/jsonb document",
86 c.type_name
87 )),
88 None => problems.push(format!(
89 "`{source}` returns no column `{column}`, the row's document the query reads"
90 )),
91 }
92 problems
93}
94
95pub async fn validate_function_roots(
102 schema: &CompiledSchema,
103 catalog: &PgCatalog,
104) -> Result<Vec<String>> {
105 let mut problems = Vec::new();
106 for query in function_roots(schema) {
107 let source = query.sql_source.as_deref().unwrap_or_default();
108 let candidates = catalog.resolve_functions(source).await?;
109 problems.extend(
110 check_function_root(query, &candidates)
111 .into_iter()
112 .map(|p| format!("query `{}` (sql_source: {source}): {p}", query.name)),
113 );
114 }
115 Ok(problems)
116}
117
118#[cfg(test)]
119#[path = "function_root_contract_tests.rs"]
120mod tests;