Completed
Push — master ( 5ae7b2...20e1fc )
by Ivan
02:21
created

TableQuery::insert()   B

Complexity

Conditions 8
Paths 18

Size

Total Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 10.9144

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 38
ccs 18
cts 28
cp 0.6429
rs 8.0675
cc 8
nc 18
nop 1
crap 10.9144
1
<?php
2
namespace vakata\database\schema;
3
4
use vakata\collection\Collection;
5
use vakata\database\DBInterface;
6
use vakata\database\DBException;
7
use vakata\database\ResultInterface;
8
9
/**
10
 * A database query class
11
 */
12
class TableQuery implements \IteratorAggregate, \ArrayAccess, \Countable
13
{
14
    const SEP = '___';
15
    /**
16
     * @var DBInterface
17
     */
18
    protected $db;
19
    /**
20
     * @var Table
21
     */
22
    protected $definition;
23
    /**
24
     * @var TableQueryIterator|null
25
     */
26
    protected $qiterator;
27
28
    /**
29
     * @var array
30
     */
31
    protected $where = [];
32
    /**
33
     * @var array
34
     */
35
    protected $order = [];
36
    /**
37
     * @var array
38
     */
39
    protected $group = [];
40
    /**
41
     * @var array
42
     */
43
    protected $having = [];
44
    /**
45
     * @var int[]
46
     */
47
    protected $li_of = [0,0,0];
48
    /**
49
     * @var array
50
     */
51
    protected $fields = [];
52
    /**
53
     * @var array
54
     */
55
    protected $withr = [];
56
    /**
57
     * @var array
58
     */
59
    protected $joins = [];
60
    /**
61
     * @var array
62
     */
63
    protected $pkey = [];
64
    /**
65
     * @var array
66
     */
67
    protected $aliases = [];
68
69
    /**
70
     * Create an instance
71
     * @param  DBInterface    $db         the database connection
72
     * @param  Table|string   $table      the name or definition of the main table in the query
73
     */
74 50
    public function __construct(DBInterface $db, $table)
75
    {
76 50
        $this->db = $db;
77 50
        $this->definition = $table instanceof Table ? $table : $this->db->definition((string)$table);
78 50
        $primary = $this->definition->getPrimaryKey();
79 50
        $columns = $this->definition->getColumns();
80 50
        $this->pkey = count($primary) ? $primary : $columns;
81 50
        $this->columns($columns);
82 50
    }
83
    public function __clone()
84
    {
85
        $this->reset();
86
    }
87
    /**
88
     * Get the table definition of the queried table
89
     * @return Table        the definition
90
     */
91
    public function getDefinition() : Table
92
    {
93
        return $this->definition;
94
    }
95
96 50
    protected function getColumn($column)
97
    {
98 50
        $column = explode('.', $column);
99 50
        if (count($column) === 1) {
100 50
            $column = [ $this->definition->getName(), $column[0] ];
101 50
            $col = $this->definition->getColumn($column[1]);
102 50
            if (!$col) {
103 50
                throw new DBException('Invalid column name in own table');
104
            }
105 8
        } elseif (count($column) === 2) {
106 8
            if ($column[0] === $this->definition->getName()) {
107
                $col = $this->definition->getColumn($column[1]);
108
                if (!$col) {
109
                    throw new DBException('Invalid column name in own table');
110
                }
111
            } else {
112 8
                if ($this->definition->hasRelation($column[0])) {
113 6
                    $col = $this->definition->getRelation($column[0])->table->getColumn($column[1]);
114 6
                    if (!$col) {
115 6
                        throw new DBException('Invalid column name in related table');
116
                    }
117 2
                } else if (isset($this->joins[$column[0]])) {
118 2
                    $col = $this->joins[$column[0]]->table->getColumn($column[1]);
119 2
                    if (!$col) {
120 2
                        throw new DBException('Invalid column name in related table');
121
                    }
122
                } else {
123 8
                    throw new DBException('Invalid foreign table name: ' . implode(',', $column));
124
                }
125
            }
126
        } else {
127
            $name = array_pop($column);
128
            $this->with(implode('.', $column));
129
            $table = $this->definition;
130
            $table = array_reduce(
131
                $column,
132
                function ($carry, $item) use (&$table) {
133
                    $table = $table->getRelation($item)->table;
134
                    return $table;
135
                }
136
            );
137
            $col = $table->getColumn($name);
138
            $column = [ implode(static::SEP, $column), $name ];
139
        }
140 50
        return [ 'name' => implode('.', $column), 'data' => $col ];
141
    }
142 22
    protected function normalizeValue(TableColumn $col, $value)
143
    {
144 22
        $strict = (int)$this->db->driverOption('strict', 0) > 0;
145 22
        if ($value === null && $col->isNullable()) {
146
            return null;
147
        }
148 22
        switch ($col->getBasicType()) {
149 22
            case 'date':
150
                if (is_string($value)) {
151
                    $temp = strtotime($value);
152
                    if (!$temp) {
153
                        if ($strict) {
154
                            throw new DBException('Invalid value for date column ' . $col->getName());
155
                        }
156
                        return null;
157
                    }
158
                    return date('Y-m-d', $temp);
159
                }
160
                if (is_int($value)) {
161
                    return date('Y-m-d', $value);
162
                }
163
                if ($value instanceof \DateTime) {
164
                    return $value->format('Y-m-d');
165
                }
166
                if ($strict) {
167
                    throw new DBException('Invalid value (unknown data type) for date column ' . $col->getName());
168
                }
169
                return $value;
170 22
            case 'datetime':
171
                if (is_string($value)) {
172
                    $temp = strtotime($value);
173
                    if (!$temp) {
174
                        if ($strict) {
175
                            throw new DBException('Invalid value for datetime column ' . $col->getName());
176
                        }
177
                        return null;
178
                    }
179
                    return date('Y-m-d H:i:s', $temp);
180
                }
181
                if (is_int($value)) {
182
                    return date('Y-m-d H:i:s', $value);
183
                }
184
                if ($value instanceof \DateTime) {
185
                    return $value->format('Y-m-d H:i:s');
186
                }
187
                if ($strict) {
188
                    throw new DBException('Invalid value (unknown data type) for datetime column ' . $col->getName());
189
                }
190
                return $value;
191 22
            case 'enum':
192
                $values = $col->getValues();
193
                if (is_int($value)) {
194
                    if (!isset($values[$value])) {
195
                        if ($strict) {
196
                            throw new DBException('Invalid value (using integer) for enum ' . $col->getName());
197
                        }
198
                        return $value;
199
                    }
200
                    return $values[$value];
201
                }
202
                if (!in_array($value, $col->getValues())) {
203
                    if ($strict) {
204
                        throw new DBException('Invalid value for enum ' . $col->getName());
205
                    }
206
                    return 0;
207
                }
208
                return $value;
209 22
            case 'int':
210 6
                return (int)preg_replace('([^+\-0-9]+)', '', $value);
211 18
            case 'float':
212
                return (float)preg_replace('([^+\-0-9.]+)', '', str_replace(',', '.', $value));
213 18
            case 'text':
214
                // check using strlen first, in order to avoid hitting mb_ functions which might be polyfilled
215
                // because the polyfill is quite slow
216 18
                if ($col->hasLength() && strlen($value) > $col->getLength() && mb_strlen($value) > $col->getLength()) {
217 4
                    if ($strict) {
218 2
                        throw new DBException('Invalid value for text column ' . $col->getName());
219
                    }
220 2
                    return mb_substr($value, 0, $col->getLength());
221
                }
222 15
                return $value;
223
            default: // time, blob, etc
224
                return $value;
225
        }
226
    }
227
228 16
    protected function filterSQL(string $column, $value, bool $negate = false) : array
229
    {
230 16
        list($name, $column) = array_values($this->getColumn($column));
231 16
        if (is_array($value) && count($value) === 1 && isset($value['not'])) {
232 2
            $negate = true;
233 2
            $value = $value['not'];
234
        }
235 16
        if (is_array($value) && count($value) === 1 && isset($value['like'])) {
236 2
            $value = $value['like'];
237
            // str_replace(['%', '_'], ['\\%','\\_'], $q)
238 2
            return $negate ?
239
                [
240
                    $name . ' NOT LIKE ?',
241
                    [ $this->normalizeValue($column, $value) ]
242
                ] :
243
                [
244 2
                    $name . ' LIKE ?',
245 2
                    [ $this->normalizeValue($column, $value) ]
246
                ];
247
        }
248 14
        if (is_null($value)) {
249
            return $negate ?
250
                [ $name . ' IS NOT NULL', [] ]:
251
                [ $name . ' IS NULL', [] ];
252
        }
253 14
        if (!is_array($value)) {
254 14
            return $negate ?
255
                [
256 2
                    $name . ' <> ?',
257 2
                    [ $this->normalizeValue($column, $value) ]
258
                ] :
259
                [
260 14
                    $name . ' = ?',
261 14
                    [ $this->normalizeValue($column, $value) ]
262
                ];
263
        }
264 6
        if (isset($value['beg']) && strlen($value['beg']) && (!isset($value['end']) || !strlen($value['end']))) {
265
            $value = [ 'gte' => $value['beg'] ];
266
        }
267 6
        if (isset($value['end']) && strlen($value['end']) && (!isset($value['beg']) || !strlen($value['beg']))) {
268
            $value = [ 'lte' => $value['end'] ];
269
        }
270 6
        if (isset($value['beg']) && isset($value['end'])) {
271
            return $negate ?
272
                [
273
                    $name.' NOT BETWEEN ? AND ?',
274
                    [
275
                        $this->normalizeValue($column, $value['beg']),
276
                        $this->normalizeValue($column, $value['end'])
277
                    ]
278
                ] :
279
                [
280
                    $name.' BETWEEN ? AND ?',
281
                    [
282
                        $this->normalizeValue($column, $value['beg']),
283
                        $this->normalizeValue($column, $value['end'])
284
                    ]
285
                ];
286
        }
287 6
        if (isset($value['gt']) || isset($value['lt']) || isset($value['gte']) || isset($value['lte'])) {
288 4
            $sql = [];
289 4
            $par = [];
290 4
            if (isset($value['gt'])) {
291 2
                $sql[] = $name. ' ' . ($negate ? '<=' : '>') . ' ?';
292 2
                $par[] = $this->normalizeValue($column, $value['gt']);
293
            }
294 4
            if (isset($value['gte'])) {
295 2
                $sql[] = $name. ' ' . ($negate ? '<' : '>=') . ' ?';
296 2
                $par[] = $this->normalizeValue($column, $value['gte']);
297
            }
298 4
            if (isset($value['lt'])) {
299 4
                $sql[] = $name. ' ' . ($negate ? '>=' : '<') . ' ?';
300 4
                $par[] = $this->normalizeValue($column, $value['lt']);
301
            }
302 4
            if (isset($value['lte'])) {
303 2
                $sql[] = $name. ' ' . ($negate ? '>' : '<=') . ' ?';
304 2
                $par[] = $this->normalizeValue($column, $value['lte']);
305
            }
306
            return [
307 4
                '(' . implode(' AND ', $sql) . ')',
308 4
                $par
309
            ];
310
        }
311 4
        return $negate ?
312
            [
313
                $name . ' NOT IN (??)',
314
                [ array_map(function ($v) use ($column) { return $this->normalizeValue($column, $v); }, $value) ]
315
            ] :
316
            [
317 4
                $name . ' IN (??)',
318
                [ array_map(function ($v) use ($column) { return $this->normalizeValue($column, $v); }, $value) ]
319
            ];
320
    }
321
    /**
322
     * Filter the results by a column and a value
323
     * @param  string $column  the column name to filter by (related columns can be used - for example: author.name)
324
     * @param  mixed  $value   a required value, array of values or range of values (range example: ['beg'=>1,'end'=>3])
325
     * @param  bool   $negate  optional boolean indicating that the filter should be negated
326
     * @return $this
327
     */
328 12
    public function filter(string $column, $value, bool $negate = false) : self
329
    {
330 12
        $sql = $this->filterSQL($column, $value, $negate);
331 12
        return strlen($sql[0]) ? $this->where($sql[0], $sql[1]) : $this;
332
    }
333
    /**
334
     * Filter the results matching any of the criteria
335
     * @param  array $criteria  each row is a column, value and optional negate flag (same as filter method)
336
     * @return $this
337
     */
338 6
    public function any(array $criteria) : self
339
    {
340 6
        $sql = [];
341 6
        $par = [];
342 6
        foreach ($criteria as $row) {
343 6
            if (isset($row[1])) {
344 6
                $temp = $this->filterSQL($row[0], $row[1] ?? null, $row[2] ?? false);
345 6
                $sql[] = $temp[0];
346 6
                $par = array_merge($par, $temp[1]);
347
            }
348
        }
349 6
        return $this->where('(' . implode(' OR ', $sql) . ')', $par);
350
    }
351
    /**
352
     * Filter the results matching all of the criteria
353
     * @param  array $criteria  each row is a column, value and optional negate flag (same as filter method)
354
     * @return $this
355
     */
356 4
    public function all(array $criteria) : self
357
    {
358 4
        $sql = [];
359 4
        $par = [];
360 4
        foreach ($criteria as $row) {
361 4
            if (isset($row[1])) {
362 4
                $temp = $this->filterSQL($row[0], $row[1] ?? null, $row[2] ?? false);
363 4
                $sql[] = $temp[0];
364 4
                $par = array_merge($par, $temp[1]);
365
            }
366
        }
367 4
        return $this->where('(' . implode(' AND ', $sql) . ')', $par);
368
    }
369
    /**
370
     * Sort by a column
371
     * @param  string       $column the column name to sort by (related columns can be used - for example: author.name)
372
     * @param  bool|boolean $desc   should the sorting be in descending order, defaults to `false`
373
     * @return $this
374
     */
375
    public function sort(string $column, bool $desc = false) : self
376
    {
377
        return $this->order($this->getColumn($column)['name'] . ' ' . ($desc ? 'DESC' : 'ASC'));
378
    }
379
    /**
380
     * Group by a column (or columns)
381
     * @param  string|array        $column the column name (or names) to group by
382
     * @return $this
383
     */
384 2
    public function group($column) : self
385
    {
386 2
        if (!is_array($column)) {
387 2
            $column = [ $column ];
388
        }
389 2
        foreach ($column as $k => $v) {
390 2
            $column[$k] = $this->getColumn($v)['name'];
391
        }
392 2
        return $this->groupBy(implode(', ', $column), []);
393
    }
394
    /**
395
     * Get a part of the data
396
     * @param  int|integer $page    the page number to get (1-based), defaults to 1
397
     * @param  int|integer $perPage the number of records per page - defaults to 25
398
     * @return $this
399
     */
400
    public function paginate(int $page = 1, int $perPage = 25) : self
401
    {
402
        return $this->limit($perPage, ($page - 1) * $perPage);
403
    }
404 2
    public function __call($name, $data)
405
    {
406 2
        if (strpos($name, 'filterBy') === 0) {
407
            return $this->filter(strtolower(substr($name, 8)), $data[0]);
408
        }
409 2
        if (strpos($name, 'sortBy') === 0) {
410
            return $this->sort(strtolower(substr($name, 6)), $data[0]);
411
        }
412 2
        if (strpos($name, 'groupBy') === 0) {
413 2
            return $this->group(strtolower(substr($name, 7)));
414
        }
415
    }
416
    /**
417
     * Remove all filters, sorting, etc
418
     * @return $this
419
     */
420 6
    public function reset() : self
421
    {
422 6
        $this->where = [];
423 6
        $this->joins = [];
424 6
        $this->group = [];
425 6
        $this->withr = [];
426 6
        $this->order = [];
427 6
        $this->having = [];
428 6
        $this->aliases = [];
429 6
        $this->li_of = [0,0,0];
430 6
        $this->qiterator = null;
431 6
        return $this;
432
    }
433
    /**
434
     * Apply advanced grouping
435
     * @param  string $sql    SQL statement to use in the GROUP BY clause
436
     * @param  array  $params optional params for the statement (defaults to an empty array)
437
     * @return $this
438
     */
439 2
    public function groupBy(string $sql, array $params = []) : self
440
    {
441 2
        $this->qiterator = null;
442 2
        $this->group = [ $sql, $params ];
443 2
        return $this;
444
    }
445
    /**
446
     * Join a table to the query (no need to do this for relations defined with foreign keys)
447
     * @param  Table|string $table     the table to join
448
     * @param  array        $fields    what to join on (joined_table_field => other_field) 
449
     * @param  string|null  $name      alias for the join, defaults to the table name 
450
     * @param  bool         $multiple  are multiple rows joined (results in a LEFT JOIN), default to true 
451
     * @return $this
452
     */
453 2
    public function join($table, array $fields, string $name = null, bool $multiple = true)
454
    {
455 2
        $table = $table instanceof Table ? $table : $this->db->definition((string)$table);
456 2
        $name = $name ?? $table->getName();
457 2
        if (isset($this->joins[$name]) || $this->definition->hasRelation($name)) {
458
            throw new DBException('Alias / table name already in use');
459
        }
460 2
        $this->joins[$name] = new TableRelation($name, $table, [], $multiple);
461 2
        foreach ($fields as $k => $v) {
462 2
            $k = explode('.', $k, 2);
463 2
            $k = count($k) == 2 ? $k[1] : $k[0];
464 2
            $this->joins[$name]->keymap[$this->getColumn($name . '.' . $k)['name']] = $this->getColumn($v)['name'];
465
        }
466 2
        return $this;
467
    }
468
    /**
469
     * Apply an advanced filter (can be called multiple times)
470
     * @param  string $sql    SQL statement to be used in the where clause
471
     * @param  array  $params parameters for the SQL statement (defaults to an empty array)
472
     * @return $this
473
     */
474 18
    public function where(string $sql, array $params = []) : self
475
    {
476 18
        $this->qiterator = null;
477 18
        $this->where[] = [ $sql, $params ];
478 18
        return $this;
479
    }
480
    /**
481
     * Apply an advanced HAVING filter (can be called multiple times)
482
     * @param  string $sql    SQL statement to be used in the HAING clause
483
     * @param  array  $params parameters for the SQL statement (defaults to an empty array)
484
     * @return $this
485
     */
486 2
    public function having(string $sql, array $params = []) : self
487
    {
488 2
        $this->qiterator = null;
489 2
        $this->having[] = [ $sql, $params ];
490 2
        return $this;
491
    }
492
    /**
493
     * Apply advanced sorting
494
     * @param  string $sql    SQL statement to use in the ORDER clause
495
     * @param  array  $params optional params for the statement (defaults to an empty array)
496
     * @return $this
497
     */
498 2
    public function order(string $sql, array $params = []) : self
499
    {
500 2
        $this->qiterator = null;
501 2
        $name = null;
502 2
        if (!count($params)) {
503 2
            $name = preg_replace('(\s+(ASC|DESC)\s*$)i', '', $sql);
504
            try {
505 2
                $name = $this->getColumn(trim($name))['name'];
506 2
            } catch (\Exception $e) {
507 2
                $name = null;
508
            }
509
        }
510 2
        $this->order = [ $sql, $params, $name ];
511 2
        return $this;
512
    }
513
    /**
514
     * Apply an advanced limit
515
     * @param  int         $limit  number of rows to return
516
     * @param  int         $offset number of rows to skip from the beginning (defaults to 0)
517
     * @return $this
518
     */
519 2
    public function limit(int $limit, int $offset = 0, bool $limitOnMainTable = false) : self
520
    {
521 2
        $this->qiterator = null;
522 2
        $this->li_of = [ $limit, $offset, $limitOnMainTable ? 1 : 0 ];
523 2
        return $this;
524
    }
525
    /**
526
     * Get the number of records
527
     * @return int the total number of records (does not respect pagination)
528
     */
529 16
    public function count() : int
530
    {
531 16
        $aliases = [];
532 16
        $getAlias = function ($name) use (&$aliases) {
533
            // to bypass use: return $name;
534 4
            return $aliases[$name] = $aliases[$name] ?? 'alias' . static::SEP . count($aliases);
535 16
        };
536 16
        $table = $this->definition->getName();
537 16
        $sql = 'SELECT COUNT(DISTINCT '.$table.'.'.implode(', '.$table.'.', $this->pkey).') FROM '.$table.' ';
538 16
        $par = [];
539
        
540 16
        $relations = $this->withr;
541 16
        foreach ($relations as $k => $v) {
542
            $getAlias($k);
543
        }
544 16
        $f = $this->fields;
0 ignored issues
show
Unused Code introduced by
$f is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
545 16
        $w = $this->where;
546 16
        $h = $this->having;
547 16
        $o = $this->order;
548 16
        $g = $this->group;
549
        $j = array_map(function ($v) { return clone $v; }, $this->joins);
550 16
        foreach ($this->definition->getRelations() as $k => $v) {
551 16
            foreach ($w as $kk => $vv) {
552 10
                if (preg_match('(\b'.preg_quote($k . '.'). ')i', $vv[0])) {
553 4
                    $relations[$k] = [ $v, $table ];
554 10
                    $w[$kk][0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $vv[0]);
555
                }
556
            }
557 16
            if (isset($o[0]) && preg_match('(\b'.preg_quote($k . '.'). ')i', $o[0])) {
558
                $relations[$k] = [ $v, $table ];
559
            }
560 16
            foreach ($h as $kk => $vv) {
561
                if (preg_match('(\b'.preg_quote($k . '.'). ')i', $vv[0])) {
562
                    $relations[$k] = [ $relation, $table ];
0 ignored issues
show
Bug introduced by
The variable $relation does not exist. Did you mean $relations?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
563
                    $h[$kk][0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $vv[0]);
564
                }
565
            }
566 16
            if (isset($g[0]) && preg_match('(\b'.preg_quote($k . '.'). ')i', $g[0])) {
567
                $relations[$k] = [ $relation, $table ];
0 ignored issues
show
Bug introduced by
The variable $relation does not exist. Did you mean $relations?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
568
                $g[0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $g[0]);
569
            }
570 16
            foreach ($j as $kk => $v) {
571
                foreach ($v->keymap as $kkk => $vv) {
572
                    if (preg_match('(\b'.preg_quote($k . '.'). ')i', $vv)) {
573
                        $relations[$k] = [ $relation, $table ];
0 ignored issues
show
Bug introduced by
The variable $relation does not exist. Did you mean $relations?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
574 16
                        $j[$k]->keymap[$kkk] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $vv);
575
                    }
576
                }
577
            }
578
        }
579
580 16
        foreach ($j as $k => $v) {
581
            $sql .= ($v->many ? 'LEFT ' : '' ) . 'JOIN '.$v->table->getName().' '.$k.' ON ';
582
            $tmp = [];
583
            foreach ($v->keymap as $kk => $vv) {
584
                $tmp[] = $kk.' = '.$vv;
585
            }
586
            $sql .= implode(' AND ', $tmp) . ' ';
587
        }
588 16
        foreach ($relations as $k => $v) {
589 4
            $table = $v[1] !== $this->definition->getName() ? $getAlias($v[1]) : $v[1];
590 4
            $v = $v[0];
591 4
            if ($v->pivot) {
592 4
                $alias = $getAlias($k.'_pivot');
593 4
                $sql .= 'LEFT JOIN '.$v->pivot->getName().' '.$alias.' ON ';
594 4
                $tmp = [];
595 4
                foreach ($v->keymap as $kk => $vv) {
596 4
                    $tmp[] = $table.'.'.$kk.' = '.$alias.'.'.$vv.' ';
597
                }
598 4
                $sql .= implode(' AND ', $tmp) . ' ';
599 4
                $sql .= 'LEFT JOIN '.$v->table->getName().' '.$getAlias($k).' ON ';
600 4
                $tmp = [];
601 4
                foreach ($v->pivot_keymap as $kk => $vv) {
602 4
                    $tmp[] = $getAlias($k).'.'.$vv.' = '.$alias.'.'.$kk.' ';
603
                }
604 4
                $sql .= implode(' AND ', $tmp) . ' ';
605
            } else {
606 4
                $alias = $getAlias($k);
607 4
                $sql .= 'LEFT JOIN '.$v->table->getName().' '.$alias.' ON ';
608 4
                $tmp = [];
609 4
                foreach ($v->keymap as $kk => $vv) {
610 4
                    $tmp[] = $table.'.'.$kk.' = '.$alias.'.'.$vv.' ';
611
                }
612 4
                if ($v->sql) {
613
                    $tmp[] = $v->sql . ' ';
614
                    $par = array_merge($par, $v->par ?? []);
615
                }
616 4
                $sql .= implode(' AND ', $tmp) . ' ';
617
            }
618
        }
619 16
        if (count($w)) {
620 10
            $sql .= 'WHERE ';
621 10
            $tmp = [];
622 10
            foreach ($w as $v) {
623 10
                $tmp[] = '(' . $v[0] . ')';
624 10
                $par = array_merge($par, $v[1]);
625
            }
626 10
            $sql .= implode(' AND ', $tmp).' ';
627
        }
628 16
        if (count($g)) {
629
            $sql .= 'GROUP BY ' . $g[0] . ' ';
630
            $par = array_merge($par, $g[1]);
631
        }
632 16
        if (count($h)) {
633
            $sql .= 'HAVING ';
634
            $tmp = [];
635
            foreach ($h as $v) {
636
                $tmp[] = '(' . $v[0] . ')';
637
                $par = array_merge($par, $v[1]);
638
            }
639
            $sql .= implode(' AND ', $tmp).' ';
640
        }
641 16
        return $this->db->one($sql, $par);
642
    }
