Skip to content

fix(SQLite): preserve indexes and foreign keys when altering an introspected table - #7392

Merged
morozov merged 4 commits into
doctrine:4.4.xfrom
fballiano:fix/sqlite-alter-table-drops-indexes
Jul 24, 2026
Merged

fix(SQLite): preserve indexes and foreign keys when altering an introspected table#7392
morozov merged 4 commits into
doctrine:4.4.xfrom
fballiano:fix/sqlite-alter-table-drops-indexes

Conversation

@fballiano

@fballiano fballiano commented Jun 10, 2026

Copy link
Copy Markdown
Contributor
Q A
Type bug
Fixed issues n/a

Summary

alterTable() on SQLite silently drops every index and foreign key (and the primary key) of the altered table whenever the table was obtained through the new introspection API. Rows survive the recreation; the indexes and foreign keys are gone, with no error or deprecation.

$conn = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
$sm = $conn->createSchemaManager();

$conn->executeStatement('CREATE TABLE products (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)');
$conn->executeStatement('CREATE UNIQUE INDEX idx_sku ON products (sku)');
$conn->executeStatement('CREATE INDEX idx_qty ON products (qty)');

$old = $sm->introspectTableByUnquotedName('products');
$new = $old->edit()
    ->modifyColumnByUnquotedName('qty', static function (ColumnEditor $e): void {
        $e->setTypeName(Types::STRING)->setLength(32);
    })
    ->create();
$sm->alterTable($sm->createComparator()->compareTables($old, $new));

// idx_sku and idx_qty are gone

Root cause

The legacy column-name accessors of an introspected table are inconsistent. Column::getName() returns the bare, unquoted name (sku), but Index::getColumns() and ForeignKeyConstraint::getLocalColumns()/getForeignColumns() return the raw map keys, which the new introspection API marks as quoted ("sku"). The SQLite table-recreation path maps index and foreign-key columns to the altered table through getDiffColumnNameMap(), which is keyed by the unquoted column names, so every lookup misses and the branch meant to drop indexes referencing dropped columns:

if (! isset($nameMap[$normalizedColumnName])) {
    unset($indexes[$key]);
    continue 2;
}

fires for every index and foreign key of the table.

The existing testNonSimpleAlterTableCreatedFromDDL does not catch this because it uses the deprecated introspectTable(), which returns legacy-unquoted names that happen to match the map.

Fix

The table-recreation path now reads the referencing column names through the non-deprecated Index::getIndexedColumns() and ForeignKeyConstraint::getReferencingColumnNames() accessors and compares their unquoted identifier values. This makes the lookup agnostic of whether the table was introspected (which marks its column names as quoted) or built in memory, without relying on the quoted form of the deprecated getColumns() / getLocalColumns() accessors.

Note: normalizing those deprecated accessors to return unquoted names was considered but rejected, because their quoted form is load-bearing for quoting propagation in user-built schemas (it would break testQuotedColumnInForeignKeyPropagation across all platforms).

The regression test, which is not platform-specific, is added to SchemaManagerFunctionalTestCase so it runs against every platform. It is built through the Schema API and fails without the fix (indexes and foreign keys dropped) and passes with it.

…spected table

The table-recreation path maps index and foreign-key columns to the
altered table through getDiffColumnNameMap(), which is keyed by the
bare column names (Column::getName()). The index and foreign-key
column accessors of an introspected table return the names in their
quoted form ('"sku"'), so every map lookup missed and the branch
meant to drop indexes referencing dropped columns silently dropped
every index and foreign key of the table instead.

Unquote the column names before the lookup, so only indexes and
foreign keys whose columns were actually dropped are removed.

Signed-off-by: Fabrizio Balliano <fabrizio.balliano@gmail.com>
@fballiano
fballiano marked this pull request as ready for review June 10, 2026 07:52
Comment thread src/Platforms/SQLitePlatform.php Outdated
Comment thread tests/Functional/Schema/SQLiteSchemaManagerTest.php Outdated
@fballiano
fballiano force-pushed the fix/sqlite-alter-table-drops-indexes branch from 6d58dd6 to 4f8d03e Compare June 10, 2026 16:09
@fballiano
fballiano marked this pull request as draft June 10, 2026 16:19
@fballiano
fballiano force-pushed the fix/sqlite-alter-table-drops-indexes branch 2 times, most recently from 8502adc to abc5d06 Compare June 10, 2026 16:24
…spected table

