Completed
Push — master ( 5c1aea...9ef1c4 )
by Luke
03:03
created

Collection::walk()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 2
dl 0
loc 5
ccs 3
cts 3
cp 1
crap 1
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * CSVelte: Slender, elegant CSV for PHP
4
 * Inspired by Python's CSV module and Frictionless Data and the W3C's CSV
5
 * standardization efforts, CSVelte was written in an effort to take all the
6
 * suck out of working with CSV.
7
 *
8
 * @version   v0.2.1
9
 * @copyright Copyright (c) 2016 Luke Visinoni <[email protected]>
10
 * @author    Luke Visinoni <[email protected]>
11
 * @license   https://github.com/deni-zen/csvelte/blob/master/LICENSE The MIT License (MIT)
12
 */
13
namespace CSVelte;
14
15
use \Iterator;
16
use \Countable;
17
use \ArrayAccess;
18
use \OutOfBoundsException;
19
use \InvalidArgumentException;
20
use \RuntimeException;
21
22
/**
23
 * Collection class.
24
 *
25
 * Represents a collection of data. This is a one-dimensional structure that is
26
 * represented internally with a simple array. It provides several very
27
 * convenient operations to be performed on its data.
28
 *
29
 * @package   CSVelte
30
 * @copyright (c) 2016, Luke Visinoni <[email protected]>
31
 * @author    Luke Visinoni <[email protected]>
32
 * @since     v0.2.1
33
 * @todo      Most of this class's methods will return a new Collection class
34
 *     rather than modify the existing class. There needs to be a clear distinction
35
 *     as to which ones don't and why. Also, some methods return a single value.
36
 *     These also need to be clear.
37
 * @todo      Need to make sure method naming, args, return values, concepts, etc.
38
 *     are consistent. This is a very large class with a LOT of methods. It will
39
 *     be very difficult to not let it blow up and get extremely messy. Go through
40
 *     and refactor each method. Make sure there is nothing superfluous and that
41
 *     everything makes sense and is intuitive to use. Also, because this class
42
 *     is so enourmous it is going to be a bitch to test. Good test coverage is
43
 *     going to require a LOT of tests. So put that on the list as well...
44
 * @todo      Implement whichever SPL classes/interfaces you can (that make sense).
45
 *     Probably a good idea to implement/extend some of these:
46
 *         Interfaces - RecursiveIterator, SeekableIterator, OuterIterator, IteratorAggregate
47
 *         Classes - FilterIterator, CallbackFilterIterator, CachingIterator, IteratorIterator, etc.
48
 * @replaces  \CSVelte\Utils
49
 */
50
class Collection implements Countable, ArrayAccess
51
{
52
    /**
53
     * Constants used as comparison operators in where() method
54
     */
55
56
    /** Use this operator constant to test for identity (exact same) **/
57
    const WHERE_ID = '===';
58
59
    /** Use this operator constant to test for non-identity **/
60
    const WHERE_NID = '!==';
61
62
    /** Use this operator constant to test for equality **/
63
    const WHERE_EQ = '==';
64
65
    /** Use this operator constant to test for non-equality **/
66
    const WHERE_NEQ = '!=';
67
68
    /** Use this operator constant to test for less-than **/
69
    const WHERE_LT = '<';
70
71
    /** Use this operator constant to test for greater-than or equal-to **/
72
    const WHERE_LTE = '<=';
73
74
    /** Use this operator constant to test for greater-than **/
75
    const WHERE_GT = '>';
76
77
    /** Use this operator constant to test for greater-than or equal-to **/
78
    const WHERE_GTE = '>=';
79
80
    /** Use this operator constant to test for case insensitive equality **/
81
    const WHERE_LIKE = 'like';
82
83
    /** Use this operator constant to test for case instensitiv inequality **/
84
    const WHERE_NLIKE = '!like';
85
86
    /** Use this operator constant to test for descendants or instances of a class **/
87
    const WHERE_ISA = 'instanceof';
88
89
    /** Use this operator constant to test for values that aren't descendants or instances of a class  **/
90
    const WHERE_NISA = '!instanceof';
91
92
    /** Use this operator constant to test for internal PHP types **/
93
    const WHERE_TOF = 'typeof';
94
95
    /** Use this operator constant to test for internal PHP type (negated) **/
96
    const WHERE_NTOF = '!typeof';
97
98
    /** Use this operator constant to test against a regex pattern **/
99
    const WHERE_MATCH = 'match';
100
101
    /** Use this operator constant to test against a regex pattern (negated) **/
102
    const WHERE_NMATCH = '!match';
103
104
    /**
105
     * Underlying array
106
     * @var array The array of data for this collection
107
     */
108
    protected $data = [];
109
110
    /**
111
     * Collection constructor.
112
     *
113
     * Set the data for this collection using $data
114
     *
115
     * @param array|Iterator|null $data Either an array or an object that can be accessed
116
     *     as if it were an array.
117
     */
118 127
    public function __construct($data = null)
119
    {
120 127
        $this->assertArrayOrIterator($data);
121 126
        if (!is_null($data)) {
122 125
            $this->setData($data);
123 125
        }
124 126
    }
125
126
    /**
127
     * Invokes the object as a function.
128
     *
129
     * If called with no arguments, it will return underlying data array
130
     * If called with array as first argument, array will be merged into data array
131
     * If called with second param, it will call $this->set($key, $val)
132
     * If called with null as first param and key as second param, it will call $this->offsetUnset($key)
133
     *
134
     * @param null|array $val If an array, it will be merged into the collection
135
     *     If both this arg and second arg are null, underlying data array will be returned
136
     * @param null|mixed $key If null and first arg is callable, this method will call map with callable
137
     *     If this value is not null but first arg is, it will call $this->offsetUnset($key)
138
     *     If this value is not null and first arg is anything other than callable, it will return $this->set($key, $val)
139
     * @see the description for various possible method signatures
140
     * @return mixed The return value depends entirely upon the arguments passed
141
     *     to it. See description for various possible arguments/return value combinations
142
     */
143 6
    public function __invoke($val = null, $key = null)
144
    {
145 6
        if (is_null($val)) {
146 3
            if (is_null($key)) {
147 3
                return $this->data;
148
            } else {
149 1
                return $this->offsetUnset($key);
150
            }
151
        } else {
152 4
            if (is_null($key)) {
153 3
                if (is_array($val)) return $this->merge($val);
154
                else {
155 2
                    if (is_callable($val)) {
156 2
                        return $this->map($val);
157
                    }
158
                }
159
            } else {
160 1
                $this->offsetSet($key, $val);
161
            }
162
        }
163 1
        return $this;
164
    }
165
166
    /**
167
     * Set internal collection data.
168
     *
169
     * Use an array or iterator to set this collection's data.
170
     *
171
     * @param array|Iterator $data The data to set for this collection
172
     * @return $this
173
     * @throws InvalidArgumentException If invalid data type
174
     */
175 125
    protected function setData($data)
176
    {
177 125
        $this->assertArrayOrIterator($data);
178 125
        foreach ($data as $key => $val) {
179 125
            $this->data[$key] = $val;
180 125
        }
181 125
        return $this;
182
    }
183
184
    /**
185
     * Get data as an array.
186
     *
187
     * @return array Collection data as an array
188
     */
189 63
    public function toArray()
190
    {
191 63
        $data = [];
192 63
        foreach($this->data as $key => $val) {
193 63
            $data[$key] = (is_object($val) && method_exists($val, 'toArray')) ? $val->toArray() : $val;
194 63
        }
195 63
        return $data;
196
    }
197
198
    /**
199
     * Get array keys
200
     *
201
     * @return Collection The collection's keys (as a collection)
202
     */
203 3
    public function keys()
204
    {
205 3
        return new self(array_keys($this->data));
206
    }
207
208
    /**
209
     * Merge data (array or iterator)
210
     *
211
     * Pass an array to this method to have it merged into the collection. A new
212
     * collection will be created with the merged data and returned.
213
     *
214
     * @param array|iterator $data Data to merge into the collection
215
     * @param boolean $overwrite Whether existing values should be overwritten
216
     * @return Collection A new collection with $data merged into it
217
     */
218 2
    public function merge($data = null, $overwrite = true)
219
    {
220 2
        $this->assertArrayOrIterator($data);
221 2
        $coll = new self($this->data);
222 2
        if (is_null($data)) {
223
            return $coll;
224
        }
225 2
        foreach ($data as $key => $val) {
226 2
            $coll->set($key, $val, $overwrite);
227 2
        }
228 2
        return $coll;
229
    }
230
231
    /**
232
     * Test whether this collection contains the given value, optionally at a
233
     * specific key.
234
     *
235
     * This will return true if the collection contains a value equivalent to $val.
236
     * If $val is a callable (function/method), than the callable will be called
237
     * with $val, $key as its arguments (in that order). If the callable returns
238
     * any truthy value, than this method will return true.
239
     *
240
     * @param mixed|callable $val Either the value to check for or a callable that
241
     *     accepts $key,$val and returns true if collection contains $val
242
     * @param mixed $key If not null, the only the value for this key will be checked
243
     * @return boolean True if this collection contains $val, $key
244
     */
245 49
    public function contains($val, $key = null)
246
    {
247 49
        if (is_callable($callback = $val)) {
248 48
            foreach ($this->data as $key => $val) {
249 48
                if ($callback($val, $key)) return true;
250 40
            }
251 42
        } elseif (in_array($val, $this->data)) {
252 9
            return (is_null($key) || (isset($this->data[$key]) && $this->data[$key] == $val));
253
        }
254 42
        return false;
255
    }
256
257
    /**
258
     * Tabular Where Search.
259
     *
260
     * Search for values of a certain key that meet a particular search criteria
261
     * using either one of the "Collection::WHERE_" class constants, or its string
262
     * counterpart.
263
     *
264
     * Warning: Only works for tabular collections (2-dimensional data array)
265
     *
266
     * @param string $key The key to compare to $val
267
     * @param mixed|Callable $val Either a value to test against or a callable to
268
     *     run your own custom "where comparison logic"
269
     * @param string $comp The type of comparison operation ot use (such as "=="
270
     *     or "instanceof"). Must be one of the self::WHERE_* constants' values
271
     *     listed at the top of this class.
272
     * @return Collection A collection of rows that meet the criteria
273
     *     specified by $key, $val, and $comp
274
     */
275 4
    public function where($key, $val, $comp = null)
276
    {
277 4
        $this->assertIsTabular();
278 3
        $data = [];
279 3
        if ($this->has($key, true)) {
280 3
            if (is_callable($val)) {
281 1
                foreach ($this->data as $ln => $row) {
282 1
                    if ($val($row[$key], $key)) {
283 1
                        $data[$ln] = $row;
284 1
                    }
285 1
                }
286 1
            } else {
287 3
                foreach ($this->data as $ln => $row) {
288 3
                    $fieldval = $row[$key];
289 3
                    switch (strtolower($comp)) {
290 3
                        case self::WHERE_ID:
291 1
                            $comparison = $fieldval === $val;
292 1
                            break;
293 3
                        case self::WHERE_NID:
294 1
                            $comparison = $fieldval !== $val;
295 1
                            break;
296 3
                        case self::WHERE_LT:
297
                            $comparison = $fieldval < $val;
298
                            break;
299 3
                        case self::WHERE_LTE:
300 1
                            $comparison = $fieldval <= $val;
301 1
                            break;
302 3
                        case self::WHERE_GT:
303 1
                            $comparison = $fieldval > $val;
304 1
                            break;
305 3
                        case self::WHERE_GTE:
306
                            $comparison = $fieldval >= $val;
307
                            break;
308 3
                        case self::WHERE_LIKE:
309 1
                            $comparison = strtolower($fieldval) == strtolower($val);
310 1
                            break;
311 3
                        case self::WHERE_NLIKE:
312 1
                            $comparison = strtolower($fieldval) != strtolower($val);
313 1
                            break;
314 3
                        case self::WHERE_ISA:
315 1
                            $comparison = (is_object($fieldval) && $fieldval instanceof $val);
316 1
                            break;
317 3
                        case self::WHERE_NISA:
318 1
                            $comparison = (!is_object($fieldval) || !($fieldval instanceof $val));
319 1
                            break;
320 3
                        case self::WHERE_TOF:
321 1
                            $comparison = (strtolower(gettype($fieldval)) == strtolower($val));
322 1
                            break;
323 3
                        case self::WHERE_NTOF:
324 1
                            $comparison = (strtolower(gettype($fieldval)) != strtolower($val));
325 1
                            break;
326 3
                        case self::WHERE_NEQ:
327 1
                            $comparison = $fieldval != $val;
328 1
                            break;
329 2
                        case self::WHERE_MATCH:
330 1
                            $match = preg_match($val, $fieldval);
331 1
                            $comparison = $match === 1;
332 1
                            break;
333 2
                        case self::WHERE_NMATCH:
334 1
                            $match = preg_match($val, $fieldval);
335 1
                            $comparison = $match === 0;
336 1
                            break;
337 1
                        case self::WHERE_EQ:
338 1
                        default:
339 1
                            $comparison = $fieldval == $val;
340 1
                            break;
341 3
                    }
342 3
                    if ($comparison) {
343 3
                        $data[$ln] = $row;
344 3
                    }
345 3
                }
346
            }
347 3
        }
348 3
        return new self($data);
349
    }
350
351
    /**
352
     * Get the key at a given numerical position.
353
     *
354
     * This method will give you the key at the specified numerical offset,
355
     * regardless of how it's indexed (associatively, unordered numerical, etc.).
356
     * This allows you to find out what the first key is. Or the second. etc.
357
     *
358
     * @param int $pos Numerical position
359
     * @return mixed The key at numerical position
360
     * @throws \OutOfBoundsException If you request a position that doesn't exist
361
     * @todo Allow negative $pos to start counting from end
362
     */
363 66
    public function getKeyAtPosition($pos)
364
    {
365 66
        $i = 0;
366 66
        foreach ($this->data as $key => $val) {
367 66
            if ($i === $pos) return $key;
368 13
            $i++;
369 18
        }
370 17
        throw new OutOfBoundsException("Collection data does not contain a key at given position: " . $pos);
371
    }
372
373
    /**
374
     * Get the value at a given numerical position.
375
     *
376
     * This method will give you the value at the specified numerical offset,
377
     * regardless of how it's indexed (associatively, unordered numerical, etc.).
378
     * This allows you to find out what the first value is. Or the second. etc.
379
     *
380
     * @param int $pos Numerical position
381
     * @return mixed The value at numerical position
382
     * @throws \OutOfBoundsException If you request a position that doesn't exist
383
     * @todo Allow negative $pos to start counting from end
384
     */
385 65
    public function getValueAtPosition($pos)
386
    {
387 65
        return $this->data[$this->getKeyAtPosition($pos)];
388
    }
389
390
    /**
391
     * Determine if this collection has a value at the specified numerical position.
392
     *
393
     * @param int $pos Numerical position
394
     * @return boolean Whether there exists a value at specified position
395
     */
396 58
    public function hasPosition($pos)
397
    {
398
        try {
399 58
            $this->getKeyAtPosition($pos);
400 58
        } catch (OutOfBoundsException $e) {
401 9
            return false;
402
        }
403 58
        return true;
404
    }
405
406
    /**
407
     * "Pop" an item from the end of a collection.
408
     *
409
     * Removes an item from the bottom of the collection's underlying array and
410
     * returns it. This will actually remove the item from the collection.
411
     *
412
     * @param boolean $discard Whether to discard the popped item and return
413
     *     $this instead of the default behavior
414
     * @return mixed Whatever the last item in the collection is
415
     */
416 1
    public function pop($discard = false)
417
    {
418 1
        $popped = array_pop($this->data);
419 1
        return ($discard) ? $this : $popped;
420
    }
421
422
    /**
423
     * "Shift" an item from the top of a collection.
424
     *
425
     * Removes an item from the top of the collection's underlying array and
426
     * returns it. This will actually remove the item from the collection.
427
     *
428
     * @param boolean $discard Whether to discard the shifted item and return
429
     *     $this instead of the default behavior
430
     * @return mixed Whatever the first item in the collection is
431
     */
432 22
    public function shift($discard = false)
433
    {
434 22
        $shifted = array_shift($this->data);
435 22
        return ($discard) ? $this : $shifted;
436
    }
437
438
    /**
439
     * "Push" an item onto the end of the collection.
440
     *
441
     * Adds item(s) to the end of the collection's underlying array.
442
     *
443
     * @param mixed ... The item(s) to push onto the end of the collection. You may
444
     *     also add additional arguments to push multiple items onto the end
445
     * @return $this
446
     */
447 22
    public function push()
448
    {
449 22
        foreach (func_get_args() as $arg) {
450 22
            array_push($this->data, $arg);
451 22
        }
452 22
        return $this;
453
    }
454
455
    /**
456
     * "Unshift" an item onto the beginning of the collection.
457
     *
458
     * Adds item(s) to the beginning of the collection's underlying array.
459
     *
460
     * @param mixed ... The item(s) to push onto the top of the collection. You may
461
     *     also add additional arguments to add multiple items
462
     * @return $this
463
     */
464 1
    public function unshift()
465
    {
466 1
        foreach (array_reverse(func_get_args()) as $arg) {
467 1
            array_unshift($this->data, $arg);
468 1
        }
469 1
        return $this;
470
    }
471
472
    /**
473
     * "Insert" an item at a given numerical position.
474
     *
475
     * Regardless of how the collection is keyed (numerically or otherwise), this
476
     * method will insert an item at a given numerical position. If the given
477
     * position is more than there are items in the collection, the given item
478
     * will simply be added to the end. Nothing is overwritten with this method.
479
     * All elements that come after $offset will simply be shifted a space.
480
     *
481
     * Note: This method is one of the few that will modify the collection in
482
     *       place rather than returning a new one.
483
     *
484
     * @param int $offset The offset (position) at which you want to insert an item
485
     * @param mixed $item The item(s) to push onto the top of the collection
486
     * @return $this
487
     */
488 1
    public function insert($offset, $item)
489
    {
490 1
        $top = array_slice($this->data, 0, $offset);
491 1
        $bottom = array_slice($this->data, $offset);
492 1
        $this->data = array_merge($top, [$item], $bottom);
493 1
        return $this;
494
    }
495
496
    /**
497
     * Pad collection to specified length.
498
     *
499
     * Pad the collection to a specific length, filling it with a given value. A
500
     * new collection with padded values is returned.
501
     *
502
     * @param int $size The number of values you want this collection to have
503
     * @param mixed $with The value you want to pad the collection with
504
     * @return Collection A new collection, padded to specified size
505
     */
506 1
    public function pad($size, $with = null)
507
    {
508 1
        return new self(array_pad($this->data, (int) $size, $with));
509
    }
510
511
    /**
512
     * Check if this collection has a value at the given key.
513
     *
514
     * If this is a tabular data collection, this will check if the table has the
515
     * given key by default. You can change this behavior by passing false as the
516
     * second argument (this will change the behavior to check for a given key
517
     * at the row-level so it will likely only ever be numerical).
518
     *
519
     * @param mixed $key The key you want to check
520
     * @param bool $column True if you want to check a specific column
521
     * @return bool Whether there's a value at $key
522
     */
523 27
    public function has($key, $column = true)
524
    {
525
        // we only need to check one row for the existance of $key because the
526
        // isTabular() method ensures every row has the same keys
527 27
        if ($column && $this->isTabular() && $first = reset($this->data)) {
528 3
            return array_key_exists($key, $first);
529
        }
530
        // if we don't have tabular data or we don't want to check for a column...
531 24
        return array_key_exists($key, $this->data);
532
    }
533
534
    /**
535
     * Get the value at the given key.
536
     *
537
     * If there is a value at the given key, it will be returned. If there isn't,
538
     * a default may be specified. If you would like for this method to throw an
539
     * exception when there is no value at $key, pass true as the third argument
540
     *
541
     * @param  mixed  $key      The key you want to test for
542
     * @param  mixed  $default  The default to return if there is no value at $key
543
     * @param  boolean $throwExc Whether to throw an exception on failure to find
544
     *     a value at the given key.
545
     * @return mixed            Either the value at $key or the specified default
546
     *     value
547
     * @throws \OutOfBoundsException If value can't be found at $key and $throwExc
548
     *     is set to true
549
     */
550 33
    public function get($key, $default = null, $throwExc = false)
551
    {
552 33
        if (array_key_exists($key, $this->data)) {
553 31
            return $this->data[$key];
554
        } else {
555 7
            if ($throwExc) {
556 6
                throw new OutOfBoundsException("Collection data does not contain value for given key: " . $key);
557
            }
558
        }
559 2
        return $default;
560
    }
561
562
    /**
563
     * Set value for the given key.
564
     *
565
     * Given $key, this will set $this->data[$key] to the value of $val. If that
566
     * index already has a value, it will be overwritten unless $overwrite is set
567
     * to false. In that case nothing happens.
568
     *
569
     * @param string $key The key you want to set a value for
570
     * @param mixed $value The value you want to set key to
571
     * @param boolean $overwrite Whether to overwrite existing value
572
     * @return $this
573
     */
574 26
    public function set($key, $value = null, $overwrite = true)
575
    {
576 26
        if (!array_key_exists($key, $this->data) || $overwrite) {
577 26
            $this->data[$key] = $value;
578 26
        }
579 26
        return $this;
580
    }
581
582
    /**
583
     * Unset value at the given offset.
584
     *
585
     * This method is used when the end-user uses a colleciton as an array and
586
     * calls unset($collection[5]).
587
     *
588
     * @param mixed $offset The offset at which to unset
589
     * @return $this
590
     * @todo create an alias for this... maybe delete() or remove()
591
     */
592 2
    public function offsetUnset($offset)
593
    {
594 2
        if ($this->has($offset)) {
595 2
            unset($this->data[$offset]);
596 2
        }
597 2
        return $this;
598
    }
599
600
    /**
601
     * Alias of self::has
602
     *
603
     * @param int|mixed The offset to test for
604
     * @return boolean Whether a value exists at $offset
605
     */
606
    public function offsetExists($offset)
607
    {
608
        return $this->has($offset);
609
    }
610
611
    /**
612
     * Alias of self::set
613
     *
614
     * @param int|mixed $offset The offset to set
615
     * @param mixed $value The value to set it to
616
     * @return Collection
617
     */
618 1
    public function offsetSet($offset, $value)
619
    {
620 1
        $this->set($offset, $value);
621 1
        return $this;
622
    }
623
624
    /**
625
     * Alias of self::get
626
     *
627
     * @param int|mixed The offset to get
628
     * @return mixed The value at $offset
629
     */
630
    public function offsetGet($offset)
631
    {
632
        return $this->get($offset);
633
    }
634
635
    /**
636
     * Increment an item.
637
     *
638
     * Increment the item specified by $key by one value. Intended for integers
639
     * but also works (using this term loosely) for letters. Any other data type
640
     * it may modify is unintended behavior at best.
641
     *
642
     * This method modifies its internal data array rather than returning a new
643
     * collection.
644
     *
645
     * @param  mixed $key The key of the item you want to increment.
646
     * @return $this
647
     */
648 3
    public function increment($key)
649
    {
650 3
        $val = $this->get($key, null, true);
651 3
        $this->set($key, ++$val);
652 3
        return $this;
653
    }
654
655
    /**
656
     * Decrement an item.
657
     *
658
     * Frcrement the item specified by $key by one value. Intended for integers.
659
     * Does not work for letters and if it does anything to anything else, it's
660
     * unintended at best.
661
     *
662
     * This method modifies its internal data array rather than returning a new
663
     * collection.
664
     *
665
     * @param  mixed $key The key of the item you want to decrement.
666
     * @return $this
667
     */
668 1
    public function decrement($key)
669
    {
670 1
        $val = $this->get($key, null, true);
671 1
        $this->set($key, --$val);
672 1
        return $this;
673
    }
674
675
    /**
676
     * Count the items in this collection.
677
     *
678
     * Returns either the number of items in the collection or, if this is a
679
     * collection of tabular data, and you pass true as the first argument, you
680
     * will get back a collection containing the count of each row (which will
681
     * always be the same so maybe I should still just return an integer).
682
     *
683
     * @param boolean $multi Whether to count just the items in the collection or
684
     *     to count the items in each tabular data row.
685
     * @return string Either an integer count or a collection of counts
686
     */
687 42
    public function count($multi = false)
688
    {
689 42
        if ($multi) {
690
            // if every value is an array...
691 1
            if (false !== ($condRet = $this->if2DMapInternalMethod(__METHOD__))) {
692 1
                return $condRet;
693
            }
694
        }
695
        // just count main array
696 42
        return count($this->data);
697
    }
698
699
    /**
700
     * Collection map.
701
     *
702
     * Apply a callback to each element in the collection and return the
703
     * resulting collection. The resulting collection will contain the return
704
     * values of each call to $callback.
705
     *
706
     * @param Callable $callback A callback to apply to each item in the collection
707
     * @return Collection A collection of callback return values
708
     */
709 34
    public function map(Callable $callback)
710
    {
711 34
        return new self(array_map($callback, $this->data));
712
    }
713
714
    /**
715
     * Walk the collection.
716
     *
717
     * Walk through each item in the collection, calling a function for each
718
     * item in the collection. This is one of the few methods that doesn't return
719
     * a new collection. All changes will be to the existing collection object.
720
     *
721
     * Note: return false from the collback to stop walking.
722
     *
723
     * @param Callable $callback A callback function to call for each item in the collection
724
     * @param mixed $userdata Any extra data you'd like passed to your callback
725
     * @return $this
726
     */
727 34
    public function walk(Callable $callback, $userdata = null)
728
    {
729 34
        array_walk($this->data, $callback, $userdata);
730 34
        return $this;
731
    }
732
733
    /**
734
     * Call a user function for each item in the collection. If function returns
735
     * false, loop is terminated.
736
     *
737
     * @param callable $callback The callback function to call for each item
738
     * @return $this
739
     * @todo I'm not entirely sure what this method should do... return new
740
     *     collection? modify this one?
741
     * @todo This method appears to be a duplicate of walk(). Is it even necessary?
742
     */
743 2
    public function each(Callable $callback)
744
    {
745 2
        foreach ($this->data as $key => $val) {
746 2
            if (!$ret = $callback($val, $key)) {
747 2
                if ($ret === false) break;
748 2
            }
749 2
        }
750 2
        return $this;
751
    }
752
753
    /**
754
     * Reduce collection to single value.
755
     *
756
     * Reduces the collection to a single value by calling a callback for each
757
     * item in the collection, carrying along an accumulative value as it does so.
758
     * The final value is then returned.
759
     *
760
     * @param Callable $callback The function to reduce the collection
761
     * @param mixed $initial The initial value to set the accumulative value to
762
     * @return mixed Whatever the final value from the callback is
763
     */
764
    public function reduce(Callable $callback, $initial = null)
765
    {
766
        return array_reduce($this->data, $callback, $initial);
767
    }
768
769
    /**
770
     * Filter out unwanted items using a callback function.
771
     *
772
     * @param Callable $callback
773
     * @return Collection A new collection with filtered items removed
774
     */
775 23
    public function filter(Callable $callback)
776
    {
777 23
        $keys = [];
778 23
        foreach ($this->data as $key => $val) {
779 23
            if (false === $callback($val, $key)) $keys[$key] = true;
780 23
        }
781 23
        return new self(array_diff_key($this->data, $keys));
782
    }
783
784
    /**
785
     * Get first match.
786
     *
787
     * Get first value that meets criteria specified with $callback function.
788
     *
789
     * @param Callable $callback A callback with arguments ($val, $key). If it
790
     *     returns true, that $val will be returned.
791
     * @return mixed The first $val that meets criteria specified with $callback
792
     */
793 1
    public function first(Callable $callback)
794
    {
795 1
        foreach ($this->data as $key => $val) {
796 1
            if ($callback($val, $key)) return $val;
797 1
        }
798
        return null;
799
    }
800
801
    /**
802
     * Get last match.
803
     *
804
     * Get last value that meets criteria specified with $callback function.
805
     *
806
     * @param Callable $callback A callback with arguments ($val, $key). If it
807
     *     returns true, that $val will be returned.
808
     * @return mixed The last $val that meets criteria specified with $callback
809
     */
810 1
    public function last(Callable $callback)
811
    {
812 1
        $elem = null;
813 1
        foreach ($this->data as $key => $val) {
814 1
            if ($callback($val, $key)) $elem = $val;
815 1
        }
816 1
        return $elem;
817
    }
818
819
    /**
820
     * Collection value frequency.
821
     *
822
     * Returns an array where the key is a value in the collection and the value
823
     * is the number of times that value appears in the collection.
824
     *
825
     * @return Collection A collection of value frequencies (see description)
826
     */
827 23
    public function frequency()
828
    {
829 23
        if (false !== ($condRet = $this->if2DMapInternalMethod(__METHOD__))) {
830 22
            return $condRet;
831
        }
832 23
        $freq = [];
833 23
        foreach ($this->data as $val) {
834 23
            $key = is_numeric($val) ? $val : (string) $val;
835 23
            if (!isset($freq[$key])) {
836 23
                $freq[$key] = 0;
837 23
            }
838 23
            $freq[$key]++;
839 23
        }
840 23
        return new self($freq);
841
    }
842
843
    /**
844
     * Unique collection.
845
     *
846
     * Returns a collection with duplicate values removed. If two-dimensional,
847
     * then each array within the collection will have its duplicates removed.
848
     *
849
     * @return Collection A new collection with duplicate values removed.
850
     */
851 23
    public function unique()
852
    {
853 23
        if (false !== ($condRet = $this->if2DMapInternalMethod(__METHOD__))) {
854 6
            return $condRet;
855
        }
856 20
        return new self(array_unique($this->data));
857
    }
858
859
    /**
860
     * Get duplicate values.
861
     *
862
     * Returns a collection of arrays where the key is the duplicate value
863
     * and the value is an array of keys from the original collection.
864
     *
865
     * @return Collection A new collection with duplicate values.
866
     */
867 7
    public function duplicates()
868
    {
869 7
        $dups = [];
870
        $this->walk(function($val, $key) use (&$dups) {
871 7
            $dups[$val][] = $key;
872 7
        });
873
        return (new self($dups))->filter(function($val) {
874 7
            return (count($val) > 1);
875 7
        });
876
    }
877
878
    /**
879
     * Reverse keys/values.
880
     *
881
     * Get a new collection where the keys and values have been swapped.
882
     *
883
     * @return Collection A new collection where keys/values have been swapped
884
     */
885 1
    public function flip()
886
    {
887 1
        return new self(array_flip($this->data));
888
    }
889
890
    /**
891
     * Return an array of key/value pairs.
892
     *
893
     * Return array can either be in [key,value] or [key => value] format. The
894
     * first is the default.
895
     *
896
     * @param boolean $alt Whether you want pairs in [k => v] rather than [k, v] format
897
     * @return Collection A collection of key/value pairs
898
     */
899 1
    public function pairs($alt = false)
900
    {
901 1
        return new self(array_map(
902
            function ($key, $val) use ($alt) {
903 1
                if ($alt) {
904 1
                    return [$key => $val];
905
                } else {
906 1
                    return [$key, $val];
907
                }
908 1
            },
909 1
            array_keys($this->data),
910 1
            array_values($this->data)
911 1
        ));
912
    }
913
914
    /**
915
     * Get average of data items.
916
     *
917
     * @return mixed The average of all items in collection
918
     */
919 2
    public function sum()
920
    {
921 2
        if (false !== ($condRet = $this->if2DMapInternalMethod(__METHOD__))) {
922 1
            return $condRet;
923
        }
924 2
        $this->assertNumericValues();
925 2
        return array_sum($this->data);
926
    }
927
928
    /**
929
     * Get average of data items.
930
     *
931
     * If two-dimensional it will return a collection of averages.
932
     *
933
     * @return mixed|Collection The average of all items in collection
934
     */
935 3
    public function average()
936
    {
937 3
        if (false !== ($condRet = $this->if2DMapInternalMethod(__METHOD__))) {
938 1
            return $condRet;
939
        }
940 3
        $this->assertNumericValues();
941 3
        $total = array_sum($this->data);
942 3
        $count = count($this->data);
943 3
        return $total / $count;
944
    }
945
946
    /**
947
     * Get largest item in the collection
948
     *
949
     * @return mixed The largest item in the collection
950
     */
951 9
    public function max()
952
    {
953 9
        if (false !== ($condRet = $this->if2DMapInternalMethod(__METHOD__))) {
954 1
            return $condRet;
955
        }
956 9
        $this->assertNumericValues();
957 9
        return max($this->data);
958
    }
959
960
    /**
961
     * Get smallest item in the collection
962
     *
963
     * @return mixed The smallest item in the collection
964
     */
965 5
    public function min()
966
    {
967 5
        if (false !== ($condRet = $this->if2DMapInternalMethod(__METHOD__))) {
968 1
            return $condRet;
969
        }
970 5
        $this->assertNumericValues();
971 5
        return min($this->data);
972
    }
973
974
    /**
975
     * Get mode of data items.
976
     *
977
     * @return mixed The mode of all items in collection
978
     */
979 9
    public function mode()
980
    {
981 9
        if (false !== ($condRet = $this->if2DMapInternalMethod(__METHOD__))) {
982 7
            return $condRet;
983
        }
984
        $strvals = $this->map(function($val){
985 9
            return (string) $val;
986 9
        });
987 9
        $this->assertNumericValues();
988 8
        $counts = array_count_values($strvals->toArray());
989 8
        arsort($counts);
990 8
        $mode = key($counts);
991 8
        return (strpos($mode, '.')) ? floatval($mode) : intval($mode);
992
    }
993
994
    /**
995
     * Get median of data items.
996
     *
997
     * @return mixed The median of all items in collection
998
     */
999 2
    public function median()
1000
    {
1001 2
        if (false !== ($condRet = $this->if2DMapInternalMethod(__METHOD__))) {
1002 1
            return $condRet;
1003
        }
1004 2
        $this->assertNumericValues();
1005 2
        $count = count($this->data);
1006 2
        natcasesort($this->data);
1007 2
        $middle = ($count / 2);
1008 2
        $values = array_values($this->data);
1009 2
        if ($count % 2 == 0) {
1010
            // even number, use middle
1011 2
            $low = $values[$middle - 1];
1012 2
            $high = $values[$middle];
1013 2
            return ($low + $high) / 2;
1014
        }
1015
        // odd number return median
1016 2
        return $values[$middle];
1017
    }
1018
1019
    /**
1020
     * Join items together into a string
1021
     *
1022
     * @param string $glue The string to join items together with
1023
     * @return string A string with all items in the collection strung together
1024
     * @todo Make this work with 2D collection
1025
     */
1026 18
    public function join($glue)
1027
    {
1028 18
        return implode($glue, $this->data);
1029
    }
1030
1031
    /**
1032
     * Is the collection empty?
1033
     *
1034
     * @return boolean Whether the collection is empty
1035
     */
1036 1
    public function isEmpty()
1037
    {
1038 1
        return empty($this->data);
1039
    }
1040
1041
    /**
1042
     * Immediately invoke a callback.
1043
     *
1044
     * @param Callable $callback A callback to invoke with ($this)
1045
     * @return mixed Whatever the callback returns
1046
     */
1047 1
    public function value(Callable $callback)
1048
    {
1049 1
        return $callback($this);
1050
    }
1051
1052
    /**
1053
     * Sort the collection.
1054
     *
1055
     * This method can sort your collection in any which way you please. By
1056
     * default it uses a case-insensitive natural order algorithm, but you can
1057
     * pass it any sorting algorithm you like.
1058
     *
1059
     * @param Callable $callback The sorting function you want to use
1060
     * @param boolean $preserve_keys Whether you want to preserve keys
1061
     * @return Collection A new collection sorted by $callback
1062
     */
1063 23
    public function sort(Callable $callback = null, $preserve_keys = true)
1064
    {
1065 23
        if (is_null($callback)) $callback = 'strcasecmp';
1066 23
        if (!is_callable($callback)) {
1067
            throw new InvalidArgumentException(sprintf(
1068
                'Invalid argument supplied for %s. Expected %s, got: "%s".',
1069
                __METHOD__,
1070
                'Callable',
1071
                gettype($callback)
1072
            ));
1073
        }
1074 23
        $data = $this->data;
1075 23
        if ($preserve_keys) {
1076 23
            uasort($data, $callback);
1077 23
        } else {
1078 2
            usort($data, $callback);
1079
        }
1080 23
        return new self($data);
1081
    }
1082
1083
    /**
1084
     * Order tabular data.
1085
     *
1086
     * Order a tabular dataset by a given key/comparison algorithm
1087
     *
1088
     * @param string $key The key you want to order by
1089
     * @param Callable $cmp The sorting comparison algorithm to use
1090
     * @param boolean $preserve_keys Whether keys should be preserved
1091
     * @return Collection A new collection sorted by $cmp and $key
1092
     */
1093 1
    public function orderBy($key, Callable $cmp = null, $preserve_keys = true)
1094
    {
1095 1
        $this->assertIsTabular();
1096
        return $this->sort(function($a, $b) use ($key, $cmp) {
1097 1
            if (!isset($a[$key]) || !isset($b[$key])) {
1098
                throw new RuntimeException('Cannot order collection by non-existant key: ' . $key);
1099
            }
1100 1
            if (is_null($cmp)) {
1101 1
                return strcasecmp($a[$key], $b[$key]);
1102
            } else {
1103 1
                return $cmp($a[$key], $b[$key]);
1104
            }
1105 1
        }, $preserve_keys);
1106
    }
1107
1108
    /**
1109
     * Reverse collection order.
1110
     *
1111
     * Reverse the order of items in a collection. Sometimes it's easier than
1112
     * trying to write a particular sorting algurithm that sorts forwards and back.
1113
     *
1114
     * @param boolean $preserve_keys Whether keys should be preserved
1115
     * @return Collection A new collection in reverse order
1116
     */
1117 22
    public function reverse($preserve_keys = true)
1118
    {
1119 22
        return new self(array_reverse($this->data, $preserve_keys));
1120
    }
1121
1122
    /**
1123
     * Is method an internal 2D map internal method?
1124
     *
1125
     * This is an internal method used to check if a particular method is one of
1126
     * the 2D map methods.
1127
     *
1128
     * @internal
1129
     * @param string $method The method name to check
1130
     * @return boolean True if method is a 2D map internal method
1131
     */
1132 39
    protected function if2DMapInternalMethod($method)
1133
    {
1134 39
        if ($this->is2D()) {
1135 29
            $method = explode('::', $method, 2);
1136 29
            if (count($method) == 2) {
1137 29
                $method = $method[1];
1138
                return $this->map(function($val) use ($method) {
1139 29
                    return (new self($val))->$method();
1140 29
                });
1141
            }
1142
        }
1143 38
        return false;
1144
    }
1145
1146
    /**
1147
     * Is this collection two-dimensional
1148
     *
1149
     * If all items of the collection are arrays this will return true.
1150
     *
1151
     * @return boolean whether this is two-dimensional
1152
     */
1153 47
    public function is2D()
1154
    {
1155
        return !$this->contains(function($val){
1156 47
            return !is_array($val);
1157 47
        });
1158
    }
1159
1160
    /**
1161
     * Is this a tabular collection?
1162
     *
1163
     * If this is a two-dimensional collection with the same keys in every array,
1164
     * this method will return true.
1165
     *
1166
     * @return boolean Whether this is a tabular collection
1167
     */
1168 36
    public function isTabular()
1169
    {
1170 36
        if ($this->is2D()) {
1171
            // look through each item in the collection and if an array, grab its keys
1172
            // and throw them in an array to be analyzed later...
1173 32
            $test = [];
1174
            $this->walk(function($val, $key) use (&$test) {
1175 11
                if (is_array($val)) {
1176 11
                    $test[$key] = array_keys($val);
1177 11
                    return true;
1178
                }
1179
                return false;
1180 32
            });
1181
1182
            // if the list of array keys is shorter than the total amount of items in
1183
            // the collection, than this is not tabular data
1184 32
            if (count($test) != count($this)) return false;
1185
1186
            // loop through the array of each item's array keys that we just created
1187
            // and compare it to the FIRST item. If any array contains different keys
1188
            // than this is not tabular data.
1189 32
            $first = array_shift($test);
1190 32
            foreach ($test as $key => $keys) {
1191 11
                $diff = array_diff($first, $keys);
1192 11
                if (!empty($diff)) return false;
1193 28
            }
1194 28
            return true;
1195
        }
1196 25
        return false;
1197
    }
1198
1199
    /**
1200
     * Assert this collection is two-dimensional.
1201
     *
1202
     * Although a collection must be two-dimensional to be tabular, the opposite
1203
     * is not necessarily true. This will throw an exception if this collection
1204
     * contains anything but arrays.
1205
     *
1206
     * @throws
1207
     */
1208
    protected function assertIs2D()
1209
    {
1210
        if (!$this->is2D()) {
1211
            throw new RuntimeException('Invalid data type, requires two-dimensional array.');
1212
        }
1213
    }
1214
1215 5
    protected function assertIsTabular()
1216
    {
1217 5
        if (!$this->isTabular()) {
1218 1
            throw new RuntimeException('Invalid data type, requires tabular data (two-dimensional array where each sub-array has the same keys).');
1219
        }
1220 4
    }
1221
1222
    protected function assertNumericValues()
1223
    {
1224 19
        if ($this->contains(function($val){
1225 19
            return !is_numeric($val);
1226 19
        })) {
1227
            // can't average non-numeric data
1228 1
            throw new InvalidArgumentException(sprintf(
1229 1
                "%s expects collection of integers or collection of arrays of integers",
1230
                __METHOD__
1231 1
            ));
1232
        }
1233 18
    }
1234
1235 127
    protected function assertArrayOrIterator($data)
1236
    {
1237 127
        if (is_null($data) || is_array($data) || $data instanceof Iterator) {
1238 126
            return;
1239
        }
1240 1
        throw new InvalidArgumentException("Invalid type for collection data: " . gettype($data));
1241
    }
1242
}
1243