diff --git a/src/Core/KeyValueStorage/Capabilities/ICanManageKeyValuePairs.php b/src/Core/KeyValueStorage/Capabilities/ICanManageKeyValuePairs.php new file mode 100644 index 000000000..11c8b772b --- /dev/null +++ b/src/Core/KeyValueStorage/Capabilities/ICanManageKeyValuePairs.php @@ -0,0 +1,64 @@ +. + * + */ + +namespace Friendica\Core\KeyValueStorage\Capabilities; + +use Friendica\Core\KeyValueStorage\Exceptions\KeyValueStoragePersistenceException; + +/** + * Interface for Friendica specific Key-Value pair storage + */ +interface ICanManageKeyValuePairs extends \ArrayAccess +{ + /** + * Get a particular value from the KeyValue Storage + * + * @param string $key The key to query + * + * @return mixed Stored value or null if it does not exist + * + * @throws KeyValueStoragePersistenceException In case the persistence layer throws errors + * + */ + public function get(string $key); + + /** + * Sets a value for a given key + * + * Note: Please do not store booleans - convert to 0/1 integer values! + * + * @param string $key The configuration key to set + * @param mixed $value The value to store + * + * @throws KeyValueStoragePersistenceException In case the persistence layer throws errors + */ + public function set(string $key, $value): void; + + /** + * Deletes the given key. + * + * @param string $key The configuration key to delete + * + * @throws KeyValueStoragePersistenceException In case the persistence layer throws errors + * + */ + public function delete(string $key): void; +} diff --git a/src/Core/KeyValueStorage/Exceptions/KeyValueStoragePersistenceException.php b/src/Core/KeyValueStorage/Exceptions/KeyValueStoragePersistenceException.php new file mode 100644 index 000000000..ad04c33ae --- /dev/null +++ b/src/Core/KeyValueStorage/Exceptions/KeyValueStoragePersistenceException.php @@ -0,0 +1,30 @@ +. + * + */ + +namespace Friendica\Core\KeyValueStorage\Exceptions; + +class KeyValueStoragePersistenceException extends \RuntimeException +{ + public function __construct($message = "", \Throwable $previous = null) + { + parent::__construct($message, 500, $previous); + } +} diff --git a/src/Core/KeyValueStorage/Type/AbstractKeyValueStorage.php b/src/Core/KeyValueStorage/Type/AbstractKeyValueStorage.php new file mode 100644 index 000000000..cfcebb6ff --- /dev/null +++ b/src/Core/KeyValueStorage/Type/AbstractKeyValueStorage.php @@ -0,0 +1,48 @@ +. + * + */ + +namespace Friendica\Core\KeyValueStorage\Type; + +use Friendica\Core\KeyValueStorage\Capabilities\ICanManageKeyValuePairs; + +/** + * An abstract helper class for Key-Value storage classes + */ +abstract class AbstractKeyValueStorage implements ICanManageKeyValuePairs +{ + /** {@inheritDoc} */ + public function get(string $key) + { + return $this->offsetGet($key); + } + + /** {@inheritDoc} */ + public function set(string $key, $value): void + { + $this->offsetSet($key, $value); + } + + /** {@inheritDoc} */ + public function delete(string $key): void + { + $this->offsetUnset($key); + } +} diff --git a/src/Core/KeyValueStorage/Type/DBKeyValueStorage.php b/src/Core/KeyValueStorage/Type/DBKeyValueStorage.php new file mode 100644 index 000000000..1d7040c04 --- /dev/null +++ b/src/Core/KeyValueStorage/Type/DBKeyValueStorage.php @@ -0,0 +1,116 @@ +. + * + */ + +namespace Friendica\Core\KeyValueStorage\Type; + +use Friendica\Core\Config\Util\ValueConversion; +use Friendica\Core\KeyValueStorage\Exceptions\KeyValueStoragePersistenceException; +use Friendica\Database\Database; + +/** + * A Key-Value storage provider with DB as persistence layer + */ +class DBKeyValueStorage extends AbstractKeyValueStorage +{ + const DB_KEY_VALUE_TABLE = 'key-value'; + + /** @var Database */ + protected $database; + + public function __construct(Database $database) + { + $this->database = $database; + } + + /** {@inheritDoc} */ + public function offsetExists($offset): bool + { + try { + return $this->database->exists(self::DB_KEY_VALUE_TABLE, ['k' => $offset]); + } catch (\Exception $exception) { + throw new KeyValueStoragePersistenceException(sprintf('Cannot check storage with key %s', $offset), $exception); + } + } + + /** {@inheritDoc} */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + try { + $result = $this->database->selectFirst(self::DB_KEY_VALUE_TABLE, ['v'], ['k' => $offset]); + + if ($this->database->isResult($result)) { + $value = ValueConversion::toConfigValue($result['v']); + + // just return it in case it is set + if (isset($value)) { + return $value; + } + } + } catch (\Exception $exception) { + throw new KeyValueStoragePersistenceException(sprintf('Cannot get value for key %s', $offset), $exception); + } + + return null; + } + + /** {@inheritDoc} */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + try { + // We store our setting values in a string variable. + // So we have to do the conversion here so that the compare below works. + // The exception are array values. + $compare_value = (!is_array($value) ? (string)$value : $value); + $stored_value = $this->get($offset); + + if (isset($stored_value) && ($stored_value === $compare_value)) { + return; + } + + $dbValue = ValueConversion::toDbValue($value); + + $return = $this->database->update(self::DB_KEY_VALUE_TABLE, ['v' => $dbValue], ['k' => $offset], true); + + if (!$return) { + throw new \Exception(sprintf('database update failed: %s', $this->database->errorMessage())); + } + } catch (\Exception $exception) { + throw new KeyValueStoragePersistenceException(sprintf('Cannot set value for %s for key %s', $value, $offset), $exception); + } + } + + /** {@inheritDoc} */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + try { + $return = $this->database->delete(self::DB_KEY_VALUE_TABLE, ['k' => $offset]); + + if (!$return) { + throw new \Exception(sprintf('database deletion failed: %s', $this->database->errorMessage())); + } + } catch (\Exception $exception) { + throw new KeyValueStoragePersistenceException(sprintf('Cannot delete value with key %s', $offset), $exception); + } + } +} diff --git a/src/DI.php b/src/DI.php index bf2b5a237..31f2511d4 100644 --- a/src/DI.php +++ b/src/DI.php @@ -22,6 +22,7 @@ namespace Friendica; use Dice\Dice; +use Friendica\Core\KeyValueStorage\Capabilities\ICanManageKeyValuePairs; use Friendica\Core\Session\Capability\IHandleSessions; use Friendica\Core\Session\Capability\IHandleUserSessions; use Friendica\Navigation\SystemMessages; @@ -181,6 +182,11 @@ abstract class DI return self::$dice->create(Core\Config\Capability\IManageConfigValues::class); } + public static function keyValue(): ICanManageKeyValuePairs + { + return self::$dice->create(Core\KeyValueStorage\Capabilities\ICanManageKeyValuePairs::class); + } + /** * @return Core\PConfig\Capability\IManagePersonalConfigValues */ diff --git a/static/dependencies.config.php b/static/dependencies.config.php index a4c52e004..645ab968e 100644 --- a/static/dependencies.config.php +++ b/static/dependencies.config.php @@ -245,6 +245,9 @@ return [ ['getBackend', [], Dice::CHAIN_CALL], ], ], + \Friendica\Core\KeyValueStorage\Capabilities\ICanManageKeyValuePairs::class => [ + 'instanceOf' => \Friendica\Core\KeyValueStorage\Type\DBKeyValueStorage::class, + ], Network\HTTPClient\Capability\ICanSendHttpRequests::class => [ 'instanceOf' => Network\HTTPClient\Factory\HttpClient::class, 'call' => [ diff --git a/tests/src/Core/KeyValueStorage/DBKeyValueStorageTest.php b/tests/src/Core/KeyValueStorage/DBKeyValueStorageTest.php new file mode 100644 index 000000000..16fa9ab7e --- /dev/null +++ b/tests/src/Core/KeyValueStorage/DBKeyValueStorageTest.php @@ -0,0 +1,64 @@ +. + * + */ + +namespace Friendica\Test\src\Core\KeyValueStorage; + +use Friendica\Core\Config\ValueObject\Cache; +use Friendica\Core\KeyValueStorage\Capabilities\ICanManageKeyValuePairs; +use Friendica\Core\KeyValueStorage\Type\DBKeyValueStorage; +use Friendica\Database\Definition\DbaDefinition; +use Friendica\Database\Definition\ViewDefinition; +use Friendica\Test\DatabaseTestTrait; +use Friendica\Test\Util\Database\StaticDatabase; +use Friendica\Util\BasePath; +use Friendica\Util\Profiler; + +class DBKeyValueStorageTest extends KeyValueStorageTest +{ + use DatabaseTestTrait; + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpDb(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->tearDownDb(); + } + + public function getInstance(): ICanManageKeyValuePairs + { + $cache = new Cache(); + $cache->set('database', 'disable_pdo', true); + + $basePath = new BasePath(dirname(__FILE__, 5), $_SERVER); + + $database = new StaticDatabase($cache, new Profiler($cache), (new DbaDefinition($basePath->getPath()))->load(), (new ViewDefinition($basePath->getPath()))->load()); + $database->setTestmode(true); + + return new DBKeyValueStorage($database); + } +} diff --git a/tests/src/Core/KeyValueStorage/KeyValueStorageTest.php b/tests/src/Core/KeyValueStorage/KeyValueStorageTest.php new file mode 100644 index 000000000..6c393310d --- /dev/null +++ b/tests/src/Core/KeyValueStorage/KeyValueStorageTest.php @@ -0,0 +1,105 @@ +. + * + */ + +namespace Friendica\Test\src\Core\KeyValueStorage; + +use Friendica\Core\KeyValueStorage\Capabilities\ICanManageKeyValuePairs; +use Friendica\Test\MockedTest; + +abstract class KeyValueStorageTest extends MockedTest +{ + abstract public function getInstance(): ICanManageKeyValuePairs; + + public function testInstance() + { + $instance = $this->getInstance(); + + self::assertInstanceOf(ICanManageKeyValuePairs::class, $instance); + } + + public function dataTests(): array + { + return [ + 'string' => ['k' => 'data', 'v' => 'it'], + 'boolTrue' => ['k' => 'data', 'v' => true], + 'boolFalse' => ['k' => 'data', 'v' => false], + 'integer' => ['k' => 'data', 'v' => 235], + 'decimal' => ['k' => 'data', 'v' => 2.456], + 'array' => ['k' => 'data', 'v' => ['1', 2, '3', true, false]], + 'boolIntTrue' => ['k' => 'data', 'v' => 1], + 'boolIntFalse' => ['k' => 'data', 'v' => 0], + ]; + } + + /** + * @dataProvider dataTests + */ + public function testGetSetDelete($k, $v) + { + $instance = $this->getInstance(); + + $instance->set($k, $v); + + self::assertEquals($v, $instance->get($k)); + self::assertEquals($v, $instance[$k]); + + $instance->delete($k); + + self::assertNull($instance->get($k)); + self::assertNull($instance[$k]); + } + + /** + * @dataProvider dataTests + */ + public function testSetOverride($k, $v) + { + $instance = $this->getInstance(); + + $instance->set($k, $v); + + self::assertEquals($v, $instance->get($k)); + self::assertEquals($v, $instance[$k]); + + $instance->set($k, 'another_value'); + + self::assertEquals('another_value', $instance->get($k)); + self::assertEquals('another_value', $instance[$k]); + } + + /** + * @dataProvider dataTests + */ + public function testOffsetSetDelete($k, $v) + { + $instance = $this->getInstance(); + + $instance[$k] = $v; + + self::assertEquals($v, $instance->get($k)); + self::assertEquals($v, $instance[$k]); + + unset($instance[$k]); + + self::assertNull($instance->get($k)); + self::assertNull($instance[$k]); + } +}