Completed
Push — master ( e67db6...a9efe4 )
by Damian
10:26
created

DataQuery   D

Complexity

Total Complexity 124

Size/Duplication

Total Lines 920
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 11
Metric Value
wmc 124
lcom 2
cbo 11
dl 0
loc 920
rs 4

44 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
A __clone() 0 3 1
A dataClass() 0 3 1
A query() 0 3 1
C removeFilterOn() 0 41 7
B initialiseQuery() 0 32 4
A setQueriedColumns() 0 3 1
F getFinalisedQuery() 0 111 22
C ensureSelectContainsOrderbyColumns() 0 64 11
A execute() 0 3 1
A sql() 0 3 1
A count() 0 4 1
A max() 0 3 1
A min() 0 3 1
A avg() 0 3 1
A sum() 0 3 1
A aggregate() 0 3 1
A firstRow() 0 3 1
A lastRow() 0 3 1
C selectColumnsFromTable() 0 25 11
A groupby() 0 4 1
A having() 0 4 1
A disjunctiveGroup() 0 3 1
A conjunctiveGroup() 0 3 1
A where() 0 6 2
A whereAny() 0 6 2
A sort() 0 9 2
A reverseSort() 0 4 1
A limit() 0 4 1
A distinct() 0 4 1
A innerJoin() 0 6 2
A leftJoin() 0 6 2
D applyRelation() 0 41 9
C joinHasOneRelation() 0 38 7
C joinHasManyRelation() 0 39 7
B joinManyManyRelationship() 0 24 3
A subtract() 0 11 1
A selectFromTable() 0 9 1
A column() 0 10 1
A expressionForField() 0 15 3
A selectField() 0 3 1
A setQueryParam() 0 3 1
A getQueryParam() 0 4 2
A getQueryParams() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like DataQuery often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DataQuery, and based on these observations, apply Extract Interface, too.

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
	 * @var array
28
	 */
29
	protected $collidingFields = array();
30
31
	private $queriedColumns = null;
32
33
	/**
34
	 * @var Boolean
35
	 */
36
	private $queryFinalised = false;
37
38
	// TODO: replace subclass_access with this
39
	protected $querySubclasses = true;
40
	// TODO: replace restrictclasses with this
41
	protected $filterByClassName = true;
42
43
	/**
44
	 * Create a new DataQuery.
45
	 *
46
	 * @param String The name of the DataObject class that you wish to query
47
	 */
48
	public function __construct($dataClass) {
49
		$this->dataClass = $dataClass;
50
		$this->initialiseQuery();
51
	}
52
53
	/**
54
	 * Clone this object
55
	 */
56
	public function __clone() {
57
		$this->query = clone $this->query;
58
	}
59
60
	/**
61
	 * Return the {@link DataObject} class that is being queried.
62
	 */
63
	public function dataClass() {
64
		return $this->dataClass;
65
	}
66
67
	/**
68
	 * Return the {@link SQLSelect} object that represents the current query; note that it will
69
	 * be a clone of the object.
70
	 */
71
	public function query() {
72
		return $this->getFinalisedQuery();
73
	}
74
75
76
	/**
77
	 * Remove a filter from the query
78
	 *
79
	 * @param string|array $fieldExpression The predicate of the condition to remove
80
	 * (ignoring parameters). The expression will be considered a match if it's
81
	 * contained within any other predicate.
82
	 * @return DataQuery Self reference
83
	 */
84
	public function removeFilterOn($fieldExpression) {
85
		$matched = false;
86
87
		// If given a parameterised condition extract only the condition
88
		if(is_array($fieldExpression)) {
89
			reset($fieldExpression);
90
			$fieldExpression = key($fieldExpression);
91
		}
92
93
		$where = $this->query->getWhere();
94
		// Iterate through each condition
95
		foreach($where as $i => $condition) {
96
97
			// Rewrite condition groups as plain conditions before comparison
98
			if($condition instanceof SQLConditionGroup) {
99
				$predicate = $condition->conditionSQL($parameters);
100
				$condition = array($predicate => $parameters);
101
			}
102
103
			// As each condition is a single length array, do a single
104
			// iteration to extract the predicate and parameters
105
			foreach($condition as $predicate => $parameters) {
106
				// @see SQLSelect::addWhere for why this is required here
107
				if(strpos($predicate, $fieldExpression) !== false) {
108
					unset($where[$i]);
109
					$matched = true;
110
				}
111
				// Enforce single-item condition predicate => parameters structure
112
				break;
113
			}
114
		}
115
116
		// set the entire where clause back, but clear the original one first
117
		if($matched) {
118
			$this->query->setWhere($where);
119
		} else {
120
			throw new InvalidArgumentException("Couldn't find $fieldExpression in the query filter.");
121
		}
122
123
		return $this;
124
	}
