Skip to content

Commit 8886281

Browse files
authored
Fix double SQL finalization (#55)
1 parent 77ef1af commit 8886281

File tree

5 files changed

+95
-33
lines changed

5 files changed

+95
-33
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ class MaxExecutionTimeSqlWalker extends HintHandler
5151

5252
SqlNode is an enum of all `walkXxx` methods in Doctrine's SqlWalker, so you are able to intercept any part of AST processing the SqlWalker does.
5353

54+
### Limitations
55+
- Please note that since [doctrine/orm 3.3.0](https://github.com/doctrine/orm/pull/11188), the produced SQL gets finalized with `LIMIT` / `OFFSET` / `FOR UPDATE` after `SqlWalker` processing is done.
56+
- Thus, implementors should be aware that those SQL parts can be appended to the SQL after `HintHandler` processing.
57+
- This means that e.g. placing a comment at the end of the SQL breaks LIMIT functionality completely
58+
5459
### Implementors
5560
- [shipmonk/doctrine-mysql-optimizer-hints](https://github.com/shipmonk-rnd/doctrine-mysql-optimizer-hints) (since v2)
5661
- [shipmonk/doctrine-mysql-index-hints](https://github.com/shipmonk-rnd/doctrine-mysql-index-hints) (since v3)

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
"phpunit/phpunit": "10.5.36",
2020
"shipmonk/composer-dependency-analyser": "1.7.0",
2121
"shipmonk/phpstan-rules": "3.2.1",
22-
"slevomat/coding-standard": "8.15.0"
22+
"slevomat/coding-standard": "8.15.0",
23+
"symfony/cache": "^6.4.13",
24+
"symfony/cache-contracts": "^3.5.0"
2325
},
2426
"autoload": {
2527
"psr-4": {

phpcs.xml.dist

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,6 @@
269269
<rule ref="SlevomatCodingStandard.Classes.EmptyLinesAroundClassBraces"/>
270270
<rule ref="SlevomatCodingStandard.Classes.TraitUseDeclaration" />
271271
<rule ref="SlevomatCodingStandard.Classes.TraitUseSpacing" />
272-
<rule ref="SlevomatCodingStandard.Classes.DisallowConstructorPropertyPromotion" />
273272
<rule ref="SlevomatCodingStandard.Commenting.DocCommentSpacing">
274273
<properties>
275274
<property name="linesCountBeforeFirstContent" value="0"/>
@@ -305,7 +304,6 @@
305304
<rule ref="SlevomatCodingStandard.Functions.StaticClosure"/>
306305
<rule ref="SlevomatCodingStandard.Functions.UselessParameterDefaultValue"/>
307306
<rule ref="SlevomatCodingStandard.Functions.UnusedInheritedVariablePassedToClosure"/>
308-
<rule ref="SlevomatCodingStandard.Functions.DisallowArrowFunction"/>
309307
<rule ref="SlevomatCodingStandard.Namespaces.UseFromSameNamespace"/>
310308
<rule ref="SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses">
311309
<properties>

src/HintDrivenSqlWalker.php

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,15 @@
5353
use Doctrine\ORM\Query\AST\UpdateItem;
5454
use Doctrine\ORM\Query\AST\UpdateStatement;
5555
use Doctrine\ORM\Query\AST\WhereClause;
56-
use Doctrine\ORM\Query\Exec\PreparedExecutorFinalizer;
57-
use Doctrine\ORM\Query\Exec\SingleSelectSqlFinalizer;
58-
use Doctrine\ORM\Query\Exec\SqlFinalizer;
59-
use Doctrine\ORM\Query\OutputWalker;
6056
use Doctrine\ORM\Query\Parser;
61-
use Doctrine\ORM\Query\SqlWalker;
57+
use Doctrine\ORM\Query\SqlOutputWalker;
6258
use LogicException;
6359
use function is_a;
6460

6561
/**
6662
* @psalm-import-type QueryComponent from Parser
6763
*/
68-
class HintDrivenSqlWalker extends SqlWalker implements OutputWalker
64+
class HintDrivenSqlWalker extends SqlOutputWalker
6965
{
7066

7167
/**
@@ -95,20 +91,10 @@ public function __construct(
9591
}
9692
}
9793

98-
public function getFinalizer(DeleteStatement|UpdateStatement|SelectStatement $AST): SqlFinalizer
94+
protected function createSqlForFinalizer(SelectStatement $selectStatement): string
9995
{
100-
switch (true) {
101-
case $AST instanceof SelectStatement:
102-
return new SingleSelectSqlFinalizer($this->walkSelectStatement($AST));
103-
104-
case $AST instanceof UpdateStatement:
105-
return new PreparedExecutorFinalizer($this->createUpdateStatementExecutor($AST));
106-
107-
case $AST instanceof DeleteStatement: // @phpstan-ignore instanceof.alwaysTrue (keep it readable)
108-
return new PreparedExecutorFinalizer($this->createDeleteStatementExecutor($AST));
109-
}
110-
111-
throw new LogicException('Unexpected AST node type');
96+
$selectStatementSql = parent::createSqlForFinalizer($selectStatement);
97+
return $this->callWalkers(SqlNode::SelectStatement, $selectStatementSql);
11298
}
11399

114100
public function walkSelectStatement(SelectStatement $AST): string

tests/HintDrivenSqlWalkerTest.php

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,63 +14,133 @@
1414
use PHPUnit\Framework\TestCase;
1515
use ShipMonk\Doctrine\Walker\Handlers\CommentWholeSqlHintHandler;
1616
use ShipMonk\Doctrine\Walker\Handlers\LowercaseSelectHintHandler;
17-
use function sprintf;
17+
use Symfony\Component\Cache\Adapter\ArrayAdapter;
1818

1919
class HintDrivenSqlWalkerTest extends TestCase
2020
{
2121

2222
/**
23+
* @param callable(EntityManager):Query $queryCallback
2324
* @dataProvider walksProvider
2425
*/
2526
public function testWalker(
26-
string $dql,
27+
callable $queryCallback,
2728
string $handlerClass,
2829
mixed $hintValue,
2930
string $expectedSql,
3031
): void
3132
{
3233
$entityManagerMock = $this->createEntityManagerMock();
3334

34-
$query = new Query($entityManagerMock);
35-
$query->setDQL($dql);
36-
35+
$query = $queryCallback($entityManagerMock);
3736
$query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, HintDrivenSqlWalker::class);
3837
$query->setHint($handlerClass, $hintValue);
3938
$producedSql = $query->getSQL();
4039

4140
self::assertSame($expectedSql, $producedSql);
4241
}
4342

43+
public function testPagination(): void
44+
{
45+
$pageSize = 10;
46+
$entityManagerMock = $this->createEntityManagerMock();
47+
48+
self::assertNotNull(
49+
$entityManagerMock->getConfiguration()->getQueryCache(),
50+
'QueryCache needed. The purpose of this test is to ensure that we do not break the pagination by using a cache',
51+
);
52+
53+
$expectedSqls = [
54+
'select d0_.id AS id_0 FROM dummy_entity d0_ LIMIT 10',
55+
'select d0_.id AS id_0 FROM dummy_entity d0_ LIMIT 10 OFFSET 10',
56+
'select d0_.id AS id_0 FROM dummy_entity d0_ LIMIT 10 OFFSET 20',
57+
];
58+
59+
foreach ([0, 1, 2] as $page) {
60+
$query = $entityManagerMock->createQueryBuilder()
61+
->select('w')
62+
->from(DummyEntity::class, 'w')
63+
->getQuery()
64+
->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, HintDrivenSqlWalker::class)
65+
->setHint(LowercaseSelectHintHandler::class, null)
66+
->setFirstResult($page * $pageSize)
67+
->setMaxResults($pageSize);
68+
$producedSql = $query->getSQL();
69+
70+
self::assertSame($expectedSqls[$page], $producedSql, 'Page ' . $page . ' failed:');
71+
}
72+
}
73+
4474
/**
45-
* @return Generator<string, array{string, class-string<HintHandler>, mixed, string}>
75+
* @return Generator<string, array{callable(EntityManager):Query, class-string<HintHandler>, mixed, string}>
4676
*/
4777
public static function walksProvider(): iterable
4878
{
49-
$selectDql = sprintf('SELECT w FROM %s w', DummyEntity::class);
79+
$selectQueryCallback = static function (EntityManager $entityManager): Query {
80+
return $entityManager->createQueryBuilder()
81+
->select('w')
82+
->from(DummyEntity::class, 'w')
83+
->getQuery();
84+
};
85+
86+
$selectWithLimitQueryCallback = static function (EntityManager $entityManager): Query {
87+
return $entityManager->createQueryBuilder()
88+
->select('w')
89+
->from(DummyEntity::class, 'w')
90+
->setMaxResults(1)
91+
->getQuery();
92+
};
93+
94+
$updateQueryCallback = static function (EntityManager $entityManager): Query {
95+
return $entityManager->createQueryBuilder()
96+
->update(DummyEntity::class, 'w')
97+
->set('w.id', 1)
98+
->getQuery();
99+
};
100+
101+
$deleteQueryCallback = static function (EntityManager $entityManager): Query {
102+
return $entityManager->createQueryBuilder()
103+
->delete(DummyEntity::class, 'w')
104+
->getQuery();
105+
};
50106

51107
yield 'Lowercase select' => [
52-
$selectDql,
108+
$selectQueryCallback,
53109
LowercaseSelectHintHandler::class,
54110
null,
55111
'select d0_.id AS id_0 FROM dummy_entity d0_',
56112
];
57113

114+
yield 'Lowercase select with LIMIT' => [
115+
$selectWithLimitQueryCallback,
116+
LowercaseSelectHintHandler::class,
117+
null,
118+
'select d0_.id AS id_0 FROM dummy_entity d0_ LIMIT 1',
119+
];
120+
58121
yield 'Comment whole sql - select' => [
59-
$selectDql,
122+
$selectQueryCallback,
60123
CommentWholeSqlHintHandler::class,
61124
'custom comment',
62125
'SELECT d0_.id AS id_0 FROM dummy_entity d0_ -- custom comment',
63126
];
64127

128+
yield 'Comment whole sql - select with LIMIT' => [
129+
$selectWithLimitQueryCallback,
130+
CommentWholeSqlHintHandler::class,
131+
'custom comment',
132+
'SELECT d0_.id AS id_0 FROM dummy_entity d0_ -- custom comment LIMIT 1', // see readme limitations
133+
];
134+
65135
yield 'Comment whole sql - update' => [
66-
sprintf('UPDATE %s w SET w.id = 1', DummyEntity::class),
136+
$updateQueryCallback,
67137
CommentWholeSqlHintHandler::class,
68138
'custom comment',
69139
'UPDATE dummy_entity SET id = 1 -- custom comment',
70140
];
71141

72142
yield 'Comment whole sql - delete' => [
73-
sprintf('DELETE FROM %s w', DummyEntity::class),
143+
$deleteQueryCallback,
74144
CommentWholeSqlHintHandler::class,
75145
'custom comment',
76146
'DELETE FROM dummy_entity -- custom comment',
@@ -82,6 +152,7 @@ private function createEntityManagerMock(): EntityManager
82152
$config = new Configuration();
83153
$config->setProxyNamespace('Tmp\Doctrine\Tests\Proxies');
84154
$config->setProxyDir('/tmp/doctrine');
155+
$config->setQueryCache(new ArrayAdapter());
85156
$config->setAutoGenerateProxyClasses(false);
86157
$config->setSecondLevelCacheEnabled(false);
87158
$config->setMetadataDriverImpl(new AttributeDriver([__DIR__]));

0 commit comments

Comments
 (0)