643
    /**
644
     * Specify which columns to fetch (be default all table columns are fetched)
645
     * @param  array $fields optional array of columns to select (related columns can be used too)
646
     * @return $this
647
     */
648 50
    public function columns(array $fields) : self
649
    {
650 50
        foreach ($fields as $k => $v) {
651 50
            if (strpos($v, '*') !== false) {
652
                $temp = explode('.', $v);
653
                if (count($temp) === 1) {
654
                    $table = $this->definition->getName();
655
                    $cols = $this->definition->getColumns();
656
                } else if (count($temp) === 2) {
657
                    $table = $temp[0];
658
                    if ($this->definition->hasRelation($table)) {
659
                        $cols = $this->definition->getRelation($table)->table->getColumns();
660
                    } else if (isset($this->joins[$table])) {
661
                        $cols = $this->joins[$table]->table->getColumns();
662
                    } else {
663
                        throw new DBException('Invalid foreign table name');
664
                    }
665
                } else {
666
                    array_pop($temp);
667
                    $this->with(implode('.', $temp));
668
                    $table = array_reduce(
669
                        $temp,
670
                        function ($carry, $item) use (&$table) {
671
                            return $table->getRelation($item)->table;
672
                        }
673
                    );
674
                    $cols = $table->getColumns();
675
                    $table = implode(static::SEP, $temp);
676
                }
677
                unset($fields[$k]);
678
                foreach ($cols as $col) {
679 50
                    $fields[] = $table . '.' . $col;
680
                }
681
            }
682
        }
683 50
        $primary = $this->definition->getPrimaryKey();
684 50
        foreach ($fields as $k => $v) {
685
            try {
686 50
                $fields[$k] = $this->getColumn($v)['name'];
687 2
            } catch (DBException $e) {
688 50
                $fields[$k] = $v;
689
            }
690
        }
691 50
        foreach ($primary as $field) {
692 50
            $field = $this->getColumn($field)['name'];
693 50
            if (!in_array($field, $fields)) {
694 50
                $fields[] = $field;
695
            }
696
        }
697 50
        $this->fields = $fields;
698 50
        return $this;
699
    }
