refactor(commercial) : découpler l'hydratation des collections de la sélection clients (ERP-100) (#50)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## Contexte Issu de la review ERP-62 (#44). `DoctrineClientRepository::createListQueryBuilder()` portait 3 `leftJoin+addSelect` to-many imbriqués (`categories × addresses × addresses.sites`) **partagés** entre : - la **liste paginée** (`ClientProvider`) — bornée, OK ; - l'**export XLSX** et **`?pagination=false`** — `getResult()` sans pagination → hydratation du **produit cartésien sur tout le référentiel** (1 client à 5 cat × 4 adr × 3 sites = 60 lignes SQL, × N clients). Défaut d'altitude : un « QueryBuilder de liste » (contrat = filtres) imposait une stratégie d'hydratation à tout appelant. ## Changements - **`createListQueryBuilder()`** redevient **filtres + tri seuls** — conforme au contrat de l'interface. - Nouvelle méthode **`hydrateListCollections(array $clients)`** : recharge les collections en **2 requêtes `WHERE id IN(...)` séparées** (catégories d'un côté, adresses+sites de l'autre) via l'identity map Doctrine. Casse le triple cartésien en `cat + (addr × site)`. - **3 appelants** branchés sur cette stratégie unique : - liste paginée : `fetchJoinCollection: false` (COUNT simple) + hydratation de la page ; - `?pagination=false` : hydratation après `getResult()` ; - export XLSX : hydratation après `getResult()`. ## Tests - `make test` : **465 OK**. - Nouveau test `ClientExportControllerTest::testExportPopulatesCategoryAndSiteColumns` : garde-fou sur les valeurs Catégories/Sites de l'export (qu'un oubli d'hydratation rendrait silencieusement vides). - `php-cs-fixer` : 0 correction. ## Notes - Benchmark « 1000+ clients » non exécuté (pas de jeu de données à cette échelle en dev) ; le cartésien est supprimé structurellement. - `addr × site` reste un join imbriqué (inévitable pour agréger les sites par adresse), désormais non multiplié par les catégories. Closes ERP-100. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #50 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #50.
This commit is contained in:
@@ -33,6 +33,12 @@ interface ClientRepositoryInterface
|
||||
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
||||
* partagent strictement la meme logique de selection.
|
||||
*
|
||||
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
|
||||
* l'hydratation des collections affichees est une decision de l'appelant
|
||||
* (cf. {@see self::hydrateListCollections()}), pour ne pas imposer le cout
|
||||
* d'un produit cartesien a un consommateur qui ne filtrerait/compterait que
|
||||
* (ERP-100).
|
||||
*
|
||||
* @param list<string> $categoryCodes
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
@@ -43,4 +49,19 @@ interface ClientRepositoryInterface
|
||||
array $siteIds = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder;
|
||||
|
||||
/**
|
||||
* Hydrate en lot les collections affichees par le repertoire (categories,
|
||||
* adresses et leurs sites) sur un jeu de clients DEJA charges, via l'identity
|
||||
* map Doctrine (memes instances). A appeler apres une selection bornee (page
|
||||
* courante ou jeu d'export) pour eviter le N+1 a la serialisation, sans
|
||||
* imposer de fetch-join au QueryBuilder de selection (ERP-100).
|
||||
*
|
||||
* Charge les categories et les adresses/sites en DEUX requetes distinctes
|
||||
* (et non un triple fetch-join) pour ne pas multiplier categories x adresses
|
||||
* x sites en un seul produit cartesien.
|
||||
*
|
||||
* @param list<Client> $clients
|
||||
*/
|
||||
public function hydrateListCollections(array $clients): void;
|
||||
}
|
||||
|
||||
@@ -83,8 +83,13 @@ final class ClientProvider implements ProviderInterface
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
// @var list<Client> $result
|
||||
return $qb->getQuery()->getResult();
|
||||
/** @var list<Client> $clients */
|
||||
$clients = $qb->getQuery()->getResult();
|
||||
// Hydratation batchee des collections affichees (cf. ERP-100) : evite
|
||||
// le N+1 si la serialisation touche categories/sites, sans cartesien.
|
||||
$this->repository->hydrateListCollections($clients);
|
||||
|
||||
return $clients;
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
@@ -93,9 +98,13 @@ final class ClientProvider implements ProviderInterface
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// fetchJoinCollection: true pour un COUNT correct des que des JOINs
|
||||
// to-many seront ajoutes (sous-collections embarquees en detail).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||
// Le QB de selection ne porte plus de fetch-join to-many (ERP-100) : le
|
||||
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
|
||||
// puis on hydrate ses collections en lot (memes entites managees).
|
||||
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
$this->repository->hydrateListCollections(iterator_to_array($paginator));
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -69,6 +69,11 @@ final class ClientExportController
|
||||
->getResult()
|
||||
;
|
||||
|
||||
// Hydratation batchee des categories + adresses/sites (ERP-100) : le QB de
|
||||
// selection ne fetch-join plus, on remplit les collections en 2 requetes
|
||||
// IN bornees plutot que d'hydrater un produit cartesien sur tout le jeu.
|
||||
$this->repository->hydrateListCollections($clients);
|
||||
|
||||
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
|
||||
@@ -38,16 +38,12 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
||||
array $siteIds = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder {
|
||||
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
|
||||
// L'hydratation des collections affichees (Catégories / Site(s)) est
|
||||
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
|
||||
// imposer un produit cartesien aux chemins non pagines (export,
|
||||
// ?pagination=false) — ERP-100.
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
// Jointures + addSelect pour hydrater en une seule requete les
|
||||
// collections affichees par le Repertoire (colonnes Catégories /
|
||||
// Site(s)) : sans cela, la serialisation declenche un N+1 (une
|
||||
// requete par client, puis par adresse). Le Paginator ORM
|
||||
// (fetchJoinCollection: true, cf. ClientProvider) gere le COUNT
|
||||
// malgre ces jointures to-many.
|
||||
->leftJoin('c.categories', 'cat')->addSelect('cat')
|
||||
->leftJoin('c.addresses', 'addr')->addSelect('addr')
|
||||
->leftJoin('addr.sites', 'site')->addSelect('site')
|
||||
->andWhere('c.deletedAt IS NULL')
|
||||
->orderBy('c.companyName', 'ASC')
|
||||
;
|
||||
@@ -66,6 +62,46 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function hydrateListCollections(array $clients): void
|
||||
{
|
||||
if ([] === $clients) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ids des clients deja charges (entites managees). On rehydrate leurs
|
||||
// collections via l'identity map : les requetes ci-dessous renvoient les
|
||||
// MEMES instances Client, dont les collections sont alors remplies.
|
||||
$ids = [];
|
||||
foreach ($clients as $client) {
|
||||
$id = $client->getId();
|
||||
if (null !== $id) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
if ([] === $ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1re passe : categories (colonne « Catégories »). Produit c x cat seul.
|
||||
$this->createQueryBuilder('c')
|
||||
->leftJoin('c.categories', 'cat')->addSelect('cat')
|
||||
->where('c.id IN (:ids)')->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
// 2e passe : adresses + sites (colonne « Site(s) », sites portes par les
|
||||
// adresses — RG-1.10). Le join addr -> site reste imbrique mais n'est
|
||||
// plus multiplie par les categories : le cartesien global est casse.
|
||||
$this->createQueryBuilder('c')
|
||||
->leftJoin('c.addresses', 'addr')->addSelect('addr')
|
||||
->leftJoin('addr.sites', 'site')->addSelect('site')
|
||||
->where('c.id IN (:ids)')->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||
|
||||
Reference in New Issue
Block a user