1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace Helix\DB; |
4
|
|
|
|
5
|
|
|
use Closure; |
6
|
|
|
use Countable; |
7
|
|
|
use Generator; |
8
|
|
|
use Helix\DB; |
9
|
|
|
use Helix\DB\SQL\ExpressionInterface; |
10
|
|
|
use Helix\DB\SQL\Predicate; |
11
|
|
|
use IteratorAggregate; |
12
|
|
|
|
13
|
|
|
/** |
14
|
|
|
* Represents a `SELECT` query. |
15
|
|
|
* |
16
|
|
|
* @method static static factory(DB $db, $table, array $columns) |
17
|
|
|
*/ |
18
|
|
|
class Select extends AbstractTable implements Countable, IteratorAggregate, ExpressionInterface { |
19
|
|
|
|
20
|
|
|
use FactoryTrait; |
21
|
|
|
|
22
|
|
|
/** |
23
|
|
|
* Compiled column list. |
24
|
|
|
* |
25
|
|
|
* @internal |
26
|
|
|
* @var string |
27
|
|
|
*/ |
28
|
|
|
protected $_columns = ''; |
29
|
|
|
|
30
|
|
|
/** |
31
|
|
|
* Compiled column list. |
32
|
|
|
* |
33
|
|
|
* @internal |
34
|
|
|
* @var string |
35
|
|
|
*/ |
36
|
|
|
protected $_group = ''; |
37
|
|
|
|
38
|
|
|
/** |
39
|
|
|
* Compiled predicates. |
40
|
|
|
* |
41
|
|
|
* @internal |
42
|
|
|
* @var string |
43
|
|
|
*/ |
44
|
|
|
protected $_having = ''; |
45
|
|
|
|
46
|
|
|
/** |
47
|
|
|
* Compiled unions and intersections. |
48
|
|
|
* |
49
|
|
|
* @internal |
50
|
|
|
* @var string |
51
|
|
|
*/ |
52
|
|
|
protected $_import = ''; |
53
|
|
|
|
54
|
|
|
/** |
55
|
|
|
* Compiled joins. |
56
|
|
|
* |
57
|
|
|
* @internal |
58
|
|
|
* @var string |
59
|
|
|
*/ |
60
|
|
|
protected $_join = ''; |
61
|
|
|
|
62
|
|
|
/** |
63
|
|
|
* Compiled limit and offset. |
64
|
|
|
* |
65
|
|
|
* @internal |
66
|
|
|
* @var string |
67
|
|
|
*/ |
68
|
|
|
protected $_limit = ''; |
69
|
|
|
|
70
|
|
|
/** |
71
|
|
|
* Compiled column list. |
72
|
|
|
* |
73
|
|
|
* @internal |
74
|
|
|
* @var string |
75
|
|
|
*/ |
76
|
|
|
protected $_order = ''; |
77
|
|
|
|
78
|
|
|
/** |
79
|
|
|
* Compiled source table. |
80
|
|
|
* |
81
|
|
|
* @var string |
82
|
|
|
*/ |
83
|
|
|
protected $_table; |
84
|
|
|
|
85
|
|
|
/** |
86
|
|
|
* Compiled predicates. |
87
|
|
|
* |
88
|
|
|
* @internal |
89
|
|
|
* @var string |
90
|
|
|
*/ |
91
|
|
|
protected $_where = ''; |
92
|
|
|
|
93
|
|
|
/** |
94
|
|
|
* Human-readable alias. |
95
|
|
|
* |
96
|
|
|
* @var string |
97
|
|
|
*/ |
98
|
|
|
protected $alias; |
99
|
|
|
|
100
|
|
|
/** |
101
|
|
|
* A callback to yield each result. |
102
|
|
|
* Defaults to yielding directly from the statement. |
103
|
|
|
* |
104
|
|
|
* @var Closure `(Statement $statement):Generator` |
105
|
|
|
*/ |
106
|
|
|
protected $fetcher; |
107
|
|
|
|
108
|
|
|
/** |
109
|
|
|
* Columns that can be accessed by an outer query. |
110
|
|
|
* |
111
|
|
|
* @var Column[] |
112
|
|
|
*/ |
113
|
|
|
protected $refs = []; |
114
|
|
|
|
115
|
|
|
/** |
116
|
|
|
* The original table given to the constructor. |
117
|
|
|
* |
118
|
|
|
* @var AbstractTable |
119
|
|
|
*/ |
120
|
|
|
protected $table; |
121
|
|
|
|
122
|
|
|
/** |
123
|
|
|
* @param DB $db |
124
|
|
|
* @param string|AbstractTable $table |
125
|
|
|
* @param string[] $columns |
126
|
|
|
*/ |
127
|
|
|
public function __construct (DB $db, $table, array $columns = ['*']) { |
128
|
|
|
static $autoAlias = 0; |
129
|
|
|
$autoAlias++; |
130
|
|
|
parent::__construct($db); |
131
|
|
|
if ($table instanceof Select) { |
132
|
|
|
$this->_table = $table->toSubquery(); |
133
|
|
|
$this->alias = "_anon{$autoAlias}_{$table->alias}"; |
134
|
|
|
} |
135
|
|
|
else { |
136
|
|
|
if (is_string($table)) { |
137
|
|
|
$table = $db->getTable($table); |
138
|
|
|
assert(isset($table)); |
139
|
|
|
} |
140
|
|
|
$this->_table = (string)$table; |
141
|
|
|
$this->alias = "_anon{$autoAlias}_{$table}"; |
142
|
|
|
} |
143
|
|
|
$this->table = $table; |
|
|
|
|
144
|
|
|
$this->setColumns($columns); |
145
|
|
|
$this->fetcher = function(Statement $statement) { |
146
|
|
|
yield from $statement; |
147
|
|
|
}; |
148
|
|
|
} |
149
|
|
|
|
150
|
|
|
/** |
151
|
|
|
* @param array $args |
152
|
|
|
* @return Statement |
153
|
|
|
*/ |
154
|
|
|
public function __invoke (array $args = []) { |
155
|
|
|
return $this->execute($args); |
156
|
|
|
} |
157
|
|
|
|
158
|
|
|
/** |
159
|
|
|
* Returns the alias. |
160
|
|
|
* |
161
|
|
|
* @return string |
162
|
|
|
*/ |
163
|
|
|
final public function __toString () { |
164
|
|
|
return $this->alias; |
165
|
|
|
} |
166
|
|
|
|
167
|
|
|
/** |
168
|
|
|
* Clones the instance and selects `COUNT(*)`, using the given execution arguments. |
169
|
|
|
* |
170
|
|
|
* @param array $args Execution arguments. |
171
|
|
|
* @return int |
172
|
|
|
*/ |
173
|
|
|
public function count (array $args = []): int { |
174
|
|
|
$clone = clone $this; |
175
|
|
|
$clone->_columns = 'COUNT(*)'; |
176
|
|
|
$clone->_order = ''; |
177
|
|
|
return (int)$clone->execute($args)->fetchColumn(); |
178
|
|
|
} |
179
|
|
|
|
180
|
|
|
/** |
181
|
|
|
* Executes the select, preparing a statement first if arguments are used. |
182
|
|
|
* |
183
|
|
|
* @param array $args |
184
|
|
|
* @return Statement |
185
|
|
|
*/ |
186
|
|
|
public function execute (array $args = []) { |
187
|
|
|
if (empty($args)) { |
188
|
|
|
return $this->db->query($this->toSql()); |
189
|
|
|
} |
190
|
|
|
return $this->prepare()->__invoke($args); |
191
|
|
|
} |
192
|
|
|
|
193
|
|
|
/** |
194
|
|
|
* Executes and fetches all results. |
195
|
|
|
* |
196
|
|
|
* @see fetcher |
197
|
|
|
* |
198
|
|
|
* @param array $args Execution arguments. |
199
|
|
|
* @return array |
200
|
|
|
*/ |
201
|
|
|
public function getAll (array $args = []): array { |
202
|
|
|
return iterator_to_array($this->fetcher->__invoke($this->execute($args))); |
203
|
|
|
} |
204
|
|
|
|
205
|
|
|
/** |
206
|
|
|
* @return Column[] |
207
|
|
|
*/ |
208
|
|
|
public function getColumns () { |
209
|
|
|
return $this->refs; |
210
|
|
|
} |
211
|
|
|
|
212
|
|
|
/** |
213
|
|
|
* Executes and yields from the fetcher. |
214
|
|
|
* This is preferable over `fetchAll()` for iterating large result sets. |
215
|
|
|
* |
216
|
|
|
* @see fetcher |
217
|
|
|
* |
218
|
|
|
* @param array $args Execution arguments. |
219
|
|
|
* @return Generator |
220
|
|
|
*/ |
221
|
|
|
public function getEach (array $args = []) { |
222
|
|
|
yield from $this->fetcher->__invoke($this->execute($args)); |
223
|
|
|
} |
224
|
|
|
|
225
|
|
|
/** |
226
|
|
|
* Executes and returns from the fetcher. |
227
|
|
|
* |
228
|
|
|
* @see fetcher |
229
|
|
|
* |
230
|
|
|
* @param array $args |
231
|
|
|
* @return mixed |
232
|
|
|
*/ |
233
|
|
|
public function getFirst (array $args = []) { |
234
|
|
|
return $this->getEach($args)->current(); |
235
|
|
|
} |
236
|
|
|
|
237
|
|
|
/** |
238
|
|
|
* Executes without arguments and yields from the fetcher. |
239
|
|
|
* |
240
|
|
|
* @see fetcher |
241
|
|
|
* |
242
|
|
|
* @return Generator |
243
|
|
|
*/ |
244
|
|
|
public function getIterator () { |
245
|
|
|
yield from $this->getEach(); |
246
|
|
|
} |
247
|
|
|
|
248
|
|
|
/** |
249
|
|
|
* Executes and returns the first column of the first row. |
250
|
|
|
* Use this for reductive queries that only have a single result. |
251
|
|
|
* |
252
|
|
|
* @return mixed |
253
|
|
|
*/ |
254
|
|
|
public function getResult (array $args = []) { |
255
|
|
|
return $this->execute($args)->fetchColumn(); |
256
|
|
|
} |
257
|
|
|
|
258
|
|
|
/** |
259
|
|
|
* Adds a column to the `GROUP BY` clause. |
260
|
|
|
* |
261
|
|
|
* @param string $column |
262
|
|
|
* @return $this |
263
|
|
|
*/ |
264
|
|
|
public function group (string $column) { |
265
|
|
|
if (!strlen($this->_group)) { |
266
|
|
|
$this->_group = " GROUP BY {$column}"; |
267
|
|
|
} |
268
|
|
|
else { |
269
|
|
|
$this->_group .= ", {$column}"; |
270
|
|
|
} |
271
|
|
|
return $this; |
272
|
|
|
} |
273
|
|
|
|
274
|
|
|
/** |
275
|
|
|
* Adds conditions to the `HAVING` clause. |
276
|
|
|
* |
277
|
|
|
* @param string ...$conditions |
278
|
|
|
* @return $this |
279
|
|
|
*/ |
280
|
|
|
public function having (string ...$conditions) { |
281
|
|
|
assert(count($conditions) > 0); |
282
|
|
|
$conditions = implode(' AND ', $conditions); |
283
|
|
|
if (!strlen($this->_having)) { |
284
|
|
|
$this->_having = " HAVING {$conditions}"; |
285
|
|
|
} |
286
|
|
|
else { |
287
|
|
|
$this->_having .= " AND {$conditions}"; |
288
|
|
|
} |
289
|
|
|
return $this; |
290
|
|
|
} |
291
|
|
|
|
292
|
|
|
/** |
293
|
|
|
* `INTERSECT SELECT ...` |
294
|
|
|
* |
295
|
|
|
* > Note: MySQL does not support `INTERSECT`. An `INNER JOIN` on every column is used instead. |
296
|
|
|
* |
297
|
|
|
* @param Select $select |
298
|
|
|
* @return $this |
299
|
|
|
*/ |
300
|
|
|
public function intersect (Select $select) { |
301
|
|
|
if ($this->db->isMySQL()) { |
302
|
|
|
// to be standards compliant, this hack must fail if they don't have the same cols. |
303
|
|
|
assert(count($this->refs) === count($select->refs) and !array_diff_key($this->refs, $select->refs)); |
304
|
|
|
$this->join($select, ...array_map(function(string $alias, Column $ref) { |
305
|
|
|
return $ref->is($this->refs[$alias]); |
306
|
|
|
}, array_keys($select->refs), $select->refs)); |
307
|
|
|
return $this; |
308
|
|
|
} |
309
|
|
|
$select = clone $select; |
310
|
|
|
$select->_order = ''; |
311
|
|
|
$select->_limit = ''; |
312
|
|
|
$this->_import .= " INTERSECT {$select->toSql()}"; |
313
|
|
|
return $this; |
314
|
|
|
} |
315
|
|
|
|
316
|
|
|
/** |
317
|
|
|
* `NOT EXISTS (SELECT ...)` |
318
|
|
|
* |
319
|
|
|
* @return Predicate |
320
|
|
|
*/ |
321
|
|
|
public function isEmpty () { |
322
|
|
|
return Predicate::factory($this->db, "NOT EXISTS ({$this->toSql()})"); |
323
|
|
|
} |
324
|
|
|
|
325
|
|
|
/** |
326
|
|
|
* `EXISTS (SELECT ...)` |
327
|
|
|
* |
328
|
|
|
* @return Predicate |
329
|
|
|
*/ |
330
|
|
|
public function isNotEmpty () { |
331
|
|
|
return Predicate::factory($this->db, "EXISTS ({$this->toSql()})"); |
332
|
|
|
} |
333
|
|
|
|
334
|
|
|
/** |
335
|
|
|
* Adds `INNER JOIN $table ON $conditions` |
336
|
|
|
* |
337
|
|
|
* @param string|Select $table |
338
|
|
|
* @param string ...$conditions |
339
|
|
|
* @return $this |
340
|
|
|
*/ |
341
|
|
|
public function join ($table, string ...$conditions) { |
342
|
|
|
assert(count($conditions) > 0); |
343
|
|
|
if ($table instanceof Select) { |
344
|
|
|
$table = $table->toSubquery(); |
345
|
|
|
} |
346
|
|
|
$conditions = implode(' AND ', $conditions); |
347
|
|
|
$this->_join .= " INNER JOIN {$table} ON {$conditions}"; |
348
|
|
|
return $this; |
349
|
|
|
} |
350
|
|
|
|
351
|
|
|
/** |
352
|
|
|
* Adds `LEFT JOIN $table ON $conditions` |
353
|
|
|
* |
354
|
|
|
* @param string|Select $table |
355
|
|
|
* @param string ...$conditions |
356
|
|
|
* @return $this |
357
|
|
|
*/ |
358
|
|
|
public function joinLeft ($table, string ...$conditions) { |
359
|
|
|
assert(count($conditions) > 0); |
360
|
|
|
if ($table instanceof Select) { |
361
|
|
|
$table = $table->toSubquery(); |
362
|
|
|
} |
363
|
|
|
$conditions = implode(' AND ', $conditions); |
364
|
|
|
$this->_join .= " LEFT JOIN {$table} ON {$conditions}"; |
365
|
|
|
return $this; |
366
|
|
|
} |
367
|
|
|
|
368
|
|
|
/** |
369
|
|
|
* Sets the `LIMIT` clause. |
370
|
|
|
* |
371
|
|
|
* @param int $limit |
372
|
|
|
* @param int $offset |
373
|
|
|
* @return $this |
374
|
|
|
*/ |
375
|
|
|
public function limit (int $limit, int $offset = 0) { |
376
|
|
|
if ($limit == 0) { |
377
|
|
|
$this->_limit = ''; |
378
|
|
|
} |
379
|
|
|
else { |
380
|
|
|
$this->_limit = " LIMIT {$limit}"; |
381
|
|
|
if ($offset > 1) { |
382
|
|
|
$this->_limit .= " OFFSET {$offset}"; |
383
|
|
|
} |
384
|
|
|
} |
385
|
|
|
return $this; |
386
|
|
|
} |
387
|
|
|
|
388
|
|
|
/** |
389
|
|
|
* Returns a reference {@link Column} for an outer query, qualified by the instance's alias. |
390
|
|
|
* |
391
|
|
|
* @param int|string $ref Ordinal or reference name. |
392
|
|
|
* @return null|Column |
393
|
|
|
*/ |
394
|
|
|
public function offsetGet ($ref) { |
395
|
|
|
if (is_int($ref)) { |
396
|
|
|
return current(array_slice($this->refs, $ref, 1)) ?: null; |
397
|
|
|
} |
398
|
|
|
return $this->refs[$ref] ?? null; |
399
|
|
|
} |
400
|
|
|
|
401
|
|
|
/** |
402
|
|
|
* Sets the `ORDER BY` clause. |
403
|
|
|
* |
404
|
|
|
* @param string $order |
405
|
|
|
* @return $this |
406
|
|
|
*/ |
407
|
|
|
public function order (string $order) { |
408
|
|
|
if (strlen($order)) { |
409
|
|
|
$order = " ORDER BY {$order}"; |
410
|
|
|
} |
411
|
|
|
$this->_order = $order; |
412
|
|
|
return $this; |
413
|
|
|
} |
414
|
|
|
|
415
|
|
|
/** |
416
|
|
|
* @return Statement |
417
|
|
|
*/ |
418
|
|
|
public function prepare () { |
419
|
|
|
return $this->db->prepare($this->toSql()); |
420
|
|
|
} |
421
|
|
|
|
422
|
|
|
/** |
423
|
|
|
* @param string $alias |
424
|
|
|
* @return $this |
425
|
|
|
*/ |
426
|
|
|
public function setAlias (string $alias) { |
427
|
|
|
$this->alias = $alias; |
428
|
|
|
foreach ($this->refs as $k => $column) { |
429
|
|
|
$this->refs[$k] = $column->setQualifier($alias); |
430
|
|
|
} |
431
|
|
|
return $this; |
432
|
|
|
} |
433
|
|
|
|
434
|
|
|
/** |
435
|
|
|
* Compiles the column list and exposed reference columns. |
436
|
|
|
* |
437
|
|
|
* Columns may be expressions, like `COUNT(*)` |
438
|
|
|
* Unless an alias is given for such expressions, they can't be referenced externally. |
439
|
|
|
* |
440
|
|
|
* @param string[] $expressions Keyed by alias if applicable. |
441
|
|
|
* @return $this |
442
|
|
|
*/ |
443
|
|
|
public function setColumns (array $expressions = ['*']) { |
444
|
|
|
if ($expressions === ['*']) { |
445
|
|
|
$expressions = array_keys($this->table->getColumns()); |
446
|
|
|
} |
447
|
|
|
$this->refs = []; |
448
|
|
|
$_columns = []; |
449
|
|
|
foreach ($expressions as $alias => $expr) { |
450
|
|
|
preg_match('/^([a-z_][a-z0-9_]+\.)?(?<name>[a-z_][a-z0-9_]+)$/i', $expr, $match); |
451
|
|
|
$name = $match['name'] ?? null; |
452
|
|
|
if (is_int($alias)) { |
453
|
|
|
$alias = $name; |
454
|
|
|
} |
455
|
|
|
elseif ($alias !== $name) { |
456
|
|
|
$expr .= " AS {$alias}"; |
457
|
|
|
} |
458
|
|
|
if (isset($alias)) { |
459
|
|
|
$this->refs[$alias] = Column::factory($this->db, $alias, $this->alias); |
460
|
|
|
} |
461
|
|
|
$_columns[] = "{$expr}"; |
462
|
|
|
} |
463
|
|
|
$this->_columns = implode(', ', $_columns); |
464
|
|
|
return $this; |
465
|
|
|
} |
466
|
|
|
|
467
|
|
|
/** |
468
|
|
|
* @param Closure $fetcher |
469
|
|
|
* @return $this |
470
|
|
|
*/ |
471
|
|
|
public function setFetcher (Closure $fetcher) { |
472
|
|
|
$this->fetcher = $fetcher; |
473
|
|
|
return $this; |
474
|
|
|
} |
475
|
|
|
|
476
|
|
|
/** |
477
|
|
|
* `SELECT ...` |
478
|
|
|
* |
479
|
|
|
* @return string |
480
|
|
|
*/ |
481
|
|
|
public function toSql (): string { |
482
|
|
|
$sql = "SELECT {$this->_columns} FROM {$this->_table}"; |
483
|
|
|
$sql .= $this->_join; |
484
|
|
|
$sql .= $this->_where; |
485
|
|
|
$sql .= $this->_group; |
486
|
|
|
$sql .= $this->_having; |
487
|
|
|
$sql .= $this->_import; |
488
|
|
|
$sql .= $this->_order; |
489
|
|
|
$sql .= $this->_limit; |
490
|
|
|
return $sql; |
491
|
|
|
} |
492
|
|
|
|
493
|
|
|
/** |
494
|
|
|
* `(SELECT ...) AS ALIAS` |
495
|
|
|
* |
496
|
|
|
* @return string |
497
|
|
|
*/ |
498
|
|
|
public function toSubquery (): string { |
499
|
|
|
return "({$this->toSql()}) AS {$this->alias}"; |
500
|
|
|
} |
501
|
|
|
|
502
|
|
|
/** |
503
|
|
|
* `UNION SELECT ...` |
504
|
|
|
* |
505
|
|
|
* @param Select $select |
506
|
|
|
* @return $this |
507
|
|
|
*/ |
508
|
|
|
public function union (Select $select) { |
509
|
|
|
$select = clone $select; |
510
|
|
|
$select->_order = ''; |
511
|
|
|
$select->_limit = ''; |
512
|
|
|
$this->_import .= " UNION {$select->toSql()}"; |
513
|
|
|
return $this; |
514
|
|
|
} |
515
|
|
|
|
516
|
|
|
/** |
517
|
|
|
* `UNION ALL SELECT ...` |
518
|
|
|
* |
519
|
|
|
* @param Select $select |
520
|
|
|
* @return $this |
521
|
|
|
*/ |
522
|
|
|
public function unionAll (Select $select) { |
523
|
|
|
$select = clone $select; |
524
|
|
|
$select->_order = ''; |
525
|
|
|
$select->_limit = ''; |
526
|
|
|
$this->_import .= " UNION ALL {$select->toSql()}"; |
527
|
|
|
return $this; |
528
|
|
|
} |
529
|
|
|
|
530
|
|
|
/** |
531
|
|
|
* Adds conditions to the `WHERE` clause. |
532
|
|
|
* |
533
|
|
|
* @param string ...$conditions |
534
|
|
|
* @return $this |
535
|
|
|
*/ |
536
|
|
|
public function where (string ...$conditions) { |
537
|
|
|
assert(count($conditions) > 0); |
538
|
|
|
$conditions = implode(' AND ', $conditions); |
539
|
|
|
if (!strlen($this->_where)) { |
540
|
|
|
$this->_where = " WHERE {$conditions}"; |
541
|
|
|
} |
542
|
|
|
else { |
543
|
|
|
$this->_where .= " AND {$conditions}"; |
544
|
|
|
} |
545
|
|
|
return $this; |
546
|
|
|
} |
547
|
|
|
} |
Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.
For example, imagine you have a variable
$accountId
that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to theid
property of an instance of theAccount
class. This class holds a proper account, so the id value must no longer be false.Either this assignment is in error or a type check should be added for that assignment.