700
    /**
701
     * Perform the actual fetch
702
     * @param  array|null $fields optional array of columns to select (related columns can be used too)
703
     * @return \Iterator               the query result as an iterator
704
     */
705 36
    public function iterator(array $fields = null) : \Iterator
706
    {
707 36
        if ($this->qiterator) {
708 22
            return $this->qiterator;
709
        }
710 36
        $aliases = [];
711 36
        $getAlias = function ($name) use (&$aliases) {
712
            // to bypass use: return $name;
713 12
            return $aliases[$name] = $aliases[$name] ?? 'alias' . static::SEP . count($aliases);
714 36
        };
715 36
        $table = $this->definition->getName();
716 36
        if ($fields !== null) {
717 2
            $this->columns($fields);
718
        }
719 36
        $relations = $this->withr;
720 36
        foreach ($relations as $k => $v) {
721 10
            $getAlias($k);
722
        }
723
724 36
        $f = $this->fields;
725 36
        $w = $this->where;
726 36
        $h = $this->having;
727 36
        $o = $this->order;
728 36
        $g = $this->group;
729
        $j = array_map(function ($v) { return clone $v; }, $this->joins);
730
731 36
        $porder = [];
732 36
        foreach ($this->definition->getPrimaryKey() as $field) {
733 36
            $porder[] = $this->getColumn($field)['name'];
734
        }
735
736 36
        foreach ($this->definition->getRelations() as $k => $relation) {
737 36
            foreach ($f as $kk => $field) {
738 36
                if (strpos($field, $k . '.') === 0) {
739
                    $relations[$k] = [ $relation, $table ];
740 36
                    $f[$kk] = str_replace($k . '.', $getAlias($k) . '.', $field);
741
                }
742
            }
743 36
            foreach ($w as $kk => $v) {
744 4
                if (preg_match('(\b'.preg_quote($k . '.'). ')i', $v[0])) {
745 2
                    $relations[$k] = [ $relation, $table ];
746 4
                    $w[$kk][0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $v[0]);
747
                }
748
            }
749 36
            foreach ($h as $kk => $v) {
750 2
                if (preg_match('(\b'.preg_quote($k . '.'). ')i', $v[0])) {
751
                    $relations[$k] = [ $relation, $table ];
752 2
                    $h[$kk][0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $v[0]);
753
                }
754
            }
755 36
            if (isset($o[0]) && preg_match('(\b'.preg_quote($k . '.'). ')i', $o[0])) {
756
                $relations[$k] = [ $relation, $table ];
757
                $o[0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $o[0]);
758
            }
759 36
            if (isset($g[0]) && preg_match('(\b'.preg_quote($k . '.'). ')i', $g[0])) {
760
                $relations[$k] = [ $relation, $table ];
761
                $g[0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $g[0]);
762
            }
763 36
            foreach ($j as $kk => $v) {
764 2
                foreach ($v->keymap as $kkk => $vv) {
765 2
                    if (preg_match('(\b'.preg_quote($k . '.'). ')i', $vv)) {
766
                        $relations[$k] = [ $relation, $table ];
767 36
                        $j[$k]->keymap[$kkk] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $vv);
768
                    }
769
                }
770
            }
771
        }
