Passed
Push — 4 ( dcdc25...6fc25e )
by
unknown
07:34
created

DataList::chunkedFetch()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 26
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 12
nc 6
nop 1
dl 0
loc 26
rs 9.5555
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
     * Create a new DataList.
54
     * No querying is done on construction, but the initial query schema is set up.
55
     *
56
     * @param string $dataClass - The DataObject class to query.
57
     */
58
    public function __construct($dataClass)
59
    {
60
        $this->dataClass = $dataClass;
61
        $this->dataQuery = new DataQuery($this->dataClass);
62
63
        parent::__construct();
64
    }
65
66
    /**
67
     * Get the dataClass name for this DataList, ie the DataObject ClassName
68
     *
69
     * @return string
70
     */
71
    public function dataClass()
72
    {
73
        return $this->dataClass;
74
    }
75
76
    /**
77
     * When cloning this object, clone the dataQuery object as well
78
     */
79
    public function __clone()
80
    {
81
        $this->dataQuery = clone $this->dataQuery;
82
    }
83
84
    /**
85
     * Return a copy of the internal {@link DataQuery} object
86
     *
87
     * Because the returned value is a copy, modifying it won't affect this list's contents. If
88
     * you want to alter the data query directly, use the alterDataQuery method
89
     *
90
     * @return DataQuery
91
     */
92
    public function dataQuery()
93
    {
94
        return clone $this->dataQuery;
95
    }
96
97
    /**
98
     * @var bool - Indicates if we are in an alterDataQueryCall already, so alterDataQuery can be re-entrant
99
     */
100
    protected $inAlterDataQueryCall = false;
101
102
    /**
103
     * Return a new DataList instance with the underlying {@link DataQuery} object altered
104
     *
105
     * If you want to alter the underlying dataQuery for this list, this wrapper method
106
     * will ensure that you can do so without mutating the existing List object.
107
     *
108
     * It clones this list, calls the passed callback function with the dataQuery of the new
109
     * list as it's first parameter (and the list as it's second), then returns the list
110
     *
111
     * Note that this function is re-entrant - it's safe to call this inside a callback passed to
112
     * alterDataQuery
113
     *
114
     * @param callable $callback
115
     * @return static
116
     * @throws Exception
117
     */
118
    public function alterDataQuery($callback)
119
    {
120
        if ($this->inAlterDataQueryCall) {
121
            $list = $this;
122
123
            $res = call_user_func($callback, $list->dataQuery, $list);
124
            if ($res) {
125
                $list->dataQuery = $res;
126
            }
127
128
            return $list;
129
        }
130
131
        $list = clone $this;
132
        $list->inAlterDataQueryCall = true;
133
134
        try {
135
            $res = $callback($list->dataQuery, $list);
136
            if ($res) {
137
                $list->dataQuery = $res;
138
            }
139
        } catch (Exception $e) {
140
            $list->inAlterDataQueryCall = false;
141
            throw $e;
142
        }
143
144
        $list->inAlterDataQueryCall = false;
145
        return $list;
146
    }
147
148
    /**
149
     * Return a new DataList instance with the underlying {@link DataQuery} object changed
150
     *
151
     * @param DataQuery $dataQuery
152
     * @return static
153
     */
154
    public function setDataQuery(DataQuery $dataQuery)
155
    {
156
        $clone = clone $this;
157
        $clone->dataQuery = $dataQuery;
158
        return $clone;
159
    }
160
161
    /**
162
     * Returns a new DataList instance with the specified query parameter assigned
163
     *
164
     * @param string|array $keyOrArray Either the single key to set, or an array of key value pairs to set
165
     * @param mixed $val If $keyOrArray is not an array, this is the value to set
166
     * @return static
167
     */
168
    public function setDataQueryParam($keyOrArray, $val = null)
169
    {
170
        $clone = clone $this;
171
172
        if (is_array($keyOrArray)) {
173
            foreach ($keyOrArray as $key => $value) {
174
                $clone->dataQuery->setQueryParam($key, $value);
175
            }
176
        } else {
177
            $clone->dataQuery->setQueryParam($keyOrArray, $val);
178
        }
179
180
        return $clone;
181
    }
182
183
    /**
184
     * Returns the SQL query that will be used to get this DataList's records.  Good for debugging. :-)
185
     *
186
     * @param array $parameters Out variable for parameters required for this query
187
     * @return string The resulting SQL query (may be parameterised)
188
     */
189
    public function sql(&$parameters = [])
190
    {
191
        return $this->dataQuery->query()->sql($parameters);
192
    }
193
194
    /**
195
     * Return a new DataList instance with a WHERE clause added to this list's query.
196
     *
197
     * Supports parameterised queries.
198
     * See SQLSelect::addWhere() for syntax examples, although DataList
199
     * won't expand multiple method arguments as SQLSelect does.
200
     *
201
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
202
     * paramaterised queries
203
     * @return static
204
     */
205
    public function where($filter)
206
    {
207
        return $this->alterDataQuery(function (DataQuery $query) use ($filter) {
208
            $query->where($filter);
209
        });
210
    }
211
212
    /**
213
     * Return a new DataList instance with a WHERE clause added to this list's query.
214
     * All conditions provided in the filter will be joined with an OR
215
     *
216
     * Supports parameterised queries.
217
     * See SQLSelect::addWhere() for syntax examples, although DataList
218
     * won't expand multiple method arguments as SQLSelect does.
219
     *
220
     * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or
221
     * paramaterised queries
222
     * @return static
223
     */
