Passed
Push — 4 ( 6678f9...487beb )
by Loz
08:59 queued 11s
created

DataQuery::ensureSelectContainsOrderbyColumns()   C

Complexity

Conditions 12
Paths 12

Size

Total Lines 64
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 32
nc 12
nop 2
dl 0
loc 64
rs 6.9666
c 0
b 0
f 0

How to fix   Long Method    Complexity   

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:

1
<?php
2
3
namespace SilverStripe\ORM;
4
5
use SilverStripe\Core\ClassInfo;
6
use SilverStripe\Core\Convert;
7
use SilverStripe\Core\Injector\Injector;
8
use SilverStripe\ORM\Connect\Query;
9
use SilverStripe\ORM\Queries\SQLConditionGroup;
10
use SilverStripe\ORM\Queries\SQLSelect;
11
use InvalidArgumentException;
12
13
/**
14
 * An object representing a query of data from the DataObject's supporting database.
15
 * Acts as a wrapper over {@link SQLSelect} and performs all of the query generation.
16
 * Used extensively by {@link DataList}.
17
 *
18
 * Unlike DataList, modifiers on DataQuery modify the object rather than returning a clone.
19
 * DataList is immutable, DataQuery is mutable.
20
 */
21
class DataQuery
22
{
23
24
    /**
25
     * @var string
26
     */
27
    protected $dataClass;
28
29
    /**
30
     * @var SQLSelect
31
     */
32
    protected $query;
33
34
    /**
35
     * Map of all field names to an array of conflicting column SQL
36
     *
37
     * E.g.
38
     * array(
39
     *   'Title' => array(
40
     *     '"MyTable"."Title"',
41
     *     '"AnotherTable"."Title"',
42
     *   )
43
     * )
44
     *
45
     * @var array
46
     */
47
    protected $collidingFields = array();
48
49
    /**
50
     * Allows custom callback to be registered before getFinalisedQuery is called.
51
     *
52
     * @var DataQueryManipulator[]
53
     */
54
    protected $dataQueryManipulators = [];
55
56
    private $queriedColumns = null;
57
58
    /**
59
     * @var bool
60
     */
61
    private $queryFinalised = false;
0 ignored issues
show
introduced by
The private property $queryFinalised is not used, and could be removed.
Loading history...
62
63
    // TODO: replace subclass_access with this
64
    protected $querySubclasses = true;
65
    // TODO: replace restrictclasses with this
66
    protected $filterByClassName = true;
67
68
    /**
69
     * Create a new DataQuery.
70
     *
71
     * @param string $dataClass The name of the DataObject class that you wish to query
72
     */
73
    public function __construct($dataClass)
74
    {
75
        $this->dataClass = $dataClass;
76
        $this->initialiseQuery();
77
    }
78
79
    /**
80
     * Clone this object
81
     */
82
    public function __clone()
83
    {
84
        $this->query = clone $this->query;
85
    }
86
87
    /**
88
     * Return the {@link DataObject} class that is being queried.
89
     *
90
     * @return string
91
     */
92
    public function dataClass()
93
    {
94
        return $this->dataClass;
95
    }
96
97
    /**
98
     * Return the {@link SQLSelect} object that represents the current query; note that it will
99
     * be a clone of the object.
100
     *
101
     * @return SQLSelect
102
     */
103
    public function query()
104
    {
105
        return $this->getFinalisedQuery();
106
    }
107
108
109
    /**
110
     * Remove a filter from the query
111
     *
112
     * @param string|array $fieldExpression The predicate of the condition to remove
113
     * (ignoring parameters). The expression will be considered a match if it's
114
     * contained within any other predicate.
115
     * @return $this
116
     */
117
    public function removeFilterOn($fieldExpression)
118
    {
119
        $matched = false;
120
121
        // If given a parameterised condition extract only the condition
122
        if (is_array($fieldExpression)) {
123
            reset($fieldExpression);
124
            $fieldExpression = key($fieldExpression);
125
        }
126
127
        $where = $this->query->getWhere();
128
        // Iterate through each condition
129
        foreach ($where as $i => $condition) {
130
            // Rewrite condition groups as plain conditions before comparison
131
            if ($condition instanceof SQLConditionGroup) {
132
                $predicate = $condition->conditionSQL($parameters);
133
                $condition = array($predicate => $parameters);
134
            }
135
136
            // As each condition is a single length array, do a single
137
            // iteration to extract the predicate and parameters
138
            foreach ($condition as $predicate => $parameters) {
139
                // @see SQLSelect::addWhere for why this is required here
140
                if (strpos($predicate, $fieldExpression) !== false) {
141
                    unset($where[$i]);
142
                    $matched = true;
143
                }
144
                // Enforce single-item condition predicate => parameters structure
145
                break;
146
            }
147
        }
148
149
        // set the entire where clause back, but clear the original one first
150
        if ($matched) {
151
            $this->query->setWhere($where);
152
        } else {
153
            throw new InvalidArgumentException("Couldn't find $fieldExpression in the query filter.");
154
        }
155
156
        return $this;
157
    }
158
159
    /**
160
     * Set up the simplest initial query
161
     */
162
    protected function initialiseQuery()
163
    {
164
        // Join on base table and let lazy loading join subtables
165
        $baseClass = DataObject::getSchema()->baseDataClass($this->dataClass());
166
        if (!$baseClass) {
167
            throw new InvalidArgumentException("DataQuery::create() Can't find data classes for '{$this->dataClass}'");
168
        }
169
170
        // Build our intial query
171
        $this->query = new SQLSelect(array());
172
        $this->query->setDistinct(true);
173
174
        if ($sort = singleton($this->dataClass)->config()->get('default_sort')) {
175
            $this->sort($sort);
176
        }
177
178
        $baseTable = DataObject::getSchema()->tableName($baseClass);
179
        $this->query->setFrom("\"{$baseTable}\"");
180
181
        $obj = Injector::inst()->get($baseClass);
182
        $obj->extend('augmentDataQueryCreation', $this->query, $this);
183
    }
184
185
    /**
186
     * @param array $queriedColumns
187
     * @return $this
188
     */
189
    public function setQueriedColumns($queriedColumns)
190
    {
191
        $this->queriedColumns = $queriedColumns;
192
        return $this;
193
    }
194
195
    /**
196
     * Ensure that the query is ready to execute.
197
     *
198
     * @param array|null $queriedColumns Any columns to filter the query by
199
     * @return SQLSelect The finalised sql query
200
     */
201
    public function getFinalisedQuery($queriedColumns = null)
202
    {
203
        if (!$queriedColumns) {
204
            $queriedColumns = $this->queriedColumns;
205
        }
206
        if ($queriedColumns) {
207
            $queriedColumns = array_merge($queriedColumns, array('Created', 'LastEdited', 'ClassName'));
208
        }
209
        $query = clone $this->query;
210
211
        // Apply manipulators before finalising query
212
        foreach ($this->getDataQueryManipulators() as $manipulator) {
213
            $manipulator->beforeGetFinalisedQuery($this, $queriedColumns, $query);
214
        }
215
216
        $schema = DataObject::getSchema();
217
        $baseDataClass = $schema->baseDataClass($this->dataClass());
218
        $baseIDColumn = $schema->sqlColumnForField($baseDataClass, 'ID');
219
        $ancestorClasses = ClassInfo::ancestry($this->dataClass(), true);
220
221
        // Generate the list of tables to iterate over and the list of columns required
222
        // by any existing where clauses. This second step is skipped if we're fetching
223
        // the whole dataobject as any required columns will get selected regardless.
224
        if ($queriedColumns) {
225
            // Specifying certain columns allows joining of child tables
226
            $tableClasses = ClassInfo::dataClassesFor($this->dataClass);
227
228
            // Ensure that any filtered columns are included in the selected columns
229
            foreach ($query->getWhereParameterised($parameters) as $where) {
230
                // Check for any columns in the form '"Column" = ?' or '"Table"."Column"' = ?
231
                if (preg_match_all(
232
                    '/(?:"(?<table>[^"]+)"\.)?"(?<column>[^"]+)"(?:[^\.]|$)/',
233
                    $where,
234
                    $matches,
235
                    PREG_SET_ORDER
236
                )) {
237
                    foreach ($matches as $match) {
238
                        $column = $match['column'];
239
                        if (!in_array($column, $queriedColumns)) {
240
                            $queriedColumns[] = $column;
241
                        }
242
                    }
243
                }
244
            }
245
        } else {
246
            $tableClasses = $ancestorClasses;
247
        }
248
249
        // Iterate over the tables and check what we need to select from them. If any selects are made (or the table is
250
        // required for a select)
251
        foreach ($tableClasses as $tableClass) {
252
            // Determine explicit columns to select
253
            $selectColumns = null;
254
            if ($queriedColumns) {
255
                // Restrict queried columns to that on the selected table
256
                $tableFields = $schema->databaseFields($tableClass, false);
257
                unset($tableFields['ID']);
258
                $selectColumns = array_intersect($queriedColumns, array_keys($tableFields));
259
            }
260
261
            // If this is a subclass without any explicitly requested columns, omit this from the query
262
            if (!in_array($tableClass, $ancestorClasses) && empty($selectColumns)) {
263
                continue;
264
            }
265
266
            // Select necessary columns (unless an explicitly empty array)
267
            if ($selectColumns !== array()) {
268
                $this->selectColumnsFromTable($query, $tableClass, $selectColumns);
269
            }
270
271
            // Join if not the base table
272
            if ($tableClass !== $baseDataClass) {
273
                $tableName = $schema->tableName($tableClass);
274
                $query->addLeftJoin(
275
                    $tableName,
276
                    "\"{$tableName}\".\"ID\" = {$baseIDColumn}",
277
                    $tableName,
278
                    10
279
                );
280
            }
281
        }
282
283
        // Resolve colliding fields
284
        if ($this->collidingFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->collidingFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
285
            foreach ($this->collidingFields as $collisionField => $collisions) {
286
                $caseClauses = array();
287
                foreach ($collisions as $collision) {
288
                    if (preg_match('/^"(?<table>[^"]+)"\./', $collision, $matches)) {
289
                        $collisionTable = $matches['table'];
290
                        $collisionClass = $schema->tableClass($collisionTable);
291
                        if ($collisionClass) {
292
                            $collisionClassColumn = $schema->sqlColumnForField($collisionClass, 'ClassName');
293
                            $collisionClasses = ClassInfo::subclassesFor($collisionClass);
294
                            $collisionClassesSQL = implode(', ', Convert::raw2sql($collisionClasses, true));
295
                            $caseClauses[] = "WHEN {$collisionClassColumn} IN ({$collisionClassesSQL}) THEN $collision";
296
                        }
297
                    } else {
298
                        user_error("Bad collision item '$collision'", E_USER_WARNING);
299
                    }
300
                }
301
                $query->selectField("CASE " . implode(" ", $caseClauses) . " ELSE NULL END", $collisionField);
302
            }
303
        }
304
305
306
        if ($this->filterByClassName) {
307
            // If querying the base class, don't bother filtering on class name
308
            if ($this->dataClass != $baseDataClass) {
309
                // Get the ClassName values to filter to
310
                $classNames = ClassInfo::subclassesFor($this->dataClass);
311
                $classNamesPlaceholders = DB::placeholders($classNames);
312
                $baseClassColumn = $schema->sqlColumnForField($baseDataClass, 'ClassName');
313
                $query->addWhere(array(
314
                    "{$baseClassColumn} IN ($classNamesPlaceholders)" => $classNames
315
                ));
316
            }
317
        }
318
319
        // Select ID
320
        $query->selectField($baseIDColumn, "ID");
321
322
        // Select RecordClassName
323
        $baseClassColumn = $schema->sqlColumnForField($baseDataClass, 'ClassName');
324
        $query->selectField(
325
            "
326
			CASE WHEN {$baseClassColumn} IS NOT NULL THEN {$baseClassColumn}
327
			ELSE " . Convert::raw2sql($baseDataClass, true) . " END",
328
            "RecordClassName"
329
        );
330
331
        // TODO: Versioned, Translatable, SiteTreeSubsites, etc, could probably be better implemented as subclasses
332
        // of DataQuery
333
334
        $obj = Injector::inst()->get($this->dataClass);
335
        $obj->extend('augmentSQL', $query, $this);
336
337
        $this->ensureSelectContainsOrderbyColumns($query);
338
339
        // Apply post-finalisation manipulations
340
        foreach ($this->getDataQueryManipulators() as $manipulator) {
341
            $manipulator->afterGetFinalisedQuery($this, $queriedColumns, $query);
342
        }
343
344
        return $query;
345
    }
346
347
    /**
348
     * Ensure that if a query has an order by clause, those columns are present in the select.
349
     *
350
     * @param SQLSelect $query
351
     * @param array $originalSelect
352
     */
353
    protected function ensureSelectContainsOrderbyColumns($query, $originalSelect = array())
354
    {
355
        if ($orderby = $query->getOrderBy()) {
356
            $newOrderby = array();
357
            foreach ($orderby as $k => $dir) {
358
                $newOrderby[$k] = $dir;
359
360
                // don't touch functions in the ORDER BY or public function calls
361
                // selected as fields
362
                if (strpos($k, '(') !== false) {
363
                    continue;
364
                }
365
366
                $col = str_replace('"', '', trim($k));
367
                $parts = explode('.', $col);
368
369
                // Pull through SortColumn references from the originalSelect variables
370
                if (preg_match('/_SortColumn/', $col)) {
371
                    if (isset($originalSelect[$col])) {
372
                        $query->selectField($originalSelect[$col], $col);
373
                    }
374
                    continue;
375
                }
376
377
                if (count($parts) == 1) {
378
                    // Get expression for sort value
379
                    $qualCol = "\"{$parts[0]}\"";
380
                    $table = DataObject::getSchema()->tableForField($this->dataClass(), $parts[0]);
381
                    if ($table) {
382
                        $qualCol = "\"{$table}\".{$qualCol}";
383
                    }
384
385
                    // remove original sort
386
                    unset($newOrderby[$k]);
387
388
                    // add new columns sort
389
                    $newOrderby[$qualCol] = $dir;
390
391
                    // To-do: Remove this if block once SQLSelect::$select has been refactored to store getSelect()
392
                    // format internally; then this check can be part of selectField()
393
                    $selects = $query->getSelect();
394
                    if (!isset($selects[$col]) && !in_array($qualCol, $selects)) {
395
                        $query->selectField($qualCol);
396
                    }
397
                } else {
398
                    $qualCol = '"' . implode('"."', $parts) . '"';
399
400
                    if (!in_array($qualCol, $query->getSelect())) {
401
                        unset($newOrderby[$k]);
402
403
                        // Find the first free "_SortColumnX" slot
404
                        // and assign it to $key
405
                        $i = 0;
406
                        while (isset($orderby[$key = "\"_SortColumn$i\""])) {
407
                            ++$i;
408
                        }
409
410
                        $newOrderby[$key] = $dir;
411
                        $query->selectField($qualCol, "_SortColumn$i");
412
                    }
413
                }
414
            }
415
416
            $query->setOrderBy($newOrderby);
417
        }
418
    }
419
420
    /**
421
     * Execute the query and return the result as {@link SS_Query} object.
422
     *
423
     * @return Query
424
     */
425
    public function execute()
426
    {
427
        return $this->getFinalisedQuery()->execute();
428
    }
429
430
    /**
431
     * Return this query's SQL
432
     *
433
     * @param array $parameters Out variable for parameters required for this query
434
     * @return string The resulting SQL query (may be paramaterised)
435
     */
436
    public function sql(&$parameters = array())
437
    {
438
        return $this->getFinalisedQuery()->sql($parameters);
439
    }
440
441
    /**
442
     * Return the number of records in this query.
443
     * Note that this will issue a separate SELECT COUNT() query.
444
     *
445
     * @return int
446
     */
447
    public function count()
448
    {
449
        $quotedColumn = DataObject::getSchema()->sqlColumnForField($this->dataClass(), 'ID');
450
        return $this->getFinalisedQuery()->count("DISTINCT {$quotedColumn}");
451
    }
452
453
    /**
454
     * Return the maximum value of the given field in this DataList
455
     *
456
     * @param string $field Unquoted database column name. Will be ANSI quoted
457
     * automatically so must not contain double quotes.
458
     * @return string
459
     */
460
    public function max($field)
461
    {
462
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
463
        if (!$table) {
464
            return $this->aggregate("MAX(\"$field\")");
465
        }
466
        return $this->aggregate("MAX(\"$table\".\"$field\")");
467
    }
468
469
    /**
470
     * Return the minimum value of the given field in this DataList
471
     *
472
     * @param string $field Unquoted database column name. Will be ANSI quoted
473
     * automatically so must not contain double quotes.
474
     * @return string
475
     */
476
    public function min($field)
477
    {
478
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
479
        if (!$table) {
480
            return $this->aggregate("MIN(\"$field\")");
481
        }
482
        return $this->aggregate("MIN(\"$table\".\"$field\")");
483
    }
484
485
    /**
486
     * Return the average value of the given field in this DataList
487
     *
488
     * @param string $field Unquoted database column name. Will be ANSI quoted
489
     * automatically so must not contain double quotes.
490
     * @return string
491
     */
492
    public function avg($field)
493
    {
494
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
495
        if (!$table) {
496
            return $this->aggregate("AVG(\"$field\")");
497
        }
498
        return $this->aggregate("AVG(\"$table\".\"$field\")");
499
    }
500
501
    /**
502
     * Return the sum of the values of the given field in this DataList
503
     *
504
     * @param string $field Unquoted database column name. Will be ANSI quoted
505
     * automatically so must not contain double quotes.
506
     * @return string
507
     */
508
    public function sum($field)
509
    {
510
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
511
        if (!$table) {
512
            return $this->aggregate("SUM(\"$field\")");
513
        }
514
        return $this->aggregate("SUM(\"$table\".\"$field\")");
515
    }
516
517
    /**
518
     * Runs a raw aggregate expression.  Please handle escaping yourself
519
     *
520
     * @param string $expression An aggregate expression, such as 'MAX("Balance")', or a set of them
521
     * (as an escaped SQL statement)
522
     * @return string
523
     */
524
    public function aggregate($expression)
525
    {
526
        return $this->getFinalisedQuery()->aggregate($expression)->execute()->value();
527
    }
528
529
    /**
530
     * Return the first row that would be returned by this full DataQuery
531
     * Note that this will issue a separate SELECT ... LIMIT 1 query.
532
     *
533
     * @return SQLSelect
534
     */
535
    public function firstRow()
536
    {
537
        return $this->getFinalisedQuery()->firstRow();
538
    }
539
540
    /**
541
     * Return the last row that would be returned by this full DataQuery
542
     * Note that this will issue a separate SELECT ... LIMIT query.
543
     *
544
     * @return SQLSelect
545
     */
546
    public function lastRow()
547
    {
548
        return $this->getFinalisedQuery()->lastRow();
549
    }
550
551
    /**
552
     * Update the SELECT clause of the query with the columns from the given table
553
     *
554
     * @param SQLSelect $query
555
     * @param string $tableClass Class to select from
556
     * @param array $columns
557
     */
558
    protected function selectColumnsFromTable(SQLSelect &$query, $tableClass, $columns = null)
559
    {
560
        // Add SQL for multi-value fields
561
        $schema = DataObject::getSchema();
562
        $databaseFields = $schema->databaseFields($tableClass, false);
563
        $compositeFields = $schema->compositeFields($tableClass, false);
564
        unset($databaseFields['ID']);
565
        foreach ($databaseFields as $k => $v) {
566
            if ((is_null($columns) || in_array($k, $columns)) && !isset($compositeFields[$k])) {
567
                // Update $collidingFields if necessary
568
                $expressionForField = $query->expressionForField($k);
569
                $quotedField = $schema->sqlColumnForField($tableClass, $k);
570
                if ($expressionForField) {
571
                    if (!isset($this->collidingFields[$k])) {
572
                        $this->collidingFields[$k] = array($expressionForField);
573
                    }
574
                    $this->collidingFields[$k][] = $quotedField;
575
                } else {
576
                    $query->selectField($quotedField, $k);
577
                }
578
            }
579
        }
580
        foreach ($compositeFields as $k => $v) {
581
            if ((is_null($columns) || in_array($k, $columns)) && $v) {
582
                $tableName = $schema->tableName($tableClass);
583
                $dbO = Injector::inst()->create($v, $k);
584
                $dbO->setTable($tableName);
585
                $dbO->addToQuery($query);
586
            }
587
        }
588
    }
589
590
    /**
591
     * Append a GROUP BY clause to this query.
592
     *
593
     * @param string $groupby Escaped SQL statement
594
     * @return $this
595
     */
596
    public function groupby($groupby)
597
    {
598
        $this->query->addGroupBy($groupby);
599
        return $this;
600
    }
601
602
    /**
603
     * Append a HAVING clause to this query.
604
     *
605
     * @param mixed $having Predicate(s) to set, as escaped SQL statements or parameterised queries
606
     * @return $this
607
     */
608
    public function having($having)
609
    {
610
        $this->query->addHaving($having);
611
        return $this;
612
    }
613
614
    /**
615
     * Create a disjunctive subgroup.
616
     *
617
     * That is a subgroup joined by OR
618
     *
619
     * @return DataQuery_SubGroup
620
     */
621
    public function disjunctiveGroup()
622
    {
623
        return new DataQuery_SubGroup($this, 'OR');
624
    }
625
626
    /**
627
     * Create a conjunctive subgroup
628
     *
629
     * That is a subgroup joined by AND
630
     *
631
     * @return DataQuery_SubGroup
632
     */
633
    public function conjunctiveGroup()
634
    {
635
        return new DataQuery_SubGroup($this, 'AND');
636
    }
637
638
    /**
639
     * Adds a WHERE clause.
640
     *
641
     * @see SQLSelect::addWhere() for syntax examples, although DataQuery
642
     * won't expand multiple arguments as SQLSelect does.
643
     *
644
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
645
     * paramaterised queries
646
     * @return $this
647
     */
648
    public function where($filter)
649
    {
650
        if ($filter) {
651
            $this->query->addWhere($filter);
652
        }
653
        return $this;
654
    }
655
656
    /**
657
     * Append a WHERE with OR.
658
     *
659
     * @see SQLSelect::addWhere() for syntax examples, although DataQuery
660
     * won't expand multiple method arguments as SQLSelect does.
661
     *
662
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
663
     * paramaterised queries
664
     * @return $this
665
     */
666
    public function whereAny($filter)
667
    {
668
        if ($filter) {
669
            $this->query->addWhereAny($filter);
670
        }
671
        return $this;
672
    }
673
674
    /**
675
     * Set the ORDER BY clause of this query
676
     *
677
     * @see SQLSelect::orderby()
678
     *
679
     * @param string $sort Column to sort on (escaped SQL statement)
680
     * @param string $direction Direction ("ASC" or "DESC", escaped SQL statement)
681
     * @param bool $clear Clear existing values
682
     * @return $this
683
     */
684
    public function sort($sort = null, $direction = null, $clear = true)
685
    {
686
        if ($clear) {
687
            $this->query->setOrderBy($sort, $direction);
688
        } else {
689
            $this->query->addOrderBy($sort, $direction);
690
        }
691
692
        return $this;
693
    }
694
695
    /**
696
     * Reverse order by clause
697
     *
698
     * @return $this
699
     */
700
    public function reverseSort()
701
    {
702
        $this->query->reverseOrderBy();
703
        return $this;
704
    }
705
706
    /**
707
     * Set the limit of this query.
708
     *
709
     * @param int $limit
710
     * @param int $offset
711
     * @return $this
712
     */
713
    public function limit($limit, $offset = 0)
714
    {
715
        $this->query->setLimit($limit, $offset);
716
        return $this;
717
    }
718
719
    /**
720
     * Set whether this query should be distinct or not.
721
     *
722
     * @param bool $value
723
     * @return $this
724
     */
725
    public function distinct($value)
726
    {
727
        $this->query->setDistinct($value);
728
        return $this;
729
    }
730
731
    /**
732
     * Add an INNER JOIN clause to this query.
733
     *
734
     * @param string $table The unquoted table name to join to.
735
     * @param string $onClause The filter for the join (escaped SQL statement)
736
     * @param string $alias An optional alias name (unquoted)
737
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
738
     * will cause the query to appear first. The default is 20, and joins created automatically by the
739
     * ORM have a value of 10.
740
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
741
     * @return $this
742
     */
743
    public function innerJoin($table, $onClause, $alias = null, $order = 20, $parameters = array())
744
    {
745
        if ($table) {
746
            $this->query->addInnerJoin($table, $onClause, $alias, $order, $parameters);
747
        }
748
        return $this;
749
    }
750
751
    /**
752
     * Add a LEFT JOIN clause to this query.
753
     *
754
     * @param string $table The unquoted table to join to.
755
     * @param string $onClause The filter for the join (escaped SQL statement).
756
     * @param string $alias An optional alias name (unquoted)
757
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
758
     * will cause the query to appear first. The default is 20, and joins created automatically by the
759
     * ORM have a value of 10.
760
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
761
     * @return $this
762
     */
763
    public function leftJoin($table, $onClause, $alias = null, $order = 20, $parameters = array())
764
    {
765
        if ($table) {
766
            $this->query->addLeftJoin($table, $onClause, $alias, $order, $parameters);
767
        }
768
        return $this;
769
    }
770
771
    /**
772
     * Prefix of all joined table aliases. E.g. ->filter('Banner.Image.Title)'
773
     * Will join the Banner, and then Image relations
774
     * `$relationPrefx` will be `banner_image_`
775
     * Each table in the Image chain will be suffixed to this prefix. E.g.
776
     * `banner_image_File` and `banner_image_Image`
777
     *
778
     * This will be null if no relation is joined.
779
     * E.g. `->filter('Title')`
780
     *
781
     * @param string|array $relation Relation in '.' delimited string, or array of parts
782
     * @return string Table prefix
783
     */
784
    public static function applyRelationPrefix($relation)
785
    {
786
        if (!$relation) {
787
            return null;
788
        }
789
        if (is_string($relation)) {
790
            $relation = explode(".", $relation);
791
        }
792
        return strtolower(implode('_', $relation)) . '_';
793
    }
794
795
    /**
796
     * Traverse the relationship fields, and add the table
797
     * mappings to the query object state. This has to be called
798
     * in any overloaded {@link SearchFilter->apply()} methods manually.
799
     *
800
     * Note, that in order to filter against the joined relation user code must
801
     * use {@see tablePrefix()} to get the table alias used for this relation.
802
     *
803
     * @param string|array $relation The array/dot-syntax relation to follow
804
     * @param bool $linearOnly Set to true to restrict to linear relations only. Set this
805
     * if this relation will be used for sorting, and should not include duplicate rows.
806
     * @return string The model class of the related item
807
     */
808
    public function applyRelation($relation, $linearOnly = false)
809
    {
810
        // NO-OP
811
        if (!$relation) {
812
            return $this->dataClass;
813
        }
814
815
        if (is_string($relation)) {
816
            $relation = explode(".", $relation);
817
        }
818
819
        $modelClass = $this->dataClass;
820
821
        $schema = DataObject::getSchema();
822
        $currentRelation = [];
823
        foreach ($relation as $rel) {
824
            // Get prefix for join for this table (and parent to join on)
825
            $parentPrefix = $this->applyRelationPrefix($currentRelation);
826
            $currentRelation[] = $rel;
827
            $tablePrefix = $this->applyRelationPrefix($currentRelation);
828
829
            // Check has_one
830
            if ($component = $schema->hasOneComponent($modelClass, $rel)) {
831
                // Join via has_one
832
                $this->joinHasOneRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix);
833
                $modelClass = $component;
834
                continue;
835
            }
836
837
            // Check has_many
838
            if ($component = $schema->hasManyComponent($modelClass, $rel)) {
839
                // Fail on non-linear relations
840
                if ($linearOnly) {
841
                    throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
842
                }
843
                // Join via has_many
844
                $this->joinHasManyRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix, 'has_many');
845
                $modelClass = $component;
846
                continue;
847
            }
848
849
            // check belongs_to (like has_many but linear safe)
850
            if ($component = $schema->belongsToComponent($modelClass, $rel)) {
851
                // Piggy back off has_many logic
852
                $this->joinHasManyRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix, 'belongs_to');
853
                $modelClass = $component;
854
                continue;
855
            }
