Skip to content

Commit 3c178e1

Browse files
committed
fix(delta): address critical/warning findings from multi-perspective review
Three bugs surfaced by the /review pass, each with a regression test: 1. RenameTable unusable on follow-up codegen. After a rename, the new delta (written to the new table dir) is the only file in that dir; reducing it raises ConflictError because state.table (set from empty_state to the new name) doesn't match op.old_table. Fix: in delta mode, move the old table's delta files into the new dir before orphan removal, and make CreateTable.apply_op populate state.table from op.table so the full history (CreateTable + ops with old name) reduces through the RenameTable cleanly. 2. SetCreateTableOptions was a no-op — squash silently dropped user-set storage options like `WITH (fillfactor=70)`. Fix: apply_op now writes create_table_options to state, CreateTable captures it too, and empty_state/1 exposes the key so squash's `initial_operations_for_state` re-emits it. 3. state.empty? was stale after pseudo-op mutations (SetBaseFilter / SetCreateTableOptions / SetHasCreateAction). state_empty?/1 ignored those fields entirely. Fix: each pseudo-op's apply_op now sets empty?: false, and state_empty?/1 factors in base_filter / create_table_options / has_create_action when computing emptiness at load-time. Also consolidates squash_snapshots.ex's inline initial-state map with Reducer.empty_state/1 to prevent the three copies from drifting — this was the underlying cause of the `:create_table_options` KeyError in the existing squash test after fix #2. Tests added: * test/snapshot_delta_test.exs — unit tests for create_table_options round-trip through Codec + Reducer + initial_operations_for_state, SetCreateTableOptions state propagation, and state_empty?/SetBaseFilter consistency. * test/delta_mode_integration_test.exs — full lifecycle rename + follow-up codegen test that reduces the new table dir and asserts state is coherent.
1 parent e8649e3 commit 3c178e1

5 files changed

Lines changed: 319 additions & 23 deletions

File tree

lib/migration_generator/migration_generator.ex

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,14 @@ defmodule AshPostgres.MigrationGenerator do
566566
create_new_snapshot(snapshots_with_operations, repo_name(repo), opts, tenant?)
567567
)
568568
|> then(fn files ->
569+
# In delta mode, a rename must move the OLD table's delta files into
570+
# the NEW table's directory so the reducer sees the full history
571+
# (initial deltas + RenameTable) when it walks the new dir next
572+
# codegen. After this move the old dir is empty and the
573+
# subsequent `remove_orphan_snapshots` simply deletes the empty
574+
# shell. In full-state mode there's only one snapshot file per
575+
# table, so the rename does not require a move.
576+
move_rename_snapshots(rename_map, repo, opts)
569577
remove_orphan_snapshots(orphan_snapshots, opts)
570578
files
571579
end)
@@ -3661,6 +3669,56 @@ defmodule AshPostgres.MigrationGenerator do
36613669
end
36623670
end
36633671

3672+
defp move_rename_snapshots(rename_map, repo, opts) do
3673+
if opts.dry_run || opts.check || opts.snapshots_only || rename_map == %{} do
3674+
:ok
3675+
else
3676+
if snapshot_format(opts, repo) == :delta do
3677+
Enum.each(rename_map, fn {{new_table, new_schema},
3678+
%{old_table: old_table, old_schema: old_schema, snapshot: snap}} ->
3679+
old_snapshot = %{
3680+
table: old_table,
3681+
schema: old_schema,
3682+
repo: repo,
3683+
multitenancy: snap.multitenancy
3684+
}
3685+
3686+
new_snapshot = %{
3687+
table: new_table,
3688+
schema: new_schema,
3689+
repo: repo,
3690+
multitenancy: snap.multitenancy
3691+
}
3692+
3693+
old_folder = get_snapshot_folder(old_snapshot, opts)
3694+
new_folder = get_snapshot_folder(new_snapshot, opts)
3695+
old_dir = get_snapshot_path(old_snapshot, old_folder)
3696+
new_dir = get_snapshot_path(new_snapshot, new_folder)
3697+
3698+
if File.exists?(old_dir) do
3699+
File.mkdir_p!(new_dir)
3700+
3701+
old_dir
3702+
|> File.ls!()
3703+
|> Enum.each(fn file ->
3704+
src = Path.join(old_dir, file)
3705+
dst = Path.join(new_dir, file)
3706+
3707+
# Skip if destination already exists — should not happen given
3708+
# rename detection only fires when the new dir is empty, but be
3709+
# defensive to avoid clobbering.
3710+
if !File.exists?(dst) do
3711+
File.rename!(src, dst)
3712+
end
3713+
end)
3714+
end
3715+
end)
3716+
end
3717+
3718+
:ok
3719+
end
3720+
end
3721+
36643722
defp resolve_renames(_table, adding, [], _opts), do: {adding, [], []}
36653723

