diff --git a/src/Sql/AbstractPreparableSql.php b/src/Sql/AbstractPreparableSql.php index 70f11f85..211e1367 100644 --- a/src/Sql/AbstractPreparableSql.php +++ b/src/Sql/AbstractPreparableSql.php @@ -33,6 +33,8 @@ public function prepareStatement( $statementContainer->setParameterContainer($parameterContainer); } + $this->resetRenderState(); + $statementContainer->setSql( $this->buildSqlString($adapter->getPlatform(), $adapter->getDriver(), $parameterContainer), ); diff --git a/src/Sql/AbstractSql.php b/src/Sql/AbstractSql.php index 5ea9aaad..f14fc358 100644 --- a/src/Sql/AbstractSql.php +++ b/src/Sql/AbstractSql.php @@ -56,9 +56,9 @@ abstract class AbstractSql implements SqlInterface /** * Information used during processing * - * @var array{paramPrefix: string, subselectCount: int} + * @var array{paramPrefix: string, subselectCount: int, expressionCount: int} */ - protected array $processInfo = ['paramPrefix' => '', 'subselectCount' => 0]; + protected array $processInfo = ['paramPrefix' => '', 'subselectCount' => 0, 'expressionCount' => 0]; /** @var array */ protected array $instanceParameterIndex = []; @@ -73,6 +73,8 @@ public function getSqlString(?PlatformInterface $adapterPlatform = null): string { $adapterPlatform ??= new DefaultAdapterPlatform(); + $this->resetRenderState(); + return $this->buildSqlString($adapterPlatform); } @@ -201,7 +203,6 @@ protected function localizeVariables(): void } /** - * @staticvar int $runtimeExpressionPrefix * @throws Exception\RuntimeException */ protected function processExpression( @@ -211,8 +212,6 @@ protected function processExpression( ?ParameterContainer $parameterContainer = null, ?string $namedParameterPrefix = null, ): string { - static $runtimeExpressionPrefix = 0; - $expressionData = $expression->getExpressionData(); $specification = $expressionData['spec']; $expressionValues = $expressionData['values']; @@ -223,7 +222,7 @@ protected function processExpression( $namedParameterPrefix = match (true) { null === $namedParameterPrefix || '' === $namedParameterPrefix => $parameterContainer - ? 'expr' . $runtimeExpressionPrefix++ . 'Param' + ? 'expr' . $this->processInfo['expressionCount']++ . 'Param' : '', default => $this->processInfo['paramPrefix'] . str_replace([' ', "\t", "\n", "\r"], replace: '__', subject: $namedParameterPrefix), @@ -412,11 +411,16 @@ protected function processSubSelect( if ($parameterContainer instanceof ParameterContainer) { $processInfoContext = $decorator instanceof PlatformDecoratorInterface ? $subselect : $decorator; $this->processInfo['subselectCount']++; - $processInfoContext->processInfo['subselectCount'] = $this->processInfo['subselectCount']; - $processInfoContext->processInfo['paramPrefix'] = "subselect{$processInfoContext->processInfo['subselectCount']}"; + $processInfoContext->processInfo = [ + 'paramPrefix' => "subselect{$this->processInfo['subselectCount']}", + 'subselectCount' => $this->processInfo['subselectCount'], + 'expressionCount' => $this->processInfo['expressionCount'], + ]; + $processInfoContext->instanceParameterIndex = []; - $sql = $decorator->buildSqlString($platform, $driver, $parameterContainer); - $this->processInfo['subselectCount'] = $decorator->processInfo['subselectCount']; + $sql = $decorator->buildSqlString($platform, $driver, $parameterContainer); + $this->processInfo['subselectCount'] = $decorator->processInfo['subselectCount']; + $this->processInfo['expressionCount'] = $decorator->processInfo['expressionCount']; return $sql; } @@ -434,6 +438,23 @@ protected function renderTable(string $table, ?string $alias = null): string return $alias ? "{$table} AS {$alias}" : $table; } + /** + * Clear the parameter naming state left by a previous render + * + * Called by the top-level render entry points so that rendering the same object again produces + * the same SQL and parameter names. A platform decorator also clears its subject, whose state it + * copies at the start of each render. + */ + protected function resetRenderState(): void + { + $this->processInfo = ['paramPrefix' => '', 'subselectCount' => 0, 'expressionCount' => 0]; + $this->instanceParameterIndex = []; + + if ($this instanceof PlatformDecoratorInterface && $this->subject instanceof self) { + $this->subject->resetRenderState(); + } + } + /** * @param ColumnReference|ColumnValue $column * @throws Exception\RuntimeException diff --git a/test/unit/Sql/RenderStateTest.php b/test/unit/Sql/RenderStateTest.php new file mode 100644 index 00000000..06c94016 --- /dev/null +++ b/test/unit/Sql/RenderStateTest.php @@ -0,0 +1,204 @@ +}> */ + public static function statementProvider(): array + { + return [ + 'select' => [ + static fn(): PreparableSqlInterface => (new Select('t'))->columns([ + 'id', + 'n' => new Expression('COALESCE(x, ?)', [0]), + ]) + ->where(['a' => 1, 'b' => 2]) + ->where([new In('c', (new Select('u'))->columns(['id'])->where(['d' => 3]))]) + ->order(new Expression('FIELD(id, ?)', [4])) + ->limit(10) + ->offset(20), + 'SELECT "t"."id" AS "id", COALESCE(x, :n1) AS "n" FROM "t" WHERE "a" = :where1 AND "b" = :where2' + . ' AND "c" IN (SELECT "u"."id" AS "id" FROM "u" WHERE "d" = :subselect1where1)' + . ' ORDER BY FIELD(id, :expr0Param1) LIMIT :limit OFFSET :offset', + [ + 'n1' => 0, + 'where1' => 1, + 'where2' => 2, + 'subselect1where1' => 3, + 'expr0Param1' => 4, + 'limit' => 10, + 'offset' => 20, + ], + ], + 'update' => [ + static fn(): PreparableSqlInterface => (new Update('t'))->set([ + 'a' => 1, + 'b' => new Expression('b + ?', [2]), + ]) + ->where(['c' => 3]) + ->where([new In('d', (new Select('u'))->columns(['id'])->where(['e' => 4]))]), + 'UPDATE "t" SET "a" = :a, "b" = b + :expr0Param1 WHERE "c" = :where1' + . ' AND "d" IN (SELECT "u"."id" AS "id" FROM "u" WHERE "e" = :subselect1where1)', + ['a' => 1, 'expr0Param1' => 2, 'where1' => 3, 'subselect1where1' => 4], + ], + 'insert' => [ + static fn(): PreparableSqlInterface => (new Insert('t')) + ->values(['a' => 1, 'b' => new Expression('LOWER(?)', ['X'])]), + 'INSERT INTO "t" ("a", "b") VALUES (:a, LOWER(:expr0Param1))', + ['a' => 1, 'expr0Param1' => 'X'], + ], + 'insert from select' => [ + static fn(): PreparableSqlInterface => (new Insert('t'))->columns(['a']) + ->select((new Select('u'))->columns(['a'])->where(['b' => 1])), + 'INSERT INTO "t" ("a") SELECT "u"."a" AS "a" FROM "u" WHERE "b" = :subselect1where1', + ['subselect1where1' => 1], + ], + 'delete' => [ + static fn(): PreparableSqlInterface => (new Delete('t'))->where(['a' => 1]) + ->where([new In('b', (new Select('u'))->columns(['id'])->where(['c' => 2]))]), + 'DELETE FROM "t" WHERE "a" = :where1' + . ' AND "b" IN (SELECT "u"."id" AS "id" FROM "u" WHERE "c" = :subselect1where1)', + ['where1' => 1, 'subselect1where1' => 2], + ], + 'combine' => [ + static fn(): PreparableSqlInterface => new Combine([ + (new Select('t'))->where(['a' => 1]), + (new Select('u'))->where(['a' => 2]), + ]), + '(SELECT "t".* FROM "t" WHERE "a" = :subselect1where1)' + . ' UNION (SELECT "u".* FROM "u" WHERE "a" = :subselect2where1)', + ['subselect1where1' => 1, 'subselect2where1' => 2], + ], + 'unnamed expressions in parent and subselect' => [ + static fn(): PreparableSqlInterface => (new Select('t'))->where([ + new In('a', (new Select('u'))->columns(['id'])->order(new Expression('FIELD(id, ?)', [1]))), + ]) + ->order(new Expression('FIELD(id, ?)', [2])), + 'SELECT "t".* FROM "t" WHERE "a" IN' + . ' (SELECT "u"."id" AS "id" FROM "u" ORDER BY FIELD(id, :expr0Param1))' + . ' ORDER BY FIELD(id, :expr1Param1)', + ['expr0Param1' => 1, 'expr1Param1' => 2], + ], + ]; + } + + #[Test] + #[Group('unit')] + public function platformDecoratorRendersTheSameParameterNamesAsItsSubjectOnEveryPrepare(): void + { + $subject = (new Select('t'))->where(['a' => 1]) + ->where([new In('b', (new Select('u'))->columns(['id'])->where(['c' => 2]))]); + $decorator = new SelectDecorator(); + $expected = [ + 'SELECT "t".* FROM "t" WHERE "a" = :where1' + . ' AND "b" IN (SELECT "u"."id" AS "id" FROM "u" WHERE "c" = :subselect1where1)', + ['where1' => 1, 'subselect1where1' => 2], + ]; + + static::assertSame( + [$expected, $expected, $expected], + [ + $this->prepare($subject), + $this->prepare($decorator->setSubject($subject)), + $this->prepare($decorator->setSubject($subject)), + ], + ); + } + + /** + * @param Closure(): PreparableSqlInterface $factory + * @param array $expectedParameters + */ + #[Test] + #[Group('unit')] + #[DataProvider('statementProvider')] + public function preparingTheSameStatementTwiceRendersIdenticalSqlAndParameterNames( + Closure $factory, + string $expectedSql, + array $expectedParameters, + ): void { + $statement = $factory(); + + static::assertSame( + [ + [$expectedSql, $expectedParameters], + [$expectedSql, $expectedParameters], + ], + [$this->prepare($statement), $this->prepare($statement)], + ); + } + + #[Test] + #[Group('unit')] + public function renderingAsStringBetweenPreparesDoesNotChangeParameterNames(): void + { + $select = (new Select('t'))->where(['a' => 1]) + ->where([new In('b', (new Select('u'))->columns(['id'])->where(['c' => 2]))]); + + $first = $this->prepare($select); + $select->getSqlString(new TrustingSql92Platform()); + + static::assertSame($first, $this->prepare($select)); + } + + #[Test] + #[Group('unit')] + public function selectPreviouslyRenderedAsSubselectHasNoSubselectPrefixAtTopLevel(): void + { + $inner = (new Select('u'))->columns(['id'])->where(['c' => 2]); + $this->prepare((new Select('t'))->where([new In('b', $inner)])); + + static::assertSame( + ['SELECT "u"."id" AS "id" FROM "u" WHERE "c" = :where1', ['where1' => 2]], + $this->prepare($inner), + ); + } + + /** + * @return array{?string, array} + */ + private function prepare(PreparableSqlInterface $statement): array + { + $driver = $this->createStub(DriverInterface::class); + $driver->method('formatParameterName')->willReturnCallback(static fn(string $name): string => ":{$name}"); + + $adapter = $this->createStub(AdapterInterface::class); + $adapter->method('getDriver')->willReturn($driver); + $adapter->method('getPlatform')->willReturn(new TrustingSql92Platform()); + + $container = $statement->prepareStatement($adapter, new StatementContainer()); + + return [$container->getSql(), $container->getParameterContainer()?->getNamedArray() ?? []]; + } +}