diff --git a/composer.json b/composer.json index 0c0e76d7..595de792 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ }, "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "config": { diff --git a/docs/query-builder.md b/docs/query-builder.md index d165eea3..bcfbaacf 100644 --- a/docs/query-builder.md +++ b/docs/query-builder.md @@ -91,20 +91,37 @@ $builder->limitBy(20, 10); // sets offset to 1O #### INNER, LEFT and RIGHT JOIN -Choose from `joinInner()`, `joinLeft()`, and `joinRight()` methods. Each of them has the same signature. Arguments: +Use `joinOnce()` for deduplicated joins, or `addInnerJoin()`, `addLeftJoin()`, and `addRightJoin()` when you want to append another join clause explicitly. + +The `add*Join()` methods all have the same signature. Arguments: - to expression - target expression, do not forget to escape the target table name, you may define also an alias, - on expression - ON clause expression, - arguments for expressions. ```php $builder->from('[authors]', 'a'); -$builder->joinLeft('[books] AS [b]', '[a.id] = [b.authorId] AND [b.title] = %s', $title); +$builder->addLeftJoin('[books] AS [b]', '[a.id] = [b.authorId] AND [b.title] = %s', $title); // will produce // FROM [authors] AS [a] // LEFT JOIN [books] AS [b] ON ([a.id] = [b.authorId] AND [b.title] = %s) ``` +Use `joinOnce()` if you want the same logical join to be added at most once: + +```php +$builder->from('[authors]', 'a'); +$builder->joinOnce('LEFT', '[books] AS [b]', '[a.id] = [b.authorId]', []); +``` + +`joinOnce()` deduplicates by the join type and the two SQL expressions. The placeholder arguments are not part of the deduplication hash. This mainly matters for joins built with modifiers such as `%table` or `%column`, where different parameter values can still produce the same SQL expression shape. In that case, pass a different `hashSuffix`: + +```php +$builder->from('[authors]', 'a'); +$builder->joinOnce('LEFT', '%table', '%table.id = tbl.book_id', ['book_tag'], 'book-tag'); +$builder->joinOnce('LEFT', '%table', '%table.id = tbl.book_id', ['author_tag'], 'author-tag'); +``` + #### INDEX HINTS (MySQL) Use `indexHints()` method to tell the query planner how to efficiently execute your query. diff --git a/src/QueryBuilder/QueryBuilder.php b/src/QueryBuilder/QueryBuilder.php index 3f9fe7ff..bc52b5a0 100644 --- a/src/QueryBuilder/QueryBuilder.php +++ b/src/QueryBuilder/QueryBuilder.php @@ -7,6 +7,8 @@ use Nextras\Dbal\Exception\InvalidStateException; use Nextras\Dbal\Platforms\IPlatform; use Nextras\Dbal\Utils\StrictObjectTrait; +use function array_merge; +use function md5; class QueryBuilder @@ -21,13 +23,15 @@ class QueryBuilder 'select' => null, 'from' => null, 'indexHints' => null, - 'join' => null, 'where' => null, 'group' => null, 'having' => null, 'order' => null, ]; + /** @var array> */ + protected array $joinArgs = []; + /** @var literal-string[]|null */ protected $select; @@ -40,7 +44,7 @@ class QueryBuilder /** @var literal-string|null */ protected $indexHints; - /** @var array|null */ + /** @var array|null */ protected $join; /** @var literal-string|null */ @@ -84,15 +88,20 @@ public function getQuerySql(): string */ public function getQueryParameters(): array { + $joinArgs = []; + foreach ($this->joinArgs as $args) { + $joinArgs = array_merge($joinArgs, $args); + } + return array_merge( (array) $this->args['select'], (array) $this->args['from'], (array) $this->args['indexHints'], - (array) $this->args['join'], + $joinArgs, (array) $this->args['where'], (array) $this->args['group'], (array) $this->args['having'], - (array) $this->args['order'] + (array) $this->args['order'], ); } @@ -135,6 +144,14 @@ protected function getFromClauses(): string */ public function getClause(string $part): array { + if ($part === 'join') { + $joinArgs = []; + foreach ($this->joinArgs as $args) { + $joinArgs = array_merge($joinArgs, $args); + } + return [$this->join, $joinArgs]; + } + if (!isset($this->args[$part]) && !array_key_exists($part, $this->args)) { throw new InvalidArgumentException("Unknown '$part' clause type."); } @@ -187,35 +204,47 @@ public function getFromAlias(): ?string /** + * Adds (another) INNER JOIN clause. + * + * To prevent JOIN clause duplication, see {@see joinOnce()} method. + * * @param literal-string $toExpression * @param literal-string $onExpression * @param array $args */ - public function joinInner(string $toExpression, string $onExpression, ...$args): self + public function addInnerJoin(string $toExpression, string $onExpression, ...$args): self { - return $this->join('INNER', $toExpression, $onExpression, $args); + return $this->addJoin('INNER', $toExpression, $onExpression, $args); } /** + * Adds (another) LEFT JOIN clause. + * + * To prevent JOIN clause duplication, see {@see joinOnce()} method. + * * @param literal-string $toExpression * @param literal-string $onExpression * @param array $args */ - public function joinLeft(string $toExpression, string $onExpression, ...$args): self + public function addLeftJoin(string $toExpression, string $onExpression, ...$args): self { - return $this->join('LEFT', $toExpression, $onExpression, $args); + return $this->addJoin('LEFT', $toExpression, $onExpression, $args); } /** + * Adds (another) RIGHT JOIN clause. + * + * To prevent JOIN clause duplication, see {@see joinOnce()} method. + * * @param literal-string $toExpression * @param literal-string $onExpression * @param array $args */ - public function joinRight(string $toExpression, string $onExpression, ...$args): self + public function addRightJoin(string $toExpression, string $onExpression, ...$args): self { - return $this->join('RIGHT', $toExpression, $onExpression, $args); + return $this->addJoin('RIGHT', $toExpression, $onExpression, $args); } @@ -223,7 +252,7 @@ public function removeJoins(): self { $this->dirty(); $this->join = null; - $this->args['join'] = null; + $this->joinArgs = []; return $this; } @@ -233,7 +262,7 @@ public function removeJoins(): self * @param literal-string $onExpression * @param array $args */ - protected function join(string $type, string $toExpression, string $onExpression, array $args): self + protected function addJoin(string $type, string $toExpression, string $onExpression, array $args): self { $this->dirty(); $this->join[] = [ @@ -241,7 +270,51 @@ protected function join(string $type, string $toExpression, string $onExpression 'table' => $toExpression, 'on' => $onExpression, ]; - $this->pushArgs('join', $args); + $this->joinArgs[] = $args; + return $this; + } + + + /** + * Adds {@see $joinType} JOIN with deduplication; reuses an already added join clause. + * + * This method tries to reuse a previously added join by using {@see $joinType}, {@see $toExpression}, + * {@see $onExpression}, and {@see $hashSuffix} to construct the unique join identifier. + * + * The join {@see $args} are intentionally not part of the hash. As a result, two calls that differ only in the + * arguments are considered the same join unless they use a different {@see $hashSuffix}. + * + * This mainly matters when the expressions are dynamically constructed using modifiers. In that case, different + * parameter values may still produce the same SQL expression shape. To distinguish those joins, use + * {@see $hashSuffix}, which becomes part of the hash yet does not affect safe SQL construction. + * + * ```php + * // use hashSuffix to distinguish joins that share the same SQL expression shape + * $builder->joinOnce('LEFT', '%table', '%table.id = %table.another_id', [$table, $anotherTable], hashSuffix: $table . $anotherTable); + * ``` + * + * @param literal-string $joinType the SQL type of join: LEFT, INNER, OUTER, RIGHT, etc. + * @param literal-string $toExpression + * @param literal-string $onExpression + * @param array $args + * @param string $hashSuffix additional part of the JOIN hash used to distinguish otherwise identical joins + */ + public function joinOnce( + string $joinType, + string $toExpression, + string $onExpression, + array $args, + string $hashSuffix = '', + ): self + { + $this->dirty(); + $hash = md5($joinType . ';' . $toExpression . ';' . $onExpression . ';' . $hashSuffix); + $this->join[$hash] = [ + 'type' => $joinType, + 'table' => $toExpression, + 'on' => $onExpression, + ]; + $this->joinArgs[$hash] = $args; return $this; } diff --git a/tests/cases/unit/QueryBuilderTest.joins.phpt b/tests/cases/unit/QueryBuilderTest.joins.phpt index edb8e2d0..0b4aef4e 100644 --- a/tests/cases/unit/QueryBuilderTest.joins.phpt +++ b/tests/cases/unit/QueryBuilderTest.joins.phpt @@ -27,11 +27,65 @@ class QueryBuilderJoinsTest extends QueryBuilderTestCase $this->builder() ->from('one', 'o') ->select('*') - ->joinLeft('two AS [t]', 'o.userId = t.userId') - ->joinLeft('%table', '%table.id = tbl.t1_id', 't1', 't1') - ->joinLeft('%table', '%table.id = tbl.t2_id', 't2', 't2') - ->joinInner('three AS [th]', 't.userId = th.userId') - ->joinRight('four AS [f]', 'th.userId = f.userId') + ->addLeftJoin('two AS [t]', 'o.userId = t.userId') + ->addLeftJoin('%table', '%table.id = tbl.t1_id', 't1', 't1') + ->addLeftJoin('%table', '%table.id = tbl.t2_id', 't2', 't2') + ->addInnerJoin('three AS [th]', 't.userId = th.userId') + ->addRightJoin('four AS [f]', 'th.userId = f.userId') + ); + } + + + public function testAddDuplicateJoinAppendsAnotherJoin(): void + { + $this->assertBuilder( + [ + 'SELECT * FROM one AS [o] ' + . 'LEFT JOIN two AS [t] ON (o.userId = t.userId) ' + . 'LEFT JOIN two AS [t] ON (o.userId = t.userId)', + ], + $this->builder() + ->from('one', 'o') + ->select('*') + ->addLeftJoin('two AS [t]', 'o.userId = t.userId') + ->addLeftJoin('two AS [t]', 'o.userId = t.userId') + ); + } + + + public function testJoinOnceDeduplicatesSameJoin(): void + { + $this->assertBuilder( + [ + 'SELECT * FROM one AS [o] ' + . 'LEFT JOIN two AS [t] ON (o.userId = t.userId)', + ], + $this->builder() + ->from('one', 'o') + ->select('*') + ->joinOnce('LEFT', 'two AS [t]', 'o.userId = t.userId', []) + ->joinOnce('LEFT', 'two AS [t]', 'o.userId = t.userId', []) + ); + } + + + public function testJoinOnceHashSuffixDistinguishesJoins(): void + { + $this->assertBuilder( + [ + 'SELECT * FROM one AS [o] ' + . 'LEFT JOIN %table ON (%table.id = tbl.id) ' + . 'LEFT JOIN %table ON (%table.id = tbl.id)', + 't1', + 't1', + 't2', + 't2', + ], + $this->builder() + ->from('one', 'o') + ->select('*') + ->joinOnce('LEFT', '%table', '%table.id = tbl.id', ['t1', 't1'], 'join-1') + ->joinOnce('LEFT', '%table', '%table.id = tbl.id', ['t2', 't2'], 'join-2') ); } }