772 36
        $select = [];
773 36
        foreach ($f as $k => $field) {
774 36
            $select[] = $field . (!is_numeric($k) ? ' ' . $k : '');
775
        }
776 36
        foreach ($this->withr as $name => $relation) {
777 10
            foreach ($relation[0]->table->getColumns() as $column) {
778 10
                $select[] = $getAlias($name) . '.' . $column . ' ' . $getAlias($name . static::SEP . $column);
779
            }
780
        }
781 36
        $sql = 'SELECT '.implode(', ', $select).' FROM '.$table.' ';
782 36
        $par = [];
783 36
        $many = false;
784 36
        foreach ($j as $k => $v) {
785 2
            if ($v->many) {
786 2
                $many = true;
787
            }
788 2
            $sql .= ($v->many ? 'LEFT ' : '' ) . 'JOIN '.$v->table->getName().' '.$k.' ON ';
789 2
            $tmp = [];
790 2
            foreach ($v->keymap as $kk => $vv) {
791 2
                $tmp[] = $kk.' = '.$vv;
792
            }
793 2
            $sql .= implode(' AND ', $tmp) . ' ';
794
        }
795 36
        foreach ($relations as $relation => $v) {
796 12
            $table = $v[1] !== $this->definition->getName() ? $getAlias($v[1]) : $v[1];
797 12
            $v = $v[0];
798 12
            if ($v->many || $v->pivot) {
799 10
                $many = true;
800
            }
801 12
            if ($v->pivot) {
802 8
                $alias = $getAlias($relation.'_pivot');
803 8
                $sql .= 'LEFT JOIN '.$v->pivot->getName().' '.$alias.' ON ';
804 8
                $tmp = [];
805 8
                foreach ($v->keymap as $kk => $vv) {
806 8
                    $tmp[] = $table.'.'.$kk.' = '.$alias.'.'.$vv.' ';
807
                }
808 8
                $sql .= implode(' AND ', $tmp) . ' ';
809 8
                $sql .= 'LEFT JOIN '.$v->table->getName().' '.$getAlias($relation).' ON ';
810 8
                $tmp = [];
811 8
                foreach ($v->pivot_keymap as $kk => $vv) {
812 8
                    $tmp[] = $getAlias($relation).'.'.$vv.' = '.$alias.'.'.$kk.' ';
813
                }
814 8
                $sql .= implode(' AND ', $tmp) . ' ';
815
            } else {
816 10
                $alias = $getAlias($relation);
817
818 10
                $sql .= 'LEFT JOIN '.$v->table->getName().' '.$alias.' ON ';
819 10
                $tmp = [];
820 10
                foreach ($v->keymap as $kk => $vv) {
821 10
                    $tmp[] = $table.'.'.$kk.' = '.$alias.'.'.$vv.' ';
822
                }
823 10
                if ($v->sql) {
824
                    $tmp[] = $v->sql . ' ';
825
                    $par = array_merge($par, $v->par ?? []);
826
                }
827 12
                $sql .= implode(' AND ', $tmp) . ' ';
828
            }
829
        }
