diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitApiController.php index d348ac8ce..88892426b 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitApiController.php @@ -329,7 +329,8 @@ public function getSummit($summit_id) return $this->processRequest(function () use ($summit_id) { Log::debug(sprintf("OAuth2SummitApiController::getSummit %s", $summit_id)); - $summit = SummitFinderStrategyFactory::build($this->repository, $this->resource_server_context)->find($summit_id); + $summit = SummitFinderStrategyFactory::build($this->repository, $this->resource_server_context) + ->find($summit_id, ['event_types','badge_features_types','badge_features_types.image','presentation_categories','ticket_types','locations','category_groups','badge_access_level_types']); if (!$summit instanceof Summit || $summit->isDeleting()) return $this->error404(); $current_member = $this->resource_server_context->getCurrentUser(); @@ -1082,4 +1083,4 @@ public function validateBadge($summit_id, $badge) { ); }); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Apis/Protected/Summit/Strategies/CurrentSummitFinderStrategy.php b/app/Http/Controllers/Apis/Protected/Summit/Strategies/CurrentSummitFinderStrategy.php index 32d9d3d3a..f63e0d315 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/Strategies/CurrentSummitFinderStrategy.php +++ b/app/Http/Controllers/Apis/Protected/Summit/Strategies/CurrentSummitFinderStrategy.php @@ -49,14 +49,21 @@ public function __construct } /** - * @param mixed $summit_id - * @return null|Summit + * @param $summit_id + * @param array $relations + * @return mixed|Summit|\models\utils\IEntity|null */ - public function find($summit_id) + public function find($summit_id, array $relations = []) { + if(count($relations) > 0){ + $summit = $summit_id === 'current' ? $this->repository->getCurrentAndRelations($relations) : $this->repository->getByIdAndRelations(intval($summit_id), $relations); + if(is_null($summit)) + $summit = $this->repository->getBySlugAndRelations(strval($summit_id), $relations); + return $summit; + } $summit = $summit_id === 'current' ? $this->repository->getCurrent() : $this->repository->getById(intval($summit_id)); if(is_null($summit)) $summit = $this->repository->getBySlug(strval($summit_id)); return $summit; } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Apis/Protected/Summit/Strategies/ISummitFinderStrategy.php b/app/Http/Controllers/Apis/Protected/Summit/Strategies/ISummitFinderStrategy.php index a38ce9d7c..c1e4fe507 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/Strategies/ISummitFinderStrategy.php +++ b/app/Http/Controllers/Apis/Protected/Summit/Strategies/ISummitFinderStrategy.php @@ -23,8 +23,9 @@ interface ISummitFinderStrategy { /** - * @param mixed $summit_id - * @return null|Summit + * @param $summit_id + * @param array $relations + * @return mixed */ - public function find($summit_id); -} \ No newline at end of file + public function find($summit_id, array $relations = []); +} diff --git a/app/Models/Foundation/Summit/Repositories/ISummitRepository.php b/app/Models/Foundation/Summit/Repositories/ISummitRepository.php index e83a53537..7e75e077e 100644 --- a/app/Models/Foundation/Summit/Repositories/ISummitRepository.php +++ b/app/Models/Foundation/Summit/Repositories/ISummitRepository.php @@ -105,4 +105,15 @@ public function getAllWithExternalRegistrationFeed():array; * @return PagingResponse */ public function getRegistrationCompanies(Summit $summit, PagingInfo $paging_info, Filter $filter = null, Order $order = null):PagingResponse; -} \ No newline at end of file + + public function getBySlugAndRelations(string $slug, array $relations = []): ?Summit; + + public function getByIdAndRelations(int $id, array $relations = []): ?Summit; + + /** + * @param array $relations + * @return Summit|null + */ + public function getCurrentAndRelations(array $relations = []):?Summit; + +} diff --git a/app/Repositories/Summit/DoctrineSummitRepository.php b/app/Repositories/Summit/DoctrineSummitRepository.php index e6d0115d7..3c0704bb3 100644 --- a/app/Repositories/Summit/DoctrineSummitRepository.php +++ b/app/Repositories/Summit/DoctrineSummitRepository.php @@ -12,6 +12,7 @@ * limitations under the License. **/ +use App\libs\Utils\Doctrine\GraphLoaderTrait; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; use Illuminate\Support\Facades\Log; @@ -19,7 +20,6 @@ use models\summit\Summit; use App\Repositories\SilverStripeDoctrineRepository; use utils\DoctrineFilterMapping; -use utils\DoctrineHavingFilterMapping; use utils\Filter; use utils\Order; use utils\PagingInfo; @@ -33,6 +33,8 @@ final class DoctrineSummitRepository extends SilverStripeDoctrineRepository implements ISummitRepository { + use GraphLoaderTrait; + /** * @return array @@ -438,4 +440,59 @@ public function getRegistrationCompanies(Summit $summit, PagingInfo $paging_info return new PagingResponse($total, $paging_info->getPerPage(), $paging_info->getCurrentPage(), $last_page, $companies); } + + public function getBySlugAndRelations(string $slug, array $relations = []): ?Summit + { + try { + return $this->loadGraphBy( + $this->getEntityManager(), + Summit::class, + $relations, + // WHERE configurator + function ($qb, string $rootAlias) use ($slug): void { + $qb->where("$rootAlias.slug = :slug") + ->setParameter('slug', strtolower($slug)); + } + ); + } catch (\Throwable $e) { + Log::warning($e); + return null; + } + } + + public function getByIdAndRelations(int $id, array $relations = []): ?Summit + { + try { + return $this->loadGraphBy( + $this->getEntityManager(), + Summit::class, + $relations, + function ($qb, string $rootAlias) use ($id): void { + $qb->where("$rootAlias.id = :id") + ->setParameter('id', $id); + } + ); + } catch (\Throwable $e) { + Log::warning($e); + return null; + } + } + + public function getCurrentAndRelations(array $relations = []): ?Summit + { + try { + return $this->loadGraphBy( + $this->getEntityManager(), + Summit::class, + $relations, + function ($qb, string $rootAlias): void { + $qb->where('s.active = 1') + ->orderBy('s.begin_date', 'DESC'); + } + ); + } catch (\Throwable $e) { + Log::warning($e); + return null; + } + } } diff --git a/app/libs/Utils/Doctrine/GraphLoaderTrait.php b/app/libs/Utils/Doctrine/GraphLoaderTrait.php new file mode 100644 index 000000000..439d5f726 --- /dev/null +++ b/app/libs/Utils/Doctrine/GraphLoaderTrait.php @@ -0,0 +1,208 @@ + trim((string)$r), + $relations + )))); + } + + /** + * Partition requested relations into: + * - $toOneDirect: direct ToOne on the root entity + * - $topCollections: top-level ToMany relations on the root entity + * - $nestedByCollection: nested ToOne under a top-level collection (dot-notation) + * + * @return array{toOneDirect: string[], topCollections: string[], nestedByCollection: array} + */ + protected function partitionRelations(ClassMetadata $meta, array $relations): array + { + $toOneDirect = []; + $topCollections = []; + $nestedByCollection = []; + + foreach ($relations as $r) { + $parts = explode('.', $r); + $top = $parts[0]; + + if (!isset($meta->associationMappings[$top])) { + // Not a mapped association on the root entity; skip. + continue; + } + + $type = $meta->associationMappings[$top]['type'] ?? null; + + if (count($parts) === 1) { + // Direct relation on root + if (in_array($type, [ClassMetadata::MANY_TO_ONE, ClassMetadata::ONE_TO_ONE], true)) { + $toOneDirect[] = $top; + } else { + // ONE_TO_MANY or MANY_TO_MANY + $topCollections[] = $top; + } + } else { + // Dot-notation: top is a collection; the rest are assumed ToOne + $topCollections[] = $top; + $nestedByCollection[$top] = array_merge( + $nestedByCollection[$top] ?? [], + array_slice($parts, 1) + ); + } + } + + // De-duplicate + $toOneDirect = array_values(array_unique($toOneDirect)); + $topCollections = array_values(array_unique($topCollections)); + foreach ($nestedByCollection as $k => $arr) { + $nestedByCollection[$k] = array_values(array_unique($arr)); + } + + return compact('toOneDirect', 'topCollections', 'nestedByCollection'); + } + + /** + * Build and execute the root query: + * - SELECT root alias + * - LEFT JOIN + addSelect for each direct ToOne relation + * + * Returns the hydrated root entity or null. + */ + protected function loadRootWithToOne( + EntityManagerInterface $em, + string $entityClass, + array $toOneDirect, + callable $whereConfigurator // function(QueryBuilder $qb, string $rootAlias): void + ): ?object { + $qb = $em->createQueryBuilder(); + $qb->select('r') + ->from($entityClass, 'r'); + + foreach ($toOneDirect as $rel) { + $alias = 'r_' . $rel; + $qb->leftJoin("r.$rel", $alias)->addSelect($alias); + } + + // Let caller apply WHERE / params + $whereConfigurator($qb, 'r'); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * For a given root id, load exactly one top-level collection and optional nested ToOne under items of that collection. + * Example: root -> badgeFeatureTypes (collection) -> file (ToOne) + * + * This hydrates into the current UnitOfWork; no need to assign the result. + */ + protected function hydrateCollectionWithNestedToOne( + EntityManagerInterface $em, + string $entityClass, + string|int $rootId, + string $collection, + array $nestedToOne = [] + ): void { + $rootAlias = 'r2'; + $colAlias = 'c2_' . $collection; + + $selects = [$rootAlias, $colAlias]; + $joins = ["LEFT JOIN $rootAlias.$collection $colAlias"]; + + foreach ($nestedToOne as $nestedRel) { + $nestedAlias = $colAlias . '_' . $nestedRel; + $joins[] = "LEFT JOIN $colAlias.$nestedRel $nestedAlias"; + $selects[] = $nestedAlias; + } + + $dql = sprintf( + 'SELECT DISTINCT %s FROM %s %s %s WHERE %s.id = :id', + implode(', ', $selects), + $entityClass, + $rootAlias, + implode(' ', $joins), + $rootAlias + ); + + $em->createQuery($dql) + ->setParameter('id', $rootId) + ->getOneOrNullResult(); + } + + /** + * High-level convenience: load a root entity by an arbitrary field using a where configurator, + * plus a flexible graph (ToOne direct + one query per requested top-level collection). + * + * Returns the root entity or null. + */ + protected function loadGraphBy( + EntityManagerInterface $em, + string $entityClass, + array $relations, + callable $whereConfigurator // function(QueryBuilder $qb, string $rootAlias): void + ): ?object { + $meta = $em->getClassMetadata($entityClass); + $relations = $this->normalizeRelations($relations); + $partitions = $this->partitionRelations($meta, $relations); + + // 1) Root + direct ToOne + $root = $this->loadRootWithToOne( + $em, + $entityClass, + $partitions['toOneDirect'], + $whereConfigurator + ); + + if (!$root) { + return null; + } + + // 2) Per collection query (+ optional nested ToOne) + $idField = $meta->getSingleIdentifierFieldName(); + $getter = 'get' . ucfirst($idField); + $rootId = $root->$getter(); + + foreach ($partitions['topCollections'] as $collection) { + // Defensive: ensure mapping still exists + if (!isset($meta->associationMappings[$collection])) { + continue; + } + + $nested = $partitions['nestedByCollection'][$collection] ?? []; + $this->hydrateCollectionWithNestedToOne( + $em, + $entityClass, + $rootId, + $collection, + $nested + ); + } + + return $root; + } +} diff --git a/tests/OAuth2SummitApiTest.php b/tests/OAuth2SummitApiTest.php index 7c2a0c554..f2dc68610 100644 --- a/tests/OAuth2SummitApiTest.php +++ b/tests/OAuth2SummitApiTest.php @@ -306,6 +306,49 @@ public function testGetSummit2() $this->assertResponseStatus(200); } + public function testGetSummit3() + { + + $params = [ + 'fields' => ' +id,name,start_date,end_date,time_zone_id,time_zone_label,secondary_logo,slug,support_email,start_showing_venues_date,dates_with_events,logo,dates_label,registration_allowed_refund_request_till_date,allow_update_attendee_extra_questions,is_virtual,registration_disclaimer_mandatory,registration_disclaimer_content,reassign_ticket_till_date,is_main,title,description,time_zone,event_types.id,tracks.id,tracks.name,tracks.code,tracks.order,tracks.parent_id,tracks.color,tracks.text_color,tracks.subtracks.id,tracks.subtracks.name,tracks.subtracks.code,tracks.subtracks.order,tracks.subtracks.parent_id,tracks.subtracks.color,tracks.subtracks.text_color,ticket_types.id,ticket_types.name,ticket_types.created,ticket_types.cost,track_groups.id,track_groups.name,track_groups.color,locations.id,locations.class_name,locations.is_main,locations.name,locations.city,locations.country,locations.venue.name', + 'expand' => 'event_types,badge_features_types,tracks,track_groups,presentation_levels,locations,schedule_settings,ticket_types,schedule_settings.filters,schedule_settings.pre_filters,tracks.subtracks,locations.venue', + 'relations' => 'dates_with_events,locations,payment_profiles,time_zone,ticket_types.none,tracks,tracks.subtracks,tracks.subtracks.none,track_groups.none,locations.none,locations.venue.none,event_types.none', + 'id' => self::$summit->getId() + ]; + + $response = $this->action( + "GET", + "OAuth2SummitApiController@getSummit", + $params, + [], + [], + [], + $this->getAuthHeaders() + ); + $content = $response->getContent(); + $summit = json_decode($content); + $this->assertTrue(!is_null($summit)); + $this->assertResponseStatus(200); + + $response = $this->action( + "GET", + "OAuth2SummitApiController@getSummit", + $params, + [], + [], + [], + $this->getAuthHeaders() + ); + + $content = $response->getContent(); + $summit = json_decode($content); + $this->assertTrue(!is_null($summit)); + $this->assertTrue(count($summit->event_types) > 0); + $this->assertTrue(count($summit->tracks) > 0); + $this->assertResponseStatus(200); + } + public function testAddSummitAlreadyExistsName(){ $params = [ @@ -1184,4 +1227,4 @@ public function testValidateBadge(){ $attendee_badge = json_decode($content); $this->assertNotNull($attendee_badge); } -} \ No newline at end of file +}