Completed
Push — 4 ( 6b7842...1eb66e )
by Robbie
27s queued 18s
created

DataQuery::exists()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 27
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 4
eloc 13
c 2
b 0
f 0
nc 6
nop 0
dl 0
loc 27
rs 9.8333
1
<?php
2
3
namespace SilverStripe\ORM;
4
5
use SilverStripe\Core\ClassInfo;
6
use SilverStripe\Core\Convert;
7
use SilverStripe\Core\Extensible;
8
use SilverStripe\Core\Injector\Injector;
9
use SilverStripe\ORM\Connect\Query;
10
use SilverStripe\ORM\Queries\SQLConditionGroup;
11
use SilverStripe\ORM\Queries\SQLSelect;
12
use InvalidArgumentException;
13
14
/**
15
 * An object representing a query of data from the DataObject's supporting database.
16
 * Acts as a wrapper over {@link SQLSelect} and performs all of the query generation.
17
 * Used extensively by {@link DataList}.
18
 *
19
 * Unlike DataList, modifiers on DataQuery modify the object rather than returning a clone.
20
 * DataList is immutable, DataQuery is mutable.
21
 */
22
class DataQuery
23
{
24
25
    use Extensible;
26
27
    /**
28
     * @var string
29
     */
30
    protected $dataClass;
31
32
    /**
33
     * @var SQLSelect
34
     */
35
    protected $query;
36
37
    /**
38
     * Map of all field names to an array of conflicting column SQL
39
     *
40
     * E.g.
41
     * [
42
     *   'Title' => [
43
     *     '"MyTable"."Title"',
44
     *     '"AnotherTable"."Title"',
45
     *   ]
46
     * ]
47
     *
48
     * @var array
49
     */
50
    protected $collidingFields = [];
51
52
    /**
53
     * Allows custom callback to be registered before getFinalisedQuery is called.
54
     *
55
     * @var DataQueryManipulator[]
56
     */
57
    protected $dataQueryManipulators = [];
58
59
    private $queriedColumns = null;
60
61
    /**
62
     * @var bool
63
     */
64
    private $queryFinalised = false;
0 ignored issues
show
introduced by
The private property $queryFinalised is not used, and could be removed.
Loading history...
65
66
    // TODO: replace subclass_access with this
67
    protected $querySubclasses = true;
68
    // TODO: replace restrictclasses with this
69
    protected $filterByClassName = true;
70
71
    /**
72
     * Create a new DataQuery.
73
     *
74
     * @param string $dataClass The name of the DataObject class that you wish to query
75
     */
76
    public function __construct($dataClass)
77
    {
78
        $this->dataClass = $dataClass;
79
        $this->initialiseQuery();
80
    }
81
82
    /**
83
     * Clone this object
84
     */
85
    public function __clone()
86
    {
87
        $this->query = clone $this->query;
88
    }
89
90
    /**
91
     * Return the {@link DataObject} class that is being queried.
92
     *
93
     * @return string
94
     */
95
    public function dataClass()
96
    {
97
        return $this->dataClass;
98
    }
99
100
    /**
101
     * Return the {@link SQLSelect} object that represents the current query; note that it will
102
     * be a clone of the object.
103
     *
104
     * @return SQLSelect
105
     */
106
    public function query()
107
    {
108
        return $this->getFinalisedQuery();
109
    }
110
111
112
    /**
113
     * Remove a filter from the query
114
     *
115
     * @param string|array $fieldExpression The predicate of the condition to remove
116
     * (ignoring parameters). The expression will be considered a match if it's
117
     * contained within any other predicate.
118
     * @return $this
119
     */
120
    public function removeFilterOn($fieldExpression)
121
    {
122
        $matched = false;
123
124
        // If given a parameterised condition extract only the condition
125
        if (is_array($fieldExpression)) {
126
            reset($fieldExpression);
127
            $fieldExpression = key($fieldExpression);
128
        }
129
130
        $where = $this->query->getWhere();
131
        // Iterate through each condition
132
        foreach ($where as $i => $condition) {
133
            // Rewrite condition groups as plain conditions before comparison
134
            if ($condition instanceof SQLConditionGroup) {
135
                $predicate = $condition->conditionSQL($parameters);
136
                $condition = [$predicate => $parameters];
137
            }
138
139
            // As each condition is a single length array, do a single
140
            // iteration to extract the predicate and parameters
141
            foreach ($condition as $predicate => $parameters) {
142
                // @see SQLSelect::addWhere for why this is required here
143
                if (strpos($predicate, $fieldExpression) !== false) {
144
                    unset($where[$i]);
145
                    $matched = true;
146
                }
147
                // Enforce single-item condition predicate => parameters structure
148
                break;
149
            }
150
        }
151
152
        // set the entire where clause back, but clear the original one first
153
        if ($matched) {
154
            $this->query->setWhere($where);
155
        } else {
156
            throw new InvalidArgumentException("Couldn't find $fieldExpression in the query filter.");
157
        }
158
159
        return $this;
160
    }
161
162
    /**
163
     * Set up the simplest initial query
164
     */
165
    protected function initialiseQuery()
166
    {
167
        // Join on base table and let lazy loading join subtables
168
        $baseClass = DataObject::getSchema()->baseDataClass($this->dataClass());
169
        if (!$baseClass) {
170
            throw new InvalidArgumentException("DataQuery::create() Can't find data classes for '{$this->dataClass}'");
171
        }
172
173
        // Build our intial query
174
        $this->query = new SQLSelect([]);
175
        $this->query->setDistinct(true);
176
177
        if ($sort = singleton($this->dataClass)->config()->get('default_sort')) {
178
            $this->sort($sort);
179
        }
180
181
        $baseTable = DataObject::getSchema()->tableName($baseClass);
182
        $this->query->setFrom("\"{$baseTable}\"");
183
184
        $obj = Injector::inst()->get($baseClass);
185
        $obj->extend('augmentDataQueryCreation', $this->query, $this);
186
    }
187
188
    /**
189
     * @param array $queriedColumns
190
     * @return $this
191
     */
192
    public function setQueriedColumns($queriedColumns)
193
    {
194
        $this->queriedColumns = $queriedColumns;
195
        return $this;
196
    }
197
198
    /**
199
     * Ensure that the query is ready to execute.
200
     *
201
     * @param array|null $queriedColumns Any columns to filter the query by
202
     * @return SQLSelect The finalised sql query
203
     */
204
    public function getFinalisedQuery($queriedColumns = null)
205
    {
206
        if (!$queriedColumns) {
207
            $queriedColumns = $this->queriedColumns;
208
        }
209
        if ($queriedColumns) {
210
            $queriedColumns = array_merge($queriedColumns, ['Created', 'LastEdited', 'ClassName']);
211
        }
212
        $query = clone $this->query;
213
214
        // Apply manipulators before finalising query
215
        foreach ($this->getDataQueryManipulators() as $manipulator) {
216
            $manipulator->beforeGetFinalisedQuery($this, $queriedColumns, $query);
217
        }
218
219
        $schema = DataObject::getSchema();
220
        $baseDataClass = $schema->baseDataClass($this->dataClass());
221
        $baseIDColumn = $schema->sqlColumnForField($baseDataClass, 'ID');
222
        $ancestorClasses = ClassInfo::ancestry($this->dataClass(), true);
223
224
        // Generate the list of tables to iterate over and the list of columns required
225
        // by any existing where clauses. This second step is skipped if we're fetching
226
        // the whole dataobject as any required columns will get selected regardless.
227
        if ($queriedColumns) {
228
            // Specifying certain columns allows joining of child tables
229
            $tableClasses = ClassInfo::dataClassesFor($this->dataClass);
230
231
            // Ensure that any filtered columns are included in the selected columns
232
            foreach ($query->getWhereParameterised($parameters) as $where) {
233
                // Check for any columns in the form '"Column" = ?' or '"Table"."Column"' = ?
234
                if (preg_match_all(
235
                    '/(?:"(?<table>[^"]+)"\.)?"(?<column>[^"]+)"(?:[^\.]|$)/',
236
                    $where,
237
                    $matches,
238
                    PREG_SET_ORDER
239
                )) {
240
                    foreach ($matches as $match) {
241
                        $column = $match['column'];
242
                        if (!in_array($column, $queriedColumns)) {
243
                            $queriedColumns[] = $column;
244
                        }
245
                    }
246
                }
247
            }
248
        } else {
249
            $tableClasses = $ancestorClasses;
250
        }
251
252
        // Iterate over the tables and check what we need to select from them. If any selects are made (or the table is
253
        // required for a select)
254
        foreach ($tableClasses as $tableClass) {
255
            // Determine explicit columns to select
256
            $selectColumns = null;
257
            if ($queriedColumns) {
258
                // Restrict queried columns to that on the selected table
259
                $tableFields = $schema->databaseFields($tableClass, false);
260
                unset($tableFields['ID']);
261
                $selectColumns = array_intersect($queriedColumns, array_keys($tableFields));
262
            }
263
264
            // If this is a subclass without any explicitly requested columns, omit this from the query
265
            if (!in_array($tableClass, $ancestorClasses) && empty($selectColumns)) {
266
                continue;
267
            }
268
269
            // Select necessary columns (unless an explicitly empty array)
270
            if ($selectColumns !== []) {
271
                $this->selectColumnsFromTable($query, $tableClass, $selectColumns);
272
            }
273
274
            // Join if not the base table
275
            if ($tableClass !== $baseDataClass) {
276
                $tableName = $schema->tableName($tableClass);
277
                $query->addLeftJoin(
278
                    $tableName,
279
                    "\"{$tableName}\".\"ID\" = {$baseIDColumn}",
280
                    $tableName,
281
                    10
282
                );
283
            }
284
        }
285
286
        // Resolve colliding fields
287
        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...
288
            foreach ($this->collidingFields as $collisionField => $collisions) {
289
                $caseClauses = [];
290
                foreach ($collisions as $collision) {
291
                    if (preg_match('/^"(?<table>[^"]+)"\./', $collision, $matches)) {
292
                        $collisionTable = $matches['table'];
293
                        $collisionClass = $schema->tableClass($collisionTable);
294
                        if ($collisionClass) {
295
                            $collisionClassColumn = $schema->sqlColumnForField($collisionClass, 'ClassName');
296
                            $collisionClasses = ClassInfo::subclassesFor($collisionClass);
297
                            $collisionClassesSQL = implode(', ', Convert::raw2sql($collisionClasses, true));
298
                            $caseClauses[] = "WHEN {$collisionClassColumn} IN ({$collisionClassesSQL}) THEN $collision";
299
                        }
300
                    } else {
301
                        user_error("Bad collision item '$collision'", E_USER_WARNING);
302
                    }
303
                }
304
                $query->selectField("CASE " . implode(" ", $caseClauses) . " ELSE NULL END", $collisionField);
305
            }
306
        }
307
308
309
        if ($this->filterByClassName) {
310
            // If querying the base class, don't bother filtering on class name
311
            if ($this->dataClass != $baseDataClass) {
312
                // Get the ClassName values to filter to
313
                $classNames = ClassInfo::subclassesFor($this->dataClass);
314
                $classNamesPlaceholders = DB::placeholders($classNames);
315
                $baseClassColumn = $schema->sqlColumnForField($baseDataClass, 'ClassName');
316
                $query->addWhere([
317
                    "{$baseClassColumn} IN ($classNamesPlaceholders)" => $classNames
318
                ]);
319
            }
320
        }
321
322
        // Select ID
323
        $query->selectField($baseIDColumn, "ID");
324
325
        // Select RecordClassName
326
        $baseClassColumn = $schema->sqlColumnForField($baseDataClass, 'ClassName');
327
        $query->selectField(
328
            "
329
			CASE WHEN {$baseClassColumn} IS NOT NULL THEN {$baseClassColumn}
330
			ELSE " . Convert::raw2sql($baseDataClass, true) . " END",
331
            "RecordClassName"
332
        );
333
334
        // TODO: Versioned, Translatable, SiteTreeSubsites, etc, could probably be better implemented as subclasses
335
        // of DataQuery
336
337
        $obj = Injector::inst()->get($this->dataClass);
338
        $obj->extend('augmentSQL', $query, $this);
339
340
        $this->ensureSelectContainsOrderbyColumns($query);
341
342
        // Apply post-finalisation manipulations
343
        foreach ($this->getDataQueryManipulators() as $manipulator) {
344
            $manipulator->afterGetFinalisedQuery($this, $queriedColumns, $query);
345
        }
346
347
        return $query;
348
    }
