Completed
Push — fix-2494 ( 3153ee...40d9bb )
by Sam
13:43 queued 06:38
created

DataList::last()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 0
dl 0
loc 7
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\ORM;
4
5
use SilverStripe\Core\Injector\Injector;
6
use SilverStripe\Dev\Debug;
7
use SilverStripe\ORM\Filters\SearchFilter;
8
use SilverStripe\ORM\Queries\SQLConditionGroup;
9
use SilverStripe\View\ViewableData;
10
use ArrayIterator;
11
use Exception;
12
use InvalidArgumentException;
13
use LogicException;
14
15
/**
16
 * Implements a "lazy loading" DataObjectSet.
17
 * Uses {@link DataQuery} to do the actual query generation.
18
 *
19
 * DataLists are _immutable_ as far as the query they represent is concerned. When you call a method that
20
 * alters the query, a new DataList instance is returned, rather than modifying the existing instance
21
 *
22
 * When you add or remove an element to the list the query remains the same, but because you have modified
23
 * the underlying data the contents of the list changes. These are some of those methods:
24
 *
25
 *   - add
26
 *   - addMany
27
 *   - remove
28
 *   - removeMany
29
 *   - removeByID
30
 *   - removeByFilter
31
 *   - removeAll
32
 *
33
 * Subclasses of DataList may add other methods that have the same effect.
34
 */
