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::{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/// Result of auditing `sql_source` views for the `security_invoker` requirement.
149#[derive(Debug)]
150pub struct SecurityInvokerAudit {
151    /// Whether any RLS policy exists in the database (RLS is in use at all).
152    pub rls_in_use:            bool,
153    /// The audited views (by bare relname) that are NOT `security_invoker`.
154    pub views_without_invoker: Vec<String>,
155}
156
157/// A live PostgreSQL connection pool for catalog introspection.
158pub struct PgCatalog {
159    pool: Pool,
160}
161
162impl PgCatalog {
163    /// Connect to `db_url` (PostgreSQL only) for catalog introspection.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if `db_url` is not a `postgres://` URL or the pool
168    /// cannot be created. (Connection failures surface lazily on first query.)
169    pub fn connect(db_url: &str) -> Result<Self> {
170        if !db_url.starts_with("postgres") {
171            anyhow::bail!(
172                "--against-db requires a PostgreSQL connection URL (postgres://…); got: {db_url}"
173            );
174        }
175        let mut cfg = Config::new();
176        cfg.url = Some(db_url.to_string());
177        cfg.manager = Some(ManagerConfig {
178            recycling_method: RecyclingMethod::Fast,
179        });
180        cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
181        let pool = cfg
182            .create_pool(Some(Runtime::Tokio1), NoTls)
183            .context("failed to create PostgreSQL connection pool for --against-db")?;
184        Ok(Self { pool })
185    }
186
187    /// Resolve all overloads of a (possibly schema-qualified) function name.
188    ///
189    /// Unqualified names resolve against the connection's `search_path`
190    /// (`current_schemas(false)`), mirroring how the runtime resolves an
191    /// unqualified `sql_source`. Matching is case-sensitive — the runtime quotes
192    /// each identifier component, so `proname` is compared verbatim.
193    ///
194    /// Returns an empty vec when no function of that name is visible.
195    ///
196    /// # Errors
197    ///
198    /// Returns an error if the connection or any catalog query fails.
199    pub async fn resolve_functions(&self, sql_source: &str) -> Result<Vec<PgFunction>> {
200        // One row per overload: input-arg types/names (modes IN/INOUT/VARIADIC
201        // only — OUT/TABLE columns are excluded so the count matches what the
202        // server binds positionally) plus oid + proretset for follow-up.
203        const BASE: &str = "\
204            SELECT p.oid, n.nspname, p.proname, p.proretset, \
205              COALESCE((SELECT array_agg(format_type(t, NULL) ORDER BY ord) \
206                FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
207                  WITH ORDINALITY AS a(t, ord) \
208                LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
209                WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_types, \
210              COALESCE((SELECT array_agg(COALESCE(nm, '') ORDER BY ord) \
211                FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
212                  WITH ORDINALITY AS a(t, ord) \
213                LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
214                LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
215                WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_names \
216            FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace ";
217
218        let client = self.pool.get().await.context("failed to acquire DB connection")?;
219        let (schema, name) = split_qualified(sql_source);
220
221        let rows = if let Some(schema) = schema {
222            client
223                .query(
224                    &format!("{BASE} WHERE n.nspname = $1 AND p.proname = $2"),
225                    &[&schema, &name],
226                )
227                .await
228        } else {
229            client
230                .query(
231                    &format!(
232                        "{BASE} WHERE p.proname = $1 AND n.nspname = ANY(current_schemas(false))"
233                    ),
234                    &[&name],
235                )
236                .await
237        }
238        .context("failed to query pg_proc for function resolution")?;
239
240        let mut functions = Vec::with_capacity(rows.len());
241        for row in rows {
242            let oid: u32 = row.get("oid");
243            let in_names: Vec<String> = row.get("in_names");
244            functions.push(PgFunction {
245                schema:      row.get("nspname"),
246                name:        row.get("proname"),
247                in_types:    row.get("in_types"),
248                in_names:    in_names
249                    .into_iter()
250                    .map(|n| if n.is_empty() { None } else { Some(n) })
251                    .collect(),
252                out_columns: self.out_columns(oid).await?,
253                returns_set: row.get("proretset"),
254            });
255        }
256        Ok(functions)
257    }
258
259    /// Output columns of one function (by oid).
260    ///
261    /// Tries the `OUT`/`TABLE` parameter path first (`RETURNS TABLE(…)`); if the
262    /// function has none, falls back to the attributes of a composite return
263    /// type (`RETURNS <composite>` / `RETURNS SETOF <composite>`). A scalar or
264    /// bare-`record` return yields an empty vec.
265    async fn out_columns(&self, oid: u32) -> Result<Vec<OutColumn>> {
266        let client = self.pool.get().await.context("failed to acquire DB connection")?;
267
268        // Path 1: OUT/TABLE output parameters.
269        let table_rows = client
270            .query(
271                "SELECT COALESCE(nm, '') AS name, format_type(t, NULL) AS type_name, \
272                   (tt.typtype = 'e') AS is_enum \
273                 FROM pg_proc p, \
274                   unnest(p.proallargtypes) WITH ORDINALITY AS a(t, ord) \
275                   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                   JOIN pg_type tt ON tt.oid = t \
278                 WHERE p.oid = $1 AND m.mode IN ('o','b','t') ORDER BY ord",
279                &[&oid],
280            )
281            .await
282            .context("failed to query OUT/TABLE result columns")?;
283        if !table_rows.is_empty() {
284            return Ok(table_rows.iter().map(row_to_out_column).collect());
285        }
286
287        // Path 2: composite return type attributes.
288        let comp_rows = client
289            .query(
290                "SELECT att.attname AS name, \
291                   format_type(att.atttypid, att.atttypmod) AS type_name, \
292                   (at.typtype = 'e') AS is_enum \
293                 FROM pg_proc p \
294                   JOIN pg_type rt ON rt.oid = p.prorettype \
295                   JOIN pg_attribute att ON att.attrelid = rt.typrelid \
296                     AND att.attnum > 0 AND NOT att.attisdropped \
297                   JOIN pg_type at ON at.oid = att.atttypid \
298                 WHERE p.oid = $1 ORDER BY att.attnum",
299                &[&oid],
300            )
301            .await
302            .context("failed to query composite result columns")?;
303        Ok(comp_rows.iter().map(row_to_out_column).collect())
304    }
305
306    /// Whether the `plpgsql_check` extension is installable (present in
307    /// `pg_available_extensions`).
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if the catalog query fails.
312    pub async fn plpgsql_check_available(&self) -> Result<bool> {
313        let client = self.pool.get().await.context("failed to acquire DB connection")?;
314        let row = client
315            .query_one(
316                "SELECT EXISTS(SELECT 1 FROM pg_available_extensions WHERE name = 'plpgsql_check')",
317                &[],
318            )
319            .await
320            .context("failed to probe for the plpgsql_check extension")?;
321        Ok(row.get(0))
322    }
323
324    /// Run the PL/pgSQL body-resolution pass over `schemas` and return every
325    /// unresolved internal call (#409).
326    ///
327    /// Skips gracefully when `plpgsql_check` is unavailable
328    /// ([`PlpgsqlCheckOutcome::Unavailable`]). Trigger functions are excluded
329    /// (they need a relation OID to analyse), and only `error`-level
330    /// `function … does not exist` diagnostics are reported — warnings and
331    /// runtime-only temp-table references are not contract breakage.
332    ///
333    /// # Errors
334    ///
335    /// Returns an error if `CREATE EXTENSION` or the analysis query fails (e.g.
336    /// insufficient privileges to create the extension).
337    pub async fn plpgsql_check_unresolved_calls(
338        &self,
339        schemas: &[String],
340    ) -> Result<PlpgsqlCheckOutcome> {
341        if !self.plpgsql_check_available().await? {
342            return Ok(PlpgsqlCheckOutcome::Unavailable);
343        }
344        let client = self.pool.get().await.context("failed to acquire DB connection")?;
345        client
346            .batch_execute("CREATE EXTENSION IF NOT EXISTS plpgsql_check")
347            .await
348            .context("failed to CREATE EXTENSION plpgsql_check")?;
349
350        let rows = client
351            .query(
352                "SELECT (p.oid::regprocedure)::text AS caller, t.lineno, t.message \
353                 FROM pg_proc p \
354                   JOIN pg_namespace n ON n.oid = p.pronamespace \
355                   JOIN pg_language  l ON l.oid = p.prolang AND l.lanname = 'plpgsql' \
356                   CROSS JOIN LATERAL plpgsql_check_function_tb(p.oid, fatal_errors := false) t \
357                 WHERE n.nspname = ANY($1) \
358                   AND p.prorettype <> 'trigger'::regtype \
359                   AND t.level = 'error' \
360                   AND t.message ~* 'function .* does not exist' \
361                 ORDER BY 1, 2",
362                &[&schemas],
363            )
364            .await
365            .context("failed to run the plpgsql_check body-resolution pass")?;
366
367        let errors = rows
368            .iter()
369            .map(|row| BodyError {
370                caller:  row.get("caller"),
371                lineno:  row.get("lineno"),
372                message: row.get("message"),
373            })
374            .collect();
375        Ok(PlpgsqlCheckOutcome::Ran { errors })
376    }
377
378    /// Read the columns of `schema.table` from `information_schema.columns`
379    /// (name + `udt_name`), in declaration order.
380    ///
381    /// Returns an **empty** vec when the table does not exist (or is invisible to
382    /// the connecting role) — the change-log contract drift check (#380) reads
383    /// that as "table absent, the migration will install it" rather than an
384    /// error.
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if the connection or the catalog query fails.
389    pub async fn table_columns(&self, schema: &str, table: &str) -> Result<Vec<LiveColumn>> {
390        let client = self.pool.get().await.context("failed to acquire DB connection")?;
391        let rows = client
392            .query(
393                "SELECT column_name, udt_name FROM information_schema.columns \
394                 WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position",
395                &[&schema, &table],
396            )
397            .await
398            .context("failed to query information_schema.columns")?;
399        Ok(rows
400            .iter()
401            .map(|row| LiveColumn {
402                name:     row.get("column_name"),
403                udt_name: row.get("udt_name"),
404            })
405            .collect())
406    }
407
408    /// Read the live RLS posture of `core.tb_entity_change_log` and whether the
409    /// connecting role can read it under that posture (#437 F6 / #443).
410    ///
411    /// Returns `None` when the table is absent (or invisible to the connecting
412    /// role) — the doctor check reads that as "not present, skipped".
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if the connection or the catalog query fails.
417    pub async fn change_log_rls_status(&self) -> Result<Option<ChangeLogRlsStatus>> {
418        let client = self.pool.get().await.context("failed to acquire DB connection")?;
419        let rows = client
420            .query(
421                "SELECT c.relrowsecurity AS rls_enabled, \
422                        (r.rolbypassrls OR r.rolsuper) AS can_bypass, \
423                        (pg_get_userbyid(c.relowner) = current_user) AS is_owner, \
424                        current_user::text AS role_name \
425                 FROM pg_class c \
426                   JOIN pg_namespace n ON n.oid = c.relnamespace \
427                   JOIN pg_roles r ON r.rolname = current_user \
428                 WHERE n.nspname = 'core' AND c.relname = 'tb_entity_change_log'",
429                &[],
430            )
431            .await
432            .context("failed to query change-log RLS status from pg_class/pg_roles")?;
433        Ok(rows.first().map(|row| ChangeLogRlsStatus {
434            rls_enabled: row.get("rls_enabled"),
435            can_bypass:  row.get("can_bypass"),
436            is_owner:    row.get("is_owner"),
437            role_name:   row.get("role_name"),
438        }))
439    }
440
441    /// Audit the given `sql_source` views (bare relnames) for `security_invoker`.
442    ///
443    /// A *default* view runs with the view owner's privileges and bypasses the
444    /// caller's RLS; only a `security_invoker` view (PG 15+) honours base-table RLS
445    /// — the requirement the cascade RLS boundary (and the query path) rely on.
446    /// Returns which audited views lack the option, plus whether any RLS policy
447    /// exists at all (the requirement only bites under RLS).
448    ///
449    /// # Errors
450    ///
451    /// Returns an error if the connection or a catalog query fails.
452    pub async fn security_invoker_audit(&self, views: &[String]) -> Result<SecurityInvokerAudit> {
453        let client = self.pool.get().await.context("failed to acquire DB connection")?;
454        let rls_in_use: bool = client
455            .query_one("SELECT EXISTS (SELECT 1 FROM pg_policies) AS in_use", &[])
456            .await
457            .context("failed to query pg_policies for RLS usage")?
458            .get("in_use");
459        let rows = client
460            .query(
461                "SELECT c.relname \
462                 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
463                 WHERE c.relkind = 'v' AND c.relname = ANY($1) \
464                   AND NOT EXISTS ( \
465                     SELECT 1 FROM pg_options_to_table(c.reloptions) o \
466                     WHERE o.option_name = 'security_invoker' \
467                       AND lower(o.option_value) IN ('true', 'on', '1'))",
468                &[&views],
469            )
470            .await
471            .context("failed to audit view security_invoker options from pg_class")?;
472        Ok(SecurityInvokerAudit {
473            rls_in_use,
474            views_without_invoker: rows.iter().map(|r| r.get::<_, String>("relname")).collect(),
475        })
476    }
477
478    /// Read the privileges granted to the `PUBLIC` pseudo-role on the three
479    /// change-log relations (the table + its two views) that exist in `core`
480    /// (#443).
481    ///
482    /// Returns one [`PublicGrant`] per *present* relation, listing the privileges
483    /// PUBLIC currently holds (empty when none). An empty vec means none of the
484    /// three relations exist — the doctor check reads that as "not present,
485    /// skipped". PUBLIC grants are read from `pg_class.relacl` via `aclexplode`
486    /// (the PUBLIC grantee has OID `0`); a `NULL` ACL (the default for a fresh
487    /// relation) explodes to no rows, i.e. PUBLIC holds nothing.
488    ///
489    /// # Errors
490    ///
491    /// Returns an error if the connection or the catalog query fails.
492    pub async fn change_log_public_grants(&self) -> Result<Vec<PublicGrant>> {
493        let client = self.pool.get().await.context("failed to acquire DB connection")?;
494        let rows = client
495            .query(
496                "SELECT c.relname, \
497                        COALESCE( \
498                          array_agg(DISTINCT a.privilege_type) FILTER (WHERE a.grantee = 0), \
499                          ARRAY[]::text[]) AS public_privs \
500                 FROM pg_class c \
501                   JOIN pg_namespace n ON n.oid = c.relnamespace \
502                   LEFT JOIN LATERAL aclexplode(c.relacl) a ON true \
503                 WHERE n.nspname = 'core' \
504                   AND c.relname IN ('tb_entity_change_log', 'v_entity_change_log', \
505                                     'v_entity_change_log_debezium') \
506                 GROUP BY c.relname \
507                 ORDER BY c.relname",
508                &[],
509            )
510            .await
511            .context("failed to query change-log PUBLIC grants from pg_class.relacl")?;
512        Ok(rows
513            .iter()
514            .map(|row| PublicGrant {
515                relname:    row.get("relname"),
516                privileges: row.get("public_privs"),
517            })
518            .collect())
519    }
520
521    /// Read the security posture of `core.fn_entity_change_log_capture()` (#443 /
522    /// #437 F6): whether it is `SECURITY DEFINER` (`pg_proc.prosecdef`) and the
523    /// pinned `search_path` from `pg_proc.proconfig`, if any.
524    ///
525    /// Returns `None` when the function is absent — the doctor check reads that as
526    /// "not present, skipped".
527    ///
528    /// # Errors
529    ///
530    /// Returns an error if the connection or the catalog query fails.
531    pub async fn capture_fn_security(&self) -> Result<Option<CaptureFnSecurity>> {
532        let client = self.pool.get().await.context("failed to acquire DB connection")?;
533        let rows = client
534            .query(
535                "SELECT p.prosecdef AS security_definer, \
536                        (SELECT cfg FROM unnest(p.proconfig) AS cfg \
537                          WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path_cfg \
538                 FROM pg_proc p \
539                   JOIN pg_namespace n ON n.oid = p.pronamespace \
540                 WHERE n.nspname = 'core' AND p.proname = 'fn_entity_change_log_capture' \
541                 LIMIT 1",
542                &[],
543            )
544            .await
545            .context("failed to query capture-function security from pg_proc")?;
546        Ok(rows.first().map(|row| {
547            let raw: Option<String> = row.get("search_path_cfg");
548            CaptureFnSecurity {
549                security_definer: row.get("security_definer"),
550                search_path:      raw
551                    .map(|s| s.strip_prefix("search_path=").unwrap_or(&s).to_string()),
552            }
553        }))
554    }
555}
556
557/// Build an [`OutColumn`] from a `(name, type_name, is_enum)` catalog row.
558fn row_to_out_column(row: &tokio_postgres::Row) -> OutColumn {
559    OutColumn {
560        name:      row.get("name"),
561        type_name: row.get("type_name"),
562        is_enum:   row.get("is_enum"),
563    }
564}
565
566/// Split a possibly schema-qualified function name into `(schema, name)`.
567///
568/// Mirrors the runtime's identifier handling: an unqualified name (no `.`)
569/// resolves via `search_path`; a `schema.fn` name pins the schema. Split on the
570/// first `.` so `schema.fn` → `(Some("schema"), "fn")`.
571fn split_qualified(sql_source: &str) -> (Option<String>, String) {
572    match sql_source.split_once('.') {
573        Some((schema, name)) => (Some(schema.to_string()), name.to_string()),
574        None => (None, sql_source.to_string()),
575    }
576}