Skip to main content

fraiseql_server/
sql_source_check.rs

1//! Opt-in fail-fast `sql_source` existence check at server boot (#487).
2//!
3//! Turns a declared-but-unbacked `sql_source` from a silent-until-hit per-request
4//! 500 into a **loud-early** boot failure. Default **OFF** — when disabled the boot
5//! path is byte-for-byte unchanged.
6//!
7//! Postgres-only. It executes the shared
8//! [`fraiseql_core::schema::sql_source_probes`] work-list — the *same* definition
9//! of "backed" the CLI `validate --against-db` gate uses — through the live
10//! [`DatabaseAdapter`], so the two cannot drift. The probe SQL embeds quoted
11//! identifiers (the adapter's raw-SQL entry point takes no bind parameters) and
12//! resolves them verbatim, exactly as the runtime does.
13
14use fraiseql_core::{
15    db::{DatabaseAdapter, quote_postgres_identifier},
16    schema::{CompiledSchema, SourceKind, SourceProbe, sql_source_probes},
17};
18
19/// SQL returning a single boolean column `source_exists` for one probe.
20///
21/// A relation is resolved with `to_regclass` on the case-sensitively-quoted
22/// identifier (`quote_postgres_identifier` — the runtime's own quoting), embedded
23/// as a string literal because [`DatabaseAdapter::execute_raw_query`] takes no bind
24/// parameters. A function is resolved via `pg_proc` (`prokind IN ('f','p')`),
25/// schema-qualified or `current_schemas`-scoped. Identifiers come from the trusted
26/// compiled schema but single quotes are still doubled defensively.
27fn existence_sql(probe: &SourceProbe) -> String {
28    match probe.kind {
29        SourceKind::Relation => {
30            let ident = match &probe.schema {
31                Some(s) => format!(
32                    "{}.{}",
33                    quote_postgres_identifier(s),
34                    quote_postgres_identifier(&probe.name)
35                ),
36                None => quote_postgres_identifier(&probe.name),
37            };
38            let literal = ident.replace('\'', "''");
39            format!("SELECT to_regclass('{literal}') IS NOT NULL AS source_exists")
40        },
41        SourceKind::Function => {
42            let name = probe.name.replace('\'', "''");
43            match &probe.schema {
44                Some(s) => {
45                    let schema = s.replace('\'', "''");
46                    format!(
47                        "SELECT EXISTS(SELECT 1 FROM pg_proc p \
48                           JOIN pg_namespace n ON n.oid = p.pronamespace \
49                           WHERE n.nspname = '{schema}' AND p.proname = '{name}' \
50                           AND p.prokind IN ('f','p')) AS source_exists"
51                    )
52                },
53                None => format!(
54                    "SELECT EXISTS(SELECT 1 FROM pg_proc p \
55                       JOIN pg_namespace n ON n.oid = p.pronamespace \
56                       WHERE p.proname = '{name}' \
57                       AND n.nspname = ANY(current_schemas(false)) \
58                       AND p.prokind IN ('f','p')) AS source_exists"
59                ),
60            }
61        },
62    }
63}
64
65/// Probe every declared `sql_source` and return the ones **not** backed by a live
66/// database object, in declaration order — empty means every source is backed.
67///
68/// # Errors
69///
70/// Returns a [`fraiseql_core::FraiseQLError`] if a probe query fails — including on
71/// adapters with no raw-SQL path (the wire backend), where this check is never
72/// enabled.
73pub async fn find_unbacked_sources<A: DatabaseAdapter>(
74    schema: &CompiledSchema,
75    adapter: &A,
76) -> fraiseql_core::Result<Vec<SourceProbe>> {
77    let mut unbacked = Vec::new();
78    for probe in sql_source_probes(schema) {
79        let rows = adapter.execute_raw_query(&existence_sql(&probe)).await?;
80        let exists = rows
81            .first()
82            .and_then(|r| r.get("source_exists"))
83            .and_then(serde_json::Value::as_bool)
84            .unwrap_or(false);
85        if !exists {
86            unbacked.push(probe);
87        }
88    }
89    Ok(unbacked)
90}
91
92/// Render an unbacked-source list as a boot diagnostic. Shape is kept stable so the
93/// release-smoke harness can assert on it.
94#[must_use]
95pub fn format_unbacked(unbacked: &[SourceProbe]) -> String {
96    use std::fmt::Write as _;
97
98    let mut out = String::from(
99        "fail-fast sql_source validation failed — declared sources are not backed by the database:",
100    );
101    for probe in unbacked {
102        let kind = match probe.kind {
103            SourceKind::Relation => "relation",
104            SourceKind::Function => "function",
105        };
106        let _ = write!(out, "\n  - {} ({kind}) does not exist", probe.display_name());
107    }
108    out
109}
110
111#[cfg(test)]
112#[path = "sql_source_check_tests.rs"]
113mod sql_source_check_tests;