diff --git a/typo3/sysext/core/Classes/Database/Query/QueryBuilder.php b/typo3/sysext/core/Classes/Database/Query/QueryBuilder.php index 33b3595fe4f57cb2ad47424b04a81efea482ceb1..d3abf84394c2f4ef5640823cf9f21be2ed992d95 100644 --- a/typo3/sysext/core/Classes/Database/Query/QueryBuilder.php +++ b/typo3/sysext/core/Classes/Database/Query/QueryBuilder.php @@ -26,6 +26,7 @@ use Doctrine\DBAL\Query\Expression\CompositeExpression; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; use TYPO3\CMS\Core\Database\Query\Restriction\DefaultRestrictionContainer; +use TYPO3\CMS\Core\Database\Query\Restriction\LimitToTablesRestrictionContainer; use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -111,6 +112,16 @@ class QueryBuilder $this->restrictionContainer = $restrictionContainer; } + /** + * Limits ALL currently active restrictions of the restriction container to the table aliases given + * + * @param array $tableAliases + */ + public function limitRestrictionsToTables(array $tableAliases): void + { + $this->restrictionContainer = GeneralUtility::makeInstance(LimitToTablesRestrictionContainer::class)->addForTables($this->restrictionContainer, $tableAliases); + } + /** * Re-apply default restrictions */ diff --git a/typo3/sysext/core/Classes/Database/Query/Restriction/LimitToTablesRestrictionContainer.php b/typo3/sysext/core/Classes/Database/Query/Restriction/LimitToTablesRestrictionContainer.php new file mode 100644 index 0000000000000000000000000000000000000000..bd191ba813f2fbcdd43cfea50e19fce8b2312b8b --- /dev/null +++ b/typo3/sysext/core/Classes/Database/Query/Restriction/LimitToTablesRestrictionContainer.php @@ -0,0 +1,115 @@ +<?php + +declare(strict_types=1); +namespace TYPO3\CMS\Core\Database\Query\Restriction; + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression; +use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder; + +/** + * Restriction container that applies added restrictions only to the given table aliases. + * Enforced restrictions are treated equally to all other restrictions. + */ +class LimitToTablesRestrictionContainer implements QueryRestrictionContainerInterface +{ + /** + * @var QueryRestrictionInterface[] + */ + private $restrictions = []; + + /** + * @var QueryRestrictionContainerInterface[] + */ + private $restrictionContainer = []; + + /** + * @var array + */ + private $applicableTableAliases; + + public function removeAll(): QueryRestrictionContainerInterface + { + $this->applicableTableAliases = $this->restrictions = $this->restrictionContainer = []; + return $this; + } + + public function removeByType(string $restrictionType): QueryRestrictionContainerInterface + { + unset($this->applicableTableAliases[$restrictionType], $this->restrictions[$restrictionType]); + foreach ($this->restrictionContainer as $restrictionContainer) { + $restrictionContainer->removeByType($restrictionType); + } + return $this; + } + + public function add(QueryRestrictionInterface $restriction): QueryRestrictionContainerInterface + { + $this->restrictions[get_class($restriction)] = $restriction; + if ($restriction instanceof QueryRestrictionContainerInterface) { + $this->restrictionContainer[get_class($restriction)] = $restriction; + } + return $this; + } + + /** + * Adds the restriction, but also remembers which table aliases it should be applied to + * + * @param QueryRestrictionInterface $restriction + * @param array $tableAliases flat array of table aliases, not table names + * @return QueryRestrictionContainerInterface + */ + public function addForTables(QueryRestrictionInterface $restriction, array $tableAliases): QueryRestrictionContainerInterface + { + $this->applicableTableAliases[get_class($restriction)] = $tableAliases; + return $this->add($restriction); + } + + /** + * Main method to build expressions for given tables, but respecting configured filters. + * + * @param array $queriedTables Array of tables, where array key is table alias and value is a table name + * @param ExpressionBuilder $expressionBuilder Expression builder instance to add restrictions with + * @return CompositeExpression The result of query builder expression(s) + */ + public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression + { + $constraints = []; + foreach ($this->restrictions as $name => $restriction) { + $constraints[] = $restriction->buildExpression( + $this->filterApplicableTableAliases($queriedTables, $name), + $expressionBuilder + ); + } + return $expressionBuilder->andX(...$constraints); + } + + private function filterApplicableTableAliases(array $queriedTables, string $name): array + { + if (!isset($this->applicableTableAliases[$name])) { + return $queriedTables; + } + + $filteredTables = []; + foreach ($this->applicableTableAliases[$name] as $tableAlias) { + if (!isset($queriedTables[$tableAlias])) { + throw new \LogicException(sprintf('Applicable table alias "%s" is not in queried tables', $tableAlias), 1558354033); + } + $filteredTables[$tableAlias] = $queriedTables[$tableAlias]; + } + + return $filteredTables; + } +} diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-87776-LimitRestrictionToTablesInQueryBuilder.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-87776-LimitRestrictionToTablesInQueryBuilder.rst new file mode 100644 index 0000000000000000000000000000000000000000..d97a68c2fbf91ae6b8550e42897d98cc11d95230 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-87776-LimitRestrictionToTablesInQueryBuilder.rst @@ -0,0 +1,71 @@ +.. include:: ../../Includes.txt + +============================================================== +Feature: #87776 - Limit Restriction to table/s in QueryBuilder +============================================================== + +See :issue:`87776` + +Description +=========== + +In some cases it is needed to apply restrictions only to a certain table. +With the new `\TYPO3\CMS\Core\Database\Query\Restriction\LimitToTablesRestrictionContainer` +it is possible to apply restrictions to a query only for a given set of tables, or to be precise, table aliases. +Since it is a restriction container, it can be added to the restrictions of the query builder and +it can hold restrictions itself. The restrictions it holds can be limited to tables like this: + +Example implementation: +----------------------- + +.. code-block:: php + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content'); + $queryBuilder->getRestrictions() + ->removeByType(HiddenRestriction::class) + ->add( + GeneralUtility::makeInstance(LimitToTablesRestrictionContainer::class) + ->addForTables(GeneralUtility::makeInstance(HiddenRestriction::class), ['tt']) + ); + $queryBuilder->select('tt.uid', 'tt.header', 'sc.title') + ->from('tt_content', 'tt') + ->from('sys_category', 'sc') + ->from('sys_category_record_mm', 'scmm') + ->where( + $queryBuilder->expr()->eq('scmm.uid_foreign', $queryBuilder->quoteIdentifier('tt.uid')), + $queryBuilder->expr()->eq('scmm.uid_local', $queryBuilder->quoteIdentifier('sc.uid')), + $queryBuilder->expr()->eq('tt.uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)) + ); + +In this example the HiddenRestriction is only applied to `tt` table alias of `tt_content`. + +Furthermore it is possible to restrict the complete set of restrictions of a query builder to a +given set of table aliases + +.. code-block:: php + + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content'); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(HiddenRestriction::class)); + $queryBuilder->getRestrictions()->limitRestrictionsToTables(['c2']); + $queryBuilder + ->select('c1.*') + ->from('tt_content', 'c1') + ->leftJoin('c1', 'tt_content', 'c2', 'c1.parent_field = c2.uid') + ->orWhere($queryBuilder->expr()->isNull('c2.uid'), $queryBuilder->expr()->eq('c2.pid', $queryBuilder->createNamedParameter(1, \PDO::PARAM_INT))); + +Which will result in: + +.. code-block:: sql + + SELECT "c1".* + FROM "tt_content" "c1" + LEFT JOIN "tt_content" "c2" ON c1.parent_field = c2.uid + WHERE (("c2"."uid" IS NULL) OR ("c2"."pid" = 1)) AND ("c2"."hidden" = 0)) + +Impact +====== + +It is now easily possible to add restrictions that are only applied to certain tables/ table aliases, +by using `\TYPO3\CMS\Core\Database\Query\Restriction\LimitToTablesRestrictionContainer`. + +.. index:: Database, ext:core, API diff --git a/typo3/sysext/core/Tests/Unit/Database/Query/QueryBuilderTest.php b/typo3/sysext/core/Tests/Unit/Database/Query/QueryBuilderTest.php index 7b680142d11d79e81222671967f6aa5fe53a8b0f..40e44ab3d0d651416065c09cc54c4940a67105f8 100644 --- a/typo3/sysext/core/Tests/Unit/Database/Query/QueryBuilderTest.php +++ b/typo3/sysext/core/Tests/Unit/Database/Query/QueryBuilderTest.php @@ -1446,4 +1446,105 @@ class QueryBuilderTest extends UnitTestCase $this->connection->quoteIdentifier('aField')->shouldHaveBeenCalled(); self::assertSame($expectation, $result); } + + /** + * @test + */ + public function limitRestrictionsToTablesLimitsRestrictionsInTheContainerToTheGivenTables(): void + { + $GLOBALS['TCA']['tt_content']['ctrl'] = $GLOBALS['TCA']['pages']['ctrl'] = [ + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'hidden', + ], + ]; + + $this->connection->quoteIdentifier(Argument::cetera()) + ->willReturnArgument(0); + $this->connection->quoteIdentifiers(Argument::cetera()) + ->willReturnArgument(0); + + $connectionBuilder = GeneralUtility::makeInstance( + \Doctrine\DBAL\Query\QueryBuilder::class, + $this->connection->reveal() + ); + + $expressionBuilder = GeneralUtility::makeInstance(ExpressionBuilder::class, $this->connection->reveal()); + $this->connection->getExpressionBuilder()->willReturn($expressionBuilder); + + $subject = new QueryBuilder( + $this->connection->reveal(), + null, + $connectionBuilder + ); + $subject->limitRestrictionsToTables(['pages']); + + $subject->select('*') + ->from('pages') + ->leftJoin( + 'pages', + 'tt_content', + 'content', + 'pages.uid = content.pid' + ) + ->where($expressionBuilder->eq('uid', 1)); + + $this->connection->executeQuery( + 'SELECT * FROM pages LEFT JOIN tt_content content ON pages.uid = content.pid WHERE (uid = 1) AND ((pages.deleted = 0) AND (pages.hidden = 0))', + Argument::cetera() + )->shouldBeCalled(); + + $subject->execute(); + } + + /** + * @test + */ + public function restrictionsCanStillBeRemovedAfterTheyHaveBeenLimitedToTables(): void + { + $GLOBALS['TCA']['tt_content']['ctrl'] = $GLOBALS['TCA']['pages']['ctrl'] = [ + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'hidden', + ], + ]; + + $this->connection->quoteIdentifier(Argument::cetera()) + ->willReturnArgument(0); + $this->connection->quoteIdentifiers(Argument::cetera()) + ->willReturnArgument(0); + + $connectionBuilder = GeneralUtility::makeInstance( + \Doctrine\DBAL\Query\QueryBuilder::class, + $this->connection->reveal() + ); + + $expressionBuilder = GeneralUtility::makeInstance(ExpressionBuilder::class, $this->connection->reveal()); + $this->connection->getExpressionBuilder()->willReturn($expressionBuilder); + + $subject = new QueryBuilder( + $this->connection->reveal(), + null, + $connectionBuilder + ); + $subject->limitRestrictionsToTables(['pages']); + $subject->getRestrictions()->removeByType(DeletedRestriction::class); + + $subject->select('*') + ->from('pages') + ->leftJoin( + 'pages', + 'tt_content', + 'content', + 'pages.uid = content.pid' + ) + ->where($expressionBuilder->eq('uid', 1)); + + $this->connection->executeQuery( + 'SELECT * FROM pages LEFT JOIN tt_content content ON pages.uid = content.pid WHERE (uid = 1) AND (pages.hidden = 0)', + Argument::cetera() + )->shouldBeCalled(); + + $subject->execute(); + } } diff --git a/typo3/sysext/core/Tests/Unit/Database/Query/Restriction/LimitToTablesRestrictionContainerTest.php b/typo3/sysext/core/Tests/Unit/Database/Query/Restriction/LimitToTablesRestrictionContainerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c3355398573e50b81053fda5caaaee055b78ad4d --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Database/Query/Restriction/LimitToTablesRestrictionContainerTest.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); +namespace TYPO3\CMS\Core\Tests\Unit\Database\Query\Restriction; + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +use TYPO3\CMS\Core\Database\Query\Restriction\DefaultRestrictionContainer; +use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction; +use TYPO3\CMS\Core\Database\Query\Restriction\LimitToTablesRestrictionContainer; +use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface; + +class LimitToTablesRestrictionContainerTest extends AbstractRestrictionTestCase +{ + /** + * @test + */ + public function buildExpressionAddsRestrictionsOnlyToGivenAlias(): void + { + $GLOBALS['TCA']['bTable']['ctrl']['enablecolumns']['disabled'] = 'hidden'; + $subject = new LimitToTablesRestrictionContainer(); + $subject->addForTables(new HiddenRestriction(), ['bt']); + $expression = $subject->buildExpression(['aTable' => 'aTable', 'bTable' => 'bTable', 'bt' => 'bTable'], $this->expressionBuilder); + + self::assertSame('"bt"."hidden" = 0', (string)$expression); + } + + /** + * @test + */ + public function buildExpressionAddsRestrictionsOfDefaultRestrictionContainerOnlyToGivenAlias(): void + { + $GLOBALS['TCA']['bTable']['ctrl']['enablecolumns']['disabled'] = 'hidden'; + $GLOBALS['TCA']['bTable']['ctrl']['delete'] = 'deleted'; + $subject = new LimitToTablesRestrictionContainer(); + $subject->addForTables(new DefaultRestrictionContainer(), ['bt']); + $expression = $subject->buildExpression(['aTable' => 'aTable', 'bTable' => 'bTable', 'bt' => 'bTable'], $this->expressionBuilder); + + self::assertSame('("bt"."deleted" = 0) AND ("bt"."hidden" = 0)', (string)$expression); + } + + /** + * @test + */ + public function removeByTypeRemovesRestrictionsByTypeAlsoFromDefaultRestrictionContainer(): void + { + $GLOBALS['TCA']['bTable']['ctrl']['enablecolumns']['disabled'] = 'hidden'; + $GLOBALS['TCA']['bTable']['ctrl']['delete'] = 'deleted'; + $subject = new LimitToTablesRestrictionContainer(); + $subject->addForTables(new DefaultRestrictionContainer(), ['bt']); + $subject->removeByType(DeletedRestriction::class); + $expression = $subject->buildExpression(['aTable' => 'aTable', 'bTable' => 'bTable', 'bt' => 'bTable'], $this->expressionBuilder); + + self::assertSame('"bt"."hidden" = 0', (string)$expression); + } + + /** + * @test + */ + public function removeByTypeRemovesRestrictionsByTypeAlsoFromAnyRestrictionContainer(): void + { + $GLOBALS['TCA']['bTable']['ctrl']['enablecolumns']['disabled'] = 'hidden'; + $GLOBALS['TCA']['bTable']['ctrl']['delete'] = 'deleted'; + $subject = new LimitToTablesRestrictionContainer(); + $containerProphecy = $this->prophesize(QueryRestrictionContainerInterface::class); + $containerProphecy->removeByType(DeletedRestriction::class)->shouldBeCalled(); + $containerProphecy->buildExpression(['bt' => 'bTable'], $this->expressionBuilder)->willReturn($this->expressionBuilder->andX([]))->shouldBeCalled(); + $subject->addForTables($containerProphecy->reveal(), ['bt']); + $subject->removeByType(DeletedRestriction::class); + $subject->buildExpression(['aTable' => 'aTable', 'bTable' => 'bTable', 'bt' => 'bTable'], $this->expressionBuilder); + } + + /** + * @test + */ + public function buildRestrictionsThrowsExceptionWhenGivenAliasIsNotInQueriedTables(): void + { + $this->expectException(\LogicException::class); + $subject = new LimitToTablesRestrictionContainer(); + $subject->addForTables(new HiddenRestriction(), ['bt']); + $subject->buildExpression(['aTable' => 'aTable'], $this->expressionBuilder); + } +}