Skip to content

Commit 03076f2

Browse files
committed
feat(database): add closure-based join conditions
- Add JoinClause for protected JOIN ON column and value conditions - Support grouped and nested join conditions with Query Builder-style methods - Share join condition compilation with SQLSRV - Document the closure join API and add focused builder coverage Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent df9f137 commit 03076f2

10 files changed

Lines changed: 747 additions & 72 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -625,21 +625,13 @@ public function fromSubquery(BaseBuilder $from, string $alias): self
625625
/**
626626
* Generates the JOIN portion of the query
627627
*
628-
* @param RawSql|string $cond
628+
* @param Closure(JoinClause): void|RawSql|string $cond
629629
*
630630
* @return $this
631631
*/
632632
public function join(string $table, $cond, string $type = '', ?bool $escape = null)
633633
{
634-
if ($type !== '') {
635-
$type = strtoupper(trim($type));
636-
637-
if (! in_array($type, $this->joinTypes, true)) {
638-
$type = '';
639-
} else {
640-
$type .= ' ';
641-
}
642-
}
634+
$type = $this->normalizeJoinType($type);
643635

644636
// Extract any aliases that might exist. We use this information
645637
// in the protectIdentifiers to know whether to add a table prefix
@@ -654,10 +646,39 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu
654646
$table = $this->db->protectIdentifiers($table, true, null, false);
655647
}
656648

649+
$cond = $this->compileJoinCondition($cond, $escape);
650+
651+
// Assemble the JOIN statement
652+
$this->QBJoin[] = $type . 'JOIN ' . $table . $cond;
653+
654+
return $this;
655+
}
656+
657+
protected function normalizeJoinType(string $type): string
658+
{
659+
if ($type === '') {
660+
return '';
661+
}
662+
663+
$type = strtoupper(trim($type));
664+
665+
return in_array($type, $this->joinTypes, true) ? $type . ' ' : '';
666+
}
667+
668+
/**
669+
* @param Closure(JoinClause): void|RawSql|string $cond
670+
*/
671+
protected function compileJoinCondition(Closure|RawSql|string $cond, bool $escape): string
672+
{
657673
if ($cond instanceof RawSql) {
658-
$this->QBJoin[] = $type . 'JOIN ' . $table . ' ON ' . $cond;
674+
return ' ON ' . $cond;
675+
}
659676

660-
return $this;
677+
if ($cond instanceof Closure) {
678+
$joinClause = new JoinClause($this->db, fn (string $key, mixed $value, bool $escape): string => $this->setBind($key, $value, $escape), $escape);
679+
$cond($joinClause);
680+
681+
return $joinClause->compile();
661682
}
662683

663684
if (! $this->hasOperator($cond)) {
@@ -701,10 +722,7 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu
701722
}
702723
}
703724

704-
// Assemble the JOIN statement
705-
$this->QBJoin[] = $type . 'JOIN ' . $table . $cond;
706-
707-
return $this;
725+
return $cond;
708726
}
709727

