Skip to main content

fraiseql_core/schema/
source_probe.rs

1//! The single, cross-crate definition of "what counts as a backed `sql_source`".
2//!
3//! A compiled schema declares, per operation, a `sql_source`: a **relation** (the
4//! view/table a query reads) or a **function** (the SQL function a mutation calls).
5//! Two separate consumers must agree on which database objects have to exist for a
6//! schema to be servable:
7//!
8//! - `fraiseql-cli` — `compile --database` / `doctor` / `validate --against-db` (executes each
9//!   probe through `pg_catalog`/the introspector).
10//! - `fraiseql-server` — the opt-in fail-fast boot check (executes each probe through the live
11//!   `DatabaseAdapter`).
12//!
13//! [`sql_source_probes`] turns a [`CompiledSchema`] into the work-list once, so the
14//! CLI gate and the server boot check cannot drift on the definition of "backed".
15//! Each side runs the list with its own connector.
16
17use crate::{
18    db::{DatabaseAdapter, quote_postgres_identifier},
19    error::{FraiseQLError, Result},
20    schema::{CompiledSchema, MutationOperation},
21};
22
23/// Whether a `sql_source` names a relation (query backing) or a function
24/// (mutation backing). They are resolved differently: a relation via
25/// `to_regclass` / the relation catalog, a function via `pg_proc`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SourceKind {
28    /// A table / view / materialized view a query reads from.
29    Relation,
30    /// A SQL function a mutation calls.
31    Function,
32}
33
34/// One database object a schema declares it depends on, parsed from a `sql_source`.
35///
36/// The identifier is kept **verbatim** (case-sensitive): the runtime resolves it
37/// through `quote_postgres_identifier`, so a probe must too.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct SourceProbe {
40    /// Explicit schema qualifier (`events` in `events.v_log`), or `None` for a
41    /// bare name resolved against the connection `search_path`.
42    pub schema: Option<String>,
43    /// The relation or function name, verbatim (no case-folding).
44    pub name:   String,
45    /// Whether to resolve `name` as a relation or a function.
46    pub kind:   SourceKind,
47}
48
49impl SourceProbe {
50    /// Render the probe as a (possibly schema-qualified) identifier — the form a
51    /// human-facing diagnostic shows (e.g. `events.v_log`, `app.create_order`).
52    #[must_use]
53    pub fn display_name(&self) -> String {
54        match &self.schema {
55            Some(s) => format!("{s}.{}", self.name),
56            None => self.name.clone(),
57        }
58    }
59}
60
61/// Split a possibly schema-qualified `sql_source` into `(schema?, name)`.
62///
63/// Naive `split_once('.')`, matching the two existing splitters in `fraiseql-cli`
64/// (`database_validator::split_schema_qualified` and `pg_catalog::split_qualified`)
65/// so all three agree. A quoted identifier containing a literal dot is out of
66/// scope — bare/dotted names only, kept verbatim.
67fn split_source(sql_source: &str) -> (Option<String>, String) {
68    match sql_source.split_once('.') {
69        Some((schema, name)) => (Some(schema.to_string()), name.to_string()),
70        None => (None, sql_source.to_string()),
71    }
72}
73
74/// Resolve a mutation's backing function name: its explicit `sql_source`, else the
75/// operation's non-empty table. `None` ⇒ not SQL-backed (federation / `Custom`
76/// without a table) and therefore not probed. Mirrors the `#397` mutation-contract
77/// `resolve_sql_source`.
78const fn mutation_source(mutation: &crate::schema::MutationDefinition) -> Option<&str> {
79    if let Some(src) = &mutation.sql_source {
80        return Some(src.as_str());
81    }
82    match &mutation.operation {
83        MutationOperation::Insert { table }
84        | MutationOperation::Update { table }
85        | MutationOperation::Delete { table }
86            if !table.is_empty() =>
87        {
88            Some(table.as_str())
89        },
90        _ => None,
91    }
92}
93
94/// Build the work-list of database objects a compiled schema must be backed by.
95///
96/// Queries contribute a [`SourceKind::Relation`] probe (their `sql_source` view),
97/// mutations a [`SourceKind::Function`] probe (their `sql_source`, or operation
98/// table). Operations with no SQL source (federation / non-SQL) are skipped — they
99/// have nothing to probe. This is the single definition both the CLI existence
100/// gate (#485) and the server fail-fast boot check (#487) consume.
101#[must_use]
102pub fn sql_source_probes(schema: &CompiledSchema) -> Vec<SourceProbe> {
103    let mut probes = Vec::with_capacity(schema.queries.len() + schema.mutations.len());
104
105    for query in &schema.queries {
106        if let Some(source) = &query.sql_source {
107            let (schema_part, name) = split_source(source);
108            probes.push(SourceProbe {
109                schema: schema_part,
110                name,
111                kind: SourceKind::Relation,
112            });
113        }
114    }
115
116    for mutation in &schema.mutations {
117        if let Some(source) = mutation_source(mutation) {
118            let (schema_part, name) = split_source(source);
119            probes.push(SourceProbe {
120                schema: schema_part,
121                name,
122                kind: SourceKind::Function,
123            });
124        }
125    }
126
127    probes
128}
129
130/// Refuse a schema whose sources a hot standby cannot read, when reads go to replicas
131/// (#1390).
132///
133/// PostgreSQL refuses to read an UNLOGGED or temporary relation during recovery
134/// ("cannot access temporary or unlogged relations during recovery"), so with read
135/// replicas configured every query over such a source fails on every replica — and
136/// `pg_tviews` creates its `tv_*` tables UNLOGGED by default. Views are followed to the
137/// relations they read (a logged view over an unlogged table fails the same way);
138/// materialized views are not, because they store their own rows.
139///
140/// # Errors
141///
142/// `FraiseQLError::Configuration` naming each source and the unlogged relation it
143/// depends on.
144pub async fn refuse_standby_unreadable_sources<A: DatabaseAdapter + ?Sized>(
145    adapter: &A,
146    schema: &CompiledSchema,
147) -> Result<()> {
148    if !adapter.serves_reads_from_standbys() {
149        return Ok(());
150    }
151    let unreadable = standby_unreadable_sources(adapter, schema).await?;
152    if unreadable.is_empty() {
153        return Ok(());
154    }
155    Err(FraiseQLError::Configuration {
156        message: format!(
157            "Read replicas are configured, but {} source relation(s) of this schema depend \
158             on an UNLOGGED or temporary table, which a hot standby cannot read: every query \
159             over them would fail on every replica.\n  - {}\nMake those tables LOGGED \
160             (`ALTER TABLE … SET LOGGED`; for pg_tviews, set \
161             `pg_tviews.unlogged_by_default = off` and recreate the TVIEWs) — at the cost \
162             of WAL for every refresh — or remove `read_replica_urls`.",
163            unreadable.len(),
164            unreadable.join("\n  - ")
165        ),
166    })
167}
168
169/// The relation sources of `schema` a hot standby cannot read (#1390).
170///
171/// Each is reported as `<source> — reads <table>`, for every UNLOGGED or temporary table
172/// it depends on. Read from the primary's catalog, so it answers whether or not replicas
173/// are configured.
174///
175/// # Errors
176///
177/// `FraiseQLError::Database` if the catalog query fails.
178pub async fn standby_unreadable_sources<A: DatabaseAdapter + ?Sized>(
179    adapter: &A,
180    schema: &CompiledSchema,
181) -> Result<Vec<String>> {
182    let mut unreadable: Vec<String> = Vec::new();
183    for probe in sql_source_probes(schema) {
184        if probe.kind != SourceKind::Relation {
185            continue;
186        }
187        let ident = match &probe.schema {
188            Some(s) => format!(
189                "{}.{}",
190                quote_postgres_identifier(s),
191                quote_postgres_identifier(&probe.name)
192            ),
193            None => quote_postgres_identifier(&probe.name),
194        };
195        let literal = ident.replace('\'', "''");
196        let sql = format!(
197            "WITH RECURSIVE rel(oid, kind) AS ( \
198               SELECT c.oid, c.relkind FROM pg_class c WHERE c.oid = to_regclass('{literal}') \
199               UNION \
200               SELECT base.oid, base.relkind FROM rel \
201               JOIN pg_rewrite r ON r.ev_class = rel.oid \
202               JOIN pg_depend d ON d.classid = 'pg_rewrite'::regclass AND d.objid = r.oid \
203                                AND d.refclassid = 'pg_class'::regclass \
204               JOIN pg_class base ON base.oid = d.refobjid AND base.oid <> rel.oid \
205               WHERE rel.kind = 'v' \
206             ) \
207             SELECT c.oid::regclass::text AS relation FROM rel \
208             JOIN pg_class c ON c.oid = rel.oid WHERE c.relpersistence IN ('u', 't') \
209             ORDER BY 1"
210        );
211        for row in adapter.execute_raw_query(&sql).await? {
212            if let Some(relation) = row.get("relation").and_then(serde_json::Value::as_str) {
213                unreadable.push(format!("{} — reads {relation}", probe.display_name()));
214            }
215        }
216    }
217    Ok(unreadable)
218}
219
220#[cfg(test)]
221#[path = "source_probe_tests.rs"]
222mod source_probe_tests;