349
350
    /**
351
     * Ensure that if a query has an order by clause, those columns are present in the select.
352
     *
353
     * @param SQLSelect $query
354
     * @param array $originalSelect
355
     */
356
    protected function ensureSelectContainsOrderbyColumns($query, $originalSelect = [])
357
    {
358
        if ($orderby = $query->getOrderBy()) {
359
            $newOrderby = [];
360
            foreach ($orderby as $k => $dir) {
361
                $newOrderby[$k] = $dir;
362
363
                // don't touch functions in the ORDER BY or public function calls
364
                // selected as fields
365
                if (strpos($k, '(') !== false) {
366
                    continue;
367
                }
368
369
                $col = str_replace('"', '', trim($k));
370
                $parts = explode('.', $col);
371
372
                // Pull through SortColumn references from the originalSelect variables
373
                if (preg_match('/_SortColumn/', $col)) {
374
                    if (isset($originalSelect[$col])) {
375
                        $query->selectField($originalSelect[$col], $col);
376
                    }
377
                    continue;
378
                }
379
380
                if (count($parts) == 1) {
381
                    // Get expression for sort value
382
                    $qualCol = "\"{$parts[0]}\"";
383
                    $table = DataObject::getSchema()->tableForField($this->dataClass(), $parts[0]);
384
                    if ($table) {
385
                        $qualCol = "\"{$table}\".{$qualCol}";
386
                    }
387
388
                    // remove original sort
389
                    unset($newOrderby[$k]);
390
391
                    // add new columns sort
392
                    $newOrderby[$qualCol] = $dir;
393
394
                    // To-do: Remove this if block once SQLSelect::$select has been refactored to store getSelect()
395
                    // format internally; then this check can be part of selectField()
396
                    $selects = $query->getSelect();
397
                    if (!isset($selects[$col]) && !in_array($qualCol, $selects)) {
398
                        $query->selectField($qualCol);
399
                    }
400
                } else {
401
                    $qualCol = '"' . implode('"."', $parts) . '"';
402
403
                    if (!in_array($qualCol, $query->getSelect())) {
404
                        unset($newOrderby[$k]);
405
406
                        // Find the first free "_SortColumnX" slot
407
                        // and assign it to $key
408
                        $i = 0;
409
                        while (isset($newOrderby[$key = "\"_SortColumn$i\""]) || isset($orderby[$key = "\"_SortColumn$i\""])) {
0 ignored issues
show
Unused Code introduced by
The assignment to $key is dead and can be removed.
Loading history...
410
                            ++$i;
411
                        }
412
413
                        $newOrderby[$key] = $dir;
414
                        $query->selectField($qualCol, "_SortColumn$i");
415
                    }
416
                }
417
            }
418
419
            $query->setOrderBy($newOrderby);
420
        }
421
    }
