Completed
Push — master ( 9e3f76...51d53f )
by Hamish
10:45
created

DataQuery::lastRow()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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