diff options
Diffstat (limited to 'vendor/doctrine/orm/src/Persisters/Entity')
6 files changed, 3276 insertions, 0 deletions
diff --git a/vendor/doctrine/orm/src/Persisters/Entity/AbstractEntityInheritancePersister.php b/vendor/doctrine/orm/src/Persisters/Entity/AbstractEntityInheritancePersister.php new file mode 100644 index 0000000..cf8a74e --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/AbstractEntityInheritancePersister.php | |||
@@ -0,0 +1,66 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Doctrine\ORM\Persisters\Entity; | ||
6 | |||
7 | use Doctrine\DBAL\Types\Type; | ||
8 | use Doctrine\ORM\Mapping\ClassMetadata; | ||
9 | |||
10 | use function sprintf; | ||
11 | |||
12 | /** | ||
13 | * Base class for entity persisters that implement a certain inheritance mapping strategy. | ||
14 | * All these persisters are assumed to use a discriminator column to discriminate entity | ||
15 | * types in the hierarchy. | ||
16 | */ | ||
17 | abstract class AbstractEntityInheritancePersister extends BasicEntityPersister | ||
18 | { | ||
19 | /** | ||
20 | * {@inheritDoc} | ||
21 | */ | ||
22 | protected function prepareInsertData(object $entity): array | ||
23 | { | ||
24 | $data = parent::prepareInsertData($entity); | ||
25 | |||
26 | // Populate the discriminator column | ||
27 | $discColumn = $this->class->getDiscriminatorColumn(); | ||
28 | $this->columnTypes[$discColumn->name] = $discColumn->type; | ||
29 | $data[$this->getDiscriminatorColumnTableName()][$discColumn->name] = $this->class->discriminatorValue; | ||
30 | |||
31 | return $data; | ||
32 | } | ||
33 | |||
34 | /** | ||
35 | * Gets the name of the table that contains the discriminator column. | ||
36 | */ | ||
37 | abstract protected function getDiscriminatorColumnTableName(): string; | ||
38 | |||
39 | protected function getSelectColumnSQL(string $field, ClassMetadata $class, string $alias = 'r'): string | ||
40 | { | ||
41 | $tableAlias = $alias === 'r' ? '' : $alias; | ||
42 | $fieldMapping = $class->fieldMappings[$field]; | ||
43 | $columnAlias = $this->getSQLColumnAlias($fieldMapping->columnName); | ||
44 | $sql = sprintf( | ||
45 | '%s.%s', | ||
46 | $this->getSQLTableAlias($class->name, $tableAlias), | ||
47 | $this->quoteStrategy->getColumnName($field, $class, $this->platform), | ||
48 | ); | ||
49 | |||
50 | $this->currentPersisterContext->rsm->addFieldResult($alias, $columnAlias, $field, $class->name); | ||
51 | |||
52 | $type = Type::getType($fieldMapping->type); | ||
53 | $sql = $type->convertToPHPValueSQL($sql, $this->platform); | ||
54 | |||
55 | return $sql . ' AS ' . $columnAlias; | ||
56 | } | ||
57 | |||
58 | protected function getSelectJoinColumnSQL(string $tableAlias, string $joinColumnName, string $quotedColumnName, string $type): string | ||
59 | { | ||
60 | $columnAlias = $this->getSQLColumnAlias($joinColumnName); | ||
61 | |||
62 | $this->currentPersisterContext->rsm->addMetaResult('r', $columnAlias, $joinColumnName, false, $type); | ||
63 | |||
64 | return $tableAlias . '.' . $quotedColumnName . ' AS ' . $columnAlias; | ||
65 | } | ||
66 | } | ||
diff --git a/vendor/doctrine/orm/src/Persisters/Entity/BasicEntityPersister.php b/vendor/doctrine/orm/src/Persisters/Entity/BasicEntityPersister.php new file mode 100644 index 0000000..377e03c --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/BasicEntityPersister.php | |||
@@ -0,0 +1,2085 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Doctrine\ORM\Persisters\Entity; | ||
6 | |||
7 | use BackedEnum; | ||
8 | use Doctrine\Common\Collections\Criteria; | ||
9 | use Doctrine\Common\Collections\Expr\Comparison; | ||
10 | use Doctrine\Common\Collections\Order; | ||
11 | use Doctrine\DBAL\ArrayParameterType; | ||
12 | use Doctrine\DBAL\Connection; | ||
13 | use Doctrine\DBAL\LockMode; | ||
14 | use Doctrine\DBAL\ParameterType; | ||
15 | use Doctrine\DBAL\Platforms\AbstractPlatform; | ||
16 | use Doctrine\DBAL\Result; | ||
17 | use Doctrine\DBAL\Types\Type; | ||
18 | use Doctrine\DBAL\Types\Types; | ||
19 | use Doctrine\ORM\EntityManagerInterface; | ||
20 | use Doctrine\ORM\Mapping\AssociationMapping; | ||
21 | use Doctrine\ORM\Mapping\ClassMetadata; | ||
22 | use Doctrine\ORM\Mapping\JoinColumnMapping; | ||
23 | use Doctrine\ORM\Mapping\ManyToManyAssociationMapping; | ||
24 | use Doctrine\ORM\Mapping\MappingException; | ||
25 | use Doctrine\ORM\Mapping\OneToManyAssociationMapping; | ||
26 | use Doctrine\ORM\Mapping\QuoteStrategy; | ||
27 | use Doctrine\ORM\OptimisticLockException; | ||
28 | use Doctrine\ORM\PersistentCollection; | ||
29 | use Doctrine\ORM\Persisters\Exception\CantUseInOperatorOnCompositeKeys; | ||
30 | use Doctrine\ORM\Persisters\Exception\InvalidOrientation; | ||
31 | use Doctrine\ORM\Persisters\Exception\UnrecognizedField; | ||
32 | use Doctrine\ORM\Persisters\SqlExpressionVisitor; | ||
33 | use Doctrine\ORM\Persisters\SqlValueVisitor; | ||
34 | use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver; | ||
35 | use Doctrine\ORM\Query; | ||
36 | use Doctrine\ORM\Query\QueryException; | ||
37 | use Doctrine\ORM\Query\ResultSetMapping; | ||
38 | use Doctrine\ORM\Repository\Exception\InvalidFindByCall; | ||
39 | use Doctrine\ORM\UnitOfWork; | ||
40 | use Doctrine\ORM\Utility\IdentifierFlattener; | ||
41 | use Doctrine\ORM\Utility\LockSqlHelper; | ||
42 | use Doctrine\ORM\Utility\PersisterHelper; | ||
43 | use LengthException; | ||
44 | |||
45 | use function array_combine; | ||
46 | use function array_keys; | ||
47 | use function array_map; | ||
48 | use function array_merge; | ||
49 | use function array_search; | ||
50 | use function array_unique; | ||
51 | use function array_values; | ||
52 | use function assert; | ||
53 | use function count; | ||
54 | use function implode; | ||
55 | use function is_array; | ||
56 | use function is_object; | ||
57 | use function reset; | ||
58 | use function spl_object_id; | ||
59 | use function sprintf; | ||
60 | use function str_contains; | ||
61 | use function strtoupper; | ||
62 | use function trim; | ||
63 | |||
64 | /** | ||
65 | * A BasicEntityPersister maps an entity to a single table in a relational database. | ||
66 | * | ||
67 | * A persister is always responsible for a single entity type. | ||
68 | * | ||
69 | * EntityPersisters are used during a UnitOfWork to apply any changes to the persistent | ||
70 | * state of entities onto a relational database when the UnitOfWork is committed, | ||
71 | * as well as for basic querying of entities and their associations (not DQL). | ||
72 | * | ||
73 | * The persisting operations that are invoked during a commit of a UnitOfWork to | ||
74 | * persist the persistent entity state are: | ||
75 | * | ||
76 | * - {@link addInsert} : To schedule an entity for insertion. | ||
77 | * - {@link executeInserts} : To execute all scheduled insertions. | ||
78 | * - {@link update} : To update the persistent state of an entity. | ||
79 | * - {@link delete} : To delete the persistent state of an entity. | ||
80 | * | ||
81 | * As can be seen from the above list, insertions are batched and executed all at once | ||
82 | * for increased efficiency. | ||
83 | * | ||
84 | * The querying operations invoked during a UnitOfWork, either through direct find | ||
85 | * requests or lazy-loading, are the following: | ||
86 | * | ||
87 | * - {@link load} : Loads (the state of) a single, managed entity. | ||
88 | * - {@link loadAll} : Loads multiple, managed entities. | ||
89 | * - {@link loadOneToOneEntity} : Loads a one/many-to-one entity association (lazy-loading). | ||
90 | * - {@link loadOneToManyCollection} : Loads a one-to-many entity association (lazy-loading). | ||
91 | * - {@link loadManyToManyCollection} : Loads a many-to-many entity association (lazy-loading). | ||
92 | * | ||
93 | * The BasicEntityPersister implementation provides the default behavior for | ||
94 | * persisting and querying entities that are mapped to a single database table. | ||
95 | * | ||
96 | * Subclasses can be created to provide custom persisting and querying strategies, | ||
97 | * i.e. spanning multiple tables. | ||
98 | */ | ||
99 | class BasicEntityPersister implements EntityPersister | ||
100 | { | ||
101 | use LockSqlHelper; | ||
102 | |||
103 | /** @var array<string,string> */ | ||
104 | private static array $comparisonMap = [ | ||
105 | Comparison::EQ => '= %s', | ||
106 | Comparison::NEQ => '!= %s', | ||
107 | Comparison::GT => '> %s', | ||
108 | Comparison::GTE => '>= %s', | ||
109 | Comparison::LT => '< %s', | ||
110 | Comparison::LTE => '<= %s', | ||
111 | Comparison::IN => 'IN (%s)', | ||
112 | Comparison::NIN => 'NOT IN (%s)', | ||
113 | Comparison::CONTAINS => 'LIKE %s', | ||
114 | Comparison::STARTS_WITH => 'LIKE %s', | ||
115 | Comparison::ENDS_WITH => 'LIKE %s', | ||
116 | ]; | ||
117 | |||
118 | /** | ||
119 | * The underlying DBAL Connection of the used EntityManager. | ||
120 | */ | ||
121 | protected Connection $conn; | ||
122 | |||
123 | /** | ||
124 | * The database platform. | ||
125 | */ | ||
126 | protected AbstractPlatform $platform; | ||
127 | |||
128 | /** | ||
129 | * Queued inserts. | ||
130 | * | ||
131 | * @psalm-var array<int, object> | ||
132 | */ | ||
133 | protected array $queuedInserts = []; | ||
134 | |||
135 | /** | ||
136 | * The map of column names to DBAL mapping types of all prepared columns used | ||
137 | * when INSERTing or UPDATEing an entity. | ||
138 | * | ||
139 | * @see prepareInsertData($entity) | ||
140 | * @see prepareUpdateData($entity) | ||
141 | * | ||
142 | * @var mixed[] | ||
143 | */ | ||
144 | protected array $columnTypes = []; | ||
145 | |||
146 | /** | ||
147 | * The map of quoted column names. | ||
148 | * | ||
149 | * @see prepareInsertData($entity) | ||
150 | * @see prepareUpdateData($entity) | ||
151 | * | ||
152 | * @var mixed[] | ||
153 | */ | ||
154 | protected array $quotedColumns = []; | ||
155 | |||
156 | /** | ||
157 | * The INSERT SQL statement used for entities handled by this persister. | ||
158 | * This SQL is only generated once per request, if at all. | ||
159 | */ | ||
160 | private string|null $insertSql = null; | ||
161 | |||
162 | /** | ||
163 | * The quote strategy. | ||
164 | */ | ||
165 | protected QuoteStrategy $quoteStrategy; | ||
166 | |||
167 | /** | ||
168 | * The IdentifierFlattener used for manipulating identifiers | ||
169 | */ | ||
170 | protected readonly IdentifierFlattener $identifierFlattener; | ||
171 | |||
172 | protected CachedPersisterContext $currentPersisterContext; | ||
173 | private readonly CachedPersisterContext $limitsHandlingContext; | ||
174 | private readonly CachedPersisterContext $noLimitsContext; | ||
175 | |||
176 | /** | ||
177 | * Initializes a new <tt>BasicEntityPersister</tt> that uses the given EntityManager | ||
178 | * and persists instances of the class described by the given ClassMetadata descriptor. | ||
179 | * | ||
180 | * @param ClassMetadata $class Metadata object that describes the mapping of the mapped entity class. | ||
181 | */ | ||
182 | public function __construct( | ||
183 | protected EntityManagerInterface $em, | ||
184 | protected ClassMetadata $class, | ||
185 | ) { | ||
186 | $this->conn = $em->getConnection(); | ||
187 | $this->platform = $this->conn->getDatabasePlatform(); | ||
188 | $this->quoteStrategy = $em->getConfiguration()->getQuoteStrategy(); | ||
189 | $this->identifierFlattener = new IdentifierFlattener($em->getUnitOfWork(), $em->getMetadataFactory()); | ||
190 | $this->noLimitsContext = $this->currentPersisterContext = new CachedPersisterContext( | ||
191 | $class, | ||
192 | new Query\ResultSetMapping(), | ||
193 | false, | ||
194 | ); | ||
195 | $this->limitsHandlingContext = new CachedPersisterContext( | ||
196 | $class, | ||
197 | new Query\ResultSetMapping(), | ||
198 | true, | ||
199 | ); | ||
200 | } | ||
201 | |||
202 | public function getClassMetadata(): ClassMetadata | ||
203 | { | ||
204 | return $this->class; | ||
205 | } | ||
206 | |||
207 | public function getResultSetMapping(): ResultSetMapping | ||
208 | { | ||
209 | return $this->currentPersisterContext->rsm; | ||
210 | } | ||
211 | |||
212 | public function addInsert(object $entity): void | ||
213 | { | ||
214 | $this->queuedInserts[spl_object_id($entity)] = $entity; | ||
215 | } | ||
216 | |||
217 | /** | ||
218 | * {@inheritDoc} | ||
219 | */ | ||
220 | public function getInserts(): array | ||
221 | { | ||
222 | return $this->queuedInserts; | ||
223 | } | ||
224 | |||
225 | public function executeInserts(): void | ||
226 | { | ||
227 | if (! $this->queuedInserts) { | ||
228 | return; | ||
229 | } | ||
230 | |||
231 | $uow = $this->em->getUnitOfWork(); | ||
232 | $idGenerator = $this->class->idGenerator; | ||
233 | $isPostInsertId = $idGenerator->isPostInsertGenerator(); | ||
234 | |||
235 | $stmt = $this->conn->prepare($this->getInsertSQL()); | ||
236 | $tableName = $this->class->getTableName(); | ||
237 | |||
238 | foreach ($this->queuedInserts as $key => $entity) { | ||
239 | $insertData = $this->prepareInsertData($entity); | ||
240 | |||
241 | if (isset($insertData[$tableName])) { | ||
242 | $paramIndex = 1; | ||
243 | |||
244 | foreach ($insertData[$tableName] as $column => $value) { | ||
245 | $stmt->bindValue($paramIndex++, $value, $this->columnTypes[$column]); | ||
246 | } | ||
247 | } | ||
248 | |||
249 | $stmt->executeStatement(); | ||
250 | |||
251 | if ($isPostInsertId) { | ||
252 | $generatedId = $idGenerator->generateId($this->em, $entity); | ||
253 | $id = [$this->class->identifier[0] => $generatedId]; | ||
254 | |||
255 | $uow->assignPostInsertId($entity, $generatedId); | ||
256 | } else { | ||
257 | $id = $this->class->getIdentifierValues($entity); | ||
258 | } | ||
259 | |||
260 | if ($this->class->requiresFetchAfterChange) { | ||
261 | $this->assignDefaultVersionAndUpsertableValues($entity, $id); | ||
262 | } | ||
263 | |||
264 | // Unset this queued insert, so that the prepareUpdateData() method knows right away | ||
265 | // (for the next entity already) that the current entity has been written to the database | ||
266 | // and no extra updates need to be scheduled to refer to it. | ||
267 | // | ||
268 | // In \Doctrine\ORM\UnitOfWork::executeInserts(), the UoW already removed entities | ||
269 | // from its own list (\Doctrine\ORM\UnitOfWork::$entityInsertions) right after they | ||
270 | // were given to our addInsert() method. | ||
271 | unset($this->queuedInserts[$key]); | ||
272 | } | ||
273 | } | ||
274 | |||
275 | /** | ||
276 | * Retrieves the default version value which was created | ||
277 | * by the preceding INSERT statement and assigns it back in to the | ||
278 | * entities version field if the given entity is versioned. | ||
279 | * Also retrieves values of columns marked as 'non insertable' and / or | ||
280 | * 'not updatable' and assigns them back to the entities corresponding fields. | ||
281 | * | ||
282 | * @param mixed[] $id | ||
283 | */ | ||
284 | protected function assignDefaultVersionAndUpsertableValues(object $entity, array $id): void | ||
285 | { | ||
286 | $values = $this->fetchVersionAndNotUpsertableValues($this->class, $id); | ||
287 | |||
288 | foreach ($values as $field => $value) { | ||
289 | $value = Type::getType($this->class->fieldMappings[$field]->type)->convertToPHPValue($value, $this->platform); | ||
290 | |||
291 | $this->class->setFieldValue($entity, $field, $value); | ||
292 | } | ||
293 | } | ||
294 | |||
295 | /** | ||
296 | * Fetches the current version value of a versioned entity and / or the values of fields | ||
297 | * marked as 'not insertable' and / or 'not updatable'. | ||
298 | * | ||
299 | * @param mixed[] $id | ||
300 | */ | ||
301 | protected function fetchVersionAndNotUpsertableValues(ClassMetadata $versionedClass, array $id): mixed | ||
302 | { | ||
303 | $columnNames = []; | ||
304 | foreach ($this->class->fieldMappings as $key => $column) { | ||
305 | if (isset($column->generated) || ($this->class->isVersioned && $key === $versionedClass->versionField)) { | ||
306 | $columnNames[$key] = $this->quoteStrategy->getColumnName($key, $versionedClass, $this->platform); | ||
307 | } | ||
308 | } | ||
309 | |||
310 | $tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform); | ||
311 | $identifier = $this->quoteStrategy->getIdentifierColumnNames($versionedClass, $this->platform); | ||
312 | |||
313 | // FIXME: Order with composite keys might not be correct | ||
314 | $sql = 'SELECT ' . implode(', ', $columnNames) | ||
315 | . ' FROM ' . $tableName | ||
316 | . ' WHERE ' . implode(' = ? AND ', $identifier) . ' = ?'; | ||
317 | |||
318 | $flatId = $this->identifierFlattener->flattenIdentifier($versionedClass, $id); | ||
319 | |||
320 | $values = $this->conn->fetchNumeric( | ||
321 | $sql, | ||
322 | array_values($flatId), | ||
323 | $this->extractIdentifierTypes($id, $versionedClass), | ||
324 | ); | ||
325 | |||
326 | if ($values === false) { | ||
327 | throw new LengthException('Unexpected empty result for database query.'); | ||
328 | } | ||
329 | |||
330 | $values = array_combine(array_keys($columnNames), $values); | ||
331 | |||
332 | if (! $values) { | ||
333 | throw new LengthException('Unexpected number of database columns.'); | ||
334 | } | ||
335 | |||
336 | return $values; | ||
337 | } | ||
338 | |||
339 | /** | ||
340 | * @param mixed[] $id | ||
341 | * | ||
342 | * @return list<ParameterType|int|string> | ||
343 | * @psalm-return list<ParameterType::*|ArrayParameterType::*|string> | ||
344 | */ | ||
345 | final protected function extractIdentifierTypes(array $id, ClassMetadata $versionedClass): array | ||
346 | { | ||
347 | $types = []; | ||
348 | |||
349 | foreach ($id as $field => $value) { | ||
350 | $types = [...$types, ...$this->getTypes($field, $value, $versionedClass)]; | ||
351 | } | ||
352 | |||
353 | return $types; | ||
354 | } | ||
355 | |||
356 | public function update(object $entity): void | ||
357 | { | ||
358 | $tableName = $this->class->getTableName(); | ||
359 | $updateData = $this->prepareUpdateData($entity); | ||
360 | |||
361 | if (! isset($updateData[$tableName])) { | ||
362 | return; | ||
363 | } | ||
364 | |||
365 | $data = $updateData[$tableName]; | ||
366 | |||
367 | if (! $data) { | ||
368 | return; | ||
369 | } | ||
370 | |||
371 | $isVersioned = $this->class->isVersioned; | ||
372 | $quotedTableName = $this->quoteStrategy->getTableName($this->class, $this->platform); | ||
373 | |||
374 | $this->updateTable($entity, $quotedTableName, $data, $isVersioned); | ||
375 | |||
376 | if ($this->class->requiresFetchAfterChange) { | ||
377 | $id = $this->class->getIdentifierValues($entity); | ||
378 | |||
379 | $this->assignDefaultVersionAndUpsertableValues($entity, $id); | ||
380 | } | ||
381 | } | ||
382 | |||
383 | /** | ||
384 | * Performs an UPDATE statement for an entity on a specific table. | ||
385 | * The UPDATE can optionally be versioned, which requires the entity to have a version field. | ||
386 | * | ||
387 | * @param object $entity The entity object being updated. | ||
388 | * @param string $quotedTableName The quoted name of the table to apply the UPDATE on. | ||
389 | * @param mixed[] $updateData The map of columns to update (column => value). | ||
390 | * @param bool $versioned Whether the UPDATE should be versioned. | ||
391 | * | ||
392 | * @throws UnrecognizedField | ||
393 | * @throws OptimisticLockException | ||
394 | */ | ||
395 | final protected function updateTable( | ||
396 | object $entity, | ||
397 | string $quotedTableName, | ||
398 | array $updateData, | ||
399 | bool $versioned = false, | ||
400 | ): void { | ||
401 | $set = []; | ||
402 | $types = []; | ||
403 | $params = []; | ||
404 | |||
405 | foreach ($updateData as $columnName => $value) { | ||
406 | $placeholder = '?'; | ||
407 | $column = $columnName; | ||
408 | |||
409 | switch (true) { | ||
410 | case isset($this->class->fieldNames[$columnName]): | ||
411 | $fieldName = $this->class->fieldNames[$columnName]; | ||
412 | $column = $this->quoteStrategy->getColumnName($fieldName, $this->class, $this->platform); | ||
413 | |||
414 | if (isset($this->class->fieldMappings[$fieldName])) { | ||
415 | $type = Type::getType($this->columnTypes[$columnName]); | ||
416 | $placeholder = $type->convertToDatabaseValueSQL('?', $this->platform); | ||
417 | } | ||
418 | |||
419 | break; | ||
420 | |||
421 | case isset($this->quotedColumns[$columnName]): | ||
422 | $column = $this->quotedColumns[$columnName]; | ||
423 | |||
424 | break; | ||
425 | } | ||
426 | |||
427 | $params[] = $value; | ||
428 | $set[] = $column . ' = ' . $placeholder; | ||
429 | $types[] = $this->columnTypes[$columnName]; | ||
430 | } | ||
431 | |||
432 | $where = []; | ||
433 | $identifier = $this->em->getUnitOfWork()->getEntityIdentifier($entity); | ||
434 | |||
435 | foreach ($this->class->identifier as $idField) { | ||
436 | if (! isset($this->class->associationMappings[$idField])) { | ||
437 | $params[] = $identifier[$idField]; | ||
438 | $types[] = $this->class->fieldMappings[$idField]->type; | ||
439 | $where[] = $this->quoteStrategy->getColumnName($idField, $this->class, $this->platform); | ||
440 | |||
441 | continue; | ||
442 | } | ||
443 | |||
444 | assert($this->class->associationMappings[$idField]->isToOneOwningSide()); | ||
445 | |||
446 | $params[] = $identifier[$idField]; | ||
447 | $where[] = $this->quoteStrategy->getJoinColumnName( | ||
448 | $this->class->associationMappings[$idField]->joinColumns[0], | ||
449 | $this->class, | ||
450 | $this->platform, | ||
451 | ); | ||
452 | |||
453 | $targetMapping = $this->em->getClassMetadata($this->class->associationMappings[$idField]->targetEntity); | ||
454 | $targetType = PersisterHelper::getTypeOfField($targetMapping->identifier[0], $targetMapping, $this->em); | ||
455 | |||
456 | if ($targetType === []) { | ||
457 | throw UnrecognizedField::byFullyQualifiedName($this->class->name, $targetMapping->identifier[0]); | ||
458 | } | ||
459 | |||
460 | $types[] = reset($targetType); | ||
461 | } | ||
462 | |||
463 | if ($versioned) { | ||
464 | $versionField = $this->class->versionField; | ||
465 | assert($versionField !== null); | ||
466 | $versionFieldType = $this->class->fieldMappings[$versionField]->type; | ||
467 | $versionColumn = $this->quoteStrategy->getColumnName($versionField, $this->class, $this->platform); | ||
468 | |||
469 | $where[] = $versionColumn; | ||
470 | $types[] = $this->class->fieldMappings[$versionField]->type; | ||
471 | $params[] = $this->class->reflFields[$versionField]->getValue($entity); | ||
472 | |||
473 | switch ($versionFieldType) { | ||
474 | case Types::SMALLINT: | ||
475 | case Types::INTEGER: | ||
476 | case Types::BIGINT: | ||
477 | $set[] = $versionColumn . ' = ' . $versionColumn . ' + 1'; | ||
478 | break; | ||
479 | |||
480 | case Types::DATETIME_MUTABLE: | ||
481 | $set[] = $versionColumn . ' = CURRENT_TIMESTAMP'; | ||
482 | break; | ||
483 | } | ||
484 | } | ||
485 | |||
486 | $sql = 'UPDATE ' . $quotedTableName | ||
487 | . ' SET ' . implode(', ', $set) | ||
488 | . ' WHERE ' . implode(' = ? AND ', $where) . ' = ?'; | ||
489 | |||
490 | $result = $this->conn->executeStatement($sql, $params, $types); | ||
491 | |||
492 | if ($versioned && ! $result) { | ||
493 | throw OptimisticLockException::lockFailed($entity); | ||
494 | } | ||
495 | } | ||
496 | |||
497 | /** | ||
498 | * @param array<mixed> $identifier | ||
499 | * @param string[] $types | ||
500 | * | ||
501 | * @todo Add check for platform if it supports foreign keys/cascading. | ||
502 | */ | ||
503 | protected function deleteJoinTableRecords(array $identifier, array $types): void | ||
504 | { | ||
505 | foreach ($this->class->associationMappings as $mapping) { | ||
506 | if (! $mapping->isManyToMany() || $mapping->isOnDeleteCascade) { | ||
507 | continue; | ||
508 | } | ||
509 | |||
510 | // @Todo this only covers scenarios with no inheritance or of the same level. Is there something | ||
511 | // like self-referential relationship between different levels of an inheritance hierarchy? I hope not! | ||
512 | $selfReferential = ($mapping->targetEntity === $mapping->sourceEntity); | ||
513 | $class = $this->class; | ||
514 | $association = $mapping; | ||
515 | $otherColumns = []; | ||
516 | $otherKeys = []; | ||
517 | $keys = []; | ||
518 | |||
519 | if (! $mapping->isOwningSide()) { | ||
520 | $class = $this->em->getClassMetadata($mapping->targetEntity); | ||
521 | } | ||
522 | |||
523 | $association = $this->em->getMetadataFactory()->getOwningSide($association); | ||
524 | $joinColumns = $mapping->isOwningSide() | ||
525 | ? $association->joinTable->joinColumns | ||
526 | : $association->joinTable->inverseJoinColumns; | ||
527 | |||
528 | if ($selfReferential) { | ||
529 | $otherColumns = ! $mapping->isOwningSide() | ||
530 | ? $association->joinTable->joinColumns | ||
531 | : $association->joinTable->inverseJoinColumns; | ||
532 | } | ||
533 | |||
534 | foreach ($joinColumns as $joinColumn) { | ||
535 | $keys[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); | ||
536 | } | ||
537 | |||
538 | foreach ($otherColumns as $joinColumn) { | ||
539 | $otherKeys[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); | ||
540 | } | ||
541 | |||
542 | $joinTableName = $this->quoteStrategy->getJoinTableName($association, $this->class, $this->platform); | ||
543 | |||
544 | $this->conn->delete($joinTableName, array_combine($keys, $identifier), $types); | ||
545 | |||
546 | if ($selfReferential) { | ||
547 | $this->conn->delete($joinTableName, array_combine($otherKeys, $identifier), $types); | ||
548 | } | ||
549 | } | ||
550 | } | ||
551 | |||
552 | public function delete(object $entity): bool | ||
553 | { | ||
554 | $class = $this->class; | ||
555 | $identifier = $this->em->getUnitOfWork()->getEntityIdentifier($entity); | ||
556 | $tableName = $this->quoteStrategy->getTableName($class, $this->platform); | ||
557 | $idColumns = $this->quoteStrategy->getIdentifierColumnNames($class, $this->platform); | ||
558 | $id = array_combine($idColumns, $identifier); | ||
559 | $types = $this->getClassIdentifiersTypes($class); | ||
560 | |||
561 | $this->deleteJoinTableRecords($identifier, $types); | ||
562 | |||
563 | return (bool) $this->conn->delete($tableName, $id, $types); | ||
564 | } | ||
565 | |||
566 | /** | ||
567 | * Prepares the changeset of an entity for database insertion (UPDATE). | ||
568 | * | ||
569 | * The changeset is obtained from the currently running UnitOfWork. | ||
570 | * | ||
571 | * During this preparation the array that is passed as the second parameter is filled with | ||
572 | * <columnName> => <value> pairs, grouped by table name. | ||
573 | * | ||
574 | * Example: | ||
575 | * <code> | ||
576 | * array( | ||
577 | * 'foo_table' => array('column1' => 'value1', 'column2' => 'value2', ...), | ||
578 | * 'bar_table' => array('columnX' => 'valueX', 'columnY' => 'valueY', ...), | ||
579 | * ... | ||
580 | * ) | ||
581 | * </code> | ||
582 | * | ||
583 | * @param object $entity The entity for which to prepare the data. | ||
584 | * @param bool $isInsert Whether the data to be prepared refers to an insert statement. | ||
585 | * | ||
586 | * @return mixed[][] The prepared data. | ||
587 | * @psalm-return array<string, array<array-key, mixed|null>> | ||
588 | */ | ||
589 | protected function prepareUpdateData(object $entity, bool $isInsert = false): array | ||
590 | { | ||
591 | $versionField = null; | ||
592 | $result = []; | ||
593 | $uow = $this->em->getUnitOfWork(); | ||
594 | |||
595 | $versioned = $this->class->isVersioned; | ||
596 | if ($versioned !== false) { | ||
597 | $versionField = $this->class->versionField; | ||
598 | } | ||
599 | |||
600 | foreach ($uow->getEntityChangeSet($entity) as $field => $change) { | ||
601 | if (isset($versionField) && $versionField === $field) { | ||
602 | continue; | ||
603 | } | ||
604 | |||
605 | if (isset($this->class->embeddedClasses[$field])) { | ||
606 | continue; | ||
607 | } | ||
608 | |||
609 | $newVal = $change[1]; | ||
610 | |||
611 | if (! isset($this->class->associationMappings[$field])) { | ||
612 | $fieldMapping = $this->class->fieldMappings[$field]; | ||
613 | $columnName = $fieldMapping->columnName; | ||
614 | |||
615 | if (! $isInsert && isset($fieldMapping->notUpdatable)) { | ||
616 | continue; | ||
617 | } | ||
618 | |||
619 | if ($isInsert && isset($fieldMapping->notInsertable)) { | ||
620 | continue; | ||
621 | } | ||
622 | |||
623 | $this->columnTypes[$columnName] = $fieldMapping->type; | ||
624 | |||
625 | $result[$this->getOwningTable($field)][$columnName] = $newVal; | ||
626 | |||
627 | continue; | ||
628 | } | ||
629 | |||
630 | $assoc = $this->class->associationMappings[$field]; | ||
631 | |||
632 | // Only owning side of x-1 associations can have a FK column. | ||
633 | if (! $assoc->isToOneOwningSide()) { | ||
634 | continue; | ||
635 | } | ||
636 | |||
637 | if ($newVal !== null) { | ||
638 | $oid = spl_object_id($newVal); | ||
639 | |||
640 | // If the associated entity $newVal is not yet persisted and/or does not yet have | ||
641 | // an ID assigned, we must set $newVal = null. This will insert a null value and | ||
642 | // schedule an extra update on the UnitOfWork. | ||
643 | // | ||
644 | // This gives us extra time to a) possibly obtain a database-generated identifier | ||
645 | // value for $newVal, and b) insert $newVal into the database before the foreign | ||
646 | // key reference is being made. | ||
647 | // | ||
648 | // When looking at $this->queuedInserts and $uow->isScheduledForInsert, be aware | ||
649 | // of the implementation details that our own executeInserts() method will remove | ||
650 | // entities from the former as soon as the insert statement has been executed and | ||
651 | // a post-insert ID has been assigned (if necessary), and that the UnitOfWork has | ||
652 | // already removed entities from its own list at the time they were passed to our | ||
653 | // addInsert() method. | ||
654 | // | ||
655 | // Then, there is one extra exception we can make: An entity that references back to itself | ||
656 | // _and_ uses an application-provided ID (the "NONE" generator strategy) also does not | ||
657 | // need the extra update, although it is still in the list of insertions itself. | ||
658 | // This looks like a minor optimization at first, but is the capstone for being able to | ||
659 | // use non-NULLable, self-referencing associations in applications that provide IDs (like UUIDs). | ||
660 | if ( | ||
661 | (isset($this->queuedInserts[$oid]) || $uow->isScheduledForInsert($newVal)) | ||
662 | && ! ($newVal === $entity && $this->class->isIdentifierNatural()) | ||
663 | ) { | ||
664 | $uow->scheduleExtraUpdate($entity, [$field => [null, $newVal]]); | ||
665 | |||
666 | $newVal = null; | ||
667 | } | ||
668 | } | ||
669 | |||
670 | $newValId = null; | ||
671 | |||
672 | if ($newVal !== null) { | ||
673 | $newValId = $uow->getEntityIdentifier($newVal); | ||
674 | } | ||
675 | |||
676 | $targetClass = $this->em->getClassMetadata($assoc->targetEntity); | ||
677 | $owningTable = $this->getOwningTable($field); | ||
678 | |||
679 | foreach ($assoc->joinColumns as $joinColumn) { | ||
680 | $sourceColumn = $joinColumn->name; | ||
681 | $targetColumn = $joinColumn->referencedColumnName; | ||
682 | $quotedColumn = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); | ||
683 | |||
684 | $this->quotedColumns[$sourceColumn] = $quotedColumn; | ||
685 | $this->columnTypes[$sourceColumn] = PersisterHelper::getTypeOfColumn($targetColumn, $targetClass, $this->em); | ||
686 | $result[$owningTable][$sourceColumn] = $newValId | ||
687 | ? $newValId[$targetClass->getFieldForColumn($targetColumn)] | ||
688 | : null; | ||
689 | } | ||
690 | } | ||
691 | |||
692 | return $result; | ||
693 | } | ||
694 | |||
695 | /** | ||
696 | * Prepares the data changeset of a managed entity for database insertion (initial INSERT). | ||
697 | * The changeset of the entity is obtained from the currently running UnitOfWork. | ||
698 | * | ||
699 | * The default insert data preparation is the same as for updates. | ||
700 | * | ||
701 | * @see prepareUpdateData | ||
702 | * | ||
703 | * @param object $entity The entity for which to prepare the data. | ||
704 | * | ||
705 | * @return mixed[][] The prepared data for the tables to update. | ||
706 | * @psalm-return array<string, mixed[]> | ||
707 | */ | ||
708 | protected function prepareInsertData(object $entity): array | ||
709 | { | ||
710 | return $this->prepareUpdateData($entity, true); | ||
711 | } | ||
712 | |||
713 | public function getOwningTable(string $fieldName): string | ||
714 | { | ||
715 | return $this->class->getTableName(); | ||
716 | } | ||
717 | |||
718 | /** | ||
719 | * {@inheritDoc} | ||
720 | */ | ||
721 | public function load( | ||
722 | array $criteria, | ||
723 | object|null $entity = null, | ||
724 | AssociationMapping|null $assoc = null, | ||
725 | array $hints = [], | ||
726 | LockMode|int|null $lockMode = null, | ||
727 | int|null $limit = null, | ||
728 | array|null $orderBy = null, | ||
729 | ): object|null { | ||
730 | $this->switchPersisterContext(null, $limit); | ||
731 | |||
732 | $sql = $this->getSelectSQL($criteria, $assoc, $lockMode, $limit, null, $orderBy); | ||
733 | [$params, $types] = $this->expandParameters($criteria); | ||
734 | $stmt = $this->conn->executeQuery($sql, $params, $types); | ||
735 | |||
736 | if ($entity !== null) { | ||
737 | $hints[Query::HINT_REFRESH] = true; | ||
738 | $hints[Query::HINT_REFRESH_ENTITY] = $entity; | ||
739 | } | ||
740 | |||
741 | $hydrator = $this->em->newHydrator($this->currentPersisterContext->selectJoinSql ? Query::HYDRATE_OBJECT : Query::HYDRATE_SIMPLEOBJECT); | ||
742 | $entities = $hydrator->hydrateAll($stmt, $this->currentPersisterContext->rsm, $hints); | ||
743 | |||
744 | return $entities ? $entities[0] : null; | ||
745 | } | ||
746 | |||
747 | /** | ||
748 | * {@inheritDoc} | ||
749 | */ | ||
750 | public function loadById(array $identifier, object|null $entity = null): object|null | ||
751 | { | ||
752 | return $this->load($identifier, $entity); | ||
753 | } | ||
754 | |||
755 | /** | ||
756 | * {@inheritDoc} | ||
757 | */ | ||
758 | public function loadOneToOneEntity(AssociationMapping $assoc, object $sourceEntity, array $identifier = []): object|null | ||
759 | { | ||
760 | $foundEntity = $this->em->getUnitOfWork()->tryGetById($identifier, $assoc->targetEntity); | ||
761 | if ($foundEntity !== false) { | ||
762 | return $foundEntity; | ||
763 | } | ||
764 | |||
765 | $targetClass = $this->em->getClassMetadata($assoc->targetEntity); | ||
766 | |||
767 | if ($assoc->isOwningSide()) { | ||
768 | $isInverseSingleValued = $assoc->inversedBy !== null && ! $targetClass->isCollectionValuedAssociation($assoc->inversedBy); | ||
769 | |||
770 | // Mark inverse side as fetched in the hints, otherwise the UoW would | ||
771 | // try to load it in a separate query (remember: to-one inverse sides can not be lazy). | ||
772 | $hints = []; | ||
773 | |||
774 | if ($isInverseSingleValued) { | ||
775 | $hints['fetched']['r'][$assoc->inversedBy] = true; | ||
776 | } | ||
777 | |||
778 | $targetEntity = $this->load($identifier, null, $assoc, $hints); | ||
779 | |||
780 | // Complete bidirectional association, if necessary | ||
781 | if ($targetEntity !== null && $isInverseSingleValued) { | ||
782 | $targetClass->reflFields[$assoc->inversedBy]->setValue($targetEntity, $sourceEntity); | ||
783 | } | ||
784 | |||
785 | return $targetEntity; | ||
786 | } | ||
787 | |||
788 | assert(isset($assoc->mappedBy)); | ||
789 | $sourceClass = $this->em->getClassMetadata($assoc->sourceEntity); | ||
790 | $owningAssoc = $targetClass->getAssociationMapping($assoc->mappedBy); | ||
791 | assert($owningAssoc->isOneToOneOwningSide()); | ||
792 | |||
793 | $computedIdentifier = []; | ||
794 | |||
795 | // TRICKY: since the association is specular source and target are flipped | ||
796 | foreach ($owningAssoc->targetToSourceKeyColumns as $sourceKeyColumn => $targetKeyColumn) { | ||
797 | if (! isset($sourceClass->fieldNames[$sourceKeyColumn])) { | ||
798 | throw MappingException::joinColumnMustPointToMappedField( | ||
799 | $sourceClass->name, | ||
800 | $sourceKeyColumn, | ||
801 | ); | ||
802 | } | ||
803 | |||
804 | $computedIdentifier[$targetClass->getFieldForColumn($targetKeyColumn)] = | ||
805 | $sourceClass->reflFields[$sourceClass->fieldNames[$sourceKeyColumn]]->getValue($sourceEntity); | ||
806 | } | ||
807 | |||
808 | $targetEntity = $this->load($computedIdentifier, null, $assoc); | ||
809 | |||
810 | if ($targetEntity !== null) { | ||
811 | $targetClass->setFieldValue($targetEntity, $assoc->mappedBy, $sourceEntity); | ||
812 | } | ||
813 | |||
814 | return $targetEntity; | ||
815 | } | ||
816 | |||
817 | /** | ||
818 | * {@inheritDoc} | ||
819 | */ | ||
820 | public function refresh(array $id, object $entity, LockMode|int|null $lockMode = null): void | ||
821 | { | ||
822 | $sql = $this->getSelectSQL($id, null, $lockMode); | ||
823 | [$params, $types] = $this->expandParameters($id); | ||
824 | $stmt = $this->conn->executeQuery($sql, $params, $types); | ||
825 | |||
826 | $hydrator = $this->em->newHydrator(Query::HYDRATE_OBJECT); | ||
827 | $hydrator->hydrateAll($stmt, $this->currentPersisterContext->rsm, [Query::HINT_REFRESH => true]); | ||
828 | } | ||
829 | |||
830 | public function count(array|Criteria $criteria = []): int | ||
831 | { | ||
832 | $sql = $this->getCountSQL($criteria); | ||
833 | |||
834 | [$params, $types] = $criteria instanceof Criteria | ||
835 | ? $this->expandCriteriaParameters($criteria) | ||
836 | : $this->expandParameters($criteria); | ||
837 | |||
838 | return (int) $this->conn->executeQuery($sql, $params, $types)->fetchOne(); | ||
839 | } | ||
840 | |||
841 | /** | ||
842 | * {@inheritDoc} | ||
843 | */ | ||
844 | public function loadCriteria(Criteria $criteria): array | ||
845 | { | ||
846 | $orderBy = array_map( | ||
847 | static fn (Order $order): string => $order->value, | ||
848 | $criteria->orderings(), | ||
849 | ); | ||
850 | $limit = $criteria->getMaxResults(); | ||
851 | $offset = $criteria->getFirstResult(); | ||
852 | $query = $this->getSelectSQL($criteria, null, null, $limit, $offset, $orderBy); | ||
853 | |||
854 | [$params, $types] = $this->expandCriteriaParameters($criteria); | ||
855 | |||
856 | $stmt = $this->conn->executeQuery($query, $params, $types); | ||
857 | $hydrator = $this->em->newHydrator($this->currentPersisterContext->selectJoinSql ? Query::HYDRATE_OBJECT : Query::HYDRATE_SIMPLEOBJECT); | ||
858 | |||
859 | return $hydrator->hydrateAll($stmt, $this->currentPersisterContext->rsm, [UnitOfWork::HINT_DEFEREAGERLOAD => true]); | ||
860 | } | ||
861 | |||
862 | /** | ||
863 | * {@inheritDoc} | ||
864 | */ | ||
865 | public function expandCriteriaParameters(Criteria $criteria): array | ||
866 | { | ||
867 | $expression = $criteria->getWhereExpression(); | ||
868 | $sqlParams = []; | ||
869 | $sqlTypes = []; | ||
870 | |||
871 | if ($expression === null) { | ||
872 | return [$sqlParams, $sqlTypes]; | ||
873 | } | ||
874 | |||
875 | $valueVisitor = new SqlValueVisitor(); | ||
876 | |||
877 | $valueVisitor->dispatch($expression); | ||
878 | |||
879 | [, $types] = $valueVisitor->getParamsAndTypes(); | ||
880 | |||
881 | foreach ($types as $type) { | ||
882 | [$field, $value, $operator] = $type; | ||
883 | |||
884 | if ($value === null && ($operator === Comparison::EQ || $operator === Comparison::NEQ)) { | ||
885 | continue; | ||
886 | } | ||
887 | |||
888 | $sqlParams = [...$sqlParams, ...$this->getValues($value)]; | ||
889 | $sqlTypes = [...$sqlTypes, ...$this->getTypes($field, $value, $this->class)]; | ||
890 | } | ||
891 | |||
892 | return [$sqlParams, $sqlTypes]; | ||
893 | } | ||
894 | |||
895 | /** | ||
896 | * {@inheritDoc} | ||
897 | */ | ||
898 | public function loadAll( | ||
899 | array $criteria = [], | ||
900 | array|null $orderBy = null, | ||
901 | int|null $limit = null, | ||
902 | int|null $offset = null, | ||
903 | ): array { | ||
904 | $this->switchPersisterContext($offset, $limit); | ||
905 | |||
906 | $sql = $this->getSelectSQL($criteria, null, null, $limit, $offset, $orderBy); | ||
907 | [$params, $types] = $this->expandParameters($criteria); | ||
908 | $stmt = $this->conn->executeQuery($sql, $params, $types); | ||
909 | |||
910 | $hydrator = $this->em->newHydrator($this->currentPersisterContext->selectJoinSql ? Query::HYDRATE_OBJECT : Query::HYDRATE_SIMPLEOBJECT); | ||
911 | |||
912 | return $hydrator->hydrateAll($stmt, $this->currentPersisterContext->rsm, [UnitOfWork::HINT_DEFEREAGERLOAD => true]); | ||
913 | } | ||
914 | |||
915 | /** | ||
916 | * {@inheritDoc} | ||
917 | */ | ||
918 | public function getManyToManyCollection( | ||
919 | AssociationMapping $assoc, | ||
920 | object $sourceEntity, | ||
921 | int|null $offset = null, | ||
922 | int|null $limit = null, | ||
923 | ): array { | ||
924 | assert($assoc->isManyToMany()); | ||
925 | $this->switchPersisterContext($offset, $limit); | ||
926 | |||
927 | $stmt = $this->getManyToManyStatement($assoc, $sourceEntity, $offset, $limit); | ||
928 | |||
929 | return $this->loadArrayFromResult($assoc, $stmt); | ||
930 | } | ||
931 | |||
932 | /** | ||
933 | * Loads an array of entities from a given DBAL statement. | ||
934 | * | ||
935 | * @return mixed[] | ||
936 | */ | ||
937 | private function loadArrayFromResult(AssociationMapping $assoc, Result $stmt): array | ||
938 | { | ||
939 | $rsm = $this->currentPersisterContext->rsm; | ||
940 | $hints = [UnitOfWork::HINT_DEFEREAGERLOAD => true]; | ||
941 | |||
942 | if ($assoc->isIndexed()) { | ||
943 | $rsm = clone $this->currentPersisterContext->rsm; // this is necessary because the "default rsm" should be changed. | ||
944 | $rsm->addIndexBy('r', $assoc->indexBy()); | ||
945 | } | ||
946 | |||
947 | return $this->em->newHydrator(Query::HYDRATE_OBJECT)->hydrateAll($stmt, $rsm, $hints); | ||
948 | } | ||
949 | |||
950 | /** | ||
951 | * Hydrates a collection from a given DBAL statement. | ||
952 | * | ||
953 | * @return mixed[] | ||
954 | */ | ||
955 | private function loadCollectionFromStatement( | ||
956 | AssociationMapping $assoc, | ||
957 | Result $stmt, | ||
958 | PersistentCollection $coll, | ||
959 | ): array { | ||
960 | $rsm = $this->currentPersisterContext->rsm; | ||
961 | $hints = [ | ||
962 | UnitOfWork::HINT_DEFEREAGERLOAD => true, | ||
963 | 'collection' => $coll, | ||
964 | ]; | ||
965 | |||
966 | if ($assoc->isIndexed()) { | ||
967 | $rsm = clone $this->currentPersisterContext->rsm; // this is necessary because the "default rsm" should be changed. | ||
968 | $rsm->addIndexBy('r', $assoc->indexBy()); | ||
969 | } | ||
970 | |||
971 | return $this->em->newHydrator(Query::HYDRATE_OBJECT)->hydrateAll($stmt, $rsm, $hints); | ||
972 | } | ||
973 | |||
974 | /** | ||
975 | * {@inheritDoc} | ||
976 | */ | ||
977 | public function loadManyToManyCollection(AssociationMapping $assoc, object $sourceEntity, PersistentCollection $collection): array | ||
978 | { | ||
979 | assert($assoc->isManyToMany()); | ||
980 | $stmt = $this->getManyToManyStatement($assoc, $sourceEntity); | ||
981 | |||
982 | return $this->loadCollectionFromStatement($assoc, $stmt, $collection); | ||
983 | } | ||
984 | |||
985 | /** @throws MappingException */ | ||
986 | private function getManyToManyStatement( | ||
987 | AssociationMapping&ManyToManyAssociationMapping $assoc, | ||
988 | object $sourceEntity, | ||
989 | int|null $offset = null, | ||
990 | int|null $limit = null, | ||
991 | ): Result { | ||
992 | $this->switchPersisterContext($offset, $limit); | ||
993 | |||
994 | $sourceClass = $this->em->getClassMetadata($assoc->sourceEntity); | ||
995 | $class = $sourceClass; | ||
996 | $association = $assoc; | ||
997 | $criteria = []; | ||
998 | $parameters = []; | ||
999 | |||
1000 | if (! $assoc->isOwningSide()) { | ||
1001 | $class = $this->em->getClassMetadata($assoc->targetEntity); | ||
1002 | } | ||
1003 | |||
1004 | $association = $this->em->getMetadataFactory()->getOwningSide($assoc); | ||
1005 | $joinColumns = $assoc->isOwningSide() | ||
1006 | ? $association->joinTable->joinColumns | ||
1007 | : $association->joinTable->inverseJoinColumns; | ||
1008 | |||
1009 | $quotedJoinTable = $this->quoteStrategy->getJoinTableName($association, $class, $this->platform); | ||
1010 | |||
1011 | foreach ($joinColumns as $joinColumn) { | ||
1012 | $sourceKeyColumn = $joinColumn->referencedColumnName; | ||
1013 | $quotedKeyColumn = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); | ||
1014 | |||
1015 | switch (true) { | ||
1016 | case $sourceClass->containsForeignIdentifier: | ||
1017 | $field = $sourceClass->getFieldForColumn($sourceKeyColumn); | ||
1018 | $value = $sourceClass->reflFields[$field]->getValue($sourceEntity); | ||
1019 | |||
1020 | if (isset($sourceClass->associationMappings[$field])) { | ||
1021 | $value = $this->em->getUnitOfWork()->getEntityIdentifier($value); | ||
1022 | $value = $value[$this->em->getClassMetadata($sourceClass->associationMappings[$field]->targetEntity)->identifier[0]]; | ||
1023 | } | ||
1024 | |||
1025 | break; | ||
1026 | |||
1027 | case isset($sourceClass->fieldNames[$sourceKeyColumn]): | ||
1028 | $field = $sourceClass->fieldNames[$sourceKeyColumn]; | ||
1029 | $value = $sourceClass->reflFields[$field]->getValue($sourceEntity); | ||
1030 | |||
1031 | break; | ||
1032 | |||
1033 | default: | ||
1034 | throw MappingException::joinColumnMustPointToMappedField( | ||
1035 | $sourceClass->name, | ||
1036 | $sourceKeyColumn, | ||
1037 | ); | ||
1038 | } | ||
1039 | |||
1040 | $criteria[$quotedJoinTable . '.' . $quotedKeyColumn] = $value; | ||
1041 | $parameters[] = [ | ||
1042 | 'value' => $value, | ||
1043 | 'field' => $field, | ||
1044 | 'class' => $sourceClass, | ||
1045 | ]; | ||
1046 | } | ||
1047 | |||
1048 | $sql = $this->getSelectSQL($criteria, $assoc, null, $limit, $offset); | ||
1049 | [$params, $types] = $this->expandToManyParameters($parameters); | ||
1050 | |||
1051 | return $this->conn->executeQuery($sql, $params, $types); | ||
1052 | } | ||
1053 | |||
1054 | public function getSelectSQL( | ||
1055 | array|Criteria $criteria, | ||
1056 | AssociationMapping|null $assoc = null, | ||
1057 | LockMode|int|null $lockMode = null, | ||
1058 | int|null $limit = null, | ||
1059 | int|null $offset = null, | ||
1060 | array|null $orderBy = null, | ||
1061 | ): string { | ||
1062 | $this->switchPersisterContext($offset, $limit); | ||
1063 | |||
1064 | $joinSql = ''; | ||
1065 | $orderBySql = ''; | ||
1066 | |||
1067 | if ($assoc !== null && $assoc->isManyToMany()) { | ||
1068 | $joinSql = $this->getSelectManyToManyJoinSQL($assoc); | ||
1069 | } | ||
1070 | |||
1071 | if ($assoc !== null && $assoc->isOrdered()) { | ||
1072 | $orderBy = $assoc->orderBy(); | ||
1073 | } | ||
1074 | |||
1075 | if ($orderBy) { | ||
1076 | $orderBySql = $this->getOrderBySQL($orderBy, $this->getSQLTableAlias($this->class->name)); | ||
1077 | } | ||
1078 | |||
1079 | $conditionSql = $criteria instanceof Criteria | ||
1080 | ? $this->getSelectConditionCriteriaSQL($criteria) | ||
1081 | : $this->getSelectConditionSQL($criteria, $assoc); | ||
1082 | |||
1083 | $lockSql = match ($lockMode) { | ||
1084 | LockMode::PESSIMISTIC_READ => ' ' . $this->getReadLockSQL($this->platform), | ||
1085 | LockMode::PESSIMISTIC_WRITE => ' ' . $this->getWriteLockSQL($this->platform), | ||
1086 | default => '', | ||
1087 | }; | ||
1088 | |||
1089 | $columnList = $this->getSelectColumnsSQL(); | ||
1090 | $tableAlias = $this->getSQLTableAlias($this->class->name); | ||
1091 | $filterSql = $this->generateFilterConditionSQL($this->class, $tableAlias); | ||
1092 | $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); | ||
1093 | |||
1094 | if ($filterSql !== '') { | ||
1095 | $conditionSql = $conditionSql | ||
1096 | ? $conditionSql . ' AND ' . $filterSql | ||
1097 | : $filterSql; | ||
1098 | } | ||
1099 | |||
1100 | $select = 'SELECT ' . $columnList; | ||
1101 | $from = ' FROM ' . $tableName . ' ' . $tableAlias; | ||
1102 | $join = $this->currentPersisterContext->selectJoinSql . $joinSql; | ||
1103 | $where = ($conditionSql ? ' WHERE ' . $conditionSql : ''); | ||
1104 | $lock = $this->platform->appendLockHint($from, $lockMode ?? LockMode::NONE); | ||
1105 | $query = $select | ||
1106 | . $lock | ||
1107 | . $join | ||
1108 | . $where | ||
1109 | . $orderBySql; | ||
1110 | |||
1111 | return $this->platform->modifyLimitQuery($query, $limit, $offset ?? 0) . $lockSql; | ||
1112 | } | ||
1113 | |||
1114 | public function getCountSQL(array|Criteria $criteria = []): string | ||
1115 | { | ||
1116 | $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); | ||
1117 | $tableAlias = $this->getSQLTableAlias($this->class->name); | ||
1118 | |||
1119 | $conditionSql = $criteria instanceof Criteria | ||
1120 | ? $this->getSelectConditionCriteriaSQL($criteria) | ||
1121 | : $this->getSelectConditionSQL($criteria); | ||
1122 | |||
1123 | $filterSql = $this->generateFilterConditionSQL($this->class, $tableAlias); | ||
1124 | |||
1125 | if ($filterSql !== '') { | ||
1126 | $conditionSql = $conditionSql | ||
1127 | ? $conditionSql . ' AND ' . $filterSql | ||
1128 | : $filterSql; | ||
1129 | } | ||
1130 | |||
1131 | return 'SELECT COUNT(*) ' | ||
1132 | . 'FROM ' . $tableName . ' ' . $tableAlias | ||
1133 | . (empty($conditionSql) ? '' : ' WHERE ' . $conditionSql); | ||
1134 | } | ||
1135 | |||
1136 | /** | ||
1137 | * Gets the ORDER BY SQL snippet for ordered collections. | ||
1138 | * | ||
1139 | * @psalm-param array<string, string> $orderBy | ||
1140 | * | ||
1141 | * @throws InvalidOrientation | ||
1142 | * @throws InvalidFindByCall | ||
1143 | * @throws UnrecognizedField | ||
1144 | */ | ||
1145 | final protected function getOrderBySQL(array $orderBy, string $baseTableAlias): string | ||
1146 | { | ||
1147 | $orderByList = []; | ||
1148 | |||
1149 | foreach ($orderBy as $fieldName => $orientation) { | ||
1150 | $orientation = strtoupper(trim($orientation)); | ||
1151 | |||
1152 | if ($orientation !== 'ASC' && $orientation !== 'DESC') { | ||
1153 | throw InvalidOrientation::fromClassNameAndField($this->class->name, $fieldName); | ||
1154 | } | ||
1155 | |||
1156 | if (isset($this->class->fieldMappings[$fieldName])) { | ||
1157 | $tableAlias = isset($this->class->fieldMappings[$fieldName]->inherited) | ||
1158 | ? $this->getSQLTableAlias($this->class->fieldMappings[$fieldName]->inherited) | ||
1159 | : $baseTableAlias; | ||
1160 | |||
1161 | $columnName = $this->quoteStrategy->getColumnName($fieldName, $this->class, $this->platform); | ||
1162 | $orderByList[] = $tableAlias . '.' . $columnName . ' ' . $orientation; | ||
1163 | |||
1164 | continue; | ||
1165 | } | ||
1166 | |||
1167 | if (isset($this->class->associationMappings[$fieldName])) { | ||
1168 | $association = $this->class->associationMappings[$fieldName]; | ||
1169 | if (! $association->isOwningSide()) { | ||
1170 | throw InvalidFindByCall::fromInverseSideUsage($this->class->name, $fieldName); | ||
1171 | } | ||
1172 | |||
1173 | assert($association->isToOneOwningSide()); | ||
1174 | |||
1175 | $tableAlias = isset($association->inherited) | ||
1176 | ? $this->getSQLTableAlias($association->inherited) | ||
1177 | : $baseTableAlias; | ||
1178 | |||
1179 | foreach ($association->joinColumns as $joinColumn) { | ||
1180 | $columnName = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1181 | $orderByList[] = $tableAlias . '.' . $columnName . ' ' . $orientation; | ||
1182 | } | ||
1183 | |||
1184 | continue; | ||
1185 | } | ||
1186 | |||
1187 | throw UnrecognizedField::byFullyQualifiedName($this->class->name, $fieldName); | ||
1188 | } | ||
1189 | |||
1190 | return ' ORDER BY ' . implode(', ', $orderByList); | ||
1191 | } | ||
1192 | |||
1193 | /** | ||
1194 | * Gets the SQL fragment with the list of columns to select when querying for | ||
1195 | * an entity in this persister. | ||
1196 | * | ||
1197 | * Subclasses should override this method to alter or change the select column | ||
1198 | * list SQL fragment. Note that in the implementation of BasicEntityPersister | ||
1199 | * the resulting SQL fragment is generated only once and cached in {@link selectColumnListSql}. | ||
1200 | * Subclasses may or may not do the same. | ||
1201 | */ | ||
1202 | protected function getSelectColumnsSQL(): string | ||
1203 | { | ||
1204 | if ($this->currentPersisterContext->selectColumnListSql !== null) { | ||
1205 | return $this->currentPersisterContext->selectColumnListSql; | ||
1206 | } | ||
1207 | |||
1208 | $columnList = []; | ||
1209 | $this->currentPersisterContext->rsm->addEntityResult($this->class->name, 'r'); // r for root | ||
1210 | |||
1211 | // Add regular columns to select list | ||
1212 | foreach ($this->class->fieldNames as $field) { | ||
1213 | $columnList[] = $this->getSelectColumnSQL($field, $this->class); | ||
1214 | } | ||
1215 | |||
1216 | $this->currentPersisterContext->selectJoinSql = ''; | ||
1217 | $eagerAliasCounter = 0; | ||
1218 | |||
1219 | foreach ($this->class->associationMappings as $assocField => $assoc) { | ||
1220 | $assocColumnSQL = $this->getSelectColumnAssociationSQL($assocField, $assoc, $this->class); | ||
1221 | |||
1222 | if ($assocColumnSQL) { | ||
1223 | $columnList[] = $assocColumnSQL; | ||
1224 | } | ||
1225 | |||
1226 | $isAssocToOneInverseSide = $assoc->isToOne() && ! $assoc->isOwningSide(); | ||
1227 | $isAssocFromOneEager = $assoc->isToOne() && $assoc->fetch === ClassMetadata::FETCH_EAGER; | ||
1228 | |||
1229 | if (! ($isAssocFromOneEager || $isAssocToOneInverseSide)) { | ||
1230 | continue; | ||
1231 | } | ||
1232 | |||
1233 | if ($assoc->isToMany() && $this->currentPersisterContext->handlesLimits) { | ||
1234 | continue; | ||
1235 | } | ||
1236 | |||
1237 | $eagerEntity = $this->em->getClassMetadata($assoc->targetEntity); | ||
1238 | |||
1239 | if ($eagerEntity->inheritanceType !== ClassMetadata::INHERITANCE_TYPE_NONE) { | ||
1240 | continue; // now this is why you shouldn't use inheritance | ||
1241 | } | ||
1242 | |||
1243 | $assocAlias = 'e' . ($eagerAliasCounter++); | ||
1244 | $this->currentPersisterContext->rsm->addJoinedEntityResult($assoc->targetEntity, $assocAlias, 'r', $assocField); | ||
1245 | |||
1246 | foreach ($eagerEntity->fieldNames as $field) { | ||
1247 | $columnList[] = $this->getSelectColumnSQL($field, $eagerEntity, $assocAlias); | ||
1248 | } | ||
1249 | |||
1250 | foreach ($eagerEntity->associationMappings as $eagerAssocField => $eagerAssoc) { | ||
1251 | $eagerAssocColumnSQL = $this->getSelectColumnAssociationSQL( | ||
1252 | $eagerAssocField, | ||
1253 | $eagerAssoc, | ||
1254 | $eagerEntity, | ||
1255 | $assocAlias, | ||
1256 | ); | ||
1257 | |||
1258 | if ($eagerAssocColumnSQL) { | ||
1259 | $columnList[] = $eagerAssocColumnSQL; | ||
1260 | } | ||
1261 | } | ||
1262 | |||
1263 | $association = $assoc; | ||
1264 | $joinCondition = []; | ||
1265 | |||
1266 | if ($assoc->isIndexed()) { | ||
1267 | assert($assoc->isToMany()); | ||
1268 | $this->currentPersisterContext->rsm->addIndexBy($assocAlias, $assoc->indexBy()); | ||
1269 | } | ||
1270 | |||
1271 | if (! $assoc->isOwningSide()) { | ||
1272 | $eagerEntity = $this->em->getClassMetadata($assoc->targetEntity); | ||
1273 | $association = $eagerEntity->getAssociationMapping($assoc->mappedBy); | ||
1274 | } | ||
1275 | |||
1276 | assert($association->isToOneOwningSide()); | ||
1277 | |||
1278 | $joinTableAlias = $this->getSQLTableAlias($eagerEntity->name, $assocAlias); | ||
1279 | $joinTableName = $this->quoteStrategy->getTableName($eagerEntity, $this->platform); | ||
1280 | |||
1281 | if ($assoc->isOwningSide()) { | ||
1282 | $tableAlias = $this->getSQLTableAlias($association->targetEntity, $assocAlias); | ||
1283 | $this->currentPersisterContext->selectJoinSql .= ' ' . $this->getJoinSQLForJoinColumns($association->joinColumns); | ||
1284 | |||
1285 | foreach ($association->joinColumns as $joinColumn) { | ||
1286 | $sourceCol = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1287 | $targetCol = $this->quoteStrategy->getReferencedJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1288 | $joinCondition[] = $this->getSQLTableAlias($association->sourceEntity) | ||
1289 | . '.' . $sourceCol . ' = ' . $tableAlias . '.' . $targetCol; | ||
1290 | } | ||
1291 | |||
1292 | // Add filter SQL | ||
1293 | $filterSql = $this->generateFilterConditionSQL($eagerEntity, $tableAlias); | ||
1294 | if ($filterSql) { | ||
1295 | $joinCondition[] = $filterSql; | ||
1296 | } | ||
1297 | } else { | ||
1298 | $this->currentPersisterContext->selectJoinSql .= ' LEFT JOIN'; | ||
1299 | |||
1300 | foreach ($association->joinColumns as $joinColumn) { | ||
1301 | $sourceCol = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1302 | $targetCol = $this->quoteStrategy->getReferencedJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1303 | |||
1304 | $joinCondition[] = $this->getSQLTableAlias($association->sourceEntity, $assocAlias) . '.' . $sourceCol . ' = ' | ||
1305 | . $this->getSQLTableAlias($association->targetEntity) . '.' . $targetCol; | ||
1306 | } | ||
1307 | } | ||
1308 | |||
1309 | $this->currentPersisterContext->selectJoinSql .= ' ' . $joinTableName . ' ' . $joinTableAlias . ' ON '; | ||
1310 | $this->currentPersisterContext->selectJoinSql .= implode(' AND ', $joinCondition); | ||
1311 | } | ||
1312 | |||
1313 | $this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList); | ||
1314 | |||
1315 | return $this->currentPersisterContext->selectColumnListSql; | ||
1316 | } | ||
1317 | |||
1318 | /** Gets the SQL join fragment used when selecting entities from an association. */ | ||
1319 | protected function getSelectColumnAssociationSQL( | ||
1320 | string $field, | ||
1321 | AssociationMapping $assoc, | ||
1322 | ClassMetadata $class, | ||
1323 | string $alias = 'r', | ||
1324 | ): string { | ||
1325 | if (! $assoc->isToOneOwningSide()) { | ||
1326 | return ''; | ||
1327 | } | ||
1328 | |||
1329 | $columnList = []; | ||
1330 | $targetClass = $this->em->getClassMetadata($assoc->targetEntity); | ||
1331 | $isIdentifier = isset($assoc->id) && $assoc->id === true; | ||
1332 | $sqlTableAlias = $this->getSQLTableAlias($class->name, ($alias === 'r' ? '' : $alias)); | ||
1333 | |||
1334 | foreach ($assoc->joinColumns as $joinColumn) { | ||
1335 | $quotedColumn = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1336 | $resultColumnName = $this->getSQLColumnAlias($joinColumn->name); | ||
1337 | $type = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em); | ||
1338 | |||
1339 | $this->currentPersisterContext->rsm->addMetaResult($alias, $resultColumnName, $joinColumn->name, $isIdentifier, $type); | ||
1340 | |||
1341 | $columnList[] = sprintf('%s.%s AS %s', $sqlTableAlias, $quotedColumn, $resultColumnName); | ||
1342 | } | ||
1343 | |||
1344 | return implode(', ', $columnList); | ||
1345 | } | ||
1346 | |||
1347 | /** | ||
1348 | * Gets the SQL join fragment used when selecting entities from a | ||
1349 | * many-to-many association. | ||
1350 | */ | ||
1351 | protected function getSelectManyToManyJoinSQL(AssociationMapping&ManyToManyAssociationMapping $manyToMany): string | ||
1352 | { | ||
1353 | $conditions = []; | ||
1354 | $association = $manyToMany; | ||
1355 | $sourceTableAlias = $this->getSQLTableAlias($this->class->name); | ||
1356 | |||
1357 | $association = $this->em->getMetadataFactory()->getOwningSide($manyToMany); | ||
1358 | $joinTableName = $this->quoteStrategy->getJoinTableName($association, $this->class, $this->platform); | ||
1359 | $joinColumns = $manyToMany->isOwningSide() | ||
1360 | ? $association->joinTable->inverseJoinColumns | ||
1361 | : $association->joinTable->joinColumns; | ||
1362 | |||
1363 | foreach ($joinColumns as $joinColumn) { | ||
1364 | $quotedSourceColumn = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1365 | $quotedTargetColumn = $this->quoteStrategy->getReferencedJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1366 | $conditions[] = $sourceTableAlias . '.' . $quotedTargetColumn . ' = ' . $joinTableName . '.' . $quotedSourceColumn; | ||
1367 | } | ||
1368 | |||
1369 | return ' INNER JOIN ' . $joinTableName . ' ON ' . implode(' AND ', $conditions); | ||
1370 | } | ||
1371 | |||
1372 | public function getInsertSQL(): string | ||
1373 | { | ||
1374 | if ($this->insertSql !== null) { | ||
1375 | return $this->insertSql; | ||
1376 | } | ||
1377 | |||
1378 | $columns = $this->getInsertColumnList(); | ||
1379 | $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); | ||
1380 | |||
1381 | if (empty($columns)) { | ||
1382 | $identityColumn = $this->quoteStrategy->getColumnName($this->class->identifier[0], $this->class, $this->platform); | ||
1383 | $this->insertSql = $this->platform->getEmptyIdentityInsertSQL($tableName, $identityColumn); | ||
1384 | |||
1385 | return $this->insertSql; | ||
1386 | } | ||
1387 | |||
1388 | $values = []; | ||
1389 | $columns = array_unique($columns); | ||
1390 | |||
1391 | foreach ($columns as $column) { | ||
1392 | $placeholder = '?'; | ||
1393 | |||
1394 | if ( | ||
1395 | isset($this->class->fieldNames[$column]) | ||
1396 | && isset($this->columnTypes[$this->class->fieldNames[$column]]) | ||
1397 | && isset($this->class->fieldMappings[$this->class->fieldNames[$column]]) | ||
1398 | ) { | ||
1399 | $type = Type::getType($this->columnTypes[$this->class->fieldNames[$column]]); | ||
1400 | $placeholder = $type->convertToDatabaseValueSQL('?', $this->platform); | ||
1401 | } | ||
1402 | |||
1403 | $values[] = $placeholder; | ||
1404 | } | ||
1405 | |||
1406 | $columns = implode(', ', $columns); | ||
1407 | $values = implode(', ', $values); | ||
1408 | |||
1409 | $this->insertSql = sprintf('INSERT INTO %s (%s) VALUES (%s)', $tableName, $columns, $values); | ||
1410 | |||
1411 | return $this->insertSql; | ||
1412 | } | ||
1413 | |||
1414 | /** | ||
1415 | * Gets the list of columns to put in the INSERT SQL statement. | ||
1416 | * | ||
1417 | * Subclasses should override this method to alter or change the list of | ||
1418 | * columns placed in the INSERT statements used by the persister. | ||
1419 | * | ||
1420 | * @psalm-return list<string> | ||
1421 | */ | ||
1422 | protected function getInsertColumnList(): array | ||
1423 | { | ||
1424 | $columns = []; | ||
1425 | |||
1426 | foreach ($this->class->reflFields as $name => $field) { | ||
1427 | if ($this->class->isVersioned && $this->class->versionField === $name) { | ||
1428 | continue; | ||
1429 | } | ||
1430 | |||
1431 | if (isset($this->class->embeddedClasses[$name])) { | ||
1432 | continue; | ||
1433 | } | ||
1434 | |||
1435 | if (isset($this->class->associationMappings[$name])) { | ||
1436 | $assoc = $this->class->associationMappings[$name]; | ||
1437 | |||
1438 | if ($assoc->isToOneOwningSide()) { | ||
1439 | foreach ($assoc->joinColumns as $joinColumn) { | ||
1440 | $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1441 | } | ||
1442 | } | ||
1443 | |||
1444 | continue; | ||
1445 | } | ||
1446 | |||
1447 | if (! $this->class->isIdGeneratorIdentity() || $this->class->identifier[0] !== $name) { | ||
1448 | if (isset($this->class->fieldMappings[$name]->notInsertable)) { | ||
1449 | continue; | ||
1450 | } | ||
1451 | |||
1452 | $columns[] = $this->quoteStrategy->getColumnName($name, $this->class, $this->platform); | ||
1453 | $this->columnTypes[$name] = $this->class->fieldMappings[$name]->type; | ||
1454 | } | ||
1455 | } | ||
1456 | |||
1457 | return $columns; | ||
1458 | } | ||
1459 | |||
1460 | /** | ||
1461 | * Gets the SQL snippet of a qualified column name for the given field name. | ||
1462 | * | ||
1463 | * @param ClassMetadata $class The class that declares this field. The table this class is | ||
1464 | * mapped to must own the column for the given field. | ||
1465 | */ | ||
1466 | protected function getSelectColumnSQL(string $field, ClassMetadata $class, string $alias = 'r'): string | ||
1467 | { | ||
1468 | $root = $alias === 'r' ? '' : $alias; | ||
1469 | $tableAlias = $this->getSQLTableAlias($class->name, $root); | ||
1470 | $fieldMapping = $class->fieldMappings[$field]; | ||
1471 | $sql = sprintf('%s.%s', $tableAlias, $this->quoteStrategy->getColumnName($field, $class, $this->platform)); | ||
1472 | $columnAlias = $this->getSQLColumnAlias($fieldMapping->columnName); | ||
1473 | |||
1474 | $this->currentPersisterContext->rsm->addFieldResult($alias, $columnAlias, $field); | ||
1475 | if (! empty($fieldMapping->enumType)) { | ||
1476 | $this->currentPersisterContext->rsm->addEnumResult($columnAlias, $fieldMapping->enumType); | ||
1477 | } | ||
1478 | |||
1479 | $type = Type::getType($fieldMapping->type); | ||
1480 | $sql = $type->convertToPHPValueSQL($sql, $this->platform); | ||
1481 | |||
1482 | return $sql . ' AS ' . $columnAlias; | ||
1483 | } | ||
1484 | |||
1485 | /** | ||
1486 | * Gets the SQL table alias for the given class name. | ||
1487 | * | ||
1488 | * @todo Reconsider. Binding table aliases to class names is not such a good idea. | ||
1489 | */ | ||
1490 | protected function getSQLTableAlias(string $className, string $assocName = ''): string | ||
1491 | { | ||
1492 | if ($assocName) { | ||
1493 | $className .= '#' . $assocName; | ||
1494 | } | ||
1495 | |||
1496 | if (isset($this->currentPersisterContext->sqlTableAliases[$className])) { | ||
1497 | return $this->currentPersisterContext->sqlTableAliases[$className]; | ||
1498 | } | ||
1499 | |||
1500 | $tableAlias = 't' . $this->currentPersisterContext->sqlAliasCounter++; | ||
1501 | |||
1502 | $this->currentPersisterContext->sqlTableAliases[$className] = $tableAlias; | ||
1503 | |||
1504 | return $tableAlias; | ||
1505 | } | ||
1506 | |||
1507 | /** | ||
1508 | * {@inheritDoc} | ||
1509 | */ | ||
1510 | public function lock(array $criteria, LockMode|int $lockMode): void | ||
1511 | { | ||
1512 | $conditionSql = $this->getSelectConditionSQL($criteria); | ||
1513 | |||
1514 | $lockSql = match ($lockMode) { | ||
1515 | LockMode::PESSIMISTIC_READ => $this->getReadLockSQL($this->platform), | ||
1516 | LockMode::PESSIMISTIC_WRITE => $this->getWriteLockSQL($this->platform), | ||
1517 | default => '', | ||
1518 | }; | ||
1519 | |||
1520 | $lock = $this->getLockTablesSql($lockMode); | ||
1521 | $where = ($conditionSql ? ' WHERE ' . $conditionSql : '') . ' '; | ||
1522 | $sql = 'SELECT 1 ' | ||
1523 | . $lock | ||
1524 | . $where | ||
1525 | . $lockSql; | ||
1526 | |||
1527 | [$params, $types] = $this->expandParameters($criteria); | ||
1528 | |||
1529 | $this->conn->executeQuery($sql, $params, $types); | ||
1530 | } | ||
1531 | |||
1532 | /** | ||
1533 | * Gets the FROM and optionally JOIN conditions to lock the entity managed by this persister. | ||
1534 | * | ||
1535 | * @psalm-param LockMode::* $lockMode | ||
1536 | */ | ||
1537 | protected function getLockTablesSql(LockMode|int $lockMode): string | ||
1538 | { | ||
1539 | return $this->platform->appendLockHint( | ||
1540 | 'FROM ' | ||
1541 | . $this->quoteStrategy->getTableName($this->class, $this->platform) . ' ' | ||
1542 | . $this->getSQLTableAlias($this->class->name), | ||
1543 | $lockMode, | ||
1544 | ); | ||
1545 | } | ||
1546 | |||
1547 | /** | ||
1548 | * Gets the Select Where Condition from a Criteria object. | ||
1549 | */ | ||
1550 | protected function getSelectConditionCriteriaSQL(Criteria $criteria): string | ||
1551 | { | ||
1552 | $expression = $criteria->getWhereExpression(); | ||
1553 | |||
1554 | if ($expression === null) { | ||
1555 | return ''; | ||
1556 | } | ||
1557 | |||
1558 | $visitor = new SqlExpressionVisitor($this, $this->class); | ||
1559 | |||
1560 | return $visitor->dispatch($expression); | ||
1561 | } | ||
1562 | |||
1563 | public function getSelectConditionStatementSQL( | ||
1564 | string $field, | ||
1565 | mixed $value, | ||
1566 | AssociationMapping|null $assoc = null, | ||
1567 | string|null $comparison = null, | ||
1568 | ): string { | ||
1569 | $selectedColumns = []; | ||
1570 | $columns = $this->getSelectConditionStatementColumnSQL($field, $assoc); | ||
1571 | |||
1572 | if (count($columns) > 1 && $comparison === Comparison::IN) { | ||
1573 | /* | ||
1574 | * @todo try to support multi-column IN expressions. | ||
1575 | * Example: (col1, col2) IN (('val1A', 'val2A'), ('val1B', 'val2B')) | ||
1576 | */ | ||
1577 | throw CantUseInOperatorOnCompositeKeys::create(); | ||
1578 | } | ||
1579 | |||
1580 | foreach ($columns as $column) { | ||
1581 | $placeholder = '?'; | ||
1582 | |||
1583 | if (isset($this->class->fieldMappings[$field])) { | ||
1584 | $type = Type::getType($this->class->fieldMappings[$field]->type); | ||
1585 | $placeholder = $type->convertToDatabaseValueSQL($placeholder, $this->platform); | ||
1586 | } | ||
1587 | |||
1588 | if ($comparison !== null) { | ||
1589 | // special case null value handling | ||
1590 | if (($comparison === Comparison::EQ || $comparison === Comparison::IS) && $value === null) { | ||
1591 | $selectedColumns[] = $column . ' IS NULL'; | ||
1592 | |||
1593 | continue; | ||
1594 | } | ||
1595 | |||
1596 | if ($comparison === Comparison::NEQ && $value === null) { | ||
1597 | $selectedColumns[] = $column . ' IS NOT NULL'; | ||
1598 | |||
1599 | continue; | ||
1600 | } | ||
1601 | |||
1602 | $selectedColumns[] = $column . ' ' . sprintf(self::$comparisonMap[$comparison], $placeholder); | ||
1603 | |||
1604 | continue; | ||
1605 | } | ||
1606 | |||
1607 | if (is_array($value)) { | ||
1608 | $in = sprintf('%s IN (%s)', $column, $placeholder); | ||
1609 | |||
1610 | if (array_search(null, $value, true) !== false) { | ||
1611 | $selectedColumns[] = sprintf('(%s OR %s IS NULL)', $in, $column); | ||
1612 | |||
1613 | continue; | ||
1614 | } | ||
1615 | |||
1616 | $selectedColumns[] = $in; | ||
1617 | |||
1618 | continue; | ||
1619 | } | ||
1620 | |||
1621 | if ($value === null) { | ||
1622 | $selectedColumns[] = sprintf('%s IS NULL', $column); | ||
1623 | |||
1624 | continue; | ||
1625 | } | ||
1626 | |||
1627 | $selectedColumns[] = sprintf('%s = %s', $column, $placeholder); | ||
1628 | } | ||
1629 | |||
1630 | return implode(' AND ', $selectedColumns); | ||
1631 | } | ||
1632 | |||
1633 | /** | ||
1634 | * Builds the left-hand-side of a where condition statement. | ||
1635 | * | ||
1636 | * @return string[] | ||
1637 | * @psalm-return list<string> | ||
1638 | * | ||
1639 | * @throws InvalidFindByCall | ||
1640 | * @throws UnrecognizedField | ||
1641 | */ | ||
1642 | private function getSelectConditionStatementColumnSQL( | ||
1643 | string $field, | ||
1644 | AssociationMapping|null $assoc = null, | ||
1645 | ): array { | ||
1646 | if (isset($this->class->fieldMappings[$field])) { | ||
1647 | $className = $this->class->fieldMappings[$field]->inherited ?? $this->class->name; | ||
1648 | |||
1649 | return [$this->getSQLTableAlias($className) . '.' . $this->quoteStrategy->getColumnName($field, $this->class, $this->platform)]; | ||
1650 | } | ||
1651 | |||
1652 | if (isset($this->class->associationMappings[$field])) { | ||
1653 | $association = $this->class->associationMappings[$field]; | ||
1654 | // Many-To-Many requires join table check for joinColumn | ||
1655 | $columns = []; | ||
1656 | $class = $this->class; | ||
1657 | |||
1658 | if ($association->isManyToMany()) { | ||
1659 | assert($assoc !== null); | ||
1660 | if (! $association->isOwningSide()) { | ||
1661 | $association = $assoc; | ||
1662 | } | ||
1663 | |||
1664 | assert($association->isManyToManyOwningSide()); | ||
1665 | |||
1666 | $joinTableName = $this->quoteStrategy->getJoinTableName($association, $class, $this->platform); | ||
1667 | $joinColumns = $assoc->isOwningSide() | ||
1668 | ? $association->joinTable->joinColumns | ||
1669 | : $association->joinTable->inverseJoinColumns; | ||
1670 | |||
1671 | foreach ($joinColumns as $joinColumn) { | ||
1672 | $columns[] = $joinTableName . '.' . $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform); | ||
1673 | } | ||
1674 | } else { | ||
1675 | if (! $association->isOwningSide()) { | ||
1676 | throw InvalidFindByCall::fromInverseSideUsage( | ||
1677 | $this->class->name, | ||
1678 | $field, | ||
1679 | ); | ||
1680 | } | ||
1681 | |||
1682 | assert($association->isToOneOwningSide()); | ||
1683 | |||
1684 | $className = $association->inherited ?? $this->class->name; | ||
1685 | |||
1686 | foreach ($association->joinColumns as $joinColumn) { | ||
1687 | $columns[] = $this->getSQLTableAlias($className) . '.' . $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform); | ||
1688 | } | ||
1689 | } | ||
1690 | |||
1691 | return $columns; | ||
1692 | } | ||
1693 | |||
1694 | if ($assoc !== null && ! str_contains($field, ' ') && ! str_contains($field, '(')) { | ||
1695 | // very careless developers could potentially open up this normally hidden api for userland attacks, | ||
1696 | // therefore checking for spaces and function calls which are not allowed. | ||
1697 | |||
1698 | // found a join column condition, not really a "field" | ||
1699 | return [$field]; | ||
1700 | } | ||
1701 | |||
1702 | throw UnrecognizedField::byFullyQualifiedName($this->class->name, $field); | ||
1703 | } | ||
1704 | |||
1705 | /** | ||
1706 | * Gets the conditional SQL fragment used in the WHERE clause when selecting | ||
1707 | * entities in this persister. | ||
1708 | * | ||
1709 | * Subclasses are supposed to override this method if they intend to change | ||
1710 | * or alter the criteria by which entities are selected. | ||
1711 | * | ||
1712 | * @psalm-param array<string, mixed> $criteria | ||
1713 | */ | ||
1714 | protected function getSelectConditionSQL(array $criteria, AssociationMapping|null $assoc = null): string | ||
1715 | { | ||
1716 | $conditions = []; | ||
1717 | |||
1718 | foreach ($criteria as $field => $value) { | ||
1719 | $conditions[] = $this->getSelectConditionStatementSQL($field, $value, $assoc); | ||
1720 | } | ||
1721 | |||
1722 | return implode(' AND ', $conditions); | ||
1723 | } | ||
1724 | |||
1725 | /** | ||
1726 | * {@inheritDoc} | ||
1727 | */ | ||
1728 | public function getOneToManyCollection( | ||
1729 | AssociationMapping $assoc, | ||
1730 | object $sourceEntity, | ||
1731 | int|null $offset = null, | ||
1732 | int|null $limit = null, | ||
1733 | ): array { | ||
1734 | assert($assoc instanceof OneToManyAssociationMapping); | ||
1735 | $this->switchPersisterContext($offset, $limit); | ||
1736 | |||
1737 | $stmt = $this->getOneToManyStatement($assoc, $sourceEntity, $offset, $limit); | ||
1738 | |||
1739 | return $this->loadArrayFromResult($assoc, $stmt); | ||
1740 | } | ||
1741 | |||
1742 | public function loadOneToManyCollection( | ||
1743 | AssociationMapping $assoc, | ||
1744 | object $sourceEntity, | ||
1745 | PersistentCollection $collection, | ||
1746 | ): mixed { | ||
1747 | assert($assoc instanceof OneToManyAssociationMapping); | ||
1748 | $stmt = $this->getOneToManyStatement($assoc, $sourceEntity); | ||
1749 | |||
1750 | return $this->loadCollectionFromStatement($assoc, $stmt, $collection); | ||
1751 | } | ||
1752 | |||
1753 | /** Builds criteria and execute SQL statement to fetch the one to many entities from. */ | ||
1754 | private function getOneToManyStatement( | ||
1755 | OneToManyAssociationMapping $assoc, | ||
1756 | object $sourceEntity, | ||
1757 | int|null $offset = null, | ||
1758 | int|null $limit = null, | ||
1759 | ): Result { | ||
1760 | $this->switchPersisterContext($offset, $limit); | ||
1761 | |||
1762 | $criteria = []; | ||
1763 | $parameters = []; | ||
1764 | $owningAssoc = $this->class->associationMappings[$assoc->mappedBy]; | ||
1765 | $sourceClass = $this->em->getClassMetadata($assoc->sourceEntity); | ||
1766 | $tableAlias = $this->getSQLTableAlias($owningAssoc->inherited ?? $this->class->name); | ||
1767 | assert($owningAssoc->isManyToOne()); | ||
1768 | |||
1769 | foreach ($owningAssoc->targetToSourceKeyColumns as $sourceKeyColumn => $targetKeyColumn) { | ||
1770 | if ($sourceClass->containsForeignIdentifier) { | ||
1771 | $field = $sourceClass->getFieldForColumn($sourceKeyColumn); | ||
1772 | $value = $sourceClass->reflFields[$field]->getValue($sourceEntity); | ||
1773 | |||
1774 | if (isset($sourceClass->associationMappings[$field])) { | ||
1775 | $value = $this->em->getUnitOfWork()->getEntityIdentifier($value); | ||
1776 | $value = $value[$this->em->getClassMetadata($sourceClass->associationMappings[$field]->targetEntity)->identifier[0]]; | ||
1777 | } | ||
1778 | |||
1779 | $criteria[$tableAlias . '.' . $targetKeyColumn] = $value; | ||
1780 | $parameters[] = [ | ||
1781 | 'value' => $value, | ||
1782 | 'field' => $field, | ||
1783 | 'class' => $sourceClass, | ||
1784 | ]; | ||
1785 | |||
1786 | continue; | ||
1787 | } | ||
1788 | |||
1789 | $field = $sourceClass->fieldNames[$sourceKeyColumn]; | ||
1790 | $value = $sourceClass->reflFields[$field]->getValue($sourceEntity); | ||
1791 | |||
1792 | $criteria[$tableAlias . '.' . $targetKeyColumn] = $value; | ||
1793 | $parameters[] = [ | ||
1794 | 'value' => $value, | ||
1795 | 'field' => $field, | ||
1796 | 'class' => $sourceClass, | ||
1797 | ]; | ||
1798 | } | ||
1799 | |||
1800 | $sql = $this->getSelectSQL($criteria, $assoc, null, $limit, $offset); | ||
1801 | [$params, $types] = $this->expandToManyParameters($parameters); | ||
1802 | |||
1803 | return $this->conn->executeQuery($sql, $params, $types); | ||
1804 | } | ||
1805 | |||
1806 | /** | ||
1807 | * {@inheritDoc} | ||
1808 | */ | ||
1809 | public function expandParameters(array $criteria): array | ||
1810 | { | ||
1811 | $params = []; | ||
1812 | $types = []; | ||
1813 | |||
1814 | foreach ($criteria as $field => $value) { | ||
1815 | if ($value === null) { | ||
1816 | continue; // skip null values. | ||
1817 | } | ||
1818 | |||
1819 | $types = [...$types, ...$this->getTypes($field, $value, $this->class)]; | ||
1820 | $params = array_merge($params, $this->getValues($value)); | ||
1821 | } | ||
1822 | |||
1823 | return [$params, $types]; | ||
1824 | } | ||
1825 | |||
1826 | /** | ||
1827 | * Expands the parameters from the given criteria and use the correct binding types if found, | ||
1828 | * specialized for OneToMany or ManyToMany associations. | ||
1829 | * | ||
1830 | * @param mixed[][] $criteria an array of arrays containing following: | ||
1831 | * - field to which each criterion will be bound | ||
1832 | * - value to be bound | ||
1833 | * - class to which the field belongs to | ||
1834 | * | ||
1835 | * @return mixed[][] | ||
1836 | * @psalm-return array{0: array, 1: list<ParameterType::*|ArrayParameterType::*|string>} | ||
1837 | */ | ||
1838 | private function expandToManyParameters(array $criteria): array | ||
1839 | { | ||
1840 | $params = []; | ||
1841 | $types = []; | ||
1842 | |||
1843 | foreach ($criteria as $criterion) { | ||
1844 | if ($criterion['value'] === null) { | ||
1845 | continue; // skip null values. | ||
1846 | } | ||
1847 | |||
1848 | $types = [...$types, ...$this->getTypes($criterion['field'], $criterion['value'], $criterion['class'])]; | ||
1849 | $params = array_merge($params, $this->getValues($criterion['value'])); | ||
1850 | } | ||
1851 | |||
1852 | return [$params, $types]; | ||
1853 | } | ||
1854 | |||
1855 | /** | ||
1856 | * Infers field types to be used by parameter type casting. | ||
1857 | * | ||
1858 | * @return list<ParameterType|ArrayParameterType|int|string> | ||
1859 | * @psalm-return list<ParameterType::*|ArrayParameterType::*|string> | ||
1860 | * | ||
1861 | * @throws QueryException | ||
1862 | */ | ||
1863 | private function getTypes(string $field, mixed $value, ClassMetadata $class): array | ||
1864 | { | ||
1865 | $types = []; | ||
1866 | |||
1867 | switch (true) { | ||
1868 | case isset($class->fieldMappings[$field]): | ||
1869 | $types = array_merge($types, [$class->fieldMappings[$field]->type]); | ||
1870 | break; | ||
1871 | |||
1872 | case isset($class->associationMappings[$field]): | ||
1873 | $assoc = $this->em->getMetadataFactory()->getOwningSide($class->associationMappings[$field]); | ||
1874 | $class = $this->em->getClassMetadata($assoc->targetEntity); | ||
1875 | |||
1876 | if ($assoc->isManyToManyOwningSide()) { | ||
1877 | $columns = $assoc->relationToTargetKeyColumns; | ||
1878 | } else { | ||
1879 | assert($assoc->isToOneOwningSide()); | ||
1880 | $columns = $assoc->sourceToTargetKeyColumns; | ||
1881 | } | ||
1882 | |||
1883 | foreach ($columns as $column) { | ||
1884 | $types[] = PersisterHelper::getTypeOfColumn($column, $class, $this->em); | ||
1885 | } | ||
1886 | |||
1887 | break; | ||
1888 | |||
1889 | default: | ||
1890 | $types[] = ParameterType::STRING; | ||
1891 | break; | ||
1892 | } | ||
1893 | |||
1894 | if (is_array($value)) { | ||
1895 | return array_map($this->getArrayBindingType(...), $types); | ||
1896 | } | ||
1897 | |||
1898 | return $types; | ||
1899 | } | ||
1900 | |||
1901 | /** @psalm-return ArrayParameterType::* */ | ||
1902 | private function getArrayBindingType(ParameterType|int|string $type): ArrayParameterType|int | ||
1903 | { | ||
1904 | if (! $type instanceof ParameterType) { | ||
1905 | $type = Type::getType((string) $type)->getBindingType(); | ||
1906 | } | ||
1907 | |||
1908 | return match ($type) { | ||
1909 | ParameterType::STRING => ArrayParameterType::STRING, | ||
1910 | ParameterType::INTEGER => ArrayParameterType::INTEGER, | ||
1911 | ParameterType::ASCII => ArrayParameterType::ASCII, | ||
1912 | }; | ||
1913 | } | ||
1914 | |||
1915 | /** | ||
1916 | * Retrieves the parameters that identifies a value. | ||
1917 | * | ||
1918 | * @return mixed[] | ||
1919 | */ | ||
1920 | private function getValues(mixed $value): array | ||
1921 | { | ||
1922 | if (is_array($value)) { | ||
1923 | $newValue = []; | ||
1924 | |||
1925 | foreach ($value as $itemValue) { | ||
1926 | $newValue = array_merge($newValue, $this->getValues($itemValue)); | ||
1927 | } | ||
1928 | |||
1929 | return [$newValue]; | ||
1930 | } | ||
1931 | |||
1932 | return $this->getIndividualValue($value); | ||
1933 | } | ||
1934 | |||
1935 | /** | ||
1936 | * Retrieves an individual parameter value. | ||
1937 | * | ||
1938 | * @psalm-return list<mixed> | ||
1939 | */ | ||
1940 | private function getIndividualValue(mixed $value): array | ||
1941 | { | ||
1942 | if (! is_object($value)) { | ||
1943 | return [$value]; | ||
1944 | } | ||
1945 | |||
1946 | if ($value instanceof BackedEnum) { | ||
1947 | return [$value->value]; | ||
1948 | } | ||
1949 | |||
1950 | $valueClass = DefaultProxyClassNameResolver::getClass($value); | ||
1951 | |||
1952 | if ($this->em->getMetadataFactory()->isTransient($valueClass)) { | ||
1953 | return [$value]; | ||
1954 | } | ||
1955 | |||
1956 | $class = $this->em->getClassMetadata($valueClass); | ||
1957 | |||
1958 | if ($class->isIdentifierComposite) { | ||
1959 | $newValue = []; | ||
1960 | |||
1961 | foreach ($class->getIdentifierValues($value) as $innerValue) { | ||
1962 | $newValue = array_merge($newValue, $this->getValues($innerValue)); | ||
1963 | } | ||
1964 | |||
1965 | return $newValue; | ||
1966 | } | ||
1967 | |||
1968 | return [$this->em->getUnitOfWork()->getSingleIdentifierValue($value)]; | ||
1969 | } | ||
1970 | |||
1971 | public function exists(object $entity, Criteria|null $extraConditions = null): bool | ||
1972 | { | ||
1973 | $criteria = $this->class->getIdentifierValues($entity); | ||
1974 | |||
1975 | if (! $criteria) { | ||
1976 | return false; | ||
1977 | } | ||
1978 | |||
1979 | $alias = $this->getSQLTableAlias($this->class->name); | ||
1980 | |||
1981 | $sql = 'SELECT 1 ' | ||
1982 | . $this->getLockTablesSql(LockMode::NONE) | ||
1983 | . ' WHERE ' . $this->getSelectConditionSQL($criteria); | ||
1984 | |||
1985 | [$params, $types] = $this->expandParameters($criteria); | ||
1986 | |||
1987 | if ($extraConditions !== null) { | ||
1988 | $sql .= ' AND ' . $this->getSelectConditionCriteriaSQL($extraConditions); | ||
1989 | [$criteriaParams, $criteriaTypes] = $this->expandCriteriaParameters($extraConditions); | ||
1990 | |||
1991 | $params = [...$params, ...$criteriaParams]; | ||
1992 | $types = [...$types, ...$criteriaTypes]; | ||
1993 | } | ||
1994 | |||
1995 | $filterSql = $this->generateFilterConditionSQL($this->class, $alias); | ||
1996 | if ($filterSql) { | ||
1997 | $sql .= ' AND ' . $filterSql; | ||
1998 | } | ||
1999 | |||
2000 | return (bool) $this->conn->fetchOne($sql, $params, $types); | ||
2001 | } | ||
2002 | |||
2003 | /** | ||
2004 | * Generates the appropriate join SQL for the given join column. | ||
2005 | * | ||
2006 | * @param list<JoinColumnMapping> $joinColumns The join columns definition of an association. | ||
2007 | * | ||
2008 | * @return string LEFT JOIN if one of the columns is nullable, INNER JOIN otherwise. | ||
2009 | */ | ||
2010 | protected function getJoinSQLForJoinColumns(array $joinColumns): string | ||
2011 | { | ||
2012 | // if one of the join columns is nullable, return left join | ||
2013 | foreach ($joinColumns as $joinColumn) { | ||
2014 | if (! isset($joinColumn->nullable) || $joinColumn->nullable) { | ||
2015 | return 'LEFT JOIN'; | ||
2016 | } | ||
2017 | } | ||
2018 | |||
2019 | return 'INNER JOIN'; | ||
2020 | } | ||
2021 | |||
2022 | public function getSQLColumnAlias(string $columnName): string | ||
2023 | { | ||
2024 | return $this->quoteStrategy->getColumnAlias($columnName, $this->currentPersisterContext->sqlAliasCounter++, $this->platform); | ||
2025 | } | ||
2026 | |||
2027 | /** | ||
2028 | * Generates the filter SQL for a given entity and table alias. | ||
2029 | * | ||
2030 | * @param ClassMetadata $targetEntity Metadata of the target entity. | ||
2031 | * @param string $targetTableAlias The table alias of the joined/selected table. | ||
2032 | * | ||
2033 | * @return string The SQL query part to add to a query. | ||
2034 | */ | ||
2035 | protected function generateFilterConditionSQL(ClassMetadata $targetEntity, string $targetTableAlias): string | ||
2036 | { | ||
2037 | $filterClauses = []; | ||
2038 | |||
2039 | foreach ($this->em->getFilters()->getEnabledFilters() as $filter) { | ||
2040 | $filterExpr = $filter->addFilterConstraint($targetEntity, $targetTableAlias); | ||
2041 | if ($filterExpr !== '') { | ||
2042 | $filterClauses[] = '(' . $filterExpr . ')'; | ||
2043 | } | ||
2044 | } | ||
2045 | |||
2046 | $sql = implode(' AND ', $filterClauses); | ||
2047 | |||
2048 | return $sql ? '(' . $sql . ')' : ''; // Wrap again to avoid "X or Y and FilterConditionSQL" | ||
2049 | } | ||
2050 | |||
2051 | /** | ||
2052 | * Switches persister context according to current query offset/limits | ||
2053 | * | ||
2054 | * This is due to the fact that to-many associations cannot be fetch-joined when a limit is involved | ||
2055 | */ | ||
2056 | protected function switchPersisterContext(int|null $offset, int|null $limit): void | ||
2057 | { | ||
2058 | if ($offset === null && $limit === null) { | ||
2059 | $this->currentPersisterContext = $this->noLimitsContext; | ||
2060 | |||
2061 | return; | ||
2062 | } | ||
2063 | |||
2064 | $this->currentPersisterContext = $this->limitsHandlingContext; | ||
2065 | } | ||
2066 | |||
2067 | /** | ||
2068 | * @return string[] | ||
2069 | * @psalm-return list<string> | ||
2070 | */ | ||
2071 | protected function getClassIdentifiersTypes(ClassMetadata $class): array | ||
2072 | { | ||
2073 | $entityManager = $this->em; | ||
2074 | |||
2075 | return array_map( | ||
2076 | static function ($fieldName) use ($class, $entityManager): string { | ||
2077 | $types = PersisterHelper::getTypeOfField($fieldName, $class, $entityManager); | ||
2078 | assert(isset($types[0])); | ||
2079 | |||
2080 | return $types[0]; | ||
2081 | }, | ||
2082 | $class->identifier, | ||
2083 | ); | ||
2084 | } | ||
2085 | } | ||
diff --git a/vendor/doctrine/orm/src/Persisters/Entity/CachedPersisterContext.php b/vendor/doctrine/orm/src/Persisters/Entity/CachedPersisterContext.php new file mode 100644 index 0000000..03d053b --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/CachedPersisterContext.php | |||
@@ -0,0 +1,60 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Doctrine\ORM\Persisters\Entity; | ||
6 | |||
7 | use Doctrine\ORM\Query\ResultSetMapping; | ||
8 | use Doctrine\Persistence\Mapping\ClassMetadata; | ||
9 | |||
10 | /** | ||
11 | * A swappable persister context to use as a container for the current | ||
12 | * generated query/resultSetMapping/type binding information. | ||
13 | * | ||
14 | * This class is a utility class to be used only by the persister API | ||
15 | * | ||
16 | * This object is highly mutable due to performance reasons. Same reasoning | ||
17 | * behind its properties being public. | ||
18 | */ | ||
19 | class CachedPersisterContext | ||
20 | { | ||
21 | /** | ||
22 | * The SELECT column list SQL fragment used for querying entities by this persister. | ||
23 | * This SQL fragment is only generated once per request, if at all. | ||
24 | */ | ||
25 | public string|null $selectColumnListSql = null; | ||
26 | |||
27 | /** | ||
28 | * The JOIN SQL fragment used to eagerly load all many-to-one and one-to-one | ||
29 | * associations configured as FETCH_EAGER, as well as all inverse one-to-one associations. | ||
30 | */ | ||
31 | public string|null $selectJoinSql = null; | ||
32 | |||
33 | /** | ||
34 | * Counter for creating unique SQL table and column aliases. | ||
35 | */ | ||
36 | public int $sqlAliasCounter = 0; | ||
37 | |||
38 | /** | ||
39 | * Map from class names (FQCN) to the corresponding generated SQL table aliases. | ||
40 | * | ||
41 | * @var array<class-string, string> | ||
42 | */ | ||
43 | public array $sqlTableAliases = []; | ||
44 | |||
45 | public function __construct( | ||
46 | /** | ||
47 | * Metadata object that describes the mapping of the mapped entity class. | ||
48 | */ | ||
49 | public ClassMetadata $class, | ||
50 | /** | ||
51 | * ResultSetMapping that is used for all queries. Is generated lazily once per request. | ||
52 | */ | ||
53 | public ResultSetMapping $rsm, | ||
54 | /** | ||
55 | * Whether this persistent context is considering limit operations applied to the selection queries | ||
56 | */ | ||
57 | public bool $handlesLimits, | ||
58 | ) { | ||
59 | } | ||
60 | } | ||
diff --git a/vendor/doctrine/orm/src/Persisters/Entity/EntityPersister.php b/vendor/doctrine/orm/src/Persisters/Entity/EntityPersister.php new file mode 100644 index 0000000..6b278a7 --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/EntityPersister.php | |||
@@ -0,0 +1,298 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Doctrine\ORM\Persisters\Entity; | ||
6 | |||
7 | use Doctrine\Common\Collections\Criteria; | ||
8 | use Doctrine\DBAL\ArrayParameterType; | ||
9 | use Doctrine\DBAL\LockMode; | ||
10 | use Doctrine\DBAL\ParameterType; | ||
11 | use Doctrine\ORM\Mapping\AssociationMapping; | ||
12 | use Doctrine\ORM\Mapping\ClassMetadata; | ||
13 | use Doctrine\ORM\Mapping\MappingException; | ||
14 | use Doctrine\ORM\PersistentCollection; | ||
15 | use Doctrine\ORM\Query\ResultSetMapping; | ||
16 | |||
17 | /** | ||
18 | * Entity persister interface | ||
19 | * Define the behavior that should be implemented by all entity persisters. | ||
20 | */ | ||
21 | interface EntityPersister | ||
22 | { | ||
23 | public function getClassMetadata(): ClassMetadata; | ||
24 | |||
25 | /** | ||
26 | * Gets the ResultSetMapping used for hydration. | ||
27 | */ | ||
28 | public function getResultSetMapping(): ResultSetMapping; | ||
29 | |||
30 | /** | ||
31 | * Get all queued inserts. | ||
32 | * | ||
33 | * @return object[] | ||
34 | */ | ||
35 | public function getInserts(): array; | ||
36 | |||
37 | /** | ||
38 | * Gets the INSERT SQL used by the persister to persist a new entity. | ||
39 | * | ||
40 | * @TODO It should not be here. | ||
41 | * But its necessary since JoinedSubclassPersister#executeInserts invoke the root persister. | ||
42 | */ | ||
43 | public function getInsertSQL(): string; | ||
44 | |||
45 | /** | ||
46 | * Gets the SELECT SQL to select one or more entities by a set of field criteria. | ||
47 | * | ||
48 | * @param mixed[]|Criteria $criteria | ||
49 | * @param mixed[]|null $orderBy | ||
50 | * @psalm-param AssociationMapping|null $assoc | ||
51 | * @psalm-param LockMode::*|null $lockMode | ||
52 | */ | ||
53 | public function getSelectSQL( | ||
54 | array|Criteria $criteria, | ||
55 | AssociationMapping|null $assoc = null, | ||
56 | LockMode|int|null $lockMode = null, | ||
57 | int|null $limit = null, | ||
58 | int|null $offset = null, | ||
59 | array|null $orderBy = null, | ||
60 | ): string; | ||
61 | |||
62 | /** | ||
63 | * Get the COUNT SQL to count entities (optionally based on a criteria) | ||
64 | * | ||
65 | * @param mixed[]|Criteria $criteria | ||
66 | */ | ||
67 | public function getCountSQL(array|Criteria $criteria = []): string; | ||
68 | |||
69 | /** | ||
70 | * Expands the parameters from the given criteria and use the correct binding types if found. | ||
71 | * | ||
72 | * @param string[] $criteria | ||
73 | * | ||
74 | * @psalm-return array{list<mixed>, list<ParameterType::*|ArrayParameterType::*|string>} | ||
75 | */ | ||
76 | public function expandParameters(array $criteria): array; | ||
77 | |||
78 | /** | ||
79 | * Expands Criteria Parameters by walking the expressions and grabbing all parameters and types from it. | ||
80 | * | ||
81 | * @psalm-return array{list<mixed>, list<ParameterType::*|ArrayParameterType::*|string>} | ||
82 | */ | ||
83 | public function expandCriteriaParameters(Criteria $criteria): array; | ||
84 | |||
85 | /** Gets the SQL WHERE condition for matching a field with a given value. */ | ||
86 | public function getSelectConditionStatementSQL( | ||
87 | string $field, | ||
88 | mixed $value, | ||
89 | AssociationMapping|null $assoc = null, | ||
90 | string|null $comparison = null, | ||
91 | ): string; | ||
92 | |||
93 | /** | ||
94 | * Adds an entity to the queued insertions. | ||
95 | * The entity remains queued until {@link executeInserts} is invoked. | ||
96 | */ | ||
97 | public function addInsert(object $entity): void; | ||
98 | |||
99 | /** | ||
100 | * Executes all queued entity insertions. | ||
101 | * | ||
102 | * If no inserts are queued, invoking this method is a NOOP. | ||
103 | */ | ||
104 | public function executeInserts(): void; | ||
105 | |||
106 | /** | ||
107 | * Updates a managed entity. The entity is updated according to its current changeset | ||
108 | * in the running UnitOfWork. If there is no changeset, nothing is updated. | ||
109 | */ | ||
110 | public function update(object $entity): void; | ||
111 | |||
112 | /** | ||
113 | * Deletes a managed entity. | ||
114 | * | ||
115 | * The entity to delete must be managed and have a persistent identifier. | ||
116 | * The deletion happens instantaneously. | ||
117 | * | ||
118 | * Subclasses may override this method to customize the semantics of entity deletion. | ||
119 | * | ||
120 | * @return bool TRUE if the entity got deleted in the database, FALSE otherwise. | ||
121 | */ | ||
122 | public function delete(object $entity): bool; | ||
123 | |||
124 | /** | ||
125 | * Count entities (optionally filtered by a criteria) | ||
126 | * | ||
127 | * @param mixed[]|Criteria $criteria | ||
128 | */ | ||
129 | public function count(array|Criteria $criteria = []): int; | ||
130 | |||
131 | /** | ||
132 | * Gets the name of the table that owns the column the given field is mapped to. | ||
133 | * | ||
134 | * The default implementation in BasicEntityPersister always returns the name | ||
135 | * of the table the entity type of this persister is mapped to, since an entity | ||
136 | * is always persisted to a single table with a BasicEntityPersister. | ||
137 | */ | ||
138 | public function getOwningTable(string $fieldName): string; | ||
139 | |||
140 | /** | ||
141 | * Loads an entity by a list of field criteria. | ||
142 | * | ||
143 | * @param mixed[] $criteria The criteria by which to load the entity. | ||
144 | * @param object|null $entity The entity to load the data into. If not specified, | ||
145 | * a new entity is created. | ||
146 | * @param AssociationMapping|null $assoc The association that connects the entity | ||
147 | * to load to another entity, if any. | ||
148 | * @param mixed[] $hints Hints for entity creation. | ||
149 | * @param LockMode|int|null $lockMode One of the \Doctrine\DBAL\LockMode::* constants | ||
150 | * or NULL if no specific lock mode should be used | ||
151 | * for loading the entity. | ||
152 | * @param int|null $limit Limit number of results. | ||
153 | * @param string[]|null $orderBy Criteria to order by. | ||
154 | * @psalm-param array<string, mixed> $criteria | ||
155 | * @psalm-param array<string, mixed> $hints | ||
156 | * @psalm-param LockMode::*|null $lockMode | ||
157 | * @psalm-param array<string, string>|null $orderBy | ||
158 | * | ||
159 | * @return object|null The loaded and managed entity instance or NULL if the entity can not be found. | ||
160 | * | ||
161 | * @todo Check identity map? loadById method? Try to guess whether $criteria is the id? | ||
162 | */ | ||
163 | public function load( | ||
164 | array $criteria, | ||
165 | object|null $entity = null, | ||
166 | AssociationMapping|null $assoc = null, | ||
167 | array $hints = [], | ||
168 | LockMode|int|null $lockMode = null, | ||
169 | int|null $limit = null, | ||
170 | array|null $orderBy = null, | ||
171 | ): object|null; | ||
172 | |||
173 | /** | ||
174 | * Loads an entity by identifier. | ||
175 | * | ||
176 | * @param object|null $entity The entity to load the data into. If not specified, a new entity is created. | ||
177 | * @psalm-param array<string, mixed> $identifier The entity identifier. | ||
178 | * | ||
179 | * @return object|null The loaded and managed entity instance or NULL if the entity can not be found. | ||
180 | * | ||
181 | * @todo Check parameters | ||
182 | */ | ||
183 | public function loadById(array $identifier, object|null $entity = null): object|null; | ||
184 | |||
185 | /** | ||
186 | * Loads an entity of this persister's mapped class as part of a single-valued | ||
187 | * association from another entity. | ||
188 | * | ||
189 | * @param AssociationMapping $assoc The association to load. | ||
190 | * @param object $sourceEntity The entity that owns the association (not necessarily the "owning side"). | ||
191 | * @psalm-param array<string, mixed> $identifier The identifier of the entity to load. Must be provided if | ||
192 | * the association to load represents the owning side, otherwise | ||
193 | * the identifier is derived from the $sourceEntity. | ||
194 | * | ||
195 | * @return object|null The loaded and managed entity instance or NULL if the entity can not be found. | ||
196 | * | ||
197 | * @throws MappingException | ||
198 | */ | ||
199 | public function loadOneToOneEntity(AssociationMapping $assoc, object $sourceEntity, array $identifier = []): object|null; | ||
200 | |||
201 | /** | ||
202 | * Refreshes a managed entity. | ||
203 | * | ||
204 | * @param LockMode|int|null $lockMode One of the \Doctrine\DBAL\LockMode::* constants | ||
205 | * or NULL if no specific lock mode should be used | ||
206 | * for refreshing the managed entity. | ||
207 | * @psalm-param array<string, mixed> $id The identifier of the entity as an | ||
208 | * associative array from column or | ||
209 | * field names to values. | ||
210 | * @psalm-param LockMode::*|null $lockMode | ||
211 | */ | ||
212 | public function refresh(array $id, object $entity, LockMode|int|null $lockMode = null): void; | ||
213 | |||
214 | /** | ||
215 | * Loads Entities matching the given Criteria object. | ||
216 | * | ||
217 | * @return mixed[] | ||
218 | */ | ||
219 | public function loadCriteria(Criteria $criteria): array; | ||
220 | |||
221 | /** | ||
222 | * Loads a list of entities by a list of field criteria. | ||
223 | * | ||
224 | * @psalm-param array<string, string>|null $orderBy | ||
225 | * @psalm-param array<string, mixed> $criteria | ||
226 | * | ||
227 | * @return mixed[] | ||
228 | */ | ||
229 | public function loadAll( | ||
230 | array $criteria = [], | ||
231 | array|null $orderBy = null, | ||
232 | int|null $limit = null, | ||
233 | int|null $offset = null, | ||
234 | ): array; | ||
235 | |||
236 | /** | ||
237 | * Gets (sliced or full) elements of the given collection. | ||
238 | * | ||
239 | * @return mixed[] | ||
240 | */ | ||
241 | public function getManyToManyCollection( | ||
242 | AssociationMapping $assoc, | ||
243 | object $sourceEntity, | ||
244 | int|null $offset = null, | ||
245 | int|null $limit = null, | ||
246 | ): array; | ||
247 | |||
248 | /** | ||
249 | * Loads a collection of entities of a many-to-many association. | ||
250 | * | ||
251 | * @param AssociationMapping $assoc The association mapping of the association being loaded. | ||
252 | * @param object $sourceEntity The entity that owns the collection. | ||
253 | * @param PersistentCollection $collection The collection to fill. | ||
254 | * | ||
255 | * @return mixed[] | ||
256 | */ | ||
257 | public function loadManyToManyCollection( | ||
258 | AssociationMapping $assoc, | ||
259 | object $sourceEntity, | ||
260 | PersistentCollection $collection, | ||
261 | ): array; | ||
262 | |||
263 | /** | ||
264 | * Loads a collection of entities in a one-to-many association. | ||
265 | * | ||
266 | * @param PersistentCollection $collection The collection to load/fill. | ||
267 | */ | ||
268 | public function loadOneToManyCollection( | ||
269 | AssociationMapping $assoc, | ||
270 | object $sourceEntity, | ||
271 | PersistentCollection $collection, | ||
272 | ): mixed; | ||
273 | |||
274 | /** | ||
275 | * Locks all rows of this entity matching the given criteria with the specified pessimistic lock mode. | ||
276 | * | ||
277 | * @psalm-param array<string, mixed> $criteria | ||
278 | * @psalm-param LockMode::* $lockMode | ||
279 | */ | ||
280 | public function lock(array $criteria, LockMode|int $lockMode): void; | ||
281 | |||
282 | /** | ||
283 | * Returns an array with (sliced or full list) of elements in the specified collection. | ||
284 | * | ||
285 | * @return mixed[] | ||
286 | */ | ||
287 | public function getOneToManyCollection( | ||
288 | AssociationMapping $assoc, | ||
289 | object $sourceEntity, | ||
290 | int|null $offset = null, | ||
291 | int|null $limit = null, | ||
292 | ): array; | ||
293 | |||
294 | /** | ||
295 | * Checks whether the given managed entity exists in the database. | ||
296 | */ | ||
297 | public function exists(object $entity, Criteria|null $extraConditions = null): bool; | ||
298 | } | ||
diff --git a/vendor/doctrine/orm/src/Persisters/Entity/JoinedSubclassPersister.php b/vendor/doctrine/orm/src/Persisters/Entity/JoinedSubclassPersister.php new file mode 100644 index 0000000..76719a2 --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/JoinedSubclassPersister.php | |||
@@ -0,0 +1,601 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Doctrine\ORM\Persisters\Entity; | ||
6 | |||
7 | use Doctrine\Common\Collections\Criteria; | ||
8 | use Doctrine\DBAL\LockMode; | ||
9 | use Doctrine\DBAL\Types\Type; | ||
10 | use Doctrine\DBAL\Types\Types; | ||
11 | use Doctrine\ORM\Internal\SQLResultCasing; | ||
12 | use Doctrine\ORM\Mapping\AssociationMapping; | ||
13 | use Doctrine\ORM\Mapping\ClassMetadata; | ||
14 | use Doctrine\ORM\Utility\LockSqlHelper; | ||
15 | use Doctrine\ORM\Utility\PersisterHelper; | ||
16 | use LengthException; | ||
17 | |||
18 | use function array_combine; | ||
19 | use function array_keys; | ||
20 | use function array_values; | ||
21 | use function implode; | ||
22 | |||
23 | /** | ||
24 | * The joined subclass persister maps a single entity instance to several tables in the | ||
25 | * database as it is defined by the <tt>Class Table Inheritance</tt> strategy. | ||
26 | * | ||
27 | * @see https://martinfowler.com/eaaCatalog/classTableInheritance.html | ||
28 | */ | ||
29 | class JoinedSubclassPersister extends AbstractEntityInheritancePersister | ||
30 | { | ||
31 | use LockSqlHelper; | ||
32 | use SQLResultCasing; | ||
33 | |||
34 | /** | ||
35 | * Map that maps column names to the table names that own them. | ||
36 | * This is mainly a temporary cache, used during a single request. | ||
37 | * | ||
38 | * @psalm-var array<string, string> | ||
39 | */ | ||
40 | private array $owningTableMap = []; | ||
41 | |||
42 | /** | ||
43 | * Map of table to quoted table names. | ||
44 | * | ||
45 | * @psalm-var array<string, string> | ||
46 | */ | ||
47 | private array $quotedTableMap = []; | ||
48 | |||
49 | protected function getDiscriminatorColumnTableName(): string | ||
50 | { | ||
51 | $class = $this->class->name !== $this->class->rootEntityName | ||
52 | ? $this->em->getClassMetadata($this->class->rootEntityName) | ||
53 | : $this->class; | ||
54 | |||
55 | return $class->getTableName(); | ||
56 | } | ||
57 | |||
58 | /** | ||
59 | * This function finds the ClassMetadata instance in an inheritance hierarchy | ||
60 | * that is responsible for enabling versioning. | ||
61 | */ | ||
62 | private function getVersionedClassMetadata(): ClassMetadata | ||
63 | { | ||
64 | if (isset($this->class->fieldMappings[$this->class->versionField]->inherited)) { | ||
65 | $definingClassName = $this->class->fieldMappings[$this->class->versionField]->inherited; | ||
66 | |||
67 | return $this->em->getClassMetadata($definingClassName); | ||
68 | } | ||
69 | |||
70 | return $this->class; | ||
71 | } | ||
72 | |||
73 | /** | ||
74 | * Gets the name of the table that owns the column the given field is mapped to. | ||
75 | */ | ||
76 | public function getOwningTable(string $fieldName): string | ||
77 | { | ||
78 | if (isset($this->owningTableMap[$fieldName])) { | ||
79 | return $this->owningTableMap[$fieldName]; | ||
80 | } | ||
81 | |||
82 | $cm = match (true) { | ||
83 | isset($this->class->associationMappings[$fieldName]->inherited) | ||
84 | => $this->em->getClassMetadata($this->class->associationMappings[$fieldName]->inherited), | ||
85 | isset($this->class->fieldMappings[$fieldName]->inherited) | ||
86 | => $this->em->getClassMetadata($this->class->fieldMappings[$fieldName]->inherited), | ||
87 | default => $this->class, | ||
88 | }; | ||
89 | |||
90 | $tableName = $cm->getTableName(); | ||
91 | $quotedTableName = $this->quoteStrategy->getTableName($cm, $this->platform); | ||
92 | |||
93 | $this->owningTableMap[$fieldName] = $tableName; | ||
94 | $this->quotedTableMap[$tableName] = $quotedTableName; | ||
95 | |||
96 | return $tableName; | ||
97 | } | ||
98 | |||
99 | public function executeInserts(): void | ||
100 | { | ||
101 | if (! $this->queuedInserts) { | ||
102 | return; | ||
103 | } | ||
104 | |||
105 | $uow = $this->em->getUnitOfWork(); | ||
106 | $idGenerator = $this->class->idGenerator; | ||
107 | $isPostInsertId = $idGenerator->isPostInsertGenerator(); | ||
108 | $rootClass = $this->class->name !== $this->class->rootEntityName | ||
109 | ? $this->em->getClassMetadata($this->class->rootEntityName) | ||
110 | : $this->class; | ||
111 | |||
112 | // Prepare statement for the root table | ||
113 | $rootPersister = $this->em->getUnitOfWork()->getEntityPersister($rootClass->name); | ||
114 | $rootTableName = $rootClass->getTableName(); | ||
115 | $rootTableStmt = $this->conn->prepare($rootPersister->getInsertSQL()); | ||
116 | |||
117 | // Prepare statements for sub tables. | ||
118 | $subTableStmts = []; | ||
119 | |||
120 | if ($rootClass !== $this->class) { | ||
121 | $subTableStmts[$this->class->getTableName()] = $this->conn->prepare($this->getInsertSQL()); | ||
122 | } | ||
123 | |||
124 | foreach ($this->class->parentClasses as $parentClassName) { | ||
125 | $parentClass = $this->em->getClassMetadata($parentClassName); | ||
126 | $parentTableName = $parentClass->getTableName(); | ||
127 | |||
128 | if ($parentClass !== $rootClass) { | ||
129 | $parentPersister = $this->em->getUnitOfWork()->getEntityPersister($parentClassName); | ||
130 | $subTableStmts[$parentTableName] = $this->conn->prepare($parentPersister->getInsertSQL()); | ||
131 | } | ||
132 | } | ||
133 | |||
134 | // Execute all inserts. For each entity: | ||
135 | // 1) Insert on root table | ||
136 | // 2) Insert on sub tables | ||
137 | foreach ($this->queuedInserts as $entity) { | ||
138 | $insertData = $this->prepareInsertData($entity); | ||
139 | |||
140 | // Execute insert on root table | ||
141 | $paramIndex = 1; | ||
142 | |||
143 | foreach ($insertData[$rootTableName] as $columnName => $value) { | ||
144 | $rootTableStmt->bindValue($paramIndex++, $value, $this->columnTypes[$columnName]); | ||
145 | } | ||
146 | |||
147 | $rootTableStmt->executeStatement(); | ||
148 | |||
149 | if ($isPostInsertId) { | ||
150 | $generatedId = $idGenerator->generateId($this->em, $entity); | ||
151 | $id = [$this->class->identifier[0] => $generatedId]; | ||
152 | |||
153 | $uow->assignPostInsertId($entity, $generatedId); | ||
154 | } else { | ||
155 | $id = $this->em->getUnitOfWork()->getEntityIdentifier($entity); | ||
156 | } | ||
157 | |||
158 | // Execute inserts on subtables. | ||
159 | // The order doesn't matter because all child tables link to the root table via FK. | ||
160 | foreach ($subTableStmts as $tableName => $stmt) { | ||
161 | $paramIndex = 1; | ||
162 | $data = $insertData[$tableName] ?? []; | ||
163 | |||
164 | foreach ($id as $idName => $idVal) { | ||
165 | $type = $this->columnTypes[$idName] ?? Types::STRING; | ||
166 | |||
167 | $stmt->bindValue($paramIndex++, $idVal, $type); | ||
168 | } | ||
169 | |||
170 | foreach ($data as $columnName => $value) { | ||
171 | if (! isset($id[$columnName])) { | ||
172 | $stmt->bindValue($paramIndex++, $value, $this->columnTypes[$columnName]); | ||
173 | } | ||
174 | } | ||
175 | |||
176 | $stmt->executeStatement(); | ||
177 | } | ||
178 | |||
179 | if ($this->class->requiresFetchAfterChange) { | ||
180 | $this->assignDefaultVersionAndUpsertableValues($entity, $id); | ||
181 | } | ||
182 | } | ||
183 | |||
184 | $this->queuedInserts = []; | ||
185 | } | ||
186 | |||
187 | public function update(object $entity): void | ||
188 | { | ||
189 | $updateData = $this->prepareUpdateData($entity); | ||
190 | |||
191 | if (! $updateData) { | ||
192 | return; | ||
193 | } | ||
194 | |||
195 | $isVersioned = $this->class->isVersioned; | ||
196 | |||
197 | $versionedClass = $this->getVersionedClassMetadata(); | ||
198 | $versionedTable = $versionedClass->getTableName(); | ||
199 | |||
200 | foreach ($updateData as $tableName => $data) { | ||
201 | $tableName = $this->quotedTableMap[$tableName]; | ||
202 | $versioned = $isVersioned && $versionedTable === $tableName; | ||
203 | |||
204 | $this->updateTable($entity, $tableName, $data, $versioned); | ||
205 | } | ||
206 | |||
207 | if ($this->class->requiresFetchAfterChange) { | ||
208 | // Make sure the table with the version column is updated even if no columns on that | ||
209 | // table were affected. | ||
210 | if ($isVersioned && ! isset($updateData[$versionedTable])) { | ||
211 | $tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform); | ||
212 | |||
213 | $this->updateTable($entity, $tableName, [], true); | ||
214 | } | ||
215 | |||
216 | $identifiers = $this->em->getUnitOfWork()->getEntityIdentifier($entity); | ||
217 | |||
218 | $this->assignDefaultVersionAndUpsertableValues($entity, $identifiers); | ||
219 | } | ||
220 | } | ||
221 | |||
222 | public function delete(object $entity): bool | ||
223 | { | ||
224 | $identifier = $this->em->getUnitOfWork()->getEntityIdentifier($entity); | ||
225 | $id = array_combine($this->class->getIdentifierColumnNames(), $identifier); | ||
226 | $types = $this->getClassIdentifiersTypes($this->class); | ||
227 | |||
228 | $this->deleteJoinTableRecords($identifier, $types); | ||
229 | |||
230 | // Delete the row from the root table. Cascades do the rest. | ||
231 | $rootClass = $this->em->getClassMetadata($this->class->rootEntityName); | ||
232 | $rootTable = $this->quoteStrategy->getTableName($rootClass, $this->platform); | ||
233 | $rootTypes = $this->getClassIdentifiersTypes($rootClass); | ||
234 | |||
235 | return (bool) $this->conn->delete($rootTable, $id, $rootTypes); | ||
236 | } | ||
237 | |||
238 | public function getSelectSQL( | ||
239 | array|Criteria $criteria, | ||
240 | AssociationMapping|null $assoc = null, | ||
241 | LockMode|int|null $lockMode = null, | ||
242 | int|null $limit = null, | ||
243 | int|null $offset = null, | ||
244 | array|null $orderBy = null, | ||
245 | ): string { | ||
246 | $this->switchPersisterContext($offset, $limit); | ||
247 | |||
248 | $baseTableAlias = $this->getSQLTableAlias($this->class->name); | ||
249 | $joinSql = $this->getJoinSql($baseTableAlias); | ||
250 | |||
251 | if ($assoc !== null && $assoc->isManyToMany()) { | ||
252 | $joinSql .= $this->getSelectManyToManyJoinSQL($assoc); | ||
253 | } | ||
254 | |||
255 | $conditionSql = $criteria instanceof Criteria | ||
256 | ? $this->getSelectConditionCriteriaSQL($criteria) | ||
257 | : $this->getSelectConditionSQL($criteria, $assoc); | ||
258 | |||
259 | $filterSql = $this->generateFilterConditionSQL( | ||
260 | $this->em->getClassMetadata($this->class->rootEntityName), | ||
261 | $this->getSQLTableAlias($this->class->rootEntityName), | ||
262 | ); | ||
263 | // If the current class in the root entity, add the filters | ||
264 | if ($filterSql) { | ||
265 | $conditionSql .= $conditionSql | ||
266 | ? ' AND ' . $filterSql | ||
267 | : $filterSql; | ||
268 | } | ||
269 | |||
270 | $orderBySql = ''; | ||
271 | |||
272 | if ($assoc !== null && $assoc->isOrdered()) { | ||
273 | $orderBy = $assoc->orderBy(); | ||
274 | } | ||
275 | |||
276 | if ($orderBy) { | ||
277 | $orderBySql = $this->getOrderBySQL($orderBy, $baseTableAlias); | ||
278 | } | ||
279 | |||
280 | $lockSql = ''; | ||
281 | |||
282 | switch ($lockMode) { | ||
283 | case LockMode::PESSIMISTIC_READ: | ||
284 | $lockSql = ' ' . $this->getReadLockSQL($this->platform); | ||
285 | |||
286 | break; | ||
287 | |||
288 | case LockMode::PESSIMISTIC_WRITE: | ||
289 | $lockSql = ' ' . $this->getWriteLockSQL($this->platform); | ||
290 | |||
291 | break; | ||
292 | } | ||
293 | |||
294 | $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); | ||
295 | $from = ' FROM ' . $tableName . ' ' . $baseTableAlias; | ||
296 | $where = $conditionSql !== '' ? ' WHERE ' . $conditionSql : ''; | ||
297 | $lock = $this->platform->appendLockHint($from, $lockMode ?? LockMode::NONE); | ||
298 | $columnList = $this->getSelectColumnsSQL(); | ||
299 | $query = 'SELECT ' . $columnList | ||
300 | . $lock | ||
301 | . $joinSql | ||
302 | . $where | ||
303 | . $orderBySql; | ||
304 | |||
305 | return $this->platform->modifyLimitQuery($query, $limit, $offset ?? 0) . $lockSql; | ||
306 | } | ||
307 | |||
308 | public function getCountSQL(array|Criteria $criteria = []): string | ||
309 | { | ||
310 | $tableName = $this->quoteStrategy->getTableName($this->class, $this->platform); | ||
311 | $baseTableAlias = $this->getSQLTableAlias($this->class->name); | ||
312 | $joinSql = $this->getJoinSql($baseTableAlias); | ||
313 | |||
314 | $conditionSql = $criteria instanceof Criteria | ||
315 | ? $this->getSelectConditionCriteriaSQL($criteria) | ||
316 | : $this->getSelectConditionSQL($criteria); | ||
317 | |||
318 | $filterSql = $this->generateFilterConditionSQL($this->em->getClassMetadata($this->class->rootEntityName), $this->getSQLTableAlias($this->class->rootEntityName)); | ||
319 | |||
320 | if ($filterSql !== '') { | ||
321 | $conditionSql = $conditionSql | ||
322 | ? $conditionSql . ' AND ' . $filterSql | ||
323 | : $filterSql; | ||
324 | } | ||
325 | |||
326 | return 'SELECT COUNT(*) ' | ||
327 | . 'FROM ' . $tableName . ' ' . $baseTableAlias | ||
328 | . $joinSql | ||
329 | . (empty($conditionSql) ? '' : ' WHERE ' . $conditionSql); | ||
330 | } | ||
331 | |||
332 | protected function getLockTablesSql(LockMode|int $lockMode): string | ||
333 | { | ||
334 | $joinSql = ''; | ||
335 | $identifierColumns = $this->class->getIdentifierColumnNames(); | ||
336 | $baseTableAlias = $this->getSQLTableAlias($this->class->name); | ||
337 | |||
338 | // INNER JOIN parent tables | ||
339 | foreach ($this->class->parentClasses as $parentClassName) { | ||
340 | $conditions = []; | ||
341 | $tableAlias = $this->getSQLTableAlias($parentClassName); | ||
342 | $parentClass = $this->em->getClassMetadata($parentClassName); | ||
343 | $joinSql .= ' INNER JOIN ' . $this->quoteStrategy->getTableName($parentClass, $this->platform) . ' ' . $tableAlias . ' ON '; | ||
344 | |||
345 | foreach ($identifierColumns as $idColumn) { | ||
346 | $conditions[] = $baseTableAlias . '.' . $idColumn . ' = ' . $tableAlias . '.' . $idColumn; | ||
347 | } | ||
348 | |||
349 | $joinSql .= implode(' AND ', $conditions); | ||
350 | } | ||
351 | |||
352 | return parent::getLockTablesSql($lockMode) . $joinSql; | ||
353 | } | ||
354 | |||
355 | /** | ||
356 | * Ensure this method is never called. This persister overrides getSelectEntitiesSQL directly. | ||
357 | */ | ||
358 | protected function getSelectColumnsSQL(): string | ||
359 | { | ||
360 | // Create the column list fragment only once | ||
361 | if ($this->currentPersisterContext->selectColumnListSql !== null) { | ||
362 | return $this->currentPersisterContext->selectColumnListSql; | ||
363 | } | ||
364 | |||
365 | $columnList = []; | ||
366 | $discrColumn = $this->class->getDiscriminatorColumn(); | ||
367 | $discrColumnName = $discrColumn->name; | ||
368 | $discrColumnType = $discrColumn->type; | ||
369 | $baseTableAlias = $this->getSQLTableAlias($this->class->name); | ||
370 | $resultColumnName = $this->getSQLResultCasing($this->platform, $discrColumnName); | ||
371 | |||
372 | $this->currentPersisterContext->rsm->addEntityResult($this->class->name, 'r'); | ||
373 | $this->currentPersisterContext->rsm->setDiscriminatorColumn('r', $resultColumnName); | ||
374 | $this->currentPersisterContext->rsm->addMetaResult('r', $resultColumnName, $discrColumnName, false, $discrColumnType); | ||
375 | |||
376 | // Add regular columns | ||
377 | foreach ($this->class->fieldMappings as $fieldName => $mapping) { | ||
378 | $class = isset($mapping->inherited) | ||
379 | ? $this->em->getClassMetadata($mapping->inherited) | ||
380 | : $this->class; | ||
381 | |||
382 | $columnList[] = $this->getSelectColumnSQL($fieldName, $class); | ||
383 | } | ||
384 | |||
385 | // Add foreign key columns | ||
386 | foreach ($this->class->associationMappings as $mapping) { | ||
387 | if (! $mapping->isToOneOwningSide()) { | ||
388 | continue; | ||
389 | } | ||
390 | |||
391 | $tableAlias = isset($mapping->inherited) | ||
392 | ? $this->getSQLTableAlias($mapping->inherited) | ||
393 | : $baseTableAlias; | ||
394 | |||
395 | $targetClass = $this->em->getClassMetadata($mapping->targetEntity); | ||
396 | |||
397 | foreach ($mapping->joinColumns as $joinColumn) { | ||
398 | $columnList[] = $this->getSelectJoinColumnSQL( | ||
399 | $tableAlias, | ||
400 | $joinColumn->name, | ||
401 | $this->quoteStrategy->getJoinColumnName($joinColumn, $this->class, $this->platform), | ||
402 | PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em), | ||
403 | ); | ||
404 | } | ||
405 | } | ||
406 | |||
407 | // Add discriminator column (DO NOT ALIAS, see AbstractEntityInheritancePersister#processSQLResult). | ||
408 | $tableAlias = $this->class->rootEntityName === $this->class->name | ||
409 | ? $baseTableAlias | ||
410 | : $this->getSQLTableAlias($this->class->rootEntityName); | ||
411 | |||
412 | $columnList[] = $tableAlias . '.' . $discrColumnName; | ||
413 | |||
414 | // sub tables | ||
415 | foreach ($this->class->subClasses as $subClassName) { | ||
416 | $subClass = $this->em->getClassMetadata($subClassName); | ||
417 | $tableAlias = $this->getSQLTableAlias($subClassName); | ||
418 | |||
419 | // Add subclass columns | ||
420 | foreach ($subClass->fieldMappings as $fieldName => $mapping) { | ||
421 | if (isset($mapping->inherited)) { | ||
422 | continue; | ||
423 | } | ||
424 | |||
425 | $columnList[] = $this->getSelectColumnSQL($fieldName, $subClass); | ||
426 | } | ||
427 | |||
428 | // Add join columns (foreign keys) | ||
429 | foreach ($subClass->associationMappings as $mapping) { | ||
430 | if (! $mapping->isToOneOwningSide() || isset($mapping->inherited)) { | ||
431 | continue; | ||
432 | } | ||
433 | |||
434 | $targetClass = $this->em->getClassMetadata($mapping->targetEntity); | ||
435 | |||
436 | foreach ($mapping->joinColumns as $joinColumn) { | ||
437 | $columnList[] = $this->getSelectJoinColumnSQL( | ||
438 | $tableAlias, | ||
439 | $joinColumn->name, | ||
440 | $this->quoteStrategy->getJoinColumnName($joinColumn, $subClass, $this->platform), | ||
441 | PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em), | ||
442 | ); | ||
443 | } | ||
444 | } | ||
445 | } | ||
446 | |||
447 | $this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList); | ||
448 | |||
449 | return $this->currentPersisterContext->selectColumnListSql; | ||
450 | } | ||
451 | |||
452 | /** | ||
453 | * {@inheritDoc} | ||
454 | */ | ||
455 | protected function getInsertColumnList(): array | ||
456 | { | ||
457 | // Identifier columns must always come first in the column list of subclasses. | ||
458 | $columns = $this->class->parentClasses | ||
459 | ? $this->class->getIdentifierColumnNames() | ||
460 | : []; | ||
461 | |||
462 | foreach ($this->class->reflFields as $name => $field) { | ||
463 | if ( | ||
464 | isset($this->class->fieldMappings[$name]->inherited) | ||
465 | && ! isset($this->class->fieldMappings[$name]->id) | ||
466 | || isset($this->class->associationMappings[$name]->inherited) | ||
467 | || ($this->class->isVersioned && $this->class->versionField === $name) | ||
468 | || isset($this->class->embeddedClasses[$name]) | ||
469 | || isset($this->class->fieldMappings[$name]->notInsertable) | ||
470 | ) { | ||
471 | continue; | ||
472 | } | ||
473 | |||
474 | if (isset($this->class->associationMappings[$name])) { | ||
475 | $assoc = $this->class->associationMappings[$name]; | ||
476 | if ($assoc->isToOneOwningSide()) { | ||
477 | foreach ($assoc->targetToSourceKeyColumns as $sourceCol) { | ||
478 | $columns[] = $sourceCol; | ||
479 | } | ||
480 | } | ||
481 | } elseif ( | ||
482 | $this->class->name !== $this->class->rootEntityName || | ||
483 | ! $this->class->isIdGeneratorIdentity() || $this->class->identifier[0] !== $name | ||
484 | ) { | ||
485 | $columns[] = $this->quoteStrategy->getColumnName($name, $this->class, $this->platform); | ||
486 | $this->columnTypes[$name] = $this->class->fieldMappings[$name]->type; | ||
487 | } | ||
488 | } | ||
489 | |||
490 | // Add discriminator column if it is the topmost class. | ||
491 | if ($this->class->name === $this->class->rootEntityName) { | ||
492 | $columns[] = $this->class->getDiscriminatorColumn()->name; | ||
493 | } | ||
494 | |||
495 | return $columns; | ||
496 | } | ||
497 | |||
498 | /** | ||
499 | * {@inheritDoc} | ||
500 | */ | ||
501 | protected function assignDefaultVersionAndUpsertableValues(object $entity, array $id): void | ||
502 | { | ||
503 | $values = $this->fetchVersionAndNotUpsertableValues($this->getVersionedClassMetadata(), $id); | ||
504 | |||
505 | foreach ($values as $field => $value) { | ||
506 | $value = Type::getType($this->class->fieldMappings[$field]->type)->convertToPHPValue($value, $this->platform); | ||
507 | |||
508 | $this->class->setFieldValue($entity, $field, $value); | ||
509 | } | ||
510 | } | ||
511 | |||
512 | /** | ||
513 | * {@inheritDoc} | ||
514 | */ | ||
515 | protected function fetchVersionAndNotUpsertableValues(ClassMetadata $versionedClass, array $id): mixed | ||
516 | { | ||
517 | $columnNames = []; | ||
518 | foreach ($this->class->fieldMappings as $key => $column) { | ||
519 | $class = null; | ||
520 | if ($this->class->isVersioned && $key === $versionedClass->versionField) { | ||
521 | $class = $versionedClass; | ||
522 | } elseif (isset($column->generated)) { | ||
523 | $class = isset($column->inherited) | ||
524 | ? $this->em->getClassMetadata($column->inherited) | ||
525 | : $this->class; | ||
526 | } else { | ||
527 | continue; | ||
528 | } | ||
529 | |||
530 | $columnNames[$key] = $this->getSelectColumnSQL($key, $class); | ||
531 | } | ||
532 | |||
533 | $tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform); | ||
534 | $baseTableAlias = $this->getSQLTableAlias($this->class->name); | ||
535 | $joinSql = $this->getJoinSql($baseTableAlias); | ||
536 | $identifier = $this->quoteStrategy->getIdentifierColumnNames($versionedClass, $this->platform); | ||
537 | foreach ($identifier as $i => $idValue) { | ||
538 | $identifier[$i] = $baseTableAlias . '.' . $idValue; | ||
539 | } | ||
540 | |||
541 | $sql = 'SELECT ' . implode(', ', $columnNames) | ||
542 | . ' FROM ' . $tableName . ' ' . $baseTableAlias | ||
543 | . $joinSql | ||
544 | . ' WHERE ' . implode(' = ? AND ', $identifier) . ' = ?'; | ||
545 | |||
546 | $flatId = $this->identifierFlattener->flattenIdentifier($versionedClass, $id); | ||
547 | $values = $this->conn->fetchNumeric( | ||
548 | $sql, | ||
549 | array_values($flatId), | ||
550 | $this->extractIdentifierTypes($id, $versionedClass), | ||
551 | ); | ||
552 | |||
553 | if ($values === false) { | ||
554 | throw new LengthException('Unexpected empty result for database query.'); | ||
555 | } | ||
556 | |||
557 | $values = array_combine(array_keys($columnNames), $values); | ||
558 | |||
559 | if (! $values) { | ||
560 | throw new LengthException('Unexpected number of database columns.'); | ||
561 | } | ||
562 | |||
563 | return $values; | ||
564 | } | ||
565 | |||
566 | private function getJoinSql(string $baseTableAlias): string | ||
567 | { | ||
568 | $joinSql = ''; | ||
569 | $identifierColumn = $this->class->getIdentifierColumnNames(); | ||
570 | |||
571 | // INNER JOIN parent tables | ||
572 | foreach ($this->class->parentClasses as $parentClassName) { | ||
573 | $conditions = []; | ||
574 | $parentClass = $this->em->getClassMetadata($parentClassName); | ||
575 | $tableAlias = $this->getSQLTableAlias($parentClassName); | ||
576 | $joinSql .= ' INNER JOIN ' . $this->quoteStrategy->getTableName($parentClass, $this->platform) . ' ' . $tableAlias . ' ON '; | ||
577 | |||
578 | foreach ($identifierColumn as $idColumn) { | ||
579 | $conditions[] = $baseTableAlias . '.' . $idColumn . ' = ' . $tableAlias . '.' . $idColumn; | ||
580 | } | ||
581 | |||
582 | $joinSql .= implode(' AND ', $conditions); | ||
583 | } | ||
584 | |||
585 | // OUTER JOIN sub tables | ||
586 | foreach ($this->class->subClasses as $subClassName) { | ||
587 | $conditions = []; | ||
588 | $subClass = $this->em->getClassMetadata($subClassName); | ||
589 | $tableAlias = $this->getSQLTableAlias($subClassName); | ||
590 | $joinSql .= ' LEFT JOIN ' . $this->quoteStrategy->getTableName($subClass, $this->platform) . ' ' . $tableAlias . ' ON '; | ||
591 | |||
592 | foreach ($identifierColumn as $idColumn) { | ||
593 | $conditions[] = $baseTableAlias . '.' . $idColumn . ' = ' . $tableAlias . '.' . $idColumn; | ||
594 | } | ||
595 | |||
596 | $joinSql .= implode(' AND ', $conditions); | ||
597 | } | ||
598 | |||
599 | return $joinSql; | ||
600 | } | ||
601 | } | ||
diff --git a/vendor/doctrine/orm/src/Persisters/Entity/SingleTablePersister.php b/vendor/doctrine/orm/src/Persisters/Entity/SingleTablePersister.php new file mode 100644 index 0000000..4a4d999 --- /dev/null +++ b/vendor/doctrine/orm/src/Persisters/Entity/SingleTablePersister.php | |||
@@ -0,0 +1,166 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Doctrine\ORM\Persisters\Entity; | ||
6 | |||
7 | use Doctrine\Common\Collections\Criteria; | ||
8 | use Doctrine\ORM\Internal\SQLResultCasing; | ||
9 | use Doctrine\ORM\Mapping\AssociationMapping; | ||
10 | use Doctrine\ORM\Mapping\ClassMetadata; | ||
11 | use Doctrine\ORM\Utility\PersisterHelper; | ||
12 | |||
13 | use function array_flip; | ||
14 | use function array_intersect; | ||
15 | use function array_map; | ||
16 | use function array_unshift; | ||
17 | use function implode; | ||
18 | use function strval; | ||
19 | |||
20 | /** | ||
21 | * Persister for entities that participate in a hierarchy mapped with the | ||
22 | * SINGLE_TABLE strategy. | ||
23 | * | ||
24 | * @link https://martinfowler.com/eaaCatalog/singleTableInheritance.html | ||
25 | */ | ||
26 | class SingleTablePersister extends AbstractEntityInheritancePersister | ||
27 | { | ||
28 | use SQLResultCasing; | ||
29 | |||
30 | protected function getDiscriminatorColumnTableName(): string | ||
31 | { | ||
32 | return $this->class->getTableName(); | ||
33 | } | ||
34 | |||
35 | protected function getSelectColumnsSQL(): string | ||
36 | { | ||
37 | $columnList = []; | ||
38 | if ($this->currentPersisterContext->selectColumnListSql !== null) { | ||
39 | return $this->currentPersisterContext->selectColumnListSql; | ||
40 | } | ||
41 | |||
42 | $columnList[] = parent::getSelectColumnsSQL(); | ||
43 | |||
44 | $rootClass = $this->em->getClassMetadata($this->class->rootEntityName); | ||
45 | $tableAlias = $this->getSQLTableAlias($rootClass->name); | ||
46 | |||
47 | // Append discriminator column | ||
48 | $discrColumn = $this->class->getDiscriminatorColumn(); | ||
49 | $discrColumnName = $discrColumn->name; | ||
50 | $discrColumnType = $discrColumn->type; | ||
51 | |||
52 | $columnList[] = $tableAlias . '.' . $discrColumnName; | ||
53 | |||
54 | $resultColumnName = $this->getSQLResultCasing($this->platform, $discrColumnName); | ||
55 | |||
56 | $this->currentPersisterContext->rsm->setDiscriminatorColumn('r', $resultColumnName); | ||
57 | $this->currentPersisterContext->rsm->addMetaResult('r', $resultColumnName, $discrColumnName, false, $discrColumnType); | ||
58 | |||
59 | // Append subclass columns | ||
60 | foreach ($this->class->subClasses as $subClassName) { | ||
61 | $subClass = $this->em->getClassMetadata($subClassName); | ||
62 | |||
63 | // Regular columns | ||
64 | foreach ($subClass->fieldMappings as $fieldName => $mapping) { | ||
65 | if (isset($mapping->inherited)) { | ||
66 | continue; | ||
67 | } | ||
68 | |||
69 | $columnList[] = $this->getSelectColumnSQL($fieldName, $subClass); | ||
70 | } | ||
71 | |||
72 | // Foreign key columns | ||
73 | foreach ($subClass->associationMappings as $assoc) { | ||
74 | if (! $assoc->isToOneOwningSide() || isset($assoc->inherited)) { | ||
75 | continue; | ||
76 | } | ||
77 | |||
78 | $targetClass = $this->em->getClassMetadata($assoc->targetEntity); | ||
79 | |||
80 | foreach ($assoc->joinColumns as $joinColumn) { | ||
81 | $columnList[] = $this->getSelectJoinColumnSQL( | ||
82 | $tableAlias, | ||
83 | $joinColumn->name, | ||
84 | $this->quoteStrategy->getJoinColumnName($joinColumn, $subClass, $this->platform), | ||
85 | PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em), | ||
86 | ); | ||
87 | } | ||
88 | } | ||
89 | } | ||
90 | |||
91 | $this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList); | ||
92 | |||
93 | return $this->currentPersisterContext->selectColumnListSql; | ||
94 | } | ||
95 | |||
96 | /** | ||
97 | * {@inheritDoc} | ||
98 | */ | ||
99 | protected function getInsertColumnList(): array | ||
100 | { | ||
101 | $columns = parent::getInsertColumnList(); | ||
102 | |||
103 | // Add discriminator column to the INSERT SQL | ||
104 | $columns[] = $this->class->getDiscriminatorColumn()->name; | ||
105 | |||
106 | return $columns; | ||
107 | } | ||
108 | |||
109 | protected function getSQLTableAlias(string $className, string $assocName = ''): string | ||
110 | { | ||
111 | return parent::getSQLTableAlias($this->class->rootEntityName, $assocName); | ||
112 | } | ||
113 | |||
114 | /** | ||
115 | * {@inheritDoc} | ||
116 | */ | ||
117 | protected function getSelectConditionSQL(array $criteria, AssociationMapping|null $assoc = null): string | ||
118 | { | ||
119 | $conditionSql = parent::getSelectConditionSQL($criteria, $assoc); | ||
120 | |||
121 | if ($conditionSql) { | ||
122 | $conditionSql .= ' AND '; | ||
123 | } | ||
124 | |||
125 | return $conditionSql . $this->getSelectConditionDiscriminatorValueSQL(); | ||
126 | } | ||
127 | |||
128 | protected function getSelectConditionCriteriaSQL(Criteria $criteria): string | ||
129 | { | ||
130 | $conditionSql = parent::getSelectConditionCriteriaSQL($criteria); | ||
131 | |||
132 | if ($conditionSql) { | ||
133 | $conditionSql .= ' AND '; | ||
134 | } | ||
135 | |||
136 | return $conditionSql . $this->getSelectConditionDiscriminatorValueSQL(); | ||
137 | } | ||
138 | |||
139 | protected function getSelectConditionDiscriminatorValueSQL(): string | ||
140 | { | ||
141 | $values = array_map($this->conn->quote(...), array_map( | ||
142 | strval(...), | ||
143 | array_flip(array_intersect($this->class->discriminatorMap, $this->class->subClasses)), | ||
144 | )); | ||
145 | |||
146 | if ($this->class->discriminatorValue !== null) { // discriminators can be 0 | ||
147 | array_unshift($values, $this->conn->quote((string) $this->class->discriminatorValue)); | ||
148 | } | ||
149 | |||
150 | $discColumnName = $this->class->getDiscriminatorColumn()->name; | ||
151 | |||
152 | $values = implode(', ', $values); | ||
153 | $tableAlias = $this->getSQLTableAlias($this->class->name); | ||
154 | |||
155 | return $tableAlias . '.' . $discColumnName . ' IN (' . $values . ')'; | ||
156 | } | ||
157 | |||
158 | protected function generateFilterConditionSQL(ClassMetadata $targetEntity, string $targetTableAlias): string | ||
159 | { | ||
160 | // Ensure that the filters are applied to the root entity of the inheritance tree | ||
161 | $targetEntity = $this->em->getClassMetadata($targetEntity->rootEntityName); | ||
162 | // we don't care about the $targetTableAlias, in a STI there is only one table. | ||
163 | |||
164 | return parent::generateFilterConditionSQL($targetEntity, $targetTableAlias); | ||
165 | } | ||
166 | } | ||