36663724
defp resolve_renames(_table, [], removing, _opts), do: {[], removing, []}

lib/migration_generator/reducer.ex

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ defmodule AshPostgres.MigrationGenerator.Reducer do
102102
base_filter: nil,
103103
has_create_action: true,
104104
drop_table_opted_out: false,
105+
create_table_options: nil,
105106
empty?: true,
106107
multitenancy: %{attribute: nil, strategy: nil, global: nil}
107108
}
@@ -112,8 +113,14 @@ defmodule AshPostgres.MigrationGenerator.Reducer do
112113
# =================================================================
113114

114115
defp state_empty?(state) do
116+
# Any of these indicate non-emptiness:
117+
# - collections: attributes, identities, indexes, statements, constraints
118+
# - scalar pseudo-op fields: base_filter, create_table_options
119+
# - has_create_action flipped off from the default
115120
state.attributes == [] and state.identities == [] and state.custom_indexes == [] and
116-
state.custom_statements == [] and state.check_constraints == []
121+
state.custom_statements == [] and state.check_constraints == [] and
122+
is_nil(state.base_filter) and is_nil(state.create_table_options) and
123+
state.has_create_action == true
117124
end
118125

119126
defp snapshot_directory(snapshot, opts) do
@@ -208,12 +215,20 @@ defmodule AshPostgres.MigrationGenerator.Reducer do
208215
def apply_op(state, %Operation.CreateTable{} = op) do
209216
# Deltas are persisted in migration-emission order, which places CreateTable
210217
# AFTER the AddAttribute ops that populate the table. Applying it is
211-
# therefore a metadata refresh — it sets the schema, multitenancy, and
212-
# repo context but does not require the state to be empty.
218+
# therefore a metadata refresh — it sets the table, schema, multitenancy,
219+
# create_table_options, and repo context but does not require the state
220+
# to be empty.
221+
#
222+
# Setting state.table from op.table is important for the rename chain:
223+
# a subsequent RenameTable op compares state.table to op.old_table, so
224+
# state.table must start from the delta's original table name (which may
225+
# differ from the current resource's table).
213226
%{
214227
state
215-
| schema: op.schema,
228+
| table: op.table,
229+
schema: op.schema,
216230
multitenancy: op.multitenancy,
231+
create_table_options: op.create_table_options,
217232
empty?: false
218233
}
219234
|> maybe_put(:repo, op.repo)
@@ -361,12 +376,14 @@ defmodule AshPostgres.MigrationGenerator.Reducer do
361376
%{state | check_constraints: Enum.reject(state.check_constraints, &(&1.name == c.name))}
362377
end
363378

364-
def apply_op(state, %Operation.SetBaseFilter{new_value: v}), do: %{state | base_filter: v}
379+
def apply_op(state, %Operation.SetBaseFilter{new_value: v}),
380+
do: %{state | base_filter: v, empty?: false}
365381

366382
def apply_op(state, %Operation.SetHasCreateAction{new_value: v}),
367-
do: %{state | has_create_action: v}
383+
do: %{state | has_create_action: v, empty?: false}
368384

369-
def apply_op(state, %Operation.SetCreateTableOptions{new_value: _v}), do: state
385+
def apply_op(state, %Operation.SetCreateTableOptions{new_value: v}),
386+
do: %{state | create_table_options: v, empty?: false}
370387

371388
def apply_op(state, %Operation.OptOutDropTable{}),
372389
do: %{state | drop_table_opted_out: true}

lib/mix/tasks/ash_postgres.squash_snapshots.ex

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -203,22 +203,17 @@ defmodule Mix.Tasks.AshPostgres.SquashSnapshots do
203203
end
204204

