Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/en/reference/dql-doctrine-query-language.rst
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,10 @@ as the following example shows:
References to related entities are only possible in the WHERE
clause and using sub-selects.

For entities of a Class Table Inheritance hierarchy, a temporary table is used
to update the rows of all the tables involved, see
:ref:`Bulk UPDATE and DELETE statements <inheritance_mapping_cti_bulk_statements>`.

.. warning::

DQL UPDATE statements are ported directly into a
Expand All @@ -777,6 +781,9 @@ as simple as the UPDATE syntax:
DELETE MyProject\Model\User u WHERE u.id = 4

The same restrictions apply for the reference of related entities.
As with UPDATE queries, a temporary table is used for entities of a Class Table
Inheritance hierarchy, see
:ref:`Bulk UPDATE and DELETE statements <inheritance_mapping_cti_bulk_statements>`.

.. warning::

Expand Down
45 changes: 45 additions & 0 deletions docs/en/reference/inheritance-mapping.rst
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,51 @@ or auto-increment details). Furthermore each child table has to
have a foreign key pointing from the id column to the root table id
column and cascading on delete.

.. _inheritance_mapping_cti_bulk_statements:

Bulk UPDATE and DELETE statements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The rows of an entity are spread over several tables, so a DQL ``UPDATE`` or
``DELETE`` statement on an entity of a Class Table Inheritance hierarchy cannot
be executed as a single SQL statement. Instead, the ORM

1. creates a temporary table for the identifiers of the affected rows,
2. copies the identifiers of the rows matching the ``WHERE`` clause into it,
3. updates or deletes the rows in each table of the hierarchy that is concerned,
using these identifiers, and
4. drops the temporary table.

The same happens when the whole collection of a one-to-many association with
``orphanRemoval`` is replaced by a new one and the target entity is part of a
Class Table Inheritance hierarchy. The database user therefore needs the
privilege to create temporary tables.

The identifier columns of the temporary table are declared from the mapping of
the identifier of the root entity, so that their values can be compared with the
ones of the actual tables. The type, the ``length``, ``precision`` and ``scale``
and the ``fixed``, ``unsigned``, ``charset`` and ``collation`` options of the
column are taken over. A ``string`` identifier without ``length`` gets the same
default length as in the SchemaTool (see :doc:`/reference/advanced-configuration`).
A ``columnDefinition`` is not used.

.. note::

MySQL and MariaDB usually refuse to compare string columns of different
collations ("Illegal mix of collations"). Besides, the primary key of a
temporary table with a case insensitive collation cannot hold identifiers
that differ only in case, although your tables can when their collation is
case sensitive. A temporary table would get the default character set and
collation of the database, which are not necessarily the ones of your
tables. For identifiers of a string type, the temporary table is therefore
created with the character set and collation of the root table: the
``charset`` and ``collation`` entries of the ``options`` of its ``#[Table]``
attribute take precedence over the ``defaultTableOptions`` of the
connection, and the ``charset`` parameter of the connection is used as a
last resort. If your tables were created with other options than the ones
configured for the ORM, the statements can fail with the errors mentioned
above.

.. _inheritence_mapping_overrides:

Overrides
Expand Down
24 changes: 6 additions & 18 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,12 @@ parameters:
count: 1
path: src/Internal/HydrationCompleteHandler.php

-
message: '#^Parameter \#1 \$columns of method Doctrine\\DBAL\\Platforms\\AbstractPlatform\:\:getColumnDeclarationListSQL\(\) expects list\<array\{name\: string, type\?\: Doctrine\\DBAL\\Types\\Type, typeName\?\: string, default\?\: mixed, notnull\?\: bool, autoincrement\?\: bool, columnDefinition\?\: non\-empty\-string\|null, comment\?\: string, \.\.\.\}\>, array\<string, array\<string, mixed\>\> given\.$#'
identifier: argument.type
count: 1
path: src/Internal/Query/TemporaryIdTable.php

-
message: '#^Offset int\|null might not exist on array\<int, object\>\.$#'
identifier: offsetAccess.notFound
Expand Down Expand Up @@ -1848,12 +1854,6 @@ parameters:
count: 1
path: src/Persisters/Collection/OneToManyPersister.php

-
message: '#^Parameter \#1 \$columns of method Doctrine\\DBAL\\Platforms\\AbstractPlatform\:\:getColumnDeclarationListSQL\(\) expects list\<array\{name\: string, type\?\: Doctrine\\DBAL\\Types\\Type, typeName\?\: string, default\?\: mixed, notnull\?\: bool, autoincrement\?\: bool, columnDefinition\?\: non\-empty\-string\|null, comment\?\: string, \.\.\.\}\>, array\<string, array\{name\: string, notnull\: true, type\: Doctrine\\DBAL\\Types\\Type\}\> given\.$#'
identifier: argument.type
count: 1
path: src/Persisters/Collection/OneToManyPersister.php