422
423
    /**
424
     * Execute the query and return the result as {@link SS_Query} object.
425
     *
426
     * @return Query
427
     */
428
    public function execute()
429
    {
430
        return $this->getFinalisedQuery()->execute();
431
    }
432
433
    /**
434
     * Return this query's SQL
435
     *
436
     * @param array $parameters Out variable for parameters required for this query
437
     * @return string The resulting SQL query (may be paramaterised)
438
     */
439
    public function sql(&$parameters = [])
440
    {
441
        return $this->getFinalisedQuery()->sql($parameters);
442
    }
443
444
    /**
445
     * Return the number of records in this query.
446
     * Note that this will issue a separate SELECT COUNT() query.
447
     *
448
     * @return int
449
     */
450
    public function count()
451
    {
452
        $quotedColumn = DataObject::getSchema()->sqlColumnForField($this->dataClass(), 'ID');
453
        return $this->getFinalisedQuery()->count("DISTINCT {$quotedColumn}");
454
    }
455
456
    /**
457
     * Return whether this dataquery will have records. This will use `EXISTS` statements in SQL which are more
458
     * performant - especially when used in combination with indexed columns (that you're filtering on)
459
     *
460
     * @return bool
461
     */
462
    public function exists(): bool
463
    {
464
        // Grab a statement selecting "everything" - the engine shouldn't care what's being selected in an "EXISTS"
465
        // statement anyway
466
        $statement = $this->getFinalisedQuery();
467
468
        // Clear limit, distinct, and order as it's not relevant for an exists query
469
        $statement->setDistinct(false);
470
        $statement->setOrderBy(null);
471
        $statement->setLimit(null);
472
473
        // We can remove grouping if there's no "having" that might be relying on an aggregate
474
        // Additionally, the columns being selected no longer matter
475
        $having = $statement->getHaving();
476
        if (empty($having)) {
477
            $statement->setSelect('*');
478
            $statement->setGroupBy(null);
479
        }
480
481
        // Wrap the whole thing in an "EXISTS"
482
        $sql = 'SELECT EXISTS(' . $statement->sql($params) . ')';
483
        $result = DB::prepared_query($sql, $params);
484
        $row = $result->first();
485
        $result = reset($row);
486
487
        // Checking for 't' supports PostgreSQL before silverstripe/[email protected]
488
        return $result === true || $result === 1 || $result === 't';
489
    }
490
491
    /**
492
     * Return the maximum value of the given field in this DataList
493
     *
494
     * @param string $field Unquoted database column name. Will be ANSI quoted
495
     * automatically so must not contain double quotes.
496
     * @return string
497
     */
498
    public function max($field)
499
    {
500
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
501
        if (!$table) {
502
            return $this->aggregate("MAX(\"$field\")");
503
        }
504
        return $this->aggregate("MAX(\"$table\".\"$field\")");
505
    }
