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;
+ }
+}