-
message: '#^Method Doctrine\\ORM\\Persisters\\Entity\\AbstractEntityInheritancePersister\:\:getSelectColumnSQL\(\) has parameter \$class with generic class Doctrine\\ORM\\Mapping\\ClassMetadata but does not specify its types\: T$#'
identifier: missingType.generics
Expand Down Expand Up @@ -2304,12 +2304,6 @@ parameters:
count: 1
path: src/Query/Exec/MultiTableDeleteExecutor.php

-
message: '#^Parameter \#1 \$columns of method Doctrine\\DBAL\\Platforms\\AbstractPlatform\:\:getColumnDeclarationListSQL\(\) expects list\<array\{name\: string, type\?\: Doctrine\\DBAL\\Types\\Type, typeName\?\: string, default\?\: mixed, notnull\?\: bool, autoincrement\?\: bool, columnDefinition\?\: non\-empty\-string\|null, comment\?\: string, \.\.\.\}\>, array\<string, array\{name\: string, notnull\: true, type\: Doctrine\\DBAL\\Types\\Type\}\> given\.$#'
identifier: argument.type
count: 1
path: src/Query/Exec/MultiTableDeleteExecutor.php

-
message: '#^Argument of an invalid type list\<string\>\|string supplied for foreach, only iterables are supported\.$#'
identifier: foreach.nonIterable
Expand All @@ -2322,12 +2316,6 @@ parameters:
count: 1
path: src/Query/Exec/MultiTableUpdateExecutor.php

-
message: '#^Parameter \#1 \$columns of method Doctrine\\DBAL\\Platforms\\AbstractPlatform\:\:getColumnDeclarationListSQL\(\) expects list\<array\{name\: string, type\?\: Doctrine\\DBAL\\Types\\Type, typeName\?\: string, default\?\: mixed, notnull\?\: bool, autoincrement\?\: bool, columnDefinition\?\: non\-empty\-string\|null, comment\?\: string, \.\.\.\}\>, array\<string, array\{name\: string, notnull\: true, type\: Doctrine\\DBAL\\Types\\Type\}\> given\.$#'
identifier: argument.type
count: 1
path: src/Query/Exec/MultiTableUpdateExecutor.php

-
message: '#^Parameter \#3 \$types of method Doctrine\\DBAL\\Connection\:\:executeStatement\(\) expects array\<int\<0, max\>\|string, Doctrine\\DBAL\\ArrayParameterType\|Doctrine\\DBAL\\ParameterType\|Doctrine\\DBAL\\Types\\Type\|string\>, list\<Doctrine\\DBAL\\ArrayParameterType\:\:ASCII\|Doctrine\\DBAL\\ArrayParameterType\:\:BINARY\|Doctrine\\DBAL\\ArrayParameterType\:\:INTEGER\|Doctrine\\DBAL\\ArrayParameterType\:\:STRING\|Doctrine\\DBAL\\ParameterType\:\:ASCII\|Doctrine\\DBAL\\ParameterType\:\:BINARY\|Doctrine\\DBAL\\ParameterType\:\:BOOLEAN\|Doctrine\\DBAL\\ParameterType\:\:INTEGER\|Doctrine\\DBAL\\ParameterType\:\:LARGE_OBJECT\|Doctrine\\DBAL\\ParameterType\:\:NULL\|Doctrine\\DBAL\\ParameterType\:\:STRING\|Doctrine\\DBAL\\Types\\Type\|int\|string\> given\.$#'
identifier: argument.type
Expand Down
155 changes: 155 additions & 0 deletions src/Internal/Query/TemporaryIdTable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

declare(strict_types=1);

namespace Doctrine\ORM\Internal\Query;

use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Types\StringType;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\FieldMapping;
use Doctrine\ORM\Utility\PersisterHelper;

use function implode;
use function is_string;

