diff --git a/Classes/Domain/Repository/CommonRepository.php b/Classes/Domain/Repository/CommonRepository.php index 66e4169..f647dee 100644 --- a/Classes/Domain/Repository/CommonRepository.php +++ b/Classes/Domain/Repository/CommonRepository.php @@ -5,8 +5,11 @@ namespace SourceBroker\T3api\Domain\Repository; use SourceBroker\T3api\Domain\Model\ApiFilter; +use SourceBroker\T3api\Domain\Model\CollectionOperation; use SourceBroker\T3api\Domain\Model\OperationInterface; +use SourceBroker\T3api\Exception\MissingCollectionOperationException; use SourceBroker\T3api\Filter\FilterInterface; +use SourceBroker\T3api\Filter\QueryModifierInterface; use SourceBroker\T3api\Security\FilterAccessChecker; use SourceBroker\T3api\Service\StorageService; use Symfony\Component\HttpFoundation\Request; @@ -89,6 +92,7 @@ public function findFiltered(array $apiFilters, Request $request): QueryInterfac $query = $this->createQuery(); $constraintGroups = []; + $queryModifiers = []; $apiFilters = $this->filterGrantedFilters($apiFilters); $apiFilters = $this->filterAndSortApiFiltersByQueryParams($apiFilters, $queryParams); @@ -111,6 +115,10 @@ public function findFiltered(array $apiFilters, Request $request): QueryInterfac [$constraint] ); } + + if ($filter instanceof QueryModifierInterface) { + $queryModifiers[] = [$filter, $apiFilter]; + } } $constraints = []; @@ -122,6 +130,26 @@ public function findFiltered(array $apiFilters, Request $request): QueryInterfac $query->matching($query->logicalAnd(...$constraints)); } + // Let query-modifier filters adjust the fully-constrained query — e.g. apply an ORDER BY + // the QOM constraint model cannot express (ORDER BY FIELD(uid, ...) for a relevance + // ranking). Runs after matching() so the query already carries every constraint. + if ($queryModifiers !== []) { + $operation = $this->operation ?? null; + if (!$operation instanceof CollectionOperation) { + throw new MissingCollectionOperationException( + sprintf( + 'Query modifiers require a repository built for a collection operation, `%s` given.', + get_debug_type($operation) + ), + 1784102345678 + ); + } + + foreach ($queryModifiers as [$filter, $apiFilter]) { + $filter->modifyQuery($query, $apiFilter, $operation); + } + } + return $query; } diff --git a/Classes/Exception/MissingCollectionOperationException.php b/Classes/Exception/MissingCollectionOperationException.php new file mode 100644 index 0000000..21c72c4 --- /dev/null +++ b/Classes/Exception/MissingCollectionOperationException.php @@ -0,0 +1,11 @@ + + */ + private array $orderedUidsPerDeclaration = []; + + /** + * Resolve the matching record UIDs, in the order they should appear, for the filter value(s). + * + * @param mixed $values the raw filter value(s) from the request + * @return int[]|null null → no lookup performed (filter inactive, matches everything); + * [] → lookup performed but nothing matched (matches nothing); + * int[] → matching UIDs in the order to apply + */ + abstract protected function resolveOrderedUids($values, ApiFilter $apiFilter): ?array; + + public function filterProperty( + string $property, + $values, + QueryInterface $query, + ApiFilter $apiFilter + ): ?ConstraintInterface { + $uids = $this->resolveOrderedUids($values, $apiFilter); + if ($uids === null) { + unset($this->orderedUidsPerDeclaration[spl_object_id($apiFilter)]); + + return null; + } + + $this->orderedUidsPerDeclaration[spl_object_id($apiFilter)] = $uids; + + // The appended 0 guards the empty case: in('uid', []) is invalid SQL; uid 0 never matches. + return $query->in('uid', array_merge($uids, [0])); + } + + public function modifyQuery(QueryInterface $query, ApiFilter $apiFilter, CollectionOperation $operation): void + { + $orderedUids = $this->orderedUidsPerDeclaration[spl_object_id($apiFilter)] ?? []; + unset($this->orderedUidsPerDeclaration[spl_object_id($apiFilter)]); + + if ($orderedUids === [] || !$query instanceof Query) { + return; + } + + $statement = $query->getStatement(); + if ($statement !== null && $statement->getStatement() instanceof QueryBuilder) { + // An earlier query modifier already rewrote this query into a QueryBuilder statement. + // Reconverting the QOM would silently discard its work — append this ordering to the + // existing builder instead, as a tie-breaker after the earlier modifier's ordering. + $queryBuilder = $statement->getStatement(); + $queryBuilder->getConcreteQueryBuilder() + ->addOrderBy($this->buildFieldOrderExpression($queryBuilder, $query, $orderedUids)); + + return; + } + + $queryBuilder = GeneralUtility::makeInstance(Typo3DbQueryParser::class) + ->convertQueryToDoctrineQueryBuilder($query); + + // The ordering goes to the concrete Doctrine QueryBuilder: TYPO3's facade would quote the + // whole FIELD() expression as an identifier, and orderBy() on the concrete builder also + // replaces any ordering Typo3DbQueryParser derived from the Extbase query. + $queryBuilder->getConcreteQueryBuilder() + ->orderBy($this->buildFieldOrderExpression($queryBuilder, $query, $orderedUids)); + + $query->statement($queryBuilder); + } + + /** + * @param int[] $orderedUids + */ + private function buildFieldOrderExpression(QueryBuilder $queryBuilder, Query $query, array $orderedUids): string + { + return 'FIELD(' + . $queryBuilder->quoteIdentifier($this->getTableName($query) . '.uid') . ', ' + . implode(',', array_map('intval', $orderedUids)) + . ')'; + } +} diff --git a/Classes/Filter/FilterInterface.php b/Classes/Filter/FilterInterface.php index 0fccb28..0e1fd50 100644 --- a/Classes/Filter/FilterInterface.php +++ b/Classes/Filter/FilterInterface.php @@ -10,6 +10,12 @@ interface FilterInterface { + /** + * Planned for the next major version (v6): a CollectionOperation parameter will be added here + * (as in QueryModifierInterface::modifyQuery()), mirroring API Platform's filter contract + * where apply() receives the Operation. It cannot happen within 5.x — it would break every + * existing custom filter implementation. + */ public function filterProperty( string $property, $values, diff --git a/Classes/Filter/QueryModifierInterface.php b/Classes/Filter/QueryModifierInterface.php new file mode 100644 index 0000000..5e66dad --- /dev/null +++ b/Classes/Filter/QueryModifierInterface.php @@ -0,0 +1,31 @@ +statement(...) effectively replaces the whole query — Extbase + * executes the statement and ignores QOM-level changes made by later modifiers, and a later + * statement(...) call overwrites an earlier one (last one wins). A statement-based modifier + * should therefore check $query->getStatement() first and mutate the QueryBuilder already + * present instead of reconverting, as AbstractOrderedUidsFilter does. + */ +interface QueryModifierInterface +{ + public function modifyQuery(QueryInterface $query, ApiFilter $apiFilter, CollectionOperation $operation): void; +} diff --git a/Classes/Response/AbstractCollectionResponse.php b/Classes/Response/AbstractCollectionResponse.php index 80b4ad1..d462f20 100644 --- a/Classes/Response/AbstractCollectionResponse.php +++ b/Classes/Response/AbstractCollectionResponse.php @@ -7,6 +7,8 @@ use GoldSpecDigital\ObjectOrientedOAS\Objects\Schema; use SourceBroker\T3api\Domain\Model\CollectionOperation; use Symfony\Component\HttpFoundation\Request; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Extbase\Persistence\Generic\Query; use TYPO3\CMS\Extbase\Persistence\QueryInterface; abstract class AbstractCollectionResponse @@ -56,7 +58,26 @@ protected function applyPagination(): QueryInterface return $this->query; } - return (clone $this->query) + $paginatedQuery = clone $this->query; + + // When the query is backed by a raw Doctrine QueryBuilder statement (e.g. a custom + // filter or operation handler that needs an ORDER BY the Extbase QOM cannot express, + // such as ORDER BY FIELD(uid, ...) for a relevance ranking), the shallow clone above + // still shares the very same QueryBuilder instance with $this->query. Extbase applies + // this (paginated) query's limit/offset onto that shared builder at execution time, + // and it then leaks into the separate, unpaginated count() query used by + // getTotalItems() — turning its "SELECT COUNT(*) ... LIMIT n OFFSET m" into an empty + // result, because the single count row is skipped by the offset. Give the paginated + // query its own clone of the builder so the limit/offset stay isolated to the members + // query and never reach the count query. No-op for normal QOM queries (no statement). + if ($paginatedQuery instanceof Query) { + $statement = $paginatedQuery->getStatement(); + if ($statement !== null && $statement->getStatement() instanceof QueryBuilder) { + $paginatedQuery->statement(clone $statement->getStatement(), $statement->getBoundVariables()); + } + } + + return $paginatedQuery ->setLimit($pagination->getNumberOfItemsPerPage()) ->setOffset($pagination->getOffset()); } diff --git a/Classes/Service/SerializerMetadataService.php b/Classes/Service/SerializerMetadataService.php index 4db92cc..e12adf6 100644 --- a/Classes/Service/SerializerMetadataService.php +++ b/Classes/Service/SerializerMetadataService.php @@ -35,12 +35,15 @@ public static function generateAutoloadForClass(string $class): void } foreach (self::getClassHierarchy($class) as $reflectionClass) { - if (in_array($reflectionClass->getName(), self::$runtimeGeneratedCache, true)) { + $generatedMetadataFile = self::getAutogeneratedFilePath($reflectionClass->getName()); + + // Keyed by target file, not class name: the file path contains Environment::getVarPath(), + // which changes per test instance while this static cache spans the whole PHP process - + // a class-name key would wrongly skip generation into a fresh var path. + if (in_array($generatedMetadataFile, self::$runtimeGeneratedCache, true)) { continue; } - $generatedMetadataFile = self::getAutogeneratedFilePath($reflectionClass->getName()); - $classMergedMetadata = array_replace_recursive( self::getForClass($reflectionClass), self::getMetadataFromMetadataDirs($reflectionClass->getName()) @@ -53,7 +56,7 @@ public static function generateAutoloadForClass(string $class): void ); rename($tmpFile, $generatedMetadataFile); - self::$runtimeGeneratedCache[] = $reflectionClass->getName(); + self::$runtimeGeneratedCache[] = $generatedMetadataFile; } } diff --git a/Documentation/Filtering/CustomFilters/Index.rst b/Documentation/Filtering/CustomFilters/Index.rst index c22f41e..fff3a52 100644 --- a/Documentation/Filtering/CustomFilters/Index.rst +++ b/Documentation/Filtering/CustomFilters/Index.rst @@ -66,6 +66,42 @@ It may be useful, but not required, to extend class ``\SourceBroker\T3api\Filter .. note:: Instance of your custom filter will be created using Extbase's ObjectManager, so you can inject into it any other services if you need them. +.. _filtering_custom-filters_query-modifiers: + +Query modifiers +=============== + +A constraint returned from ``filterProperty`` is sometimes not enough — for example when the filter needs to apply a custom ``ORDER BY`` which has to take the complete query into account. For such cases a custom filter can additionally implement ``\SourceBroker\T3api\Filter\QueryModifierInterface``: + +.. code-block:: php + + interface QueryModifierInterface + { + public function modifyQuery(QueryInterface $query, ApiFilter $apiFilter, CollectionOperation $operation): void; + } + +Method ``modifyQuery`` accepts 3 arguments: + +- $query (``TYPO3\CMS\Extbase\Persistence\QueryInterface``) - Instance of Extbase's query, already carrying the combined constraints of all filters. +- $apiFilter (``SourceBroker\T3api\Domain\Model\ApiFilter``) - The filter declaration being applied. +- $operation (``SourceBroker\T3api\Domain\Model\CollectionOperation``) - The collection operation being served, so the filter can act per endpoint (path, resource, attributes). + +After the constraints of all filters have been combined and applied to the query, t3api calls ``modifyQuery`` on every filter implementing this interface. The filters run in the same request-driven order as ``filterProperty``. Query modifiers only ever run for collection ``GET`` operations — the same scope in which filters apply at all. + +.. important:: + Modifications done through Extbase's query API compose across multiple modifiers. A modifier + which replaces the query with a Doctrine QueryBuilder statement (``$query->statement(...)``) + does not: Extbase then executes only the statement, so QOM-level changes made by later + modifiers are ignored, and a later ``statement()`` call overwrites an earlier one (last one + wins). A statement-based modifier should check ``$query->getStatement()`` first and mutate + the QueryBuilder already present instead of reconverting — see + ``\SourceBroker\T3api\Filter\AbstractOrderedUidsFilter`` for a reference implementation. + +A ready-made base class using this seam is :ref:`AbstractOrderedUidsFilter `, which returns a collection in the order of an externally resolved UID list (e.g. search results ordered by relevance). + +Registration +============ + To use your new custom filter is it just needed to pass it as first parameter into ``@ApiFilter`` annotation: .. code-block:: php diff --git a/Documentation/Filtering/Index.rst b/Documentation/Filtering/Index.rst index 3c31de6..0cb1451 100644 --- a/Documentation/Filtering/Index.rst +++ b/Documentation/Filtering/Index.rst @@ -117,6 +117,7 @@ There are few build-in filters. See the next pages. BuiltinFilters/Index CustomFilters/Index + OrderedUidsFilter/Index SqlInOperator/Index SqlOrOperator/Index diff --git a/Documentation/Filtering/OrderedUidsFilter/Index.rst b/Documentation/Filtering/OrderedUidsFilter/Index.rst new file mode 100644 index 0000000..ea4defa --- /dev/null +++ b/Documentation/Filtering/OrderedUidsFilter/Index.rst @@ -0,0 +1,78 @@ +.. _filtering_ordered-uids-filter: + +=================== +Ordered UIDs filter +=================== + +``\SourceBroker\T3api\Filter\AbstractOrderedUidsFilter`` is an abstract base class for filters which resolve the filter value to an ordered list of record UIDs and want the collection returned in exactly that order. Typical use cases: + +- relevance-ranked results from an external search engine (Elasticsearch, Algolia, Solr, ...), +- recommendations computed by an external service, +- manually curated lists. + +A concrete filter implements a single method — ``resolveOrderedUids()``. The base class then does the rest: + +- it constrains the collection query to the resolved UIDs (``uid IN (...)``), +- as a :ref:`query modifier ` it applies the ordering as ``ORDER BY FIELD(uid, ...)`` after the constraints of all filters have been combined — an ordering which Extbase's query API cannot express on its own. + +The ordering plays well with the rest of t3api: pagination (``limit``/``offset``) is applied on top of it and ``totalItems`` is still calculated from the unpaginated query. + +Several such filters on one resource compose as well: each filter's ``uid IN (...)`` constraint applies (the collection is the intersection of all matches), the ranking of the first filter present in the request's query string becomes the primary ordering and the following ones are appended as tie-breakers. + +Return value contract of ``resolveOrderedUids()``: + +- ``null`` - no lookup was performed; the filter is inactive and does not affect the result at all, +- ``[]`` (empty array) - the lookup was performed but nothing matched; the collection result will be empty, +- ``[12, 5, 8]`` - the matching UIDs, in the order in which they should be returned. + +.. important:: + The generated ``ORDER BY FIELD()`` clause is specific to MySQL and MariaDB. If you run TYPO3 on another DBMS, you need a different solution. + +Example implementation of a filter backed by an external search engine: + +.. code-block:: php + + declare(strict_types=1); + namespace Vendor\Extension\Filter; + + use SourceBroker\T3api\Domain\Model\ApiFilter; + use SourceBroker\T3api\Filter\AbstractOrderedUidsFilter; + use Vendor\Extension\Service\SearchEngineClient; + + class RelevanceSearchFilter extends AbstractOrderedUidsFilter + { + public function __construct(private readonly SearchEngineClient $searchEngineClient) + { + } + + protected function resolveOrderedUids($values, ApiFilter $apiFilter): ?array + { + $phrase = trim((string)(is_array($values) ? reset($values) : $values)); + + if ($phrase === '') { + return null; + } + + // Returns the matching record UIDs ordered by relevance score. + return $this->searchEngineClient->searchUids($phrase); + } + } + +Registration works the same as for any other filter: + +.. code-block:: php + + use SourceBroker\T3api\Annotation as T3api; + use Vendor\Extension\Filter\RelevanceSearchFilter; + + /** + * @T3api\ApiFilter( + * RelevanceSearchFilter::class, + * arguments={"parameterName"="search"} + * ) + */ + class Recipe extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity + { + } + +A request like ``/api/recipes?search=pasta`` then returns the matching records in the order delivered by the search engine, with pagination and ``totalItems`` working as usual. diff --git a/Tests/Functional/Dispatcher/AbstractDispatcherTestCase.php b/Tests/Functional/Dispatcher/AbstractDispatcherTestCase.php new file mode 100644 index 0000000..0a26fbe --- /dev/null +++ b/Tests/Functional/Dispatcher/AbstractDispatcherTestCase.php @@ -0,0 +1,122 @@ +writeSiteConfiguration(); + + // SiteService::getCurrent() falls back to TYPO3\CMS\Core\Http\ServerRequestFactory::fromGlobals(), + // which reads the real PHP superglobals (not $GLOBALS['TYPO3_REQUEST']) and refuses to build a + // request URL on CLI unless $_SERVER carries one - fake it so site resolution succeeds. + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['REQUEST_URI'] = '/_api/books'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $site = $this->get(SiteFinder::class)->getSiteByIdentifier(self::SITE_IDENTIFIER); + + // Extbase's ConfigurationManager resolves this request through $GLOBALS['TYPO3_REQUEST'] to build + // the persistence QuerySettings used by CommonRepository. Marking it FE would route through + // FrontendConfigurationManager, which hard-requires a `frontend.typoscript` request attribute that + // is normally populated by the frontend middleware stack we deliberately bypass here. Marking it BE + // routes through BackendConfigurationManager instead, which is built to gracefully compute a + // (page-less, in our case) TypoScript setup without a real page tree - this is the same mechanism + // every Extbase backend module relies on. `applicationType` itself is not read anywhere in t3api's + // own code, so this only affects Extbase's internal TypoScript bootstrapping, not dispatch behaviour. + $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest('https://example.com/_api/books', 'GET')) + ->withAttribute('site', $site) + ->withAttribute('language', $site->getDefaultLanguage()) + ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE); + } + + protected function dispatchGet(string $url): string + { + $symfonyRequest = SymfonyRequest::create($url, 'GET'); + $requestContext = (new RequestContext())->fromRequest($symfonyRequest); + $response = new Response(); + + return $this->get(Bootstrap::class) + ->processOperationByRequest($requestContext, $symfonyRequest, $response); + } + + /** + * Writes the site configuration file directly instead of going through + * `TYPO3\CMS\Core\Configuration\SiteWriter`, which only exists from TYPO3 13.1 onwards + * (in earlier versions writing was a responsibility of `SiteConfiguration` itself) - this + * keeps the test working across the whole TYPO3 12/13/14 support range. + */ + private function writeSiteConfiguration(): void + { + $siteConfigurationDirectory = Environment::getConfigPath() . '/sites/' . self::SITE_IDENTIFIER; + GeneralUtility::mkdir_deep($siteConfigurationDirectory); + file_put_contents($siteConfigurationDirectory . '/config.yaml', Yaml::dump([ + 'rootPageId' => 1, + 'base' => '/', + 'languages' => [ + 0 => [ + 'title' => 'English', + 'enabled' => true, + 'languageId' => 0, + 'base' => '/', + 'locale' => 'en_US.UTF-8', + 'navigationTitle' => 'English', + ], + ], + 'routeEnhancers' => [ + 'T3apiResourceEnhancer' => [ + 'type' => 'T3apiResourceEnhancer', + 'basePath' => '_api', + ], + ], + ], 99, 2)); + + $this->flushSiteConfigurationCaches(); + } + + /** + * `SiteConfiguration` caches its resolved sites under the fixed identifier `sites-configuration` + * in both the `core` and `runtime` caches; a direct file write bypasses the invalidation that + * `SiteWriter::write()`/`SiteConfiguration::write()` would otherwise trigger via + * `SiteConfigurationChangedEvent`, so both entries are removed explicitly here. + */ + private function flushSiteConfigurationCaches(): void + { + $cacheManager = $this->get(CacheManager::class); + $cacheManager->getCache('core')->remove('sites-configuration'); + $cacheManager->getCache('runtime')->remove('sites-configuration'); + } +} diff --git a/Tests/Functional/Dispatcher/CollectionPaginationDispatcherTest.php b/Tests/Functional/Dispatcher/CollectionPaginationDispatcherTest.php new file mode 100644 index 0000000..eb03d0c --- /dev/null +++ b/Tests/Functional/Dispatcher/CollectionPaginationDispatcherTest.php @@ -0,0 +1,177 @@ +importCSVDataSet(__DIR__ . '/../Fixtures/products.csv'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/categories.csv'); + } + + #[Test] + public function plainCollectionIsPaginatedWithStableTotalItemsOnEveryPage(): void + { + $firstPage = $this->dispatchProductsGet(['itemsPerPage' => 125, 'page' => 1]); + $secondPage = $this->dispatchProductsGet(['itemsPerPage' => 125, 'page' => 2]); + $thirdPage = $this->dispatchProductsGet(['itemsPerPage' => 125, 'page' => 3]); + + self::assertCount(125, $firstPage['hydra:member']); + self::assertCount(125, $secondPage['hydra:member']); + self::assertCount(50, $thirdPage['hydra:member']); + self::assertSame(300, $firstPage['hydra:totalItems']); + self::assertSame(300, $secondPage['hydra:totalItems']); + self::assertSame(300, $thirdPage['hydra:totalItems']); + self::assertEqualsCanonicalizing( + range(1, 300), + array_merge( + $this->memberUids($firstPage), + $this->memberUids($secondPage), + $this->memberUids($thirdPage) + ), + 'All pages together must cover every fixture record exactly once.' + ); + } + + #[Test] + public function orderedUidsFilterReturnsMembersInResolvedOrderOnBothPages(): void + { + $queryParams = ['fixedOrder' => '11,2,9,4,7,6', 'itemsPerPage' => 3]; + + $firstPage = $this->dispatchProductsGet($queryParams + ['page' => 1]); + $secondPage = $this->dispatchProductsGet($queryParams + ['page' => 2]); + + self::assertSame([11, 2, 9], $this->memberUids($firstPage)); + self::assertSame([4, 7, 6], $this->memberUids($secondPage)); + self::assertSame(6, $firstPage['hydra:totalItems']); + self::assertSame( + 6, + $secondPage['hydra:totalItems'], + 'totalItems must stay the full match count on the second page - if the paginated query' + . ' shared its QueryBuilder with the count query, the leaked limit/offset would skip' + . ' the single COUNT(*) row and collapse totalItems to 0.' + ); + } + + #[Test] + public function orderedUidsFilterRestrictsCollectionToResolvedUidsKeepingTheirOrder(): void + { + $response = $this->dispatchProductsGet(['fixedOrder' => '9,3']); + + self::assertSame([9, 3], $this->memberUids($response)); + self::assertSame(2, $response['hydra:totalItems']); + } + + /** + * `fixedOrder` and `tieBreakOrder` are two declarations of the same FixedOrderFilter class + * (one singleton instance, state keyed per declaration). Their `uid IN (...)` constraints + * intersect and the ranking of the first filter in the query string stays the primary + * ordering — the second one appends to the already-present QueryBuilder statement instead + * of overwriting it. + */ + #[Test] + public function twoOrderedUidsFiltersIntersectConstraintsAndFirstRequestedRankingStaysPrimary(): void + { + $response = $this->dispatchProductsGet(['fixedOrder' => '4,3,2,1', 'tieBreakOrder' => '1,2,3']); + + self::assertSame([3, 2, 1], $this->memberUids($response)); + self::assertSame(3, $response['hydra:totalItems']); + } + + /** + * `MaxTitleLengthFilter` contributes a WHERE condition the QOM cannot express: + * `LENGTH(title) <= 6` keeps "Brie" (uid 2) and "Alfa" (uid 1) of the requested UIDs and + * drops "Hazelnut" (uid 8) and "Grapefruit" (uid 7). Declared second in the query string, it + * must append its condition to the QueryBuilder statement left by the ordered-UIDs filter — + * and the count query behind totalItems must see that condition too. + */ + #[Test] + public function statementModifierAddingWhereConditionComposesWithEarlierOrderedUidsRanking(): void + { + $response = $this->dispatchProductsGet(['fixedOrder' => '8,2,7,1', 'maxTitleLength' => 6]); + + self::assertSame([2, 1], $this->memberUids($response)); + self::assertSame(2, $response['hydra:totalItems']); + } + + /** + * Same combination with the parameter order reversed: now the WHERE-contributing modifier + * converts the query first and the ordered-UIDs filter appends its FIELD() ranking to the + * already-present QueryBuilder statement. Both hand-off directions must yield the same result. + */ + #[Test] + public function orderedUidsRankingComposesWithEarlierStatementModifierAddingWhereCondition(): void + { + $response = $this->dispatchProductsGet(['maxTitleLength' => 6, 'fixedOrder' => '8,2,7,1']); + + self::assertSame([2, 1], $this->memberUids($response)); + self::assertSame(2, $response['hydra:totalItems']); + } + + /** + * `CategoryOrderFilter` applies an ordering WITH ties (`ORDER BY category`; products 4 and 2 + * share category 1, products 8 and 6 share category 2), so the ordered-UIDs ranking appended + * afterwards observably breaks the ties within each category group. This discriminates all + * three possible behaviours of the second modifier: tie-breaking append → [4, 2, 8, 6]; + * destructive statement overwrite → [8, 4, 6, 2]; no effect at all → [2, 4, 6, 8]. + */ + #[Test] + public function orderedUidsRankingBreaksTiesOfEarlierStatementOrdering(): void + { + $response = $this->dispatchProductsGet(['categoryOrder' => 1, 'fixedOrder' => '8,4,6,2']); + + self::assertSame([4, 2, 8, 6], $this->memberUids($response)); + self::assertSame(4, $response['hydra:totalItems']); + } + + #[Test] + public function orderedUidsFilterMatchingNothingReturnsEmptyCollection(): void + { + $response = $this->dispatchProductsGet(['fixedOrder' => 'none']); + + self::assertSame([], $response['hydra:member']); + self::assertSame(0, $response['hydra:totalItems']); + } + + private function dispatchProductsGet(array $queryParams): array + { + return json_decode( + $this->dispatchGet('https://example.com/_api/products?' . http_build_query($queryParams)), + true, + 512, + JSON_THROW_ON_ERROR + ); + } + + /** + * @return int[] + */ + private function memberUids(array $decodedResponse): array + { + return array_map( + static fn(array $member): int => $member['uid'], + $decodedResponse['hydra:member'] + ); + } +} diff --git a/Tests/Functional/Dispatcher/ResponseCacheDispatcherTest.php b/Tests/Functional/Dispatcher/ResponseCacheDispatcherTest.php index efda7e0..595b4da 100644 --- a/Tests/Functional/Dispatcher/ResponseCacheDispatcherTest.php +++ b/Tests/Functional/Dispatcher/ResponseCacheDispatcherTest.php @@ -6,72 +6,26 @@ use PHPUnit\Framework\Attributes\Test; use Psr\EventDispatcher\EventDispatcherInterface; -use SourceBroker\T3api\Dispatcher\Bootstrap; use SourceBroker\T3api\Event\RecordUpdatedEvent; -use Symfony\Component\HttpFoundation\Request as SymfonyRequest; -use Symfony\Component\Routing\RequestContext; -use Symfony\Component\Yaml\Yaml; use TYPO3\CMS\Core\Cache\CacheManager; -use TYPO3\CMS\Core\Core\Environment; -use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder; -use TYPO3\CMS\Core\Http\Response; -use TYPO3\CMS\Core\Http\ServerRequest; -use TYPO3\CMS\Core\Site\SiteFinder; -use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface; -use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; /** * Full-cycle functional test for the response cache dispatcher integration: * MISS (fresh output) -> HIT (stale output served after a direct SQL change) -> * invalidation via the real PSR-14 event dispatcher -> MISS again with fresh data. - * - * Both scenarios live in this single test class deliberately: `SiteService::getCurrent()` - * and `RouteService`'s route-enhancer lookup memoize their result in function-local static - * variables for the lifetime of the PHP process, so every scenario that depends on site/route - * resolution has to run against the one site configuration written in setUp(). */ -class ResponseCacheDispatcherTest extends FunctionalTestCase +class ResponseCacheDispatcherTest extends AbstractDispatcherTestCase { - protected array $testExtensionsToLoad = [ - 'typo3conf/ext/t3api', - 'typo3conf/ext/t3api/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test', - ]; + private const TABLE = 'tx_functionaltest_domain_model_book'; - private const TABLE = 'tx_responsecachetest_domain_model_book'; - - private const AUTHOR_TABLE = 'tx_responsecachetest_domain_model_author'; - - private const SITE_IDENTIFIER = 'functional-test'; + private const AUTHOR_TABLE = 'tx_functionaltest_domain_model_author'; protected function setUp(): void { parent::setUp(); $this->importCSVDataSet(__DIR__ . '/../Fixtures/books.csv'); $this->importCSVDataSet(__DIR__ . '/../Fixtures/authors.csv'); - $this->writeSiteConfiguration(); - - // SiteService::getCurrent() falls back to TYPO3\CMS\Core\Http\ServerRequestFactory::fromGlobals(), - // which reads the real PHP superglobals (not $GLOBALS['TYPO3_REQUEST']) and refuses to build a - // request URL on CLI unless $_SERVER carries one - fake it so site resolution succeeds. - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/_api/books'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $site = $this->get(SiteFinder::class)->getSiteByIdentifier(self::SITE_IDENTIFIER); - - // Extbase's ConfigurationManager resolves this request through $GLOBALS['TYPO3_REQUEST'] to build - // the persistence QuerySettings used by CommonRepository. Marking it FE would route through - // FrontendConfigurationManager, which hard-requires a `frontend.typoscript` request attribute that - // is normally populated by the frontend middleware stack we deliberately bypass here. Marking it BE - // routes through BackendConfigurationManager instead, which is built to gracefully compute a - // (page-less, in our case) TypoScript setup without a real page tree - this is the same mechanism - // every Extbase backend module relies on. `applicationType` itself is not read anywhere in t3api's - // own code, so this only affects Extbase's internal TypoScript bootstrapping, not dispatch behaviour. - $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest('https://example.com/_api/books', 'GET')) - ->withAttribute('site', $site) - ->withAttribute('language', $site->getDefaultLanguage()) - ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE); } #[Test] @@ -415,68 +369,11 @@ private function insertBook(string $title): int private function dispatchCollectionGet(string $url = 'https://example.com/_api/books'): string { - $symfonyRequest = SymfonyRequest::create($url, 'GET'); - $requestContext = (new RequestContext())->fromRequest($symfonyRequest); - $response = new Response(); - - return $this->get(Bootstrap::class) - ->processOperationByRequest($requestContext, $symfonyRequest, $response); + return $this->dispatchGet($url); } private function dispatchBookItemGet(int $uid): string { - $symfonyRequest = SymfonyRequest::create('https://example.com/_api/books/' . $uid, 'GET'); - $requestContext = (new RequestContext())->fromRequest($symfonyRequest); - $response = new Response(); - - return $this->get(Bootstrap::class) - ->processOperationByRequest($requestContext, $symfonyRequest, $response); - } - - /** - * Writes the site configuration file directly instead of going through - * `TYPO3\CMS\Core\Configuration\SiteWriter`, which only exists from TYPO3 13.1 onwards - * (in earlier versions writing was a responsibility of `SiteConfiguration` itself) - this - * keeps the test working across the whole TYPO3 12/13/14 support range. - */ - private function writeSiteConfiguration(): void - { - $siteConfigurationDirectory = Environment::getConfigPath() . '/sites/' . self::SITE_IDENTIFIER; - GeneralUtility::mkdir_deep($siteConfigurationDirectory); - file_put_contents($siteConfigurationDirectory . '/config.yaml', Yaml::dump([ - 'rootPageId' => 1, - 'base' => '/', - 'languages' => [ - 0 => [ - 'title' => 'English', - 'enabled' => true, - 'languageId' => 0, - 'base' => '/', - 'locale' => 'en_US.UTF-8', - 'navigationTitle' => 'English', - ], - ], - 'routeEnhancers' => [ - 'T3apiResourceEnhancer' => [ - 'type' => 'T3apiResourceEnhancer', - 'basePath' => '_api', - ], - ], - ], 99, 2)); - - $this->flushSiteConfigurationCaches(); - } - - /** - * `SiteConfiguration` caches its resolved sites under the fixed identifier `sites-configuration` - * in both the `core` and `runtime` caches; a direct file write bypasses the invalidation that - * `SiteWriter::write()`/`SiteConfiguration::write()` would otherwise trigger via - * `SiteConfigurationChangedEvent`, so both entries are removed explicitly here. - */ - private function flushSiteConfigurationCaches(): void - { - $cacheManager = $this->get(CacheManager::class); - $cacheManager->getCache('core')->remove('sites-configuration'); - $cacheManager->getCache('runtime')->remove('sites-configuration'); + return $this->dispatchGet('https://example.com/_api/books/' . $uid); } } diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Author.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Author.php similarity index 90% rename from Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Author.php rename to Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Author.php index a764aae..66f0174 100644 --- a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Author.php +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Author.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace T3apiTests\ResponseCacheTest\Domain\Model; +namespace T3apiTests\FunctionalTest\Domain\Model; use TYPO3\CMS\Extbase\DomainObject\AbstractEntity; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Book.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Book.php similarity index 95% rename from Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Book.php rename to Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Book.php index cbaed4e..f7524b2 100644 --- a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Book.php +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Book.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace T3apiTests\ResponseCacheTest\Domain\Model; +namespace T3apiTests\FunctionalTest\Domain\Model; use SourceBroker\T3api\Annotation\ApiResource; use TYPO3\CMS\Extbase\DomainObject\AbstractEntity; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Category.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Category.php new file mode 100644 index 0000000..350ecab --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Category.php @@ -0,0 +1,17 @@ +title; + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Product.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Product.php new file mode 100644 index 0000000..3010e2e --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Product.php @@ -0,0 +1,70 @@ +title; + } + + public function getCategory(): ?Category + { + return $this->category; + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Filter/CategoryOrderFilter.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Filter/CategoryOrderFilter.php new file mode 100644 index 0000000..9ceb982 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Filter/CategoryOrderFilter.php @@ -0,0 +1,68 @@ +active = (bool)(is_array($values) ? reset($values) : $values); + + return null; + } + + public function modifyQuery(QueryInterface $query, ApiFilter $apiFilter, CollectionOperation $operation): void + { + $active = $this->active; + $this->active = false; + + if (!$active || !$query instanceof Query) { + return; + } + + $statement = $query->getStatement(); + if ($statement !== null && $statement->getStatement() instanceof QueryBuilder) { + $queryBuilder = $statement->getStatement(); + $queryBuilder->getConcreteQueryBuilder() + ->addOrderBy($this->buildCategoryOrderExpression($queryBuilder, $query)); + + return; + } + + $queryBuilder = GeneralUtility::makeInstance(Typo3DbQueryParser::class) + ->convertQueryToDoctrineQueryBuilder($query); + $queryBuilder->getConcreteQueryBuilder() + ->orderBy($this->buildCategoryOrderExpression($queryBuilder, $query)); + + $query->statement($queryBuilder); + } + + private function buildCategoryOrderExpression(QueryBuilder $queryBuilder, Query $query): string + { + return $queryBuilder->quoteIdentifier($this->getTableName($query) . '.category'); + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Filter/FixedOrderFilter.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Filter/FixedOrderFilter.php new file mode 100644 index 0000000..66b1668 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Filter/FixedOrderFilter.php @@ -0,0 +1,32 @@ +maxLength = (int)(is_array($values) ? reset($values) : $values); + + return null; + } + + public function modifyQuery(QueryInterface $query, ApiFilter $apiFilter, CollectionOperation $operation): void + { + $maxLength = $this->maxLength; + $this->maxLength = 0; + + if ($maxLength <= 0 || !$query instanceof Query) { + return; + } + + $statement = $query->getStatement(); + if ($statement !== null && $statement->getStatement() instanceof QueryBuilder) { + $queryBuilder = $statement->getStatement(); + $queryBuilder->andWhere($this->buildTitleLengthCondition($queryBuilder, $query, $maxLength)); + + return; + } + + $queryBuilder = GeneralUtility::makeInstance(Typo3DbQueryParser::class) + ->convertQueryToDoctrineQueryBuilder($query); + $queryBuilder->andWhere($this->buildTitleLengthCondition($queryBuilder, $query, $maxLength)); + + $query->statement($queryBuilder); + } + + private function buildTitleLengthCondition(QueryBuilder $queryBuilder, Query $query, int $maxLength): string + { + return 'LENGTH(' . $queryBuilder->quoteIdentifier($this->getTableName($query) . '.title') . ') <= ' . $maxLength; + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Tca/TcaFixtureBuilder.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Tca/TcaFixtureBuilder.php new file mode 100644 index 0000000..1116ab5 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Tca/TcaFixtureBuilder.php @@ -0,0 +1,33 @@ + [ + 'title' => $title, + 'label' => $labelField, + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'hidden', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + ], + 'columns' => $columns, + 'types' => [ + '0' => ['showitem' => $showitem], + ], + ]; + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/Services.yaml b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/Services.yaml similarity index 84% rename from Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/Services.yaml rename to Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/Services.yaml index 7aead45..6a2c3a7 100644 --- a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/Services.yaml +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/Services.yaml @@ -4,7 +4,7 @@ services: autoconfigure: true public: false - T3apiTests\ResponseCacheTest\: + T3apiTests\FunctionalTest\: resource: '../Classes/*' SourceBroker\T3api\Dispatcher\Bootstrap: diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_author.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_author.php new file mode 100644 index 0000000..e49ad83 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_author.php @@ -0,0 +1,17 @@ + [ + 'label' => 'Name', + 'config' => [ + 'type' => 'input', + ], + ], + ], + 'name', + 'name' +); diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_book.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_book.php new file mode 100644 index 0000000..1be3ce4 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_book.php @@ -0,0 +1,26 @@ + [ + 'label' => 'Title', + 'config' => [ + 'type' => 'input', + ], + ], + 'author' => [ + 'label' => 'Author', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'foreign_table' => 'tx_functionaltest_domain_model_author', + 'foreign_table_where' => 'ORDER BY tx_functionaltest_domain_model_author.name ASC', + 'default' => 0, + ], + ], + ], + 'title, author' +); diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_category.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_category.php new file mode 100644 index 0000000..350fef1 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_category.php @@ -0,0 +1,16 @@ + [ + 'label' => 'Title', + 'config' => [ + 'type' => 'input', + ], + ], + ], + 'title' +); diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_product.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_product.php new file mode 100644 index 0000000..da72947 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_product.php @@ -0,0 +1,25 @@ + [ + 'label' => 'Title', + 'config' => [ + 'type' => 'input', + ], + ], + 'category' => [ + 'label' => 'Category', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'foreign_table' => 'tx_functionaltest_domain_model_category', + 'default' => 0, + ], + ], + ], + 'title, category' +); diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/composer.json b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/composer.json similarity index 60% rename from Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/composer.json rename to Tests/Functional/Fixtures/Extensions/t3api_functional_test/composer.json index 39ac581..0f93c8c 100644 --- a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/composer.json +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/composer.json @@ -1,5 +1,5 @@ { - "name": "t3api/response-cache-test", + "name": "t3api/functional-test", "type": "typo3-cms-extension", "license": "GPL-2.0-or-later", "require": { @@ -7,12 +7,12 @@ }, "autoload": { "psr-4": { - "T3apiTests\\ResponseCacheTest\\": "Classes/" + "T3apiTests\\FunctionalTest\\": "Classes/" } }, "extra": { "typo3/cms": { - "extension-key": "t3api_response_cache_test" + "extension-key": "t3api_functional_test" } } } diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_emconf.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_emconf.php similarity index 81% rename from Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_emconf.php rename to Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_emconf.php index bf3767a..1b33bb3 100644 --- a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_emconf.php +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_emconf.php @@ -2,7 +2,7 @@ /** @var string $_EXTKEY */ $EM_CONF[$_EXTKEY] = [ - 'title' => 't3api response cache test fixture', + 'title' => 't3api functional test fixture', 'category' => 'example', 'state' => 'stable', 'version' => '1.0.0', diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql new file mode 100644 index 0000000..94e7af6 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql @@ -0,0 +1,17 @@ +CREATE TABLE tx_functionaltest_domain_model_book ( + title varchar(255) DEFAULT '' NOT NULL, + author int(11) unsigned DEFAULT '0' NOT NULL +); + +CREATE TABLE tx_functionaltest_domain_model_author ( + name varchar(255) DEFAULT '' NOT NULL +); + +CREATE TABLE tx_functionaltest_domain_model_product ( + title varchar(255) DEFAULT '' NOT NULL, + category int(11) unsigned DEFAULT '0' NOT NULL +); + +CREATE TABLE tx_functionaltest_domain_model_category ( + title varchar(255) DEFAULT '' NOT NULL +); diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_author.php b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_author.php deleted file mode 100644 index e101ac7..0000000 --- a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_author.php +++ /dev/null @@ -1,25 +0,0 @@ - [ - 'title' => 'Author', - 'label' => 'name', - 'delete' => 'deleted', - 'enablecolumns' => [ - 'disabled' => 'hidden', - 'starttime' => 'starttime', - 'endtime' => 'endtime', - ], - ], - 'columns' => [ - 'name' => [ - 'label' => 'Name', - 'config' => [ - 'type' => 'input', - ], - ], - ], - 'types' => [ - '0' => ['showitem' => 'name'], - ], -]; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_book.php b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_book.php deleted file mode 100644 index e167d88..0000000 --- a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_book.php +++ /dev/null @@ -1,35 +0,0 @@ - [ - 'title' => 'Book', - 'label' => 'title', - 'delete' => 'deleted', - 'enablecolumns' => [ - 'disabled' => 'hidden', - 'starttime' => 'starttime', - 'endtime' => 'endtime', - ], - ], - 'columns' => [ - 'title' => [ - 'label' => 'Title', - 'config' => [ - 'type' => 'input', - ], - ], - 'author' => [ - 'label' => 'Author', - 'config' => [ - 'type' => 'select', - 'renderType' => 'selectSingle', - 'foreign_table' => 'tx_responsecachetest_domain_model_author', - 'foreign_table_where' => 'ORDER BY tx_responsecachetest_domain_model_author.name ASC', - 'default' => 0, - ], - ], - ], - 'types' => [ - '0' => ['showitem' => 'title, author'], - ], -]; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_tables.sql b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_tables.sql deleted file mode 100644 index 7f7cadc..0000000 --- a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_tables.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE tx_responsecachetest_domain_model_book ( - title varchar(255) DEFAULT '' NOT NULL, - author int(11) unsigned DEFAULT '0' NOT NULL -); - -CREATE TABLE tx_responsecachetest_domain_model_author ( - name varchar(255) DEFAULT '' NOT NULL -); diff --git a/Tests/Functional/Fixtures/authors.csv b/Tests/Functional/Fixtures/authors.csv index cf73fe5..1bb0ab7 100644 --- a/Tests/Functional/Fixtures/authors.csv +++ b/Tests/Functional/Fixtures/authors.csv @@ -1,4 +1,4 @@ -"tx_responsecachetest_domain_model_author",, +"tx_functionaltest_domain_model_author",, ,"uid","pid","name" ,1,0,"Author A" ,2,0,"Author B" diff --git a/Tests/Functional/Fixtures/books.csv b/Tests/Functional/Fixtures/books.csv index 1a6f97c..fb8e413 100644 --- a/Tests/Functional/Fixtures/books.csv +++ b/Tests/Functional/Fixtures/books.csv @@ -1,4 +1,4 @@ -"tx_responsecachetest_domain_model_book",,,, +"tx_functionaltest_domain_model_book",,,, ,"uid","pid","title","author" ,1,0,"First book",1 ,2,0,"Second book",2 diff --git a/Tests/Functional/Fixtures/categories.csv b/Tests/Functional/Fixtures/categories.csv new file mode 100644 index 0000000..d196cfb --- /dev/null +++ b/Tests/Functional/Fixtures/categories.csv @@ -0,0 +1,5 @@ +"tx_functionaltest_domain_model_category",,, +,"uid","pid","title" +,1,0,"Kitchen" +,2,0,"Garden" +,3,0,"Decor" diff --git a/Tests/Functional/Fixtures/products.csv b/Tests/Functional/Fixtures/products.csv new file mode 100644 index 0000000..c8e4358 --- /dev/null +++ b/Tests/Functional/Fixtures/products.csv @@ -0,0 +1,302 @@ +"tx_functionaltest_domain_model_product",,,, +,"uid","pid","title","category" +,1,0,"Alfa",1 +,2,0,"Brie",1 +,3,0,"Cedar",1 +,4,0,"Dune",1 +,5,0,"Ebony",2 +,6,0,"Fig",2 +,7,0,"Grapefruit",2 +,8,0,"Hazelnut",2 +,9,0,"Iceberg lettuce",3 +,10,0,"Jackfruit",3 +,11,0,"Kiwi",3 +,12,0,"Lemongrass",3 +,13,0,"Amber Basket",1 +,14,0,"Amber Bottle",2 +,15,0,"Amber Candle",3 +,16,0,"Amber Chair",1 +,17,0,"Amber Clock",2 +,18,0,"Amber Cup",3 +,19,0,"Amber Jar",1 +,20,0,"Amber Kettle",2 +,21,0,"Amber Lamp",3 +,22,0,"Amber Mirror",1 +,23,0,"Amber Pillow",2 +,24,0,"Amber Plate",3 +,25,0,"Amber Shelf",1 +,26,0,"Amber Spoon",2 +,27,0,"Amber Tray",3 +,28,0,"Amber Vase",1 +,29,0,"Bright Basket",2 +,30,0,"Bright Bottle",3 +,31,0,"Bright Candle",1 +,32,0,"Bright Chair",2 +,33,0,"Bright Clock",3 +,34,0,"Bright Cup",1 +,35,0,"Bright Jar",2 +,36,0,"Bright Kettle",3 +,37,0,"Bright Lamp",1 +,38,0,"Bright Mirror",2 +,39,0,"Bright Pillow",3 +,40,0,"Bright Plate",1 +,41,0,"Bright Shelf",2 +,42,0,"Bright Spoon",3 +,43,0,"Bright Tray",1 +,44,0,"Bright Vase",2 +,45,0,"Coastal Basket",3 +,46,0,"Coastal Bottle",1 +,47,0,"Coastal Candle",2 +,48,0,"Coastal Chair",3 +,49,0,"Coastal Clock",1 +,50,0,"Coastal Cup",2 +,51,0,"Coastal Jar",3 +,52,0,"Coastal Kettle",1 +,53,0,"Coastal Lamp",2 +,54,0,"Coastal Mirror",3 +,55,0,"Coastal Pillow",1 +,56,0,"Coastal Plate",2 +,57,0,"Coastal Shelf",3 +,58,0,"Coastal Spoon",1 +,59,0,"Coastal Tray",2 +,60,0,"Coastal Vase",3 +,61,0,"Dusty Basket",1 +,62,0,"Dusty Bottle",2 +,63,0,"Dusty Candle",3 +,64,0,"Dusty Chair",1 +,65,0,"Dusty Clock",2 +,66,0,"Dusty Cup",3 +,67,0,"Dusty Jar",1 +,68,0,"Dusty Kettle",2 +,69,0,"Dusty Lamp",3 +,70,0,"Dusty Mirror",1 +,71,0,"Dusty Pillow",2 +,72,0,"Dusty Plate",3 +,73,0,"Dusty Shelf",1 +,74,0,"Dusty Spoon",2 +,75,0,"Dusty Tray",3 +,76,0,"Dusty Vase",1 +,77,0,"Emerald Basket",2 +,78,0,"Emerald Bottle",3 +,79,0,"Emerald Candle",1 +,80,0,"Emerald Chair",2 +,81,0,"Emerald Clock",3 +,82,0,"Emerald Cup",1 +,83,0,"Emerald Jar",2 +,84,0,"Emerald Kettle",3 +,85,0,"Emerald Lamp",1 +,86,0,"Emerald Mirror",2 +,87,0,"Emerald Pillow",3 +,88,0,"Emerald Plate",1 +,89,0,"Emerald Shelf",2 +,90,0,"Emerald Spoon",3 +,91,0,"Emerald Tray",1 +,92,0,"Emerald Vase",2 +,93,0,"Frosted Basket",3 +,94,0,"Frosted Bottle",1 +,95,0,"Frosted Candle",2 +,96,0,"Frosted Chair",3 +,97,0,"Frosted Clock",1 +,98,0,"Frosted Cup",2 +,99,0,"Frosted Jar",3 +,100,0,"Frosted Kettle",1 +,101,0,"Frosted Lamp",2 +,102,0,"Frosted Mirror",3 +,103,0,"Frosted Pillow",1 +,104,0,"Frosted Plate",2 +,105,0,"Frosted Shelf",3 +,106,0,"Frosted Spoon",1 +,107,0,"Frosted Tray",2 +,108,0,"Frosted Vase",3 +,109,0,"Golden Basket",1 +,110,0,"Golden Bottle",2 +,111,0,"Golden Candle",3 +,112,0,"Golden Chair",1 +,113,0,"Golden Clock",2 +,114,0,"Golden Cup",3 +,115,0,"Golden Jar",1 +,116,0,"Golden Kettle",2 +,117,0,"Golden Lamp",3 +,118,0,"Golden Mirror",1 +,119,0,"Golden Pillow",2 +,120,0,"Golden Plate",3 +,121,0,"Golden Shelf",1 +,122,0,"Golden Spoon",2 +,123,0,"Golden Tray",3 +,124,0,"Golden Vase",1 +,125,0,"Hazel Basket",2 +,126,0,"Hazel Bottle",3 +,127,0,"Hazel Candle",1 +,128,0,"Hazel Chair",2 +,129,0,"Hazel Clock",3 +,130,0,"Hazel Cup",1 +,131,0,"Hazel Jar",2 +,132,0,"Hazel Kettle",3 +,133,0,"Hazel Lamp",1 +,134,0,"Hazel Mirror",2 +,135,0,"Hazel Pillow",3 +,136,0,"Hazel Plate",1 +,137,0,"Hazel Shelf",2 +,138,0,"Hazel Spoon",3 +,139,0,"Hazel Tray",1 +,140,0,"Hazel Vase",2 +,141,0,"Ivory Basket",3 +,142,0,"Ivory Bottle",1 +,143,0,"Ivory Candle",2 +,144,0,"Ivory Chair",3 +,145,0,"Ivory Clock",1 +,146,0,"Ivory Cup",2 +,147,0,"Ivory Jar",3 +,148,0,"Ivory Kettle",1 +,149,0,"Ivory Lamp",2 +,150,0,"Ivory Mirror",3 +,151,0,"Ivory Pillow",1 +,152,0,"Ivory Plate",2 +,153,0,"Ivory Shelf",3 +,154,0,"Ivory Spoon",1 +,155,0,"Ivory Tray",2 +,156,0,"Ivory Vase",3 +,157,0,"Jade Basket",1 +,158,0,"Jade Bottle",2 +,159,0,"Jade Candle",3 +,160,0,"Jade Chair",1 +,161,0,"Jade Clock",2 +,162,0,"Jade Cup",3 +,163,0,"Jade Jar",1 +,164,0,"Jade Kettle",2 +,165,0,"Jade Lamp",3 +,166,0,"Jade Mirror",1 +,167,0,"Jade Pillow",2 +,168,0,"Jade Plate",3 +,169,0,"Jade Shelf",1 +,170,0,"Jade Spoon",2 +,171,0,"Jade Tray",3 +,172,0,"Jade Vase",1 +,173,0,"Copper Basket",2 +,174,0,"Copper Bottle",3 +,175,0,"Copper Candle",1 +,176,0,"Copper Chair",2 +,177,0,"Copper Clock",3 +,178,0,"Copper Cup",1 +,179,0,"Copper Jar",2 +,180,0,"Copper Kettle",3 +,181,0,"Copper Lamp",1 +,182,0,"Copper Mirror",2 +,183,0,"Copper Pillow",3 +,184,0,"Copper Plate",1 +,185,0,"Copper Shelf",2 +,186,0,"Copper Spoon",3 +,187,0,"Copper Tray",1 +,188,0,"Copper Vase",2 +,189,0,"Linen Basket",3 +,190,0,"Linen Bottle",1 +,191,0,"Linen Candle",2 +,192,0,"Linen Chair",3 +,193,0,"Linen Clock",1 +,194,0,"Linen Cup",2 +,195,0,"Linen Jar",3 +,196,0,"Linen Kettle",1 +,197,0,"Linen Lamp",2 +,198,0,"Linen Mirror",3 +,199,0,"Linen Pillow",1 +,200,0,"Linen Plate",2 +,201,0,"Linen Shelf",3 +,202,0,"Linen Spoon",1 +,203,0,"Linen Tray",2 +,204,0,"Linen Vase",3 +,205,0,"Mellow Basket",1 +,206,0,"Mellow Bottle",2 +,207,0,"Mellow Candle",3 +,208,0,"Mellow Chair",1 +,209,0,"Mellow Clock",2 +,210,0,"Mellow Cup",3 +,211,0,"Mellow Jar",1 +,212,0,"Mellow Kettle",2 +,213,0,"Mellow Lamp",3 +,214,0,"Mellow Mirror",1 +,215,0,"Mellow Pillow",2 +,216,0,"Mellow Plate",3 +,217,0,"Mellow Shelf",1 +,218,0,"Mellow Spoon",2 +,219,0,"Mellow Tray",3 +,220,0,"Mellow Vase",1 +,221,0,"Nordic Basket",2 +,222,0,"Nordic Bottle",3 +,223,0,"Nordic Candle",1 +,224,0,"Nordic Chair",2 +,225,0,"Nordic Clock",3 +,226,0,"Nordic Cup",1 +,227,0,"Nordic Jar",2 +,228,0,"Nordic Kettle",3 +,229,0,"Nordic Lamp",1 +,230,0,"Nordic Mirror",2 +,231,0,"Nordic Pillow",3 +,232,0,"Nordic Plate",1 +,233,0,"Nordic Shelf",2 +,234,0,"Nordic Spoon",3 +,235,0,"Nordic Tray",1 +,236,0,"Nordic Vase",2 +,237,0,"Olive Basket",3 +,238,0,"Olive Bottle",1 +,239,0,"Olive Candle",2 +,240,0,"Olive Chair",3 +,241,0,"Olive Clock",1 +,242,0,"Olive Cup",2 +,243,0,"Olive Jar",3 +,244,0,"Olive Kettle",1 +,245,0,"Olive Lamp",2 +,246,0,"Olive Mirror",3 +,247,0,"Olive Pillow",1 +,248,0,"Olive Plate",2 +,249,0,"Olive Shelf",3 +,250,0,"Olive Spoon",1 +,251,0,"Olive Tray",2 +,252,0,"Olive Vase",3 +,253,0,"Pearl Basket",1 +,254,0,"Pearl Bottle",2 +,255,0,"Pearl Candle",3 +,256,0,"Pearl Chair",1 +,257,0,"Pearl Clock",2 +,258,0,"Pearl Cup",3 +,259,0,"Pearl Jar",1 +,260,0,"Pearl Kettle",2 +,261,0,"Pearl Lamp",3 +,262,0,"Pearl Mirror",1 +,263,0,"Pearl Pillow",2 +,264,0,"Pearl Plate",3 +,265,0,"Pearl Shelf",1 +,266,0,"Pearl Spoon",2 +,267,0,"Pearl Tray",3 +,268,0,"Pearl Vase",1 +,269,0,"Rustic Basket",2 +,270,0,"Rustic Bottle",3 +,271,0,"Rustic Candle",1 +,272,0,"Rustic Chair",2 +,273,0,"Rustic Clock",3 +,274,0,"Rustic Cup",1 +,275,0,"Rustic Jar",2 +,276,0,"Rustic Kettle",3 +,277,0,"Rustic Lamp",1 +,278,0,"Rustic Mirror",2 +,279,0,"Rustic Pillow",3 +,280,0,"Rustic Plate",1 +,281,0,"Rustic Shelf",2 +,282,0,"Rustic Spoon",3 +,283,0,"Rustic Tray",1 +,284,0,"Rustic Vase",2 +,285,0,"Silver Basket",3 +,286,0,"Silver Bottle",1 +,287,0,"Silver Candle",2 +,288,0,"Silver Chair",3 +,289,0,"Silver Clock",1 +,290,0,"Silver Cup",2 +,291,0,"Silver Jar",3 +,292,0,"Silver Kettle",1 +,293,0,"Silver Lamp",2 +,294,0,"Silver Mirror",3 +,295,0,"Silver Pillow",1 +,296,0,"Silver Plate",2 +,297,0,"Silver Shelf",3 +,298,0,"Silver Spoon",1 +,299,0,"Silver Tray",2 +,300,0,"Silver Vase",3 diff --git a/Tests/Unit/Filter/AbstractOrderedUidsFilterTest.php b/Tests/Unit/Filter/AbstractOrderedUidsFilterTest.php new file mode 100644 index 0000000..ee592eb --- /dev/null +++ b/Tests/Unit/Filter/AbstractOrderedUidsFilterTest.php @@ -0,0 +1,207 @@ +createMock(QueryInterface::class); + $query->expects(self::never())->method('in'); + + self::assertNull( + $this->createFilter()->filterProperty('search', null, $query, $this->createApiFilter()) + ); + } + + #[Test] + public function filterPropertyConstrainsQueryToResolvedUidsWithZeroGuard(): void + { + $constraint = $this->createMock(ConstraintInterface::class); + $query = $this->createMock(QueryInterface::class); + $query->expects(self::once()) + ->method('in') + ->with('uid', [5, 3, 9, 0]) + ->willReturn($constraint); + + self::assertSame( + $constraint, + $this->createFilter()->filterProperty('search', [5, 3, 9], $query, $this->createApiFilter()) + ); + } + + #[Test] + public function filterPropertyMatchesNothingWhenLookupReturnsNoUids(): void + { + $query = $this->createMock(QueryInterface::class); + $query->expects(self::once()) + ->method('in') + ->with('uid', [0]) + ->willReturn($this->createMock(ConstraintInterface::class)); + + $this->createFilter()->filterProperty('search', [], $query, $this->createApiFilter()); + } + + #[Test] + public function modifyQueryDoesNothingWhenNoUidsWereResolved(): void + { + $filter = $this->createFilter(); + $apiFilter = $this->createApiFilter(); + + $query = $this->createMock(Query::class); + $query->expects(self::never())->method('statement'); + + $filter->filterProperty('search', null, $query, $apiFilter); + $filter->modifyQuery($query, $apiFilter, $this->createOperation()); + } + + #[Test] + public function modifyQueryDoesNothingWhenQueryIsNotExtbaseGenericQuery(): void + { + $filter = $this->createFilter(); + $apiFilter = $this->createApiFilter(); + + $query = $this->createMock(QueryInterface::class); + $query->method('in')->willReturn($this->createMock(ConstraintInterface::class)); + $query->expects(self::never())->method('getSource'); + + $filter->filterProperty('search', [1, 2], $query, $apiFilter); + $filter->modifyQuery($query, $apiFilter, $this->createOperation()); + } + + #[Test] + public function modifyQueryAppliesFieldOrderingToConcreteQueryBuilder(): void + { + $filter = $this->createFilter(); + $apiFilter = $this->createApiFilter(); + + $query = $this->createQueryMock(); + + $doctrineQueryBuilder = $this->createMock(DoctrineQueryBuilder::class); + $doctrineQueryBuilder->expects(self::once()) + ->method('orderBy') + ->with('FIELD(`tx_foo_domain_model_item`.`uid`, 5,3,9)'); + + $queryBuilder = $this->createQueryBuilderMock($doctrineQueryBuilder); + + $queryParser = $this->createMock(Typo3DbQueryParser::class); + $queryParser->method('convertQueryToDoctrineQueryBuilder')->willReturnMap([[$query, $queryBuilder]]); + GeneralUtility::addInstance(Typo3DbQueryParser::class, $queryParser); + + $query->expects(self::once())->method('statement')->with($queryBuilder); + + $filter->filterProperty('search', [5, 3, 9], $query, $apiFilter); + $filter->modifyQuery($query, $apiFilter, $this->createOperation()); + } + + #[Test] + public function modifyQueryAppendsOrderingAsTieBreakerWhenQueryAlreadyCarriesQueryBuilderStatement(): void + { + $filter = $this->createFilter(); + $apiFilter = $this->createApiFilter(); + + $doctrineQueryBuilder = $this->createMock(DoctrineQueryBuilder::class); + $doctrineQueryBuilder->expects(self::never())->method('orderBy'); + $doctrineQueryBuilder->expects(self::once()) + ->method('addOrderBy') + ->with('FIELD(`tx_foo_domain_model_item`.`uid`, 7,8)'); + + $queryBuilder = $this->createQueryBuilderMock($doctrineQueryBuilder); + + $query = $this->createQueryMock(); + $query->method('getStatement')->willReturn(new Statement($queryBuilder)); + $query->expects(self::never())->method('statement'); + + $filter->filterProperty('search', [7, 8], $query, $apiFilter); + $filter->modifyQuery($query, $apiFilter, $this->createOperation()); + } + + #[Test] + public function modifyQueryTracksResolvedUidsPerApiFilterDeclaration(): void + { + $filter = $this->createFilter(); + $firstDeclaration = $this->createApiFilter(); + $secondDeclaration = $this->createApiFilter(); + + $query = $this->createQueryMock(); + + $doctrineQueryBuilder = $this->createMock(DoctrineQueryBuilder::class); + $doctrineQueryBuilder->expects(self::once()) + ->method('orderBy') + ->with('FIELD(`tx_foo_domain_model_item`.`uid`, 5,3)'); + + $queryBuilder = $this->createQueryBuilderMock($doctrineQueryBuilder); + + $queryParser = $this->createMock(Typo3DbQueryParser::class); + $queryParser->method('convertQueryToDoctrineQueryBuilder')->willReturn($queryBuilder); + GeneralUtility::addInstance(Typo3DbQueryParser::class, $queryParser); + + $filter->filterProperty('search', [5, 3], $query, $firstDeclaration); + $filter->filterProperty('search', [9], $query, $secondDeclaration); + + $filter->modifyQuery($query, $firstDeclaration, $this->createOperation()); + } + + private function createFilter(): AbstractOrderedUidsFilter + { + return new class () extends AbstractOrderedUidsFilter { + protected function resolveOrderedUids($values, ApiFilter $apiFilter): ?array + { + return $values; + } + }; + } + + private function createApiFilter(): ApiFilter + { + return new ApiFilter('filterClass', 'search', 'strategy', []); + } + + private function createOperation(): CollectionOperation + { + return $this->createMock(CollectionOperation::class); + } + + /** + * @return Query&\PHPUnit\Framework\MockObject\MockObject + */ + private function createQueryMock(): Query + { + $query = $this->createMock(Query::class); + $query->method('in')->willReturn($this->createMock(ConstraintInterface::class)); + $query->method('getSource')->willReturn(new Selector('tx_foo_domain_model_item', null)); + + return $query; + } + + /** + * @return QueryBuilder&\PHPUnit\Framework\MockObject\MockObject + */ + private function createQueryBuilderMock(DoctrineQueryBuilder $doctrineQueryBuilder): QueryBuilder + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->method('getConcreteQueryBuilder')->willReturn($doctrineQueryBuilder); + $queryBuilder->method('quoteIdentifier') + ->willReturnMap([['tx_foo_domain_model_item.uid', '`tx_foo_domain_model_item`.`uid`']]); + + return $queryBuilder; + } +} diff --git a/Tests/Unit/Response/AbstractCollectionResponseTest.php b/Tests/Unit/Response/AbstractCollectionResponseTest.php new file mode 100644 index 0000000..4086a1e --- /dev/null +++ b/Tests/Unit/Response/AbstractCollectionResponseTest.php @@ -0,0 +1,122 @@ +createQuery(); + $response = $this->createResponse($query, $this->createOperation(false)); + + self::assertSame($query, $response->callApplyPagination()); + } + + #[Test] + public function applyPaginationAppliesLimitAndOffsetOnQueryClone(): void + { + $query = $this->createQuery(); + $response = $this->createResponse($query, $this->createOperation(true, 10, 20)); + + $paginatedQuery = $response->callApplyPagination(); + + self::assertNotSame($query, $paginatedQuery); + self::assertSame(10, $paginatedQuery->getLimit()); + self::assertSame(20, $paginatedQuery->getOffset()); + self::assertNull($query->getLimit()); + } + + #[Test] + public function applyPaginationGivesQueryBuilderBackedStatementItsOwnQueryBuilderClone(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $query = $this->createQuery(); + $query->statement($queryBuilder, ['foo' => 'bar']); + + $response = $this->createResponse($query, $this->createOperation(true, 10, 20)); + + $paginatedQuery = $response->callApplyPagination(); + $paginatedStatement = $paginatedQuery->getStatement(); + + self::assertInstanceOf(QueryBuilder::class, $paginatedStatement->getStatement()); + self::assertNotSame($queryBuilder, $paginatedStatement->getStatement()); + self::assertSame(['foo' => 'bar'], $paginatedStatement->getBoundVariables()); + self::assertSame($queryBuilder, $query->getStatement()->getStatement()); + } + + #[Test] + public function applyPaginationLeavesRawSqlStatementUntouched(): void + { + $query = $this->createQuery(); + $query->statement('SELECT * FROM tx_foo_domain_model_item'); + + $response = $this->createResponse($query, $this->createOperation(true, 10, 20)); + + $paginatedQuery = $response->callApplyPagination(); + + self::assertSame($query->getStatement(), $paginatedQuery->getStatement()); + } + + private function createResponse(QueryInterface $query, CollectionOperation $operation): CollectionResponseFixture + { + return new CollectionResponseFixture($operation, Request::create('/items'), $query); + } + + private function createOperation( + bool $paginationEnabled, + int $itemsPerPage = 10, + int $offset = 0 + ): CollectionOperation { + $pagination = $this->createMock(Pagination::class); + $pagination->method('setParametersFromRequest')->willReturnSelf(); + $pagination->method('isEnabled')->willReturn($paginationEnabled); + $pagination->method('getNumberOfItemsPerPage')->willReturn($itemsPerPage); + $pagination->method('getOffset')->willReturn($offset); + + $operation = $this->createMock(CollectionOperation::class); + $operation->method('getPagination')->willReturn($pagination); + + return $operation; + } + + private function createQuery(): Query + { + return new Query( + $this->createMock(DataMapFactory::class), + $this->createMock(PersistenceManagerInterface::class), + new QueryObjectModelFactory(), + $this->createMock(ContainerInterface::class) + ); + } +} + +final class CollectionResponseFixture extends AbstractCollectionResponse +{ + public static function getOpenApiSchema(string $membersReference): Schema + { + return Schema::object(); + } + + public function callApplyPagination(): QueryInterface + { + return $this->applyPagination(); + } +} diff --git a/composer.json b/composer.json index 27ab17d..d2e2a04 100644 --- a/composer.json +++ b/composer.json @@ -55,7 +55,7 @@ "autoload-dev": { "psr-4": { "SourceBroker\\T3api\\Tests\\": "Tests", - "T3apiTests\\ResponseCacheTest\\": "Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes" + "T3apiTests\\FunctionalTest\\": "Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes" } }, "config": {