125
126
	/**
127
	 * Set up the simplest initial query
128
	 */
129
	public function initialiseQuery() {
130
		// Get the tables to join to.
131
		// Don't get any subclass tables - let lazy loading do that.
132
		$tableClasses = ClassInfo::ancestry($this->dataClass, true);
133
134
		// Error checking
135
		if(!$tableClasses) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $tableClasses 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...
136
			if(!SS_ClassLoader::instance()->hasManifest()) {
137
				user_error("DataObjects have been requested before the manifest is loaded. Please ensure you are not"
138
					. " querying the database in _config.php.", E_USER_ERROR);
139
			} else {
140
				user_error("DataList::create Can't find data classes (classes linked to tables) for"
141
					. " $this->dataClass. Please ensure you run dev/build after creating a new DataObject.",
142
					E_USER_ERROR);
143
			}
144
		}
145
146
		$baseClass = array_shift($tableClasses);
147
148
		// Build our intial query
149
		$this->query = new SQLSelect(array());
150
		$this->query->setDistinct(true);
151
152
		if($sort = singleton($this->dataClass)->stat('default_sort')) {
153
			$this->sort($sort);
154
		}
155
156
		$this->query->setFrom("\"$baseClass\"");
157
158
		$obj = Injector::inst()->get($baseClass);
159
		$obj->extend('augmentDataQueryCreation', $this->query, $this);
160
	}
161
162
	public function setQueriedColumns($queriedColumns) {
163
		$this->queriedColumns = $queriedColumns;
164
	}
165
166
	/**
167
	 * Ensure that the query is ready to execute.
168
	 *
169
	 * @param array|null $queriedColumns Any columns to filter the query by
170
	 * @return SQLSelect The finalised sql query
171
	 */
172
	public function getFinalisedQuery($queriedColumns = null) {
173
		if(!$queriedColumns) $queriedColumns = $this->queriedColumns;
174
		if($queriedColumns) {
175
			$queriedColumns = array_merge($queriedColumns, array('Created', 'LastEdited', 'ClassName'));
176
		}
177
178
		$query = clone $this->query;
179
		$ancestorTables = ClassInfo::ancestry($this->dataClass, true);
180
181
		// Generate the list of tables to iterate over and the list of columns required
182
		// by any existing where clauses. This second step is skipped if we're fetching
183
		// the whole dataobject as any required columns will get selected regardless.
184
		if($queriedColumns) {
185
			// Specifying certain columns allows joining of child tables
186
			$tableClasses = ClassInfo::dataClassesFor($this->dataClass);
187
188
			foreach ($query->getWhereParameterised($parameters) as $where) {
189
				// Check for just the column, in the form '"Column" = ?' and the form '"Table"."Column"' = ?
190
				if (preg_match('/^"([^"]+)"/', $where, $matches) ||
191
					preg_match('/^"([^"]+)"\."[^"]+"/', $where, $matches)) {
192
					if (!in_array($matches[1], $queriedColumns)) $queriedColumns[] = $matches[1];
193
				}
194
			}
195
		} else {
196
			$tableClasses = $ancestorTables;
197
		}
198
199
		$tableNames = array_values($tableClasses);
200
		$baseClass = $tableNames[0];
201
202
		// Iterate over the tables and check what we need to select from them. If any selects are made (or the table is
203
		// required for a select)
204
		foreach($tableClasses as $tableClass) {
0 ignored issues
show
Bug introduced by
The expression $tableClasses of type null|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
205
206
			// Determine explicit columns to select
207
			$selectColumns = null;
208
			if ($queriedColumns) {
209
				// Restrict queried columns to that on the selected table
210
				$tableFields = DataObject::database_fields($tableClass);
211
				unset($tableFields['ID']);
212
				$selectColumns = array_intersect($queriedColumns, array_keys($tableFields));
213
			}
214
215
			// If this is a subclass without any explicitly requested columns, omit this from the query
216
			if(!in_array($tableClass, $ancestorTables) && empty($selectColumns)) continue;
217
218
			// Select necessary columns (unless an explicitly empty array)
219
			if($selectColumns !== array()) {
220
				$this->selectColumnsFromTable($query, $tableClass, $selectColumns);
221
			}
222
223
			// Join if not the base table
224
			if($tableClass !== $baseClass) {
225
				$query->addLeftJoin($tableClass, "\"$tableClass\".\"ID\" = \"$baseClass\".\"ID\"", $tableClass, 10);
226
			}
227
		}
228
229
230
231
		// Resolve colliding fields
232
		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...
233
			foreach($this->collidingFields as $k => $collisions) {
234
				$caseClauses = array();
235
				foreach($collisions as $collision) {
236
					if(preg_match('/^"([^"]+)"/', $collision, $matches)) {
237
						$collisionBase = $matches[1];
238
						if(class_exists($collisionBase)) {
239
						$collisionClasses = ClassInfo::subclassesFor($collisionBase);
240
						$collisionClasses = Convert::raw2sql($collisionClasses, true);
241
						$caseClauses[] = "WHEN \"$baseClass\".\"ClassName\" IN ("
242
							. implode(", ", $collisionClasses) . ") THEN $collision";
243
						}
244
					} else {
245
						user_error("Bad collision item '$collision'", E_USER_WARNING);
246
					}
247
				}
248
				$query->selectField("CASE " . implode( " ", $caseClauses) . " ELSE NULL END", $k);
249
			}
250
		}