When the altered table is obtained through the introspection API, its
index and foreign-key column names are marked as quoted, so the legacy
Index::getColumns() / ForeignKeyConstraint::getLocalColumns() accessors
return them in quoted form ('"sku"'). The SQLite table-recreation path
maps those columns to the altered table through getDiffColumnNameMap(),
which is keyed by the unquoted column names, so every lookup missed and
the branch meant to drop indexes referencing dropped columns silently
dropped every index and foreign key (and the primary key) of the table
instead.

Read the referencing column names through the non-deprecated
Index::getIndexedColumns() and
ForeignKeyConstraint::getReferencingColumnNames() accessors and compare
their unquoted identifier values, so the lookup is agnostic of whether
the table was introspected or built in memory.

Add a regression test to SchemaManagerFunctionalTestCase so the
scenario, which is not platform-specific, runs against every platform.

Signed-off-by: Fabrizio Balliano <fabrizio.balliano@gmail.com>
@fballiano
fballiano force-pushed the fix/sqlite-alter-table-drops-indexes branch from abc5d06 to a03a27d Compare June 10, 2026 16:30
@fballiano

fballiano commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Reworked again after running the suite on CI.

Normalizing the legacy accessors (Index::getColumns(), ForeignKeyConstraint::getLocalColumns()/getForeignColumns()) to return unquoted names turned out not to be viable: their quoted form is load-bearing for quoting propagation in user-built schemas, so it breaks testQuotedColumnInForeignKeyPropagation on every platform.

Instead the fix stays in the SQLite table-recreation path but no longer relies on the quoted legacy accessors. It reads the referencing column names via the non-deprecated Index::getIndexedColumns() and ForeignKeyConstraint::getReferencingColumnNames() and compares their unquoted identifier values, so the lookup is agnostic of whether the table was introspected (which marks the names as quoted) or built in memory.

The regression test lives in SchemaManagerFunctionalTestCase so it runs against every platform.

I ran the full suite on my fork to check CI: fballiano#1, all green.

@fballiano
fballiano marked this pull request as ready for review July 22, 2026 20:02
@fballiano

Copy link
Copy Markdown
Contributor Author

Sorry @morozov I forgot to mark this for review 🤦🏻 Hope it's ok now, I'm not sure about your previous note about the test suite actually.

@morozov morozov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Directionally, this looks right. Still needs some polishing.

Comment thread src/Platforms/SQLitePlatform.php Outdated
Comment thread src/Platforms/SQLitePlatform.php Outdated
Comment thread tests/Functional/Schema/SchemaManagerFunctionalTestCase.php Outdated
Comment thread tests/Functional/Schema/SchemaManagerFunctionalTestCase.php Outdated
…ospection

The referential actions of a foreign key created without explicit actions
are reported inconsistently across platforms (MariaDB reports RESTRICT,
MySQL reports NO ACTION), so the post-alteration foreign keys are compared
against the pre-alteration introspection instead of the in-memory
definition.
@fballiano

Copy link
Copy Markdown
Contributor Author

@morozov thank you, I hope I addressed all the issues in the right way, CI is green again :-)

@morozov morozov added this to the 4.4.5 milestone Jul 23, 2026
@morozov
morozov merged commit 34b6299 into doctrine:4.4.x Jul 24, 2026
130 checks passed
morozov pushed a commit that referenced this pull request Aug 10, 2026
…me columns

When an index is dropped and another one over the same columns is added,
AbstractMySQLPlatform combines both into a single ALTER TABLE statement,
so that the columns are never left unindexed. It compares the two column
lists through the legacy Index::getColumns() accessor, which returns the
raw map keys: the introspection API marks them as quoted ('"parent_id"'),
while an index built in memory keeps them bare ('parent_id'). The two
lists therefore never match when the dropped index comes from an
introspected table, the statements are not combined, and the drop is
emitted on its own.

That makes the alteration fail whenever the dropped index is the one
backing a foreign key: InnoDB rejects the drop with "Cannot drop index
'X': needed in a foreign key constraint" (which disabling
foreign_key_checks does not lift), even though the added index would
provide the same cover.

Read the column names through the non-deprecated
Index::getIndexedColumns() accessor and compare their unquoted identifier
values, so the comparison is agnostic of whether the index was
introspected or built in memory, as done in #7392 for the same accessor
in the SQLite table-recreation path.

Signed-off-by: Fabrizio Balliano <fabrizio.balliano@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants