Skip to content
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

Draft: Datetime composite key #11598

Open
wants to merge 12 commits into
base: 2.19.x
Choose a base branch
from
8 changes: 4 additions & 4 deletions docs/en/tutorials/composite-primary-keys.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ General Considerations
Every entity with a composite key cannot use an id generator other than "NONE". That means
the ID fields have to have their values set before you call ``EntityManager#persist($entity)``.

Primitive Types only
~~~~~~~~~~~~~~~~~~~~
Allowed Types
~~~~~~~~~~~~~

You can have composite keys as long as they only consist of the primitive types
``integer`` and ``string``. Suppose you want to create a database of cars and use the model-name
and year of production as primary keys:
``integer`` and ``string`` or object of ``DateTimeImmutable``. Suppose you want to create a database of cars and use
the model-name and year of production as primary keys:

.. configuration-block::

Expand Down
23 changes: 22 additions & 1 deletion src/Cache/EntityCacheKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@

namespace Doctrine\ORM\Cache;

use function array_map;
use function implode;
use function is_scalar;
use function ksort;
use function md5;
use function serialize;
use function str_replace;
use function strtolower;

Expand Down Expand Up @@ -43,6 +47,23 @@ public function __construct($entityClass, array $identifier)
$this->identifier = $identifier;
$this->entityClass = $entityClass;

parent::__construct(str_replace('\\', '.', strtolower($entityClass) . '_' . implode(' ', $identifier)));
parent::__construct(
str_replace(
'\\',
'.',
strtolower($entityClass) . '_' . $this->serializeIdentifier($identifier)
)
);
}

/** @param array<int|string|object> $identifier */
private function serializeIdentifier(array $identifier): string
{
return implode(' ', array_map(
static function ($id) {
return is_scalar($id) ? $id : md5(serialize($id));
},
$identifier
));
}
}
10 changes: 10 additions & 0 deletions src/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Doctrine\ORM;

use BackedEnum;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
Expand Down Expand Up @@ -1785,6 +1787,10 @@ final public static function getIdHashByIdentifier(array $identifier): string
if ($value instanceof BackedEnum) {
$identifier[$k] = $value->value;
}

if ($value instanceof DateTimeImmutable) {
$identifier[$k] = $value->format(DateTime::ATOM);
}
}

return implode(
Expand Down Expand Up @@ -3048,6 +3054,10 @@ public function createEntity($className, array $data, &$hints = [])
$joinColumnValue = $joinColumnValue->value;
}

if ($joinColumnValue instanceof DateTimeImmutable) {
$joinColumnValue = $joinColumnValue->format(DateTime::ATOM);
}

