Passed
Push — programming-is-horrible ( 2233ca...483908 )
by Sam
08:19
created

MySQLDatabase::supportsLocks()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\ORM\Connect;
4
5
use SilverStripe\Assets\File;
6
use SilverStripe\CMS\Model\SiteTree;
0 ignored issues
show
Bug introduced by
The type SilverStripe\CMS\Model\SiteTree was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
7
use SilverStripe\Core\Config\Configurable;
8
use SilverStripe\Core\Convert;
9
use SilverStripe\ORM\PaginatedList;
10
use SilverStripe\ORM\DataList;
11
use SilverStripe\ORM\ArrayList;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\ORM\Queries\SQLSelect;
14
use Exception;
15
16
/**
17
 * MySQL connector class.
18
 *
19
 * Supported indexes for {@link requireTable()}
20
 *
21
 * You are advised to backup your tables if changing settings on an existing database
22
 * `connection_charset` and `charset` should be equal, similarly so should `connection_collation` and `collation`
23
 */
24
class MySQLDatabase extends Database implements TransactionManager
25
{
26
    use Configurable;
27
28
    /**
29
     * Default connection charset (may be overridden in $databaseConfig)
30
     *
31
     * @config
32
     * @var String
33
     */
34
    private static $connection_charset = 'utf8';
0 ignored issues
show
introduced by
The private property $connection_charset is not used, and could be removed.
Loading history...
35
36
    /**
37
     * Default connection collation
38
     *
39
     * @config
40
     * @var string
41
     */
42
    private static $connection_collation = 'utf8_general_ci';
0 ignored issues
show
introduced by
The private property $connection_collation is not used, and could be removed.
Loading history...
43
44
    /**
45
     * Default charset
46
     *
47
     * @config
48
     * @var string
49
     */
50
    private static $charset = 'utf8';
0 ignored issues
show
introduced by
The private property $charset is not used, and could be removed.
Loading history...
51
52
    /**
53
     * Cache for getTransactionManager()
54
     *
55
     * @var TransactionManager
56
     */
57
    private $transactionManager = null;
58
59
    /**
60
     * Default collation
61
     *
62
     * @config
63
     * @var string
64
     */
65
    private static $collation = 'utf8_general_ci';
0 ignored issues
show
introduced by
The private property $collation is not used, and could be removed.
Loading history...
66
67
    public function connect($parameters)
68
    {
69
        // Ensure that driver is available (required by PDO)
70
        if (empty($parameters['driver'])) {
71
            $parameters['driver'] = $this->getDatabaseServer();
72
        }
73
74
        // Set charset
75
        if (empty($parameters['charset']) && ($charset = static::config()->get('connection_charset'))) {
76
            $parameters['charset'] = $charset;
77
        }
78
79
        // Set collation
80
        if (empty($parameters['collation']) && ($collation = static::config()->get('connection_collation'))) {
81
            $parameters['collation'] = $collation;
82
        }
83
84
        // Notify connector of parameters
85
        $this->connector->connect($parameters);
86
87
        // This is important!
88
        $this->setSQLMode('ANSI');
89
90
        if (isset($parameters['timezone'])) {
91
            $this->selectTimezone($parameters['timezone']);
92
        }
93
94
        // SS_Database subclass maintains responsibility for selecting database
95
        // once connected in order to correctly handle schema queries about
96
        // existence of database, error handling at the correct level, etc
97
        if (!empty($parameters['database'])) {
98
            $this->selectDatabase($parameters['database'], false, false);
99
        }
100
    }
101
102
    /**
103
     * Sets the SQL mode
104
     *
105
     * @param string $mode Connection mode
106
     */
107
    public function setSQLMode($mode)
108
    {
109
        if (empty($mode)) {
110
            return;
111
        }
112
        $this->preparedQuery("SET sql_mode = ?", array($mode));
113
    }
114
115
    /**
116
     * Sets the system timezone for the database connection
117
     *
118
     * @param string $timezone
119
     */
120
    public function selectTimezone($timezone)
121
    {
122
        if (empty($timezone)) {
123
            return;
124
        }
125
        $this->preparedQuery("SET SESSION time_zone = ?", array($timezone));
126
    }
127
128
    public function supportsCollations()
129
    {
130
        return true;
131
    }
132
133
    public function supportsTimezoneOverride()
134
    {
135
        return true;
136
    }
137
138
    public function getDatabaseServer()
139
    {
140
        return "mysql";
141
    }
142
143
    /**
144
     * The core search engine, used by this class and its subclasses to do fun stuff.
145
     * Searches both SiteTree and File.
146
     *
147
     * @param array $classesToSearch
148
     * @param string $keywords Keywords as a string.
149
     * @param int $start
150
     * @param int $pageLength
151
     * @param string $sortBy
152
     * @param string $extraFilter
153
     * @param bool $booleanSearch
154
     * @param string $alternativeFileFilter
155
     * @param bool $invertedMatch
156
     * @return PaginatedList
157
     * @throws Exception
158
     */
159
    public function searchEngine(
160
        $classesToSearch,
161
        $keywords,
162
        $start,
163
        $pageLength,
164
        $sortBy = "Relevance DESC",
165
        $extraFilter = "",
166
        $booleanSearch = false,
167
        $alternativeFileFilter = "",
168
        $invertedMatch = false
169
    ) {
170
        $pageClass = SiteTree::class;
171
        $fileClass = File::class;
172
        if (!class_exists($pageClass)) {
173
            throw new Exception('MySQLDatabase->searchEngine() requires "SiteTree" class');
174
        }
175
        if (!class_exists($fileClass)) {
176
            throw new Exception('MySQLDatabase->searchEngine() requires "File" class');
177
        }
178
179
        $keywords = $this->escapeString($keywords);
180
        $htmlEntityKeywords = htmlentities($keywords, ENT_NOQUOTES, 'UTF-8');
181
182
        $extraFilters = array($pageClass => '', $fileClass => '');
183
184
        $boolean = '';
185
        if ($booleanSearch) {
186
            $boolean = "IN BOOLEAN MODE";
187
        }
188
189
        if ($extraFilter) {
190
            $extraFilters[$pageClass] = " AND $extraFilter";
191
192
            if ($alternativeFileFilter) {
193
                $extraFilters[$fileClass] = " AND $alternativeFileFilter";
194
            } else {
195
                $extraFilters[$fileClass] = $extraFilters[$pageClass];
196
            }
197
        }
198
199
        // Always ensure that only pages with ShowInSearch = 1 can be searched
200
        $extraFilters[$pageClass] .= " AND ShowInSearch <> 0";
201
202
        // File.ShowInSearch was added later, keep the database driver backwards compatible
203
        // by checking for its existence first
204
        $fileTable = DataObject::getSchema()->tableName($fileClass);
205
        $fields = $this->getSchemaManager()->fieldList($fileTable);
206
        if (array_key_exists('ShowInSearch', $fields)) {
207
            $extraFilters[$fileClass] .= " AND ShowInSearch <> 0";
208
        }
209
210
        $limit = (int)$start . ", " . (int)$pageLength;
211
212
        $notMatch = $invertedMatch
213
                ? "NOT "
214
                : "";
215
        if ($keywords) {
216
            $match[$pageClass] = "
0 ignored issues
show
Comprehensibility Best Practice introduced by
$match was never initialized. Although not strictly required by PHP, it is generally a good practice to add $match = array(); before regardless.
Loading history...
217
				MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$keywords' $boolean)
218
				+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityKeywords' $boolean)
219
			";
220
            $fileClassSQL = Convert::raw2sql($fileClass);
221
            $match[$fileClass] = "MATCH (Name, Title) AGAINST ('$keywords' $boolean) AND ClassName = '$fileClassSQL'";
222
223
            // We make the relevance search by converting a boolean mode search into a normal one
224
            $relevanceKeywords = str_replace(array('*', '+', '-'), '', $keywords);
225
            $htmlEntityRelevanceKeywords = str_replace(array('*', '+', '-'), '', $htmlEntityKeywords);
226
            $relevance[$pageClass] = "MATCH (Title, MenuTitle, Content, MetaDescription) "
0 ignored issues
show
Comprehensibility Best Practice introduced by
$relevance was never initialized. Although not strictly required by PHP, it is generally a good practice to add $relevance = array(); before regardless.
Loading history...
227
                    . "AGAINST ('$relevanceKeywords') "
228
                    . "+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityRelevanceKeywords')";
229
            $relevance[$fileClass] = "MATCH (Name, Title) AGAINST ('$relevanceKeywords')";
230
        } else {
231
            $relevance[$pageClass] = $relevance[$fileClass] = 1;
232
            $match[$pageClass] = $match[$fileClass] = "1 = 1";
233
        }
234
235
        // Generate initial DataLists and base table names
236
        $lists = array();
237
        $sqlTables = array($pageClass => '', $fileClass => '');
238
        foreach ($classesToSearch as $class) {
239
            $lists[$class] = DataList::create($class)->where($notMatch . $match[$class] . $extraFilters[$class]);
240
            $sqlTables[$class] = '"' . DataObject::getSchema()->tableName($class) . '"';
241
        }
242
243
        $charset = static::config()->get('charset');
244
245
        // Make column selection lists
246
        $select = array(
247
            $pageClass => array(
248
                "ClassName", "{$sqlTables[$pageClass]}.\"ID\"", "ParentID",
249
                "Title", "MenuTitle", "URLSegment", "Content",
250
                "LastEdited", "Created",
251
                "Name" => "_{$charset}''",
252
                "Relevance" => $relevance[$pageClass], "CanViewType"
253
            ),
254
            $fileClass => array(
255
                "ClassName", "{$sqlTables[$fileClass]}.\"ID\"", "ParentID",
256
                "Title", "MenuTitle" => "_{$charset}''", "URLSegment" => "_{$charset}''", "Content" => "_{$charset}''",
257
                "LastEdited", "Created",
258
                "Name",
259
                "Relevance" => $relevance[$fileClass], "CanViewType" => "NULL"
260
            ),
261
        );
262
263
        // Process and combine queries
264
        $querySQLs = array();
265
        $queryParameters = array();
266
        $totalCount = 0;
267
        foreach ($lists as $class => $list) {
268
            /** @var SQLSelect $query */
269
            $query = $list->dataQuery()->query();
270
271
            // There's no need to do all that joining
272
            $query->setFrom($sqlTables[$class]);
273
            $query->setSelect($select[$class]);
274
            $query->setOrderBy(array());
275
276
            $querySQLs[] = $query->sql($parameters);
277
            $queryParameters = array_merge($queryParameters, $parameters);
278
279
            $totalCount += $query->unlimitedRowCount();
280
        }
281
        $fullQuery = implode(" UNION ", $querySQLs) . " ORDER BY $sortBy LIMIT $limit";
282
283
        // Get records
284
        $records = $this->preparedQuery($fullQuery, $queryParameters);
285
286
        $objects = array();
287
288
        foreach ($records as $record) {
289
            $objects[] = new $record['ClassName']($record);
290
        }
291
292
        $list = new PaginatedList(new ArrayList($objects));
293
        $list->setPageStart($start);
294
        $list->setPageLength($pageLength);
295
        $list->setTotalItems($totalCount);
296
297
        // The list has already been limited by the query above
298
        $list->setLimitItems(false);
299
300
        return $list;
301
    }
302
303
304
    /**
305
     * Returns the TransactionManager to handle transactions for this database.
306
     *
307
     * @return TransactionManager
308
     */
309
    protected function getTransactionManager()
310
    {
311
        if (!$this->transactionManager) {
312
            // PDOConnector providers this
313
            if ($this->connector instanceof TransactionManager) {
314
                $this->transactionManager = new NestedTransactionManager($this->connector);
315
            // Direct database access does not
316
            } else {
317
                $this->transactionManager = new NestedTransactionManager(new MySQLTransactionManager($this));
318
            }
319
        }
320
        return $this->transactionManager;
321
    }
322
    public function supportsTransactions()
323
    {
324
        return true;
325
    }
326
    public function supportsSavepoints()
327
    {
328
        return $this->getTransactionManager()->supportsSavepoints();
329
    }
330
331
    public function transactionStart($transactionMode = false, $sessionCharacteristics = false)
332
    {
333
        $this->getTransactionManager()->transactionStart($transactionMode, $sessionCharacteristics);
334
    }
335
336
    public function transactionSavepoint($savepoint)
337
    {
338
        $this->getTransactionManager()->transactionSavepoint($savepoint);
339
    }
340
341
    public function transactionRollback($savepoint = false)
342
    {
343
        return $this->getTransactionManager()->transactionRollback($savepoint);
344
    }
345
346
    public function transactionDepth()
347
    {
348
        return $this->getTransactionManager()->transactionDepth();
349
    }
350
351
    public function transactionEnd($chain = false)
352
    {
353
        $result = $this->getTransactionManager()->transactionEnd();
354
355
        if ($chain) {
356
            Deprecation::notice('4.4', '$chain argument is deprecated');
0 ignored issues
show
Bug introduced by
The type SilverStripe\ORM\Connect\Deprecation was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
357
            return $this->getTransactionManager()->transactionStart();
358
        }
359
360
        return $result;
361
    }
362
363
    /**
364
     * In error condition, set transactionNesting to zero
365
     */
366
    protected function resetTransactionNesting()
367
    {
368
        // Check whether to use a connector's built-in transaction methods
369
        if ($this->connector instanceof TransactionalDBConnector) {
0 ignored issues
show
Bug introduced by
The type SilverStripe\ORM\Connect\TransactionalDBConnector was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
370
            if ($this->transactionNesting > 0) {
371
                $this->connector->transactionRollback();
0 ignored issues
show
Bug introduced by
The method transactionRollback() does not exist on SilverStripe\ORM\Connect\DBConnector. It seems like you code against a sub-type of SilverStripe\ORM\Connect\DBConnector such as SilverStripe\ORM\Connect\PDOConnector. ( Ignorable by Annotation )

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

371
                $this->connector->/** @scrutinizer ignore-call */ 
372
                                  transactionRollback();
Loading history...
372
            }
373
        }
374
        $this->transactionNesting = 0;
0 ignored issues
show
Bug Best Practice introduced by
The property transactionNesting does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
375
    }
376
377
    public function query($sql, $errorLevel = E_USER_ERROR)
378
    {
379
        $this->inspectQuery($sql);
380
        return parent::query($sql, $errorLevel);
381
    }
382
383
    public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR)
