summaryrefslogtreecommitdiff
path: root/vendor/doctrine/orm/src/Persisters
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/doctrine/orm/src/Persisters')
-rw-r--r--vendor/doctrine/orm/src/Persisters/Collection/AbstractCollectionPersister.php50
-rw-r--r--vendor/doctrine/orm/src/Persisters/Collection/CollectionPersister.php59
-rw-r--r--vendor/doctrine/orm/src/Persisters/Collection/ManyToManyPersister.php770
-rw-r--r--vendor/doctrine/orm/src/Persisters/Collection/OneToManyPersister.php264
-rw-r--r--vendor/doctrine/orm/src/Persisters/Entity/AbstractEntityInheritancePersister.php66
-rw-r--r--vendor/doctrine/orm/src/Persisters/Entity/BasicEntityPersister.php2085
-rw-r--r--vendor/doctrine/orm/src/Persisters/Entity/CachedPersisterContext.php60
-rw-r--r--vendor/doctrine/orm/src/Persisters/Entity/EntityPersister.php298
-rw-r--r--vendor/doctrine/orm/src/Persisters/Entity/JoinedSubclassPersister.php601
-rw-r--r--vendor/doctrine/orm/src/Persisters/Entity/SingleTablePersister.php166
-rw-r--r--vendor/doctrine/orm/src/Persisters/Exception/CantUseInOperatorOnCompositeKeys.php15
-rw-r--r--vendor/doctrine/orm/src/Persisters/Exception/InvalidOrientation.php15
-rw-r--r--vendor/doctrine/orm/src/Persisters/Exception/UnrecognizedField.php24
-rw-r--r--vendor/doctrine/orm/src/Persisters/MatchingAssociationFieldRequiresObject.php22
-rw-r--r--vendor/doctrine/orm/src/Persisters/PersisterException.php23
-rw-r--r--vendor/doctrine/orm/src/Persisters/SqlExpressionVisitor.php79
-rw-r--r--vendor/doctrine/orm/src/Persisters/SqlValueVisitor.php88
17 files changed, 4685 insertions, 0 deletions
diff --git a/vendor/doctrine/orm/src/Persisters/Collection/AbstractCollectionPersister.php b/vendor/doctrine/orm/src/Persisters/Collection/AbstractCollectionPersister.php
new file mode 100644
index 0000000..26f0b9e
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/Collection/AbstractCollectionPersister.php
@@ -0,0 +1,50 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Collection;
6
7use Doctrine\DBAL\Connection;
8use Doctrine\DBAL\Platforms\AbstractPlatform;
9use Doctrine\ORM\EntityManagerInterface;
10use Doctrine\ORM\Mapping\QuoteStrategy;
11use Doctrine\ORM\UnitOfWork;
12
13/**
14 * Base class for all collection persisters.
15 */
16abstract class AbstractCollectionPersister implements CollectionPersister
17{
18 protected Connection $conn;
19 protected UnitOfWork $uow;
20 protected AbstractPlatform $platform;
21 protected QuoteStrategy $quoteStrategy;
22
23 /**
24 * Initializes a new instance of a class derived from AbstractCollectionPersister.
25 */
26 public function __construct(
27 protected EntityManagerInterface $em,
28 ) {
29 $this->uow = $em->getUnitOfWork();
30 $this->conn = $em->getConnection();
31 $this->platform = $this->conn->getDatabasePlatform();
32 $this->quoteStrategy = $em->getConfiguration()->getQuoteStrategy();
33 }
34
35 /**
36 * Check if entity is in a valid state for operations.
37 */
38 protected function isValidEntityState(object $entity): bool
39 {
40 $entityState = $this->uow->getEntityState($entity, UnitOfWork::STATE_NEW);
41
42 if ($entityState === UnitOfWork::STATE_NEW) {
43 return false;
44 }
45
46 // If Entity is scheduled for inclusion, it is not in this collection.
47 // We can assure that because it would have return true before on array check
48 return ! ($entityState === UnitOfWork::STATE_MANAGED && $this->uow->isScheduledForInsert($entity));
49 }
50}
diff --git a/vendor/doctrine/orm/src/Persisters/Collection/CollectionPersister.php b/vendor/doctrine/orm/src/Persisters/Collection/CollectionPersister.php
new file mode 100644
index 0000000..07c4eaf
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/Collection/CollectionPersister.php
@@ -0,0 +1,59 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Collection;
6
7use Doctrine\Common\Collections\Criteria;
8use Doctrine\ORM\PersistentCollection;
9
10/**
11 * Define the behavior that should be implemented by all collection persisters.
12 */
13interface CollectionPersister
14{
15 /**
16 * Deletes the persistent state represented by the given collection.
17 */
18 public function delete(PersistentCollection $collection): void;
19
20 /**
21 * Updates the given collection, synchronizing its state with the database
22 * by inserting, updating and deleting individual elements.
23 */
24 public function update(PersistentCollection $collection): void;
25
26 /**
27 * Counts the size of this persistent collection.
28 */
29 public function count(PersistentCollection $collection): int;
30
31 /**
32 * Slices elements.
33 *
34 * @return mixed[]
35 */
36 public function slice(PersistentCollection $collection, int $offset, int|null $length = null): array;
37
38 /**
39 * Checks for existence of an element.
40 */
41 public function contains(PersistentCollection $collection, object $element): bool;
42
43 /**
44 * Checks for existence of a key.
45 */
46 public function containsKey(PersistentCollection $collection, mixed $key): bool;
47
48 /**
49 * Gets an element by key.
50 */
51 public function get(PersistentCollection $collection, mixed $index): mixed;
52
53 /**
54 * Loads association entities matching the given Criteria object.
55 *
56 * @return mixed[]
57 */
58 public function loadCriteria(PersistentCollection $collection, Criteria $criteria): array;
59}
diff --git a/vendor/doctrine/orm/src/Persisters/Collection/ManyToManyPersister.php b/vendor/doctrine/orm/src/Persisters/Collection/ManyToManyPersister.php
new file mode 100644
index 0000000..7cf993d
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/Collection/ManyToManyPersister.php
@@ -0,0 +1,770 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Collection;
6
7use BadMethodCallException;
8use Doctrine\Common\Collections\Criteria;
9use Doctrine\Common\Collections\Expr\Comparison;
10use Doctrine\DBAL\Exception as DBALException;
11use Doctrine\DBAL\LockMode;
12use Doctrine\ORM\Mapping\AssociationMapping;
13use Doctrine\ORM\Mapping\ClassMetadata;
14use Doctrine\ORM\Mapping\InverseSideMapping;
15use Doctrine\ORM\Mapping\ManyToManyAssociationMapping;
16use Doctrine\ORM\PersistentCollection;
17use Doctrine\ORM\Persisters\SqlValueVisitor;
18use Doctrine\ORM\Query;
19use Doctrine\ORM\Utility\PersisterHelper;
20
21use function array_fill;
22use function array_pop;
23use function assert;
24use function count;
25use function implode;
26use function in_array;
27use function reset;
28use function sprintf;
29
30/**
31 * Persister for many-to-many collections.
32 */
33class ManyToManyPersister extends AbstractCollectionPersister
34{
35 public function delete(PersistentCollection $collection): void
36 {
37 $mapping = $this->getMapping($collection);
38
39 if (! $mapping->isOwningSide()) {
40 return; // ignore inverse side
41 }
42
43 assert($mapping->isManyToManyOwningSide());
44
45 $types = [];
46 $class = $this->em->getClassMetadata($mapping->sourceEntity);
47
48 foreach ($mapping->joinTable->joinColumns as $joinColumn) {
49 $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $class, $this->em);
50 }
51
52 $this->conn->executeStatement($this->getDeleteSQL($collection), $this->getDeleteSQLParameters($collection), $types);
53 }
54
55 public function update(PersistentCollection $collection): void
56 {
57 $mapping = $this->getMapping($collection);
58
59 if (! $mapping->isOwningSide()) {
60 return; // ignore inverse side
61 }
62
63 [$deleteSql, $deleteTypes] = $this->getDeleteRowSQL($collection);
64 [$insertSql, $insertTypes] = $this->getInsertRowSQL($collection);
65
66 foreach ($collection->getDeleteDiff() as $element) {
67 $this->conn->executeStatement(
68 $deleteSql,
69 $this->getDeleteRowSQLParameters($collection, $element),
70 $deleteTypes,
71 );
72 }
73
74 foreach ($collection->getInsertDiff() as $element) {
75 $this->conn->executeStatement(
76 $insertSql,
77 $this->getInsertRowSQLParameters($collection, $element),
78 $insertTypes,
79 );
80 }
81 }
82
83 public function get(PersistentCollection $collection, mixed $index): object|null
84 {
85 $mapping = $this->getMapping($collection);
86
87 if (! $mapping->isIndexed()) {
88 throw new BadMethodCallException('Selecting a collection by index is only supported on indexed collections.');
89 }
90
91 $persister = $this->uow->getEntityPersister($mapping->targetEntity);
92 $mappedKey = $mapping->isOwningSide()
93 ? $mapping->inversedBy
94 : $mapping->mappedBy;
95
96 assert($mappedKey !== null);
97
98 return $persister->load(
99 [$mappedKey => $collection->getOwner(), $mapping->indexBy() => $index],
100 null,
101 $mapping,
102 [],
103 LockMode::NONE,
104 1,
105 );
106 }
107
108 public function count(PersistentCollection $collection): int
109 {
110 $conditions = [];
111 $params = [];
112 $types = [];
113 $mapping = $this->getMapping($collection);
114 $id = $this->uow->getEntityIdentifier($collection->getOwner());
115 $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity);
116 $association = $this->em->getMetadataFactory()->getOwningSide($mapping);
117
118 $joinTableName = $this->quoteStrategy->getJoinTableName($association, $sourceClass, $this->platform);
119 $joinColumns = ! $mapping->isOwningSide()
120 ? $association->joinTable->inverseJoinColumns
121 : $association->joinTable->joinColumns;
122
123 foreach ($joinColumns as $joinColumn) {
124 $columnName = $this->quoteStrategy->getJoinColumnName($joinColumn, $sourceClass, $this->platform);
125 $referencedName = $joinColumn->referencedColumnName;
126 $conditions[] = 't.' . $columnName . ' = ?';
127 $params[] = $id[$sourceClass->getFieldForColumn($referencedName)];
128 $types[] = PersisterHelper::getTypeOfColumn($referencedName, $sourceClass, $this->em);
129 }
130
131 [$joinTargetEntitySQL, $filterSql] = $this->getFilterSql($mapping);
132
133 if ($filterSql) {
134 $conditions[] = $filterSql;
135 }
136
137 // If there is a provided criteria, make part of conditions
138 // @todo Fix this. Current SQL returns something like:
139 /*if ($criteria && ($expression = $criteria->getWhereExpression()) !== null) {
140 // A join is needed on the target entity
141 $targetTableName = $this->quoteStrategy->getTableName($targetClass, $this->platform);
142 $targetJoinSql = ' JOIN ' . $targetTableName . ' te'
143 . ' ON' . implode(' AND ', $this->getOnConditionSQL($association));
144
145 // And criteria conditions needs to be added
146 $persister = $this->uow->getEntityPersister($targetClass->name);
147 $visitor = new SqlExpressionVisitor($persister, $targetClass);
148 $conditions[] = $visitor->dispatch($expression);
149
150 $joinTargetEntitySQL = $targetJoinSql . $joinTargetEntitySQL;
151 }*/
152
153 $sql = 'SELECT COUNT(*)'
154 . ' FROM ' . $joinTableName . ' t'
155 . $joinTargetEntitySQL
156 . ' WHERE ' . implode(' AND ', $conditions);
157
158 return (int) $this->conn->fetchOne($sql, $params, $types);
159 }
160
161 /**
162 * {@inheritDoc}
163 */
164 public function slice(PersistentCollection $collection, int $offset, int|null $length = null): array
165 {
166 $mapping = $this->getMapping($collection);
167 $persister = $this->uow->getEntityPersister($mapping->targetEntity);
168
169 return $persister->getManyToManyCollection($mapping, $collection->getOwner(), $offset, $length);
170 }
171
172 public function containsKey(PersistentCollection $collection, mixed $key): bool
173 {
174 $mapping = $this->getMapping($collection);
175
176 if (! $mapping->isIndexed()) {
177 throw new BadMethodCallException('Selecting a collection by index is only supported on indexed collections.');
178 }
179
180 [$quotedJoinTable, $whereClauses, $params, $types] = $this->getJoinTableRestrictionsWithKey(
181 $collection,
182 (string) $key,
183 true,
184 );
185
186 $sql = 'SELECT 1 FROM ' . $quotedJoinTable . ' WHERE ' . implode(' AND ', $whereClauses);
187
188 return (bool) $this->conn->fetchOne($sql, $params, $types);
189 }
190
191 public function contains(PersistentCollection $collection, object $element): bool
192 {
193 if (! $this->isValidEntityState($element)) {
194 return false;
195 }
196
197 [$quotedJoinTable, $whereClauses, $params, $types] = $this->getJoinTableRestrictions(
198 $collection,
199 $element,
200 true,
201 );
202
203 $sql = 'SELECT 1 FROM ' . $quotedJoinTable . ' WHERE ' . implode(' AND ', $whereClauses);
204
205 return (bool) $this->conn->fetchOne($sql, $params, $types);
206 }
207
208 /**
209 * {@inheritDoc}
210 */
211 public function loadCriteria(PersistentCollection $collection, Criteria $criteria): array
212 {
213 $mapping = $this->getMapping($collection);
214 $owner = $collection->getOwner();
215 $ownerMetadata = $this->em->getClassMetadata($owner::class);
216 $id = $this->uow->getEntityIdentifier($owner);
217 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
218 $onConditions = $this->getOnConditionSQL($mapping);
219 $whereClauses = $params = [];
220 $paramTypes = [];
221
222 if (! $mapping->isOwningSide()) {
223 assert($mapping instanceof InverseSideMapping);
224 $associationSourceClass = $targetClass;
225 $sourceRelationMode = 'relationToTargetKeyColumns';
226 } else {
227 $associationSourceClass = $ownerMetadata;
228 $sourceRelationMode = 'relationToSourceKeyColumns';
229 }
230
231 $mapping = $this->em->getMetadataFactory()->getOwningSide($mapping);
232
233 foreach ($mapping->$sourceRelationMode as $key => $value) {
234 $whereClauses[] = sprintf('t.%s = ?', $key);
235 $params[] = $ownerMetadata->containsForeignIdentifier
236 ? $id[$ownerMetadata->getFieldForColumn($value)]
237 : $id[$ownerMetadata->fieldNames[$value]];
238 $paramTypes[] = PersisterHelper::getTypeOfColumn($value, $ownerMetadata, $this->em);
239 }
240
241 $parameters = $this->expandCriteriaParameters($criteria);
242
243 foreach ($parameters as $parameter) {
244 [$name, $value, $operator] = $parameter;
245
246 $field = $this->quoteStrategy->getColumnName($name, $targetClass, $this->platform);
247
248 if ($value === null && ($operator === Comparison::EQ || $operator === Comparison::NEQ)) {
249 $whereClauses[] = sprintf('te.%s %s NULL', $field, $operator === Comparison::EQ ? 'IS' : 'IS NOT');
250 } else {
251 $whereClauses[] = sprintf('te.%s %s ?', $field, $operator);
252 $params[] = $value;
253 $paramTypes[] = PersisterHelper::getTypeOfField($name, $targetClass, $this->em)[0];
254 }
255 }
256
257 $tableName = $this->quoteStrategy->getTableName($targetClass, $this->platform);
258 $joinTable = $this->quoteStrategy->getJoinTableName($mapping, $associationSourceClass, $this->platform);
259
260 $rsm = new Query\ResultSetMappingBuilder($this->em);
261 $rsm->addRootEntityFromClassMetadata($targetClass->name, 'te');
262
263 $sql = 'SELECT ' . $rsm->generateSelectClause()
264 . ' FROM ' . $tableName . ' te'
265 . ' JOIN ' . $joinTable . ' t ON'
266 . implode(' AND ', $onConditions)
267 . ' WHERE ' . implode(' AND ', $whereClauses);
268
269 $sql .= $this->getOrderingSql($criteria, $targetClass);
270
271 $sql .= $this->getLimitSql($criteria);
272
273 $stmt = $this->conn->executeQuery($sql, $params, $paramTypes);
274
275 return $this
276 ->em
277 ->newHydrator(Query::HYDRATE_OBJECT)
278 ->hydrateAll($stmt, $rsm);
279 }
280
281 /**
282 * Generates the filter SQL for a given mapping.
283 *
284 * This method is not used for actually grabbing the related entities
285 * but when the extra-lazy collection methods are called on a filtered
286 * association. This is why besides the many to many table we also
287 * have to join in the actual entities table leading to additional
288 * JOIN.
289 *
290 * @param AssociationMapping $mapping Array containing mapping information.
291 *
292 * @return string[] ordered tuple:
293 * - JOIN condition to add to the SQL
294 * - WHERE condition to add to the SQL
295 * @psalm-return array{0: string, 1: string}
296 */
297 public function getFilterSql(AssociationMapping $mapping): array
298 {
299 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
300 $rootClass = $this->em->getClassMetadata($targetClass->rootEntityName);
301 $filterSql = $this->generateFilterConditionSQL($rootClass, 'te');
302
303 if ($filterSql === '') {
304 return ['', ''];
305 }
306
307 // A join is needed if there is filtering on the target entity
308 $tableName = $this->quoteStrategy->getTableName($rootClass, $this->platform);
309 $joinSql = ' JOIN ' . $tableName . ' te'
310 . ' ON' . implode(' AND ', $this->getOnConditionSQL($mapping));
311
312 return [$joinSql, $filterSql];
313 }
314
315 /**
316 * Generates the filter SQL for a given entity and table alias.
317 *
318 * @param ClassMetadata $targetEntity Metadata of the target entity.
319 * @param string $targetTableAlias The table alias of the joined/selected table.
320 *
321 * @return string The SQL query part to add to a query.
322 */
323 protected function generateFilterConditionSQL(ClassMetadata $targetEntity, string $targetTableAlias): string
324 {
325 $filterClauses = [];
326
327 foreach ($this->em->getFilters()->getEnabledFilters() as $filter) {
328 $filterExpr = $filter->addFilterConstraint($targetEntity, $targetTableAlias);
329 if ($filterExpr) {
330 $filterClauses[] = '(' . $filterExpr . ')';
331 }
332 }
333
334 return $filterClauses
335 ? '(' . implode(' AND ', $filterClauses) . ')'
336 : '';
337 }
338
339 /**
340 * Generate ON condition
341 *
342 * @return string[]
343 * @psalm-return list<string>
344 */
345 protected function getOnConditionSQL(AssociationMapping $mapping): array
346 {
347 $association = $this->em->getMetadataFactory()->getOwningSide($mapping);
348 $joinColumns = $mapping->isOwningSide()
349 ? $association->joinTable->inverseJoinColumns
350 : $association->joinTable->joinColumns;
351
352 $conditions = [];
353
354 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
355 foreach ($joinColumns as $joinColumn) {
356 $joinColumnName = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform);
357 $refColumnName = $this->quoteStrategy->getReferencedJoinColumnName($joinColumn, $targetClass, $this->platform);
358
359 $conditions[] = ' t.' . $joinColumnName . ' = te.' . $refColumnName;
360 }
361
362 return $conditions;
363 }
364
365 protected function getDeleteSQL(PersistentCollection $collection): string
366 {
367 $columns = [];
368 $mapping = $this->getMapping($collection);
369 assert($mapping->isManyToManyOwningSide());
370 $class = $this->em->getClassMetadata($collection->getOwner()::class);
371 $joinTable = $this->quoteStrategy->getJoinTableName($mapping, $class, $this->platform);
372
373 foreach ($mapping->joinTable->joinColumns as $joinColumn) {
374 $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform);
375 }
376
377 return 'DELETE FROM ' . $joinTable
378 . ' WHERE ' . implode(' = ? AND ', $columns) . ' = ?';
379 }
380
381 /**
382 * Internal note: Order of the parameters must be the same as the order of the columns in getDeleteSql.
383 *
384 * @return list<mixed>
385 */
386 protected function getDeleteSQLParameters(PersistentCollection $collection): array
387 {
388 $mapping = $this->getMapping($collection);
389 assert($mapping->isManyToManyOwningSide());
390 $identifier = $this->uow->getEntityIdentifier($collection->getOwner());
391
392 // Optimization for single column identifier
393 if (count($mapping->relationToSourceKeyColumns) === 1) {
394 return [reset($identifier)];
395 }
396
397 // Composite identifier
398 $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity);
399 $params = [];
400
401 foreach ($mapping->relationToSourceKeyColumns as $columnName => $refColumnName) {
402 $params[] = isset($sourceClass->fieldNames[$refColumnName])
403 ? $identifier[$sourceClass->fieldNames[$refColumnName]]
404 : $identifier[$sourceClass->getFieldForColumn($refColumnName)];
405 }
406
407 return $params;
408 }
409
410 /**
411 * Gets the SQL statement used for deleting a row from the collection.
412 *
413 * @return string[]|string[][] ordered tuple containing the SQL to be executed and an array
414 * of types for bound parameters
415 * @psalm-return array{0: string, 1: list<string>}
416 */
417 protected function getDeleteRowSQL(PersistentCollection $collection): array
418 {
419 $mapping = $this->getMapping($collection);
420 assert($mapping->isManyToManyOwningSide());
421 $class = $this->em->getClassMetadata($mapping->sourceEntity);
422 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
423 $columns = [];
424 $types = [];
425
426 foreach ($mapping->joinTable->joinColumns as $joinColumn) {
427 $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $class, $this->platform);
428 $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $class, $this->em);
429 }
430
431 foreach ($mapping->joinTable->inverseJoinColumns as $joinColumn) {
432 $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform);
433 $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em);
434 }
435
436 return [
437 'DELETE FROM ' . $this->quoteStrategy->getJoinTableName($mapping, $class, $this->platform)
438 . ' WHERE ' . implode(' = ? AND ', $columns) . ' = ?',
439 $types,
440 ];
441 }
442
443 /**
444 * Gets the SQL parameters for the corresponding SQL statement to delete the given
445 * element from the given collection.
446 *
447 * Internal note: Order of the parameters must be the same as the order of the columns in getDeleteRowSql.
448 *
449 * @return mixed[]
450 * @psalm-return list<mixed>
451 */
452 protected function getDeleteRowSQLParameters(PersistentCollection $collection, object $element): array
453 {
454 return $this->collectJoinTableColumnParameters($collection, $element);
455 }
456
457 /**
458 * Gets the SQL statement used for inserting a row in the collection.
459 *
460 * @return string[]|string[][] ordered tuple containing the SQL to be executed and an array
461 * of types for bound parameters
462 * @psalm-return array{0: string, 1: list<string>}
463 */
464 protected function getInsertRowSQL(PersistentCollection $collection): array
465 {
466 $columns = [];
467 $types = [];
468 $mapping = $this->getMapping($collection);
469 assert($mapping->isManyToManyOwningSide());
470 $class = $this->em->getClassMetadata($mapping->sourceEntity);
471 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
472
473 foreach ($mapping->joinTable->joinColumns as $joinColumn) {
474 $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform);
475 $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $class, $this->em);
476 }
477
478 foreach ($mapping->joinTable->inverseJoinColumns as $joinColumn) {
479 $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform);
480 $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $targetClass, $this->em);
481 }
482
483 return [
484 'INSERT INTO ' . $this->quoteStrategy->getJoinTableName($mapping, $class, $this->platform)
485 . ' (' . implode(', ', $columns) . ')'
486 . ' VALUES'
487 . ' (' . implode(', ', array_fill(0, count($columns), '?')) . ')',
488 $types,
489 ];
490 }
491
492 /**
493 * Gets the SQL parameters for the corresponding SQL statement to insert the given
494 * element of the given collection into the database.
495 *
496 * Internal note: Order of the parameters must be the same as the order of the columns in getInsertRowSql.
497 *
498 * @return mixed[]
499 * @psalm-return list<mixed>
500 */
501 protected function getInsertRowSQLParameters(PersistentCollection $collection, object $element): array
502 {
503 return $this->collectJoinTableColumnParameters($collection, $element);
504 }
505
506 /**
507 * Collects the parameters for inserting/deleting on the join table in the order
508 * of the join table columns as specified in ManyToManyMapping#joinTableColumns.
509 *
510 * @return mixed[]
511 * @psalm-return list<mixed>
512 */
513 private function collectJoinTableColumnParameters(
514 PersistentCollection $collection,
515 object $element,
516 ): array {
517 $params = [];
518 $mapping = $this->getMapping($collection);
519 assert($mapping->isManyToManyOwningSide());
520 $isComposite = count($mapping->joinTableColumns) > 2;
521
522 $identifier1 = $this->uow->getEntityIdentifier($collection->getOwner());
523 $identifier2 = $this->uow->getEntityIdentifier($element);
524
525 $class1 = $class2 = null;
526 if ($isComposite) {
527 $class1 = $this->em->getClassMetadata($collection->getOwner()::class);
528 $class2 = $collection->getTypeClass();
529 }
530
531 foreach ($mapping->joinTableColumns as $joinTableColumn) {
532 $isRelationToSource = isset($mapping->relationToSourceKeyColumns[$joinTableColumn]);
533
534 if (! $isComposite) {
535 $params[] = $isRelationToSource ? array_pop($identifier1) : array_pop($identifier2);
536
537 continue;
538 }
539
540 if ($isRelationToSource) {
541 $params[] = $identifier1[$class1->getFieldForColumn($mapping->relationToSourceKeyColumns[$joinTableColumn])];
542
543 continue;
544 }
545
546 $params[] = $identifier2[$class2->getFieldForColumn($mapping->relationToTargetKeyColumns[$joinTableColumn])];
547 }
548
549 return $params;
550 }
551
552 /**
553 * @param bool $addFilters Whether the filter SQL should be included or not.
554 *
555 * @return mixed[] ordered vector:
556 * - quoted join table name
557 * - where clauses to be added for filtering
558 * - parameters to be bound for filtering
559 * - types of the parameters to be bound for filtering
560 * @psalm-return array{0: string, 1: list<string>, 2: list<mixed>, 3: list<string>}
561 */
562 private function getJoinTableRestrictionsWithKey(
563 PersistentCollection $collection,
564 string $key,
565 bool $addFilters,
566 ): array {
567 $filterMapping = $this->getMapping($collection);
568 $mapping = $filterMapping;
569 $indexBy = $mapping->indexBy();
570 $id = $this->uow->getEntityIdentifier($collection->getOwner());
571 $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity);
572 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
573
574 if (! $mapping->isOwningSide()) {
575 assert($mapping instanceof InverseSideMapping);
576 $associationSourceClass = $this->em->getClassMetadata($mapping->targetEntity);
577 $mapping = $associationSourceClass->associationMappings[$mapping->mappedBy];
578 assert($mapping->isManyToManyOwningSide());
579 $joinColumns = $mapping->joinTable->joinColumns;
580 $sourceRelationMode = 'relationToTargetKeyColumns';
581 $targetRelationMode = 'relationToSourceKeyColumns';
582 } else {
583 assert($mapping->isManyToManyOwningSide());
584 $associationSourceClass = $this->em->getClassMetadata($mapping->sourceEntity);
585 $joinColumns = $mapping->joinTable->inverseJoinColumns;
586 $sourceRelationMode = 'relationToSourceKeyColumns';
587 $targetRelationMode = 'relationToTargetKeyColumns';
588 }
589
590 $quotedJoinTable = $this->quoteStrategy->getJoinTableName($mapping, $associationSourceClass, $this->platform) . ' t';
591 $whereClauses = [];
592 $params = [];
593 $types = [];
594
595 $joinNeeded = ! in_array($indexBy, $targetClass->identifier, true);
596
597 if ($joinNeeded) { // extra join needed if indexBy is not a @id
598 $joinConditions = [];
599
600 foreach ($joinColumns as $joinTableColumn) {
601 $joinConditions[] = 't.' . $joinTableColumn->name . ' = tr.' . $joinTableColumn->referencedColumnName;
602 }
603
604 $tableName = $this->quoteStrategy->getTableName($targetClass, $this->platform);
605 $quotedJoinTable .= ' JOIN ' . $tableName . ' tr ON ' . implode(' AND ', $joinConditions);
606 $columnName = $targetClass->getColumnName($indexBy);
607
608 $whereClauses[] = 'tr.' . $columnName . ' = ?';
609 $params[] = $key;
610 $types[] = PersisterHelper::getTypeOfColumn($columnName, $targetClass, $this->em);
611 }
612
613 foreach ($mapping->joinTableColumns as $joinTableColumn) {
614 if (isset($mapping->{$sourceRelationMode}[$joinTableColumn])) {
615 $column = $mapping->{$sourceRelationMode}[$joinTableColumn];
616 $whereClauses[] = 't.' . $joinTableColumn . ' = ?';
617 $params[] = $sourceClass->containsForeignIdentifier
618 ? $id[$sourceClass->getFieldForColumn($column)]
619 : $id[$sourceClass->fieldNames[$column]];
620 $types[] = PersisterHelper::getTypeOfColumn($column, $sourceClass, $this->em);
621 } elseif (! $joinNeeded) {
622 $column = $mapping->{$targetRelationMode}[$joinTableColumn];
623
624 $whereClauses[] = 't.' . $joinTableColumn . ' = ?';
625 $params[] = $key;
626 $types[] = PersisterHelper::getTypeOfColumn($column, $targetClass, $this->em);
627 }
628 }
629
630 if ($addFilters) {
631 [$joinTargetEntitySQL, $filterSql] = $this->getFilterSql($filterMapping);
632
633 if ($filterSql) {
634 $quotedJoinTable .= ' ' . $joinTargetEntitySQL;
635 $whereClauses[] = $filterSql;
636 }
637 }
638
639 return [$quotedJoinTable, $whereClauses, $params, $types];
640 }
641
642 /**
643 * @param bool $addFilters Whether the filter SQL should be included or not.
644 *
645 * @return mixed[] ordered vector:
646 * - quoted join table name
647 * - where clauses to be added for filtering
648 * - parameters to be bound for filtering
649 * - types of the parameters to be bound for filtering
650 * @psalm-return array{0: string, 1: list<string>, 2: list<mixed>, 3: list<string>}
651 */
652 private function getJoinTableRestrictions(
653 PersistentCollection $collection,
654 object $element,
655 bool $addFilters,
656 ): array {
657 $filterMapping = $this->getMapping($collection);
658 $mapping = $filterMapping;
659
660 if (! $mapping->isOwningSide()) {
661 $sourceClass = $this->em->getClassMetadata($mapping->targetEntity);
662 $targetClass = $this->em->getClassMetadata($mapping->sourceEntity);
663 $sourceId = $this->uow->getEntityIdentifier($element);
664 $targetId = $this->uow->getEntityIdentifier($collection->getOwner());
665 } else {
666 $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity);
667 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
668 $sourceId = $this->uow->getEntityIdentifier($collection->getOwner());
669 $targetId = $this->uow->getEntityIdentifier($element);
670 }
671
672 $mapping = $this->em->getMetadataFactory()->getOwningSide($mapping);
673
674 $quotedJoinTable = $this->quoteStrategy->getJoinTableName($mapping, $sourceClass, $this->platform);
675 $whereClauses = [];
676 $params = [];
677 $types = [];
678
679 foreach ($mapping->joinTableColumns as $joinTableColumn) {
680 $whereClauses[] = ($addFilters ? 't.' : '') . $joinTableColumn . ' = ?';
681
682 if (isset($mapping->relationToTargetKeyColumns[$joinTableColumn])) {
683 $targetColumn = $mapping->relationToTargetKeyColumns[$joinTableColumn];
684 $params[] = $targetId[$targetClass->getFieldForColumn($targetColumn)];
685 $types[] = PersisterHelper::getTypeOfColumn($targetColumn, $targetClass, $this->em);
686
687 continue;
688 }
689
690 // relationToSourceKeyColumns
691 $targetColumn = $mapping->relationToSourceKeyColumns[$joinTableColumn];
692 $params[] = $sourceId[$sourceClass->getFieldForColumn($targetColumn)];
693 $types[] = PersisterHelper::getTypeOfColumn($targetColumn, $sourceClass, $this->em);
694 }
695
696 if ($addFilters) {
697 $quotedJoinTable .= ' t';
698
699 [$joinTargetEntitySQL, $filterSql] = $this->getFilterSql($filterMapping);
700
701 if ($filterSql) {
702 $quotedJoinTable .= ' ' . $joinTargetEntitySQL;
703 $whereClauses[] = $filterSql;
704 }
705 }
706
707 return [$quotedJoinTable, $whereClauses, $params, $types];
708 }
709
710 /**
711 * Expands Criteria Parameters by walking the expressions and grabbing all
712 * parameters and types from it.
713 *
714 * @return mixed[][]
715 */
716 private function expandCriteriaParameters(Criteria $criteria): array
717 {
718 $expression = $criteria->getWhereExpression();
719
720 if ($expression === null) {
721 return [];
722 }
723
724 $valueVisitor = new SqlValueVisitor();
725
726 $valueVisitor->dispatch($expression);
727
728 [, $types] = $valueVisitor->getParamsAndTypes();
729
730 return $types;
731 }
732
733 private function getOrderingSql(Criteria $criteria, ClassMetadata $targetClass): string
734 {
735 $orderings = $criteria->orderings();
736 if ($orderings) {
737 $orderBy = [];
738 foreach ($orderings as $name => $direction) {
739 $field = $this->quoteStrategy->getColumnName(
740 $name,
741 $targetClass,
742 $this->platform,
743 );
744 $orderBy[] = $field . ' ' . $direction->value;
745 }
746
747 return ' ORDER BY ' . implode(', ', $orderBy);
748 }
749
750 return '';
751 }
752
753 /** @throws DBALException */
754 private function getLimitSql(Criteria $criteria): string
755 {
756 $limit = $criteria->getMaxResults();
757 $offset = $criteria->getFirstResult();
758
759 return $this->platform->modifyLimitQuery('', $limit, $offset ?? 0);
760 }
761
762 private function getMapping(PersistentCollection $collection): AssociationMapping&ManyToManyAssociationMapping
763 {
764 $mapping = $collection->getMapping();
765
766 assert($mapping instanceof ManyToManyAssociationMapping);
767
768 return $mapping;
769 }
770}
diff --git a/vendor/doctrine/orm/src/Persisters/Collection/OneToManyPersister.php b/vendor/doctrine/orm/src/Persisters/Collection/OneToManyPersister.php
new file mode 100644
index 0000000..0727b1f
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/Collection/OneToManyPersister.php
@@ -0,0 +1,264 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Collection;
6
7use BadMethodCallException;
8use Doctrine\Common\Collections\Criteria;
9use Doctrine\DBAL\Exception as DBALException;
10use Doctrine\DBAL\Types\Type;
11use Doctrine\ORM\EntityNotFoundException;
12use Doctrine\ORM\Mapping\MappingException;
13use Doctrine\ORM\Mapping\OneToManyAssociationMapping;
14use Doctrine\ORM\PersistentCollection;
15use Doctrine\ORM\Utility\PersisterHelper;
16
17use function array_reverse;
18use function array_values;
19use function assert;
20use function implode;
21use function is_int;
22use function is_string;
23
24/**
25 * Persister for one-to-many collections.
26 */
27class OneToManyPersister extends AbstractCollectionPersister
28{
29 public function delete(PersistentCollection $collection): void
30 {
31 // The only valid case here is when you have weak entities. In this
32 // scenario, you have @OneToMany with orphanRemoval=true, and replacing
33 // the entire collection with a new would trigger this operation.
34 $mapping = $this->getMapping($collection);
35
36 if (! $mapping->orphanRemoval) {
37 // Handling non-orphan removal should never happen, as @OneToMany
38 // can only be inverse side. For owning side one to many, it is
39 // required to have a join table, which would classify as a ManyToManyPersister.
40 return;
41 }
42
43 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
44
45 $targetClass->isInheritanceTypeJoined()
46 ? $this->deleteJoinedEntityCollection($collection)
47 : $this->deleteEntityCollection($collection);
48 }
49
50 public function update(PersistentCollection $collection): void
51 {
52 // This can never happen. One to many can only be inverse side.
53 // For owning side one to many, it is required to have a join table,
54 // then classifying it as a ManyToManyPersister.
55 return;
56 }
57
58 public function get(PersistentCollection $collection, mixed $index): object|null
59 {
60 $mapping = $this->getMapping($collection);
61
62 if (! $mapping->isIndexed()) {
63 throw new BadMethodCallException('Selecting a collection by index is only supported on indexed collections.');
64 }
65
66 $persister = $this->uow->getEntityPersister($mapping->targetEntity);
67
68 return $persister->load(
69 [
70 $mapping->mappedBy => $collection->getOwner(),
71 $mapping->indexBy() => $index,
72 ],
73 null,
74 $mapping,
75 [],
76 null,
77 1,
78 );
79 }
80
81 public function count(PersistentCollection $collection): int
82 {
83 $mapping = $this->getMapping($collection);
84 $persister = $this->uow->getEntityPersister($mapping->targetEntity);
85
86 // only works with single id identifier entities. Will throw an
87 // exception in Entity Persisters if that is not the case for the
88 // 'mappedBy' field.
89 $criteria = new Criteria(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner()));
90
91 return $persister->count($criteria);
92 }
93
94 /**
95 * {@inheritDoc}
96 */
97 public function slice(PersistentCollection $collection, int $offset, int|null $length = null): array
98 {
99 $mapping = $this->getMapping($collection);
100 $persister = $this->uow->getEntityPersister($mapping->targetEntity);
101
102 return $persister->getOneToManyCollection($mapping, $collection->getOwner(), $offset, $length);
103 }
104
105 public function containsKey(PersistentCollection $collection, mixed $key): bool
106 {
107 $mapping = $this->getMapping($collection);
108
109 if (! $mapping->isIndexed()) {
110 throw new BadMethodCallException('Selecting a collection by index is only supported on indexed collections.');
111 }
112
113 $persister = $this->uow->getEntityPersister($mapping->targetEntity);
114
115 // only works with single id identifier entities. Will throw an
116 // exception in Entity Persisters if that is not the case for the
117 // 'mappedBy' field.
118 $criteria = new Criteria();
119
120 $criteria->andWhere(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner()));
121 $criteria->andWhere(Criteria::expr()->eq($mapping->indexBy(), $key));
122
123 return (bool) $persister->count($criteria);
124 }
125
126 public function contains(PersistentCollection $collection, object $element): bool
127 {
128 if (! $this->isValidEntityState($element)) {
129 return false;
130 }
131
132 $mapping = $this->getMapping($collection);
133 $persister = $this->uow->getEntityPersister($mapping->targetEntity);
134
135 // only works with single id identifier entities. Will throw an
136 // exception in Entity Persisters if that is not the case for the
137 // 'mappedBy' field.
138 $criteria = new Criteria(Criteria::expr()->eq($mapping->mappedBy, $collection->getOwner()));
139
140 return $persister->exists($element, $criteria);
141 }
142
143 /**
144 * {@inheritDoc}
145 */
146 public function loadCriteria(PersistentCollection $collection, Criteria $criteria): array
147 {
148 throw new BadMethodCallException('Filtering a collection by Criteria is not supported by this CollectionPersister.');
149 }
150
151 /**
152 * @throws DBALException
153 * @throws EntityNotFoundException
154 * @throws MappingException
155 */
156 private function deleteEntityCollection(PersistentCollection $collection): int
157 {
158 $mapping = $this->getMapping($collection);
159 $identifier = $this->uow->getEntityIdentifier($collection->getOwner());
160 $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity);
161 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
162 $columns = [];
163 $parameters = [];
164 $types = [];
165
166 foreach ($this->em->getMetadataFactory()->getOwningSide($mapping)->joinColumns as $joinColumn) {
167 $columns[] = $this->quoteStrategy->getJoinColumnName($joinColumn, $targetClass, $this->platform);
168 $parameters[] = $identifier[$sourceClass->getFieldForColumn($joinColumn->referencedColumnName)];
169 $types[] = PersisterHelper::getTypeOfColumn($joinColumn->referencedColumnName, $sourceClass, $this->em);
170 }
171
172 $statement = 'DELETE FROM ' . $this->quoteStrategy->getTableName($targetClass, $this->platform)
173 . ' WHERE ' . implode(' = ? AND ', $columns) . ' = ?';
174
175 if ($targetClass->isInheritanceTypeSingleTable()) {
176 $discriminatorColumn = $targetClass->getDiscriminatorColumn();
177 $statement .= ' AND ' . $discriminatorColumn->name . ' = ?';
178 $parameters[] = $targetClass->discriminatorValue;
179 $types[] = $discriminatorColumn->type;
180 }
181
182 $numAffected = $this->conn->executeStatement($statement, $parameters, $types);
183
184 assert(is_int($numAffected));
185
186 return $numAffected;
187 }
188
189 /**
190 * Delete Class Table Inheritance entities.
191 * A temporary table is needed to keep IDs to be deleted in both parent and child class' tables.
192 *
193 * Thanks Steve Ebersole (Hibernate) for idea on how to tackle reliably this scenario, we owe him a beer! =)
194 *
195 * @throws DBALException
196 */
197 private function deleteJoinedEntityCollection(PersistentCollection $collection): int
198 {
199 $mapping = $this->getMapping($collection);
200 $sourceClass = $this->em->getClassMetadata($mapping->sourceEntity);
201 $targetClass = $this->em->getClassMetadata($mapping->targetEntity);
202 $rootClass = $this->em->getClassMetadata($targetClass->rootEntityName);
203
204 // 1) Build temporary table DDL
205 $tempTable = $this->platform->getTemporaryTableName($rootClass->getTemporaryIdTableName());
206 $idColumnNames = $rootClass->getIdentifierColumnNames();
207 $idColumnList = implode(', ', $idColumnNames);
208 $columnDefinitions = [];
209
210 foreach ($idColumnNames as $idColumnName) {
211 $columnDefinitions[$idColumnName] = [
212 'name' => $idColumnName,
213 'notnull' => true,
214 'type' => Type::getType(PersisterHelper::getTypeOfColumn($idColumnName, $rootClass, $this->em)),
215 ];
216 }
217
218 $statement = $this->platform->getCreateTemporaryTableSnippetSQL() . ' ' . $tempTable
219 . ' (' . $this->platform->getColumnDeclarationListSQL($columnDefinitions) . ')';
220
221 $this->conn->executeStatement($statement);
222
223 // 2) Build insert table records into temporary table
224 $query = $this->em->createQuery(
225 ' SELECT t0.' . implode(', t0.', $rootClass->getIdentifierFieldNames())
226 . ' FROM ' . $targetClass->name . ' t0 WHERE t0.' . $mapping->mappedBy . ' = :owner',
227 )->setParameter('owner', $collection->getOwner());
228
229 $sql = $query->getSQL();
230 assert(is_string($sql));
231 $statement = 'INSERT INTO ' . $tempTable . ' (' . $idColumnList . ') ' . $sql;
232 $parameters = array_values($sourceClass->getIdentifierValues($collection->getOwner()));
233 $numDeleted = $this->conn->executeStatement($statement, $parameters);
234
235 // 3) Delete records on each table in the hierarchy
236 $classNames = [...$targetClass->parentClasses, ...[$targetClass->name], ...$targetClass->subClasses];
237
238 foreach (array_reverse($classNames) as $className) {
239 $tableName = $this->quoteStrategy->getTableName($this->em->getClassMetadata($className), $this->platform);
240 $statement = 'DELETE FROM ' . $tableName . ' WHERE (' . $idColumnList . ')'
241 . ' IN (SELECT ' . $idColumnList . ' FROM ' . $tempTable . ')';
242
243 $this->conn->executeStatement($statement);
244 }
245
246 // 4) Drop temporary table
247 $statement = $this->platform->getDropTemporaryTableSQL($tempTable);
248
249 $this->conn->executeStatement($statement);
250
251 assert(is_int($numDeleted));
252
253 return $numDeleted;
254 }
255
256 private function getMapping(PersistentCollection $collection): OneToManyAssociationMapping
257 {
258 $mapping = $collection->getMapping();
259
260 assert($mapping->isOneToMany());
261
262 return $mapping;
263 }
264}
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
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Entity;
6
7use Doctrine\DBAL\Types\Type;
8use Doctrine\ORM\Mapping\ClassMetadata;
9
10use 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 */
17abstract 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
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Entity;
6
7use BackedEnum;
8use Doctrine\Common\Collections\Criteria;
9use Doctrine\Common\Collections\Expr\Comparison;
10use Doctrine\Common\Collections\Order;
11use Doctrine\DBAL\ArrayParameterType;
12use Doctrine\DBAL\Connection;
13use Doctrine\DBAL\LockMode;
14use Doctrine\DBAL\ParameterType;
15use Doctrine\DBAL\Platforms\AbstractPlatform;
16use Doctrine\DBAL\Result;
17use Doctrine\DBAL\Types\Type;
18use Doctrine\DBAL\Types\Types;
19use Doctrine\ORM\EntityManagerInterface;
20use Doctrine\ORM\Mapping\AssociationMapping;
21use Doctrine\ORM\Mapping\ClassMetadata;
22use Doctrine\ORM\Mapping\JoinColumnMapping;
23use Doctrine\ORM\Mapping\ManyToManyAssociationMapping;
24use Doctrine\ORM\Mapping\MappingException;
25use Doctrine\ORM\Mapping\OneToManyAssociationMapping;
26use Doctrine\ORM\Mapping\QuoteStrategy;
27use Doctrine\ORM\OptimisticLockException;
28use Doctrine\ORM\PersistentCollection;
29use Doctrine\ORM\Persisters\Exception\CantUseInOperatorOnCompositeKeys;
30use Doctrine\ORM\Persisters\Exception\InvalidOrientation;
31use Doctrine\ORM\Persisters\Exception\UnrecognizedField;
32use Doctrine\ORM\Persisters\SqlExpressionVisitor;
33use Doctrine\ORM\Persisters\SqlValueVisitor;
34use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver;
35use Doctrine\ORM\Query;
36use Doctrine\ORM\Query\QueryException;
37use Doctrine\ORM\Query\ResultSetMapping;
38use Doctrine\ORM\Repository\Exception\InvalidFindByCall;
39use Doctrine\ORM\UnitOfWork;
40use Doctrine\ORM\Utility\IdentifierFlattener;
41use Doctrine\ORM\Utility\LockSqlHelper;
42use Doctrine\ORM\Utility\PersisterHelper;
43use LengthException;
44
45use function array_combine;
46use function array_keys;
47use function array_map;
48use function array_merge;
49use function array_search;
50use function array_unique;
51use function array_values;
52use function assert;
53use function count;
54use function implode;
55use function is_array;
56use function is_object;
57use function reset;
58use function spl_object_id;
59use function sprintf;
60use function str_contains;
61use function strtoupper;
62use 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 */
99class 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
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Entity;
6
7use Doctrine\ORM\Query\ResultSetMapping;
8use 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 */
19class 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
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Entity;
6
7use Doctrine\Common\Collections\Criteria;
8use Doctrine\DBAL\ArrayParameterType;
9use Doctrine\DBAL\LockMode;
10use Doctrine\DBAL\ParameterType;
11use Doctrine\ORM\Mapping\AssociationMapping;
12use Doctrine\ORM\Mapping\ClassMetadata;
13use Doctrine\ORM\Mapping\MappingException;
14use Doctrine\ORM\PersistentCollection;
15use Doctrine\ORM\Query\ResultSetMapping;
16
17/**
18 * Entity persister interface
19 * Define the behavior that should be implemented by all entity persisters.
20 */
21interface 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
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Entity;
6
7use Doctrine\Common\Collections\Criteria;
8use Doctrine\DBAL\LockMode;
9use Doctrine\DBAL\Types\Type;
10use Doctrine\DBAL\Types\Types;
11use Doctrine\ORM\Internal\SQLResultCasing;
12use Doctrine\ORM\Mapping\AssociationMapping;
13use Doctrine\ORM\Mapping\ClassMetadata;
14use Doctrine\ORM\Utility\LockSqlHelper;
15use Doctrine\ORM\Utility\PersisterHelper;
16use LengthException;
17
18use function array_combine;
19use function array_keys;
20use function array_values;
21use 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 */
29class 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
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Entity;
6
7use Doctrine\Common\Collections\Criteria;
8use Doctrine\ORM\Internal\SQLResultCasing;
9use Doctrine\ORM\Mapping\AssociationMapping;
10use Doctrine\ORM\Mapping\ClassMetadata;
11use Doctrine\ORM\Utility\PersisterHelper;
12
13use function array_flip;
14use function array_intersect;
15use function array_map;
16use function array_unshift;
17use function implode;
18use 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 */
26class 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}
diff --git a/vendor/doctrine/orm/src/Persisters/Exception/CantUseInOperatorOnCompositeKeys.php b/vendor/doctrine/orm/src/Persisters/Exception/CantUseInOperatorOnCompositeKeys.php
new file mode 100644
index 0000000..5c91312
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/Exception/CantUseInOperatorOnCompositeKeys.php
@@ -0,0 +1,15 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Exception;
6
7use Doctrine\ORM\Exception\PersisterException;
8
9class CantUseInOperatorOnCompositeKeys extends PersisterException
10{
11 public static function create(): self
12 {
13 return new self("Can't use IN operator on entities that have composite keys.");
14 }
15}
diff --git a/vendor/doctrine/orm/src/Persisters/Exception/InvalidOrientation.php b/vendor/doctrine/orm/src/Persisters/Exception/InvalidOrientation.php
new file mode 100644
index 0000000..7532800
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/Exception/InvalidOrientation.php
@@ -0,0 +1,15 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Exception;
6
7use Doctrine\ORM\Exception\PersisterException;
8
9class InvalidOrientation extends PersisterException
10{
11 public static function fromClassNameAndField(string $className, string $field): self
12 {
13 return new self('Invalid order by orientation specified for ' . $className . '#' . $field);
14 }
15}
diff --git a/vendor/doctrine/orm/src/Persisters/Exception/UnrecognizedField.php b/vendor/doctrine/orm/src/Persisters/Exception/UnrecognizedField.php
new file mode 100644
index 0000000..be7303e
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/Exception/UnrecognizedField.php
@@ -0,0 +1,24 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters\Exception;
6
7use Doctrine\ORM\Exception\PersisterException;
8
9use function sprintf;
10
11final class UnrecognizedField extends PersisterException
12{
13 /** @deprecated Use {@see byFullyQualifiedName()} instead. */
14 public static function byName(string $field): self
15 {
16 return new self(sprintf('Unrecognized field: %s', $field));
17 }
18
19 /** @param class-string $className */
20 public static function byFullyQualifiedName(string $className, string $field): self
21 {
22 return new self(sprintf('Unrecognized field: %s::$%s', $className, $field));
23 }
24}
diff --git a/vendor/doctrine/orm/src/Persisters/MatchingAssociationFieldRequiresObject.php b/vendor/doctrine/orm/src/Persisters/MatchingAssociationFieldRequiresObject.php
new file mode 100644
index 0000000..4e7251e
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/MatchingAssociationFieldRequiresObject.php
@@ -0,0 +1,22 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters;
6
7use Doctrine\ORM\Exception\PersisterException;
8
9use function sprintf;
10
11final class MatchingAssociationFieldRequiresObject extends PersisterException
12{
13 public static function fromClassAndAssociation(string $class, string $associationName): self
14 {
15 return new self(sprintf(
16 'Cannot match on %s::%s with a non-object value. Matching objects by id is ' .
17 'not compatible with matching on an in-memory collection, which compares objects by reference.',
18 $class,
19 $associationName,
20 ));
21 }
22}
diff --git a/vendor/doctrine/orm/src/Persisters/PersisterException.php b/vendor/doctrine/orm/src/Persisters/PersisterException.php
new file mode 100644
index 0000000..0016472
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/PersisterException.php
@@ -0,0 +1,23 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters;
6
7use Doctrine\ORM\Exception\ORMException;
8use Exception;
9
10use function sprintf;
11
12class PersisterException extends Exception implements ORMException
13{
14 public static function matchingAssocationFieldRequiresObject(string $class, string $associationName): PersisterException
15 {
16 return new self(sprintf(
17 'Cannot match on %s::%s with a non-object value. Matching objects by id is ' .
18 'not compatible with matching on an in-memory collection, which compares objects by reference.',
19 $class,
20 $associationName,
21 ));
22 }
23}
diff --git a/vendor/doctrine/orm/src/Persisters/SqlExpressionVisitor.php b/vendor/doctrine/orm/src/Persisters/SqlExpressionVisitor.php
new file mode 100644
index 0000000..df60f6a
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/SqlExpressionVisitor.php
@@ -0,0 +1,79 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters;
6
7use Doctrine\Common\Collections\Expr\Comparison;
8use Doctrine\Common\Collections\Expr\CompositeExpression;
9use Doctrine\Common\Collections\Expr\ExpressionVisitor;
10use Doctrine\Common\Collections\Expr\Value;
11use Doctrine\ORM\Mapping\ClassMetadata;
12use Doctrine\ORM\Persisters\Entity\BasicEntityPersister;
13use RuntimeException;
14
15use function implode;
16use function in_array;
17use function is_object;
18
19/**
20 * Visit Expressions and generate SQL WHERE conditions from them.
21 */
22class SqlExpressionVisitor extends ExpressionVisitor
23{
24 public function __construct(
25 private readonly BasicEntityPersister $persister,
26 private readonly ClassMetadata $classMetadata,
27 ) {
28 }
29
30 /** Converts a comparison expression into the target query language output. */
31 public function walkComparison(Comparison $comparison): string
32 {
33 $field = $comparison->getField();
34 $value = $comparison->getValue()->getValue(); // shortcut for walkValue()
35
36 if (
37 isset($this->classMetadata->associationMappings[$field]) &&
38 $value !== null &&
39 ! is_object($value) &&
40 ! in_array($comparison->getOperator(), [Comparison::IN, Comparison::NIN], true)
41 ) {
42 throw MatchingAssociationFieldRequiresObject::fromClassAndAssociation(
43 $this->classMetadata->name,
44 $field,
45 );
46 }
47
48 return $this->persister->getSelectConditionStatementSQL($field, $value, null, $comparison->getOperator());
49 }
50
51 /**
52 * Converts a composite expression into the target query language output.
53 *
54 * @throws RuntimeException
55 */
56 public function walkCompositeExpression(CompositeExpression $expr): string
57 {
58 $expressionList = [];
59
60 foreach ($expr->getExpressionList() as $child) {
61 $expressionList[] = $this->dispatch($child);
62 }
63
64 return match ($expr->getType()) {
65 CompositeExpression::TYPE_AND => '(' . implode(' AND ', $expressionList) . ')',
66 CompositeExpression::TYPE_OR => '(' . implode(' OR ', $expressionList) . ')',
67 CompositeExpression::TYPE_NOT => 'NOT (' . $expressionList[0] . ')',
68 default => throw new RuntimeException('Unknown composite ' . $expr->getType()),
69 };
70 }
71
72 /**
73 * Converts a value expression into the target query language part.
74 */
75 public function walkValue(Value $value): string
76 {
77 return '?';
78 }
79}
diff --git a/vendor/doctrine/orm/src/Persisters/SqlValueVisitor.php b/vendor/doctrine/orm/src/Persisters/SqlValueVisitor.php
new file mode 100644
index 0000000..7f987ad
--- /dev/null
+++ b/vendor/doctrine/orm/src/Persisters/SqlValueVisitor.php
@@ -0,0 +1,88 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Doctrine\ORM\Persisters;
6
7use Doctrine\Common\Collections\Expr\Comparison;
8use Doctrine\Common\Collections\Expr\CompositeExpression;
9use Doctrine\Common\Collections\Expr\ExpressionVisitor;
10use Doctrine\Common\Collections\Expr\Value;
11
12/**
13 * Extract the values from a criteria/expression
14 */
15class SqlValueVisitor extends ExpressionVisitor
16{
17 /** @var mixed[] */
18 private array $values = [];
19
20 /** @var mixed[][] */
21 private array $types = [];
22
23 /**
24 * Converts a comparison expression into the target query language output.
25 *
26 * {@inheritDoc}
27 */
28 public function walkComparison(Comparison $comparison)
29 {
30 $value = $this->getValueFromComparison($comparison);
31
32 $this->values[] = $value;
33 $this->types[] = [$comparison->getField(), $value, $comparison->getOperator()];
34
35 return null;
36 }
37
38 /**
39 * Converts a composite expression into the target query language output.
40 *
41 * {@inheritDoc}
42 */
43 public function walkCompositeExpression(CompositeExpression $expr)
44 {
45 foreach ($expr->getExpressionList() as $child) {
46 $this->dispatch($child);
47 }
48
49 return null;
50 }
51
52 /**
53 * Converts a value expression into the target query language part.
54 *
55 * {@inheritDoc}
56 */
57 public function walkValue(Value $value)
58 {
59 return null;
60 }
61
62 /**
63 * Returns the Parameters and Types necessary for matching the last visited expression.
64 *
65 * @return mixed[][]
66 * @psalm-return array{0: array, 1: array<array<mixed>>}
67 */
68 public function getParamsAndTypes(): array
69 {
70 return [$this->values, $this->types];
71 }
72
73 /**
74 * Returns the value from a Comparison. In case of a CONTAINS comparison,
75 * the value is wrapped in %-signs, because it will be used in a LIKE clause.
76 */
77 protected function getValueFromComparison(Comparison $comparison): mixed
78 {
79 $value = $comparison->getValue()->getValue();
80
81 return match ($comparison->getOperator()) {
82 Comparison::CONTAINS => '%' . $value . '%',
83 Comparison::STARTS_WITH => $value . '%',
84 Comparison::ENDS_WITH => '%' . $value,
85 default => $value,
86 };
87 }
88}