Passed
Push — fix-1683 ( 00f5cf )
by Sam
08:14
created

DataQuery::subtract()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 8
nc 1
nop 2
dl 0
loc 11
rs 10
c 0
b 0
f 0
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
            $i = 0;
358
            foreach ($orderby as $k => $dir) {
359
                $newOrderby[$k] = $dir;
360
361
                // don't touch functions in the ORDER BY or public function calls
362
                // selected as fields
363
                if (strpos($k, '(') !== false) {
364
                    continue;
365
                }
366
367
                $col = str_replace('"', '', trim($k));
368
                $parts = explode('.', $col);
369
370
                // Pull through SortColumn references from the originalSelect variables
371
                if (preg_match('/_SortColumn/', $col)) {
372
                    if (isset($originalSelect[$col])) {
373
                        $query->selectField($originalSelect[$col], $col);
374
                    }
375
376
                    continue;
377
                }
378
379
                if (count($parts) == 1) {
380
                    // Get expression for sort value
381
                    $qualCol = "\"{$parts[0]}\"";
382
                    $table = DataObject::getSchema()->tableForField($this->dataClass(), $parts[0]);
383
                    if ($table) {
384
                        $qualCol = "\"{$table}\".{$qualCol}";
385
                    }
386
387
                    // remove original sort
388
                    unset($newOrderby[$k]);
389
390
                    // add new columns sort
391
                    $newOrderby[$qualCol] = $dir;
392
393
                    // To-do: Remove this if block once SQLSelect::$select has been refactored to store getSelect()
394
                    // format internally; then this check can be part of selectField()
395
                    $selects = $query->getSelect();
396
                    if (!isset($selects[$col]) && !in_array($qualCol, $selects)) {
397
                        $query->selectField($qualCol);
398
                    }
399
                } else {
400
                    $qualCol = '"' . implode('"."', $parts) . '"';
401
402
                    if (!in_array($qualCol, $query->getSelect())) {
403
                        unset($newOrderby[$k]);
404
405
                        $newOrderby["\"_SortColumn$i\""] = $dir;
406
                        $query->selectField($qualCol, "_SortColumn$i");
407
408
                        $i++;
409
                    }
410
                }
411
            }
412
413
            $query->setOrderBy($newOrderby);
414
        }
415
    }
416
417
    /**
418
     * Execute the query and return the result as {@link SS_Query} object.
419
     *
420
     * @return Query
421
     */
422
    public function execute()
423
    {
424
        return $this->getFinalisedQuery()->execute();
425
    }
426
427
    /**
428
     * Return this query's SQL
429
     *
430
     * @param array $parameters Out variable for parameters required for this query
431
     * @return string The resulting SQL query (may be paramaterised)
432
     */
433
    public function sql(&$parameters = array())
434
    {
435
        return $this->getFinalisedQuery()->sql($parameters);
436
    }
437
438
    /**
439
     * Return the number of records in this query.
440
     * Note that this will issue a separate SELECT COUNT() query.
441
     *
442
     * @return int
443
     */
444
    public function count()
445
    {
446
        $quotedColumn = DataObject::getSchema()->sqlColumnForField($this->dataClass(), 'ID');
447
        return $this->getFinalisedQuery()->count("DISTINCT {$quotedColumn}");
448
    }
449
450
    /**
451
     * Return the maximum value of the given field in this DataList
452
     *
453
     * @param string $field Unquoted database column name. Will be ANSI quoted
454
     * automatically so must not contain double quotes.
455
     * @return string
456
     */
457
    public function max($field)
458
    {
459
        $quotedField = $this->joinSubclassTableForField($field);
460
        return $this->aggregate("MAX($quotedField)");
461
    }
462
463
    /**
464
     * Return the minimum value of the given field in this DataList
465
     *
466
     * @param string $field Unquoted database column name. Will be ANSI quoted
467
     * automatically so must not contain double quotes.
468
     * @return string
469
     */
470
    public function min($field)
471
    {
472
        $quotedField = $this->joinSubclassTableForField($field);
473
        return $this->aggregate("MIN($quotedField)");
474
    }
475
476
    /**
477
     * Return the average value of the given field in this DataList
478
     *
479
     * @param string $field Unquoted database column name. Will be ANSI quoted
480
     * automatically so must not contain double quotes.
481
     * @return string
482
     */
