Passed
Push — phpstan ( c4fbd2...07dbe1 )
by Sam
08:10
created

MySQLDatabase::connect()   B

Complexity

Conditions 8
Paths 32

Size

Total Lines 32
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 12
nc 32
nop 1
dl 0
loc 32
rs 8.4444
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\Dev\Deprecation;
10
use SilverStripe\ORM\PaginatedList;
11
use SilverStripe\ORM\DataList;
12
use SilverStripe\ORM\ArrayList;
13
use SilverStripe\ORM\DataObject;
14
use SilverStripe\ORM\Queries\SQLSelect;
15
use Exception;
16
17
/**
18
 * MySQL connector class.
19
 *
20
 * Supported indexes for {@link requireTable()}
21
 *
22
 * You are advised to backup your tables if changing settings on an existing database
23
 * `connection_charset` and `charset` should be equal, similarly so should `connection_collation` and `collation`
24
 */
25
class MySQLDatabase extends Database implements TransactionManager
26
{
27
    use Configurable;
28
29
    /**
30
     * Default connection charset (may be overridden in $databaseConfig)
31
     *
32
     * @config
33
     * @var String
34
     */
35
    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...
36
37
    /**
38
     * Default connection collation
39
     *
40
     * @config
41
     * @var string
42
     */
43
    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...
44
45
    /**
46
     * Default charset
47
     *
48
     * @config
49
     * @var string
50
     */
51
    private static $charset = 'utf8';
0 ignored issues
show
introduced by
The private property $charset is not used, and could be removed.
Loading history...
52
53
    /**
54
     * Cache for getTransactionManager()
55
     *
56
     * @var TransactionManager
57
     */
58
    private $transactionManager = null;
59
60
    /**
61
     * Default collation
62
     *
63
     * @config
64
     * @var string
65
     */
66
    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...
67
68
    public function connect($parameters)
69
    {
70
        // Ensure that driver is available (required by PDO)
71
        if (empty($parameters['driver'])) {
72
            $parameters['driver'] = $this->getDatabaseServer();
73
        }
74
75
        // Set charset
76
        if (empty($parameters['charset']) && ($charset = static::config()->get('connection_charset'))) {
77
            $parameters['charset'] = $charset;
78
        }
79
80
        // Set collation
81
        if (empty($parameters['collation']) && ($collation = static::config()->get('connection_collation'))) {
82
            $parameters['collation'] = $collation;
83
        }
84
85
        // Notify connector of parameters
86
        $this->connector->connect($parameters);
87
88
        // This is important!
89
        $this->setSQLMode('ANSI');
90
91
        if (isset($parameters['timezone'])) {
92
            $this->selectTimezone($parameters['timezone']);
93
        }
94
95
        // SS_Database subclass maintains responsibility for selecting database
96
        // once connected in order to correctly handle schema queries about
97
        // existence of database, error handling at the correct level, etc
98
        if (!empty($parameters['database'])) {
99
            $this->selectDatabase($parameters['database'], false, false);
100
        }
101
    }
102
103
    /**
104
     * Sets the SQL mode
105
     *
106
     * @param string $mode Connection mode
107
     */
108
    public function setSQLMode($mode)
109
    {
110
        if (empty($mode)) {
111
            return;
112
        }
113
        $this->preparedQuery("SET sql_mode = ?", array($mode));
114
    }
115
116
    /**
117
     * Sets the system timezone for the database connection
118
     *
119
     * @param string $timezone
120
     */
121
    public function selectTimezone($timezone)
122
    {
123
        if (empty($timezone)) {
124
            return;
125
        }
126
        $this->preparedQuery("SET SESSION time_zone = ?", array($timezone));
127
    }
128
129
    public function supportsCollations()
130
    {
131
        return true;
132
    }
133
134
    public function supportsTimezoneOverride()
135
    {
136
        return true;
137
    }
138
139
    public function getDatabaseServer()
140
    {
141
        return "mysql";
142
    }
143
144
    /**
145
     * The core search engine, used by this class and its subclasses to do fun stuff.
146
     * Searches both SiteTree and File.
147
     *
148
     * @param array $classesToSearch
149
     * @param string $keywords Keywords as a string.
150
     * @param int $start
151
     * @param int $pageLength
152
     * @param string $sortBy
153
     * @param string $extraFilter
154
     * @param bool $booleanSearch
155
     * @param string $alternativeFileFilter
156
     * @param bool $invertedMatch
157
     * @return PaginatedList
158
     * @throws Exception
159
     */
160
    public function searchEngine(
161
        $classesToSearch,
162
        $keywords,
163
        $start,
164
        $pageLength,
165
        $sortBy = "Relevance DESC",
166
        $extraFilter = "",
167
        $booleanSearch = false,
168
        $alternativeFileFilter = "",
169
        $invertedMatch = false
170
    ) {
171
        $pageClass = SiteTree::class;
172
        $fileClass = File::class;
173
        if (!class_exists($pageClass)) {
174
            throw new Exception('MySQLDatabase->searchEngine() requires "SiteTree" class');
175
        }
176
        if (!class_exists($fileClass)) {
177
            throw new Exception('MySQLDatabase->searchEngine() requires "File" class');
178
        }
179
180
        $keywords = $this->escapeString($keywords);
181
        $htmlEntityKeywords = htmlentities($keywords, ENT_NOQUOTES, 'UTF-8');
182
183
        $extraFilters = array($pageClass => '', $fileClass => '');
184
185
        $boolean = '';
186
        if ($booleanSearch) {
187
            $boolean = "IN BOOLEAN MODE";
188
        }
189
190
        if ($extraFilter) {
191
            $extraFilters[$pageClass] = " AND $extraFilter";
192
193
            if ($alternativeFileFilter) {
194
                $extraFilters[$fileClass] = " AND $alternativeFileFilter";
195
            } else {
196
                $extraFilters[$fileClass] = $extraFilters[$pageClass];
197
            }
198
        }
199
200
        // Always ensure that only pages with ShowInSearch = 1 can be searched
201
        $extraFilters[$pageClass] .= " AND ShowInSearch <> 0";
202
203
        // File.ShowInSearch was added later, keep the database driver backwards compatible
204
        // by checking for its existence first
205
        $fileTable = DataObject::getSchema()->tableName($fileClass);
206
        $fields = $this->getSchemaManager()->fieldList($fileTable);
207
        if (array_key_exists('ShowInSearch', $fields)) {
208
            $extraFilters[$fileClass] .= " AND ShowInSearch <> 0";
209
        }
210
211
        $limit = (int)$start . ", " . (int)$pageLength;
212
213
        $notMatch = $invertedMatch
214
                ? "NOT "
215
                : "";
216
        if ($keywords) {
217
            $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...
218
				MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$keywords' $boolean)
219
				+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityKeywords' $boolean)
220
			";
221
            $fileClassSQL = Convert::raw2sql($fileClass);
222
            $match[$fileClass] = "MATCH (Name, Title) AGAINST ('$keywords' $boolean) AND ClassName = '$fileClassSQL'";
223
224
            // We make the relevance search by converting a boolean mode search into a normal one
225
            $relevanceKeywords = str_replace(array('*', '+', '-'), '', $keywords);
226
            $htmlEntityRelevanceKeywords = str_replace(array('*', '+', '-'), '', $htmlEntityKeywords);
227
            $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...
228
                    . "AGAINST ('$relevanceKeywords') "
229
                    . "+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityRelevanceKeywords')";
230
            $relevance[$fileClass] = "MATCH (Name, Title) AGAINST ('$relevanceKeywords')";
231
        } else {
232
            $relevance[$pageClass] = $relevance[$fileClass] = 1;
233
            $match[$pageClass] = $match[$fileClass] = "1 = 1";
234
        }
235
236
        // Generate initial DataLists and base table names
237
        $lists = array();
238
        $sqlTables = array($pageClass => '', $fileClass => '');
239
        foreach ($classesToSearch as $class) {
240
            $lists[$class] = DataList::create($class)->where($notMatch . $match[$class] . $extraFilters[$class]);
241
            $sqlTables[$class] = '"' . DataObject::getSchema()->tableName($class) . '"';
242
        }
243
244
        $charset = static::config()->get('charset');
245
246
        // Make column selection lists
247
        $select = array(
248
            $pageClass => array(
249
                "ClassName", "{$sqlTables[$pageClass]}.\"ID\"", "ParentID",
250
                "Title", "MenuTitle", "URLSegment", "Content",
251
                "LastEdited", "Created",
252
                "Name" => "_{$charset}''",
253
                "Relevance" => $relevance[$pageClass], "CanViewType"
254
            ),
255
            $fileClass => array(
256
                "ClassName", "{$sqlTables[$fileClass]}.\"ID\"", "ParentID",
257
                "Title", "MenuTitle" => "_{$charset}''", "URLSegment" => "_{$charset}''", "Content" => "_{$charset}''",
258
                "LastEdited", "Created",
259
                "Name",
260
                "Relevance" => $relevance[$fileClass], "CanViewType" => "NULL"
261
            ),
262
        );
263
264
        // Process and combine queries
265
        $querySQLs = array();
266
        $queryParameters = array();
267
        $totalCount = 0;
268
        foreach ($lists as $class => $list) {
269
            /** @var SQLSelect $query */
270
            $query = $list->dataQuery()->query();
271
272
            // There's no need to do all that joining
273
            $query->setFrom($sqlTables[$class]);
274
            $query->setSelect($select[$class]);
275
            $query->setOrderBy(array());
276
277
            $querySQLs[] = $query->sql($parameters);
278
            $queryParameters = array_merge($queryParameters, $parameters);
279
280
            $totalCount += $query->unlimitedRowCount();
281
        }
282
        $fullQuery = implode(" UNION ", $querySQLs) . " ORDER BY $sortBy LIMIT $limit";
283
284
        // Get records
285
        $records = $this->preparedQuery($fullQuery, $queryParameters);
286
287
        $objects = array();
288
289
        foreach ($records as $record) {
290
            $objects[] = new $record['ClassName']($record);
291
        }
292
293
        $list = new PaginatedList(new ArrayList($objects));
294
        $list->setPageStart($start);
295
        $list->setPageLength($pageLength);
296
        $list->setTotalItems($totalCount);
297
298
        // The list has already been limited by the query above
299
        $list->setLimitItems(false);
300
301
        return $list;
302
    }
303
304
305
    /**
306
     * Returns the TransactionManager to handle transactions for this database.
307
     *
308
     * @return TransactionManager
309
     */
310
    protected function getTransactionManager()
311
    {
312
        if (!$this->transactionManager) {
313
            // PDOConnector providers this
314
            if ($this->connector instanceof TransactionManager) {
315
                $this->transactionManager = new NestedTransactionManager($this->connector);
316
            // Direct database access does not
317
            } else {
318
                $this->transactionManager = new NestedTransactionManager(new MySQLTransactionManager($this));
319
            }
320
        }
321
        return $this->transactionManager;
322
    }
323
    public function supportsTransactions()
324
    {
325
        return true;
326
    }
327
    public function supportsSavepoints()
328
    {
329
        return $this->getTransactionManager()->supportsSavepoints();
330
    }
331
332
    public function transactionStart($transactionMode = false, $sessionCharacteristics = false)
333
    {
334
        $this->getTransactionManager()->transactionStart($transactionMode, $sessionCharacteristics);
335
    }
336
337
    public function transactionSavepoint($savepoint)
338
    {
339
        $this->getTransactionManager()->transactionSavepoint($savepoint);
340
    }
341
342
    public function transactionRollback($savepoint = false)
343
    {
344
        return $this->getTransactionManager()->transactionRollback($savepoint);
345
    }
346
347
    public function transactionDepth()
348
    {
349
        return $this->getTransactionManager()->transactionDepth();
350
    }
351
352
    public function transactionEnd($chain = false)
353
    {
354
        $result = $this->getTransactionManager()->transactionEnd();
355
356
        if ($chain) {
357
            Deprecation::notice('4.4', '$chain argument is deprecated');
358
            return $this->getTransactionManager()->transactionStart();
359
        }
360
361
        return $result;
362
    }
363
364
    public function query($sql, $errorLevel = E_USER_ERROR)
365
    {
366
        $this->inspectQuery($sql);
367
        return parent::query($sql, $errorLevel);
368
    }
369
370
    public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR)
