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

MySQLDatabase::searchEngine()   F

Complexity

Conditions 12
Paths 386

Size

Total Lines 142
Code Lines 83

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 83
nc 386
nop 9
dl 0
loc 142
rs 3.4175
c 0
b 0
f 0

How to fix   Long Method    Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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