Skip to main content

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::Pool;
21use fraiseql_db::postgres::PostgresTlsConfig;
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(…)`). A function a query reads from must.
62    pub returns_set: bool,
63    /// `pg_proc.provolatile`: `"i"` (`IMMUTABLE`), `"s"` (`STABLE`) or `"v"` (`VOLATILE`).
64    /// A function a query reads from may not be `VOLATILE`: a read may be cached,
65    /// retried or served from a replica, and none of those is safe for a function that
66    /// writes.
67    pub volatility:  String,
68    /// Implementation language (`pg_language.lanname`, e.g. `plpgsql`, `sql`,
69    /// `c`). The payload-key consumption scan (#384) only reads `plpgsql`/`sql`
70    /// bodies — for other languages `source` is a symbol, not scannable text.
71    pub language:    String,
72    /// Function body (`pg_proc.prosrc`). For `plpgsql`/`sql` functions this is
73    /// the actual source text.
74    pub source:      String,
75}
76
77/// A single unresolved internal call surfaced by `plpgsql_check` (#409).
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct BodyError {
80    /// Calling function, rendered as `schema.fn(argtypes)` (`regprocedure`).
81    pub caller:  String,
82    /// Line number within the function body, when reported.
83    pub lineno:  Option<i32>,
84    /// The `plpgsql_check` diagnostic message.
85    pub message: String,
86}
87
88/// A single live column of a table, as the change-log contract drift check
89/// (#380) reads it from `information_schema.columns`.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct LiveColumn {
92    /// Column name (`information_schema.columns.column_name`).
93    pub name:     String,
94    /// `PostgreSQL` base type (`information_schema.columns.udt_name`): the
95    /// lower-case underlying type, e.g. `"uuid"`, `"int8"`, `"text"`, `"jsonb"`,
96    /// `"timestamptz"`, `"_text"` (the element form of `text[]`). Compared
97    /// against [`fraiseql_observers::migrations::ContractColumn::udt`].
98    pub udt_name: String,
99}
100
101/// Outcome of the PL/pgSQL body-resolution pass.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum PlpgsqlCheckOutcome {
104    /// `plpgsql_check` is not available on this server — the pass was skipped.
105    Unavailable,
106    /// The pass ran; `errors` is empty when every internal call resolved.
107    Ran {
108        /// Unresolved internal calls found.
109        errors: Vec<BodyError>,
110    },
111}
112
113/// Live Row-Level Security posture of `core.tb_entity_change_log` and whether the
114/// connecting role can read it under that posture (for the `fraiseql doctor`
115/// change-log RLS check, #437 F6 / #443).
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct ChangeLogRlsStatus {
118    /// Whether `ENABLE ROW LEVEL SECURITY` is active on the table.
119    pub rls_enabled: bool,
120    /// Whether the connecting role bypasses RLS (`BYPASSRLS` or superuser).
121    pub can_bypass:  bool,
122    /// Whether the connecting role owns the table (owners are exempt under `ENABLE`).
123    pub is_owner:    bool,
124    /// The connecting role name (for the diagnostic message).
125    pub role_name:   String,
126}
127
128/// PUBLIC-held privileges on one change-log relation, for the `fraiseql doctor`
129/// least-privilege check (#443).
130///
131/// Migration 12 `REVOKE ALL … FROM PUBLIC` keeps the change-log — every tenant's
132/// before/after payload — off the world-readable `PUBLIC` pseudo-role; a non-empty
133/// `privileges` means that REVOKE is not in force and the relation is
134/// world-accessible.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct PublicGrant {
137    /// Relation name (`tb_entity_change_log` / `v_entity_change_log` /
138    /// `v_entity_change_log_debezium`).
139    pub relname:    String,
140    /// Privilege types currently granted to PUBLIC (e.g. `["SELECT"]`); empty when
141    /// the least-privilege baseline is intact.
142    pub privileges: Vec<String>,
143}
144
145/// Live actor-attribution posture of `core.tb_entity_change_log` for the
146/// `fraiseql doctor` actor check (#390).
147///
148/// The actor model stamps every mutation-runner write with a canonical
149/// [`fraiseql_core::security::ActorType`] token; migration 08's
150/// `chk_entity_change_log_actor_type` refuses anything else on new writes. This
151/// introspection reports what the *live* table actually holds, so the doctor can
152/// surface pre-constraint rogue values and unattributed rows.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct ChangeLogActorStats {
155    /// Rows whose `actor_type` is NULL — pre-actor-model rows, or writes made
156    /// without an authenticated security context.
157    pub null_rows:            i64,
158    /// Distinct non-NULL `actor_type` values outside the canonical token set —
159    /// evidence of a rogue writer (only possible before the CHECK constraint, or
160    /// while it is missing).
161    pub unknown_values:       Vec<String>,
162    /// Whether `chk_entity_change_log_actor_type` is installed on the table.
163    pub constraint_installed: bool,
164}
165
166/// Security posture of `core.fn_entity_change_log_capture()` for the
167/// `fraiseql doctor` capture-function check (#443 / #437 F6).
168///
169/// The external-write capture function must be `SECURITY DEFINER` (so it runs as
170/// the table owner, exempt under change-log RLS) **with a pinned `search_path`** (a
171/// DEFINER function with a mutable `search_path` is a privilege-escalation vector).
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct CaptureFnSecurity {
174    /// Whether the function is `SECURITY DEFINER` (`pg_proc.prosecdef`).
175    pub security_definer: bool,
176    /// The pinned `search_path` value from `pg_proc.proconfig` (the text after
177    /// `search_path=`), or `None` when no `search_path` is pinned.
178    pub search_path:      Option<String>,
179}
180
181/// Result of auditing `sql_source` views for the `security_invoker` requirement.
182#[derive(Debug)]
183pub struct SecurityInvokerAudit {
184    /// Whether any RLS policy exists in the database (RLS is in use at all).
185    pub rls_in_use:            bool,
186    /// The audited views (by bare relname) that are NOT `security_invoker`.
187    pub views_without_invoker: Vec<String>,
188}
189
190/// A row-level security policy whose expression reads a session setting.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct PolicySettingRead {
193    /// The policy's table schema.
194    pub schema:  String,
195    /// The policy's table.
196    pub table:   String,
197    /// The policy's name.
198    pub policy:  String,
199    /// The setting its `USING` or `WITH CHECK` expression reads.
200    pub setting: String,
201}
202
203/// A live `PostgreSQL` connection pool for catalog introspection.
204pub struct PgCatalog {
205    pool: Pool,
206}
207
208impl PgCatalog {
209    /// Read the catalogue through a pool the caller already connected.
210    #[must_use]
211    pub(crate) const fn from_pool(pool: Pool) -> Self {
212        Self { pool }
213    }
214
215    /// Connect to `db_url` (`PostgreSQL` only) for catalog introspection.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if `db_url` is not a PostgreSQL connection string (see
220    /// [`crate::connection::require_postgres`]), the server cannot be reached, or
221    /// it is older than PostgreSQL 18.
222    pub async fn connect(db_url: &str, tls: &PostgresTlsConfig) -> Result<Self> {
223        let pool = crate::connection::postgres_pool(db_url, "catalogue introspection", tls).await?;
224        Ok(Self { pool })
225    }
226
227    /// Whether the plan of `select` reads any index once sequential scans are priced out
228    /// (#1513): the question "is there an index that serves this", asked of the planner
229    /// rather than of index names, so an equivalent index under any name counts.
230    ///
231    /// Runs in a read-only transaction that is rolled back; `enable_seqscan = off` is
232    /// local to it.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the connection fails or `select` does not plan (a missing
237    /// relation).
238    pub async fn plan_reads_an_index(&self, select: &str) -> Result<bool> {
239        let mut client = self.pool.get().await.context("failed to acquire DB connection")?;
240        let tx = client.build_transaction().read_only(true).start().await?;
241        tx.batch_execute("SET LOCAL enable_seqscan = off").await?;
242        let row = tx.query_one(&format!("EXPLAIN (FORMAT JSON) {select}"), &[]).await?;
243        let plan: serde_json::Value = row.try_get(0)?;
244        tx.rollback().await?;
245        Ok(plan_reads_index(&plan))
246    }
247
248    /// Resolve all overloads of a (possibly schema-qualified) function name.
249    ///
250    /// Unqualified names resolve against the connection's `search_path`
251    /// (`current_schemas(false)`), mirroring how the runtime resolves an
252    /// unqualified `sql_source`. Matching is case-sensitive — the runtime quotes
253    /// each identifier component, so `proname` is compared verbatim.
254    ///
255    /// Returns an empty vec when no function of that name is visible.
256    ///
257    /// # Errors
258    ///
259    /// Returns an error if the connection or any catalog query fails.
260    pub async fn resolve_functions(&self, sql_source: &str) -> Result<Vec<PgFunction>> {
261        // One row per overload: input-arg types/names (modes IN/INOUT/VARIADIC
262        // only — OUT/TABLE columns are excluded so the count matches what the
263        // server binds positionally) plus oid + proretset for follow-up.
264        const BASE: &str = "\
265            SELECT p.oid, n.nspname, p.proname, p.proretset, p.provolatile::text AS provolatile, \
266              l.lanname, COALESCE(p.prosrc, '') AS prosrc, \
267              COALESCE((SELECT array_agg(format_type(t, NULL) ORDER BY ord) \
268                FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
269                  WITH ORDINALITY AS a(t, ord) \
270                LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
271                WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_types, \
272              COALESCE((SELECT array_agg(COALESCE(nm, '') ORDER BY ord) \
273                FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
274                  WITH ORDINALITY AS a(t, ord) \
275                LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
276                LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
277                WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_names \
278            FROM pg_proc p \
279              JOIN pg_namespace n ON n.oid = p.pronamespace \
280              JOIN pg_language l ON l.oid = p.prolang ";
281
282        let client = self.pool.get().await.context("failed to acquire DB connection")?;
283        let (schema, name) = split_qualified(sql_source);
284
285        let rows = if let Some(schema) = schema {
286            client
287                .query(
288                    &format!("{BASE} WHERE n.nspname = $1 AND p.proname = $2"),
289                    &[&schema, &name],
290                )
291                .await
292        } else {
293            client
294                .query(
295                    &format!(
296                        "{BASE} WHERE p.proname = $1 AND n.nspname = ANY(current_schemas(false))"
297                    ),
298                    &[&name],
299                )
300                .await
301        }
302        .context("failed to query pg_proc for function resolution")?;
303
304        let mut functions = Vec::with_capacity(rows.len());
305        for row in rows {
306            let oid: u32 = row.get("oid");
307            let in_names: Vec<String> = row.get("in_names");
308            functions.push(PgFunction {
309                schema:      row.get("nspname"),
310                name:        row.get("proname"),
311                in_types:    row.get("in_types"),
312                in_names:    in_names
313                    .into_iter()
314                    .map(|n| if n.is_empty() { None } else { Some(n) })
315                    .collect(),
316                out_columns: self.out_columns(oid).await?,
317                returns_set: row.get("proretset"),
318                volatility:  row.get("provolatile"),
319                language:    row.get("lanname"),
320                source:      row.get("prosrc"),
321            });
322        }
323        Ok(functions)
324    }
325
326    /// Output columns of one function (by oid).
327    ///
328    /// Tries the `OUT`/`TABLE` parameter path first (`RETURNS TABLE(…)`); if the
329    /// function has none, falls back to the attributes of a composite return
330    /// type (`RETURNS <composite>` / `RETURNS SETOF <composite>`). A scalar or
331    /// bare-`record` return yields an empty vec.
332    async fn out_columns(&self, oid: u32) -> Result<Vec<OutColumn>> {
333        let client = self.pool.get().await.context("failed to acquire DB connection")?;
334
335        // Path 1: OUT/TABLE output parameters.
336        let table_rows = client
337            .query(
338                "SELECT COALESCE(nm, '') AS name, format_type(t, NULL) AS type_name, \
339                   (tt.typtype = 'e') AS is_enum \
340                 FROM pg_proc p, \
341                   unnest(p.proallargtypes) WITH ORDINALITY AS a(t, ord) \
342                   JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
343                   LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
344                   JOIN pg_type tt ON tt.oid = t \
345                 WHERE p.oid = $1 AND m.mode IN ('o','b','t') ORDER BY ord",
346                &[&oid],
347            )
348            .await
349            .context("failed to query OUT/TABLE result columns")?;
350        if !table_rows.is_empty() {
351            return Ok(table_rows.iter().map(row_to_out_column).collect());
352        }
353
354        // Path 2: composite return type attributes.
355        let comp_rows = client
356            .query(
357                "SELECT att.attname AS name, \
358                   format_type(att.atttypid, att.atttypmod) AS type_name, \
359                   (at.typtype = 'e') AS is_enum \
360                 FROM pg_proc p \
361                   JOIN pg_type rt ON rt.oid = p.prorettype \
362                   JOIN pg_attribute att ON att.attrelid = rt.typrelid \
363                     AND att.attnum > 0 AND NOT att.attisdropped \
364                   JOIN pg_type at ON at.oid = att.atttypid \
365                 WHERE p.oid = $1 ORDER BY att.attnum",
366                &[&oid],
367            )
368            .await
369            .context("failed to query composite result columns")?;
370        Ok(comp_rows.iter().map(row_to_out_column).collect())
371    }
372
373    /// Whether the `plpgsql_check` extension is installable (present in
374    /// `pg_available_extensions`).
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if the catalog query fails.
379    pub async fn plpgsql_check_available(&self) -> Result<bool> {
380        let client = self.pool.get().await.context("failed to acquire DB connection")?;
381        let row = client
382            .query_one(
383                "SELECT EXISTS(SELECT 1 FROM pg_available_extensions WHERE name = 'plpgsql_check')",
384                &[],
385            )
386            .await
387            .context("failed to probe for the plpgsql_check extension")?;
388        Ok(row.get(0))
389    }
390
391    /// The references between TVIEWs that `sql_sources` name (#1587), from `pg_tviews`'
392    /// registry: each child TVIEW's column holding a parent TVIEW's identity, proven by a
393    /// `FOREIGN KEY` and indexed (a reference whose index is gone is left out: it would not
394    /// be served by one). Both ends are matched by relation, so a plain view over a TVIEW, or
395    /// a source naming none, takes part in none. Empty when the registry has no `references`.
396    ///
397    /// # Errors
398    ///
399    /// Returns an error if the catalog query fails.
400    pub async fn tview_references(
401        &self,
402        sql_sources: &[String],
403    ) -> Result<Vec<crate::schema::native_joins::TviewReference>> {
404        let client = self.pool.get().await.context("failed to acquire DB connection")?;
405        let has_references: bool = client
406            .query_one(
407                "SELECT EXISTS (SELECT 1 FROM pg_attribute \
408                 WHERE attrelid = to_regclass('tviews.registry') AND attname = 'references' \
409                 AND NOT attisdropped)",
410                &[],
411            )
412            .await
413            .context("failed to probe for tviews.registry.references")?
414            .get(0);
415        let mut sql_sources = sql_sources.to_vec();
416        sql_sources.sort();
417        sql_sources.dedup();
418        if !has_references || sql_sources.is_empty() {
419            return Ok(Vec::new());
420        }
421        let rows = client
422            .query(
423                &format!(
424                    "WITH s AS (SELECT name, to_regclass(name)::oid AS rel \
425                                FROM unnest($1::text[]) AS name), \
426                          tv AS (SELECT {tview}::oid AS rel, \
427                                        g.entity, g.\"references\" AS refs \
428                                 FROM tviews.registry g) \
429                 SELECT c.name AS child, p.name AS parent, ref.key AS foreign_key, \
430                        ref.value ->> 'column' AS referenced_key \
431                 FROM s c JOIN tv ct ON ct.rel = c.rel \
432                 CROSS JOIN LATERAL jsonb_each(ct.refs) ref \
433                 JOIN tv pt ON pt.entity = ref.value ->> 'entity' \
434                 JOIN s p ON p.rel = pt.rel \
435                 WHERE ref.value ->> 'index' IS NOT NULL",
436                    tview = fraiseql_db::usable_relation("g.schema", "g.name"),
437                ),
438                &[&sql_sources],
439            )
440            .await
441            .context("failed to read tviews.registry.references")?;
442        Ok(rows
443            .iter()
444            .map(|row| crate::schema::native_joins::TviewReference {
445                child:          row.get("child"),
446                parent:         row.get("parent"),
447                foreign_key:    row.get("foreign_key"),
448                referenced_key: row.get("referenced_key"),
449            })
450            .collect())
451    }
452
453    /// Whether `tviews.pg_tviews_flush_and_report(integer, boolean, boolean)`, the call a
454    /// `cascade_source = "pg_tviews"` mutation makes before its commit (#1391), resolves: a
455    /// function of that name whose first three parameters are those and whose others all
456    /// have defaults. `pg_tviews` added a defaulted fourth (`types`), so an exact signature
457    /// is not the question (#1600).
458    ///
459    /// # Errors
460    ///
461    /// Returns an error if the catalog query fails.
462    pub async fn pg_tviews_flush_available(&self) -> Result<bool> {
463        let client = self.pool.get().await.context("failed to acquire DB connection")?;
464        let row = client
465            .query_one(
466                "SELECT EXISTS (SELECT 1 FROM pg_proc p \
467                 JOIN pg_namespace n ON n.oid = p.pronamespace \
468                 WHERE n.nspname = 'tviews' AND p.proname = 'pg_tviews_flush_and_report' \
469                 AND p.pronargs >= 3 AND p.pronargs - p.pronargdefaults <= 3 \
470                 AND p.proargtypes[0] = 'integer'::regtype \
471                 AND p.proargtypes[1] = 'boolean'::regtype \
472                 AND p.proargtypes[2] = 'boolean'::regtype)",
473                &[],
474            )
475            .await
476            .context("failed to probe for pg_tviews_flush_and_report")?;
477        Ok(row.get(0))
478    }
479
480    /// Run the PL/pgSQL body-resolution pass over `schemas` and return every
481    /// unresolved internal call (#409).
482    ///
483    /// Skips gracefully when `plpgsql_check` is unavailable
484    /// ([`PlpgsqlCheckOutcome::Unavailable`]). Trigger functions are excluded
485    /// (they need a relation OID to analyse), and only `error`-level
486    /// `function … does not exist` diagnostics are reported — warnings and
487    /// runtime-only temp-table references are not contract breakage.
488    ///
489    /// # Errors
490    ///
491    /// Returns an error if `CREATE EXTENSION` or the analysis query fails (e.g.
492    /// insufficient privileges to create the extension).
493    pub async fn plpgsql_check_unresolved_calls(
494        &self,
495        schemas: &[String],
496    ) -> Result<PlpgsqlCheckOutcome> {
497        if !self.plpgsql_check_available().await? {
498            return Ok(PlpgsqlCheckOutcome::Unavailable);
499        }
500        let client = self.pool.get().await.context("failed to acquire DB connection")?;
501        client
502            .batch_execute("CREATE EXTENSION IF NOT EXISTS plpgsql_check")
503            .await
504            .context("failed to CREATE EXTENSION plpgsql_check")?;
505
506        let rows = client
507            .query(
508                "SELECT (p.oid::regprocedure)::text AS caller, t.lineno, t.message \
509                 FROM pg_proc p \
510                   JOIN pg_namespace n ON n.oid = p.pronamespace \
511                   JOIN pg_language  l ON l.oid = p.prolang AND l.lanname = 'plpgsql' \
512                   CROSS JOIN LATERAL plpgsql_check_function_tb(p.oid, fatal_errors := false) t \
513                 WHERE n.nspname = ANY($1) \
514                   AND p.prorettype <> 'trigger'::regtype \
515                   AND t.level = 'error' \
516                   AND t.message ~* 'function .* does not exist' \
517                 ORDER BY 1, 2",
518                &[&schemas],
519            )
520            .await
521            .context("failed to run the plpgsql_check body-resolution pass")?;
522
523        let errors = rows
524            .iter()
525            .map(|row| BodyError {
526                caller:  row.get("caller"),
527                lineno:  row.get("lineno"),
528                message: row.get("message"),
529            })
530            .collect();
531        Ok(PlpgsqlCheckOutcome::Ran { errors })
532    }
533
534    /// Read the columns of `schema.table` from `information_schema.columns`
535    /// (name + `udt_name`), in declaration order.
536    ///
537    /// Returns an **empty** vec when the table does not exist (or is invisible to
538    /// the connecting role) — the change-log contract drift check (#380) reads
539    /// that as "table absent, the migration will install it" rather than an
540    /// error.
541    ///
542    /// # Errors
543    ///
544    /// Returns an error if the connection or the catalog query fails.
545    pub async fn table_columns(&self, schema: &str, table: &str) -> Result<Vec<LiveColumn>> {
546        let client = self.pool.get().await.context("failed to acquire DB connection")?;
547        let rows = client
548            .query(
549                "SELECT column_name, udt_name FROM information_schema.columns \
550                 WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position",
551                &[&schema, &table],
552            )
553            .await
554            .context("failed to query information_schema.columns")?;
555        Ok(rows
556            .iter()
557            .map(|row| LiveColumn {
558                name:     row.get("column_name"),
559                udt_name: row.get("udt_name"),
560            })
561            .collect())
562    }
563
564    /// Read the live RLS posture of `core.tb_entity_change_log` and whether the
565    /// connecting role can read it under that posture (#437 F6 / #443).
566    ///
567    /// Returns `None` when the table is absent (or invisible to the connecting
568    /// role) — the doctor check reads that as "not present, skipped".
569    ///
570    /// # Errors
571    ///
572    /// Returns an error if the connection or the catalog query fails.
573    pub async fn change_log_rls_status(&self) -> Result<Option<ChangeLogRlsStatus>> {
574        let client = self.pool.get().await.context("failed to acquire DB connection")?;
575        let rows = client
576            .query(
577                "SELECT c.relrowsecurity AS rls_enabled, \
578                        (r.rolbypassrls OR r.rolsuper) AS can_bypass, \
579                        (pg_get_userbyid(c.relowner) = current_user) AS is_owner, \
580                        current_user::text AS role_name \
581                 FROM pg_class c \
582                   JOIN pg_namespace n ON n.oid = c.relnamespace \
583                   JOIN pg_roles r ON r.rolname = current_user \
584                 WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log'",
585                &[],
586            )
587            .await
588            .context("failed to query change-log RLS status from pg_class/pg_roles")?;
589        Ok(rows.first().map(|row| ChangeLogRlsStatus {
590            rls_enabled: row.get("rls_enabled"),
591            can_bypass:  row.get("can_bypass"),
592            is_owner:    row.get("is_owner"),
593            role_name:   row.get("role_name"),
594        }))
595    }
596
597    /// The row-level security policies whose `USING` or `WITH CHECK` expression calls
598    /// `current_setting` on one of `settings`, one entry per (policy, setting).
599    ///
600    /// Read from the expression text `pg_policies` deparses (`current_setting('app.x'::text,
601    /// true)`), case-insensitively. A policy that reads the setting through a function it
602    /// calls is not seen.
603    ///
604    /// # Errors
605    ///
606    /// Returns an error if the connection or the catalog query fails.
607    pub async fn policies_reading_settings(
608        &self,
609        settings: &[String],
610    ) -> Result<Vec<PolicySettingRead>> {
611        let client = self.pool.get().await.context("failed to acquire DB connection")?;
612        let rows = client
613            .query(
614                "SELECT p.schemaname::text AS schema_name, p.tablename::text AS table_name, \
615                        p.policyname::text AS policy_name, s.name AS setting \
616                 FROM pg_policies p CROSS JOIN unnest($1::text[]) AS s(name) \
617                 WHERE strpos(lower(coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')), \
618                              'current_setting(''' || lower(s.name) || '''') > 0 \
619                 ORDER BY 1, 2, 3, 4",
620                &[&settings],
621            )
622            .await
623            .context("failed to read policy expressions from pg_policies")?;
624        Ok(rows
625            .iter()
626            .map(|r| PolicySettingRead {
627                schema:  r.get("schema_name"),
628                table:   r.get("table_name"),
629                policy:  r.get("policy_name"),
630                setting: r.get("setting"),
631            })
632            .collect())
633    }
634
635    /// Audit the given `sql_source` views (bare relnames) for `security_invoker`.
636    ///
637    /// A *default* view runs with the view owner's privileges and bypasses the
638    /// caller's RLS; only a `security_invoker` view honours base-table RLS
639    /// — the requirement the cascade RLS boundary (and the query path) rely on.
640    /// Returns which audited views lack the option, plus whether any RLS policy
641    /// exists at all (the requirement only bites under RLS).
642    ///
643    /// # Errors
644    ///
645    /// Returns an error if the connection or a catalog query fails.
646    pub async fn security_invoker_audit(&self, views: &[String]) -> Result<SecurityInvokerAudit> {
647        let client = self.pool.get().await.context("failed to acquire DB connection")?;
648        let rls_in_use: bool = client
649            .query_one("SELECT EXISTS (SELECT 1 FROM pg_policies) AS in_use", &[])
650            .await
651            .context("failed to query pg_policies for RLS usage")?
652            .get("in_use");
653        let rows = client
654            .query(
655                "SELECT c.relname \
656                 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
657                 WHERE c.relkind = 'v' AND c.relname = ANY($1) \
658                   AND NOT EXISTS ( \
659                     SELECT 1 FROM pg_options_to_table(c.reloptions) o \
660                     WHERE o.option_name = 'security_invoker' \
661                       AND lower(o.option_value) IN ('true', 'on', '1'))",
662                &[&views],
663            )
664            .await
665            .context("failed to audit view security_invoker options from pg_class")?;
666        Ok(SecurityInvokerAudit {
667            rls_in_use,
668            views_without_invoker: rows.iter().map(|r| r.get::<_, String>("relname")).collect(),
669        })
670    }
671
672    /// Read the privileges granted to the `PUBLIC` pseudo-role on the three
673    /// change-log relations (the table + its two views) that exist in `core`
674    /// (#443).
675    ///
676    /// Returns one [`PublicGrant`] per *present* relation, listing the privileges
677    /// PUBLIC currently holds (empty when none). An empty vec means none of the
678    /// three relations exist — the doctor check reads that as "not present,
679    /// skipped". PUBLIC grants are read from `pg_class.relacl` via `aclexplode`
680    /// (the PUBLIC grantee has OID `0`); a `NULL` ACL (the default for a fresh
681    /// relation) explodes to no rows, i.e. PUBLIC holds nothing.
682    ///
683    /// # Errors
684    ///
685    /// Returns an error if the connection or the catalog query fails.
686    pub async fn change_log_public_grants(&self) -> Result<Vec<PublicGrant>> {
687        let client = self.pool.get().await.context("failed to acquire DB connection")?;
688        let rows = client
689            .query(
690                "SELECT c.relname, \
691                        COALESCE( \
692                          array_agg(DISTINCT a.privilege_type) FILTER (WHERE a.grantee = 0), \
693                          ARRAY[]::text[]) AS public_privs \
694                 FROM pg_class c \
695                   JOIN pg_namespace n ON n.oid = c.relnamespace \
696                   LEFT JOIN LATERAL aclexplode(c.relacl) a ON true \
697                 WHERE n.nspname = 'core' \
698                   AND c.relname IN ('tb_entity_change_log', 'v_entity_change_log', \
699                                     'v_entity_change_log_debezium') \
700                 GROUP BY c.relname \
701                 ORDER BY c.relname",
702                &[],
703            )
704            .await
705            .context("failed to query change-log PUBLIC grants from pg_class.relacl")?;
706        Ok(rows
707            .iter()
708            .map(|row| PublicGrant {
709                relname:    row.get("relname"),
710                privileges: row.get("public_privs"),
711            })
712            .collect())
713    }
714
715    /// Read the live actor-attribution posture of `core.tb_entity_change_log`
716    /// (#390): how many rows carry no `actor_type`, which out-of-contract values
717    /// exist, and whether the domain CHECK constraint is installed.
718    ///
719    /// `valid_tokens` is the canonical roster (built from
720    /// [`fraiseql_core::security::ActorType::ALL`] by the caller). Returns `None`
721    /// when the table is absent — the doctor check reads that as "not present,
722    /// skipped".
723    ///
724    /// # Errors
725    ///
726    /// Returns an error if the connection or a catalog query fails.
727    pub async fn change_log_actor_stats(
728        &self,
729        valid_tokens: &[String],
730    ) -> Result<Option<ChangeLogActorStats>> {
731        let client = self.pool.get().await.context("failed to acquire DB connection")?;
732        let table_exists: bool = client
733            .query_one(
734                "SELECT EXISTS (SELECT 1 FROM pg_class c \
735                   JOIN pg_namespace n ON n.oid = c.relnamespace \
736                 WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log') AS present",
737                &[],
738            )
739            .await
740            .context("failed to check for core.tb_entity_change_log in pg_class")?
741            .get("present");
742        if !table_exists {
743            return Ok(None);
744        }
745
746        let row = client
747            .query_one(
748                "SELECT \
749                   (SELECT count(*) FROM core.tb_entity_change_log \
750                     WHERE actor_type IS NULL) AS null_rows, \
751                   (SELECT COALESCE(array_agg(DISTINCT actor_type ORDER BY actor_type), \
752                                    ARRAY[]::text[]) \
753                      FROM core.tb_entity_change_log \
754                     WHERE actor_type IS NOT NULL AND actor_type <> ALL($1)) AS unknown_values, \
755                   EXISTS (SELECT 1 FROM pg_constraint con \
756                             JOIN pg_class c ON c.oid = con.conrelid \
757                             JOIN pg_namespace n ON n.oid = c.relnamespace \
758                           WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log' \
759                             AND con.conname = 'chk_entity_change_log_actor_type') \
760                     AS constraint_installed",
761                &[&valid_tokens],
762            )
763            .await
764            .context("failed to query change-log actor attribution stats")?;
765        Ok(Some(ChangeLogActorStats {
766            null_rows:            row.get("null_rows"),
767            unknown_values:       row.get("unknown_values"),
768            constraint_installed: row.get("constraint_installed"),
769        }))
770    }
771
772    /// Read the security posture of `core.fn_entity_change_log_capture()` (#443 /
773    /// #437 F6): whether it is `SECURITY DEFINER` (`pg_proc.prosecdef`) and the
774    /// pinned `search_path` from `pg_proc.proconfig`, if any.
775    ///
776    /// Returns `None` when the function is absent — the doctor check reads that as
777    /// "not present, skipped".
778    ///
779    /// # Errors
780    ///
781    /// Returns an error if the connection or the catalog query fails.
782    pub async fn capture_fn_security(&self) -> Result<Option<CaptureFnSecurity>> {
783        let client = self.pool.get().await.context("failed to acquire DB connection")?;
784        let rows = client
785            .query(
786                "SELECT p.prosecdef AS security_definer, \
787                        (SELECT cfg FROM unnest(p.proconfig) AS cfg \
788                          WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path_cfg \
789                 FROM pg_proc p \
790                   JOIN pg_namespace n ON n.oid = p.pronamespace \
791                 WHERE n.nspname = 'core' AND p.proname = 'fn_entity_change_log_capture' \
792                 LIMIT 1",
793                &[],
794            )
795            .await
796            .context("failed to query capture-function security from pg_proc")?;
797        Ok(rows.first().map(|row| {
798            let raw: Option<String> = row.get("search_path_cfg");
799            CaptureFnSecurity {
800                security_definer: row.get("security_definer"),
801                search_path:      raw
802                    .map(|s| s.strip_prefix("search_path=").unwrap_or(&s).to_string()),
803            }
804        }))
805    }
806}
807
808/// Build an [`OutColumn`] from a `(name, type_name, is_enum)` catalog row.
809fn row_to_out_column(row: &tokio_postgres::Row) -> OutColumn {
810    OutColumn {
811        name:      row.get("name"),
812        type_name: row.get("type_name"),
813        is_enum:   row.get("is_enum"),
814    }
815}
816
817/// Split a possibly schema-qualified function name into `(schema, name)`.
818///
819/// Mirrors the runtime's identifier handling: an unqualified name (no `.`)
820/// resolves via `search_path`; a `schema.fn` name pins the schema. Split on the
821/// first `.` so `schema.fn` → `(Some("schema"), "fn")`.
822fn split_qualified(sql_source: &str) -> (Option<String>, String) {
823    match sql_source.split_once('.') {
824        Some((schema, name)) => (Some(schema.to_string()), name.to_string()),
825        None => (None, sql_source.to_string()),
826    }
827}
828
829/// Whether any node of an `EXPLAIN (FORMAT JSON)` plan reads an index.
830fn plan_reads_index(plan: &serde_json::Value) -> bool {
831    match plan {
832        serde_json::Value::Object(node) => {
833            node.contains_key("Index Name") || node.values().any(plan_reads_index)
834        },
835        serde_json::Value::Array(items) => items.iter().any(plan_reads_index),
836        _ => false,
837    }
838}