371
    {
372
        $this->inspectQuery($sql);
373
        return parent::preparedQuery($sql, $parameters, $errorLevel);
374
    }
375
376
    /**
377
     * Inspect a SQL query prior to execution
378
     *
379
     * @param string $sql
380
     */
381
    protected function inspectQuery($sql)
382
    {
383
        // Any DDL discards transactions.
384
        // See https://dev.mysql.com/doc/internals/en/transactions-notes-on-ddl-and-normal-transaction.html
385
        // on why we need to be over-eager
386
        $isDDL = $this->getConnector()->isQueryDDL($sql);
387
        if ($isDDL) {
388
            $tm = $this->getTransactionManager();
389
            if ($tm instanceof NestedTransactionManager) {
390
                $tm->resetTransactionNesting();
391
            }
392
        }
393
    }
394
395
    public function comparisonClause(
396
        $field,
397
        $value,
398
        $exact = false,
399
        $negate = false,
400
        $caseSensitive = null,
401
        $parameterised = false
402
    ) {
403
        if ($exact && $caseSensitive === null) {
404
            $comp = ($negate) ? '!=' : '=';
405
        } else {
406
            $comp = ($caseSensitive) ? 'LIKE BINARY' : 'LIKE';
407
            if ($negate) {
408
                $comp = 'NOT ' . $comp;
409
            }
410
        }
411
412
        if ($parameterised) {
413
            return sprintf("%s %s ?", $field, $comp);
414
        } else {
415
            return sprintf("%s %s '%s'", $field, $comp, $value);
416
        }
417
    }
