Passed
Pull Request — 4 (#8209)
by Ingo
09:07
created

DBSchemaManager::convertIndexSpec()   A

Complexity

Conditions 6
Paths 2

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
nc 2
nop 1
dl 0
loc 19
rs 9.0111
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\ORM\Connect;
4
5
use SilverStripe\Control\Director;
6
use SilverStripe\Core\Config\Config;
7
use SilverStripe\Core\Injector\Injector;
8
use SilverStripe\ORM\FieldType\DBPrimaryKey;
9
use SilverStripe\ORM\FieldType\DBField;
10
use Exception;
11
12
/**
13
 * Represents and handles all schema management for a database
14
 */
15
abstract class DBSchemaManager
16
{
17
18
    /**
19
     *
20
     * @config
21
     * Check tables when running /dev/build, and repair them if necessary.
22
     * In case of large databases or more fine-grained control on how to handle
23
     * data corruption in tables, you can disable this behaviour and handle it
24
     * outside of this class, e.g. through a nightly system task with extended logging capabilities.
25
     *
26
     * @var bool
27
     */
28
    private static $check_and_repair_on_build = true;
29
30
    /**
31
     * Check if tables should be renamed in a case-sensitive fashion.
32
     * Note: This should still work even on case-insensitive databases.
33
     *
34
     * @var bool
35
     */
36
    private static $fix_table_case_on_build = true;
37
38
    /**
39
     * Instance of the database controller this schema belongs to
40
     *
41
     * @var Database
42
     */
43
    protected $database = null;
44
45
    /**
46
     * If this is false, then information about database operations
47
     * will be displayed, eg creation of tables.
48
     *
49
     * @var boolean
50
     */
51
    protected $supressOutput = false;
52
53
    /**
54
     * @var array
55
     */
56
    protected static $table_name_warnings = [];
57
58
    /**
59
     * @param string
60
     * @deprecated 4.0..5.0
61
     */
62
    public static function showTableNameWarning($table, $class)
63
    {
64
        static::$table_name_warnings[$table] = $class;
65
    }
66
67
    /**
68
     * Injector injection point for database controller
69
     *
70
     * @param Database $database
71
     */
72
    public function setDatabase(Database $database)
73
    {
74
        $this->database = $database;
75
    }
76
77
    /**
78
     * The table list, generated by the tableList() function.
79
     * Used by the requireTable() function.
80
     *
81
     * @var array
82
     */
83
    protected $tableList;
84
85
    /**
86
     * Keeps track whether we are currently updating the schema.
87
     *
88
     * @var boolean
89
     */
90
    protected $schemaIsUpdating = false;
91
92
    /**
93
     * Large array structure that represents a schema update transaction
94
     *
95
     * @var array
96
     */
97
    protected $schemaUpdateTransaction;
98
99
    /**
100
     * Enable supression of database messages.
101
     *
102
     * @param bool $quiet
103
     */
104
    public function quiet($quiet = true)
105
    {
106
        $this->supressOutput = $quiet;
107
    }
108
109
    /**
110
     * Execute the given SQL query.
111
     * This abstract function must be defined by subclasses as part of the actual implementation.
112
     * It should return a subclass of SS_Query as the result.
113
     *
114
     * @param string $sql The SQL query to execute
115
     * @param int $errorLevel The level of error reporting to enable for the query
116
     * @return Query
117
     */
118
    public function query($sql, $errorLevel = E_USER_ERROR)
119
    {
120
        return $this->database->query($sql, $errorLevel);
121
    }
122
123
124
    /**
125
     * Execute the given SQL parameterised query with the specified arguments
126
     *
127
     * @param string $sql The SQL query to execute. The ? character will denote parameters.
128
     * @param array $parameters An ordered list of arguments.
129
     * @param int $errorLevel The level of error reporting to enable for the query
130
     * @return Query
131
     */
132
    public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR)
133
    {
134
        return $this->database->preparedQuery($sql, $parameters, $errorLevel);
135
    }
136
137
    /**
138
     * Initiates a schema update within a single callback
139
     *
140
     * @param callable $callback
141
     */
142
    public function schemaUpdate($callback)
143
    {
144
        // Begin schema update
145
        $this->schemaIsUpdating = true;
146
147
        // Update table list
148
        $this->tableList = array();
149
        $tables = $this->tableList();
150
        foreach ($tables as $table) {
151
            $this->tableList[strtolower($table)] = $table;
152
        }
153
154
        // Clear update list for client code to mess around with
155
        $this->schemaUpdateTransaction = array();
156
157
        /** @var Exception $error */
158
        $error = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $error is dead and can be removed.
Loading history...
159
        try {
160
            // Yield control to client code
161
            $callback();
162
163
            // If the client code has cancelled the update then abort
164
            if (!$this->isSchemaUpdating()) {
165
                return;
166
            }
167
168
            // End schema update
169
            foreach ($this->schemaUpdateTransaction as $tableName => $changes) {
170
                $advancedOptions = isset($changes['advancedOptions']) ? $changes['advancedOptions'] : null;
171
                switch ($changes['command']) {
172
                    case 'create':
173
                        $this->createTable(
174
                            $tableName,
175
                            $changes['newFields'],
176
                            $changes['newIndexes'],
177
                            $changes['options'],
178
                            $advancedOptions
179
                        );
180
                        break;
181
182
                    case 'alter':
183
                        $this->alterTable(
184
                            $tableName,
185
                            $changes['newFields'],
186
                            $changes['newIndexes'],
187
                            $changes['alteredFields'],
188
                            $changes['alteredIndexes'],
189
                            $changes['alteredOptions'],
190
                            $advancedOptions
191
                        );
192
                        break;
193
                }
194
            }
195
        } finally {
196
            $this->schemaUpdateTransaction = null;
197
            $this->schemaIsUpdating = false;
198
        }
199
    }
200
201
    /**
202
     * Cancels the schema updates requested during (but not after) schemaUpdate() call.
203
     */
204
    public function cancelSchemaUpdate()
205
    {
206
        $this->schemaUpdateTransaction = null;
207
        $this->schemaIsUpdating = false;
208
    }
209
210
    /**
211
     * Returns true if we are during a schema update.
212
     *
213
     * @return boolean
214
     */
215
    function isSchemaUpdating()
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
216
    {
217
        return $this->schemaIsUpdating;
218
    }
219
220
    /**
221
     * Returns true if schema modifications were requested during (but not after) schemaUpdate() call.
222
     *
223
     * @return boolean
224
     */
225
    public function doesSchemaNeedUpdating()
226
    {
227
        return (bool) $this->schemaUpdateTransaction;
228
    }
229
230
    // Transactional schema altering functions - they don't do anything except for update schemaUpdateTransaction
231
232
    /**
233
     * Instruct the schema manager to record a table creation to later execute
234
     *
235
     * @param string $table Name of the table
236
     * @param array $options Create table options (ENGINE, etc.)
237
     * @param array $advanced_options Advanced table creation options
238
     */
239
    public function transCreateTable($table, $options = null, $advanced_options = null)
240
    {
241
        $this->schemaUpdateTransaction[$table] = array(
242
            'command' => 'create',
243
            'newFields' => array(),
244
            'newIndexes' => array(),
245
            'options' => $options,
246
            'advancedOptions' => $advanced_options
247
        );
248
    }
249
250
    /**
251
     * Instruct the schema manager to record a table alteration to later execute
252
     *
253
     * @param string $table Name of the table
254
     * @param array $options Create table options (ENGINE, etc.)
255
     * @param array $advanced_options Advanced table creation options
256
     */
257
    public function transAlterTable($table, $options, $advanced_options)
258
    {
259
        $this->transInitTable($table);
260
        $this->schemaUpdateTransaction[$table]['alteredOptions'] = $options;
261
        $this->schemaUpdateTransaction[$table]['advancedOptions'] = $advanced_options;
262
    }
263
264
    /**
265
     * Instruct the schema manager to record a field to be later created
266
     *
267
     * @param string $table Name of the table to hold this field
268
     * @param string $field Name of the field to create
269
     * @param string $schema Field specification as a string
270
     */
271
    public function transCreateField($table, $field, $schema)
272
    {
273
        $this->transInitTable($table);
274
        $this->schemaUpdateTransaction[$table]['newFields'][$field] = $schema;
275
    }
276
277
    /**
278
     * Instruct the schema manager to record an index to be later created
279
     *
280
     * @param string $table Name of the table to hold this index
281
     * @param string $index Name of the index to create
282
     * @param array $schema Already parsed index specification
283
     */
284
    public function transCreateIndex($table, $index, $schema)
285
    {
286
        $this->transInitTable($table);
287
        $this->schemaUpdateTransaction[$table]['newIndexes'][$index] = $schema;
288
    }
289
290
    /**
291
     * Instruct the schema manager to record a field to be later updated
292
     *
293
     * @param string $table Name of the table to hold this field
294
     * @param string $field Name of the field to update
295
     * @param string $schema Field specification as a string
296
     */
297
    public function transAlterField($table, $field, $schema)
298
    {
299
        $this->transInitTable($table);
300
        $this->schemaUpdateTransaction[$table]['alteredFields'][$field] = $schema;
301
    }
302
303
    /**
304
     * Instruct the schema manager to record an index to be later updated
305
     *
306
     * @param string $table Name of the table to hold this index
307
     * @param string $index Name of the index to update
308
     * @param array $schema Already parsed index specification
309
     */
310
    public function transAlterIndex($table, $index, $schema)
311
    {
312
        $this->transInitTable($table);
313
        $this->schemaUpdateTransaction[$table]['alteredIndexes'][$index] = $schema;
314
    }
315
316
    /**
317
     * Handler for the other transXXX methods - mark the given table as being altered
318
     * if it doesn't already exist
319
     *
320
     * @param string $table Name of the table to initialise
321
     */
322
    protected function transInitTable($table)
323
    {
324
        if (!isset($this->schemaUpdateTransaction[$table])) {
325
            $this->schemaUpdateTransaction[$table] = array(
326
                'command' => 'alter',
327
                'newFields' => array(),
328
                'newIndexes' => array(),
329
                'alteredFields' => array(),
330
                'alteredIndexes' => array(),
331
                'alteredOptions' => ''
332
            );
333
        }
334
    }
335
336
    /**
337
     * Generate the following table in the database, modifying whatever already exists
338
     * as necessary.
339
     *
340
     * @todo Change detection for CREATE TABLE $options other than "Engine"
341
     *
342
     * @param string $table The name of the table
343
     * @param array $fieldSchema A list of the fields to create, in the same form as DataObject::$db
344
     * @param array $indexSchema A list of indexes to create. See {@link requireIndex()}
345
     * The values of the array can be one of:
346
     *   - true: Create a single column index on the field named the same as the index.
347
     *   - array('fields' => array('A','B','C'), 'type' => 'index/unique/fulltext'): This gives you full
348
     *     control over the index.
349
     * @param boolean $hasAutoIncPK A flag indicating that the primary key on this table is an autoincrement type
350
     * @param array $options Create table options (ENGINE, etc.)
351
     * @param array|bool $extensions List of extensions
352
     */
353
    public function requireTable(
354
        $table,
355
        $fieldSchema = null,
356
        $indexSchema = null,
357
        $hasAutoIncPK = true,
358
        $options = array(),
359
        $extensions = false
360
    ) {
361
        if (!isset($this->tableList[strtolower($table)])) {
362
            $this->transCreateTable($table, $options, $extensions);
0 ignored issues
show
Bug introduced by
It seems like $extensions can also be of type boolean; however, parameter $advanced_options of SilverStripe\ORM\Connect...ger::transCreateTable() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

362
            $this->transCreateTable($table, $options, /** @scrutinizer ignore-type */ $extensions);
Loading history...
363
            $this->alterationMessage("Table $table: created", "created");
364
        } else {
365
            if (Config::inst()->get(static::class, 'fix_table_case_on_build')) {
366
                $this->fixTableCase($table);
367
            }
368
            if (Config::inst()->get(static::class, 'check_and_repair_on_build')) {
369
                $this->checkAndRepairTable($table);
370
            }
371
372
            // Check if options changed
373
            $tableOptionsChanged = false;
374
            // Check for DB constant on the schema class
375
            $dbIDName = sprintf('%s::ID', static::class);
376
            $dbID = defined($dbIDName) ? constant($dbIDName) : null;
377
            if ($dbID && isset($options[$dbID])) {
378
                if (preg_match('/ENGINE=([^\s]*)/', $options[$dbID], $alteredEngineMatches)) {
379
                    $alteredEngine = $alteredEngineMatches[1];
380
                    $tableStatus = $this->query(sprintf('SHOW TABLE STATUS LIKE \'%s\'', $table))->first();
381
                    $tableOptionsChanged = ($tableStatus['Engine'] != $alteredEngine);
382
                }
383
            }
384
385
            if ($tableOptionsChanged || ($extensions && $this->database->supportsExtensions($extensions))) {
0 ignored issues
show
Bug introduced by
It seems like $extensions can also be of type true; however, parameter $extensions of SilverStripe\ORM\Connect...e::supportsExtensions() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

385
            if ($tableOptionsChanged || ($extensions && $this->database->supportsExtensions(/** @scrutinizer ignore-type */ $extensions))) {
Loading history...
386
                $this->transAlterTable($table, $options, $extensions);
0 ignored issues
show
Bug introduced by
It seems like $extensions can also be of type boolean; however, parameter $advanced_options of SilverStripe\ORM\Connect...ager::transAlterTable() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

386
                $this->transAlterTable($table, $options, /** @scrutinizer ignore-type */ $extensions);
Loading history...
387
            }
388
        }
389
390
        //DB ABSTRACTION: we need to convert this to a db-specific version:
391
        if (!isset($fieldSchema['ID'])) {
392
            $this->requireField($table, 'ID', $this->IdColumn(false, $hasAutoIncPK));
393
        }
394
395
        // Create custom fields
396
        if ($fieldSchema) {
397
            foreach ($fieldSchema as $fieldName => $fieldSpec) {
398
                //Is this an array field?
399
                $arrayValue = '';
400
                if (strpos($fieldSpec, '[') !== false) {
401
                    //If so, remove it and store that info separately
402
                    $pos = strpos($fieldSpec, '[');
403
                    $arrayValue = substr($fieldSpec, $pos);
404
                    $fieldSpec = substr($fieldSpec, 0, $pos);
405
                }
406
407
                /** @var DBField $fieldObj */
408
                $fieldObj = Injector::inst()->create($fieldSpec, $fieldName);
409
                $fieldObj->setArrayValue($arrayValue);
0 ignored issues
show
Bug introduced by
$arrayValue of type string is incompatible with the type array expected by parameter $value of SilverStripe\ORM\FieldTy...BField::setArrayValue(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

409
                $fieldObj->setArrayValue(/** @scrutinizer ignore-type */ $arrayValue);
Loading history...
410
411
                $fieldObj->setTable($table);
412
413
                if ($fieldObj instanceof DBPrimaryKey) {
414
                    /** @var DBPrimaryKey $fieldObj */
415
                    $fieldObj->setAutoIncrement($hasAutoIncPK);
416
                }
417
418
                $fieldObj->requireField();
419
            }
420
        }
421
422
        // Create custom indexes
423
        if ($indexSchema) {
424
            foreach ($indexSchema as $indexName => $indexSpec) {
425
                $this->requireIndex($table, $indexName, $indexSpec);
426
            }
427
        }
428
429
        // Check and display notice about $table_name
430
        static $table_name_info_sent = false;
431
432
        if (isset(static::$table_name_warnings[$table])) {
433
            if (!$table_name_info_sent) {
434
                $this->alterationMessage(
435
                    <<<'MESSAGE'
436
<strong>Please note:</strong> It is strongly recommended to define a
437
table_name for all namespaced models. Not defining a table_name may cause generated table
438
names to be too long and may not be supported by your current database engine. The generated
439
naming scheme will also change when upgrading to SilverStripe 5.0 and potentially break.
440
MESSAGE
441
                    ,
442
                    'error'
443
                );
444
                $table_name_info_sent = true;
445
            }
446
447
            $this->alterationMessage('table_name not set for class ' . static::$table_name_warnings[$table], 'notice');
448
        }
449
    }
450
451
    /**
452
     * If the given table exists, move it out of the way by renaming it to _obsolete_(tablename).
453
     * @param string $table The table name.
454
     */
455
    public function dontRequireTable($table)
456
    {
457
        if (!isset($this->tableList[strtolower($table)])) {
458
            return;
459
        }
460
        $prefix = "_obsolete_{$table}";
461
        $suffix = '';
462
        $renameTo = $prefix . $suffix;
463
        while (isset($this->tableList[strtolower($renameTo)])) {
464
            $suffix = $suffix
465
                    ? ((int)$suffix + 1)
466
                    : 2;
467
            $renameTo = $prefix . $suffix;
468
        }
469
        $renameFrom = $this->tableList[strtolower($table)];
470
        $this->renameTable($renameFrom, $renameTo);
471
        $this->alterationMessage("Table $table: renamed to $renameTo", "obsolete");
472
    }
473
474
    /**
475
     * Generate the given index in the database, modifying whatever already exists as necessary.
476
     *
477
     * The keys of the array are the names of the index.
478
     * The values of the array can be one of:
479
     *  - true: Create a single column index on the field named the same as the index.
480
     *  - array('type' => 'index|unique|fulltext', 'value' => 'FieldA, FieldB'): This gives you full
481
     *    control over the index.
482
     *
483
     * @param string $table The table name.
484
     * @param string $index The index name.
485
     * @param string|array|boolean $spec The specification of the index in any
486
     * loose format. See requireTable() for more information.
487
     */
488
    public function requireIndex($table, $index, $spec)
489
    {
490
        // Detect if adding to a new table
491
        $newTable = !isset($this->tableList[strtolower($table)]);
492
493
        // Force spec into standard array format
494
        $specString = $this->convertIndexSpec($spec);
495
496
        // Check existing index
497
        $oldSpecString = null;
498
        $indexKey = null;
499
        if (!$newTable) {
500
            $indexKey = $this->indexKey($table, $index, $spec);
501
            $indexList = $this->indexList($table);
502
            if (isset($indexList[$indexKey])) {
503
                // $oldSpec should be in standard array format
504
                $oldSpec = $indexList[$indexKey];
505
                $oldSpecString = $this->convertIndexSpec($oldSpec);
506
            }
507
        }
508
509
        // Initiate either generation or modification of index
510
        if ($newTable || !isset($indexList[$indexKey])) {
511
            // New index
512
            $this->transCreateIndex($table, $index, $spec);
513
            $this->alterationMessage("Index $table.$index: created as $specString", "created");
514
        } elseif ($oldSpecString != $specString) {
515
            // Updated index
516
            $this->transAlterIndex($table, $index, $spec);
517
            $this->alterationMessage(
518
                "Index $table.$index: changed to $specString <i class=\"build-info-before\">(from $oldSpecString)</i>",
519
                "changed"
520
            );
521
        }
522
    }
523
524
    /**
525
     * Splits a spec string safely, considering quoted columns, whitespace,
526
     * and cleaning brackets
527
     *
528
     * @param string $spec The input index specification string
529
     * @return array List of columns in the spec
530
     */
531
    protected function explodeColumnString($spec)
532
    {
533
        // Remove any leading/trailing brackets and outlying modifiers
534
        // E.g. 'unique (Title, "QuotedColumn");' => 'Title, "QuotedColumn"'
535
        $containedSpec = preg_replace('/(.*\(\s*)|(\s*\).*)/', '', $spec);
536
537
        // Split potentially quoted modifiers
538
        // E.g. 'Title, "QuotedColumn"' => array('Title', 'QuotedColumn')
0 ignored issues
show
Unused Code Comprehensibility introduced by
42% 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...
539
        return preg_split('/"?\s*,\s*"?/', trim($containedSpec, '(") '));
540
    }
541
542
    /**
543
     * Builds a properly quoted column list from an array
544
     *
545
     * @param array $columns List of columns to implode
546
     * @return string A properly quoted list of column names
547
     */
548
    protected function implodeColumnList($columns)
549
    {
550
        if (empty($columns)) {
551
            return '';
552
        }
553
        return '"' . implode('","', $columns) . '"';
554
    }
555
556
    /**
557
     * Given an index specification in the form of a string ensure that each
558
     * column name is property quoted, stripping brackets and modifiers.
559
     * This index may also be in the form of a "CREATE INDEX..." sql fragment
560
     *
561
     * @param string $spec The input specification or query. E.g. 'unique (Column1, Column2)'
562
     * @return string The properly quoted column list. E.g. '"Column1", "Column2"'
563
     */
564
    protected function quoteColumnSpecString($spec)
565
    {
566
        $bits = $this->explodeColumnString($spec);
567
        return $this->implodeColumnList($bits);
568
    }
569
570
    /**
571
     * Given an index spec determines the index type
572
     *
573
     * @param array|string $spec
574
     * @return string
575
     */
576
    protected function determineIndexType($spec)
577
    {
578
        // check array spec
579
        if (is_array($spec) && isset($spec['type'])) {
580
            return $spec['type'];
581
        } elseif (!is_array($spec) && preg_match('/(?<type>\w+)\s*\(/', $spec, $matchType)) {
582
            return strtolower($matchType['type']);
583
        } else {
584
            return 'index';
585
        }
586
    }
587
588
    /**
589
     * This takes the index spec which has been provided by a class (ie static $indexes = blah blah)
590
     * and turns it into a proper string.
591
     * Some indexes may be arrays, such as fulltext and unique indexes, and this allows database-specific
592
     * arrays to be created. See {@link requireTable()} for details on the index format.
593
     *
594
     * @see http://dev.mysql.com/doc/refman/5.0/en/create-index.html
595
     * @see parseIndexSpec() for approximate inverse
596
     *
597
     * @param string|array $indexSpec
598
     * @return string
599
     */
600
    protected function convertIndexSpec($indexSpec)
601
    {
602
        // Return already converted spec
603
        if (!is_array($indexSpec)
604
            || !array_key_exists('type', $indexSpec)
605
            || !array_key_exists('columns', $indexSpec)
606
            || !is_array($indexSpec['columns'])
607
            || array_key_exists('value', $indexSpec)
608
        ) {
609
            throw new \InvalidArgumentException(
610
                sprintf(
611
                    'argument to convertIndexSpec must be correct indexSpec, %s given',
612
                    var_export($indexSpec, true)
613
                )
614
            );
615
        }
616
617
        // Combine elements into standard string format
618
        return sprintf('%s (%s)', $indexSpec['type'], $this->implodeColumnList($indexSpec['columns']));
619
    }
620
621
    /**
622
     * Returns true if the given table is exists in the current database
623
     *
624
     * @param string $tableName Name of table to check
625
     * @return boolean Flag indicating existence of table
626
     */
627
    abstract public function hasTable($tableName);
628
629
    /**
630
     * Return true if the table exists and already has a the field specified
631
     *
632
     * @param string $tableName - The table to check
633
     * @param string $fieldName - The field to check
634
     * @return bool - True if the table exists and the field exists on the table
635
     */
636
    public function hasField($tableName, $fieldName)
637
    {
638
        if (!$this->hasTable($tableName)) {
639
            return false;
640
        }
641
        $fields = $this->fieldList($tableName);
642
        return array_key_exists($fieldName, $fields);
643
    }
644
645
    /**
646
     * Generate the given field on the table, modifying whatever already exists as necessary.
647
     *
648
     * @param string $table The table name.
649
     * @param string $field The field name.
650
     * @param array|string $spec The field specification. If passed in array syntax, the specific database
651
     *  driver takes care of the ALTER TABLE syntax. If passed as a string, its assumed to
652
     *  be prepared as a direct SQL framgment ready for insertion into ALTER TABLE. In this case you'll
653
     *  need to take care of database abstraction in your DBField subclass.
654
     */
655
    public function requireField($table, $field, $spec)
656
    {
657
        //TODO: this is starting to get extremely fragmented.
658
        //There are two different versions of $spec floating around, and their content changes depending
659
        //on how they are structured.  This needs to be tidied up.
660
        $fieldValue = null;
661
        $newTable = false;
662
663
        // backwards compatibility patch for pre 2.4 requireField() calls
664
        $spec_orig = $spec;
665
666
        if (!is_string($spec)) {
667
            $spec['parts']['name'] = $field;
668
            $spec_orig['parts']['name'] = $field;
669
            //Convert the $spec array into a database-specific string
670
            $spec = $this->{$spec['type']}($spec['parts'], true);
671
        }
672
673
        // Collations didn't come in until MySQL 4.1.  Anything earlier will throw a syntax error if you try and use
674
        // collations.
675
        // TODO: move this to the MySQLDatabase file, or drop it altogether?
676
        if (!$this->database->supportsCollations()) {
677
            $spec = preg_replace('/ *character set [^ ]+( collate [^ ]+)?( |$)/', '\\2', $spec);
678
        }
679
680
        if (!isset($this->tableList[strtolower($table)])) {
681
            $newTable = true;
682
        }
683
684
        if (is_array($spec)) {
685
            $specValue = $this->$spec_orig['type']($spec_orig['parts']);
686
        } else {
687
            $specValue = $spec;
688
        }
689
690
        // We need to get db-specific versions of the ID column:
691
        if ($spec_orig == $this->IdColumn() || $spec_orig == $this->IdColumn(true)) {
692
            $specValue = $this->IdColumn(true);
693
        }
694
695
        if (!$newTable) {
696
            $fieldList = $this->fieldList($table);
697
            if (isset($fieldList[$field])) {
698
                if (is_array($fieldList[$field])) {
699
                    $fieldValue = $fieldList[$field]['data_type'];
700
                } else {
701
                    $fieldValue = $fieldList[$field];
702
                }
703
            }
704
        }
705
706
        // Get the version of the field as we would create it. This is used for comparison purposes to see if the
707
        // existing field is different to what we now want
708
        if (is_array($spec_orig)) {
709
            $spec_orig = $this->{$spec_orig['type']}($spec_orig['parts']);
710
        }
711
712
        if ($newTable || $fieldValue == '') {
713
            $this->transCreateField($table, $field, $spec_orig);
714
            $this->alterationMessage("Field $table.$field: created as $spec_orig", "created");
715
        } elseif ($fieldValue != $specValue) {
716
            // If enums/sets are being modified, then we need to fix existing data in the table.
717
            // Update any records where the enum is set to a legacy value to be set to the default.
718
            foreach (array('enum', 'set') as $enumtype) {
719
                if (preg_match("/^$enumtype/i", $specValue)) {
720
                    $newStr = preg_replace("/(^$enumtype\\s*\\(')|('\\).*)/i", "", $spec_orig);
721
                    $new = preg_split("/'\\s*,\\s*'/", $newStr);
722
723
                    $oldStr = preg_replace("/(^$enumtype\\s*\\(')|('\\).*)/i", "", $fieldValue);
724
                    $old = preg_split("/'\\s*,\\s*'/", $oldStr);
725
726
                    $holder = array();
727
                    foreach ($old as $check) {
728
                        if (!in_array($check, $new)) {
0 ignored issues
show
Bug introduced by
It seems like $new can also be of type false; however, parameter $haystack of in_array() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

728
                        if (!in_array($check, /** @scrutinizer ignore-type */ $new)) {
Loading history...
729
                            $holder[] = $check;
730
                        }
731
                    }
732
                    if (count($holder)) {
733
                        $default = explode('default ', $spec_orig);
734
                        $default = $default[1];
735
                        $query = "UPDATE \"$table\" SET $field=$default WHERE $field IN (";
736
                        for ($i = 0; $i + 1 < count($holder); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
737
                            $query .= "'{$holder[$i]}', ";
738
                        }
739
                        $query .= "'{$holder[$i]}')";
740
                        $this->query($query);
741
                        $amount = $this->database->affectedRows();
742
                        $this->alterationMessage("Changed $amount rows to default value of field $field"
743
                                . " (Value: $default)");
744
                    }
745
                }
746
            }
747
            $this->transAlterField($table, $field, $spec_orig);
748
            $this->alterationMessage(
749
                "Field $table.$field: changed to $specValue <i class=\"build-info-before\">(from {$fieldValue})</i>",
750
                "changed"
751
            );
752
        }
753
    }
754
755
    /**
756
     * If the given field exists, move it out of the way by renaming it to _obsolete_(fieldname).
757
     *
758
     * @param string $table
759
     * @param string $fieldName
760
     */
761
    public function dontRequireField($table, $fieldName)
762
    {
763
        $fieldList = $this->fieldList($table);
764
        if (array_key_exists($fieldName, $fieldList)) {
765
            $suffix = '';
766
            while (isset($fieldList[strtolower("_obsolete_{$fieldName}$suffix")])) {
767
                $suffix = $suffix
768
                        ? ((int)$suffix + 1)
769
                        : 2;
770
            }
771
            $this->renameField($table, $fieldName, "_obsolete_{$fieldName}$suffix");
772
            $this->alterationMessage(
773
                "Field $table.$fieldName: renamed to $table._obsolete_{$fieldName}$suffix",
774
                "obsolete"
775
            );
776
        }
777
    }
778
779
    /**
780
     * Show a message about database alteration
781
     *
782
     * @param string $message to display
783
     * @param string $type one of [created|changed|repaired|obsolete|deleted|error]
784
     */
785
    public function alterationMessage($message, $type = "")
786
    {
787
        if (!$this->supressOutput) {
788
            if (Director::is_cli()) {
789
                switch ($type) {
790
                    case "created":
791
                    case "changed":
792
                    case "repaired":
793
                        $sign = "+";
794
                        break;
795
                    case "obsolete":
796
                    case "deleted":
797
                        $sign = '-';
798
                        break;
799
                    case "notice":
800
                        $sign = '*';
801
                        break;
802
                    case "error":
803
                        $sign = "!";
804
                        break;
805
                    default:
806
                        $sign = " ";
807
                }
808
                $message = strip_tags($message);
809
                echo "  $sign $message\n";
810
            } else {
811
                switch ($type) {
812
                    case "created":
813
                        $class = "success";
814
                        break;
815
                    case "obsolete":
816
                        $class = "error";
817
                        break;
818
                    case "notice":
819
                        $class = "warning";
820
                        break;
821
                    case "error":
822
                        $class = "error";
823
                        break;
824
                    case "deleted":
825
                        $class = "error";
826
                        break;
827
                    case "changed":
828
                        $class = "info";
829
                        break;
830
                    case "repaired":
831
                        $class = "info";
832
                        break;
833
                    default:
834
                        $class = "";
835
                }
836
                echo "<li class=\"$class\">$message</li>";
837
            }
838
        }
839
    }
840
841
    /**
842
     * This returns the data type for the id column which is the primary key for each table
843
     *
844
     * @param boolean $asDbValue
845
     * @param boolean $hasAutoIncPK
846
     * @return string
847
     */
848
    abstract public function IdColumn($asDbValue = false, $hasAutoIncPK = true);
849
850
    /**
851
     * Checks a table's integrity and repairs it if necessary.
852
     *
853
     * @param string $tableName The name of the table.
854
     * @return boolean Return true if the table has integrity after the method is complete.
855
     */
856
    abstract public function checkAndRepairTable($tableName);
857
858
859
    /**
860
     * Ensure the given table has the correct case
861
     *
862
     * @param string $tableName Name of table in desired case
863
     */
864
    public function fixTableCase($tableName)
865
    {
866
        // Check if table exists
867
        $tables = $this->tableList();
868
        if (!array_key_exists(strtolower($tableName), $tables)) {
869
            return;
870
        }
871
872
        // Check if case differs
873
        $currentName = $tables[strtolower($tableName)];
874
        if ($currentName === $tableName) {
875
            return;
876
        }
877
878
        $this->alterationMessage(
879
            "Table $tableName: renamed from $currentName",
880
            "repaired"
881
        );
882
883
        // Rename via temp table to avoid case-sensitivity issues
884
        $tempTable = "__TEMP__{$tableName}";
885
        $this->renameTable($currentName, $tempTable);
886
        $this->renameTable($tempTable, $tableName);
887
    }
888
889
    /**
890
     * Returns the values of the given enum field
891
     *
892
     * @param string $tableName Name of table to check
893
     * @param string $fieldName name of enum field to check
894
     * @return array List of enum values
895
     */
896
    abstract public function enumValuesForField($tableName, $fieldName);
897
898
899
    /*
900
     * This is a lookup table for data types.
901
     * For instance, Postgres uses 'INT', while MySQL uses 'UNSIGNED'
902
     * So this is a DB-specific list of equivilents.
903
     *
904
     * @param string $type
905
     * @return string
906
     */
907
    abstract public function dbDataType($type);
908
909
    /**
910
     * Retrieves the list of all databases the user has access to
911
     *
912
     * @return array List of database names
913
     */
914
    abstract public function databaseList();
915
916
    /**
917
     * Determine if the database with the specified name exists
918
     *
919
     * @param string $name Name of the database to check for
920
     * @return boolean Flag indicating whether this database exists
921
     */
922
    abstract public function databaseExists($name);
923
924
    /**
925
     * Create a database with the specified name
926
     *
927
     * @param string $name Name of the database to create
928
     * @return boolean True if successful
929
     */
930
    abstract public function createDatabase($name);
931
932
    /**
933
     * Drops a database with the specified name
934
     *
935
     * @param string $name Name of the database to drop
936
     */
937
    abstract public function dropDatabase($name);
938
939
    /**
940
     * Alter an index on a table.
941
     *
942
     * @param string $tableName The name of the table.
943
     * @param string $indexName The name of the index.
944
     * @param array $indexSpec The specification of the index, see Database::requireIndex() for more details.
945
     * @todo Find out where this is called from - Is it even used? Aren't indexes always dropped and re-added?
946
     */
947
    abstract public function alterIndex($tableName, $indexName, $indexSpec);
948
949
    /**
950
     * Determines the key that should be used to identify this index
951
     * when retrieved from DBSchemaManager->indexList.
952
     * In some connectors this is the database-visible name, in others the
953
     * usercode-visible name.
954
     *
955
     * @param string $table
956
     * @param string $index
957
     * @param array $spec
958
     * @return string Key for this index
959
     */
960
    abstract protected function indexKey($table, $index, $spec);
961
962
    /**
963
     * Return the list of indexes in a table.
964
     *
965
     * @param string $table The table name.
966
     * @return array[array] List of current indexes in the table, each in standard
0 ignored issues
show
Documentation Bug introduced by
The doc comment array[array] at position 1 could not be parsed: Expected ']' at position 1, but found '['.
Loading history...
967
     * array form. The key for this array should be predictable using the indexKey
968
     * method
969
     */
970
    abstract public function indexList($table);
971
972
    /**
973
     * Returns a list of all tables in the database.
974
     * Keys are table names in lower case, values are table names in case that
975
     * database expects.
976
     *
977
     * @return array
978
     */
979
    abstract public function tableList();
980
981
    /**
982
     * Create a new table.
983
     *
984
     * @param string $table The name of the table
985
     * @param array $fields A map of field names to field types
986
     * @param array $indexes A map of indexes
987
     * @param array $options An map of additional options.  The available keys are as follows:
988
     *   - 'MSSQLDatabase'/'MySQLDatabase'/'PostgreSQLDatabase' - database-specific options such as "engine" for MySQL.
989
     *   - 'temporary' - If true, then a temporary table will be created
990
     * @param array $advancedOptions Advanced creation options
991
     * @return string The table name generated.  This may be different from the table name, for example with temporary
992
     * tables.
993
     */
994
    abstract public function createTable(
995
        $table,
996
        $fields = null,
997
        $indexes = null,
998
        $options = null,
999
        $advancedOptions = null
1000
    );
1001
1002
    /**
1003
     * Alter a table's schema.
1004
     *
1005
     * @param string $table The name of the table to alter
1006
     * @param array $newFields New fields, a map of field name => field schema
1007
     * @param array $newIndexes New indexes, a map of index name => index type
1008
     * @param array $alteredFields Updated fields, a map of field name => field schema
1009
     * @param array $alteredIndexes Updated indexes, a map of index name => index type
1010
     * @param array $alteredOptions
1011
     * @param array $advancedOptions
1012
     */
1013
    abstract public function alterTable(
1014
        $table,
1015
        $newFields = null,
1016
        $newIndexes = null,
1017
        $alteredFields = null,
1018
        $alteredIndexes = null,
1019
        $alteredOptions = null,
1020
        $advancedOptions = null
1021
    );
1022
1023
    /**
1024
     * Rename a table.
1025
     *
1026
     * @param string $oldTableName The old table name.
1027
     * @param string $newTableName The new table name.
1028
     */
1029
    abstract public function renameTable($oldTableName, $newTableName);
1030
1031
    /**
1032
     * Create a new field on a table.
1033
     *
1034
     * @param string $table Name of the table.
1035
     * @param string $field Name of the field to add.
1036
     * @param string $spec The field specification, eg 'INTEGER NOT NULL'
1037
     */
1038
    abstract public function createField($table, $field, $spec);
1039
1040
    /**
1041
     * Change the database column name of the given field.
1042
     *
1043
     * @param string $tableName The name of the tbale the field is in.
1044
     * @param string $oldName The name of the field to change.
1045
     * @param string $newName The new name of the field
1046
     */
1047
    abstract public function renameField($tableName, $oldName, $newName);
1048
1049
    /**
1050
     * Get a list of all the fields for the given table.
1051
     * Returns a map of field name => field spec.
1052
     *
1053
     * @param string $table The table name.
1054
     * @return array
1055
     */
1056
    abstract public function fieldList($table);
1057
1058
    /**
1059
     *
1060
     * This allows the cached values for a table's field list to be erased.
1061
     * If $tablename is empty, then the whole cache is erased.
1062
     *
1063
     * @param string $tableName
1064
     * @return boolean
1065
     */
1066
    public function clearCachedFieldlist($tableName = null)
1067
    {
1068
        return true;
1069
    }
1070
1071
1072
    /**
1073
     * Returns data type for 'boolean' column
1074
     *
1075
     * @param array $values Contains a tokenised list of info about this data type
1076
     * @return string
1077
     */
1078
    abstract public function boolean($values);
1079
1080
    /**
1081
     * Returns data type for 'date' column
1082
     *
1083
     * @param array $values Contains a tokenised list of info about this data type
1084
     * @return string
1085
     */
1086
    abstract public function date($values);
1087
1088
    /**
1089
     * Returns data type for 'decimal' column
1090
     *
1091
     * @param array $values Contains a tokenised list of info about this data type
1092
     * @return string
1093
     */
1094
    abstract public function decimal($values);
1095
1096
    /**
1097
     * Returns data type for 'set' column
1098
     *
1099
     * @param array $values Contains a tokenised list of info about this data type
1100
     * @return string
1101
     */
1102
    abstract public function enum($values);
1103
1104
    /**
1105
     * Returns data type for 'set' column
1106
     *
1107
     * @param array $values Contains a tokenised list of info about this data type
1108
     * @return string
1109
     */
1110
    abstract public function set($values);
1111
1112
    /**
1113
     * Returns data type for 'float' column
1114
     *
1115
     * @param array $values Contains a tokenised list of info about this data type
1116
     * @return string
1117
     */
1118
    abstract public function float($values);
1119
1120
    /**
1121
     * Returns data type for 'int' column
1122
     *
1123
     * @param array $values Contains a tokenised list of info about this data type
1124
     * @return string
1125
     */
1126
    abstract public function int($values);
1127
1128
    /**
1129
     * Returns data type for 'datetime' column
1130
     *
1131
     * @param array $values Contains a tokenised list of info about this data type
1132
     * @return string
1133
     */
1134
    abstract public function datetime($values);
1135
1136
    /**
1137
     * Returns data type for 'text' column
1138
     *
1139
     * @param array $values Contains a tokenised list of info about this data type
1140
     * @return string
1141
     */
1142
    abstract public function text($values);
1143
1144
    /**
1145
     * Returns data type for 'time' column
1146
     *
1147
     * @param array $values Contains a tokenised list of info about this data type
1148
     * @return string
1149
     */
1150
    abstract public function time($values);
1151
1152
    /**
1153
     * Returns data type for 'varchar' column
1154
     *
1155
     * @param array $values Contains a tokenised list of info about this data type
1156
     * @return string
1157
     */
1158
    abstract public function varchar($values);
1159
1160
    /*
1161
     * Returns data type for 'year' column
1162
     *
1163
     * @param array $values Contains a tokenised list of info about this data type
1164
     * @return string
1165
     */
1166
    abstract public function year($values);
1167
}
1168