Completed
Push — master ( c17796...052b15 )
by Damian
01:29
created

DataQuery::getQueryParams()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\ORM;
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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $collisionClass of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
460
        if (!$table) {
461
            return $this->aggregate("MAX(\"$field\")");
462
        }
463
        return $this->aggregate("MAX(\"$table\".\"$field\")");
464
    }
465
466
    /**
467
     * Return the minimum value of the given field in this DataList
468
     *
469
     * @param string $field Unquoted database column name. Will be ANSI quoted
470
     * automatically so must not contain double quotes.
471
     * @return string
472
     */
473
    public function min($field)
474
    {
475
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
476
        if (!$table) {
477
            return $this->aggregate("MIN(\"$field\")");
478
        }
479
        return $this->aggregate("MIN(\"$table\".\"$field\")");
480
    }
481
482
    /**
483
     * Return the average value of the given field in this DataList
484
     *
485
     * @param string $field Unquoted database column name. Will be ANSI quoted
486
     * automatically so must not contain double quotes.
487
     * @return string
488
     */
489
    public function avg($field)
490
    {
491
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
492
        if (!$table) {
493
            return $this->aggregate("AVG(\"$field\")");
494
        }
495
        return $this->aggregate("AVG(\"$table\".\"$field\")");
496
    }
497
498
    /**
499
     * Return the sum of the values of the given field in this DataList
500
     *
501
     * @param string $field Unquoted database column name. Will be ANSI quoted
502
     * automatically so must not contain double quotes.
503
     * @return string
504
     */
505
    public function sum($field)
506
    {
507
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
508
        if (!$table) {
509
            return $this->aggregate("SUM(\"$field\")");
510
        }
511
        return $this->aggregate("SUM(\"$table\".\"$field\")");
512
    }
513
514
    /**
515
     * Runs a raw aggregate expression.  Please handle escaping yourself
516
     *
517
     * @param string $expression An aggregate expression, such as 'MAX("Balance")', or a set of them
518
     * (as an escaped SQL statement)
519
     * @return string
520
     */
521
    public function aggregate($expression)
522
    {
523
        return $this->getFinalisedQuery()->aggregate($expression)->execute()->value();
524
    }
525
526
    /**
527
     * Return the first row that would be returned by this full DataQuery
528
     * Note that this will issue a separate SELECT ... LIMIT 1 query.
529
     *
530
     * @return SQLSelect
531
     */
532
    public function firstRow()
533
    {
534
        return $this->getFinalisedQuery()->firstRow();
535
    }
536
537
    /**
538
     * Return the last row that would be returned by this full DataQuery
539
     * Note that this will issue a separate SELECT ... LIMIT query.
540
     *
541
     * @return SQLSelect
542
     */
543
    public function lastRow()
544
    {
545
        return $this->getFinalisedQuery()->lastRow();
546
    }
547
548
    /**
549
     * Update the SELECT clause of the query with the columns from the given table
550
     *
551
     * @param SQLSelect $query
552
     * @param string $tableClass Class to select from
553
     * @param array $columns
554
     */
555
    protected function selectColumnsFromTable(SQLSelect &$query, $tableClass, $columns = null)
556
    {
557
        // Add SQL for multi-value fields
558
        $schema = DataObject::getSchema();
559
        $databaseFields = $schema->databaseFields($tableClass, false);
560
        $compositeFields = $schema->compositeFields($tableClass, false);
561
        unset($databaseFields['ID']);
562
        foreach ($databaseFields as $k => $v) {
563
            if ((is_null($columns) || in_array($k, $columns)) && !isset($compositeFields[$k])) {
564
                // Update $collidingFields if necessary
565
                $expressionForField = $query->expressionForField($k);
566
                $quotedField = $schema->sqlColumnForField($tableClass, $k);
567
                if ($expressionForField) {
568
                    if (!isset($this->collidingFields[$k])) {
569
                        $this->collidingFields[$k] = array($expressionForField);
570
                    }
571
                    $this->collidingFields[$k][] = $quotedField;
572
                } else {
573
                    $query->selectField($quotedField, $k);
574
                }
575
            }
576
        }
577
        foreach ($compositeFields as $k => $v) {
578
            if ((is_null($columns) || in_array($k, $columns)) && $v) {
579
                $tableName = $schema->tableName($tableClass);
580
                $dbO = Injector::inst()->create($v, $k);
581
                $dbO->setTable($tableName);
582
                $dbO->addToQuery($query);
583
            }
584
        }
585
    }