483
    public function avg($field)
484
    {
485
        $quotedField = $this->joinSubclassTableForField($field);
486
        return $this->aggregate("AVG($quotedField)");
487
    }
488
489
    /**
490
     * Return the sum of the values of the given field in this DataList
491
     *
492
     * @param string $field Unquoted database column name. Will be ANSI quoted
493
     * automatically so must not contain double quotes.
494
     * @return string
495
     */
496
    public function sum($field)
497
    {
498
        $quotedField = $this->joinSubclassTableForField($field);
499
        return $this->aggregate("SUM($quotedField)");
500
    }
501
502
    /**
503
     * Runs a raw aggregate expression.  Please handle escaping yourself
504
     *
505
     * @param string $expression An aggregate expression, such as 'MAX("Balance")', or a set of them
506
     * (as an escaped SQL statement)
507
     * @return string
508
     */
509
    public function aggregate($expression)
510
    {
511
        return $this->getFinalisedQuery()->aggregate($expression)->execute()->value();
512
    }
513
514
    /**
515
     * Return the first row that would be returned by this full DataQuery
516
     * Note that this will issue a separate SELECT ... LIMIT 1 query.
517
     *
518
     * @return SQLSelect
519
     */
520
    public function firstRow()
521
    {
522
        return $this->getFinalisedQuery()->firstRow();
523
    }
524
525
    /**
526
     * Return the last row that would be returned by this full DataQuery
527
     * Note that this will issue a separate SELECT ... LIMIT query.
528
     *
529
     * @return SQLSelect
530
     */
531
    public function lastRow()
532
    {
533
        return $this->getFinalisedQuery()->lastRow();
534
    }
535
536
    /**
537
     * Update the SELECT clause of the query with the columns from the given table
538
     *
539
     * @param SQLSelect $query
540
     * @param string $tableClass Class to select from
541
     * @param array $columns
542
     */
543
    protected function selectColumnsFromTable(SQLSelect &$query, $tableClass, $columns = null)
544
    {
545
        // Add SQL for multi-value fields
546
        $schema = DataObject::getSchema();
547
        $databaseFields = $schema->databaseFields($tableClass, false);
548
        $compositeFields = $schema->compositeFields($tableClass, false);
549
        unset($databaseFields['ID']);
550
        foreach ($databaseFields as $k => $v) {
551
            if ((is_null($columns) || in_array($k, $columns)) && !isset($compositeFields[$k])) {
552
                // Update $collidingFields if necessary
553
                $expressionForField = $query->expressionForField($k);
554
                $quotedField = $schema->sqlColumnForField($tableClass, $k);
555
                if ($expressionForField) {
556
                    if (!isset($this->collidingFields[$k])) {
557
                        $this->collidingFields[$k] = array($expressionForField);
558
                    }
559
                    $this->collidingFields[$k][] = $quotedField;
560
                } else {
561
                    $query->selectField($quotedField, $k);
562
                }
563
            }
564
        }
565
        foreach ($compositeFields as $k => $v) {
566
            if ((is_null($columns) || in_array($k, $columns)) && $v) {
567
                $tableName = $schema->tableName($tableClass);
568
                $dbO = Injector::inst()->create($v, $k);
569
                $dbO->setTable($tableName);
570
                $dbO->addToQuery($query);
571
            }
572
        }
573
    }
574
575
    /**
576
     * Append a GROUP BY clause to this query.
577
     *
578
     * @param string $groupby Escaped SQL statement
579
     * @return $this
580
     */
581
    public function groupby($groupby)
582
    {
583
        $this->query->addGroupBy($groupby);
584
        return $this;
585
    }
586
587
    /**
588
     * Append a HAVING clause to this query.
589
     *
590
     * @param mixed $having Predicate(s) to set, as escaped SQL statements or parameterised queries
591
     * @return $this
592
     */
593
    public function having($having)
594
    {
595
        $this->query->addHaving($having);
596
        return $this;
597
    }
598
599
    /**
600
     * Create a disjunctive subgroup.
601
     *
602
     * That is a subgroup joined by OR
603
     *
604
     * @return DataQuery_SubGroup
605
     */
606
    public function disjunctiveGroup()
607
    {
608
        return new DataQuery_SubGroup($this, 'OR');
609
    }