251
252
253
		if($this->filterByClassName) {
254
			// If querying the base class, don't bother filtering on class name
255
			if($this->dataClass != $baseClass) {
256
				// Get the ClassName values to filter to
257
				$classNames = ClassInfo::subclassesFor($this->dataClass);
258
				if(!$classNames) user_error("DataList::create() Can't find data sub-classes for '$callerClass'");
0 ignored issues
show
Bug introduced by
The variable $callerClass does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
259
				$classNamesPlaceholders = DB::placeholders($classNames);
0 ignored issues
show
Bug introduced by
It seems like $classNames defined by \ClassInfo::subclassesFor($this->dataClass) on line 257 can also be of type null; however, DB::placeholders() does only seem to accept array|integer, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
260
				$query->addWhere(array(
261
					"\"$baseClass\".\"ClassName\" IN ($classNamesPlaceholders)" => $classNames
262
				));
263
			}
264
		}
265
266
		$query->selectField("\"$baseClass\".\"ID\"", "ID");
267
		$query->selectField("
268
			CASE WHEN \"$baseClass\".\"ClassName\" IS NOT NULL THEN \"$baseClass\".\"ClassName\"
269
			ELSE ".Convert::raw2sql($baseClass, true)." END",
270
			"RecordClassName"
271
		);
272
273
		// TODO: Versioned, Translatable, SiteTreeSubsites, etc, could probably be better implemented as subclasses
274
		// of DataQuery
275
276
		$obj = Injector::inst()->get($this->dataClass);
277
		$obj->extend('augmentSQL', $query, $this);
278
279
		$this->ensureSelectContainsOrderbyColumns($query);
280
281
		return $query;
282
	}
283
284
	/**
285
	 * Ensure that if a query has an order by clause, those columns are present in the select.
286
	 *
287
	 * @param SQLSelect $query
288
	 * @return null
289
	 */
290
	protected function ensureSelectContainsOrderbyColumns($query, $originalSelect = array()) {
291
		$tableClasses = ClassInfo::dataClassesFor($this->dataClass);
292
		$baseClass = array_shift($tableClasses);
293
294
		if($orderby = $query->getOrderBy()) {
295
			$newOrderby = array();
296
			$i = 0;
297
			foreach($orderby as $k => $dir) {
298
				$newOrderby[$k] = $dir;
299
300
				// don't touch functions in the ORDER BY or public function calls
301
				// selected as fields
302
				if(strpos($k, '(') !== false) continue;
303
304
				$col = str_replace('"', '', trim($k));
305
				$parts = explode('.', $col);
306
307
				// Pull through SortColumn references from the originalSelect variables
308
				if(preg_match('/_SortColumn/', $col)) {
309
					if(isset($originalSelect[$col])) {
310
						$query->selectField($originalSelect[$col], $col);
311
					}
312
313
					continue;
314
				}
315
316
				if(count($parts) == 1) {
317
318
					if(DataObject::has_own_table_database_field($baseClass, $parts[0])) {
319
						$qualCol = "\"$baseClass\".\"{$parts[0]}\"";
320
					} else {
321
						$qualCol = "\"$parts[0]\"";
322
					}
323
324
					// remove original sort
325
					unset($newOrderby[$k]);
326
327
					// add new columns sort
328
					$newOrderby[$qualCol] = $dir;
329
330
					// To-do: Remove this if block once SQLSelect::$select has been refactored to store getSelect()
331
					// format internally; then this check can be part of selectField()
332
					$selects = $query->getSelect();
333
					if(!isset($selects[$col]) && !in_array($qualCol, $selects)) {
334
						$query->selectField($qualCol);
335
					}
336
				} else {
337
					$qualCol = '"' . implode('"."', $parts) . '"';
338
339
					if(!in_array($qualCol, $query->getSelect())) {
340
						unset($newOrderby[$k]);
341
342
						$newOrderby["\"_SortColumn$i\""] = $dir;
343
						$query->selectField($qualCol, "_SortColumn$i");
344
345
						$i++;
346
					}
347
				}
348
349
			}
350
351
			$query->setOrderBy($newOrderby);
352
		}
353
	}
