fraiseql_cli/schema/pg_catalog.rs
1//! Live-PostgreSQL catalog access for the `--against-db` checks.
2//!
3//! A thin wrapper over a [`deadpool_postgres`] pool that answers the two
4//! questions the `validate --against-db` (#397) and `doctor --against-db`
5//! (#409) checks need:
6//!
7//! - **Function resolution / signature** — for a `sql_source` name, which overloads exist, what are
8//! their *input* argument types/names (in call order), and what columns does each return?
9//! (`resolve_functions`)
10//! - **PL/pgSQL body resolution** — does the `plpgsql_check` extension exist, and which managed
11//! functions call something the catalog can't resolve? (`plpgsql_check_available` /
12//! `plpgsql_check_unresolved_calls`)
13//!
14//! All catalog queries are PostgreSQL-specific (`pg_proc`, `pg_type`,
15//! `pg_attribute`, `plpgsql_check`); the surrounding logic in
16//! [`super::mutation_contract`] is database-agnostic and unit-tested without a
17//! connection.
18
19use anyhow::{Context, Result};
20use deadpool_postgres::{Config, ManagerConfig, Pool, RecyclingMethod, Runtime};
21use tokio_postgres::NoTls;
22
23/// A single output column of a PostgreSQL function's result row.
24///
25/// Populated from either the `OUT`/`TABLE` parameters of a `RETURNS TABLE(…)`
26/// function or the attributes of a composite `RETURNS <type>` — both are how
27/// FraiseQL mutation functions declare the row the server decodes into
28/// `MutationResponse`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct OutColumn {
31 /// Column name as it appears in the result row (matched against the
32 /// `MutationResponse` field names).
33 pub name: String,
34 /// `format_type` rendering of the column type (e.g. `"boolean"`,
35 /// `"jsonb"`, `"text[]"`, `"app.mutation_error_class"`).
36 pub type_name: String,
37 /// Whether the column type is a PostgreSQL `enum` (`pg_type.typtype = 'e'`).
38 /// Mutation functions legitimately type `error_class` as either `text` or a
39 /// project-specific enum, so the contract check accepts both.
40 pub is_enum: bool,
41}
42
43/// One overload of a PostgreSQL function, reduced to what the mutation-contract
44/// check needs: the *input* arguments (in positional call order, excluding
45/// `OUT`/`TABLE` columns) and the output columns.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct PgFunction {
48 /// Schema the function lives in.
49 pub schema: String,
50 /// Function name (unqualified).
51 pub name: String,
52 /// Input argument types in call order (`format_type`), modes `IN`/`INOUT`/
53 /// `VARIADIC` only — these are the positions the server binds.
54 pub in_types: Vec<String>,
55 /// Input argument names in call order; `None` for unnamed positions.
56 pub in_names: Vec<Option<String>>,
57 /// Output columns of the result row (see [`OutColumn`]). Empty when the
58 /// function returns a scalar / `record` the catalog cannot expand.
59 pub out_columns: Vec<OutColumn>,
60 /// Whether the function returns a set (`RETURNS SETOF …` / `RETURNS
61 /// TABLE(…)`). Informational only.
62 pub returns_set: bool,
63}
64
65/// A single unresolved internal call surfaced by `plpgsql_check` (#409).
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct BodyError {
68 /// Calling function, rendered as `schema.fn(argtypes)` (`regprocedure`).
69 pub caller: String,
70 /// Line number within the function body, when reported.
71 pub lineno: Option<i32>,
72 /// The `plpgsql_check` diagnostic message.
73 pub message: String,
74}
75
76/// A single live column of a table, as the change-log contract drift check
77/// (#380) reads it from `information_schema.columns`.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct LiveColumn {
80 /// Column name (`information_schema.columns.column_name`).
81 pub name: String,
82 /// PostgreSQL base type (`information_schema.columns.udt_name`): the
83 /// lower-case underlying type, e.g. `"uuid"`, `"int8"`, `"text"`, `"jsonb"`,
84 /// `"timestamptz"`, `"_text"` (the element form of `text[]`). Compared
85 /// against [`fraiseql_observers::migrations::ContractColumn::udt`].
86 pub udt_name: String,
87}
88
89/// Outcome of the PL/pgSQL body-resolution pass.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum PlpgsqlCheckOutcome {
92 /// `plpgsql_check` is not available on this server — the pass was skipped.
93 Unavailable,
94 /// The pass ran; `errors` is empty when every internal call resolved.
95 Ran {
96 /// Unresolved internal calls found.
97 errors: Vec<BodyError>,
98 },
99}
100
101/// Live Row-Level Security posture of `core.tb_entity_change_log` and whether the
102/// connecting role can read it under that posture (for the `fraiseql doctor`
103/// change-log RLS check, #437 F6 / #443).
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ChangeLogRlsStatus {
106 /// Whether `ENABLE ROW LEVEL SECURITY` is active on the table.
107 pub rls_enabled: bool,
108 /// Whether the connecting role bypasses RLS (`BYPASSRLS` or superuser).
109 pub can_bypass: bool,
110 /// Whether the connecting role owns the table (owners are exempt under `ENABLE`).
111 pub is_owner: bool,
112 /// The connecting role name (for the diagnostic message).
113 pub role_name: String,
114}
115
116/// PUBLIC-held privileges on one change-log relation, for the `fraiseql doctor`
117/// least-privilege check (#443).
118///
119/// Migration 12 `REVOKE ALL … FROM PUBLIC` keeps the change-log — every tenant's
120/// before/after payload — off the world-readable `PUBLIC` pseudo-role; a non-empty
121/// `privileges` means that REVOKE is not in force and the relation is
122/// world-accessible.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct PublicGrant {
125 /// Relation name (`tb_entity_change_log` / `v_entity_change_log` /
126 /// `v_entity_change_log_debezium`).
127 pub relname: String,
128 /// Privilege types currently granted to PUBLIC (e.g. `["SELECT"]`); empty when
129 /// the least-privilege baseline is intact.
130 pub privileges: Vec<String>,
131}
132
133/// Security posture of `core.fn_entity_change_log_capture()` for the
134/// `fraiseql doctor` capture-function check (#443 / #437 F6).
135///
136/// The external-write capture function must be `SECURITY DEFINER` (so it runs as
137/// the table owner, exempt under change-log RLS) **with a pinned `search_path`** (a
138/// DEFINER function with a mutable `search_path` is a privilege-escalation vector).
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct CaptureFnSecurity {
141 /// Whether the function is `SECURITY DEFINER` (`pg_proc.prosecdef`).
142 pub security_definer: bool,
143 /// The pinned `search_path` value from `pg_proc.proconfig` (the text after
144 /// `search_path=`), or `None` when no `search_path` is pinned.
145 pub search_path: Option<String>,
146}
147
148/// A live PostgreSQL connection pool for catalog introspection.
149pub struct PgCatalog {
150 pool: Pool,
151}
152
153impl PgCatalog {
154 /// Connect to `db_url` (PostgreSQL only) for catalog introspection.
155 ///
156 /// # Errors
157 ///
158 /// Returns an error if `db_url` is not a `postgres://` URL or the pool
159 /// cannot be created. (Connection failures surface lazily on first query.)
160 pub fn connect(db_url: &str) -> Result<Self> {
161 if !db_url.starts_with("postgres") {
162 anyhow::bail!(
163 "--against-db requires a PostgreSQL connection URL (postgres://…); got: {db_url}"
164 );
165 }
166 let mut cfg = Config::new();
167 cfg.url = Some(db_url.to_string());
168 cfg.manager = Some(ManagerConfig {
169 recycling_method: RecyclingMethod::Fast,
170 });
171 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
172 let pool = cfg
173 .create_pool(Some(Runtime::Tokio1), NoTls)
174 .context("failed to create PostgreSQL connection pool for --against-db")?;
175 Ok(Self { pool })
176 }
177
178 /// Resolve all overloads of a (possibly schema-qualified) function name.
179 ///
180 /// Unqualified names resolve against the connection's `search_path`
181 /// (`current_schemas(false)`), mirroring how the runtime resolves an
182 /// unqualified `sql_source`. Matching is case-sensitive — the runtime quotes
183 /// each identifier component, so `proname` is compared verbatim.
184 ///
185 /// Returns an empty vec when no function of that name is visible.
186 ///
187 /// # Errors
188 ///
189 /// Returns an error if the connection or any catalog query fails.
190 pub async fn resolve_functions(&self, sql_source: &str) -> Result<Vec<PgFunction>> {
191 // One row per overload: input-arg types/names (modes IN/INOUT/VARIADIC
192 // only — OUT/TABLE columns are excluded so the count matches what the
193 // server binds positionally) plus oid + proretset for follow-up.
194 const BASE: &str = "\
195 SELECT p.oid, n.nspname, p.proname, p.proretset, \
196 COALESCE((SELECT array_agg(format_type(t, NULL) ORDER BY ord) \
197 FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
198 WITH ORDINALITY AS a(t, ord) \
199 LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
200 WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_types, \
201 COALESCE((SELECT array_agg(COALESCE(nm, '') ORDER BY ord) \
202 FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
203 WITH ORDINALITY AS a(t, ord) \
204 LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
205 LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
206 WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_names \
207 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace ";
208
209 let client = self.pool.get().await.context("failed to acquire DB connection")?;
210 let (schema, name) = split_qualified(sql_source);
211
212 let rows = if let Some(schema) = schema {
213 client
214 .query(
215 &format!("{BASE} WHERE n.nspname = $1 AND p.proname = $2"),
216 &[&schema, &name],
217 )
218 .await
219 } else {
220 client
221 .query(
222 &format!(
223 "{BASE} WHERE p.proname = $1 AND n.nspname = ANY(current_schemas(false))"
224 ),
225 &[&name],
226 )
227 .await
228 }
229 .context("failed to query pg_proc for function resolution")?;
230
231 let mut functions = Vec::with_capacity(rows.len());
232 for row in rows {
233 let oid: u32 = row.get("oid");
234 let in_names: Vec<String> = row.get("in_names");
235 functions.push(PgFunction {
236 schema: row.get("nspname"),
237 name: row.get("proname"),
238 in_types: row.get("in_types"),
239 in_names: in_names
240 .into_iter()
241 .map(|n| if n.is_empty() { None } else { Some(n) })
242 .collect(),
243 out_columns: self.out_columns(oid).await?,
244 returns_set: row.get("proretset"),
245 });
246 }
247 Ok(functions)
248 }
249
250 /// Output columns of one function (by oid).
251 ///
252 /// Tries the `OUT`/`TABLE` parameter path first (`RETURNS TABLE(…)`); if the
253 /// function has none, falls back to the attributes of a composite return
254 /// type (`RETURNS <composite>` / `RETURNS SETOF <composite>`). A scalar or
255 /// bare-`record` return yields an empty vec.
256 async fn out_columns(&self, oid: u32) -> Result<Vec<OutColumn>> {
257 let client = self.pool.get().await.context("failed to acquire DB connection")?;
258
259 // Path 1: OUT/TABLE output parameters.
260 let table_rows = client
261 .query(
262 "SELECT COALESCE(nm, '') AS name, format_type(t, NULL) AS type_name, \
263 (tt.typtype = 'e') AS is_enum \
264 FROM pg_proc p, \
265 unnest(p.proallargtypes) WITH ORDINALITY AS a(t, ord) \
266 JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
267 LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
268 JOIN pg_type tt ON tt.oid = t \
269 WHERE p.oid = $1 AND m.mode IN ('o','b','t') ORDER BY ord",
270 &[&oid],
271 )
272 .await
273 .context("failed to query OUT/TABLE result columns")?;
274 if !table_rows.is_empty() {
275 return Ok(table_rows.iter().map(row_to_out_column).collect());
276 }
277
278 // Path 2: composite return type attributes.
279 let comp_rows = client
280 .query(
281 "SELECT att.attname AS name, \
282 format_type(att.atttypid, att.atttypmod) AS type_name, \
283 (at.typtype = 'e') AS is_enum \
284 FROM pg_proc p \
285 JOIN pg_type rt ON rt.oid = p.prorettype \
286 JOIN pg_attribute att ON att.attrelid = rt.typrelid \
287 AND att.attnum > 0 AND NOT att.attisdropped \
288 JOIN pg_type at ON at.oid = att.atttypid \
289 WHERE p.oid = $1 ORDER BY att.attnum",
290 &[&oid],
291 )
292 .await
293 .context("failed to query composite result columns")?;
294 Ok(comp_rows.iter().map(row_to_out_column).collect())
295 }
296
297 /// Whether the `plpgsql_check` extension is installable (present in
298 /// `pg_available_extensions`).
299 ///
300 /// # Errors
301 ///
302 /// Returns an error if the catalog query fails.
303 pub async fn plpgsql_check_available(&self) -> Result<bool> {
304 let client = self.pool.get().await.context("failed to acquire DB connection")?;
305 let row = client
306 .query_one(
307 "SELECT EXISTS(SELECT 1 FROM pg_available_extensions WHERE name = 'plpgsql_check')",
308 &[],
309 )
310 .await
311 .context("failed to probe for the plpgsql_check extension")?;
312 Ok(row.get(0))
313 }
314
315 /// Run the PL/pgSQL body-resolution pass over `schemas` and return every
316 /// unresolved internal call (#409).
317 ///
318 /// Skips gracefully when `plpgsql_check` is unavailable
319 /// ([`PlpgsqlCheckOutcome::Unavailable`]). Trigger functions are excluded
320 /// (they need a relation OID to analyse), and only `error`-level
321 /// `function … does not exist` diagnostics are reported — warnings and
322 /// runtime-only temp-table references are not contract breakage.
323 ///
324 /// # Errors
325 ///
326 /// Returns an error if `CREATE EXTENSION` or the analysis query fails (e.g.
327 /// insufficient privileges to create the extension).
328 pub async fn plpgsql_check_unresolved_calls(
329 &self,
330 schemas: &[String],
331 ) -> Result<PlpgsqlCheckOutcome> {
332 if !self.plpgsql_check_available().await? {
333 return Ok(PlpgsqlCheckOutcome::Unavailable);
334 }
335 let client = self.pool.get().await.context("failed to acquire DB connection")?;
336 client
337 .batch_execute("CREATE EXTENSION IF NOT EXISTS plpgsql_check")
338 .await
339 .context("failed to CREATE EXTENSION plpgsql_check")?;
340
341 let rows = client
342 .query(
343 "SELECT (p.oid::regprocedure)::text AS caller, t.lineno, t.message \
344 FROM pg_proc p \
345 JOIN pg_namespace n ON n.oid = p.pronamespace \
346 JOIN pg_language l ON l.oid = p.prolang AND l.lanname = 'plpgsql' \
347 CROSS JOIN LATERAL plpgsql_check_function_tb(p.oid, fatal_errors := false) t \
348 WHERE n.nspname = ANY($1) \
349 AND p.prorettype <> 'trigger'::regtype \
350 AND t.level = 'error' \
351 AND t.message ~* 'function .* does not exist' \
352 ORDER BY 1, 2",
353 &[&schemas],
354 )
355 .await
356 .context("failed to run the plpgsql_check body-resolution pass")?;
357
358 let errors = rows
359 .iter()
360 .map(|row| BodyError {
361 caller: row.get("caller"),
362 lineno: row.get("lineno"),
363 message: row.get("message"),
364 })
365 .collect();
366 Ok(PlpgsqlCheckOutcome::Ran { errors })
367 }
368
369 /// Read the columns of `schema.table` from `information_schema.columns`
370 /// (name + `udt_name`), in declaration order.
371 ///
372 /// Returns an **empty** vec when the table does not exist (or is invisible to
373 /// the connecting role) — the change-log contract drift check (#380) reads
374 /// that as "table absent, the migration will install it" rather than an
375 /// error.
376 ///
377 /// # Errors
378 ///
379 /// Returns an error if the connection or the catalog query fails.
380 pub async fn table_columns(&self, schema: &str, table: &str) -> Result<Vec<LiveColumn>> {
381 let client = self.pool.get().await.context("failed to acquire DB connection")?;
382 let rows = client
383 .query(
384 "SELECT column_name, udt_name FROM information_schema.columns \
385 WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position",
386 &[&schema, &table],
387 )
388 .await
389 .context("failed to query information_schema.columns")?;
390 Ok(rows
391 .iter()
392 .map(|row| LiveColumn {
393 name: row.get("column_name"),
394 udt_name: row.get("udt_name"),
395 })
396 .collect())
397 }
398
399 /// Read the live RLS posture of `core.tb_entity_change_log` and whether the
400 /// connecting role can read it under that posture (#437 F6 / #443).
401 ///
402 /// Returns `None` when the table is absent (or invisible to the connecting
403 /// role) — the doctor check reads that as "not present, skipped".
404 ///
405 /// # Errors
406 ///
407 /// Returns an error if the connection or the catalog query fails.
408 pub async fn change_log_rls_status(&self) -> Result<Option<ChangeLogRlsStatus>> {
409 let client = self.pool.get().await.context("failed to acquire DB connection")?;
410 let rows = client
411 .query(
412 "SELECT c.relrowsecurity AS rls_enabled, \
413 (r.rolbypassrls OR r.rolsuper) AS can_bypass, \
414 (pg_get_userbyid(c.relowner) = current_user) AS is_owner, \
415 current_user::text AS role_name \
416 FROM pg_class c \
417 JOIN pg_namespace n ON n.oid = c.relnamespace \
418 JOIN pg_roles r ON r.rolname = current_user \
419 WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log'",
420 &[],
421 )
422 .await
423 .context("failed to query change-log RLS status from pg_class/pg_roles")?;
424 Ok(rows.first().map(|row| ChangeLogRlsStatus {
425 rls_enabled: row.get("rls_enabled"),
426 can_bypass: row.get("can_bypass"),
427 is_owner: row.get("is_owner"),
428 role_name: row.get("role_name"),
429 }))
430 }
431
432 /// Read the privileges granted to the `PUBLIC` pseudo-role on the three
433 /// change-log relations (the table + its two views) that exist in `core`
434 /// (#443).
435 ///
436 /// Returns one [`PublicGrant`] per *present* relation, listing the privileges
437 /// PUBLIC currently holds (empty when none). An empty vec means none of the
438 /// three relations exist — the doctor check reads that as "not present,
439 /// skipped". PUBLIC grants are read from `pg_class.relacl` via `aclexplode`
440 /// (the PUBLIC grantee has OID `0`); a `NULL` ACL (the default for a fresh
441 /// relation) explodes to no rows, i.e. PUBLIC holds nothing.
442 ///
443 /// # Errors
444 ///
445 /// Returns an error if the connection or the catalog query fails.
446 pub async fn change_log_public_grants(&self) -> Result<Vec<PublicGrant>> {
447 let client = self.pool.get().await.context("failed to acquire DB connection")?;
448 let rows = client
449 .query(
450 "SELECT c.relname, \
451 COALESCE( \
452 array_agg(DISTINCT a.privilege_type) FILTER (WHERE a.grantee = 0), \
453 ARRAY[]::text[]) AS public_privs \
454 FROM pg_class c \
455 JOIN pg_namespace n ON n.oid = c.relnamespace \
456 LEFT JOIN LATERAL aclexplode(c.relacl) a ON true \
457 WHERE n.nspname = 'core' \
458 AND c.relname IN ('tb_entity_change_log', 'v_entity_change_log', \
459 'v_entity_change_log_debezium') \
460 GROUP BY c.relname \
461 ORDER BY c.relname",
462 &[],
463 )
464 .await
465 .context("failed to query change-log PUBLIC grants from pg_class.relacl")?;
466 Ok(rows
467 .iter()
468 .map(|row| PublicGrant {
469 relname: row.get("relname"),
470 privileges: row.get("public_privs"),
471 })
472 .collect())
473 }
474
475 /// Read the security posture of `core.fn_entity_change_log_capture()` (#443 /
476 /// #437 F6): whether it is `SECURITY DEFINER` (`pg_proc.prosecdef`) and the
477 /// pinned `search_path` from `pg_proc.proconfig`, if any.
478 ///
479 /// Returns `None` when the function is absent — the doctor check reads that as
480 /// "not present, skipped".
481 ///
482 /// # Errors
483 ///
484 /// Returns an error if the connection or the catalog query fails.
485 pub async fn capture_fn_security(&self) -> Result<Option<CaptureFnSecurity>> {
486 let client = self.pool.get().await.context("failed to acquire DB connection")?;
487 let rows = client
488 .query(
489 "SELECT p.prosecdef AS security_definer, \
490 (SELECT cfg FROM unnest(p.proconfig) AS cfg \
491 WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path_cfg \
492 FROM pg_proc p \
493 JOIN pg_namespace n ON n.oid = p.pronamespace \
494 WHERE n.nspname = 'core' AND p.proname = 'fn_entity_change_log_capture' \
495 LIMIT 1",
496 &[],
497 )
498 .await
499 .context("failed to query capture-function security from pg_proc")?;
500 Ok(rows.first().map(|row| {
501 let raw: Option<String> = row.get("search_path_cfg");
502 CaptureFnSecurity {
503 security_definer: row.get("security_definer"),
504 search_path: raw
505 .map(|s| s.strip_prefix("search_path=").unwrap_or(&s).to_string()),
506 }
507 }))
508 }
509}
510
511/// Build an [`OutColumn`] from a `(name, type_name, is_enum)` catalog row.
512fn row_to_out_column(row: &tokio_postgres::Row) -> OutColumn {
513 OutColumn {
514 name: row.get("name"),
515 type_name: row.get("type_name"),
516 is_enum: row.get("is_enum"),
517 }
518}
519
520/// Split a possibly schema-qualified function name into `(schema, name)`.
521///
522/// Mirrors the runtime's identifier handling: an unqualified name (no `.`)
523/// resolves via `search_path`; a `schema.fn` name pins the schema. Split on the
524/// first `.` so `schema.fn` → `(Some("schema"), "fn")`.
525fn split_qualified(sql_source: &str) -> (Option<String>, String) {
526 match sql_source.split_once('.') {
527 Some((schema, name)) => (Some(schema.to_string()), name.to_string()),
528 None => (None, sql_source.to_string()),
529 }
530}