856
857
            // Check many_many
858
            if ($component = $schema->manyManyComponent($modelClass, $rel)) {
859
                // Fail on non-linear relations
860
                if ($linearOnly) {
861
                    throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
862
                }
863
                $this->joinManyManyRelationship(
864
                    $component['relationClass'],
865
                    $component['parentClass'],
866
                    $component['childClass'],
867
                    $component['parentField'],
868
                    $component['childField'],
869
                    $component['join'],
870
                    $parentPrefix,
871
                    $tablePrefix
872
                );
873
                $modelClass = $component['childClass'];
874
                continue;
875
            }
876
877
            // no relation
878
            throw new InvalidArgumentException("$rel is not a relation on model $modelClass");
879
        }
880
881
        return $modelClass;
882
    }
883
884
    /**
885
     * Join the given has_many relation to this query.
886
     * Also works with belongs_to
887
     *
888
     * Doesn't work with polymorphic relationships
889
     *
890
     * @param string $localClass Name of class that has the has_many to the joined class
891
     * @param string $localField Name of the has_many relationship to join
892
     * @param string $foreignClass Class to join
893
     * @param string $localPrefix Table prefix for parent class
894
     * @param string $foreignPrefix Table prefix to use
895
     * @param string $type 'has_many' or 'belongs_to'
896
     */
