pgevolve-core 0.4.1

Postgres declarative schema management — core library (parser, IR, diff, planner) powering the pgevolve CLI.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Convert raw catalog rows into [`Catalog`] IR.
//!
//! This is the heart of the catalog reader: it stitches together rows from
//! [`crate::catalog::CatalogQuery`] into the same IR shape the source-side
//! parser produces.
//!
//! The strategy:
//! - Schemas, tables, sequences, columns: direct field-for-field translation.
//! - Indexes: re-parse `pg_get_indexdef` text via `pg_query` and reuse
//!   [`crate::parse::builder::index_stmt::build_index`].
//! - Constraints: build PK/UNIQUE/FK from row fields; for CHECK, extract the
//!   expression from `pg_get_constraintdef` text.
//! - Default expressions: parse the `pg_get_expr` text and run it through
//!   the same default-expr builder the source parser uses.
//! - SERIAL/IDENTITY ownership: walk the dependencies rows and populate
//!   `Sequence.owned_by` and the column-side `Identity`/`Default::Sequence`
//!   linkage so source-IR and catalog-IR converge on the same shape.

mod aggregates;
pub(in crate::catalog) mod collations;
pub(super) mod default_privileges;
pub(super) mod event_triggers;
mod functions;
mod partitions;
pub(super) mod policies;
pub(super) mod publications;
pub(in crate::catalog) mod statistics;
pub(super) mod subscriptions;
mod tables;
mod triggers;
mod user_types;
mod views;

use std::collections::HashMap;
use std::path::PathBuf;

use pg_query::NodeEnum;

use crate::catalog::CatalogQuery;
use crate::catalog::DriftReport;
use crate::catalog::error::CatalogError;
use crate::catalog::filter::CatalogFilter;
use crate::catalog::rows::Row;
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::catalog::Catalog;
use crate::ir::constraint::{FkMatchType, ReferentialAction};
use crate::ir::default_expr::NormalizedExpr;
use crate::ir::extension::Extension;
use crate::ir::sequence::Sequence;

/// Bundle of rows passed to [`assemble`].
pub struct RawRows {
    pub version: crate::catalog::version::PgVersion,
    pub schemas: Vec<Row>,
    pub tables: Vec<Row>,
    pub columns: Vec<Row>,
    pub constraints: Vec<Row>,
    pub indexes: Vec<Row>,
    pub sequences: Vec<Row>,
    pub dependencies: Vec<Row>,
    pub views_and_mvs: Vec<Row>,
    pub view_columns: Vec<Row>,
    pub user_types: Vec<Row>,
    pub enum_values: Vec<Row>,
    pub domain_details: Vec<Row>,
    pub domain_checks: Vec<Row>,
    pub composite_attributes: Vec<Row>,
    pub functions: Vec<Row>,
    /// `pg_aggregate` rows joined to their wrapper `pg_proc` entry.
    pub aggregates: Vec<Row>,
    pub extensions: Vec<Row>,
    pub triggers: Vec<Row>,
    pub partitioned_tables: Vec<Row>,
    pub partitions: Vec<Row>,
    pub default_privileges: Vec<Row>,
    pub policies: Vec<Row>,
    /// `pg_publication` rows.
    pub publications: Vec<Row>,
    /// `pg_publication_rel` rows.
    pub publication_rels: Vec<Row>,
    /// `pg_publication_namespace` rows (PG 15+; empty on PG 14).
    pub publication_namespaces: Vec<Row>,
    /// Column-attnum resolver rows from `pg_attribute` joined to
    /// `pg_publication_rel`.
    pub publication_attributes: Vec<Row>,
    /// `pg_event_trigger` rows (database-global; extension-owned excluded).
    pub event_triggers: Vec<Row>,
    /// `pg_subscription` rows. Empty when the connection lacks superuser
    /// privilege (`DriftReport::unreadable_subscriptions` is set in that case).
    pub subscriptions: Vec<Row>,
}

