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

RikudouSage / DynamoDbCachePsr6 / 14580657379

21 Apr 2025 08:17PM UTC coverage: 99.455% (-0.5%) from 100.0%
14580657379

push

github

web-flow
Feat: Add support for batch deleting of items (#33)

39 of 41 new or added lines in 1 file covered. (95.12%)

365 of 367 relevant lines covered (99.46%)

11.28 hits per line

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

99.25
/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) {
57✔
58
            $clock = new Clock();
55✔
59
        }
60
        $this->clock = $clock;
57✔
61

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

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

74
        if ($prefix !== null && strlen($prefix) >= self::MAX_KEY_LENGTH) {
57✔
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())) {
57✔
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
        $keys = array_map(function ($key) {
7✔
139
            if ($exception = $this->getExceptionForInvalidKey($this->getKey($key))) {
7✔
140
                throw $exception;
1✔
141
            }
142

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

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

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

201
        return $result;
6✔
202
    }
203

204
    /**
205
     * @throws InvalidArgumentException
206
     */
207
    public function hasItem(string $key): bool
208
    {
209
        return $this->getItem($key)->isHit();
6✔
210
    }
211

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

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

246
            return $response->getUnprocessedItems();
1✔
247
        };
1✔
248

249
        /** @var array<string, WriteRequest[]> $unprocessed */
250
        $unprocessed = [];
1✔
251
        /** @var array<string> $requestItems */
252
        $requestItems = [];
1✔
253
        foreach ($items as $item) {
1✔
254
            if (count($requestItems) >= 25) {
1✔
NEW
255
                $unprocessed = array_merge($unprocessed, $handler($requestItems));
×
NEW
256
                $requestItems = [];
×
257
            }
258

259
            $requestItems[] = $item[$this->primaryField]->getS();
1✔
260
        }
261

262
        if (count($requestItems) > 0) {
1✔
263
            $unprocessed = array_merge($unprocessed, $handler($requestItems));
1✔
264
        }
265

266
        return count($unprocessed) === 0;
1✔
267
    }
268

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

281
        if ($exception = $this->getExceptionForInvalidKey($key)) {
9✔
282
            throw $exception;
1✔
283
        }
284

285
        try {
286
            $item = $this->getRawItem($key);
8✔
287
        } catch (CacheItemNotFoundException $e) {
3✔
288
            return false;
1✔
289
        }
290

291
        if (!isset($item[$this->valueField])) {
5✔
292
            return false;
5✔
293
        }
294

295
        return $this->client->deleteItem([
5✔
296
            'Key' => [
5✔
297
                $this->primaryField => [
5✔
298
                    'S' => $key,
5✔
299
                ],
5✔
300
            ],
5✔
301
            'TableName' => $this->tableName,
5✔
302
        ])->resolve();
5✔
303
    }
304

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

318
            return $this->getKey($key);
6✔
319
        }, $keys);
7✔
320

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

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

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

361
            if ($expiresAt = $item->getExpiresAt()) {
9✔
362
                $data['Item'][$this->ttlField]['N'] = (string) $expiresAt->getTimestamp();
8✔
363
            }
364

365
            $this->client->putItem($data);
9✔
366

367
            return true;
9✔
368
            // @codeCoverageIgnoreStart
369
        } catch (ClientException $e) {
370
            return false;
371
            // @codeCoverageIgnoreEnd
372
        }
373
    }
374

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

385
        $this->deferred[] = $item;
5✔
386

387
        return true;
5✔
388
    }
389

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

400
            if ($itemResult) {
4✔
401
                unset($this->deferred[$key]);
4✔
402
            }
403
        }
404

405
        return $result;
4✔
406
    }
407

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

421
        return $item->get();
2✔
422
    }
423

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

439
        return $this->save($item);
2✔
440
    }
441

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

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

470
                return $default;
2✔
471
            }, $this->iterableToArray($this->getItems($this->iterableToArray($keys))))
4✔
472
        );
4✔
473
        assert(is_array($result));
474

475
        return $result;
3✔
476
    }
477

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

495
        return $this->commit();
3✔
496
    }
497

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

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

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

531
        return null;
43✔
532
    }
533

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

552
    private function getKey(string $key): string
553
    {
554
        if ($this->prefix !== null) {
44✔
555
            return $this->prefix . $key;
14✔
556
        }
557

558
        return $key;
31✔
559
    }
560

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

575
        try {
576
            $item = $this->client->getItem($input);
33✔
577

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

590
            throw new LogicException("This exception shouldn't happen because invalid network mode should be handled in constructor"); // @codeCoverageIgnore
591
        }
592
    }
593

594
    private function generateCompliantKey(string $key): string
595
    {
596
        $key = $this->getKey($key);
1✔
597
        $suffix = '_trunc_' . md5($key);
1✔
598

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

© 2026 Coveralls, Inc