897
    protected function joinHasManyRelation(
898
        $localClass,
899
        $localField,
900
        $foreignClass,
901
        $localPrefix = null,
902
        $foreignPrefix = null,
903
        $type = 'has_many'
904
    ) {
905
        if (!$foreignClass || $foreignClass === DataObject::class) {
906
            throw new InvalidArgumentException("Could not find a has_many relationship {$localField} on {$localClass}");
907
        }
908
        $schema = DataObject::getSchema();
909
910
        // Skip if already joined
911
        // Note: don't just check base class, since we need to join on the table with the actual relation key
912
        $foreignTable = $schema->tableName($foreignClass);
913
        $foreignTableAliased = $foreignPrefix . $foreignTable;
914
        if ($this->query->isJoinedTo($foreignTableAliased)) {
915
            return;
916
        }
917
918
        // Join table with associated has_one
919
        /** @var DataObject $model */
920
        $foreignKey = $schema->getRemoteJoinField($localClass, $localField, $type, $polymorphic);
921
        $localIDColumn = $schema->sqlColumnForField($localClass, 'ID', $localPrefix);
922
        if ($polymorphic) {
923
            $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}ID", $foreignPrefix);
924
            $foreignKeyClassColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}Class", $foreignPrefix);
925
            $localClassColumn = $schema->sqlColumnForField($localClass, 'ClassName', $localPrefix);
