Skip to content

Commit af97ce6

Browse files
[FEATURE] KeyValueStorage: Introduce Refinery transformations for get()
This commit suggests enhancing the KeyValueStorage to integrate Refinery transformations in the `get()` method. Updated the API, documentation, and tests to support default transformations and validation.
1 parent f31f55b commit af97ce6

10 files changed

Lines changed: 116 additions & 46 deletions

File tree

components/ILIAS/Authentication/Authentication.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public function init(
3737

3838
$implement[UI\Storage::class] = static fn() =>
3939
new Authentication\KeyValueStorage\UiStorageAdapter(
40-
$use[KeyValueStorage\Services::class]->session(['ui', 'storage'])
40+
$use[KeyValueStorage\Services::class]->session(['ui', 'storage']),
41+
$pull[\ILIAS\Refinery\Factory::class]
4142
);
4243

4344
$contribute[\ILIAS\Setup\Agent::class] = static fn() =>

components/ILIAS/Authentication/src/KeyValueStorage/UiStorageAdapter.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,22 @@
2121
namespace ILIAS\Authentication\KeyValueStorage;
2222

2323
use ILIAS\KeyValueStorage\Store;
24+
use ILIAS\Refinery\Factory as Refinery;
25+
use ILIAS\Refinery\Transformation;
2426
use ILIAS\UI\Storage as UiStorage;
2527

2628
/**
2729
* Adapts session-scoped key-value storage to the UI ArrayAccess contract.
2830
*/
2931
final readonly class UiStorageAdapter implements UiStorage
3032
{
31-
public function __construct(private Store $storage)
32-
{
33+
private Transformation $as_stored;
34+
35+
public function __construct(
36+
private Store $storage,
37+
Refinery $refinery
38+
) {
39+
$this->as_stored = $refinery->identity();
3340
}
3441

3542
public function offsetExists(mixed $offset): bool
@@ -39,7 +46,7 @@ public function offsetExists(mixed $offset): bool
3946

4047
public function offsetGet(mixed $offset): mixed
4148
{
42-
return $this->storage->get($this->assertStringOffset($offset));
49+
return $this->storage->get($this->assertStringOffset($offset), $this->as_stored);
4350
}
4451

4552
public function offsetSet(mixed $offset, mixed $value): void

components/ILIAS/Authentication/tests/KeyValueStorage/UiStorageAdapterTest.php

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222

2323
use ILIAS\Authentication\KeyValueStorage\SessionRepository;
2424
use ILIAS\Authentication\KeyValueStorage\UiStorageAdapter;
25+
use ILIAS\Data\Factory as DataFactory;
2526
use ILIAS\KeyValueStorage\Internal\KeyRules;
2627
use ILIAS\KeyValueStorage\Internal\NamespacedStore;
2728
use ILIAS\KeyValueStorage\Internal\Values;
2829
use ILIAS\KeyValueStorage\Internal\StorageNamespace;
30+
use ILIAS\Refinery\Factory as Refinery;
2931
use ILIAS\UI\Implementation\Component\Navigation\Sequence\Sequence;
3032
use ILIAS\UI\Implementation\Component\Table\Data;
3133
use ILIAS\UI\Implementation\Component\Table\Ordering;
@@ -41,12 +43,19 @@ class UiStorageAdapterTest extends TestCase
4143
protected function setUp(): void
4244
{
4345
$_SESSION = [];
44-
$this->adapter = new UiStorageAdapter(new NamespacedStore(
45-
new StorageNamespace(['ui', 'storage']),
46-
new SessionRepository(),
47-
new KeyRules(),
48-
new Values()
49-
));
46+
$language = $this->getMockBuilder(\ilLanguage::class)
47+
->disableOriginalConstructor()
48+
->getMock();
49+
$refinery = new Refinery(new DataFactory(), $language);
50+
$this->adapter = new UiStorageAdapter(
51+
new NamespacedStore(
52+
new StorageNamespace(['ui', 'storage']),
53+
new SessionRepository(),
54+
new KeyRules(),
55+
new Values()
56+
),
57+
$refinery
58+
);
5059
}
5160

5261
public function testAViewStateSurvivesAWriteAndReadCycle(): void

components/ILIAS/KeyValueStorage/README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,21 @@ $store = $storage->session(['my_component', 'view_state']);
2222
$store->set('sort_column', 'title');
2323
$store->set('filters', ['status' => 'open', 'limit' => 10]);
2424

25-
$column = $store->get('sort_column', 'id');
25+
$column = $store->get(
26+
'sort_column',
27+
$DIC->refinery()->byTrying([
28+
$DIC->refinery()->kindlyTo()->string(),
29+
$DIC->refinery()->always('id'),
30+
])
31+
);
2632
$store->has('filters');
2733
$store->delete('sort_column');
2834
$store->clear(); // only this namespace
2935
```
3036

37+
`get()` always takes a Refinery `Transformation`, like the HTTP request wrappers.
38+
Absent keys are passed to the transformation as `null`.
39+
3140
| Scope | Lives | Accessor |
3241
|---|---|---|
3342
| Session | until the session ends | `Services::session()` |
@@ -131,7 +140,7 @@ An implementation must keep the namespaces apart, must return `null` from
131140
|---|---|
132141
| `$define` | `Services`, `SessionRepository` |
133142
| `$implement` | `Services` |
134-
| `$pull` | `ILIAS\Database\Connection` |
143+
| `$pull` | `ILIAS\Database\Connection`, `ILIAS\Refinery\Factory` (setup agent) |
135144
| `$contribute` | `ILIAS\Setup\Agent` |
136145

137146
```mermaid
@@ -150,7 +159,6 @@ components/ILIAS/KeyValueStorage/
150159
├── README.md
151160
├── PRIVACY.md
152161
├── ROADMAP.md
153-
├── maintenance.json
154162
├── src/
155163
│ ├── Services.php consumer entry point
156164
│ ├── Store.php one namespace

components/ILIAS/KeyValueStorage/src/Internal/NamespacedStore.php

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

2323
use ILIAS\KeyValueStorage\Repository;
2424
use ILIAS\KeyValueStorage\Store;
25+
use ILIAS\Refinery\Transformation;
2526

2627
/**
2728
* A store bound to one namespace of one repository.
@@ -61,13 +62,13 @@ public function has(string $key): bool
6162
return $this->repository->has($this->namespace, $key);
6263
}
6364

64-
public function get(string $key, mixed $default = null): mixed
65+
public function get(string $key, Transformation $transformation): mixed
6566
{
6667
$this->key_rules->check($key);
6768

6869
[$is_present, $value] = $this->readDecoded($key);
6970

70-
return $is_present ? $value : $default;
71+
return $transformation->transform($is_present ? $value : null);
7172
}
7273

7374
public function set(string $key, mixed $value): void

components/ILIAS/KeyValueStorage/src/Internal/Values.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ public function encode(mixed $value): string
4545
try {
4646
return \json_encode($value, JSON_THROW_ON_ERROR, self::MAX_DEPTH);
4747
} catch (\JsonException $e) {
48-
throw new \InvalidArgumentException('The value could not be encoded: ' . $e->getMessage(), 0, $e);
48+
throw new \InvalidArgumentException(
49+
'The value could not be encoded: ' . $e->getMessage(),
50+
0,
51+
$e
52+
);
4953
}
5054
}
5155

@@ -55,12 +59,10 @@ public function encode(mixed $value): string
5559
public function decode(string $value): mixed
5660
{
5761
try {
58-
$decoded = \json_decode($value, true, self::MAX_DEPTH, JSON_THROW_ON_ERROR);
62+
return \json_decode($value, true, self::MAX_DEPTH, JSON_THROW_ON_ERROR);
5963
} catch (\JsonException $e) {
6064
throw new InvalidStoredValueException('The stored value is not valid JSON.', 0, $e);
6165
}
62-
63-
return $decoded;
6466
}
6567

6668
private function checkEncodable(mixed $value): void

components/ILIAS/KeyValueStorage/src/Store.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
namespace ILIAS\KeyValueStorage;
2222

23+
use ILIAS\Refinery\Transformation;
24+
2325
/**
2426
* Key-value store scoped to one namespace.
2527
*
@@ -34,10 +36,14 @@ interface Store
3436
public function has(string $key): bool;
3537

3638
/**
39+
* Reads a value and applies a Refinery transformation, like HTTP request wrappers.
40+
*
41+
* Absent keys are passed to the transformation as {@code null}.
42+
*
3743
* @throws \InvalidArgumentException if the key is invalid
3844
* @throws Exception\InvalidStoredValueException if the stored value cannot be read back
3945
*/
40-
public function get(string $key, mixed $default = null): mixed;
46+
public function get(string $key, Transformation $transformation): mixed;
4147

4248
/**
4349
* @throws \InvalidArgumentException if the key is invalid or the value cannot be stored

components/ILIAS/KeyValueStorage/tests/Internal/NamespacedStoreTest.php

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@
2525
use ILIAS\KeyValueStorage\Internal\Values;
2626
use ILIAS\KeyValueStorage\Internal\StorageNamespace;
2727
use ILIAS\Tests\KeyValueStorage\InMemoryRepository;
28+
use ILIAS\Tests\KeyValueStorage\RefineryHelper;
2829
use PHPUnit\Framework\TestCase;
2930

3031
class NamespacedStoreTest extends TestCase
3132
{
33+
use RefineryHelper;
34+
3235
private InMemoryRepository $repository;
3336

3437
private StorageNamespace $namespace;
@@ -56,22 +59,29 @@ public function testValuesAreStoredEncodedAndReadBackDecoded(): void
5659
$this->repository->entries['my_component.view_state']['filters']
5760
);
5861

59-
$this->assertSame(['status' => 'open', 'limit' => 10], $this->storeFor($this->namespace)->get('filters'));
62+
$this->assertSame(
63+
['status' => 'open', 'limit' => 10],
64+
$this->storeFor($this->namespace)->get('filters', $this->asStored())
65+
);
6066
}
6167

62-
public function testAbsentKeysYieldTheDefault(): void
68+
public function testAbsentKeysArePassedToTheTransformationAsNull(): void
6369
{
64-
$this->assertNull($this->store->get('absent'));
65-
$this->assertSame('fallback', $this->store->get('absent', 'fallback'));
70+
$this->assertNull($this->store->get('absent', $this->asStored()));
71+
$this->assertSame(
72+
'fallback',
73+
$this->store->get('absent', $this->withDefault('fallback'))
74+
);
6675
$this->assertFalse($this->store->has('absent'));
6776
}
6877

69-
public function testAStoredNullIsDistinguishedFromAnAbsentKey(): void
78+
public function testAStoredNullIsDistinguishedFromAnAbsentKeyViaHas(): void
7079
{
7180
$this->store->set('maybe', null);
7281

7382
$this->assertTrue($this->store->has('maybe'));
74-
$this->assertNull($this->store->get('maybe', 'fallback'));
83+
$this->assertNull($this->store->get('maybe', $this->asStored()));
84+
$this->assertFalse($this->store->has('absent'));
7585
}
7686

7787
public function testDeleteRemovesASingleKey(): void
@@ -101,32 +111,35 @@ public function testReadingTheSameKeyTwiceHitsTheRepositoryOnce(): void
101111
{
102112
$this->repository->entries['my_component.view_state']['sort'] = '"title"';
103113

104-
$this->assertSame('title', $this->store->get('sort'));
105-
$this->assertSame('title', $this->store->get('sort'));
114+
$this->assertSame('title', $this->store->get('sort', $this->asStored()));
115+
$this->assertSame('title', $this->store->get('sort', $this->asStored()));
106116

107117
$this->assertSame(1, $this->repository->reads);
108118
}
109119

110120
public function testAnAbsentKeyIsOnlyLookedUpOnce(): void
111121
{
112-
$this->store->get('absent');
113-
$this->store->get('absent');
122+
$this->store->get('absent', $this->asStored());
123+
$this->store->get('absent', $this->asStored());
114124

115125
$this->assertSame(1, $this->repository->reads);
116126
}
117127

118-
public function testAKnownAbsentKeyStillYieldsTheDefault(): void
128+
public function testAKnownAbsentKeyStillYieldsTheTransformedDefault(): void
119129
{
120-
$this->store->get('absent');
130+
$this->store->get('absent', $this->asStored());
121131

122-
$this->assertSame('fallback', $this->store->get('absent', 'fallback'));
132+
$this->assertSame(
133+
'fallback',
134+
$this->store->get('absent', $this->withDefault('fallback'))
135+
);
123136
$this->assertSame(1, $this->repository->reads);
124137
}
125138

126139
public function testHasAnswersFromWhatWasAlreadyRead(): void
127140
{
128141
$this->repository->entries['my_component.view_state']['sort'] = '"title"';
129-
$this->store->get('sort');
142+
$this->store->get('sort', $this->asStored());
130143

131144
$this->assertTrue($this->store->has('sort'));
132145
$this->assertSame(1, $this->repository->reads);
@@ -136,7 +149,7 @@ public function testAWriteIsVisibleWithoutReadingTheRepositoryAgain(): void
136149
{
137150
$this->store->set('sort', 'title');
138151

139-
$this->assertSame('title', $this->store->get('sort'));
152+
$this->assertSame('title', $this->store->get('sort', $this->asStored()));
140153
$this->assertSame(0, $this->repository->reads);
141154
}
142155

@@ -152,18 +165,30 @@ public function jsonSerialize(): array
152165
}
153166
});
154167

155-
$this->assertSame(['a' => 1], $this->store->get('value'));
168+
$this->assertSame(['a' => 1], $this->store->get('value', $this->asStored()));
156169
}
157170

158171
public function testEveryOperationValidatesTheKey(): void
159172
{
160-
foreach (['has', 'get', 'delete'] as $method) {
161-
try {
162-
$this->store->{$method}('in:valid');
163-
$this->fail($method . '() accepted an invalid key.');
164-
} catch (\InvalidArgumentException) {
165-
$this->addToAssertionCount(1);
166-
}
173+
try {
174+
$this->store->has('in:valid');
175+
$this->fail('has() accepted an invalid key.');
176+
} catch (\InvalidArgumentException) {
177+
$this->addToAssertionCount(1);
178+
}
179+
180+
try {
181+
$this->store->get('in:valid', $this->asStored());
182+
$this->fail('get() accepted an invalid key.');
183+
} catch (\InvalidArgumentException) {
184+
$this->addToAssertionCount(1);
185+
}
186+
187+
try {
188+
$this->store->delete('in:valid');
189+
$this->fail('delete() accepted an invalid key.');
190+
} catch (\InvalidArgumentException) {
191+
$this->addToAssertionCount(1);
167192
}
168193

169194
$this->expectException(\InvalidArgumentException::class);
@@ -175,6 +200,6 @@ public function testAnInvalidKeyIsRejectedEvenWhenTheValueIsAlreadyKnown(): void
175200
$this->repository->entries['my_component.view_state']['in:valid'] = '1';
176201

177202
$this->expectException(\InvalidArgumentException::class);
178-
$this->store->get('in:valid');
203+
$this->store->get('in:valid', $this->asStored());
179204
}
180205
}

components/ILIAS/KeyValueStorage/tests/Internal/StorageServicesTest.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@
2323
use ILIAS\KeyValueStorage\Internal\StorageServices;
2424
use ILIAS\KeyValueStorage\SessionRepository;
2525
use ILIAS\Tests\KeyValueStorage\InMemoryRepository;
26+
use ILIAS\Tests\KeyValueStorage\RefineryHelper;
2627
use PHPUnit\Framework\TestCase;
2728

2829
class StorageServicesTest extends TestCase
2930
{
31+
use RefineryHelper;
32+
3033
private InMemoryRepository $session;
3134

3235
private InMemoryRepository $persistent;
@@ -78,7 +81,10 @@ public function testTheSharedStoreKeepsWhatWasWrittenThroughIt(): void
7881
{
7982
$this->services->session(['ui', 'storage'])->set('sort', 'title');
8083

81-
$this->assertSame('title', $this->services->session(['ui', 'storage'])->get('sort'));
84+
$this->assertSame(
85+
'title',
86+
$this->services->session(['ui', 'storage'])->get('sort', $this->asStored())
87+
);
8288
$this->assertSame(0, $this->session->reads);
8389
}
8490
}

0 commit comments

Comments
 (0)