Completed
Pull Request — master (#31)
by Damian
06:15
created

MSSQLSchemaManager::createTable()   C

Complexity

Conditions 7
Paths 16

Size

Total Lines 34
Code Lines 15

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 34
rs 6.7272
cc 7
eloc 15
nc 16
nop 5
1
<?php
2
3
namespace SilverStripe\MSSQL;
4
5
6
use SilverStripe\ORM\Connect\DBSchemaManager;
7
8
9
10
/**
11
 * Represents and handles all schema management for a MS SQL database
12
 *
13
 * @package mssql
14
 */
15
class MSSQLSchemaManager extends DBSchemaManager
16
{
17
18
    /**
19
     * Stores per-request cached constraint checks that come from the database.
20
     *
21
     * @var array
22
     */
23
    protected static $cached_checks = array();
24
25
    /**
26
     * Builds the internal MS SQL Server index name given the silverstripe table and index name
27
     *
28
     * @param string $tableName
29
     * @param string $indexName
30
     * @param string $prefix The optional prefix for the index. Defaults to "ix" for indexes.
31
     * @return string The name of the index
32
     */
33
    public function buildMSSQLIndexName($tableName, $indexName, $prefix = 'ix')
34
    {
35
36
        // Cleanup names of namespaced tables
37
        $tableName = str_replace('\\', '_', $tableName);
38
        $indexName = str_replace('\\', '_', $indexName);
39
40
        return "{$prefix}_{$tableName}_{$indexName}";
41
    }
42
43
44
    /**
45
     * This will set up the full text search capabilities.
46
     *
47
     * @param string $name Name of full text catalog to use
48
     */
49
    public function createFullTextCatalog($name = 'ftCatalog')
50
    {
51
        $result = $this->fullTextCatalogExists();
52
        if (!$result) {
53
            $this->query("CREATE FULLTEXT CATALOG \"$name\" AS DEFAULT;");
54
        }
55
    }
56
57
    /**
58
     * Check that a fulltext catalog has been created yet.
59
     *
60
     * @param string $name Name of full text catalog to use
61
     * @return boolean
62
     */
63
    public function fullTextCatalogExists($name = 'ftCatalog')
64
    {
65
        return (bool) $this->preparedQuery(
66
            "SELECT name FROM sys.fulltext_catalogs WHERE name = ?;",
67
            array($name)
68
        )->value();
69
    }
70
71
    /**
72
     * Sleep until the catalog has been fully rebuilt. This is a busy wait designed for situations
73
     * when you need to be sure the index is up to date - for example in unit tests.
74
     *
75
     * TODO: move this to Database class? Can we assume this will be useful for all databases?
76
     * Also see the wrapper functions "waitUntilIndexingFinished" in SearchFormTest and TranslatableSearchFormTest
77
     *
78
     * @param int $maxWaitingTime Time in seconds to wait for the database.
79
     */
80
    public function waitUntilIndexingFinished($maxWaitingTime = 15)
81
    {
82
        if (!$this->database->fullTextEnabled()) {
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class SilverStripe\ORM\Connect\SS_Database as the method fullTextEnabled() does only exist in the following sub-classes of SilverStripe\ORM\Connect\SS_Database: SilverStripe\MSSQL\MSSQLAzureDatabase, SilverStripe\MSSQL\MSSQLDatabase. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

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

class MyUser extends 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 sub-classes 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 parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
83
            return;
84
        }
85
86
        $this->query("EXEC sp_fulltext_catalog 'ftCatalog', 'Rebuild';");
87
88
        // Busy wait until it's done updating, but no longer than 15 seconds.
89
        $start = time();
90
        while (time() - $start < $maxWaitingTime) {
91
            $status = $this->query("EXEC sp_help_fulltext_catalogs 'ftCatalog';")->first();
92
93
            if (isset($status['STATUS']) && $status['STATUS'] == 0) {
94
                // Idle!
95
                break;
96
            }
97
            sleep(1);
98
        }
99
    }
100
101
    /**
102
     * Check if a fulltext index exists on a particular table name.
103
     *
104
     * @param string $tableName
105
     * @return boolean TRUE index exists | FALSE index does not exist | NULL no support
106
     */
107
    public function fulltextIndexExists($tableName)
108
    {
109
        // Special case for no full text index support
110
        if (!$this->database->fullTextEnabled()) {
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class SilverStripe\ORM\Connect\SS_Database as the method fullTextEnabled() does only exist in the following sub-classes of SilverStripe\ORM\Connect\SS_Database: SilverStripe\MSSQL\MSSQLAzureDatabase, SilverStripe\MSSQL\MSSQLDatabase. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

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

class MyUser extends 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 sub-classes 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 parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
111
            return null;
112
        }
113
114
        return (bool) $this->preparedQuery("
115
			SELECT 1 FROM sys.fulltext_indexes i
116
			JOIN sys.objects o ON i.object_id = o.object_id
117
			WHERE o.name = ?",
118
            array($tableName)
119
        )->value();
120
    }
121
122
    /**
123
     * MSSQL stores the primary key column with an internal identifier,
124
     * so a lookup needs to be done to determine it.
125
     *
126
     * @param string $tableName Name of table with primary key column "ID"
127
     * @return string Internal identifier for primary key
128
     */
129
    public function getPrimaryKey($tableName)
130
    {
131
        $indexes = $this->query("EXEC sp_helpindex '$tableName';");
132
        $indexName = '';
133
        foreach ($indexes as $index) {
134
            if ($index['index_keys'] == 'ID') {
135
                $indexName = $index['index_name'];
136
                break;
137
            }
138
        }
139
140
        return $indexName;
141
    }
142
143
    /**
144
     * Gets the identity column of a table
145
     *
146
     * @param string $tableName
147
     * @return string|null
148
     */
149
    public function getIdentityColumn($tableName)
150
    {
151
        return $this->preparedQuery("
152
			SELECT
153
				TABLE_NAME + '.' + COLUMN_NAME,
154
				TABLE_NAME
155
 			FROM
156
				INFORMATION_SCHEMA.COLUMNS
157
 			WHERE
158
				TABLE_SCHEMA = ? AND
159
				COLUMNPROPERTY(object_id(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 AND
160
				TABLE_NAME = ?
161
		", array('dbo', $tableName))->value();
162
    }
163
164
    public function createDatabase($name)
165
    {
166
        $this->query("CREATE DATABASE \"$name\"");
167
    }
168
169
    public function dropDatabase($name)
170
    {
171
        $this->query("DROP DATABASE \"$name\"");
172
    }
173
174
    public function databaseExists($name)
175
    {
176
        $databases = $this->databaseList();
177
        foreach ($databases as $dbname) {
178
            if ($dbname == $name) {
179
                return true;
180
            }
181
        }
182
        return false;
183
    }
184
185
    public function databaseList()
186
    {
187
        return $this->query('SELECT NAME FROM sys.sysdatabases')->column();
188
    }
189
190
    /**
191
     * Create a new table.
192
     * @param string $tableName The name of the table
193
     * @param array $fields A map of field names to field types
194
     * @param array $indexes A map of indexes
195
     * @param array $options An map of additional options.  The available keys are as follows:
196
     *   - 'MSSQLDatabase'/'MySQLDatabase'/'PostgreSQLDatabase' - database-specific options such as "engine" for MySQL.
197
     *   - 'temporary' - If true, then a temporary table will be created
198
     * @param array $advancedOptions
199
     * @return string The table name generated.  This may be different from the table name, for example with temporary tables.
200
     */
201
    public function createTable($tableName, $fields = null, $indexes = null, $options = null, $advancedOptions = null)
202
    {
203
        $fieldSchemas = $indexSchemas = "";
204
        if ($fields) {
205
            foreach ($fields as $k => $v) {
206
                $fieldSchemas .= "\"$k\" $v,\n";
207
            }
208
        }
209
210
        // Temporary tables start with "#" in MSSQL-land
211
        if (!empty($options['temporary'])) {
212
            // Randomize the temp table name to avoid conflicts in the tempdb table which derived databases share
213
            $tableName = "#$tableName" . '-' . rand(1000000, 9999999);
214
        }
215
216
        $this->query("CREATE TABLE \"$tableName\" (
217
			$fieldSchemas
218
			primary key (\"ID\")
219
		);");
220
221
        //we need to generate indexes like this: CREATE INDEX IX_vault_to_export ON vault (to_export);
222
        //This needs to be done AFTER the table creation, so we can set up the fulltext indexes correctly
223
        if ($indexes) {
224
            foreach ($indexes as $k => $v) {
225
                $indexSchemas .= $this->getIndexSqlDefinition($tableName, $k, $v) . "\n";
226
            }
227
        }
228
229
        if ($indexSchemas) {
230
            $this->query($indexSchemas);
231
        }
232
233
        return $tableName;
234
    }
235
236
    /**
237
     * Alter a table's schema.
238
     * @param string $tableName The name of the table to alter
239
     * @param array $newFields New fields, a map of field name => field schema
240
     * @param array $newIndexes New indexes, a map of index name => index type
241
     * @param array $alteredFields Updated fields, a map of field name => field schema
242
     * @param array $alteredIndexes Updated indexes, a map of index name => index type
243
     * @param array $alteredOptions
244
     * @param array $advancedOptions
245
     */
246
    public function alterTable($tableName, $newFields = null, $newIndexes = null, $alteredFields = null, $alteredIndexes = null, $alteredOptions=null, $advancedOptions=null)
247
    {
248
        $alterList = array();
249
250
        // drop any fulltext indexes that exist on the table before altering the structure
251
        if ($this->fulltextIndexExists($tableName)) {
252
            $alterList[] = "\nDROP FULLTEXT INDEX ON \"$tableName\";";
253
        }
254
255
        if ($newFields) {
256
            foreach ($newFields as $k => $v) {
257
                $alterList[] = "ALTER TABLE \"$tableName\" ADD \"$k\" $v";
258
            }
259
        }
260
261
        if ($alteredFields) {
262
            foreach ($alteredFields as $k => $v) {
263
                $alterList[] = $this->alterTableAlterColumn($tableName, $k, $v);
264
            }
265
        }
266
        if ($alteredIndexes) {
267
            foreach ($alteredIndexes as $k => $v) {
268
                $alterList[] = $this->getIndexSqlDefinition($tableName, $k, $v);
269
            }
270
        }
271
        if ($newIndexes) {
272
            foreach ($newIndexes as $k => $v) {
273
                $alterList[] = $this->getIndexSqlDefinition($tableName, $k, $v);
274
            }
275
        }
276
277
        if ($alterList) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $alterList of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
278
            foreach ($alterList as $alteration) {
279
                if ($alteration != '') {
280
                    $this->query($alteration);
281
                }
282
            }
283
        }
284
    }
285
286
    /**
287
     * Given the table and column name, retrieve the constraint name for that column
288
     * in the table.
289
     *
290
     * @param string $tableName Table name column resides in
291
     * @param string $columnName Column name the constraint is for
292
     * @return string|null
293
     */
294
    public function getConstraintName($tableName, $columnName)
295
    {
296
        return $this->preparedQuery("
297
			SELECT CONSTRAINT_NAME
298
			FROM INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE
299
			WHERE TABLE_NAME = ? AND COLUMN_NAME = ?",
300
            array($tableName, $columnName)
301
        )->value();
302
    }
303
304
    /**
305
     * Given a table and column name, return a check constraint clause for that column in
306
     * the table.
307
     *
308
     * This is an expensive query, so it is cached per-request and stored by table. The initial
309
     * call for a table that has not been cached will query all columns and store that
310
     * so subsequent calls are fast.
311
     *
312
     * @param string $tableName Table name column resides in
313
     * @param string $columnName Column name the constraint is for
314
     * @return string The check string
315
     */
316
    public function getConstraintCheckClause($tableName, $columnName)
317
    {
318
        // Check already processed table columns
319
        if (isset(self::$cached_checks[$tableName])) {
320
            if (!isset(self::$cached_checks[$tableName][$columnName])) {
321
                return null;
322
            }
323
            return self::$cached_checks[$tableName][$columnName];
324
        }
325
326
        // Regenerate cehcks for this table
327
        $checks = array();
328
        foreach ($this->preparedQuery("
329
			SELECT CAST(CHECK_CLAUSE AS TEXT) AS CHECK_CLAUSE, COLUMN_NAME
330
			FROM INFORMATION_SCHEMA.CHECK_CONSTRAINTS AS CC
331
			INNER JOIN INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE AS CCU ON CCU.CONSTRAINT_NAME = CC.CONSTRAINT_NAME
332
			WHERE TABLE_NAME = ?",
333
            array($tableName)
334
        ) as $record) {
335
            $checks[$record['COLUMN_NAME']] = $record['CHECK_CLAUSE'];
336
        }
337
        self::$cached_checks[$tableName] = $checks;
338
339
        // Return via cached records
340
        return $this->getConstraintCheckClause($tableName, $columnName);
341
    }
342
343
    /**
344
     * Return the name of the default constraint applied to $tableName.$colName.
345
     * Will return null if no such constraint exists
346
     *
347
     * @param string $tableName Name of the table
348
     * @param string $colName Name of the column
349
     * @return string|null
350
     */
351
    protected function defaultConstraintName($tableName, $colName)
352
    {
353
        return $this->preparedQuery("
354
			SELECT s.name --default name
355
			FROM sys.sysobjects s
356
			join sys.syscolumns c ON s.parent_obj = c.id
357
			WHERE s.xtype = 'd'
358
			and c.cdefault = s.id
359
			and parent_obj = OBJECT_ID(?)
360
			and c.name = ?",
361
            array($tableName, $colName)
362
        )->value();
363
    }
364
365
    /**
366
     * Get enum values from a constraint check clause.
367
     *
368
     * @param string $clause Check clause to parse values from
369
     * @return array Enum values
370
     */
371
    protected function enumValuesFromCheckClause($clause)
372
    {
373
        $segments = preg_split('/ +OR *\[/i', $clause);
374
        $constraints = array();
375
        foreach ($segments as $segment) {
376
            $bits = preg_split('/ *= */', $segment);
377
            for ($i = 1; $i < sizeof($bits); $i += 2) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function sizeof() 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...
378
                array_unshift($constraints, substr(rtrim($bits[$i], ')'), 1, -1));
379
            }
380
        }
381
        return $constraints;
382
    }
383
384
    /*
385
     * Creates an ALTER expression for a column in MS SQL
386
     *
387
     * @param string $tableName Name of the table to be altered
388
     * @param string $colName   Name of the column to be altered
389
     * @param string $colSpec   String which contains conditions for a column
390
     * @return string
391
     */
392
    protected function alterTableAlterColumn($tableName, $colName, $colSpec)
393
    {
394
395
        // First, we split the column specifications into parts
396
        // TODO: this returns an empty array for the following string: int(11) not null auto_increment
397
        //		 on second thoughts, why is an auto_increment field being passed through?
398
        $pattern = '/^(?<definition>[\w()]+)\s?(?<null>(?:not\s)?null)?\s?(?<default>default\s[\w\']+)?\s?(?<check>check\s?[\w()\'",\s]+)?$/i';
399
        $matches = array();
400
        preg_match($pattern, $colSpec, $matches);
401
402
        // drop the index if it exists
403
        $alterQueries = array();
404
405
        // drop *ALL* indexes on a table before proceeding
406
        // this won't drop primary keys, though
407
        $indexes = $this->indexNames($tableName);
408
        foreach ($indexes as $indexName) {
409
            $alterQueries[] = "DROP INDEX \"$indexName\" ON \"$tableName\";";
410
        }
411
412
        $prefix = "ALTER TABLE \"$tableName\" ";
413
414
        // Remove the old default prior to adjusting the column.
415
        if ($defaultConstraintName = $this->defaultConstraintName($tableName, $colName)) {
416
            $alterQueries[] = "$prefix DROP CONSTRAINT \"$defaultConstraintName\";";
417
        }
418
419
        if (isset($matches['definition'])) {
420
            //We will prevent any changes being made to the ID column.  Primary key indexes will have a fit if we do anything here.
421
            if ($colName != 'ID') {
422
423
                // SET null / not null
424
                $nullFragment = empty($matches['null']) ? '' : " {$matches['null']}";
425
                $alterQueries[] = "$prefix ALTER COLUMN \"$colName\" {$matches['definition']}$nullFragment;";
426
427
                // Add a default back
428
                if (!empty($matches['default'])) {
429
                    $alterQueries[] = "$prefix ADD {$matches['default']} FOR \"$colName\";";
430
                }
431
432
                // SET check constraint (The constraint HAS to be dropped)
433
                if (!empty($matches['check'])) {
434
                    $constraint = $this->getConstraintName($tableName, $colName);
435
                    if ($constraint) {
436
                        $alterQueries[] = "$prefix DROP CONSTRAINT {$constraint};";
437
                    }
438
439
                    //NOTE: 'with nocheck' seems to solve a few problems I've been having for modifying existing tables.
440
                    $alterQueries[] = "$prefix WITH NOCHECK ADD CONSTRAINT \"{$tableName}_{$colName}_check\" {$matches['check']};";
441
                }
442
            }
443
        }
444
445
        return implode("\n", $alterQueries);
446
    }
447
448
    public function renameTable($oldTableName, $newTableName)
449
    {
450
        $this->query("EXEC sp_rename \"$oldTableName\", \"$newTableName\"");
451
    }
452
453
    /**
454
     * Checks a table's integrity and repairs it if necessary.
455
     * NOTE: MSSQL does not appear to support any vacuum or optimise commands
456
     *
457
     * @var string $tableName The name of the table.
458
     * @return boolean Return true if the table has integrity after the method is complete.
459
     */
460
    public function checkAndRepairTable($tableName)
461
    {
462
        return true;
463
    }
464
465
    public function createField($tableName, $fieldName, $fieldSpec)
466
    {
467
        $this->query("ALTER TABLE \"$tableName\" ADD \"$fieldName\" $fieldSpec");
468
    }
469
470
    /**
471
     * Change the database type of the given field.
472
     * @param string $tableName The name of the tbale the field is in.
473
     * @param string $fieldName The name of the field to change.
474
     * @param string $fieldSpec The new field specification
475
     */
476
    public function alterField($tableName, $fieldName, $fieldSpec)
477
    {
478
        $this->query("ALTER TABLE \"$tableName\" CHANGE \"$fieldName\" \"$fieldName\" $fieldSpec");
479
    }
480
481
    public function renameField($tableName, $oldName, $newName)
482
    {
483
        $this->query("EXEC sp_rename @objname = '$tableName.$oldName', @newname = '$newName', @objtype = 'COLUMN'");
484
    }
485
486
    public function fieldList($table)
487
    {
488
        //This gets us more information than we need, but I've included it all for the moment....
489
        $fieldRecords = $this->preparedQuery("SELECT ordinal_position, column_name, data_type, column_default,
490
			is_nullable, character_maximum_length, numeric_precision, numeric_scale, collation_name
491
			FROM information_schema.columns WHERE table_name = ?
492
			ORDER BY ordinal_position;",
493
            array($table)
494
        );
495
496
        // Cache the records from the query - otherwise a lack of multiple active result sets
497
        // will cause subsequent queries to fail in this method
498
        $fields = array();
499
        $output = array();
500
        foreach ($fieldRecords as $record) {
501
            $fields[] = $record;
502
        }
503
504
        foreach ($fields as $field) {
505
            // Update the data_type field to be a complete column definition string for use by
506
            // SS_Database::requireField()
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% 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...
507
            switch ($field['data_type']) {
508
                case 'int':
509
                case 'bigint':
510
                case 'numeric':
511
                case 'float':
512
                case 'bit':
513
                    if ($field['data_type'] != 'bigint' && $field['data_type'] != 'int' && $sizeSuffix = $field['numeric_precision']) {
514
                        $field['data_type'] .= "($sizeSuffix)";
515
                    }
516
517 View Code Duplication
                    if ($field['is_nullable'] == 'YES') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
518
                        $field['data_type'] .= ' null';
519
                    } else {
520
                        $field['data_type'] .= ' not null';
521
                    }
522 View Code Duplication
                    if ($field['column_default']) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
523
                        $default=substr($field['column_default'], 2, -2);
524
                        $field['data_type'] .= " default $default";
525
                    }
526
                    break;
527
528
                case 'decimal':
529
                    if ($field['numeric_precision']) {
530
                        $sizeSuffix = $field['numeric_precision'] . ',' . $field['numeric_scale'];
531
                        $field['data_type'] .= "($sizeSuffix)";
532
                    }
533
534 View Code Duplication
                    if ($field['is_nullable'] == 'YES') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
535
                        $field['data_type'] .= ' null';
536
                    } else {
537
                        $field['data_type'] .= ' not null';
538
                    }
539 View Code Duplication
                    if ($field['column_default']) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
540
                        $default=substr($field['column_default'], 2, -2);
541
                        $field['data_type'] .= " default $default";
542
                    }
543
                    break;
544
545
                case 'nvarchar':
546
                case 'varchar':
0 ignored issues
show
Coding Style introduced by
There must be a comment when fall-through is intentional in a non-empty case body
Loading history...
547
                    //Check to see if there's a constraint attached to this column:
548
                    $clause = $this->getConstraintCheckClause($table, $field['column_name']);
549
                    if ($clause) {
550
                        $constraints = $this->enumValuesFromCheckClause($clause);
551
                        $default=substr($field['column_default'], 2, -2);
552
                        $field['data_type'] = $this->enum(array(
553
                            'default' => $default,
554
                            'name' => $field['column_name'],
555
                            'enums' => $constraints,
556
                            'table' => $table
557
                        ));
558
                        break;
559
                    }
560
561
                default:
562
                    $sizeSuffix = $field['character_maximum_length'];
563
                    if ($sizeSuffix == '-1') {
564
                        $sizeSuffix = 'max';
565
                    }
566
                    if ($sizeSuffix) {
567
                        $field['data_type'] .= "($sizeSuffix)";
568
                    }
569
570 View Code Duplication
                    if ($field['is_nullable'] == 'YES') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
571
                        $field['data_type'] .= ' null';
572
                    } else {
573
                        $field['data_type'] .= ' not null';
574
                    }
575 View Code Duplication
                    if ($field['column_default']) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
576
                        $default=substr($field['column_default'], 2, -2);
577
                        $field['data_type'] .= " default '$default'";
578
                    }
579
            }
