Passed
Push — int-types ( e9891a...c34c73 )
by Sam
06:45
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
     * @var TransactionManager
55
     */
56
    private $transactionManager = null;
57
58
    /**
59
     * Default collation
60
     *
61
     * @config
62
     * @var string
63
     */
64
    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...
65
66
    public function connect($parameters)
67
    {
68
        // Ensure that driver is available (required by PDO)
69
        if (empty($parameters['driver'])) {
70
            $parameters['driver'] = $this->getDatabaseServer();
71
        }
72
73
        // Set charset
74
        if (empty($parameters['charset']) && ($charset = static::config()->get('connection_charset'))) {
75
            $parameters['charset'] = $charset;
76
        }
77
78
        // Set collation
79
        if (empty($parameters['collation']) && ($collation = static::config()->get('connection_collation'))) {
80
            $parameters['collation'] = $collation;
81
        }
82
83
        // Notify connector of parameters
84
        $this->connector->connect($parameters);
85
86
        // This is important!
87
        $this->setSQLMode('ANSI');
88
89
        if (isset($parameters['timezone'])) {
90
            $this->selectTimezone($parameters['timezone']);
91
        }
92
93
        // SS_Database subclass maintains responsibility for selecting database
94
        // once connected in order to correctly handle schema queries about
95
        // existence of database, error handling at the correct level, etc
96
        if (!empty($parameters['database'])) {
97
            $this->selectDatabase($parameters['database'], false, false);
98
        }
99
    }
100
101
    /**
102
     * Sets the SQL mode
103
     *
104
     * @param string $mode Connection mode
105
     */
106
    public function setSQLMode($mode)
107
    {
108
        if (empty($mode)) {
109
            return;
110
        }
111
        $this->preparedQuery("SET sql_mode = ?", array($mode));
112
    }
113
114
    /**
115
     * Sets the system timezone for the database connection
116
     *
117
     * @param string $timezone
118
     */
119
    public function selectTimezone($timezone)
120
    {
121
        if (empty($timezone)) {
122
            return;
123
        }
124
        $this->preparedQuery("SET SESSION time_zone = ?", array($timezone));
125
    }
126
127
    public function supportsCollations()
128
    {
129
        return true;
130
    }
131
132
    public function supportsTimezoneOverride()
133
    {
134
        return true;
135
    }
136
137
    public function getDatabaseServer()
138
    {
139
        return "mysql";
140
    }
141
142
    /**
143
     * The core search engine, used by this class and its subclasses to do fun stuff.
144
     * Searches both SiteTree and File.
145
     *
146
     * @param array $classesToSearch
147
     * @param string $keywords Keywords as a string.
148
     * @param int $start
149
     * @param int $pageLength
150
     * @param string $sortBy
151
     * @param string $extraFilter
152
     * @param bool $booleanSearch
153
     * @param string $alternativeFileFilter
154
     * @param bool $invertedMatch
155
     * @return PaginatedList
156
     * @throws Exception
157
     */
