vendor/doctrine/orm/src/Persisters/Entity/BasicEntityPersister.php line 1126

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