580
            $output[$field['column_name']] = $field;
581
        }
582
583
        return $output;
584
    }
585
586
    /**
587
     * Create an index on a table.
588
     * @param string $tableName The name of the table.
589
     * @param string $indexName The name of the index.
590
     * @param string $indexSpec The specification of the index, see SS_Database::requireIndex() for more details.
591
     */
592
    public function createIndex($tableName, $indexName, $indexSpec)
593
    {
594
        $this->query($this->getIndexSqlDefinition($tableName, $indexName, $indexSpec));
595
    }
596
597
    /**
598
     * Return SQL for dropping and recreating an index
599
     *
600
     * @param string $tableName Name of table to create this index against
601
     * @param string $indexName Name of this index
602
     * @param array|string $indexSpec Index specification, either as a raw string
603
     * or parsed array form
604
     * @return string The SQL required to generate this index
605
     */
606
    protected function getIndexSqlDefinition($tableName, $indexName, $indexSpec)
607
    {
608
609
        // Determine index name
610
        $index = $this->buildMSSQLIndexName($tableName, $indexName);
611
612
        // Consolidate/Cleanup spec into array format
613
        $indexSpec = $this->parseIndexSpec($indexName, $indexSpec);
614
615
        $drop = "IF EXISTS (SELECT name FROM sys.indexes WHERE name = '$index') DROP INDEX $index ON \"$tableName\";";
616
617
        // create a type-specific index
618
        if ($indexSpec['type'] == 'fulltext' && $this->database->fullTextEnabled()) {
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class SilverStripe\ORM\Connect\SS_Database as the method fullTextEnabled() does only exist in the following sub-classes of SilverStripe\ORM\Connect\SS_Database: SilverStripe\MSSQL\MSSQLAzureDatabase, SilverStripe\MSSQL\MSSQLDatabase. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

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

class MyUser extends 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 sub-classes 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 parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
619
            // enable fulltext on this table
620
            $this->createFullTextCatalog();
621
            $primary_key = $this->getPrimaryKey($tableName);
622
623
            if ($primary_key) {
624
                return "$drop CREATE FULLTEXT INDEX ON \"$tableName\" ({$indexSpec['value']})"
625
                     . "KEY INDEX $primary_key WITH CHANGE_TRACKING AUTO;";
626
            }
627
        }
628
629
        if ($indexSpec['type'] == 'unique') {
630
            return "$drop CREATE UNIQUE INDEX $index ON \"$tableName\" ({$indexSpec['value']});";
631
        }
632
633
        return "$drop CREATE INDEX $index ON \"$tableName\" ({$indexSpec['value']});";
634
    }