/**
* Builds the DDL of the temporary table in which the identifiers of the affected rows are kept while
* the tables of a class table inheritance hierarchy are updated or deleted one after the other: bulk DQL
* UPDATE and DELETE statements, and the deletion of a one-to-many collection with orphan removal.
*
* The identifiers are matched against the identifier columns of the tables of the hierarchy
* (`WHERE (id) IN (SELECT id FROM temporary_table)`), so the columns of the temporary table are
* declared like the identifier columns of the root table:
*
* - Type, length, precision, scale, fixed and unsigned. Otherwise the table could not store every
* identifier, or the platform refuses to declare it: DBAL 4 requires the length of (N)VARCHAR
* columns on MySQL, MariaDB and SQL Server.
* - Character set and collation. Otherwise MySQL and MariaDB refuse to compare string identifiers
* that do not share their collation ("Illegal mix of collations"), and a case insensitive
* temporary table cannot hold identifiers that differ by case only, unlike the case sensitive
* tables it is compared with. The character set and the collation are the ones of the column
* mapping or, on MySQL and MariaDB, the default ones of the table, like the SchemaTool declares
* them for the tables of the entities.
*
* A custom `columnDefinition` of an identifier is not taken over.
*
* @internal
*/
final class TemporaryIdTable
{
/**
* Returns the statement that creates the temporary identifier table of the hierarchy of the given root class.
*
* @param ClassMetadata<object> $rootClass
*/
public static function getCreateSQL(string $tableName, ClassMetadata $rootClass, EntityManagerInterface $em): string
{
$platform = $em->getConnection()->getDatabasePlatform();
$idColumnNames = $rootClass->getIdentifierColumnNames();
$columns = [];

foreach ($idColumnNames as $columnName) {
$columns[$columnName] = self::getColumnDefinition(
$columnName,
PersisterHelper::getFieldMappingOfColumn($columnName, $rootClass, $em),
$em,
);
}

return $platform->getCreateTemporaryTableSnippetSQL() . ' ' . $tableName . ' ('
. $platform->getColumnDeclarationListSQL($columns) . ', PRIMARY KEY(' . implode(',', $idColumnNames) . '))'
. self::getTableOptionsSQL($columns, $rootClass, $em);
}

/** @return array<string, mixed> The definition in the format expected by the column declaration of the DBAL platform. */
private static function getColumnDefinition(string $columnName, FieldMapping $mapping, EntityManagerInterface $em): array
{
$options = $mapping->options ?? [];
$length = $mapping->length;

// The SchemaTool applies the same default when it creates the table of the entity.
if ($length === null && $mapping->type === Types::STRING) {
$length = $em->getConfiguration()->getDefaultStringTypeSchemaLength();
}

$definition = [
'name' => $columnName,
// DBAL 4.5 reads the type name from "typeName" and deprecates the type instance under "type",
// older versions only know the latter and ignore "typeName".
'type' => Type::getType($mapping->type),
'typeName' => $mapping->type,
'notnull' => true,
'length' => $length,
'precision' => $mapping->precision,
'scale' => $mapping->scale,
'fixed' => $options['fixed'] ?? false,
'unsigned' => $options['unsigned'] ?? false,
];

// Like the SchemaTool, hand the character set and the collation of the column over to the platform.
foreach (['charset', 'collation'] as $option) {
if (isset($options[$option])) {
$definition[$option] = $options[$option];
}
}

return $definition;
}

/**
* Returns the character set and the collation that the columns get from the table when they declare none.
*
* Only MySQL and MariaDB derive them from the table, the other platforms use the ones of the database.
* They are declared for string identifiers only: the other column types ignore them, and the statements
* for entities without a string identifier stay as they were.
*
* @param array<string, array<string, mixed>> $columns
* @param ClassMetadata<object> $rootClass
*/
private static function getTableOptionsSQL(array $columns, ClassMetadata $rootClass, EntityManagerInterface $em): string
{
$connection = $em->getConnection();
$platform = $connection->getDatabasePlatform();

if (! $platform instanceof AbstractMySQLPlatform || ! self::hasStringColumn($columns)) {
return '';
}

$params = $connection->getParams();
$options = $rootClass->table['options'] ?? [];

// The options of the table win over the default ones, the connection charset is the last resort.
// The "collate" option is deprecated since DBAL 3.
$charset = $options['charset'] ?? $params['defaultTableOptions']['charset'] ?? $params['charset'] ?? null;
$collation = $options['collation'] ?? $options['collate']
?? $params['defaultTableOptions']['collation'] ?? $params['defaultTableOptions']['collate'] ?? null;

$sql = '';

if (is_string($charset)) {
$sql .= ' DEFAULT CHARACTER SET ' . $charset;
}

if (is_string($collation)) {
$sql .= ' COLLATE ' . $platform->quoteSingleIdentifier($collation);
}

return $sql;
}

/** @param array<string, array<string, mixed>> $columns */
private static function hasStringColumn(array $columns): bool
{
foreach ($columns as $column) {
if ($column['type'] instanceof StringType) {
return true;
}
}

return false;
}
}
20 changes: 5 additions & 15 deletions src/Persisters/Collection/OneToManyPersister.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
use BadMethodCallException;
use Doctrine\Common\Collections\Criteria;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\Internal\Query\TemporaryIdTable;
use Doctrine\ORM\Mapping\MappingException;
use Doctrine\ORM\Mapping\OneToManyAssociationMapping;
use Doctrine\ORM\PersistentCollection;
Expand Down Expand Up @@ -215,21 +215,11 @@ private function deleteJoinedEntityCollection(PersistentCollection $collection):
$rootClass = $this->em->getClassMetadata($targetClass->rootEntityName);

