Skip to content

Commit 9082e68

Browse files
committed
test(delta): lock in behavior around simplifications
Self-audit turned up two undertested behavioral diffs in the simplify pass. Addressed: 1. RenameAttribute check ORDER: the pre-refactor version checked "source present" first and "destination free" second. My helper-based refactor accidentally flipped these, producing a "destination already exists" error in the corner case where both source was missing AND destination existed. Restored the original order (inline, since replace_in_coll doesn't fit the two-phase check pattern) and added a regression test that locks the order. 2. `state.empty?` after add-then-remove-all: the simplify pass dropped the `state_empty?/1` final-override in `load_reduced_state/2`. This changed the semantics from "are all collections currently empty" to "was any op applied" — a real behavioral diff. This is intentional and more correct for the one consumer (`pkey_operations`), but there was no test that would catch a regression. Added one. Also added tests for previously-unexercised paths: * AddCustomStatement / RemoveCustomStatement apply_op (exercises the new `add_to_coll`/`remove_from_coll` helpers against the `:custom_statements` collection). * RenameAttribute destination-exists happy path (checks the second branch after source is found). 28 tests total, all passing.
1 parent 30261f9 commit 9082e68

2 files changed

Lines changed: 169 additions & 7 deletions

File tree

lib/migration_generator/reducer.ex

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -235,14 +235,24 @@ defmodule AshPostgres.MigrationGenerator.Reducer do
235235
old_attribute: old_attr,
236236
new_attribute: new_attr
237237
}) do
238-
if old_attr.source != new_attr.source and
239-
Enum.any?(state.attributes, &(&1.source == new_attr.source)) do
240-
throw_conflict("RenameAttribute: destination #{inspect(new_attr.source)} already exists")
241-
end
238+
# Preserve the check order from the pre-refactor version: missing source
239+
# is reported before existing destination. Callers and error-matching tests
240+
# rely on the "source not present" message for the common misuse case of
241+
# applying a rename against a mutated state.
242+
case Enum.find_index(state.attributes, &(&1.source == old_attr.source)) do
243+
nil ->
244+
throw_conflict("RenameAttribute: source #{inspect(old_attr.source)} not present")
245+
246+
idx ->
247+
if old_attr.source != new_attr.source and
248+
Enum.any?(state.attributes, &(&1.source == new_attr.source)) do
249+
throw_conflict(
250+
"RenameAttribute: destination #{inspect(new_attr.source)} already exists"
251+
)
252+
end
242253

243-
replace_in_coll(state, :attributes, &(&1.source == old_attr.source), new_attr, fn ->
244-
"RenameAttribute: source #{inspect(old_attr.source)} not present"
245-
end)
254+
%{state | attributes: List.replace_at(state.attributes, idx, new_attr)}
255+
end
246256
end
247257

248258
def apply_op(state, %Operation.AddUniqueIndex{identity: identity}),

test/snapshot_delta_test.exs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,158 @@ defmodule AshPostgres.SnapshotDeltaTest do
318318