224
    public function whereAny($filter)
225
    {
226
        return $this->alterDataQuery(function (DataQuery $query) use ($filter) {
227
            $query->whereAny($filter);
228
        });
229
    }
230
231
232
233
    /**
234
     * Returns true if this DataList can be sorted by the given field.
235
     *
236
     * @param string $fieldName
237
     * @return boolean
238
     */
239
    public function canSortBy($fieldName)
240
    {
241
        return $this->dataQuery()->query()->canSortBy($fieldName);
242
    }
243
244
    /**
245
     * Returns true if this DataList can be filtered by the given field.
246
     *
247
     * @param string $fieldName (May be a related field in dot notation like Member.FirstName)
248
     * @return boolean
249
     */
250
    public function canFilterBy($fieldName)
251
    {
252
        $model = singleton($this->dataClass);
253
        $relations = explode(".", $fieldName);
254
        // First validate the relationships
255
        $fieldName = array_pop($relations);
256
        foreach ($relations as $r) {
257
            $relationClass = $model->getRelationClass($r);
258
            if (!$relationClass) {
259
                return false;
260
            }
261
            $model = singleton($relationClass);
262
            if (!$model) {
263
                return false;
264
            }
265
        }
266
        // Then check field
267
        if ($model->hasDatabaseField($fieldName)) {
268
            return true;
269
        }
270
        return false;
271
    }
272
273
    /**
274
     * Return a new DataList instance with the records returned in this query
275
     * restricted by a limit clause.
276
     *
277
     * @param int $limit
278
     * @param int $offset
279
     * @return static
280
     */
281
    public function limit($limit, $offset = 0)
282
    {
283
        return $this->alterDataQuery(function (DataQuery $query) use ($limit, $offset) {
284
            $query->limit($limit, $offset);
285
        });
286
    }
287
288
    /**
289
     * Return a new DataList instance with distinct records or not
290
     *
291
     * @param bool $value
292
     * @return static
293
     */
294
    public function distinct($value)
295
    {
296
        return $this->alterDataQuery(function (DataQuery $query) use ($value) {
297
            $query->distinct($value);
298
        });
299
    }
300
301
    /**
302
     * Return a new DataList instance as a copy of this data list with the sort
303
     * order set.
304
     *
305
     * @see SS_List::sort()
306
     * @see SQLSelect::orderby
307
     * @example $list = $list->sort('Name'); // default ASC sorting
308
     * @example $list = $list->sort('Name DESC'); // DESC sorting
309
     * @example $list = $list->sort('Name', 'ASC');
310
     * @example $list = $list->sort(array('Name'=>'ASC', 'Age'=>'DESC'));
311
     *
312
     * @param string|array Escaped SQL statement. If passed as array, all keys and values are assumed to be escaped.
0 ignored issues
show
Bug introduced by
The type SilverStripe\ORM\Escaped was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
313
     * @return static
314
     */
315
    public function sort()
316
    {
317
        $count = func_num_args();
318
319
        if ($count == 0) {
320
            return $this;
321
        }
322
323
        if ($count > 2) {
324
            throw new InvalidArgumentException('This method takes zero, one or two arguments');
325
        }
326
327
        if ($count == 2) {
328
            $col = null;
329
            $dir = null;
330
            list($col, $dir) = func_get_args();
331
332
            // Validate direction
333
            if (!in_array(strtolower($dir), ['desc', 'asc'])) {
334
                user_error('Second argument to sort must be either ASC or DESC');
335
            }
336
337
            $sort = [$col => $dir];
338
        } else {
339
            $sort = func_get_arg(0);
340
        }
341
342
        return $this->alterDataQuery(function (DataQuery $query, DataList $list) use ($sort) {
343
344
            if (is_string($sort) && $sort) {
345
                if (false !== stripos($sort, ' asc') || false !== stripos($sort, ' desc')) {
346
                    $query->sort($sort);
347
                } else {
348
                    $list->applyRelation($sort, $column, true);
349
                    $query->sort($column, 'ASC');
350
                }
351
            } elseif (is_array($sort)) {
352
                // sort(array('Name'=>'desc'));
353
                $query->sort(null, null); // wipe the sort
354
355
                foreach ($sort as $column => $direction) {
356
                    // Convert column expressions to SQL fragment, while still allowing the passing of raw SQL
357
                    // fragments.
358
                    $list->applyRelation($column, $relationColumn, true);
359
                    $query->sort($relationColumn, $direction, false);
360
                }
361
            }
362
        });
363
    }
