Skip to content

Commit d589dbc

Browse files
authored
Add doctrine ObjectRegistry that remember object identities (#70)
* Introduce a doctrine ObjectRegistry that caches found objects during jobs * Fixed PHPStan issues * Fixed tests * Documented ObjectRegistry related * Fixed PHPStan issue with generic repository in closure * Fixed code style * Fixed code style * Add test for edge case when class is not a managed entity
1 parent c71176e commit d589dbc

8 files changed

+449
-30
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ composer require yokai/batch-doctrine-persistence
2525
This package provides:
2626

2727
- an [item writer](docs/object-item-writer.md) that persists objects through object manager
28+
- an [object registry](docs/object-registry.md) that remembers found objects identities
2829

2930

3031
## Contribution

docs/object-registry.md

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Object registry
2+
3+
Imagine that in an `ItemJob` you need to find objects from a database.
4+
5+
```php
6+
use App\Entity\Product;
7+
use Doctrine\Persistence\ObjectRepository;
8+
use Yokai\Batch\Job\Item\ItemProcessorInterface;
9+
10+
class DenormalizeProductProcessor implements ItemProcessorInterface
11+
{
12+
public function __construct(
13+
private ObjectRepository $repository,
14+
) {
15+
}
16+
17+
/**
18+
* @param array<string, mixed> $item
19+
*/
20+
public function process(mixed $item): Product
21+
{
22+
$product = $this->repository->findOneBy(['sku' => $item['sku']]);
23+
24+
$product ??= new Product($item['sku']);
25+
26+
$product->setName($item['name']);
27+
$product->setPrice($item['price']);
28+
//...
29+
30+
return $product;
31+
}
32+
}
33+
```
34+
35+
The problem here is that every time you will call `findOneBy`, you will have to query the database.
36+
The object might already be in Doctrine's memory, so it won't be hydrated twice, but the query will be done every time.
37+
38+
The role of the `ObjectRegistry` is to remember found objects identities, and query these objects with it instead.
39+
40+
```diff
41+
use App\Entity\Product;
42+
-use Doctrine\Persistence\ObjectRepository;
43+
+use Yokai\Batch\Bridge\Doctrine\Persistence\ObjectRegistry;
44+
use Yokai\Batch\Job\Item\ItemProcessorInterface;
45+
46+
class DenormalizeProductProcessor implements ItemProcessorInterface
47+
{
48+
public function __construct(
49+
- private ObjectRepository $repository,
50+
+ private ObjectRegistry $registry,
51+
) {
52+
}
53+
54+
/**
55+
* @param array<string, mixed> $item
56+
*/
57+
public function process(mixed $item): Product
58+
{
59+
- $product = $this->repository->findOneBy(['sku' => $item['sku']]);
60+
+ $product = $this->registry->findOneBy(Product::class, ['sku' => $item['sku']]);
61+
62+
$product ??= new Product($item['sku']);
63+
64+
$product->setName($item['name']);
65+
$product->setPrice($item['price']);
66+
//...
67+
68+
return $product;
69+
}
70+
}
71+
```
72+
73+
The first time, the query will hit the database, and the object identity will be remembered in the registry.
74+
Everytime after that, the registry will call `Doctrine\Persistence\ObjectManager::find` instead.
75+
If the object is still in Doctrine's memory, it will be returned directly.
76+
Otherwise, the query will be the fastest possible because it will use the object identity.
77+
78+
79+
## On the same subject
80+
81+
- [What is an item job ?](https://github.com/yokai-php/batch/blob/0.x/docs/domain/item-job.md)

src/ObjectRegistry.php

+93
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\Bridge\Doctrine\Persistence;
6+
7+
use Doctrine\Persistence\ManagerRegistry;
8+
use Doctrine\Persistence\ObjectManager;
9+
use Doctrine\Persistence\ObjectRepository;
10+
use Yokai\Batch\Exception\InvalidArgumentException;
11+
12+
/**
13+
* This class will remember objects identifies when found.
14+
* Using it as a proxy to your queries will simplify queries afterward.
15+
*/
16+
final class ObjectRegistry
17+
{
18+
/**
19+
* @var array<class-string, array<string, array<string, mixed>>>
20+
*/
21+
private array $identities = [];
22+
23+
public function __construct(
24+
private ManagerRegistry $doctrine,
25+
) {
26+
}
27+
28+
/**
29+
* Finds a single object by a set of criteria.
30+
*
31+
* @template T of object
32+
*
33+
* @param class-string<T> $class
34+
* @param array<string, mixed> $criteria
35+
*
36+
* @return T|null
37+
*/
38+
public function findOneBy(string $class, array $criteria): ?object
39+
{
40+
return $this->findOneUsing(
41+
$class,
42+
function ($repository) use ($criteria) {
43+
/** @var ObjectRepository<T> $repository */
44+
45+
return $repository->findOneBy($criteria);
46+
},
47+
serialize($criteria)
48+
);
49+
}
50+
51+
/**
52+
* Finds a single object by using a callback to find it.
53+
*
54+
* @template T of object
55+
*
56+
* @param class-string<T> $class
57+
* @param \Closure(ObjectRepository<T>, ObjectManager): (T|null) $closure
58+
*
59+
* @return T|null
60+
*/
61+
public function findOneUsing(string $class, \Closure $closure, string $key = null): ?object
62+
{
63+
$manager = $this->doctrine->getManagerForClass($class);
64+
if ($manager === null) {
65+
throw new InvalidArgumentException(sprintf('Class "%s" is not a managed Doctrine entity.', $class));
66+
}
67+
68+
$key ??= spl_object_hash($closure);
69+
$key = md5($key);
70+
71+
$identity = $this->identities[$class][$key] ?? null;
72+
if ($identity !== null) {
73+
return $manager->find($class, $identity);
74+
}
75+
76+
$object = $closure($manager->getRepository($class), $manager);
77+
78+
if ($object instanceof $class) {
79+
$this->identities[$class] ??= [];
80+
$this->identities[$class][$key] = $manager->getClassMetadata($class)->getIdentifierValues($object);
81+
}
82+
83+
return $object;
84+
}
85+
86+
/**
87+
* Removes all remembered identities of all classes.
88+
*/
89+
public function reset(): void
90+
{
91+
$this->identities = [];
92+
}
93+
}

tests/DoctrinePersistenceTestCase.php

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yokai\Batch\Tests\Bridge\Doctrine\Persistence;
6+
7+
use Doctrine\DBAL\DriverManager;
8+
use Doctrine\ORM\Configuration;
9+
use Doctrine\ORM\EntityManager;
10+
use Doctrine\ORM\EntityManagerInterface;
11+
use Doctrine\ORM\ORMSetup;
12+
use Doctrine\ORM\Tools\SchemaTool;
13+
use Doctrine\Persistence\ManagerRegistry;
14+
use PHPUnit\Framework\TestCase;
15+
use Yokai\Batch\Tests\Bridge\Doctrine\Persistence\Dummy\SimpleManagerRegistry;
16+
17+
abstract class DoctrinePersistenceTestCase extends TestCase
18+
{
19+
protected EntityManagerInterface $authManager;
20+
protected EntityManagerInterface $shopManager;
21+
protected ManagerRegistry $doctrine;
22+
23+
protected function setUp(): void
24+
{
25+
// It is important to have both attribute & annotation configurations because
26+
// otherwise Doctrine do not seem to be able to find which manager is responsible
27+
// to manage an entity or another.
28+
$authConfig = ORMSetup::createAttributeMetadataConfiguration([__DIR__ . '/Entity/Auth'], true);
29+
$shopConfig = ORMSetup::createAnnotationMetadataConfiguration([__DIR__ . '/Entity/Shop'], true);
30+
31+
$this->setUpConfigs($authConfig, $shopConfig);
32+
33+
$connection = DriverManager::getConnection(['url' => \getenv('DATABASE_URL')]);
34+
$this->authManager = new EntityManager($connection, $authConfig);
35+
$this->shopManager = new EntityManager($connection, $shopConfig);
36+
37+
$this->doctrine = new SimpleManagerRegistry(['auth' => $this->authManager, 'shop' => $this->shopManager]);
38+
39+
/** @var EntityManager $manager */
40+
foreach ($this->doctrine->getManagers() as $manager) {
41+
(new SchemaTool($manager))
42+
->createSchema($manager->getMetadataFactory()->getAllMetadata());
43+
}
44+
45+
$this->setUpFixtures();
46+
}
47+
48+
protected function setUpConfigs(Configuration $authConfig, Configuration $shopConfig): void
49+
{
50+
}
51+
52+
protected function setUpFixtures(): void
53+
{
54+
}
55+
}
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
/**
14+
* @var array<string, ObjectRepository>
15+
*/
16+
private array $repositories = [];
17+
18+
public function __construct(
19+
/**
20+
* @var class-string<ObjectRepository>
21+
*/
22+
private string $class,
23+
private RepositoryFactory $decorated,
24+
) {
25+
}
26+
27+
public function getRepository(EntityManagerInterface $entityManager, $entityName): ObjectRepository
28+
{
29+
return $this->repositories[$entityName] ??= new $this->class($this->decorated->getRepository($entityManager, $entityName));
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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(__FUNCTION__, 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+
$this->calls[$key] = true;
60+
}
61+
}

0 commit comments

Comments
 (0)