319319
assert {:threw, "RemoveAttribute: attribute :email not present"} = caught
320320
end
321+
322+
test "RenameAttribute: missing source is reported BEFORE existing destination",
323+
%{snapshot: snapshot} do
324+
# Regression test for post-refactor check ordering. Before the helper
325+
# collapse, the reducer checked "source present" first and "destination
326+
# free" second. If both were violated (source missing + destination
327+
# present), the old message was "source :x not present". Make sure the
328+
# refactor didn't flip the order.
329+
state =
330+
snapshot
331+
|> Reducer.empty_state()
332+
|> Reducer.apply_op(%Operation.AddAttribute{
333+
table: snapshot.table,
334+
schema: nil,
335+
multitenancy: @base_multitenancy,
336+
old_multitenancy: @base_multitenancy,
337+
attribute: @text_attribute
338+
})
339+
340+
caught =
341+
try do
342+
# Destination (:email) exists, source (:nonexistent_src) doesn't.
343+
Reducer.apply_op(state, %Operation.RenameAttribute{
344+
table: snapshot.table,
345+
schema: nil,
346+
multitenancy: @base_multitenancy,
347+
old_multitenancy: @base_multitenancy,
348+
old_attribute: %{@text_attribute | source: :nonexistent_src},
349+
new_attribute: @text_attribute
350+
})
351+
352+
:no_error
353+
catch
354+
:throw, {:reducer_error, reason} -> {:threw, reason}
355+
end
356+
357+
assert {:threw, "RenameAttribute: source :nonexistent_src not present"} = caught
358+
end
359+
360+
test "RenameAttribute: existing destination throws after source-found check",
361+
%{snapshot: snapshot} do
362+
# Separate clause: source exists, but destination collision.
363+
other_attr = %{@text_attribute | source: :other_col}
364+
365+
state =
366+
snapshot
367+
|> Reducer.empty_state()
368+
|> Reducer.apply_op(%Operation.AddAttribute{
369+
table: snapshot.table,
370+
schema: nil,
371+
multitenancy: @base_multitenancy,
372+
old_multitenancy: @base_multitenancy,
373+
attribute: @text_attribute
374+
})
375+
|> Reducer.apply_op(%Operation.AddAttribute{
376+
table: snapshot.table,
377+
schema: nil,
378+
multitenancy: @base_multitenancy,
379+
old_multitenancy: @base_multitenancy,
380+
attribute: other_attr
381+
})
382+
383+
caught =
384+
try do
385+
Reducer.apply_op(state, %Operation.RenameAttribute{
386+
table: snapshot.table,
387+
schema: nil,
388+
multitenancy: @base_multitenancy,
389+
old_multitenancy: @base_multitenancy,
390+
old_attribute: @text_attribute,
391+
new_attribute: other_attr
392+
})
393+
394+
:no_error
395+
catch
396+
:throw, {:reducer_error, reason} -> {:threw, reason}
397+
end
398+
399+
assert {:threw, "RenameAttribute: destination :other_col already exists"} = caught
400+
end
401+
402+
test "AddCustomStatement / RemoveCustomStatement round-trip through apply_op",
403+
%{snapshot: snapshot} do
404+
# Apply_op path for these ops wasn't previously exercised by a unit
405+
# test. Covering the helper-based add/remove plumbing explicitly.
406+
statement = %{name: :enable_trgm, up: "create extension pg_trgm", down: "drop extension pg_trgm"}
407+
408+
state =
409+
snapshot
410+
|> Reducer.empty_state()
411+
|> Reducer.apply_op(%Operation.AddCustomStatement{
412+
table: snapshot.table,
413+
statement: statement
414+
})
415+
416+
assert state.empty? == false
417+
assert [^statement] = state.custom_statements
418+
419+
state =
420+
Reducer.apply_op(state, %Operation.RemoveCustomStatement{
421+
table: snapshot.table,
422+
statement: statement
423+
})
424+
425+
assert state.custom_statements == []
426+
427+
# Remove again should conflict
428+
caught =
429+
try do
430+
Reducer.apply_op(state, %Operation.RemoveCustomStatement{
431+
table: snapshot.table,
432+
statement: statement
433+
})
434+
435+
:no_error
436+
catch
437+
:throw, {:reducer_error, reason} -> {:threw, reason}
438+
end
439+
440+
assert {:threw, "RemoveCustomStatement: statement :enable_trgm not present"} = caught
441+
end
442+
443+
test "state.empty? after add-then-remove-all reflects 'state was touched', not 'state is empty now'",
444+
%{snapshot: snapshot} do
445+
# Intentional post-simplification behavior: dropping the final
446+
# state_empty?/1 recomputation means `empty?` tracks "has any op been
447+
# applied" (inline per apply_op), not "are all collections empty right
448+
# now". For `pkey_operations`' purpose — deciding whether to skip pkey
449+
# drops because we're a fresh create — this is more correct: removing
450+
# attributes from an existing table is NOT a fresh-create scenario, so
451+
# we must keep `empty?=false`.
452+
state =
453+
snapshot
454+
|> Reducer.empty_state()
455+
|> Reducer.apply_op(%Operation.AddAttribute{
456+
table: snapshot.table,
457+
schema: nil,
458+
multitenancy: @base_multitenancy,
459+
old_multitenancy: @base_multitenancy,
460+
attribute: @text_attribute
461+
})
462+
|> Reducer.apply_op(%Operation.RemoveAttribute{
463+
table: snapshot.table,
464+
schema: nil,
465+
multitenancy: @base_multitenancy,
466+
old_multitenancy: @base_multitenancy,
467+
attribute: @text_attribute
468+
})
469+
470+
assert state.attributes == []
471+
refute state.empty?, "empty? must stay false once state has been touched by any op"
472+
end
321473
end
322474

323475
describe "load_reduced_state" do

0 commit comments

Comments
 (0)