364
365
    /**
366
     * Return a copy of this list which only includes items with these characteristics
367
     *
368
     * @see Filterable::filter()
369
     *
370
     * @example $list = $list->filter('Name', 'bob'); // only bob in the list
371
     * @example $list = $list->filter('Name', array('aziz', 'bob'); // aziz and bob in list
372
     * @example $list = $list->filter(array('Name'=>'bob', 'Age'=>21)); // bob with the age 21
373
     * @example $list = $list->filter(array('Name'=>'bob', 'Age'=>array(21, 43))); // bob with the Age 21 or 43
374
     * @example $list = $list->filter(array('Name'=>array('aziz','bob'), 'Age'=>array(21, 43)));
375
     *          // aziz with the age 21 or 43 and bob with the Age 21 or 43
376
     *
377
     * Note: When filtering on nullable columns, null checks will be automatically added.
378
     * E.g. ->filter('Field:not', 'value) will generate '... OR "Field" IS NULL', and
379
     * ->filter('Field:not', null) will generate '"Field" IS NOT NULL'
380
     *
381
     * @todo extract the sql from $customQuery into a SQLGenerator class
382
     *
383
     * @param string|array Escaped SQL statement. If passed as array, all keys and values will be escaped internally
384
     * @return $this
385
     */
386
    public function filter()
387
    {
388
        // Validate and process arguments
389
        $arguments = func_get_args();
390
        switch (sizeof($arguments)) {
391
            case 1:
392
                $filters = $arguments[0];
393
394
                break;
395
            case 2:
396
                $filters = [$arguments[0] => $arguments[1]];
397
398
                break;
399
            default:
400
                throw new InvalidArgumentException('Incorrect number of arguments passed to filter()');
401
        }
402
403
        return $this->addFilter($filters);
404
    }
405
406
    /**
407
     * Return a new instance of the list with an added filter
408
     *
409
     * @param array $filterArray
410
     * @return $this
411
     */
412
    public function addFilter($filterArray)
413
    {
414
        $list = $this;
415
416
        foreach ($filterArray as $expression => $value) {
417
            $filter = $this->createSearchFilter($expression, $value);
418
            $list = $list->alterDataQuery([$filter, 'apply']);
419
        }
420
421
        return $list;
422
    }
423
424
    /**
425
     * Return a copy of this list which contains items matching any of these characteristics.
426
     *
427
     * @example // only bob in the list
428
     *          $list = $list->filterAny('Name', 'bob');
429
     *          // SQL: WHERE "Name" = 'bob'
430
     * @example // azis or bob in the list
431
     *          $list = $list->filterAny('Name', array('aziz', 'bob');
432
     *          // SQL: WHERE ("Name" IN ('aziz','bob'))
433
     * @example // bob or anyone aged 21 in the list
434
     *          $list = $list->filterAny(array('Name'=>'bob, 'Age'=>21));
435
     *          // SQL: WHERE ("Name" = 'bob' OR "Age" = '21')
436
     * @example // bob or anyone aged 21 or 43 in the list
437
     *          $list = $list->filterAny(array('Name'=>'bob, 'Age'=>array(21, 43)));
438
     *          // SQL: WHERE ("Name" = 'bob' OR ("Age" IN ('21', '43'))
439
     * @example // all bobs, phils or anyone aged 21 or 43 in the list
440
     *          $list = $list->filterAny(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43)));
441
     *          // SQL: WHERE (("Name" IN ('bob', 'phil')) OR ("Age" IN ('21', '43'))
442
     *
443
     * @todo extract the sql from this method into a SQLGenerator class
444
     *
445
     * @param string|array See {@link filter()}
0 ignored issues
show
Bug introduced by
The type SilverStripe\ORM\See was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
446
     * @return static
447
     */
448
    public function filterAny()
449
    {
450
        $numberFuncArgs = count(func_get_args());
451
        $whereArguments = [];
452
453
        if ($numberFuncArgs == 1 && is_array(func_get_arg(0))) {
454
            $whereArguments = func_get_arg(0);
455
        } elseif ($numberFuncArgs == 2) {
456
            $whereArguments[func_get_arg(0)] = func_get_arg(1);
457
        } else {
458
            throw new InvalidArgumentException('Incorrect number of arguments passed to filterAny()');
459
        }
460
461
        return $this->alterDataQuery(function (DataQuery $query) use ($whereArguments) {
462
            $subquery = $query->disjunctiveGroup();
463
464
            foreach ($whereArguments as $field => $value) {
465
                $filter = $this->createSearchFilter($field, $value);
466
                $filter->apply($subquery);
467
            }
468
        });
469
    }
470
471
    /**
472
     * Note that, in the current implementation, the filtered list will be an ArrayList, but this may change in a
473
     * future implementation.
474
     * @see Filterable::filterByCallback()
475
     *
476
     * @example $list = $list->filterByCallback(function($item, $list) { return $item->Age == 9; })
477
     * @param callable $callback
478
     * @return ArrayList (this may change in future implementations)
479
     */
480
    public function filterByCallback($callback)
481
    {
482
        if (!is_callable($callback)) {
483
            throw new LogicException(sprintf(
484
                "SS_Filterable::filterByCallback() passed callback must be callable, '%s' given",
485
                gettype($callback)
486
            ));
487
        }
488
        /** @var ArrayList $output */
489
        $output = ArrayList::create();
490
        foreach ($this as $item) {
491
            if (call_user_func($callback, $item, $this)) {
492
                $output->push($item);
493
            }
494
        }
495
        return $output;
496
    }
