Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
},
"extra": {
"branch-alias": {
"dev-main": "5.0-dev"
"dev-main": "6.0-dev"
}
},
"config": {
Expand Down
21 changes: 19 additions & 2 deletions docs/query-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
99 changes: 86 additions & 13 deletions src/QueryBuilder/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,13 +23,15 @@ class QueryBuilder
'select' => null,
'from' => null,
'indexHints' => null,
'join' => null,
'where' => null,
'group' => null,
'having' => null,
'order' => null,
];

/** @var array<string|int, array<mixed>> */
protected array $joinArgs = [];

/** @var literal-string[]|null */
protected $select;

Expand All @@ -40,7 +44,7 @@ class QueryBuilder
/** @var literal-string|null */
protected $indexHints;

/** @var array<array{type: string, table: literal-string, on: string}>|null */
/** @var array<string|int, array{type: string, table: literal-string, on: string}>|null */
protected $join;

/** @var literal-string|null */
Expand Down Expand Up @@ -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'],
);
}

Expand Down Expand Up @@ -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.");
}
Expand Down Expand Up @@ -187,43 +204,55 @@ 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<int, mixed> $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<int, mixed> $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<int, mixed> $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);
}


public function removeJoins(): self
{
$this->dirty();
$this->join = null;
$this->args['join'] = null;
$this->joinArgs = [];
return $this;
}

Expand All @@ -233,15 +262,59 @@ public function removeJoins(): self
* @param literal-string $onExpression
* @param array<mixed> $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[] = [
'type' => $type,
'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<mixed> $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;
}

Expand Down
64 changes: 59 additions & 5 deletions tests/cases/unit/QueryBuilderTest.joins.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -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')
);
}
}
Expand Down
Loading