pgevolve_core/catalog/mod.rs
1//! Catalog reader: live Postgres `pg_catalog` → [`crate::ir::catalog::Catalog`].
2//!
3//! The reader is split into:
4//!
5//! - [`CatalogQuerier`] — a sync, driver-agnostic trait. Adapters (the binary
6//! uses `tokio-postgres`) execute parameterized SQL and return [`rows::Row`]
7//! values.
8//! - Per-version SQL strings in [`queries`].
9//! - [`filter::CatalogFilter`] — managed-schema list + ignore globs.
10//! - [`read_catalog`] — top-level entry point that orchestrates the queries
11//! and assembles their rows into IR.
12
13pub mod cluster;
14pub mod error;
15pub mod filter;
16pub mod queries;
17pub mod rows;
18pub mod version;
19
20pub use error::CatalogError;
21pub use filter::CatalogFilter;
22pub use rows::{Row, Value};
23pub use version::PgVersion;
24
25mod assemble;
26pub(crate) mod grants;
27pub(crate) mod publications;
28pub(crate) mod reloptions;
29pub(crate) mod statistics;
30pub(crate) mod subscriptions;
31
32use crate::identifier::{Identifier, QualifiedName};
33use crate::ir::catalog::Catalog;
34
35/// Drift detected between the canonical catalog IR and the live Postgres state.
36///
37/// The catalog reader always surfaces all constraints and indexes in the IR
38/// regardless of their validation state. This report captures the *extra*
39/// observation that some of them are in a transitional / incomplete state:
40/// - `pending_validation`: constraints with `pg_constraint.convalidated = false`
41/// (added `NOT VALID`, never validated).
42/// - `invalid_indexes`: indexes with `pg_index.indisvalid = false` (e.g., a
43/// `CREATE INDEX CONCURRENTLY` that failed and left an INVALID index).
44/// - `unmanaged_language_routines`: routines whose `LANGUAGE` is neither `sql`
45/// nor `plpgsql` (e.g., `plperl`, `python3u`). pgevolve v0.2 does not
46/// manage these; they are surfaced in the drift report so callers can
47/// inspect them. The associated row is skipped and never appears in
48/// `catalog.functions` / `catalog.procedures`.
49/// - `unreadable_subscriptions`: the connection used for the catalog read had
50/// insufficient privilege to query `pg_subscription` (sqlstate 42501). The
51/// subscription list in the returned catalog is empty; the operator must use
52/// a superuser connection to get subscription data.
53///
54/// The differ consumes this report and emits [`crate::diff::change::Change::ValidateConstraint`]
55/// and [`crate::diff::change::Change::RecreateIndex`] to recover automatically.
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct DriftReport {
58 /// Constraints present in the catalog but with `convalidated = false`.
59 /// Identified by `(table_qname, constraint_name)`.
60 pub pending_validation: Vec<(QualifiedName, Identifier)>,
61 /// Indexes present in the catalog but with `indisvalid = false`.
62 /// Identified by index qname.
63 pub invalid_indexes: Vec<QualifiedName>,
64 /// Routines whose `LANGUAGE` is not `sql` or `plpgsql`.
65 /// Identified by `(qname, language_name)`.
66 pub unmanaged_language_routines: Vec<(QualifiedName, String)>,
67 /// `pg_subscription` was unreadable due to insufficient privilege (sqlstate
68 /// 42501). `catalog.subscriptions` will be empty when this is `true`.
69 pub unreadable_subscriptions: bool,
70}
71
72/// Identifier for each catalog query the reader runs. Adapters dispatch on
73/// this enum to pick the per-version SQL string.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum CatalogQuery {
76 /// `SHOW server_version_num`.
77 PgVersion,
78 /// `pg_namespace` rows for managed schemas.
79 Schemas,
80 /// `pg_class` (relkind='r') for managed tables.
81 Tables,
82 /// `pg_attribute` joined with `pg_attrdef`/`pg_type` for managed tables.
83 Columns,
84 /// `pg_constraint` for managed tables (PK/UNIQUE/FK/CHECK).
85 Constraints,
86 /// `pg_index` for managed tables (excluding constraint-backing indexes).
87 Indexes,
88 /// `pg_class` (relkind='S') joined with `pg_sequence`.
89 Sequences,
90 /// `pg_description` (currently inlined into the per-object queries).
91 Comments,
92 /// `pg_depend` rows linking sequences to their owning columns.
93 Dependencies,
94 /// `pg_class` (relkind IN ('v','m')) joined with `pg_get_viewdef`.
95 ViewsAndMvs,
96 /// `pg_attribute` for view and materialized view columns.
97 ViewColumns,
98 /// `pg_type` filtered to `typtype IN ('e','d','c')` for user-defined types.
99 UserTypes,
100 /// `pg_enum` labels for enum types.
101 EnumValues,
102 /// Base-type and nullability details for domain types.
103 DomainDetails,
104 /// Named CHECK constraints attached to domain types.
105 DomainChecks,
106 /// Attributes (fields) of composite types.
107 CompositeAttributes,
108 /// `pg_proc` rows for functions and procedures (prokind IN 'f','p').
109 Functions,
110 /// `pg_extension` rows for installed extensions.
111 Extensions,
112 /// `pg_trigger` rows for user triggers (excluding internal + extension-owned).
113 Triggers,
114 /// `pg_class` (relkind='p') rows for partitioned-table parents.
115 PartitionedTables,
116 /// `pg_class` (relispartition=true) rows for child partitions.
117 Partitions,
118 /// `pg_authid` rows for cluster roles (with `pg_shdescription` for comments).
119 ///
120 /// Uses `$1::text[]` as the bootstrap-role filter (names to exclude), not a
121 /// managed-schema list. `takes_text_array_param` returns `true` so the adapter
122 /// passes the parameter; the cluster reader supplies bootstrap role names.
123 ClusterRoles,
124 /// `pg_auth_members` edges joined to `pg_authid` for role names.
125 ///
126 /// Same `$1::text[]` bootstrap-role filter as [`Self::ClusterRoles`].
127 ClusterMembers,
128 /// `pg_default_acl` rows joined to `pg_authid` and `pg_namespace`.
129 ///
130 /// Returns one row per (`target_role`, schema, `object_type`) tuple. Rows for
131 /// predefined `pg_*` roles are filtered out. Takes **no** `$1::text[]`
132 /// parameter; `takes_text_array_param` returns `false` for this variant.
133 DefaultPrivileges,
134 /// `pg_policies` rows for managed schemas.
135 ///
136 /// Returns one row per policy, scoped to `schemaname = ANY($1::text[])`.
137 /// Decoded into [`crate::ir::policy::Policy`] and attached to their
138 /// owning `Table` by the assembler. Policies on unmanaged tables are
139 /// silently dropped.
140 Policies,
141 /// `pg_publication` rows for all publications in the database.
142 ///
143 /// Publications are database-global (not schema-scoped); takes **no**
144 /// `$1::text[]` parameter (`takes_text_array_param` returns `false`).
145 Publications,
146 /// `pg_publication_rel` rows — one per (publication, table) membership.
147 ///
148 /// PG 15+ includes `prqual` (row filter) and `prattrs` (column list);
149 /// PG 14 variant substitutes `NULL` for both. Takes **no** parameter.
150 PublicationRel,
151 /// `pg_publication_namespace` rows — one per (publication, schema)
152 /// membership (PG 15+ only). PG 14 variant returns zero rows.
153 /// Takes **no** parameter.
154 PublicationNamespace,
155 /// `pg_attribute` rows for every column of every table referenced by
156 /// any publication. Used to resolve column attnums to names. Takes **no**
157 /// parameter.
158 PublicationAttributes,
159 /// `pg_subscription` rows for all subscriptions in the database.
160 ///
161 /// Subscriptions are database-global (not schema-scoped). Takes **no**
162 /// `$1::text[]` parameter (`takes_text_array_param` returns `false`).
163 ///
164 /// `pg_subscription` is superuser-readable only. Non-super connections
165 /// will receive an empty result or a permission error; the assembler
166 /// catches the error and sets `DriftReport::unreadable_subscriptions`.
167 Subscriptions,
168 /// `pg_statistic_ext` rows for managed schemas. Takes `$1::text[]`
169 /// (managed schema names).
170 Statistics,
171 /// Column-attnum resolver for statistics target tables. Bulk-fetched once;
172 /// grouped by `target_oid` in the assembler. Takes `$1::text[]`.
173 StatisticAttributes,
174 /// Bulk expression decode via `pg_get_statisticsobjdef_expressions` for all
175 /// statistics in managed schemas. Returns one row per expression entry with
176 /// columns `(stat_oid, expr_index, expr_sql)`. Takes `$1::text[]`
177 /// (managed schema names).
178 StatisticExpressions,
179}
180
181impl CatalogQuery {
182 /// Whether this query accepts a `$1::text[]` argument.
183 ///
184 /// The semantic meaning of the array varies by variant: managed-schema
185 /// names for per-DB queries, bootstrap-role names for cluster queries.
186 /// The adapter is responsible for passing the right slice to the right
187 /// variant.
188 ///
189 /// A few variants (`PgVersion`, `Extensions`) take no parameters at all;
190 /// this method returns `false` for those.
191 #[must_use]
192 pub const fn takes_text_array_param(self) -> bool {
193 !matches!(
194 self,
195 Self::PgVersion
196 | Self::Extensions
197 | Self::DefaultPrivileges
198 | Self::Publications
199 | Self::PublicationRel
200 | Self::PublicationNamespace
201 | Self::PublicationAttributes
202 | Self::Subscriptions
203 )
204 }
205
206 // Note: `Policies` takes `$1::text[]` (managed schemas), so it is NOT in
207 // the exclusion list above — `takes_text_array_param` returns `true` for it.
208}
209
210/// Sync, driver-agnostic catalog query interface.
211///
212/// Interface implemented by callers (typically the binary) to execute catalog
213/// queries against a live database. Implementations are expected to be sync —
214/// async drivers can wrap their runtime in [`fetch`](Self::fetch).
215pub trait CatalogQuerier {
216 /// Execute the named query with the supplied `$1::text[]` parameter (when
217 /// applicable; see [`CatalogQuery::takes_text_array_param`]).
218 ///
219 /// The semantic meaning of `text_array_param` varies by variant:
220 /// managed-schema names for per-DB queries, bootstrap-role names for
221 /// cluster queries. Pass an empty slice for queries that take no parameter.
222 fn fetch(
223 &self,
224 query: CatalogQuery,
225 text_array_param: &[&str],
226 ) -> Result<Vec<Row>, CatalogError>;
227}
228
229/// Read every catalog query, assemble the IR, and canonicalize.
230///
231/// Returns a `(Catalog, DriftReport)` tuple. The catalog contains all objects
232/// including those in transitional states (NOT VALID constraints, INVALID
233/// indexes). The drift report captures which objects are in those states so the
234/// differ can emit recovery changes.
235pub fn read_catalog(
236 querier: &dyn CatalogQuerier,
237 filter: &CatalogFilter,
238) -> Result<(Catalog, DriftReport), CatalogError> {
239 let version = PgVersion::detect(querier)?;
240 let managed: Vec<&str> = filter.managed_schemas_param();
241
242 let schemas_rows = querier.fetch(CatalogQuery::Schemas, &managed)?;
243 let tables_rows = querier.fetch(CatalogQuery::Tables, &managed)?;
244 let columns_rows = querier.fetch(CatalogQuery::Columns, &managed)?;
245 let constraints_rows = querier.fetch(CatalogQuery::Constraints, &managed)?;
246 let indexes_rows = querier.fetch(CatalogQuery::Indexes, &managed)?;
247 let sequences_rows = querier.fetch(CatalogQuery::Sequences, &managed)?;
248 let dependencies_rows = querier.fetch(CatalogQuery::Dependencies, &managed)?;
249 let views_and_mvs_rows = querier.fetch(CatalogQuery::ViewsAndMvs, &managed)?;
250 let view_columns_rows = querier.fetch(CatalogQuery::ViewColumns, &managed)?;
251 let user_types_rows = querier.fetch(CatalogQuery::UserTypes, &managed)?;
252 let enum_values_rows = querier.fetch(CatalogQuery::EnumValues, &managed)?;
253 let domain_details_rows = querier.fetch(CatalogQuery::DomainDetails, &managed)?;
254 let domain_checks_rows = querier.fetch(CatalogQuery::DomainChecks, &managed)?;
255 let composite_attributes_rows = querier.fetch(CatalogQuery::CompositeAttributes, &managed)?;
256 let functions_rows = querier.fetch(CatalogQuery::Functions, &managed)?;
257 let extensions_rows = querier.fetch(CatalogQuery::Extensions, &managed)?;
258 let triggers_rows = querier.fetch(CatalogQuery::Triggers, &managed)?;
259 let partitioned_tables_rows = querier.fetch(CatalogQuery::PartitionedTables, &managed)?;
260 let partitions_rows = querier.fetch(CatalogQuery::Partitions, &managed)?;
261 let default_privileges_rows = querier.fetch(CatalogQuery::DefaultPrivileges, &[])?;
262 let policies_rows = querier.fetch(CatalogQuery::Policies, &managed)?;
263 let publications_rows = querier.fetch(CatalogQuery::Publications, &[])?;
264 let publication_rels_rows = querier.fetch(CatalogQuery::PublicationRel, &[])?;
265 let publication_namespaces_rows = querier.fetch(CatalogQuery::PublicationNamespace, &[])?;
266 let publication_attributes_rows = querier.fetch(CatalogQuery::PublicationAttributes, &[])?;
267
268 // `pg_subscription` is superuser-only. If the querier returns a
269 // `QueryFailed` error whose message contains the PG sqlstate 42501
270 // (insufficient_privilege), we silently return empty rows and record the
271 // gap in the drift report. Any other error is propagated normally.
272 let (subscriptions_rows, unreadable_subscriptions) =
273 match querier.fetch(CatalogQuery::Subscriptions, &[]) {
274 Ok(rows) => (rows, false),
275 Err(CatalogError::QueryFailed { message, .. })
276 if message.contains("42501") || message.contains("insufficient_privilege") =>
277 {
278 (vec![], true)
279 }
280 Err(e) => return Err(e),
281 };
282
283 // Statistics — schema-scoped. Attribute rows resolve stxkeys attnums to
284 // column names. Expression rows are bulk-fetched for all managed schemas.
285 let statistics_rows = querier.fetch(CatalogQuery::Statistics, &managed)?;
286 let statistic_attributes_rows = querier.fetch(CatalogQuery::StatisticAttributes, &managed)?;
287 let statistic_expressions_rows = querier.fetch(CatalogQuery::StatisticExpressions, &managed)?;
288
289 let raw = assemble::RawRows {
290 version,
291 schemas: schemas_rows,
292 tables: tables_rows,
293 columns: columns_rows,
294 constraints: constraints_rows,
295 indexes: indexes_rows,
296 sequences: sequences_rows,
297 dependencies: dependencies_rows,
298 views_and_mvs: views_and_mvs_rows,
299 view_columns: view_columns_rows,
300 user_types: user_types_rows,
301 enum_values: enum_values_rows,
302 domain_details: domain_details_rows,
303 domain_checks: domain_checks_rows,
304 composite_attributes: composite_attributes_rows,
305 functions: functions_rows,
306 extensions: extensions_rows,
307 triggers: triggers_rows,
308 partitioned_tables: partitioned_tables_rows,
309 partitions: partitions_rows,
310 default_privileges: default_privileges_rows,
311 policies: policies_rows,
312 publications: publications_rows,
313 publication_rels: publication_rels_rows,
314 publication_namespaces: publication_namespaces_rows,
315 publication_attributes: publication_attributes_rows,
316 subscriptions: subscriptions_rows,
317 };
318 let (mut catalog, mut drift) = assemble::assemble(raw, filter)?;
319 drift.unreadable_subscriptions = unreadable_subscriptions;
320
321 // Assemble statistics after the main assemble pass. All three row sets
322 // (base rows, attribute rows, expression rows) are already bulk-fetched.
323 catalog.statistics = assemble::statistics::assemble_statistics(
324 &statistics_rows,
325 &statistic_attributes_rows,
326 &statistic_expressions_rows,
327 )?;
328
329 Ok((catalog.canonicalize()?, drift))
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use std::cell::RefCell;
336 use std::collections::HashMap;
337
338 /// Mock querier that returns canned rows by query name.
339 struct MockQuerier {
340 rows: RefCell<HashMap<CatalogQuery, Vec<Row>>>,
341 }
342
343 impl MockQuerier {
344 fn new() -> Self {
345 Self {
346 rows: RefCell::new(HashMap::new()),
347 }
348 }
349 fn set(&self, q: CatalogQuery, rows: Vec<Row>) {
350 self.rows.borrow_mut().insert(q, rows);
351 }
352 }
353
354 impl CatalogQuerier for MockQuerier {
355 fn fetch(
356 &self,
357 q: CatalogQuery,
358 _text_array_param: &[&str],
359 ) -> Result<Vec<Row>, CatalogError> {
360 Ok(self.rows.borrow().get(&q).cloned().unwrap_or_default())
361 }
362 }
363
364 #[test]
365 fn empty_catalog_round_trips() {
366 let m = MockQuerier::new();
367 m.set(
368 CatalogQuery::PgVersion,
369 vec![Row::new().with("server_version_num", Value::Integer(160_000))],
370 );
371 let filter = CatalogFilter::new(vec![], vec![]).unwrap();
372 let (cat, drift) = read_catalog(&m, &filter).expect("reads");
373 assert!(cat.tables.is_empty());
374 assert!(cat.schemas.is_empty());
375 assert!(drift.pending_validation.is_empty());
376 assert!(drift.invalid_indexes.is_empty());
377 }
378}