497
498
    /**
499
     * Given a field or relation name, apply it safely to this datalist.
500
     *
501
     * Unlike getRelationName, this is immutable and will fallback to the quoted field
502
     * name if not a relation.
503
     *
504
     * Example use (simple WHERE condition on data sitting in a related table):
505
     *
506
     * <code>
507
     *  $columnName = null;
508
     *  $list = Page::get()
509
     *    ->applyRelation('TaxonomyTerms.ID', $columnName)
510
     *    ->where([$columnName => 'my value']);
511
     * </code>
512
     *
513
     *
514
     * @param string $field Name of field or relation to apply
515
     * @param string $columnName Quoted column name (by reference)
516
     * @param bool $linearOnly Set to true to restrict to linear relations only. Set this
517
     * if this relation will be used for sorting, and should not include duplicate rows.
518
     * @return $this DataList with this relation applied
519
     */
520
    public function applyRelation($field, &$columnName = null, $linearOnly = false)
521
    {
522
        // If field is invalid, return it without modification
523
        if (!$this->isValidRelationName($field)) {
524
            $columnName = $field;
525
            return $this;
526
        }
527
528
        // Simple fields without relations are mapped directly
529
        if (strpos($field, '.') === false) {
530
            $columnName = '"' . $field . '"';
531
            return $this;
532
        }
533
534
        return $this->alterDataQuery(
535
            function (DataQuery $query) use ($field, &$columnName, $linearOnly) {
536
                $relations = explode('.', $field);
537
                $fieldName = array_pop($relations);
538
539
                // Apply relation
540
                $relationModelName = $query->applyRelation($relations, $linearOnly);
541
                $relationPrefix = $query->applyRelationPrefix($relations);
542
543
                // Find the db field the relation belongs to
544
                $columnName = DataObject::getSchema()
545
                    ->sqlColumnForField($relationModelName, $fieldName, $relationPrefix);
546
            }
547
        );
548
    }
549
550
    /**
551
     * Check if the given field specification could be interpreted as an unquoted relation name
552
     *
553
     * @param string $field
554
     * @return bool
555
     */
556
    protected function isValidRelationName($field)
557
    {
558
        return preg_match('/^[A-Z0-9._]+$/i', $field);
559
    }
560
561
    /**
562
     * Given a filter expression and value construct a {@see SearchFilter} instance
563
     *
564
     * @param string $filter E.g. `Name:ExactMatch:not`, `Name:ExactMatch`, `Name:not`, `Name`
565
     * @param mixed $value Value of the filter
566
     * @return SearchFilter
567
     */
568
    protected function createSearchFilter($filter, $value)
569
    {
570
        // Field name is always the first component
571
        $fieldArgs = explode(':', $filter);
572
        $fieldName = array_shift($fieldArgs);
573
574
        // Inspect type of second argument to determine context
575
        $secondArg = array_shift($fieldArgs);
576
        $modifiers = $fieldArgs;
577
        if (!$secondArg) {
578
            // Use default filter if none specified. E.g. `->filter(['Name' => $myname])`
579
            $filterServiceName = 'DataListFilter.default';
580
        } else {
581
            // The presence of a second argument is by default ambiguous; We need to query
582
            // Whether this is a valid modifier on the default filter, or a filter itself.
583
            /** @var SearchFilter $defaultFilterInstance */
584
            $defaultFilterInstance = Injector::inst()->get('DataListFilter.default');
585
            if (in_array(strtolower($secondArg), $defaultFilterInstance->getSupportedModifiers())) {
586
                // Treat second (and any subsequent) argument as modifiers, using default filter
587
                $filterServiceName = 'DataListFilter.default';
588
                array_unshift($modifiers, $secondArg);
589
            } else {
590
                // Second argument isn't a valid modifier, so assume is filter identifier
591
                $filterServiceName = "DataListFilter.{$secondArg}";
592
            }
593
        }
594
595
        // Build instance
596
        return Injector::inst()->create($filterServiceName, $fieldName, $value, $modifiers);
597
    }
598
599
    /**
600
     * Return a copy of this list which does not contain any items that match all params
601
     *
602
     * @example $list = $list->exclude('Name', 'bob'); // exclude bob from list
603
     * @example $list = $list->exclude('Name', array('aziz', 'bob'); // exclude aziz and bob from list
604
     * @example $list = $list->exclude(array('Name'=>'bob, 'Age'=>21)); // exclude bob that has Age 21
605
     * @example $list = $list->exclude(array('Name'=>'bob, 'Age'=>array(21, 43))); // exclude bob with Age 21 or 43
606
     * @example $list = $list->exclude(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43)));
607
     *          // bob age 21 or 43, phil age 21 or 43 would be excluded
608
     *
609
     * @todo extract the sql from this method into a SQLGenerator class
610
     *
611
     * @param string|array
612
     * @param string [optional]
613
     *
614
     * @return $this
615
     */
616
    public function exclude()
617
    {
618
        $numberFuncArgs = count(func_get_args());
619
        $whereArguments = [];
620
621
        if ($numberFuncArgs == 1 && is_array(func_get_arg(0))) {
622
            $whereArguments = func_get_arg(0);
623
        } elseif ($numberFuncArgs == 2) {
624
            $whereArguments[func_get_arg(0)] = func_get_arg(1);
625
        } else {
626
            throw new InvalidArgumentException('Incorrect number of arguments passed to exclude()');
627
        }
628
629
        return $this->alterDataQuery(function (DataQuery $query) use ($whereArguments) {
630
            $subquery = $query->disjunctiveGroup();
631
632
            foreach ($whereArguments as $field => $value) {
633
                $filter = $this->createSearchFilter($field, $value);
634
                $filter->exclude($subquery);
635
            }
636
        });
637
    }
