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;