506
507
    /**
508
     * Return the minimum value of the given field in this DataList
509
     *
510
     * @param string $field Unquoted database column name. Will be ANSI quoted
511
     * automatically so must not contain double quotes.
512
     * @return string
513
     */
514
    public function min($field)
515
    {
516
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
517
        if (!$table) {
518
            return $this->aggregate("MIN(\"$field\")");
519
        }
520
        return $this->aggregate("MIN(\"$table\".\"$field\")");
521
    }
522
523
    /**
524
     * Return the average value of the given field in this DataList
525
     *
526
     * @param string $field Unquoted database column name. Will be ANSI quoted
527
     * automatically so must not contain double quotes.
528
     * @return string
529
     */
530
    public function avg($field)
531
    {
532
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
533
        if (!$table) {
534
            return $this->aggregate("AVG(\"$field\")");
535
        }
536
        return $this->aggregate("AVG(\"$table\".\"$field\")");
537
    }
538
539
    /**
540
     * Return the sum of the values of the given field in this DataList
541
     *
542
     * @param string $field Unquoted database column name. Will be ANSI quoted
543
     * automatically so must not contain double quotes.
544
     * @return string
545
     */
546
    public function sum($field)
547
    {
548
        $table = DataObject::getSchema()->tableForField($this->dataClass, $field);
549
        if (!$table) {
550
            return $this->aggregate("SUM(\"$field\")");
551
        }
552
        return $this->aggregate("SUM(\"$table\".\"$field\")");
553
    }
554
555
    /**
556
     * Runs a raw aggregate expression.  Please handle escaping yourself
557
     *
558
     * @param string $expression An aggregate expression, such as 'MAX("Balance")', or a set of them
559
     * (as an escaped SQL statement)
560
     * @return string
561
     */
562
    public function aggregate($expression)
563
    {
564
        return $this->getFinalisedQuery()->aggregate($expression)->execute()->value();
565
    }
566
567
    /**
568
     * Return the first row that would be returned by this full DataQuery
569
     * Note that this will issue a separate SELECT ... LIMIT 1 query.
570
     *
571
     * @return SQLSelect
572
     */
573
    public function firstRow()
574
    {
575
        return $this->getFinalisedQuery()->firstRow();
576
    }
577
578
    /**
579
     * Return the last row that would be returned by this full DataQuery
580
     * Note that this will issue a separate SELECT ... LIMIT query.
581
     *
582
     * @return SQLSelect
583
     */
584
    public function lastRow()
585
    {
586
        return $this->getFinalisedQuery()->lastRow();
587
    }
588
589
    /**
590
     * Update the SELECT clause of the query with the columns from the given table
591
     *
592
     * @param SQLSelect $query
593
     * @param string $tableClass Class to select from
594
     * @param array $columns
595
     */
596
    protected function selectColumnsFromTable(SQLSelect &$query, $tableClass, $columns = null)
597
    {
598
        // Add SQL for multi-value fields
599
        $schema = DataObject::getSchema();
600
        $databaseFields = $schema->databaseFields($tableClass, false);
601
        $compositeFields = $schema->compositeFields($tableClass, false);
602
        unset($databaseFields['ID']);
603
        foreach ($databaseFields as $k => $v) {
604
            if ((is_null($columns) || in_array($k, $columns)) && !isset($compositeFields[$k])) {
605
                // Update $collidingFields if necessary
606
                $expressionForField = $query->expressionForField($k);
607
                $quotedField = $schema->sqlColumnForField($tableClass, $k);
608
                if ($expressionForField) {
609
                    if (!isset($this->collidingFields[$k])) {
610
                        $this->collidingFields[$k] = [$expressionForField];
611
                    }
612
                    $this->collidingFields[$k][] = $quotedField;
613
                } else {
614
                    $query->selectField($quotedField, $k);
615
                }
616
            }
617
        }
618
        foreach ($compositeFields as $k => $v) {
619
            if ((is_null($columns) || in_array($k, $columns)) && $v) {
620
                $tableName = $schema->tableName($tableClass);
621
                $dbO = Injector::inst()->create($v, $k);
622
                $dbO->setTable($tableName);
623
                $dbO->addToQuery($query);
624
            }
625
        }
626
    }
627
628
    /**
629
     * Append a GROUP BY clause to this query.
630
     *
631
     * @param string $groupby Escaped SQL statement
632
     * @return $this
633
     */
634
    public function groupby($groupby)
635
    {
636
        $this->query->addGroupBy($groupby);
637
        return $this;
638
    }
639
640
    /**
641
     * Append a HAVING clause to this query.
642
     *
643
     * @param mixed $having Predicate(s) to set, as escaped SQL statements or parameterised queries
644
     * @return $this
645
     */
646
    public function having($having)
647
    {
648
        $this->query->addHaving($having);
649
        return $this;
650
    }
651
652
    /**
653
     * Create a disjunctive subgroup.
654
     *
655
     * That is a subgroup joined by OR
656
     *
657
     * @return DataQuery_SubGroup
658
     */
659
    public function disjunctiveGroup()
660
    {
661
        return new DataQuery_SubGroup($this, 'OR');
662
    }
663
664
    /**
665
     * Create a conjunctive subgroup
666
     *
667
     * That is a subgroup joined by AND
668
     *
669
     * @return DataQuery_SubGroup
670
     */
671
    public function conjunctiveGroup()
672
    {
673
        return new DataQuery_SubGroup($this, 'AND');
674
    }
675
676
    /**
677
     * Adds a WHERE clause.
678
     *
679
     * @see SQLSelect::addWhere() for syntax examples, although DataQuery
680
     * won't expand multiple arguments as SQLSelect does.
681
     *
682
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
683
     * paramaterised queries
684
     * @return $this
685
     */
686
    public function where($filter)
687
    {
688
        if ($filter) {
689
            $this->query->addWhere($filter);
690
        }
691
        return $this;
692
    }
693
694
    /**
695
     * Append a WHERE with OR.
696
     *
697
     * @see SQLSelect::addWhere() for syntax examples, although DataQuery
698
     * won't expand multiple method arguments as SQLSelect does.
699
     *
700
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
701
     * paramaterised queries
702
     * @return $this
703
     */
