IR
Deeper dive on the data model that everything diffs against. Source for
this doc lives in crates/pgevolve-core/src/ir/.
Top-level shape
#![allow(unused)] fn main() { pub struct Catalog { pub schemas: Vec<Schema>, pub extensions: Vec<Extension>, pub tables: Vec<Table>, pub indexes: Vec<Index>, pub sequences: Vec<Sequence>, pub views: Vec<View>, pub materialized_views: Vec<MaterializedView>, pub types: Vec<UserType>, pub functions: Vec<Function>, pub procedures: Vec<Procedure>, pub triggers: Vec<Trigger>, pub default_privileges: Vec<DefaultPrivilegeRule>, } }
Flat collections, no nesting (e.g., Table::indexes) for a reason:
indexes live in their own namespace within a schema and reference their
table by qname. Hierarchical nesting would have to maintain referential
integrity at every mutation; flat lists + name-based references defer
that to canonicalization time.
Cluster-level state (Role, RoleAttributes) lives in a sibling
ClusterCatalog rather than on Catalog, since cluster surface is
managed via the separate pgevolve cluster … subcommand family. See
docs/spec/cluster.md.
The implicit name-based relationships look like this:
erDiagram
Catalog ||--o{ Schema : "schemas[]"
Catalog ||--o{ Table : "tables[]"
Catalog ||--o{ Index : "indexes[]"
Catalog ||--o{ Sequence : "sequences[]"
Catalog ||--o{ View : "views[]"
Catalog ||--o{ MaterializedView : "materialized_views[]"
Table ||--o{ Column : "columns[]"
Table ||--o{ Constraint : "constraints[]"
View ||--o{ ViewColumn : "columns[]"
MaterializedView ||--o{ ViewColumn : "columns[]"
Index }o--|| Table : "table (qname)"
Sequence }o--o| Table : "owned_by (optional)"
Schema ||--o{ Table : "qname.schema"
Schema ||--o{ Index : "qname.schema"
Schema ||--o{ Sequence : "qname.schema"
Schema ||--o{ View : "qname.schema"
Schema ||--o{ MaterializedView : "qname.schema"
Canonicalization
Catalog::canonicalize() delegates to ir::canon::canonicalize, which
runs four ordered passes:
filter_pg_defaults— IR field values that match PG's documented defaults becomeNone(sequence min/max, function cost/rows, columnpg_catalog.defaultcollation). Both source-built and catalog-readCatalogs pass through this, so a function declared withoutCOSTround-trips byte-equal with the catalog reading of the same function.sentinel_view_columns— view and materialized-view column types collapse to a sharedview_columnsentinel. Body changes are already captured bybody_canonical(an AST hash); per-column types are redundant info derived from the body.renumber_enum_sort_orders— each enum'ssort_ordervalues are re-indexed to1.0, 2.0, 3.0, …in current order. PG stores floats; source assigns sequential 1-indexed; this pass aligns the two.sort_and_dedupe— sort each collection by its canonical key (schema.name,qname, etc.); reject duplicates. Runs last so duplicate detection sees the post-normalization state.
The output is byte-stable: identical inputs always produce identical
serialized output. This is what makes PlanId deterministic.
When PG returns a default we hadn't expected, the fix lands in one of
the four passes (most commonly filter_pg_defaults). Catalog readers
and source builders are kept "raw" — they never filter — so the rule
is discoverable in one place.
Failure modes (only sort_and_dedupe is fallible):
IrError::InvalidIdentifier("duplicate schema: foo")— twoSchemas with the same name.- Same for tables / indexes / sequences / views / MVs / types / functions / procedures.
Diff derive
Most IR structs derive their Diff impl rather than hand-writing it:
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DiffMacro)] pub struct Sequence { pub qname: QualifiedName, // default strategy (Display) #[diff(via_debug)] pub data_type: ColumnType, pub start: i64, // ... #[diff(via_debug)] pub min_value: Option<i64>, // ... } }
The derive (from the pgevolve-core-macros crate, re-exported as
pgevolve_core::ir::eq::DiffMacro) supports three field attributes:
- no attribute — emit
diff_field(name, &self.x, &other.x). RequiresPartialEq + Display. #[diff(skip)]— omit the field entirely.#[diff(via_debug)]— emitdiff_field(name, format!("{:?}", ...)). ForOption<T>,Vec<T>, enums withoutDisplay.#[diff(nested)]— emitprefix_diffs(name, self.x.diff(&other.x)). For fields whose type already implementsDiff.
Hand-written impls remain for: Catalog (orchestrates diff_keyed
over many Vec<T> collections), Function (custom qname(args) key
in path), Table / View / MaterializedView (pair columns by name
with order-drift reporting), UserType (intentional dump-all on
inequality), and the enum impls (ConstraintKind, DefaultExpr,
ColumnType).
Identifier and QualifiedName
Identifier is a single SQL identifier. Two constructors:
Identifier::from_unquoted(s)— accepts[a-z_][a-z0-9_$]*shapes; rejects anything that would need quoting. This is what the parser and the user-facing config consume.Identifier::from_quoted(s)— accepts the body of a double-quoted identifier (with""→"already unescaped).
render_sql() returns the canonical SQL form, quoting only when
necessary. Two Identifiers compare equal iff their as_str()
representations match.
QualifiedName { schema, name } — schema-qualified schema.name.
Used wherever a top-level object name appears.
ColumnType
The single most fact-laden type in the IR. ColumnType is the
canonical form of a Postgres column type:
#![allow(unused)] fn main() { pub enum ColumnType { Boolean, SmallInt, Integer, BigInt, Real, DoublePrecision, Numeric { precision: Option<u16>, scale: Option<i16> }, Text, Varchar { len: Option<u32> }, Char { len: Option<u32> }, Bytea, Date, Time { precision: Option<u8>, with_tz: bool }, Timestamp { precision: Option<u8>, with_tz: bool }, Interval { fields: Option<String>, precision: Option<u8> }, Bit { len: u32, varying: bool }, Uuid, Json, Jsonb, NetAddress(NetAddressKind), Array { element: Box<ColumnType>, dims: u8 }, UserDefined(QualifiedName), Other { raw: String }, } }
Why the canonical form matters
PG accepts many spellings for the same type:
| Source spelling | Catalog form | Canonical IR |
|---|---|---|
int | integer | ColumnType::Integer |
int4 | integer | ColumnType::Integer |
decimal | numeric | ColumnType::Numeric { .. } |
bool | boolean | ColumnType::Boolean |
timestamptz | timestamp with time zone | ColumnType::Timestamp { with_tz: true, .. } |
varchar (no length) | character varying (no length) | ColumnType::Varchar { len: None } |
ColumnType::parse_from_pg_type_string does the source-side and
catalog-side normalization to the same canonical form, so diff
operates on equality rather than on textual difference.
Other and UserDefined — the escape hatches
Other { raw: String }— types pgevolve doesn't recognize. Treated as opaque strings; twoOthers match iff their raw strings match byte-for-byte. This lets the parser handle types it doesn't understand instead of aborting.UserDefined(QualifiedName)— qualified references to user-defined types (enums, domains, composites). v0.1 doesn't introspect their structure; v0.2 will.
DefaultExpr
#![allow(unused)] fn main() { pub enum DefaultExpr { Literal(LiteralValue), Sequence(QualifiedName), Expr(NormalizedExpr), } }
Literal— a typed literal (bool, integer, float, text, bytea, NULL).Sequence— detected fromnextval('schema.seq'::regclass)or the barenextval('schema.seq'). Both forms normalize to the sameQualifiedName.Expr(NormalizedExpr)— any other expression, preserved as canonical text.
NormalizedExpr
#![allow(unused)] fn main() { pub struct NormalizedExpr { pub canonical_text: String, pub ast_hash: [u8; 32], } }
The canonical text is what you get after:
- Lowercasing keywords.
- Sorting operands of commutative operators (
a + b≡b + a). - Stripping redundant casts (
'foo'::text→'foo'if the column is alreadytext). - Folding redundant parens.
Two NormalizedExprs compare equal iff their canonical_texts match.
The ast_hash is a BLAKE3 hash of the canonical text, kept for fast
equality checks and as a stable identity for an expression.
NormalizedBody
#![allow(unused)] fn main() { pub struct NormalizedBody { pub canonical_text: String, pub canonical_hash: [u8; 32], } }
NormalizedBody is the statement-scope counterpart to NormalizedExpr.
Where NormalizedExpr canonicalizes a single expression (e.g., a column
default or a CHECK predicate), NormalizedBody canonicalizes the body
of a body-bearing object — a view's SELECT statement, a function's
body, a trigger's action. The same canonicalization rules apply (keyword
case, redundant parens, etc.), plus one cross-version normalization:
for any SelectStmt whose FROM names exactly one relation, the
canonicalizer rewrites ColumnRef [<from-alias>, <col>] → [<col>].
PG14's pg_get_viewdef keeps the redundant qualifier while PG17 strips
it; this pass makes both forms equal so the differ doesn't see a
phantom body change on PG14.
In v0.1 no objects carry a body; NormalizedBody is scaffolding for
v0.2 views and functions. It is kept in pgevolve-core::parse::normalize_body
so body-bearing objects added by v0.2 sub-specs can reuse the same
diffing semantics as NormalizedExpr.
Table
#![allow(unused)] fn main() { pub struct Table { pub qname: QualifiedName, pub columns: Vec<Column>, pub constraints: Vec<Constraint>, pub partition_by: Option<PartitionBy>, // partitioned parent pub partition_of: Option<PartitionOf>, // partition child pub comment: Option<String>, // --- v0.3 cross-cutting state --------------------------------- pub owner: Option<Identifier>, // v0.3.1: None = unmanaged pub grants: Vec<Grant>, // v0.3.1: empty = no grants pub rls_enabled: bool, // v0.3.2: PG default false pub rls_forced: bool, // v0.3.2: PG default false pub policies: Vec<Policy>, // v0.3.2: RLS policies pub storage: TableStorageOptions, // v0.3.3: WITH (…) } }
The first six fields are the v0.1/v0.2 surface (structure, columns,
constraints, partitioning, comment). The last six are
v0.3 cross-cutting state —
each one follows the lenient-drift convention: Option<T> / Vec<T> /
bool where the absent / empty / false state means "unmanaged."
Index and MaterializedView carry a subset of the same v0.3 fields:
Indexaddsstorage: IndexStorageOptions.MaterializedViewaddsowner,grants, andstorage(MaterializedViewStorageOptions = TableStorageOptions).
Grant, Policy, *StorageOptions
Defined in crates/pgevolve-core/src/ir/{grant,policy,reloptions}.rs.
Grant { grantee, privileges, with_grant_option, columns: Option<Vec<Identifier>> }.columns = Some(_)is a column-level grant on a table/view/MV;Noneis object-level. Canonicalized: stable sort by(grantee, privileges).Policy { name, command, permissive, roles, using, with_check }. USING / WITH CHECK reuseNormalizedExpr(same canon as CHECK constraints). Command-kind changes (e.g.,FOR SELECT→FOR UPDATE) diff as DROP + CREATE.TableStorageOptions/IndexStorageOptions— typedOption<T>fields for the well-known reloption keys (fillfactor, autovacuum_*, parallel_workers, fastupdate, buffering, pages_per_range, …) plusextra: BTreeMap<String, String>for extension or unknown keys. f64 fields wrapNotNanF64soEq/Hash/Ordwork. Per-AM fillfactor ranges are enforced at parse time (seedocs/spec/reloptions.md).
Column attributes
#![allow(unused)] fn main() { pub struct Column { pub name: Identifier, pub ty: ColumnType, pub nullable: bool, pub default: Option<DefaultExpr>, pub identity: Option<Identity>, pub generated: Option<Generated>, pub collation: Option<QualifiedName>, pub comment: Option<String>, } }
nullable = falsecorresponds toNOT NULL— modeled as a column-level boolean rather than as aConstraintbecause it's significantly cheaper to diff.identity—GENERATED ALWAYS / BY DEFAULT AS IDENTITYwith the backing sequence options.generated—GENERATED ALWAYS AS (expr) STORED. Postgres doesn't yet supportVIRTUAL.collation— only the explicit collation; the catalog reader normalizespg_catalog.defaulttoNoneso it doesn't appear as drift on every text column.
Constraint
#![allow(unused)] fn main() { pub struct Constraint { pub qname: QualifiedName, pub kind: ConstraintKind, pub deferrable: Deferrable, pub comment: Option<String>, } pub enum ConstraintKind { PrimaryKey { columns: Vec<Identifier>, include: Vec<Identifier> }, Unique { columns: Vec<Identifier>, include: Vec<Identifier>, nulls_distinct: bool }, ForeignKey(ForeignKey), Check { expression: NormalizedExpr, no_inherit: bool }, } }
Constraints are paired by qname.name (within a table) during diff.
Two constraints with the same name but different bodies diff as a
"replace" — pgevolve emits DROP CONSTRAINT + ADD CONSTRAINT.
Index
#![allow(unused)] fn main() { pub struct Index { pub qname: QualifiedName, pub table: QualifiedName, pub method: IndexMethod, pub columns: Vec<IndexColumn>, pub include: Vec<Identifier>, pub unique: bool, pub nulls_not_distinct: bool, pub predicate: Option<NormalizedExpr>, pub tablespace: Option<Identifier>, pub comment: Option<String>, } }
Indexes are first-class IR objects (paired by their own qname, not by their backing table). This makes "rename the index" or "change the opclass on column 2" a single-row diff entry.
View and MaterializedView
Added in v0.2. Source: crates/pgevolve-core/src/ir/view.rs.
#![allow(unused)] fn main() { pub struct View { pub qname: QualifiedName, pub columns: Vec<ViewColumn>, pub body_canonical: NormalizedBody, pub body_dependencies: Vec<DepEdge>, pub security_barrier: Option<bool>, pub security_invoker: Option<bool>, pub comment: Option<String>, // raw_body: parser-internal sentinel; not serialized. } pub struct MaterializedView { pub qname: QualifiedName, pub columns: Vec<ViewColumn>, pub body_canonical: NormalizedBody, pub body_dependencies: Vec<DepEdge>, pub comment: Option<String>, // raw_body: parser-internal sentinel; not serialized. } }
body_canonical: NormalizedBody
The canonicalized SELECT body. NormalizedBody::from_sql (in
parse/normalize_body.rs) feeds the raw SQL through pg_query's
parse + deparse cycle, strips redundant table-qualifier prefixes from
column refs whose FROM names a single relation (see NormalizedBody
above), and collapses whitespace. The same function is called on the
source side (T3/T4 parse pass) and the catalog side (T5 reader, which
calls pg_get_viewdef). Because both sides go through the same
normalization, the differ compares canonical texts directly without
knowing anything about SQL semantics.
canonical_hash (BLAKE3 of the text, domain-separated with
pgevolve-normalized-body-v1\n) is kept for fast equality checks and
stable identity.
body_dependencies: Vec<DepEdge>
Dependency edges extracted from the body AST by the T4 AST canonicalization
pass (parse/ast_canon.rs). Each DepEdge has:
#![allow(unused)] fn main() { pub struct DepEdge { pub from: NodeId, // NodeId::View or NodeId::Mv pub to: NodeId, // NodeId::Table, NodeId::View, or NodeId::Mv pub source: DepSource, // DepSource::AstExtracted } }
body_dependencies is what makes the planner's dependent-recreation walk
possible (see plan/recreate_views.rs). It is also what the
view-body-references-unmanaged-schema lint rule checks.
ViewColumn
#![allow(unused)] fn main() { pub struct ViewColumn { pub name: Identifier, pub column_type: ColumnType, pub comment: Option<String>, } }
A single named column in a view or materialized view. When constructed
from the source parser (T3), column_type is set to
ColumnType::Other { raw: "unresolved" } as a sentinel; the T4 AST
canonicalization pass fills in the resolved type. When built from the
live catalog (T5), column_type is parsed from
format_type(a.atttypid, a.atttypmod).
UserType
#![allow(unused)] fn main() { pub struct UserType { pub qname: QualifiedName, pub kind: UserTypeKind, pub comment: Option<String>, } pub enum UserTypeKind { Enum { values: Vec<EnumValue> }, Domain { base: ColumnType, nullable: bool, default: Option<NormalizedExpr>, check_constraints: Vec<DomainCheck>, collation: Option<QualifiedName> }, Composite { attributes: Vec<CompositeAttribute> }, } }
UserTypes live in Catalog::types: Vec<UserType>, sorted by qname after
canonicalize(). Source lives in crates/pgevolve-core/src/ir/user_type.rs.
EnumValue
#![allow(unused)] fn main() { pub struct EnumValue { pub name: String, pub sort_order: f32, // mirrors pg_enum.enumsortorder } }
sort_order is f32 (matching Postgres's real4) to enable byte-stable
round-trip. Eq and Hash are implemented using the IEEE 754 bit pattern.
DomainCheck
#![allow(unused)] fn main() { pub struct DomainCheck { pub name: Identifier, pub expression: NormalizedExpr, } }
Domain defaults and CHECK expressions use NormalizedExpr — the same
canonicalized-text representation as column defaults and inline CHECK
constraints. Two NormalizedExprs compare equal iff their canonical_texts
match, making domain diffs insensitive to whitespace and keyword case.
CompositeAttribute
#![allow(unused)] fn main() { pub struct CompositeAttribute { pub name: Identifier, pub ty: ColumnType, pub collation: Option<QualifiedName>, } }
Function
Added in v0.2. Source: crates/pgevolve-core/src/ir/function.rs.
#![allow(unused)] fn main() { pub struct Function { pub qname: QualifiedName, pub args: Vec<FunctionArg>, pub arg_types_normalized: NormalizedArgTypes, pub return_type: ReturnType, pub language: FunctionLanguage, pub body: NormalizedBody, pub body_dependencies: Vec<DepEdge>, pub volatility: Volatility, pub strict: bool, pub security: SecurityMode, pub parallel: ParallelSafety, pub leakproof: bool, pub cost: Option<f32>, pub rows: Option<f32>, pub comment: Option<String>, } }
Functions live in Catalog::functions: Vec<Function>, sorted by (qname, arg_types_normalized) after canonicalize().
Identity rule
Function identity is (qname, arg_types_normalized). arg_types_normalized covers the IN/INOUT/VARIADIC args only (mirrors Postgres's proargtypes), enabling overloads with the same name but different input signatures to coexist.
NormalizedArgTypes stores the canonical type list and a BLAKE3 hash of the comma-joined type strings for fast equality and ordering.
body_dependencies: Vec<DepEdge>
Dependency edges extracted from the body AST by the T4 body parser (parse/builder/plpgsql.rs):
- SQL bodies: extracted from
RangeVarnodes in the SQL AST (schema-qualified references only). - PL/pgSQL bodies: extracted from static embedded SQL statements (
PLpgSQL_stmt_execsql). Dynamic SQL (EXECUTE) edges must be declared explicitly via-- @pgevolve dep: schema.namedirectives; undeclaredEXECUTEsites fire theplpgsql-dynamic-sqllint rule.
DepEdge.source is DepSource::AstExtracted for parsed edges and DepSource::AstDeclared for directive edges.
Procedure
Added in v0.2. Source: crates/pgevolve-core/src/ir/procedure.rs.
#![allow(unused)] fn main() { pub struct Procedure { pub qname: QualifiedName, pub args: Vec<FunctionArg>, pub language: FunctionLanguage, pub body: NormalizedBody, pub body_dependencies: Vec<DepEdge>, pub security: SecurityMode, pub commits_in_body: bool, pub comment: Option<String>, } }
Procedures live in Catalog::procedures: Vec<Procedure>, sorted by qname after canonicalize().
Identity rule
Procedure identity is qname only (no arg-type disambiguation). pgevolve v0.2 deliberately restricts procedures to a single definition per qualified name. This simplifies the plan-format and intent model; overloading can be added in a future sub-spec.
commits_in_body
Set to true by the PL/pgSQL body parser when it detects PLpgSQL_stmt_commit or PLpgSQL_stmt_rollback nodes anywhere in the body AST (including inside IF, LOOP, etc.). The planner uses this flag to emit the step with TransactionConstraint::OutsideTransaction, since a procedure containing COMMIT/ROLLBACK cannot run inside an outer BEGIN … COMMIT block.
Sequence
#![allow(unused)] fn main() { pub struct Sequence { pub qname: QualifiedName, pub data_type: ColumnType, // SmallInt / Integer / BigInt pub start: i64, pub increment: i64, pub min_value: Option<i64>, // None == PG type-default pub max_value: Option<i64>, pub cache: i64, pub cycle: bool, pub owned_by: Option<SequenceOwner>, pub comment: Option<String>, } }
The catalog reader normalizes PG's per-type defaults for min_value /
max_value to None (the same reasoning as collation normalization).
What's deliberately not in the IR
NOT VALIDconstraints. The IR represents only fully-validated constraints;NOT VALIDis an intermediate planner artifact.- Auto-generated index names. All indexes must be named in
source. Constraint-backing indexes are tied to the constraint, not
modeled as separate
Indexes. - Row data. pgevolve never reads or writes table contents.
postgresql.confsettings. Roles are in scope as of v0.3.0 via the separateClusterCatalog; cluster-level GUCs and tablespaces remain out of scope.pg_catalog/information_schema— unmanaged schemas don't appear in the IR.
How the diff walks the IR
Catalog::diff(other) -> Vec<Difference>:
- Pair-by-key over each top-level collection (
schemas,tables,indexes,sequences). - For paired objects, recurse into nested collections (columns, constraints).
- For each unmatched-on-the-left key, emit
present → removed. - For each unmatched-on-the-right key, emit
missing → added. - For each matched pair, recurse into the per-field diff.
The output Vec<Difference> is flat: every leaf change has a
slash-or-dot path like tables.app.users.columns.email.nullable. The
linter uses this for findings; the differ converts it into a
ChangeSet of higher-level Change enum variants.