710728
/**

system/Database/JoinClause.php

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Database;
15+
16+
use Closure;
17+
use CodeIgniter\Exceptions\InvalidArgumentException;
18+
19+
/**
20+
* Builds conditions for a JOIN ON clause.
21+
*/
22+
class JoinClause
23+
{
24+
/**
25+
* @var list<string>
26+
*/
27+
private array $conditions = [];
28+
29+
private int $conditionCount = 0;
30+
31+
/**
32+
* @var list<int>
33+
*/
34+
private array $groupConditionCounts = [];
35+
36+
private bool $groupStarted = false;
37+
38+
/**
39+
* @param Closure(string, mixed, bool): string $setBind
40+
*
41+
* @internal This class is normally created by BaseBuilder::join().
42+
*/
43+
public function __construct(
44+
private readonly BaseConnection $db,
45+
private readonly Closure $setBind,
46+
private readonly bool $escape,
47+
) {
48+
}
49+
50+
/**
51+
* Adds a column comparison to the JOIN ON clause.
52+
*
53+
* @param non-empty-string $first First column name, optionally with comparison operator
54+
* @param non-empty-string $second Second column name
55+
* @param bool|null $escape Whether to protect identifiers
56+
*
57+
* @throws InvalidArgumentException
58+
*/
59+
public function on(string $first, string $second, ?bool $escape = null): static
60+
{
61+
return $this->onColumn($first, $second, 'AND ', $escape);
62+
}
63+
64+
/**
65+
* Adds an OR column comparison to the JOIN ON clause.
66+
*
67+
* @param non-empty-string $first First column name, optionally with comparison operator
68+
* @param non-empty-string $second Second column name
69+
* @param bool|null $escape Whether to protect identifiers
70+
*
71+
* @throws InvalidArgumentException
72+
*/
73+
public function orOn(string $first, string $second, ?bool $escape = null): static
74+
{
75+
return $this->onColumn($first, $second, 'OR ', $escape);
76+
}
77+
78+
/**
79+
* Adds a value comparison to the JOIN ON clause.
80+
*
81+
* @param non-empty-string $key Column name, optionally with comparison operator
82+
* @param mixed $value Value to bind
83+
* @param bool|null $escape Whether to protect identifiers
84+
*
85+
* @throws InvalidArgumentException
86+
*/
87+
public function where(string $key, mixed $value = null, ?bool $escape = null): static
88+
{
89+
return $this->whereHaving($key, $value, 'AND ', $escape);
90+
}
91+
92+
/**
93+
* Adds an OR value comparison to the JOIN ON clause.
94+
*
95+
* @param non-empty-string $key Column name, optionally with comparison operator
96+
* @param mixed $value Value to bind
97+
* @param bool|null $escape Whether to protect identifiers
98+
*
99+
* @throws InvalidArgumentException
100+
*/
101+
public function orWhere(string $key, mixed $value = null, ?bool $escape = null): static
102+
{
103+
return $this->whereHaving($key, $value, 'OR ', $escape);
104+
}
105+
106+
/**
107+
* Starts a condition group.
108+
*/
109+
public function groupStart(): static
110+
{
111+
return $this->groupStartPrepare();
112+
}
113+
114+
/**
115+
* Starts a condition group, prefixed with OR.
116+
*/
117+
public function orGroupStart(): static
118+
{
119+
return $this->groupStartPrepare('', 'OR ');
120+
}
121+
122+
/**
123+
* Starts a condition group, prefixed with NOT.
124+
*/
125+
public function notGroupStart(): static
126+
{
127+
return $this->groupStartPrepare('NOT ');
128+
}
129+
130+
/**
131+
* Starts a condition group, prefixed with OR NOT.
132+
*/
133+
public function orNotGroupStart(): static
134+
{
135+
return $this->groupStartPrepare('NOT ', 'OR ');
136+
}
137+
138+
/**
139+
* Ends the current condition group.
140+
*
141+
* @throws InvalidArgumentException
142+
*/
143+
public function groupEnd(): static
144+
{
145+
if ($this->groupConditionCounts === []) {
146+
throw new InvalidArgumentException('JoinClause groupEnd() called without a matching groupStart().');
147+
}
148+
149+
$conditionCount = array_pop($this->groupConditionCounts);
150+
151+
if ($conditionCount === 0) {
152+
throw new InvalidArgumentException('JoinClause groups must contain at least one condition.');
153+
}
154+
155+
$this->conditions[] = ')';
156+
$this->groupStarted = false;
157+
$this->incrementConditionCount();
158+
159+
return $this;
160+
}
161+
162+
/**
163+
* Compiles the JOIN ON clause conditions.
164+
*
165+
* @internal
166+
*
167+
* @throws InvalidArgumentException
168+
*/
169+
public function compile(): string
170+
{
171+
if ($this->groupConditionCounts !== []) {
172+
throw new InvalidArgumentException('JoinClause groups must be balanced.');
173+
}
174+
175+
if ($this->conditionCount === 0) {
176+
throw new InvalidArgumentException('JoinClause must contain at least one condition.');
177+
}
178+
179+
return ' ON ' . implode('', $this->conditions);
180+
}
181+
182+
/**
183+
* @param non-empty-string $first
184+
* @param non-empty-string $second
185+
*/
186+
private function onColumn(string $first, string $second, string $type, ?bool $escape): static
187+
{
188+
[$first, $operator] = $this->parseFirstColumn($first);
189+
$second = trim($second);
190+
191+
if ($first === '' || $second === '') {
192+
throw new InvalidArgumentException('JoinClause column comparisons expect $first and $second to be non-empty strings.');
193+
}
194+
195+
$escape ??= $this->escape;
196+
197+
if ($escape) {
198+
$first = $this->db->protectIdentifiers($first, false, true);
199+
$second = $this->db->protectIdentifiers($second, false, true);
200+
}
201+
202+
$this->appendCondition($this->prefix($type) . $first . ' ' . $operator . ' ' . $second);
203+
204+
return $this;
205+
}
206+
207+
private function whereHaving(string $key, mixed $value, string $type, ?bool $escape): static
208+
{
209+
$key = trim($key);
210+
211+
if ($key === '') {
212+
throw new InvalidArgumentException('JoinClause value comparisons expect $key to be a non-empty string.');
213+
}
214+
215+
$escape ??= $this->escape;
216+
217+
if ($value !== null) {
218+
[$key, $operator] = $this->parseWhereKey($key);
219+
$bind = ($this->setBind)($key, $value, $escape);
220+
$condition = $this->protectIdentifier($key, $escape) . $operator . " :{$bind}:";
221+
} elseif (preg_match('/\s*(!=|<>|IS(?:\s+NOT)?)\s*$/i', $key, $match, PREG_OFFSET_CAPTURE) === 1) {
222+
$key = substr($key, 0, $match[0][1]);
223+
$operator = $match[1][0] === '=' || strcasecmp($match[1][0], 'IS') === 0 ? ' IS NULL' : ' IS NOT NULL';
224+
$condition = $this->protectIdentifier($key, $escape) . $operator;
225+
} else {
226+
$condition = $this->protectIdentifier($key, $escape) . ' IS NULL';
227+
}
228+
229+
$this->appendCondition($this->prefix($type) . $condition);
230+
231+
return $this;
232+
}
233+
234+
private function groupStartPrepare(string $not = '', string $type = 'AND '): static
235+
{
236+
$this->conditions[] = $this->prefix($type) . $not . '(';
237+
$this->groupConditionCounts[] = 0;
238+
$this->groupStarted = true;
239+
240+
return $this;
241+
}
242+
243+
/**
244+
* @return array{string, string}
245+
*/
246+
private function parseFirstColumn(string $first): array
247+
{
248+
$first = trim($first);
249+
250+
if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $first, $match) === 1) {
251+
return [rtrim(substr($first, 0, -strlen($match[0]))), trim($match[1])];
252+
}
253+
254+
return [$first, '='];
255+
}
256+
257+
/**
258+
* @return array{string, string}
259+
*/
260+
private function parseWhereKey(string $key): array
261+
{
262+
if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $key, $match) === 1) {
263+
return [rtrim(substr($key, 0, -strlen($match[0]))), ' ' . trim($match[1])];
264+
}
265+
266+
return [$key, ' ='];
267+
}
268+
269+
private function protectIdentifier(string $identifier, bool $escape): string
270+
{
271+
return $escape ? $this->db->protectIdentifiers($identifier) : $identifier;
272+
}
273+
274+
private function prefix(string $type): string
275+
{
276+
if ($this->conditions === [] || $this->groupStarted) {
277+
$this->groupStarted = false;
278+
279+
return '';
280+
}
281+
282+
return ' ' . $type;
283+
}
284+
285+
private function appendCondition(string $condition): void
286+
{
287+
$this->conditions[] = $condition;
288+
$this->incrementConditionCount();
289+
}
290+
291+
private function incrementConditionCount(): void
292+
{
293+
if ($this->groupConditionCounts === []) {
294+
$this->conditionCount++;
295+
296+
return;
297+
}
298+
299+
$index = array_key_last($this->groupConditionCounts);
300+
$this->groupConditionCounts[$index]++;
301+
}
302+
}

0 commit comments

Comments
 (0)