635
636
    public function alterIndex($tableName, $indexName, $indexSpec)
637
    {
638
        $this->createIndex($tableName, $indexName, $indexSpec);
639
    }
640
641
    /**
642
     * Return the list of indexes in a table.
643
     * @param string $table The table name.
644
     * @return array
645
     */
646
    public function indexList($table)
647
    {
648
        $indexes = $this->query("EXEC sp_helpindex '$table';");
649
        $indexList = array();
650
651
        // Enumerate all basic indexes
652
        foreach ($indexes as $index) {
653
            if (strpos($index['index_description'], 'unique') !== false) {
654
                $indexType = 'unique ';
655
            } else {
656
                $indexType = 'index ';
657
            }
658
659
            // Extract name from index
660
            $baseIndexName = $this->buildMSSQLIndexName($table, '');
661
            $indexName = substr($index['index_name'], strlen($baseIndexName));
662
663
            // Extract columns
664
            $columns = $this->quoteColumnSpecString($index['index_keys']);
665
            $indexList[$indexName] = $this->parseIndexSpec($indexName, array(
666
                'name' => $indexName,
667
                'value' => $columns,
668
                'type' => $indexType
669
            ));
670
        }
671
672
        // Now we need to check to see if we have any fulltext indexes attached to this table:
673
        if ($this->database->fullTextEnabled()) {
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class SilverStripe\ORM\Connect\SS_Database as the method fullTextEnabled() does only exist in the following sub-classes of SilverStripe\ORM\Connect\SS_Database: SilverStripe\MSSQL\MSSQLAzureDatabase, SilverStripe\MSSQL\MSSQLDatabase. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

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

class MyUser extends 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 sub-classes 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 parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
674
            $result = $this->query('EXEC sp_help_fulltext_columns;');
675
676
            // Extract columns from this fulltext definition
677
            $columns = array();
678
            foreach ($result as $row) {
679
                if ($row['TABLE_NAME'] == $table) {
680
                    $columns[] = $row['FULLTEXT_COLUMN_NAME'];
681
                }
682
            }
683
684
            if (!empty($columns)) {
685
                $indexList['SearchFields'] = $this->parseIndexSpec('SearchFields', array(
686
                    'name' => 'SearchFields',
687
                    'value' => $this->implodeColumnList($columns),
688
                    'type' => 'fulltext'
689
                ));
690
            }
691
        }
692
693
        return $indexList;
694
    }
