perf(brands): paginate ArcadiaBrandDirectory and BrandRepository with SQL limit/offset and cache warming

This commit is contained in:
Jérémy Munsch 2026-08-31 12:37:16 +02:00
parent 97a73a5e05
commit 742da323aa
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
10 changed files with 96 additions and 47 deletions

View file

@ -24,22 +24,17 @@ final readonly class FindAllBrands implements Usecase
{
Assert::isInstanceOf($input, Input::class);
$arcadiaBrands = $this->arcadiaBrandDirectory->findAll();
$brandItems = [];
$paginated = $this->arcadiaBrandDirectory->findAll($input->pagination);
$brandItems = [];
foreach ($arcadiaBrands as $arcadiaBrand) {
foreach ($paginated->getItems() as $arcadiaBrand) {
$brand = $this->brandResolver->resolve($arcadiaBrand->id);
$brandItems[] = BrandItem::fromDomain($brand);
}
$total = count($brandItems);
$offset = $input->pagination->from();
$limit = $input->pagination->pageSize();
$paged = array_values(array_slice($brandItems, $offset, $limit));
return new Output(
brands: $paged,
total: $total,
brands: $brandItems,
total: $paginated->getTotal(),
page: $input->pagination->pageNumber(),
perPage: $input->pagination->pageSize(),
);

View file

@ -7,12 +7,14 @@ namespace Domain\Ports\Directories;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Shared\DDD\Port\Directory;
use Shared\Paginated;
use Shared\Pagination;
/** @extends Directory<ArcadiaId, ArcadiaBrand> */
interface ArcadiaBrandDirectory extends Directory
{
/** @return list<ArcadiaBrand> */
public function findAll(): array;
/** @return Paginated<ArcadiaBrand> */
public function findAll(Pagination $pagination): Paginated;
public function find(ArcadiaId $id): ?ArcadiaBrand;
}

View file

@ -9,6 +9,8 @@ use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Shared\DDD\Port\Repository;
use Shared\Paginated;
use Shared\Pagination;
/** @extends Repository<BrandId, Brand> */
interface BrandRepository extends Repository
@ -19,8 +21,8 @@ interface BrandRepository extends Repository
public function findByArcadiaId(ArcadiaId $arcadiaId): ?Brand;
/** @return list<Brand> */
public function findAll(): array;
/** @return Paginated<Brand> */
public function findAll(Pagination $pagination): Paginated;
public function persist(Brand $entity): void;

View file

@ -11,6 +11,8 @@ use Domain\Ports\Directories\ArcadiaBrandDirectory as ArcadiaBrandDirectoryPort;
use Illuminate\Database\Connection;
use Illuminate\Database\Query\Builder;
use Infra\Adapters\Mappers\ArcadiaBrandMapper;
use Shared\Paginated;
use Shared\Pagination;
final class ArcadiaMarqueBrandDirectory implements ArcadiaBrandDirectoryPort
{
@ -23,9 +25,9 @@ final class ArcadiaMarqueBrandDirectory implements ArcadiaBrandDirectoryPort
}
#[\Override]
public function findAll(): array
public function findAll(Pagination $pagination): Paginated
{
$rows = $this->connection
$query = $this->connection
->table(MarqueTable::table($this->connection))
->where('COD_MARQ', '>', 0)
->where('LIB_MARQ', 'NOT LIKE', 'Z -%')
@ -34,7 +36,14 @@ final class ArcadiaMarqueBrandDirectory implements ArcadiaBrandDirectoryPort
})
->where(static function (Builder $q): void {
$q->whereNull('TYPE_MARQUE')->orWhere('TYPE_MARQUE', '!=', 99);
})
});
$total = (int) (clone $query)->count();
$rows = (clone $query)
->orderBy('COD_MARQ', 'asc')
->offset($pagination->from())
->limit($pagination->pageSize())
->get();
$items = [];
@ -45,7 +54,7 @@ final class ArcadiaMarqueBrandDirectory implements ArcadiaBrandDirectoryPort
$items[] = $domain;
}
return $items;
return new Paginated($pagination, $items, $total);
}
#[\Override]

View file

