DatabaseCache.php
PHP
Path: src/Cache/DatabaseCache.php
<?php
namespace mini\Cache;
use mini\Database\DatabaseInterface;
use mini\Mini;
use Psr\SimpleCache\CacheInterface;
/**
* Database-backed PSR-16 SimpleCache implementation
*
* Stores cache data in database with automatic garbage collection.
* Uses the 'mini_cache' table for storage.
*
* IMPORTANT: Fetches DatabaseInterface from container on each access to ensure
* proper scoping in long-running applications (cache is Singleton, db is Scoped).
*/
class DatabaseCache implements CacheInterface
{
private string $tableName = 'mini_cache';
public function __construct()
{
$this->ensureTableExists();
$this->maybeGarbageCollect();
}
/**
* Get DatabaseInterface from container (fresh per request)
*/
private function db(): DatabaseInterface
{
return Mini::$mini->get(DatabaseInterface::class);
}
/**
* Ensure the cache table exists
*/
private function ensureTableExists(): void
{
$this->db()->exec("
CREATE TABLE IF NOT EXISTS {$this->tableName} (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)
");
// Create index for efficient garbage collection
$this->db()->exec("
CREATE INDEX IF NOT EXISTS idx_mini_cache_expires_at
ON {$this->tableName} (expires_at)
");
}
/**
* Randomly trigger garbage collection (1 in 10,000 chance)
*/
private function maybeGarbageCollect(): void
{
if (mt_rand(0, 10000) === 0) {
$this->garbageCollect();
}
}
/**
* Remove expired cache entries
*/
private function garbageCollect(): void
{
$now = time();
$this->db()->exec(
"DELETE FROM {$this->tableName} WHERE expires_at IS NOT NULL AND expires_at < ?",
[$now]
);
}
/**
* Validate cache key
*/
private function validateKey(string $key): void
{
if (empty($key)) {
throw new \InvalidArgumentException('Cache key cannot be empty');
}
// PSR-16 specifies these characters are not allowed: {}()/\@
// Note: colon (:) is allowed in PSR-16
if (preg_match('/[{}()\/@\\\]/', $key)) {
throw new \InvalidArgumentException('Cache key contains invalid characters: ' . $key);
}
}
/**
* Calculate expiration timestamp from TTL
*/
private function calculateExpiration(null|int|\DateInterval $ttl): ?int
{
if ($ttl === null) {
return null; // No expiration
}
if ($ttl instanceof \DateInterval) {
$now = new \DateTime();
$expires = $now->add($ttl);
return $expires->getTimestamp();
}
if ($ttl <= 0) {
return time() - 1; // Already expired
}
return time() + $ttl;
}
public function get(string $key, mixed $default = null): mixed
{
$this->validateKey($key);
$row = $this->db()->queryOne(
"SELECT value, expires_at FROM {$this->tableName} WHERE key = ?",
[$key]
);
if (!$row) {
return $default;
}
// Check if expired
if ($row->expires_at !== null && $row->expires_at < time()) {
$this->delete($key);
return $default;
}
return unserialize($row->value);
}
public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
{
$this->validateKey($key);
$serializedValue = serialize($value);
$expiresAt = $this->calculateExpiration($ttl);
try {
$this->db()->exec(
"INSERT OR REPLACE INTO {$this->tableName} (key, value, expires_at, created_at)
VALUES (?, ?, ?, ?)",
[$key, $serializedValue, $expiresAt, time()]
);
return true;
} catch (\Exception $e) {
return false;
}
}
public function delete(string $key): bool
{
$this->validateKey($key);
try {
$this->db()->exec("DELETE FROM {$this->tableName} WHERE key = ?", [$key]);
return true;
} catch (\Exception $e) {
return false;
}
}
public function clear(): bool
{
try {
$this->db()->exec("DELETE FROM {$this->tableName}");
return true;
} catch (\Exception $e) {
return false;
}
}
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, null|int|\DateInterval $ttl = null): bool
{
$success = true;
foreach ($values as $key => $value) {
if (!$this->set($key, $value, $ttl)) {
$success = false;
}
}
return $success;
}
public function deleteMultiple(iterable $keys): bool
{
$success = true;
foreach ($keys as $key) {
if (!$this->delete($key)) {
$success = false;
}
}
return $success;
}
public function has(string $key): bool
{
$this->validateKey($key);
$row = $this->db()->queryOne(
"SELECT expires_at FROM {$this->tableName} WHERE key = ?",
[$key]
);
if (!$row) {
return false;
}
// Check if expired
if ($row->expires_at !== null && $row->expires_at < time()) {
$this->delete($key);
return false;
}
return true;
}
/**
* Get cache statistics for debugging
*/
public function getStats(): array
{
$total = $this->db()->queryField("SELECT COUNT(*) FROM {$this->tableName}");
$expired = $this->db()->queryField(
"SELECT COUNT(*) FROM {$this->tableName} WHERE expires_at IS NOT NULL AND expires_at < ?",
[time()]
);
return [
'total_entries' => $total,
'expired_entries' => $expired,
'active_entries' => $total - $expired,
];
}
/**
* Manually trigger garbage collection
*/
public function cleanup(): int
{
$before = $this->db()->queryField("SELECT COUNT(*) FROM {$this->tableName}");
$this->garbageCollect();
$after = $this->db()->queryField("SELECT COUNT(*) FROM {$this->tableName}");
return $before - $after;
}
}