695
696
    /**
697
     * For a given table name, get all the internal index names,
698
     * except for those that are primary keys and fulltext indexes.
699
     *
700
     * @return array
701
     */
702
    public function indexNames($tableName)
703
    {
704
        return $this->preparedQuery('
705
			SELECT ind.name FROM sys.indexes ind
706
			INNER JOIN sys.tables t ON ind.object_id = t.object_id
707
			WHERE is_primary_key = 0 AND t.name = ?',
708
            array($tableName)
709
        )->column();
710
    }
711
712
    public function tableList()
713
    {
714
        $tables = array();
715
        foreach ($this->query("EXEC sp_tables @table_owner = 'dbo';") as $record) {
716
            $tables[strtolower($record['TABLE_NAME'])] = $record['TABLE_NAME'];
717
        }
718
        return $tables;
719
    }
720
721
    /**
722
     * Return a boolean type-formatted string
723
     * We use 'bit' so that we can do numeric-based comparisons
724
     *
725
     * @param array $values Contains a tokenised list of info about this data type
726
     * @return string
727
     */
728
    public function boolean($values)
729
    {
730
        $default = ($values['default']) ? '1' : '0';
731
        return 'bit not null default ' . $default;
732
    }
733
734
    /**
735
     * Return a date type-formatted string.
736
     *
737
     * @param array $values Contains a tokenised list of info about this data type
738
     * @return string
739
     */