35
class DataList extends ViewableData implements SS_List, Filterable, Sortable, Limitable
36
{
37
38
    /**
39
     * The DataObject class name that this data list is querying
40
     *
41
     * @var string
42
     */
43
    protected $dataClass;
44
45
    /**
46
     * The {@link DataQuery} object responsible for getting this DataList's records
47
     *
48
     * @var DataQuery
49
     */
50
    protected $dataQuery;
51
52
    /**
53
     * The DataModel from which this DataList comes.
54
     *
55
     * @var DataModel
56
     */
57
    protected $model;
58
59
    /**
60
     * Create a new DataList.
61
     * No querying is done on construction, but the initial query schema is set up.
62
     *
63
     * @param string $dataClass - The DataObject class to query.
64
     */
65
    public function __construct($dataClass)
66
    {
67
        $this->dataClass = $dataClass;
68
        $this->dataQuery = new DataQuery($this->dataClass);
69
70
        parent::__construct();
71
    }
72
73
    /**
74
     * Set the DataModel
75
     *
76
     * @param DataModel $model
77
     */
78
    public function setDataModel(DataModel $model)
79
    {
80
        $this->model = $model;
81
    }
82
83
    /**
84
     * Get the dataClass name for this DataList, ie the DataObject ClassName
85
     *
86
     * @return string
87
     */
88
    public function dataClass()
89
    {
90
        return $this->dataClass;
91
    }
92
93
    /**
94
     * When cloning this object, clone the dataQuery object as well
95
     */
96
    public function __clone()
97
    {
98
        $this->dataQuery = clone $this->dataQuery;
99
    }
100
101
    /**
102
     * Return a copy of the internal {@link DataQuery} object
103
     *
104
     * Because the returned value is a copy, modifying it won't affect this list's contents. If
105
     * you want to alter the data query directly, use the alterDataQuery method
106
     *
107
     * @return DataQuery
108
     */
109
    public function dataQuery()
110
    {
111
        return clone $this->dataQuery;
112
    }
113
114
    /**
115
     * @var bool - Indicates if we are in an alterDataQueryCall already, so alterDataQuery can be re-entrant
116
     */
117
    protected $inAlterDataQueryCall = false;
118
119
    /**
120
     * Return a new DataList instance with the underlying {@link DataQuery} object altered
121
     *
122
     * If you want to alter the underlying dataQuery for this list, this wrapper method
123
     * will ensure that you can do so without mutating the existing List object.
124
     *
125
     * It clones this list, calls the passed callback function with the dataQuery of the new
126
     * list as it's first parameter (and the list as it's second), then returns the list
127
     *
128
     * Note that this function is re-entrant - it's safe to call this inside a callback passed to
129
     * alterDataQuery
130
     *
131
     * @param callable $callback
132
     * @return static
133
     * @throws Exception
134
     */
135
    public function alterDataQuery($callback)
136
    {
137
        if ($this->inAlterDataQueryCall) {
138
            $list = $this;
139
140
            $res = call_user_func($callback, $list->dataQuery, $list);
141
            if ($res) {
142
                $list->dataQuery = $res;
143
            }
144
145
            return $list;
146
        } else {
147
            $list = clone $this;
148
            $list->inAlterDataQueryCall = true;
149
150
            try {
151
                $res = call_user_func($callback, $list->dataQuery, $list);
152
                if ($res) {
153
                    $list->dataQuery = $res;
154
                }
155
            } catch (Exception $e) {
156
                $list->inAlterDataQueryCall = false;
157
                throw $e;
158
            }
159
160
            $list->inAlterDataQueryCall = false;
161
            return $list;
162
        }
163
    }
164
165
    /**
166
     * Return a new DataList instance with the underlying {@link DataQuery} object changed
167
     *
168
     * @param DataQuery $dataQuery
169
     * @return static
170
     */
171
    public function setDataQuery(DataQuery $dataQuery)
172
    {
173
        $clone = clone $this;
174
        $clone->dataQuery = $dataQuery;
175
        return $clone;
176
    }
177
178
    /**
179
     * Returns a new DataList instance with the specified query parameter assigned
180
     *
181
     * @param string|array $keyOrArray Either the single key to set, or an array of key value pairs to set
182
     * @param mixed $val If $keyOrArray is not an array, this is the value to set
183
     * @return static
184
     */
185
    public function setDataQueryParam($keyOrArray, $val = null)
186
    {
187
        $clone = clone $this;
188
189
        if (is_array($keyOrArray)) {
190
            foreach ($keyOrArray as $key => $val) {
191
                $clone->dataQuery->setQueryParam($key, $val);
192
            }
193
        } else {
194
            $clone->dataQuery->setQueryParam($keyOrArray, $val);
195
        }
196
197
        return $clone;
198
    }
199
200
    /**
201
     * Returns the SQL query that will be used to get this DataList's records.  Good for debugging. :-)
202
     *
203
     * @param array $parameters Out variable for parameters required for this query
204
     * @return string The resulting SQL query (may be paramaterised)
205
     */
206
    public function sql(&$parameters = array())
207
    {
208
        return $this->dataQuery->query()->sql($parameters);
209
    }
210
211
    /**
212
     * Return a new DataList instance with a WHERE clause added to this list's query.
213
     *
214
     * Supports parameterised queries.
215
     * See SQLSelect::addWhere() for syntax examples, although DataList
216
     * won't expand multiple method arguments as SQLSelect does.
217
     *
218
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
219
     * paramaterised queries
220
     * @return static
221
     */
222
    public function where($filter)
223
    {
224
        return $this->alterDataQuery(function (DataQuery $query) use ($filter) {
225
            $query->where($filter);
226
        });
227
    }
228
229
    /**
230
     * Return a new DataList instance with a WHERE clause added to this list's query.
231
     * All conditions provided in the filter will be joined with an OR
232
     *
233
     * Supports parameterised queries.
234
     * See SQLSelect::addWhere() for syntax examples, although DataList
235
     * won't expand multiple method arguments as SQLSelect does.
236
     *
237
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
238
     * paramaterised queries
239
     * @return static
240
     */
241
    public function whereAny($filter)
242
    {
243
        return $this->alterDataQuery(function (DataQuery $query) use ($filter) {
244
            $query->whereAny($filter);
245
        });
246
    }
247
248
249
250
    /**
251
     * Returns true if this DataList can be sorted by the given field.
252
     *
253
     * @param string $fieldName
254
     * @return boolean
255
     */
256
    public function canSortBy($fieldName)
257
    {
258
        return $this->dataQuery()->query()->canSortBy($fieldName);
259
    }
260
261
    /**
262
     * Returns true if this DataList can be filtered by the given field.
263
     *
264
     * @param string $fieldName (May be a related field in dot notation like Member.FirstName)
265
     * @return boolean
266
     */
267
    public function canFilterBy($fieldName)
268
    {
269
        $model = singleton($this->dataClass);
270
        $relations = explode(".", $fieldName);
271
        // First validate the relationships
272
        $fieldName = array_pop($relations);
273
        foreach ($relations as $r) {
274
            $relationClass = $model->getRelationClass($r);
275
            if (!$relationClass) {
276
                return false;
277
            }
278
            $model = singleton($relationClass);
279
            if (!$model) {
280
                return false;
281
            }
282
        }
283
        // Then check field
284
        if ($model->hasDatabaseField($fieldName)) {
285
            return true;
286
        }
287
        return false;
288
    }
289
290
    /**
291
     * Return a new DataList instance with the records returned in this query
292
     * restricted by a limit clause.
293
     *
294
     * @param int $limit
295
     * @param int $offset
296
     * @return static
297
     */
298
    public function limit($limit, $offset = 0)
299
    {
300
        return $this->alterDataQuery(function (DataQuery $query) use ($limit, $offset) {
301
            $query->limit($limit, $offset);
302
        });
303
    }
304
305
    /**
306
     * Return a new DataList instance with distinct records or not
307
     *
308
     * @param bool $value
309
     * @return static
310
     */
311
    public function distinct($value)
312
    {
313
        return $this->alterDataQuery(function (DataQuery $query) use ($value) {
314
            $query->distinct($value);
315
        });
316
    }
317
318
    /**
319
     * Return a new DataList instance as a copy of this data list with the sort
320
     * order set.
321
     *
322
     * @see SS_List::sort()
323
     * @see SQLSelect::orderby
324
     * @example $list = $list->sort('Name'); // default ASC sorting
325
     * @example $list = $list->sort('Name DESC'); // DESC sorting
326
     * @example $list = $list->sort('Name', 'ASC');
327
     * @example $list = $list->sort(array('Name'=>'ASC', 'Age'=>'DESC'));
328
     *
329
     * @param String|array Escaped SQL statement. If passed as array, all keys and values are assumed to be escaped.
330
     * @return static
331
     */
332
    public function sort()
333
    {
334
        $count = func_num_args();
335
336
        if ($count == 0) {
337
            return $this;
338
        }
339
340
        if ($count > 2) {
341
            throw new InvalidArgumentException('This method takes zero, one or two arguments');
342
        }
343
344
        if ($count == 2) {
345
            $col = null;
346
            $dir = null;
347
            list($col, $dir) = func_get_args();
348
349
            // Validate direction
350
            if (!in_array(strtolower($dir), array('desc','asc'))) {
351
                user_error('Second argument to sort must be either ASC or DESC');
352
            }
353
354
            $sort = array($col => $dir);
355
        } else {
356
            $sort = func_get_arg(0);
357
        }
358
359
        return $this->alterDataQuery(function (DataQuery $query, DataList $list) use ($sort) {
360
361
            if (is_string($sort) && $sort) {
362
                if (stristr($sort, ' asc') || stristr($sort, ' desc')) {
363
                    $query->sort($sort);
364
                } else {
365
                    $list->applyRelation($sort, $column, true);
366
                    $query->sort($column, 'ASC');
367
                }
368
            } elseif (is_array($sort)) {
369
                // sort(array('Name'=>'desc'));
370
                $query->sort(null, null); // wipe the sort
371
372
                foreach ($sort as $column => $direction) {
373
                    // Convert column expressions to SQL fragment, while still allowing the passing of raw SQL
374
                    // fragments.
375
                    $list->applyRelation($column, $relationColumn, true);
376
                    $query->sort($relationColumn, $direction, false);
377
                }
378
            }
379
        });
380
    }
381
382
    /**
383
     * Return a copy of this list which only includes items with these charactaristics
384
     *
385
     * @see SS_List::filter()
386
     *
387
     * @example $list = $list->filter('Name', 'bob'); // only bob in the list
388
     * @example $list = $list->filter('Name', array('aziz', 'bob'); // aziz and bob in list
389
     * @example $list = $list->filter(array('Name'=>'bob', 'Age'=>21)); // bob with the age 21
390
     * @example $list = $list->filter(array('Name'=>'bob', 'Age'=>array(21, 43))); // bob with the Age 21 or 43
391
     * @example $list = $list->filter(array('Name'=>array('aziz','bob'), 'Age'=>array(21, 43)));
392
     *          // aziz with the age 21 or 43 and bob with the Age 21 or 43
393
     *
394
     * Note: When filtering on nullable columns, null checks will be automatically added.
395
     * E.g. ->filter('Field:not', 'value) will generate '... OR "Field" IS NULL', and
396
     * ->filter('Field:not', null) will generate '"Field" IS NOT NULL'
397
     *
398
     * @todo extract the sql from $customQuery into a SQLGenerator class
399
     *
400
     * @param string|array Escaped SQL statement. If passed as array, all keys and values will be escaped internally
401
     * @return $this
402
     */
403
    public function filter()
404
    {
405
        // Validate and process arguments
406
        $arguments = func_get_args();
407
        switch (sizeof($arguments)) {
408
            case 1:
409
                $filters = $arguments[0];
410
411
                break;
412
            case 2:
413
                $filters = array($arguments[0] => $arguments[1]);
414
415
                break;
416
            default:
417
                throw new InvalidArgumentException('Incorrect number of arguments passed to filter()');
418
        }
419
420
        return $this->addFilter($filters);
421
    }
422
423
    /**
424
     * Return a new instance of the list with an added filter
425
     *
426
     * @param array $filterArray
427
     * @return $this
428
     */
429
    public function addFilter($filterArray)
430
    {
431
        $list = $this;
432
433
        foreach ($filterArray as $expression => $value) {
434
            $filter = $this->createSearchFilter($expression, $value);
435
            $list = $list->alterDataQuery(array($filter, 'apply'));
436
        }
437
438
        return $list;
439
    }
440
441
    /**
442
     * Return a copy of this list which contains items matching any of these charactaristics.
443
     *
444
     * @example // only bob in the list
445
     *          $list = $list->filterAny('Name', 'bob');
446
     *          // SQL: WHERE "Name" = 'bob'
447
     * @example // azis or bob in the list
448
     *          $list = $list->filterAny('Name', array('aziz', 'bob');
449
     *          // SQL: WHERE ("Name" IN ('aziz','bob'))
450
     * @example // bob or anyone aged 21 in the list
451
     *          $list = $list->filterAny(array('Name'=>'bob, 'Age'=>21));
452
     *          // SQL: WHERE ("Name" = 'bob' OR "Age" = '21')
453
     * @example // bob or anyone aged 21 or 43 in the list
454
     *          $list = $list->filterAny(array('Name'=>'bob, 'Age'=>array(21, 43)));
455
     *          // SQL: WHERE ("Name" = 'bob' OR ("Age" IN ('21', '43'))
456
     * @example // all bobs, phils or anyone aged 21 or 43 in the list
457
     *          $list = $list->filterAny(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43)));
458
     *          // SQL: WHERE (("Name" IN ('bob', 'phil')) OR ("Age" IN ('21', '43'))
459
     *
460
     * @todo extract the sql from this method into a SQLGenerator class
461
     *
462
     * @param string|array See {@link filter()}
463
     * @return static
464
     */
465 View Code Duplication
    public function filterAny()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
466
    {
467
        $numberFuncArgs = count(func_get_args());
468
        $whereArguments = array();
469
470
        if ($numberFuncArgs == 1 && is_array(func_get_arg(0))) {
471
            $whereArguments = func_get_arg(0);
472
        } elseif ($numberFuncArgs == 2) {
473
            $whereArguments[func_get_arg(0)] = func_get_arg(1);
474
        } else {
475
            throw new InvalidArgumentException('Incorrect number of arguments passed to filterAny()');
476
        }
477
478
        return $this->alterDataQuery(function (DataQuery $query) use ($whereArguments) {
479
            $subquery = $query->disjunctiveGroup();
480
481
            foreach ($whereArguments as $field => $value) {
482
                $filter = $this->createSearchFilter($field, $value);
483
                $filter->apply($subquery);
484
            }
485
        });
486
    }
487
488
    /**
489
     * Note that, in the current implementation, the filtered list will be an ArrayList, but this may change in a
490
     * future implementation.
491
     * @see Filterable::filterByCallback()
492
     *
493
     * @example $list = $list->filterByCallback(function($item, $list) { return $item->Age == 9; })
494
     * @param callable $callback
495
     * @return ArrayList (this may change in future implementations)
496
     */
497 View Code Duplication
    public function filterByCallback($callback)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
498
    {
499
        if (!is_callable($callback)) {
500
            throw new LogicException(sprintf(
501
                "SS_Filterable::filterByCallback() passed callback must be callable, '%s' given",
502
                gettype($callback)
503
            ));
504
        }
505
        /** @var ArrayList $output */
506
        $output = ArrayList::create();
507
        foreach ($this as $item) {
508
            if (call_user_func($callback, $item, $this)) {
509
                $output->push($item);
510
            }
511
        }
512
        return $output;
513
    }
514
515
    /**
516
     * Given a field or relation name, apply it safely to this datalist.
517
     *
518
     * Unlike getRelationName, this is immutable and will fallback to the quoted field
519
     * name if not a relation.
520
     *
521
     * @param string $field Name of field or relation to apply
522
     * @param string &$columnName Quoted column name
523
     * @param bool $linearOnly Set to true to restrict to linear relations only. Set this
524
     * if this relation will be used for sorting, and should not include duplicate rows.
525
     * @return $this DataList with this relation applied
526
     */
527
    public function applyRelation($field, &$columnName = null, $linearOnly = false)
528
    {
529
        // If field is invalid, return it without modification
530
        if (!$this->isValidRelationName($field)) {
531
            $columnName = $field;
532
            return $this;
533
        }
534
535
        // Simple fields without relations are mapped directly
536
        if (strpos($field, '.') === false) {
537
            $columnName = '"'.$field.'"';
538
            return $this;
539
        }
540
541
        return $this->alterDataQuery(
542
            function (DataQuery $query) use ($field, &$columnName, $linearOnly) {
543
                $relations = explode('.', $field);
544
                $fieldName = array_pop($relations);
545
546
                // Apply
547
                $relationModelName = $query->applyRelation($relations, $linearOnly);
548
549
                // Find the db field the relation belongs to
550
                $columnName = DataObject::getSchema()->sqlColumnForField($relationModelName, $fieldName);
551
            }
552
        );
553
    }
554
555
    /**
556
     * Check if the given field specification could be interpreted as an unquoted relation name
557
     *
558
     * @param string $field
559
     * @return bool
560
     */
561
    protected function isValidRelationName($field)
562
    {
563
        return preg_match('/^[A-Z0-9._]+$/i', $field);
564
    }
565
566
    /**
567
     * Given a filter expression and value construct a {@see SearchFilter} instance
568
     *
569
     * @param string $filter E.g. `Name:ExactMatch:not`, `Name:ExactMatch`, `Name:not`, `Name`
570
     * @param mixed $value Value of the filter
571
     * @return SearchFilter
572
     */
573
    protected function createSearchFilter($filter, $value)
574
    {
575
        // Field name is always the first component
576
        $fieldArgs = explode(':', $filter);
577
        $fieldName = array_shift($fieldArgs);
578
579
        // Inspect type of second argument to determine context
580
        $secondArg = array_shift($fieldArgs);
581
        $modifiers = $fieldArgs;
582
        if (!$secondArg) {
583
            // Use default filter if none specified. E.g. `->filter(['Name' => $myname])`
584
            $filterServiceName = 'DataListFilter.default';
585
        } else {
586
            // The presence of a second argument is by default ambiguous; We need to query
587
            // Whether this is a valid modifier on the default filter, or a filter itself.
588
            /** @var SearchFilter $defaultFilterInstance */
589
            $defaultFilterInstance = Injector::inst()->get('DataListFilter.default');
590
            if (in_array(strtolower($secondArg), $defaultFilterInstance->getSupportedModifiers())) {
591
                // Treat second (and any subsequent) argument as modifiers, using default filter
592
                $filterServiceName = 'DataListFilter.default';
593
                array_unshift($modifiers, $secondArg);
594
            } else {
595
                // Second argument isn't a valid modifier, so assume is filter identifier
596
                $filterServiceName = "DataListFilter.{$secondArg}";
597
            }
598
        }
599
600
        // Build instance
601
        return Injector::inst()->create($filterServiceName, $fieldName, $value, $modifiers);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Container\ContainerInterface as the method create() does only exist in the following implementations of said interface: SilverStripe\Core\Injector\Injector.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
602
    }
603
604
    /**
605
     * Return a copy of this list which does not contain any items with these charactaristics
606
     *
607
     * @see SS_List::exclude()
608
     * @example $list = $list->exclude('Name', 'bob'); // exclude bob from list
609
     * @example $list = $list->exclude('Name', array('aziz', 'bob'); // exclude aziz and bob from list
610
     * @example $list = $list->exclude(array('Name'=>'bob, 'Age'=>21)); // exclude bob that has Age 21
611
     * @example $list = $list->exclude(array('Name'=>'bob, 'Age'=>array(21, 43))); // exclude bob with Age 21 or 43
612
     * @example $list = $list->exclude(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43)));
613
     *          // bob age 21 or 43, phil age 21 or 43 would be excluded
614
     *
615
     * @todo extract the sql from this method into a SQLGenerator class
616
     *
617
     * @param string|array Escaped SQL statement. If passed as array, all keys and values will be escaped internally
618
     * @return $this
619
     */
620 View Code Duplication
    public function exclude()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
621
    {
622
        $numberFuncArgs = count(func_get_args());
623
        $whereArguments = array();
624
625
        if ($numberFuncArgs == 1 && is_array(func_get_arg(0))) {
626
            $whereArguments = func_get_arg(0);
627
        } elseif ($numberFuncArgs == 2) {
628
            $whereArguments[func_get_arg(0)] = func_get_arg(1);
629
        } else {
630
            throw new InvalidArgumentException('Incorrect number of arguments passed to exclude()');
631
        }
632
633
        return $this->alterDataQuery(function (DataQuery $query) use ($whereArguments) {
634
            $subquery = $query->disjunctiveGroup();
635
636
            foreach ($whereArguments as $field => $value) {
637
                $filter = $this->createSearchFilter($field, $value);
638
                $filter->exclude($subquery);
639
            }
640
        });
641
    }
642
643
    /**
644
     * This method returns a copy of this list that does not contain any DataObjects that exists in $list
645
     *
646
     * The $list passed needs to contain the same dataclass as $this
647
     *
648
     * @param DataList $list
649
     * @return static
650
     * @throws InvalidArgumentException
651
     */
652
    public function subtract(DataList $list)
653
    {
654
        if ($this->dataClass() != $list->dataClass()) {
655
            throw new InvalidArgumentException('The list passed must have the same dataclass as this class');
656
        }
657
658
        return $this->alterDataQuery(function (DataQuery $query) use ($list) {
659
            $query->subtract($list->dataQuery());
660
        });
661
    }
662
663
    /**
664
     * Return a new DataList instance with an inner join clause added to this list's query.
665
     *
666
     * @param string $table Table name (unquoted and as escaped SQL)
667
     * @param string $onClause Escaped SQL statement, e.g. '"Table1"."ID" = "Table2"."ID"'
668
     * @param string $alias - if you want this table to be aliased under another name
669
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
670
     * will cause the query to appear first. The default is 20, and joins created automatically by the
671
     * ORM have a value of 10.
672
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
673
     * @return static
674
     */
675
    public function innerJoin($table, $onClause, $alias = null, $order = 20, $parameters = array())
676
    {
677
        return $this->alterDataQuery(function (DataQuery $query) use ($table, $onClause, $alias, $order, $parameters) {
678
            $query->innerJoin($table, $onClause, $alias, $order, $parameters);
679
        });
680
    }
681
682
    /**
683
     * Return a new DataList instance with a left join clause added to this list's query.
684
     *
685
     * @param string $table Table name (unquoted and as escaped SQL)
686
     * @param string $onClause Escaped SQL statement, e.g. '"Table1"."ID" = "Table2"."ID"'
687
     * @param string $alias - if you want this table to be aliased under another name
688
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
689
     * will cause the query to appear first. The default is 20, and joins created automatically by the
690
     * ORM have a value of 10.
691
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
692
     * @return static
693
     */
694
    public function leftJoin($table, $onClause, $alias = null, $order = 20, $parameters = array())
695
    {
696
        return $this->alterDataQuery(function (DataQuery $query) use ($table, $onClause, $alias, $order, $parameters) {
697
            $query->leftJoin($table, $onClause, $alias, $order, $parameters);
698
        });
699
    }
700
701
    /**
702
     * Return an array of the actual items that this DataList contains at this stage.
703
     * This is when the query is actually executed.
704
     *
705
     * @return array
706
     */
707
    public function toArray()
708
    {
709
        $query = $this->dataQuery->query();
710
        $rows = $query->execute();
711
        $results = array();
712
713
        foreach ($rows as $row) {
714
            $results[] = $this->createDataObject($row);
715
        }
716
717
        return $results;
718
    }
719
720
    /**
721
     * Return this list as an array and every object it as an sub array as well
722
     *
723
     * @return array
724
     */
725
    public function toNestedArray()
726
    {
727
        $result = array();
728
729
        foreach ($this as $item) {
730
            $result[] = $item->toMap();
731
        }
732
733
        return $result;
734
    }
735
736
    /**
737
     * Walks the list using the specified callback
738
     *
739
     * @param callable $callback
740
     * @return $this
741
     */
742
    public function each($callback)
743
    {
744
        foreach ($this as $row) {
745
            $callback($row);
746
        }
747
748
        return $this;
749
    }
750
751 View Code Duplication
    public function debug()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
752
    {
753
        $val = "<h2>" . $this->class . "</h2><ul>";
754
755
        foreach ($this->toNestedArray() as $item) {
756
            $val .= "<li style=\"list-style-type: disc; margin-left: 20px\">" . Debug::text($item) . "</li>";
757
        }
758
        $val .= "</ul>";
759
        return $val;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $val; (string) is incompatible with the return type of the parent method SilverStripe\View\ViewableData::Debug of type SilverStripe\View\ViewableData_Debugger.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
760
    }
761
762
    /**
763
     * Returns a map of this list
764
     *
765
     * @param string $keyField - the 'key' field of the result array
766
     * @param string $titleField - the value field of the result array
767
     * @return Map
768
     */
769
    public function map($keyField = 'ID', $titleField = 'Title')
770
    {
771
        return new Map($this, $keyField, $titleField);
772
    }
773
774
    /**
775
     * Create a DataObject from the given SQL row
776
     *
777
     * @param array $row
778
     * @return DataObject
779
     */
780
    public function createDataObject($row)
781
    {
782
        $class = $this->dataClass;
783
784
        if (empty($row['ClassName'])) {
785
            $row['ClassName'] = $class;
786
        }
787
788
        // Failover from RecordClassName to ClassName
789
        if (empty($row['RecordClassName'])) {
790
            $row['RecordClassName'] = $row['ClassName'];
791
        }
792
793
        // Instantiate the class mentioned in RecordClassName only if it exists, otherwise default to $this->dataClass
794
        if (class_exists($row['RecordClassName'])) {
795
            $class = $row['RecordClassName'];
796
        }
797
798
        $item = Injector::inst()->create($class, $row, false, $this->model, $this->getQueryParams());
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Container\ContainerInterface as the method create() does only exist in the following implementations of said interface: SilverStripe\Core\Injector\Injector.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
799
800
        return $item;
801
    }
802
803
    /**
804
     * Get query parameters for this list.
805
     * These values will be assigned as query parameters to newly created objects from this list.
806
     *
807
     * @return array
808
     */
809
    public function getQueryParams()
810
    {
811
        return $this->dataQuery()->getQueryParams();
812
    }
813
814
    /**
815
     * Returns an Iterator for this DataList.
816
     * This function allows you to use DataLists in foreach loops
817
     *
818
     * @return ArrayIterator
819
     */
820
    public function getIterator()
821
    {
822
        return new ArrayIterator($this->toArray());
823
    }
824
825
    /**
826
     * Return the number of items in this DataList
827
     *
828
     * @return int
829
     */
830
    public function count()
831
    {
832
        return $this->dataQuery->count();
833
    }
834
835
    /**
836
     * Return the maximum value of the given field in this DataList
837
     *
838
     * @param string $fieldName
839
     * @return mixed
840
     */
841
    public function max($fieldName)
842
    {
843
        return $this->dataQuery->max($fieldName);
844
    }
845
846
    /**
847
     * Return the minimum value of the given field in this DataList
848
     *
849
     * @param string $fieldName
850
     * @return mixed
851
     */
852
    public function min($fieldName)
853
    {
854
        return $this->dataQuery->min($fieldName);
855
    }
856
857
    /**
858
     * Return the average value of the given field in this DataList
859
     *
860
     * @param string $fieldName
861
     * @return mixed
862
     */
863
    public function avg($fieldName)
864
    {
865
        return $this->dataQuery->avg($fieldName);
866
    }
867
868
    /**
869
     * Return the sum of the values of the given field in this DataList
870
     *
871
     * @param string $fieldName
872
     * @return mixed
873
     */
874
    public function sum($fieldName)
875
    {
876
        return $this->dataQuery->sum($fieldName);
877
    }
878
879
880
    /**
881
     * Returns the first item in this DataList
882
     *
883
     * @return DataObject
884
     */
885
    public function first()
886
    {
887
        foreach ($this->dataQuery->firstRow()->execute() as $row) {
888
            return $this->createDataObject($row);
889
        }
890
        return null;
891
    }
892
893
    /**
894
     * Returns the last item in this DataList
895
     *
896
     *  @return DataObject
897
     */
898
    public function last()
899
    {
900
        foreach ($this->dataQuery->lastRow()->execute() as $row) {
901
            return $this->createDataObject($row);
902
        }
903
        return null;
904
    }
905
906
    /**
907
     * Returns true if this DataList has items
908
     *
909
     * @return bool
910
     */
911
    public function exists()
912
    {
913
        return $this->count() > 0;
914
    }
915
916
    /**
917
     * Find the first DataObject of this DataList where the given key = value
918
     *
919
     * @param string $key
920
     * @param string $value
921
     * @return DataObject|null
922
     */
923
    public function find($key, $value)
924
    {
925
        return $this->filter($key, $value)->first();
926
    }
927
928
    /**
929
     * Restrict the columns to fetch into this DataList
930
     *
931
     * @param array $queriedColumns
932
     * @return static
933
     */
934
    public function setQueriedColumns($queriedColumns)
935
    {
936
        return $this->alterDataQuery(function (DataQuery $query) use ($queriedColumns) {
937
            $query->setQueriedColumns($queriedColumns);
938
        });
939
    }
940
941
    /**
942
     * Filter this list to only contain the given Primary IDs
943
     *
944
     * @param array $ids Array of integers
945
     * @return $this
946
     */
947
    public function byIDs($ids)
948
    {
949
        return $this->filter('ID', $ids);
950
    }
951
952
    /**
953
     * Return the first DataObject with the given ID
954
     *
955
     * @param int $id
956
     * @return DataObject
957
     */
958
    public function byID($id)
959
    {
960
        return $this->filter('ID', $id)->first();
961
    }
962
963
    /**
964
     * Returns an array of a single field value for all items in the list.
965
     *
966
     * @param string $colName
967
     * @return array
968
     */
969
    public function column($colName = "ID")
970
    {
971
        return $this->dataQuery->column($colName);
972
    }
973
974
    // Member altering methods
975
976
    /**
977
     * Sets the ComponentSet to be the given ID list.
978
     * Records will be added and deleted as appropriate.
979
     *
980
     * @param array $idList List of IDs.
981
     */
982
    public function setByIDList($idList)
983
    {
984
        $has = array();
985
986
        // Index current data
987
        foreach ($this->column() as $id) {
988
            $has[$id] = true;
989
        }
990
991
        // Keep track of items to delete
992
        $itemsToDelete = $has;
993
994
        // add items in the list
995
        // $id is the database ID of the record
996
        if ($idList) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $idList 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...
997
            foreach ($idList as $id) {
998
                unset($itemsToDelete[$id]);
999
                if ($id && !isset($has[$id])) {
1000
                    $this->add($id);
1001
                }
1002
            }
1003
        }
1004
1005
        // Remove any items that haven't been mentioned
1006
        $this->removeMany(array_keys($itemsToDelete));
1007
    }
