perf(directory): memoize arcadia brand lookups to eliminate N+1 queries during INCO sheet hydration (F-2026-0003)

This commit is contained in:
Jérémy Munsch 2026-08-26 09:51:47 +02:00
parent 861a1a8146
commit 18b83b6e67
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
3 changed files with 124 additions and 9 deletions

View file

@ -12,27 +12,43 @@ use Infra\Database\Models\ArcadiaBrand as ArcadiaBrandModel;
final class EloquentArcadiaBrandDirectory implements ArcadiaBrandDirectory
{
/** @var array<string, ?ArcadiaBrand> */
private array $cache = [];
#[\Override]
public function findAll(): array
{
return array_values(
ArcadiaBrandModel::query()
->get()
->map(fn (ArcadiaBrandModel $model): ArcadiaBrand => $this->toDomain($model))
->all()
);
$models = ArcadiaBrandModel::query()->get();
$items = [];
foreach ($models as $model) {
$domain = $this->toDomain($model);
$this->cache[$domain->id->value] = $domain;
$items[] = $domain;
}
return $items;
}
#[\Override]
public function find(ArcadiaId $id): ?ArcadiaBrand
{
if (array_key_exists($id->value, $this->cache)) {
return $this->cache[$id->value];
}
$model = ArcadiaBrandModel::query()->find($id->value);
if ($model === null) {
$this->cache[$id->value] = null;
return null;
}
return $this->toDomain($model);
$domain = $this->toDomain($model);
$this->cache[$id->value] = $domain;
return $domain;
}
private function toDomain(ArcadiaBrandModel $model): ArcadiaBrand

View file

@ -25,6 +25,7 @@ use Infra\Adapters\Repositories\EloquentBrandRepository;
use Infra\Cache\BrandCache;
use Infra\Config\ArcadiaStorageConfig;
use Infra\Usecase\LaravelExecutor;
use Psr\Log\LoggerInterface;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Event\TransactionalDomainEventDispatcher;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
@ -38,14 +39,14 @@ class DomainServiceProvider extends ServiceProvider
$this->app->bind(IncoSheetDirectory::class, EloquentIncoSheetDirectory::class);
$this->app->bind(IncoShortSheetDirectory::class, EloquentIncoShortSheetDirectory::class);
$this->app->bind(IncoCompositionDirectory::class, EloquentIncoCompositionDirectory::class);
$this->app->bind(ArcadiaBrandDirectory::class, EloquentArcadiaBrandDirectory::class);
$this->app->scoped(ArcadiaBrandDirectory::class, EloquentArcadiaBrandDirectory::class);
$this->app->bind(BrandRepository::class, EloquentBrandRepository::class);
$this->app->bind(ClientAuditLogDirectory::class, EloquentClientAuditLogDirectory::class);
$this->app->bind(BrandCache::class, static function (Application $app): BrandCache {
$storageConfig = $app->make(ArcadiaStorageConfig::class);
$cache = Cache::store($storageConfig->cacheStore);
$logger = $app->make(\Psr\Log\LoggerInterface::class);
$logger = $app->make(LoggerInterface::class);
return new BrandCache($cache, $logger);
});

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Infra\Adapters\Directories;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand as ArcadiaBrandDomain;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Infra\Adapters\Directories\EloquentArcadiaBrandDirectory;
use Infra\Database\Models\ArcadiaBrand;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
covers(EloquentArcadiaBrandDirectory::class);
describe('EloquentArcadiaBrandDirectory Integration Tests', function (): void {
beforeEach(function (): void {
ArcadiaBrand::query()->delete();
ArcadiaBrand::query()->create(['code' => 1, 'libelle' => 'Le Gaulois']);
ArcadiaBrand::query()->create(['code' => 2, 'libelle' => 'Maître CoQ']);
});
it('finds an arcadia brand by ArcadiaId and memoizes the result in memory', function (): void {
// GIVEN
$directory = new EloquentArcadiaBrandDirectory();
$arcadiaId = new ArcadiaId('1');
// WHEN: First lookup (hits database)
$first = $directory->find($arcadiaId);
// THEN
expect($first)->toBeInstanceOf(ArcadiaBrandDomain::class)
->and($first?->id->value)->toBe('1')
->and($first?->name->value)->toBe('Le Gaulois');
// WHEN: Second lookup - DB query count should not increase because of in-memory caching
$queryCount = 0;
DB::listen(function () use (&$queryCount): void {
$queryCount++;
});
$second = $directory->find($arcadiaId);
// THEN: Second call returns the exact memoized entity with 0 extra queries
expect($second)->toBe($first)
->and($queryCount)->toBe(0);
});
it('returns null when arcadia brand is not found and caches the null result', function (): void {
// GIVEN
$directory = new EloquentArcadiaBrandDirectory();
$unknownId = new ArcadiaId('999');
// WHEN: First lookup for non-existent brand
$first = $directory->find($unknownId);
// THEN
expect($first)->toBeNull();
// WHEN: Second lookup
$queryCount = 0;
DB::listen(function () use (&$queryCount): void {
$queryCount++;
});
$second = $directory->find($unknownId);
// THEN
expect($second)->toBeNull()
->and($queryCount)->toBe(0);
});
it('finds all arcadia brands and populates the cache', function (): void {
// GIVEN
$directory = new EloquentArcadiaBrandDirectory();
// WHEN
$all = $directory->findAll();
// THEN
expect($all)->toHaveCount(2);
// WHEN: Calling find() on cached items
$queryCount = 0;
DB::listen(function () use (&$queryCount): void {
$queryCount++;
});
$brand1 = $directory->find(new ArcadiaId('1'));
// THEN
expect($brand1)->not->toBeNull()
->and($brand1?->name->value)->toBe('Le Gaulois')
->and($queryCount)->toBe(0);
});
});