740
    public function date($values)
741
    {
742
        return 'date null';
743
    }
744
745
    /**
746
     * Return a decimal type-formatted string
747
     *
748
     * @param array $values Contains a tokenised list of info about this data type
749
     * @return string
750
     */
751
    public function decimal($values)
752
    {
753
        // Avoid empty strings being put in the db
754
        if ($values['precision'] == '') {
755
            $precision = 1;
756
        } else {
757
            $precision = $values['precision'];
758
        }
759
760
        $defaultValue = '0';
761
        if (isset($values['default']) && is_numeric($values['default'])) {
762
            $defaultValue = $values['default'];
763
        }
764
765
        return "decimal($precision) not null default $defaultValue";
766
    }
767
768
    /**
769
     * Return a enum type-formatted string
770
     *
771
     * @param array $values Contains a tokenised list of info about this data type
772
     * @return string
773
     */
774
    public function enum($values)
775
    {
776
        // Enums are a bit different. We'll be creating a varchar(255) with a constraint of all the
777
        // usual enum options.
778
        // NOTE: In this one instance, we are including the table name in the values array
779
780
        $maxLength = max(array_map('strlen', $values['enums']));
781
782
        return "varchar($maxLength) not null default '" . $values['default']
783
            . "' check(\"" . $values['name'] . "\" in ('" . implode("','", $values['enums'])
784
            . "'))";
785
    }