610
611
    /**
612
     * Create a conjunctive subgroup
613
     *
614
     * That is a subgroup joined by AND
615
     *
616
     * @return DataQuery_SubGroup
617
     */
618
    public function conjunctiveGroup()
619
    {
620
        return new DataQuery_SubGroup($this, 'AND');
621
    }
622
623
    /**
624
     * Adds a WHERE clause.
625
     *
626
     * @see SQLSelect::addWhere() for syntax examples, although DataQuery
627
     * won't expand multiple arguments as SQLSelect does.
628
     *
629
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
630
     * paramaterised queries
631
     * @return $this
632
     */
633
    public function where($filter)
634
    {
635
        if ($filter) {
636
            $this->query->addWhere($filter);
637
        }
638
        return $this;
639
    }
640
641
    /**
642
     * Append a WHERE with OR.
643
     *
644
     * @see SQLSelect::addWhere() for syntax examples, although DataQuery
645
     * won't expand multiple method arguments as SQLSelect does.
646
     *
647
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
648
     * paramaterised queries
649
     * @return $this
650
     */
651
    public function whereAny($filter)
652
    {
653
        if ($filter) {
654
            $this->query->addWhereAny($filter);
655
        }
656
        return $this;
657
    }
658
659
    /**
660
     * Set the ORDER BY clause of this query
661
     *
662
     * @see SQLSelect::orderby()
663
     *
664
     * @param string $sort Column to sort on (escaped SQL statement)
665
     * @param string $direction Direction ("ASC" or "DESC", escaped SQL statement)
666
     * @param bool $clear Clear existing values
667
     * @return $this
668
     */
669
    public function sort($sort = null, $direction = null, $clear = true)
670
    {
671
        $this->joinSubclassTableForField($sort);
672
673
        if ($clear) {
674
            $this->query->setOrderBy($sort, $direction);
675
        } else {
676
            $this->query->addOrderBy($sort, $direction);
677
        }
678
679
        return $this;
680
    }
681
682
    /**
683
     * Reverse order by clause
684
     *
685
     * @return $this
686
     */
687
    public function reverseSort()
688
    {
689
        $this->query->reverseOrderBy();
690
        return $this;
691
    }
692
693
    /**
694
     * Set the limit of this query.
695
     *
696
     * @param int $limit
697
     * @param int $offset
698
     * @return $this
699
     */
700
    public function limit($limit, $offset = 0)
701
    {
702
        $this->query->setLimit($limit, $offset);
703
        return $this;
704
    }
705
706
    /**
707
     * Set whether this query should be distinct or not.
708
     *
709
     * @param bool $value
710
     * @return $this
711
     */
712
    public function distinct($value)
713
    {
714
        $this->query->setDistinct($value);
715
        return $this;
716
    }
717
718
    /**
719
     * Add an INNER JOIN clause to this query.
720
     *
721
     * @param string $table The unquoted table name to join to.
722
     * @param string $onClause The filter for the join (escaped SQL statement)
723
     * @param string $alias An optional alias name (unquoted)
724
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
725
     * will cause the query to appear first. The default is 20, and joins created automatically by the
726
     * ORM have a value of 10.
727
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
728
     * @return $this
729
     */
730
    public function innerJoin($table, $onClause, $alias = null, $order = 20, $parameters = array())
731
    {
732
        if ($table) {
733
            $this->query->addInnerJoin($table, $onClause, $alias, $order, $parameters);
734
        }
735
        return $this;
736
    }
737
738
    /**
739
     * Add a LEFT JOIN clause to this query.
740
     *
741
     * @param string $table The unquoted table to join to.
742
     * @param string $onClause The filter for the join (escaped SQL statement).
743
     * @param string $alias An optional alias name (unquoted)
744
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
745
     * will cause the query to appear first. The default is 20, and joins created automatically by the
746
     * ORM have a value of 10.
747
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
748
     * @return $this
749
     */
750
    public function leftJoin($table, $onClause, $alias = null, $order = 20, $parameters = array())
751
    {
752
        if ($table) {
753
            $this->query->addLeftJoin($table, $onClause, $alias, $order, $parameters);
754
        }
755
        return $this;
756
    }
