Skip to content

Commit 6a207ce

Browse files
committed
Use serialize() for keys inside TypedMap
1 parent 2aab91c commit 6a207ce

8 files changed

Lines changed: 221 additions & 40 deletions

File tree

src/Key.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
/**
88
* @api
9-
* @psalm-immutable
10-
* @template-covariant TValue
9+
* @template TValue
1110
*/
12-
interface Key extends \UnitEnum {}
11+
interface Key {}

src/KeyIsNotDefined.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44

55
namespace Typhoon\TypedMap;
66

7+
use function Typhoon\Formatter\format;
8+
79
/**
810
* @api
911
*/
1012
final class KeyIsNotDefined extends \RuntimeException
1113
{
14+
/**
15+
* @param Key<*> $key
16+
*/
1217
public function __construct(Key $key)
1318
{
14-
parent::__construct(\sprintf('Key %s::%s is not defined in the TypedMap', $key::class, $key->name));
19+
parent::__construct(\sprintf('Key %s is not defined in the TypedMap', format($key)));
1520
}
1621
}

src/OptionalKey.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66

77
/**
88
* @api
9-
* @psalm-immutable
10-
* @template-covariant TValue
9+
* @template TValue
1110
* @extends Key<TValue>
1211
*/
1312
interface OptionalKey extends Key

src/TypedMap.php

Lines changed: 23 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,24 @@
66

77
/**
88
* @api
9-
* @psalm-immutable
10-
* @implements \ArrayAccess<Key, mixed>
9+
* @implements \ArrayAccess<Key<*>, mixed>
1110
*/
1211
final class TypedMap implements \ArrayAccess, \Countable
1312
{
1413
/**
15-
* @var array<non-empty-string, mixed>
14+
* @var array<string, mixed>
1615
*/
1716
private array $values = [];
1817

1918
/**
20-
* @psalm-pure
21-
* @return non-empty-string
22-
*/
23-
private static function keyToString(Key $key): string
24-
{
25-
return $key::class . '::' . $key->name;
26-
}
27-
28-
/**
29-
* @psalm-immutable
3019
* @template T
3120
* @param Key<T> $key
3221
* @param T $value
3322
*/
3423
public static function one(Key $key, mixed $value): self
3524
{
3625
$map = new self();
37-
$map->values[self::keyToString($key)] = $value;
26+
$map->values[serialize($key)] = $value;
3827

3928
return $map;
4029
}
@@ -46,21 +35,8 @@ public static function one(Key $key, mixed $value): self
4635
*/
4736
public function with(Key $key, mixed $value): self
4837
{
49-
$stringKey = self::keyToString($key);
50-
51-
if ($key instanceof OptionalKey && $value === $key->default($this)) {
52-
if (isset($this->values[$stringKey])) {
53-
$copy = clone $this;
54-
unset($copy->values[$stringKey]);
55-
56-
return $copy;
57-
}
58-
59-
return $this;
60-
}
61-
6238
$copy = clone $this;
63-
$copy->values[$stringKey] = $value;
39+
$copy->values[serialize($key)] = $value;
6440

6541
return $copy;
6642
}
@@ -73,33 +49,37 @@ public function withMap(self $map): self
7349
return $copy;
7450
}
7551

52+
/**
53+
* @param Key<*> ...$keys
54+
*/
7655
public function without(Key ...$keys): self
7756
{
7857
$copy = clone $this;
7958

8059
foreach ($keys as $key) {
81-
unset($copy->values[self::keyToString($key)]);
60+
unset($copy->values[serialize($key)]);
8261
}
8362

8463
return $copy;
8564
}
8665

8766
public function offsetExists(mixed $offset): bool
8867
{
89-
return isset($this->values[self::keyToString($offset)]);
68+
return isset($this->values[serialize($offset)]);
9069
}
9170