586
587
    /**
588
     * Append a GROUP BY clause to this query.
589
     *
590
     * @param string $groupby Escaped SQL statement
591
     * @return $this
592
     */
593
    public function groupby($groupby)
594
    {
595
        $this->query->addGroupBy($groupby);
596
        return $this;
597
    }
598
599
    /**
600
     * Append a HAVING clause to this query.
601
     *
602
     * @param string $having Escaped SQL statement
603
     * @return $this
604
     */
605
    public function having($having)
606
    {
607
        $this->query->addHaving($having);
608
        return $this;
609
    }
610
611
    /**
612
     * Create a disjunctive subgroup.
613
     *
614
     * That is a subgroup joined by OR
615
     *
616
     * @return DataQuery_SubGroup
617
     */
618
    public function disjunctiveGroup()
619
    {
620
        return new DataQuery_SubGroup($this, 'OR');
621
    }
622
623
    /**
624
     * Create a conjunctive subgroup
625
     *
626
     * That is a subgroup joined by AND
627
     *
628
     * @return DataQuery_SubGroup
629
     */
630
    public function conjunctiveGroup()
631
    {
632
        return new DataQuery_SubGroup($this, 'AND');
633
    }
634
635
    /**
636
     * Adds a WHERE clause.
637
     *
638
     * @see SQLSelect::addWhere() for syntax examples, although DataQuery
639
     * won't expand multiple arguments as SQLSelect does.
640
     *
641
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
642
     * paramaterised queries
643
     * @return $this
644
     */
645
    public function where($filter)
646
    {
647
        if ($filter) {
648
            $this->query->addWhere($filter);
649
        }
650
        return $this;
651
    }
652
653
    /**
654
     * Append a WHERE with OR.
655
     *
656
     * @see SQLSelect::addWhere() for syntax examples, although DataQuery
657
     * won't expand multiple method arguments as SQLSelect does.
658
     *
659
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
660
     * paramaterised queries
661
     * @return $this
662
     */
663
    public function whereAny($filter)
664
    {
665
        if ($filter) {
666
            $this->query->addWhereAny($filter);
667
        }
668
        return $this;
669
    }
670
671
    /**
672
     * Set the ORDER BY clause of this query
673
     *
674
     * @see SQLSelect::orderby()
675
     *
676
     * @param string $sort Column to sort on (escaped SQL statement)
677
     * @param string $direction Direction ("ASC" or "DESC", escaped SQL statement)
678
     * @param bool $clear Clear existing values
679
     * @return $this
680
     */
681
    public function sort($sort = null, $direction = null, $clear = true)
682
    {
683
        if ($clear) {
684
            $this->query->setOrderBy($sort, $direction);
685
        } else {
686
            $this->query->addOrderBy($sort, $direction);
687
        }
688
689
        return $this;
690
    }
691
692
    /**
693
     * Reverse order by clause
694
     *
695
     * @return $this
696
     */
697
    public function reverseSort()
698
    {
699
        $this->query->reverseOrderBy();
700
        return $this;
701
    }
702
703
    /**
704
     * Set the limit of this query.
705
     *
706
     * @param int $limit
707
     * @param int $offset
708
     * @return $this
709
     */
710
    public function limit($limit, $offset = 0)
711
    {
712
        $this->query->setLimit($limit, $offset);
713
        return $this;
714
    }
715
716
    /**
717
     * Set whether this query should be distinct or not.
718
     *
719
     * @param bool $value
720
     * @return $this
721
     */