418
419
    public function formattedDatetimeClause($date, $format)
420
    {
421
        preg_match_all('/%(.)/', $format, $matches);
422
        foreach ($matches[1] as $match) {
423
            if (array_search($match, array('Y', 'm', 'd', 'H', 'i', 's', 'U')) === false) {
424
                user_error('formattedDatetimeClause(): unsupported format character %' . $match, E_USER_WARNING);
425
            }
426
        }
427
428
        if (preg_match('/^now$/i', $date)) {
429
            $date = "NOW()";
430
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) {
431
            $date = "'$date'";
432
        }
433
434
        if ($format == '%U') {
435
            return "UNIX_TIMESTAMP($date)";
436
        }
437
438
        return "DATE_FORMAT($date, '$format')";
439
    }
440
441
    public function datetimeIntervalClause($date, $interval)
442
    {
443
        $interval = preg_replace('/(year|month|day|hour|minute|second)s/i', '$1', $interval);
444
445
        if (preg_match('/^now$/i', $date)) {
446
            $date = "NOW()";
447
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) {
448
            $date = "'$date'";
449
        }
450
451
        return "$date + INTERVAL $interval";
452
    }
453
454
    public function datetimeDifferenceClause($date1, $date2)
455
    {
456
        // First date format
457
        if (preg_match('/^now$/i', $date1)) {
458
            $date1 = "NOW()";
459
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date1)) {
460
            $date1 = "'$date1'";
461
        }