1008
1009
    /**
1010
     * Returns an array with both the keys and values set to the IDs of the records in this list.
1011
     * Does not respect sort order. Use ->column("ID") to get an ID list with the current sort.
1012
     *
1013
     * @return array
1014
     */
1015
    public function getIDList()
1016
    {
1017
        $ids = $this->column("ID");
1018
        return $ids ? array_combine($ids, $ids) : array();
1019
    }
1020
1021
    /**
1022
     * Returns a HasManyList or ManyMany list representing the querying of a relation across all
1023
     * objects in this data list.  For it to work, the relation must be defined on the data class
1024
     * that you used to create this DataList.
1025
     *
1026
     * Example: Get members from all Groups:
1027
     *
1028
     *     DataList::Create("Group")->relation("Members")
1029
     *
1030
     * @param string $relationName
1031
     * @return HasManyList|ManyManyList
1032
     */
1033
    public function relation($relationName)
1034
    {
1035
        $ids = $this->column('ID');
1036
        return singleton($this->dataClass)->$relationName()->forForeignID($ids);
1037
    }
1038
1039
    public function dbObject($fieldName)
1040
    {
1041
        return singleton($this->dataClass)->dbObject($fieldName);
1042
    }
1043
1044
    /**
1045
     * Add a number of items to the component set.
1046
     *
1047
     * @param array $items Items to add, as either DataObjects or IDs.
1048
     * @return $this
1049
     */