757
758
    /**
759
     * Return the prefix of all joined table aliases. E.g. ->filter('Banner.Image.Title)'
760
     * Will join the Banner, and then Image relations
761
     * `$relationPrefx` will be `banner_image_`
762
     * Each table in the Image chain will be suffixed to this prefix. E.g.
763
     * `banner_image_File` and `banner_image_Image`
764
     *
765
     * This will be null if no relation is joined.
766
     * E.g. `->filter('Title')`
767
     *
768
     * @param string|array $relation Relation in '.' delimited string, or array of parts
769
     * @return string Table prefix
770
     */
771
    public static function applyRelationPrefix($relation)
772
    {
773
        if (!$relation) {
774
            return null;
775
        }
776
        if (is_string($relation)) {
777
            $relation = explode(".", $relation);
778
        }
779
        return strtolower(implode('_', $relation)) . '_';
780
    }
781
782
    /**
783
     * Traverse the relationship fields, and add the table
784
     * mappings to the query object state. This has to be called
785
     * in any overloaded {@link SearchFilter->apply()} methods manually.
786
     *
787
     * Note, that in order to filter against the joined relation user code must
788
     * use {@see tablePrefix()} to get the table alias used for this relation.
789
     *
790
     * @param string|array $relation The array/dot-syntax relation to follow
791
     * @param bool $linearOnly Set to true to restrict to linear relations only. Set this
792
     * if this relation will be used for sorting, and should not include duplicate rows.
793
     * @return string The model class of the related item
794
     */
795
    public function applyRelation($relation, $linearOnly = false)
796
    {
797
        // NO-OP
798
        if (!$relation) {
799
            return $this->dataClass;
800
        }
801
802
        if (is_string($relation)) {
803
            $relation = explode(".", $relation);
804
        }
805
806
        $modelClass = $this->dataClass;
807
808
        $schema = DataObject::getSchema();
809
        $currentRelation = [];
810
        foreach ($relation as $rel) {
811
            // Get prefix for join for this table (and parent to join on)
812
            $parentPrefix = $this->applyRelationPrefix($currentRelation);
813
            $currentRelation[] = $rel;
814
            $tablePrefix = $this->applyRelationPrefix($currentRelation);
815
816
            // Check has_one
817
            if ($component = $schema->hasOneComponent($modelClass, $rel)) {
818
                // Join via has_one
819
                $this->joinHasOneRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix);
820
                $modelClass = $component;
821
                continue;
822
            }
823
824
            // Check has_many
825
            if ($component = $schema->hasManyComponent($modelClass, $rel)) {
826
                // Fail on non-linear relations
827
                if ($linearOnly) {
828
                    throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
829
                }
830
                // Join via has_many
831
                $this->joinHasManyRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix, 'has_many');
832
                $modelClass = $component;
833
                continue;
834
            }
835
836
            // check belongs_to (like has_many but linear safe)
837
            if ($component = $schema->belongsToComponent($modelClass, $rel)) {
838
                // Piggy back off has_many logic
839
                $this->joinHasManyRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix, 'belongs_to');
840
                $modelClass = $component;
841
                continue;
842
            }
843
844
            // Check many_many
845
            if ($component = $schema->manyManyComponent($modelClass, $rel)) {
846
                // Fail on non-linear relations
847
                if ($linearOnly) {
848
                    throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
849
                }
850
                $this->joinManyManyRelationship(
851
                    $component['relationClass'],
852
                    $component['parentClass'],
853
                    $component['childClass'],
854
                    $component['parentField'],
855
                    $component['childField'],
856
                    $component['join'],
857
                    $parentPrefix,
858
                    $tablePrefix
859
                );
860
                $modelClass = $component['childClass'];
861
                continue;
862
            }
863
864
            // no relation
865
            throw new InvalidArgumentException("$rel is not a relation on model $modelClass");
866
        }
867
868
        return $modelClass;
869
    }
870
871
    /**
872
     * Ensure that the subclass table for the given field is joined
873
     * In the case of a field contained in multiple subclasses, all will be joined and a COALESCE() will be returned
874
     *
875
     * @return string The table-qualified, quoted field name
876
     */
877
    public function joinSubclassTableForField($field)