638
639
    /**
640
     * Return a copy of this list which does not contain any items with any of these params
641
     *
642
     * @example $list = $list->excludeAny('Name', 'bob'); // exclude bob from list
643
     * @example $list = $list->excludeAny('Name', array('aziz', 'bob'); // exclude aziz and bob from list
644
     * @example $list = $list->excludeAny(array('Name'=>'bob, 'Age'=>21)); // exclude bob or Age 21
645
     * @example $list = $list->excludeAny(array('Name'=>'bob, 'Age'=>array(21, 43))); // exclude bob or Age 21 or 43
646
     * @example $list = $list->excludeAny(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43)));
647
     *          // bob, phil, 21 or 43 would be excluded
648
     *
649
     * @param string|array
650
     * @param string [optional]
651
     *
652
     * @return $this
653
     */
654
    public function excludeAny()
655
    {
656
        $numberFuncArgs = count(func_get_args());
657
        $whereArguments = [];
658
659
        if ($numberFuncArgs == 1 && is_array(func_get_arg(0))) {
660
            $whereArguments = func_get_arg(0);
661
        } elseif ($numberFuncArgs == 2) {
662
            $whereArguments[func_get_arg(0)] = func_get_arg(1);
663
        } else {
664
            throw new InvalidArgumentException('Incorrect number of arguments passed to excludeAny()');
665
        }
666
667
        return $this->alterDataQuery(function (DataQuery $dataQuery) use ($whereArguments) {
668
            foreach ($whereArguments as $field => $value) {
669
                $filter = $this->createSearchFilter($field, $value);
670
                $filter->exclude($dataQuery);
671
            }
672
            return $dataQuery;
673
        });
674
    }
675
676
    /**
677
     * This method returns a copy of this list that does not contain any DataObjects that exists in $list
678
     *
679
     * The $list passed needs to contain the same dataclass as $this
680
     *
681
     * @param DataList $list
682
     * @return static
683
     * @throws InvalidArgumentException
684
     */
685
    public function subtract(DataList $list)
686
    {
687
        if ($this->dataClass() != $list->dataClass()) {
688
            throw new InvalidArgumentException('The list passed must have the same dataclass as this class');
689
        }
690
691
        return $this->alterDataQuery(function (DataQuery $query) use ($list) {
692
            $query->subtract($list->dataQuery());
693
        });
694
    }
695
696
    /**
697
     * Return a new DataList instance with an inner join clause added to this list's query.
698
     *
699
     * @param string $table Table name (unquoted and as escaped SQL)
700
     * @param string $onClause Escaped SQL statement, e.g. '"Table1"."ID" = "Table2"."ID"'
701
     * @param string $alias - if you want this table to be aliased under another name
702
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
703
     * will cause the query to appear first. The default is 20, and joins created automatically by the
704
     * ORM have a value of 10.
705
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
706
     * @return static
707
     */
708
    public function innerJoin($table, $onClause, $alias = null, $order = 20, $parameters = [])
709
    {
710
        return $this->alterDataQuery(function (DataQuery $query) use ($table, $onClause, $alias, $order, $parameters) {
711
            $query->innerJoin($table, $onClause, $alias, $order, $parameters);
712
        });
713
    }
714
715
    /**
716
     * Return a new DataList instance with a left join clause added to this list's query.
717
     *
718
     * @param string $table Table name (unquoted and as escaped SQL)
719
     * @param string $onClause Escaped SQL statement, e.g. '"Table1"."ID" = "Table2"."ID"'
720
     * @param string $alias - if you want this table to be aliased under another name
721
     * @param int $order A numerical index to control the order that joins are added to the query; lower order values
722
     * will cause the query to appear first. The default is 20, and joins created automatically by the
723
     * ORM have a value of 10.
724
     * @param array $parameters Any additional parameters if the join is a parameterised subquery
725
     * @return static
726
     */
727
    public function leftJoin($table, $onClause, $alias = null, $order = 20, $parameters = [])
728
    {
729
        return $this->alterDataQuery(function (DataQuery $query) use ($table, $onClause, $alias, $order, $parameters) {
730
            $query->leftJoin($table, $onClause, $alias, $order, $parameters);
731
        });
732
    }
733
734
    /**
735
     * Return an array of the actual items that this DataList contains at this stage.
736
     * This is when the query is actually executed.
737
     *
738
     * @return array
739
     */
740
    public function toArray()
741
    {
742
        $query = $this->dataQuery->query();
743
        $rows = $query->execute();
744
        $results = [];
745
746
        foreach ($rows as $row) {
747
            $results[] = $this->createDataObject($row);
748
        }
749
750
        return $results;
751
    }
752
753
    /**
754
     * Return this list as an array and every object it as an sub array as well
755
     *
756
     * @return array
757
     */
758
    public function toNestedArray()
759
    {
760
        $result = [];
761
762
        foreach ($this as $item) {
763
            $result[] = $item->toMap();
764
        }
765
766
        return $result;
767
    }
768
769
    /**
770
     * Walks the list using the specified callback
771
     *
772
     * @param callable $callback
773
     * @return $this
774
     */
775
    public function each($callback)
776
    {
777
        foreach ($this as $row) {
778
            $callback($row);
779
        }
780
781
        return $this;
782
    }
783
784
    /**
785
     * Returns a generator for this DataList
786
     *
787
     * @return \Generator&DataObject[]
788
     */
789
    public function getGenerator()
790
    {
791
        $query = $this->dataQuery->query()->execute();
792
793
        while ($row = $query->record()) {
794
            yield $this->createDataObject($row);
795
        }
796
    }
797
798
    public function debug()
799
    {
800
        $val = "<h2>" . static::class . "</h2><ul>";
801
        foreach ($this->toNestedArray() as $item) {
802
            $val .= "<li style=\"list-style-type: disc; margin-left: 20px\">" . Debug::text($item) . "</li>";
803
        }
804
        $val .= "</ul>";
805
        return $val;
806
    }
807
808
    /**
809
     * Returns a map of this list
810
     *
811
     * @param string $keyField - the 'key' field of the result array
812
     * @param string $titleField - the value field of the result array
813
     * @return Map
814
     */
815
    public function map($keyField = 'ID', $titleField = 'Title')
816
    {
817
        return new Map($this, $keyField, $titleField);
818
    }
819
820
    /**
821
     * Create a DataObject from the given SQL row
822
     * If called without $row['ID'] set, then a new object will be created rather than rehydrated.
823
     *
824
     * @param array $row
825
     * @return DataObject
826
     */
827
    public function createDataObject($row)
828
    {
829
        $class = $this->dataClass;
830
831
        if (empty($row['ClassName'])) {
832
            $row['ClassName'] = $class;
833
        }
834
835
        // Failover from RecordClassName to ClassName
836
        if (empty($row['RecordClassName'])) {
837
            $row['RecordClassName'] = $row['ClassName'];
838
        }
839
840
        // Instantiate the class mentioned in RecordClassName only if it exists, otherwise default to $this->dataClass
841
        if (class_exists($row['RecordClassName'])) {
842
            $class = $row['RecordClassName'];
843
        }
844
845
        $creationType = empty($row['ID']) ? DataObject::CREATE_OBJECT : DataObject::CREATE_HYDRATED;
846
847
        $item = Injector::inst()->create($class, $row, $creationType, $this->getQueryParams());
848
849
        return $item;
850
    }
851
852
    /**
853
     * Get query parameters for this list.
854
     * These values will be assigned as query parameters to newly created objects from this list.
855
     *
856
     * @return array
857
     */
858
    public function getQueryParams()
859
    {
860
        return $this->dataQuery()->getQueryParams();
861
    }
862
863
    /**
864
     * Returns an Iterator for this DataList.
865
     * This function allows you to use DataLists in foreach loops
866
     *
867
     * @return ArrayIterator
868
     */
869
    public function getIterator()
870
    {
871
        return new ArrayIterator($this->toArray());
872
    }
873
874
    /**
875
     * Return the number of items in this DataList
876
     *
877
     * @return int
878
     */
879
    public function count()
880
    {
881
        return $this->dataQuery->count();
882
    }
883
884
    /**
885
     * Return the maximum value of the given field in this DataList
886
     *
887
     * @param string $fieldName
888
     * @return mixed
889
     */
890
    public function max($fieldName)
891
    {
892
        return $this->dataQuery->max($fieldName);
893
    }
894
895
    /**
896
     * Return the minimum value of the given field in this DataList
897
     *
898
     * @param string $fieldName
899
     * @return mixed
900
     */
901
    public function min($fieldName)
902
    {
903
        return $this->dataQuery->min($fieldName);
904
    }
905
906
    /**
907
     * Return the average value of the given field in this DataList
908
     *
909
     * @param string $fieldName
910
     * @return mixed
911
     */
912
    public function avg($fieldName)
913
    {
914
        return $this->dataQuery->avg($fieldName);
915
    }
916
917
    /**
918
     * Return the sum of the values of the given field in this DataList
919
     *
920
     * @param string $fieldName
921
     * @return mixed
922
     */
923
    public function sum($fieldName)
924
    {
925
        return $this->dataQuery->sum($fieldName);
926
    }
927
928
929
    /**
930
     * Returns the first item in this DataList (instanceof DataObject)
931
     *
932
     * The object returned is not cached, unlike {@link DataObject::get_one()}
933
     *
934
     * @return DataObject|null
935
     */
936
    public function first()
937
    {
938
        foreach ($this->dataQuery->firstRow()->execute() as $row) {
939
            return $this->createDataObject($row);
940
        }
941
        return null;
942
    }
943
944
    /**
945
     * Returns the last item in this DataList (instanceof DataObject)
946
     *
947
     * The object returned is not cached, unlike {@link DataObject::get_one()}
948
     *
949
     * @return DataObject|null
950
     */
951
    public function last()
952
    {
953
        foreach ($this->dataQuery->lastRow()->execute() as $row) {
954
            return $this->createDataObject($row);
955
        }
956
        return null;
957
    }
958
959
    /**
960
     * Returns true if this DataList has items
961
     *
962
     * @return bool
963
     */
964
    public function exists()
965
    {
966
        return $this->dataQuery->exists();
967
    }
968
969
    /**
970
     * Find the first DataObject of this DataList where the given key = value
971
     *
972
     * The object returned is not cached, unlike {@link DataObject::get_one()}
973
     *
974
     * @param string $key
975
     * @param string $value
976
     * @return DataObject|null
977
     */
