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

DataQuery::joinManyManyRelationship()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 53
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 29
nc 8
nop 8
dl 0
loc 53
rs 9.1448
c 0
b 0
f 0

How to fix   Long Method    Many Parameters   

Long Method

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

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

Commonly applied refactorings include:

Many Parameters

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

There are several approaches to avoid long parameter lists:

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

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

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

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

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

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

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

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