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(…)`). Informational only.
62    pub returns_set: bool,
63    /// Implementation language (`pg_language.lanname`, e.g. `plpgsql`, `sql`,
64    /// `c`). The payload-key consumption scan (#384) only reads `plpgsql`/`sql`
65    /// bodies — for other languages `source` is a symbol, not scannable text.
66    pub language:    String,
67    /// Function body (`pg_proc.prosrc`). For `plpgsql`/`sql` functions this is
68    /// the actual source text.
69    pub source:      String,
70}
71
72/// A single unresolved internal call surfaced by `plpgsql_check` (#409).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct BodyError {
75    /// Calling function, rendered as `schema.fn(argtypes)` (`regprocedure`).
76    pub caller:  String,
77    /// Line number within the function body, when reported.
78    pub lineno:  Option<i32>,
79    /// The `plpgsql_check` diagnostic message.
80    pub message: String,
81}
82
83/// A single live column of a table, as the change-log contract drift check
84/// (#380) reads it from `information_schema.columns`.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct LiveColumn {
87    /// Column name (`information_schema.columns.column_name`).
88    pub name:     String,
89    /// `PostgreSQL` base type (`information_schema.columns.udt_name`): the
90    /// lower-case underlying type, e.g. `"uuid"`, `"int8"`, `"text"`, `"jsonb"`,
91    /// `"timestamptz"`, `"_text"` (the element form of `text[]`). Compared
92    /// against [`fraiseql_observers::migrations::ContractColumn::udt`].
93    pub udt_name: String,
94}
95
96/// Outcome of the PL/pgSQL body-resolution pass.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum PlpgsqlCheckOutcome {
99    /// `plpgsql_check` is not available on this server — the pass was skipped.
100    Unavailable,
101    /// The pass ran; `errors` is empty when every internal call resolved.
102    Ran {
103        /// Unresolved internal calls found.
104        errors: Vec<BodyError>,
105    },
106}
107
108/// Live Row-Level Security posture of `core.tb_entity_change_log` and whether the
109/// connecting role can read it under that posture (for the `fraiseql doctor`
110/// change-log RLS check, #437 F6 / #443).
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ChangeLogRlsStatus {
113    /// Whether `ENABLE ROW LEVEL SECURITY` is active on the table.
114    pub rls_enabled: bool,
115    /// Whether the connecting role bypasses RLS (`BYPASSRLS` or superuser).
116    pub can_bypass:  bool,
117    /// Whether the connecting role owns the table (owners are exempt under `ENABLE`).
118    pub is_owner:    bool,
119    /// The connecting role name (for the diagnostic message).
120    pub role_name:   String,
121}
122
123/// PUBLIC-held privileges on one change-log relation, for the `fraiseql doctor`
124/// least-privilege check (#443).
125///
126/// Migration 12 `REVOKE ALL … FROM PUBLIC` keeps the change-log — every tenant's
127/// before/after payload — off the world-readable `PUBLIC` pseudo-role; a non-empty
128/// `privileges` means that REVOKE is not in force and the relation is
129/// world-accessible.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct PublicGrant {
132    /// Relation name (`tb_entity_change_log` / `v_entity_change_log` /
133    /// `v_entity_change_log_debezium`).
134    pub relname:    String,
135    /// Privilege types currently granted to PUBLIC (e.g. `["SELECT"]`); empty when
136    /// the least-privilege baseline is intact.
137    pub privileges: Vec<String>,
138}
139
140/// Live actor-attribution posture of `core.tb_entity_change_log` for the
141/// `fraiseql doctor` actor check (#390).
142///
143/// The actor model stamps every mutation-runner write with a canonical
144/// [`fraiseql_core::security::ActorType`] token; migration 08's
145/// `chk_entity_change_log_actor_type` refuses anything else on new writes. This
146/// introspection reports what the *live* table actually holds, so the doctor can
147/// surface pre-constraint rogue values and unattributed rows.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct ChangeLogActorStats {
150    /// Rows whose `actor_type` is NULL — pre-actor-model rows, or writes made
151    /// without an authenticated security context.
152    pub null_rows:            i64,
153    /// Distinct non-NULL `actor_type` values outside the canonical token set —
154    /// evidence of a rogue writer (only possible before the CHECK constraint, or
155    /// while it is missing).
156    pub unknown_values:       Vec<String>,
157    /// Whether `chk_entity_change_log_actor_type` is installed on the table.
158    pub constraint_installed: bool,
159}
160
161/// Security posture of `core.fn_entity_change_log_capture()` for the
162/// `fraiseql doctor` capture-function check (#443 / #437 F6).
163///
164/// The external-write capture function must be `SECURITY DEFINER` (so it runs as
165/// the table owner, exempt under change-log RLS) **with a pinned `search_path`** (a
166/// DEFINER function with a mutable `search_path` is a privilege-escalation vector).
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct CaptureFnSecurity {
169    /// Whether the function is `SECURITY DEFINER` (`pg_proc.prosecdef`).
170    pub security_definer: bool,
171    /// The pinned `search_path` value from `pg_proc.proconfig` (the text after
172    /// `search_path=`), or `None` when no `search_path` is pinned.
173    pub search_path:      Option<String>,
174}
175
176/// Result of auditing `sql_source` views for the `security_invoker` requirement.
177#[derive(Debug)]
178pub struct SecurityInvokerAudit {
179    /// Whether any RLS policy exists in the database (RLS is in use at all).
180    pub rls_in_use:            bool,
181    /// The audited views (by bare relname) that are NOT `security_invoker`.
182    pub views_without_invoker: Vec<String>,
183}
184
185/// A live `PostgreSQL` connection pool for catalog introspection.
186pub struct PgCatalog {
187    pool: Pool,
188}
189
190impl PgCatalog {
191    /// Read the catalogue through a pool the caller already connected.
192    #[must_use]
193    pub(crate) const fn from_pool(pool: Pool) -> Self {
194        Self { pool }
195    }
196
197    /// Connect to `db_url` (`PostgreSQL` only) for catalog introspection.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if `db_url` is not a PostgreSQL connection string (see
202    /// [`crate::connection::require_postgres`]), the server cannot be reached, or
203    /// it is older than PostgreSQL 18.
204    pub async fn connect(db_url: &str, tls: &PostgresTlsConfig) -> Result<Self> {
205        let pool = crate::connection::postgres_pool(db_url, "catalogue introspection", tls).await?;
206        Ok(Self { pool })
207    }
208
209    /// Resolve all overloads of a (possibly schema-qualified) function name.
210    ///
211    /// Unqualified names resolve against the connection's `search_path`
212    /// (`current_schemas(false)`), mirroring how the runtime resolves an
213    /// unqualified `sql_source`. Matching is case-sensitive — the runtime quotes
214    /// each identifier component, so `proname` is compared verbatim.
215    ///
216    /// Returns an empty vec when no function of that name is visible.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the connection or any catalog query fails.
221    pub async fn resolve_functions(&self, sql_source: &str) -> Result<Vec<PgFunction>> {
222        // One row per overload: input-arg types/names (modes IN/INOUT/VARIADIC
223        // only — OUT/TABLE columns are excluded so the count matches what the
224        // server binds positionally) plus oid + proretset for follow-up.
225        const BASE: &str = "\
226            SELECT p.oid, n.nspname, p.proname, p.proretset, \
227              l.lanname, COALESCE(p.prosrc, '') AS prosrc, \
228              COALESCE((SELECT array_agg(format_type(t, NULL) ORDER BY ord) \
229                FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
230                  WITH ORDINALITY AS a(t, ord) \
231                LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
232                WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_types, \
233              COALESCE((SELECT array_agg(COALESCE(nm, '') ORDER BY ord) \
234                FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
235                  WITH ORDINALITY AS a(t, ord) \
236                LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
237                LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
238                WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_names \
239            FROM pg_proc p \
240              JOIN pg_namespace n ON n.oid = p.pronamespace \
241              JOIN pg_language l ON l.oid = p.prolang ";
242
243        let client = self.pool.get().await.context("failed to acquire DB connection")?;
244        let (schema, name) = split_qualified(sql_source);
245
246        let rows = if let Some(schema) = schema {
247            client
248                .query(
249                    &format!("{BASE} WHERE n.nspname = $1 AND p.proname = $2"),
250                    &[&schema, &name],
251                )
252                .await
253        } else {
254            client
255                .query(
256                    &format!(
257                        "{BASE} WHERE p.proname = $1 AND n.nspname = ANY(current_schemas(false))"
258                    ),
259                    &[&name],
260                )
261                .await
262        }
263        .context("failed to query pg_proc for function resolution")?;
264
265        let mut functions = Vec::with_capacity(rows.len());
266        for row in rows {
267            let oid: u32 = row.get("oid");
268            let in_names: Vec<String> = row.get("in_names");
269            functions.push(PgFunction {
270                schema:      row.get("nspname"),
271                name:        row.get("proname"),
272                in_types:    row.get("in_types"),
273                in_names:    in_names
274                    .into_iter()
275                    .map(|n| if n.is_empty() { None } else { Some(n) })
276                    .collect(),
277                out_columns: self.out_columns(oid).await?,
278                returns_set: row.get("proretset"),
279                language:    row.get("lanname"),
280                source:      row.get("prosrc"),
281            });
282        }
283        Ok(functions)
284    }
285
286    /// Output columns of one function (by oid).
287    ///
288    /// Tries the `OUT`/`TABLE` parameter path first (`RETURNS TABLE(…)`); if the
289    /// function has none, falls back to the attributes of a composite return
290    /// type (`RETURNS <composite>` / `RETURNS SETOF <composite>`). A scalar or
291    /// bare-`record` return yields an empty vec.
292    async fn out_columns(&self, oid: u32) -> Result<Vec<OutColumn>> {
293        let client = self.pool.get().await.context("failed to acquire DB connection")?;
294
295        // Path 1: OUT/TABLE output parameters.
296        let table_rows = client
297            .query(
298                "SELECT COALESCE(nm, '') AS name, format_type(t, NULL) AS type_name, \
299                   (tt.typtype = 'e') AS is_enum \
300                 FROM pg_proc p, \
301                   unnest(p.proallargtypes) WITH ORDINALITY AS a(t, ord) \
302                   JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
303                   LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
304                   JOIN pg_type tt ON tt.oid = t \
305                 WHERE p.oid = $1 AND m.mode IN ('o','b','t') ORDER BY ord",
306                &[&oid],
307            )
308            .await
309            .context("failed to query OUT/TABLE result columns")?;
310        if !table_rows.is_empty() {
311            return Ok(table_rows.iter().map(row_to_out_column).collect());
312        }
313
314        // Path 2: composite return type attributes.
315        let comp_rows = client
316            .query(
317                "SELECT att.attname AS name, \
318                   format_type(att.atttypid, att.atttypmod) AS type_name, \
319                   (at.typtype = 'e') AS is_enum \
320                 FROM pg_proc p \
321                   JOIN pg_type rt ON rt.oid = p.prorettype \
322                   JOIN pg_attribute att ON att.attrelid = rt.typrelid \
323                     AND att.attnum > 0 AND NOT att.attisdropped \
324                   JOIN pg_type at ON at.oid = att.atttypid \
325                 WHERE p.oid = $1 ORDER BY att.attnum",
326                &[&oid],
327            )
328            .await
329            .context("failed to query composite result columns")?;
330        Ok(comp_rows.iter().map(row_to_out_column).collect())
331    }
332
333    /// Whether the `plpgsql_check` extension is installable (present in
334    /// `pg_available_extensions`).
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if the catalog query fails.
339    pub async fn plpgsql_check_available(&self) -> Result<bool> {
340        let client = self.pool.get().await.context("failed to acquire DB connection")?;
341        let row = client
342            .query_one(
343                "SELECT EXISTS(SELECT 1 FROM pg_available_extensions WHERE name = 'plpgsql_check')",
344                &[],
345            )
346            .await
347            .context("failed to probe for the plpgsql_check extension")?;
348        Ok(row.get(0))
349    }
350
351    /// Run the PL/pgSQL body-resolution pass over `schemas` and return every
352    /// unresolved internal call (#409).
353    ///
354    /// Skips gracefully when `plpgsql_check` is unavailable
355    /// ([`PlpgsqlCheckOutcome::Unavailable`]). Trigger functions are excluded
356    /// (they need a relation OID to analyse), and only `error`-level
357    /// `function … does not exist` diagnostics are reported — warnings and
358    /// runtime-only temp-table references are not contract breakage.
359    ///
360    /// # Errors
361    ///
362    /// Returns an error if `CREATE EXTENSION` or the analysis query fails (e.g.
363    /// insufficient privileges to create the extension).
364    pub async fn plpgsql_check_unresolved_calls(
365        &self,
366        schemas: &[String],
367    ) -> Result<PlpgsqlCheckOutcome> {
368        if !self.plpgsql_check_available().await? {
369            return Ok(PlpgsqlCheckOutcome::Unavailable);
370        }
371        let client = self.pool.get().await.context("failed to acquire DB connection")?;
372        client
373            .batch_execute("CREATE EXTENSION IF NOT EXISTS plpgsql_check")
374            .await
375            .context("failed to CREATE EXTENSION plpgsql_check")?;
376
377        let rows = client
378            .query(
379                "SELECT (p.oid::regprocedure)::text AS caller, t.lineno, t.message \
380                 FROM pg_proc p \
381                   JOIN pg_namespace n ON n.oid = p.pronamespace \
382                   JOIN pg_language  l ON l.oid = p.prolang AND l.lanname = 'plpgsql' \
383                   CROSS JOIN LATERAL plpgsql_check_function_tb(p.oid, fatal_errors := false) t \
384                 WHERE n.nspname = ANY($1) \
385                   AND p.prorettype <> 'trigger'::regtype \
386                   AND t.level = 'error' \
387                   AND t.message ~* 'function .* does not exist' \
388                 ORDER BY 1, 2",
389                &[&schemas],
390            )
391            .await
392            .context("failed to run the plpgsql_check body-resolution pass")?;
393
394        let errors = rows
395            .iter()
396            .map(|row| BodyError {
397                caller:  row.get("caller"),
398                lineno:  row.get("lineno"),
399                message: row.get("message"),
400            })
401            .collect();
402        Ok(PlpgsqlCheckOutcome::Ran { errors })
403    }
404
405    /// Read the columns of `schema.table` from `information_schema.columns`
406    /// (name + `udt_name`), in declaration order.
407    ///
408    /// Returns an **empty** vec when the table does not exist (or is invisible to
409    /// the connecting role) — the change-log contract drift check (#380) reads
410    /// that as "table absent, the migration will install it" rather than an
411    /// error.
412    ///
413    /// # Errors
414    ///
415    /// Returns an error if the connection or the catalog query fails.
416    pub async fn table_columns(&self, schema: &str, table: &str) -> Result<Vec<LiveColumn>> {
417        let client = self.pool.get().await.context("failed to acquire DB connection")?;
418        let rows = client
419            .query(
420                "SELECT column_name, udt_name FROM information_schema.columns \
421                 WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position",
422                &[&schema, &table],
423            )
424            .await
425            .context("failed to query information_schema.columns")?;
426        Ok(rows
427            .iter()
428            .map(|row| LiveColumn {
429                name:     row.get("column_name"),
430                udt_name: row.get("udt_name"),
431            })
432            .collect())
433    }
434
435    /// Read the live RLS posture of `core.tb_entity_change_log` and whether the
436    /// connecting role can read it under that posture (#437 F6 / #443).
437    ///
438    /// Returns `None` when the table is absent (or invisible to the connecting
439    /// role) — the doctor check reads that as "not present, skipped".
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if the connection or the catalog query fails.
444    pub async fn change_log_rls_status(&self) -> Result<Option<ChangeLogRlsStatus>> {
445        let client = self.pool.get().await.context("failed to acquire DB connection")?;
446        let rows = client
447            .query(
448                "SELECT c.relrowsecurity AS rls_enabled, \
449                        (r.rolbypassrls OR r.rolsuper) AS can_bypass, \
450                        (pg_get_userbyid(c.relowner) = current_user) AS is_owner, \
451                        current_user::text AS role_name \
452                 FROM pg_class c \
453                   JOIN pg_namespace n ON n.oid = c.relnamespace \
454                   JOIN pg_roles r ON r.rolname = current_user \
455                 WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log'",
456                &[],
457            )
458            .await
459            .context("failed to query change-log RLS status from pg_class/pg_roles")?;
460        Ok(rows.first().map(|row| ChangeLogRlsStatus {
461            rls_enabled: row.get("rls_enabled"),
462            can_bypass:  row.get("can_bypass"),
463            is_owner:    row.get("is_owner"),
464            role_name:   row.get("role_name"),
465        }))
466    }
467
468    /// Audit the given `sql_source` views (bare relnames) for `security_invoker`.
469    ///
470    /// A *default* view runs with the view owner's privileges and bypasses the
471    /// caller's RLS; only a `security_invoker` view honours base-table RLS
472    /// — the requirement the cascade RLS boundary (and the query path) rely on.
473    /// Returns which audited views lack the option, plus whether any RLS policy
474    /// exists at all (the requirement only bites under RLS).
475    ///
476    /// # Errors
477    ///
478    /// Returns an error if the connection or a catalog query fails.
479    pub async fn security_invoker_audit(&self, views: &[String]) -> Result<SecurityInvokerAudit> {
480        let client = self.pool.get().await.context("failed to acquire DB connection")?;
481        let rls_in_use: bool = client
482            .query_one("SELECT EXISTS (SELECT 1 FROM pg_policies) AS in_use", &[])
483            .await
484            .context("failed to query pg_policies for RLS usage")?
485            .get("in_use");
486        let rows = client
487            .query(
488                "SELECT c.relname \
489                 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
490                 WHERE c.relkind = 'v' AND c.relname = ANY($1) \
491                   AND NOT EXISTS ( \
492                     SELECT 1 FROM pg_options_to_table(c.reloptions) o \
493                     WHERE o.option_name = 'security_invoker' \
494                       AND lower(o.option_value) IN ('true', 'on', '1'))",
495                &[&views],
496            )
497            .await
498            .context("failed to audit view security_invoker options from pg_class")?;
499        Ok(SecurityInvokerAudit {
500            rls_in_use,
501            views_without_invoker: rows.iter().map(|r| r.get::<_, String>("relname")).collect(),
502        })
503    }
504
505    /// Read the privileges granted to the `PUBLIC` pseudo-role on the three
506    /// change-log relations (the table + its two views) that exist in `core`
507    /// (#443).
508    ///
509    /// Returns one [`PublicGrant`] per *present* relation, listing the privileges
510    /// PUBLIC currently holds (empty when none). An empty vec means none of the
511    /// three relations exist — the doctor check reads that as "not present,
512    /// skipped". PUBLIC grants are read from `pg_class.relacl` via `aclexplode`
513    /// (the PUBLIC grantee has OID `0`); a `NULL` ACL (the default for a fresh
514    /// relation) explodes to no rows, i.e. PUBLIC holds nothing.
515    ///
516    /// # Errors
517    ///
518    /// Returns an error if the connection or the catalog query fails.
519    pub async fn change_log_public_grants(&self) -> Result<Vec<PublicGrant>> {
520        let client = self.pool.get().await.context("failed to acquire DB connection")?;
521        let rows = client
522            .query(
523                "SELECT c.relname, \
524                        COALESCE( \
525                          array_agg(DISTINCT a.privilege_type) FILTER (WHERE a.grantee = 0), \
526                          ARRAY[]::text[]) AS public_privs \
527                 FROM pg_class c \
528                   JOIN pg_namespace n ON n.oid = c.relnamespace \
529                   LEFT JOIN LATERAL aclexplode(c.relacl) a ON true \
530                 WHERE n.nspname = 'core' \
531                   AND c.relname IN ('tb_entity_change_log', 'v_entity_change_log', \
532                                     'v_entity_change_log_debezium') \
533                 GROUP BY c.relname \
534                 ORDER BY c.relname",
535                &[],
536            )
537            .await
538            .context("failed to query change-log PUBLIC grants from pg_class.relacl")?;
539        Ok(rows
540            .iter()
541            .map(|row| PublicGrant {
542                relname:    row.get("relname"),
543                privileges: row.get("public_privs"),
544            })
545            .collect())
546    }
547
548    /// Read the live actor-attribution posture of `core.tb_entity_change_log`
549    /// (#390): how many rows carry no `actor_type`, which out-of-contract values
550    /// exist, and whether the domain CHECK constraint is installed.
551    ///
552    /// `valid_tokens` is the canonical roster (built from
553    /// [`fraiseql_core::security::ActorType::ALL`] by the caller). Returns `None`
554    /// when the table is absent — the doctor check reads that as "not present,
555    /// skipped".
556    ///
557    /// # Errors
558    ///
559    /// Returns an error if the connection or a catalog query fails.
560    pub async fn change_log_actor_stats(
561        &self,
562        valid_tokens: &[String],
563    ) -> Result<Option<ChangeLogActorStats>> {
564        let client = self.pool.get().await.context("failed to acquire DB connection")?;
565        let table_exists: bool = client
566            .query_one(
567                "SELECT EXISTS (SELECT 1 FROM pg_class c \
568                   JOIN pg_namespace n ON n.oid = c.relnamespace \
569                 WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log') AS present",
570                &[],
571            )
572            .await
573            .context("failed to check for core.tb_entity_change_log in pg_class")?
574            .get("present");
575        if !table_exists {
576            return Ok(None);
577        }
578
579        let row = client
580            .query_one(
581                "SELECT \
582                   (SELECT count(*) FROM core.tb_entity_change_log \
583                     WHERE actor_type IS NULL) AS null_rows, \
584                   (SELECT COALESCE(array_agg(DISTINCT actor_type ORDER BY actor_type), \
585                                    ARRAY[]::text[]) \
586                      FROM core.tb_entity_change_log \
587                     WHERE actor_type IS NOT NULL AND actor_type <> ALL($1)) AS unknown_values, \
588                   EXISTS (SELECT 1 FROM pg_constraint con \
589                             JOIN pg_class c ON c.oid = con.conrelid \
590                             JOIN pg_namespace n ON n.oid = c.relnamespace \
591                           WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log' \
592                             AND con.conname = 'chk_entity_change_log_actor_type') \
593                     AS constraint_installed",
594                &[&valid_tokens],
595            )
596            .await
597            .context("failed to query change-log actor attribution stats")?;
598        Ok(Some(ChangeLogActorStats {
599            null_rows:            row.get("null_rows"),
600            unknown_values:       row.get("unknown_values"),
601            constraint_installed: row.get("constraint_installed"),
602        }))
603    }
604
605    /// Read the security posture of `core.fn_entity_change_log_capture()` (#443 /
606    /// #437 F6): whether it is `SECURITY DEFINER` (`pg_proc.prosecdef`) and the
607    /// pinned `search_path` from `pg_proc.proconfig`, if any.
608    ///
609    /// Returns `None` when the function is absent — the doctor check reads that as
610    /// "not present, skipped".
611    ///
612    /// # Errors
613    ///
614    /// Returns an error if the connection or the catalog query fails.
615    pub async fn capture_fn_security(&self) -> Result<Option<CaptureFnSecurity>> {
616        let client = self.pool.get().await.context("failed to acquire DB connection")?;
617        let rows = client
618            .query(
619                "SELECT p.prosecdef AS security_definer, \
620                        (SELECT cfg FROM unnest(p.proconfig) AS cfg \
621                          WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path_cfg \
622                 FROM pg_proc p \
623                   JOIN pg_namespace n ON n.oid = p.pronamespace \
624                 WHERE n.nspname = 'core' AND p.proname = 'fn_entity_change_log_capture' \
625                 LIMIT 1",
626                &[],
627            )
628            .await
629            .context("failed to query capture-function security from pg_proc")?;
630        Ok(rows.first().map(|row| {
631            let raw: Option<String> = row.get("search_path_cfg");
632            CaptureFnSecurity {
633                security_definer: row.get("security_definer"),
634                search_path:      raw
635                    .map(|s| s.strip_prefix("search_path=").unwrap_or(&s).to_string()),
636            }
637        }))
638    }
639}
640
641/// Build an [`OutColumn`] from a `(name, type_name, is_enum)` catalog row.
642fn row_to_out_column(row: &tokio_postgres::Row) -> OutColumn {
643    OutColumn {
644        name:      row.get("name"),
645        type_name: row.get("type_name"),
646        is_enum:   row.get("is_enum"),
647    }
648}
649
650/// Split a possibly schema-qualified function name into `(schema, name)`.
651///
652/// Mirrors the runtime's identifier handling: an unqualified name (no `.`)
653/// resolves via `search_path`; a `schema.fn` name pins the schema. Split on the
654/// first `.` so `schema.fn` → `(Some("schema"), "fn")`.
655fn split_qualified(sql_source: &str) -> (Option<String>, String) {
656    match sql_source.split_once('.') {
657        Some((schema, name)) => (Some(schema.to_string()), name.to_string()),
658        None => (None, sql_source.to_string()),
659    }
660}