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..2ef71138f1 --- /dev/null +++ b/tests/Tests/ORM/Functional/ComparatorRegistryBasedChangeDetectionTest.php @@ -0,0 +1,93 @@ +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)); + } + + 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 + $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); + } +}