Passed
Push — master ( fddfe5...a6d7b3 )
by y
02:19
created

Select::limit()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 11
rs 10
cc 3
nc 3
nop 2
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;
1 ignored issue
show
Documentation Bug introduced by
It seems like $table can also be of type string. However, the property $table is declared as type Helix\DB\AbstractTable. Maybe add an additional type check?

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 the id property of an instance of the Account 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.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
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
}