• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 25633858291

10 May 2026 04:28PM UTC coverage: 88.484% (+0.07%) from 88.418%
25633858291

Pull #10186

github

web-flow
Merge 5d80b808f into df9f13771
Pull Request #10186: feat: add closure-based Query Builder join conditions

84 of 87 new or added lines in 3 files covered. (96.55%)

23911 of 27023 relevant lines covered (88.48%)

218.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.77
/system/Database/JoinClause.php
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
    }
22✔
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);
19✔
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);
2✔
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);
14✔
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);
8✔
104
    }
105

106
    /**
107
     * Starts a condition group.
108
     */
109
    public function groupStart(): static
110
    {
111
        return $this->groupStartPrepare();
7✔
112
    }
113

114
    /**
115
     * Starts a condition group, prefixed with OR.
116
     */
117
    public function orGroupStart(): static
118
    {
119
        return $this->groupStartPrepare('', 'OR ');
2✔
120
    }
121

122
    /**
123
     * Starts a condition group, prefixed with NOT.
124
     */
125
    public function notGroupStart(): static
126
    {
127
        return $this->groupStartPrepare('NOT ');
1✔
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 ');
1✔
136
    }
137

138
    /**
139
     * Ends the current condition group.
140
     *
141
     * @throws InvalidArgumentException
142
     */
143
    public function groupEnd(): static
144
    {
145
        if ($this->groupConditionCounts === []) {
10✔
146
            throw new InvalidArgumentException('JoinClause groupEnd() called without a matching groupStart().');
1✔
147
        }
148

149
        $conditionCount = array_pop($this->groupConditionCounts);
9✔
150

151
        if ($conditionCount === 0) {
9✔
152
            throw new InvalidArgumentException('JoinClause groups must contain at least one condition.');
1✔
153
        }
154

155
        $this->conditions[] = ')';
8✔
156
        $this->groupStarted = false;
8✔
157
        $this->incrementConditionCount();
8✔
158

159
        return $this;
8✔
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 !== []) {
20✔
172
            throw new InvalidArgumentException('JoinClause groups must be balanced.');
1✔
173
        }
174

175
        if ($this->conditionCount === 0) {
19✔
176
            throw new InvalidArgumentException('JoinClause must contain at least one condition.');
1✔
177
        }
178

179
        return ' ON ' . implode('', $this->conditions);
18✔
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);
19✔
189
        $second             = trim($second);
19✔
190

191
        if ($first === '' || $second === '') {
19✔
NEW
192
            throw new InvalidArgumentException('JoinClause column comparisons expect $first and $second to be non-empty strings.');
×
193
        }
194

195
        $escape ??= $this->escape;
19✔
196

197
        if ($escape) {
19✔
198
            $first  = $this->db->protectIdentifiers($first, false, true);
16✔
199
            $second = $this->db->protectIdentifiers($second, false, true);
16✔
200
        }
201

202
        $this->appendCondition($this->prefix($type) . $first . ' ' . $operator . ' ' . $second);
19✔
203

204
        return $this;
19✔
205
    }
206

207
    private function whereHaving(string $key, mixed $value, string $type, ?bool $escape): static
208
    {
209
        $key = trim($key);
15✔
210

211
        if ($key === '') {
15✔
NEW
212
            throw new InvalidArgumentException('JoinClause value comparisons expect $key to be a non-empty string.');
×
213
        }
214

215
        $escape ??= $this->escape;
15✔
216

217
        if ($value !== null) {
15✔
218
            [$key, $operator] = $this->parseWhereKey($key);
14✔
219
            $bind             = ($this->setBind)($key, $value, $escape);
14✔
220
            $condition        = $this->protectIdentifier($key, $escape) . $operator . " :{$bind}:";
14✔
221
        } elseif (preg_match('/\s*(!=|<>|IS(?:\s+NOT)?)\s*$/i', $key, $match, PREG_OFFSET_CAPTURE) === 1) {
2✔
222
            $key       = substr($key, 0, $match[0][1]);
2✔
223
            $operator  = $match[1][0] === '=' || strcasecmp($match[1][0], 'IS') === 0 ? ' IS NULL' : ' IS NOT NULL';
2✔
224
            $condition = $this->protectIdentifier($key, $escape) . $operator;
2✔
225
        } else {
226
            $condition = $this->protectIdentifier($key, $escape) . ' IS NULL';
1✔
227
        }
228

229
        $this->appendCondition($this->prefix($type) . $condition);
15✔
230

231
        return $this;
15✔
232
    }
233

234
    private function groupStartPrepare(string $not = '', string $type = 'AND '): static
235
    {
236
        $this->conditions[]           = $this->prefix($type) . $not . '(';
10✔
237
        $this->groupConditionCounts[] = 0;
10✔
238
        $this->groupStarted           = true;
10✔
239

240
        return $this;
10✔
241
    }
242

243
    /**
244
     * @return array{string, string}
245
     */
246
    private function parseFirstColumn(string $first): array
247
    {
248
        $first = trim($first);
19✔
249

250
        if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $first, $match) === 1) {
19✔
251
            return [rtrim(substr($first, 0, -strlen($match[0]))), trim($match[1])];
1✔
252
        }
253

254
        return [$first, '='];
18✔
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) {
14✔
NEW
263
            return [rtrim(substr($key, 0, -strlen($match[0]))), ' ' . trim($match[1])];
×
264
        }
265

266
        return [$key, ' ='];
14✔
267
    }
268

269
    private function protectIdentifier(string $identifier, bool $escape): string
270
    {
271
        return $escape ? $this->db->protectIdentifiers($identifier) : $identifier;
15✔
272
    }
273

274
    private function prefix(string $type): string
275
    {
276
        if ($this->conditions === [] || $this->groupStarted) {
20✔
277
            $this->groupStarted = false;
20✔
278

279
            return '';
20✔
280
        }
281

282
        return ' ' . $type;
16✔
283
    }
284

285
    private function appendCondition(string $condition): void
286
    {
287
        $this->conditions[] = $condition;
19✔
288
        $this->incrementConditionCount();
19✔
289
    }
290

291
    private function incrementConditionCount(): void
292
    {
293
        if ($this->groupConditionCounts === []) {
19✔
294
            $this->conditionCount++;
18✔
295

296
            return;
18✔
297
        }
298

299
        $index = array_key_last($this->groupConditionCounts);
9✔
300
        $this->groupConditionCounts[$index]++;
9✔
301
    }
302
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc