Completed
Push — 4.2.0 ( f03d5e...24bd0a )
by Daniel
12:22 queued 05:26
created

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

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

344
            $this->transactionRollback('NESTEDTRANSACTION' . /** @scrutinizer ignore-type */ $this->transactionNesting);
Loading history...
345
        } else {
346
            $this->query('ROLLBACK');
347
        }
348
        return true;
349
    }
350
351
    public function transactionDepth()
352
    {
353
        return $this->transactionNesting;
354
    }
355
356
    public function transactionEnd($chain = false)
357
    {
358
        // Fail if transaction isn't available
359
        if (!$this->transactionNesting) {
360
            return false;
361
        }
362
        --$this->transactionNesting;
363
        if ($this->transactionNesting <= 0) {
364
            $this->transactionNesting = 0;
0 ignored issues
show
Documentation Bug introduced by
The property $transactionNesting was declared of type boolean, but 0 is of type integer. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
365
            $this->query('COMMIT AND ' . ($chain ? '' : 'NO ') . 'CHAIN');
366
        }
367
        return true;
368
    }
369
370
    /**
371
     * In error condition, set transactionNesting to zero
372
     */
373
    protected function resetTransactionNesting()
374
    {
375
        $this->transactionNesting = 0;
0 ignored issues
show
Documentation Bug introduced by
The property $transactionNesting was declared of type boolean, but 0 is of type integer. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
376
    }
377
378
    public function query($sql, $errorLevel = E_USER_ERROR)
379
    {
380
        $this->inspectQuery($sql);
381
        return parent::query($sql, $errorLevel);
382
    }
383
384
    public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR)
385
    {
386
        $this->inspectQuery($sql);
387
        return parent::preparedQuery($sql, $parameters, $errorLevel);
388
    }
389
390
    /**
391
     * Inspect a SQL query prior to execution
392
     *
393
     * @param string $sql
394
     */
395
    protected function inspectQuery($sql)
396
    {
397
        // Any DDL discards transactions.
398
        // See https://dev.mysql.com/doc/internals/en/transactions-notes-on-ddl-and-normal-transaction.html
399
        // on why we need to be over-eager
400
        $isDDL = $this->getConnector()->isQueryDDL($sql);
401
        if ($isDDL) {
402
            $this->resetTransactionNesting();
403
        }
404
    }
405
406
    public function comparisonClause(
407
        $field,
408
        $value,
409
        $exact = false,
410
        $negate = false,
411
        $caseSensitive = null,
412
        $parameterised = false
413
    ) {
414
        if ($exact && $caseSensitive === null) {
415
            $comp = ($negate) ? '!=' : '=';
416
        } else {
417
            $comp = ($caseSensitive) ? 'LIKE BINARY' : 'LIKE';
418
            if ($negate) {
419
                $comp = 'NOT ' . $comp;
420
            }
421
        }
422
423
        if ($parameterised) {
424
            return sprintf("%s %s ?", $field, $comp);
425
        } else {
426
            return sprintf("%s %s '%s'", $field, $comp, $value);
427
        }
428
    }
429
430
    public function formattedDatetimeClause($date, $format)
431
    {
432
        preg_match_all('/%(.)/', $format, $matches);
433
        foreach ($matches[1] as $match) {
434
            if (array_search($match, array('Y', 'm', 'd', 'H', 'i', 's', 'U')) === false) {
435
                user_error('formattedDatetimeClause(): unsupported format character %' . $match, E_USER_WARNING);
436
            }
437
        }
438
439
        if (preg_match('/^now$/i', $date)) {
440
            $date = "NOW()";
441
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) {
442
            $date = "'$date'";
443
        }
444
445
        if ($format == '%U') {
446
            return "UNIX_TIMESTAMP($date)";
447
        }
448
449
        return "DATE_FORMAT($date, '$format')";
450
    }
451
452
    public function datetimeIntervalClause($date, $interval)
453
    {
454
        $interval = preg_replace('/(year|month|day|hour|minute|second)s/i', '$1', $interval);
455
456
        if (preg_match('/^now$/i', $date)) {
457
            $date = "NOW()";
458
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) {
459
            $date = "'$date'";
460
        }
461
462
        return "$date + INTERVAL $interval";
463
    }
464
465
    public function datetimeDifferenceClause($date1, $date2)
466
    {
467
        // First date format
468
        if (preg_match('/^now$/i', $date1)) {
469
            $date1 = "NOW()";
470
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date1)) {
471
            $date1 = "'$date1'";
472
        }
473
        // Second date format
474
        if (preg_match('/^now$/i', $date2)) {
475
            $date2 = "NOW()";
476
        } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date2)) {
477
            $date2 = "'$date2'";
478
        }
479
480
        return "UNIX_TIMESTAMP($date1) - UNIX_TIMESTAMP($date2)";
481
    }
482
483
    public function supportsLocks()
484
    {
485
        return true;
486
    }
487
488
    public function canLock($name)
489
    {
490
        $id = $this->getLockIdentifier($name);
491
        return (bool) $this->query(sprintf("SELECT IS_FREE_LOCK('%s')", $id))->value();
492
    }
493
494
    public function getLock($name, $timeout = 5)
495
    {
496
        $id = $this->getLockIdentifier($name);
497
498
        // MySQL auto-releases existing locks on subsequent GET_LOCK() calls,
499
        // in contrast to PostgreSQL and SQL Server who stack the locks.
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