1050
    public function addMany($items)
1051
    {
1052
        foreach ($items as $item) {
1053
            $this->add($item);
1054
        }
1055
        return $this;
1056
    }
1057
1058
    /**
1059
     * Remove the items from this list with the given IDs
1060
     *
1061
     * @param array $idList
1062
     * @return $this
1063
     */
1064
    public function removeMany($idList)
1065
    {
1066
        foreach ($idList as $id) {
1067
            $this->removeByID($id);
1068
        }
1069
        return $this;
1070
    }
1071
1072
    /**
1073
     * Remove every element in this DataList matching the given $filter.
1074
     *
1075
     * @param string $filter - a sql type where filter
1076
     * @return $this
1077
     */
1078
    public function removeByFilter($filter)
1079
    {
1080
        foreach ($this->where($filter) as $item) {
1081
            $this->remove($item);
1082
        }
1083
        return $this;
1084
    }
1085
1086
    /**
1087
     * Remove every element in this DataList.
1088
     *
1089
     * @return $this
1090
     */
1091
    public function removeAll()
1092
    {
1093
        foreach ($this as $item) {
1094
            $this->remove($item);
1095
        }
1096
        return $this;
1097
    }
1098
1099
    /**
1100
     * This method are overloaded by HasManyList and ManyMany list to perform more sophisticated
1101
     * list manipulation
1102
     *
1103
     * @param mixed $item
1104
     */
