Skip to content

fix(table): reject deleted schema move targets - #1905

Merged
laskoviymishka merged 2 commits into
apache:mainfrom
mattfaltyn:fix-1903-deleted-move-target
Aug 28, 2026
Merged

fix(table): reject deleted schema move targets#1905
laskoviymishka merged 2 commits into
apache:mainfrom
mattfaltyn:fix-1903-deleted-move-target

Conversation

@mattfaltyn

Copy link
Copy Markdown
Contributor

Description

Fixes #1903.

Reject schema updates that either move a field relative to a column already staged for deletion or delete a column already referenced as a move target. This prevents moveFields from silently omitting the moved field when the target is absent from the resulting schema.

The regression test covers both operation orders and both MoveBefore and MoveAfter.

Testing

  • go test ./table -run 'TestMoveRelativeToDeletedColumnRejected|TestMoveColumn|TestDeleteColumn|TestChainedOperations' -count=1
  • make test
  • make test-assert
  • make test-race
  • make lint
  • go build ./...

Signed-off-by: Matt Faltyn <faltyn.matthew@gmail.com>

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, targeted fix: the two symmetric guards are the right instinct, and they close the exact #1903 repro cleanly in both orderings.

I'd hold for now. The guards patch the two entry points, but the actual silent drop lives in moveFields: the !found { continue } branch (~line 1370) pulls the field out of the working slice and then bails with no error. This PR makes that branch unreachable for the two tested orderings, but it's still a landmine for any future path — and there's already one it doesn't cover: MoveBefore(["age"], ["name"]) then DeleteColumn(["age"]) slips past both guards and silently swallows the staged move. I'd rather fix moveFields to not drop on !found so the root is closed in one place, instead of enumerating call-site orderings.

A couple of things I'd like to settle before merge:

  • fix (or at least guard) the silent drop at its root in moveFields, so the move-source-delete ordering surfaces an error too
  • tighten the test to distinguish the two guards (split on the distinct messages) and add a positive case where a delete plus an unrelated move still succeeds

Once those are settled, happy to take another pass.

Comment thread table/update_schema.go
return fmt.Errorf("field that has updates cannot be deleted: %s", fullName)
}
}
for _, move := range u.moves[parentID] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the two guards close both orderings in #1903, but they're patching entry points — the real silent drop is in moveFields, where the !found { continue } branch (~line 1370) removes the field from the working slice and then bails with no error. this PR makes that branch unreachable for the tested orderings, but it's still a landmine for any future path.

and there's already one it doesn't cover: MoveBefore(["age"], ["name"]) then DeleteColumn(["age"]). this guard only inspects move.RelativeTo, not move.FieldID, so neither guard fires and age's staged move is silently swallowed at apply (output happens to be correct, but there's no diagnostic).

I'd rather fix moveFields to not drop on !found — restore the field and continue, or return an error — so the root is closed in one place instead of enumerating call-site orderings. wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“fix moveFields to not drop on !found

Great catch — moveFields now fails closed for missing sources or targets, with source-delete and direct target-loss coverage.

Comment thread table/update_schema_test.go Outdated
t.Run(tt.name, func(t *testing.T) {
tbl := New([]string{"id"}, testMetadata, "", nil, nil)
_, err := tt.build(NewUpdateSchema(tbl.NewTransaction(), true, true)).Apply()
require.ErrorContains(t, err, "move target")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these assertions can't tell the two guards apart — both error messages contain "move target", so cases 1/3 exercise the moveColumn guard and 2/4 the deleteColumn guard, but all four pass on the same substring. if one guard broke and the other picked up the slack, this would stay green.

I'd split it by the distinguishing text: assert "cannot be deleted" for the move-then-delete cases and "has been deleted" for the delete-then-move cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“split it by the distinguishing text”

Good suggestion — the two orderings now assert has been deleted and cannot be deleted separately.

})
}

func TestMoveRelativeToDeletedColumnRejected(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all four cases expect an error, so this proves the guards fire but not that they fire only when they should. I'd add a positive case — DeleteColumn(["name"]) then MoveBefore(["age"], ["id"]) should still succeed and land in the expected order — so an over-eager guard can't slip through unnoticed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“add a positive case”

Agreed — unrelated deletion plus move now succeeds and asserts the final field order.

{
name: "delete then move before",
build: func(update *UpdateSchema) *UpdateSchema {
return update.DeleteColumn([]string{"name"}).MoveBefore([]string{"age"}, []string{"name"})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are all flat top-level fields, so the deleteColumn guard only ever scans u.moves[-1] (parentID resolves to the root). a nested target like address.city keys its move under address's ID — a different bucket that's currently untested. one nested case would pin that path down.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“one nested case would pin that path down”

Nice call — added a nested address.city target case to exercise the parent-ID bucket.

Signed-off-by: Matt Faltyn <faltyn.matthew@gmail.com>

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌬️ ⛵

@laskoviymishka
laskoviymishka merged commit 45ec796 into apache:main Aug 28, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UpdateSchema: moving relative to a deleted column silently drops the moved column

2 participants