722
    public function distinct($value)
723
    {
724
        $this->query->setDistinct($value);
725
        return $this;
726
    }
727
728
    /**
729
     * Add an INNER JOIN clause to this query.
730
     *
731
     * @param string $table The unquoted table name to join to.
732
     * @param string $onClause The filter for the join (escaped SQL statement)
733
     * @param string $alias An optional alias name (unquoted)
734
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
735
     * will cause the query to appear first. The default is 20, and joins created automatically by the
736
     * ORM have a value of 10.
737
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
738
     * @return $this
739
     */
740
    public function innerJoin($table, $onClause, $alias = null, $order = 20, $parameters = array())
741
    {
742
        if ($table) {
743
            $this->query->addInnerJoin($table, $onClause, $alias, $order, $parameters);
744
        }
745
        return $this;
746
    }
747
748
    /**
749
     * Add a LEFT JOIN clause to this query.
750
     *
751
     * @param string $table The unquoted table to join to.
752
     * @param string $onClause The filter for the join (escaped SQL statement).
753
     * @param string $alias An optional alias name (unquoted)
754
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
755
     * will cause the query to appear first. The default is 20, and joins created automatically by the
756
     * ORM have a value of 10.
757
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
758
     * @return $this
759
     */
760
    public function leftJoin($table, $onClause, $alias = null, $order = 20, $parameters = array())
761
    {
762
        if ($table) {
763
            $this->query->addLeftJoin($table, $onClause, $alias, $order, $parameters);
764
        }
765
        return $this;
766
    }
767
768
    /**
769
     * Prefix of all joined table aliases. E.g. ->filter('Banner.Image.Title)'
770
     * Will join the Banner, and then Image relations
771
     * `$relationPrefx` will be `banner_image_`
772
     * Each table in the Image chain will be suffixed to this prefix. E.g.
773
     * `banner_image_File` and `banner_image_Image`
774
     *
775
     * This will be null if no relation is joined.
776
     * E.g. `->filter('Title')`
777
     *
778
     * @param string|array $relation Relation in '.' delimited string, or array of parts
779
     * @return string Table prefix
780
     */
781
    public static function applyRelationPrefix($relation)
782
    {
783
        if (!$relation) {
784
            return null;
785
        }
786
        if (is_string($relation)) {
787
            $relation = explode(".", $relation);
788
        }
789
        return strtolower(implode('_', $relation)) . '_';
790
    }
791
792
    /**
793
     * Traverse the relationship fields, and add the table
794
     * mappings to the query object state. This has to be called
795
     * in any overloaded {@link SearchFilter->apply()} methods manually.
796
     *
797
     * Note, that in order to filter against the joined relation user code must
798
     * use {@see tablePrefix()} to get the table alias used for this relation.
799
     *
800
     * @param string|array $relation The array/dot-syntax relation to follow
801
     * @param bool $linearOnly Set to true to restrict to linear relations only. Set this
802
     * if this relation will be used for sorting, and should not include duplicate rows.
803
     * @return string The model class of the related item
804
     */
805
    public function applyRelation($relation, $linearOnly = false)
806
    {
807
        // NO-OP
808
        if (!$relation) {
809
            return $this->dataClass;
810
        }
811
812
        if (is_string($relation)) {
813
            $relation = explode(".", $relation);
814
        }
815
816
        $modelClass = $this->dataClass;
817
818
        $schema = DataObject::getSchema();
819
        $currentRelation = [];
820
        foreach ($relation as $rel) {
821
            // Get prefix for join for this table (and parent to join on)
822
            $parentPrefix = $this->applyRelationPrefix($currentRelation);
823
            $currentRelation[] = $rel;
824
            $tablePrefix = $this->applyRelationPrefix($currentRelation);
825
826
            // Check has_one
827
            if ($component = $schema->hasOneComponent($modelClass, $rel)) {
828
                // Join via has_one
829
                $this->joinHasOneRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix);
830
                $modelClass = $component;
831
                continue;
832
            }
833
834
            // Check has_many
835
            if ($component = $schema->hasManyComponent($modelClass, $rel)) {
836
                // Fail on non-linear relations
837
                if ($linearOnly) {
838
                    throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
839
                }
840
                // Join via has_many
841
                $this->joinHasManyRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix, 'has_many');
842
                $modelClass = $component;
843
                continue;
844
            }