9271
/**
9372
* @template T
9473
* @param Key<T> $offset
9574
* @return T
9675
* @throws KeyIsNotDefined
76+
* @phpstan-ignore method.childParameterType
9777
*/
9878
public function offsetGet(mixed $offset): mixed
9979
{
100-
$key = self::keyToString($offset);
80+
$key = serialize($offset);
10181

102-
if (isset($this->values[$key])) {
82+
if (\array_key_exists($key, $this->values)) {
10383
/** @var T */
10484
return $this->values[$key];
10585
}
@@ -122,19 +102,28 @@ public function offsetUnset(mixed $offset): never
122102
throw new \BadMethodCallException(\sprintf('%s is immutable', self::class));
123103
}
124104

105+
/**
106+
* @return array<string, mixed>
107+
*/
125108
public function __serialize(): array
126109
{
127110
return $this->values;
128111
}
129112

130113
/**
131-
* @param array<non-empty-string, mixed> $data
114+
* @param array<mixed> $data
132115
*/
133116
public function __unserialize(array $data): void
134117
{
135-
$this->values = $data;
118+
foreach ($data as $key => $value) {
119+
\assert(\is_string($key) && unserialize($key) instanceof Key);
120+
$this->values[$key] = $value;
121+
}
136122
}
137123

124+
/**
125+
* @return non-negative-int
126+
*/
138127
public function count(): int
139128
{
140129
return \count($this->values);

tests/KeyIsNotDefinedTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Typhoon\TypedMap;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\TestCase;
9+
10+
#[CoversClass(KeyIsNotDefined::class)]
11+
final class KeyIsNotDefinedTest extends TestCase
12+
{
13+
public function testMessage(): void
14+
{
15+
$exception = new KeyIsNotDefined(Keys::A);
16+
17+
self::assertSame('Key Typhoon\TypedMap\Keys::A is not defined in the TypedMap', $exception->getMessage());
18+
}
19+
}

tests/Keys.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Typhoon\TypedMap;
6+
7+
/**
8+
* @implements Key<mixed>
9+
*/
10+
enum Keys implements Key
11+
{
12+
case A;
13+
case B;
14+
}

tests/OptionalKeys.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Typhoon\TypedMap;
6+
7+
/**
8+
* @implements OptionalKey<string>
9+
*/
10+
enum OptionalKeys implements OptionalKey
11+
{
12+
public const DEFAULT = '129afde0-d3d2-4b36-b073-b06fecb6d775';
13+
case A;
14+
15+
public function default(TypedMap $map): mixed
16+
{
17+
return self::DEFAULT;
18+
}
19+
}

tests/TypedMapTest.php

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Typhoon\TypedMap;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\TestCase;
9+
10+
#[CoversClass(TypedMap::class)]
11+
final class TypedMapTest extends TestCase
12+
{
13+
public function testItReturnsDefaultValueForAnOptionalKey(): void
14+
{
15+
$map = new TypedMap();
16+
17+
$value = $map[OptionalKeys::A];
18+
19+
self::assertSame(OptionalKeys::DEFAULT, $value);
20+
}
21+
22+
public function testItReturnsFalseForNonSetOffset(): void
23+
{
24+
$map = new TypedMap();
25+
26+
self::assertFalse(isset($map[Keys::A]));
27+
}
28+
29+
public function testItThrowsWhenRequiredKeyDoesNotExist(): void
30+
{
31+
$map = new TypedMap();
32+
33+
$this->expectExceptionObject(new KeyIsNotDefined(Keys::A));
34+
35+
/** @phpstan-ignore expr.resultUnused */
36+
$map[Keys::A];
37+
}
38+
39+
public function testWithReturnsNewMapWithAddedKey(): void
40+
{
41+
$map = TypedMap::one(Keys::A, 123);
42+
$initialMapCopy = clone $map;
43+
44+
$newMap = $map->with(Keys::B, 'abc');
45+
46+
self::assertSame(123, $newMap[Keys::A]);
47+
self::assertSame('abc', $newMap[Keys::B]);
48+
self::assertEquals($initialMapCopy, $map);
49+
self::assertNotSame($map, $newMap);
50+
}
51+
52+
public function testWithMapReturnsNewMapWithMergedKeys(): void
53+
{
54+
$map1 = TypedMap::one(Keys::A, 123);
55+
$initialMap1Copy = clone $map1;
56+
$map2 = TypedMap::one(Keys::B, 'abc');
57+
$initialMap2Copy = clone $map2;
58+
59+
$merged = $map1->withMap($map2);
60+
61+
self::assertSame(123, $merged[Keys::A]);
62+
self::assertSame('abc', $merged[Keys::B]);
63+
self::assertEquals($initialMap1Copy, $map1);
64+
self::assertEquals($initialMap2Copy, $map2);
65+
self::assertNotSame($map1, $merged);
66+
self::assertNotSame($map2, $merged);
67+
}
68+
69+
public function testItRemovesKeyViaWithout(): void
70+
{
71+
$map = TypedMap::one(Keys::A, 123);
72+
$initialMapCopy = clone $map;
73+
74+
$newMap = $map->without(Keys::A, Keys::B);
75+
76+
self::assertCount(0, $newMap);
77+
self::assertEquals($initialMapCopy, $map);
78+
self::assertNotSame($map, $newMap);
79+
}
80+
81+
public function testWithMapReplacesExistingKeys(): void
82+
{
83+
$map = TypedMap::one(Keys::A, 'a')->with(Keys::B, 'b');
84+
$map2 = TypedMap::one(Keys::A, 'a2');
85+
86+
$merged = $map->withMap($map2);
87+
88+
self::assertSame($merged[Keys::A], 'a2');
89+
self::assertSame($merged[Keys::B], 'b');
90+
}
91+
92+
public function testMapCount(): void
93+
{
94+
$map = new TypedMap();
95+
$map2 = TypedMap::one(Keys::A, 'a2');
96+
97+
self::assertCount(0, $map);
98+
self::assertCount(1, $map2);
99+
}
100+
101+
public function testOffsetSetThrows(): void
102+
{
103+
$map = new TypedMap();
104+
105+
$this->expectExceptionObject(new \BadMethodCallException('Typhoon\TypedMap\TypedMap is immutable'));
106+
107+
$map[Keys::A] = 'a';
108+
}
109+
110+
public function testOffsetUnsetThrows(): void
111+
{
112+
$map = new TypedMap();
113+
114+
$this->expectExceptionObject(new \BadMethodCallException('Typhoon\TypedMap\TypedMap is immutable'));
115+
116+
unset($map[Keys::A]);
117+
}
118+
119+
public function testItDeserializesCorrectly(): void
120+
{
121+
$map = TypedMap::one(Keys::A, 'a')->with(Keys::B, new \stdClass());
122+
123+
$unserialized = unserialize(serialize($map));
124+
125+
self::assertEquals($map, $unserialized);
126+
}
127+
128+
public function testSerializedRepresentationDoesNotChange(): void
129+
{
130+
$map = TypedMap::one(Keys::A, 'a')->with(Keys::B, 123);
131+
132+
self::assertSame(
133+
'O:25:"Typhoon\TypedMap\TypedMap":2:{s:31:"E:23:"Typhoon\TypedMap\Keys:A";";s:1:"a";s:31:"E:23:"Typhoon\TypedMap\Keys:B";";i:123;}',
134+
serialize($map),
135+
);
136+
}
137+
}

0 commit comments

Comments
 (0)