878
    {
879
        $schema = DataObject::getSchema();
880
        $tables = $schema->tablesForField($this->dataClass, $field);
881
882
        // Can't find a subclass table(s)
883
        if (!$tables) {
884
            return "\"$field\"";
885
        }
886
887
        $baseDataClass = $schema->baseDataClass($this->dataClass());
888
        $baseIDColumn = $schema->sqlColumnForField($baseDataClass, 'ID');
889
890
        // Join any tables that haven't already been joined
891
        foreach ($tables as $table) {
0 ignored issues
show
Bug introduced by
The expression $tables of type string is not traversable.
Loading history...
892
            if (!$this->query->isJoinedTo($table)) {
893
                $this->query->addLeftJoin(
894
                    $table,
895
                    "\"{$table}\".\"ID\" = {$baseIDColumn}",
896
                    $table,
897
                    // Priority 11 comes after the getFinalisedQuery() joins, but before all others
898
                    11
899
                );
900
            }
901
        }
902
903
        // Return the fully qualified column name
904
        if (count($tables) === 1) {
0 ignored issues
show
Bug introduced by
$tables of type string is incompatible with the type Countable|array expected by parameter $var of count(). ( Ignorable by Annotation )

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

904
        if (count(/** @scrutinizer ignore-type */ $tables) === 1) {
Loading history...
905
            return "\"$table\".\"$field\"";
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $table seems to be defined by a foreach iteration on line 891. Are you sure the iterator is never empty, otherwise this variable is not defined?
Loading history...
906
907
        // If multiple tables share the same column, COALESCE() them together
908
        } else {
909
            $columns = array_map(
910
                function ($table) use ($field) {
911
                    return "\"$table\".\"$field\"";
912
                },
913
                $tables
0 ignored issues
show
Bug introduced by
$tables of type string is incompatible with the type array expected by parameter $arr1 of array_map(). ( Ignorable by Annotation )

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

913
                /** @scrutinizer ignore-type */ $tables
Loading history...
914
            );
915
            return "COALESCE(" . implode($columns, ', ') . ")";
0 ignored issues
show
Unused Code introduced by
The call to implode() has too many arguments starting with ', '. ( Ignorable by Annotation )

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

915
            return "COALESCE(" . /** @scrutinizer ignore-call */ implode($columns, ', ') . ")";

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
916
        }
917
    }
918
919
    /**
920
     * Join the given has_many relation to this query.
921
     * Also works with belongs_to
922
     *
923
     * Doesn't work with polymorphic relationships
924
     *
925
     * @param string $localClass Name of class that has the has_many to the joined class
926
     * @param string $localField Name of the has_many relationship to join
927
     * @param string $foreignClass Class to join
928
     * @param string $localPrefix Table prefix for parent class
929
     * @param string $foreignPrefix Table prefix to use
930
     * @param string $type 'has_many' or 'belongs_to'
931
     */
932
    protected function joinHasManyRelation(
933
        $localClass,
934
        $localField,
935
        $foreignClass,
936
        $localPrefix = null,
937
        $foreignPrefix = null,
938
        $type = 'has_many'
939
    ) {
940
        if (!$foreignClass || $foreignClass === DataObject::class) {
941
            throw new InvalidArgumentException("Could not find a has_many relationship {$localField} on {$localClass}");
942
        }
943
        $schema = DataObject::getSchema();
944
945
        // Skip if already joined
946
        // Note: don't just check base class, since we need to join on the table with the actual relation key
947
        $foreignTable = $schema->tableName($foreignClass);
948
        $foreignTableAliased = $foreignPrefix . $foreignTable;
949
        if ($this->query->isJoinedTo($foreignTableAliased)) {
950
            return;
951
        }
952
953
        // Join table with associated has_one
954
        /** @var DataObject $model */
955
        $foreignKey = $schema->getRemoteJoinField($localClass, $localField, $type, $polymorphic);
956
        $localIDColumn = $schema->sqlColumnForField($localClass, 'ID', $localPrefix);
957
        if ($polymorphic) {
958
            $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}ID", $foreignPrefix);
959
            $foreignKeyClassColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}Class", $foreignPrefix);
960
            $localClassColumn = $schema->sqlColumnForField($localClass, 'ClassName', $localPrefix);
961
            $joinExpression =
962
                "{$foreignKeyIDColumn} = {$localIDColumn} AND {$foreignKeyClassColumn} = {$localClassColumn}";
963
        } else {
964
            $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, $foreignKey, $foreignPrefix);