786
787
    /**
788
     * @todo Make this work like {@link MySQLDatabase::set()}
789
     */
790
    public function set($values)
791
    {
792
        return $this->enum($values);
793
    }
794
795
    /**
796
     * Return a float type-formatted string.
797
     *
798
     * @param array $values Contains a tokenised list of info about this data type
799
     * @return string
800
     */
801
    public function float($values)
802
    {
803
        return 'float(53) not null default ' . $values['default'];
804
    }
805
806
    /**
807
     * Return a int type-formatted string
808
     *
809
     * @param array $values Contains a tokenised list of info about this data type
810
     * @return string
811
     */
812
    public function int($values)
813
    {
814
        return 'int not null default ' . (int) $values['default'];
815
    }
816
817
    /**
818
     * Return a bigint type-formatted string
819
     *
820
     * @param array $values Contains a tokenised list of info about this data type
821
     * @return string
822
     */
823
    public function bigint($values)
824
    {
825
        return 'bigint not null default ' . (int) $values['default'];
826
    }
827
828
    /**
829
     * Return a datetime type-formatted string
830
     * For MS SQL, we simply return the word 'timestamp', no other parameters are necessary
831
     *
832
     * @param array $values Contains a tokenised list of info about this data type
833
     * @return string
834
     */
835
    public function datetime($values)
836
    {
837
        return 'datetime null';
838
    }
839
840
    /**
841
     * Return a text type-formatted string
842
     *
843
     * @param array $values Contains a tokenised list of info about this data type
844
     * @return string
845
     */
846
    public function text($values)
847
    {
848
        $collation = MSSQLDatabase::get_collation();
849
        $collationSQL = $collation ? " COLLATE $collation" : "";
850
        return "nvarchar(max)$collationSQL null";
851
    }
852
853
    /**
854
     * Return a time type-formatted string.
855
     *
856
     * @param array $values Contains a tokenised list of info about this data type
857
     * @return string
858
     */
859
    public function time($values)
860
    {
861
        return 'time null';
862
    }
863
864
    /**
865
     * Return a varchar type-formatted string
866
     *
867
     * @param array $values Contains a tokenised list of info about this data type
868
     * @return string
869
     */
870
    public function varchar($values)
871
    {
872
        $collation = MSSQLDatabase::get_collation();
873
        $collationSQL = $collation ? " COLLATE $collation" : "";
874
        return "nvarchar(" . $values['precision'] . ")$collationSQL null";
875
    }
876
877
    /**
878
     * Return a 4 digit numeric type.
879
     *
880
     * @param array $values
881
     * @return string
882
     */
883
    public function year($values)