978
    public function find($key, $value)
979
    {
980
        return $this->filter($key, $value)->first();
981
    }
982
983
    /**
984
     * Restrict the columns to fetch into this DataList
985
     *
986
     * @param array $queriedColumns
987
     * @return static
988
     */
989
    public function setQueriedColumns($queriedColumns)
990
    {
991
        return $this->alterDataQuery(function (DataQuery $query) use ($queriedColumns) {
992
            $query->setQueriedColumns($queriedColumns);
993
        });
994
    }
995
996
    /**
997
     * Filter this list to only contain the given Primary IDs
998
     *
999
     * @param array $ids Array of integers
1000
     * @return $this
1001
     */
1002
    public function byIDs($ids)
1003
    {
1004
        return $this->filter('ID', $ids);
1005
    }
1006
1007
    /**
1008
     * Return the first DataObject with the given ID
1009
     *
1010
     * The object returned is not cached, unlike {@link DataObject::get_by_id()}
1011
     *
1012
     * @param int $id
1013
     * @return DataObject|null
1014
     */
1015
    public function byID($id)
1016
    {
1017
        return $this->filter('ID', $id)->first();
1018
    }
1019
1020
    /**
1021
     * Returns an array of a single field value for all items in the list.
1022
     *
1023
     * @param string $colName
1024
     * @return array
1025
     */
1026
    public function column($colName = "ID")
1027
    {
1028
        $dataQuery = clone $this->dataQuery;
1029
        return $dataQuery->distinct(false)->column($colName);
1030
    }
1031
1032
    /**
1033
     * Returns a unque array of a single field value for all items in the list.
1034
     *
1035
     * @param string $colName
1036
     * @return array
1037
     */
1038
    public function columnUnique($colName = "ID")
1039
    {
1040
        return $this->dataQuery->distinct(true)->column($colName);
1041
    }
1042
1043
    // Member altering methods
1044
1045
    /**
1046
     * Sets the ComponentSet to be the given ID list.
1047
     * Records will be added and deleted as appropriate.
1048
     *
1049
     * @param array $idList List of IDs.
1050
     */
1051
    public function setByIDList($idList)
1052
    {
1053
        $has = [];
1054
1055
        // Index current data
1056
        foreach ($this->column() as $id) {
1057
            $has[$id] = true;
1058
        }
1059
1060
        // Keep track of items to delete
1061
        $itemsToDelete = $has;
1062
1063
        // add items in the list
1064
        // $id is the database ID of the record
1065
        if ($idList) {
1066
            foreach ($idList as $id) {
1067
                unset($itemsToDelete[$id]);
1068
                if ($id && !isset($has[$id])) {
1069
                    $this->add($id);
1070
                }
1071
            }
1072
        }
1073
1074
        // Remove any items that haven't been mentioned
1075
        $this->removeMany(array_keys($itemsToDelete));
1076
    }
1077
1078
    /**
1079
     * Returns an array with both the keys and values set to the IDs of the records in this list.
1080
     * Does not respect sort order. Use ->column("ID") to get an ID list with the current sort.
1081
     *
1082
     * @return array
1083
     */
1084
    public function getIDList()
1085
    {
1086
        $ids = $this->column("ID");
1087
        return $ids ? array_combine($ids, $ids) : [];
1088
    }
1089
1090
    /**
1091
     * Returns a HasManyList or ManyMany list representing the querying of a relation across all
1092
     * objects in this data list.  For it to work, the relation must be defined on the data class
1093
     * that you used to create this DataList.
1094
     *
1095
     * Example: Get members from all Groups:
1096
     *
1097
     *     DataList::Create(\SilverStripe\Security\Group::class)->relation("Members")
1098
     *
1099
     * @param string $relationName
1100
     * @return HasManyList|ManyManyList
1101
     */
1102
    public function relation($relationName)
1103
    {
1104
        $ids = $this->column('ID');
1105
        $singleton = DataObject::singleton($this->dataClass);
1106
        /** @var HasManyList|ManyManyList $relation */
1107
        $relation = $singleton->$relationName($ids);
1108
        return $relation;
1109
    }
1110
1111
    public function dbObject($fieldName)
1112
    {
1113
        return singleton($this->dataClass)->dbObject($fieldName);
1114
    }
1115
1116
    /**
1117
     * Add a number of items to the component set.
1118
     *
1119
     * @param array $items Items to add, as either DataObjects or IDs.
1120
     * @return $this
1121
     */
1122
    public function addMany($items)
1123
    {
1124
        foreach ($items as $item) {
1125
            $this->add($item);
1126
        }
1127
        return $this;
1128
    }
1129
1130
    /**
1131
     * Remove the items from this list with the given IDs
1132
     *
1133
     * @param array $idList
1134
     * @return $this
1135
     */
1136
    public function removeMany($idList)
1137
    {
1138
        foreach ($idList as $id) {
1139
            $this->removeByID($id);
1140
        }
1141
        return $this;
1142
    }
1143
1144
    /**
1145
     * Remove every element in this DataList matching the given $filter.
1146
     *
1147
     * @param string|array $filter - a sql type where filter
1148
     * @return $this
1149
     */
1150
    public function removeByFilter($filter)