965
            $joinExpression = "{$foreignKeyIDColumn} = {$localIDColumn}";
966
        }
967
        $this->query->addLeftJoin(
968
            $foreignTable,
969
            $joinExpression,
970
            $foreignTableAliased
971
        );
972
973
        // Add join clause to the component's ancestry classes so that the search filter could search on
974
        // its ancestor fields.
975
        $ancestry = ClassInfo::ancestry($foreignClass, true);
976
        $ancestry = array_reverse($ancestry);
977
        foreach ($ancestry as $ancestor) {
978
            $ancestorTable = $schema->tableName($ancestor);
979
            if ($ancestorTable !== $foreignTable) {
980
                $ancestorTableAliased = $foreignPrefix . $ancestorTable;
981
                $this->query->addLeftJoin(
982
                    $ancestorTable,
983
                    "\"{$foreignTableAliased}\".\"ID\" = \"{$ancestorTableAliased}\".\"ID\"",
984
                    $ancestorTableAliased
985
                );
986
            }
987
        }
988
    }
989
990
    /**
991
     * Join the given class to this query with the given key
992
     *
993
     * @param string $localClass Name of class that has the has_one to the joined class
994
     * @param string $localField Name of the has_one relationship to joi
995
     * @param string $foreignClass Class to join
996
     * @param string $localPrefix Table prefix to use for local class
997
     * @param string $foreignPrefix Table prefix to use for joined table
998
     */
999
    protected function joinHasOneRelation(
1000
        $localClass,
1001
        $localField,
1002
        $foreignClass,
1003
        $localPrefix = null,
1004
        $foreignPrefix = null
1005
    ) {
1006
        if (!$foreignClass) {
1007
            throw new InvalidArgumentException("Could not find a has_one relationship {$localField} on {$localClass}");
1008
        }
1009
1010
        if ($foreignClass === DataObject::class) {
1011
            throw new InvalidArgumentException(
1012
                "Could not join polymorphic has_one relationship {$localField} on {$localClass}"
1013
            );
1014
        }
1015
        $schema = DataObject::getSchema();
1016
1017
        // Skip if already joined
1018
        $foreignBaseClass = $schema->baseDataClass($foreignClass);
1019
        $foreignBaseTable = $schema->tableName($foreignBaseClass);
1020
        if ($this->query->isJoinedTo($foreignPrefix . $foreignBaseTable)) {
1021
            return;
1022
        }
1023
1024
        // Join base table
1025
        $foreignIDColumn = $schema->sqlColumnForField($foreignBaseClass, 'ID', $foreignPrefix);
1026
        $localColumn = $schema->sqlColumnForField($localClass, "{$localField}ID", $localPrefix);
1027
        $this->query->addLeftJoin(
1028
            $foreignBaseTable,
1029
            "{$foreignIDColumn} = {$localColumn}",
1030
            $foreignPrefix . $foreignBaseTable
1031
        );
1032
1033
        // Add join clause to the component's ancestry classes so that the search filter could search on
1034
        // its ancestor fields.
1035
        $ancestry = ClassInfo::ancestry($foreignClass, true);
1036
        if (!empty($ancestry)) {
1037
            $ancestry = array_reverse($ancestry);
1038
            foreach ($ancestry as $ancestor) {
1039
                $ancestorTable = $schema->tableName($ancestor);
1040
                if ($ancestorTable !== $foreignBaseTable) {
1041
                    $ancestorTableAliased = $foreignPrefix . $ancestorTable;
1042
                    $this->query->addLeftJoin(
1043
                        $ancestorTable,
1044
                        "{$foreignIDColumn} = \"{$ancestorTableAliased}\".\"ID\"",
1045
                        $ancestorTableAliased
1046
                    );
1047
                }
1048
            }
1049
        }
1050
    }
1051
1052
    /**
1053
     * Join table via many_many relationship
1054
     *
1055
     * @param string $relationClass
1056
     * @param string $parentClass
1057
     * @param string $componentClass
1058
     * @param string $parentField
1059
     * @param string $componentField
1060
     * @param string $relationClassOrTable Name of relation table
1061
     * @param string $parentPrefix Table prefix for parent class
1062
     * @param string $componentPrefix Table prefix to use for both joined and mapping table
1063
     */
1064
    protected function joinManyManyRelationship(
1065
        $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

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