Skip to content
This repository
Browse code

API Prep DataList for immutability in 3.1 per 7673

DataList had several methods that should act on a copy and return
that copy, but was instead mutating the existing list.

We cant change this behaviour in the 3.0 line for backwards compt.
reasons, but this makes the desired behavior the default, and
makes disabling the mutation in 3.1 easier

It also introduces two new methods to deal with the common pattern
of wanting to modify the underlying dataQuery, which we want to be
able to reliably do in a way that always acts immutably. The main
method of these two is alterDataQuery
  • Loading branch information...
commit e8e460445724a491f4e706971900ac680270a711 1 parent 1ed41b8
Hamish Friedlander authored

Showing 1 changed file with 230 additions and 93 deletions. Show diff stats Hide diff stats

  1. 323  model/DataList.php
323  model/DataList.php
@@ -2,7 +2,21 @@
2 2
 /**
3 3
  * Implements a "lazy loading" DataObjectSet.
4 4
  * Uses {@link DataQuery} to do the actual query generation.
5  
- * 
  5
+ *
  6
+ * todo 3.1: In 3.0 the below is not currently true for backwards compatible reasons, but code should not rely on current behaviour
  7
+ *
  8
+ * DataLists have two sets of methods.
  9
+ *
  10
+ * 1). Selection methods (SS_Filterable, SS_Sortable, SS_Limitable) change the way the list is built, but does not
  11
+ *     alter underlying data. There are no external affects from selection methods once this list instance is destructed.
  12
+ *
  13
+ * 2). Mutation methods change the underlying data. The change persists into the underlying data storage layer.
  14
+ *
  15
+ * DataLists are _immutable_ as far as selection methods go - they all return new instances of DataList, rather
  16
+ * than change the current list.
  17
+ *
  18
+ * DataLists are _mutable_ as far as mutation methods go - they all act on the existing DataList instance.
  19
+ *
6 20
  * @package framework
7 21
  * @subpackage model
8 22
  */
@@ -67,12 +81,109 @@ public function __clone() {
67 81
 	}
68 82
 	
69 83
 	/**
70  
-	 * Return the internal {@link DataQuery} object for direct manipulation
71  
-	 * 
  84
+	 * Return a copy of the internal {@link DataQuery} object
  85
+	 *
  86
+	 * todo 3.1: In 3.0 the below is not currently true for backwards compatible reasons, but code should not rely on this
  87
+	 * Because the returned value is a copy, modifying it won't affect this list's contents. If
  88
+	 * you want to alter the data query directly, use the alterDataQuery method
  89
+	 *
72 90
 	 * @return DataQuery
73 91
 	 */
74 92
 	public function dataQuery() {
75  
-		return $this->dataQuery;
  93
+		// TODO 3.1: This method potentially mutates self
  94
+		return /* clone */ $this->dataQuery;
  95
+	}
  96
+
  97
+	/**
  98
+	 * @var bool - Indicates if we are in an alterDataQueryCall already, so alterDataQuery can be re-entrant
  99
+	 */
  100
+	protected $inAlterDataQueryCall = false;
  101
+
  102
+	/**
  103
+	 * Return a new DataList instance with the underlying {@link DataQuery} object altered
  104
+	 *
  105
+	 * If you want to alter the underlying dataQuery for this list, this wrapper method
  106
+	 * will ensure that you can do so without mutating the existing List object.
  107
+	 *
  108
+	 * It clones this list, calls the passed callback function with the dataQuery of the new
  109
+	 * list as it's first parameter (and the list as it's second), then returns the list
  110
+	 *
  111
+	 * Note that this function is re-entrant - it's safe to call this inside a callback passed to
  112
+	 * alterDataQuery
  113
+	 *
  114
+	 * @param $callback
  115
+	 * @return DataList
  116
+	 */
  117
