diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml index 96b7a616..2f65797e 100644 --- a/.github/workflows/database.yml +++ b/.github/workflows/database.yml @@ -6,6 +6,8 @@ on: - ".github/workflows/database.yml" - "src/System/Database/**" - "tests/DataBase/**" + - "src/System/Cache/Storage/PdoStorage.php" + - "tests/Cache/Storage/PdoStorageRealConnectionTest.php" branches: - master pull_request: @@ -13,6 +15,8 @@ on: - ".github/workflows/database.yml" - "src/System/Database/**" - "tests/DataBase/**" + - "src/System/Cache/Storage/PdoStorage.php" + - "tests/Cache/Storage/PdoStorageRealConnectionTest.php" jobs: mysql_57: @@ -53,7 +57,7 @@ jobs: command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress - name: Execute tests - run: vendor/bin/phpunit tests/DataBase --exclude-group not-for-mysql5.7 + run: vendor/bin/phpunit --group database --exclude-group not-for-mysql5.7 env: DB_CONNECTION: mysql DB_USERNAME: root @@ -96,7 +100,7 @@ jobs: command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress - name: Execute tests - run: vendor/bin/phpunit tests/DataBase + run: vendor/bin/phpunit --group database env: DB_CONNECTION: mysql DB_USERNAME: root @@ -139,6 +143,6 @@ jobs: command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress - name: Execute tests - run: vendor/bin/phpunit tests/DataBase + run: vendor/bin/phpunit --group database env: DB_CONNECTION: mariadb diff --git a/composer.json b/composer.json index aa5f1ac8..40b6b1bf 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "rector/rector": "^1.2" }, "suggest": { - "ext-redis": "Required to use the phpredis connector.", + "ext-pdo": "Required to use the DataBase component.", + "ext-redis": "Required to use the Redis component.", "ext-apcu": "Required to use the APCu cache driver." }, "autoload": { diff --git a/src/System/Cache/Storage/PdoStorage.php b/src/System/Cache/Storage/PdoStorage.php new file mode 100644 index 00000000..1fd2a817 --- /dev/null +++ b/src/System/Cache/Storage/PdoStorage.php @@ -0,0 +1,214 @@ +pdo instanceof $class)) { + throw new \InvalidArgumentException('The pdo must be an instance of \PDO.'); + } + } + + public function get(string $key, mixed $default = null): mixed + { + $table = $this->quoteIdentifier($this->table); + $key_column = $this->quoteIdentifier('key'); + + $stmt = $this->pdo->prepare("SELECT value, expiration FROM {$table} WHERE {$key_column} = :key"); + $stmt->execute(['key' => $key]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (false === $row) { + return $default; + } + + if (time() >= (int) $row['expiration']) { + $this->delete($key); + + return $default; + } + + return unserialize($row['value']); + } + + public function set(string $key, mixed $value, int|\DateInterval|null $ttl = null): bool + { + $expiration = $this->calculateExpirationTimestamp($ttl); + $serialized = serialize($value); + + $table = $this->quoteIdentifier($this->table); + $key_column = $this->quoteIdentifier('key'); + + $stmt = $this->pdo->prepare("DELETE FROM {$table} WHERE {$key_column} = :key"); + $stmt->execute(['key' => $key]); + + $value_column = $this->quoteIdentifier('value'); + $exp_column = $this->quoteIdentifier('expiration'); + + $stmt = $this->pdo->prepare("INSERT INTO {$table} ({$key_column}, {$value_column}, {$exp_column}) VALUES (:key, :value, :expiration)"); + + return $stmt->execute([ + 'key' => $key, + 'value' => $serialized, + 'expiration' => $expiration, + ]); + } + + public function delete(string $key): bool + { + $table = $this->quoteIdentifier($this->table); + $key_column = $this->quoteIdentifier('key'); + + $stmt = $this->pdo->prepare("DELETE FROM {$table} WHERE {$key_column} = :key"); + + return $stmt->execute(['key' => $key]); + } + + public function clear(): bool + { + $table = $this->quoteIdentifier($this->table); + $stmt = $this->pdo->prepare("DELETE FROM {$table}"); + + return $stmt->execute(); + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $result = []; + foreach ($keys as $key) { + $result[$key] = $this->get($key, $default); + } + + return $result; + } + + public function setMultiple(iterable $values, int|\DateInterval|null $ttl = null): bool + { + $success = true; + foreach ($values as $key => $value) { + if (false === $this->set($key, $value, $ttl)) { + $success = false; + } + } + + return $success; + } + + public function deleteMultiple(iterable $keys): bool + { + $success = true; + foreach ($keys as $key) { + if (false === $this->delete($key)) { + $success = false; + } + } + + return $success; + } + + public function has(string $key): bool + { + $table = $this->quoteIdentifier($this->table); + $key_column = $this->quoteIdentifier('key'); + $exp_column = $this->quoteIdentifier('expiration'); + + $stmt = $this->pdo->prepare("SELECT 1 FROM {$table} WHERE {$key_column} = :key AND {$exp_column} > :now"); + $stmt->execute([ + 'key' => $key, + 'now' => time(), + ]); + + return false !== $stmt->fetchColumn(); + } + + public function increment(string $key, int $value): int + { + $table = $this->quoteIdentifier($this->table); + $key_column = $this->quoteIdentifier('key'); + + $stmt = $this->pdo->prepare("SELECT value, expiration FROM {$table} WHERE {$key_column} = :key"); + $stmt->execute(['key' => $key]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (false === $row || time() >= (int) $row['expiration']) { + $this->set($key, $value); + + return $value; + } + + $current = unserialize($row['value']); + $expiration = (int) $row['expiration']; + + if (false === is_int($current)) { + throw new \InvalidArgumentException('Value increment must be integer.'); + } + + $new = $current + $value; + $ttl = $expiration - time(); + + $this->set($key, $new, max(0, $ttl)); + + return $new; + } + + public function decrement(string $key, int $value): int + { + return $this->increment($key, $value * -1); + } + + public function remember(string $key, int|\DateInterval|null $ttl, \Closure $callback): mixed + { + $value = $this->get($key); + + if (null !== $value) { + return $value; + } + + $this->set($key, $value = $callback(), $ttl); + + return $value; + } + + private function quoteIdentifier(string $identifier): string + { + $driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + + return match ($driver) { + 'mysql', 'mariadb' => "`{$identifier}`", + default => "\"{$identifier}\"", + }; + } + + private function calculateExpirationTimestamp(int|\DateInterval|\DateTimeInterface|null $ttl): int + { + if ($ttl instanceof \DateInterval) { + return (new \DateTimeImmutable())->add($ttl)->getTimestamp(); + } + + if ($ttl instanceof \DateTimeInterface) { + return $ttl->getTimestamp(); + } + + $ttl ??= $this->defaultTTL; + + return time() + $ttl; + } +} diff --git a/src/System/Cache/composer.json b/src/System/Cache/composer.json index 95c4459b..c76c99af 100644 --- a/src/System/Cache/composer.json +++ b/src/System/Cache/composer.json @@ -17,6 +17,9 @@ "suggest": { "ext-apcu": "Required to use the APCu cache driver." }, + "suggest": { + "ext-pdo": "Required to use the PDO cache driver." + }, "autoload": { "psr-4": { "System\\Cache\\": "" diff --git a/tests/Cache/Storage/PdoStorageRealConnectionTest.php b/tests/Cache/Storage/PdoStorageRealConnectionTest.php new file mode 100644 index 00000000..a3b45bb8 --- /dev/null +++ b/tests/Cache/Storage/PdoStorageRealConnectionTest.php @@ -0,0 +1,136 @@ +driver = $_ENV['DB_CONNECTION'] ?? 'mysql'; + $host = $_ENV['DB_HOST'] ?? '127.0.0.1'; + $db = $_ENV['DB_DATABASE'] ?? 'forge'; + $user = $_ENV['DB_USERNAME'] ?? 'root'; + $pass = $_ENV['DB_PASSWORD'] ?? ''; + $port = $_ENV['DB_PORT'] ?? '3306'; + + try { + $dsn = "{$this->driver}:host={$host};port={$port};dbname={$db};charset=utf8mb4"; + $this->pdo = new \PDO($dsn, $user, $pass, [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + ]); + } catch (\PDOException $e) { + $this->markTestSkipped('Database connection failed: ' . $e->getMessage()); + } + + $this->createCacheTable(); + $this->storage = new PdoStorage($this->pdo, 'cache', 60); + } + + protected function tearDown(): void + { + if (isset($this->pdo)) { + $this->dropCacheTable(); + } + } + + private function createCacheTable(): void + { + $quote = match ($this->driver) { + 'mysql', 'mariadb' => '`', + default => '"', + }; + + $this->pdo->exec(" + CREATE TABLE IF NOT EXISTS cache ( + {$quote}key{$quote} VARCHAR(255) PRIMARY KEY, + {$quote}value{$quote} TEXT, + {$quote}expiration{$quote} INT + ) + "); + } + + private function dropCacheTable(): void + { + $this->pdo->exec('DROP TABLE IF EXISTS cache'); + } + + /** + * @test + * + * @testdox it can get and set cache on real connection + * + * @covers \System\Cache\Storage\PdoStorage::get + * @covers \System\Cache\Storage\PdoStorage::set + */ + public function itCanGetAndSetCacheOnRealConnection() + { + $this->assertTrue($this->storage->set('real_key', ['complex' => 'data', 'number' => 123])); + $result = $this->storage->get('real_key'); + + $this->assertIsArray($result); + $this->assertEquals('data', $result['complex']); + $this->assertEquals(123, $result['number']); + } + + /** + * @test + * + * @testdox it should handle cache expiration on real connection + * + * @covers \System\Cache\Storage\PdoStorage::get + */ + public function itShouldHandleCacheExpirationOnRealConnection() + { + $this->storage->set('expired_soon', 'bye', 1); + $this->assertEquals('bye', $this->storage->get('expired_soon')); + + // Wait for expiration + sleep(2); + + $this->assertNull($this->storage->get('expired_soon')); + } + + /** + * @test + * + * @testdox it can increment cache on real connection + * + * @covers \System\Cache\Storage\PdoStorage::increment + */ + public function itCanIncrementCacheOnRealConnection() + { + $this->storage->set('counter', 10, 10); + $this->assertEquals(15, $this->storage->increment('counter', 5)); + $this->assertEquals(15, $this->storage->get('counter')); + } + + /** + * @test + * + * @testdox it can clear cache on real connection + * + * @covers \System\Cache\Storage\PdoStorage::clear + */ + public function itCanClearCacheOnRealConnection() + { + $this->storage->set('a', 1); + $this->storage->set('b', 2); + $this->assertTrue($this->storage->clear()); + $this->assertFalse($this->storage->has('a')); + $this->assertFalse($this->storage->has('b')); + } +} diff --git a/tests/Cache/Storage/PdoStorageTest.php b/tests/Cache/Storage/PdoStorageTest.php new file mode 100644 index 00000000..a7ee8d60 --- /dev/null +++ b/tests/Cache/Storage/PdoStorageTest.php @@ -0,0 +1,170 @@ +pdo = new \PDO('sqlite::memory:'); + $this->pdo->exec(' + CREATE TABLE cache ( + "key" VARCHAR(255) PRIMARY KEY, + "value" TEXT, + "expiration" INT + ) + '); + + $this->storage = new PdoStorage($this->pdo, 'cache', 60); + } + + /** + * @test + * + * @testdox it can get and set cache + * + * @covers \System\Cache\Storage\PdoStorage::get + * @covers \System\Cache\Storage\PdoStorage::set + * @covers \System\Cache\Storage\PdoStorage::quoteIdentifier + */ + public function itCanGetAndSetCache() + { + $this->assertTrue($this->storage->set('foo', 'bar')); + $this->assertEquals('bar', $this->storage->get('foo')); + } + + /** + * @test + * + * @testdox it should return null if cache expired + * + * @covers \System\Cache\Storage\PdoStorage::get + */ + public function itShouldReturnNullIfCacheExpired() + { + $this->storage->set('foo', 'bar', -1); + $this->assertNull($this->storage->get('foo')); + } + + /** + * @test + * + * @testdox it can delete cache + * + * @covers \System\Cache\Storage\PdoStorage::delete + */ + public function itCanDeleteCache() + { + $this->storage->set('foo', 'bar'); + $this->assertTrue($this->storage->delete('foo')); + $this->assertNull($this->storage->get('foo')); + } + + /** + * @test + * + * @testdox it can clear all cache + * + * @covers \System\Cache\Storage\PdoStorage::clear + */ + public function itCanClearAllCache() + { + $this->storage->set('foo', 'bar'); + $this->storage->set('baz', 'qux'); + $this->assertTrue($this->storage->clear()); + $this->assertNull($this->storage->get('foo')); + $this->assertNull($this->storage->get('baz')); + } + + /** + * @test + * + * @testdox it can check if cache exists + * + * @covers \System\Cache\Storage\PdoStorage::has + */ + public function itCanCheckIfCacheExists() + { + $this->storage->set('foo', 'bar'); + $this->assertTrue($this->storage->has('foo')); + $this->assertFalse($this->storage->has('not_found')); + } + + /** + * @test + * + * @testdox it can increment cache value + * + * @covers \System\Cache\Storage\PdoStorage::increment + */ + public function itCanIncrementCacheValue() + { + $this->storage->set('num', 1); + $this->assertEquals(2, $this->storage->increment('num', 1)); + $this->assertEquals(2, $this->storage->get('num')); + } + + /** + * @test + * + * @testdox it can decrement cache value + * + * @covers \System\Cache\Storage\PdoStorage::decrement + */ + public function itCanDecrementCacheValue() + { + $this->storage->set('num', 10); + $this->assertEquals(7, $this->storage->decrement('num', 3)); + $this->assertEquals(7, $this->storage->get('num')); + } + + /** + * @test + * + * @testdox it can remember cache value + * + * @covers \System\Cache\Storage\PdoStorage::remember + */ + public function itCanRememberCacheValue() + { + $this->assertNull($this->storage->get('rem')); + $value = $this->storage->remember('rem', 60, fn () => 'remembered'); + $this->assertEquals('remembered', $value); + $this->assertEquals('remembered', $this->storage->get('rem')); + } + + /** + * @test + * + * @testdox it can handle multiple cache operations + * + * @covers \System\Cache\Storage\PdoStorage::getMultiple + * @covers \System\Cache\Storage\PdoStorage::setMultiple + * @covers \System\Cache\Storage\PdoStorage::deleteMultiple + */ + public function itCanHandleMultipleCacheOperations() + { + $values = ['a' => 1, 'b' => 2]; + $this->assertTrue($this->storage->setMultiple($values)); + + $get = $this->storage->getMultiple(['a', 'b', 'c'], 'default'); + $this->assertEquals(['a' => 1, 'b' => 2, 'c' => 'default'], (array) $get); + + $this->assertTrue($this->storage->deleteMultiple(['a', 'b'])); + $this->assertNull($this->storage->get('a')); + $this->assertNull($this->storage->get('b')); + } +}