/// Convert raw rows into a [`Catalog`] and a [`DriftReport`]. Caller is
/// responsible for canonicalization.
pub fn assemble(
    raw: RawRows,
    filter: &CatalogFilter,
) -> Result<(Catalog, DriftReport), CatalogError> {
    let RawRows {
        version: _version,
        schemas,
        tables,
        columns,
        constraints,
        indexes,
        sequences,
        dependencies,
        views_and_mvs,
        view_columns,
        user_types,
        enum_values,
        domain_details,
        domain_checks,
        composite_attributes,
        functions,
        aggregates,
        extensions,
        triggers,
        partitioned_tables,
        partitions,
        default_privileges,
        policies,
        publications: pub_rows,
        publication_rels,
        publication_namespaces,
        publication_attributes,
        event_triggers,
        subscriptions: sub_rows,
    } = raw;

    let mut catalog = Catalog::empty();
    let mut drift = DriftReport::default();

    // Build table family: schemas, tables, columns, constraints, indexes,
    // sequences, and SERIAL/IDENTITY ownership wiring.
    catalog.schemas = tables::build_schemas(&schemas, filter)?;

    // Attach constraints to their tables. Also collect drift from NOT VALID constraints.
    let mut tables_mut = tables::build_tables(tables, &columns, filter)?;
    tables::apply_constraints(&mut tables_mut, &constraints, filter, &mut drift)?;

    // Build indexes (re-parsing pg_get_indexdef). Also collect drift from INVALID indexes.
    catalog.indexes = tables::build_indexes(&indexes, filter, &mut drift)?;

    // Build sequences.
    let mut sequence_by_qname: HashMap<String, Sequence> = HashMap::new();
    for r in &sequences {
        if let Some(s) = tables::build_sequence(r, filter)? {
            sequence_by_qname.insert(s.qname.to_string(), s);
        }
    }

    // Wire SERIAL/IDENTITY ownership.
    tables::apply_dependencies(&dependencies, &mut tables_mut, &mut sequence_by_qname)?;

    catalog.tables = tables_mut.into_values().collect();
    catalog.sequences = sequence_by_qname.into_values().collect();

    // Attach RLS policies to their tables. Must run after `catalog.tables` is set.
    policies::attach_policies(&policies, &mut catalog.tables)?;

    // Build views and materialized views.
    let (views, materialized_views) =
        views::build_views_and_mvs(&views_and_mvs, &view_columns, filter)?;
    catalog.views = views;
    catalog.materialized_views = materialized_views;

    // Build user-defined types (enums, domains, composites).
    catalog.types = user_types::build_user_types(
        &user_types,
        &enum_values,
        &domain_details,
        &domain_checks,
        &composite_attributes,
        filter,
    )?;

    // Build functions and procedures from pg_proc.
    let (fns, procs) = functions::build_functions_and_procedures(&functions, filter, &mut drift)?;
    catalog.functions = fns;
    catalog.procedures = procs;

    // Build aggregates from pg_aggregate. Ordered-set / hypothetical-set
    // aggregates and aggregates with unmanaged-language state/final functions
    // are skipped and recorded in `drift.unmanaged_aggregates`.
    catalog.aggregates = aggregates::assemble_aggregates(&aggregates, filter, &mut drift)?;

    // Build extensions from pg_extension.
    catalog.extensions = build_extensions(&extensions)?;

    // Build triggers from pg_trigger (re-parses pg_get_triggerdef output).
    catalog.triggers = triggers::build_triggers(&triggers)?;

    // Merge partition metadata: re-parse pg_get_partkeydef / pg_get_expr(relpartbound).
    partitions::merge_partition_metadata(&mut catalog, &partitioned_tables, &partitions)?;

    // Build ALTER DEFAULT PRIVILEGES rules from pg_default_acl.
    catalog.default_privileges = default_privileges::build_default_privileges(&default_privileges)?;

    // Build publications from pg_publication + pg_publication_rel + pg_publication_namespace.
    catalog.publications = publications::assemble_publications(
        &pub_rows,
        &publication_rels,
        &publication_namespaces,
        &publication_attributes,
    )?;

    // Build event triggers from pg_event_trigger (database-global;
    // extension-owned excluded at the SQL layer).
    catalog.event_triggers = event_triggers::assemble_event_triggers(&event_triggers)?;

    // Build subscriptions from pg_subscription. Rows may be empty if the
    // connection lacked superuser privilege; the drift flag is set upstream
    // by read_catalog before assemble() is called.
    catalog.subscriptions = subscriptions::assemble_subscriptions(&sub_rows)?;

    // Statistics are assembled by `read_catalog` directly (not via RawRows)
    // because the expression-decode step requires a live querier. The field
    // starts as `Vec::new()` (from `Catalog::empty()`) and is populated after
    // `assemble()` returns.

    Ok((catalog, drift))
}

// ---- shared helpers used by multiple sub-modules ----

/// Build a qualified name from two row fields.
pub(super) fn qname_from(
    r: &Row,
    q: CatalogQuery,
    schema_key: &str,
    name_key: &str,
) -> Result<QualifiedName, CatalogError> {
    let schema = ident_required(&r.get_text(q, schema_key)?)?;
    let name = ident_required(&r.get_text(q, name_key)?)?;
    Ok(QualifiedName::new(schema, name))
}

