• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

RikudouSage / DynamoDbCachePsr6 / 22776633301

06 Mar 2026 06:30PM UTC coverage: 96.325% (-3.1%) from 99.458%
22776633301

Pull #37

github

web-flow
Merge 7098dad0a into e612f9757
Pull Request #37: Chore: Replace clock

0 of 12 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

367 of 381 relevant lines covered (96.33%)

10.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.26
/src/DynamoDbCache.php
1
<?php
2

3
namespace Rikudou\DynamoDbCache;
4

5
use AsyncAws\Core\Exception\Http\ClientException;
6
use AsyncAws\Core\Exception\Http\HttpException;
7
use AsyncAws\Core\Exception\Http\NetworkException;
8
use AsyncAws\DynamoDb\DynamoDbClient;
9
use AsyncAws\DynamoDb\ValueObject\AttributeValue;
10
use AsyncAws\DynamoDb\ValueObject\KeysAndAttributes;
11
use AsyncAws\DynamoDb\ValueObject\WriteRequest;
12
use DateInterval;
13
use JetBrains\PhpStorm\ExpectedValues;
14
use LogicException;
15
use Psr\Cache\CacheItemInterface;
16
use Psr\Cache\CacheItemPoolInterface;
17
use Psr\SimpleCache\CacheInterface;
18
use Rikudou\Clock\Clock;
19
use Rikudou\Clock\ClockInterface;
20
use Rikudou\DynamoDbCache\Converter\CacheItemConverterRegistry;
21
use Rikudou\DynamoDbCache\Converter\DefaultCacheItemConverter;
22
use Rikudou\DynamoDbCache\Encoder\CacheItemEncoderInterface;
23
use Rikudou\DynamoDbCache\Encoder\SerializeItemEncoder;
24
use Rikudou\DynamoDbCache\Enum\NetworkErrorMode;
25
use Rikudou\DynamoDbCache\Exception\CacheItemNotFoundException;
26
use Rikudou\DynamoDbCache\Exception\InvalidArgumentException;
27