926
            $joinExpression =
927
                "{$foreignKeyIDColumn} = {$localIDColumn} AND {$foreignKeyClassColumn} = {$localClassColumn}";
928
        } else {
929
            $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, $foreignKey, $foreignPrefix);
930
            $joinExpression = "{$foreignKeyIDColumn} = {$localIDColumn}";
931
        }
932
        $this->query->addLeftJoin(
933
            $foreignTable,
934
            $joinExpression,
935
            $foreignTableAliased
936
        );
937
938
        // Add join clause to the component's ancestry classes so that the search filter could search on
939
        // its ancestor fields.
940
        $ancestry = ClassInfo::ancestry($foreignClass, true);
941
        $ancestry = array_reverse($ancestry);
942
        foreach ($ancestry as $ancestor) {
943
            $ancestorTable = $schema->tableName($ancestor);
944
            if ($ancestorTable !== $foreignTable) {
945
                $ancestorTableAliased = $foreignPrefix . $ancestorTable;
946
                $this->query->addLeftJoin(
947
                    $ancestorTable,
948
                    "\"{$foreignTableAliased}\".\"ID\" = \"{$ancestorTableAliased}\".\"ID\"",
949
                    $ancestorTableAliased
950
                );
951
            }
952
        }
953
    }
954
955
    /**
956
     * Join the given class to this query with the given key
957
     *
958
     * @param string $localClass Name of class that has the has_one to the joined class
959
     * @param string $localField Name of the has_one relationship to joi
960
     * @param string $foreignClass Class to join
961
     * @param string $localPrefix Table prefix to use for local class
962
     * @param string $foreignPrefix Table prefix to use for joined table
963
     */
964
    protected function joinHasOneRelation(
965
        $localClass,
966
        $localField,
967
        $foreignClass,
968
        $localPrefix = null,
969
        $foreignPrefix = null
970
    ) {
971
        if (!$foreignClass) {
972
            throw new InvalidArgumentException("Could not find a has_one relationship {$localField} on {$localClass}");
973
        }
974
975
        if ($foreignClass === DataObject::class) {
976
            throw new InvalidArgumentException(
977
                "Could not join polymorphic has_one relationship {$localField} on {$localClass}"
978
            );
979
        }
980
        $schema = DataObject::getSchema();
981
982
        // Skip if already joined
983
        $foreignBaseClass = $schema->baseDataClass($foreignClass);
984
        $foreignBaseTable = $schema->tableName($foreignBaseClass);
985
        if ($this->query->isJoinedTo($foreignPrefix . $foreignBaseTable)) {
986
            return;
987
        }
988
989
        // Join base table
990
        $foreignIDColumn = $schema->sqlColumnForField($foreignBaseClass, 'ID', $foreignPrefix);
991
        $localColumn = $schema->sqlColumnForField($localClass, "{$localField}ID", $localPrefix);
992
        $this->query->addLeftJoin(
993
            $foreignBaseTable,
994
            "{$foreignIDColumn} = {$localColumn}",
995
            $foreignPrefix . $foreignBaseTable
996
        );
997
998
        // Add join clause to the component's ancestry classes so that the search filter could search on
999
        // its ancestor fields.
1000
        $ancestry = ClassInfo::ancestry($foreignClass, true);
1001
        if (!empty($ancestry)) {
1002
            $ancestry = array_reverse($ancestry);
1003
            foreach ($ancestry as $ancestor) {
1004
                $ancestorTable = $schema->tableName($ancestor);
1005
                if ($ancestorTable !== $foreignBaseTable) {
1006
                    $ancestorTableAliased = $foreignPrefix . $ancestorTable;
1007
                    $this->query->addLeftJoin(
1008
                        $ancestorTable,
1009
                        "{$foreignIDColumn} = \"{$ancestorTableAliased}\".\"ID\"",
1010
                        $ancestorTableAliased
1011
                    );
1012
                }
1013
            }
1014
        }
1015
    }