/// Parse a raw string as an unquoted identifier, mapping the error to [`CatalogError`].
pub(super) fn ident_required(s: &str) -> Result<Identifier, CatalogError> {
    Identifier::from_unquoted(s)
        .map_err(|e| CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(e.to_string())))
}

/// Build a [`QualifiedName`] from two raw string slices, emitting a
/// [`CatalogError::Ir`] on invalid identifier.
pub(super) fn qname_from_strings(schema: &str, name: &str) -> Result<QualifiedName, CatalogError> {
    let s = Identifier::from_unquoted(schema).map_err(|e| {
        CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(format!(
            "bad schema identifier {schema:?}: {e}"
        )))
    })?;
    let n = Identifier::from_unquoted(name).map_err(|e| {
        CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(format!(
            "bad name identifier {name:?}: {e}"
        )))
    })?;
    Ok(QualifiedName::new(s, n))
}

/// Parse a referential-action single-character code into [`ReferentialAction`].
pub(super) fn parse_referential_action(s: &str) -> ReferentialAction {
    match s {
        "r" => ReferentialAction::Restrict,
        "c" => ReferentialAction::Cascade,
        "n" => ReferentialAction::SetNull(vec![]),
        "d" => ReferentialAction::SetDefault(vec![]),
        // `a` (default) or empty/space.
        _ => ReferentialAction::NoAction,
    }
}

/// Parse a match-type single-character code into [`FkMatchType`].
pub(super) const fn parse_match_type(s: &str) -> FkMatchType {
    let b = s.as_bytes();
    if b.len() == 1 && (b[0] == b'f' || b[0] == b'F') {
        FkMatchType::Full
    } else {
        FkMatchType::Simple
    }
}

/// Extract the referenced-column list from a `pg_get_constraintdef` FK body.
///
/// Wraps the constraint body in a synthetic `CREATE TABLE` statement so that
/// `pg_query` can produce a typed AST, then reads the `pk_attrs` field of the
/// resulting [`pg_query::protobuf::Constraint`] node — those are the columns on
/// the *referenced* (primary-key) side of the FK.
///
/// Returns `None` when the body cannot be parsed or yields no constraint node,
/// so callers can fall back to a placeholder list.
///
/// Constitution §5: parsing is not reimplemented — all SQL decomposition goes
/// through `pg_query`.
pub(super) fn parse_fk_referenced_columns(def: &str) -> Option<Vec<Identifier>> {
    // Wrap in a synthetic CREATE TABLE so pg_query sees a full statement.
    let synthetic =
        format!("CREATE TABLE _pgevolve_synth (_pgevolve_dummy int, CONSTRAINT _c {def});");
    let parsed = pg_query::parse(&synthetic).ok()?;

    // Dig into: RawStmt → CreateStmt → table_elts → Constraint
    let stmt_node = parsed
        .protobuf
        .stmts
        .into_iter()
        .next()
        .and_then(|raw| raw.stmt)
        .and_then(|n| n.node)?;
    let NodeEnum::CreateStmt(create) = stmt_node else {
        return None;
    };

    let constraint = create.table_elts.into_iter().find_map(|n| match n.node {
        Some(NodeEnum::Constraint(c)) => Some(c),
        _ => None,
    })?;

    // pk_attrs holds the referenced (right-hand) column names; fk_attrs holds
    // the local (left-hand) column names.
    let columns: Vec<Identifier> = constraint
        .pk_attrs
        .into_iter()
        .filter_map(|n| match n.node {
            Some(NodeEnum::String(s)) => Identifier::from_unquoted(&s.sval).ok(),
            _ => None,
        })
        .collect();

    if columns.is_empty() {
        None
    } else {
        Some(columns)
    }
}

/// Strip the outer `CHECK (` / `)` from a `pg_get_constraintdef` payload and
/// reparse the inner predicate.
pub(super) fn parse_check_expression(def: &str) -> Result<NormalizedExpr, CatalogError> {
    let s = def.trim();
    let inner = s
        .strip_prefix("CHECK")
        .or_else(|| s.strip_prefix("check"))
        .map(str::trim_start)
        .and_then(|rest| rest.strip_prefix('('))
        .and_then(|rest| rest.strip_suffix(')'))
        .unwrap_or(s);
    reparse_expression_text(inner)
}