// 1) Build temporary table DDL
$tempTable = $this->platform->getTemporaryTableName($rootClass->getTemporaryIdTableName());
$idColumnNames = $rootClass->getIdentifierColumnNames();
$idColumnList = implode(', ', $idColumnNames);
$columnDefinitions = [];

foreach ($idColumnNames as $idColumnName) {
$columnDefinitions[$idColumnName] = [
'name' => $idColumnName,
'notnull' => true,
'type' => Type::getType(PersisterHelper::getTypeOfColumn($idColumnName, $rootClass, $this->em)),
];
}
$tempTable = $this->platform->getTemporaryTableName($rootClass->getTemporaryIdTableName());
$idColumnNames = $rootClass->getIdentifierColumnNames();
$idColumnList = implode(', ', $idColumnNames);

$statement = $this->platform->getCreateTemporaryTableSnippetSQL() . ' ' . $tempTable
. ' (' . $this->platform->getColumnDeclarationListSQL($columnDefinitions) . ')';
$statement = TemporaryIdTable::getCreateSQL($tempTable, $rootClass, $this->em);

$this->conn->executeStatement($statement);

Expand Down
15 changes: 2 additions & 13 deletions src/Query/Exec/MultiTableDeleteExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Internal\Query\TemporaryIdTable;
use Doctrine\ORM\Query\AST;
use Doctrine\ORM\Query\AST\DeleteStatement;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Utility\PersisterHelper;
use Throwable;

use function array_reverse;
Expand Down Expand Up @@ -85,17 +84,7 @@ public function __construct(AST\Node $AST, SqlWalker $sqlWalker)
}

// 4. Store DDL for temporary identifier table.
$columnDefinitions = [];
foreach ($idColumnNames as $idColumnName) {
$columnDefinitions[$idColumnName] = [
'name' => $idColumnName,
'notnull' => true,
'type' => Type::getType(PersisterHelper::getTypeOfColumn($idColumnName, $rootClass, $em)),
];
}

$this->createTempTableSql = $platform->getCreateTemporaryTableSnippetSQL() . ' ' . $tempTable . ' ('
. $platform->getColumnDeclarationListSQL($columnDefinitions) . ', PRIMARY KEY(' . implode(',', $idColumnNames) . '))';
$this->createTempTableSql = TemporaryIdTable::getCreateSQL($tempTable, $rootClass, $em);
$this->dropTempTableSql = $platform->getDropTemporaryTableSQL($tempTable);
}

Expand Down
16 changes: 2 additions & 14 deletions src/Query/Exec/MultiTableUpdateExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Internal\Query\TemporaryIdTable;
use Doctrine\ORM\Query\AST;
use Doctrine\ORM\Query\AST\UpdateStatement;
use Doctrine\ORM\Query\ParameterTypeInferer;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Utility\PersisterHelper;

use function array_reverse;
use function array_slice;
Expand Down Expand Up @@ -124,18 +123,7 @@ public function __construct(AST\Node $AST, SqlWalker $sqlWalker)
$this->insertSql = $insertSql;

// 4. Store DDL for temporary identifier table.
$columnDefinitions = [];

foreach ($idColumnNames as $idColumnName) {
$columnDefinitions[$idColumnName] = [
'name' => $idColumnName,
'notnull' => true,
'type' => Type::getType(PersisterHelper::getTypeOfColumn($idColumnName, $rootClass, $em)),
];
}

$this->createTempTableSql = $platform->getCreateTemporaryTableSnippetSQL() . ' ' . $tempTable . ' ('
. $platform->getColumnDeclarationListSQL($columnDefinitions) . ', PRIMARY KEY(' . implode(',', $idColumnNames) . '))';
$this->createTempTableSql = TemporaryIdTable::getCreateSQL($tempTable, $rootClass, $em);

$this->dropTempTableSql = $platform->getDropTemporaryTableSQL($tempTable);
}
Expand Down
Loading
Loading