Skip to content

fix: apply the CustomFieldData migration as one statement - #822

Merged
blaipr merged 1 commit into
mainfrom
fix/a-refused-migration-can-be-run-again
Aug 18, 2026
Merged

fix: apply the CustomFieldData migration as one statement#822
blaipr merged 1 commit into
mainfrom
fix/a-refused-migration-can-be-run-again

Conversation

@blaipr

@blaipr blaipr commented Aug 18, 2026

Copy link
Copy Markdown
Member

40024210101.sql gives CustomFieldData the identity it should always have had — dropping the surrogate id, making (moduleId, itemId, definitionId) the primary key. It did so as two statements, and DDL commits as it goes, so those are two commits.

Reproduced against a real server, on a table holding a duplicate of that triple — which the surrogate key allowed:

alter table CustomFieldData drop column id           -> succeeds
alter table CustomFieldData add primary key (...)    -> ERROR 1062 Duplicate entry '1-1-1' for key 'PRIMARY'
retry: alter table CustomFieldData drop column id    -> ERROR 1091 Can't DROP COLUMN `id`

UpgradeDatabase::apply() throws before writing the new database version, which is correct — but it means the version stays old and the upgrade is retried from the top, where it now dies on the already-applied statement before ever reaching the one that failed. The installation can neither finish the upgrade nor repeat it, and the column it needed is gone.

One ALTER

As a single statement the server applies both clauses or neither. Verified: the same duplicate still refuses it, id is still there afterwards, and once the operator clears the duplicates the identical file succeeds and the composite key is in place.

Nothing else changes — the migration is keyed by database version, so an installation that has already run it will not run it again.

Tests

MigrationIsAtomicTest runs the real file through the real parser (MysqlFileParser) against a real server, on a table shaped the way an installation's is before the migration. It asserts the refusal leaves the table exactly as it was — because that is what leaves the operator something to run again — then clears the duplicate and asserts the same file applies.

Reverting the file fails it on precisely that: the migration was refused, so the table must be as it was — with id still there, or the operator has nothing left to run again.

The existing unit test hard-coded both statements and errored the moment they became one, which is what it is there for. It now pins the single statement and carries the reason.

3967 unit tests pass; PHPStan level 6 on src and PHPCS clean.

`40024210101.sql` gives CustomFieldData the identity it should always have had,
dropping the surrogate `id` and making (moduleId, itemId, definitionId) the
primary key. It did so as two statements, and DDL commits as it goes, so those
are two commits:

    drop column id       -> succeeds
    add primary key      -> ERROR 1062 Duplicate entry '1-1-1' for key 'PRIMARY'
    retry                -> ERROR 1091 Can't DROP COLUMN `id`

Any installation holding a duplicate of that triple — which the surrogate key
allowed — loses the column, keeps its old database version because
`UpgradeDatabase::apply()` throws before writing it, and then fails every retry
on the already-applied statement before reaching the one that failed. The
upgrade can neither be finished nor repeated.

As one ALTER the server applies both or neither: the duplicate still refuses it,
the table is left as it was, and once the operator clears the duplicates the
same file succeeds.

`MigrationIsAtomicTest` runs the real file through the real parser against a
real server, on a table shaped the way an installation's is before the
migration. The unit test hard-coded both statements and errored the moment they
became one, which is what it is there for; it now pins the single statement and
points at the new test for the reason.
@blaipr
blaipr merged commit 9cd812d into main Aug 18, 2026
8 checks passed
@blaipr
blaipr deleted the fix/a-refused-migration-can-be-run-again branch August 18, 2026 21:23
blaipr added a commit that referenced this pull request Aug 18, 2026
Two guards over the way #822 stopped being repeatable, both of which fail today
if the thing they describe is undone.

A migration that is refused part way has to leave the operator something to run
again. `40024210101.sql` gave CustomFieldData its natural primary key as two
statements; DDL commits as it goes, so the drop stood alone when the key was
refused on a duplicate, and every retry then died on `Can't DROP COLUMN id`
before reaching the statement that had failed. Each version file must now be a
single statement, or wholly inside one transaction with no DDL in it — DDL would
commit and end the transaction under the statements after it — or written so
that running it twice is harmless. Both files satisfy it, one each way.

`Upgrade::getTargetUpgradeHandlers()` yields a handler's versions in the order
`getAttributes()` returns them, which is the order they are written in the
class. Nothing sorts them. They ascend today, so the schema change runs before
the data migration that needs it; a version added at the top of the list would
run first. Out-of-order migrations are not something an installation reports.

The messages say what to do about it rather than only what is wrong, because
whoever sees them is adding a migration and has a choice of three ways to
satisfy the rule.

CLAUDE.md records the pattern these two guard, which covers half of the recent
fixes: a guard that is not where the change happens — a limit tested against a
row already read, a counter written back from a stale read, a hash stored
outside the transaction holding what it describes, two commits made out of one
logical change. Where the codebase gets it right the guard and the change are
one statement. The suite counts there are refreshed to match.
blaipr added a commit that referenced this pull request Aug 18, 2026
Two guards over the way #822 stopped being repeatable, both of which fail today
if the thing they describe is undone.

A migration that is refused part way has to leave the operator something to run
again. `40024210101.sql` gave CustomFieldData its natural primary key as two
statements; DDL commits as it goes, so the drop stood alone when the key was
refused on a duplicate, and every retry then died on `Can't DROP COLUMN id`
before reaching the statement that had failed. Each version file must now be a
single statement, or wholly inside one transaction with no DDL in it — DDL would
commit and end the transaction under the statements after it — or written so
that running it twice is harmless. Both files satisfy it, one each way.

`Upgrade::getTargetUpgradeHandlers()` yields a handler's versions in the order
`getAttributes()` returns them, which is the order they are written in the
class. Nothing sorts them. They ascend today, so the schema change runs before
the data migration that needs it; a version added at the top of the list would
run first. Out-of-order migrations are not something an installation reports.

The messages say what to do about it rather than only what is wrong, because
whoever sees them is adding a migration and has a choice of three ways to
satisfy the rule.

CLAUDE.md records the pattern these two guard, which covers half of the recent
fixes: a guard that is not where the change happens — a limit tested against a
row already read, a counter written back from a stale read, a hash stored
outside the transaction holding what it describes, two commits made out of one
logical change. Where the codebase gets it right the guard and the change are
one statement. The suite counts there are refreshed to match.
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.

1 participant