158
    public function searchEngine(
159
        $classesToSearch,
160
        $keywords,
161
        $start,
162
        $pageLength,
163
        $sortBy = "Relevance DESC",
164
        $extraFilter = "",
165
        $booleanSearch = false,
166
        $alternativeFileFilter = "",
167
        $invertedMatch = false
168
    ) {
169
        $pageClass = SiteTree::class;
170
        $fileClass = File::class;
171
        if (!class_exists($pageClass)) {
172
            throw new Exception('MySQLDatabase->searchEngine() requires "SiteTree" class');
173
        }
174
        if (!class_exists($fileClass)) {
175
            throw new Exception('MySQLDatabase->searchEngine() requires "File" class');
176
        }
177
178
        $keywords = $this->escapeString($keywords);
179
        $htmlEntityKeywords = htmlentities($keywords, ENT_NOQUOTES, 'UTF-8');
180
181
        $extraFilters = array($pageClass => '', $fileClass => '');
182
183
        $boolean = '';
184
        if ($booleanSearch) {
185
            $boolean = "IN BOOLEAN MODE";
186
        }
187
188
        if ($extraFilter) {
189
            $extraFilters[$pageClass] = " AND $extraFilter";
190
191
            if ($alternativeFileFilter) {
192
                $extraFilters[$fileClass] = " AND $alternativeFileFilter";
193
            } else {
194
                $extraFilters[$fileClass] = $extraFilters[$pageClass];
195
            }
196
        }
197
198
        // Always ensure that only pages with ShowInSearch = 1 can be searched
199
        $extraFilters[$pageClass] .= " AND ShowInSearch <> 0";
200
201
        // File.ShowInSearch was added later, keep the database driver backwards compatible
202
        // by checking for its existence first
203
        $fileTable = DataObject::getSchema()->tableName($fileClass);
204
        $fields = $this->getSchemaManager()->fieldList($fileTable);
205
        if (array_key_exists('ShowInSearch', $fields)) {
206
            $extraFilters[$fileClass] .= " AND ShowInSearch <> 0";
207
        }
208
209
        $limit = (int)$start . ", " . (int)$pageLength;
210
211
        $notMatch = $invertedMatch
212
                ? "NOT "
213
                : "";
214
        if ($keywords) {
215
            $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...
216
				MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$keywords' $boolean)
217
				+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityKeywords' $boolean)
218
			";
219
            $fileClassSQL = Convert::raw2sql($fileClass);
220
            $match[$fileClass] = "MATCH (Name, Title) AGAINST ('$keywords' $boolean) AND ClassName = '$fileClassSQL'";
221
222
            // We make the relevance search by converting a boolean mode search into a normal one
223
            $relevanceKeywords = str_replace(array('*', '+', '-'), '', $keywords);
224
            $htmlEntityRelevanceKeywords = str_replace(array('*', '+', '-'), '', $htmlEntityKeywords);
225
            $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...
226
                    . "AGAINST ('$relevanceKeywords') "
227
                    . "+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityRelevanceKeywords')";
228
            $relevance[$fileClass] = "MATCH (Name, Title) AGAINST ('$relevanceKeywords')";
229
        } else {
230
            $relevance[$pageClass] = $relevance[$fileClass] = 1;
231
            $match[$pageClass] = $match[$fileClass] = "1 = 1";
232
        }
233
234
        // Generate initial DataLists and base table names
235
        $lists = array();
236
        $sqlTables = array($pageClass => '', $fileClass => '');
237
        foreach ($classesToSearch as $class) {
238
            $lists[$class] = DataList::create($class)->where($notMatch . $match[$class] . $extraFilters[$class]);
239
            $sqlTables[$class] = '"' . DataObject::getSchema()->tableName($class) . '"';
240
        }
241
242
        $charset = static::config()->get('charset');
243
244
        // Make column selection lists
245
        $select = array(
246
            $pageClass => array(
247
                "ClassName", "{$sqlTables[$pageClass]}.\"ID\"", "ParentID",
248
                "Title", "MenuTitle", "URLSegment", "Content",
249
                "LastEdited", "Created",
250
                "Name" => "_{$charset}''",
251
                "Relevance" => $relevance[$pageClass], "CanViewType"
252
            ),
253
            $fileClass => array(
254
                "ClassName", "{$sqlTables[$fileClass]}.\"ID\"", "ParentID",
255
                "Title", "MenuTitle" => "_{$charset}''", "URLSegment" => "_{$charset}''", "Content" => "_{$charset}''",
256
                "LastEdited", "Created",
257
                "Name",
258
                "Relevance" => $relevance[$fileClass], "CanViewType" => "NULL"
259
            ),
260
        );
261
262
        // Process and combine queries
263
        $querySQLs = array();
264
        $queryParameters = array();
265
        $totalCount = 0;
266
        foreach ($lists as $class => $list) {
267
            /** @var SQLSelect $query */
268
            $query = $list->dataQuery()->query();
269
270
            // There's no need to do all that joining
271
            $query->setFrom($sqlTables[$class]);
272
            $query->setSelect($select[$class]);
273
            $query->setOrderBy(array());
274
275
            $querySQLs[] = $query->sql($parameters);
276
            $queryParameters = array_merge($queryParameters, $parameters);
277
278
            $totalCount += $query->unlimitedRowCount();
279
        }
280
        $fullQuery = implode(" UNION ", $querySQLs) . " ORDER BY $sortBy LIMIT $limit";
281
282
        // Get records
283
        $records = $this->preparedQuery($fullQuery, $queryParameters);
284
285
        $objects = array();
286
287
        foreach ($records as $record) {
288
            $objects[] = new $record['ClassName']($record);
289
        }
290
291
        $list = new PaginatedList(new ArrayList($objects));
292
        $list->setPageStart($start);
293
        $list->setPageLength($pageLength);
294
        $list->setTotalItems($totalCount);
295
296
        // The list has already been limited by the query above
297
        $list->setLimitItems(false);
298
299
        return $list;
300
    }
301
302
303
    /**
304
     * Returns the TransactionManager to handle transactions for this database.
305
     *
306
     * @return TransactionManager
307
     */
308
    protected function getTransactionManager()
309
    {
310
        if (!$this->transactionManager) {
311
            // PDOConnector providers this
312
            if ($this->connector instanceof TransactionManager) {
313
                $this->transactionManager = new NestedTransactionManager($this->connector);
314
            // Direct database access does not
315
            } else {
316
                $this->transactionManager = new NestedTransactionManager(new MySQLTransactionManager($this));
317
            }
318
        }
319
        return $this->transactionManager;
320
    }
321
    public function supportsTransactions()
322
    {
323
        return true;
324
    }
325
    public function supportsSavepoints()
326
    {
327
        return $this->getTransactionManager()->supportsSavepoints();
328
    }
329
330
    public function transactionStart($transactionMode = false, $sessionCharacteristics = false)
331
    {
332
        $this->getTransactionManager()->transactionStart($transactionMode, $sessionCharacteristics);
333
    }
334
335
    public function transactionSavepoint($savepoint)
336
    {
337
        $this->getTransactionManager()->transactionSavepoint($savepoint);
338
    }
339
340
    public function transactionRollback($savepoint = false)
341
    {
342
        return $this->getTransactionManager()->transactionRollback($savepoint);
343
    }
344
345
    public function transactionDepth()
346
    {
347
        return $this->getTransactionManager()->transactionDepth();
348
    }
349
350
    public function transactionEnd($chain = false)
351
    {
352
        $result = $this->getTransactionManager()->transactionEnd();
353
354
        if ($chain) {
355
            Deprecation::notice('4.3', '$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...
356
            return $this->getTransactionManager()->transactionStart();
357
        }
358
359
        return $result;
360
    }
361
362
    /**
363
     * In error condition, set transactionNesting to zero
364
     */
365
    protected function resetTransactionNesting()
366
    {
367
        // Check whether to use a connector's built-in transaction methods
368
        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...
369
            if ($this->transactionNesting > 0) {
370
                $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

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