354
355
	/**
356
	 * Execute the query and return the result as {@link SS_Query} object.
357
	 *
358
	 * @return SS_Query
359
	 */
360
	public function execute() {
361
		return $this->getFinalisedQuery()->execute();
362
	}
363
364
	/**
365
	 * Return this query's SQL
366
	 *
367
	 * @param array $parameters Out variable for parameters required for this query
368
	 * @return string The resulting SQL query (may be paramaterised)
369
	 */
370
	public function sql(&$parameters = array()) {
371
		return $this->getFinalisedQuery()->sql($parameters);
372
	}
373
374
	/**
375
	 * Return the number of records in this query.
376
	 * Note that this will issue a separate SELECT COUNT() query.
377
	 */
378
	public function count() {
379
		$baseClass = ClassInfo::baseDataClass($this->dataClass);
380
		return $this->getFinalisedQuery()->count("DISTINCT \"$baseClass\".\"ID\"");
381
	}
382
383
	/**
384
	 * Return the maximum value of the given field in this DataList
385
	 *
386
	 * @param String $field Unquoted database column name. Will be ANSI quoted
387
	 * automatically so must not contain double quotes.
388
	 */
389
	public function max($field) {
390
		return $this->aggregate("MAX(\"$field\")");
391
	}
392
393
	/**
394
	 * Return the minimum value of the given field in this DataList
395
	 *
396
	 * @param String $field Unquoted database column name. Will be ANSI quoted
397
	 * automatically so must not contain double quotes.
398
	 */
399
	public function min($field) {
400
		return $this->aggregate("MIN(\"$field\")");
401
	}
402
403
	/**
404
	 * Return the average value of the given field in this DataList
405
	 *
406
	 * @param String $field Unquoted database column name. Will be ANSI quoted
407
	 * automatically so must not contain double quotes.
408
	 */
409
	public function avg($field) {
410
		return $this->aggregate("AVG(\"$field\")");
411
	}
412
413
	/**
414
	 * Return the sum of the values of the given field in this DataList
415
	 *
416
	 * @param String $field Unquoted database column name. Will be ANSI quoted
417
	 * automatically so must not contain double quotes.
418
	 */
419
	public function sum($field) {
420
		return $this->aggregate("SUM(\"$field\")");
421
	}
422
423
	/**
424
	 * Runs a raw aggregate expression.  Please handle escaping yourself
425
	 */
426
	public function aggregate($expression) {
427
		return $this->getFinalisedQuery()->aggregate($expression)->execute()->value();
428
	}
429
430
	/**
431
	 * Return the first row that would be returned by this full DataQuery
432
	 * Note that this will issue a separate SELECT ... LIMIT 1 query.
433
	 */
434
	public function firstRow() {
435
		return $this->getFinalisedQuery()->firstRow();
436
	}
437
438
	/**
439
	 * Return the last row that would be returned by this full DataQuery
440
	 * Note that this will issue a separate SELECT ... LIMIT query.
441
	 */
442
	public function lastRow() {
443
		return $this->getFinalisedQuery()->lastRow();
444
	}
445
446
	/**
447
	 * Update the SELECT clause of the query with the columns from the given table
448
	 */
449
	protected function selectColumnsFromTable(SQLSelect &$query, $tableClass, $columns = null) {
450
		// Add SQL for multi-value fields
451
		$databaseFields = DataObject::database_fields($tableClass);
452
		$compositeFields = DataObject::composite_fields($tableClass, false);
453
		unset($databaseFields['ID']);
454
		foreach($databaseFields as $k => $v) {
455
			if((is_null($columns) || in_array($k, $columns)) && !isset($compositeFields[$k])) {
456
				// Update $collidingFields if necessary
457
				if($expressionForField = $query->expressionForField($k)) {
458
					if(!isset($this->collidingFields[$k])) $this->collidingFields[$k] = array($expressionForField);
459
					$this->collidingFields[$k][] = "\"$tableClass\".\"$k\"";
460
461
				} else {
462
					$query->selectField("\"$tableClass\".\"$k\"", $k);
463
				}
464
			}
465
		}
466
		foreach($compositeFields as $k => $v) {
467
			if((is_null($columns) || in_array($k, $columns)) && $v) {
468
				$dbO = Object::create_from_string($v, $k);
469
				$dbO->setTable($tableClass);
470
				$dbO->addToQuery($query);
471
			}
472
		}
473
	}
