Skip to main content

fraiseql_cli/schema/
function_root_contract.rs

1//! The database contract of a query read from a function.
2//!
3//! A query whose `sql_source_kind` is `function` is read as `SELECT … FROM fn($1, …)`: the
4//! executor binds the query's own arguments (auto-params left out) in declaration order, then
5//! one parameter per inject param, and reads the query's `jsonb_column` from each row. The
6//! function must therefore exist with exactly that many input parameters, return a set, return
7//! that column as `json`/`jsonb`, and not be `VOLATILE`: a read may be cached, retried or served
8//! from a replica, and none of those is safe for a function that writes.
9//!
10//! [`check_function_root`] answers that from catalog rows, so it is tested without a database;
11//! [`validate_function_roots`] reads the rows for every function root of a schema.
12
13use anyhow::Result;
14use fraiseql_core::schema::{CompiledSchema, QueryDefinition};
15
16use crate::schema::pg_catalog::{PgCatalog, PgFunction};
17
18/// The queries of `schema` that read from a function.
19pub fn function_roots(schema: &CompiledSchema) -> impl Iterator<Item = &QueryDefinition> {
20    schema.queries.iter().filter(|q| !q.sql_source_kind.is_relation())
21}
22
23/// What is wrong with the function `query` reads, given every overload of its name
24/// (`candidates`); empty when the read will bind and run.
25#[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
95/// Check every function root of `schema` against the database, as
96/// `query \`name\` (sql_source: fn): problem` lines.
97///
98/// # Errors
99///
100/// Returns an error if a catalog query fails.
101pub 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;