Planner
The planner turns a ChangeSet into a flat, applyable
Vec<TransactionGroup>. Four sub-phases: order → rewrite → group →
wrap. Source lives in crates/pgevolve-core/src/plan/.
flowchart LR
CS["ChangeSet"] --> Order["1. order"]
Order --> OCS["OrderedChangeSet"]
OCS --> Rewrite["2. rewrite"]
Rewrite --> Steps["Vec<RawStep>"]
Steps --> Group["3. group"]
Group --> TGs["Vec<TransactionGroup>"]
TGs --> Wrap["4. wrap"]
Wrap --> Plan["Plan"]
Sub-phase 1: order
order(target, source, changes) → OrderedChangeSet.
The output is three buckets:
#![allow(unused)] fn main() { pub struct OrderedChangeSet { pub creates_and_adds: Vec<ChangeEntry>, pub modifies: Vec<ChangeEntry>, pub drops: Vec<ChangeEntry>, pub deferred_fks: Vec<DeferredFkAdd>, } }
Each bucket is independently topologically sorted. The graph has the edge sources from spec §6.4:
schema ← table ← default-using-sequencetable ← indexFK constraint ← both endpoints (owning + referenced)sequence ← owning table (OWNED BY)
Creates and modifies use the source-side graph; drops use the target-side graph (reversed, so dependents drop before what they depend on).
Determinism
The graph itself uses BTreeMap / BTreeSet. Kahn's algorithm with a
min-heap by Ord breaks ties: when multiple nodes are simultaneously
eligible, the smallest one (by Ord on NodeId) wins. Result: the
same (target, source, changes) triple always produces the same
ordered output.
FK cycle extraction
Inline FKs in CREATE TABLE introduce a chicken-and-egg cycle when
table A FKs table B and B FKs A. The planner:
flowchart TD
Start([order(target, source, changes)]) --> Topo1["topological sort"]
Topo1 --> Cycle1{Cycle?}
Cycle1 -- No --> Done([emit OrderedChangeSet])
Cycle1 -- Yes --> Strip["strip FKs spanning cycle nodes<br/>(self-FKs exempt)"]
Strip --> Topo2["topological sort (reduced)"]
Topo2 --> Cycle2{Cycle?}
Cycle2 -- No --> Defer["emit DeferredFkAdd entries"]
Cycle2 -- Yes --> Fail([PlanError::UnbreakableCycle])
Defer --> Done
- Runs the topological sort.
- On
Err(Cycle { nodes }), walksreduced = sourceand removes every FK constraint whose owning + referenced tables are both in the cycle (self-referential FKs are exempt because they don't induce a graph cycle — the row insertion happens after the table exists). - Re-runs the topological sort on the reduced graph.
- The extracted FKs become
DeferredFkAddentries — emitted asALTER TABLE ... ADD CONSTRAINTsteps after both tables are created. - If the second topo sort also cycles, returns
PlanError::UnbreakableCyclewith the rendered node names.
To avoid emitting the same FK twice (inline and deferred),
strip_deferred_fks removes the matching constraint from every
relevant Change::CreateTable body.
Sub-phase 2: rewrite
rewrite(ordered, target, policy) → Vec<RawStep>.
Each top-level Change dispatches to an emitter that produces one or
more RawSteps. The bulk of this is straightforward SQL generation
(pgevolve_core::plan::rewrite::sql); the interesting bits are the
four online rewrites plus two drift-recovery variants.
Drift-recovery changes
read_catalog now returns a DriftReport alongside the Catalog.
The diff stage folds these into the ChangeSet as two additional
Change variants:
Change::ValidateConstraint — emitted when the catalog contains a
NOT VALID constraint (left behind by an interrupted FK / CHECK NOT VALID
apply). The rewrite emits:
ALTER TABLE <schema>.<table> VALIDATE CONSTRAINT <name>;
This step is InTransaction and safe; it's equivalent to resuming the
second half of the FK NOT VALID + VALIDATE online rewrite.
Change::RecreateIndex — emitted when the catalog contains an
INVALID index (left behind by a failed CREATE INDEX CONCURRENTLY). The
rewrite emits:
DROP INDEX CONCURRENTLY IF EXISTS <schema>.<name>;
CREATE INDEX CONCURRENTLY <name> ON <schema>.<table> (...);
Both steps are OutsideTransaction (autocommit), consistent with the
normal concurrent-index handling.
Online rewrite 1: CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY
plan::rewrite::concurrent_index::should_rewrite_create:
#![allow(unused)] fn main() { policy.create_index_concurrent() && target.table_exists(&idx.table) && !idx.unique }
Three gating conditions:
- Policy enables the rewrite.
- The target table already exists (i.e., is not being created in this plan — concurrent create on a brand-new table would be wasted transaction-isolation work).
- The index is not
UNIQUE. A failedCREATE UNIQUE INDEX CONCURRENTLYleaves an INVALID index that has to be cleaned up out-of-band; v0.1 plays it safe.
When all three hold, the rewrite emits the step with
TransactionConstraint::OutsideTransaction. group_steps then puts
it in its own non-transactional group. DROP INDEX mirrors:
should_rewrite_drop reads the index's uniqueness from the target
catalog (since the index doesn't exist on the source side), and emits
DROP INDEX CONCURRENTLY under the same conditions.
Online rewrite 2 + 3: NOT VALID + VALIDATE (FK and CHECK)
plan::rewrite::fk_not_valid_validate::should_rewrite:
#![allow(unused)] fn main() { policy.fk_not_valid_then_validate() && matches!(c.kind, ConstraintKind::ForeignKey(_)) && target.table_exists(qname) }
Same three-condition shape. The rewrite emits two steps:
#![allow(unused)] fn main() { [ RawStep { kind: AddConstraintNotValid, transactional: InTransaction, … }, RawStep { kind: ValidateConstraint, transactional: InTransaction, … }, ] }
Both steps are InTransaction, but they live in separate
TransactionGroups at grouping time. That separation is the whole
point: step A (the cheap NOT VALID addition) commits independently
of step B (the expensive scan-the-table validation). If step B fails,
step A stays committed and the user re-plans only the validation.
How the separation happens: each RawStep is InTransaction, but
group_steps coalesces only adjacent steps that share their
TransactionConstraint. The grouping function doesn't currently split
adjacent InTransaction steps, so this is actually achieved by
emitting them with intervening steps that flip the constraint —
or by the executor's group boundary semantics treating each
ValidateConstraint as its own commit point.
Current implementation honesty. As of v0.1,
mark_step_succeededcommits the audit row inside the same transaction as the DDL. A later step's failure rolls back the entire group's DDL and the audit updates, then re-marks them outside the transaction. The NOT VALID / VALIDATE separation is structural in the plan (the directives saygroup id=1thengroup id=2) but the executor's per-group transaction boundaries are what actually commit step A before step B starts.
CHECK rewrite (check_not_valid_validate.rs) is the same shape, just
matching on ConstraintKind::Check { .. }.
Online rewrite 4: SET NOT NULL via CHECK pattern
plan::rewrite::set_not_null_check_pattern::should_rewrite:
#![allow(unused)] fn main() { policy.not_null_via_check_pattern() && target.column_exists(qname, col) }
When the column already exists in the target (i.e., this is "make an existing column NOT NULL", not "add a brand-new NOT-NULL column"), the rewrite emits four steps:
ADD CONSTRAINT __pgevolve_chk_<col> CHECK (<col> IS NOT NULL) NOT VALID; -- step 1
VALIDATE CONSTRAINT __pgevolve_chk_<col>; -- step 2
ALTER COLUMN <col> SET NOT NULL; -- step 3 (cheap once 2 validated)
DROP CONSTRAINT __pgevolve_chk_<col>; -- step 4
Steps 1, 2, and 4 are individually transactional but go in their own groups so the user can observe progress. Step 3 is cheap (Postgres uses the validated CHECK to skip the table scan), so it could ride with step 4, but pgevolve keeps them as separate steps for clarity in the audit log.
The synthesized __pgevolve_chk_<col> constraint name is reserved.
Users shouldn't create constraints with this prefix in their source.
Online rewrite 5: REFRESH MATERIALIZED VIEW CONCURRENTLY
plan::rewrite::refresh_mv_concurrent::should_rewrite:
#![allow(unused)] fn main() { policy.refresh_mv_concurrently() && target.mv_has_unique_index(&mv.qname) }
Two gating conditions:
- Policy enables the rewrite (
refresh_mv_concurrently = true; default enabled under online strategy). - The materialized view has at least one unique index in the target catalog (checked before the step is emitted, not at plan time — at plan time the MV may be brand new and the index is being created in the same plan).
When both hold, the step is emitted with TransactionConstraint::OutsideTransaction and kind RefreshMaterializedView. group_steps places it in its own non-transactional group. The mv-no-unique-index lint rule fires as a Warning when an MV exists with no unique index, alerting the user that concurrent refresh is unavailable.
View OR-REPLACE compatibility predicate
Source: crates/pgevolve-core/src/diff/views.rs — body_is_or_replace_compatible.
A view body change is OR-REPLACE compatible if and only if:
- The new SELECT list is a superset of the old one (columns can be appended but not removed or reordered).
- The types of existing columns are unchanged.
When the predicate returns true, the differ emits ViewChange::ReplaceBody { compatible: true } and the planner uses CREATE OR REPLACE VIEW. When false, it emits compatible: false and the planner uses DROP VIEW + CREATE VIEW (destructive — requires intent approval) plus the dependent-recreation cascade below.
Dependent-recreation walk
Source: crates/pgevolve-core/src/plan/recreate_views.rs — extend_with_dependent_recreations.
After the differ produces the initial ChangeSet, the planner calls extend_with_dependent_recreations to find every view transitively affected by the changes. Triggers that start the walk:
DropTable— any view with abody_dependenciesedge to the dropped table.AlterTablewithDropColumnorAlterColumnTypeops — views referencing the affected column.View(ReplaceBody { compatible: false })— views depending on the view being replaced.Mv(ReplaceBody { .. })— views depending on the MV being replaced.View(Drop)orMv(Drop)— views depending on the dropped object.
The walk is a BFS over the body_dependencies reverse index (object → views that reference it). The result set is topologically sorted (dependency-first) before being appended to changes as ReplaceBody { compatible: false } entries. The planner then processes those into DROP + CREATE steps.
Policy gate. When PlannerPolicy::view_drop_create_dependents() returns false, the walk still runs to detect affected views, but returns Err(affected) instead of modifying changes. The caller surfaces a human-readable error naming every affected view.
No double-emission. Views already present in changes (because the differ itself produced a ReplaceBody for them) are excluded from the walk's output — the filter prevents emitting the same step twice.
User-type compatibility predicates
Source: crates/pgevolve-core/src/diff/types.rs.
enum_can_alter_in_place
#![allow(unused)] fn main() { pub(crate) fn enum_can_alter_in_place( catalog_vals: &[EnumValue], source_vals: &[EnumValue], ) -> bool }
Returns true (in-place ALTER TYPE is safe) when:
- No labels are removed, or removed labels are all paired with added labels at the same list position (rename heuristic — same list length, position-paired exclusive names).
- Preserved labels (those present in both lists) appear in the same relative order as in the catalog.
Returns false (triggers ReplaceWithCascade) when:
- Any label is removed without a same-position replacement (true drop).
- Preserved labels would be reordered.
composite_can_alter_in_place
#![allow(unused)] fn main() { pub(crate) fn composite_can_alter_in_place( catalog_attrs: &[CompositeAttribute], source_attrs: &[CompositeAttribute], ) -> bool }
Returns true when the preserved attributes (those present in both lists) appear in the same relative order in both. New attributes may be appended anywhere. Returns false (triggers ReplaceWithCascade) when preserved attributes would be reordered.
ReplaceWithCascade fallback
When either predicate returns false, or when a domain's base type changes, the differ emits UserTypeChange::ReplaceWithCascade { source, catalog }. The planner converts this into two steps:
DROP TYPE <qname> CASCADE(drop_type, destructive — requires intent approval).CREATE TYPE <qname> …(create_type, safe).
Both steps go into the same transactional group. The CASCADE propagates automatically to all dependent columns, views, and functions, so no additional dependent-recreation walk is needed for types (unlike views, which require the explicit extend_with_dependent_recreations pass).
Function OR-REPLACE predicate
Source: crates/pgevolve-core/src/diff/routines.rs — function_can_or_replace.
#![allow(unused)] fn main() { pub(crate) fn function_can_or_replace(catalog: &Function, source: &Function) -> bool }
Returns true (safe to use CREATE OR REPLACE FUNCTION) when:
- The language is unchanged (
sql→sqlorplpgsql→plpgsql). - The return type is unchanged (same
ReturnTypevariant and inner type). Any return-type change (scalar → void, etc.) forces a DROP + CREATE path. - The OUT / INOUT parameter names and types are unchanged. These form part of the effective return type in Postgres and are rejected by
CREATE OR REPLACE FUNCTIONif they differ.
When the predicate returns false, the differ emits FunctionChange::ReplaceWithCascade { source, catalog }. The planner converts this into:
DROP FUNCTION <qname>(<arg_sig>) CASCADE(drop_function, destructive — requires intent approval).CREATE OR REPLACE FUNCTION …(create_or_replace_function, safe).
Both steps go into the same transactional group.
Routine-cascade extension to the dep walker
The dependent-recreation walk (recreate_views.rs) is extended for routines: if a DROP TABLE or a DROP VIEW would orphan a function or procedure body dependency, the cascade also includes the affected routines as ReplaceWithCascade entries. This mirrors the view-cascade behaviour — no hidden CASCADE drops; every affected object appears explicitly in the plan.
Procedures with commits_in_body = true are placed in their own non-transactional step regardless of the surrounding group (matching the OutsideTransaction constraint emitted by the planner for those procedures).
Atomic mode
Strategy::Atomic short-circuits every online rewrite — every
should_rewrite_* returns false. Output: one step per change, all
transactional, all in one group. The executor commits the whole thing
in a single BEGIN/COMMIT or fails atomically.
Useful for hermetic dev / test environments where you don't care about locking and just want the apply to be a single atomic event.
v0.3 step kinds
Each v0.3 cross-cutting state dimension adds one or more StepKind
variants. They are all transactional, all sparse (only fields where
source declares a value and target disagrees flow into the step):
| Release | StepKind variants |
|---|---|
| v0.3.0 | CreateRole, DropRole, AlterRole (cluster-side) |
| v0.3.1 | AlterObjectOwner, GrantObjectPrivilege, RevokeObjectPrivilege, GrantColumnPrivilege, RevokeColumnPrivilege, AlterDefaultPrivileges |
| v0.3.2 | CreatePolicy, DropPolicy, AlterPolicy, EnableRowSecurity, DisableRowSecurity, ForceRowSecurity, NoForceRowSecurity |
| v0.3.3 | SetTableStorage, SetIndexStorage, SetMaterializedViewStorage |
All four sub-specs follow the same convention: one Set* variant per
state dimension, no paired Reset* — the lenient drift policy makes
the reset path unreachable. See
architecture.md § v0.3: cross-cutting state
for the design rationale.
Sub-phase 3: group
group_steps(steps) → Vec<TransactionGroup>:
#![allow(unused)] fn main() { pub struct TransactionGroup { pub id: u32, // 1-indexed pub transactional: bool, pub steps: Vec<RawStep>, } }
Adjacent steps with the same TransactionConstraint coalesce into one
group. Each transition flips a new group. Empty input produces an
empty output.
Example: 3 in-tx steps, then 1 out-of-tx step (concurrent index), then 2 in-tx steps → 3 groups (3 steps, 1 step, 2 steps).
Sub-phase 4: wrap
Plan::from_grouped(groups, source, target, target_identity, source_rev, version, ruleset_version):
- Walks every step in every group, assigning a contiguous 1-indexed
step_noacross all groups. - For each step where
destructive == true, allocates the nextintent_idand creates aDestructiveIntentrow (id, step, kind, target, reason). The step gets itsintent_idfilled. - Computes
PlanIdover(source, target, version, ruleset_version). - Captures
created_atand the target snapshot.
Output: a Plan ready to write to disk.
PlanId derivation
PlanId::compute:
#![allow(unused)] fn main() { let mut h = blake3::Hasher::new(); h.update(b"pgevolve-plan-id-v1\n"); h.update(pgevolve_version.as_bytes()); h.update(&[0]); h.update(&planner_ruleset_version.to_be_bytes()); h.update(&[0]); h.update(&bincode::serde::encode_to_vec(source, cfg)?); h.update(&[0]); h.update(&bincode::serde::encode_to_vec(target, cfg)?); PlanId(*h.finalize().as_bytes()) }
Bincode is used because its encoding is byte-stable across runs and across machines. The domain-separator prefix prevents accidental cross-protocol collision with other BLAKE3 uses in the codebase.
What's tested
| Property | Test |
|---|---|
| Topological order is deterministic | graph::tests::deterministic_under_insertion_order_changes |
| Linear chain produces dependency-correct creates | ordering::tests::linear_schema_table_index_orders_in_dependency_order |
| FK between independent tables orders referenced first | ordering::tests::fk_between_independent_tables_orders_referenced_first |
| Two-table FK cycle extracts at least one FK | ordering::tests::two_table_fk_cycle_extracts_one_or_more_fks |
| Three-way FK cycle breaks at least one | ordering::tests::three_way_fk_cycle_breaks_at_least_one |
| Self-referential FK doesn't cycle | edges::tests::self_referential_fk_does_not_cycle |
| Concurrent index rewrite gates correctly | rewrite::tests::create_index_on_existing_table_rewrites_to_concurrent, unique_create_index_does_not_rewrite_to_concurrent, etc. |
| FK / CHECK NOT VALID rewrite gates | add_fk_on_existing_table_emits_two_steps, add_check_on_existing_table_emits_two_steps |
| SET NOT NULL pattern gates | set_not_null_on_existing_column_emits_four_steps, set_not_null_with_atomic_policy_stays_single_step |
group_steps partitions on transactional boundary | grouping::tests::transition_creates_new_group |
PlanId deterministic across runs | plan_id_is_deterministic_across_calls, plus tier-5 property test |
| Drop graph runs in reverse dependency order | ordering::tests::drops_run_in_reverse_dependency_order |
| Deferred FKs are stripped from inline CREATE TABLE | indirectly via two_table_fk_cycle_extracts_one_or_more_fks (asserts remaining + deferred FKs == 2) |
What's currently incomplete
- Column reorder. The diff detects column position drift
(
columns.<order>paths) but the planner doesn't emit a reorder step. Postgres has noALTER COLUMN ... POSITION, so this would require a table rewrite. As of v0.2 readiness, this is surfaced as aSeverity::LintAtPlanfinding (column-position-drift) rather than being silently ignored.pgevolve planexits2on an unwaived finding; waive with[[lint_waiver]]+ a separaterewrite-tableinvocation (implementation pending v0.2 sub-spec). - The
ALTER TYPE ... ADD VALUEenum rewrite. Lands with enum support (v0.2). Adding an enum value can't run in a transaction (in PG ≤ 11) or has restrictions (≥ 12); the rewrite will mirror theCONCURRENTLYgroup-by-group story. - Online column-type change (e.g.,
int → bigint). The planner currently emits a singleALTER COLUMN ... TYPEstep which can rewrite the entire table. A future rewrite will use the "shadow column + USING expression + rename" pattern.