462
        // Second date format
463
        if (preg_match('/^now$/i', $date2)) {
464
            $date2 = "NOW()";
465
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date2)) {
466
            $date2 = "'$date2'";
467
        }
468
469
        return "UNIX_TIMESTAMP($date1) - UNIX_TIMESTAMP($date2)";
470
    }
471
472
    public function supportsLocks()
473
    {
474
        return true;
475
    }
476
477
    public function canLock($name)
478
    {
479
        $id = $this->getLockIdentifier($name);
480
        return (bool) $this->query(sprintf("SELECT IS_FREE_LOCK('%s')", $id))->value();
481
    }
482
483
    public function getLock($name, $timeout = 5)
484
    {
485
        $id = $this->getLockIdentifier($name);
486
487
        // MySQL 5.7.4 and below auto-releases existing locks on subsequent GET_LOCK() calls.
488
        // MySQL 5.7.5 and newer allow multiple locks per sessions even with the same name.
489
        // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
490
        return (bool) $this->query(sprintf("SELECT GET_LOCK('%s', %d)", $id, $timeout))->value();
491
    }
492
493
    public function releaseLock($name)
494
    {
495
        $id = $this->getLockIdentifier($name);
496
        return (bool) $this->query(sprintf("SELECT RELEASE_LOCK('%s')", $id))->value();
497
    }
498
499
    protected function getLockIdentifier($name)
500
    {
501
        // Prefix with database name
502
        $dbName = $this->connector->getSelectedDatabase() ;
503
        return $this->escapeString("{$dbName}_{$name}");
504
    }
505
506
    public function now()
507
    {
508
        // MySQL uses NOW() to return the current date/time.
509
        return 'NOW()';
510
    }
511
512
    public function random()
513
    {
514
        return 'RAND()';
515
    }
516
517
    /**
518
     * Clear all data in a given table
519
     *
520
     * @param string $table Name of table
521
     */
522
    public function clearTable($table)
523
    {
524
        $this->query("DELETE FROM \"$table\"");
525
526
        // Check if resetting the auto-increment is needed
527
        $autoIncrement = $this->preparedQuery(
528
            'SELECT "AUTO_INCREMENT" FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?',
529
            [ $this->getSelectedDatabase(), $table]
530
        )->value();
531
532
        if ($autoIncrement > 1) {
533
            $this->query("ALTER TABLE \"$table\" AUTO_INCREMENT = 1");
534
        }
535
    }
536
}
537