830
831 36
        if ($many && count($porder) && $this->li_of[2] === 1) {
832
            $ids = $this->ids();
833
            if (count($ids)) {
834
                if (count($porder) > 1) {
835
                    $pkw = [];
836
                    foreach ($porder as $name) {
837
                        $pkw[] = $name . ' = ?';
838
                    }
839
                    $pkw = '(' . implode(' AND ', $pkw) . ')';
840
                    $pkp = [];
841
                    foreach ($ids as $id) {
842
                        foreach ($id as $p) {
843
                            $pkp[] = $p;
844
                        }
845
                    }
846
                    $w[] = [
847
                        implode(' OR ', array_fill(0, count($ids), $pkw)),
848
                        $pkp
849
                    ];
850
                } else {
851
                    $w[] = [ $porder[0] . ' IN ('.implode(',', array_fill(0, count($ids), '?')).')', $ids ];
852
                }
853
            } else {
854
                $w[] = [ '1=0', [] ];
855
            }
856
        }
857 36
        if (count($w)) {
858 4
            $sql .= 'WHERE ';
859 4
            $tmp = [];
860 4
            foreach ($w as $v) {
861 4
                $tmp[] = '(' . $v[0] . ')';
862 4
                $par = array_merge($par, $v[1]);
863
            }
864 4
            $sql .= implode(' AND ', $tmp).' ';
865
        }
