From 72499420e280bee4b970e3be0ba7c41b16f9dc85 Mon Sep 17 00:00:00 2001 From: Dave Redfern Date: Sat, 15 Jun 2024 19:00:36 -0400 Subject: [PATCH] Allow collections to only contain a certain type --- CHANGELOG.md | 6 ++ CHANGELOG_V5.md | 20 ++++++ src/AbstractCollection.php | 18 +++++ src/Behaviours/CanAddAndRemoveItems.php | 5 ++ src/Behaviours/CannotAddDuplicateItems.php | 4 ++ .../Mutate/AppendOnlyUniqueValues.php | 7 +- src/Behaviours/Mutate/AppendValues.php | 7 +- .../Mutate/CombineOnlyUniqueValues.php | 2 + src/Behaviours/Mutate/CombineValues.php | 6 +- src/Behaviours/Mutate/Fill.php | 5 ++ .../Mutate/MergeOnlyUniqueValues.php | 2 + src/Behaviours/Mutate/MergeValues.php | 6 +- src/Behaviours/Mutate/Pad.php | 3 + .../Mutate/PrependOnlyUniqueValues.php | 3 + src/Behaviours/Mutate/PrependValues.php | 3 + src/Behaviours/Mutate/ReplaceValues.php | 5 ++ .../Mutate/UnionOnlyUniqueValues.php | 2 + src/Behaviours/Mutate/UnionValues.php | 6 +- src/Exceptions/InvalidItemTypeException.php | 39 +++++++++++ src/MutableCollection.php | 8 ++- src/SimpleCollection.php | 9 ++- src/Utils/Value.php | 38 ++++++++++- tests/ExtendedCollectionTest.php | 66 +++++++++++++++++++ tests/Fixtures/MyCollection.php | 2 +- 24 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 src/Exceptions/InvalidItemTypeException.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f040643..1dc965b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ Change Log ========== +2024-06-10 +---------- + + * Add support for a "typed" collection that contains only values of type + * Set default collection types to SimpleCollection / MutableCollection to allow for typed collections + 2024-02-24 - 5.4.0 ------------------ diff --git a/CHANGELOG_V5.md b/CHANGELOG_V5.md index beb1ab1..8cbb070 100644 --- a/CHANGELOG_V5.md +++ b/CHANGELOG_V5.md @@ -28,3 +28,23 @@ All features previously marked as deprecated in the 4.X series have been removed * Keys are typed to `int|string` across the board instead of inferred mixed/string * `GetWithDotNotation::get()` no longer supports `null` for all values; use `all()` instead + +### 5.5.0 addition of type to collections + +From 5.5.0, collections can be extended and the "type" set to restrict the collection to a specific type +of values. The type can be any of: + + * int + * float + * bool + * string + * scalar + * array + * object or interface class + +To accommodate this change, `SimpleCollection` and `MutableCollection` now have the collectionClass set to themselves +to prevent issues of methods like `map()` causing errors due to returning values not supported by the type. + +To set the type: extend the collection type you wish to make type specific, and then set the property `$type` to +the type you want the collection to contain. This will typically be a class name, though the standard PHP types +can be used - except for resources. diff --git a/src/AbstractCollection.php b/src/AbstractCollection.php index 3057886..1626969 100644 --- a/src/AbstractCollection.php +++ b/src/AbstractCollection.php @@ -39,6 +39,13 @@ abstract class AbstractCollection implements Collection */ protected ?string $collectionClass = null; + /** + * The type of values that this collection is restricted to, can use scalar types or class names + * + * @var string|null + */ + protected ?string $type = null; + protected array $items = []; public static function collect(mixed $items = []): Collection|static @@ -80,6 +87,7 @@ public static function __set_state($array): object { $object = new static(); $object->items = $array['items']; + $object->type = $array['type']; return $object; } @@ -128,6 +136,16 @@ public function setCollectionClass(string $class): void $this->collectionClass = $class; } + public function isTyped(): bool + { + return $this->type !== null; + } + + public function type(): ?string + { + return $this->type; + } + public function offsetExists($offset): bool { return array_key_exists($offset, $this->items); diff --git a/src/Behaviours/CanAddAndRemoveItems.php b/src/Behaviours/CanAddAndRemoveItems.php index bbdc8ba..3fddc60 100644 --- a/src/Behaviours/CanAddAndRemoveItems.php +++ b/src/Behaviours/CanAddAndRemoveItems.php @@ -2,6 +2,9 @@ namespace Somnambulist\Components\Collection\Behaviours; +use Somnambulist\Components\Collection\Exceptions\InvalidItemTypeException; +use Somnambulist\Components\Collection\Utils\Value; + /** * @property array $items */ @@ -10,6 +13,8 @@ trait CanAddAndRemoveItems final public function offsetSet($offset, $value): void { + Value::assertIsOfType($value, $this->type); + if (null === $offset) { $this->items[] = $value; } else { diff --git a/src/Behaviours/CannotAddDuplicateItems.php b/src/Behaviours/CannotAddDuplicateItems.php index cd07be9..814ee76 100644 --- a/src/Behaviours/CannotAddDuplicateItems.php +++ b/src/Behaviours/CannotAddDuplicateItems.php @@ -3,6 +3,8 @@ namespace Somnambulist\Components\Collection\Behaviours; use Somnambulist\Components\Collection\Exceptions\DuplicateItemException; +use Somnambulist\Components\Collection\Exceptions\InvalidItemTypeException; +use Somnambulist\Components\Collection\Utils\Value; /** * @property array $items @@ -12,6 +14,8 @@ trait CannotAddDuplicateItems final public function offsetSet($offset, $value): void { + Value::assertIsOfType($value, $this->type); + if ($this->contains($value)) { throw DuplicateItemException::found($value, array_search($value, $this->items)); } diff --git a/src/Behaviours/Mutate/AppendOnlyUniqueValues.php b/src/Behaviours/Mutate/AppendOnlyUniqueValues.php index 8a1b71b..571dee7 100644 --- a/src/Behaviours/Mutate/AppendOnlyUniqueValues.php +++ b/src/Behaviours/Mutate/AppendOnlyUniqueValues.php @@ -4,6 +4,7 @@ use Somnambulist\Components\Collection\Contracts\Collection; use Somnambulist\Components\Collection\Exceptions\DuplicateItemException; +use Somnambulist\Components\Collection\Utils\Value; use function array_push; /** @@ -38,6 +39,8 @@ public function add(mixed $value): Collection|static public function append(mixed ...$value): Collection|static { foreach ($value as $item) { + Value::assertIsOfType($item, $this->type); + if ($this->contains($item)) { throw DuplicateItemException::found($value, $this->keys($item)->first()); } @@ -50,7 +53,7 @@ public function append(mixed ...$value): Collection|static } /** - * Push all of the given items onto the collection. + * Push all the given items onto the collection. * * @param iterable $items * @@ -59,7 +62,7 @@ public function append(mixed ...$value): Collection|static public function concat(iterable $items): Collection|static { foreach ($items as $item) { - $this->push($item); + $this->append($item); } return $this; diff --git a/src/Behaviours/Mutate/AppendValues.php b/src/Behaviours/Mutate/AppendValues.php index e8f3322..7e32881 100644 --- a/src/Behaviours/Mutate/AppendValues.php +++ b/src/Behaviours/Mutate/AppendValues.php @@ -3,6 +3,7 @@ namespace Somnambulist\Components\Collection\Behaviours\Mutate; use Somnambulist\Components\Collection\Contracts\Collection; +use Somnambulist\Components\Collection\Utils\Value; use function array_push; /** @@ -36,13 +37,15 @@ public function add(mixed $value): Collection|static */ public function append(mixed ...$value): Collection|static { + Value::assertAllOfType($value, $this->type); + array_push($this->items, ...$value); return $this; } /** - * Push all of the given items onto the collection. + * Push all the given items onto the collection. * * @param iterable $items * @@ -51,7 +54,7 @@ public function append(mixed ...$value): Collection|static public function concat(iterable $items): Collection|static { foreach ($items as $item) { - $this->push($item); + $this->append($item); } return $this; diff --git a/src/Behaviours/Mutate/CombineOnlyUniqueValues.php b/src/Behaviours/Mutate/CombineOnlyUniqueValues.php index f6628e3..f4f484f 100644 --- a/src/Behaviours/Mutate/CombineOnlyUniqueValues.php +++ b/src/Behaviours/Mutate/CombineOnlyUniqueValues.php @@ -27,6 +27,8 @@ public function combine(mixed $items): Collection|static $items = Value::toArray($items); $unique = array_unique($items); + Value::assertAllOfType($items, $this->type); + if (count($items) !== count($unique)) { throw DuplicateItemException::preparedValuesContainDuplicates(__FUNCTION__); } diff --git a/src/Behaviours/Mutate/CombineValues.php b/src/Behaviours/Mutate/CombineValues.php index 33b7aac..9d90265 100644 --- a/src/Behaviours/Mutate/CombineValues.php +++ b/src/Behaviours/Mutate/CombineValues.php @@ -23,6 +23,10 @@ trait CombineValues */ public function combine(mixed $items): Collection|static { - return $this->new(array_combine($this->items, Value::toArray($items))); + $items = Value::toArray($items); + + Value::assertAllOfType($items, $this->type); + + return $this->new(array_combine($this->items, $items)); } } diff --git a/src/Behaviours/Mutate/Fill.php b/src/Behaviours/Mutate/Fill.php index 92805b5..c2d0849 100644 --- a/src/Behaviours/Mutate/Fill.php +++ b/src/Behaviours/Mutate/Fill.php @@ -3,6 +3,7 @@ namespace Somnambulist\Components\Collection\Behaviours\Mutate; use Somnambulist\Components\Collection\Contracts\Collection; +use Somnambulist\Components\Collection\Utils\Value; use function array_fill; use function array_fill_keys; @@ -27,6 +28,8 @@ trait Fill */ public function fill(int $start, int $count, mixed $value): Collection|static { + Value::assertIsOfType($value, $this->type); + return $this->new(array_fill($start, $count, $value)); } @@ -44,6 +47,8 @@ public function fill(int $start, int $count, mixed $value): Collection|static */ public function fillKeysWith(mixed $value): Collection|static { + Value::assertIsOfType($value, $this->type); + return $this->new(array_fill_keys($this->values()->toArray(), $value)); } } diff --git a/src/Behaviours/Mutate/MergeOnlyUniqueValues.php b/src/Behaviours/Mutate/MergeOnlyUniqueValues.php index c6e5b40..141a368 100644 --- a/src/Behaviours/Mutate/MergeOnlyUniqueValues.php +++ b/src/Behaviours/Mutate/MergeOnlyUniqueValues.php @@ -28,6 +28,8 @@ public function merge(mixed $value): Collection|static $items = Value::toArray($value); $unique = array_unique($items); + Value::assertAllOfType($items, $this->type); + if (count($items) !== count($unique)) { throw DuplicateItemException::preparedValuesContainDuplicates(__FUNCTION__); } diff --git a/src/Behaviours/Mutate/MergeValues.php b/src/Behaviours/Mutate/MergeValues.php index ee59ed1..2a1acf8 100644 --- a/src/Behaviours/Mutate/MergeValues.php +++ b/src/Behaviours/Mutate/MergeValues.php @@ -27,7 +27,11 @@ trait MergeValues */ public function merge(mixed $value): Collection|static { - $this->items = array_merge($this->items, Value::toArray($value)); + $value = Value::toArray($value); + + Value::assertAllOfType($value, $this->type); + + $this->items = array_merge($this->items, $value); return $this; } diff --git a/src/Behaviours/Mutate/Pad.php b/src/Behaviours/Mutate/Pad.php index b240c3e..501ded8 100644 --- a/src/Behaviours/Mutate/Pad.php +++ b/src/Behaviours/Mutate/Pad.php @@ -3,6 +3,7 @@ namespace Somnambulist\Components\Collection\Behaviours\Mutate; use Somnambulist\Components\Collection\Contracts\Collection; +use Somnambulist\Components\Collection\Utils\Value; use function array_pad; /** @@ -23,6 +24,8 @@ trait Pad */ public function pad(int $size, mixed $value): Collection|static { + Value::assertIsOfType($value, $this->type); + $this->items = array_pad($this->items, $size, $value); return $this; diff --git a/src/Behaviours/Mutate/PrependOnlyUniqueValues.php b/src/Behaviours/Mutate/PrependOnlyUniqueValues.php index 8653152..0996aca 100644 --- a/src/Behaviours/Mutate/PrependOnlyUniqueValues.php +++ b/src/Behaviours/Mutate/PrependOnlyUniqueValues.php @@ -4,6 +4,7 @@ use Somnambulist\Components\Collection\Contracts\Collection; use Somnambulist\Components\Collection\Exceptions\DuplicateItemException; +use Somnambulist\Components\Collection\Utils\Value; use function array_unshift; /** @@ -24,6 +25,8 @@ trait PrependOnlyUniqueValues public function prepend(mixed ...$value): Collection|static { foreach ($value as $item) { + Value::assertIsOfType($item, $this->type); + if ($this->contains($item)) { throw DuplicateItemException::found($value, $this->keys($item)->first()); } diff --git a/src/Behaviours/Mutate/PrependValues.php b/src/Behaviours/Mutate/PrependValues.php index 55a109f..b276da1 100644 --- a/src/Behaviours/Mutate/PrependValues.php +++ b/src/Behaviours/Mutate/PrependValues.php @@ -3,6 +3,7 @@ namespace Somnambulist\Components\Collection\Behaviours\Mutate; use Somnambulist\Components\Collection\Contracts\Collection; +use Somnambulist\Components\Collection\Utils\Value; use function array_unshift; /** @@ -22,6 +23,8 @@ trait PrependValues */ public function prepend(mixed ...$value): Collection|static { + Value::assertAllOfType($value, $this->type); + array_unshift($this->items, ...$value); return $this; diff --git a/src/Behaviours/Mutate/ReplaceValues.php b/src/Behaviours/Mutate/ReplaceValues.php index f25f2d4..ce89e41 100644 --- a/src/Behaviours/Mutate/ReplaceValues.php +++ b/src/Behaviours/Mutate/ReplaceValues.php @@ -3,6 +3,7 @@ namespace Somnambulist\Components\Collection\Behaviours\Mutate; use Somnambulist\Components\Collection\Contracts\Collection; +use Somnambulist\Components\Collection\Utils\Value; use function array_replace; use function array_replace_recursive; @@ -21,6 +22,8 @@ trait ReplaceValues */ public function replace(array ...$items): Collection|static { + Value::assertAllOfType($items, $this->type); + $this->items = array_replace($this->items, ...$items); return $this; @@ -35,6 +38,8 @@ public function replace(array ...$items): Collection|static */ public function replaceRecursively(array ...$items): Collection|static { + Value::assertAllOfType($items, $this->type); + $this->items = array_replace_recursive($this->items, ...$items); return $this; diff --git a/src/Behaviours/Mutate/UnionOnlyUniqueValues.php b/src/Behaviours/Mutate/UnionOnlyUniqueValues.php index 3cdfe26..366eae5 100644 --- a/src/Behaviours/Mutate/UnionOnlyUniqueValues.php +++ b/src/Behaviours/Mutate/UnionOnlyUniqueValues.php @@ -23,6 +23,8 @@ public function union(mixed $items): Collection|static { $items = Value::toArray($items); + Value::assertAllOfType($items, $this->type); + foreach ($items as $key => $item) { if ($this->contains($item)) { throw DuplicateItemException::found($item, $this->keys($item)->first()); diff --git a/src/Behaviours/Mutate/UnionValues.php b/src/Behaviours/Mutate/UnionValues.php index df33915..8109b58 100644 --- a/src/Behaviours/Mutate/UnionValues.php +++ b/src/Behaviours/Mutate/UnionValues.php @@ -20,7 +20,11 @@ trait UnionValues */ public function union(mixed $items): Collection|static { - $this->items = $this->items + Value::toArray($items); + $items = Value::toArray($items); + + Value::assertAllOfType($items, $this->type); + + $this->items = $this->items + $items; return $this; } diff --git a/src/Exceptions/InvalidItemTypeException.php b/src/Exceptions/InvalidItemTypeException.php new file mode 100644 index 0000000..d8cdc28 --- /dev/null +++ b/src/Exceptions/InvalidItemTypeException.php @@ -0,0 +1,39 @@ +value = $value; + $e->type = $type; + + return $e; + } + + public function getValue(): mixed + { + return $this->value; + } + + public function getType(): string + { + return $this->type; + } +} diff --git a/src/MutableCollection.php b/src/MutableCollection.php index 18a38e9..ffb8f1b 100644 --- a/src/MutableCollection.php +++ b/src/MutableCollection.php @@ -68,8 +68,14 @@ class MutableCollection extends AbstractCollection implements use Sortable; use StringHelpers; + protected ?string $collectionClass = MutableCollection::class; + public function __construct(mixed $items = []) { - $this->items = Value::toArray($items); + $items = Value::toArray($items); + + Value::assertAllOfType($items, $this->type); + + $this->items = $items; } } diff --git a/src/SimpleCollection.php b/src/SimpleCollection.php index 775e15d..b5821d1 100644 --- a/src/SimpleCollection.php +++ b/src/SimpleCollection.php @@ -12,7 +12,6 @@ */ class SimpleCollection extends AbstractCollection { - use Behaviours\CanAddAndRemoveItems; use Behaviours\Assertion\Assert; use Behaviours\MapReduce\Collapse; @@ -45,8 +44,14 @@ class SimpleCollection extends AbstractCollection use Behaviours\Export\ExportToArray; use Behaviours\Export\ExportToJson; + protected ?string $collectionClass = SimpleCollection::class; + public function __construct(mixed $items = []) { - $this->items = Value::toArray($items); + $items = Value::toArray($items); + + Value::assertAllOfType($items, $this->type); + + $this->items = $items; } } diff --git a/src/Utils/Value.php b/src/Utils/Value.php index b184782..48871d2 100644 --- a/src/Utils/Value.php +++ b/src/Utils/Value.php @@ -9,6 +9,7 @@ use ReflectionMethod; use Somnambulist\Components\Collection\Contracts\Arrayable; use Somnambulist\Components\Collection\Contracts\Collection; +use Somnambulist\Components\Collection\Exceptions\InvalidItemTypeException; use Somnambulist\Components\Collection\MutableCollection; use stdClass; use Traversable; @@ -23,7 +24,9 @@ final class Value { - private function __construct() {} + private function __construct() + { + } /** * Provides a callable for fetching data from a collection item @@ -40,7 +43,7 @@ public static function accessor(mixed $value): callable return $value; } - return fn ($item) => KeyWalker::get($item, $value); + return fn($item) => KeyWalker::get($item, $value); } /** @@ -255,6 +258,37 @@ public static function isAccessibleByKey(mixed $value): bool return is_array($value) || $value instanceof ArrayAccess; } + public static function isOfType(mixed $value, string $type): bool + { + return match ($type) { + 'array' => is_array($value), + 'bool' => is_bool($value), + 'float' => is_float($value), + 'int' => is_int($value), + 'scalar' => is_scalar($value), + 'string' => is_string($value), + default => is_a($value, $type, true), + }; + } + + public static function assertIsOfType(mixed $value, ?string $type): void + { + if (!is_null($type) && !self::isOfType($value, $type)) { + throw InvalidItemTypeException::invalidItem($value, $type); + } + } + + public static function assertAllOfType(iterable $values, ?string $type): void + { + if (is_null($type)) { + return; + } + + foreach ($values as $value) { + self::assertIsOfType($value, $type); + } + } + public static function getArgumentCountForCallable($callable): int { // Ref: https://stackoverflow.com/questions/13071186/how-to-get-the-number-of-parameters-of-a-run-time-determined-callable diff --git a/tests/ExtendedCollectionTest.php b/tests/ExtendedCollectionTest.php index 8b04e0a..f13873a 100644 --- a/tests/ExtendedCollectionTest.php +++ b/tests/ExtendedCollectionTest.php @@ -2,11 +2,15 @@ namespace Somnambulist\Components\Collection\Tests; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Somnambulist\Components\Collection\Exceptions\InvalidItemTypeException; use Somnambulist\Components\Collection\FrozenCollection; use Somnambulist\Components\Collection\MutableCollection; use Somnambulist\Components\Collection\Tests\Fixtures\MyCollection; use Somnambulist\Components\Collection\Tests\Fixtures\MyFrozenCollection; +use Somnambulist\Components\Collection\Tests\Fixtures\MyObject; +use Somnambulist\Components\Collection\Tests\Fixtures\MyObject2; use function strpos; class ExtendedCollectionTest extends TestCase @@ -44,4 +48,66 @@ public function testPreservesTypeOnFrozenCollection() $this->assertEquals(FrozenCollection::class, $col2->getFreezableClass()); $this->assertInstanceOf(MyFrozenCollection::class, $frozen); } + + public function testCanSetTypeOfValues() + { + $col = new class extends MutableCollection { + protected ?string $type = MyObject::class; + }; + + $col->add(new MyObject('foo', 'bar', 'baz', 'example')); + + $this->assertCount(1, $col); + } + + public function testTypedCollectionsWillFallbackToUntypedCollections() + { + $col = new class extends MutableCollection { + protected ?string $type = MyObject::class; + }; + + $col->add(new MyObject('foo', 'bar', 'baz', 'example')); + + $this->assertCount(1, $col); + + $this->assertInstanceOf(MutableCollection::class, $col->map(fn ($o) => $o->foo)); + } + + #[DataProvider('methodsThatShouldFailByType')] + public function testWhenTypeSetRejectsValues(string $method, mixed $value) + { + $col = new class extends MutableCollection { + protected ?string $type = MyObject::class; + }; + + $this->expectException(InvalidItemTypeException::class); + + match ($method) { + 'fill' => $col->fill(1, 3, $value), + 'pad' => $col->pad(1, $value), + 'set' => $col->set(1, $value), + default => $col->$method($value), + }; + } + + public static function methodsThatShouldFailByType(): array + { + $obj = new MyObject2('foo', 'example'); + + return [ + ['add', $obj], + ['append', $obj], + ['combine', $obj], + ['concat', [$obj]], + ['fill', $obj], + ['fillKeysWith', $obj], + ['merge', $obj], + ['pad', $obj], + ['prepend', $obj], + ['push', $obj], + ['replace', [$obj]], + ['set', $obj], + ['union', $obj], + ]; + } } diff --git a/tests/Fixtures/MyCollection.php b/tests/Fixtures/MyCollection.php index 5d034f3..e72c7f3 100644 --- a/tests/Fixtures/MyCollection.php +++ b/tests/Fixtures/MyCollection.php @@ -6,5 +6,5 @@ class MyCollection extends MutableCollection { - + protected ?string $collectionClass = MyCollection::class; }