845
846
            // check belongs_to (like has_many but linear safe)
847
            if ($component = $schema->belongsToComponent($modelClass, $rel)) {
848
                // Piggy back off has_many logic
849
                $this->joinHasManyRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix, 'belongs_to');
850
                $modelClass = $component;
851
                continue;
852
            }
853
854
            // Check many_many
855
            if ($component = $schema->manyManyComponent($modelClass, $rel)) {
856
                // Fail on non-linear relations
857
                if ($linearOnly) {
858
                    throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
859
                }
860
                $this->joinManyManyRelationship(
861
                    $component['relationClass'],
862
                    $component['parentClass'],
863
                    $component['childClass'],
864
                    $component['parentField'],
865
                    $component['childField'],
866
                    $component['join'],
867
                    $parentPrefix,
868
                    $tablePrefix
869
                );
870
                $modelClass = $component['childClass'];
871
                continue;
872
            }
873
874
            // no relation
875
            throw new InvalidArgumentException("$rel is not a relation on model $modelClass");
876
        }
877
878
        return $modelClass;
879
    }
880
881
    /**
882
     * Join the given has_many relation to this query.
883
     * Also works with belongs_to
884
     *
885
     * Doesn't work with polymorphic relationships
886
     *
887
     * @param string $localClass Name of class that has the has_many to the joined class
888
     * @param string $localField Name of the has_many relationship to join
889
     * @param string $foreignClass Class to join
890
     * @param string $localPrefix Table prefix for parent class
891
     * @param string $foreignPrefix Table prefix to use
892
     * @param string $type 'has_many' or 'belongs_to'
893
     */
894
    protected function joinHasManyRelation(
895
        $localClass,
896
        $localField,
897
        $foreignClass,
898
        $localPrefix = null,
899
        $foreignPrefix = null,
900
        $type = 'has_many'
901
    ) {
902
        if (!$foreignClass || $foreignClass === DataObject::class) {
903
            throw new InvalidArgumentException("Could not find a has_many relationship {$localField} on {$localClass}");
904
        }
905
        $schema = DataObject::getSchema();
906
907
        // Skip if already joined
908
        // Note: don't just check base class, since we need to join on the table with the actual relation key
909
        $foreignTable = $schema->tableName($foreignClass);
910
        $foreignTableAliased = $foreignPrefix . $foreignTable;
911
        if ($this->query->isJoinedTo($foreignTableAliased)) {
912
            return;
913
        }
914
915
        // Join table with associated has_one
916
        /** @var DataObject $model */
917
        $foreignKey = $schema->getRemoteJoinField($localClass, $localField, $type, $polymorphic);
918
        $localIDColumn = $schema->sqlColumnForField($localClass, 'ID', $localPrefix);
919
        if ($polymorphic) {
920
            $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}ID", $foreignPrefix);
921
            $foreignKeyClassColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}Class", $foreignPrefix);
922
            $localClassColumn = $schema->sqlColumnForField($localClass, 'ClassName', $localPrefix);
923
            $joinExpression =
924
                "{$foreignKeyIDColumn} = {$localIDColumn} AND {$foreignKeyClassColumn} = {$localClassColumn}";
925
        } else {
926
            $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, $foreignKey, $foreignPrefix);
927
            $joinExpression = "{$foreignKeyIDColumn} = {$localIDColumn}";
928
        }
929
        $this->query->addLeftJoin(
930
            $foreignTable,
931
            $joinExpression,
932
            $foreignTableAliased
933
        );