1105
    public function add($item)
1106
    {
1107
        // Nothing needs to happen by default
1108
        // TO DO: If a filter is given to this data list then
1109
    }
1110
1111
    /**
1112
     * Return a new item to add to this DataList.
1113
     *
1114
     * @todo This doesn't factor in filters.
1115
     * @param array $initialFields
1116
     * @return DataObject
1117
     */
1118
    public function newObject($initialFields = null)
1119
    {
1120
        $class = $this->dataClass;
1121
        return Injector::inst()->create($class, $initialFields, false, $this->model);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Container\ContainerInterface as the method create() does only exist in the following implementations of said interface: SilverStripe\Core\Injector\Injector.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
1122
    }
1123
1124
    /**
1125
     * Remove this item by deleting it
1126
     *
1127
     * @param DataObject $item
1128
     * @todo Allow for amendment of this behaviour - for example, we can remove an item from
1129
     * an "ActiveItems" DataList by chaning the status to inactive.
1130
     */
1131
    public function remove($item)
1132
    {
1133
        // By default, we remove an item from a DataList by deleting it.
1134
        $this->removeByID($item->ID);
1135
    }
1136
1137
    /**
1138
     * Remove an item from this DataList by ID
1139
     *
1140
     * @param int $itemID The primary ID
1141
     */
