Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ 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:
paths:
- ".github/workflows/database.yml"
- "src/System/Database/**"
- "tests/DataBase/**"
- "src/System/Cache/Storage/PdoStorage.php"
- "tests/Cache/Storage/PdoStorageRealConnectionTest.php"

jobs:
mysql_57:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
214 changes: 214 additions & 0 deletions src/System/Cache/Storage/PdoStorage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php

declare(strict_types=1);

namespace System\Cache\Storage;

use System\Cache\CacheInterface;

class PdoStorage implements CacheInterface
{
/**
* @param \PDO $pdo PDO instance
*/
public function __construct(
private object $pdo,
private string $table = 'cache',
private int $defaultTTL = 3_600,
) {
/** @var class-string $class */
$class = '\PDO';
if (false === extension_loaded('pdo') || false === class_exists($class)) {
throw new \RuntimeException('The PDO extension is required to use PdoStorage.');
}

if (false === ($this->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;
}
}
3 changes: 3 additions & 0 deletions src/System/Cache/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\\": ""
Expand Down
Loading
Loading