934
935
        // Add join clause to the component's ancestry classes so that the search filter could search on
936
        // its ancestor fields.
937
        $ancestry = ClassInfo::ancestry($foreignClass, true);
938
        $ancestry = array_reverse($ancestry);
939
        foreach ($ancestry as $ancestor) {
940
            $ancestorTable = $schema->tableName($ancestor);
941
            if ($ancestorTable !== $foreignTable) {
942
                $ancestorTableAliased = $foreignPrefix.$ancestorTable;
943
                $this->query->addLeftJoin(
944
                    $ancestorTable,
945
                    "\"{$foreignTableAliased}\".\"ID\" = \"{$ancestorTableAliased}\".\"ID\"",
946
                    $ancestorTableAliased
947
                );
948
            }
949
        }
950
    }
951
952
    /**
953
     * Join the given class to this query with the given key
954
     *
955
     * @param string $localClass Name of class that has the has_one to the joined class
956
     * @param string $localField Name of the has_one relationship to joi
957
     * @param string $foreignClass Class to join
958
     * @param string $localPrefix Table prefix to use for local class
959
     * @param string $foreignPrefix Table prefix to use for joined table
960
     */
961
    protected function joinHasOneRelation(
962
        $localClass,
963
        $localField,
964
        $foreignClass,
965
        $localPrefix = null,
966
        $foreignPrefix = null
967
    ) {
968
        if (!$foreignClass) {
969
            throw new InvalidArgumentException("Could not find a has_one relationship {$localField} on {$localClass}");
970
        }
971
972
        if ($foreignClass === DataObject::class) {
973
            throw new InvalidArgumentException(
974
                "Could not join polymorphic has_one relationship {$localField} on {$localClass}"
975
            );
976
        }
977
        $schema = DataObject::getSchema();
978
979
        // Skip if already joined
980
        $foreignBaseClass = $schema->baseDataClass($foreignClass);
981
        $foreignBaseTable = $schema->tableName($foreignBaseClass);
982
        if ($this->query->isJoinedTo($foreignPrefix.$foreignBaseTable)) {
983
            return;
984
        }
985
986
        // Join base table
987
        $foreignIDColumn = $schema->sqlColumnForField($foreignBaseClass, 'ID', $foreignPrefix);
988
        $localColumn = $schema->sqlColumnForField($localClass, "{$localField}ID", $localPrefix);
989
        $this->query->addLeftJoin(
990
            $foreignBaseTable,
991
            "{$foreignIDColumn} = {$localColumn}",
992
            $foreignPrefix.$foreignBaseTable
993
        );
994
995
        // Add join clause to the component's ancestry classes so that the search filter could search on
996
        // its ancestor fields.
997
        $ancestry = ClassInfo::ancestry($foreignClass, true);
998
        if (!empty($ancestry)) {
999
            $ancestry = array_reverse($ancestry);
1000
            foreach ($ancestry as $ancestor) {
1001
                $ancestorTable = $schema->tableName($ancestor);
1002
                if ($ancestorTable !== $foreignBaseTable) {
1003
                    $ancestorTableAliased = $foreignPrefix.$ancestorTable;
1004
                    $this->query->addLeftJoin(
1005
                        $ancestorTable,
1006
                        "{$foreignIDColumn} = \"{$ancestorTableAliased}\".\"ID\"",
1007
                        $ancestorTableAliased
1008
                    );
1009
                }
1010
            }
1011
        }
1012
    }
1013
1014
    /**
1015
     * Join table via many_many relationship
1016
     *
1017
     * @param string $relationClass
1018
     * @param string $parentClass
1019
     * @param string $componentClass
1020
     * @param string $parentField
1021
     * @param string $componentField
1022
     * @param string $relationClassOrTable Name of relation table
1023
     * @param string $parentPrefix Table prefix for parent class
1024
     * @param string $componentPrefix Table prefix to use for both joined and mapping table
1025
     */
1026
    protected function joinManyManyRelationship(
1027
        $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

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