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

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

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

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

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

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

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

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

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

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

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

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

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

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