-
Notifications
You must be signed in to change notification settings - Fork 811
Expand file tree
/
Copy pathDataQuery.php
More file actions
1670 lines (1500 loc) · 61.3 KB
/
Copy pathDataQuery.php
File metadata and controls
1670 lines (1500 loc) · 61.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
namespace SilverStripe\ORM;
use Exception;
use InvalidArgumentException;
use ReflectionException;
use ReflectionMethod;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Resettable;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\Connect\Query;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\Queries\SQLConditionGroup;
use SilverStripe\ORM\Queries\SQLSelect;
use SilverStripe\Security\RandomGenerator;
/**
* An object representing a query of data from the DataObject's supporting database.
* Acts as a wrapper over {@link SQLSelect} and performs all of the query generation.
* Used extensively by {@link DataList}.
*
* Unlike DataList, modifiers on DataQuery modify the object rather than returning a clone.
* DataList is immutable, DataQuery is mutable.
*/
class DataQuery implements Resettable
{
use Extensible;
/**
* @var string
*/
protected $dataClass;
/**
* @var SQLSelect
*/
protected $query;
/**
* Map of all field names to an array of conflicting column SQL
*
* E.g.
* [
* 'Title' => [
* '"MyTable"."Title"',
* '"AnotherTable"."Title"',
* ]
* ]
*
* @var array
*/
protected $collidingFields = [];
/**
* If true, collisions are allowed for statements aliased as db columns
*/
private $allowCollidingFieldStatements = false;
/**
* Allows custom callback to be registered before getFinalisedQuery is called.
*
* @var DataQueryManipulator[]
*/
protected $dataQueryManipulators = [];
private $queriedColumns = null;
/**
* @var bool
*/
private $queryFinalised = false;
protected $querySubclasses = true;
protected $filterByClassName = true;
/**
* A cache of database field specs and whether they implement an addToQuery() method
* @see DataQuery::shouldAddToQuery()
* @internal
*/
private static array $shouldAddToQueryCache = [];
/**
* Whether the query result should be cached or not
*/
private bool $useCache = false;
/**
* @inheritDoc
* For most purposes, do not call this method directly. Call reset on DataList instead.
*/
public static function reset(string $class = ''): void
{
if (!$class) {
SQLSelect::reset();
return;
}
// Reset for all superclasses as well, since superclass queries
// include records from subclasses
$classHierarchy = ClassInfo::ancestry($class);
foreach ($classHierarchy as $currentClass) {
SQLSelect::reset($currentClass);
}
}
/**
* Create a new DataQuery.
*
* @param string $dataClass The name of the DataObject class that you wish to query
*/
public function __construct($dataClass)
{
$this->dataClass = $dataClass;
$this->initialiseQuery();
}
/**
* Clone this object
*/
public function __clone()
{
$this->query = clone $this->query;
}
/**
* Return the {@link DataObject} class that is being queried.
*
* @return string
*/
public function dataClass()
{
return $this->dataClass;
}
/**
* Return the {@link SQLSelect} object that represents the current query; note that it will
* be a clone of the object.
*
* @return SQLSelect
*/
public function query()
{
return $this->getFinalisedQuery();
}
/**
* Remove a filter from the query
*
* @param string|array $fieldExpression The predicate of the condition to remove
* (ignoring parameters). The expression will be considered a match if it's
* contained within any other predicate.
* @return $this
*/
public function removeFilterOn($fieldExpression)
{
$matched = false;
// If given a parameterised condition extract only the condition
if (is_array($fieldExpression)) {
reset($fieldExpression);
$fieldExpression = key($fieldExpression ?? []);
}
$where = $this->query->getWhere();
// Iterate through each condition
foreach ($where as $i => $condition) {
// Rewrite condition groups as plain conditions before comparison
if ($condition instanceof SQLConditionGroup) {
$predicate = $condition->conditionSQL($parameters);
$condition = [$predicate => $parameters];
}
// As each condition is a single length array, do a single
// iteration to extract the predicate and parameters
foreach ($condition as $predicate => $parameters) {
// @see SQLSelect::addWhere for why this is required here
if (strpos($predicate ?? '', $fieldExpression ?? '') !== false) {
unset($where[$i]);
$matched = true;
}
// Enforce single-item condition predicate => parameters structure
break;
}
}
// set the entire where clause back, but clear the original one first
if ($matched) {
$this->query->setWhere($where);
} else {
throw new InvalidArgumentException("Couldn't find $fieldExpression in the query filter.");
}
return $this;
}
/**
* Set up the simplest initial query
*/
protected function initialiseQuery()
{
// Join on base table and let lazy loading join subtables
$baseClass = DataObject::getSchema()->baseDataClass($this->dataClass());
if (!$baseClass) {
throw new InvalidArgumentException("DataQuery::create() Can't find data classes for '{$this->dataClass}'");
}
// Build our initial query
$this->query = new SQLSelect([]);
$this->query->setDistinct(true);
if ($sort = singleton($this->dataClass)->config()->get('default_sort')) {
$this->sort($sort);
}
$baseTable = DataObject::getSchema()->tableName($baseClass);
$this->query->setFrom("\"{$baseTable}\"");
$obj = Injector::inst()->get($baseClass);
$obj->extend('augmentDataQueryCreation', $this->query, $this);
}
/**
* @param array $queriedColumns
* @return $this
*/
public function setQueriedColumns($queriedColumns)
{
$this->queriedColumns = $queriedColumns;
return $this;
}
/**
* Ensure that the query is ready to execute.
*
* @param array|null $queriedColumns Any columns to filter the query by
* @return SQLSelect The finalised sql query
*/
public function getFinalisedQuery($queriedColumns = null)
{
if (!$queriedColumns) {
$queriedColumns = $this->queriedColumns;
}
if ($queriedColumns) {
// Add fixed fields to the query
// ID is a special case and gets added separately later
$fixedFields = DataObject::config()->uninherited('fixed_fields');
unset($fixedFields['ID']);
$queriedColumns = array_merge($queriedColumns, array_keys($fixedFields));
}
$query = clone $this->query;
// Apply manipulators before finalising query
foreach ($this->getDataQueryManipulators() as $manipulator) {
$manipulator->beforeGetFinalisedQuery($this, $queriedColumns, $query);
}
$schema = DataObject::getSchema();
$baseDataClass = $schema->baseDataClass($this->dataClass());
$baseIDColumn = $schema->sqlColumnForField($baseDataClass, 'ID');
$ancestorClasses = ClassInfo::ancestry($this->dataClass(), true);
// Generate the list of tables to iterate over and the list of columns required
// by any existing where clauses. This second step is skipped if we're fetching
// the whole dataobject as any required columns will get selected regardless.
if ($queriedColumns) {
// Specifying certain columns allows joining of child tables
$tableClasses = ClassInfo::dataClassesFor($this->dataClass);
// Ensure that any filtered columns are included in the selected columns
foreach ($query->getWhereParameterised($parameters) as $where) {
// Check for any columns in the form '"Column" = ?' or '"Table"."Column"' = ?
if (preg_match_all(
'/(?:"(?<table>[^"]+)"\.)?"(?<column>[^"]+)"(?:[^\.]|$)/',
$where ?? '',
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$column = $match['column'];
if (!in_array($column, $queriedColumns ?? [])) {
$queriedColumns[] = $column;
}
}
}
}
} else {
$tableClasses = $ancestorClasses;
}
// Iterate over the tables and check what we need to select from them. If any selects are made (or the table is
// required for a select)
foreach ($tableClasses as $tableClass) {
// Determine explicit columns to select
$selectColumns = null;
if ($queriedColumns) {
// Restrict queried columns to that on the selected table
$tableFields = $schema->databaseFields($tableClass, false);
unset($tableFields['ID']);
$selectColumns = array_intersect($queriedColumns ?? [], array_keys($tableFields ?? []));
}
// If this is a subclass without any explicitly requested columns, omit this from the query
if (!in_array($tableClass, $ancestorClasses ?? []) && empty($selectColumns)) {
continue;
}
// Select necessary columns (unless an explicitly empty array)
if ($selectColumns !== []) {
$this->selectColumnsFromTable($query, $tableClass, $selectColumns);
}
// Join if not the base table
if ($tableClass !== $baseDataClass) {
$tableName = $schema->tableName($tableClass);
$query->addLeftJoin(
$tableName,
"\"{$tableName}\".\"ID\" = {$baseIDColumn}",
$tableName,
10
);
}
}
// Resolve colliding fields
if ($this->collidingFields) {
foreach ($this->collidingFields as $collisionField => $collisions) {
$caseClauses = [];
$lastClauses = [];
foreach ($collisions as $collision) {
if (preg_match('/^"(?<table>[^"]+)"\./', $collision ?? '', $matches)) {
$collisionTable = $matches['table'];
$collisionClass = $schema->tableClass($collisionTable);
if ($collisionClass) {
$collisionClassColumn = $schema->sqlColumnForField($collisionClass, 'ClassName');
$collisionClasses = ClassInfo::subclassesFor($collisionClass);
$collisionClassesSQL = implode(', ', Convert::raw2sql($collisionClasses, true));
// Only add clause if this is already joined to avoid "Unknown column 'ClassName'" error
$collisionTableForClassName = $schema->tableForField($collisionClass, 'ClassName');
if (array_key_exists($collisionTableForClassName, $query->getFrom())) {
$caseClauses[] = "WHEN {$collisionClassColumn} IN ({$collisionClassesSQL}) THEN $collision";
}
}
} else {
if ($this->getAllowCollidingFieldStatements()) {
$lastClauses[] = "WHEN $collision IS NOT NULL THEN $collision";
} else {
user_error("Bad collision item '$collision'", E_USER_WARNING);
}
}
}
$caseClauses = array_merge($caseClauses, $lastClauses);
if (!empty($caseClauses)) {
$query->selectField("CASE " . implode(" ", $caseClauses) . " ELSE NULL END", $collisionField);
}
}
}
if ($this->filterByClassName) {
// If querying the base class, don't bother filtering on class name
if ($this->dataClass != $baseDataClass) {
// Get the ClassName values to filter to
$classNames = ClassInfo::subclassesFor($this->dataClass);
$classNamesPlaceholders = DB::placeholders($classNames);
$baseClassColumn = $schema->sqlColumnForField($baseDataClass, 'ClassName');
$query->addWhere([
"{$baseClassColumn} IN ($classNamesPlaceholders)" => $classNames
]);
}
}
// Select ID
$query->selectField($baseIDColumn, "ID");
// Select RecordClassName
$baseClassColumn = $schema->sqlColumnForField($baseDataClass, 'ClassName');
$query->selectField(
"
CASE WHEN {$baseClassColumn} IS NOT NULL THEN {$baseClassColumn}
ELSE " . Convert::raw2sql($baseDataClass, true) . " END",
"RecordClassName"
);
$obj = Injector::inst()->get($this->dataClass);
$obj->extend('augmentSQL', $query, $this);
$this->ensureSelectContainsOrderbyColumns($query);
// Apply post-finalisation manipulations
foreach ($this->getDataQueryManipulators() as $manipulator) {
$manipulator->afterGetFinalisedQuery($this, $queriedColumns, $query);
}
return $query;
}
/**
* Ensure that if a query has an order by clause, those columns are present in the select.
*
* @param SQLSelect $query
* @param array $originalSelect
*/
protected function ensureSelectContainsOrderbyColumns($query, $originalSelect = [])
{
if ($orderby = $query->getOrderBy()) {
$newOrderby = [];
foreach ($orderby as $k => $dir) {
$newOrderby[$k] = $dir;
// don't touch functions in the ORDER BY or public function calls
// selected as fields
if (strpos($k ?? '', '(') !== false) {
continue;
}
$col = str_replace('"', '', trim($k ?? ''));
$parts = explode('.', $col ?? '');
// Pull through SortColumn references from the originalSelect variables
if (preg_match('/_SortColumn/', $col ?? '')) {
if (isset($originalSelect[$col])) {
$query->selectField($originalSelect[$col], $col);
}
continue;
}
// If we're already selecting the value with an expression (for example using a case statement or function call,
// which may select values in dynamic ways), just make sure the sort column is quotes and move on.
$selects = $query->getSelect();
// This regex looks for anything that isn't a single column (with optional table name and ANSI quotes).
if (isset($selects[$col]) && !preg_match('/^\s*"?[^.("]+"?\.?"?[^("]*"?\s*$/', $selects[$col])) {
unset($newOrderby[$k]);
$newOrderby['"' . $col . '"'] = $dir;
continue;
}
// Make sure we select the correct column, and both fully qualify and ANSI quote the sort reference.
if (count($parts ?? []) == 1) {
// Get expression for sort value
$qualCol = "\"{$parts[0]}\"";
$table = DataObject::getSchema()->tableForField($this->dataClass(), $parts[0]);
if ($table) {
$qualCol = "\"{$table}\".{$qualCol}";
}
// remove original sort
unset($newOrderby[$k]);
// add new columns sort
$newOrderby[$qualCol] = $dir;
// To-do: Remove this if block once SQLSelect::$select has been refactored to store getSelect()
// format internally; then this check can be part of selectField()
if (!isset($selects[$col]) && !in_array($qualCol, $selects ?? [])) {
// Use the original select if possible.
if (array_key_exists($col, $originalSelect ?? [])) {
$query->selectField($originalSelect[$col], $col);
} else {
$query->selectField($qualCol);
}
}
} else {
$qualCol = '"' . implode('"."', $parts) . '"';
if (!in_array($qualCol, $query->getSelect() ?? [])) {
unset($newOrderby[$k]);
// Find the first free "_SortColumnX" slot
// and assign it to $key
$i = 0;
while (isset($newOrderby[$key = "\"_SortColumn$i\""]) || isset($orderby[$key = "\"_SortColumn$i\""])) {
++$i;
}
$newOrderby[$key] = $dir;
$query->selectField($qualCol, "_SortColumn$i");
}
}
}
$query->setOrderBy($newOrderby);
}
}
/**
* Execute the query and return the result as {@link SS_Query} object.
*
* @return Query
*/
public function execute()
{
return $this->withCorrectDatabase(
fn() => $this->getFinalisedQuery()->execute()
);
}
/**
* Return this query's SQL
*
* @param array $parameters Out variable for parameters required for this query
* @return string The resulting SQL query (may be parameterised)
*/
public function sql(&$parameters = [])
{
return $this->getFinalisedQuery()->sql($parameters);
}
/**
* Return the number of records in this query.
* Note that this will issue a separate SELECT COUNT() query.
*
* @return int
*/
public function count()
{
$quotedColumn = DataObject::getSchema()->sqlColumnForField($this->dataClass(), 'ID');
return $this->withCorrectDatabase(function () use ($quotedColumn) {
$finalisedQuery = $this->getFinalisedQuery();
// COUNT(DISTINCT ...) can be slower compared to COUNT(...) because it requires sorting and removing duplicates to find the unique values
// When using only one table and counting by ID (and since there are no NULL IDs), we can ignore DISTINCT
if (count($finalisedQuery->getFrom()) === 1) {
return $finalisedQuery->count($quotedColumn);
}
// The COUNT(DISTINCT ...) is added in case a join is added to the query (to apply a filter on related records)
return $finalisedQuery->count("DISTINCT {$quotedColumn}");
});
}
/**
* Return whether this dataquery will have records. This will use `EXISTS` statements in SQL which are more
* performant - especially when used in combination with indexed columns (that you're filtering on)
*
* @return bool
*/
public function exists(): bool
{
// Grab a statement selecting "everything" - the engine shouldn't care what's being selected in an "EXISTS"
// statement anyway
$statement = $this->getFinalisedQuery();
// Clear distinct, and order as it's not relevant for an exists query
$statement->setDistinct(false);
$statement->setOrderBy(null);
// We can remove grouping if there's no "having" that might be relying on an aggregate
// Additionally, the columns being selected no longer matter
$having = $statement->getHaving();
if (empty($having)) {
$statement->setSelect('*');
$statement->setGroupBy(null);
}
// Wrap the whole thing in an "EXISTS"
$subQuerySql = $statement->sql($params);
$selectExists = SQLSelect::create('1')->setUseCache($this->useCache, $this->dataClass())->addWhere(['EXISTS (' . $subQuerySql . ')' => $params]);
$queryResult = $this->withCorrectDatabase(
fn() => $selectExists->execute()
);
$row = $queryResult->record();
if ($row) {
$result = reset($row);
} else {
$result = false;
}
return $result === true || $result === 1 || $result === '1';
}
/**
* Return the maximum value of the given field in this DataList
*
* @param string $field Unquoted database column name. Will be ANSI quoted
* automatically so must not contain double quotes.
* @return string
*/
public function max($field)
{
$table = DataObject::getSchema()->tableForField($this->dataClass, $field);
if (!$table) {
return $this->aggregate("MAX(\"$field\")");
}
return $this->aggregate("MAX(\"$table\".\"$field\")");
}
/**
* Return the minimum value of the given field in this DataList
*
* @param string $field Unquoted database column name. Will be ANSI quoted
* automatically so must not contain double quotes.
* @return string
*/
public function min($field)
{
$table = DataObject::getSchema()->tableForField($this->dataClass, $field);
if (!$table) {
return $this->aggregate("MIN(\"$field\")");
}
return $this->aggregate("MIN(\"$table\".\"$field\")");
}
/**
* Return the average value of the given field in this DataList
*
* @param string $field Unquoted database column name. Will be ANSI quoted
* automatically so must not contain double quotes.
* @return string
*/
public function avg($field)
{
$table = DataObject::getSchema()->tableForField($this->dataClass, $field);
if (!$table) {
return $this->aggregate("AVG(\"$field\")");
}
return $this->aggregate("AVG(\"$table\".\"$field\")");
}
/**
* Return the sum of the values of the given field in this DataList
*
* @param string $field Unquoted database column name. Will be ANSI quoted
* automatically so must not contain double quotes.
* @return string
*/
public function sum($field)
{
$table = DataObject::getSchema()->tableForField($this->dataClass, $field);
if (!$table) {
return $this->aggregate("SUM(\"$field\")");
}
return $this->aggregate("SUM(\"$table\".\"$field\")");
}
/**
* Runs a raw aggregate expression. Please handle escaping yourself
*
* @param string $expression An aggregate expression, such as 'MAX("Balance")', or a set of them
* (as an escaped SQL statement)
* @return string
*/
public function aggregate($expression)
{
return $this->withCorrectDatabase(
fn() => $this->getFinalisedQuery()->aggregate($expression)->execute()->value()
);
}
/**
* Return the first row that would be returned by this full DataQuery
* Note that this will issue a separate SELECT ... LIMIT 1 query.
*
* @return SQLSelect
*/
public function firstRow()
{
return $this->withCorrectDatabase(
fn() => $this->getFinalisedQuery()->firstRow()
);
}
/**
* Return the last row that would be returned by this full DataQuery
* Note that this will issue a separate SELECT ... LIMIT query.
*
* @return SQLSelect
*/
public function lastRow()
{
return $this->withCorrectDatabase(
fn() => $this->getFinalisedQuery()->lastRow()
);
}
/**
* Update the SELECT clause of the query with the columns from the given table
*
* @param SQLSelect $query
* @param string $tableClass Class to select from
* @param array $columns
*/
protected function selectColumnsFromTable(SQLSelect &$query, $tableClass, $columns = null)
{
// Add SQL for multi-value fields
$schema = DataObject::getSchema();
$databaseFields = $schema->databaseFields($tableClass, false);
$compositeFields = $schema->compositeFields($tableClass, false);
$tableName = $schema->tableName($tableClass);
unset($databaseFields['ID']);
foreach ($databaseFields as $k => $v) {
if ((is_null($columns) || in_array($k, $columns ?? [])) && !isset($compositeFields[$k])) {
// Update $collidingFields if necessary
$expressionForField = $query->expressionForField($k);
$quotedField = $schema->sqlColumnForField($tableClass, $k);
if ($expressionForField) {
if (!isset($this->collidingFields[$k])) {
$this->collidingFields[$k] = [$expressionForField];
}
$this->collidingFields[$k][] = $quotedField;
} else {
$query->selectField($quotedField, $k);
}
if ($this->shouldAddToQuery($v)) {
$dbO = Injector::inst()->create($v, $k);
$dbO->setTable($tableName);
$dbO->addToQuery($query);
}
}
}
foreach ($compositeFields as $k => $v) {
if ((is_null($columns) || in_array($k, $columns ?? [])) && $v && $this->shouldAddToQuery($v)) {
$dbO = Injector::inst()->create($v, $k);
$dbO->setTable($tableName);
$dbO->addToQuery($query);
}
}
}
/**
* This method determines whether a subclass of DBField has overridden the DBField::addToQuery() method to
* implement custom logic. This allows selectColumnsFromTable() to skip the relatively expensive step of
* building singleton objects to call the method if it has no effect
*
* @param string $dbFieldSpec A string like "Varchar(255)"
* @throws Exception
* @internal
*/
private function shouldAddToQuery(string $dbFieldSpec): bool
{
[$serviceName] = ClassInfo::parse_class_spec($dbFieldSpec);
if (array_key_exists($serviceName, DataQuery::$shouldAddToQueryCache)) {
return DataQuery::$shouldAddToQueryCache[$serviceName];
}
$class = Injector::inst()->getServiceSpec($serviceName)['class'] ?? $serviceName;
$method = new ReflectionMethod($class, 'addToQuery');
$shouldAddToQuery = $method->getDeclaringClass()->getName() !== DBField::class;
DataQuery::$shouldAddToQueryCache[$serviceName] = $shouldAddToQuery;
return DataQuery::$shouldAddToQueryCache[$serviceName];
}
/**
* Append a GROUP BY clause to this query.
*
* @param string $groupby Escaped SQL statement
* @return $this
*/
public function groupby($groupby)
{
$this->query->addGroupBy($groupby);
return $this;
}
/**
* Append a HAVING clause to this query.
*
* @param mixed $having Predicate(s) to set, as escaped SQL statements or parameterised queries
* @return $this
*/
public function having($having)
{
$this->query->addHaving($having);
return $this;
}
/**
* Add a query to UNION with.
*
* @param string|null $type One of the SQLSelect::UNION_ALL or SQLSelect::UNION_DISTINCT constants - or null for a default union
*/
public function union(DataQuery|SQLSelect $query, ?string $type = null): static
{
if ($query instanceof DataQuery) {
$query = $query->query();
}
$this->query->addUnion($query, $type);
return $this;
}
/**
* Create a disjunctive subgroup.
*
* That is a subgroup joined by OR
*/
public function disjunctiveGroup(string $clause = 'WHERE'): DataQuery_SubGroup
{
return new DataQuery_SubGroup($this, 'OR', $clause);
}
/**
* Create a conjunctive subgroup
*
* That is a subgroup joined by AND
*/
public function conjunctiveGroup(string $clause = 'WHERE'): DataQuery_SubGroup
{
return new DataQuery_SubGroup($this, 'AND', $clause);
}
/**
* Adds a Common Table Expression (CTE), aka WITH clause.
*
* Use of this method should usually be within a conditional check against DB::get_conn()->supportsCteQueries().
*
* @param string $name The name of the WITH clause, which can be referenced in any queries UNIONed to the $query
* and in this query directly, as though it were a table name.
* @param string[] $cteFields Aliases for any columns selected in $query which can be referenced in any queries
* UNIONed to the $query and in this query directly, as though they were columns in a real table.
* NOTE: If $query is a DataQuery, then cteFields must be the names of real columns on that DataQuery's data class.
*/
public function with(string $name, DataQuery|SQLSelect $query, array $cteFields = [], bool $recursive = false): static
{
$schema = DataObject::getSchema();
// If the query is a DataQuery, make sure all manipulators, joins, etc are applied
if ($query instanceof DataQuery) {
$cteDataClass = $query->dataClass();
$query = $query->query();
// DataQuery wants to select ALL columns by default,
// but if we're setting cteFields then we only want to select those fields.
if (!empty($cteFields)) {
$selectFields = array_map(fn($colName) => $schema->sqlColumnForField($cteDataClass, $colName), $cteFields);
$query->setSelect($selectFields);
}
}
// Add the WITH clause
$this->query->addWith($name, $query, $cteFields, $recursive);
return $this;
}
/**
* Adds a WHERE clause.
*
* @see SQLSelect::addWhere() for syntax examples, although DataQuery
* won't expand multiple arguments as SQLSelect does.
*
* @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
* parameterised queries
* @return $this
*/
public function where($filter)
{
if ($filter) {
$this->query->addWhere($filter);
}
return $this;
}
/**
* Append a WHERE with OR.
*
* @see SQLSelect::addWhere() for syntax examples, although DataQuery
* won't expand multiple method arguments as SQLSelect does.
*
* @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
* parameterised queries
* @return $this
*/
public function whereAny($filter)
{
if ($filter) {
$this->query->addWhereAny($filter);
}
return $this;
}
/**
* Filters the query where $fieldToFilterBy matches values in $fieldFromOtherList from $subQuery.
*
* @param string $fieldToFilterBy The name of the column in the current query to filter by
* @param string $fieldFromOtherList The name of the column in $subQuery to get filter values from
*/
public function filterByQuery(DataQuery $subQuery, string $fieldToFilterBy = 'ID', string $fieldFromOtherList = 'ID'): static
{
return $this->filterOrExcludeByQuery($subQuery, false, $fieldToFilterBy, $fieldFromOtherList);
}
/**
* Excludes records in the query where $fieldToFilterBy matches values in $fieldFromOtherList from $subQuery.
*
* @param string $fieldToFilterBy The name of the column in the current query to filter by
* @param string $fieldFromOtherList The name of the column in $subQuery to get filter values from
*/
public function excludeByQuery(DataQuery $subQuery, string $fieldToFilterBy = 'ID', string $fieldFromOtherList = 'ID'): static
{
return $this->filterOrExcludeByQuery($subQuery, true, $fieldToFilterBy, $fieldFromOtherList);
}
/**
* Set the ORDER BY clause of this query
*
* Note: while the similarly named DataList::sort() does not allow raw SQL, DataQuery::sort() does allow it
*
* Raw SQL can be vulnerable to SQL injection attacks if used incorrectly, so it's preferable not to use it
*
* @see SQLSelect::orderby()
*
* @param string $sort Column to sort on (escaped SQL statement)
* @param string $direction Direction ("ASC" or "DESC", escaped SQL statement)
* @param bool $clear Clear existing values
* @return $this
*
*/
public function sort($sort = null, $direction = null, $clear = true)
{
if ($clear) {
$this->query->setOrderBy($sort, $direction);
} else {
$this->query->addOrderBy($sort, $direction);
}
return $this;
}
/**
* Reverse order by clause
*
* @return $this
*/
public function reverseSort()
{
$this->query->reverseOrderBy();
return $this;
}
/**
* Set the limit of this query.
*/
public function limit(?int $limit, int $offset = 0): static
{
$this->query->setLimit($limit, $offset);
return $this;
}
/**
* Set whether this query should be distinct or not.
*
* @param bool $value
* @return $this
*/
public function distinct($value)
{
$this->query->setDistinct($value);
return $this;
}
/**
* Add an INNER JOIN clause to this query.
*
* @param string $table The unquoted table name to join to.
* @param string $onClause The filter for the join (escaped SQL statement)
* @param string $alias An optional alias name (unquoted)
* @param int $order A numerical index to control the order that joins are added to the query; lower order values
* will cause the query to appear first. The default is 20, and joins created automatically by the
* ORM have a value of 10.
* @param array $parameters Any additional parameters if the join is a parameterised subquery
* @return $this
*/
public function innerJoin($table, $onClause, $alias = null, $order = 20, $parameters = [])
{
if ($table) {
$this->query->addInnerJoin($table, $onClause, $alias, $order, $parameters);
}
return $this;
}
/**
* Add a LEFT JOIN clause to this query.
*
* @param string $table The unquoted table to join to.
* @param string $onClause The filter for the join (escaped SQL statement).
* @param string $alias An optional alias name (unquoted)
* @param int $order A numerical index to control the order that joins are added to the query; lower order values
* will cause the query to appear first. The default is 20, and joins created automatically by the
* ORM have a value of 10.
* @param array $parameters Any additional parameters if the join is a parameterised subquery
* @return $this
*/
public function leftJoin($table, $onClause, $alias = null, $order = 20, $parameters = [])
{
if ($table) {
$this->query->addLeftJoin($table, $onClause, $alias, $order, $parameters);
}
return $this;
}
/**
* Add a RIGHT JOIN clause to this query.