704
    public function whereAny($filter)
705
    {
706
        if ($filter) {
707
            $this->query->addWhereAny($filter);
708
        }
709
        return $this;
710
    }
711
712
    /**
713
     * Set the ORDER BY clause of this query
714
     *
715
     * @see SQLSelect::orderby()
716
     *
717
     * @param string $sort Column to sort on (escaped SQL statement)
718
     * @param string $direction Direction ("ASC" or "DESC", escaped SQL statement)
719
     * @param bool $clear Clear existing values
720
     * @return $this
721
     */
722
    public function sort($sort = null, $direction = null, $clear = true)
723
    {
724
        if ($clear) {
725
            $this->query->setOrderBy($sort, $direction);
726
        } else {
727
            $this->query->addOrderBy($sort, $direction);
728
        }
729
730
        return $this;
731
    }
732
733
    /**
734
     * Reverse order by clause
735
     *
736
     * @return $this
737
     */
738
    public function reverseSort()
739
    {
740
        $this->query->reverseOrderBy();
741
        return $this;
742
    }
743
744
    /**
745
     * Set the limit of this query.
746
     *
747
     * @param int $limit
748
     * @param int $offset
749
     * @return $this
750
     */
751
    public function limit($limit, $offset = 0)
752
    {
753
        $this->query->setLimit($limit, $offset);
754
        return $this;
755
    }
756
757
    /**
758
     * Set whether this query should be distinct or not.
759
     *
760
     * @param bool $value
761
     * @return $this
762
     */
763
    public function distinct($value)
764
    {
765
        $this->query->setDistinct($value);
766
        return $this;
767
    }
768
769
    /**
770
     * Add an INNER JOIN clause to this query.
771
     *
772
     * @param string $table The unquoted table name to join to.
773
     * @param string $onClause The filter for the join (escaped SQL statement)
774
     * @param string $alias An optional alias name (unquoted)
775
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
776
     * will cause the query to appear first. The default is 20, and joins created automatically by the
777
     * ORM have a value of 10.
778
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
779
     * @return $this
780
     */
781
    public function innerJoin($table, $onClause, $alias = null, $order = 20, $parameters = [])
782
    {
783
        if ($table) {
784
            $this->query->addInnerJoin($table, $onClause, $alias, $order, $parameters);
785
        }
786
        return $this;
787
    }
788
789
    /**
790
     * Add a LEFT JOIN clause to this query.
791
     *
792
     * @param string $table The unquoted table to join to.
793
     * @param string $onClause The filter for the join (escaped SQL statement).
794
     * @param string $alias An optional alias name (unquoted)
795
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
796
     * will cause the query to appear first. The default is 20, and joins created automatically by the
797
     * ORM have a value of 10.
798
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
799
     * @return $this
800
     */
801
    public function leftJoin($table, $onClause, $alias = null, $order = 20, $parameters = [])
802
    {
803
        if ($table) {
804
            $this->query->addLeftJoin($table, $onClause, $alias, $order, $parameters);
805
        }
806
        return $this;
807
    }
808
809
    /**
810
     * Prefix of all joined table aliases. E.g. ->filter('Banner.Image.Title)'
811
     * Will join the Banner, and then Image relations
812
     * `$relationPrefx` will be `banner_image_`
813
     * Each table in the Image chain will be suffixed to this prefix. E.g.
814
     * `banner_image_File` and `banner_image_Image`
815
     *
816
     * This will be null if no relation is joined.
817
     * E.g. `->filter('Title')`
818
     *
819
     * @param string|array $relation Relation in '.' delimited string, or array of parts
820
     * @return string Table prefix
821
     */
822
    public static function applyRelationPrefix($relation)
823
    {
824
        if (!$relation) {
825
            return null;
826
        }
827
        if (is_string($relation)) {
828
            $relation = explode(".", $relation);
829
        }
830
        return strtolower(implode('_', $relation)) . '_';
831
    }
832
833
    /**
834
     * Traverse the relationship fields, and add the table
835
     * mappings to the query object state. This has to be called
836
     * in any overloaded {@link SearchFilter->apply()} methods manually.
837
     *
838
     * Note, that in order to filter against the joined relation user code must
839
     * use {@see tablePrefix()} to get the table alias used for this relation.
840
     *
841
     * @param string|array $relation The array/dot-syntax relation to follow
842
     * @param bool $linearOnly Set to true to restrict to linear relations only. Set this
843
     * if this relation will be used for sorting, and should not include duplicate rows.
844
     * @return string The model class of the related item
845
     */
846
    public function applyRelation($relation, $linearOnly = false)
847
    {
848
        // NO-OP
849
        if (!$relation) {
850
            return $this->dataClass;
851
        }
852
853
        if (is_string($relation)) {
854
            $relation = explode(".", $relation);
855
        }
856
857
        $modelClass = $this->dataClass;
858
859
        $schema = DataObject::getSchema();
860
        $currentRelation = [];
861
        foreach ($relation as $rel) {
862
            // Get prefix for join for this table (and parent to join on)
863
            $parentPrefix = $this->applyRelationPrefix($currentRelation);
864
            $currentRelation[] = $rel;
865
            $tablePrefix = $this->applyRelationPrefix($currentRelation);
866
867
            // Check has_one
868
            if ($component = $schema->hasOneComponent($modelClass, $rel)) {
869
                // Join via has_one
870
                $this->joinHasOneRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix);
871
                $modelClass = $component;
872
                continue;
873
            }
874
875
            // Check has_many
876
            if ($component = $schema->hasManyComponent($modelClass, $rel)) {
877
                // Fail on non-linear relations
878
                if ($linearOnly) {
879
                    throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
880
                }
881
                // Join via has_many
882
                $this->joinHasManyRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix, 'has_many');
883
                $modelClass = $component;
884
                continue;
885
            }
886
887
            // check belongs_to (like has_many but linear safe)
888
            if ($component = $schema->belongsToComponent($modelClass, $rel)) {
889
                // Piggy back off has_many logic
890
                $this->joinHasManyRelation($modelClass, $rel, $component, $parentPrefix, $tablePrefix, 'belongs_to');
891
                $modelClass = $component;
892
                continue;
893
            }
