Passed
Push — 4.1 ( cb7f15...ac53f7 )
by Maxime
08:41
created

ManyManyList   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 476
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 58
eloc 165
dl 0
loc 476
rs 4.5599
c 0
b 0
f 0

15 Methods

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

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.

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

201
        return $this->foreignIDFilter(/** @scrutinizer ignore-type */ $id);
Loading history...
202
    }
203
204
    /**
205
     * Add an item to this many_many relationship
206
     * Does so by adding an entry to the joinTable.
207
     *
208
     * Can also be used to update an already existing joinTable entry:
209
     *
210
     *     $manyManyList->add($recordID,["ExtraField" => "value"]);
211
     *
212
     * @throws InvalidArgumentException
213
     * @throws Exception
214
     *
215
     * @param DataObject|int $item
216
     * @param array $extraFields A map of additional columns to insert into the joinTable.
217
     * Column names should be ANSI quoted.
218
     * @throws Exception
219
     */
220
    public function add($item, $extraFields = array())
221
    {
222
        // Ensure nulls or empty strings are correctly treated as empty arrays
223
        if (empty($extraFields)) {
224
            $extraFields = array();
225
        }
226
227
        // Determine ID of new record
228
        $itemID = null;
229
        if (is_numeric($item)) {
230
            $itemID = $item;
231
        } elseif ($item instanceof $this->dataClass) {
232
            // Ensure record is saved
233
            if (!$item->isInDB()) {
234
                $item->write();
235
            }
236
            $itemID = $item->ID;
237
        } else {
238
            throw new InvalidArgumentException(
239
                "ManyManyList::add() expecting a $this->dataClass object, or ID value"
240
            );
241
        }
242
        if (empty($itemID)) {
243
            throw new InvalidArgumentException("ManyManyList::add() couldn't add this record");
244
        }
245
246
        // Validate foreignID
247
        $foreignIDs = $this->getForeignID();
248
        if (empty($foreignIDs)) {
249
            throw new BadMethodCallException("ManyManyList::add() can't be called until a foreign ID is set");
250
        }
251
252
        // Apply this item to each given foreign ID record
253
        if (!is_array($foreignIDs)) {
0 ignored issues
show
introduced by
The condition is_array($foreignIDs) is always false.
Loading history...
254
            $foreignIDs = array($foreignIDs);
255
        }
256
        foreach ($foreignIDs as $foreignID) {
257
            // Check for existing records for this item
258
            if ($foreignFilter = $this->foreignIDWriteFilter($foreignID)) {
0 ignored issues
show
Bug introduced by
$foreignID of type string is incompatible with the type array|integer|null expected by parameter $id of SilverStripe\ORM\ManyMan...:foreignIDWriteFilter(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

258
            if ($foreignFilter = $this->foreignIDWriteFilter(/** @scrutinizer ignore-type */ $foreignID)) {
Loading history...
259
                // With the current query, simply add the foreign and local conditions
260
                // The query can be a bit odd, especially if custom relation classes
261
                // don't join expected tables (@see Member_GroupSet for example).
262
                $query = new SQLSelect("*", "\"{$this->joinTable}\"");
263
                $query->addWhere($foreignFilter);
264
                $query->addWhere(array(
265
                    "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
266
                ));
267
                $hasExisting = ($query->count() > 0);
268
            } else {
269
                $hasExisting = false;
270
            }
271
272
            // Blank manipulation
273
            $manipulation = array(
274
                $this->joinTable => array(
275
                    'command' => $hasExisting ? 'update' : 'insert',
276
                    'fields' => array()
277
                )
278
            );
279
            if ($hasExisting) {
280
                $manipulation[$this->joinTable]['where'] = array(
281
                    "\"{$this->joinTable}\".\"{$this->foreignKey}\"" => $foreignID,
282
                    "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
283
                );
284
            }
285
286
            /** @var DBField[] $fieldObjects */
287
            $fieldObjects = [];
288
            if ($extraFields && $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...
289
                // Write extra field to manipluation in the same way
290
                // that DataObject::prepareManipulationTable writes fields
291
                foreach ($this->extraFields as $fieldName => $fieldSpec) {
292
                    // Skip fields without an assignment
293
                    if (array_key_exists($fieldName, $extraFields)) {
294
                        $fieldObject = Injector::inst()->create($fieldSpec, $fieldName);
295
                        $fieldObject->setValue($extraFields[$fieldName]);
296
                        $fieldObject->writeToManipulation($manipulation[$this->joinTable]);
297
                        $fieldObjects[$fieldName] = $fieldObject;
298
                    }
299
                }
300
            }
301
302
            $manipulation[$this->joinTable]['fields'][$this->localKey] = $itemID;
303
            $manipulation[$this->joinTable]['fields'][$this->foreignKey] = $foreignID;
304
305
            // Make sure none of our field assignments are arrays
306
            foreach ($manipulation as $tableManipulation) {
307
                if (!isset($tableManipulation['fields'])) {
308
                    continue;
309
                }
310
                foreach ($tableManipulation['fields'] as $fieldName => $fieldValue) {
311
                    if (is_array($fieldValue)) {
312
                        // If the field allows non-scalar values we'll let it do dynamic assignments
313
                        if (isset($fieldObjects[$fieldName]) && $fieldObjects[$fieldName]->scalarValueOnly()) {
314
                            throw new InvalidArgumentException(
315
                                'ManyManyList::add: parameterised field assignments are disallowed'
316
                            );
317
                        }
318
                    }
319
                }
320
            }
321
322
            DB::manipulate($manipulation);
323
        }
324
    }
325
326
    /**
327
     * Remove the given item from this list.
328
     *
329
     * Note that for a ManyManyList, the item is never actually deleted, only
330
     * the join table is affected.
331
     *
332
     * @param DataObject $item
333
     */
334
    public function remove($item)
335
    {
336
        if (!($item instanceof $this->dataClass)) {
337
            throw new InvalidArgumentException("ManyManyList::remove() expecting a $this->dataClass object");
338
        }
339
340
        return $this->removeByID($item->ID);
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->removeByID($item->ID) targeting SilverStripe\ORM\ManyManyList::removeByID() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
341
    }
342
343
    /**
344
     * Remove the given item from this list.
345
     *
346
     * Note that for a ManyManyList, the item is never actually deleted, only
347
     * the join table is affected
348
     *
349
     * @param int $itemID The item ID
350
     */
351
    public function removeByID($itemID)
352
    {
353
        if (!is_numeric($itemID)) {
0 ignored issues
show
introduced by
The condition is_numeric($itemID) is always true.
Loading history...
354
            throw new InvalidArgumentException("ManyManyList::removeById() expecting an ID");
355
        }
356
357
        $query = new SQLDelete("\"{$this->joinTable}\"");
358
359
        if ($filter = $this->foreignIDWriteFilter($this->getForeignID())) {
0 ignored issues
show
Bug introduced by
$this->getForeignID() of type string is incompatible with the type array|integer|null expected by parameter $id of SilverStripe\ORM\ManyMan...:foreignIDWriteFilter(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

359
        if ($filter = $this->foreignIDWriteFilter(/** @scrutinizer ignore-type */ $this->getForeignID())) {
Loading history...
360
            $query->setWhere($filter);
361
        } else {
362
            user_error("Can't call ManyManyList::remove() until a foreign ID is set");
363
        }
364
365
        $query->addWhere(array(
366
            "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
367
        ));
368
        $query->execute();
369
    }
370
371
    /**
372
     * Remove all items from this many-many join.  To remove a subset of items,
373
     * filter it first.
374
     *
375
     * @return void
376
     */
377
    public function removeAll()
378
    {
379
380
        // Remove the join to the join table to avoid MySQL row locking issues.
381
        $query = $this->dataQuery();
382
        $foreignFilter = $query->getQueryParam('Foreign.Filter');
383
        $query->removeFilterOn($foreignFilter);
384
385
        // Select ID column
386
        $selectQuery = $query->query();
387
        $dataClassIDColumn = DataObject::getSchema()->sqlColumnForField($this->dataClass(), 'ID');
388
        $selectQuery->setSelect($dataClassIDColumn);
389
390
        $from = $selectQuery->getFrom();
391
        unset($from[$this->joinTable]);
392
        $selectQuery->setFrom($from);
393
        $selectQuery->setOrderBy(); // ORDER BY in subselects breaks MS SQL Server and is not necessary here
394
        $selectQuery->setDistinct(false);
395
396
        // Use a sub-query as SQLite does not support setting delete targets in
397
        // joined queries.
398
        $delete = new SQLDelete();
399
        $delete->setFrom("\"{$this->joinTable}\"");
400
        $delete->addWhere($this->foreignIDFilter());
401
        $subSelect = $selectQuery->sql($parameters);
402
        $delete->addWhere(array(
403
            "\"{$this->joinTable}\".\"{$this->localKey}\" IN ($subSelect)" => $parameters
404
        ));
405
        $delete->execute();
406
    }
407
408
    /**
409
     * Find the extra field data for a single row of the relationship join
410
     * table, given the known child ID.
411
     *
412
     * @param string $componentName The name of the component
413
     * @param int $itemID The ID of the child for the relationship
414
     *
415
     * @return array Map of fieldName => fieldValue
416
     */
417
    public function getExtraData($componentName, $itemID)
0 ignored issues
show
Unused Code introduced by
The parameter $componentName is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

417
    public function getExtraData(/** @scrutinizer ignore-unused */ $componentName, $itemID)

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

Loading history...
418
    {
419
        $result = array();
420
421
        // Skip if no extrafields or unsaved record
422
        if (empty($this->extraFields) || empty($itemID)) {
423
            return $result;
424
        }
425
426
        if (!is_numeric($itemID)) {
0 ignored issues
show
introduced by
The condition is_numeric($itemID) is always true.
Loading history...
427
            throw new InvalidArgumentException('ManyManyList::getExtraData() passed a non-numeric child ID');
428
        }
429
430
        $cleanExtraFields = array();
431
        foreach ($this->extraFields as $fieldName => $dbFieldSpec) {
432
            $cleanExtraFields[] = "\"{$fieldName}\"";
433
        }
434
        $query = new SQLSelect($cleanExtraFields, "\"{$this->joinTable}\"");
435
        $filter = $this->foreignIDWriteFilter($this->getForeignID());
0 ignored issues
show
Bug introduced by
$this->getForeignID() of type string is incompatible with the type array|integer|null expected by parameter $id of SilverStripe\ORM\ManyMan...:foreignIDWriteFilter(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

435
        $filter = $this->foreignIDWriteFilter(/** @scrutinizer ignore-type */ $this->getForeignID());
Loading history...
436
        if ($filter) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $filter of type array<string,string> 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...
introduced by
$filter is a non-empty array, thus is always true.
Loading history...
437
            $query->setWhere($filter);
438
        } else {
439
            throw new BadMethodCallException("Can't call ManyManyList::getExtraData() until a foreign ID is set");
440
        }
441
        $query->addWhere(array(
442
            "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
443
        ));
444
        $queryResult = $query->execute()->current();
445
        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...
446
            foreach ($queryResult as $fieldName => $value) {
447
                $result[$fieldName] = $value;
448
            }
449
        }
450
451
        return $result;
452
    }
453
454
    /**
455
     * Gets the join table used for the relationship.
456
     *
457
     * @return string the name of the table
458
     */
459
    public function getJoinTable()
460
    {
461
        return $this->joinTable;
462
    }
463
464
    /**
465
     * Gets the key used to store the ID of the local/parent object.
466
     *
467
     * @return string the field name
468
     */
469
    public function getLocalKey()
470
    {
471
        return $this->localKey;
472
    }
473
474
    /**
475
     * Gets the key used to store the ID of the foreign/child object.
476
     *
477
     * @return string the field name
478
     */
479
    public function getForeignKey()
480
    {
481
        return $this->foreignKey;
482
    }
483
484
    /**
485
     * Gets the extra fields included in the relationship.
486
     *
487
     * @return array a map of field names to types
488
     */
489
    public function getExtraFields()
490
    {
491
        return $this->extraFields;
492
    }
493
}
494