866 36
        if (count($g)) {
867 2
            $sql .= 'GROUP BY ' . $g[0] . ' ';
868 2
            $par = array_merge($par, $g[1]);
869
        }
870 36
        if (count($h)) {
871 2
            $sql .= 'HAVING ';
872 2
            $tmp = [];
873 2
            foreach ($h as $v) {
874 2
                $tmp[] = '(' . $v[0] . ')';
875 2
                $par = array_merge($par, $v[1]);
876
            }
877 2
            $sql .= implode(' AND ', $tmp).' ';
878
        }
879 36
        if (count($o)) {
880 2
            $sql .= 'ORDER BY ' . $o[0] . ' ';
881 2
            $par = array_merge($par, $o[1]);
882
        }
883 36
        if (count($porder)) {
884 36
            $pdir = (count($o) && strpos($o[0], 'DESC') !== false) ? 'DESC' : 'ASC';
885
            $porder = array_map(function ($v) use ($pdir) { return $v . ' ' . $pdir; }, $porder);
886 36
            $sql .= (count($o) ? ', ' : 'ORDER BY ') . implode(', ', $porder) . ' ';
887
        }
888 36
        if ((!$many || $this->li_of[2] === 0 || !count($porder)) && $this->li_of[0]) {
889 2
            if ($this->db->driverName() === 'oracle') {
890
                if ((int)$this->db->driverOption('version', 0) >= 12) {
891
                    $sql .= 'OFFSET ' . $this->li_of[1] . ' ROWS FETCH NEXT ' . $this->li_of[0] . ' ROWS ONLY';
892
                } else {
893
                    $f = array_map(function ($v) {
894
                        $v = explode(' ', trim($v), 2);
895
                        if (count($v) === 2) { return $v[1]; }
896
                        $v = explode('.', $v[0], 2);
897
                        return count($v) === 2 ? $v[1] : $v[0];
898
                    }, $select);
899
                    $sql = "SELECT " . implode(', ', $f) . " 
900
                            FROM (
901
                                SELECT tbl__.*, rownum rnum__ FROM (
902
                                    " . $sql . "
903
                                ) tbl__ 
904
                                WHERE rownum <= " . ($this->li_of[0] + $this->li_of[1]) . "
905
                            ) WHERE rnum__ > " . $this->li_of[1];
906
                }
907
            } else {
908 2
                $sql .= 'LIMIT ' . $this->li_of[0] . ' OFFSET ' . $this->li_of[1];
909
            }
910
        }
911 36
        return $this->qiterator = new TableQueryIterator(
912 36
            $this->db->get($sql, $par, null, false, false), 
913 36
            $this->pkey,
914 36
            $this->withr,
915 36
            $aliases
916
        );
917
    }
918
    /**
919
     * Perform the actual fetch
920
     * @param  array|null $fields optional array of columns to select (related columns can be used too)
921
     * @return array               the query result as an array
922
     */
923 2
    public function select(array $fields = null) : array
924
    {
925 2
        return iterator_to_array($this->iterator($fields));
926
    }
927
    /**
928
     * Insert a new row in the table
929
     * @param  array   $data   key value pairs, where each key is the column name and the value is the value to insert
930
     * @return array           the inserted ID where keys are column names and values are column values
931
     */
932 6
    public function insert(array $data) : array
933
    {
934 6
        $table = $this->definition->getName();
935 6
        $columns = $this->definition->getFullColumns();
936 6
        $insert = [];
937 6
        foreach ($data as $column => $value) {
938 6
            if (isset($columns[$column])) {
939 6
                $insert[$column] = $this->normalizeValue($columns[$column], $value);
940
            }
941
        }
942 4
        if (!count($insert)) {
943
            throw new DBException('No valid columns to insert');
944
        }
945 4
        $sql = 'INSERT INTO '.$table.' ('.implode(', ', array_keys($insert)).') VALUES (??)';
946 4
        $par = [$insert];
947 4
        $primary = $this->definition->getPrimaryKey();
948 4
        if (!count($primary)) {
949
            $this->db->query($sql, $par);
950
            return [];
951
        }
952 4
        if ($this->db->driverName() === 'oracle') {
953
            $ret = [];
954
            foreach ($primary as $k) {
955
                $ret[$k] = str_repeat(' ', 255);
956
                $par[] = &$ret[$k];
957
            }
958
            $sql .= ' RETURNING ' . implode(',', $primary) . ' INTO ' . implode(',', array_fill(0, count($primary), '?'));
959
            $this->db->query($sql, $par);
960
            return $ret;
961
        } else {
962 4
            $ret = [];
963 4
            $ins = $this->db->query($sql, $par)->insertID();
964 4
            foreach ($primary as $k) {
965 4
                $ret[$k] = $data[$k] ?? $ins;
966
            }
967 4
            return $ret;
968
        }
969
    }
970
    /**
971
     * Update the filtered rows with new data
972
     * @param  array  $data key value pairs, where each key is the column name and the value is the value to insert
973
     * @return int          the number of affected rows
974
     */
975 2
    public function update(array $data) : int
976
    {
977 2
        $table = $this->definition->getName();
978 2
        $columns = $this->definition->getFullColumns();
979 2
        $update = [];
980 2
        foreach ($data as $column => $value) {
981 2
            if (isset($columns[$column])) {
982 2
                $update[$column] = $this->normalizeValue($columns[$column], $value);
983
            }
984
        }
985 2
        if (!count($update)) {
986
            throw new DBException('No valid columns to update');
987
        }
988 2
        $sql = 'UPDATE '.$table.' SET ';
989 2
        $par = [];
990
        $sql .= implode(', ', array_map(function ($v) { return $v . ' = ?'; }, array_keys($update))) . ' ';
991 2
        $par = array_merge($par, array_values($update));
992 2
        if (count($this->where)) {
993 2
            $sql .= 'WHERE ';
994 2
            $tmp = [];
995 2
            foreach ($this->where as $v) {
996 2
                $tmp[] = $v[0];
997 2
                $par = array_merge($par, $v[1]);
998
            }
999 2
            $sql .= implode(' AND ', $tmp) . ' ';
1000
        }
1001 2
        if (count($this->order)) {
1002
            $sql .= $this->order[0];
1003
            $par = array_merge($par, $this->order[1]);
1004
        }
1005 2
        return $this->db->query($sql, $par)->affected();
1006
    }
1007
    /**
1008
     * Delete the filtered rows from the DB
1009
     * @return int the number of deleted rows
1010
     */
1011 2
    public function delete() : int