384
    {
385
        $this->inspectQuery($sql);
386
        return parent::preparedQuery($sql, $parameters, $errorLevel);
387
    }
388
389
    /**
390
     * Inspect a SQL query prior to execution
391
     *
392
     * @param string $sql
393
     */
394
    protected function inspectQuery($sql)
395
    {
396
        // Any DDL discards transactions.
397
        // See https://dev.mysql.com/doc/internals/en/transactions-notes-on-ddl-and-normal-transaction.html
398
        // on why we need to be over-eager
399
        $isDDL = $this->getConnector()->isQueryDDL($sql);
400
        if ($isDDL) {
401
            $this->resetTransactionNesting();
402
        }
403
    }
404
405
    public function comparisonClause(
406
        $field,
407
        $value,
408
        $exact = false,
409
        $negate = false,
410
        $caseSensitive = null,
411
        $parameterised = false
412
    ) {
413
        if ($exact && $caseSensitive === null) {
414
            $comp = ($negate) ? '!=' : '=';
415
        } else {
416
            $comp = ($caseSensitive) ? 'LIKE BINARY' : 'LIKE';
417
            if ($negate) {
418
                $comp = 'NOT ' . $comp;
419
            }
420
        }
421
422
        if ($parameterised) {
423
            return sprintf("%s %s ?", $field, $comp);
424
        } else {
425
            return sprintf("%s %s '%s'", $field, $comp, $value);
426
        }
427
    }
428
429
    public function formattedDatetimeClause($date, $format)
430
    {
431
        preg_match_all('/%(.)/', $format, $matches);
432
        foreach ($matches[1] as $match) {
433
            if (array_search($match, array('Y', 'm', 'd', 'H', 'i', 's', 'U')) === false) {
434
                user_error('formattedDatetimeClause(): unsupported format character %' . $match, E_USER_WARNING);
435
            }
436
        }
437
438
        if (preg_match('/^now$/i', $date)) {
439
            $date = "NOW()";
440
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) {
441
            $date = "'$date'";
442
        }
443
444
        if ($format == '%U') {
445
            return "UNIX_TIMESTAMP($date)";
446
        }
447
448
        return "DATE_FORMAT($date, '$format')";
449
    }
450
451
    public function datetimeIntervalClause($date, $interval)
452
    {
453
        $interval = preg_replace('/(year|month|day|hour|minute|second)s/i', '$1', $interval);
454
455
        if (preg_match('/^now$/i', $date)) {
456
            $date = "NOW()";
457
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) {
458
            $date = "'$date'";
459
        }
460
461
        return "$date + INTERVAL $interval";
462
    }
463
464
    public function datetimeDifferenceClause($date1, $date2)
465
    {
466
        // First date format
467
        if (preg_match('/^now$/i', $date1)) {
468
            $date1 = "NOW()";
469
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date1)) {
470
            $date1 = "'$date1'";
471
        }
472
        // Second date format
473
        if (preg_match('/^now$/i', $date2)) {
474
            $date2 = "NOW()";
475
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date2)) {
476
            $date2 = "'$date2'";
477
        }
478
479
        return "UNIX_TIMESTAMP($date1) - UNIX_TIMESTAMP($date2)";
480
    }
481
482
    public function supportsLocks()
483
    {
484
        return true;
485
    }
486
487
    public function canLock($name)
488
    {
489
        $id = $this->getLockIdentifier($name);
490
        return (bool) $this->query(sprintf("SELECT IS_FREE_LOCK('%s')", $id))->value();
491
    }
492
493
    public function getLock($name, $timeout = 5)
494
    {
495
        $id = $this->getLockIdentifier($name);
496
497
        // MySQL 5.7.4 and below auto-releases existing locks on subsequent GET_LOCK() calls.
498
        // MySQL 5.7.5 and newer allow multiple locks per sessions even with the same name.
499
        // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
500
        return (bool) $this->query(sprintf("SELECT GET_LOCK('%s', %d)", $id, $timeout))->value();
501
    }
502
503
    public function releaseLock($name)
504
    {
505
        $id = $this->getLockIdentifier($name);
506
        return (bool) $this->query(sprintf("SELECT RELEASE_LOCK('%s')", $id))->value();
507
    }
508
509
    protected function getLockIdentifier($name)
510
    {
511
        // Prefix with database name
512
        $dbName = $this->connector->getSelectedDatabase() ;
513
        return $this->escapeString("{$dbName}_{$name}");
514
    }
515
516
    public function now()
517
    {
518
        // MySQL uses NOW() to return the current date/time.
519
        return 'NOW()';
520
    }
521
522
    public function random()
523
    {
524
        return 'RAND()';
525
    }
526
527
    /**
528
     * Clear all data in a given table
529
     *
530
     * @param string $table Name of table
531
     */
532
    public function clearTable($table)
533
    {
534
        $this->query("DELETE FROM \"$table\"");
535
536
        // Check if resetting the auto-increment is needed
537
        $autoIncrement = $this->preparedQuery(
538
            'SELECT "AUTO_INCREMENT" FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?',
539
            [ $this->getSelectedDatabase(), $table]
540
        )->value();
541
542
        if ($autoIncrement > 1) {
543
            $this->query("ALTER TABLE \"$table\" AUTO_INCREMENT = 1");
544
        }
545
    }
546
}
547