Skip to main content

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