/// Parse `text` as a SQL expression by wrapping it in `SELECT (...) AS x` and
/// extracting the resulting expression node, then normalize it.
pub(super) fn reparse_expression_text(text: &str) -> Result<NormalizedExpr, CatalogError> {
    let sql = format!("SELECT ({text}) AS __pgevolve_expr__");
    let parsed = pg_query::parse(&sql).map_err(|e| {
        CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(format!(
            "could not reparse expression {text:?}: {e}"
        )))
    })?;
    let stmt = parsed
        .protobuf
        .stmts
        .into_iter()
        .next()
        .and_then(|raw| raw.stmt)
        .and_then(|n| n.node)
        .ok_or_else(|| {
            CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(
                "reparsed expression had no statement".into(),
            ))
        })?;
    let NodeEnum::SelectStmt(s) = stmt else {
        return Err(CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(
            "expression scaffold did not yield SelectStmt".into(),
        )));
    };
    let target = s
        .target_list
        .into_iter()
        .next()
        .and_then(|n| n.node)
        .ok_or_else(|| {
            CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(
                "expression scaffold had no target".into(),
            ))
        })?;
    let NodeEnum::ResTarget(rt) = target else {
        return Err(CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(
            "expression scaffold target was not a ResTarget".into(),
        )));
    };
    let inner = rt.val.and_then(|n| n.node).ok_or_else(|| {
        CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(
            "expression scaffold ResTarget missing value".into(),
        ))
    })?;
    let location = crate::parse::error::SourceLocation::new(PathBuf::from("<catalog>"), 1, 1);
    crate::parse::normalize_expr::from_pg_node(&inner, None, &location).map_err(|e| {
        CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(format!(
            "could not normalize expression: {e}"
        )))
    })
}

/// Strip the outer `CHECK (…)` wrapper that `pg_get_constraintdef` prepends.
///
/// Handles both `CHECK (x)` and `CHECK ((x))` forms. The resulting slice
/// points into the original string (no allocation).
pub(super) fn strip_check_wrapper(text: &str) -> &str {
    let t = text.trim();
    let t = t.strip_prefix("CHECK").unwrap_or(t).trim_start();
    let t = t.strip_prefix('(').unwrap_or(t);
    let t = t.strip_suffix(')').unwrap_or(t);
    t.trim()
}

// ---- extensions (standalone, no sub-module needed) ----

fn build_extensions(rows: &[Row]) -> Result<Vec<Extension>, CatalogError> {
    let mut out = Vec::with_capacity(rows.len());
    for r in rows {
        let q = CatalogQuery::Extensions;
        let name_str = r.get_text(q, "name")?;
        let name = Identifier::from_unquoted(&name_str)
            .map_err(|e| CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(e.to_string())))?;
        let schema_str = r.get_text(q, "schema")?;
        let schema = Identifier::from_unquoted(&schema_str)
            .map_err(|e| CatalogError::Ir(crate::ir::IrError::InvalidIdentifier(e.to_string())))?;
        let version = r.get_text(q, "version")?;
        let comment = r.get_opt_text(q, "comment")?;
        out.push(Extension {
            name,
            schema: Some(schema),
            version: Some(version),
            comment,
        });
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fk_referenced_columns_parsed() {
        let def = "FOREIGN KEY (org_id) REFERENCES app.orgs(id) ON DELETE CASCADE";
        let cols = parse_fk_referenced_columns(def).unwrap();
        assert_eq!(cols.len(), 1);
        assert_eq!(cols[0].as_str(), "id");
    }

    #[test]
    fn fk_referenced_columns_multi() {
        let def = "FOREIGN KEY (a, b) REFERENCES app.t(x, y)";
        let cols = parse_fk_referenced_columns(def).unwrap();
        assert_eq!(cols.len(), 2);
    }

    #[test]
    fn check_expression_strips_outer_check() {
        let e = parse_check_expression("CHECK ((n > 0))").unwrap();
        assert!(e.canonical_text.contains('n') || e.canonical_text.contains('>'));
    }

    #[test]
    fn strip_check_wrapper_unwraps_single_paren_form() {
        assert_eq!(strip_check_wrapper("CHECK (VALUE > 0)"), "VALUE > 0");
    }

    #[test]
    fn strip_check_wrapper_unwraps_double_paren_form() {
        // pg_get_constraintdef sometimes emits `CHECK ((expr))`; we strip
        // exactly one layer, leaving inner parens for the parser to handle.
        assert_eq!(strip_check_wrapper("CHECK ((VALUE > 0))"), "(VALUE > 0)");
    }

    #[test]
    fn strip_check_wrapper_preserves_inner_function_parens() {
        // Stripping a single trailing `)` must not eat the closing paren
        // of an inner function call.
        assert_eq!(
            strip_check_wrapper("CHECK (length(x) > 0)"),
            "length(x) > 0",
        );
    }
}