894
895
            // Check many_many
896
            if ($component = $schema->manyManyComponent($modelClass, $rel)) {
897
                // Fail on non-linear relations
898
                if ($linearOnly) {
899
                    throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
900
                }
901
                $this->joinManyManyRelationship(
902
                    $component['relationClass'],
903
                    $component['parentClass'],
904
                    $component['childClass'],
905
                    $component['parentField'],
906
                    $component['childField'],
907
                    $component['join'],
908
                    $parentPrefix,
909
                    $tablePrefix
910
                );
911
                $modelClass = $component['childClass'];
912
                continue;
913
            }
914
915
            // no relation
916
            throw new InvalidArgumentException("$rel is not a relation on model $modelClass");
917
        }
918
919
        return $modelClass;
920
    }
921
922
    /**
923
     * Join the given has_many relation to this query.
924
     * Also works with belongs_to
925
     *
926
     * Doesn't work with polymorphic relationships
927
     *
928
     * @param string $localClass Name of class that has the has_many to the joined class
929
     * @param string $localField Name of the has_many relationship to join
930
     * @param string $foreignClass Class to join
931
     * @param string $localPrefix Table prefix for parent class
932
     * @param string $foreignPrefix Table prefix to use
933
     * @param string $type 'has_many' or 'belongs_to'
934
     */
935
    protected function joinHasManyRelation(
936
        $localClass,
937
        $localField,
938
        $foreignClass,
939
        $localPrefix = null,
940
        $foreignPrefix = null,
941
        $type = 'has_many'
942
    ) {
943
        if (!$foreignClass || $foreignClass === DataObject::class) {
944
            throw new InvalidArgumentException("Could not find a has_many relationship {$localField} on {$localClass}");
945
        }
946
        $schema = DataObject::getSchema();
947
948
        // Skip if already joined
949
        // Note: don't just check base class, since we need to join on the table with the actual relation key
950
        $foreignTable = $schema->tableName($foreignClass);
951
        $foreignTableAliased = $foreignPrefix . $foreignTable;
952
        if ($this->query->isJoinedTo($foreignTableAliased)) {
953
            return;
954
        }
955
956
        // Join table with associated has_one
957
        /** @var DataObject $model */
958
        $foreignKey = $schema->getRemoteJoinField($localClass, $localField, $type, $polymorphic);
959
        $localIDColumn = $schema->sqlColumnForField($localClass, 'ID', $localPrefix);
960
        if ($polymorphic) {
961
            $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}ID", $foreignPrefix);
962
            $foreignKeyClassColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}Class", $foreignPrefix);
963
            $localClassColumn = $schema->sqlColumnForField($localClass, 'ClassName', $localPrefix);
964
            $joinExpression =
965
                "{$foreignKeyIDColumn} = {$localIDColumn} AND {$foreignKeyClassColumn} = {$localClassColumn}";
966
        } else {
967
            $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, $foreignKey, $foreignPrefix);
968
            $joinExpression = "{$foreignKeyIDColumn} = {$localIDColumn}";
969
        }
970
        $this->query->addLeftJoin(
971
            $this->getJoinTableName($foreignClass, $foreignTable),
972
            $joinExpression,
973
            $foreignTableAliased
974
        );
975
976
        // Add join clause to the component's ancestry classes so that the search filter could search on
977
        // its ancestor fields.
978
        $ancestry = ClassInfo::ancestry($foreignClass, true);
979
        $ancestry = array_reverse($ancestry);
980
        foreach ($ancestry as $ancestor) {
981
            $ancestorTable = $schema->tableName($ancestor);
982
            if ($ancestorTable !== $foreignTable) {
983
                $ancestorTableAliased = $foreignPrefix . $ancestorTable;
984
                $this->query->addLeftJoin(
985
                    $this->getJoinTableName($ancestor, $ancestorTable),
986
                    "\"{$foreignTableAliased}\".\"ID\" = \"{$ancestorTableAliased}\".\"ID\"",
987
                    $ancestorTableAliased
988
                );
989
            }
990
        }
991
    }
992
993
    /**
994
     * Join the given class to this query with the given key
995
     *
996
     * @param string $localClass Name of class that has the has_one to the joined class
997
     * @param string $localField Name of the has_one relationship to joi
998
     * @param string $foreignClass Class to join
999
     * @param string $localPrefix Table prefix to use for local class
1000
     * @param string $foreignPrefix Table prefix to use for joined table
1001
     */
1002
    protected function joinHasOneRelation(
1003
        $localClass,
1004
        $localField,
1005
        $foreignClass,
1006
        $localPrefix = null,
1007
        $foreignPrefix = null
1008
    ) {
1009
        if (!$foreignClass) {
1010
            throw new InvalidArgumentException("Could not find a has_one relationship {$localField} on {$localClass}");
1011
        }
1012
1013
        if ($foreignClass === DataObject::class) {
1014
            throw new InvalidArgumentException(
1015
                "Could not join polymorphic has_one relationship {$localField} on {$localClass}"
1016
            );
1017
        }
1018
        $schema = DataObject::getSchema();
1019
1020
        // Skip if already joined
1021
        $foreignBaseClass = $schema->baseDataClass($foreignClass);
1022
        $foreignBaseTable = $schema->tableName($foreignBaseClass);
1023
        if ($this->query->isJoinedTo($foreignPrefix . $foreignBaseTable)) {
1024
            return;
1025
        }
1026
1027
        // Join base table
1028
        $foreignIDColumn = $schema->sqlColumnForField($foreignBaseClass, 'ID', $foreignPrefix);
1029
        $localColumn = $schema->sqlColumnForField($localClass, "{$localField}ID", $localPrefix);
1030
        $this->query->addLeftJoin(
1031
            $this->getJoinTableName($foreignClass, $foreignBaseTable),
1032
            "{$foreignIDColumn} = {$localColumn}",
1033
            $foreignPrefix . $foreignBaseTable
1034
        );
1035
1036
        // Add join clause to the component's ancestry classes so that the search filter could search on
1037
        // its ancestor fields.
1038
        $ancestry = ClassInfo::ancestry($foreignClass, true);
1039
        if (!empty($ancestry)) {
1040
            $ancestry = array_reverse($ancestry);
1041
            foreach ($ancestry as $ancestor) {
1042
                $ancestorTable = $schema->tableName($ancestor);
1043
                if ($ancestorTable !== $foreignBaseTable) {
1044
                    $ancestorTableAliased = $foreignPrefix . $ancestorTable;
1045
                    $this->query->addLeftJoin(
1046
                        $this->getJoinTableName($ancestor, $ancestorTable),
1047
                        "{$foreignIDColumn} = \"{$ancestorTableAliased}\".\"ID\"",
1048
                        $ancestorTableAliased
1049
                    );
1050
                }
1051
            }
1052
        }
1053
    }
