Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/ComparatorRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

Check failure on line 1 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

Missing declare(strict_types=1).

namespace Doctrine\ORM;

class ComparatorRegistry
{
/** @param array<class-string, callable> */

Check failure on line 7 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (default, phpstan.neon)

PHPDoc tag @param has invalid value (array<class-string, callable>): Unexpected token "*/", expected variable at offset 41 on line 1
private static array $callbacks = [];

Check failure on line 8 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (default, phpstan.neon)

Property Doctrine\ORM\ComparatorRegistry::$callbacks type has no value type specified in iterable type array.

Check failure on line 8 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

@var annotation of property \Doctrine\ORM\ComparatorRegistry::$callbacks does not specify type hint for its items.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public for fast access


/**
* @template T of object

Check failure on line 11 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

Incorrect annotations group.
* @param class-string<T> $class

Check failure on line 12 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

Expected 11 spaces after parameter type; 1 found
* @param callable(T, object): ?int

Check failure on line 13 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (default, phpstan.neon)

PHPDoc tag @param has invalid value (callable(T, object): ?int): Unexpected token "\n ", expected variable at offset 109 on line 4

Check failure on line 13 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

Missing parameter name
*/
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

Check failure on line 25 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

Usage of short nullable type hint in "?int" is disallowed.
{
foreach (self::$callbacks as $class => $callback) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not directly access the callback?

self::$callbacks[$a::class]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To support callbacks at a generic level, eg for \DateTimeInterface

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

Function is_a() should not be referenced via a fallback global name, but via a use statement.

Check failure on line 28 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

Expected 1 line after "if", found 0.
$result = $callback($a, $b);

if ($result !== null) {
return $result;
}
}
if (is_a($b, $class, false)) {

Check failure on line 35 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

Function is_a() should not be referenced via a fallback global name, but via a use statement.
$result = $callback($b, $a);

if ($result !== null) {
return -$result;

Check failure on line 39 in src/ComparatorRegistry.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (default, phpstan.neon)

Method Doctrine\ORM\ComparatorRegistry::compare() should return int|null but returns float|int.
}
}
}

return null;
}
}
5 changes: 5 additions & 0 deletions src/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (is_object($orgValue) && is_object($actualValue) && ComparatorRegistry::compare($orgValue, $actualValue) === 0) {
if (is_object($orgValue) && isset(ComparatorRegsitry::$calbacks[$orgValue::class]) && ComparatorRegistry::compare($orgValue, $actualValue) === 0) {

Copy link
Member

Choose a reason for hiding this comment

The 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];
Expand Down
52 changes: 52 additions & 0 deletions tests/Tests/ORM/ComparatorRegistryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

Check failure on line 1 in tests/Tests/ORM/ComparatorRegistryTest.php

View workflow job for this annotation

GitHub Actions / coding-standards / Coding Standards (PHP: 8.4)

Missing declare(strict_types=1).

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();
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During tests, to unregister them?

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
}
}
Loading