+	public function alterDataQuery($callback) {
  118
+		if ($this->inAlterDataQueryCall) {
  119
+			$list = $this;
  120
+
  121
+			$res = $callback($list->dataQuery, $list);
  122
+			if ($res) $list->dataQuery = $res;
  123
+
  124
+			return $list;
  125
+		}
  126
+		else {
  127
+			$list = clone $this;
  128
+			$list->inAlterDataQueryCall = true;
  129
+
  130
+			try {
  131
+				$res = $callback($list->dataQuery, $list);
  132
+				if ($res) $list->dataQuery = $res;
  133
+			}
  134
+			catch (Exception $e) {
  135
+				$list->inAlterDataQueryCall = false;
  136
+				throw $e;
  137
+			}
  138
+
  139
+			$list->inAlterDataQueryCall = false;
  140
+			return $list;
  141
+		}
  142
+	}
  143
+
  144
+	/**
  145
+	 * In 3.0.0 some methods in DataList mutate their list. We don't want to change that in the 3.0.x
  146
+	 * line, but we don't want people relying on it either. This does the same as alterDataQuery, but
  147
+	 * _does_ mutate the existing list.
  148
+	 *
  149
+	 * todo 3.1: All methods that call this need to call alterDataQuery instead
  150
+	 */
  151
+	protected function alterDataQuery_30($callback) {
  152
+		Deprecation::notice('3.1', 'DataList will become immutable in 3.1');
  153
+
  154
+		if ($this->inAlterDataQueryCall) {
  155
+			$res = $callback($this->dataQuery, $this);
  156
+			if ($res) $this->dataQuery = $res;
  157
+
  158
+			return $this;
  159
+		}
  160
+		else {
  161
+			$this->inAlterDataQueryCall = true;
  162
+
  163
+			try {
  164
+				$res = $callback($this->dataQuery, $this);
  165
+				if ($res) $this->dataQuery = $res;
  166
+			}
  167
+			catch (Exception $e) {
  168
+				$this->inAlterDataQueryCall = false;
  169
+				throw $e;
  170
+			}
  171
+
  172
+			$this->inAlterDataQueryCall = false;
  173
+			return $this;
  174
+		}
  175
+	}
  176
+
  177
+	/**
  178
+	 * Return a new DataList instance with the underlying {@link DataQuery} object changed
  179
+	 *
  180
+	 * @param DataQuery $dataQuery
  181
+	 * @return DataList
  182
+	 */
  183
+	public function setDataQuery(DataQuery $dataQuery) {
  184
+		$clone = clone $this;
  185
+		$clone->dataQuery = $dataQuery;
  186
+		return $clone;
76 187
 	}
77 188
 
78 189
 	/**
@@ -85,14 +196,15 @@ public function sql() {
85 196
 	}
86 197
 	
87 198
 	/**
88  
-	 * Add a WHERE clause to the query.
  199
+	 * Return a new DataList instance with a WHERE clause added to this list's query.
89 200
 	 *
90 201
 	 * @param string $filter Escaped SQL statement
91 202
 	 * @return DataList
92 203
 	 */
93 204
 	public function where($filter) {
94  
-		$this->dataQuery->where($filter);
95  
-		return $this;
  205
+		return $this->alterDataQuery_30(function($query) use ($filter){
  206
+			$query->where($filter);
  207
+		});
96 208
 	}
97 209
 
98 210
 	/**
@@ -118,7 +230,7 @@ public function canFilterBy($fieldName) {
118 230
 	}
119 231
 
120 232
 	/**
121  
-	 * Add an join clause to this data list's query.
  233
+	 * Return a new DataList instance with a join clause added to this list's query.
122 234
 	 *
123 235
 	 * @param type $join Escaped SQL statement
124 236
 	 * @return DataList 
@@ -126,12 +238,13 @@ public function canFilterBy($fieldName) {
126 238
 	 */
127 239
 	public function join($join) {
128 240
 		Deprecation::notice('3.0', 'Use innerJoin() or leftJoin() instead.');
129  
-		$this->dataQuery->join($join);
130  
-		return $this;
  241
+		return $this->alterDataQuery_30(function($query) use ($join){
  242
+			$query->join($join);
  243
+		});
131 244
 	}
132 245
 
