Skip to content

Fix temporary id table columns for multi-table DQL UPDATE and DELETE - #12638

Open
alireza-aminzadeh wants to merge 1 commit into
doctrine:3.7.xfrom
alireza-aminzadeh:bugfix/GH-12634-multi-table-temporary-id-table-columns
Open

alireza-aminzadeh wants to merge 1 commit into
doctrine:3.7.xfrom
alireza-aminzadeh:bugfix/GH-12634-multi-table-temporary-id-table-columns

Conversation

@alireza-aminzadeh

@alireza-aminzadeh alireza-aminzadeh commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor
Q A
Bug fix? yes
New feature? no
BC Break? no
Tests pass? yes

Summary

DQL UPDATE and DELETE statements on entities of a Class Table Inheritance (JOINED) hierarchy keep the identifiers of the affected rows in a temporary table while the tables of the hierarchy are processed one after the other (MultiTableUpdateExecutor, MultiTableDeleteExecutor). OneToManyPersister::deleteJoinedEntityCollection() does the same when the whole collection of a one-to-many association with orphanRemoval is replaced and its target entity is part of such a hierarchy.

The columns of that temporary table were declared from name, notnull and type only, so the rest of the mapping of the identifier was lost. #12634 reports it for the length of string identifiers, but there is more to it:

  • Length (the reported problem). DBAL 4 refuses to build the declaration of a VARCHAR (NVARCHAR on SQL Server) without length: MySQL, MariaDB and SQL Server fail while the executor is created (InvalidColumnDeclaration: Column "id" has invalid type, caused by ColumnLengthRequired), PostgreSQL silently creates a column without length, SQLite is not affected. The issue mentions the two executors, the same code was copied a third time in OneToManyPersister.
  • Precision and scale. DBAL 4 requires them to declare a decimal column, so a decimal identifier fails on every platform (DBAL 3 silently declared NUMERIC(10, 0)).
  • fixed and unsigned. A CHAR(36) identifier became a VARCHAR, an unsigned integer lost its UNSIGNED.
  • Character set and collation (MySQL and MariaDB). The temporary table gets the defaults of the database. As soon as the tables use another collation, e.g. through defaultTableOptions (utf8mb4_unicode_ci in the CI configuration), the statements fail with 1267 Illegal mix of collations, even once the length is declared. With case sensitive tables (utf8mb4_bin), the case insensitive primary key of the temporary table also rejects identifiers that differ in case only (1062 Duplicate entry 'abc' for key '..._id_tmp.PRIMARY').

Approach

