Passed
Push — master ( 1cdfe7...3190f6 )
by Robbie
06:58
created

ManyManyList::appendExtraFieldsToQuery()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 12
nc 4
nop 0
dl 0
loc 24
rs 9.8666
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 = [];
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 = [])
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 = [];
101
102
        foreach ($this->extraFields as $field => $spec) {
103
            $obj = Injector::inst()->create($spec);
104
105
            if ($obj instanceof DBComposite) {
106
                $this->_compositeExtraFields[$field] = [];
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 = [];
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 = [];
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|string|array $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)) {
181
            return ["$key IN (" . DB::placeholders($id) . ")"  => $id];
182
        }
183
        if ($id !== null) {
0 ignored issues
show
introduced by
The condition $id !== null is always true.
Loading history...
184
            return [$key => $id];
185
        }
186
        return null;
187
    }
188
189
    /**
190
     * Return a filter expression for the join table when writing to the join table
191
     *
192
     * When writing (add, remove, removeByID), we need to filter the join table to just the relevant
193
     * entries. However some subclasses of ManyManyList (Member_GroupSet) modify foreignIDFilter to
194
     * include additional calculated entries, so we need different filters when reading and when writing
195
     *
196
     * @param array|int|null $id (optional) An ID or an array of IDs - if not provided, will use the current ids
197
     * as per getForeignID
198
     * @return array Condition In array(SQL => parameters format)
199
     */
200
    protected function foreignIDWriteFilter($id = null)
201
    {
202
        return $this->foreignIDFilter($id);
203
    }
204
205
    /**
206
     * Add an item to this many_many relationship
207
     * Does so by adding an entry to the joinTable.
208
     *
209
     * Can also be used to update an already existing joinTable entry:
210
     *
211
     *     $manyManyList->add($recordID,["ExtraField" => "value"]);
212
     *
213
     * @throws InvalidArgumentException
214
     * @throws Exception
215
     *
216
     * @param DataObject|int $item
217
     * @param array $extraFields A map of additional columns to insert into the joinTable.
218
     * Column names should be ANSI quoted.
219
     * @throws Exception
220
     */
221
    public function add($item, $extraFields = [])
222
    {
223
        // Ensure nulls or empty strings are correctly treated as empty arrays
224
        if (empty($extraFields)) {
225
            $extraFields = [];
226
        }
227
228
        // Determine ID of new record
229
        $itemID = null;
230
        if (is_numeric($item)) {
231
            $itemID = $item;
232
        } elseif ($item instanceof $this->dataClass) {
233
            // Ensure record is saved
234
            if (!$item->isInDB()) {
235
                $item->write();
236
            }
237
            $itemID = $item->ID;
238
        } else {
239
            throw new InvalidArgumentException(
240
                "ManyManyList::add() expecting a $this->dataClass object, or ID value"
241
            );
242
        }
243
        if (empty($itemID)) {
244
            throw new InvalidArgumentException("ManyManyList::add() couldn't add this record");
245
        }
246
247
        // Validate foreignID
248
        $foreignIDs = $this->getForeignID();
249
        if (empty($foreignIDs)) {
250
            throw new BadMethodCallException("ManyManyList::add() can't be called until a foreign ID is set");
251
        }
252
253
        // Apply this item to each given foreign ID record
254
        if (!is_array($foreignIDs)) {
0 ignored issues
show
introduced by
The condition is_array($foreignIDs) is always false.
Loading history...
255
            $foreignIDs = [$foreignIDs];
256
        }
257
        foreach ($foreignIDs as $foreignID) {
258
            // Check for existing records for this item
259
            if ($foreignFilter = $this->foreignIDWriteFilter($foreignID)) {
0 ignored issues
show
Bug introduced by
$foreignID of type string is incompatible with the type null|integer|array 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

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

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

398
    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...
399
    {
400
        $result = [];
401
402
        // Skip if no extrafields or unsaved record
403
        if (empty($this->extraFields) || empty($itemID)) {
404
            return $result;
405
        }
406
407
        if (!is_numeric($itemID)) {
0 ignored issues
show
introduced by
The condition is_numeric($itemID) is always true.
Loading history...
408
            throw new InvalidArgumentException('ManyManyList::getExtraData() passed a non-numeric child ID');
409
        }
410
411
        $cleanExtraFields = [];
412
        foreach ($this->extraFields as $fieldName => $dbFieldSpec) {
413
            $cleanExtraFields[] = "\"{$fieldName}\"";
414
        }
415
        $query = SQLSelect::create($cleanExtraFields, "\"{$this->joinTable}\"");
416
        $filter = $this->foreignIDWriteFilter($this->getForeignID());
0 ignored issues
show
Bug introduced by
$this->getForeignID() of type string is incompatible with the type null|integer|array 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

416
        $filter = $this->foreignIDWriteFilter(/** @scrutinizer ignore-type */ $this->getForeignID());
Loading history...
417
        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...
418
            $query->setWhere($filter);
419
        } else {
420
            throw new BadMethodCallException("Can't call ManyManyList::getExtraData() until a foreign ID is set");
421
        }
422
        $query->addWhere([
423
            "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID
424
        ]);
425
        $queryResult = $query->execute()->record();
426
        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...
427
            foreach ($queryResult as $fieldName => $value) {
428
                $result[$fieldName] = $value;
429
            }
430
        }
431
432
        return $result;
433
    }
434
435
    /**
436
     * Gets the join table used for the relationship.
437
     *
438
     * @return string the name of the table
439
     */
440
    public function getJoinTable()
441
    {
442
        return $this->joinTable;
443
    }
444
445
    /**
446
     * Gets the key used to store the ID of the local/parent object.
447
     *
448
     * @return string the field name
449
     */
450
    public function getLocalKey()
451
    {
452
        return $this->localKey;
453
    }
454
455
    /**
456
     * Gets the key used to store the ID of the foreign/child object.
457
     *
458
     * @return string the field name
459
     */
460
    public function getForeignKey()
461
    {
462
        return $this->foreignKey;
463
    }
464
465
    /**
466
     * Gets the extra fields included in the relationship.
467
     *
468
     * @return array a map of field names to types
469
     */
470
    public function getExtraFields()
471
    {
472
        return $this->extraFields;
473
    }
474
}
475