From 9aa8d556b16112c8df7b99b5f4cb084efdbb3fa0 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Thu, 9 Oct 2025 14:37:09 +0200 Subject: [PATCH 1/2] Experiment: Use comparator functions from a global registry for changeset detection --- src/ComparatorRegistry.php | 46 ++++++++++++ src/UnitOfWork.php | 5 ++ tests/Tests/ORM/ComparatorRegistryTest.php | 52 +++++++++++++ ...aratorRegistryBasedChangeDetectionTest.php | 75 +++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 src/ComparatorRegistry.php create mode 100644 tests/Tests/ORM/ComparatorRegistryTest.php create mode 100644 tests/Tests/ORM/Functional/ComparatorRegistryBasedChangeDetectionTest.php diff --git a/src/ComparatorRegistry.php b/src/ComparatorRegistry.php new file mode 100644 index 0000000000..4a26274171 --- /dev/null +++ b/src/ComparatorRegistry.php @@ -0,0 +1,46 @@ + */ + private static array $callbacks = []; + + /** + * @template T of object + * @param class-string $class + * @param callable(T, object): ?int + */ + public static function register(string $class, callable $callback): void + { + self::$callbacks[$class] = $callback; + } + + public static function reset(): void + { + self::$callbacks = []; + } + + public static function compare(object $a, object $b): ?int + { + foreach (self::$callbacks as $class => $callback) { + if (is_a($a, $class, false)) { + $result = $callback($a, $b); + + if ($result !== null) { + return $result; + } + } + if (is_a($b, $class, false)) { + $result = $callback($b, $a); + + if ($result !== null) { + return -$result; + } + } + } + + return null; + } +} diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 4c55b72877..10acf6d12d 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -683,6 +683,11 @@ public function computeChangeSet(ClassMetadata $class, object $entity): void continue; } + // skip if Comparator from registry tells us that objects represent equal values + if (is_object($orgValue) && is_object($actualValue) && ComparatorRegistry::compare($orgValue, $actualValue) === 0) { + continue; + } + // if regular field if (! isset($class->associationMappings[$propName])) { $changeSet[$propName] = [$orgValue, $actualValue]; diff --git a/tests/Tests/ORM/ComparatorRegistryTest.php b/tests/Tests/ORM/ComparatorRegistryTest.php new file mode 100644 index 0000000000..efa7900866 --- /dev/null +++ b/tests/Tests/ORM/ComparatorRegistryTest.php @@ -0,0 +1,52 @@ + $b; + } + }); + + $nowMutable = new \DateTime(); + $nowImmutable = \DateTimeImmutable::createFromMutable($nowMutable); + $yesterdayMutable = new \DateTime('yesterday'); + $yesterdayImmutable = \DateTimeImmutable::createFromMutable($yesterdayMutable); + + self::assertSame(null, ComparatorRegistry::compare($nowMutable, new \stdClass())); + + self::assertSame(0, ComparatorRegistry::compare($nowMutable, $nowMutable)); + self::assertSame(0, ComparatorRegistry::compare($nowMutable, $nowImmutable)); + self::assertSame(0, ComparatorRegistry::compare($nowImmutable, $nowMutable)); + self::assertSame(0, ComparatorRegistry::compare($nowImmutable, $nowImmutable)); + + self::assertSame(1, ComparatorRegistry::compare($nowMutable, $yesterdayMutable)); + self::assertSame(1, ComparatorRegistry::compare($nowImmutable, $yesterdayMutable)); + self::assertSame(1, ComparatorRegistry::compare($nowMutable, $yesterdayImmutable)); + self::assertSame(1, ComparatorRegistry::compare($nowImmutable, $yesterdayImmutable)); + + self::assertSame(-1, ComparatorRegistry::compare($yesterdayMutable, $nowMutable)); + self::assertSame(-1, ComparatorRegistry::compare($yesterdayMutable, $nowImmutable)); + self::assertSame(-1, ComparatorRegistry::compare($yesterdayImmutable, $nowMutable)); + self::assertSame(-1, ComparatorRegistry::compare($yesterdayImmutable, $nowImmutable)); + } +} diff --git a/tests/Tests/ORM/Functional/ComparatorRegistryBasedChangeDetectionTest.php b/tests/Tests/ORM/Functional/ComparatorRegistryBasedChangeDetectionTest.php new file mode 100644 index 0000000000..a5ce4a963f --- /dev/null +++ b/tests/Tests/ORM/Functional/ComparatorRegistryBasedChangeDetectionTest.php @@ -0,0 +1,75 @@ +setUpEntitySchema([UserTyped::class]); + } + + protected function tearDown(): void + { + ComparatorRegistry::reset(); + } + + public function testChangingDateTimeInstanceWithoutComparator(): void + { + $user = new UserTyped(); + $user->dateTime = new \DateTime(); + $this->initializeChangesetState($user); + + $user->dateTime = clone $user->dateTime; + $this->recomputeChangeset($user); + + self::assertTrue($this->_em->getUnitOfWork()->isScheduledForUpdate($user)); + } + + public function testChangingDateTimeInstanceWithComparator(): void + { + ComparatorRegistry::register(\DateTimeInterface::class, function (\DateTimeInterface $a, object $b) { + if ($b instanceof \DateTimeInterface) { + return $a <=> $b; + } + }); + + $user = new UserTyped(); + $user->dateTime = new \DateTime(); + $this->initializeChangesetState($user); + + $user->dateTime = clone $user->dateTime; + $this->recomputeChangeset($user); + + self::assertFalse($this->_em->getUnitOfWork()->isScheduledForUpdate($user)); + } + + private function initializeChangesetState(object $entity): void + { + // Initialize UoW state + $this->_em->persist($entity); + $cm = $this->_em->getClassMetadata(get_class($entity)); + $this->_em->getUnitOfWork()->computeChangeSet($cm, $entity); + + // Run change set computation with no changes + $this->_em->getUnitOfWork()->computeChangeSet($cm, $entity); + + // sanity check + self::assertFalse($this->_em->getUnitOfWork()->isScheduledForUpdate($entity)); + } + + private function recomputeChangeset(object $entity): void + { + $cm = $this->_em->getClassMetadata(get_class($entity)); + $this->_em->getUnitOfWork()->computeChangeSet($cm, $entity); + } +} From ea8748480ea6cd56f65649b20d5a1193c43664bf Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Thu, 9 Oct 2025 15:20:04 +0200 Subject: [PATCH 2/2] Add a test where a mutable object is mutated --- ...paratorRegistryBasedChangeDetectionTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Tests/ORM/Functional/ComparatorRegistryBasedChangeDetectionTest.php b/tests/Tests/ORM/Functional/ComparatorRegistryBasedChangeDetectionTest.php index a5ce4a963f..2ef71138f1 100644 --- a/tests/Tests/ORM/Functional/ComparatorRegistryBasedChangeDetectionTest.php +++ b/tests/Tests/ORM/Functional/ComparatorRegistryBasedChangeDetectionTest.php @@ -53,6 +53,24 @@ public function testChangingDateTimeInstanceWithComparator(): void self::assertFalse($this->_em->getUnitOfWork()->isScheduledForUpdate($user)); } + public function testChangingMutableObject(): void + { + ComparatorRegistry::register(\DateTimeInterface::class, function (\DateTimeInterface $a, object $b) { + if ($b instanceof \DateTimeInterface) { + return $a <=> $b; + } + }); + + $user = new UserTyped(); + $user->dateTime = new \DateTime(); + $this->initializeChangesetState($user); + + $user->dateTime->add(new \DateInterval('P7D')); + $this->recomputeChangeset($user); + + self::assertTrue($this->_em->getUnitOfWork()->isScheduledForUpdate($user)); + } + private function initializeChangesetState(object $entity): void { // Initialize UoW state