884
    {
885
        return 'numeric(4)';
886
    }
887
888
    /**
889
     * This returns the column which is the primary key for each table
890
     *
891
     * @param bool $asDbValue
892
     * @param bool $hasAutoIncPK
893
     * @return string
894
     */
895
    public function IdColumn($asDbValue = false, $hasAutoIncPK = true)
896
    {
897
        if ($asDbValue) {
898
            return 'int not null';
899
        } elseif ($hasAutoIncPK) {
900
            return 'int identity(1,1)';
901
        } else {
902
            return 'int not null';
903
        }
904
    }
905
906
    public function hasTable($tableName)
907
    {
908
        return (bool)$this->preparedQuery(
909
            "SELECT table_name FROM information_schema.tables WHERE table_name = ?",
910
            array($tableName)
911
        )->value();
912
    }
913
914
    /**
915
     * Returns the values of the given enum field
916
     * NOTE: Experimental; introduced for db-abstraction and may changed before 2.4 is released.
917
     *
918
     * @param string $tableName
919
     * @param string $fieldName
920
     * @return array
921
     */
922
    public function enumValuesForField($tableName, $fieldName)
923
    {
924
        $classes = array();
925
926
        // Get the enum of all page types from the SiteTree table
927
        $clause = $this->getConstraintCheckClause($tableName, $fieldName);
928
        if ($clause) {
929
            $classes = $this->enumValuesFromCheckClause($clause);
930
        }
931
932
        return $classes;
933
    }
934
935
    /**
936
     * This is a lookup table for data types.
937
     *
938
     * For instance, MSSQL uses 'BIGINT', while MySQL uses 'UNSIGNED'
939
     * and PostgreSQL uses 'INT'.
940
     *
941
     * @param string $type
942
     * @return string
943
     */
944
    public function dbDataType($type)
945
    {
946
        $values = array(
947
            'unsigned integer'=>'BIGINT'
948
        );
949
        if (isset($values[$type])) {
950
            return $values[$type];
951
        } else {
952
            return '';
953
        }
954
    }
955
956
    protected function indexKey($table, $index, $spec)
957
    {
958
        return $index;
959
    }
960
}
961