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

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