diff --git a/core/lib/Drupal/Core/CoreBundle.php b/core/lib/Drupal/Core/CoreBundle.php index c1abfe5..800d0ab 100644 --- a/core/lib/Drupal/Core/CoreBundle.php +++ b/core/lib/Drupal/Core/CoreBundle.php @@ -55,6 +55,10 @@ class CoreBundle extends Bundle ->setFactoryMethod('getConnection') ->addArgument('slave'); $container->register('typed_data', 'Drupal\Core\TypedData\TypedDataManager'); + $container->register('lock', 'Drupal\Core\Lock\DatabaseLockBackend'); + $container->register('user.tempstore', 'Drupal\user\KeyValueStoreWithOwnerFactory') + ->addArgument(new Reference('database')) + ->addArgument(new Reference('lock')); $container->register('router.dumper', '\Drupal\Core\Routing\MatcherDumper') ->addArgument(new Reference('database')); diff --git a/core/lib/Drupal/Core/KeyValueStore/DatabaseStorage.php b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorage.php index 4484e9f..d36fd30 100644 --- a/core/lib/Drupal/Core/KeyValueStore/DatabaseStorage.php +++ b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorage.php @@ -7,15 +7,28 @@ namespace Drupal\Core\KeyValueStore; +use Drupal\Core\Database\Query\Merge; + /** * Defines a default key/value store implementation. * * This is Drupal's default key/value store implementation. It uses the database * to store key/value data. + * + * @todo This class still calls db_* functions directly because it's needed + * very early, pre-Container. Once the early bootstrap dependencies are + * sorted out, switch this to use an injected database connection. */ class DatabaseStorage extends StorageBase { /** + * The name of the SQL table to use. + * + * @var string + */ + protected $table; + + /** * Overrides Drupal\Core\KeyValueStore\StorageBase::__construct(). * * @param string $collection @@ -44,9 +57,9 @@ class DatabaseStorage extends StorageBase { } } catch (\Exception $e) { - // @todo: Perhaps if the database is never going to be available, - // key/value requests should return FALSE in order to allow exception - // handling to occur but for now, keep it an array, always. + // @todo Perhaps if the database is never going to be available, + // key/value requests should return FALSE in order to allow exception + // handling to occur but for now, keep it an array, always. } return $values; } @@ -80,6 +93,23 @@ class DatabaseStorage extends StorageBase { } /** + * Implements Drupal\Core\KeyValueStore\KeyValueStoreInterface::setIfNotExists(). + */ + public function setIfNotExists($key, $value) { + $result = db_merge($this->table) + ->insertFields(array( + 'collection' => $this->collection, + 'name' => $key, + 'value' => $value, + )) + ->condition('collection', $this->collection) + ->condition('name', $key) + ->condition('value', $value) + ->execute(); + return $result == Merge::STATUS_INSERT; + } + + /** * Implements Drupal\Core\KeyValueStore\KeyValueStoreInterface::deleteMultiple(). */ public function deleteMultiple(array $keys) { diff --git a/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php new file mode 100644 index 0000000..0082f75 --- /dev/null +++ b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageExpirable.php @@ -0,0 +1,145 @@ +connection = $options['connection']; + $this->table = isset($options['table']) ? $options['table'] : 'key_value_expire'; + } + + /** + * Implements Drupal\Core\KeyValueStore\KeyValueStoreInterface::getMultiple(). + */ + public function getMultiple(array $keys) { + $values = array(); + try { + $result = $this->connection->query('SELECT name, value, expire FROM {' . $this->connection->escapeTable($this->table) . '} WHERE expire > :now AND name IN (:keys) AND collection = :collection', + array( + ':now' => REQUEST_TIME, + ':keys' => $keys, + ':collection' => $this->collection, + ))->fetchAllAssoc('name'); + foreach ($keys as $key) { + if (isset($result[$key])) { + $values[$key] = unserialize($result[$key]->value); + } + } + } + catch (\Exception $e) { + // @todo Perhaps if the database is never going to be available, + // key/value requests should return FALSE in order to allow exception + // handling to occur but for now, keep it an array, always. + } + return $values; + } + + /** + * Implements Drupal\Core\KeyValueStore\KeyValueStoreInterface::getAll(). + */ + public function getAll() { + $result = $this->connection->query('SELECT name, value FROM {' . $this->connection->escapeTable($this->table) . '} WHERE collection = :collection AND expire > :now', array(':collection' => $this->collection, ':now' => REQUEST_TIME)); + $values = array(); + + foreach ($result as $item) { + if ($item) { + $values[$item->name] = unserialize($item->value); + } + } + return $values; + } + + /** + * Implements Drupal\Core\KeyValueStore\KeyValueStoreExpireInterface::setWithExpire(). + */ + function setWithExpire($key, $value, $expire) { + $this->garbageCollection(); + $this->connection->merge($this->table) + ->key(array( + 'name' => $key, + 'collection' => $this->collection, + )) + ->fields(array( + 'value' => serialize($value), + 'expire' => REQUEST_TIME + $expire, + )) + ->execute(); + } + + /** + * Implements Drupal\Core\KeyValueStore\KeyValueStoreInterface::setWithExpireIfNotExists(). + */ + function setWithExpireIfNotExists($key, $value, $expire) { + $this->garbageCollection(); + $result = $this->connection->merge($this->table) + ->insertFields(array( + 'collection' => $this->collection, + 'name' => $key, + 'value' => serialize($value), + 'expire' => REQUEST_TIME + $expire, + )) + ->condition('collection', $this->collection) + ->condition('name', $key) + ->execute(); + return $result == MERGE::STATUS_INSERT; + } + + /** + * Implements Drupal\Core\KeyValueStore\KeyValueStoreInterface::setMultipleWithExpire(). + */ + function setMultipleWithExpire(array $data, $expire) { + foreach ($data as $key => $value) { + $this->set($key, $value, $expire); + } + } + + /** + * Implements Drupal\Core\KeyValueStore\KeyValueStoreInterface::deleteMultiple(). + */ + public function deleteMultiple(array $keys) { + $this->garbageCollection(); + parent::deleteMultiple($keys); + } + + /** + * Deletes expired items. + */ + protected function garbageCollection() { + $this->connection->delete($this->table) + ->condition('expire', REQUEST_TIME, '<') + ->execute(); + } + +} diff --git a/core/lib/Drupal/Core/KeyValueStore/KeyValueStoreExpirableInterface.php b/core/lib/Drupal/Core/KeyValueStore/KeyValueStoreExpirableInterface.php new file mode 100644 index 0000000..113c6c6 --- /dev/null +++ b/core/lib/Drupal/Core/KeyValueStore/KeyValueStoreExpirableInterface.php @@ -0,0 +1,52 @@ +data[$key])) { + $this->data[$key] = $value; + return TRUE; + } + return FALSE; + } + + /** * Implements Drupal\Core\KeyValueStore\KeyValueStoreInterface::setMultiple(). */ public function setMultiple(array $data) { diff --git a/core/modules/system/system.install b/core/modules/system/system.install index 05459d0..2a032dd 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -832,6 +832,43 @@ function system_schema() { 'primary key' => array('collection', 'name'), ); + $schema['key_value_expire'] = array( + 'description' => 'Generic key-value storage table with an expire.', + 'fields' => array( + 'collection' => array( + 'description' => 'A named collection of key and value pairs.', + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + ), + 'name' => array( + 'description' => 'The key of the key-value pair. As KEY is a SQL reserved keyword, name was chosen instead.', + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + ), + 'value' => array( + 'description' => 'The value.', + 'type' => 'blob', + 'not null' => TRUE, + 'size' => 'big', + 'translatable' => TRUE, + ), + 'expire' => array( + 'description' => 'The time since Unix epoch in seconds when this item expires.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + ), + 'primary key' => array('collection', 'name'), + 'indexes' => array( + 'all' => array('name', 'collection', 'expire'), + ), + ); + $schema['menu_router'] = array( 'description' => 'Maps paths to various callbacks (access, page and title)', 'fields' => array( @@ -2102,6 +2139,50 @@ function system_update_8022() { } /** + * Create the 'key_value_expire' table. + */ +function system_update_8022() { + $table = array( + 'description' => 'Generic key-value storage table with an expire.', + 'fields' => array( + 'collection' => array( + 'description' => 'A named collection of key and value pairs.', + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + ), + 'name' => array( + 'description' => 'The key of the key-value pair. As KEY is a SQL reserved keyword, name was chosen instead.', + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + ), + 'value' => array( + 'description' => 'The value.', + 'type' => 'blob', + 'not null' => TRUE, + 'size' => 'big', + 'translatable' => TRUE, + ), + 'expire' => array( + 'description' => 'The time since Unix epoch in seconds when this item expires.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + ), + 'primary key' => array('collection', 'name'), + 'indexes' => array( + 'all' => array('name', 'collection', 'expire'), + ), + ); + + db_create_table('key_value_expire', $table); +} + +/** * @} End of "defgroup updates-7.x-to-8.x". * The next series of updates should start at 9000. */ diff --git a/core/modules/user/lib/Drupal/user/KeyValueStoreWithOwner.php b/core/modules/user/lib/Drupal/user/KeyValueStoreWithOwner.php new file mode 100644 index 0000000..24478c9 --- /dev/null +++ b/core/modules/user/lib/Drupal/user/KeyValueStoreWithOwner.php @@ -0,0 +1,155 @@ +storage = $storage; + $this->lockBackend = $lockBackend; + $this->owner = $owner; + } + + /** + * Returns the stored value for the default key. + * + * @param string $key + * The key of the data to retrieve. + * + * @return mixed + * The data to retieve, or NULL if the key does not exist. + */ + function get($key) { + if ($object = $this->storage->get($key)) { + return $object->data; + } + } + + /** + * Adds the value for the default key if it doesn't exist yet. + * + * @param string $key + * The key of the data to check and store. + * @param mixed $value + * The data to store. + * + * @return bool + * TRUE if the data was set, FALSE if it already existed. + */ + function setIfNotExists($key, $value) { + $value = (object) array( + 'owner' => $this->owner, + 'data' => $value, + 'updated' => REQUEST_TIME, + ); + return $this->storage->setWithExpireIfNotExists($key, $value, $this->expire); + } + + /** + * Sets the value for the default key. + * + * @param string $key + * The key of the data to store. + * @param mixed $value + * The data to store. + */ + function set($key, $value) { + if ($this->lockBackend->acquire($key)) { + $object = $this->storage->get($key); + if (!$object || $object->owner == $this->owner) { + $value = (object) array( + 'owner' => $this->owner, + 'data' => $value, + 'updated' => REQUEST_TIME, + ); + $this->storage->setWithExpire($key, $value, $this->expire); + } + $this->lockBackend->release($key); + } + } + + /** + * Gets the metadata for a key. + * + * @param string $key + * The key of the data to store. + * + * @return mixed + * An object with the owner and updated time if the key has a value, NULL + * otherwise. + */ + function getMetadata($key) { + $object = $this->storage->get($key); + if ($object) { + unset($object->data); + return $object; + } + } + + /** + * Deletes the value of the default key. + * + * @param string $key + * The key of the data to store. + */ + function delete($key) { + if (!$this->lockBackend->acquire($key)) { + $this->lockBackend->wait($key); + if (!$this->lockBackend->acquire($key)) { + throw new KeyValueStoreWithOwnerException("Couldn't acquire lock"); + } + } + $this->storage->delete($key); + $this->lockBackend->release($key); + } + +} diff --git a/core/modules/user/lib/Drupal/user/KeyValueStoreWithOwnerException.php b/core/modules/user/lib/Drupal/user/KeyValueStoreWithOwnerException.php new file mode 100644 index 0000000..4a35e23 --- /dev/null +++ b/core/modules/user/lib/Drupal/user/KeyValueStoreWithOwnerException.php @@ -0,0 +1,13 @@ +connection = $connection; + $this->lockBackend = $lockBackend; + } + + /** + * Creates a Drupal\user\KeyValueStoreWithOwner stored in the database. + * + * @param string $namespace + * The namespace to use for this key/value store. + * + * @return Drupal\user\KeyValueStoreWithOwner + * An instance of the the key/value store. + */ + function get($namespace) { + $storage = new DatabaseStorageExpirable($namespace, array('connection' => $this->connection)); + return new KeyValueStoreWithOwner($storage, $this->lockBackend, $GLOBALS['user']->uid ?: session_id()); + } + +} diff --git a/core/modules/user/lib/Drupal/user/Tests/TempStoreDatabaseTest.php b/core/modules/user/lib/Drupal/user/Tests/TempStoreDatabaseTest.php new file mode 100644 index 0000000..4927ffa --- /dev/null +++ b/core/modules/user/lib/Drupal/user/Tests/TempStoreDatabaseTest.php @@ -0,0 +1,165 @@ + 'TempStore', + 'description' => 'Tests the temporary object storage system.', + 'group' => 'TempStore', + ); + } + + protected function setUp() { + parent::setUp(); + module_load_install('system'); + $schema = system_schema(); + db_create_table('semaphore', $schema['semaphore']); + db_create_table('key_value_expire', $schema['key_value_expire']); + $this->storeFactory = new KeyValueStoreWithOwnerFactory(Database::getConnection(), new DatabaseLockBackend()); + $this->collection = $this->randomName(); + + // Create two users and two objects for testing. + for ($i = 0; $i <= 3; $i++) { + $this->objects[$i] = $this->randomObject(); + $this->users[$i] = mt_rand(500, 5000000); + } + } + + /** + * Generates a random PHP object. + * + * @param int $size + * The number of random keys to add to the object. + * + * @return stdClass + * The generated object, with the specified number of random keys. Each key + * has a random string value. + */ + public function randomObject($size = 4) { + $object = new stdClass(); + for ($i = 0; $i < $size; $i++) { + $random_key = $this->randomName(); + $random_value = $this->randomString(); + $object->{$random_key} = $random_value; + } + return $object; + } + + /** + * Tests the UserTempStore API. + */ + public function testUserTempStore() { + $key = $this->randomName(); + // First test that only one setIfNotExists succeeds. + for ($i = 0; $i <= 1; $i++) { + $store = $this->getStorePerUid($this->users[$i]); + // Setting twice results only in the first succeeding. + $this->assertEqual(!$i, $store->setIfNotExists($key, $this->objects[$i])); + $metadata = $store->getMetadata($key); + $this->assertEqual($this->users[0], $metadata->owner); + $this->assertIdenticalObject($this->objects[0], $store->get($key)); + } + // Remove the item. + $store->delete($key); + // Try to set it again. + $store->setIfNotExists($key, $this->objects[1]); + // This time it succeeds. + $this->assertIdenticalObject($this->objects[1], $store->get($key)); + // This user can update the object. + $store->set($key, $this->objects[2]); + $this->assertIdenticalObject($this->objects[2], $store->get($key)); + // But another can't. + $store = $this->getStorePerUid($this->users[2]); + $store->set($key, $this->objects[3]); + $this->assertIdenticalObject($this->objects[2], $store->get($key)); + // Now manually expire the item (this is not exposed by the API) and then + // assert it is no longer accessible. + db_update('key_value_expire') + ->fields(array('expire' => REQUEST_TIME - 1)) + ->condition('collection', $this->collection) + ->condition('name', $key) + ->execute(); + $this->assertFalse($store->get($key)); + } + + /** + * Returns a KeyValueStoreWithOwner belonging to the passed in user. + * + * @param int $uid + * A user ID. + * + * @return Drupal\user\KeyValueStoreWithOwner + * The key/value store object. + */ + protected function getStorePerUid($uid) { + $GLOBALS['user']->uid = $uid; + // This relies on the logged user uid!! + return $this->storeFactory->get($this->collection); + } + + /** + * Checks to see if two objects are identical. + * + * @param object $object1 + * The first object to check. + * @param object $object2 + * The second object to check. + */ + protected function assertIdenticalObject($object1, $object2) { + $identical = TRUE; + foreach ($object1 as $key => $value) { + $identical = $identical && isset($object2->$key) && $object2->$key === $value; + } + $this->assertTrue($identical, format_string('!object1 is identical to !object2', array( + '!object1' => var_export($object1, TRUE), + '!object2' => var_export($object2, TRUE), + ))); + } +}