Skip to content

Commit c4a8a24

Browse files
authored
feat: add new load graph by relation trait (#384)
chore: improvee performance of get summit by id ( N + 1 ) problem
1 parent 32a6dd8 commit c4a8a24

7 files changed

Lines changed: 341 additions & 13 deletions

File tree

app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitApiController.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,8 @@ public function getSummit($summit_id)
329329
return $this->processRequest(function () use ($summit_id) {
330330

331331
Log::debug(sprintf("OAuth2SummitApiController::getSummit %s", $summit_id));
332-
$summit = SummitFinderStrategyFactory::build($this->repository, $this->resource_server_context)->find($summit_id);
332+
$summit = SummitFinderStrategyFactory::build($this->repository, $this->resource_server_context)
333+
->find($summit_id, ['event_types','badge_features_types','badge_features_types.image','presentation_categories','ticket_types','locations','category_groups','badge_access_level_types']);
333334
if (!$summit instanceof Summit || $summit->isDeleting()) return $this->error404();
334335
$current_member = $this->resource_server_context->getCurrentUser();
335336

@@ -1082,4 +1083,4 @@ public function validateBadge($summit_id, $badge) {
10821083
);
10831084
});
10841085
}
1085-
}
1086+
}

app/Http/Controllers/Apis/Protected/Summit/Strategies/CurrentSummitFinderStrategy.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,21 @@ public function __construct
4949
}
5050

5151
/**
52-
* @param mixed $summit_id
53-
* @return null|Summit
52+
* @param $summit_id
53+
* @param array $relations
54+
* @return mixed|Summit|\models\utils\IEntity|null
5455
*/
55-
public function find($summit_id)
56+
public function find($summit_id, array $relations = [])
5657
{
58+
if(count($relations) > 0){
59+
$summit = $summit_id === 'current' ? $this->repository->getCurrentAndRelations($relations) : $this->repository->getByIdAndRelations(intval($summit_id), $relations);
60+
if(is_null($summit))
61+
$summit = $this->repository->getBySlugAndRelations(strval($summit_id), $relations);
62+
return $summit;
63+
}
5764
$summit = $summit_id === 'current' ? $this->repository->getCurrent() : $this->repository->getById(intval($summit_id));
5865
if(is_null($summit))
5966
$summit = $this->repository->getBySlug(strval($summit_id));
6067
return $summit;
6168
}
62-
}
69+
}

app/Http/Controllers/Apis/Protected/Summit/Strategies/ISummitFinderStrategy.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
interface ISummitFinderStrategy
2424
{
2525
/**
26-
* @param mixed $summit_id
27-
* @return null|Summit
26+
* @param $summit_id
27+
* @param array $relations
28+
* @return mixed
2829
*/
29-
public function find($summit_id);
30-
}
30+
public function find($summit_id, array $relations = []);
31+
}

app/Models/Foundation/Summit/Repositories/ISummitRepository.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,15 @@ public function getAllWithExternalRegistrationFeed():array;
105105
* @return PagingResponse
106106
*/
107107
public function getRegistrationCompanies(Summit $summit, PagingInfo $paging_info, Filter $filter = null, Order $order = null):PagingResponse;
108-
}
108+
109+
public function getBySlugAndRelations(string $slug, array $relations = []): ?Summit;
110+
111+
public function getByIdAndRelations(int $id, array $relations = []): ?Summit;
112+
113+
/**
114+
* @param array $relations
115+
* @return Summit|null
116+
*/
117+
public function getCurrentAndRelations(array $relations = []):?Summit;
118+
119+
}

app/Repositories/Summit/DoctrineSummitRepository.php

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
* limitations under the License.
1313
**/
1414

