diff --git a/core/core.services.yml b/core/core.services.yml index 8bce755..a7337d1 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -19,6 +19,8 @@ parameters: http.response.debug_cacheability_headers: false factory.keyvalue.expirable: default: keyvalue.expirable.database + factory.keyvalue.sorted_set: + default: keyvalue.sorted_set.database filter_protocols: - http - https @@ -384,6 +386,12 @@ services: keyvalue.expirable.database: class: Drupal\Core\KeyValueStore\KeyValueDatabaseExpirableFactory arguments: ['@serialization.phpserialize', '@database'] + keyvalue.sorted_set: + class: Drupal\Core\KeyValueStore\KeyValueSortedSetFactory + arguments: ['@service_container', '%factory.keyvalue.sorted_set%'] + keyvalue.sorted_set.database: + class: Drupal\Core\KeyValueStore\KeyValueDatabaseSortedSetFactory + arguments: ['@serialization.phpserialize', '@database'] logger.factory: class: Drupal\Core\Logger\LoggerChannelFactory parent: container.trait diff --git a/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageSortedSet.php b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageSortedSet.php new file mode 100644 index 0000000..ee98910 --- /dev/null +++ b/core/lib/Drupal/Core/KeyValueStore/DatabaseStorageSortedSet.php @@ -0,0 +1,249 @@ +collection = $collection; + $this->serializer = $serializer; + $this->connection = $connection; + $this->table = $table; + } + + /** + * {@inheritdoc} + */ + public function add($key, $value) { + return $this->addMultiple([[$key => $value]]); + } + + /** + * {@inheritdoc} + */ + public function addMultiple(array $pairs) { + foreach ($pairs as $pair) { + foreach ($pair as $key => $value) { + $try_again = FALSE; + try { + $encoded_value = $this->serializer->encode($value); + $this->connection->merge($this->table) + ->fields([ + 'collection' => $this->collection, + 'name' => $key, + 'value' => $encoded_value, + ]) + ->condition('collection', $this->collection) + ->condition('value', $encoded_value) + ->execute(); + } + catch(\Exception $e) { + // If there was an exception, try to create the table. + if (!$try_again = $this->ensureTableExists()) { + // If the exception happened for other reason than the missing + // table, propagate the exception. + throw $e; + } + } + // Now that the table has been created, try again if necessary. + if ($try_again) { + $this->add($key, $value); + } + } + } + return $this; + } + + /** + * {@inheritdoc} + */ + public function getCount() { + try { + return $this->connection->query('SELECT COUNT(*) FROM {' . $this->connection->escapeTable($this->table) . '} WHERE collection = :collection', [ + ':collection' => $this->collection + ])->fetchField(); + } + catch(\Exception $e) { + $this->catchException($e); + } + } + + /** + * {@inheritdoc} + */ + public function getRange($start, $stop = NULL) { + try { + $query = $this->connection->select($this->table, 't') + ->fields('t', ['value']) + ->orderBy('name', 'ASC') + ->condition('collection', $this->collection) + ->condition('name', $start, '>='); + + if (is_int($stop)) { + $query->condition('name', $stop, '<='); + } + + $values = []; + foreach ($query->execute() as $item) { + $values[] = $this->serializer->decode($item->value); + } + return $values; + } + catch(\Exception $e) { + $this->catchException($e); + } + } + + /** + * {@inheritdoc} + */ + public function getMaxKey() { + try { + return $this->connection->query('SELECT MAX(name) FROM {' . $this->connection->escapeTable($this->table) . '} WHERE collection = :collection', [ + ':collection' => $this->collection + ])->fetchField(); + } + catch(\Exception $e) { + $this->catchException($e); + } + } + + /** + * {@inheritdoc} + */ + public function getMinKey() { + try { + return $this->connection->query('SELECT MIN(name) FROM {' . $this->connection->escapeTable($this->table) . '} WHERE collection = :collection', [ + ':collection' => $this->collection + ])->fetchField(); + } + catch(\Exception $e) { + $this->catchException($e); + } + } + + /** + * Checks if the table exists and creates if not. + * + * @return bool + */ + protected function ensureTableExists() { + try { + $database_schema = $this->connection->schema(); + if (!$database_schema->tableExists($this->table)) { + $database_schema->createTable($this->table, $this->schemaDefinition()); + return TRUE; + } + } + // If the table already exists, then attempting to recreate it will throw an + // exception. In this case just catch the exception and do nothing. + catch (SchemaObjectExistsException $e) { + return TRUE; + } + return FALSE; + } + + /** + * Act on an exception when the table might not have been created. + * + * If the table does not yet exist, that's fine, but if the table exists and + * something else caused the exception, then propagate it. + * + * @param \Exception $e + * The exception. + * + * @throws \Exception + */ + protected function catchException(\Exception $e) { + if ($this->connection->schema()->tableExists($this->table)) { + throw $e; + } + } + + /** + * The schema definition for the sorted key-value list storage table. + * + * @return array + */ + protected function schemaDefinition() { + return [ + 'description' => 'Sorted key-value list storage table.', + 'fields' => [ + 'collection' => [ + 'description' => 'A named collection of key and value pairs.', + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + ], + // KEY is an SQL reserved word, so use 'name' as the key's field name. + 'name' => [ + 'description' => 'The index or score key for the value.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + 'size' => 'big', + ], + 'value' => [ + 'description' => 'The value.', + 'type' => 'blob', + 'not null' => TRUE, + 'size' => 'big', + ], + ], + 'indexes' => [ + 'collection_name' => ['collection', 'name'], + ], + ]; + } +} diff --git a/core/lib/Drupal/Core/KeyValueStore/KeyValueDatabaseSortedSetFactory.php b/core/lib/Drupal/Core/KeyValueStore/KeyValueDatabaseSortedSetFactory.php new file mode 100644 index 0000000..071a45d --- /dev/null +++ b/core/lib/Drupal/Core/KeyValueStore/KeyValueDatabaseSortedSetFactory.php @@ -0,0 +1,46 @@ +serializer = $serializer; + $this->connection = $connection; + } + + /** + * {@inheritdoc} + */ + public function get($collection) { + return new DatabaseStorageSortedSet($collection, $this->serializer, $this->connection); + } +} diff --git a/core/lib/Drupal/Core/KeyValueStore/KeyValueSortedSetFactory.php b/core/lib/Drupal/Core/KeyValueStore/KeyValueSortedSetFactory.php new file mode 100644 index 0000000..70ef0bd --- /dev/null +++ b/core/lib/Drupal/Core/KeyValueStore/KeyValueSortedSetFactory.php @@ -0,0 +1,16 @@ + 'a'], [2 => 'b']]. + * + * @param array $map + * An map of keys and values to add. + * + * @return $this + */ + public function addMultiple(array $map); + + /** + * Get the highest key in a collection. + * + * @return int + * The highest key in the collection. + */ + public function getMaxKey(); + + /** + * Get the lowest key in in a collection. + * + * @return int + * The lowest key in the collection. + */ + public function getMinKey(); + + /** + * Get the number of items in a collection. + * + * @return int + * The number of items in a collection. + */ + public function getCount(); + + /** + * Get multiple items within a range of keys. + * + * @param int $start + * The first key in the range. + * @param int $stop + * The last key in the range. + * + * @return array + * An array of items within the given range. + */ + public function getRange($start, $stop = NULL); + +} diff --git a/core/tests/Drupal/KernelTests/Core/KeyValueStore/DatabaseStorageSortedSetTest.php b/core/tests/Drupal/KernelTests/Core/KeyValueStore/DatabaseStorageSortedSetTest.php new file mode 100644 index 0000000..dc70268 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/KeyValueStore/DatabaseStorageSortedSetTest.php @@ -0,0 +1,144 @@ +installSchema('system', ['key_value_sorted']); + + $this->collection = $this->randomMachineName(); + $this->serializer = \Drupal::service('serialization.phpserialize'); + $this->connection = \Drupal::service('database'); + $this->store = \Drupal::service('keyvalue.sorted_set')->get($this->collection); + } + + /** + * Helper method to assert key value pairs. + * + * @param $expected_pairs array + * Array of expected key value pairs. + */ + public function assertPairs(array $expected_pairs) { + $result = $this->connection->select('key_value_sorted', 't') + ->fields('t', ['name', 'value']) + ->condition('collection', $this->collection) + ->condition('name', array_keys($expected_pairs), 'IN') + ->execute() + ->fetchAllAssoc('name'); + + $expected_count = count($expected_pairs); + $this->assertCount($expected_count, $result, "Query affected $expected_count records."); + foreach ($expected_pairs as $key => $value) { + $this->assertSame($value, $this->serializer->decode($result[$key]->value), "Key $key have value $value"); + } + } + + /** + * Helper method to assert the number of records. + * + * @param $expected int + * Expected number of records. + * @param null $message string + * The message to display. + */ + public function assertRecords($expected, $message = NULL) { + $count = $this->store->getCount(); + $this->assertEquals($expected, $count, $message ? $message : "There are $expected records."); + } + + /** + * Helper method to generate a key based on microtime(). + * + * @return integer + * A key based on microtime(). + */ + public function newKey() { + return (int) (microtime(TRUE) * 1000000); + } + + /** + * Tests getting and setting of sorted key value sets. + */ + public function testCalls() { + $key0 = $this->newKey(); + $value0 = $this->randomMachineName(); + $this->store->add($key0, $value0); + $this->assertPairs([$key0 => $value0]); + + $key1 = $this->newKey(); + $value1 = $this->randomMachineName(); + $this->store->add($key1, $value1); + $this->assertPairs([$key1 => $value1]); + + // Ensure it works to add sets with the same key. + $key2 = $this->newKey(); + $value2 = $this->randomMachineName(); + $value3 = $this->randomMachineName(); + $value4 = $this->randomMachineName(); + $this->store->addMultiple([ + [$key2 => $value2], + [$key2 => $value3], + [$key2 => $value4], + ]); + + $this->assertRecords(5, 'Correct number of records in the collection.'); + + $value = $this->store->getRange($key1, $key2); + $this->assertSame([$value1, $value2, $value3, $value4], $value); + + $value = $this->store->getRange($key1); + $this->assertSame([$value1, $value2, $value3, $value4], $value); + + $new1 = $this->newKey(); + $this->store->add($new1, $value1); + + $value = $this->store->getRange($new1, $new1); + $this->assertSame([$value1], $value, 'Value was successfully updated.'); + $this->assertRecords(5, 'Correct number of records in the collection after value update.'); + + $value = $this->store->getRange($key1, $key1); + $this->assertSame([], $value, 'Non-existing range returned empty array.'); + + $max_key = $this->store->getMaxKey(); + $this->assertEquals($new1, $max_key, 'The getMaxKey method returned correct key.'); + + $min_key = $this->store->getMinKey(); + $this->assertEquals($key0, $min_key, 'The getMinKey method returned correct key.'); + } + +}