1016
1017
    /**
1018
     * Join table via many_many relationship
1019
     *
1020
     * @param string $relationClass
1021
     * @param string $parentClass
1022
     * @param string $componentClass
1023
     * @param string $parentField
1024
     * @param string $componentField
1025
     * @param string $relationClassOrTable Name of relation table
1026
     * @param string $parentPrefix Table prefix for parent class
1027
     * @param string $componentPrefix Table prefix to use for both joined and mapping table
1028
     */
1029
    protected function joinManyManyRelationship(
1030
        $relationClass,
0 ignored issues
show
Unused Code introduced by
The parameter $relationClass is not used and could be removed. ( Ignorable by Annotation )

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

1030
        /** @scrutinizer ignore-unused */ $relationClass,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
1031
        $parentClass,
1032
        $componentClass,
1033
        $parentField,
1034
        $componentField,
1035
        $relationClassOrTable,
1036
        $parentPrefix = null,
1037
        $componentPrefix = null
1038
    ) {
1039
        $schema = DataObject::getSchema();
1040
1041
        if (class_exists($relationClassOrTable)) {
1042
            $relationClassOrTable = $schema->tableName($relationClassOrTable);
1043
        }
1044
1045
        // Check if already joined to component alias (skip join table for the check)
1046
        $componentBaseClass = $schema->baseDataClass($componentClass);
1047
        $componentBaseTable = $schema->tableName($componentBaseClass);
1048
        $componentAliasedTable = $componentPrefix . $componentBaseTable;
1049
        if ($this->query->isJoinedTo($componentAliasedTable)) {
1050
            return;
1051
        }
1052
1053
        // Join parent class to join table
1054
        $relationAliasedTable = $componentPrefix . $relationClassOrTable;
1055
        $parentIDColumn = $schema->sqlColumnForField($parentClass, 'ID', $parentPrefix);
1056
        $this->query->addLeftJoin(
1057
            $relationClassOrTable,
1058
            "\"{$relationAliasedTable}\".\"{$parentField}\" = {$parentIDColumn}",
1059
            $relationAliasedTable
1060
        );
1061
1062
        // Join on base table of component class
1063
        $componentIDColumn = $schema->sqlColumnForField($componentBaseClass, 'ID', $componentPrefix);
1064
            $this->query->addLeftJoin(
1065
                $componentBaseTable,
1066
                "\"{$relationAliasedTable}\".\"{$componentField}\" = {$componentIDColumn}",
1067
                $componentAliasedTable
1068
            );
1069
1070
        // Add join clause to the component's ancestry classes so that the search filter could search on
1071
        // its ancestor fields.
1072
        $ancestry = ClassInfo::ancestry($componentClass, true);
1073
        $ancestry = array_reverse($ancestry);
1074
        foreach ($ancestry as $ancestor) {
1075
            $ancestorTable = $schema->tableName($ancestor);
1076
            if ($ancestorTable !== $componentBaseTable) {
1077
                $ancestorTableAliased = $componentPrefix . $ancestorTable;
1078
                $this->query->addLeftJoin(
1079
                    $ancestorTable,
1080
                    "{$componentIDColumn} = \"{$ancestorTableAliased}\".\"ID\"",
1081
                    $ancestorTableAliased
1082
                );
1083
            }
1084
        }
1085
    }