474
475
	/**
476
	 * Append a GROUP BY clause to this query.
477
	 *
478
	 * @param String $groupby Escaped SQL statement
479
	 */
480
	public function groupby($groupby) {
481
		$this->query->addGroupBy($groupby);
482
		return $this;
483
	}
484
485
	/**
486
	 * Append a HAVING clause to this query.
487
	 *
488
	 * @param String $having Escaped SQL statement
489
	 */
490
	public function having($having) {
491
		$this->query->addHaving($having);
492
		return $this;
493
	}
494
495
	/**
496
	 * Create a disjunctive subgroup.
497
	 *
498
	 * That is a subgroup joined by OR
499
	 *
500
	 * @return DataQuery_SubGroup
501
	 */
502
	public function disjunctiveGroup() {
503
		return new DataQuery_SubGroup($this, 'OR');
504
	}
505
506
	/**
507
	 * Create a conjunctive subgroup
508
	 *
509
	 * That is a subgroup joined by AND
510
	 *
511
	 * @return DataQuery_SubGroup
512
	 */
513
	public function conjunctiveGroup() {
514
		return new DataQuery_SubGroup($this, 'AND');
515
	}
516
517
	/**
518
	 * Adds a WHERE clause.
519
	 *
520
	 * @see SQLSelect::addWhere() for syntax examples, although DataQuery
521
	 * won't expand multiple arguments as SQLSelect does.
522
	 *
523
	 * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
524
	 * paramaterised queries
525
	 * @return DataQuery
526
	 */
527
	public function where($filter) {
528
		if($filter) {
529
			$this->query->addWhere($filter);
530
		}
531
		return $this;
532
	}
533
534
	/**
535
	 * Append a WHERE with OR.
536
	 *
537
	 * @see SQLSelect::addWhere() for syntax examples, although DataQuery
538
	 * won't expand multiple method arguments as SQLSelect does.
539
	 *
540
	 * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
541
	 * paramaterised queries
542
	 * @return DataQuery
543
	 */
544
	public function whereAny($filter) {
545
		if($filter) {
546
			$this->query->addWhereAny($filter);
547
		}
548
		return $this;
549
	}
550
551
	/**
552
	 * Set the ORDER BY clause of this query
553
	 *
554
	 * @see SQLSelect::orderby()
555
	 *
556
	 * @param String $sort Column to sort on (escaped SQL statement)
557
	 * @param String $direction Direction ("ASC" or "DESC", escaped SQL statement)
558
	 * @param Boolean $clear Clear existing values
559
	 * @return DataQuery
560
	 */
561
	public function sort($sort = null, $direction = null, $clear = true) {
562
		if($clear) {
563
			$this->query->setOrderBy($sort, $direction);
564
		} else {
565
			$this->query->addOrderBy($sort, $direction);
566
		}
567
568
		return $this;
569
	}
570
571
	/**
572
	 * Reverse order by clause
573
	 *
574
	 * @return DataQuery
575
	 */
576
	public function reverseSort() {
577
		$this->query->reverseOrderBy();
578
		return $this;
579
	}
580
581
	/**
582
	 * Set the limit of this query.
583
	 *
584
	 * @param int $limit
585
	 * @param int $offset
586
	 */
587
	public function limit($limit, $offset = 0) {
588
		$this->query->setLimit($limit, $offset);
589
		return $this;
590
	}
591
592
	/**
593
	 * Set whether this query should be distinct or not.
594
	 *
595
	 * @param bool $value
596
	 * @return DataQuery
597
	 */
598
	public function distinct($value) {
599
		$this->query->setDistinct($value);
600
		return $this;
601
	}
602
603
	/**
604
	 * Add an INNER JOIN clause to this query.
605
	 *
606
	 * @param String $table The unquoted table name to join to.
607
	 * @param String $onClause The filter for the join (escaped SQL statement)
608
	 * @param String $alias An optional alias name (unquoted)
609
	 * @param int $order A numerical index to control the order that joins are added to the query; lower order values
610
	 * will cause the query to appear first. The default is 20, and joins created automatically by the
611
	 * ORM have a value of 10.
612
	 * @param array $parameters Any additional parameters if the join is a parameterised subquery
613
	 */
614
	public function innerJoin($table, $onClause, $alias = null, $order = 20, $parameters = array()) {
615
		if($table) {
616
			$this->query->addInnerJoin($table, $onClause, $alias, $order, $parameters);
617
		}
618
		return $this;
619
	}
