Passed
Push — 4.1 ( 802261...1362b2 )
by
unknown
15:08 queued 06:46
created

ManyManyList::getLocalKey()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
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
            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...
287
                // Write extra field to manipluation in the same way
288
                // that DataObject::prepareManipulationTable writes fields
289
                foreach ($this->extraFields as $fieldName => $fieldSpec) {
290
                    // Skip fields without an assignment
291
                    if (array_key_exists($fieldName, $extraFields)) {
292
                        $fieldObject = Injector::inst()->create($fieldSpec, $fieldName);
293
                        $fieldObject->setValue($extraFields[$fieldName]);
294
                        $fieldObject->writeToManipulation($manipulation[$this->joinTable]);
295
                    }
296
                }
297
            }
298
299
            $manipulation[$this->joinTable]['fields'][$this->localKey] = $itemID;
300
            $manipulation[$this->joinTable]['fields'][$this->foreignKey] = $foreignID;
301
302
            // Make sure none of our field assignments are arrays
303
            foreach ($manipulation as $tableManipulation) {
304
                if (!isset($tableManipulation['fields'])) {
305
                    continue;
306
                }
307
                foreach ($tableManipulation['fields'] as $fieldValue) {
308
                    if (is_array($fieldValue)) {
309
                        throw new InvalidArgumentException(
310
                            'ManyManyList::add: parameterised field assignments are disallowed'
311
                        );
312
                    }
313
                }
314
            }
315
316
            DB::manipulate($manipulation);
317
        }
318
    }
319
320
    /**
321
     * Remove the given item from this list.
322
     *
323
     * Note that for a ManyManyList, the item is never actually deleted, only
324
     * the join table is affected.
325
     *
326
     * @param DataObject $item
327
     */
328
    public function remove($item)
329
    {
330
        if (!($item instanceof $this->dataClass)) {
331
            throw new InvalidArgumentException("ManyManyList::remove() expecting a $this->dataClass object");
332
        }
333
334
        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...
335
    }
336
337
    /**
338
     * Remove the given item from this list.
339
     *
340
     * Note that for a ManyManyList, the item is never actually deleted, only
341
     * the join table is affected
342
     *
343
     * @param int $itemID The item ID
344
     */
345
    public function removeByID($itemID)
346
    {
347
        if (!is_numeric($itemID)) {
0 ignored issues
show
introduced by
The condition is_numeric($itemID) is always true.
Loading history...
348
            throw new InvalidArgumentException("ManyManyList::removeById() expecting an ID");
349
        }
350
351
        $query = new SQLDelete("\"{$this->joinTable}\"");
352
353
        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

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

411
    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...
412
    {
413
        $result = array();
414
415
        // Skip if no extrafields or unsaved record
416
        if (empty($this->extraFields) || empty($itemID)) {
417
            return $result;
418
        }
419
420
        if (!is_numeric($itemID)) {
0 ignored issues
show
introduced by
The condition is_numeric($itemID) is always true.
Loading history...
421
            throw new InvalidArgumentException('ManyManyList::getExtraData() passed a non-numeric child ID');
422
        }
423
424
        $cleanExtraFields = array();
425
        foreach ($this->extraFields as $fieldName => $dbFieldSpec) {
426
            $cleanExtraFields[] = "\"{$fieldName}\"";
427
        }
428
        $query = new SQLSelect($cleanExtraFields, "\"{$this->joinTable}\"");
429
        $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

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