205205
defp apply_squash({_folder, snapshots, _last_snapshot, into_snapshot, :delta}, _opts) do
206-
# Reduce all deltas in timestamp order.
207-
initial_state = %{
208-
attributes: [],
209-
identities: [],
210-
schema: nil,
211-
custom_indexes: [],
212-
custom_statements: [],
213-
check_constraints: [],
214-
table: nil,
215-
repo: nil,
216-
base_filter: nil,
217-
has_create_action: true,
218-
drop_table_opted_out: false,
219-
empty?: true,
220-
multitenancy: %{attribute: nil, strategy: nil, global: nil}
221-
}
206+
# Reduce all deltas in timestamp order. Start from the canonical empty
207+
# state so every field the reducer might touch is pre-populated — in
208+
# particular fields like `:create_table_options` that `apply_op` updates
209+
# via map-update syntax (`%{state | key: v}`), which would raise KeyError
210+
# on a minimal map.
211+
initial_state =
212+
AshPostgres.MigrationGenerator.Reducer.empty_state(%{
213+
table: nil,
214+
schema: nil,
215+
repo: nil
216+
})
222217

223218
state =
224219
snapshots

test/delta_mode_integration_test.exs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,90 @@ defmodule AshPostgres.DeltaModeIntegrationTest do
459459
op.old_table == "delta_posts_g_old" and op.new_table == "delta_posts_g_new"
460460
end)
461461
end
462+
463+
test "renaming a table, reducing, and generating a follow-up delta succeeds", %{
464+
snapshot_path: sp,
465+
migration_path: mp
466+
} do
467+
# Regression test: the previous `test "renaming a table produces a
468+
# RenameTable op"` only asserts the op is written into the new dir. It
469+
# never reduces that dir afterwards. Today, reducing fails because the
470+
# initial deltas stayed in the OLD dir, so the new dir starts from
471+
# `empty_state` with table=new but the first op it sees is
472+
# RenameTable{old, new} — state.table ≠ op.old_table → ConflictError.
473+
#
474+
# This test exercises the full lifecycle: rename → codegen with an
475+
# additional attribute → assert the reducer sees the combined state.
476+
defresource DeltaRenameRegen do
477+
postgres do
478+
table("delta_rename_regen_old")
479+
repo(AshPostgres.TestRepo)
480+
end
481+
482+
actions do
483+
defaults([:create, :read, :update, :destroy])
484+
end
485+
486+
attributes do
487+
uuid_primary_key(:id)
488+
attribute(:title, :string, public?: true)
489+
end
490+
end
491+
492+
defdomain([DeltaRenameRegen])
493+
run_codegen(Domain, sp, mp)
494+
495+
defresource DeltaRenameRegen do
496+
postgres do
497+
table("delta_rename_regen_new")
498+
repo(AshPostgres.TestRepo)
499+
end
500+
501+
actions do
502+
defaults([:create, :read, :update, :destroy])
503+
end
504+
505+
attributes do
506+
uuid_primary_key(:id)
507+
attribute(:title, :string, public?: true)
508+
end
509+
end
510+
511+
defdomain([DeltaRenameRegen])
512+
# "Yes" to "Are you renaming delta_rename_regen_old to delta_rename_regen_new?"
513+
send(self(), {:mix_shell_input, :yes?, true})
514+
run_codegen(Domain, sp, mp)
515+
516+
# Now add a column. Codegen must reduce the existing deltas — which
517+
# today blows up in Reducer.apply_op(%Operation.RenameTable{}, state)
518+
# because state.table != op.old_table.
519+
defresource DeltaRenameRegen do
520+
postgres do
521+
table("delta_rename_regen_new")
522+
repo(AshPostgres.TestRepo)
523+
end
524+
525+
actions do
526+
defaults([:create, :read, :update, :destroy])
527+
end
528+
529+
attributes do
530+
uuid_primary_key(:id)
531+
attribute(:title, :string, public?: true)
532+
attribute(:body, :string, public?: true)
533+
end
534+
end
535+
536+
defdomain([DeltaRenameRegen])
537+
run_codegen(Domain, sp, mp)
538+
539+
state = reduce_dir(sp, "delta_rename_regen_new")
540+
sources = Enum.map(state.attributes, & &1.source)
541+
assert :id in sources
542+
assert :title in sources
543+
assert :body in sources
544+
assert state.table == "delta_rename_regen_new"
545+
end
462546
end
463547

464548
# ===================================================================

0 commit comments

Comments
 (0)