Completed
Branch releases/v0.2.2 (1aa4be)
by Luke
03:21 queued 01:01
created

Collection::offsetGet()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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