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