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/// Outcome of the PL/pgSQL body-resolution pass.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum PlpgsqlCheckOutcome {
79 /// `plpgsql_check` is not available on this server — the pass was skipped.
80 Unavailable,
81 /// The pass ran; `errors` is empty when every internal call resolved.
82 Ran {
83 /// Unresolved internal calls found.
84 errors: Vec<BodyError>,
85 },
86}
87
88/// A live PostgreSQL connection pool for catalog introspection.
89pub struct PgCatalog {
90 pool: Pool,
91}
92
93impl PgCatalog {
94 /// Connect to `db_url` (PostgreSQL only) for catalog introspection.
95 ///
96 /// # Errors
97 ///
98 /// Returns an error if `db_url` is not a `postgres://` URL or the pool
99 /// cannot be created. (Connection failures surface lazily on first query.)
100 pub fn connect(db_url: &str) -> Result<Self> {
101 if !db_url.starts_with("postgres") {
102 anyhow::bail!(
103 "--against-db requires a PostgreSQL connection URL (postgres://…); got: {db_url}"
104 );
105 }
106 let mut cfg = Config::new();
107 cfg.url = Some(db_url.to_string());
108 cfg.manager = Some(ManagerConfig {
109 recycling_method: RecyclingMethod::Fast,
110 });
111 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
112 let pool = cfg
113 .create_pool(Some(Runtime::Tokio1), NoTls)
114 .context("failed to create PostgreSQL connection pool for --against-db")?;
115 Ok(Self { pool })
116 }
117
118 /// Resolve all overloads of a (possibly schema-qualified) function name.
119 ///
120 /// Unqualified names resolve against the connection's `search_path`
121 /// (`current_schemas(false)`), mirroring how the runtime resolves an
122 /// unqualified `sql_source`. Matching is case-sensitive — the runtime quotes
123 /// each identifier component, so `proname` is compared verbatim.
124 ///
125 /// Returns an empty vec when no function of that name is visible.
126 ///
127 /// # Errors
128 ///
129 /// Returns an error if the connection or any catalog query fails.
130 pub async fn resolve_functions(&self, sql_source: &str) -> Result<Vec<PgFunction>> {
131 // One row per overload: input-arg types/names (modes IN/INOUT/VARIADIC
132 // only — OUT/TABLE columns are excluded so the count matches what the
133 // server binds positionally) plus oid + proretset for follow-up.
134 const BASE: &str = "\
135 SELECT p.oid, n.nspname, p.proname, p.proretset, \
136 COALESCE((SELECT array_agg(format_type(t, NULL) ORDER BY ord) \
137 FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
138 WITH ORDINALITY AS a(t, ord) \
139 LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
140 WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_types, \
141 COALESCE((SELECT array_agg(COALESCE(nm, '') ORDER BY ord) \
142 FROM unnest(COALESCE(p.proallargtypes, p.proargtypes::oid[])) \
143 WITH ORDINALITY AS a(t, ord) \
144 LEFT JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
145 LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
146 WHERE COALESCE(m.mode, 'i') IN ('i','b','v')), ARRAY[]::text[]) AS in_names \
147 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace ";
148
149 let client = self.pool.get().await.context("failed to acquire DB connection")?;
150 let (schema, name) = split_qualified(sql_source);
151
152 let rows = if let Some(schema) = schema {
153 client
154 .query(
155 &format!("{BASE} WHERE n.nspname = $1 AND p.proname = $2"),
156 &[&schema, &name],
157 )
158 .await
159 } else {
160 client
161 .query(
162 &format!(
163 "{BASE} WHERE p.proname = $1 AND n.nspname = ANY(current_schemas(false))"
164 ),
165 &[&name],
166 )
167 .await
168 }
169 .context("failed to query pg_proc for function resolution")?;
170
171 let mut functions = Vec::with_capacity(rows.len());
172 for row in rows {
173 let oid: u32 = row.get("oid");
174 let in_names: Vec<String> = row.get("in_names");
175 functions.push(PgFunction {
176 schema: row.get("nspname"),
177 name: row.get("proname"),
178 in_types: row.get("in_types"),
179 in_names: in_names
180 .into_iter()
181 .map(|n| if n.is_empty() { None } else { Some(n) })
182 .collect(),
183 out_columns: self.out_columns(oid).await?,
184 returns_set: row.get("proretset"),
185 });
186 }
187 Ok(functions)
188 }
189
190 /// Output columns of one function (by oid).
191 ///
192 /// Tries the `OUT`/`TABLE` parameter path first (`RETURNS TABLE(…)`); if the
193 /// function has none, falls back to the attributes of a composite return
194 /// type (`RETURNS <composite>` / `RETURNS SETOF <composite>`). A scalar or
195 /// bare-`record` return yields an empty vec.
196 async fn out_columns(&self, oid: u32) -> Result<Vec<OutColumn>> {
197 let client = self.pool.get().await.context("failed to acquire DB connection")?;
198
199 // Path 1: OUT/TABLE output parameters.
200 let table_rows = client
201 .query(
202 "SELECT COALESCE(nm, '') AS name, format_type(t, NULL) AS type_name, \
203 (tt.typtype = 'e') AS is_enum \
204 FROM pg_proc p, \
205 unnest(p.proallargtypes) WITH ORDINALITY AS a(t, ord) \
206 JOIN unnest(p.proargmodes) WITH ORDINALITY AS m(mode, mord) ON mord = ord \
207 LEFT JOIN unnest(p.proargnames) WITH ORDINALITY AS nmt(nm, nord) ON nord = ord \
208 JOIN pg_type tt ON tt.oid = t \
209 WHERE p.oid = $1 AND m.mode IN ('o','b','t') ORDER BY ord",
210 &[&oid],
211 )
212 .await
213 .context("failed to query OUT/TABLE result columns")?;
214 if !table_rows.is_empty() {
215 return Ok(table_rows.iter().map(row_to_out_column).collect());
216 }
217
218 // Path 2: composite return type attributes.
219 let comp_rows = client
220 .query(
221 "SELECT att.attname AS name, \
222 format_type(att.atttypid, att.atttypmod) AS type_name, \
223 (at.typtype = 'e') AS is_enum \
224 FROM pg_proc p \
225 JOIN pg_type rt ON rt.oid = p.prorettype \
226 JOIN pg_attribute att ON att.attrelid = rt.typrelid \
227 AND att.attnum > 0 AND NOT att.attisdropped \
228 JOIN pg_type at ON at.oid = att.atttypid \
229 WHERE p.oid = $1 ORDER BY att.attnum",
230 &[&oid],
231 )
232 .await
233 .context("failed to query composite result columns")?;
234 Ok(comp_rows.iter().map(row_to_out_column).collect())
235 }
236
237 /// Whether the `plpgsql_check` extension is installable (present in
238 /// `pg_available_extensions`).
239 ///
240 /// # Errors
241 ///
242 /// Returns an error if the catalog query fails.
243 pub async fn plpgsql_check_available(&self) -> Result<bool> {
244 let client = self.pool.get().await.context("failed to acquire DB connection")?;
245 let row = client
246 .query_one(
247 "SELECT EXISTS(SELECT 1 FROM pg_available_extensions WHERE name = 'plpgsql_check')",
248 &[],
249 )
250 .await
251 .context("failed to probe for the plpgsql_check extension")?;
252 Ok(row.get(0))
253 }
254
255 /// Run the PL/pgSQL body-resolution pass over `schemas` and return every
256 /// unresolved internal call (#409).
257 ///
258 /// Skips gracefully when `plpgsql_check` is unavailable
259 /// ([`PlpgsqlCheckOutcome::Unavailable`]). Trigger functions are excluded
260 /// (they need a relation OID to analyse), and only `error`-level
261 /// `function … does not exist` diagnostics are reported — warnings and
262 /// runtime-only temp-table references are not contract breakage.
263 ///
264 /// # Errors
265 ///
266 /// Returns an error if `CREATE EXTENSION` or the analysis query fails (e.g.
267 /// insufficient privileges to create the extension).
268 pub async fn plpgsql_check_unresolved_calls(
269 &self,
270 schemas: &[String],
271 ) -> Result<PlpgsqlCheckOutcome> {
272 if !self.plpgsql_check_available().await? {
273 return Ok(PlpgsqlCheckOutcome::Unavailable);
274 }
275 let client = self.pool.get().await.context("failed to acquire DB connection")?;
276 client
277 .batch_execute("CREATE EXTENSION IF NOT EXISTS plpgsql_check")
278 .await
279 .context("failed to CREATE EXTENSION plpgsql_check")?;
280
281 let rows = client
282 .query(
283 "SELECT (p.oid::regprocedure)::text AS caller, t.lineno, t.message \
284 FROM pg_proc p \
285 JOIN pg_namespace n ON n.oid = p.pronamespace \
286 JOIN pg_language l ON l.oid = p.prolang AND l.lanname = 'plpgsql' \
287 CROSS JOIN LATERAL plpgsql_check_function_tb(p.oid, fatal_errors := false) t \
288 WHERE n.nspname = ANY($1) \
289 AND p.prorettype <> 'trigger'::regtype \
290 AND t.level = 'error' \
291 AND t.message ~* 'function .* does not exist' \
292 ORDER BY 1, 2",
293 &[&schemas],
294 )
295 .await
296 .context("failed to run the plpgsql_check body-resolution pass")?;
297
298 let errors = rows
299 .iter()
300 .map(|row| BodyError {
301 caller: row.get("caller"),
302 lineno: row.get("lineno"),
303 message: row.get("message"),
304 })
305 .collect();
306 Ok(PlpgsqlCheckOutcome::Ran { errors })
307 }
308}
309
310/// Build an [`OutColumn`] from a `(name, type_name, is_enum)` catalog row.
311fn row_to_out_column(row: &tokio_postgres::Row) -> OutColumn {
312 OutColumn {
313 name: row.get("name"),
314 type_name: row.get("type_name"),
315 is_enum: row.get("is_enum"),
316 }
317}
318
319/// Split a possibly schema-qualified function name into `(schema, name)`.
320///
321/// Mirrors the runtime's identifier handling: an unqualified name (no `.`)
322/// resolves via `search_path`; a `schema.fn` name pins the schema. Split on the
323/// first `.` so `schema.fn` → `(Some("schema"), "fn")`.
324fn split_qualified(sql_source: &str) -> (Option<String>, String) {
325 match sql_source.split_once('.') {
326 Some((schema, name)) => (Some(schema.to_string()), name.to_string()),
327 None => (None, sql_source.to_string()),
328 }
329}