Skip to content

Commit 6e0c0a9

Browse files
committed
feat: Add more functionality to Collection class
New functionality: - Collection now implements `ArrayAccess` interface. - New method `add` to add new elements to the collection. - Basic FP methods `map`, `filter`, and `reduce`.
1 parent 7dd103e commit 6e0c0a9

File tree

2 files changed

+338
-4
lines changed

2 files changed

+338
-4
lines changed

src/Domain/Collection.php

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,154 @@
44

55
namespace GeekCell\Ddd\Domain;
66

7+
use ArrayAccess;
78
use ArrayIterator;
89
use Assert;
910
use Countable;
1011
use IteratorAggregate;
1112
use Traversable;
1213

13-
class Collection implements Countable, IteratorAggregate
14+
class Collection implements ArrayAccess, Countable, IteratorAggregate
1415
{
1516
/**
1617
* @template T of object
1718
* @extends IteratorAggregate<T>
1819
*
1920
* @param T[] $items
2021
* @param class-string<T> $itemType
22+
*
23+
* @throws Assert\AssertionFailedException
2124
*/
2225
final public function __construct(
2326
private readonly array $items = [],
24-
?string $itemType = null,
27+
private ?string $itemType = null,
2528
) {
2629
if ($itemType !== null) {
2730
Assert\Assertion::allIsInstanceOf($items, $itemType);
2831
}
2932
}
3033

34+
/**
35+
* Add one or more items to the collection. It **does not** modify the
36+
* current collection, but returns a new one.
37+
*
38+
* @param mixed $item One or more items to add to the collection.
39+
* @return static
40+
*
41+
* @throws Assert\AssertionFailedException
42+
*/
43+
public function add(mixed $item): static
44+
{
45+
if (!is_array($item)) {
46+
$item = [$item];
47+
}
48+
49+
if ($this->itemType !== null) {
50+
Assert\Assertion::allIsInstanceOf($item, $this->itemType);
51+
}
52+
53+
return new static([...$this->items, ...$item], $this->itemType);
54+
}
55+
56+
/**
57+
* Filter the collection using the given callback. It **does not** modify
58+
* the current collection, but returns a new one.
59+
*
60+
* @param callable $callback The callback to use for filtering.
61+
* @return static
62+
*/
63+
public function filter(callable $callback): static
64+
{
65+
return new static(
66+
array_filter($this->items, $callback),
67+
$this->itemType,
68+
);
69+
}
70+
71+
/**
72+
* Map the collection using the given callback. It **does not** modify
73+
* the current collection, but returns a new one.
74+
*
75+
* @param callable $callback The callback to use for mapping.
76+
* @param bool $inferTypes Whether to infer the type of the items in the
77+
* collection based on the first item in the
78+
* mapping result. Defaults to `true`.
79+
*
80+
* @return static
81+
*/
82+
public function map(callable $callback, bool $inferTypes = true): static
83+
{
84+
$mapResult = array_map($callback, $this->items);
85+
$firstItem = reset($mapResult);
86+
87+
if ($firstItem === false || !is_object($firstItem)) {
88+
return new static($mapResult);
89+
}
90+
91+
if ($inferTypes && $this->itemType !== null) {
92+
return new static($mapResult, get_class($firstItem));
93+
}
94+
95+
return new static($mapResult);
96+
}
97+
98+
/**
99+
* Reduce the collection using the given callback.
100+
*
101+
* @param callable $callback The callback to use for reducing.
102+
* @param mixed $initial The initial value to use for reducing.
103+
*
104+
* @return mixed
105+
*/
106+
public function reduce(callable $callback, mixed $initial = null): mixed
107+
{
108+
return array_reduce($this->items, $callback, $initial);
109+
}
110+
31111
/**
32112
* @inheritDoc
33113
*/
34-
public function getIterator(): Traversable
114+
public function offsetExists(mixed $offset): bool
35115
{
36-
return new ArrayIterator($this->items);
116+
if (!is_int($offset)) {
117+
return false;
118+
}
119+
120+
return isset($this->items[$offset]);
121+
}
122+
123+
/**
124+
* @inheritDoc
125+
*/
126+
public function offsetGet(mixed $offset): mixed
127+
{
128+
if (!$this->offsetExists($offset)) {
129+
return null;
130+
}
131+
132+
return $this->items[$offset];
133+
}
134+
135+
/**
136+
* This method is not supported since it would break the immutability of the
137+
* collection.
138+
*
139+
* @inheritDoc
140+
*/
141+
public function offsetSet(mixed $offset, mixed $value): void
142+
{
143+
// Unsupported since it would break the immutability of the collection.
144+
}
145+
146+
/**
147+
* This method is not supported since it would break the immutability of the
148+
* collection.
149+
*
150+
* @inheritDoc
151+
*/
152+
public function offsetUnset(mixed $offset): void
153+
{
154+
// Unsupported since it would break the immutability of the collection.
37155
}
38156

39157
/**
@@ -43,4 +161,12 @@ public function count(): int
43161
{
44162
return count($this->items);
45163
}
164+
165+
/**
166+
* @inheritDoc
167+
*/
168+
public function getIterator(): Traversable
169+
{
170+
return new ArrayIterator($this->items);
171+
}
46172
}

