diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index e5e887a5838..44b252e2956 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -119,7 +119,7 @@ jobs: - "default" - "3@dev" postgres-version: - - "15" + - "17" extension: - pdo_pgsql - pgsql @@ -381,7 +381,7 @@ jobs: path: "reports" - name: "Upload to Codecov" - uses: "codecov/codecov-action@v4" + uses: "codecov/codecov-action@v5" with: directory: reports env: diff --git a/docs/en/reference/unitofwork.rst b/docs/en/reference/unitofwork.rst index 6d429ec01e8..9c18fba0da7 100644 --- a/docs/en/reference/unitofwork.rst +++ b/docs/en/reference/unitofwork.rst @@ -102,7 +102,7 @@ How Doctrine Detects Changes ---------------------------- Doctrine is a data-mapper that tries to achieve persistence-ignorance (PI). -This means you map php objects into a relational database that don't +This means you map PHP objects into a relational database that don't necessarily know about the database at all. A natural question would now be, "how does Doctrine even detect objects have changed?". diff --git a/docs/en/tutorials/extra-lazy-associations.rst b/docs/en/tutorials/extra-lazy-associations.rst index fbff96f428b..fbf1f00abd6 100644 --- a/docs/en/tutorials/extra-lazy-associations.rst +++ b/docs/en/tutorials/extra-lazy-associations.rst @@ -18,6 +18,7 @@ can be called without triggering a full load of the collection: - ``Collection#containsKey($key)`` - ``Collection#count()`` - ``Collection#get($key)`` +- ``Collection#isEmpty()`` - ``Collection#slice($offset, $length = null)`` For each of the above methods the following semantics apply: diff --git a/psalm.xml b/psalm.xml index 19b4e9368ca..b9e2421fdfd 100644 --- a/psalm.xml +++ b/psalm.xml @@ -50,6 +50,7 @@ + diff --git a/src/Events.php b/src/Events.php index e8f123e488b..4695a7fb772 100644 --- a/src/Events.php +++ b/src/Events.php @@ -103,16 +103,14 @@ private function __construct() * The onFlush event occurs when the EntityManager#flush() operation is invoked, * after any changes to managed entities have been determined but before any * actual database operations are executed. The event is only raised if there is - * actually something to do for the underlying UnitOfWork. If nothing needs to be done, - * the onFlush event is not raised. + * actually something to do for the underlying UnitOfWork. */ public const onFlush = 'onFlush'; /** * The postFlush event occurs when the EntityManager#flush() operation is invoked and * after all actual database operations are executed successfully. The event is only raised if there is - * actually something to do for the underlying UnitOfWork. If nothing needs to be done, - * the postFlush event is not raised. The event won't be raised if an error occurs during the + * actually something to do for the underlying UnitOfWork. The event won't be raised if an error occurs during the * flush operation. */ public const postFlush = 'postFlush'; diff --git a/src/Persisters/Entity/BasicEntityPersister.php b/src/Persisters/Entity/BasicEntityPersister.php index 5ca00cb007e..9bd8afd3cc1 100644 --- a/src/Persisters/Entity/BasicEntityPersister.php +++ b/src/Persisters/Entity/BasicEntityPersister.php @@ -199,6 +199,9 @@ class BasicEntityPersister implements EntityPersister /** @var CachedPersisterContext */ private $noLimitsContext; + /** @var ?string */ + private $filterHash = null; + /** * Initializes a new BasicEntityPersister that uses the given EntityManager * and persists instances of the class described by the given ClassMetadata descriptor. @@ -1271,7 +1274,7 @@ final protected function getOrderBySQL(array $orderBy, string $baseTableAlias): */ protected function getSelectColumnsSQL() { - if ($this->currentPersisterContext->selectColumnListSql !== null) { + if ($this->currentPersisterContext->selectColumnListSql !== null && $this->filterHash === $this->em->getFilters()->getHash()) { return $this->currentPersisterContext->selectColumnListSql; } @@ -1378,6 +1381,7 @@ protected function getSelectColumnsSQL() } $this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList); + $this->filterHash = $this->em->getFilters()->getHash(); return $this->currentPersisterContext->selectColumnListSql; } diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 2969a3e0e0f..0f775910d83 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -2473,13 +2473,13 @@ private function doRefresh($entity, array &$visited, ?int $lockMode = null): voi throw ORMInvalidArgumentException::entityNotManaged($entity); } + $this->cascadeRefresh($entity, $visited, $lockMode); + $this->getEntityPersister($class->name)->refresh( array_combine($class->getIdentifierFieldNames(), $this->entityIdentifiers[$oid]), $entity, $lockMode ); - - $this->cascadeRefresh($entity, $visited, $lockMode); } /** diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/LazyEagerCollectionTest.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/LazyEagerCollectionTest.php new file mode 100644 index 00000000000..926e0523153 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/LazyEagerCollectionTest.php @@ -0,0 +1,161 @@ +createSchemaForModels( + LazyEagerCollectionUser::class, + LazyEagerCollectionAddress::class, + LazyEagerCollectionPhone::class + ); + } + + public function testRefreshRefreshesBothLazyAndEagerCollections(): void + { + $user = new LazyEagerCollectionUser(); + $user->data = 'Guilherme'; + + $ph = new LazyEagerCollectionPhone(); + $ph->data = '12345'; + $user->addPhone($ph); + + $ad = new LazyEagerCollectionAddress(); + $ad->data = '6789'; + $user->addAddress($ad); + + $this->_em->persist($user); + $this->_em->persist($ad); + $this->_em->persist($ph); + $this->_em->flush(); + $this->_em->clear(); + + $user = $this->_em->find(LazyEagerCollectionUser::class, $user->id); + $ph = $user->phones[0]; + $ad = $user->addresses[0]; + + $ph->data = 'abc'; + $ad->data = 'def'; + + $this->_em->refresh($user); + + self::assertSame('12345', $ph->data); + self::assertSame('6789', $ad->data); + } +} + +/** + * @Entity + */ +class LazyEagerCollectionUser +{ + /** + * @var int + * @Id + * @Column(type="integer") + * @GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @var string + * @Column(type="string", length=255) + */ + public $data; + + /** + * @ORM\OneToMany(targetEntity="LazyEagerCollectionPhone", cascade={"refresh"}, fetch="EAGER", mappedBy="user") + * + * @var LazyEagerCollectionPhone[] + */ + public $phones; + + /** + * @ORM\OneToMany(targetEntity="LazyEagerCollectionAddress", cascade={"refresh"}, mappedBy="user") + * + * @var LazyEagerCollectionAddress[] + */ + public $addresses; + + public function __construct() + { + $this->addresses = new ArrayCollection(); + $this->phones = new ArrayCollection(); + } + + public function addPhone(LazyEagerCollectionPhone $phone): void + { + $phone->user = $this; + $this->phones[] = $phone; + } + + public function addAddress(LazyEagerCollectionAddress $address): void + { + $address->user = $this; + $this->addresses[] = $address; + } +} + +/** @Entity */ +class LazyEagerCollectionPhone +{ + /** + * @var int + * @Id + * @Column(type="integer") + * @GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @var string + * @Column(type="string", length=255) + */ + public $data; + + /** + * @ORM\ManyToOne(targetEntity="LazyEagerCollectionUser", inversedBy="phones") + * + * @var LazyEagerCollectionUser + */ + public $user; +} + +/** @Entity */ +class LazyEagerCollectionAddress +{ + /** + * @var int + * @Id + * @Column(type="integer") + * @GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @var string + * @Column(type="string", length=255) + */ + public $data; + + /** + * @ORM\ManyToOne(targetEntity="LazyEagerCollectionUser", inversedBy="addresses") + * + * @var LazyEagerCollectionUser + */ + public $user; +} diff --git a/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/ChangeFiltersTest.php b/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/ChangeFiltersTest.php new file mode 100644 index 00000000000..7ce97442b28 --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/ChangeFiltersTest.php @@ -0,0 +1,142 @@ +setUpEntitySchema([ + Order::class, + User::class, + ]); + } + + /** + * @return non-empty-array<"companyA"|"companyB", array{orderId: int, userId: int}> + */ + private function prepareData(): array + { + $user1 = new User(self::COMPANY_A); + $order1 = new Order($user1); + $user2 = new User(self::COMPANY_B); + $order2 = new Order($user2); + + $this->_em->persist($user1); + $this->_em->persist($order1); + $this->_em->persist($user2); + $this->_em->persist($order2); + $this->_em->flush(); + $this->_em->clear(); + + return [ + 'companyA' => ['orderId' => $order1->id, 'userId' => $user1->id], + 'companyB' => ['orderId' => $order2->id, 'userId' => $user2->id], + ]; + } + + public function testUseEnableDisableFilter(): void + { + $this->_em->getConfiguration()->addFilter(CompanySQLFilter::class, CompanySQLFilter::class); + $this->_em->getFilters()->enable(CompanySQLFilter::class)->setParameter('company', self::COMPANY_A); + + ['companyA' => $companyA, 'companyB' => $companyB] = $this->prepareData(); + + $order1 = $this->_em->find(Order::class, $companyA['orderId']); + + self::assertNotNull($order1->user, $this->generateMessage('Order1->User1 not found')); + self::assertEquals($companyA['userId'], $order1->user->id, $this->generateMessage('Order1->User1 != User1')); + + $this->_em->getFilters()->disable(CompanySQLFilter::class); + $this->_em->getFilters()->enable(CompanySQLFilter::class)->setParameter('company', self::COMPANY_B); + + $order2 = $this->_em->find(Order::class, $companyB['orderId']); + + self::assertNotNull($order2->user, $this->generateMessage('Order2->User2 not found')); + self::assertEquals($companyB['userId'], $order2->user->id, $this->generateMessage('Order2->User2 != User2')); + } + + public function testUseChangeFilterParameters(): void + { + $this->_em->getConfiguration()->addFilter(CompanySQLFilter::class, CompanySQLFilter::class); + $filter = $this->_em->getFilters()->enable(CompanySQLFilter::class); + + ['companyA' => $companyA, 'companyB' => $companyB] = $this->prepareData(); + + $filter->setParameter('company', self::COMPANY_A); + + $order1 = $this->_em->find(Order::class, $companyA['orderId']); + + self::assertNotNull($order1->user, $this->generateMessage('Order1->User1 not found')); + self::assertEquals($companyA['userId'], $order1->user->id, $this->generateMessage('Order1->User1 != User1')); + + $filter->setParameter('company', self::COMPANY_B); + + $order2 = $this->_em->find(Order::class, $companyB['orderId']); + + self::assertNotNull($order2->user, $this->generateMessage('Order2->User2 not found')); + self::assertEquals($companyB['userId'], $order2->user->id, $this->generateMessage('Order2->User2 != User2')); + } + + public function testUseQueryBuilder(): void + { + $this->_em->getConfiguration()->addFilter(CompanySQLFilter::class, CompanySQLFilter::class); + $filter = $this->_em->getFilters()->enable(CompanySQLFilter::class); + + ['companyA' => $companyA, 'companyB' => $companyB] = $this->prepareData(); + + $getOrderByIdCache = function (int $orderId): ?Order { + return $this->_em->createQueryBuilder() + ->select('orderMaster, user') + ->from(Order::class, 'orderMaster') + ->innerJoin('orderMaster.user', 'user') + ->where('orderMaster.id = :orderId') + ->setParameter('orderId', $orderId) + ->setCacheable(true) + ->getQuery() + ->setQueryCacheLifetime(10) + ->getOneOrNullResult(); + }; + + $filter->setParameter('company', self::COMPANY_A); + + $order = $getOrderByIdCache($companyB['orderId']); + self::assertNull($order); + + $order = $getOrderByIdCache($companyA['orderId']); + + self::assertInstanceOf(Order::class, $order); + self::assertInstanceOf(User::class, $order->user); + self::assertEquals($companyA['userId'], $order->user->id); + + $filter->setParameter('company', self::COMPANY_B); + + $order = $getOrderByIdCache($companyA['orderId']); + self::assertNull($order); + + $order = $getOrderByIdCache($companyB['orderId']); + + self::assertInstanceOf(Order::class, $order); + self::assertInstanceOf(User::class, $order->user); + self::assertEquals($companyB['userId'], $order->user->id); + } + + private function generateMessage(string $message): string + { + $log = $this->getLastLoggedQuery(); + + return sprintf("%s\nSQL: %s", $message, str_replace(['?'], (array) $log['params'], $log['sql'])); + } +} diff --git a/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/CompanySQLFilter.php b/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/CompanySQLFilter.php new file mode 100644 index 00000000000..e65188334ac --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/CompanySQLFilter.php @@ -0,0 +1,26 @@ +getName() === User::class) { + return sprintf('%s.%s = %s', $targetTableAlias, $targetEntity->fieldMappings['company']['fieldName'], $this->getParameter('company')); + } + + if ($targetEntity->getName() === Order::class) { + return sprintf('%s.%s = %s', $targetTableAlias, $targetEntity->fieldMappings['company']['fieldName'], $this->getParameter('company')); + } + + return ''; + } +} diff --git a/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/Order.php b/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/Order.php new file mode 100644 index 00000000000..a6d86dca8a2 --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/Order.php @@ -0,0 +1,43 @@ +user = $user; + $this->company = $user->company; + } +} diff --git a/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/User.php b/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/User.php new file mode 100644 index 00000000000..294bfdf87aa --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/User.php @@ -0,0 +1,35 @@ +company = $company; + } +}