* @copyright Copyright (c) 2014, Matthias Mullie. All rights reserved * @license LICENSE MIT */ abstract class SQL implements KeyValueStore { /** * @var \PDO */ protected $client; /** * @var string */ protected $table; /** * Create the database/indices if it does not already exist. */ abstract protected function init(); /** * @param string $table */ public function __construct(\PDO $client, $table = 'cache') { $this->client = $client; $this->table = $table; // don't throw exceptions - it's ok to fail, as long as the return value // reflects that! $this->client->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT); // make sure the database exists (or just "fail" silently) $this->init(); // now's a great time to clean up all expired items $this->clearExpired(); } /** * {@inheritdoc} */ public function get($key, &$token = null) { $statement = $this->client->prepare( "SELECT v FROM $this->table WHERE k = :key AND (e IS NULL OR e > :expire)" ); $statement->execute(array( ':key' => $key, ':expire' => date('Y-m-d H:i:s'), // right now! )); $result = $statement->fetch(\PDO::FETCH_ASSOC); if (!isset($result['v'])) { $token = null; return false; } $token = $result['v']; return $this->unserialize($result['v']); } /** * {@inheritdoc} */ public function getMulti(array $keys, array &$tokens = null) { $tokens = array(); if (empty($keys)) { return array(); } // escape input, can't bind multiple params for IN() $quoted = array(); foreach ($keys as $key) { $quoted[] = $this->client->quote($key); } $statement = $this->client->prepare( "SELECT k, v FROM $this->table WHERE k IN (".implode(',', $quoted).') AND (e IS NULL OR e > :expire)' ); $statement->execute(array(':expire' => date('Y-m-d H:i:s'))); $values = $statement->fetchAll(\PDO::FETCH_ASSOC); $result = array(); $tokens = array(); foreach ($values as $value) { $tokens[$value['k']] = $value['v']; $result[$value['k']] = $this->unserialize($value['v']); } return $result; } /** * {@inheritdoc} */ public function set($key, $value, $expire = 0) { // PostgreSQL doesn't have a decent UPSERT (like REPLACE or even INSERT // ... ON DUPLICATE KEY UPDATE ...); here's a "works for all" downgrade $success = $this->add($key, $value, $expire); if ($success) { return true; } $success = $this->replace($key, $value, $expire); if ($success) { return true; } return false; } /** * {@inheritdoc} */ public function setMulti(array $items, $expire = 0) { $success = array(); // PostgreSQL's lack of a decent UPSERT is even worse for multiple // values - we can only do them one at a time... foreach ($items as $key => $value) { $success[$key] = $this->set($key, $value, $expire); } return $success; } /** * {@inheritdoc} */ public function delete($key) { $statement = $this->client->prepare( "DELETE FROM $this->table WHERE k = :key" ); $statement->execute(array(':key' => $key)); return 1 === $statement->rowCount(); } /** * {@inheritdoc} */ public function deleteMulti(array $keys) { if (empty($keys)) { return array(); } // we'll need these to figure out which could not be deleted... $items = $this->getMulti($keys); // escape input, can't bind multiple params for IN() $quoted = array(); foreach ($keys as $key) { $quoted[] = $this->client->quote($key); } $statement = $this->client->query( "DELETE FROM $this->table WHERE k IN (".implode(',', $quoted).')' ); /* * In case of connection problems, we may not have been able to delete * any. Otherwise, we'll use the getMulti() results to figure out which * couldn't be deleted because they didn't exist at that time. */ $success = 0 !== $statement->rowCount(); $success = array_fill_keys($keys, $success); foreach ($keys as $key) { if (!array_key_exists($key, $items)) { $success[$key] = false; } } return $success; } /** * {@inheritdoc} */ public function add($key, $value, $expire = 0) { $value = $this->serialize($value); $expire = $this->expire($expire); $this->clearExpired(); $statement = $this->client->prepare( "INSERT INTO $this->table (k, v, e) VALUES (:key, :value, :expire)" ); $statement->execute(array( ':key' => $key, ':value' => $value, ':expire' => $expire, )); return 1 === $statement->rowCount(); } /** * {@inheritdoc} */ public function replace($key, $value, $expire = 0) { $value = $this->serialize($value); $expire = $this->expire($expire); $this->clearExpired(); $statement = $this->client->prepare( "UPDATE $this->table SET v = :value, e = :expire WHERE k = :key" ); $statement->execute(array( ':key' => $key, ':value' => $value, ':expire' => $expire, )); if (1 === $statement->rowCount()) { return true; } // if the value we've just replaced was the same as the replacement, as // well as the same expiration time, rowCount will have been 0, but the // operation was still a success $statement = $this->client->prepare( "SELECT e FROM $this->table WHERE k = :key AND v = :value" ); $statement->execute(array( ':key' => $key, ':value' => $value, )); return $statement->fetchColumn(0) === $expire; } /** * {@inheritdoc} */ public function cas($token, $key, $value, $expire = 0) { $value = $this->serialize($value); $expire = $this->expire($expire); $this->clearExpired(); $statement = $this->client->prepare( "UPDATE $this->table SET v = :value, e = :expire WHERE k = :key AND v = :token" ); $statement->execute(array( ':key' => $key, ':value' => $value, ':expire' => $expire, ':token' => $token, )); if (1 === $statement->rowCount()) { return true; } // if the value we've just cas'ed was the same as the replacement, as // well as the same expiration time, rowCount will have been 0, but the // operation was still a success $statement = $this->client->prepare( "SELECT e FROM $this->table WHERE k = :key AND v = :value AND v = :token" ); $statement->execute(array( ':key' => $key, ':value' => $value, ':token' => $token, )); return $statement->fetchColumn(0) === $expire; } /** * {@inheritdoc} */ public function increment($key, $offset = 1, $initial = 0, $expire = 0) { if ($offset <= 0 || $initial < 0) { return false; } return $this->doIncrement($key, $offset, $initial, $expire); } /** * {@inheritdoc} */ public function decrement($key, $offset = 1, $initial = 0, $expire = 0) { if ($offset <= 0 || $initial < 0) { return false; } return $this->doIncrement($key, -$offset, $initial, $expire); } /** * {@inheritdoc} */ public function touch($key, $expire) { $expire = $this->expire($expire); $this->clearExpired(); $statement = $this->client->prepare( "UPDATE $this->table SET e = :expire WHERE k = :key" ); $statement->execute(array( ':key' => $key, ':expire' => $expire, )); return 1 === $statement->rowCount(); } /** * {@inheritdoc} */ public function flush() { // TRUNCATE doesn't work on SQLite - DELETE works for all return false !== $this->client->exec("DELETE FROM $this->table"); } /** * {@inheritdoc} */ public function getCollection($name) { return new Collection($this, $this->client, $this->table, $name); } /** * Shared between increment/decrement: both have mostly the same logic * (decrement just increments a negative value), but need their validation * & use of non-ttl native methods split up. * * @param string $key * @param int $offset * @param int $initial * @param int $expire * * @return int|bool */ protected function doIncrement($key, $offset, $initial, $expire) { /* * I used to have all this logic in a huge & ugly query, but getting * that right on multiple SQL engines proved challenging (SQLite doesn't * do INSERT ... ON DUPLICATE KEY UPDATE ..., for example) * I'll just stuff it in a transaction & leverage existing methods. */ $this->client->beginTransaction(); $this->clearExpired(); $value = $this->get($key); if (false === $value) { $return = $this->add($key, $initial, $expire); if ($return) { $this->client->commit(); return $initial; } } elseif (is_numeric($value)) { $value += $offset; // < 0 is never possible $value = max(0, $value); $return = $this->replace($key, $value, $expire); if ($return) { $this->client->commit(); return (int) $value; } } $this->client->rollBack(); return false; } /** * Expired entries shouldn't keep filling up the database. Additionally, * we will want to remove those in order to properly rely on INSERT (for * add) and UPDATE (for replace), which assume a column exists or not, not * taking the expiration status into consideration. * An expired column should simply not exist. */ protected function clearExpired() { $statement = $this->client->prepare( "DELETE FROM $this->table WHERE e < :expire" ); $statement->execute(array(':expire' => date('Y-m-d H:i:s'))); } /** * Transforms expiration times into TIMESTAMP (Y-m-d H:i:s) format, which DB * will understand and be able to compare with other dates. * * @param int $expire * * @return string|null */ protected function expire($expire) { if (0 === $expire) { return; } // relative time in seconds, <30 days if ($expire < 30 * 24 * 60 * 60) { $expire += time(); } return date('Y-m-d H:i:s', $expire); } /** * I originally didn't want to serialize numeric values because I planned * on incrementing them in the DB, but revisited that idea. * However, not serializing numbers still causes some small DB storage gains * and it's safe (serialized data can never be confused for an int). * * @param mixed $value * * @return string|int */ protected function serialize($value) { return is_int($value) || is_float($value) ? $value : serialize($value); } /** * Numbers aren't serialized for storage size purposes. * * @param mixed $value * * @return mixed|int|float */ protected function unserialize($value) { if (is_numeric($value)) { $int = (int) $value; if ((string) $int === $value) { return $int; } $float = (float) $value; if ((string) $float === $value) { return $float; } return $value; } return unserialize($value); } }