diff --git a/src/Model.php b/src/Model.php index 65dc938b..5ce9ef6a 100644 --- a/src/Model.php +++ b/src/Model.php @@ -14,6 +14,7 @@ use DirectoryTree\ActiveRedis\Exceptions\DuplicateKeyException; use DirectoryTree\ActiveRedis\Exceptions\InvalidKeyException; use DirectoryTree\ActiveRedis\Exceptions\JsonEncodingException; +use DirectoryTree\ActiveRedis\Repositories\ArrayRepository; use DirectoryTree\ActiveRedis\Repositories\RedisRepository; use DirectoryTree\ActiveRedis\Repositories\Repository; use Illuminate\Contracts\Redis\Connection; @@ -26,6 +27,7 @@ use Illuminate\Support\Facades\Date; use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; +use InvalidArgumentException; use JsonException; use Stringable; @@ -84,6 +86,11 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, Stringable, Ur */ protected ?string $connection = null; + /** + * The repository for the model. + */ + protected static string $repository = 'redis'; + /** * Handle dynamic method calls into the model. */ @@ -411,12 +418,24 @@ public function newBuilder(): Query return new Query($this, $this->newRepository()); } + /** + * Set the repository for the model. + */ + public static function setRepository(string $repository): void + { + static::$repository = $repository; + } + /** * Create a new repository instance. */ protected function newRepository(): Repository { - return new RedisRepository($this->getConnection()); + return match ($repository = static::$repository) { + 'array' => new ArrayRepository, + 'redis' => new RedisRepository($this->getConnection()), + default => throw new InvalidArgumentException("Repository [{$repository}] is not supported."), + }; } /** diff --git a/src/Repositories/ArrayRepository.php b/src/Repositories/ArrayRepository.php new file mode 100644 index 00000000..0d036568 --- /dev/null +++ b/src/Repositories/ArrayRepository.php @@ -0,0 +1,148 @@ +data[$hash]); + } + + /** + * Chunk through the hashes matching the given pattern. + */ + public function chunk(string $pattern, int $count): Generator + { + $matches = []; + + foreach (array_keys($this->data) as $key) { + if (Str::is($pattern, $key)) { + $matches[] = $key; + } + } + + foreach (array_chunk($matches, $count) as $chunk) { + yield $chunk; + } + } + + /** + * Set the hash field's value. + */ + public function setAttribute(string $hash, string $attribute, string $value): void + { + $this->data[$hash][$attribute] = $value; + } + + /** + * Set the hash field's value. + */ + public function setAttributes(string $hash, array $attributes): void + { + foreach ($attributes as $attribute => $value) { + $this->setAttribute($hash, $attribute, $value); + } + } + + /** + * Get the hash field's value. + */ + public function getAttribute(string $hash, string $field): mixed + { + if ($this->isExpired($hash)) { + $this->delete($hash); + + return null; + } + + return $this->data[$hash][$field] ?? null; + } + + /** + * Get all the attributes in the hash. + */ + public function getAttributes(string $hash): array + { + if ($this->isExpired($hash)) { + $this->delete($hash); + + return []; + } + + return $this->data[$hash] ?? []; + } + + /** + * Set a time-to-live on a hash key. + * + * Not supported in ArrayRepository. + */ + public function setExpiry(string $hash, int $seconds): void + { + $this->expiry[$hash] = time() + $seconds; + } + + /** + * Get the time-to-live of a hash key. + */ + public function getExpiry(string $hash): ?int + { + if (! isset($this->expiry[$hash])) { + return null; + } + + $remaining = $this->expiry[$hash] - time(); + + return $remaining > 0 ? $remaining : null; + } + + /** + * Delete the attributes from the hash. + */ + public function deleteAttributes(string $hash, array|string $attributes): void + { + foreach ((array) $attributes as $attribute) { + unset($this->data[$hash][$attribute]); + } + } + + /** + * Delete the given hash. + */ + public function delete(string $hash): void + { + unset($this->data[$hash]); + } + + /** + * Perform a transaction. + */ + public function transaction(Closure $operation): void + { + $operation($this); + } + + /** + * Determine if the given hash has expired. + */ + protected function isExpired(string $hash): bool + { + return isset($this->expiry[$hash]) && $this->expiry[$hash] <= time(); + } +} diff --git a/src/Repositories/RedisRepository.php b/src/Repositories/RedisRepository.php index eb635c26..15917bc3 100644 --- a/src/Repositories/RedisRepository.php +++ b/src/Repositories/RedisRepository.php @@ -127,7 +127,9 @@ public function getExpiry(string $hash): ?int { // The number of seconds until the key will expire, or // null if the key does not exist or has no timeout. - return $this->redis->ttl($hash); + $ttl = $this->redis->ttl($hash); + + return $ttl > 0 ? $ttl : null; } /** diff --git a/tests/Repositories/ArrayRepositoryTest.php b/tests/Repositories/ArrayRepositoryTest.php new file mode 100644 index 00000000..bc6816af --- /dev/null +++ b/tests/Repositories/ArrayRepositoryTest.php @@ -0,0 +1,99 @@ + ['bar' => 'baz']]); + + expect($repository->exists('foo'))->toBeTrue(); + expect($repository->exists('bar'))->toBeFalse(); +}); + +it('can chunk through hashes matching a pattern', function () { + $repository = new ArrayRepository([ + 'user:1' => [], + 'user:2' => [], + 'post:1' => [], + 'user:3' => [], + ]); + + $chunks = iterator_to_array($repository->chunk('user:*', 2)); + + expect($chunks)->toHaveCount(2); + expect($chunks[0])->toEqual(['user:1', 'user:2']); + expect($chunks[1])->toEqual(['user:3']); +}); + +it('can set and get a single attribute', function () { + $repository = new ArrayRepository; + + $repository->setAttribute('foo', 'bar', 'baz'); + + expect($repository->getAttribute('foo', 'bar'))->toBe('baz'); +}); + +it('can set and get multiple attributes', function () { + $repository = new ArrayRepository; + + $repository->setAttributes('foo', ['bar' => 'baz', 'qux' => 'quux']); + + expect($repository->getAttributes('foo'))->toEqual(['bar' => 'baz', 'qux' => 'quux']); +}); + +it('can delete attributes', function () { + $repository = new ArrayRepository(['foo' => ['bar' => 'baz', 'qux' => 'quux']]); + + $repository->deleteAttributes('foo', 'bar'); + + expect($repository->getAttributes('foo'))->toEqual(['qux' => 'quux']); + + $repository->deleteAttributes('foo', ['qux']); + + expect($repository->getAttributes('foo'))->toEqual([]); +}); + +it('can delete a hash', function () { + $repository = new ArrayRepository(['foo' => ['bar' => 'baz']]); + + $repository->delete('foo'); + + expect($repository->exists('foo'))->toBeFalse(); +}); + +it('can set and get expiry', function () { + $repository = new ArrayRepository; + + $repository->setExpiry('foo', 10); + + expect($repository->getExpiry('foo'))->toBeGreaterThanOrEqual(9); +}); + +it('returns null for expiry of non-existent key', function () { + $repository = new ArrayRepository; + + expect($repository->getExpiry('foo'))->toBeNull(); +}); + +it('deletes expired hashes', function () { + $repository = new ArrayRepository; + + $repository->setAttributes('foo', ['bar' => 'baz']); + + $repository->setExpiry('foo', 1); + + sleep(2); + + expect($repository->getAttributes('foo'))->toEqual([]); +}); + +it('handles getting attributes from expired hash', function () { + $repository = new ArrayRepository; + + $repository->setAttributes('foo', ['bar' => 'baz']); + + $repository->setExpiry('foo', 1); + + sleep(2); + + expect($repository->getAttribute('foo', 'bar'))->toBeNull(); +}); diff --git a/tests/Repositories/RedisRepositoryTest.php b/tests/Repositories/RedisRepositoryTest.php new file mode 100644 index 00000000..b7d3508a --- /dev/null +++ b/tests/Repositories/RedisRepositoryTest.php @@ -0,0 +1,118 @@ + Redis::flushdb()); + +it('can check if a hash exists', function () { + $repository = new RedisRepository(Redis::connection()); + + $repository->setAttributes('foo', ['bar' => 'baz']); + + expect($repository->exists('foo'))->toBeTrue(); + expect($repository->exists('bar'))->toBeFalse(); + + $repository->delete('foo'); +}); + +it('can chunk through hashes matching a pattern', function () { + $repository = new RedisRepository(Redis::connection()); + + $repository->setAttributes('user:1', ['foo']); + $repository->setAttributes('user:2', ['foo']); + $repository->setAttributes('post:1', ['foo']); + $repository->setAttributes('user:3', ['foo']); + + $chunks = iterator_to_array($repository->chunk('user:*', 2)); + + expect($chunks)->toBeGreaterThanOrEqual(1); +}); + +it('can set and get a single attribute', function () { + $repository = new RedisRepository(Redis::connection()); + + $repository->setAttribute('foo', 'bar', 'baz'); + + expect($repository->getAttribute('foo', 'bar'))->toBe('baz'); + + $repository->delete('foo'); +}); + +it('can set and get multiple attributes', function () { + $repository = new RedisRepository(Redis::connection()); + + $repository->setAttributes('foo', ['bar' => 'baz', 'qux' => 'quux']); + + expect($repository->getAttributes('foo'))->toEqual(['bar' => 'baz', 'qux' => 'quux']); + + $repository->delete('foo'); +}); + +it('can delete attributes', function () { + $repository = new RedisRepository(Redis::connection()); + + $repository->setAttributes('foo', ['bar' => 'baz', 'qux' => 'quux']); + + $repository->deleteAttributes('foo', 'bar'); + + expect($repository->getAttributes('foo'))->toEqual(['qux' => 'quux']); + + $repository->deleteAttributes('foo', ['qux']); + + expect($repository->getAttributes('foo'))->toEqual([]); + + $repository->delete('foo'); +}); + +it('can delete a hash', function () { + $repository = new RedisRepository(Redis::connection()); + + $repository->setAttributes('foo', ['bar' => 'baz']); + + $repository->delete('foo'); + + expect($repository->exists('foo'))->toBeFalse(); +}); + +it('can set and get expiry', function () { + $repository = new RedisRepository(Redis::connection()); + + $repository->setAttributes('foo', ['bar' => 'baz']); + + $repository->setExpiry('foo', 10); + + expect($repository->getExpiry('foo'))->toBeGreaterThanOrEqual(9); + + $repository->delete('foo'); +}); + +it('returns null for expiry of non-existent key', function () { + $repository = new RedisRepository(Redis::connection()); + + expect($repository->getExpiry('foo'))->toBeNull(); +}); + +it('deletes expired hashes', function () { + $repository = new RedisRepository(Redis::connection()); + + $repository->setAttributes('foo', ['bar' => 'baz']); + + $repository->setExpiry('foo', 1); + + sleep(2); + + expect($repository->getAttributes('foo'))->toEqual([]); +}); + +it('handles getting attributes from expired hash', function () { + $repository = new RedisRepository(Redis::connection()); + + $repository->setAttributes('foo', ['bar' => 'baz']); + + $repository->setExpiry('foo', 1); + + sleep(2); + + expect($repository->getAttribute('foo', 'bar'))->toBeFalsy(); +});