1086
1087
    /**
1088
     * Removes the result of query from this query.
1089
     *
1090
     * @param DataQuery $subtractQuery
1091
     * @param string $field
1092
     * @return $this
1093
     */
1094
    public function subtract(DataQuery $subtractQuery, $field = 'ID')
1095
    {
1096
        $fieldExpression = $subtractQuery->expressionForField($field);
1097
        $subSelect = $subtractQuery->getFinalisedQuery();
1098
        $subSelect->setSelect(array());
1099
        $subSelect->selectField($fieldExpression, $field);
1100
        $subSelect->setOrderBy(null);
1101
        $subSelectSQL = $subSelect->sql($subSelectParameters);
1102
        $this->where(array($this->expressionForField($field) . " NOT IN ($subSelectSQL)" => $subSelectParameters));
1103
1104
        return $this;
1105
    }
1106
1107
    /**
1108
     * Select the only given fields from the given table.
1109
     *
1110
     * @param string $table Unquoted table name (will be escaped automatically)
1111
     * @param array $fields Database column names (will be escaped automatically)
1112
     * @return $this
1113
     */
1114
    public function selectFromTable($table, $fields)
1115
    {
1116
        $fieldExpressions = array_map(function ($item) use ($table) {
1117
            return Convert::symbol2sql("{$table}.{$item}");
1118
        }, $fields);
1119
1120
        $this->query->setSelect($fieldExpressions);
1121
1122
        return $this;
1123
    }