if ($targetClass->containsForeignIdentifier) {
$associatedId[$targetClass->getFieldForColumn($targetColumn)] = $joinColumnValue;
} else {
Expand Down
4 changes: 4 additions & 0 deletions src/Utility/IdentifierFlattener.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Doctrine\ORM\Utility;

use BackedEnum;
use DateTime;
use DateTimeImmutable;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\UnitOfWork;
use Doctrine\Persistence\Mapping\ClassMetadataFactory;
Expand Down Expand Up @@ -79,6 +81,8 @@ public function flattenIdentifier(ClassMetadata $class, array $id): array
} else {
if ($id[$field] instanceof BackedEnum) {
$flatId[$field] = $id[$field]->value;
} elseif ($id[$field] instanceof DateTimeImmutable) {
$flatId[$field] = $id[$field]->format(DateTime::ATOM);
} else {
$flatId[$field] = $id[$field];
}
Expand Down
83 changes: 83 additions & 0 deletions tests/Doctrine/Tests/Models/DateTimeCompositeKey/Article.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\Models\DateTimeCompositeKey;

use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\OneToMany;

#[Entity]
class Article
{
#[Id]
#[Column]
#[GeneratedValue]
private int|null $id = null;
#[Column]
private string $title;

#[Column]
private string $content;

/** @var Collection<int, ArticleAudit> */
#[OneToMany(targetEntity: ArticleAudit::class, mappedBy: 'article', cascade: ['ALL'])]
private Collection $audit;

public function __construct(string $title, string $content)
{
$this->title = $title;
$this->content = $content;
$this->audit = new ArrayCollection();
}

public function changeTitle(string $newTitle): void
{
$this->title = $newTitle;
$this->updateAudit('title');
}

public function changeContent(string $newContent): void
{
$this->content = $newContent;
$this->updateAudit('content');
}

public function getId(): ?int
{
return $this->id;
}

/**
* @return Collection<int, ArticleAudit>
*/
public function getAudit(): Collection
{
return $this->audit;
}

public function getTitle(): string
{
return $this->title;
}

public function getContent(): string
{
return $this->content;
}

private function updateAudit(string $changedKey): void
{
$this->audit[] = new ArticleAudit(
new DateTimeImmutable(),
$changedKey,
$this
);
}
}
49 changes: 49 additions & 0 deletions tests/Doctrine/Tests/Models/DateTimeCompositeKey/ArticleAudit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\Models\DateTimeCompositeKey;

use DateTimeImmutable;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\ManyToOne;

#[Entity]
class ArticleAudit
{
#[Id]
#[ManyToOne(targetEntity: Article::class, inversedBy: 'audit')]
private Article $article;

#[Id]
#[Column]
private DateTimeImmutable $issuedAt;

#[Id]
#[Column]
private string $changedKey;

public function __construct(DateTimeImmutable $issuedAt, string $changedKey, Article $article)
{
$this->issuedAt = $issuedAt;
$this->changedKey = $changedKey;
$this->article = $article;
}

public function getArticle(): Article
{
return $this->article;
}

public function getIssuedAt(): DateTimeImmutable
{
return $this->issuedAt;
}

public function getChangedKey(): string
{
return $this->changedKey;
}
}
43 changes: 35 additions & 8 deletions tests/Tests/ORM/Cache/CacheKeyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Doctrine\Tests\ORM\Cache;

use DateTimeImmutable;
use Doctrine\Deprecations\PHPUnit\VerifyDeprecations;
use Doctrine\ORM\Cache\CacheKey;
use Doctrine\ORM\Cache\CollectionCacheKey;
Expand All @@ -15,19 +16,19 @@ class CacheKeyTest extends DoctrineTestCase
{
use VerifyDeprecations;

public function testEntityCacheKeyIdentifierCollision(): void
/**
* @dataProvider collisionEntityCacheKeyDataProvider
*/
public function testEntityCacheKeyIdentifierCollision(EntityCacheKey $key1, EntityCacheKey $key2): void
{
$key1 = new EntityCacheKey('Foo', ['id' => 1]);
$key2 = new EntityCacheKey('Bar', ['id' => 1]);

self::assertNotEquals($key1->hash, $key2->hash);
}

public function testEntityCacheKeyIdentifierType(): void
/**
* @dataProvider equalEntityCacheKeyDataProvider
*/
public function testEntityCacheKeyIdentifierType(EntityCacheKey $key1, EntityCacheKey $key2): void
{
$key1 = new EntityCacheKey('Foo', ['id' => 1]);
$key2 = new EntityCacheKey('Foo', ['id' => '1']);

self::assertEquals($key1->hash, $key2->hash);
}

Expand Down Expand Up @@ -94,4 +95,30 @@ public function __construct()

self::assertSame('my-hash', $key->hash);
}

public function collisionEntityCacheKeyDataProvider(): iterable
{
yield [
new EntityCacheKey('Foo', ['id' => 1]),
new EntityCacheKey('Bar', ['id' => 1]),
];

yield [
new EntityCacheKey('Foo', ['id' => 1, 'dt' => new DateTimeImmutable('2022-01-03')]),
new EntityCacheKey('Bar', ['id' => 1, 'dt' => new DateTimeImmutable('2022-01-03')]),
];
}

public function equalEntityCacheKeyDataProvider(): iterable
{
yield [
new EntityCacheKey('Foo', ['id' => 1]),
new EntityCacheKey('Foo', ['id' => '1']),
];

yield [
new EntityCacheKey('Foo', ['id' => 1, 'dt' => new DateTimeImmutable('2022-01-03')]),
new EntityCacheKey('Foo', ['id' => '1', 'dt' => new DateTimeImmutable('2022-01-03')]),
];
}
}
33 changes: 33 additions & 0 deletions tests/Tests/ORM/UnitOfWorkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Doctrine\Tests\ORM;

use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventManager;
Expand Down Expand Up @@ -933,6 +934,38 @@ public function testRemovedEntityIsRemovedFromOneToManyCollection(): void
self::assertEmpty($user->phonenumbers->getSnapshot());
}

/**
* @dataProvider identifierValuesDataProvider
*/
public function testIdentifierHashGetter(array $givenIds, string $expectedHash): void
{
$hash = UnitOfWork::getIdHashByIdentifier($givenIds);

self::assertSame($expectedHash, $hash);
}

public static function identifierValuesDataProvider(): iterable
{
return [
[
[13],
'13',
],
[
[14, 'test'],
'14 test',
],
[
[
13,
'a2bd21fe-56fa-45a4-acc1-db4d2d93e49d',
new DateTimeImmutable('2012-12-21 21:21:00'),
],
'13 a2bd21fe-56fa-45a4-acc1-db4d2d93e49d 2012-12-21T21:21:00+00:00',
],
];
}

public function testItTriggersADeprecationNoticeWhenApplicationProvidedIdsCollide(): void
{
// We're using application-provided IDs and assign the same ID twice
Expand Down
Loading
Loading