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