1142
    public function removeByID($itemID)
1143
    {
1144
        $item = $this->byID($itemID);
1145
1146
        if ($item) {
1147
            $item->delete();
1148
        }
1149
    }
1150
1151
    /**
1152
     * Reverses a list of items.
1153
     *
1154
     * @return static
1155
     */
1156
    public function reverse()
1157
    {
1158
        return $this->alterDataQuery(function (DataQuery $query) {
1159
            $query->reverseSort();
1160
        });
1161
    }
1162
1163
    /**
1164
     * Returns whether an item with $key exists
1165
     *
1166
     * @param mixed $key
1167
     * @return bool
1168
     */
1169
    public function offsetExists($key)
1170
    {
1171
        return ($this->limit(1, $key)->first() != null);
1172
    }
1173
1174
    /**
1175
     * Returns item stored in list with index $key
1176
     *
1177
     * @param mixed $key
1178
     * @return DataObject
1179
     */
1180
    public function offsetGet($key)
1181
    {
1182
        return $this->limit(1, $key)->first();
1183
    }
1184
1185
    /**
1186
     * Set an item with the key in $key
1187
     *
1188
     * @param mixed $key
1189
     * @param mixed $value
1190
     */
1191
    public function offsetSet($key, $value)
1192
    {
1193
        user_error("Can't alter items in a DataList using array-access", E_USER_ERROR);
1194
    }
1195
1196
    /**
1197
     * Unset an item with the key in $key
1198
     *
1199
     * @param mixed $key
1200
     */
1201
    public function offsetUnset($key)
1202
    {
1203
        user_error("Can't alter items in a DataList using array-access", E_USER_ERROR);
1204
    }
1205
}
1206