-
Notifications
You must be signed in to change notification settings - Fork 2.5k
Experiment: Use comparator functions from a global registry for changeset detection #12215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 3.6.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
<?php | ||
|
||
namespace Doctrine\ORM; | ||
|
||
class ComparatorRegistry | ||
{ | ||
/** @param array<class-string, callable> */ | ||
Check failure on line 7 in src/ComparatorRegistry.php
|
||
private static array $callbacks = []; | ||
Check failure on line 8 in src/ComparatorRegistry.php
|
||
|
||
/** | ||
* @template T of object | ||
* @param class-string<T> $class | ||
* @param callable(T, object): ?int | ||
Check failure on line 13 in src/ComparatorRegistry.php
|
||
*/ | ||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not directly access the callback? self::$callbacks[$a::class] There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To support callbacks at a generic level, eg for \DateTimeInterface There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Its too expensive for too little gain, in changeset computation we are already loooping classes, all their instances, and all their properties, are 3 levels deep in loops here. Adding another one should not be done lightly. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then we cannot have comparators registered for types like \DateTimeInterface in order to catch everything implementing that, need to register all the potential class names There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And apart from is_object checks, we add the overhead only when looking at field values that are objects and where the identity changed |
||
if (is_a($a, $class, false)) { | ||
Check failure on line 28 in src/ComparatorRegistry.php
|
||
$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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should do an isset before calling a function, that is much faster. |
||||||
continue; | ||||||
} | ||||||
|
||||||
// if regular field | ||||||
if (! isset($class->associationMappings[$propName])) { | ||||||
$changeSet[$propName] = [$orgValue, $actualValue]; | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
<?php | ||
|
||
namespace Doctrine\Tests\ORM; | ||
|
||
use Doctrine\ORM\ComparatorRegistry; | ||
use PHPUnit\Framework\Attributes\Test; | ||
use PHPUnit\Framework\TestCase; | ||
|
||
class ComparatorRegistryTest extends TestCase | ||
{ | ||
protected function setUp(): void | ||
{ | ||
ComparatorRegistry::reset(); | ||
} | ||
|
||
protected function tearDown(): void | ||
{ | ||
ComparatorRegistry::reset(); | ||
} | ||
|
||
#[Test] | ||
public function compareDateTimeTypes(): void | ||
{ | ||
ComparatorRegistry::register(\DateTimeInterface::class, function (\DateTimeInterface $a, object $b): ?int { | ||
if ($b instanceof \DateTimeInterface) { | ||
return $a <=> $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)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Doctrine\Tests\ORM\Functional; | ||
|
||
use Doctrine\ORM\ComparatorRegistry; | ||
use Doctrine\Tests\Models\TypedProperties\UserTyped; | ||
use Doctrine\Tests\OrmFunctionalTestCase; | ||
|
||
class ComparatorRegistryBasedChangeDetectionTest extends OrmFunctionalTestCase | ||
{ | ||
public function setUp(): void | ||
{ | ||
ComparatorRegistry::reset(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While i get the sentiment to think this should be resettable, the comparison functions for objects are mathemtics, and mathematics is universal, it should not be necessasry to configure, overwrite, or reset them. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. During tests, to unregister them? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe, but your tests always add exactly the same callback, and the resetting does not add value there either. If this were a core feature and classes would implement the interfaces and the method, you wouldn't be able to "unset" the class to use an interface and its implementation either, or replace it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not gonna fight for this one :) |
||
parent::setUp(); | ||
|
||
$this->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); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public for fast access