Completed
Push — 3.7 ( 11b87a...bb5701 )
by
unknown
09:09 queued 01:14
created

ManyManyList   C

Complexity

Total Complexity 54

Size/Duplication

Total Lines 439
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 10

Importance

Changes 0
Metric Value
dl 0
loc 439
rs 6.4799
c 0
b 0
f 0
wmc 54
lcom 1
cbo 10

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 10 1
A linkJoinTable() 0 10 2
A appendExtraFieldsToQuery() 0 24 4
B createDataObject() 0 33 6
A foreignIDFilter() 0 13 4
A foreignIDWriteFilter() 0 3 1
F add() 0 86 18
A remove() 0 7 2
A removeByID() 0 14 3
A removeAll() 0 28 1
B getExtraData() 0 35 8
A getJoinTable() 0 3 1
A getLocalKey() 0 3 1
A getForeignKey() 0 3 1
A getExtraFields() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like ManyManyList 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 ManyManyList, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * Subclass of {@link DataList} representing a many_many relation.
5
 *
6
 * @package framework
7
 * @subpackage model
8
 */
9
class ManyManyList extends RelationList {
10
11
	/**
12
	 * @var string $joinTable
13
	 */
14
	protected $joinTable;
15
16
	/**
17
	 * @var string $localKey
18
	 */
19
	protected $localKey;
20
21
	/**
22
	 * @var string $foreignKey
23
	 */
24
	protected $foreignKey;
25
26
	/**
27
	 * @var array $extraFields
28
	 */
29
	protected $extraFields;
30
31
	/**
32
	 * @var array $_compositeExtraFields
33
	 */
34
	protected $_compositeExtraFields = array();
35
36
	/**
37
	 * Create a new ManyManyList object.
38
	 *
39
	 * A ManyManyList object represents a list of {@link DataObject} records
40
	 * that correspond to a many-many relationship.
41
	 *
42
	 * Generation of the appropriate record set is left up to the caller, using
43
	 * the normal {@link DataList} methods. Addition arguments are used to
44
	 * support {@@link add()} and {@link remove()} methods.
45
	 *
46
	 * @param string $dataClass The class of the DataObjects that this will list.
47
	 * @param string $joinTable The name of the table whose entries define the content of this many_many relation.
48
	 * @param string $localKey The key in the join table that maps to the dataClass' PK.
49
	 * @param string $foreignKey The key in the join table that maps to joined class' PK.
50
	 * @param array $extraFields A map of field => fieldtype of extra fields on the join table.
51
	 *
52
	 * @example new ManyManyList('Group','Group_Members', 'GroupID', 'MemberID');
53
	 */
54
	public function __construct($dataClass, $joinTable, $localKey, $foreignKey, $extraFields = array()) {
55
		parent::__construct($dataClass);
56
57
		$this->joinTable = $joinTable;
58
		$this->localKey = $localKey;
59
		$this->foreignKey = $foreignKey;
60
		$this->extraFields = $extraFields;
61
62
		$this->linkJoinTable();
63
	}
64
65
	/**
66
	 * Setup the join between this dataobject and the necessary mapping table
67
	 */
68
	protected function linkJoinTable() {
69
		// Join to the many-many join table
70
		$baseClass = ClassInfo::baseDataClass($this->dataClass);
71
		$this->dataQuery->innerJoin($this->joinTable, "\"{$this->joinTable}\".\"{$this->localKey}\" = \"{$baseClass}\".\"ID\"");
72
73
		// Add the extra fields to the query
74
		if($this->extraFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->extraFields 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...
75
			$this->appendExtraFieldsToQuery();
76
		}
77
	}
78
79
	/**
80
	 * Adds the many_many_extraFields to the select of the underlying
81
	 * {@link DataQuery}.
82
	 *
83
	 * @return void
84
	 */
85
	protected function appendExtraFieldsToQuery() {
86
		$finalized = array();
87
88
		foreach($this->extraFields as $field => $spec) {
89
			$obj = SS_Object::create_from_string($spec);
90
91
			if($obj instanceof CompositeDBField) {
92
				$this->_compositeExtraFields[$field] = array();
93
94
				// append the composite field names to the select
95
				foreach($obj->compositeDatabaseFields() as $subField => $subSpec) {
96
					$col = $field . $subField;
97
					$finalized[] = $col;
98
99
					// cache
100
					$this->_compositeExtraFields[$field][] = $subField;
101
				}
102
			} else {
103
				$finalized[] = $field;
104
			}
105
		}
106
107
		$this->dataQuery->addSelectFromTable($this->joinTable, $finalized);
108
	}
109
110
	/**
111
	 * Create a DataObject from the given SQL row.
112
	 *
113
	 * @param array $row
114
	 * @return DataObject
115
	 */
116
	protected function createDataObject($row) {
117
		// remove any composed fields
118
		$add = array();
119
120
		if($this->_compositeExtraFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->_compositeExtraFields 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...
121
			foreach($this->_compositeExtraFields as $fieldName => $composed) {
122
				// convert joined extra fields into their composite field types.
123
				$value = array();
124
125
				foreach($composed as $subField => $subSpec) {
126
					if(isset($row[$fieldName . $subSpec])) {
127
						$value[$subSpec] = $row[$fieldName . $subSpec];
128
129
						// don't duplicate data in the record
130
						unset($row[$fieldName . $subSpec]);
131
					}
132
				}
133
134
				$obj = SS_Object::create_from_string($this->extraFields[$fieldName], $fieldName);
135
				$obj->setValue($value, null, false);
136
137
				$add[$fieldName] = $obj;
138
			}
139
		}
140
141
		$dataObject = parent::createDataObject($row);
142
143
		foreach($add as $fieldName => $obj) {
144
			$dataObject->$fieldName = $obj;
145
		}
146
147
		return $dataObject;
148
	}
149
150
	/**
151
	 * Return a filter expression for when getting the contents of the
152
	 * relationship for some foreign ID
153
	 *
154
	 * @param int|null $id
155
	 *
156
	 * @return array
157
	 */
158
	protected function foreignIDFilter($id = null) {
159
		if ($id === null) {
160
			$id = $this->getForeignID();
161
		}
162
163
		// Apply relation filter
164
		$key = "\"{$this->joinTable}\".\"{$this->foreignKey}\"";
165
		if(is_array($id)) {
166
			return array("$key IN (".DB::placeholders($id).")"  => $id);
167
		} else if($id !== null){
168
			return array($key => $id);
169
		}
170
	}
171
172
	/**
173
	 * Return a filter expression for the join table when writing to the join table
174
	 *
175
	 * When writing (add, remove, removeByID), we need to filter the join table to just the relevant
176
	 * entries. However some subclasses of ManyManyList (Member_GroupSet) modify foreignIDFilter to
177
	 * include additional calculated entries, so we need different filters when reading and when writing
178
	 *
179
	 * @param array|int|null $id (optional) An ID or an array of IDs - if not provided, will use the current ids
180
	 * as per getForeignID
181
	 * @return array Condition In array(SQL => parameters format)
182
	 */
183
	protected function foreignIDWriteFilter($id = null) {
184
		return $this->foreignIDFilter($id);
0 ignored issues
show
Bug introduced by
It seems like $id defined by parameter $id on line 183 can also be of type array; however, ManyManyList::foreignIDFilter() does only seem to accept integer|null, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
185
	}
186
187
	/**
188
	 * Add an item to this many_many relationship
189
	 * Does so by adding an entry to the joinTable.
190
	 *
191
	 * Can also be used to update an already existing joinTable entry
192
	 *
193
	 * Example:
194
	 *
195
	 *     $manyManyList->add($recordID, array("ExtraField"=>"value"));
196
	 *
197
	 *
198
	 * @throws InvalidArgumentException
199
	 * @throws Exception
200
	 *
201
	 * @param DataObject|int $item
202
	 * @param array $extraFields A map of additional columns to insert into the joinTable.
203
	 * Column names should be ANSI quoted.
204
	 */
205
	public function add($item, $extraFields = array()) {
206
		// Ensure nulls or empty strings are correctly treated as empty arrays
207
		if(empty($extraFields)) $extraFields = array();
208
209
		// Determine ID of new record
210
		if(is_numeric($item)) {
211
			$itemID = $item;
212
		} elseif($item instanceof $this->dataClass) {
213
			$itemID = $item->ID;
214
		} else {
215
			throw new InvalidArgumentException("ManyManyList::add() expecting a $this->dataClass object, or ID value",
216
				E_USER_ERROR);
217
		}
218
219
		// Validate foreignID
220
		$foreignIDs = $this->getForeignID();
221
		if(empty($foreignIDs)) {
222
			throw new Exception("ManyManyList::add() can't be called until a foreign ID is set", E_USER_WARNING);
223
		}
224
225
		// Apply this item to each given foreign ID record
226
		if(!is_array($foreignIDs)) $foreignIDs = array($foreignIDs);
227
		foreach($foreignIDs as $foreignID) {
228
			// Check for existing records for this item
229
			if($foreignFilter = $this->foreignIDWriteFilter($foreignID)) {
230
				// With the current query, simply add the foreign and local conditions
231
				// The query can be a bit odd, especially if custom relation classes
232
				// don't join expected tables (@see Member_GroupSet for example).
233
				$query = new SQLQuery("*", "\"{$this->joinTable}\"");
0 ignored issues
show
Deprecated Code introduced by
The class SQLQuery has been deprecated with message: since version 4.0

This class, trait or interface has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the type will be removed from the class and what other constant to use instead.

Loading history...
234
				$query->addWhere($foreignFilter);
235
				$query->addWhere(array(
236
					"\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
237
				));
238
				$hasExisting = ($query->count() > 0);
239
			} else {
240
				$hasExisting = false;
241
			}
242
243
			// Blank manipulation
244
			$manipulation = array(
245
				$this->joinTable => array(
246
					'command' => $hasExisting ? 'update' : 'insert',
247
					'fields' => array()
248
				)
249
			);
250
			if($hasExisting) {
251
				$manipulation[$this->joinTable]['where'] = array(
252
					"\"{$this->joinTable}\".\"{$this->foreignKey}\"" => $foreignID,
253
					"\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
254
				);
255
			}
256
257
			if($extraFields && $this->extraFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $extraFields 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...
Bug Best Practice introduced by
The expression $this->extraFields 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...
258
				// Write extra field to manipluation in the same way
259
				// that DataObject::prepareManipulationTable writes fields
260
				foreach($this->extraFields as $fieldName => $fieldSpec) {
261
					// Skip fields without an assignment
262
					if(array_key_exists($fieldName, $extraFields)) {
263
						$fieldObject = SS_Object::create_from_string($fieldSpec, $fieldName);
264
						$fieldObject->setValue($extraFields[$fieldName]);
265
						$fieldObject->writeToManipulation($manipulation[$this->joinTable]);
266
					}
267
				}
268
			}
269
270
			$manipulation[$this->joinTable]['fields'][$this->localKey] = $itemID;
271
			$manipulation[$this->joinTable]['fields'][$this->foreignKey] = $foreignID;
272
273
            // Make sure none of our field assignment are arrays
274
            foreach ($manipulation as $tableManipulation) {
275
                if (!isset($tableManipulation['fields'])) {
276
                    continue;
277
                }
278
                foreach ($tableManipulation['fields'] as $fieldValue) {
279
                    if (is_array($fieldValue)) {
280
                        user_error(
281
                            'ManyManyList::add: parameterised field assignments are disallowed',
282
                            E_USER_ERROR
283
                        );
284
                    }
285
                }
286
            }
287
288
			DB::manipulate($manipulation);
289
		}
290
	}
291
292
	/**
293
	 * Remove the given item from this list.
294
	 *
295
	 * Note that for a ManyManyList, the item is never actually deleted, only
296
	 * the join table is affected.
297
	 *
298
	 * @param DataObject $item
299
	 */
300
	public function remove($item) {
301
		if(!($item instanceof $this->dataClass)) {
302
			throw new InvalidArgumentException("ManyManyList::remove() expecting a $this->dataClass object");
303
		}
304
305
		return $this->removeByID($item->ID);
306
	}
307
308
	/**
309
	 * Remove the given item from this list.
310
	 *
311
	 * Note that for a ManyManyList, the item is never actually deleted, only
312
	 * the join table is affected
313
	 *
314
	 * @param int $itemID The item ID
315
	 */
316
	public function removeByID($itemID) {
317
		if(!is_numeric($itemID)) throw new InvalidArgumentException("ManyManyList::removeById() expecting an ID");
318
319
		$query = new SQLDelete("\"{$this->joinTable}\"");
320
321
		if($filter = $this->foreignIDWriteFilter($this->getForeignID())) {
322
			$query->setWhere($filter);
323
		} else {
324
			user_error("Can't call ManyManyList::remove() until a foreign ID is set", E_USER_WARNING);
325
		}
326
327
		$query->addWhere(array("\"{$this->localKey}\"" => $itemID));
328
		$query->execute();
329
	}
330
331
	/**
332
	 * Remove all items from this many-many join.  To remove a subset of items,
333
	 * filter it first.
334
	 *
335
	 * @return void
336
	 */
337
	public function removeAll() {
338
		$base = ClassInfo::baseDataClass($this->dataClass());
339
340
		// Remove the join to the join table to avoid MySQL row locking issues.
341
		$query = $this->dataQuery();
342
		$foreignFilter = $query->getQueryParam('Foreign.Filter');
343
		$query->removeFilterOn($foreignFilter);
344
345
		$selectQuery = $query->query();
346
		$selectQuery->setSelect("\"{$base}\".\"ID\"");
347
348
		$from = $selectQuery->getFrom();
349
		unset($from[$this->joinTable]);
350
		$selectQuery->setFrom($from);
351
		$selectQuery->setOrderBy(); // ORDER BY in subselects breaks MS SQL Server and is not necessary here
352
		$selectQuery->setDistinct(false);
353
354
		// Use a sub-query as SQLite does not support setting delete targets in
355
		// joined queries.
356
		$delete = new SQLDelete();
357
		$delete->setFrom("\"{$this->joinTable}\"");
358
		$delete->addWhere($this->foreignIDFilter());
359
		$subSelect = $selectQuery->sql($parameters);
360
		$delete->addWhere(array(
361
			"\"{$this->joinTable}\".\"{$this->localKey}\" IN ($subSelect)" => $parameters
362
		));
363
		$delete->execute();
364
	}
365
366
	/**
367
	 * Find the extra field data for a single row of the relationship join
368
	 * table, given the known child ID.
369
	 *
370
	 * @param string $componentName The name of the component
371
	 * @param int $itemID The ID of the child for the relationship
372
	 *
373
	 * @return array Map of fieldName => fieldValue
374
	 */
375
	public function getExtraData($componentName, $itemID) {
0 ignored issues
show
Unused Code introduced by
The parameter $componentName is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
376
		$result = array();
377
378
		// Skip if no extrafields or unsaved record
379
		if(empty($this->extraFields) || empty($itemID)) {
380
			return $result;
381
		}
382
383
		if(!is_numeric($itemID)) {
384
			user_error('ComponentSet::getExtraData() passed a non-numeric child ID', E_USER_ERROR);
385
		}
386
387
		$cleanExtraFields = array();
388
		foreach ($this->extraFields as $fieldName => $dbFieldSpec) {
389
			$cleanExtraFields[] = "\"{$fieldName}\"";
390
		}
391
		$query = new SQLQuery($cleanExtraFields, "\"{$this->joinTable}\"");
0 ignored issues
show
Deprecated Code introduced by
The class SQLQuery has been deprecated with message: since version 4.0

This class, trait or interface has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the type will be removed from the class and what other constant to use instead.

Loading history...
392
		$filter = $this->foreignIDWriteFilter($this->getForeignID());
393
		if($filter) {
394
			$query->setWhere($filter);
395
		} else {
396
			user_error("Can't call ManyManyList::getExtraData() until a foreign ID is set", E_USER_WARNING);
397
		}
398
		$query->addWhere(array(
399
			"\"{$this->localKey}\"" => $itemID
400
		));
401
		$queryResult = $query->execute()->current();
402
		if ($queryResult) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $queryResult 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...
403
			foreach ($queryResult as $fieldName => $value) {
404
				$result[$fieldName] = $value;
405
			}
406
		}
407
408
		return $result;
409
	}
410
411
	/**
412
	 * Gets the join table used for the relationship.
413
	 *
414
	 * @return string the name of the table
415
	 */
416
	public function getJoinTable() {
417
		return $this->joinTable;
418
	}
419
420
	/**
421
	 * Gets the key used to store the ID of the local/parent object.
422
	 *
423
	 * @return string the field name
424
	 */
425
	public function getLocalKey() {
426
		return $this->localKey;
427
	}
428
429
	/**
430
	 * Gets the key used to store the ID of the foreign/child object.
431
	 *
432
	 * @return string the field name
433
	 */
434
	public function getForeignKey() {
435
		return $this->foreignKey;
436
	}
437
438
	/**
439
	 * Gets the extra fields included in the relationship.
440
	 *
441
	 * @return array a map of field names to types
442
	 */
443
	public function getExtraFields() {
444
		return $this->extraFields;
445
	}
446
447
}
448