620
621
	/**
622
	 * Add a LEFT JOIN clause to this query.
623
	 *
624
	 * @param string $table The unquoted table to join to.
625
	 * @param string $onClause The filter for the join (escaped SQL statement).
626
	 * @param string $alias An optional alias name (unquoted)
627
	 * @param int $order A numerical index to control the order that joins are added to the query; lower order values
628
	 * will cause the query to appear first. The default is 20, and joins created automatically by the
629
	 * ORM have a value of 10.
630
	 * @param array $parameters Any additional parameters if the join is a parameterised subquery
631
	 */
632
	public function leftJoin($table, $onClause, $alias = null, $order = 20, $parameters = array()) {
633
		if($table) {
634
			$this->query->addLeftJoin($table, $onClause, $alias, $order, $parameters);
635
		}
636
		return $this;
637
	}
638
639
	/**
640
	 * Traverse the relationship fields, and add the table
641
	 * mappings to the query object state. This has to be called
642
	 * in any overloaded {@link SearchFilter->apply()} methods manually.
643
	 *
644
	 * @param String|array $relation The array/dot-syntax relation to follow
645
	 * @param bool $linearOnly Set to true to restrict to linear relations only. Set this
646
	 * if this relation will be used for sorting, and should not include duplicate rows.
647
	 * @return The model class of the related item
648
	 */
649
	public function applyRelation($relation, $linearOnly = false) {
650
		// NO-OP
651
		if(!$relation) return $this->dataClass;
652
653
		if(is_string($relation)) $relation = explode(".", $relation);
654
655
		$modelClass = $this->dataClass;
656
657
		foreach($relation as $rel) {
658
			$model = singleton($modelClass);
659
			if ($component = $model->hasOneComponent($rel)) {
660
				// Join via has_one
661
				$this->joinHasOneRelation($modelClass, $rel, $component);
662
				$modelClass = $component;
663
664
			} elseif ($component = $model->hasManyComponent($rel)) {
665
				// Fail on non-linear relations
666
				if($linearOnly) {
667
					throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
668
				}
669
				// Join via has_many
670
				$this->joinHasManyRelation($modelClass, $rel, $component);
671
				$modelClass = $component;
672
673
			} elseif ($component = $model->manyManyComponent($rel)) {
674
				// Fail on non-linear relations
675
				if($linearOnly) {
676
					throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
677
				}
678
				// Join via many_many
679
				list($parentClass, $componentClass, $parentField, $componentField, $relationTable) = $component;
680
				$this->joinManyManyRelationship(
681
					$parentClass, $componentClass, $parentField, $componentField, $relationTable
682
				);
683
				$modelClass = $componentClass;
684
685
			}
686
		}
687
688
		return $modelClass;
689
	}
690
691
	/**
692
	 * Join the given class to this query with the given key
693
	 *
694
	 * @param string $localClass Name of class that has the has_one to the joined class
695
	 * @param string $localField Name of the has_one relationship to joi
696
	 * @param string $foreignClass Class to join
697
	 */
698
	protected function joinHasOneRelation($localClass, $localField, $foreignClass)
699
	{
700
		if (!$foreignClass) {
701
			throw new InvalidArgumentException("Could not find a has_one relationship {$localField} on {$localClass}");
702
		}
703
704
		if ($foreignClass === 'DataObject') {
705
			throw new InvalidArgumentException(
706
				"Could not join polymorphic has_one relationship {$localField} on {$localClass}"
707
			);
708
		}
709
710
		// Skip if already joined
711
		if($this->query->isJoinedTo($foreignClass)) {
712
			return;
713
		}
714
715
		$realModelClass = ClassInfo::table_for_object_field($localClass, "{$localField}ID");
716
		$foreignBase = ClassInfo::baseDataClass($foreignClass);
717
		$this->query->addLeftJoin(
718
			$foreignBase,
719
			"\"$foreignBase\".\"ID\" = \"{$realModelClass}\".\"{$localField}ID\""
720
		);
721
722
		/**
723
		 * add join clause to the component's ancestry classes so that the search filter could search on
724
		 * its ancestor fields.
725
		 */
726
		$ancestry = ClassInfo::ancestry($foreignClass, true);
727
		if(!empty($ancestry)){
728
			$ancestry = array_reverse($ancestry);
729
			foreach($ancestry as $ancestor){
730
				if($ancestor != $foreignBase) {
731
					$this->query->addLeftJoin($ancestor, "\"$foreignBase\".\"ID\" = \"$ancestor\".\"ID\"");
732
				}
733
			}
734
		}
735
	}
