feat: Optimiser la pagination avec cache-aside et ports de lecture dédiés

Les listes paginées (utilisateurs, classes, matières, affectations,
invitations parents, droits à l'image) effectuaient des requêtes SQL
complètes à chaque chargement de page, sans aucun cache. Sur les
établissements avec plusieurs centaines d'enregistrements, cela causait
des temps de réponse perceptibles et une charge inutile sur PostgreSQL.

Cette refactorisation introduit un cache tag-aware (Redis en prod,
filesystem en dev) avec invalidation événementielle, et extrait les
requêtes de lecture dans des ports Application / implémentations DBAL
conformes à l'architecture hexagonale. Un middleware Messenger garantit
l'invalidation synchrone du cache même pour les événements routés en
asynchrone (envoi d'emails), évitant ainsi toute donnée périmée côté UI.
This commit is contained in:
2026-03-01 14:33:56 +01:00
parent ce05207c64
commit 23dd7177f2
41 changed files with 2854 additions and 1584 deletions

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
/**
* Read-model port for paginated assignment queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<AssignmentWithNamesDto>
*/
interface PaginatedAssignmentsReader
{
/**
* @return PaginatedResult<AssignmentWithNamesDto>
*/
public function findPaginated(
string $tenantId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetClasses\ClassDto;
/**
* Read-model port for paginated class queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<ClassDto>
*/
interface PaginatedClassesReader
{
/**
* @return PaginatedResult<ClassDto>
*/
public function findPaginated(
string $tenantId,
string $academicYearId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
/**
* Read-model port for paginated parent invitation queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<ParentInvitationDto>
*/
interface PaginatedParentInvitationsReader
{
/**
* @return PaginatedResult<ParentInvitationDto>
*/
public function findPaginated(
string $tenantId,
?string $status,
?string $studentId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
/**
* Read-model port for paginated student image rights queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<StudentImageRightsDto>
*/
interface PaginatedStudentImageRightsReader
{
/**
* @return PaginatedResult<StudentImageRightsDto>
*/
public function findPaginated(
string $tenantId,
?string $status,
?string $search,
int $page,
int $limit,
): PaginatedResult;
/**
* Returns all students (no pagination) for export purposes.
*
* @return StudentImageRightsDto[]
*/
public function findAll(
string $tenantId,
?string $status,
): array;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetSubjects\SubjectDto;
/**
* Read-model port for paginated subject queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<SubjectDto>
*/
interface PaginatedSubjectsReader
{
/**
* @return PaginatedResult<SubjectDto>
*/
public function findPaginated(
string $tenantId,
string $schoolId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetUsers\UserDto;
/**
* Read-model port for paginated user queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<UserDto>
*/
interface PaginatedUsersReader
{
/**
* @return PaginatedResult<UserDto>
*/
public function findPaginated(
string $tenantId,
?string $role,
?string $statut,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -5,27 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetAllAssignments;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Psr\Log\LoggerInterface;
use App\Administration\Application\Port\PaginatedAssignmentsReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAllAssignmentsHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private LoggerInterface $logger,
private PaginatedAssignmentsReader $reader,
private PaginatedQueryCache $cache,
) {
}
@@ -34,101 +23,29 @@ final readonly class GetAllAssignmentsHandler
*/
public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult
{
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findAllActiveByTenant($tenantId);
// Build lookup maps for users, classes, and subjects
$users = $this->userRepository->findAllByTenant($tenantId);
/** @var array<string, array{firstName: string, lastName: string}> $userNames */
$userNames = [];
foreach ($users as $user) {
$userNames[(string) $user->id] = [
'firstName' => $user->firstName,
'lastName' => $user->lastName,
];
}
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $classNames */
$classNames = [];
foreach ($classes as $class) {
$classNames[(string) $class->id] = (string) $class->name;
}
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $subjectNames */
$subjectNames = [];
foreach ($subjects as $subject) {
$subjectNames[(string) $subject->id] = (string) $subject->name;
}
$dtos = [];
foreach ($assignments as $assignment) {
$teacherId = (string) $assignment->teacherId;
$classId = (string) $assignment->classId;
$subjectId = (string) $assignment->subjectId;
if (!isset($userNames[$teacherId])) {
$this->logger->warning('Assignment {assignmentId} references unknown teacher {teacherId}', [
'assignmentId' => (string) $assignment->id,
'teacherId' => $teacherId,
]);
}
if (!isset($classNames[$classId])) {
$this->logger->warning('Assignment {assignmentId} references unknown class {classId}', [
'assignmentId' => (string) $assignment->id,
'classId' => $classId,
]);
}
if (!isset($subjectNames[$subjectId])) {
$this->logger->warning('Assignment {assignmentId} references unknown subject {subjectId}', [
'assignmentId' => (string) $assignment->id,
'subjectId' => $subjectId,
]);
}
$teacher = $userNames[$teacherId] ?? ['firstName' => '', 'lastName' => ''];
$dtos[] = new AssignmentWithNamesDto(
id: (string) $assignment->id,
teacherId: $teacherId,
teacherFirstName: $teacher['firstName'],
teacherLastName: $teacher['lastName'],
classId: $classId,
className: $classNames[$classId] ?? '',
subjectId: $subjectId,
subjectName: $subjectNames[$subjectId] ?? '',
academicYearId: (string) $assignment->academicYearId,
status: $assignment->status->value,
startDate: $assignment->startDate,
endDate: $assignment->endDate,
createdAt: $assignment->createdAt,
);
}
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$dtos = array_values(array_filter(
$dtos,
static fn (AssignmentWithNamesDto $dto) => str_contains(mb_strtolower($dto->teacherFirstName), $searchLower)
|| str_contains(mb_strtolower($dto->teacherLastName), $searchLower)
|| str_contains(mb_strtolower($dto->className), $searchLower)
|| str_contains(mb_strtolower($dto->subjectName), $searchLower),
));
}
$total = count($dtos);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($dtos, $offset, $query->limit);
return new PaginatedResult(
items: $items,
total: $total,
page: $query->page,
limit: $query->limit,
/* @var PaginatedResult<AssignmentWithNamesDto> */
return $this->cache->getOrLoad(
'assignments',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
);
}
/**
* @return array<string, mixed>
*/
private function cacheParams(GetAllAssignmentsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'search' => $query->search,
];
}
}

View File

@@ -5,20 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetClasses;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use App\Administration\Application\Port\PaginatedClassesReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetClassesHandler
{
public function __construct(
private ClassRepository $classRepository,
private PaginatedClassesReader $reader,
private PaginatedQueryCache $cache,
) {
}
@@ -27,33 +23,31 @@ final readonly class GetClassesHandler
*/
public function __invoke(GetClassesQuery $query): PaginatedResult
{
$classes = $this->classRepository->findActiveByTenantAndYear(
TenantId::fromString($query->tenantId),
AcademicYearId::fromString($query->academicYearId),
);
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$classes = array_filter(
$classes,
static fn ($class) => str_contains(mb_strtolower((string) $class->name), $searchLower)
|| ($class->level !== null && str_contains(mb_strtolower($class->level->value), $searchLower)),
);
$classes = array_values($classes);
}
$total = count($classes);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($classes, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static fn ($class) => ClassDto::fromDomain($class),
$items,
/* @var PaginatedResult<ClassDto> */
return $this->cache->getOrLoad(
'classes',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
academicYearId: $query->academicYearId,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
/**
* @return array<string, mixed>
*/
private function cacheParams(GetClassesQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'academic_year_id' => $query->academicYearId,
'search' => $query->search,
];
}
}

View File

@@ -5,29 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentInvitations;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\Invitation\InvitationStatus;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_map;
use function array_slice;
use function array_values;
use function count;
use function mb_strtolower;
use function str_contains;
use App\Administration\Application\Port\PaginatedParentInvitationsReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetParentInvitationsHandler
{
public function __construct(
private ParentInvitationRepository $invitationRepository,
private UserRepository $userRepository,
private PaginatedParentInvitationsReader $reader,
private PaginatedQueryCache $cache,
) {
}
@@ -36,97 +23,33 @@ final readonly class GetParentInvitationsHandler
*/
public function __invoke(GetParentInvitationsQuery $query): PaginatedResult
{
$tenantId = TenantId::fromString($query->tenantId);
$invitations = $this->invitationRepository->findAllByTenant($tenantId);
if ($query->status !== null) {
$filterStatus = InvitationStatus::tryFrom($query->status);
if ($filterStatus !== null) {
$invitations = array_filter(
$invitations,
static fn ($inv) => $inv->status === $filterStatus,
);
}
}
if ($query->studentId !== null) {
$filterStudentId = UserId::fromString($query->studentId);
$invitations = array_filter(
$invitations,
static fn ($inv) => $inv->studentId->equals($filterStudentId),
);
}
// Build a student name cache for search and DTO enrichment
$studentNames = $this->loadStudentNames($invitations);
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$invitations = array_filter(
$invitations,
static function ($inv) use ($searchLower, $studentNames) {
$studentId = (string) $inv->studentId;
$firstName = $studentNames[$studentId]['firstName'] ?? '';
$lastName = $studentNames[$studentId]['lastName'] ?? '';
return str_contains(mb_strtolower((string) $inv->parentEmail), $searchLower)
|| str_contains(mb_strtolower($firstName), $searchLower)
|| str_contains(mb_strtolower($lastName), $searchLower);
},
);
}
$invitations = array_values($invitations);
$total = count($invitations);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($invitations, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static function ($inv) use ($studentNames) {
$studentId = (string) $inv->studentId;
return ParentInvitationDto::fromDomain(
$inv,
$studentNames[$studentId]['firstName'] ?? null,
$studentNames[$studentId]['lastName'] ?? null,
);
},
$items,
/* @var PaginatedResult<ParentInvitationDto> */
return $this->cache->getOrLoad(
'parent_invitations',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
status: $query->status,
studentId: $query->studentId,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
/**
* @param iterable<\App\Administration\Domain\Model\Invitation\ParentInvitation> $invitations
*
* @return array<string, array{firstName: string, lastName: string}>
* @return array<string, mixed>
*/
private function loadStudentNames(iterable $invitations): array
private function cacheParams(GetParentInvitationsQuery $query): array
{
$studentIds = [];
foreach ($invitations as $inv) {
$studentIds[(string) $inv->studentId] = true;
}
$names = [];
foreach ($studentIds as $id => $_) {
try {
$student = $this->userRepository->get(UserId::fromString($id));
$names[$id] = [
'firstName' => $student->firstName,
'lastName' => $student->lastName,
];
} catch (Throwable) {
$names[$id] = ['firstName' => '', 'lastName' => ''];
}
}
return $names;
return [
'page' => $query->page,
'limit' => $query->limit,
'status' => $query->status,
'student_id' => $query->studentId,
'search' => $query->search,
];
}
}

View File

@@ -4,41 +4,50 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsImageRights;
use App\Administration\Domain\Model\User\ImageRightsStatus;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetStudentsImageRightsHandler
{
public function __construct(
private UserRepository $userRepository,
private PaginatedStudentImageRightsReader $reader,
private PaginatedQueryCache $cache,
) {
}
/**
* @return StudentImageRightsDto[]
* @return PaginatedResult<StudentImageRightsDto>
*/
public function __invoke(GetStudentsImageRightsQuery $query): array
public function __invoke(GetStudentsImageRightsQuery $query): PaginatedResult
{
$students = $this->userRepository->findStudentsByTenant(
TenantId::fromString($query->tenantId),
/* @var PaginatedResult<StudentImageRightsDto> */
return $this->cache->getOrLoad(
'students_image_rights',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
status: $query->status,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
);
}
if ($query->status !== null) {
$filterStatus = ImageRightsStatus::tryFrom($query->status);
if ($filterStatus !== null) {
$students = array_filter(
$students,
static fn ($user) => $user->imageRightsStatus === $filterStatus,
);
}
}
return array_values(array_map(
static fn ($user) => StudentImageRightsDto::fromDomain($user),
$students,
));
/**
* @return array<string, mixed>
*/
private function cacheParams(GetStudentsImageRightsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'status' => $query->status,
'search' => $query->search,
];
}
}

View File

@@ -4,11 +4,23 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsImageRights;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetStudentsImageRightsQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public ?string $status = null,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -5,20 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetSubjects;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use App\Administration\Application\Port\PaginatedSubjectsReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetSubjectsHandler
{
public function __construct(
private SubjectRepository $subjectRepository,
private PaginatedSubjectsReader $reader,
private PaginatedQueryCache $cache,
) {
}
@@ -27,37 +23,31 @@ final readonly class GetSubjectsHandler
*/
public function __invoke(GetSubjectsQuery $query): PaginatedResult
{
$subjects = $this->subjectRepository->findActiveByTenantAndSchool(
TenantId::fromString($query->tenantId),
SchoolId::fromString($query->schoolId),
);
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$subjects = array_filter(
$subjects,
static fn ($subject) => str_contains(mb_strtolower((string) $subject->name), $searchLower)
|| str_contains(mb_strtolower((string) $subject->code), $searchLower),
);
$subjects = array_values($subjects);
}
$total = count($subjects);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($subjects, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static fn ($subject) => SubjectDto::fromDomain(
$subject,
teacherCount: 0,
classCount: 0,
),
$items,
/* @var PaginatedResult<SubjectDto> */
return $this->cache->getOrLoad(
'subjects',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
schoolId: $query->schoolId,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
/**
* @return array<string, mixed>
*/
private function cacheParams(GetSubjectsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'school_id' => $query->schoolId,
'search' => $query->search,
];
}
}

View File

@@ -5,23 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetUsers;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use App\Administration\Application\Port\PaginatedUsersReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetUsersHandler
{
public function __construct(
private UserRepository $userRepository,
private Clock $clock,
private PaginatedUsersReader $reader,
private PaginatedQueryCache $cache,
) {
}
@@ -30,54 +23,33 @@ final readonly class GetUsersHandler
*/
public function __invoke(GetUsersQuery $query): PaginatedResult
{
$users = $this->userRepository->findAllByTenant(
TenantId::fromString($query->tenantId),
);
if ($query->role !== null) {
$filterRole = Role::tryFrom($query->role);
if ($filterRole !== null) {
$users = array_filter(
$users,
static fn ($user) => $user->aLeRole($filterRole),
);
}
}
if ($query->statut !== null) {
$filterStatut = StatutCompte::tryFrom($query->statut);
if ($filterStatut !== null) {
$users = array_filter(
$users,
static fn ($user) => $user->statut === $filterStatut,
);
}
}
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$users = array_filter(
$users,
static fn ($user) => str_contains(mb_strtolower($user->firstName), $searchLower)
|| str_contains(mb_strtolower($user->lastName), $searchLower)
|| str_contains(mb_strtolower((string) $user->email), $searchLower),
);
}
$users = array_values($users);
$total = count($users);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($users, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
fn ($user) => UserDto::fromDomain($user, $this->clock),
$items,
/* @var PaginatedResult<UserDto> */
return $this->cache->getOrLoad(
'users',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
role: $query->role,
statut: $query->statut,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
/**
* @return array<string, mixed>
*/
private function cacheParams(GetUsersQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'role' => $query->role,
'statut' => $query->statut,
'search' => $query->search,
];
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Cache;
use App\Administration\Application\Dto\PaginatedResult;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use function ksort;
use function md5;
use function sprintf;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/**
* Service cache-aside pour les requêtes paginées.
*
* Chaque entrée est taguée par type d'entité + tenant,
* permettant une invalidation ciblée lors de mutations.
*/
final readonly class PaginatedQueryCache
{
public function __construct(
private TagAwareCacheInterface $paginatedQueriesCache,
) {
}
/**
* @template T
*
* @param array<string, mixed> $params Filtres + page + limit
* @param callable(): PaginatedResult<T> $loader Fonction qui exécute la requête SQL
*
* @return PaginatedResult<T>
*/
public function getOrLoad(
string $entityType,
string $tenantId,
array $params,
callable $loader,
): PaginatedResult {
$key = $this->buildKey($entityType, $tenantId, $params);
$tag = sprintf('query_%s_%s', $entityType, $tenantId);
/* @var PaginatedResult<T> */
return $this->paginatedQueriesCache->get(
$key,
static function (ItemInterface $item) use ($tag, $loader): PaginatedResult {
$item->tag([$tag]);
return $loader();
},
);
}
public function invalidate(string $entityType, string $tenantId): void
{
$this->paginatedQueriesCache->invalidateTags(
[sprintf('query_%s_%s', $entityType, $tenantId)],
);
}
/**
* @param array<string, mixed> $params
*/
private function buildKey(string $entityType, string $tenantId, array $params): string
{
ksort($params);
return sprintf('query_%s_%s_%s', $entityType, $tenantId, md5(json_encode($params, JSON_THROW_ON_ERROR)));
}
}