1012
    {
1013 2
        $table = $this->definition->getName();
1014 2
        $sql = 'DELETE FROM '.$table.' ';
1015 2
        $par = [];
1016 2
        if (count($this->where)) {
1017 2
            $sql .= 'WHERE ';
1018 2
            $tmp = [];
1019 2
            foreach ($this->where as $v) {
1020 2
                $tmp[] = $v[0];
1021 2
                $par = array_merge($par, $v[1]);
1022
            }
1023 2
            $sql .= implode(' AND ', $tmp) . ' ';
1024
        }
1025 2
        if (count($this->order)) {
1026
            $sql .= $this->order[0];
1027
            $par = array_merge($par, $this->order[1]);
1028
        }
1029 2
        return $this->db->query($sql, $par)->affected();
1030
    }
1031
    /**
1032
     * Solve the n+1 queries problem by prefetching a relation by name
1033
     * @param  string $relation the relation name to fetch along with the data
1034
     * @return $this
1035
     */
1036 10
    public function with(string $relation) : self
1037
    {
1038 10
        $this->qiterator = null;
1039 10
        $parts = explode('.', $relation);
1040 10
        $table = $this->definition;
1041 10
        array_reduce(
1042 10
            $parts,
1043 10
            function ($carry, $item) use (&$table) {
1044 10
                $relation = $table->getRelation($item);
1045 10
                if (!$relation) {
1046
                    throw new DBException('Invalid relation name');
1047
                }
1048 10
                $name = $carry ? $carry . static::SEP . $item : $item;
1049 10
                $this->withr[$name] = [ $relation, $carry ?? $table->getName() ];
1050 10
                $table = $relation->table;
1051 10
                return $name;
1052 10
            }
1053
        );
1054 10
        return $this;
1055
    }
1056
1057 6
    public function getIterator()
1058
    {
1059 6
        return $this->iterator();
1060
    }
1061
1062 30
    public function offsetGet($offset)
1063
    {
1064 30
        return $this->iterator()->offsetGet($offset);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Iterator as the method offsetGet() does only exist in the following implementations of said interface: ArrayIterator, CachingIterator, Issue523, PHP_Token_Stream, Phar, PharData, RecursiveArrayIterator, RecursiveCachingIterator, SplDoublyLinkedList, SplFixedArray, SplObjectStorage, SplQueue, SplStack, TheSeer\Tokenizer\TokenCollection, vakata\collection\Collection, vakata\database\schema\TableQueryIterator.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
1065
    }
1066
    public function offsetExists($offset)
1067
    {
1068
        return $this->iterator()->offsetExists($offset);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Iterator as the method offsetExists() does only exist in the following implementations of said interface: ArrayIterator, CachingIterator, Issue523, PHP_Token_Stream, Phar, PharData, RecursiveArrayIterator, RecursiveCachingIterator, SplDoublyLinkedList, SplFixedArray, SplObjectStorage, SplQueue, SplStack, TheSeer\Tokenizer\TokenCollection, vakata\collection\Collection, vakata\database\schema\TableQueryIterator.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
1069
    }
1070
    public function offsetUnset($offset)
1071
    {
1072
        return $this->iterator()->offsetUnset($offset);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Iterator as the method offsetUnset() does only exist in the following implementations of said interface: ArrayIterator, CachingIterator, Issue523, PHP_Token_Stream, Phar, PharData, RecursiveArrayIterator, RecursiveCachingIterator, SplDoublyLinkedList, SplFixedArray, SplObjectStorage, SplQueue, SplStack, TheSeer\Tokenizer\TokenCollection, vakata\collection\Collection, vakata\database\schema\TableQueryIterator.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
1073
    }
1074
    public function offsetSet($offset, $value)
1075
    {
1076
        return $this->iterator()->offsetSet($offset, $value);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Iterator as the method offsetSet() does only exist in the following implementations of said interface: ArrayIterator, CachingIterator, Issue523, PHP_Token_Stream, Phar, PharData, RecursiveArrayIterator, RecursiveCachingIterator, SplDoublyLinkedList, SplFixedArray, SplObjectStorage, SplQueue, SplStack, TheSeer\Tokenizer\TokenCollection, vakata\collection\Collection, vakata\database\schema\TableQueryIterator.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
1077
    }
1078
1079
    public function collection(array $fields = null) : Collection
1080
    {
1081
        return new Collection($this->iterator($fields));
1082
    }
1083
1084
    public function ids()
1085
    {
1086
        if (count($this->group)) {
1087
            throw new DBException('Can not LIMIT result set by master table when GROUP BY is used');
1088
        }
1089
        if (count($this->order) && !isset($this->order[2])) {
1090
            throw new DBException('Can not LIMIT result set by master table with a complex ORDER BY query');
1091
        }
1092
1093
        $aliases = [];
1094
        $getAlias = function ($name) use (&$aliases) {
1095
            // to bypass use: return $name;
1096
            return $aliases[$name] = $aliases[$name] ?? 'alias' . static::SEP . count($aliases);
1097
        };
1098
        
1099
        $table = $this->definition->getName();
1100
        $relations = $this->withr;
1101
        foreach ($relations as $k => $v) {
1102
            $getAlias($k);
1103
        }
1104
        $f = $this->fields;
0 ignored issues
show
Unused Code introduced by
$f is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
1105
        $w = $this->where;
1106
        $h = $this->having;
1107
        $o = $this->order;
1108
        $g = $this->group;
1109
        $j = array_map(function ($v) { return clone $v; }, $this->joins);
1110
        foreach ($this->definition->getRelations() as $k => $v) {
1111
            foreach ($w as $kk => $vv) {
1112
                if (preg_match('(\b'.preg_quote($k . '.'). ')i', $vv[0])) {
1113
                    $relations[$k] = [ $v, $table ];
1114
                    $w[$kk][0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $vv[0]);
1115
                }
1116
            }
1117
            if (isset($o[0]) && preg_match('(\b'.preg_quote($k . '.'). ')i', $o[0])) {
1118
                $relations[$k] = [ $v, $table ];
1119
                $o[0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $o[0]);
1120
                $o[2] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $o[2]);
1121
            }
1122
            foreach ($h as $kk => $vv) {
1123
                if (preg_match('(\b'.preg_quote($k . '.'). ')i', $vv[0])) {
1124
                    $relations[$k] = [ $relation, $table ];
0 ignored issues
show
Bug introduced by
The variable $relation does not exist. Did you mean $relations?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
1125
                    $h[$kk][0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $vv[0]);
1126
                }
1127
            }
1128
            if (isset($g[0]) && preg_match('(\b'.preg_quote($k . '.'). ')i', $g[0])) {
1129
                $relations[$k] = [ $relation, $table ];
0 ignored issues
show
Bug introduced by
The variable $relation does not exist. Did you mean $relations?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
1130
                $g[0] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $g[0]);
1131
            }
1132
            foreach ($j as $kk => $v) {
1133
                foreach ($v->keymap as $kkk => $vv) {
1134
                    if (preg_match('(\b'.preg_quote($k . '.'). ')i', $vv)) {
1135
                        $relations[$k] = [ $relation, $table ];
0 ignored issues
show
Bug introduced by
The variable $relation does not exist. Did you mean $relations?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
1136
                        $j[$k]->keymap[$kkk] = preg_replace('(\b'.preg_quote($k . '.'). ')i', $getAlias($k) . '.', $vv);
1137
                    }
1138
                }
1139
            }