736
737
	/**
738
	 * Join the given has_many relation to this query.
739
	 *
740
	 * Doesn't work with polymorphic relationships
741
	 *
742
	 * @param string $localClass Name of class that has the has_many to the joined class
743
	 * @param string $localField Name of the has_many relationship to join
744
	 * @param string $foreignClass Class to join
745
	 */
746
	protected function joinHasManyRelation($localClass, $localField, $foreignClass) {
747
		if(!$foreignClass || $foreignClass === 'DataObject') {
748
			throw new InvalidArgumentException("Could not find a has_many relationship {$localField} on {$localClass}");
749
		}
750
		
751
		// Skip if already joined
752
		if($this->query->isJoinedTo($foreignClass)) {
753
			return;
754
		}
755
756
		// Join table with associated has_one
757
		$model = singleton($localClass);
758
		$ancestry = $model->getClassAncestry();
759
		$foreignKey = $model->getRemoteJoinField($localField, 'has_many', $polymorphic);
0 ignored issues
show
Bug introduced by
The variable $polymorphic does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
760
		if($polymorphic) {
761
			$this->query->addLeftJoin(
762
				$foreignClass,
763
				"\"$foreignClass\".\"{$foreignKey}ID\" = \"{$ancestry[0]}\".\"ID\" AND "
764
					. "\"$foreignClass\".\"{$foreignKey}Class\" = \"{$ancestry[0]}\".\"ClassName\""
765
			);
766
		} else {
767
			$this->query->addLeftJoin(
768
				$foreignClass,
769
				"\"$foreignClass\".\"{$foreignKey}\" = \"{$ancestry[0]}\".\"ID\""
770
			);
771
		}
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
		$ancestry = array_reverse($ancestry);
779
		foreach($ancestry as $ancestor){
780
			if($ancestor != $foreignClass){
781
				$this->query->addInnerJoin($ancestor, "\"$foreignClass\".\"ID\" = \"$ancestor\".\"ID\"");
782
			}
783
		}
784
	}
785
786
	/**
787
	 * Join table via many_many relationship
788
	 *
789
	 * @param string $parentClass
790
	 * @param string $componentClass
791
	 * @param string $parentField
792
	 * @param string $componentField
793
	 * @param string $relationTable Name of relation table
794
	 */
795
	protected function joinManyManyRelationship($parentClass, $componentClass, $parentField, $componentField, $relationTable) {
796
		$parentBaseClass = ClassInfo::baseDataClass($parentClass);
797
		$componentBaseClass = ClassInfo::baseDataClass($componentClass);
798
		$this->query->addLeftJoin(
799
			$relationTable,
800
			"\"$relationTable\".\"$parentField\" = \"$parentBaseClass\".\"ID\""
801
		);
802
		$this->query->addLeftJoin(
803
			$componentBaseClass,
804
			"\"$relationTable\".\"$componentField\" = \"$componentBaseClass\".\"ID\""
805
		);
806
807
		/**
808
		 * add join clause to the component's ancestry classes so that the search filter could search on
809
		 * its ancestor fields.
810
		 */
811
		$ancestry = ClassInfo::ancestry($componentClass, true);
812
		$ancestry = array_reverse($ancestry);
813
		foreach($ancestry as $ancestor){
814
			if($ancestor != $componentBaseClass){
815
				$this->query->addInnerJoin($ancestor, "\"$componentBaseClass\".\"ID\" = \"$ancestor\".\"ID\"");
816
			}
817
		}
818
	}
819
820
	/**
821
	 * Removes the result of query from this query.
822
	 *
823
	 * @param DataQuery $subtractQuery
824
	 * @param string $field
825
	 */
826
	public function subtract(DataQuery $subtractQuery, $field='ID') {
827
		$fieldExpression = $subtractQuery->expressionForField($field);
828
		$subSelect = $subtractQuery->getFinalisedQuery();
829
		$subSelect->setSelect(array());
830
		$subSelect->selectField($fieldExpression, $field);
831
		$subSelect->setOrderBy(null);
832
		$subSelectSQL = $subSelect->sql($subSelectParameters);
833
		$this->where(array($this->expressionForField($field)." NOT IN ($subSelectSQL)" => $subSelectParameters));
834
835
		return $this;
836
	}
837
838
	/**
839
	 * Select the given fields from the given table.
840
	 *
841
	 * @param String $table Unquoted table name (will be escaped automatically)
842
	 * @param Array $fields Database column names (will be escaped automatically)
843
	 */
844
	public function selectFromTable($table, $fields) {
845
		$fieldExpressions = array_map(function($item) use($table) {
846
			return "\"$table\".\"$item\"";
847
		}, $fields);
848
849
		$this->query->setSelect($fieldExpressions);
850
851
		return $this;
852
	}
