From 0d6687c8b8d71f45b61541782e7cd1509fca0b1d Mon Sep 17 00:00:00 2001 From: Valentin Udaltsov Date: Sun, 1 Sep 2024 23:37:54 +0300 Subject: [PATCH] Refactor and test --- infection.json5.dist | 1 + phpunit.xml.dist | 4 + src/DataStructure/Internal/ArrayMap.php | 140 +-- src/DataStructure/Map.php | 161 +++- src/DataStructure/MutableMap.php | 217 ++++- tests/DataStructure/Internal/ArrayMapTest.php | 21 + tests/DataStructure/MapTest.php | 74 ++ tests/DataStructure/MapTestCase.php | 850 ++++++++++++++++++ tests/DataStructure/SerializedKeyArrayMap.php | 87 ++ 9 files changed, 1367 insertions(+), 188 deletions(-) create mode 100644 tests/DataStructure/Internal/ArrayMapTest.php create mode 100644 tests/DataStructure/MapTest.php create mode 100644 tests/DataStructure/MapTestCase.php create mode 100644 tests/DataStructure/SerializedKeyArrayMap.php diff --git a/infection.json5.dist b/infection.json5.dist index 3b8eef9b..74bd739b 100644 --- a/infection.json5.dist +++ b/infection.json5.dist @@ -6,6 +6,7 @@ "source": { "directories": [ "src/ChangeDetector", + "src/DataStructure", "src/Type", "src/TypedMap", ] diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 80e9312e..d74f07d7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -24,6 +24,10 @@ tests/Reflection/Internal/NativeAdapter/AdapterCompatibilityTest.php + + tests/DataStructure + + tests/ChangeDetector tests/Reflection/Internal/Inheritance/TypeResolversTest.php diff --git a/src/DataStructure/Internal/ArrayMap.php b/src/DataStructure/Internal/ArrayMap.php index 51924887..73c6d2d9 100644 --- a/src/DataStructure/Internal/ArrayMap.php +++ b/src/DataStructure/Internal/ArrayMap.php @@ -6,7 +6,6 @@ use Typhoon\DataStructure\KVPair; use Typhoon\DataStructure\MutableMap; -use Typhoon\DataStructure\Sequence; /** * @internal @@ -17,76 +16,10 @@ */ final class ArrayMap extends MutableMap { - /** - * @template NK - * @template NV - * @param iterable|\Closure(): iterable $values - * @return self - */ - public static function of(iterable|\Closure $values = []): self - { - /** @var self */ - $map = new self(); - $map->putAll($values); - - return $map; - } - - /** - * @template NK - * @template NV - * @param KVPair ...$kvPairs - * @return self - */ - public static function fromPairs(KVPair ...$kvPairs): self - { - /** @var self */ - $map = new self(); - $map->putPairs(...$kvPairs); - - return $map; - } - - /** - * @template NK - * @template NV - * @param iterable $keys - * @param callable(NK): NV $value - * @return self - */ - public static function fromKeys(iterable $keys, callable $value): self - { - $map = new self(); - - foreach ($keys as $key) { - $map->put($key, $value($key)); - } - - return $map; - } - - /** - * @template NK - * @template NV - * @param iterable $values - * @param callable(NV): NK $key - * @return self - */ - public static function fromValues(iterable $values, callable $key): self - { - $map = new self(); - - foreach ($values as $value) { - $map->put($key($value), $value); - } - - return $map; - } - /** * @param array> $kvPairs */ - private function __construct( + public function __construct( private array $kvPairs = [], ) {} @@ -110,14 +43,10 @@ public function putPairs(KVPair ...$kvPairs): void } } - public function putAll(iterable|\Closure $values): void + protected function doPutAll(iterable $values): void { - if ($values instanceof \Closure) { - $values = $values(); - } - if ($values instanceof self) { - $this->kvPairs = [...$this->kvPairs, ...$values->kvPairs]; + $this->kvPairs = array_replace($this->kvPairs, $values->kvPairs); return; } @@ -207,7 +136,7 @@ public function reduceKV(callable $operation): mixed * @param V|R $accumulator * @param KVPair $kv */ - static fn (mixed $accumulator, KVPair $kv): mixed => $operation($accumulator, $kv->key, $kv->value), + static fn(mixed $accumulator, KVPair $kv): mixed => $operation($accumulator, $kv->key, $kv->value), $initial->value, ); } @@ -227,47 +156,22 @@ public function foldKV(mixed $initial, callable $operation): mixed * @param I|R $accumulator * @param KVPair $kv */ - static fn (mixed $accumulator, KVPair $kv): mixed => $operation($accumulator, $kv->key, $kv->value), + static fn(mixed $accumulator, KVPair $kv): mixed => $operation($accumulator, $kv->key, $kv->value), $initial, ); } public function filterKV(callable $predicate): static { - return new self(array_filter($this->kvPairs, static fn (KVPair $kv): bool => $predicate($kv->key, $kv->value))); + return new self(array_filter($this->kvPairs, static fn(KVPair $kv): bool => $predicate($kv->key, $kv->value))); } public function mapKV(callable $mapper): static { - return new self(array_map(static fn (KVPair $kv): KVPair => $kv->withValue($mapper($kv->key, $kv->value)), $this->kvPairs)); - } - - public function reindexKV(callable $mapper): static - { - $map = new self(); - - foreach ($this->kvPairs as $kvPair) { - $key = $mapper($kvPair->key, $kvPair->value); - $map->kvPairs[ArrayMapKeyEncoder::encode($key)] = $kvPair->withKey($key); - } - - return $map; - } - - public function flip(): static - { - $map = new self(); - - foreach ($this->kvPairs as $kvPair) { - $map->kvPairs[ArrayMapKeyEncoder::encode($kvPair->value)] = $kvPair->flip(); - } - - return $map; - } - - public function reverse(): static - { - return new self(array_reverse($this->kvPairs, preserve_keys: true)); + return new self(array_map( + static fn(KVPair $kv): KVPair => $kv->withValue($mapper($kv->key, $kv->value)), + $this->kvPairs, + )); } public function usortKV(callable $comparator): static @@ -279,7 +183,7 @@ public function usortKV(callable $comparator): static * @param KVPair $kv1 * @param KVPair $kv2 */ - static fn (KVPair $kv1, KVPair $kv2) => $comparator($kv1->key, $kv1->value, $kv2->key, $kv2->value) + static fn(KVPair $kv1, KVPair $kv2) => $comparator($kv1->key, $kv1->value, $kv2->key, $kv2->value), ); return new self($kvPairs); @@ -287,27 +191,7 @@ public function usortKV(callable $comparator): static public function slice(int $offset, ?int $length = null): static { - return new self(\array_slice($this->kvPairs, $offset, $length)); - } - - public function keys(): Sequence - { - throw new \LogicException('TODO'); - } - - public function values(): Sequence - { - throw new \LogicException('TODO'); - } - - public function pairs(): Sequence - { - throw new \LogicException('TODO'); - } - - public function toArray(): array - { - return iterator_to_array($this->getIterator()); + return new self(\array_slice($this->kvPairs, $offset, $length, preserve_keys: true)); } /** diff --git a/src/DataStructure/Map.php b/src/DataStructure/Map.php index 5c96f7e9..35029d18 100644 --- a/src/DataStructure/Map.php +++ b/src/DataStructure/Map.php @@ -4,8 +4,6 @@ namespace Typhoon\DataStructure; -use Typhoon\DataStructure\Internal\ArrayMap; - /** * @api * @template-covariant K @@ -25,10 +23,11 @@ abstract class Map implements \IteratorAggregate, \Countable, \ArrayAccess */ public static function of(iterable|\Closure $values = []): self { - return ArrayMap::of($values); + return MutableMap::of($values); } /** + * @no-named-arguments * @template NK * @template NV * @param KVPair ...$kvPairs @@ -36,7 +35,7 @@ public static function of(iterable|\Closure $values = []): self */ public static function fromPairs(KVPair ...$kvPairs): self { - return ArrayMap::fromPairs(...$kvPairs); + return MutableMap::fromPairs(...$kvPairs); } /** @@ -48,7 +47,7 @@ public static function fromPairs(KVPair ...$kvPairs): self */ public static function fromKeys(iterable $keys, callable $value): self { - return ArrayMap::fromKeys($keys, $value); + return MutableMap::fromKeys($keys, $value); } /** @@ -60,7 +59,7 @@ public static function fromKeys(iterable $keys, callable $value): self */ public static function fromValues(iterable $values, callable $key): self { - return ArrayMap::fromValues($values, $key); + return MutableMap::fromValues($values, $key); } /** @@ -73,6 +72,7 @@ public static function fromValues(iterable $values, callable $key): self abstract public function with(mixed $key, mixed $value): static; /** + * @no-named-arguments * @template NK * @template NV * @param KVPair ...$kvPairs @@ -86,19 +86,45 @@ abstract public function withPairs(KVPair ...$kvPairs): static; * @param iterable|\Closure(): iterable $values * @return static */ - abstract public function withAll(iterable|\Closure $values): static; + final public function withAll(iterable|\Closure $values): static + { + if ($values instanceof \Closure) { + $values = $values(); + } + + if ($values === []) { + return $this; + } + + return $this->doWithAll($values); + } + + /** + * @template NK + * @template NV + * @param iterable $values + * @return static + */ + abstract protected function doWithAll(iterable $values): static; /** + * @no-named-arguments * @return static */ abstract public function without(mixed ...$keys): static; - abstract public function isEmpty(): bool; + public function isEmpty(): bool + { + return $this->count() === 0; + } /** * @return non-negative-int */ - abstract public function count(): int; + public function count(): int + { + return iterator_count($this->getIterator()); + } /** * @return ($key is K ? bool : false) @@ -125,18 +151,35 @@ abstract public function getOr(mixed $key, callable $or): mixed; /** * @return ?KVPair */ - abstract public function first(): ?KVPair; + public function first(): ?KVPair + { + return $this->findFirst(static fn(): bool => true); + } /** * @return ?KVPair */ - abstract public function last(): ?KVPair; + public function last(): ?KVPair + { + $started = false; + + foreach ($this->getIterator() as $key => $value) { + $started = true; + } + + if ($started) { + /** @psalm-suppress PossiblyUndefinedVariable */ + return new KVPair($key, $value); + } + + return null; + } /** * @param callable(V): bool $predicate * @return ?KVPair */ - final public function findFirst(callable $predicate): ?KVPair + public function findFirst(callable $predicate): ?KVPair { foreach ($this->getIterator() as $key => $value) { if ($predicate($value)) { @@ -151,7 +194,7 @@ final public function findFirst(callable $predicate): ?KVPair * @param callable(K, V): bool $predicate * @return ?KVPair */ - final public function findFirstKV(callable $predicate): ?KVPair + public function findFirstKV(callable $predicate): ?KVPair { foreach ($this->getIterator() as $key => $value) { if ($predicate($key, $value)) { @@ -230,7 +273,7 @@ final public function reduce(callable $operation): mixed * @param V|R $accumulator * @param V $value */ - static fn (mixed $accumulator, mixed $key, mixed $value): mixed => $operation($accumulator, $value) + static fn(mixed $accumulator, mixed $key, mixed $value): mixed => $operation($accumulator, $value), ); } @@ -239,7 +282,27 @@ final public function reduce(callable $operation): mixed * @param callable(V|R, K, V): R $operation * @return V|R */ - abstract public function reduceKV(callable $operation): mixed; + public function reduceKV(callable $operation): mixed + { + $started = false; + /** @var V|R */ + $accumulator = null; + + foreach ($this->getIterator() as $key => $value) { + if ($started) { + $accumulator = $operation($accumulator, $key, $value); + } else { + $started = true; + $accumulator = $value; + } + } + + if ($started) { + return $accumulator; + } + + throw new \RuntimeException('Empty map'); + } /** * @template I @@ -256,7 +319,7 @@ final public function fold(mixed $initial, callable $operation): mixed * @param I|R $accumulator * @param V $value */ - static fn (mixed $accumulator, mixed $key, mixed $value): mixed => $operation($accumulator, $value) + static fn(mixed $accumulator, mixed $key, mixed $value): mixed => $operation($accumulator, $value), ); } @@ -267,13 +330,20 @@ final public function fold(mixed $initial, callable $operation): mixed * @param callable(I|R, K, V): R $operation * @return I|R */ - abstract public function foldKV(mixed $initial, callable $operation): mixed; + public function foldKV(mixed $initial, callable $operation): mixed + { + foreach ($this->getIterator() as $key => $value) { + $initial = $operation($initial, $key, $value); + } + + return $initial; + } /** * @param callable(V): bool $predicate * @return static */ - final public function filter(callable $predicate): static + public function filter(callable $predicate): static { return $this->filterKV( /** @param V $value */ @@ -312,9 +382,9 @@ abstract public function mapKV(callable $mapper): static; * @param callable(V): NK $mapper * @return static */ - final public function reindex(callable $mapper): static + public function mapKey(callable $mapper): static { - return $this->reindexKV( + return $this->mapKeyKV( /** @param V $value */ static fn(mixed $key, mixed $value): mixed => $mapper($value), ); @@ -325,17 +395,36 @@ final public function reindex(callable $mapper): static * @param callable(K, V): NK $mapper * @return static */ - abstract public function reindexKV(callable $mapper): static; + abstract public function mapKeyKV(callable $mapper): static; /** - * @return static + * @template NK + * @template NV + * @param callable(V): iterable $mapper + * @return static */ - abstract public function flip(): static; + public function flatMap(callable $mapper): static + { + return $this->flatMapKV( + /** @param V $value */ + static fn(mixed $key, mixed $value): mixed => $mapper($value), + ); + } /** - * @return static + * @template NK + * @template NV + * @param callable(K, V): iterable $mapper + * @return static */ - abstract public function reverse(): static; + abstract public function flatMapKV(callable $mapper): static; + + /** + * @return static + */ + abstract public function flip(): static; + + // TODO public function reverse(): static; /** * @return static @@ -395,25 +484,17 @@ abstract public function usortKV(callable $comparator): static; */ abstract public function slice(int $offset, ?int $length = null): static; - /** - * @return Sequence - */ - abstract public function keys(): Sequence; - - /** - * @return Sequence - */ - abstract public function values(): Sequence; - - /** - * @return Sequence> - */ - abstract public function pairs(): Sequence; + // TODO: public function keys(): Sequence + // TODO: public function values(): Sequence + // TODO: public function pairs(): Sequence /** * @return (K is array-key ? array: never) */ - abstract public function toArray(): array; + public function toArray(): array + { + return iterator_to_array($this->getIterator()); + } /** * @return ($offset is K ? bool : false) diff --git a/src/DataStructure/MutableMap.php b/src/DataStructure/MutableMap.php index d11c0af9..6ca5efa9 100644 --- a/src/DataStructure/MutableMap.php +++ b/src/DataStructure/MutableMap.php @@ -11,6 +11,8 @@ * @template K * @template V * @extends Map + * @psalm-consistent-constructor + * @psalm-consistent-templates */ abstract class MutableMap extends Map { @@ -20,20 +22,29 @@ abstract class MutableMap extends Map * @param iterable|\Closure(): iterable $values * @return self */ - public static function of(iterable|\Closure $values = []): self + final public static function of(iterable|\Closure $values = []): self { - return ArrayMap::of($values); + /** @var ArrayMap */ + $map = new ArrayMap(); + $map->putAll($values); + + return $map; } /** + * @no-named-arguments * @template NK * @template NV * @param KVPair ...$kvPairs * @return self */ - public static function fromPairs(KVPair ...$kvPairs): self + final public static function fromPairs(KVPair ...$kvPairs): self { - return ArrayMap::fromPairs(...$kvPairs); + /** @var ArrayMap */ + $map = new ArrayMap(); + $map->putPairs(...$kvPairs); + + return $map; } /** @@ -43,9 +54,16 @@ public static function fromPairs(KVPair ...$kvPairs): self * @param callable(NK): NV $value * @return self */ - public static function fromKeys(iterable $keys, callable $value): self + final public static function fromKeys(iterable $keys, callable $value): self { - return ArrayMap::fromKeys($keys, $value); + /** @var ArrayMap */ + $map = new ArrayMap(); + + foreach ($keys as $key) { + $map->put($key, $value($key)); + } + + return $map; } /** @@ -55,12 +73,36 @@ public static function fromKeys(iterable $keys, callable $value): self * @param callable(NV): NK $key * @return self */ - public static function fromValues(iterable $values, callable $key): self + final public static function fromValues(iterable $values, callable $key): self + { + /** @var ArrayMap */ + $map = new ArrayMap(); + + foreach ($values as $value) { + $map->put($key($value), $value); + } + + return $map; + } + + /** + * @template NK + * @template NV + * @param NK $key + * @param NV $value + * @return static + */ + public function with(mixed $key, mixed $value): static { - return ArrayMap::fromValues($values, $key); + $map = clone $this; + /** @psalm-suppress InvalidArgument */ + $map->put($key, $value); + + return $map; } /** + * @no-named-arguments * @template NK * @template NV * @param KVPair ...$kvPairs @@ -82,19 +124,11 @@ final public function withPairs(KVPair ...$kvPairs): static /** * @template NK * @template NV - * @param iterable|\Closure(): iterable $values + * @param iterable $values * @return static */ - final public function withAll(iterable|\Closure $values): static + protected function doWithAll(iterable $values): static { - if ($values instanceof \Closure) { - $values = $values(); - } - - if ($values === []) { - return $this; - } - $map = clone $this; /** @psalm-suppress InvalidArgument */ $map->putAll($values); @@ -103,10 +137,15 @@ final public function withAll(iterable|\Closure $values): static } /** + * @no-named-arguments * @return static */ final public function without(mixed ...$keys): static { + if ($keys === []) { + return $this; + } + $map = clone $this; $map->remove(...$keys); @@ -120,16 +159,154 @@ final public function without(mixed ...$keys): static abstract public function put(mixed $key, mixed $value): void; /** + * @no-named-arguments * @param KVPair ...$kvPairs */ - abstract public function putPairs(KVPair ...$kvPairs): void; + public function putPairs(KVPair ...$kvPairs): void + { + foreach ($kvPairs as $kvPair) { + $this->put($kvPair->key, $kvPair->value); + } + } /** * @param iterable|\Closure(): iterable $values */ - abstract public function putAll(iterable|\Closure $values): void; + public function putAll(iterable|\Closure $values): void + { + if ($values instanceof \Closure) { + $values = $values(); + } + + if ($values !== []) { + $this->doPutAll($values); + } + } + + /** + * @param iterable $values + */ + protected function doPutAll(iterable $values): void + { + foreach ($values as $key => $value) { + $this->put($key, $value); + } + } + /** + * @no-named-arguments + */ abstract public function remove(mixed ...$keys): void; abstract public function clear(): void; + + /** + * @template NV + * @param callable(K, V): NV $mapper + * @return static + */ + public function mapKV(callable $mapper): static + { + $map = new static(); + + foreach ($this->getIterator() as $key => $value) { + /** @psalm-suppress InvalidArgument */ + $map->put($key, $mapper($key, $value)); + } + + return $map; + } + + /** + * @param callable(K, V): bool $predicate + * @return static + */ + public function filterKV(callable $predicate): static + { + $map = new static(); + + foreach ($this->getIterator() as $key => $value) { + if ($predicate($key, $value)) { + $map->put($key, $value); + } + } + + return $map; + } + + /** + * @template NK + * @param callable(K, V): NK $mapper + * @return static + */ + final public function mapKeyKV(callable $mapper): static + { + /** @var static */ + $map = new static(); + + foreach ($this->getIterator() as $key => $value) { + /** @psalm-suppress InvalidArgument */ + $map->put($mapper($key, $value), $value); + } + + return $map; + } + + /** + * @return static + */ + final public function flip(): static + { + $map = new static(); + + foreach ($this->getIterator() as $key => $value) { + /** @psalm-suppress InvalidArgument */ + $map->put($value, $key); + } + + return $map; + } + + /** + * @template NK + * @template NV + * @param callable(K, V): iterable $mapper + * @return static + */ + public function flatMapKV(callable $mapper): static + { + $map = new static(); + + foreach ($this->getIterator() as $key => $value) { + foreach ($mapper($key, $value) as $newKey => $newValue) { + /** @psalm-suppress InvalidArgument */ + $map->put($newKey, $newValue); + } + } + + return $map; + } + + public function slice(int $offset, ?int $length = null): static + { + if ($offset < 0) { + $offset = $this->count() + $offset; + } + + $rightOffset = match (true) { + $length === null => null, + $length < 0 => $this->count() + $length, + default => $offset + $length, + }; + + return $this->filter( + static function () use ($offset, $rightOffset): bool { + /** @var int */ + static $index = -1; + ++$index; + + return $index >= $offset && ($rightOffset === null || $index < $rightOffset); + }, + ); + } } diff --git a/tests/DataStructure/Internal/ArrayMapTest.php b/tests/DataStructure/Internal/ArrayMapTest.php new file mode 100644 index 00000000..0613d649 --- /dev/null +++ b/tests/DataStructure/Internal/ArrayMapTest.php @@ -0,0 +1,21 @@ +putAll($values); + + return $map; + } +} diff --git a/tests/DataStructure/MapTest.php b/tests/DataStructure/MapTest.php new file mode 100644 index 00000000..bf71e89f --- /dev/null +++ b/tests/DataStructure/MapTest.php @@ -0,0 +1,74 @@ + $class + */ + #[TestWith([Map::class])] + #[TestWith([MutableMap::class])] + public function testOf(string $class): void + { + $map = $class::of(['a', 'b', 'c']); + + self::assertInstanceOf(ArrayMap::class, $map); + self::assertSame(['a', 'b', 'c'], $map->toArray()); + } + + /** + * @param class-string $class + */ + #[TestWith([Map::class])] + #[TestWith([MutableMap::class])] + public function testFromPairs(string $class): void + { + $map = $class::fromPairs(new KVPair('a', 'b'), new KVPair('c', 'd')); + + self::assertInstanceOf(ArrayMap::class, $map); + self::assertSame(['a' => 'b', 'c' => 'd'], $map->toArray()); + } + + /** + * @param class-string $class + */ + #[TestWith([Map::class])] + #[TestWith([MutableMap::class])] + public function testFromKeys(string $class): void + { + $map = $class::fromKeys(['a', 'b'], static fn(string $key): string => $key . $key); + + self::assertInstanceOf(ArrayMap::class, $map); + self::assertSame(['a' => 'aa', 'b' => 'bb'], $map->toArray()); + } + + /** + * @param class-string $class + */ + #[TestWith([Map::class])] + #[TestWith([MutableMap::class])] + public function testFromValues(string $class): void + { + $map = $class::fromValues(['a', 'b'], static fn(string $value): string => $value . $value); + + self::assertInstanceOf(ArrayMap::class, $map); + self::assertSame(['aa' => 'a', 'bb' => 'b'], $map->toArray()); + } + + protected static function createMap(iterable|\Closure $values = []): MutableMap + { + $map = new SerializedKeyArrayMap(); + $map->putAll($values); + + return $map; + } +} diff --git a/tests/DataStructure/MapTestCase.php b/tests/DataStructure/MapTestCase.php new file mode 100644 index 00000000..7c367c81 --- /dev/null +++ b/tests/DataStructure/MapTestCase.php @@ -0,0 +1,850 @@ +toArray()); + // check map internal keys are same + self::assertEquals(static::createMap($expected), $map); + } + + final public function testWithReturnsNewMapWithAddedElement(): void + { + $map = static::createMap(); + + $newMap = $map->with('a', 'b'); + + self::assertTrue($map->isEmpty()); + self::assertNotSame($map, $newMap); + self::assertMapEquals(['a' => 'b'], $newMap); + } + + final public function testWithPairsWithoutArgsReturnsSameObject(): void + { + $map = static::createMap(); + + $newMap = $map->withPairs(); + + self::assertSame($newMap, $map); + } + + final public function testWithPairsReturnsNewMapWithAddedElements(): void + { + $map = static::createMap(); + + $newMap = $map->withPairs(new KVPair('a', 'b'), new KVPair('c', 'd')); + + self::assertTrue($map->isEmpty()); + self::assertNotSame($map, $newMap); + self::assertMapEquals(['a' => 'b', 'c' => 'd'], $newMap); + } + + final public function testWithAllWithEmptyArrayReturnsSameMap(): void + { + $map = static::createMap(); + + $newMap = $map->withAll([]); + + self::assertSame($newMap, $map); + } + + final public function testWithAllWithClosureReturningEmptyArrayReturnsSameMap(): void + { + $map = static::createMap(); + + $newMap = $map->withAll(static fn(): array => []); + + self::assertSame($newMap, $map); + } + + final public function testWithAllWithArrayReturnsNewMapWithAddedElements(): void + { + $map = static::createMap(); + + $newMap = $map->withAll(['a' => 'b']); + + self::assertTrue($map->isEmpty()); + self::assertNotSame($map, $newMap); + self::assertMapEquals(['a' => 'b'], $newMap); + } + + final public function testWithAllWithIteratorReturnsNewMapWithAddedElements(): void + { + $map = static::createMap(); + + $newMap = $map->withAll(new \ArrayIterator(['a' => 'b'])); + + self::assertTrue($map->isEmpty()); + self::assertNotSame($map, $newMap); + self::assertMapEquals(['a' => 'b'], $newMap); + } + + final public function testWithAllWithClosureIteratorReturnsNewMapWithAddedElements(): void + { + $map = static::createMap(); + + $newMap = $map->withAll(static fn(): \Generator => yield 'a' => 'b'); + + self::assertTrue($map->isEmpty()); + self::assertNotSame($map, $newMap); + self::assertMapEquals(['a' => 'b'], $newMap); + } + + final public function testWithAllWithClosureArrayReturnsNewMapWithAddedElements(): void + { + $map = static::createMap(); + + $newMap = $map->withAll(static fn(): array => ['a' => 'b']); + + self::assertTrue($map->isEmpty()); + self::assertNotSame($map, $newMap); + self::assertMapEquals(['a' => 'b'], $newMap); + } + + final public function testWithoutWithoutArgsReturnsSameMap(): void + { + $map = static::createMap(['a' => 'b']); + + $newMap = $map->without(); + + self::assertSame($newMap, $map); + } + + final public function testWithoutReturnsNewMapWithRemovedKeys(): void + { + $map = static::createMap(['a' => 'b', 'c' => 'd']); + + $newMap = $map->without('c'); + + self::assertMapEquals(['a' => 'b', 'c' => 'd'], $map); + self::assertNotSame($map, $newMap); + self::assertMapEquals(['a' => 'b'], $newMap); + } + + final public function testIsEmptyReturnsTrueForEmptyMap(): void + { + $map = static::createMap(); + + self::assertTrue($map->isEmpty()); + } + + final public function testIsEmptyReturnsFalseForNonEmptyMap(): void + { + $map = static::createMap(['a' => 'b']); + + self::assertFalse($map->isEmpty()); + } + + #[TestWith([[], 0])] + #[TestWith([['a'], 1])] + #[TestWith([['a', 'b'], 2])] + #[TestWith([['a', 'a', 'a'], 3])] + final public function testCountReturnsValidNumberOfElements(array $values, int $expectedCount): void + { + $map = static::createMap($values); + + self::assertCount($expectedCount, $map); + } + + final public function testContainsAndGet(): void + { + $keys = [ + null, + 0, + 1, + -1, + 0.5, + 0.00001, + NAN, + INF, + '', + 'string', + [1, 2, 3], + ['a' => 'b'], + new \stdClass(), + new \ArrayObject(), + new \ArrayObject([1, 2, 3]), + $this, + STDIN, + fopen(__FILE__, 'r'), + ]; + $map = static::createMap(static function () use ($keys): \Generator { + foreach ($keys as $key) { + yield $key => serialize($key); + } + }); + + foreach ($keys as $key) { + self::assertTrue($map->contains($key)); + self::assertTrue(isset($map[$key])); + self::assertSame(serialize($key), $map->get($key, 'NO KEY')); + /** @psalm-suppress PossiblyNullArrayOffset */ + self::assertSame(serialize($key), $map[$key]); + } + } + + /** + * @psalm-suppress UnevaluatedCode + */ + final public function testContainsReturnsFalseForNonExistingKey(): void + { + $map = static::createMap(['a']); + + self::assertFalse(isset($map[1])); + self::assertFalse($map->contains(1)); + } + + final public function testGetReturnsNullValue(): void + { + $map = static::createMap([null]); + + $value = $map->get(0, 'NO KEY'); + + self::assertNull($value); + } + + final public function testGetReturnsDefaultIfKeyDoesNotExist(): void + { + $map = static::createMap(); + + $value = $map->get(1, 'NO KEY'); + + /** @psalm-suppress RedundantConditionGivenDocblockType */ + self::assertSame('NO KEY', $value); + } + + final public function testGetOrCallsDefaultIfKeyDoesNotExist(): void + { + $map = static::createMap(); + $exception = new \LogicException('NO KEY', 123, new \RuntimeException()); + + $this->expectExceptionObject($exception); + + $map->getOr(1, static fn(): never => throw $exception); + } + + final public function testOffsetGetThrowsIfKeyDoesNotExist(): void + { + $map = static::createMap(['a']); + + $this->expectExceptionObject(new KeyIsNotDefined(1)); + + $map[1]; + } + + final public function testFirstReturnsNullForEmptyMap(): void + { + $map = static::createMap(); + + $first = $map->first(); + + self::assertNull($first); + } + + final public function testFirstReturnsActualFirstKVPair(): void + { + $map = static::createMap(['a' => 'b', 'c' => 'd']); + + $first = $map->first(); + + self::assertEquals($first, new KVPair('a', 'b')); + } + + final public function testLastReturnsNullForEmptyMap(): void + { + $map = static::createMap(); + + $last = $map->last(); + + self::assertNull($last); + } + + final public function testLastReturnsActualLastKVPair(): void + { + $map = static::createMap(['a' => 'b', 'c' => 'd']); + + $last = $map->last(); + + self::assertEquals($last, new KVPair('c', 'd')); + } + + #[TestWith([[]])] + #[TestWith([['a']])] + final public function testFindFirstReturnsNullIfNothingMatches(array $values): void + { + $map = static::createMap($values); + + $first = $map->findFirst(static fn(mixed $value): bool => $value === 'b'); + + self::assertNull($first); + } + + final public function testFindFirstReturnsFirstMatchingKVPair(): void + { + $map = static::createMap(['a', 'b', 'b']); + + $first = $map->findFirst(static fn(string $value): bool => $value === 'b'); + + self::assertEquals($first, new KVPair(1, 'b')); + } + + #[TestWith([[]])] + #[TestWith([['a', 'b']])] + final public function testFindFirstKVReturnsNullIfNothingMatches(array $values): void + { + $map = static::createMap($values); + + $first = $map->findFirstKV(static fn(mixed $key, mixed $value): bool => $key === 0 && $value === 'b'); + + self::assertNull($first); + } + + final public function testFindFirstKVReturnsFirstMatchingKVPair(): void + { + $map = static::createMap(['a', 'b', 'b']); + + $first = $map->findFirstKV(static fn(int $key, string $value): bool => $key === 2 && $value === 'b'); + + self::assertEquals($first, new KVPair(2, 'b')); + } + + #[TestWith([[], false])] + #[TestWith([['a'], true])] + #[TestWith([['a', 'a'], true])] + #[TestWith([['b', 'a'], true])] + final public function testAny(array $values, bool $expected): void + { + $map = static::createMap($values); + + $any = $map->any(static fn(mixed $value): bool => $value === 'a'); + + self::assertSame($expected, $any); + } + + #[TestWith([[], false])] + #[TestWith([['a'], false])] + #[TestWith([['b', 'a'], true])] + #[TestWith([['b', 'a', 'a'], true])] + final public function testAnyKV(array $values, bool $expected): void + { + $map = static::createMap($values); + + $any = $map->anyKV(static fn(mixed $key, mixed $value): bool => $key === 1 && $value === 'a'); + + self::assertSame($expected, $any); + } + + #[TestWith([[], true])] + #[TestWith([['a'], true])] + #[TestWith([['a', 'a'], true])] + #[TestWith([['b', 'a'], false])] + final public function testAll(array $values, bool $expected): void + { + $map = static::createMap($values); + + $any = $map->all(static fn(mixed $value): bool => $value === 'a'); + + self::assertSame($expected, $any); + } + + #[TestWith([[], true])] + #[TestWith([['a'], true])] + #[TestWith([['b', 'a'], false])] + #[TestWith([['a', 'a'], true])] + #[TestWith([['b', 'a', 'a'], false])] + #[TestWith([['a', 'a', 'a'], false])] + final public function testAllKV(array $values, bool $expected): void + { + $map = static::createMap($values); + + $all = $map->allKV(static fn(mixed $key, mixed $value): bool => $key < 2 && $value === 'a'); + + self::assertSame($expected, $all); + } + + final public function testReduceThrowsForEmptyMap(): void + { + $this->expectExceptionObject(new \RuntimeException('Empty map')); + + static::createMap()->reduce(static fn(): bool => true); + } + + final public function testReduceReturnsCorrectConcatenation(): void + { + $map = static::createMap(['a', 'b', 'c']); + + $value = $map->reduce(static fn(string $all, string $value): string => $all . $value); + + self::assertSame('abc', $value); + } + + final public function testReduceKVThrowsForEmptyMap(): void + { + $this->expectExceptionObject(new \RuntimeException('Empty map')); + + static::createMap()->reduceKV(static fn(): bool => true); + } + + final public function testReduceKVReturnsCorrectConcatenation(): void + { + $map = static::createMap(['a', 'b', 'c']); + + $value = $map->reduceKV(static fn(string $all, int $key, string $value): string => $all . $key . $value); + + self::assertSame('a1b2c', $value); + } + + final public function testReduceReturnsFirstValueIfSingleElementMap(): void + { + $map = static::createMap(['a']); + + $value = $map->reduceKV(static fn(): never => self::fail()); + + /** @psalm-suppress RedundantConditionGivenDocblockType */ + self::assertSame('a', $value); + } + + final public function testFoldReturnsInitialForEmptyMap(): void + { + $map = static::createMap(); + + $value = $map->fold('empty', static fn(): string => 'non-empty'); + + self::assertSame('empty', $value); + } + + final public function testFoldReturnsCorrectConcatenation(): void + { + $map = static::createMap(['a', 'b', 'c']); + + $value = $map->fold('init', static fn(string $all, string $value): string => $all . $value); + + self::assertSame('initabc', $value); + } + + final public function testFoldKVReturnsInitialForEmptyMap(): void + { + $map = static::createMap(); + + $value = $map->foldKV('empty', static fn(): string => 'non-empty'); + + self::assertSame('empty', $value); + } + + final public function testFoldKVReturnsCorrectConcatenation(): void + { + $map = static::createMap(['a', 'b', 'c']); + + $value = $map->foldKV('init', static fn(string $all, int $key, string $value): string => $all . $key . $value); + + self::assertSame('init0a1b2c', $value); + } + + #[TestWith([[], []])] + #[TestWith([['b'], []])] + #[TestWith([['a'], ['a']])] + #[TestWith([['b', 'a'], [1 => 'a']])] + #[TestWith([['b', 'a', 'c', 'a'], [1 => 'a', 3 => 'a']])] + final public function testFilter(array $values, array $expected): void + { + $map = static::createMap($values); + + $newMap = $map->filter(static fn(mixed $value): bool => $value === 'a'); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($expected, $newMap); + } + + /** + * @param list $values + */ + #[TestWith([[], []])] + #[TestWith([['b'], []])] + #[TestWith([['b', 'a'], [1 => 'a']])] + #[TestWith([['b', 'a', 'c', 'a'], [1 => 'a', 3 => 'a']])] + final public function testFilterKV(array $values, array $expected): void + { + $map = static::createMap($values); + + $newMap = $map->filterKV(static fn(int $key): bool => ($key % 2) === 1); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($expected, $newMap); + } + + /** + * @param array $values + */ + #[TestWith([[], []])] + #[TestWith([['a'], [1]])] + #[TestWith([['a', '', 'bb'], [1, 0, 2]])] + final public function testMap(array $values, array $expected): void + { + $map = static::createMap($values); + + $newMap = $map->map(strlen(...)); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($expected, $newMap); + } + + /** + * @param array $values + */ + #[TestWith([[], []])] + #[TestWith([['a'], ['0a']])] + #[TestWith([['a', 'b'], ['0a', '1b']])] + #[TestWith([['a' => 'b'], ['a' => 'ab']])] + final public function testMapKV(array $values, array $expected): void + { + $map = static::createMap($values); + + $newMap = $map->mapKV(static fn(int|string $key, string $value): string => $key . $value); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($expected, $newMap); + } + + /** + * @param array $values + */ + #[TestWith([[], []])] + #[TestWith([['a' => 'a'], [1 => 'a']])] + #[TestWith([['a' => 'a', '' => '', 'bb' => 'bb'], [1 => 'a', 0 => '', 2 => 'bb']])] + final public function testMapKey(array $values, array $expected): void + { + $map = static::createMap($values); + + $newMap = $map->mapKey(strlen(...)); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($expected, $newMap); + } + + /** + * @param array $values + */ + #[TestWith([[], []])] + #[TestWith([['a'], ['0a' => 'a']])] + #[TestWith([['a', 'b'], ['0a' => 'a', '1b' => 'b']])] + #[TestWith([['a' => 'b'], ['ab' => 'b']])] + final public function testMapKeyKV(array $values, array $expected): void + { + $map = static::createMap($values); + + $newMap = $map->mapKeyKV(static fn(int|string $key, string $value): string => $key . $value); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($expected, $newMap); + } + + /** + * @param array $values + */ + #[TestWith([[], []])] + #[TestWith([['a', 'b'], ['a' => 'a', 'a2' => 'a', 'b' => 'b', 'b2' => 'b']])] + final public function testFlatMap(array $values, array $expected): void + { + $map = static::createMap($values); + + $newMap = $map->flatMap(static fn(string $value): array => [$value => $value, $value . '2' => $value]); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($expected, $newMap); + } + + /** + * @param list $values + */ + #[TestWith([[], []])] + #[TestWith([['a', 'b'], [0 => 'a', 10 => 'a', 1 => 'b', 11 => 'b']])] + final public function testFlatMapKV(array $values, array $expected): void + { + $map = static::createMap($values); + + $newMap = $map->flatMapKV(static fn(int $key, string $value): array => [$key => $value, $key + 10 => $value]); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($expected, $newMap); + } + + /** + * @param array $values + */ + #[TestWith([[]])] + #[TestWith([['a']])] + #[TestWith([['a', 'b', 1]])] + #[TestWith([['a', 'a']])] + #[TestWith([['a' => 'b']])] + final public function testFlip(array $values): void + { + $map = static::createMap($values); + + $newMap = $map->flip(); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals(array_flip($values), $newMap); + } + + #[TestWith([[]])] + #[TestWith([[1]])] + #[TestWith([[1, 1, 1]])] + #[TestWith([[3, 2, 1, 4]])] + #[TestWith([['a', 'd', 'c']])] + #[TestWith([['c', 'a', 'a']])] + #[TestWith([['1', '2', '10', '20']])] + final public function testSort(array $values): void + { + $map = static::createMap($values); + $sorted = $values; + asort($sorted); + + $newMap = $map->sort(); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($sorted, $newMap); + } + + #[TestWith([[]])] + #[TestWith([[1]])] + #[TestWith([[1, 1, 1]])] + #[TestWith([[3, 2, 1, 4]])] + #[TestWith([['a', 'd', 'c']])] + #[TestWith([['c', 'a', 'a']])] + #[TestWith([['1', '2', '10', '20']])] + final public function testSortDesc(array $values): void + { + $map = static::createMap($values); + $sorted = $values; + arsort($sorted); + + $newMap = $map->sortDesc(); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($sorted, $newMap); + } + + #[TestWith([[]])] + #[TestWith([[1 => 'a', -2 => 'b', 10 => 'c']])] + #[TestWith([['a' => 1, 'aa' => 2, '0' => 3]])] + #[TestWith([['1' => 1, '10' => 2, '2' => 3, '20' => 4]])] + final public function testKsort(array $values): void + { + $map = static::createMap($values); + $sorted = $values; + ksort($sorted); + + $newMap = $map->ksort(); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($sorted, $newMap); + } + + #[TestWith([[]])] + #[TestWith([[1 => 'a', -2 => 'b', 10 => 'c']])] + #[TestWith([['a' => 1, 'aa' => 2, '0' => 3]])] + #[TestWith([['1' => 1, '10' => 2, '2' => 3, '20' => 4]])] + final public function testKsortDesc(array $values): void + { + $map = static::createMap($values); + $sorted = $values; + krsort($sorted); + + $newMap = $map->ksortDesc(); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals($sorted, $newMap); + } + + final public function testUsort(): void + { + $values = [[2], [1]]; + $map = static::createMap($values); + + $newMap = $map->usort(static fn(array $a, array $b): int => $a[0] <=> $b[0]); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals([1 => [1], 0 => [2]], $newMap); + } + + final public function testUsortKV(): void + { + $values = [-1, -20]; + $map = static::createMap($values); + + $newMap = $map->usortKV(static fn(int $ka, int $va, int $kb, int $vb): int => $ka + $va <=> $kb + $vb); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertMapEquals([1 => -20, 0 => -1], $newMap); + } + + #[TestWith([0, null])] + #[TestWith([0, 5])] + #[TestWith([0, 10])] + #[TestWith([0, 0])] + #[TestWith([0, 2])] + #[TestWith([0, -1])] + #[TestWith([0, -100])] + #[TestWith([-1, null])] + #[TestWith([-2, 0])] + #[TestWith([-2, 2])] + #[TestWith([-2, -2])] + #[TestWith([-2, -20])] + final public function testSlice(int $offset, ?int $length): void + { + $values = range(0, 4); + $expected = \array_slice($values, $offset, $length, preserve_keys: true); + $map = static::createMap($values); + + $newMap = $map->slice($offset, $length); + + self::assertNotSame($map, $newMap); + self::assertMapEquals($values, $map); + self::assertEquals($this->createMap($expected), $newMap); + } + + final public function testOffsetSetThrowsBadMethodCall(): void + { + $map = static::createMap(); + + $this->expectExceptionObject(new \BadMethodCallException()); + + $map[0] = 1; + } + + final public function testOffsetUnsetThrowsBadMethodCall(): void + { + $map = static::createMap(['a']); + + $this->expectExceptionObject(new \BadMethodCallException()); + + unset($map[0]); + } + + final public function testPutAddsNewElementAtTheEnd(): void + { + /** @var MutableMap */ + $map = static::createMap(['a', 'b', 'c']); + + $map->put(4, 'd'); + + self::assertMapEquals(['a', 'b', 'c', 4 => 'd'], $map); + } + + final public function testPutReplacesElementAtKey(): void + { + /** @var MutableMap */ + $map = static::createMap(['a', 'b', 'c']); + + $map->put(1, 'b2'); + + self::assertMapEquals(['a', 'b2', 'c'], $map); + } + + final public function testPutPairsAddsNewElementAtTheEnd(): void + { + /** @var MutableMap */ + $map = static::createMap(['a', 'b', 'c']); + + $map->putPairs(new KVPair(4, 'd')); + + self::assertMapEquals(['a', 'b', 'c', 4 => 'd'], $map); + } + + final public function testPutPairsReplacesElementAtKey(): void + { + /** @var MutableMap */ + $map = static::createMap(['a', 'b', 'c']); + + $map->putPairs(new KVPair(1, 'b2')); + + self::assertMapEquals(['a', 'b2', 'c'], $map); + } + + final public function testPutAllMapReplacesIndexes(): void + { + /** @var MutableMap */ + $map = static::createMap(['a', 'b', 'c']); + /** @var MutableMap */ + $map2 = static::createMap(['a2', 'b2', 'c2']); + + $map->putAll($map2); + + self::assertMapEquals(['a2', 'b2', 'c2'], $map); + self::assertMapEquals(['a2', 'b2', 'c2'], $map2); + } + + final public function testPutAllSupportsMap(): void + { + /** @var MutableMap */ + $map = static::createMap(['a', 'b', 'c']); + /** @var MutableMap */ + $map2 = static::createMap([2 => 'c2', 3 => 'd']); + + $map->putAll($map2); + + self::assertMapEquals(['a', 'b', 'c2', 'd'], $map); + self::assertMapEquals([2 => 'c2', 3 => 'd'], $map2); + } + + final public function testRemoveWithoutKeysDoesNotChangeMap(): void + { + /** @var MutableMap */ + $map = static::createMap(['a', 'b', 'c']); + + $map->remove(); + + self::assertMapEquals(['a', 'b', 'c'], $map); + } + + final public function testRemoveKeepsOtherKeys(): void + { + /** @var MutableMap */ + $map = static::createMap(['a', 'b', 'c']); + + $map->remove(1); + + self::assertMapEquals(['a', 2 => 'c'], $map); + } + + final public function testClear(): void + { + /** @var MutableMap */ + $map = static::createMap(['a', 'b', 'c']); + + $map->clear(); + + self::assertTrue($map->isEmpty()); + } + + /** + * @template K + * @template V + * @param iterable|\Closure(): iterable $values + * @return MutableMap + */ + abstract protected static function createMap(iterable|\Closure $values = []): MutableMap; +} diff --git a/tests/DataStructure/SerializedKeyArrayMap.php b/tests/DataStructure/SerializedKeyArrayMap.php new file mode 100644 index 00000000..99ed9997 --- /dev/null +++ b/tests/DataStructure/SerializedKeyArrayMap.php @@ -0,0 +1,87 @@ + + */ +final class SerializedKeyArrayMap extends MutableMap +{ + private static function encodeKey(mixed $key): string + { + if (\is_resource($key)) { + return 'r:' . get_resource_id($key) . ';'; + } + + return serialize($key); + } + + /** + * @param array> $kvPairs + */ + public function __construct( + private array $kvPairs = [], + ) {} + + public function contains(mixed $key): bool + { + return isset($this->kvPairs[self::encodeKey($key)]); + } + + public function getOr(mixed $key, callable $or): mixed + { + $encodedKey = self::encodeKey($key); + + if (isset($this->kvPairs[$encodedKey])) { + return $this->kvPairs[$encodedKey]->value; + } + + return $or(); + } + + public function usortKV(callable $comparator): static + { + $kvPairs = $this->kvPairs; + uasort( + $kvPairs, + /** + * @param KVPair $kv1 + * @param KVPair $kv2 + */ + static fn(KVPair $kv1, KVPair $kv2) => $comparator($kv1->key, $kv1->value, $kv2->key, $kv2->value), + ); + + return new self($kvPairs); + } + + public function put(mixed $key, mixed $value): void + { + $this->kvPairs[self::encodeKey($key)] = new KVPair($key, $value); + } + + public function remove(mixed ...$keys): void + { + foreach ($keys as $key) { + unset($this->kvPairs[self::encodeKey($key)]); + } + } + + public function clear(): void + { + $this->kvPairs = []; + } + + /** + * @return \Generator + */ + public function getIterator(): \Generator + { + foreach ($this->kvPairs as $kvPair) { + yield $kvPair->key => $kvPair->value; + } + } +}