The DDL of the temporary table was duplicated in three places, so it now lives in one internal class, Doctrine\ORM\Internal\Query\TemporaryIdTable, used by all of them.

  • The columns are declared from the FieldMapping of the identifier, like the SchemaTool does for the tables of the entities: type, length (with the same default for string columns, Configuration::getDefaultStringTypeSchemaLength()), precision, scale, the fixed and unsigned options and the charset and collation options of the column. An identifier derived from an association is resolved to the field it references; for this, PersisterHelper::getTypeOfColumn() is split into an internal getFieldMappingOfColumn() (its own behavior is unchanged). The definitions also carry the type name under typeName: DBAL 4.5 reads it and deprecates the Type instance under type (Deprecate Column::getType() in favor of Column::getTypeName() dbal#7490), older versions only know type and ignore the extra key.
  • On MySQL and MariaDB, when an identifier is a string type, the table also gets DEFAULT CHARACTER SET … COLLATE …, taken from the options of the #[Table] of the root entity first, then from the defaultTableOptions of the connection, with the charset connection parameter as the last resort. No query is needed for this, it only relies on the mapping and the connection parameters.
  • The issue suggests adding COLLATE to the columns. I went for the table-level options instead: a column-level collation does not reach the types that are stored as strings but do not take a collation from the column declaration (guid is a CHAR(36), ascii_string), and the table-level clause is only generated where the platform derives the collation of the columns from it, so the statements for PostgreSQL, SQL Server and SQLite do not change.
  • A columnDefinition of the identifier is deliberately not taken over, it is platform specific and was not before either.

Changes

  • src/Internal/Query/TemporaryIdTable.php (new): builds the CREATE TEMPORARY TABLE statement.
  • src/Query/Exec/MultiTableUpdateExecutor.php, src/Query/Exec/MultiTableDeleteExecutor.php, src/Persisters/Collection/OneToManyPersister.php: use it instead of the copied code.
  • src/Utility/PersisterHelper.php: new @internal getFieldMappingOfColumn(), getTypeOfColumn() delegates to it.
  • phpstan-baseline.neon: the three identical getColumnDeclarationListSQL() entries become one. The array keyed by column name is needed by DBAL 3, DBAL 4 wants a list whose items have a name, so it is both.
  • docs/en/reference/inheritance-mapping.rst, docs/en/reference/dql-doctrine-query-language.rst: document how bulk statements work on a Class Table Inheritance hierarchy, what the columns of the temporary table are declared from, and that the database user needs the privilege to create temporary tables.
  • Tests, see below.

Tests

  • GH12634Test (functional): bulk UPDATE and DELETE with a fixed-length string identifier, a string identifier without length and identifiers that differ in case only in a case sensitive table, plus the deletion of a replaced orphan removal collection. Without the fix it fails on MySQL and MariaDB; PostgreSQL and SQLite pass either way, as the issue says.
  • TemporaryIdTableTest (unit, no database needed): the generated statement for MySQL, MariaDB, PostgreSQL, SQL Server and SQLite, for the different types and options, including an identifier derived from an association, and the precedence of the charset and collation sources.
  • MultiTableExecutorTest, PersisterHelperTest (unit): the statements built by the executors, and the resolution of a column to its field mapping.

The identifier derived from an association has no functional test on purpose: creating the schema of a JOINED hierarchy like that makes the SchemaTool declare the inheritance foreign key twice, which DBAL 4.5 reports as a deprecation (unrelated to this change).

Checked with DBAL 4.5.0 and 3.10.0 on PHP 8.4: GH12634Test passes on SQLite, MySQL 8.0, MariaDB 11.4 and PostgreSQL 14, and the whole functional suite passes on MySQL, MariaDB and PostgreSQL (DBAL 4.5.0). PHPStan (both configurations) and phpcs are clean. SQL Server is only covered by the statement tests, I had no server to run it against. On my Windows machine four unrelated tests fail with and without this change (the SQLite build has no SQRT, and the console output width of two debug command tests).

The PHPUnit (fail on deprecations) job is already red on 3.7.x, because DBAL 4.5 deprecates passing Type instances (doctrine/dbal#7490). With deduplication on, only the first occurrence is reported, and that used to be the column declaration of the temporary table (AdvancedDqlQueryTest::testUpdateAs). This change removes it, so the job now reports the next one, DatabaseDriverTest::testIssue2059. Behind it, hidden by the deduplication, are others that are not related to this change either: tests that pass Type instances to Column (DatabaseDriverTest, DDC2387Test, GH7684Test) and DatabaseDriver calling Column::getType(). I left them alone; this change adds no deprecation of its own.

Thanks to @mostafasy for the detailed analysis in the issue.

Fixes #12634

Bulk DQL UPDATE and DELETE statements on entities of a Class Table
Inheritance hierarchy keep the identifiers of the affected rows in a
temporary table. The same happens when a one-to-many collection with
orphan removal is replaced and its target entity is part of such a
hierarchy.

The columns of that table were declared with their name, type and
nullability only, so the rest of the mapping of the identifier was lost:

* The length of string identifiers. DBAL 4 refuses to build the
  declaration of a string column without length on MySQL, MariaDB and
  SQL Server (ColumnLengthRequired), and PostgreSQL silently creates an
  unbounded column.
* The precision, the scale and the "fixed" and "unsigned" options.
* The character set and the collation. MySQL and MariaDB gave the
  temporary table the defaults of the database, so comparing it with the
  real tables failed with "Illegal mix of collations" as soon as they
  differ, for instance because of the default table options of the
  connection. When the tables are case sensitive, the primary key of the
  temporary table also rejected identifiers that differ in case only.

The statement was copied in three places. It is now built by
Doctrine\ORM\Internal\Query\TemporaryIdTable from the field mapping of
the identifier, like the SchemaTool does, including the default length
of string columns. Identifiers derived from associations are resolved
through PersisterHelper::getFieldMappingOfColumn(). The columns also
carry the name of their type, which DBAL 4.5 reads instead of the
deprecated type instance and older versions ignore.

On MySQL and MariaDB, the temporary table also gets the character set
and the collation of the root table when an identifier is a string: the
options of #[Table], then the default table options and the charset of
the connection. They are declared for the table rather than for the
columns, so that types like guid are covered as well, and nothing is
generated for the platforms that do not derive them from the table.

Fixes doctrine#12634
@alireza-aminzadeh
alireza-aminzadeh force-pushed the bugfix/GH-12634-multi-table-temporary-id-table-columns branch from 7ce753c to f0f99eb Compare September 29, 2026 18:07
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.

Multi-table UPDATE/DELETE fails with DBAL 4 for string identifiers: temporary id table column loses its length

1 participant