1151
    {
1152
        foreach ($this->where($filter) as $item) {
1153
            $this->remove($item);
1154
        }
1155
        return $this;
1156
    }
1157
1158
    /**
1159
     * Shuffle the datalist using a random function provided by the SQL engine
1160
     *
1161
     * @return $this
1162
     */
1163
    public function shuffle()
1164
    {
1165
        return $this->sort(DB::get_conn()->random());
1166
    }
1167
1168
    /**
1169
     * Remove every element in this DataList.
1170
     *
1171
     * @return $this
1172
     */
1173
    public function removeAll()
1174
    {
1175
        foreach ($this as $item) {
1176
            $this->remove($item);
1177
        }
1178
        return $this;
1179
    }
1180
1181
    /**
1182
     * This method are overloaded by HasManyList and ManyMany list to perform more sophisticated
1183
     * list manipulation
1184
     *
1185
     * @param mixed $item
1186
     */
1187
    public function add($item)
1188
    {
1189
        // Nothing needs to happen by default
1190
        // TO DO: If a filter is given to this data list then
1191
    }
1192
1193
    /**
1194
     * Return a new item to add to this DataList.
1195
     *
1196
     * @todo This doesn't factor in filters.
1197
     * @param array $initialFields
1198
     * @return DataObject
1199
     */
1200
    public function newObject($initialFields = null)
1201
    {
1202
        $class = $this->dataClass;
1203
        return Injector::inst()->create($class, $initialFields, false);
1204
    }
1205
1206
    /**
1207
     * Remove this item by deleting it
1208
     *
1209
     * @param DataObject $item
1210
     * @todo Allow for amendment of this behaviour - for example, we can remove an item from
1211
     * an "ActiveItems" DataList by chaning the status to inactive.
1212
     */
1213
    public function remove($item)
1214
    {
1215
        // By default, we remove an item from a DataList by deleting it.
1216
        $this->removeByID($item->ID);
1217
    }
1218
1219
    /**
1220
     * Remove an item from this DataList by ID
1221
     *
1222
     * @param int $itemID The primary ID
1223
     */
1224
    public function removeByID($itemID)
1225
    {
1226
        $item = $this->byID($itemID);
1227
1228
        if ($item) {
1229
            $item->delete();
1230
        }
1231
    }
1232
1233
    /**
1234
     * Reverses a list of items.
1235
     *
1236
     * @return static
1237
     */
1238
    public function reverse()
1239
    {
1240
        return $this->alterDataQuery(function (DataQuery $query) {
1241
            $query->reverseSort();
1242
        });
1243
    }
1244
1245
    /**
1246
     * Returns whether an item with $key exists
1247
     *
1248
     * @param mixed $key
1249
     * @return bool
1250
     */
1251
    public function offsetExists($key)
1252
    {
1253
        return ($this->limit(1, $key)->first() != null);
1254
    }
1255
1256
    /**
1257
     * Returns item stored in list with index $key
1258
     *
1259
     * The object returned is not cached, unlike {@link DataObject::get_one()}
1260
     *
1261
     * @param mixed $key
1262
     * @return DataObject
1263
     */
1264
    public function offsetGet($key)
1265
    {
1266
        return $this->limit(1, $key)->first();
1267
    }
1268
1269
    /**
1270
     * Set an item with the key in $key
1271
     *
1272
     * @param mixed $key
1273
     * @param mixed $value
1274
     */
1275
    public function offsetSet($key, $value)
1276
    {
1277
        throw new \BadMethodCallException("Can't alter items in a DataList using array-access");
1278
    }
1279
1280
    /**
1281
     * Unset an item with the key in $key
1282
     *
1283
     * @param mixed $key
1284
     */
1285
    public function offsetUnset($key)
1286
    {
1287
        throw new \BadMethodCallException("Can't alter items in a DataList using array-access");
1288
    }
1289
1290
    /**
1291
     * Iterate over this DataList in "chunks". This will break the query in smaller subsets and avoid loading the entire
1292
     * result set in memory at once. Beware not to perform any operations on the results that might alter the return
1293
     * order. Otherwise, you might break subsequent chunks.
1294
     *
1295
     * You also can not define a custom limit or offset when using the chunk method.
1296
     *
1297
     * @param int $chunkSize
1298
     * @throws InvalidArgumentException If `$chunkSize` has an invalid size.
1299
     * @return Generator|DataObject[]
1300
     */
1301
    public function chunkedFetch(int $chunkSize = 1000): iterable
1302
    {
1303
        if ($chunkSize < 1) {
1304
            throw new InvalidArgumentException(sprintf(
1305
                '%s::%s: chunkSize must be greater than or equal to 1',
1306
                __CLASS__,
1307
                __METHOD__
1308
            ));
1309
        }
1310
1311
        $currentChunk = 0;
1312
1313
        // Keep looping until we run out of chunks
1314
        while ($chunk = $this->limit($chunkSize, $chunkSize * $currentChunk)->getIterator()) {
1315
            // Loop over all the item in our chunk
1316
            foreach ($chunk as $item) {
1317
                yield $item;
1318
            }
1319
1320
1321
            if ($chunk->count() < $chunkSize) {
1322
                // If our last chunk had less item than our chunkSize, we've reach the end.
1323
                break;
1324
            }
1325
1326
            $currentChunk++;
1327
        }
1328
    }
1329
}
1330