853
854
	/**
855
	 * Query the given field column from the database and return as an array.
856
	 *
857
	 * @param string $field See {@link expressionForField()}.
858
	 * @return array List of column values for the specified column
859
	 */
860
	public function column($field = 'ID') {
861
		$fieldExpression = $this->expressionForField($field);
862
		$query = $this->getFinalisedQuery(array($field));
863
		$originalSelect = $query->getSelect();
864
		$query->setSelect(array());
865
		$query->selectField($fieldExpression, $field);
866
		$this->ensureSelectContainsOrderbyColumns($query, $originalSelect);
867
		
868
		return $query->execute()->column($field);
869
	}
870
871
	/**
872
	 * @param  String $field Select statement identifier, either the unquoted column name,
873
	 * the full composite SQL statement, or the alias set through {@link SQLSelect->selectField()}.
874
	 * @return String The expression used to query this field via this DataQuery
875
	 */
876
	protected function expressionForField($field) {
877
878
		// Prepare query object for selecting this field
879
		$query = $this->getFinalisedQuery(array($field));
880
881
		// Allow query to define the expression for this field
882
		$expression = $query->expressionForField($field);
883
		if(!empty($expression)) return $expression;
884
885
		// Special case for ID, if not provided
886
		if($field === 'ID') {
887
			$baseClass = ClassInfo::baseDataClass($this->dataClass);
888
			return "\"$baseClass\".\"ID\"";
889
		}
890
	}
891
892
	/**
893
	 * Select the given field expressions.
894
	 *
895
	 * @param $fieldExpression String The field to select (escaped SQL statement)
896
	 * @param $alias String The alias of that field (escaped SQL statement)
897
	 */
898
	public function selectField($fieldExpression, $alias = null) {
899
		$this->query->selectField($fieldExpression, $alias);
900
	}
901
902
	//// QUERY PARAMS
903
904
	/**
905
	 * An arbitrary store of query parameters that can be used by decorators.
906
	 * @todo This will probably be made obsolete if we have subclasses of DataList and/or DataQuery.
907
	 */
908
	private $queryParams;
909
910
	/**
911
	 * Set an arbitrary query parameter, that can be used by decorators to add additional meta-data to the query.
912
	 * It's expected that the $key will be namespaced, e.g, 'Versioned.stage' instead of just 'stage'.
913
	 */
914
	public function setQueryParam($key, $value) {
915
		$this->queryParams[$key] = $value;
916
	}
917
918
	/**
919
	 * Set an arbitrary query parameter, that can be used by decorators to add additional meta-data to the query.
920
	 */
921
	public function getQueryParam($key) {
922
		if(isset($this->queryParams[$key])) return $this->queryParams[$key];
923
		else return null;
924
	}
925
926
	/**
927
	 * Returns all query parameters
928
	 * @return array query parameters array
929
	 */
930
	public function getQueryParams() {
931
		return $this->queryParams;
932
	}
933
}
934
935
/**
936
 * Represents a subgroup inside a WHERE clause in a {@link DataQuery}
937
 *
938
 * Stores the clauses for the subgroup inside a specific {@link SQLSelect} object.
939
 * All non-where methods call their DataQuery versions, which uses the base
940
 * query object.
941
 *
942
 * @package framework
943
 */
944
class DataQuery_SubGroup extends DataQuery implements SQLConditionGroup {
945
946
	/**
947
	 *
948
	 * @var SQLSelect
949
	 */
950
	protected $whereQuery;
951
952
	public function __construct(DataQuery $base, $connective) {
953
		$this->dataClass = $base->dataClass;
954
		$this->query = $base->query;
955
		$this->whereQuery = new SQLSelect();
956
		$this->whereQuery->setConnective($connective);
957
958
		$base->where($this);
959
	}
960
961
	public function where($filter) {
962
		if($filter) {
963
			$this->whereQuery->addWhere($filter);
964
		}
965
966
		return $this;
967
	}
968
969
	public function whereAny($filter) {
970
		if($filter) {
971
			$this->whereQuery->addWhereAny($filter);
972
		}
973
974
		return $this;
975
	}
976
977
	public function conditionSQL(&$parameters) {
978
		$parameters = array();
979
980
		// Ignore empty conditions
981
		$where = $this->whereQuery->getWhere();
982
		if(empty($where)) {
983
			return null;
984
		}
985
986
		// Allow database to manage joining of conditions
987
		$sql = DB::get_conn()->getQueryBuilder()->buildWhereFragment($this->whereQuery, $parameters);
988
		return preg_replace('/^\s*WHERE\s*/i', '', $sql);
989
	}
990
}
991