Completed
Push — master ( cf8dce...10f953 )
by Todd
04:47
created

SqliteDb::insert()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 5.005

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 29
ccs 16
cts 17
cp 0.9412
rs 8.439
cc 5
eloc 18
nc 5
nop 3
crap 5.005
1
<?php
2
/**
3
 * @author Todd Burry <[email protected]>
4
 * @copyright 2009-2014 Vanilla Forums Inc.
5
 * @license MIT
6
 */
7
8
namespace Garden\Db\Drivers;
9
10
use Garden\Db\Db;
11
use Garden\Db\Identifier;
12
use Garden\Db\Literal;
13
use PDO;
14
15
/**
16
 * A {@link Db} class for connecting to SQLite.
17
 */
18
class SqliteDb extends MySqlDb {
19
    /**
20
     * {@inheritdoc}
21
     */
22 6
    protected function alterTableDb(array $alterDef, array $options = []) {
23 6
        $this->alterTableMigrate($alterDef, $options);
24 6
    }
25
26
    /**
27
     * Alter a table by creating a new table and copying the old table's data to it.
28
     *
29
     * @param array $alterDef The new definition.
30
     * @param array $options An array of options for the migration.
31
     */
32 6
    private function alterTableMigrate(array $alterDef, array $options = []) {
33 6
        $table = $alterDef['name'];
34 6
        $currentDef = $this->fetchTableDef($table);
35
36
        // Merge the table definitions if we aren't dropping stuff.
37 6
        if (!self::val(Db::OPTION_DROP, $options)) {
38 5
            $tableDef = $this->mergeTableDefs($currentDef, $alterDef);
0 ignored issues
show
Bug introduced by
It seems like $currentDef defined by $this->fetchTableDef($table) on line 34 can also be of type null; however, Garden\Db\Drivers\SqliteDb::mergeTableDefs() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
39
        } else {
40 1
            $tableDef = $alterDef['def'];
41
        }
42
43
        // Drop all of the indexes on the current table.
44 6
        foreach (self::val('indexes', $currentDef, []) as $indexDef) {
0 ignored issues
show
Bug introduced by
It seems like $currentDef defined by $this->fetchTableDef($table) on line 34 can also be of type null; however, Garden\Db\Db::val() does only seem to accept array|object, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
45 4
            if (self::val('type', $indexDef, Db::INDEX_IX) === Db::INDEX_IX) {
46 4
                $this->dropIndex($indexDef['name']);
47
            }
48
        }
49
50 6
        $tmpTable = $table.'_'.time();
51
52
        // Rename the current table.
53 6
        $this->renameTable($table, $tmpTable);
54
55
        // Create the new table.
56 6
        $this->createTableDb($tableDef, $options);
57
58
        // Figure out the columns that we can insert.
59 6
        $columns = array_keys(array_intersect_key($tableDef['columns'], $currentDef['columns']));
60
61
        // Build the insert/select statement.
62 6
        $sql = 'insert into '.$this->prefixTable($table)."\n".
63 6
            $this->bracketList($columns, '`')."\n".
64 6
            $this->buildSelect($tmpTable, [], ['columns' => $columns]);
65
66 6
        $this->queryDefine($sql);
67
68
        // Drop the temp table.
69 6
        $this->dropTable($tmpTable);
70 6
    }
71
72
    /**
73
     * Rename a table.
74
     *
75
     * @param string $old The old name of the table.
76
     * @param string $new The new name of the table.
77
     */
78 6
    private function renameTable($old, $new) {
79
        $renameSql = 'alter table '.
80 6
            $this->prefixTable($old).
81 6
            ' rename to '.
82 6
            $this->prefixTable($new);
83 6
        $this->queryDefine($renameSql);
84 6
    }
85
86
    /**
87
     * Merge a table def with its alter def so that no columns/indexes are lost in an alter.
88
     *
89
     * @param array $tableDef The table def.
90
     * @param array $alterDef The alter def.
91
     * @return array The new table def.
92
     */
93 5
    private function mergeTableDefs(array $tableDef, array $alterDef) {
94 5
        $result = $tableDef;
95
96 5
        if ($this->findPrimaryKeyIndex($alterDef['add']['indexes'])) {
97 2
            $remove = null;
98 2
            foreach ($result['indexes'] as $i => $index) {
99 2
                if ($index['type'] === Db::INDEX_PK) {
100 2
                    $remove = $i;
101
                }
102
            }
103 2
            if ($remove !== null) {
104 2
                unset($result['indexes'][$i]);
105
            }
106
        }
107
108 5
        $result['columns'] = array_merge($result['columns'], $alterDef['def']['columns']);
109 5
        $result['indexes'] = array_merge($result['indexes'], $alterDef['add']['indexes']);
110
111 5
        return $result;
112
    }
113
114
    /**
115
     * Drop an index.
116
     *
117
     * @param string $index The name of the index to drop.
118
     */
119 2
    protected function dropIndex($index) {
120
        $sql = 'drop index if exists '.
121 2
            $this->escape($index);
122 2
        $this->queryDefine($sql);
123 2
    }
124
125
    /**
126
     * {@inheritdoc}
127
     */
128 29
    protected function buildInsert($table, array $row, $options = []) {
129 29
        if (self::val(Db::OPTION_UPSERT, $options)) {
130
            throw new \Exception("Upsert is not supported.");
131 29
        } elseif (self::val(Db::OPTION_IGNORE, $options)) {
132 2
            $sql = 'insert or ignore into ';
133 28
        } elseif (self::val(Db::OPTION_REPLACE, $options)) {
134 2
            $sql = 'insert or replace into ';
135
        } else {
136 27
            $sql = 'insert into ';
137
        }
138 29
        $sql .= $this->prefixTable($table);
139
140
        // Add the list of values.
141
        $sql .=
142 29
            "\n".$this->bracketList(array_keys($row), '`').
143 29
            "\nvalues".$this->bracketList($row, "'");
144
145 29
        return $sql;
146
    }
147
148
    /**
149
     * {@inheritdoc}
150
     */
151 1
    protected function buildLike(string $column, $value): string {
152 1
        return "$column like ".$this->quote($value)." escape '\\'";
153
    }
154
155
    /**
156
     * {@inheritdoc}
157
     */
158 5
    protected function buildUpdate($table, array $set, array $where, array $options = []): string {
159
        $sql = 'update '.
160 5
            (empty($options[Db::OPTION_IGNORE]) ? '' : 'or ignore ').
161 5
            $this->prefixTable($table).
162 5
            "\nset\n  ";
163
164 5
        $parts = [];
165 5
        foreach ($set as $key => $value) {
166 5
            $escapedKey = $this->escape($key);
167 5
            $parts[] = "$escapedKey = ".$this->quote($value, $escapedKey);
168
        }
169 5
        $sql .= implode(",\n  ", $parts);
170
171 5
        if (!empty($where)) {
172 5
            $sql .= "\nwhere ".$this->buildWhere($where, Db::OP_AND);
173
        }
174
175 5
        return $sql;
176
    }
177
178
    /**
179
     * Construct a column definition string from an array defintion.
180
     *
181
     * @param string $name The name of the column.
182
     * @param array $cdef The column definition.
183
     * @return string Returns a string representing the column definition.
184
     */
185 13
    protected function columnDefString($name, array $cdef) {
186
        $cdef += [
187 13
            'autoIncrement' => false,
188
            'primary' => false,
189
            'allowNull' => false
190
        ];
191
192
        // Auto-increments MUST be of type integer.
193 13
        if ($cdef['autoIncrement']) {
194 4
            $cdef['dbtype'] = 'integer';
195
        }
196
197 13
        $result = $this->escape($name).' '.$this->nativeDbType($cdef);
198
199 13
        if ($cdef['primary'] && $cdef['autoIncrement']) {
200
//            if (val('autoincrement', $def)) {
0 ignored issues
show
Unused Code Comprehensibility introduced by
65% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
201 4
                $result .= ' primary key autoincrement';
202 4
                $cdef['primary'] = true;
203
//            }
204
        } else {
205 13
            if (!$cdef['allowNull']) {
206 12
                $result .= ' not null';
207
            }
208
209 13
            if (isset($cdef['default'])) {
210 8
                $result .= ' default '.$this->quote($cdef['default']);
211
            }
212
        }
213
214 13
        return $result;
215
    }
216
217
    /**
218
     * {@inheritdoc}
219
     */
220 13
    protected function nativeDbType(array $type) {
221 13
        static $translations = ['bool' => 'boolean', 'byte' => 'tinyint', 'short' => 'smallint', 'long' => 'bigint'];
222
223
        // Translate the dbtype to a MySQL native type.
224 13
        if (isset($translations[$type['dbtype']])) {
225 1
            $type['dbtype'] = $translations[$type['dbtype']];
226
        }
227
228
        // Change enum into varchar.
229 13
        if ($type['dbtype'] === 'enum') {
230 1
            $type['dbtype'] = 'varchar';
231 1
            $type['maxLength'] = array_reduce(
232 1
                $type['enum'],
233 1
                function ($carry, $item) {
234 1
                    return (int)max(strlen($item), $carry);
235 1
                }, 0);
236
        }
237
238 13
        if (!empty($type['autoIncrement'])) {
239 4
            $type['dbtype'] = 'integer';
240
        }
241
242
        // Unsigned is represented differently in MySQL.
243 13
        $unsigned = !empty($type['unsigned']) && empty($type['autoIncrement']);
244 13
        unset($type['unsigned']);
245
246 13
        $dbType = static::dbType($type).($unsigned ? ' unsigned' : '');
247
248 13
        return $dbType;
249
    }
250
251
    /**
252
     * {@inheritdoc}
253
     */
254 13
    protected function createTableDb(array $tableDef, array $options = []) {
255 13
        $table = $tableDef['name'];
256 13
        $parts = [];
257
258
        // Make sure the primary key columns are defined first and in order.
259 13
        $autoInc = false;
260 13
        if ($pkIndex = $this->findPrimaryKeyIndex($tableDef['indexes'])) {
261 8
            foreach ($pkIndex['columns'] as $column) {
262 8
                $cdef = $tableDef['columns'][$column];
263 8
                $parts[] = $this->columnDefString($column, $cdef);
264 8
                $autoInc |= !empty($cdef['autoIncrement']);
265 8
                unset($tableDef['columns'][$column]);
266
            }
267
        }
268
269 13
        foreach ($tableDef['columns'] as $name => $cdef) {
270 12
            $parts[] = $this->columnDefString($name, $cdef);
271
        }
272
273
        // Add the primary key index.
274 13
        if (isset($pkIndex) && !$autoInc) {
275 4
            $parts[] = 'primary key '.$this->bracketList($pkIndex['columns'], '`');
276
        }
277
278 13
        $fullTableName = $this->prefixTable($table);
279 13
        $sql = "create table $fullTableName (\n  ".
280 13
            implode(",\n  ", $parts).
281 13
            "\n)";
282
283 13
        $this->queryDefine($sql);
284
285
        // Add the rest of the indexes.
286 13
        foreach (self::val('indexes', $tableDef, []) as $index) {
287 11
            if (self::val('type', $index, Db::INDEX_IX) !== Db::INDEX_PK) {
288 11
                $this->createIndex($table, $index, $options);
289
            }
290
        }
291 13
    }
292
293
    /**
294
     * Create an index.
295
     *
296
     * @param string $table The name of the table to create the index on.
297
     * @param array $indexDef The index definition.
298
     * @param array $options Additional options for the index creation.
299
     */
300 7
    public function createIndex($table, array $indexDef, $options = []) {
301
        $sql = 'create '.
302 7
            (self::val('type', $indexDef) === Db::INDEX_UNIQUE ? 'unique ' : '').
303 7
            'index '.
304 7
            (self::val(Db::OPTION_IGNORE, $options) ? 'if not exists ' : '').
305 7
            $this->buildIndexName($table, $indexDef).
306 7
            ' on '.
307 7
            $this->prefixTable($table).
308 7
            $this->bracketList($indexDef['columns'], '`');
309
310 7
        $this->queryDefine($sql);
311 7
    }
312
313
    /**
314
     * Force a value into the appropriate php type based on its Sqlite type.
315
     *
316
     * @param mixed $value The value to force.
317
     * @param string $type The sqlite type name.
318
     * @return mixed Returns $value cast to the appropriate type.
319
     */
320 3
    protected function forceType($value, $type) {
321 3
        $type = strtolower($type);
322
323 3
        if ($type === 'null') {
324
            return null;
325 3
        } elseif (in_array($type, ['int', 'integer', 'tinyint', 'smallint',
326
            'mediumint', 'bigint', 'unsigned big int', 'int2', 'int8', 'boolean'])) {
327 3
            return (int)filter_var($value, FILTER_VALIDATE_INT);
328
        } elseif (in_array($type, ['real', 'double', 'double precision', 'float',
329
            'numeric', 'decimal(10,5)'])) {
330
            return filter_var($value, FILTER_VALIDATE_FLOAT);
331
        } else {
332
            return (string)$value;
333
        }
334
    }
335
336
    /**
337
     * Get the columns for a table..
338
     *
339
     * @param string $table The table to get the columns for.
340
     * @return array|null Returns an array of columns.
341
     */
342 6
    protected function fetchColumnDefsDb(string $table) {
343 6
        $cdefs = $this->query('pragma table_info('.$this->prefixTable($table, false).')')->fetchAll(PDO::FETCH_ASSOC);
344 6
        if (empty($cdefs)) {
345 4
            return null;
346
        }
347
348 5
        $columns = [];
349 5
        $pk = [];
350 5
        foreach ($cdefs as $cdef) {
351 5
            $column = Db::typeDef($cdef['type']);
352 5
            if ($column === null) {
353
                throw new \Exception("Unknown type '$columnType'.", 500);
354
            }
355 5
            $column['allowNull'] = !filter_var($cdef['notnull'], FILTER_VALIDATE_BOOLEAN);
356
357 5
            if ($cdef['pk']) {
358 2
                $pk[] = $cdef['name'];
359 2
                if (strcasecmp($cdef['type'], 'integer') === 0) {
360
                    $column['autoIncrement'] = true;
361
                } else {
362 2
                    $column['primary'] = true;
363
                }
364
            }
365 5
            if ($cdef['dflt_value'] !== null) {
366 3
                $column['default'] = $this->forceType($cdef['dflt_value'], $column['type']);
367
            }
368 5
            $columns[$cdef['name']] = $column;
369
        }
370
//        $tdef = ['columns' => $columns];
0 ignored issues
show
Unused Code Comprehensibility introduced by
53% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
371
//        if (!empty($pk)) {
372
//            $tdef['indexes'][Db::INDEX_PK] = [
373
//                'columns' => $pk,
374
//                'type' => Db::INDEX_PK
375
//            ];
376
//        }
377
//        $this->tables[$table] = $tdef;
378 5
        return $columns;
379
    }
380
381
    /**
382
     * Get the indexes for a table.
383
     *
384
     * @param string $table The name of the table to get the indexes for or an empty string to get all indexes.
385
     * @return array|null
386
     */
387 5
    protected function fetchIndexesDb($table = '') {
388 5
        $indexes = [];
389
390 5
        $indexInfos = $this->query('pragma index_list('.$this->prefixTable($table).')')->fetchAll(PDO::FETCH_ASSOC);
391 5
        foreach ($indexInfos as $row) {
392 4
            $indexName = $row['name'];
393 4
            if ($row['unique']) {
394 2
                $type = Db::INDEX_UNIQUE;
395
            } else {
396 2
                $type = Db::INDEX_IX;
397
            }
398
399
            // Query the columns in the index.
400 4
            $columns = $this->query('pragma index_info('.$this->quote($indexName).')')->fetchAll(PDO::FETCH_ASSOC);
401
402
            $index = [
403 4
                'name' => $indexName,
404 4
                'columns' => array_column($columns, 'name'),
405 4
                'type' => $type
406
            ];
407 4
            $indexes[] = $index;
408
        }
409
410 5
        return $indexes;
411
    }
412
413
    /**
414
     * Get the primary or secondary keys from the given rows.
415
     *
416
     * @param string $table The name of the table.
417
     * @param array $row The row to examine.
418
     * @param bool $quick Whether or not to quickly look for <tablename>ID for the primary key.
419
     * @return array|null Returns the primary keys and values from {@link $rows} or null if the primary key isn't found.
420
     */
421 2
    private function getPKValue($table, array $row, $quick = false) {
422 2
        if ($quick && isset($row[$table.'ID'])) {
423 1
            return [$table.'ID' => $row[$table.'ID']];
424
        }
425
426 1
        $tdef = $this->fetchTableDef($table);
427 1
        $cols = [];
428 1
        foreach ($tdef['columns'] as $name => $cdef) {
429 1
            if (empty($cdef['primary'])) {
430 1
                break;
431
            }
432 1
            if (!array_key_exists($name, $row)) {
433
                return null;
434
            }
435
436 1
            $cols[$name] = $row[$name];
437
        }
438 1
        return $cols;
439
    }
440
441
    /**
442
     * Get the all of table names in the database.
443
     *
444
     * @return array Returns an array of table names.
445
     */
446 1
    protected function fetchTableNamesDb() {
447
        // Get the table names.
448 1
        $tables = $this->get(
449 1
            new Identifier('sqlite_master'),
450
            [
451 1
                'type' => 'table',
452 1
                'name' => [Db::OP_LIKE => $this->escapeLike($this->getPx()).'%']
453
            ],
454
            [
455 1
                'columns' => ['name']
456
            ]
457 1
        )->fetchAll(PDO::FETCH_COLUMN);
458
459
        // Remove internal tables.
460 1
        $tables = array_filter($tables, function ($name) {
461 1
            return substr($name, 0, 7) !== 'sqlite_';
462 1
        });
463
464 1
        return $tables;
465
    }
466
467
    /**
468
     * {@inheritdoc}
469
     */
470 9
    public function insert(string $table, array $row, array $options = []) {
471
        // Sqlite doesn't support upsert so do upserts manually.
472 9
        if (self::val(Db::OPTION_UPSERT, $options)) {
473 2
            unset($options[Db::OPTION_UPSERT]);
474
475 2
            $keys = $this->getPKValue($table, $row, true);
476 2
            if (empty($keys)) {
477
                throw new \Exception("Cannot upsert with no key.", 500);
478
            }
479
            // Try updating first.
480 2
            $updated = $this->update(
481 2
                $table,
482 2
                array_diff_key($row, $keys),
483 2
                $keys,
484 2
                $options
485
            );
486 2
            if ($updated) {
487
                // Updated.
488 2
                if (count($keys) === 1) {
489 1
                    return array_pop($keys);
490
                } else {
491 1
                    return true;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return true; (boolean) is incompatible with the return type of the parent method Garden\Db\Drivers\MySqlDb::insert of type integer|string.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

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

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
492
                }
493
            }
494
        }
495
496 9
        $result = parent::insert($table, $row, $options);
497 9
        return $result;
498
    }
499
500
    /**
501
     * Optionally quote a where value.
502
     *
503
     * @param mixed $value The value to quote.
504
     * @param string $column The name of the column being operated on.
505
     * @return string Returns the value, optionally quoted.
506
     * @internal param bool $quote Whether or not to quote the value.
507
     */
508 37
    public function quote($value, string $column = ''): string {
509 37
        if ($value instanceof Literal) {
510
            /* @var Literal $value */
511 21
            return $value->getValue($this, $column);
512 32
        } elseif (in_array(gettype($value), ['integer', 'double'])) {
513 30
            return (string)$value;
514 14
        } elseif ($value instanceof \DateTimeInterface) {
515 1
            $value = $value->format(\DateTime::RFC3339);
516 14
        } elseif ($value === true) {
517
            return '1';
518 14
        } elseif ($value === false) {
519 1
            return '0';
520
        }
521
522 14
        return $this->getPDO()->quote($value);
523
    }
524
}
525