1124
1125
    /**
1126
     * Add the given fields from the given table to the select statement.
1127
     *
1128
     * @param string $table Unquoted table name (will be escaped automatically)
1129
     * @param array $fields Database column names (will be escaped automatically)
1130
     * @return $this
1131
     */
1132
    public function addSelectFromTable($table, $fields)
1133
    {
1134
        $fieldExpressions = array_map(function ($item) use ($table) {
1135
            return Convert::symbol2sql("{$table}.{$item}");
1136
        }, $fields);
1137
1138
        $this->query->addSelect($fieldExpressions);
1139
1140
        return $this;
1141
    }
1142
1143
    /**
1144
     * Query the given field column from the database and return as an array.
1145
     *
1146
     * @param string $field See {@link expressionForField()}.
1147
     * @return array List of column values for the specified column
1148
     */
1149
    public function column($field = 'ID')
1150
    {
1151
        $fieldExpression = $this->expressionForField($field);
1152
        $query = $this->getFinalisedQuery(array($field));
1153
        $originalSelect = $query->getSelect();
1154
        $query->setSelect(array());
1155
        $query->selectField($fieldExpression, $field);
1156
        $this->ensureSelectContainsOrderbyColumns($query, $originalSelect);
1157
1158
        return $query->execute()->column($field);
1159
    }
1160
1161
    /**
1162
     * @param string $field Select statement identifier, either the unquoted column name,
1163
     * the full composite SQL statement, or the alias set through {@link SQLSelect->selectField()}.
1164
     * @return string The expression used to query this field via this DataQuery
1165
     */
1166
    protected function expressionForField($field)
1167
    {
1168
        // Prepare query object for selecting this field
1169
        $query = $this->getFinalisedQuery(array($field));
1170
1171
        // Allow query to define the expression for this field
1172
        $expression = $query->expressionForField($field);
1173
        if (!empty($expression)) {
1174
            return $expression;
1175
        }
1176
1177
        // Special case for ID, if not provided
1178
        if ($field === 'ID') {
1179
            return DataObject::getSchema()->sqlColumnForField($this->dataClass, 'ID');
1180
        }
1181
        return null;
1182
    }
1183
1184
    /**
1185
     * Select the given field expressions.
1186
     *
1187
     * @param string $fieldExpression String The field to select (escaped SQL statement)
1188
     * @param string $alias String The alias of that field (escaped SQL statement)
1189
     */
1190
    public function selectField($fieldExpression, $alias = null)
1191
    {
1192
        $this->query->selectField($fieldExpression, $alias);
1193
    }
1194
1195
    //// QUERY PARAMS
1196
1197
    /**
1198
     * An arbitrary store of query parameters that can be used by decorators.
1199
     */
1200
    private $queryParams;
1201
1202
    /**
1203
     * Set an arbitrary query parameter, that can be used by decorators to add additional meta-data to the query.
1204
     * It's expected that the $key will be namespaced, e.g, 'Versioned.stage' instead of just 'stage'.
1205
     *
1206
     * @param string $key
1207
     * @param string|array $value
1208
     * @return $this
1209
     */
1210
    public function setQueryParam($key, $value)
1211
    {
1212
        $this->queryParams[$key] = $value;
1213
        return $this;
1214
    }
1215
1216
    /**
1217
     * Set an arbitrary query parameter, that can be used by decorators to add additional meta-data to the query.
1218
     *
1219
     * @param string $key
1220
     * @return string
1221
     */
1222
    public function getQueryParam($key)
1223
    {
1224
        if (isset($this->queryParams[$key])) {
1225
            return $this->queryParams[$key];
1226
        }
1227
        return null;
1228
    }
1229
1230
    /**
1231
     * Returns all query parameters
1232
     * @return array query parameters array
1233
     */
1234
    public function getQueryParams()
1235
    {
1236
        return $this->queryParams;
1237
    }
1238
1239
    /**
1240
     * Get query manipulators
1241
     *
1242
     * @return DataQueryManipulator[]
1243
     */
1244
    public function getDataQueryManipulators()
1245
    {
1246
        return $this->dataQueryManipulators;
1247
    }
1248
1249
    /**
1250
     * Assign callback to be invoked in getFinalisedQuery()
1251
     *
1252
     * @param DataQueryManipulator $manipulator
1253
     * @return $this
1254
     */
1255
    public function pushQueryManipulator(DataQueryManipulator $manipulator)
1256
    {
1257
        $this->dataQueryManipulators[] = $manipulator;
1258
        return $this;
1259
    }
1260
}
1261