From 80797599e1d3aff28c76e69904748df0e7d6407b Mon Sep 17 00:00:00 2001 From: konstantinpb23 Date: Sat, 14 Jun 2025 17:10:51 +0300 Subject: [PATCH 1/7] feat: add SubQuery injection --- src/Driver/Compiler.php | 8 +++ src/Driver/CompilerCache.php | 5 ++ src/Driver/CompilerInterface.php | 1 + src/Injection/SubQueryInjection.php | 52 ++++++++++++++++ .../Driver/Common/Query/SelectQueryTest.php | 62 +++++++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 src/Injection/SubQueryInjection.php diff --git a/src/Driver/Compiler.php b/src/Driver/Compiler.php index 2a664ec5..37f02f30 100644 --- a/src/Driver/Compiler.php +++ b/src/Driver/Compiler.php @@ -126,6 +126,9 @@ protected function fragment( return $this->selectQuery($params, $q, $tokens); + case self::SUBQUERY: + return $this->selectSubQuery($params, $q, $tokens); + case self::UPDATE_QUERY: return $this->updateQuery($params, $q, $tokens); @@ -198,6 +201,11 @@ protected function selectQuery(QueryParameters $params, Quoter $q, array $tokens ); } + protected function selectSubQuery(QueryParameters $params, Quoter $q, array $tokens): string + { + return \sprintf('( %s ) AS %s',$this->selectQuery($params,$q,$tokens), $q->quote($tokens['alias'])); + } + protected function distinct(QueryParameters $params, Quoter $q, string|bool|array $distinct): string { return $distinct === false ? '' : 'DISTINCT'; diff --git a/src/Driver/CompilerCache.php b/src/Driver/CompilerCache.php index a2684549..2d41cf79 100644 --- a/src/Driver/CompilerCache.php +++ b/src/Driver/CompilerCache.php @@ -17,6 +17,7 @@ use Cycle\Database\Injection\JsonExpression; use Cycle\Database\Injection\Parameter; use Cycle\Database\Injection\ParameterInterface; +use Cycle\Database\Injection\SubQueryInjection; use Cycle\Database\Query\QueryInterface; use Cycle\Database\Query\QueryParameters; use Cycle\Database\Query\SelectQuery; @@ -162,6 +163,10 @@ protected function hashSelectQuery(QueryParameters $params, array $tokens): stri $hash .= 's_' . ($table->getPrefix() ?? ''); $hash .= $this->hashSelectQuery($params, $table->getTokens()); continue; + }else if($table instanceof SubQueryInjection){ + $hash .= 'sb_'; + $hash .= $this->hashSelectQuery($params, $table->getTokens()); + continue; } $hash .= $table; diff --git a/src/Driver/CompilerInterface.php b/src/Driver/CompilerInterface.php index c227f523..32a8972a 100644 --- a/src/Driver/CompilerInterface.php +++ b/src/Driver/CompilerInterface.php @@ -24,6 +24,7 @@ interface CompilerInterface public const UPDATE_QUERY = 6; public const DELETE_QUERY = 7; public const JSON_EXPRESSION = 8; + public const SUBQUERY = 9; public const TOKEN_AND = '@AND'; public const TOKEN_OR = '@OR'; public const TOKEN_AND_NOT = '@AND NOT'; diff --git a/src/Injection/SubQueryInjection.php b/src/Injection/SubQueryInjection.php new file mode 100644 index 00000000..5fe46a48 --- /dev/null +++ b/src/Injection/SubQueryInjection.php @@ -0,0 +1,52 @@ +query = $query; + $this->alias = $alias; + } + + public function getType(): int + { + return CompilerInterface::SUBQUERY; + } + + public function getTokens(): array + { + return \array_merge(['alias'=>$this->alias],$this->query->getTokens()); + } + + public function getQuery(): SelectQuery { + return $this->query; + } + + public function __toString(): string{ + $parameters = new QueryParameters(); + + return Interpolator::interpolate( + $this->query->sqlStatement($parameters), + $parameters->getParameters(), + ); + } +} diff --git a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php index baf70cb2..ce57c2d7 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php @@ -10,6 +10,7 @@ use Cycle\Database\Injection\Expression; use Cycle\Database\Injection\Fragment; use Cycle\Database\Injection\Parameter; +use Cycle\Database\Injection\SubQueryInjection; use Cycle\Database\Query\SelectQuery; use Cycle\Database\Tests\Functional\Driver\Common\BaseTest; use Spiral\Pagination\PaginableInterface; @@ -2639,4 +2640,65 @@ public function testOrWhereNotWithArrayAnd(): void $select, ); } + + public function testSelectFromSubQuery() + { + $innerSelect = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'John Doe']); + $injection = new SubQueryInjection($innerSelect,'u'); + + $outerSelect = $this->database + ->select() + ->from($injection); + + $this->assertSameQuery( + 'SELECT * FROM (SELECT * FROM {users} WHERE {name} = ?) AS {u}', + $outerSelect, + ); + } + + public function testSelectFromTwoSubQuery() + { + $innerSelect1 = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'John Doe']); + $injection1 = new SubQueryInjection($innerSelect1,'u'); + + $innerSelect2 = $this->database + ->select() + ->from(['apartments']) + ->where(['dom' => 12]); + $injection2 = new SubQueryInjection($innerSelect2,'a'); + + + $outerSelect = $this->database + ->select() + ->from($injection1,$injection2); + + $this->assertSameQuery( + 'SELECT * FROM (SELECT * FROM {users} WHERE {name} = ?) AS {u}, (SELECT * FROM {apartments} WHERE {dom} = ?) AS {a}', + $outerSelect, + ); + } + + public function testSelectSelectSubQuery() + { + $innerSelect = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'John Doe']); + $injection = new SubQueryInjection($innerSelect,'u'); + + $outerSelect = $this->database + ->select(['*',$injection]) + ->from(['apartments']); + + $this->assertSameQuery( + 'SELECT *, (SELECT * FROM {users} WHERE {name} = ?) AS {u} FROM {apartments}', + $outerSelect, + ); + } } From b9cf08e1ca3911ee4cb3aab513f597988221b3eb Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 15 Jun 2025 12:32:30 +0400 Subject: [PATCH 2/7] style: fix code style; rename `SubQueryInjection` to `SubQuery` --- src/Driver/CompilerCache.php | 4 +-- src/Injection/FragmentInterface.php | 2 -- .../{SubQueryInjection.php => SubQuery.php} | 10 ++++--- .../Driver/Common/Query/SelectQueryTest.php | 27 +++++++++++-------- 4 files changed, 24 insertions(+), 19 deletions(-) rename src/Injection/{SubQueryInjection.php => SubQuery.php} (81%) diff --git a/src/Driver/CompilerCache.php b/src/Driver/CompilerCache.php index 2d41cf79..a765ae65 100644 --- a/src/Driver/CompilerCache.php +++ b/src/Driver/CompilerCache.php @@ -17,7 +17,7 @@ use Cycle\Database\Injection\JsonExpression; use Cycle\Database\Injection\Parameter; use Cycle\Database\Injection\ParameterInterface; -use Cycle\Database\Injection\SubQueryInjection; +use Cycle\Database\Injection\SubQuery; use Cycle\Database\Query\QueryInterface; use Cycle\Database\Query\QueryParameters; use Cycle\Database\Query\SelectQuery; @@ -163,7 +163,7 @@ protected function hashSelectQuery(QueryParameters $params, array $tokens): stri $hash .= 's_' . ($table->getPrefix() ?? ''); $hash .= $this->hashSelectQuery($params, $table->getTokens()); continue; - }else if($table instanceof SubQueryInjection){ + } elseif ($table instanceof SubQuery) { $hash .= 'sb_'; $hash .= $this->hashSelectQuery($params, $table->getTokens()); continue; diff --git a/src/Injection/FragmentInterface.php b/src/Injection/FragmentInterface.php index 6f74f20a..80c2302c 100644 --- a/src/Injection/FragmentInterface.php +++ b/src/Injection/FragmentInterface.php @@ -18,13 +18,11 @@ interface FragmentInterface { /** * Return the fragment type. - * */ public function getType(): int; /** * Return the fragment tokens. - * */ public function getTokens(): array; } diff --git a/src/Injection/SubQueryInjection.php b/src/Injection/SubQuery.php similarity index 81% rename from src/Injection/SubQueryInjection.php rename to src/Injection/SubQuery.php index 5fe46a48..df0432e0 100644 --- a/src/Injection/SubQueryInjection.php +++ b/src/Injection/SubQuery.php @@ -16,7 +16,7 @@ use Cycle\Database\Query\QueryParameters; use Cycle\Database\Query\SelectQuery; -class SubQueryInjection implements FragmentInterface +class SubQuery implements FragmentInterface { private SelectQuery $query; private string $alias; @@ -34,14 +34,16 @@ public function getType(): int public function getTokens(): array { - return \array_merge(['alias'=>$this->alias],$this->query->getTokens()); + return \array_merge(['alias' => $this->alias], $this->query->getTokens()); } - public function getQuery(): SelectQuery { + public function getQuery(): SelectQuery + { return $this->query; } - public function __toString(): string{ + public function __toString(): string + { $parameters = new QueryParameters(); return Interpolator::interpolate( diff --git a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php index ce57c2d7..f7c602f8 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php @@ -10,7 +10,7 @@ use Cycle\Database\Injection\Expression; use Cycle\Database\Injection\Fragment; use Cycle\Database\Injection\Parameter; -use Cycle\Database\Injection\SubQueryInjection; +use Cycle\Database\Injection\SubQuery; use Cycle\Database\Query\SelectQuery; use Cycle\Database\Tests\Functional\Driver\Common\BaseTest; use Spiral\Pagination\PaginableInterface; @@ -2641,13 +2641,13 @@ public function testOrWhereNotWithArrayAnd(): void ); } - public function testSelectFromSubQuery() + public function testSelectFromSubQuery(): void { $innerSelect = $this->database ->select() ->from(['users']) ->where(['name' => 'John Doe']); - $injection = new SubQueryInjection($innerSelect,'u'); + $injection = new SubQuery($innerSelect, 'u'); $outerSelect = $this->database ->select() @@ -2659,41 +2659,46 @@ public function testSelectFromSubQuery() ); } - public function testSelectFromTwoSubQuery() + public function testSelectFromTwoSubQuery(): void { $innerSelect1 = $this->database ->select() ->from(['users']) ->where(['name' => 'John Doe']); - $injection1 = new SubQueryInjection($innerSelect1,'u'); + $injection1 = new SubQuery($innerSelect1, 'u'); $innerSelect2 = $this->database ->select() ->from(['apartments']) ->where(['dom' => 12]); - $injection2 = new SubQueryInjection($innerSelect2,'a'); + $injection2 = new SubQuery($innerSelect2, 'a'); $outerSelect = $this->database ->select() - ->from($injection1,$injection2); + ->from($injection1, $injection2); $this->assertSameQuery( - 'SELECT * FROM (SELECT * FROM {users} WHERE {name} = ?) AS {u}, (SELECT * FROM {apartments} WHERE {dom} = ?) AS {a}', + <<database ->select() ->from(['users']) ->where(['name' => 'John Doe']); - $injection = new SubQueryInjection($innerSelect,'u'); + $injection = new SubQuery($innerSelect, 'u'); $outerSelect = $this->database - ->select(['*',$injection]) + ->select(['*', $injection]) ->from(['apartments']); $this->assertSameQuery( From 727ca0edccd01cb0ba1133240712c65775cc6bd1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 15 Jun 2025 16:25:51 +0400 Subject: [PATCH 3/7] refactor: rename `selectSubQuery` to `subQuery` and update related query logic --- src/Driver/Compiler.php | 4 ++-- src/Driver/CompilerInterface.php | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Driver/Compiler.php b/src/Driver/Compiler.php index 37f02f30..d98f0f42 100644 --- a/src/Driver/Compiler.php +++ b/src/Driver/Compiler.php @@ -127,7 +127,7 @@ protected function fragment( return $this->selectQuery($params, $q, $tokens); case self::SUBQUERY: - return $this->selectSubQuery($params, $q, $tokens); + return $this->subQuery($params, $q, $tokens); case self::UPDATE_QUERY: return $this->updateQuery($params, $q, $tokens); @@ -201,7 +201,7 @@ protected function selectQuery(QueryParameters $params, Quoter $q, array $tokens ); } - protected function selectSubQuery(QueryParameters $params, Quoter $q, array $tokens): string + protected function subQuery(QueryParameters $params, Quoter $q, array $tokens): string { return \sprintf('( %s ) AS %s',$this->selectQuery($params,$q,$tokens), $q->quote($tokens['alias'])); } diff --git a/src/Driver/CompilerInterface.php b/src/Driver/CompilerInterface.php index 32a8972a..281c124b 100644 --- a/src/Driver/CompilerInterface.php +++ b/src/Driver/CompilerInterface.php @@ -34,7 +34,6 @@ public function quoteIdentifier(string $identifier): string; /** * Compile the query fragment. - * */ public function compile( QueryParameters $params, From 09013c670cadcd865529b7c39578db2413001631 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 15 Jun 2025 16:26:04 +0400 Subject: [PATCH 4/7] test: enhance subquery tests with additional conditions and parameters --- .../Driver/Common/Query/SelectQueryTest.php | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php index f7c602f8..d4a37ae1 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php @@ -2651,10 +2651,23 @@ public function testSelectFromSubQuery(): void $outerSelect = $this->database ->select() - ->from($injection); + ->from($injection) + ->where('u.id', '>', 10); $this->assertSameQuery( - 'SELECT * FROM (SELECT * FROM {users} WHERE {name} = ?) AS {u}', + << ? + SQL, + $outerSelect, + ); + + $this->assertSameParameters( + [ + 'John Doe', + 10, + ], $outerSelect, ); } @@ -2687,6 +2700,14 @@ public function testSelectFromTwoSubQuery(): void SQL, $outerSelect, ); + + $this->assertSameParameters( + [ + 'John Doe', + 12, + ], + $outerSelect, + ); } public function testSelectSelectSubQuery(): void @@ -2705,5 +2726,12 @@ public function testSelectSelectSubQuery(): void 'SELECT *, (SELECT * FROM {users} WHERE {name} = ?) AS {u} FROM {apartments}', $outerSelect, ); + + $this->assertSameParameters( + [ + 'John Doe', + ], + $outerSelect, + ); } } From 74dbd7c11dc8812f54d4145c0fb3d6c07693d829 Mon Sep 17 00:00:00 2001 From: konstantinpb23 Date: Tue, 17 Jun 2025 07:27:35 +0300 Subject: [PATCH 5/7] feat: add parameters to SubQuery --- src/Driver/CompilerCache.php | 2 +- src/Injection/SubQuery.php | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Driver/CompilerCache.php b/src/Driver/CompilerCache.php index a765ae65..7aecba87 100644 --- a/src/Driver/CompilerCache.php +++ b/src/Driver/CompilerCache.php @@ -335,7 +335,7 @@ protected function hashColumns(QueryParameters $params, array $columns): string { $hash = ''; foreach ($columns as $column) { - if ($column instanceof Expression || $column instanceof Fragment) { + if ($column instanceof Expression || $column instanceof Fragment || $column instanceof SubQuery) { foreach ($column->getTokens()['parameters'] as $param) { $params->push($param); } diff --git a/src/Injection/SubQuery.php b/src/Injection/SubQuery.php index df0432e0..6f15cc01 100644 --- a/src/Injection/SubQuery.php +++ b/src/Injection/SubQuery.php @@ -20,11 +20,17 @@ class SubQuery implements FragmentInterface { private SelectQuery $query; private string $alias; + /** @var ParameterInterface[] */ + private array $parameters; public function __construct(SelectQuery $query, string $alias) { $this->query = $query; $this->alias = $alias; + + $parameters = new QueryParameters(); + $this->query->sqlStatement($parameters); + $this->parameters = $parameters->getParameters(); } public function getType(): int @@ -34,7 +40,12 @@ public function getType(): int public function getTokens(): array { - return \array_merge(['alias' => $this->alias], $this->query->getTokens()); + return \array_merge( + [ + 'alias' => $this->alias, + 'parameters' => $this->parameters, + ], + $this->query->getTokens()); } public function getQuery(): SelectQuery From 02484a66a23bac5f2fe3711944890642f0a4a7a2 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 17 Jun 2025 10:50:32 +0400 Subject: [PATCH 6/7] style: improve code formatting and consistency in Compiler, Jsoner, and SubQuery --- src/Driver/Compiler.php | 2 +- src/Driver/Jsoner.php | 2 +- src/Injection/SubQuery.php | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Driver/Compiler.php b/src/Driver/Compiler.php index d98f0f42..93ba54a9 100644 --- a/src/Driver/Compiler.php +++ b/src/Driver/Compiler.php @@ -203,7 +203,7 @@ protected function selectQuery(QueryParameters $params, Quoter $q, array $tokens protected function subQuery(QueryParameters $params, Quoter $q, array $tokens): string { - return \sprintf('( %s ) AS %s',$this->selectQuery($params,$q,$tokens), $q->quote($tokens['alias'])); + return \sprintf('( %s ) AS %s', $this->selectQuery($params, $q, $tokens), $q->quote($tokens['alias'])); } protected function distinct(QueryParameters $params, Quoter $q, string|bool|array $distinct): string diff --git a/src/Driver/Jsoner.php b/src/Driver/Jsoner.php index fe08c02c..7bc50eaa 100644 --- a/src/Driver/Jsoner.php +++ b/src/Driver/Jsoner.php @@ -32,7 +32,7 @@ public static function toJson(mixed $value, bool $encode = true, bool $validate $result = (string) $value; - if ($validate && !\json_validate($result)) { + if ($validate && !json_validate($result)) { throw new BuilderException('Invalid JSON value.'); } diff --git a/src/Injection/SubQuery.php b/src/Injection/SubQuery.php index 6f15cc01..cb726a4b 100644 --- a/src/Injection/SubQuery.php +++ b/src/Injection/SubQuery.php @@ -20,6 +20,7 @@ class SubQuery implements FragmentInterface { private SelectQuery $query; private string $alias; + /** @var ParameterInterface[] */ private array $parameters; @@ -45,7 +46,8 @@ public function getTokens(): array 'alias' => $this->alias, 'parameters' => $this->parameters, ], - $this->query->getTokens()); + $this->query->getTokens(), + ); } public function getQuery(): SelectQuery From 85794199f02e4d2f60f8f66de21f18e7968aafc5 Mon Sep 17 00:00:00 2001 From: konstantinpb23 Date: Fri, 20 Jun 2025 19:08:18 +0300 Subject: [PATCH 7/7] docs: cover SubQuery with comments --- src/Injection/SubQuery.php | 20 ++++++++++++++++++++ src/Query/SelectQuery.php | 11 +++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/Injection/SubQuery.php b/src/Injection/SubQuery.php index cb726a4b..2cf03274 100644 --- a/src/Injection/SubQuery.php +++ b/src/Injection/SubQuery.php @@ -16,6 +16,26 @@ use Cycle\Database\Query\QueryParameters; use Cycle\Database\Query\SelectQuery; +/** + * This fragment is used to inject a whole select statement into + * FROM and SELECT parts of the query. + * + * Examples: + * + * ``` + * $subQuery = new SubQuery($queryBuilder->select()->from(['users']),'u'); + * $query = $queryBuilder->select()->from($subQuery); + * ``` + * + * Will provide SQL like this: SELECT * FROM (SELECT * FROM users) AS u + * + * ``` + * $subQuery = new SubQuery($queryBuilder->select()->from(['users']),'u'); + * $query = $queryBuilder->select($subQuery)->from(['employee']); + * ``` + * + * Will provide SQL like this: SELECT *, (SELECT * FROM users) AS u FROM employee + */ class SubQuery implements FragmentInterface { private SelectQuery $query; diff --git a/src/Query/SelectQuery.php b/src/Query/SelectQuery.php index 52a39271..818dd6de 100644 --- a/src/Query/SelectQuery.php +++ b/src/Query/SelectQuery.php @@ -13,6 +13,7 @@ use Cycle\Database\Injection\Expression; use Cycle\Database\Injection\Fragment; +use Cycle\Database\Injection\SubQuery; use Cycle\Database\Query\Traits\WhereJsonTrait; use Cycle\Database\Driver\CompilerInterface; use Cycle\Database\Injection\FragmentInterface; @@ -84,6 +85,16 @@ public function distinct(bool|string|FragmentInterface $distinct = true): self /** * Set table names SELECT query should be performed for. Table names can be provided with * specified alias (AS construction). + * Also, it is possible to use SubQuery. + * + * Following example will provide SQL like this: SELECT * FROM (SELECT * FROM users) AS u + * + * ``` + * $subQuery = new SubQuery($queryBuilder->select()->from(['users']),'u'); + * $query = $queryBuilder->select()->from($subQuery); + * ``` + * + * @see SubQuery */ public function from(mixed $tables): self {