Completed
Push — 4.4 ( 26e3b6...8dcaed )
by
unknown
42s queued 20s
created

DataQuery::getJoinTableName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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

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