tests/Domain/CollectionTest.php

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ class Foo
1717
{
1818
}
1919

20+
/**
21+
* Test fixture for Collection.
22+
*
23+
* @package GeekCell\Ddd\Tests\Domain
24+
*/
25+
class Bar
26+
{
27+
}
28+
2029
class CollectionTest extends TestCase
2130
{
2231
public function testTypedConstructor(): void
@@ -53,6 +62,40 @@ public function testUntypedConstructor(): void
5362
$this->assertInstanceOf(Collection::class, $collection);
5463
}
5564

65+
public function testArrayAccess(): void
66+
{
67+
// Given
68+
$items = [new Foo(), new Foo(), new Foo()];
69+
70+
// When
71+
$collection = new Collection($items, Foo::class);
72+
73+
// Then
74+
$this->assertEquals($collection[0], $items[0]);
75+
$this->assertEquals($collection[1], $items[1]);
76+
$this->assertEquals($collection[2], $items[2]);
77+
$this->assertFalse(isset($collection[3]));
78+
}
79+
80+
public function testArrayAccessWithInvalidOffset(): void
81+
{
82+
// Given
83+
$items = [new Foo(), new Foo(), new Foo()];
84+
$collection = new Collection($items, Foo::class);
85+
86+
// When
87+
$result1 = $collection[10];
88+
$result2 = $collection[-1];
89+
$result3 = $collection[0.5];
90+
$result4 = $collection['foo'];
91+
92+
// When
93+
$this->assertNull($result1);
94+
$this->assertNull($result2);
95+
$this->assertNull($result3);
96+
$this->assertNull($result4);
97+
}
98+
5699
public function testCount(): void
57100
{
58101
// Given
@@ -77,4 +120,169 @@ public function testIterater(): void
77120
$this->assertEquals($items, iterator_to_array($collection));
78121
$this->assertCount(3, iterator_to_array($collection));
79122
}
123+
124+
public function testAdd(): void
125+
{
126+
// Given
127+
$items = [new Foo(), new Foo(), new Foo()];
128+
$collection = new Collection($items, Foo::class);
129+
130+
// When
131+
$newCollection = $collection->add(new Foo());
132+
133+
// Then
134+
$this->assertCount(4, $newCollection);
135+
$this->assertCount(3, $collection);
136+
$this->assertNotSame($collection, $newCollection);
137+
}
138+
139+
public function testAddMultiple(): void
140+
{
141+
// Given
142+
$items = [new Foo(), new Foo(), new Foo()];
143+
$collection = new Collection($items, Foo::class);
144+
145+
// When
146+
$newCollection = $collection->add([new Foo(), new Foo()]);
147+
148+
// Then
149+
$this->assertCount(5, $newCollection);
150+
$this->assertCount(3, $collection);
151+
$this->assertNotSame($collection, $newCollection);
152+
}
153+
154+
public function testFilter(): void
155+
{
156+
// Given
157+
$items = [1, 2, 3,4, 5, 6, 7, 8, 9, 10];
158+
$collection = new Collection($items);
159+
160+
// When
161+
$newCollection = $collection->filter(fn (int $i) => $i % 2 === 0);
162+
163+
// Then
164+
$this->assertCount(5, $newCollection);
165+
$this->assertCount(10, $collection);
166+
$this->assertNotSame($collection, $newCollection);
167+
}
168+
169+
public function testMap(): void
170+
{
171+
// Given
172+
$items = [new Foo(), new Foo(), new Foo()];
173+
$collection = new Collection($items, Foo::class);
174+
175+
// When
176+
$newCollection = $collection->map(fn (Foo $item) => new Bar());
177+
178+
// Then
179+
$this->assertCount(3, $newCollection);
180+
$this->assertCount(3, $collection);
181+
$this->assertNotSame($collection, $newCollection);
182+
}
183+
184+
public function testMapWithStrictTypes(): void
185+
{
186+
// Given
187+
$items = [new Foo(), new Foo(), new Foo()];
188+
$collection = new Collection($items, Foo::class);
189+
190+
// Then
191+
$this->expectException(Assert\InvalidArgumentException::class);
192+
193+
// When
194+
$counter = 0;
195+
$newCollection = $collection->map(
196+
function (Foo $item) use (&$counter) {
197+
if ($counter++ % 2) {
198+
return new Bar();
199+
}
200+
201+
return $item;
202+
},
203+
true,
204+
);
205+
}
206+
207+
public function testMapWithoutStrictTypes(): void
208+
{
209+
// Given
210+
$items = [new Foo(), new Foo(), new Foo()];
211+
$collection = new Collection($items, Foo::class);
212+
213+
// When
214+
$counter = 0;
215+
$newCollection = $collection->map(
216+
function ($item) use (&$counter) {
217+
if ($counter++ % 2) {
218+
return new Bar();
219+
}
220+
221+
return $item;
222+
},
223+
false,
224+
);
225+
226+
// Then
227+
$this->assertCount(3, $newCollection);
228+
$this->assertCount(3, $collection);
229+
$this->assertNotSame($collection, $newCollection);
230+
}
231+
232+
public function testMapWithStrictTypesAndScalars(): void
233+
{
234+
// Given
235+
$items = [new Foo(), new Foo(), new Foo()];
236+
$collection = new Collection($items, Foo::class);
237+
238+
// When
239+
$counter = 0;
240+
$newCollection = $collection->map(
241+
function (Foo $item) use (&$counter) {
242+
if ($counter % 2) {
243+
return $counter++;
244+
}
245+
246+
return 'foo';
247+
},
248+
true,
249+
);
250+
251+
// Then
252+
$this->assertCount(3, $newCollection);
253+
$this->assertCount(3, $collection);
254+
$this->assertNotSame($collection, $newCollection);
255+
}
256+
257+
public function testReduce(): void
258+
{
259+
// Given
260+
$items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
261+
$collection = new Collection($items);
262+
263+
// When
264+
$result = $collection->reduce(
265+
fn (int $carry, int $item) => $carry + $item,
266+
0,
267+
);
268+
269+
// Then
270+
$this->assertEquals(55, $result);
271+
}
272+
273+
public function testChaining(): void
274+
{
275+
// Given
276+
$items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
277+
$collection = new Collection($items);
278+
279+
// When
280+
$result = $collection
281+
->filter(fn (int $i) => $i % 2 === 0)
282+
->map(fn (int $i) => $i * 2)
283+
->reduce(fn (int $carry, int $item) => $carry + $item, 0);
284+
285+
// Then
286+
$this->assertEquals(60, $result);
287+
}
80288
}

0 commit comments

Comments
 (0)