133 246
 	/**
134  
-	 * Restrict the records returned in this query by a limit clause
  247
+	 * Return a new DataList instance with the records returned in this query restricted by a limit clause
135 248
 	 * 
136 249
 	 * @param int $limit
137 250
 	 * @param int $offset
@@ -143,76 +256,91 @@ public function limit($limit, $offset = 0) {
143 256
 		if($limit && !is_numeric($limit)) {
144 257
 			Deprecation::notice('3.0', 'Please pass limits as 2 arguments, rather than an array or SQL fragment.', Deprecation::SCOPE_GLOBAL);
145 258
 		}
146  
-		$this->dataQuery->limit($limit, $offset);
147  
-		return $this;
  259
+		return $this->alterDataQuery_30(function($query) use ($limit, $offset){
  260
+			$query->limit($limit, $offset);
  261
+		});
148 262
 	}
149 263
 	
150 264
 	/**
151  
-	 * Set the sort order of this data list
  265
+	 * Return a new DataList instance as a copy of this data list with the sort order set
152 266
 	 *
153 267
 	 * @see SS_List::sort()
154 268
 	 * @see SQLQuery::orderby
155  
-	 * @example $list->sort('Name'); // default ASC sorting
156  
-	 * @example $list->sort('Name DESC'); // DESC sorting
157  
-	 * @example $list->sort('Name', 'ASC');
158  
-	 * @example $list->sort(array('Name'=>'ASC,'Age'=>'DESC'));
  269
+	 * @example $list = $list->sort('Name'); // default ASC sorting
  270
+	 * @example $list = $list->sort('Name DESC'); // DESC sorting
  271
+	 * @example $list = $list->sort('Name', 'ASC');
  272
+	 * @example $list = $list->sort(array('Name'=>'ASC,'Age'=>'DESC'));
159 273
 	 *
160 274
 	 * @param String|array Escaped SQL statement. If passed as array, all keys and values are assumed to be escaped.
161 275
 	 * @return DataList
162 276
 	 */