15+
use App\libs\Utils\Doctrine\GraphLoaderTrait;
1516
use Doctrine\ORM\Query\ResultSetMappingBuilder;
1617
use Doctrine\ORM\QueryBuilder;
1718
use Illuminate\Support\Facades\Log;
1819
use models\summit\ISummitRepository;
1920
use models\summit\Summit;
2021
use App\Repositories\SilverStripeDoctrineRepository;
2122
use utils\DoctrineFilterMapping;
22-
use utils\DoctrineHavingFilterMapping;
2323
use utils\Filter;
2424
use utils\Order;
2525
use utils\PagingInfo;
@@ -33,6 +33,8 @@ final class DoctrineSummitRepository
3333
extends SilverStripeDoctrineRepository
3434
implements ISummitRepository
3535
{
36+
use GraphLoaderTrait;
37+
3638

3739
/**
3840
* @return array
@@ -438,4 +440,59 @@ public function getRegistrationCompanies(Summit $summit, PagingInfo $paging_info
438440

439441
return new PagingResponse($total, $paging_info->getPerPage(), $paging_info->getCurrentPage(), $last_page, $companies);
440442
}
443+
444+
public function getBySlugAndRelations(string $slug, array $relations = []): ?Summit
445+
{
446+
try {
447+
return $this->loadGraphBy(
448+
$this->getEntityManager(),
449+
Summit::class,
450+
$relations,
451+
// WHERE configurator
452+
function ($qb, string $rootAlias) use ($slug): void {
453+
$qb->where("$rootAlias.slug = :slug")
454+
->setParameter('slug', strtolower($slug));
455+
}
456+
);
457+
} catch (\Throwable $e) {
458+
Log::warning($e);
459+
return null;
460+
}
461+
}
462+
463+
public function getByIdAndRelations(int $id, array $relations = []): ?Summit
464+
{
465+
try {
466+
return $this->loadGraphBy(
467+
$this->getEntityManager(),
468+
Summit::class,
469+
$relations,
470+
function ($qb, string $rootAlias) use ($id): void {
471+
$qb->where("$rootAlias.id = :id")
472+
->setParameter('id', $id);
473+
}
474+
);
475+
} catch (\Throwable $e) {
476+
Log::warning($e);
477+
return null;
478+
}
479+
}
480+
481+
public function getCurrentAndRelations(array $relations = []): ?Summit
482+
{
483+
try {
484+
return $this->loadGraphBy(
485+
$this->getEntityManager(),
486+
Summit::class,
487+
$relations,
488+
function ($qb, string $rootAlias): void {
489+
$qb->where('s.active = 1')
490+
->orderBy('s.begin_date', 'DESC');
491+
}
492+
);
493+
} catch (\Throwable $e) {
494+
Log::warning($e);
495+
return null;
496+
}
497+
}
441498
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
<?php namespace App\libs\Utils\Doctrine;
2+
/*
3+
* Copyright 2025 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
use Doctrine\ORM\EntityManagerInterface;
15+
use Doctrine\ORM\Mapping\ClassMetadata;
16+
use Doctrine\ORM\Query;
17+
18+
/**
19+
* Generic helpers to load a root entity plus a flexible set of relations.
20+
* - Direct ToOne relations are joined in the root query.
21+
* - Each requested top-level ToMany relation is loaded in a separate query.
22+
* - Nested ToOne under a top-level collection (dot-notation) are joined in that same per-collection query.
23+
*/
24+
trait GraphLoaderTrait
25+
{
26+
/**
27+
* Normalize user-provided relations (trim, unique, remove empties).
28+
*/
29+
protected function normalizeRelations(array $relations): array
30+
{
31+
return array_values(array_unique(array_filter(array_map(
32+
static fn($r) => trim((string)$r),
33+
$relations
34+
))));
35+
}
36+
37+
/**
38+
* Partition requested relations into:
39+
* - $toOneDirect: direct ToOne on the root entity
40+
* - $topCollections: top-level ToMany relations on the root entity
41+
* - $nestedByCollection: nested ToOne under a top-level collection (dot-notation)
42+
*
43+
* @return array{toOneDirect: string[], topCollections: string[], nestedByCollection: array<string, string[]>}
44+
*/
45+
protected function partitionRelations(ClassMetadata $meta, array $relations): array
46+
{
47+
$toOneDirect = [];
48+
$topCollections = [];
49+
$nestedByCollection = [];
50+
51+
foreach ($relations as $r) {
52+
$parts = explode('.', $r);
53+
$top = $parts[0];
54+
55+
if (!isset($meta->associationMappings[$top])) {
56+
// Not a mapped association on the root entity; skip.
57+
continue;
58+
}
59+
60+
$type = $meta->associationMappings[$top]['type'] ?? null;
61+
62+
if (count($parts) === 1) {
63+
// Direct relation on root
64+
if (in_array($type, [ClassMetadata::MANY_TO_ONE, ClassMetadata::ONE_TO_ONE], true)) {
65+
$toOneDirect[] = $top;
66+
} else {
67+
// ONE_TO_MANY or MANY_TO_MANY
68+
$topCollections[] = $top;
69+
}
70+
} else {
71+
// Dot-notation: top is a collection; the rest are assumed ToOne
72+
$topCollections[] = $top;
73+
$nestedByCollection[$top] = array_merge(
74+
$nestedByCollection[$top] ?? [],
75+
array_slice($parts, 1)
76+
);
77+
}
78+
}
79+
80+
// De-duplicate
81+
$toOneDirect = array_values(array_unique($toOneDirect));
82+
$topCollections = array_values(array_unique($topCollections));
83+
foreach ($nestedByCollection as $k => $arr) {
84+
$nestedByCollection[$k] = array_values(array_unique($arr));
85+
}
86+
87+
return compact('toOneDirect', 'topCollections', 'nestedByCollection');
88+
}
89+
90+
/**
91+
* Build and execute the root query:
92+
* - SELECT root alias
93+
* - LEFT JOIN + addSelect for each direct ToOne relation
94+
*
95+
* Returns the hydrated root entity or null.
96+
*/
97+
protected function loadRootWithToOne(
98+
EntityManagerInterface $em,
99+
string $entityClass,
100+
array $toOneDirect,
101+
callable $whereConfigurator // function(QueryBuilder $qb, string $rootAlias): void
102+
): ?object {
103+
$qb = $em->createQueryBuilder();
104+
$qb->select('r')
105+
->from($entityClass, 'r');
106+
107+
foreach ($toOneDirect as $rel) {
108+
$alias = 'r_' . $rel;
109+
$qb->leftJoin("r.$rel", $alias)->addSelect($alias);
110+
}
111+
112+
// Let caller apply WHERE / params
113+
$whereConfigurator($qb, 'r');
114+
115+
return $qb->getQuery()->getOneOrNullResult();
116+
}
117+
118+
/**
119+
* For a given root id, load exactly one top-level collection and optional nested ToOne under items of that collection.
120+
* Example: root -> badgeFeatureTypes (collection) -> file (ToOne)
121+
*
122+
* This hydrates into the current UnitOfWork; no need to assign the result.
123+
*/
124+
protected function hydrateCollectionWithNestedToOne(
125+
EntityManagerInterface $em,
126+
string $entityClass,
127+
string|int $rootId,
128+
string $collection,
129+
array $nestedToOne = []
130+
): void {
131+
$rootAlias = 'r2';
132+
$colAlias = 'c2_' . $collection;
133+
134+
$selects = [$rootAlias, $colAlias];
135+
$joins = ["LEFT JOIN $rootAlias.$collection $colAlias"];
136+
137+
foreach ($nestedToOne as $nestedRel) {
138+
$nestedAlias = $colAlias . '_' . $nestedRel;
139+
$joins[] = "LEFT JOIN $colAlias.$nestedRel $nestedAlias";
140+
$selects[] = $nestedAlias;
141+
}
142+
143+
$dql = sprintf(
144+
'SELECT DISTINCT %s FROM %s %s %s WHERE %s.id = :id',
145+
implode(', ', $selects),
146+
$entityClass,
147+
$rootAlias,
148+
implode(' ', $joins),
149+
$rootAlias
150+
);
151+
152+
$em->createQuery($dql)
153+
->setParameter('id', $rootId)
154+
->getOneOrNullResult();
155+
}
156+
157+
/**
158+
* High-level convenience: load a root entity by an arbitrary field using a where configurator,
159+
* plus a flexible graph (ToOne direct + one query per requested top-level collection).
160+
*
161+
* Returns the root entity or null.
162+
*/
163+
protected function loadGraphBy(
164+
EntityManagerInterface $em,
165+
string $entityClass,
166+
array $relations,
167+
callable $whereConfigurator // function(QueryBuilder $qb, string $rootAlias): void
168+
): ?object {
169+
$meta = $em->getClassMetadata($entityClass);
170+
$relations = $this->normalizeRelations($relations);
171+
$partitions = $this->partitionRelations($meta, $relations);
172+
173+
// 1) Root + direct ToOne
174+
$root = $this->loadRootWithToOne(
175+
$em,
176+
$entityClass,
177+
$partitions['toOneDirect'],
178+
$whereConfigurator
179+
);
180+
181+
if (!$root) {
182+
return null;
183+
}
184+
185+
// 2) Per collection query (+ optional nested ToOne)
186+
$idField = $meta->getSingleIdentifierFieldName();
187+
$getter = 'get' . ucfirst($idField);
188+
$rootId = $root->$getter();
189+
190+
foreach ($partitions['topCollections'] as $collection) {
191+
// Defensive: ensure mapping still exists
192+
if (!isset($meta->associationMappings[$collection])) {
193+
continue;
194+
}
195+
196+
$nested = $partitions['nestedByCollection'][$collection] ?? [];
197+
$this->hydrateCollectionWithNestedToOne(
198+
$em,
199+
$entityClass,
200+
$rootId,
201+
$collection,
202+
$nested
203+
);
204+
}
205+
206+
return $root;
207+
}
208+
}

0 commit comments

Comments
 (0)