Completed
Push — 4.2.0 ( f03d5e...24bd0a )
by Daniel
12:22 queued 05:26
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\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