163 277
 	public function sort() {
164  
-		if(count(func_get_args()) == 0) {
  278
+		$count = func_num_args();
  279
+
  280
+		if($count == 0) {
165 281
 			return $this;
166 282
 		}
167 283
 		
168  
-		if(count(func_get_args()) > 2) {
  284
+		if($count > 2) {
169 285
 			throw new InvalidArgumentException('This method takes zero, one or two arguments');
170 286
 		}
171 287
 
172  
-		if(count(func_get_args()) == 2) {
173  
-			// sort('Name','Desc')
174  
-			if(!in_array(strtolower(func_get_arg(1)),array('desc','asc'))){
175  
-				user_error('Second argument to sort must be either ASC or DESC');
176  
-			}
177  
-			
178  
-			$this->dataQuery->sort(func_get_arg(0), func_get_arg(1));
  288
+		$sort = $col = $dir = null;
  289
+
  290
+		if ($count == 2) {
  291
+			list($col, $dir) = func_get_args();
179 292
 		}
180  
-		else if(is_string(func_get_arg(0)) && func_get_arg(0)){
181  
-			// sort('Name ASC')
182  
-			if(stristr(func_get_arg(0), ' asc') || stristr(func_get_arg(0), ' desc')) {
183  
-				$this->dataQuery->sort(func_get_arg(0));
184  
-			} else {
185  
-				$this->dataQuery->sort(func_get_arg(0), 'ASC');
186  
-			}
  293
+		else {
  294
+			$sort = func_get_arg(0);
187 295
 		}
188  
-		else if(is_array(func_get_arg(0))) {
189  
-			// sort(array('Name'=>'desc'));
190  
-			$this->dataQuery->sort(null, null); // wipe the sort
191  
-			
192  
-			foreach(func_get_arg(0) as $col => $dir) {
193  
-				// Convert column expressions to SQL fragment, while still allowing the passing of raw SQL fragments.
194  
-				try {
195  
-					$relCol = $this->getRelationName($col);
196  
-				} catch(InvalidArgumentException $e) {
197  
-					$relCol = $col;
  296
+
  297
+		return $this->alterDataQuery_30(function($query, $list) use ($sort, $col, $dir){
  298
+
  299
+			if ($col) {
  300
+				// sort('Name','Desc')
  301
+				if(!in_array(strtolower($dir),array('desc','asc'))){
  302
+					user_error('Second argument to sort must be either ASC or DESC');
198 303
 				}
199  
-				$this->dataQuery->sort($relCol, $dir, false);
  304
+
  305
+				$query->sort($col, $dir);
200 306
 			}
201  
-		}
202  
-		
203  
-		return $this;
  307
+
  308
+			else if(is_string($sort) && $sort){
  309
+				// sort('Name ASC')
  310
+				if(stristr($sort, ' asc') || stristr($sort, ' desc')) {
  311
+					$query->sort($sort);
  312
+				} else {
  313
+					$query->sort($sort, 'ASC');
  314
+				}
  315
+			}
  316
+
  317
+			else if(is_array($sort)) {
  318
+				// sort(array('Name'=>'desc'));
  319
+				$query->sort(null, null); // wipe the sort
  320
+
  321
+				foreach($sort as $col => $dir) {
  322
+					// Convert column expressions to SQL fragment, while still allowing the passing of raw SQL fragments.
  323
+					try {
  324
+						$relCol = $list->getRelationName($col);
  325
+					} catch(InvalidArgumentException $e) {
  326
+						$relCol = $col;
  327
+					}
  328
+					$query->sort($relCol, $dir, false);
  329
+				}
  330
+			}
  331
+		});
204 332
 	}
205 333
 
206 334
 	/**
207  
-	 * Filter the list to include items with these charactaristics
  335
+	 * Return a copy of this list which only includes items with these charactaristics
208 336
 	 *
209 337
 	 * @see SS_List::filter()
210 338
 	 *
211  
-	 * @example $list->filter('Name', 'bob'); // only bob in the list
212  
-	 * @example $list->filter('Name', array('aziz', 'bob'); // aziz and bob in list
213  
-	 * @example $list->filter(array('Name'=>'bob, 'Age'=>21)); // bob with the age 21
214  
-	 * @example $list->filter(array('Name'=>'bob, 'Age'=>array(21, 43))); // bob with the Age 21 or 43
215  
-	 * @example $list->filter(array('Name'=>array('aziz','bob'), 'Age'=>array(21, 43))); // aziz with the age 21 or 43 and bob with the Age 21 or 43
  339
+	 * @example $list = $list->filter('Name', 'bob'); // only bob in the list
  340
+	 * @example $list = $list->filter('Name', array('aziz', 'bob'); // aziz and bob in list
  341
+	 * @example $list = $list->filter(array('Name'=>'bob, 'Age'=>21)); // bob with the age 21
  342
+	 * @example $list = $list->filter(array('Name'=>'bob, 'Age'=>array(21, 43))); // bob with the Age 21 or 43
  343
+	 * @example $list = $list->filter(array('Name'=>array('aziz','bob'), 'Age'=>array(21, 43))); // aziz with the age 21 or 43 and bob with the Age 21 or 43
216 344
 	 *
217 345
 	 * @todo extract the sql from $customQuery into a SQLGenerator class
218 346
 	 *
@@ -229,13 +357,14 @@ public function filter() {
229 357
 				throw new InvalidArgumentException('Incorrect number of arguments passed to filter()');
230 358
 		}
231 359
 		
  360
+		// TODO 3.1: Once addFilter doesn't mutate self, this results in a double clone
232 361
 		$clone = clone $this;
233 362
 		$clone->addFilter($filters);
234 363
 		return $clone;
235 364
 	}
236 365
 
237 366
 	/**
238  
-	 * Modify this DataList, adding a filter
  367
+	 * Return a new instance of the list with an added filter
239 368
 	 */
240 369
 	public function addFilter($filterArray) {
241 370
 		$SQL_Statements = array();
@@ -262,12 +391,14 @@ public function addFilter($filterArray) {
262 391
 				$SQL_Statements[] = $field . ' ' . $customQuery;
263 392
 			}
264 393
 		}
265  
-		if(count($SQL_Statements)) {
  394
+
  395
+		if(!count($SQL_Statements)) return $this;
  396
+
  397
+		return $this->alterDataQuery_30(function($query) use ($SQL_Statements){
266 398
 			foreach($SQL_Statements as $SQL_Statement){
267  
-				$this->dataQuery->where($SQL_Statement);
  399
+				$query->where($SQL_Statement);
268 400
 			}
269  
-		}
270  
-		return $this;
  401
+		});
271 402
 	}
272 403
 
273 404
 	/**
@@ -299,7 +430,11 @@ public function getRelationName($field) {
299 430
 		if(!preg_match('/^[A-Z0-9._]+$/i', $field)) {
300 431
 			throw new InvalidArgumentException("Bad field expression $field");
301 432
 		}
302  
-		
  433
+
  434
+		if (!$this->inAlterDataQueryCall) {
  435
+			Deprecation::notice('3.1', 'getRelationName is mutating, and must be called inside an alterDataQuery block');
  436
+		}
  437
+
303 438
 		if(strpos($field,'.') === false) {
304 439
 			return '"'.$field.'"';
305 440
 		}
@@ -328,14 +463,14 @@ private function applyFilterContext($field, $comparisators, $value) {
328 463
 	}
329 464
 	
330 465
 	/**
331  
-	 * Exclude the list to not contain items with these characteristics
  466
+	 * Return a copy of this list which does not contain any items with these charactaristics
332 467
 	 *
333 468
 	 * @see SS_List::exclude()
334  
-	 * @example $list->exclude('Name', 'bob'); // exclude bob from list
335  
-	 * @example $list->exclude('Name', array('aziz', 'bob'); // exclude aziz and bob from list
336  
-	 * @example $list->exclude(array('Name'=>'bob, 'Age'=>21)); // exclude bob that has Age 21
337  
-	 * @example $list->exclude(array('Name'=>'bob, 'Age'=>array(21, 43))); // exclude bob with Age 21 or 43
338  
-	 * @example $list->exclude(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43))); // bob age 21 or 43, phil age 21 or 43 would be excluded
  469
+	 * @example $list = $list->exclude('Name', 'bob'); // exclude bob from list
  470
+	 * @example $list = $list->exclude('Name', array('aziz', 'bob'); // exclude aziz and bob from list
  471
+	 * @example $list = $list->exclude(array('Name'=>'bob, 'Age'=>21)); // exclude bob that has Age 21
  472
+	 * @example $list = $list->exclude(array('Name'=>'bob, 'Age'=>array(21, 43))); // exclude bob with Age 21 or 43
  473
+	 * @example $list = $list->exclude(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43))); // bob age 21 or 43, phil age 21 or 43 would be excluded
339 474
 	 *
340 475
 	 * @todo extract the sql from this method into a SQLGenerator class
341 476
 	 *
@@ -368,15 +503,17 @@ public function exclude() {
368 503
 				$SQL_Statements[] = ($fieldName . ' != \''.Convert::raw2sql($value).'\'');
369 504
 			}
370 505
 		}
371  
-		$this->dataQuery->whereAny($SQL_Statements);
372  
-		return $this;
  506
+
  507
+		if(!count($SQL_Statements)) return $this;
  508
+
  509
+		return $this->alterDataQuery_30(function($query) use ($SQL_Statements){
  510
+			$query->whereAny($SQL_Statements);
  511
+		});
373 512
 	}
374 513
 	
375 514
 	/**
376  
-	 * This method returns a list does not contain any DataObjects that exists in $list
  515
+	 * This method returns a copy of this list that does not contain any DataObjects that exists in $list
377 516
 	 * 
378  
-	 * It does not return the resulting list, it only adds the constraints on the database to exclude
379  
-	 * objects from $list.
380 517
 	 * The $list passed needs to contain the same dataclass as $this
381 518
 	 *
382 519
 	 * @param SS_List $list
@@ -387,15 +524,14 @@ public function subtract(SS_List $list) {
387 524
 		if($this->dataclass() != $list->dataclass()) {
388 525
 			throw new InvalidArgumentException('The list passed must have the same dataclass as this class');
389 526
 		}
390  
-		
391  
-		$newlist = clone $this;
392  
-		$newlist->dataQuery->subtract($list->dataQuery());
393  
-		
394  
-		return $newlist;
  527
+
  528
+		return $this->alterDataQuery(function($query) use ($list){
  529
+			$query->subtract($list->dataQuery());
  530
+		});
395 531
 	}
396 532
 	
397 533
 	/**
398  
-	 * Add an inner join clause to this data list's query.
  534
+	 * Return a new DataList instance with an inner join clause added to this list's query.
399 535
 	 *
400 536
 	 * @param string $table Table name (unquoted)
401 537
 	 * @param string $onClause Escaped SQL statement, e.g. '"Table1"."ID" = "Table2"."ID"'
@@ -403,13 +539,13 @@ public function subtract(SS_List $list) {
403 539
 	 * @return DataList 
404 540
 	 */
405 541
 	public function innerJoin($table, $onClause, $alias = null) {
406  
-		$this->dataQuery->innerJoin($table, $onClause, $alias);
407  
-		
408  
-		return $this;
  542
+		return $this->alterDataQuery_30(function($query) use ($table, $onClause, $alias){
  543
+			$query->innerJoin($table, $onClause, $alias);
  544
+		});
409 545
 	}
410 546
 
411 547
 	/**
412  
-	 * Add an left join clause to this data list's query.
  548
+	 * Return a new DataList instance with a left join clause added to this list's query.
413 549
 	 *
414 550
 	 * @param string $table Table name (unquoted)
415 551
 	 * @param string $onClause Escaped SQL statement, e.g. '"Table1"."ID" = "Table2"."ID"'
@@ -417,9 +553,9 @@ public function innerJoin($table, $onClause, $alias = null) {
417 553
 	 * @return DataList 
418 554
 	 */
419 555
 	public function leftJoin($table, $onClause, $alias = null) {
420  
-		$this->dataQuery->leftJoin($table, $onClause, $alias);
421  
-		
422  
-		return $this;
  556
+		return $this->alterDataQuery_30(function($query) use ($table, $onClause, $alias){
  557
+			$query->leftJoin($table, $onClause, $alias);
  558
+		});
423 559
 	}
424 560
 
425 561
 	/**
@@ -611,8 +747,6 @@ public function getRange($offset, $length) {
611 747
 	 * @return DataObject|null
612 748
 	 */
613 749
 	public function find($key, $value) {
614  
-		$clone = clone $this;
615  
-		
616 750
 		if($key == 'ID') {
617 751
 			$baseClass = ClassInfo::baseDataClass($this->dataClass);
618 752
 			$SQL_col = sprintf('"%s"."%s"', $baseClass, Convert::raw2sql($key));
@@ -620,6 +754,8 @@ public function find($key, $value) {
620 754
 			$SQL_col = sprintf('"%s"', Convert::raw2sql($key));
621 755
 		}
622 756
 
  757
+		// todo 3.1: In 3.1 where won't be mutating, so this can be on $this directly
  758
+		$clone = clone $this;
623 759
 		return $clone->where("$SQL_col = '" . Convert::raw2sql($value) . "'")->First();
624 760
 	}
625 761
 	
@@ -630,9 +766,9 @@ public function find($key, $value) {
630 766
 	 * @return DataList
631 767
 	 */
632 768
 	public function setQueriedColumns($queriedColumns) {
633  
-		$clone = clone $this;
634  
-		$clone->dataQuery->setQueriedColumns($queriedColumns);
635  
-		return $clone;
  769
+		return $this->alterDataQuery(function($query) use ($queriedColumns){
  770
+			$query->setQueriedColumns($queriedColumns);
  771
+		});
636 772
 	}
637 773
 
638 774
 	/**
@@ -657,8 +793,9 @@ public function byIDs(array $ids) {
657 793
 	 */
658 794
 	public function byID($id) {
659 795
 		$baseClass = ClassInfo::baseDataClass($this->dataClass);
  796
+
  797
+		// todo 3.1: In 3.1 where won't be mutating, so this can be on $this directly
660 798
 		$clone = clone $this;
661  
-		
662 799
 		return $clone->where("\"$baseClass\".\"ID\" = " . (int)$id)->First();
663 800
 	}
664 801
 	
@@ -835,9 +972,9 @@ public function removeByID($itemID) {
835 972
 	 * @return DataList
836 973
 	 */
837 974
 	public function reverse() {
838  
-		$this->dataQuery->reverseSort();
839  
-		
840  
-		return $this;
  975
+		return $this->alterDataQuery_30(function($query){
  976
+			$query->reverseSort();
  977
+		});
841 978
 	}
842 979
 	
843 980
 	/**

0 notes on commit e8e4604

Please sign in to comment.
Something went wrong with that request. Please try again.