28
final class DynamoDbCache implements CacheItemPoolInterface, CacheInterface
29
{
30
    private const RESERVED_CHARACTERS = '{}()/\@:';
31
    private const MAX_KEY_LENGTH = 2048;
32

33
    /**
34
     * @var DynamoCacheItem[]
35
     */
36
    private array $deferred = [];
37

38
    private ClockInterface $clock;
39

40
    private CacheItemConverterRegistry $converter;
41

42
    private CacheItemEncoderInterface $encoder;
43

44
    public function __construct(
45
        private string $tableName,
46
        private DynamoDbClient $client,
47
        private string $primaryField = 'id',
48
        private string $ttlField = 'ttl',
49
        private string $valueField = 'value',
50
        ?ClockInterface $clock = null,
51
        ?CacheItemConverterRegistry $converter = null,
52
        ?CacheItemEncoderInterface $encoder = null,
53
        private ?string $prefix = null,
54
        #[ExpectedValues(valuesFromClass: NetworkErrorMode::class)]
55
        private int $networkErrorMode = NetworkErrorMode::DEFAULT,
56
    ) {
57
        if ($clock === null) {
58✔
58
            $clock = new Clock();
56✔
59
        }
60
        $this->clock = $clock;
58✔
61

62
        if ($encoder === null) {
58✔
63
            $encoder = new SerializeItemEncoder();
56✔
64
        }
65
        $this->encoder = $encoder;
58✔
66

67
        if ($converter === null) {
58✔
68
            $converter = new CacheItemConverterRegistry(
56✔
69
                new DefaultCacheItemConverter($this->encoder, $this->clock)
56✔
70
            );
56✔
71
        }
72
        $this->converter = $converter;
58✔
73

74
        if ($prefix !== null && strlen($prefix) >= self::MAX_KEY_LENGTH) {
58✔
75
            throw new LogicException(
1✔
76
                sprintf('The prefix cannot be longer or equal to the maximum length: %d bytes', self::MAX_KEY_LENGTH)
1✔
77
            );
1✔
78
        }
79
        if (!in_array($networkErrorMode, NetworkErrorMode::cases())) {
58✔
80
            throw new LogicException("Unsupported network error mode: {$networkErrorMode}");
1✔
81
        }
82
    }
83

84
    /**
85
     * @throws InvalidArgumentException
86
     */
87
    public function getItem(string $key): CacheItemInterface
88
    {
89
        if ($exception = $this->getExceptionForInvalidKey($this->getKey($key))) {
28✔
90
            throw $exception;
1✔
91
        }
92

93
        $finalKey = $this->getKey($key);
27✔
94
        if (strlen($finalKey) > self::MAX_KEY_LENGTH) {
27✔
95
            $finalKey = $this->generateCompliantKey($key);
1✔
96
        }
97

98
        try {
99
            $item = $this->getRawItem($finalKey);
27✔
100
            if (!isset($item[$this->valueField])) {
23✔
101
                throw new CacheItemNotFoundException();
21✔
102
            }
103
            $data = $item[$this->valueField]->getS() ?? null;
13✔
104

105
            assert(method_exists($this->clock->now(), 'setTimestamp'));
106

107
            return new DynamoCacheItem(
13✔
108
                $finalKey,
13✔
109
                $data !== null,
13✔
110
                $data !== null ? $this->encoder->decode($data) : null,
13✔
111
                isset($item[$this->ttlField]) && $item[$this->ttlField]->getN() !== null
13✔
112
                    ? $this->clock->now()->setTimestamp((int) $item[$this->ttlField]->getN())
13✔
113
                    : null,
13✔
114
                $this->clock,
13✔
115
                $this->encoder
13✔
116
            );
13✔
117
        } catch (CacheItemNotFoundException $e) {
25✔
118
            return new DynamoCacheItem(
22✔
119
                $finalKey,
22✔
120
                false,
22✔
121
                null,
22✔
122
                null,
22✔
123
                $this->clock,
22✔
124
                $this->encoder
22✔
125
            );
22✔
126
        }
127
    }
128

129
    /**
130
     * @param string[] $keys
131
     *
132
     * @throws InvalidArgumentException
133
     *
134
     * @return iterable<int, DynamoCacheItem>
135
     */
136
    public function getItems(array $keys = []): iterable
137
    {
138
        if (!count($keys)) {
8✔
139
            return [];
1✔
140
        }
141

142
        $keys = array_map(function ($key) {
7✔
143
            if ($exception = $this->getExceptionForInvalidKey($this->getKey($key))) {
7✔
144
                throw $exception;
1✔
145
            }
146

147
            return $this->getKey($key);
6✔
148
        }, $keys);
7✔
149
        $response = $this->client->batchGetItem([
6✔
150
            'RequestItems' => [
6✔
151
                $this->tableName => new KeysAndAttributes([
6✔
152
                    'Keys' => array_map(function ($key) {
6✔
153
                        return [
6✔
154
                            $this->primaryField => new AttributeValue([
6✔
155
                                'S' => $key,
6✔
156
                            ]),
6✔
157
                        ];
6✔
158
                    }, $keys),
6✔
159
                ]),
6✔
160
            ],
6✔
161
        ]);
6✔
162

163
        $result = [];
6✔
164
        assert(method_exists($this->clock->now(), 'setTimestamp'));
165
        foreach ($response->getResponses()[$this->tableName] as $item) {
6✔
166
            if (!isset($item[$this->primaryField]) || $item[$this->primaryField]->getS() === null) {
6✔
167
                // @codeCoverageIgnoreStart
168
                continue;
169
                // @codeCoverageIgnoreEnd
170
            }
171
            if (!isset($item[$this->valueField]) || $item[$this->valueField]->getS() === null) {
6✔
172
                // @codeCoverageIgnoreStart
173
                continue;
174
                // @codeCoverageIgnoreEnd
175
            }
176
            $result[] = new DynamoCacheItem(
6✔
177
                $item[$this->primaryField]->getS(),
6✔
178
                true,
6✔
179
                $this->encoder->decode($item[$this->valueField]->getS()),
6✔
180
                isset($item[$this->ttlField]) && $item[$this->ttlField]->getN() !== null
6✔
181
                    ? $this->clock->now()->setTimestamp((int) $item[$this->ttlField]->getN())
6✔
182
                    : null,
6✔
183
                $this->clock,
6✔
184
                $this->encoder
6✔
185
            );
6✔
186
        }
187

188
        if (count($result) !== count($keys)) {
6✔
189
            $processedKeys = array_map(function (DynamoCacheItem $cacheItem) {
5✔
190
                return $cacheItem->getKey();
5✔
191
            }, $result);
5✔
192
            $unprocessed = array_diff($keys, $processedKeys);
5✔
193
            foreach ($unprocessed as $unprocessedKey) {
5✔
194
                $result[] = new DynamoCacheItem(
5✔
195
                    $unprocessedKey,
5✔
196
                    false,
5✔
197
                    null,
5✔
198
                    null,
5✔
199
                    $this->clock,
5✔
200
                    $this->encoder
5✔
201
                );
5✔
202
            }
203
        }
204

205
        return $result;
6✔
206
    }
207

208
    /**
209
     * @throws InvalidArgumentException
210
     */
211
    public function hasItem(string $key): bool
212
    {
213
        return $this->getItem($key)->isHit();
6✔
214
    }
215

216
    public function clear(): bool
217
    {
218
        $scanQuery = [
1✔
219
            'TableName' => $this->tableName,
1✔
220
        ];
1✔
221
        if ($this->prefix !== null) {
1✔
222
            $scanQuery['FilterExpression'] = 'begins_with(#id, :prefix)';
1✔
223
            $scanQuery['ExpressionAttributeNames'] = [
1✔
224
                '#id' => $this->primaryField,
1✔
225
            ];
1✔
226
            $scanQuery['ExpressionAttributeValues'] = [
1✔
227
                ':prefix' => [
1✔
228
                    'S' => $this->prefix,
1✔
229
                ],
1✔
230
            ];
1✔
231
        }
232
        $items = $this->client->scan($scanQuery);
1✔
233

234
        $handler = function (array $requestItems): array {
1✔
235
            $response = $this->client->batchWriteItem([
1✔
236
                'RequestItems' => [
1✔
237
                    $this->tableName => array_map(
1✔
238
                        fn (string $key) => [
1✔
239
                            'DeleteRequest' => [
1✔
240
                                'Key' => [
1✔
241
                                    $this->primaryField => ['S' => $key],
1✔
242
                                ],
1✔
243
                            ],
1✔
244
                        ],
1✔
245
                        $requestItems
1✔
246
                    ),
1✔
247
                ],
1✔
248
            ]);
1✔
249

250
            return $response->getUnprocessedItems();
1✔
251
        };
1✔
252

253
        /** @var array<string, WriteRequest[]> $unprocessed */
254
        $unprocessed = [];
1✔
255
        /** @var array<string> $requestItems */
256
        $requestItems = [];
1✔
257
        foreach ($items as $item) {
1✔
258
            if (count($requestItems) >= 25) {
1✔
259
                $unprocessed = array_merge($unprocessed, $handler($requestItems));
×
UNCOV
260
                $requestItems = [];
×
261
            }
262

263
            $requestItems[] = $item[$this->primaryField]->getS();
1✔
264
        }
265

266
        if (count($requestItems) > 0) {
1✔
267
            $unprocessed = array_merge($unprocessed, $handler($requestItems));
1✔
268
        }
269

270
        return count($unprocessed) === 0;
1✔
271
    }
272

273
    /**
274
     * @throws HttpException
275
     * @throws InvalidArgumentException
276
     */
277
    public function deleteItem(string|DynamoCacheItem $key): bool
278
    {
279
        if ($key instanceof DynamoCacheItem) {
9✔
280
            $key = $key->getKey();
2✔
281
        } else {
282
            $key = $this->getKey($key);
9✔
283
        }
284

285
        if ($exception = $this->getExceptionForInvalidKey($key)) {
9✔
286
            throw $exception;
1✔
287
        }
288

289
        try {
290
            $item = $this->getRawItem($key);
8✔
291
        } catch (CacheItemNotFoundException $e) {
3✔
292
            return false;
1✔
293
        }
294

295
        if (!isset($item[$this->valueField])) {
5✔
296
            return false;
5✔
297
        }
298

299
        return $this->client->deleteItem([
5✔
300
            'Key' => [
5✔
301
                $this->primaryField => [
5✔
302
                    'S' => $key,
5✔
303
                ],
5✔
304
            ],
5✔
305
            'TableName' => $this->tableName,
5✔
306
        ])->resolve();
5✔
307
    }
308

309
    /**
310
     * @param string[] $keys
311
     *
312
     * @throws HttpException
313
     * @throws InvalidArgumentException
314
     */
315
    public function deleteItems(array $keys): bool
316
    {
317
        $keys = array_map(function ($key) {
7✔
318
            if ($exception = $this->getExceptionForInvalidKey($this->getKey($key))) {
7✔
319
                throw $exception;
1✔
320
            }
321

322
            return $this->getKey($key);
6✔
323
        }, $keys);
7✔
324

325
        return $this->client->batchWriteItem([
6✔
326
            'RequestItems' => [
6✔
327
                $this->tableName => array_map(function ($key) {
6✔
328
                    return [
6✔
329
                        'DeleteRequest' => [
6✔
330
                            'Key' => [
6✔
331
                                $this->primaryField => [
6✔
332
                                    'S' => $key,
6✔
333
                                ],
6✔
334
                            ],
6✔
335
                        ],
6✔
336
                    ];
6✔
337
                }, $keys),
6✔
338
            ],
6✔
339
        ])->resolve();
6✔
340
    }
341

342
    /**
343
     * @throws InvalidArgumentException
344
     */
345
    public function save(CacheItemInterface $item): bool
346
    {
347
        $item = $this->converter->convert($item);
10✔
348
        if ($exception = $this->getExceptionForInvalidKey($item->getKey())) {
10✔
349
            throw $exception;
1✔
350
        }
351

352
        try {
353
            $data = [
9✔
354
                'Item' => [
9✔
355
                    $this->primaryField => [
9✔
356
                        'S' => $item->getKey(),
9✔
357
                    ],
9✔
358
                    $this->valueField => [
9✔
359
                        'S' => $item->getRaw(),
9✔
360
                    ],
9✔
361
                ],
9✔
362
                'TableName' => $this->tableName,
9✔
363
            ];
9✔
364

365
            if ($expiresAt = $item->getExpiresAt()) {
9✔
366
                $data['Item'][$this->ttlField]['N'] = (string) $expiresAt->getTimestamp();
8✔
367
            }
368

369
            $this->client->putItem($data);
9✔
370

371
            return true;
9✔
372
            // @codeCoverageIgnoreStart
373
        } catch (ClientException $e) {
374
            return false;
375
            // @codeCoverageIgnoreEnd
376
        }
377
    }
378

379
    /**
380
     * @throws InvalidArgumentException
381
     */
382
    public function saveDeferred(CacheItemInterface $item): bool
383
    {
384
        if ($exception = $this->getExceptionForInvalidKey($item->getKey())) {
6✔
385
            throw $exception;
1✔
386
        }
387
        $item = $this->converter->convert($item);
5✔
388

389
        $this->deferred[] = $item;
5✔
390

391
        return true;
5✔
392
    }
393

394
    /**
395
     * @throws InvalidArgumentException
396
     */
397
    public function commit(): bool
398
    {
399
        $result = true;
4✔
400
        foreach ($this->deferred as $key => $item) {
4✔
401
            $itemResult = $this->save($item);
4✔
402
            $result = $itemResult && $result;
4✔
403

404
            if ($itemResult) {
4✔
405
                unset($this->deferred[$key]);
4✔
406
            }
407
        }
408

409
        return $result;
4✔
410
    }
411

412
    /**
413
     * @param string $key
414
     * @param mixed  $default
415
     *
416
     * @throws InvalidArgumentException
417
     */
418
    public function get($key, $default = null): mixed
419
    {
420
        $item = $this->getItem($key);
3✔
421
        if (!$item->isHit()) {
2✔
422
            return $default;
2✔
423
        }
424

425
        return $item->get();
2✔
426
    }
427

428
    /**
429
     * @param string                $key
430
     * @param mixed                 $value
431
     * @param int|DateInterval|null $ttl
432
     *
433
     * @throws InvalidArgumentException
434
     */
435
    public function set($key, $value, $ttl = null): bool
436
    {
437
        $item = $this->getItem($key);
3✔
438
        if ($ttl !== null) {
2✔
439
            $item->expiresAfter($ttl);
2✔
440
        }
441
        $item->set($value);
2✔
442

443
        return $this->save($item);
2✔
444
    }
445

446
    /**
447
     * @param string $key
448
     *
449
     * @throws HttpException
450
     * @throws InvalidArgumentException
451
     */
452
    public function delete($key): bool
453
    {
454
        return $this->deleteItem($key);
3✔
455
    }
456

457
    /**
458
     * @param iterable<int, string> $keys
459
     * @param mixed                 $default
460
     *
461
     * @throws InvalidArgumentException
462
     *
463
     * @return mixed[]
464
     */
465
    public function getMultiple($keys, $default = null): iterable
466
    {
467
        $result = array_combine(
4✔
468
            $this->iterableToArray($keys),
4✔
469
            array_map(function (DynamoCacheItem $item) use ($default) {
4✔
470
                if ($item->isHit()) {
3✔
471
                    return $item->get();
3✔
472
                }
473

474
                return $default;
2✔
475
            }, $this->iterableToArray($this->getItems($this->iterableToArray($keys))))
4✔
476
        );
4✔
477
        assert(is_array($result));
478

479
        return $result;
3✔
480
    }
481

482
    /**
483
     * @param iterable<string,mixed> $values
484
     * @param int|DateInterval|null  $ttl
485
     *
486
     * @throws InvalidArgumentException
487
     */
488
    public function setMultiple($values, $ttl = null): bool
489
    {
490
        foreach ($values as $key => $value) {
4✔
491
            $item = $this->getItem($key);
4✔
492
            $item->set($value);
3✔
493
            if ($ttl !== null) {
3✔
494
                $item->expiresAfter($ttl);
2✔
495
            }
496
            $this->saveDeferred($item);
3✔
497
        }
498

499
        return $this->commit();
3✔
500
    }
501

502
    /**
503
     * @param iterable<int, string> $keys
504
     *
505
     * @throws HttpException
506
     * @throws InvalidArgumentException
507
     */
508
    public function deleteMultiple($keys): bool
509
    {
510
        return $this->deleteItems($this->iterableToArray($keys));
4✔
511
    }
512

513
    /**
514
     * @param string $key
515
     *
516
     * @throws InvalidArgumentException
517
     */
518
    public function has($key): bool
519
    {
520
        return $this->hasItem($key);
3✔
521
    }
522

523
    private function getExceptionForInvalidKey(string $key): ?InvalidArgumentException
524
    {
525
        if (strpbrk($key, self::RESERVED_CHARACTERS) !== false) {
44✔
526
            return new InvalidArgumentException(
1✔
527
                sprintf(
1✔
528
                    "The key '%s' cannot contain any of the reserved characters: '%s'",
1✔
529
                    $key,
1✔
530
                    self::RESERVED_CHARACTERS
1✔
531
                )
1✔
532
            );
1✔
533
        }
534

535
        return null;
43✔
536
    }
537

538
    /**
539
     * @template TKey of int|string
540
     * @template TValue
541
     *
542
     * @param iterable<TKey, TValue> $iterable
543
     *
544
     * @return array<TKey, TValue>
545
     */
546
    private function iterableToArray(iterable $iterable): array
547
    {
548
        if (is_array($iterable)) {
6✔
549
            return $iterable;
6✔
550
        } else {
551
            /** @noinspection PhpParamsInspection */
552
            return iterator_to_array($iterable);
1✔
553
        }
554
    }
555

556
    private function getKey(string $key): string
557
    {
558
        if ($this->prefix !== null) {
44✔
559
            return $this->prefix . $key;
14✔
560
        }
561

562
        return $key;
31✔
563
    }
564

565
    /**
566
     * @return array<string, AttributeValue>
567
     */
568
    private function getRawItem(string $key): array
569
    {
570
        $input = [
33✔
571
            'Key' => [
33✔
572
                $this->primaryField => [
33✔
573
                    'S' => $key,
33✔
574
                ],
33✔
575
            ],
33✔
576
            'TableName' => $this->tableName,
33✔
577
        ];
33✔
578

579
        try {
580
            $item = $this->client->getItem($input);
33✔
581

582
            return $item->getItem();
26✔
583
        } catch (NetworkException $e) {
7✔
584
            switch ($this->networkErrorMode) {
7✔
585
                case NetworkErrorMode::IGNORE:
586
                    throw new CacheItemNotFoundException('', 0, $e);
2✔
587
                case NetworkErrorMode::THROW:
588
                    throw $e;
2✔
589
                case NetworkErrorMode::WARNING:
590
                    trigger_error("Network error when connecting to DynamoDB: {$e->getMessage()}", E_USER_WARNING);
3✔
591
                    break; // @codeCoverageIgnore
592
            }
593

594
            throw new LogicException("This exception shouldn't happen because invalid network mode should be handled in constructor"); // @codeCoverageIgnore
595
        }
596
    }
597

598
    private function generateCompliantKey(string $key): string
599
    {
600
        $key = $this->getKey($key);
1✔
601
        $suffix = '_trunc_' . md5($key);
1✔
602

603
        return substr(
1✔
604
            $this->getKey($key),
1✔
605
            0,
1✔
606
            self::MAX_KEY_LENGTH - strlen($suffix)
1✔
607
        ) . $suffix;
1✔
608
    }
609
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc