Skip to content

Commit 2a91635

Browse files
committed
Introduce a doctrine ObjectRegistry that caches found objects during jobs
1 parent 27cf73a commit 2a91635

File tree

6 files changed

+307
-30
lines changed

6 files changed

+307
-30
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yokai\Batch\Bridge\Doctrine\Persistence;
6+
7+
use Doctrine\Persistence\ManagerRegistry;
8+
use Doctrine\Persistence\ObjectRepository;
9+
use Doctrine\Persistence\ObjectManager;
10+
11+
final class ObjectRegistry
12+
{
13+
/**
14+
* @var array<class-string, array<string, mixed[]>>
15+
*/
16+
private array $identities = [];
17+
18+
public function __construct(
19+
private ManagerRegistry $doctrine,
20+
) {
21+
}
22+
23+
/**
24+
* @template T
25+
*
26+
* @param class-string<T> $class
27+
* @param array<string, mixed> $criteria
28+
*
29+
* @return T|null
30+
*/
31+
public function findOneBy(string $class, array $criteria): ?object
32+
{
33+
return $this->findOneUsing(
34+
$class,
35+
fn(ObjectRepository $repository) => $repository->findOneBy($criteria),
36+
serialize($criteria)
37+
);
38+
}
39+
40+
/**
41+
* @template T
42+
*
43+
* @param class-string<T> $class
44+
* @param \Closure(ObjectRepository=, ObjectManager=): ?T $closure
45+
* @param string|null $key
46+
*
47+
* @return T|null
48+
*/
49+
public function findOneUsing(string $class, \Closure $closure, string $key = null): ?object
50+
{
51+
$manager = $this->doctrine->getManagerForClass($class);
52+
53+
$key ??= spl_object_hash($closure);
54+
$key = md5($key);
55+
56+
$identity = $this->identities[$class][$key] ?? null;
57+
if ($identity !== null) {
58+
return $manager->find($class, $identity);
59+
}
60+
61+
$object = $closure($manager->getRepository($class), $manager);
62+
63+
if (is_object($object)) {
64+
$this->identities[$class] ??= [];
65+
$this->identities[$class][$key] = $manager->getClassMetadata($class)->getIdentifierValues($object);
66+
}
67+
68+
return $object;
69+
}
70+
71+
public function reset(): void
72+
{
73+
$this->identities = [];
74+
}
75+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yokai\Batch\Tests\Bridge\Doctrine\Persistence;
6+
7+
use Doctrine\ORM\Configuration;
8+
use Doctrine\ORM\EntityManager;
9+
use Doctrine\ORM\EntityManagerInterface;
10+
use Doctrine\ORM\ORMSetup;
11+
use Doctrine\ORM\Tools\SchemaTool;
12+
use Doctrine\Persistence\ManagerRegistry;
13+
use PHPUnit\Framework\TestCase;
14+
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Dummy\SimpleManagerRegistry;
15+
16+
abstract class DoctrinePersistenceTestCase extends TestCase
17+
{
18+
protected EntityManagerInterface $authManager;
19+
protected EntityManagerInterface $shopManager;
20+
protected ManagerRegistry $doctrine;
21+
22+
protected function setUp(): void
23+
{
24+
// It is important to have both attribute & annotation configurations because
25+
// otherwise Doctrine do not seem to be able to find which manager is responsible
26+
// to manage an entity or another.
27+
$authConfig = ORMSetup::createAttributeMetadataConfiguration([__DIR__ . '/Entity/Auth'], true);
28+
$shopConfig = ORMSetup::createAnnotationMetadataConfiguration([__DIR__ . '/Entity/Shop'], true);
29+
30+
$this->setUpConfigs($authConfig, $shopConfig);
31+
32+
$this->authManager = EntityManager::create(['url' => \getenv('DATABASE_URL')], $authConfig);
33+
$this->shopManager = EntityManager::create(['url' => \getenv('DATABASE_URL')], $shopConfig);
34+
35+
$this->doctrine = new SimpleManagerRegistry(['auth' => $this->authManager, 'shop' => $this->shopManager]);
36+
37+
/** @var EntityManager $manager */
38+
foreach ($this->doctrine->getManagers() as $manager) {
39+
(new SchemaTool($manager))
40+
->createSchema($manager->getMetadataFactory()->getAllMetadata());
41+
}
42+
43+
$this->setUpFixtures();
44+
}
45+
46+
protected function setUpConfigs(Configuration $authConfig, Configuration $shopConfig): void
47+
{
48+
}
49+
50+
protected function setUpFixtures(): void
51+
{
52+
}
53+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Dummy;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Doctrine\ORM\Repository\RepositoryFactory;
9+
use Doctrine\Persistence\ObjectRepository;
10+
11+
class DecoratedRepositoryFactory implements RepositoryFactory
12+
{
13+
public function __construct(
14+
/**
15+
* @var class-string<ObjectRepository>
16+
*/
17+
private string $class,
18+
private RepositoryFactory $decorated,
19+
) {
20+
}
21+
22+
public function getRepository(EntityManagerInterface $entityManager, $entityName): ObjectRepository
23+
{
24+
return new $this->class($this->decorated->getRepository($entityManager, $entityName));
25+
}
26+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Dummy;
6+
7+
use Doctrine\Persistence\ObjectRepository;
8+
9+
class FindOneByCalledOnlyOnceWhenFoundRepositoryDecorator implements ObjectRepository
10+
{
11+
private array $calls = [];
12+
13+
public function __construct(
14+
private ObjectRepository $decorated,
15+
) {
16+
}
17+
18+
public function find($id)
19+
{
20+
return $this->decorated->find($id);
21+
}
22+
23+
public function findAll()
24+
{
25+
return $this->decorated->findAll();
26+
}
27+
28+
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
29+
{
30+
return $this->decorated->findBy($criteria, $orderBy, $limit, $offset);
31+
}
32+
33+
public function findOneBy(array $criteria)
34+
{
35+
$result = $this->decorated->findOneBy($criteria);
36+
if ($result === null) {
37+
return null;
38+
}
39+
40+
$this->ensureNotCalledAlready(__METHOD__, func_get_args());
41+
42+
return $result;
43+
}
44+
45+
public function getClassName()
46+
{
47+
return $this->decorated->getClassName();
48+
}
49+
50+
private function ensureNotCalledAlready(string $method, array $args): void
51+
{
52+
$key = md5($method . $serializedArgs = serialize($args));
53+
if (isset($this->calls[$key])) {
54+
throw new \LogicException(
55+
'Method ' . $method . ' with args ' . $serializedArgs . ' has already been called'
56+
);
57+
}
58+
}
59+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yokai\Batch\Tests\Bridge\Doctrine\Persistence;
6+
7+
use Doctrine\ORM\Configuration;
8+
use Doctrine\Persistence\ObjectManager;
9+
use Doctrine\Persistence\ObjectRepository;
10+
use Yokai\Batch\Bridge\Doctrine\Persistence\ObjectRegistry;
11+
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Dummy\DecoratedRepositoryFactory;
12+
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Dummy\FindOneByCalledOnlyOnceWhenFoundRepositoryDecorator;
13+
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Entity\Auth\User;
14+
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Entity\Shop\Product;
15+
16+
class ObjectRegistryTest extends DoctrinePersistenceTestCase
17+
{
18+
private User $emmet;
19+
private User $lucy;
20+
private Product $galaxyExplorer;
21+
private Product $boutiqueHotel;
22+
23+
protected function setUpConfigs(Configuration $authConfig, Configuration $shopConfig): void
24+
{
25+
$shopConfig->setRepositoryFactory(
26+
new DecoratedRepositoryFactory(
27+
FindOneByCalledOnlyOnceWhenFoundRepositoryDecorator::class,
28+
$shopConfig->getRepositoryFactory()
29+
)
30+
);
31+
}
32+
33+
protected function setUpFixtures(): void
34+
{
35+
$this->authManager->persist($this->emmet = new User('Emmet'));
36+
$this->authManager->persist($this->lucy = new User('Lucy'));
37+
$this->authManager->flush();
38+
39+
$this->shopManager->persist($this->galaxyExplorer = new Product('Galaxy Explorer'));
40+
$this->shopManager->persist($this->boutiqueHotel = new Product('Boutique Hotel'));
41+
$this->shopManager->flush();
42+
}
43+
44+
public function testFindOneBy(): void
45+
{
46+
$registry = new ObjectRegistry($this->doctrine);
47+
48+
foreach ([1, 2] as $ignored) {
49+
self::assertSame($this->emmet, $registry->findOneBy(User::class, ['name' => 'Emmet']));
50+
self::assertSame($this->lucy, $registry->findOneBy(User::class, ['name' => 'Lucy']));
51+
self::assertNull($registry->findOneBy(User::class, ['name' => 'John']));
52+
53+
self::assertSame($this->galaxyExplorer, $registry->findOneBy(Product::class, ['name' => 'Galaxy Explorer']));
54+
self::assertSame($this->boutiqueHotel, $registry->findOneBy(Product::class, ['name' => 'Boutique Hotel']));
55+
self::assertNull($registry->findOneBy(Product::class, ['name' => 'Haunted House']));
56+
}
57+
}
58+
59+
public function testFindOneUsing(): void
60+
{
61+
$registry = new ObjectRegistry($this->doctrine);
62+
63+
$closureFactory = function (ObjectManager $expectedManager, string $expectedEntityClass, array $criteria) {
64+
return function (ObjectRepository $repository, ObjectManager $manager) use (
65+
$expectedManager,
66+
$expectedEntityClass,
67+
$criteria,
68+
) {
69+
self::assertSame($expectedManager, $manager);
70+
self::assertSame($expectedEntityClass, $repository->getClassName());
71+
72+
return $repository->findOneBy($criteria);
73+
};
74+
};
75+
76+
$emmetClosure = $closureFactory($this->authManager, User::class, ['name' => 'Emmet']);
77+
$lucyClosure = $closureFactory($this->shopManager, User::class, ['name' => 'Lucy']);
78+
$johnClosure = $closureFactory($this->shopManager, User::class, ['name' => 'John']);
79+
$galaxyExplorerClosure = $closureFactory($this->shopManager, Product::class, ['name' => 'Galaxy Explorer']);
80+
$boutiqueHotelClosure = $closureFactory($this->shopManager, Product::class, ['name' => 'Boutique Hotel']);
81+
$hauntedHouseClosure = $closureFactory($this->shopManager, Product::class, ['name' => 'Haunted House']);
82+
83+
foreach ([1, 2] as $ignored) {
84+
self::assertSame($this->emmet, $registry->findOneUsing(User::class, $emmetClosure));
85+
self::assertSame($this->lucy, $registry->findOneUsing(User::class, $lucyClosure));
86+
self::assertNull($registry->findOneUsing(User::class, $johnClosure));
87+
88+
self::assertSame($this->galaxyExplorer, $registry->findOneUsing(Product::class, $galaxyExplorerClosure));
89+
self::assertSame($this->boutiqueHotel, $registry->findOneUsing(Product::class, $boutiqueHotelClosure));
90+
self::assertNull($registry->findOneUsing(Product::class, $hauntedHouseClosure));
91+
}
92+
}
93+
}

src/batch-doctrine-persistence/tests/ObjectWriterTest.php

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,15 @@
44

55
namespace Yokai\Batch\Tests\Bridge\Doctrine\Persistence;
66

7-
use Doctrine\ORM\EntityManager;
8-
use Doctrine\ORM\ORMSetup;
9-
use Doctrine\ORM\Tools\SchemaTool;
10-
use Doctrine\Persistence\ManagerRegistry;
11-
use Doctrine\Persistence\ObjectManager;
12-
use PHPUnit\Framework\TestCase;
137
use Yokai\Batch\Bridge\Doctrine\Persistence\ObjectWriter;
148
use Yokai\Batch\Exception\InvalidArgumentException;
15-
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Dummy\SimpleManagerRegistry;
169
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Entity\Auth\Group;
1710
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Entity\Auth\User;
1811
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Entity\Shop\Product;
1912
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Entity\Unknown;
2013

21-
class ObjectWriterTest extends TestCase
14+
class ObjectWriterTest extends DoctrinePersistenceTestCase
2215
{
23-
private ObjectManager $authManager;
24-
private ObjectManager $shopManager;
25-
private ManagerRegistry $doctrine;
26-
27-
protected function setUp(): void
28-
{
29-
// It is important to have both attribute & annotation configurations because
30-
// otherwise Doctrine do not seem to be able to find which manager is responsible
31-
// to manager an entity or an other.
32-
$authConfig = ORMSetup::createAttributeMetadataConfiguration([__DIR__ . '/Entity/Auth'], true);
33-
$this->authManager = EntityManager::create(['url' => \getenv('DATABASE_URL')], $authConfig);
34-
$shopConfig = ORMSetup::createAnnotationMetadataConfiguration([__DIR__ . '/Entity/Shop'], true);
35-
$this->shopManager = EntityManager::create(['url' => \getenv('DATABASE_URL')], $shopConfig);
36-
$this->doctrine = new SimpleManagerRegistry(['auth' => $this->authManager, 'shop' => $this->shopManager]);
37-
38-
/** @var EntityManager $manager */
39-
foreach ($this->doctrine->getManagers() as $manager) {
40-
(new SchemaTool($manager))
41-
->createSchema($manager->getMetadataFactory()->getAllMetadata());
42-
}
43-
}
44-
4516
public function testWriteSingleManager(): void
4617
{
4718
$userCreated = new User('initialized');

0 commit comments

Comments
 (0)