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

nextras / dbal / 28809905692

06 Jul 2026 05:18PM UTC coverage: 86.97% (-0.1%) from 87.088%
28809905692

push

github

web-flow
Merge pull request #324 from nextras/js/duplicated-joins

new QuryBuilder join API to distinguish adding/updating (BC break!)

17 of 23 new or added lines in 1 file covered. (73.91%)

2176 of 2502 relevant lines covered (86.97%)

5.1 hits per line

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

88.32
/src/QueryBuilder/QueryBuilder.php
1
<?php declare(strict_types = 1);
2

3
namespace Nextras\Dbal\QueryBuilder;
4

5

6
use Nextras\Dbal\Exception\InvalidArgumentException;
7
use Nextras\Dbal\Exception\InvalidStateException;
8
use Nextras\Dbal\Platforms\IPlatform;
9
use Nextras\Dbal\Utils\StrictObjectTrait;
10
use function array_merge;
11
use function md5;
12

13

14
class QueryBuilder
15
{
16
        use StrictObjectTrait;
17

18

19
        protected IPlatform $platform;
20

21
        /** @var array<string, array<mixed>|null> */
22
        protected $args = [
23
                'select' => null,
24
                'from' => null,
25
                'indexHints' => null,
26
                'where' => null,
27
                'group' => null,
28
                'having' => null,
29
                'order' => null,
30
        ];
31

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

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

38
        /** Denotes a SELECT DISTINCT clause. */
39
        protected bool $distinct = false;
40

41
        /** @var array{literal-string, literal-string|null}|null */
42
        protected $from;
43

44
        /** @var literal-string|null */
45
        protected $indexHints;
46

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

50
        /** @var literal-string|null */
51
        protected $where;
52

53
        /** @var literal-string[]|null */
54
        protected $group;
55

56
        /** @var literal-string|null */
57
        protected $having;
58

59
        /** @var literal-string[]|null */
60
        protected $order;
61

62
        /** @var array{?int, ?int}|null */
63
        protected $limit;
64

65
        /** @var string|null */
66
        protected $generatedSql;
67

68

69
        public function __construct(IPlatform $platform)
70
        {
71
                $this->platform = $platform;
6✔
72
        }
6✔
73

74

75
        public function getQuerySql(): string
76
        {
77
                if ($this->generatedSql !== null) {
6✔
78
                        return $this->generatedSql;
×
79
                }
80

81
                $this->generatedSql = $this->getSqlForSelect();
6✔
82
                return $this->generatedSql;
6✔
83
        }
84

85

86
        /**
87
         * @return array<mixed>
88
         */
89
        public function getQueryParameters(): array
90
        {
91
                $joinArgs = [];
6✔
92
                foreach ($this->joinArgs as $args) {
6✔
93
                        $joinArgs = array_merge($joinArgs, $args);
6✔
94
                }
95

96
                return array_merge(
6✔
97
                        (array) $this->args['select'],
6✔
98
                        (array) $this->args['from'],
6✔
99
                        (array) $this->args['indexHints'],
6✔
100
                        $joinArgs,
6✔
101
                        (array) $this->args['where'],
6✔
102
                        (array) $this->args['group'],
6✔
103
                        (array) $this->args['having'],
6✔
104
                        (array) $this->args['order'],
6✔
105
                );
106
        }
107

108

109
        protected function getSqlForSelect(): string
110
        {
111
                return
112
                        'SELECT ' . ($this->distinct ? 'DISTINCT ' : '')
6✔
113
                        . ($this->select !== null ? implode(', ', $this->select) : '*')
6✔
114
                        . ' FROM ' . $this->getFromClauses()
6✔
115
                        . ($this->where !== null ? ' WHERE ' . ($this->where) : '')
6✔
116
                        . ($this->group !== null ? ' GROUP BY ' . implode(', ', $this->group) : '')
6✔
117
                        . ($this->having !== null ? ' HAVING ' . ($this->having) : '')
6✔
118
                        . ($this->order !== null ? ' ORDER BY ' . implode(', ', $this->order) : '')
6✔
119
                        . ($this->limit !== null ? ' ' . $this->platform->formatLimitOffset(...$this->limit) : '');
6✔
120
        }
121

122

123
        protected function getFromClauses(): string
124
        {
125
                if ($this->from === null) {
6✔
126
                        throw new InvalidStateException();
×
127
                }
128
                $query = $this->from[0] . ($this->from[1] !== null ? " AS [{$this->from[1]}]" : '');
6✔
129

130
                if ($this->indexHints !== null) {
6✔
131
                        $query .= ' ' . $this->indexHints;
6✔
132
                }
133

134
                foreach ((array) $this->join as $join) {
6✔
135
                        $query .= " $join[type] JOIN $join[table] ON ($join[on])";
6✔
136
                }
137

138
                return $query;
6✔
139
        }
140

141

142
        /**
143
         * @return array{mixed, mixed}
144
         */
145
        public function getClause(string $part): array
146
        {
NEW
147
                if ($part === 'join') {
×
NEW
148
                        $joinArgs = [];
×
NEW
149
                        foreach ($this->joinArgs as $args) {
×
NEW
150
                                $joinArgs = array_merge($joinArgs, $args);
×
151
                        }
NEW
152
                        return [$this->join, $joinArgs];
×
153
                }
154

155
                if (!isset($this->args[$part]) && !array_key_exists($part, $this->args)) {
×
156
                        throw new InvalidArgumentException("Unknown '$part' clause type.");
×
157
                }
158

159
                return [$this->$part, $this->args[$part]]; // @phpstan-ignore-line
×
160
        }
161

162

163
        /**
164
         * @param literal-string $fromExpression
165
         * @param literal-string|null $alias
166
         * @param array<int, mixed> $args
167
         */
168
        public function from(string $fromExpression, ?string $alias = null, ...$args): self
169
        {
170
                $this->dirty();
6✔
171
                $this->from = [$fromExpression, $alias];
6✔
172
                $this->args['from'] = [];
6✔
173
                $this->pushArgs('from', $args);
6✔
174
                return $this;
6✔
175
        }
176

177

178
        /**
179
         * MySQL only feature.
180
         * @param literal-string|null $indexHintsExpression
181
         * @param array<int, mixed> $args
182
         * @return static
183
         */
184
        public function indexHints(?string $indexHintsExpression, ...$args): self
185
        {
186
                $this->dirty();
6✔
187
                $this->indexHints = $indexHintsExpression;
6✔
188
                $this->pushArgs('indexHints', $args);
6✔
189
                return $this;
6✔
190
        }
191

192

193
        /**
194
         * @return literal-string|null
195
         */
196
        public function getFromAlias(): ?string
197
        {
198
                if ($this->from === null) {
6✔
199
                        throw new InvalidStateException('From clause has not been set.');
6✔
200
                }
201

202
                return $this->from[1];
6✔
203
        }
204

205

206
        /**
207
         * Adds (another) INNER JOIN clause.
208
         *
209
         * To prevent JOIN clause duplication, see {@see joinOnce()} method.
210
         *
211
         * @param literal-string $toExpression
212
         * @param literal-string $onExpression
213
         * @param array<int, mixed> $args
214
         */
215
        public function addInnerJoin(string $toExpression, string $onExpression, ...$args): self
216
        {
217
                return $this->addJoin('INNER', $toExpression, $onExpression, $args);
6✔
218
        }
219

220

221
        /**
222
         * Adds (another) LEFT JOIN clause.
223
         *
224
         * To prevent JOIN clause duplication, see {@see joinOnce()} method.
225
         *
226
         * @param literal-string $toExpression
227
         * @param literal-string $onExpression
228
         * @param array<int, mixed> $args
229
         */
230
        public function addLeftJoin(string $toExpression, string $onExpression, ...$args): self
231
        {
232
                return $this->addJoin('LEFT', $toExpression, $onExpression, $args);
6✔
233
        }
234

235

236
        /**
237
         * Adds (another) RIGHT JOIN clause.
238
         *
239
         * To prevent JOIN clause duplication, see {@see joinOnce()} method.
240
         *
241
         * @param literal-string $toExpression
242
         * @param literal-string $onExpression
243
         * @param array<int, mixed> $args
244
         */
245
        public function addRightJoin(string $toExpression, string $onExpression, ...$args): self
246
        {
247
                return $this->addJoin('RIGHT', $toExpression, $onExpression, $args);
6✔
248
        }
249

250

251
        public function removeJoins(): self
252
        {
253
                $this->dirty();
×
254
                $this->join = null;
×
NEW
255
                $this->joinArgs = [];
×
256
                return $this;
×
257
        }
258

259

260
        /**
261
         * @param literal-string $toExpression
262
         * @param literal-string $onExpression
263
         * @param array<mixed> $args
264
         */
265
        protected function addJoin(string $type, string $toExpression, string $onExpression, array $args): self
266
        {
267
                $this->dirty();
6✔
268
                $this->join[] = [
6✔
269
                        'type' => $type,
6✔
270
                        'table' => $toExpression,
6✔
271
                        'on' => $onExpression,
6✔
272
                ];
273
                $this->joinArgs[] = $args;
6✔
274
                return $this;
6✔
275
        }
276

277

278
        /**
279
         * Adds {@see $joinType} JOIN with deduplication; reuses an already added join clause.
280
         *
281
         * This method tries to reuse a previously added join by using {@see $joinType}, {@see $toExpression},
282
         * {@see $onExpression}, and {@see $hashSuffix} to construct the unique join identifier.
283
         *
284
         * The join {@see $args} are intentionally not part of the hash. As a result, two calls that differ only in the
285
         * arguments are considered the same join unless they use a different {@see $hashSuffix}.
286
         *
287
         * This mainly matters when the expressions are dynamically constructed using modifiers. In that case, different
288
         * parameter values may still produce the same SQL expression shape. To distinguish those joins, use
289
         * {@see $hashSuffix}, which becomes part of the hash yet does not affect safe SQL construction.
290
         *
291
         * ```php
292
         * // use hashSuffix to distinguish joins that share the same SQL expression shape
293
         * $builder->joinOnce('LEFT', '%table', '%table.id = %table.another_id', [$table, $anotherTable], hashSuffix: $table . $anotherTable);
294
         * ```
295
         *
296
         * @param literal-string $joinType the SQL type of join: LEFT, INNER, OUTER, RIGHT, etc.
297
         * @param literal-string $toExpression
298
         * @param literal-string $onExpression
299
         * @param array<mixed> $args
300
         * @param string $hashSuffix additional part of the JOIN hash used to distinguish otherwise identical joins
301
         */
302
        public function joinOnce(
303
                string $joinType,
304
                string $toExpression,
305
                string $onExpression,
306
                array $args,
307
                string $hashSuffix = '',
308
        ): self
309
        {
310
                $this->dirty();
6✔
311
                $hash = md5($joinType . ';' . $toExpression . ';' . $onExpression . ';' . $hashSuffix);
6✔
312
                $this->join[$hash] = [
6✔
313
                        'type' => $joinType,
6✔
314
                        'table' => $toExpression,
6✔
315
                        'on' => $onExpression,
6✔
316
                ];
317
                $this->joinArgs[$hash] = $args;
6✔
318
                return $this;
6✔
319
        }
320

321

322
        /**
323
         * Sets expression as SELECT clause. Passing null sets clause to the default state.
324
         * @param literal-string|null $expression
325
         * @param array<int, mixed> $args
326
         */
327
        public function select(?string $expression = null, ...$args): self
328
        {
329
                $this->dirty();
6✔
330
                $this->select = $expression === null ? null : [$expression];
6✔
331
                $this->args['select'] = $args;
6✔
332
                return $this;
6✔
333
        }
334

335

336
        /**
337
         * Adds expression to SELECT clause.
338
         * @param literal-string $expression
339
         * @param array<int, mixed> $args
340
         */
341
        public function addSelect(string $expression, ...$args): self
342
        {
343
                $this->dirty();
6✔
344
                $this->select[] = $expression;
6✔
345
                $this->pushArgs('select', $args);
6✔
346
                return $this;
6✔
347
        }
348

349

350
        /**
351
         * Sets SELECT DISTINCT clause.
352
         * A default state is false.
353
         */
354
        public function distinct(bool $distinct): self
355
        {
356
                $this->dirty();
6✔
357
                $this->distinct = $distinct;
6✔
358
                return $this;
6✔
359
        }
360

361

362
        /**
363
         * Returns whether SELECT DISTINCT clause is set.
364
         */
365
        public function getDistinct(): bool
366
        {
367
                return $this->distinct;
×
368
        }
369

370

371
        /**
372
         * Sets expression as WHERE clause. Passing null sets clause to the default state.
373
         * @param literal-string|null $expression
374
         * @param array<int, mixed> $args
375
         */
376
        public function where(?string $expression = null, ...$args): self
377
        {
378
                $this->dirty();
6✔
379
                $this->where = $expression;
6✔
380
                $this->args['where'] = $args;
6✔
381
                return $this;
6✔
382
        }
383

384

385
        /**
386
         * Adds expression with AND to WHERE clause.
387
         * @param literal-string $expression
388
         * @param array<int, mixed> $args
389
         */
390
        public function andWhere(string $expression, ...$args): self
391
        {
392
                $this->dirty();
6✔
393
                $this->where = $this->where !== null ? '(' . $this->where . ') AND (' . $expression . ')' : $expression;
6✔
394
                $this->pushArgs('where', $args);
6✔
395
                return $this;
6✔
396
        }
397

398

399
        /**
400
         * Adds expression with OR to WHERE clause.
401
         * @param literal-string $expression
402
         * @param array<int, mixed> $args
403
         */
404
        public function orWhere(string $expression, ...$args): self
405
        {
406
                $this->dirty();
6✔
407
                $this->where = $this->where !== null ? '(' . $this->where . ') OR (' . $expression . ')' : $expression;
6✔
408
                $this->pushArgs('where', $args);
6✔
409
                return $this;
6✔
410
        }
411

412

413
        /**
414
         * Sets expression as GROUP BY clause. Passing null sets clause to the default state.
415
         * @param literal-string|null $expression
416
         * @param array<int, mixed> $args
417
         */
418
        public function groupBy(?string $expression = null, ...$args): self
419
        {
420
                $this->dirty();
6✔
421
                $this->group = $expression === null ? null : [$expression];
6✔
422
                $this->args['group'] = $args;
6✔
423
                return $this;
6✔
424
        }
425

426

427
        /**
428
         * Adds expression to GROUP BY clause.
429
         * @param literal-string $expression
430
         * @param array<int, mixed> $args
431
         */
432
        public function addGroupBy(string $expression, ...$args): self
433
        {
434
                $this->dirty();
6✔
435
                $this->group[] = $expression;
6✔
436
                $this->pushArgs('group', $args);
6✔
437
                return $this;
6✔
438
        }
439

440

441
        /**
442
         * Sets expression as HAVING clause. Passing null sets clause to the default state.
443
         * @param literal-string|null $expression
444
         * @param array<int, mixed> $args
445
         */
446
        public function having(?string $expression = null, ...$args): self
447
        {
448
                $this->dirty();
6✔
449
                $this->having = $expression;
6✔
450
                $this->args['having'] = $args;
6✔
451
                return $this;
6✔
452
        }
453

454

455
        /**
456
         * Adds expression with AND to HAVING clause.
457
         * @param literal-string $expression
458
         * @param array<int, mixed> $args
459
         */
460
        public function andHaving(string $expression, ...$args): self
461
        {
462
                $this->dirty();
6✔
463
                $this->having = $this->having !== null ? '(' . $this->having . ') AND (' . $expression . ')' : $expression;
6✔
464
                $this->pushArgs('having', $args);
6✔
465
                return $this;
6✔
466
        }
467

468

469
        /**
470
         * Adds expression with OR to HAVING clause.
471
         * @param literal-string $expression
472
         * @param array<int, mixed> $args
473
         */
474
        public function orHaving(string $expression, ...$args): self
475
        {
476
                $this->dirty();
6✔
477
                $this->having = $this->having !== null ? '(' . $this->having . ') OR (' . $expression . ')' : $expression;
6✔
478
                $this->pushArgs('having', $args);
6✔
479
                return $this;
6✔
480
        }
481

482

483
        /**
484
         * Sets expression as ORDER BY clause. Passing null sets clause to the default state.
485
         * @param literal-string|null $expression
486
         * @param array<int, mixed> $args
487
         */
488
        public function orderBy(?string $expression = null, ...$args): self
489
        {
490
                $this->dirty();
6✔
491
                $this->order = $expression === null ? null : [$expression];
6✔
492
                $this->args['order'] = $args;
6✔
493
                return $this;
6✔
494
        }
495

496

497
        /**
498
         * Adds expression to ORDER BY clause.
499
         * @param literal-string $expression
500
         * @param array<int, mixed> $args
501
         */
502
        public function addOrderBy(string $expression, ...$args): self
503
        {
504
                $this->dirty();
6✔
505
                $this->order[] = $expression;
6✔
506
                $this->pushArgs('order', $args);
6✔
507
                return $this;
6✔
508
        }
509

510

511
        /**
512
         * Sets LIMIT and OFFSET clause.
513
         */
514
        public function limitBy(?int $limit, ?int $offset = null): self
515
        {
516
                $this->dirty();
6✔
517
                $this->limit = $limit !== null || $offset !== null ? [$limit, $offset] : null;
6✔
518
                return $this;
6✔
519
        }
520

521

522
        /**
523
         * Returns true if LIMIT or OFFSET clause is set.
524
         */
525
        public function hasLimitOffsetClause(): bool
526
        {
527
                return $this->limit !== null;
6✔
528
        }
529

530

531
        /**
532
         * Returns limit and offset clause arguments.
533
         * @return array{?int, ?int}|null
534
         */
535
        public function getLimitOffsetClause(): ?array
536
        {
537
                return $this->limit;
×
538
        }
539

540

541
        protected function dirty(): void
542
        {
543
                $this->generatedSql = null;
6✔
544
        }
6✔
545

546

547
        /**
548
         * @param array<mixed> $args
549
         */
550
        protected function pushArgs(string $type, array $args): void
551
        {
552
                $this->args[$type] = array_merge((array) $this->args[$type], $args);
6✔
553
        }
6✔
554
}
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