@ -7,6 +7,8 @@ namespace Infra\Adapters\Directories;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Ports\Directories\ArcadiaBrandDirectory;
use Shared\Paginated;
use Shared\Pagination;
final class InMemoryArcadiaBrandDirectory implements ArcadiaBrandDirectory
{
@ -28,12 +30,17 @@ final class InMemoryArcadiaBrandDirectory implements ArcadiaBrandDirectory
}
#[\Override]
public function findAll(): array
public function findAll(Pagination $pagination): Paginated
{
return array_values(array_filter(
$filtered = array_values(array_filter(
$this->brands,
static fn (ArcadiaBrand $b): bool => ! str_starts_with($b->name->value, 'Z -')
));
$total = count($filtered);
$paged = array_slice($filtered, $pagination->from(), $pagination->pageSize());
return new Paginated($pagination, $paged, $total);
}
#[\Override]

View file

@ -13,6 +13,8 @@ use Domain\Ports\Repositories\BrandRepository;
use Infra\Cache\BrandCache;
use Infra\Database\Models\Brand as BrandModel;
use Ramsey\Uuid\Uuid;
use Shared\Paginated;
use Shared\Pagination;
final readonly class EloquentBrandRepository implements BrandRepository
{
@ -86,23 +88,24 @@ final readonly class EloquentBrandRepository implements BrandRepository
}
#[\Override]
public function findAll(): array
public function findAll(Pagination $pagination): Paginated
{
$cached = $this->brandCache->getAll();
if ($cached !== null) {
return $cached;
}
$query = BrandModel::query()->orderBy('name', 'asc');
$total = $query->count();
$brands = array_values(
BrandModel::query()
->get()
->map(fn (BrandModel $model): Brand => $this->toDomain($model))
->all()
);
$brands = $query
->offset($pagination->from())
->limit($pagination->pageSize())
->get()
->map(function (BrandModel $model): Brand {
$brand = $this->toDomain($model);
$this->brandCache->put($brand);
$this->brandCache->putAll(...$brands);
return $brand;
})
->all();
return $brands;
return new Paginated($pagination, array_values($brands), $total);
}
#[\Override]

View file

@ -9,6 +9,8 @@ use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Ports\Repositories\BrandRepository;
use Shared\Paginated;
use Shared\Pagination;
final class InMemoryBrandRepository implements BrandRepository
{
@ -60,9 +62,13 @@ final class InMemoryBrandRepository implements BrandRepository
}
#[\Override]
public function findAll(): array
public function findAll(Pagination $pagination): Paginated
{
return array_values($this->brands);
$all = array_values($this->brands);
$total = count($all);
$paged = array_slice($all, $pagination->from(), $pagination->pageSize());
return new Paginated($pagination, $paged, $total);
}
#[\Override]

View file

@ -15,6 +15,7 @@ use Infra\Adapters\Repositories\EloquentBrandRepository;
use Infra\Cache\BrandCache;
use Infra\Database\Models\Brand as BrandModel;
use Ramsey\Uuid\Uuid;
use Shared\Pagination;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
@ -147,30 +148,34 @@ describe('EloquentBrandRepository Integration Tests', function (): void {
->and($secondResult->name->value)->toBe('Père Dodu');
});
it('finds all brands and caches the list', function (): void {
it('finds brands with pagination and warms cache', function (): void {
// GIVEN
$brandCache = resolve(BrandCache::class);
$repository = new EloquentBrandRepository($brandCache);
$id1 = Uuid::uuid7()->toString();
$id2 = Uuid::uuid7()->toString();
BrandModel::query()->create([
'id' => Uuid::uuid7()->toString(),
'id' => $id1,
'name' => 'Brand 1',
'arcadia_id' => 1,
'uwil_public_id' => 'BRD01',
]);
BrandModel::query()->create([
'id' => Uuid::uuid7()->toString(),
'id' => $id2,
'name' => 'Brand 2',
'arcadia_id' => 2,
'uwil_public_id' => 'BRD02',
]);
// WHEN
$all = $repository->findAll();
$all = $repository->findAll(Pagination::ofPage(1, 25));
// THEN
expect($all)->toHaveCount(2)
->and(Cache::has('brand:all'))->toBeTrue();
expect($all->getItems())->toHaveCount(2)
->and($all->getTotal())->toBe(2)
->and(Cache::has("brand:id:{$id1}"))->toBeTrue()
->and(Cache::has("brand:id:{$id2}"))->toBeTrue();
});
it('gracefully recovers when cache contains corrupted or non-array data', function (): void {

View file

@ -8,6 +8,7 @@ use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Infra\Adapters\Directories\InMemoryArcadiaBrandDirectory;
use Shared\Pagination;
covers(InMemoryArcadiaBrandDirectory::class);
@ -38,16 +39,17 @@ describe('InMemoryArcadiaBrandDirectory Unit Tests', function (): void {
$directory->add($b1, $b2);
// WHEN
$all = $directory->findAll();
$all = $directory->findAll(Pagination::ofPage(1, 25));
// THEN
expect($all)->toHaveCount(2);
expect($all->getItems())->toHaveCount(2)
->and($all->getTotal())->toBe(2);
// WHEN
$directory->clear();
// THEN
expect($directory->findAll())->toBeEmpty();
expect($directory->findAll(Pagination::ofPage(1, 25))->getItems())->toBeEmpty();
});
it('filters out comment and obsolete brands starting with Z -', function (): void {
@ -58,12 +60,13 @@ describe('InMemoryArcadiaBrandDirectory Unit Tests', function (): void {
$directory->add($valid, $comment);
// WHEN
$all = $directory->findAll();
$all = $directory->findAll(Pagination::ofPage(1, 25));
$found = $directory->find(new ArcadiaId('2'));
// THEN
expect($all)->toHaveCount(1)
->and($all[0]->id->value)->toBe('1')
expect($all->getItems())->toHaveCount(1)
->and($all->getItems()[0]->id->value)->toBe('1')
->and($all->getTotal())->toBe(1)
->and($found)->toBeNull();
});
});

View file

@ -11,6 +11,7 @@ use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Infra\Adapters\Repositories\InMemoryBrandRepository;
use Ramsey\Uuid\Uuid;
use Shared\Pagination;
covers(InMemoryBrandRepository::class);
@ -44,6 +45,22 @@ describe('InMemoryBrandRepository Unit Tests', function (): void {
->and($missingByCode)->toBeNull();
});
it('finds brands with pagination', function (): void {
// GIVEN
$repository = new InMemoryBrandRepository();
$b1 = createBrand('LGL01', 'Le Gaulois', '1');
$b2 = createBrand('MCQ02', 'Maître CoQ', '2');
$repository->add($b1, $b2);
// WHEN
$paginated = $repository->findAll(Pagination::ofPage(1, 1));
// THEN
expect($paginated->getItems())->toHaveCount(1)
->and($paginated->getTotal())->toBe(2)
->and($paginated->getItems()[0])->toEqual($b1);
});
it('persists and deletes a brand', function (): void {
// GIVEN
$repository = new InMemoryBrandRepository();