1140
        }
1141
1142
        $key = array_map(function ($v) use ($table) { return $table . '.' . $v; }, $this->pkey);
1143
        $own = false;
1144
        $dir = 'ASC';
1145
        if (count($o)) {
1146
            $dir = strpos($o[0], ' DESC') ? 'DESC' : 'ASC';
1147
            $own = strpos($o[2], $table . '.') === 0;
1148
        }
1149
1150
        $dst = $key;
1151
        if ($own) {
1152
            // if using own table - do not use max/min in order - that will prevent index usage
1153
            $dst[] = $o[2] . ' orderbyfix___';
1154
        }
1155
        $dst = array_unique($dst);
1156
1157
        $par = [];
1158
        $sql  = 'SELECT DISTINCT '.implode(', ', $dst).' FROM '.$table.' ';
1159
        foreach ($j as $k => $v) {
1160
            $sql .= ($v->many ? 'LEFT ' : '' ) . 'JOIN '.$v->table->getName().' '.$k.' ON ';
1161
            $tmp = [];
1162
            foreach ($v->keymap as $kk => $vv) {
1163
                $tmp[] = $kk.' = '.$vv;
1164
            }
1165
            $sql .= implode(' AND ', $tmp) . ' ';
1166
        }
1167
        foreach ($relations as $k => $v) {
1168
            $table = $v[1] !== $this->definition->getName() ? $getAlias($v[1]) : $v[1];
1169
            $v = $v[0];
1170
            if ($v->pivot) {
1171
                $alias = $getAlias($k.'_pivot');
1172
                $sql .= 'LEFT JOIN '.$v->pivot->getName().' '.$alias.' ON ';
1173
                $tmp = [];
1174
                foreach ($v->keymap as $kk => $vv) {
1175
                    $tmp[] = $table.'.'.$kk.' = '.$alias.'.'.$vv.' ';
1176
                }
1177
                $sql .= implode(' AND ', $tmp) . ' ';
1178
                $sql .= 'LEFT JOIN '.$v->table->getName().' '.$getAlias($k).' ON ';
1179
                $tmp = [];
1180
                foreach ($v->pivot_keymap as $kk => $vv) {
1181
                    $tmp[] = $getAlias($k).'.'.$vv.' = '.$alias.'.'.$kk.' ';
1182
                }
1183
                $sql .= implode(' AND ', $tmp) . ' ';
1184
            } else {
1185
                $alias = $getAlias($k);
1186
                $sql .= 'LEFT JOIN '.$v->table->getName().' '.$alias.' ON ';
1187
                $tmp = [];
1188
                foreach ($v->keymap as $kk => $vv) {
1189
                    $tmp[] = $table.'.'.$kk.' = '.$alias.'.'.$vv.' ';
1190
                }
1191
                if ($v->sql) {
1192
                    $tmp[] = $v->sql . ' ';
1193
                    $par = array_merge($par, $v->par ?? []);
1194
                }
1195
                $sql .= implode(' AND ', $tmp) . ' ';
1196
            }
1197
        }
1198
        if (count($w)) {
1199
            $sql .= 'WHERE ';
1200
            $tmp = [];
1201
            foreach ($w as $v) {
1202
                $tmp[] = '(' . $v[0] . ')';
1203
                $par = array_merge($par, $v[1]);
1204
            }
1205
            $sql .= implode(' AND ', $tmp).' ';
1206
        }
1207
        if (!$own) {
1208
            $sql .= 'GROUP BY ' . implode(', ', $key) . ' ';
1209
        }
1210
        if (count($h)) {
1211
            $sql .= 'HAVING ';
1212
            $tmp = [];
1213
            foreach ($h as $v) {
1214
                $tmp[] = '(' . $v[0] . ')';
1215
                $par = array_merge($par, $v[1]);
1216
            }
1217
            $sql .= implode(' AND ', $tmp).' ';
1218
        }
1219
        if (count($o)) {
1220
            $sql .= 'ORDER BY ';
1221
            if ($own) {
1222
                $sql .= $o[2] . ' ' . $dir;
1223
            } else {
1224
                $sql .= 'MAX('.$o[2].') ' . $dir;
1225
            }
1226
        }
1227
        $porder = [];
1228
        $pdir = (count($o) && strpos($o[0], 'DESC') !== false) ? 'DESC' : 'ASC';
1229
        foreach ($this->definition->getPrimaryKey() as $field) {
1230
            $porder[] = $this->getColumn($field)['name'] . ' ' . $pdir;
1231
        }
1232
        if (count($porder)) {
1233
            $sql .= (count($o) ? ', ' : 'ORDER BY ') . implode(', ', $porder) . ' ';
1234
        }
1235
1236
        if ($this->li_of[0]) {
1237
            if ($this->db->driverName() === 'oracle') {
1238
                if ((int)$this->db->driverOption('version', 0) >= 12) {
1239
                    $sql .= 'OFFSET ' . $this->li_of[1] . ' ROWS FETCH NEXT ' . $this->li_of[0] . ' ROWS ONLY';
1240
                } else {
1241
                    $f = array_map(function ($v) {
0 ignored issues
show
Unused Code introduced by
$f is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
1242
                        $v = explode(' ', trim($v), 2);
1243
                        if (count($v) === 2) { return $v[1]; }
1244
                        $v = explode('.', $v[0], 2);
1245
                        return count($v) === 2 ? $v[1] : $v[0];
1246
                    }, $select);
0 ignored issues
show
Bug introduced by
The variable $select does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
1247
                    $sql = "SELECT " . implode(', ', $dst) . " 
1248
                            FROM (
1249
                                SELECT tbl__.*, rownum rnum__ FROM (
1250
                                    " . $sql . "
1251
                                ) tbl__ 
1252
                                WHERE rownum <= " . ($this->li_of[0] + $this->li_of[1]) . "
1253
                            ) WHERE rnum__ > " . $this->li_of[1];
1254
                }
1255
            } else {
1256
                $sql .= 'LIMIT ' . $this->li_of[0] . ' OFFSET ' . $this->li_of[1];
1257
            }
1258
        }
1259
        return array_map(function ($v) use ($own) {
1260
            if (isset($v['orderbyfix___'])) {
1261
                unset($v['orderbyfix___']);
1262
            }
1263
            return count($v) === 1 ? array_values($v)[0] : $v;
1264
        }, $this->db->all($sql, $par, null, false, false));
1265
    }
1266
    public function find($primary)
1267
    {
1268
        $columns = $this->definition->getPrimaryKey();
1269
        if (!count($columns)) {
1270
            throw new DBException('Missing primary key');
1271
        }
1272
        if (!is_array($primary)) {
1273
            $temp = [];
1274
            $temp[$columns[0]] = $primary;
1275
            $primary = $temp;
1276
        }
1277
        foreach ($columns as $k) {
1278
            if (!isset($primary[$k])) {
1279
                throw new DBException('Missing primary key component');
1280
            }
1281
            $this->filter($k, $primary[$k]);
1282
        }
1283
        return $this->iterator()[0];
1284
    }
1285
}
1286