1054
1055
    /**
1056
     * Join table via many_many relationship
1057
     *
1058
     * @param string $relationClass
1059
     * @param string $parentClass
1060
     * @param string $componentClass
1061
     * @param string $parentField
1062
     * @param string $componentField
1063
     * @param string $relationClassOrTable Name of relation table
1064
     * @param string $parentPrefix Table prefix for parent class
1065
     * @param string $componentPrefix Table prefix to use for both joined and mapping table
1066
     */
1067
    protected function joinManyManyRelationship(
1068
        $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

1068
        /** @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...
1069
        $parentClass,
1070
        $componentClass,
1071
        $parentField,
1072
        $componentField,
1073
        $relationClassOrTable,
1074
        $parentPrefix = null,
1075
        $componentPrefix = null
1076
    ) {
1077
        $schema = DataObject::getSchema();
1078
1079
        if (class_exists($relationClassOrTable)) {
1080
            // class is provided
1081
            $relationTable = $schema->tableName($relationClassOrTable);
1082
            $relationTableUpdated = $this->getJoinTableName($relationClassOrTable, $relationTable);
1083
        } else {
1084
            // table is provided
1085
            $relationTable = $relationClassOrTable;
1086
            $relationTableUpdated = $relationClassOrTable;
1087
        }
1088
1089
        // Check if already joined to component alias (skip join table for the check)
1090
        $componentBaseClass = $schema->baseDataClass($componentClass);
1091
        $componentBaseTable = $schema->tableName($componentBaseClass);
1092
        $componentAliasedTable = $componentPrefix . $componentBaseTable;
1093
        if ($this->query->isJoinedTo($componentAliasedTable)) {
1094
            return;
1095
        }
1096
1097
        // Join parent class to join table
1098
        $relationAliasedTable = $componentPrefix . $relationTable;
1099
        $parentIDColumn = $schema->sqlColumnForField($parentClass, 'ID', $parentPrefix);
1100
        $this->query->addLeftJoin(
1101
            $relationTableUpdated,
1102
            "\"{$relationAliasedTable}\".\"{$parentField}\" = {$parentIDColumn}",
1103
            $relationAliasedTable
1104
        );
1105
1106
        // Join on base table of component class
1107
        $componentIDColumn = $schema->sqlColumnForField($componentBaseClass, 'ID', $componentPrefix);
1108
            $this->query->addLeftJoin(
1109
                $this->getJoinTableName($componentBaseClass, $componentBaseTable),
1110
                "\"{$relationAliasedTable}\".\"{$componentField}\" = {$componentIDColumn}",
1111
                $componentAliasedTable
1112
            );
1113
1114
        // Add join clause to the component's ancestry classes so that the search filter could search on
1115
        // its ancestor fields.
1116
        $ancestry = ClassInfo::ancestry($componentClass, true);
1117
        $ancestry = array_reverse($ancestry);
1118
        foreach ($ancestry as $ancestor) {
1119
            $ancestorTable = $schema->tableName($ancestor);
1120
            if ($ancestorTable !== $componentBaseTable) {
1121
                $ancestorTableAliased = $componentPrefix . $ancestorTable;
1122
                $this->query->addLeftJoin(
1123
                    $this->getJoinTableName($ancestor, $ancestorTable),
1124
                    "{$componentIDColumn} = \"{$ancestorTableAliased}\".\"ID\"",
1125
                    $ancestorTableAliased
1126
                );
1127
            }
1128
        }
1129
    }
1130
1131
    /**
1132
     * Removes the result of query from this query.
1133
     *
1134
     * @param DataQuery $subtractQuery
1135
     * @param string $field
1136
     * @return $this
1137
     */
1138
    public function subtract(DataQuery $subtractQuery, $field = 'ID')
1139
    {
1140
        $fieldExpression = $subtractQuery->expressionForField($field);
1141
        $subSelect = $subtractQuery->getFinalisedQuery();
1142
        $subSelect->setSelect([]);
1143
        $subSelect->selectField($fieldExpression, $field);
1144
        $subSelect->setOrderBy(null);
1145
        $subSelectSQL = $subSelect->sql($subSelectParameters);
1146
        $this->where([$this->expressionForField($field) . " NOT IN ($subSelectSQL)" => $subSelectParameters]);
1147
1148
        return $this;
1149
    }
1150
1151
    /**
1152
     * Select the only given fields from the given table.
1153
     *
1154
     * @param string $table Unquoted table name (will be escaped automatically)
1155
     * @param array $fields Database column names (will be escaped automatically)
1156
     * @return $this
1157
     */
1158
    public function selectFromTable($table, $fields)
1159
    {
1160
        $fieldExpressions = array_map(function ($item) use ($table) {
1161
            return Convert::symbol2sql("{$table}.{$item}");
1162
        }, $fields);
1163
1164
        $this->query->setSelect($fieldExpressions);
1165
1166
        return $this;
1167
    }
1168
1169
    /**
1170
     * Add the given fields from the given table to the select statement.
1171
     *
1172
     * @param string $table Unquoted table name (will be escaped automatically)
1173
     * @param array $fields Database column names (will be escaped automatically)
1174
     * @return $this
1175
     */
1176
    public function addSelectFromTable($table, $fields)
1177
    {
1178
        $fieldExpressions = array_map(function ($item) use ($table) {
1179
            return Convert::symbol2sql("{$table}.{$item}");
1180
        }, $fields);
1181
1182
        $this->query->addSelect($fieldExpressions);
1183
1184
        return $this;
1185
    }
1186
1187
    /**
1188
     * Query the given field column from the database and return as an array.
1189
     * querying DB columns of related tables is supported but you need to make sure that the related table
1190
     * is already available in join
1191
     *
1192
     * @see DataList::applyRelation()
1193
     *
1194
     * example use:
1195
     *
1196
     * <code>
1197
     *  column("MyTable"."Title")
1198
     *
1199
     *  or
1200
     *
1201
     *  $columnName = null;
1202
     *  Category::get()
1203
     *    ->applyRelation('Products.Title', $columnName)
1204
     *    ->column($columnName);
1205
     * </code>
1206
     *
1207
     * @param string $field See {@link expressionForField()}.
1208
     * @return array List of column values for the specified column
1209
     * @throws InvalidArgumentException
1210
     */
1211
    public function column($field = 'ID')
1212
    {
1213
        $fieldExpression = $this->expressionForField($field);
1214
        $query = $this->getFinalisedQuery([$field]);
1215
        $originalSelect = $query->getSelect();
1216
        $query->setSelect([]);
1217
1218
        // field wasn't recognised as a valid field from the table class hierarchy
1219
        // check if the field is in format "<table_name>"."<column_name>"
1220
        // if that's the case we may want to query related table
1221
        if (!$fieldExpression) {
1222
            if (!$this->validateColumnField($field, $query)) {
1223
                throw new InvalidArgumentException('Invalid column name ' . $field);
1224
            }
1225
1226
            $fieldExpression = $field;
1227
            $field = null;
1228
        }
1229
1230
        $query->selectField($fieldExpression, $field);
1231
        $this->ensureSelectContainsOrderbyColumns($query, $originalSelect);
1232
1233
        return $query->execute()->column($field);
1234
    }
1235
1236
    /**
1237
     * @param string $field Select statement identifier, either the unquoted column name,
1238
     * the full composite SQL statement, or the alias set through {@link SQLSelect->selectField()}.
1239
     * @return string The expression used to query this field via this DataQuery
1240
     */
1241
    protected function expressionForField($field)
1242
    {
1243
        // Prepare query object for selecting this field
1244
        $query = $this->getFinalisedQuery([$field]);
1245
1246
        // Allow query to define the expression for this field
1247
        $expression = $query->expressionForField($field);
1248
        if (!empty($expression)) {
1249
            return $expression;
1250
        }
1251
1252
        // Special case for ID, if not provided
1253
        if ($field === 'ID') {
1254
            return DataObject::getSchema()->sqlColumnForField($this->dataClass, 'ID');
1255
        }
1256
        return null;
1257
    }
1258
1259
    /**
1260
     * Select the given field expressions.
1261
     *
1262
     * @param string $fieldExpression String The field to select (escaped SQL statement)
1263
     * @param string $alias String The alias of that field (escaped SQL statement)
1264
     */
1265
    public function selectField($fieldExpression, $alias = null)
1266
    {
1267
        $this->query->selectField($fieldExpression, $alias);
1268
    }
1269
1270
    //// QUERY PARAMS
1271
1272
    /**
1273
     * An arbitrary store of query parameters that can be used by decorators.
1274
     */
1275
    private $queryParams;
1276
1277
    /**
1278
     * Set an arbitrary query parameter, that can be used by decorators to add additional meta-data to the query.
1279
     * It's expected that the $key will be namespaced, e.g, 'Versioned.stage' instead of just 'stage'.
1280
     *
1281
     * @param string $key
1282
     * @param string|array $value
1283
     * @return $this
1284
     */
1285
    public function setQueryParam($key, $value)
1286
    {
1287
        $this->queryParams[$key] = $value;
1288
        return $this;
1289
    }
1290
1291
    /**
1292
     * Set an arbitrary query parameter, that can be used by decorators to add additional meta-data to the query.
1293
     *
1294
     * @param string $key
1295
     * @return string
1296
     */
1297
    public function getQueryParam($key)
1298
    {
1299
        if (isset($this->queryParams[$key])) {
1300
            return $this->queryParams[$key];
1301
        }
1302
        return null;
1303
    }
1304
1305
    /**
1306
     * Returns all query parameters
1307
     * @return array query parameters array
1308
     */
1309
    public function getQueryParams()
1310
    {
1311
        return $this->queryParams;
1312
    }
1313
1314
    /**
1315
     * Get query manipulators
1316
     *
1317
     * @return DataQueryManipulator[]
1318
     */
1319
    public function getDataQueryManipulators()
1320
    {
1321
        return $this->dataQueryManipulators;
1322
    }
1323
1324
    /**
1325
     * Assign callback to be invoked in getFinalisedQuery()
1326
     *
1327
     * @param DataQueryManipulator $manipulator
1328
     * @return $this
1329
     */
1330
    public function pushQueryManipulator(DataQueryManipulator $manipulator)
1331
    {
1332
        $this->dataQueryManipulators[] = $manipulator;
1333
        return $this;
1334
    }
1335
1336
    private function validateColumnField($field, SQLSelect $query)
1337
    {
1338
        // standard column - nothing to process here
1339
        if (strpos($field, '.') === false) {
1340
            return false;
1341
        }
1342
1343
        $fieldData = explode('.', $field);
1344
        $tablePrefix = str_replace('"', '', $fieldData[0]);
1345
1346
        // check if related table is available
1347
        return $query->isJoinedTo($tablePrefix);
1348
    }
1349
1350
    /**
1351
     * Use this extension point to alter the table name
1352
     * useful for versioning for example
1353
     *
1354
     * @param $class
1355
     * @param $table
1356
     * @return mixed
1357
     */
1358
    private function getJoinTableName($class, $table)
1359
    {
1360
        $updated = $table;
1361
        $this->invokeWithExtensions('updateJoinTableName', $class, $table, $updated);
1362
1363
        return $updated;
1364
    }
1365
}
1366