Select::getIterator()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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