diff --git a/README.md b/README.md index 2a851b6..2d6779d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Currently included drivers are: * [Redis](src/Driver/Redis/Redis.php) * [Cassandra](src/Driver/Cassandra/Cassandra.php) * [Mysqli](src/Driver/Mysqli/Mysqli.php) -* [Elasticsearch](src/Driver/Elasticsearch/Elasticsearch.php) +* [OpenSearch](src/Driver/OpenSearch/OpenSearch.php) *All of these drivers require additional extensions or packages, see "suggest" in [composer.json](composer.json).* diff --git a/composer.json b/composer.json index ec1e4e0..0ebc006 100644 --- a/composer.json +++ b/composer.json @@ -21,10 +21,11 @@ "minimum-stability": "stable", "require": { "php": ">=8.1", - "ext-json": "*" + "ext-json": "*", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1" }, "require-dev": { - "elasticsearch/elasticsearch": "^7.10", "phpunit/phpunit": "^10.5" }, "autoload": { @@ -42,6 +43,6 @@ "ext-redis": "To use the Redis Cache driver", "ext-cassandra": "To use the Cassandra NoSQL driver", "ext-mysqli": "To use the Mysqli Relational driver", - "elasticsearch/elasticsearch": "To use the Elasticsearch Search driver" + "shyim/opensearch-php-dsl": "OpenSearch DSL query builder" } } diff --git a/composer.lock b/composer.lock index 515e647..0af96bf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,101 +4,87 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "075d1013fbaf68f9833b146a138aecf6", - "packages": [], - "packages-dev": [ + "content-hash": "e65a6ad5873432956e40e565a87b7b3e", + "packages": [ { - "name": "elasticsearch/elasticsearch", - "version": "v7.17.2", + "name": "psr/http-client", + "version": "1.0.3", "source": { "type": "git", - "url": "git@github.com:elastic/elasticsearch-php.git", - "reference": "2d302233f2bb0926812d82823bb820d405e130fc" + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/2d302233f2bb0926812d82823bb820d405e130fc", - "reference": "2d302233f2bb0926812d82823bb820d405e130fc", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", "shasum": "" }, "require": { - "ext-json": ">=1.3.7", - "ezimuel/ringphp": "^1.1.2", - "php": "^7.3 || ^8.0", - "psr/log": "^1|^2|^3" - }, - "require-dev": { - "ext-yaml": "*", - "ext-zip": "*", - "mockery/mockery": "^1.2", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.4", - "symfony/finder": "~4.0" - }, - "suggest": { - "ext-curl": "*", - "monolog/monolog": "Allows for client-level logging and tracing" + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { - "files": [ - "src/autoload.php" - ], "psr-4": { - "Elasticsearch\\": "src/Elasticsearch/" + "Psr\\Http\\Client\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0", - "LGPL-2.1-only" + "MIT" ], "authors": [ { - "name": "Zachary Tong" - }, - { - "name": "Enrico Zimuel" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "PHP Client for Elasticsearch", + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", "keywords": [ - "client", - "elasticsearch", - "search" + "http", + "http-client", + "psr", + "psr-18" ], - "time": "2023-04-21T15:31:12+00:00" + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" }, { - "name": "ezimuel/guzzlestreams", - "version": "3.1.0", + "name": "psr/http-factory", + "version": "1.1.0", "source": { "type": "git", - "url": "https://github.com/ezimuel/guzzlestreams.git", - "reference": "b4b5a025dfee70d6cd34c780e07330eb93d5b997" + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezimuel/guzzlestreams/zipball/b4b5a025dfee70d6cd34c780e07330eb93d5b997", - "reference": "b4b5a025dfee70d6cd34c780e07330eb93d5b997", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "shasum": "" }, "require": { - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "~9.0" + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { "psr-4": { - "GuzzleHttp\\Stream\\": "src/" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -107,60 +93,52 @@ ], "authors": [ { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Fork of guzzle/streams (abandoned) to be used with elasticsearch-php", - "homepage": "http://guzzlephp.org/", + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", "keywords": [ - "Guzzle", - "stream" + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" ], "support": { - "source": "https://github.com/ezimuel/guzzlestreams/tree/3.1.0" + "source": "https://github.com/php-fig/http-factory" }, - "time": "2022-10-24T12:58:50+00:00" + "time": "2024-04-15T12:06:14+00:00" }, { - "name": "ezimuel/ringphp", - "version": "1.2.2", + "name": "psr/http-message", + "version": "2.0", "source": { "type": "git", - "url": "https://github.com/ezimuel/ringphp.git", - "reference": "7887fc8488013065f72f977dcb281994f5fde9f4" + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezimuel/ringphp/zipball/7887fc8488013065f72f977dcb281994f5fde9f4", - "reference": "7887fc8488013065f72f977dcb281994f5fde9f4", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { - "ezimuel/guzzlestreams": "^3.0.1", - "php": ">=5.4.0", - "react/promise": "~2.0" - }, - "replace": { - "guzzlehttp/ringphp": "self.version" - }, - "require-dev": { - "ext-curl": "*", - "phpunit/phpunit": "~9.0" - }, - "suggest": { - "ext-curl": "Guzzle will use specific adapters if cURL is present" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { - "GuzzleHttp\\Ring\\": "src/" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -169,17 +147,27 @@ ], "authors": [ { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Fork of guzzle/RingPHP (abandoned) to be used with elasticsearch-php", + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], "support": { - "source": "https://github.com/ezimuel/ringphp/tree/1.2.2" + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2022-12-07T11:28:53+00:00" - }, + "time": "2023-04-04T09:54:51+00:00" + } + ], + "packages-dev": [ { "name": "myclabs/deep-copy", "version": "1.12.1", @@ -838,128 +826,6 @@ ], "time": "2024-12-11T10:51:07+00:00" }, - { - "name": "psr/log", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/3.0.0" - }, - "time": "2021-07-14T16:46:02+00:00" - }, - { - "name": "react/promise", - "version": "v2.10.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "f913fb8cceba1e6644b7b90c4bfb678ed8a3ef38" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/f913fb8cceba1e6644b7b90c4bfb678ed8a3ef38", - "reference": "f913fb8cceba1e6644b7b90c4bfb678ed8a3ef38", - "shasum": "" - }, - "require": { - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.36" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" - ], - "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v2.10.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2023-05-02T15:15:43+00:00" - }, { "name": "sebastian/cli-parser", "version": "2.0.1", diff --git a/src/Driver/DriverRegistry.php b/src/Driver/DriverRegistry.php index 856cd30..f2fffa6 100644 --- a/src/Driver/DriverRegistry.php +++ b/src/Driver/DriverRegistry.php @@ -3,7 +3,7 @@ namespace Aternos\Model\Driver; use Aternos\Model\Driver\Cassandra\Cassandra; -use Aternos\Model\Driver\Elasticsearch\Elasticsearch; +use Aternos\Model\Driver\OpenSearch\OpenSearch; use Aternos\Model\Driver\Mysqli\Mysqli; use Aternos\Model\Driver\Redis\Redis; use Aternos\Model\Driver\Test\TestDriver; @@ -21,7 +21,7 @@ class DriverRegistry implements DriverRegistryInterface */ protected array $classes = [ Cassandra::ID => Cassandra::class, - Elasticsearch::ID => Elasticsearch::class, + OpenSearch::ID => OpenSearch::class, Mysqli::ID => Mysqli::class, Redis::ID => Redis::class, TestDriver::ID => TestDriver::class @@ -138,4 +138,4 @@ protected function __clone() protected function __construct() { } -} \ No newline at end of file +} diff --git a/src/Driver/Elasticsearch/Elasticsearch.php b/src/Driver/Elasticsearch/Elasticsearch.php deleted file mode 100644 index 7f1eb0e..0000000 --- a/src/Driver/Elasticsearch/Elasticsearch.php +++ /dev/null @@ -1,146 +0,0 @@ -client) { - $this->client = ClientBuilder::create()->build(); - } - } - - /** - * Save the model - * - * @param ModelInterface $model - * @return bool - */ - public function save(ModelInterface $model): bool - { - $params = [ - "index" => $model::getName(), - "id" => $model->getId(), - "body" => get_object_vars($model) - ]; - - $this->connect(); - $this->client->index($params); - return true; - } - - /** - * Get the model - * - * @param class-string $modelClass - * @param mixed $id - * @return ModelInterface|null - * @throws Exception - */ - public function get(string $modelClass, mixed $id, ?ModelInterface $model = null): ?ModelInterface - { - $params = [ - 'index' => $modelClass::getName(), - $modelClass::getIdField() => $id - ]; - - $this->connect(); - try { - $response = $this->client->getSource($params); - } catch (Missing404Exception $e) { - return null; - } - if (!is_array($response)) { - return null; - } - - if ($model) { - return $model->applyData($response); - } - return $modelClass::getModelFromData($response); - } - - /** - * Delete the model - * - * @param ModelInterface $model - * @return bool - */ - public function delete(ModelInterface $model): bool - { - $params = [ - "index" => $model::getName(), - "id" => $model->getId() - ]; - - $this->client->delete($params); - return true; - } - - /** - * @param Search $search - * @return SearchResult - */ - public function search(Search $search): SearchResult - { - /** @var class-string $modelClassName */ - $modelClassName = $search->getModelClassName(); - $params = [ - 'index' => $modelClassName::getName(), - 'body' => $search->getSearchQuery() - ]; - - $this->connect(); - $response = $this->client->search($params); - if (!is_array($response) || !isset($response["hits"]) || !is_array($response["hits"]) || !isset($response["hits"]["hits"]) || !is_array($response["hits"]["hits"])) { - return new SearchResult(false); - } - - $result = new SearchResult(true); - foreach ($response["hits"]["hits"] as $resultDocument) { - if (!isset($resultDocument["_source"]) || !is_array($resultDocument["_source"])) { - continue; - } - - /** @var ModelInterface $model */ - $model = new $modelClassName(); - foreach ($resultDocument["_source"] as $key => $value) { - $model->{$key} = $value; - } - $result->add($model); - } - return $result; - } -} \ No newline at end of file diff --git a/src/Driver/OpenSearch/Authentication/BasicAuthentication.php b/src/Driver/OpenSearch/Authentication/BasicAuthentication.php new file mode 100644 index 0000000..c4b2363 --- /dev/null +++ b/src/Driver/OpenSearch/Authentication/BasicAuthentication.php @@ -0,0 +1,27 @@ +withHeader("Authorization", "Basic " . base64_encode($this->username . ":" . $this->password)); + } +} diff --git a/src/Driver/OpenSearch/Authentication/BearerAuthentication.php b/src/Driver/OpenSearch/Authentication/BearerAuthentication.php new file mode 100644 index 0000000..1bbcd3b --- /dev/null +++ b/src/Driver/OpenSearch/Authentication/BearerAuthentication.php @@ -0,0 +1,25 @@ +withHeader("Authorization", "Bearer " . $this->token); + } +} diff --git a/src/Driver/OpenSearch/Authentication/OpenSearchAuthenticationInterface.php b/src/Driver/OpenSearch/Authentication/OpenSearchAuthenticationInterface.php new file mode 100644 index 0000000..d6dd164 --- /dev/null +++ b/src/Driver/OpenSearch/Authentication/OpenSearchAuthenticationInterface.php @@ -0,0 +1,14 @@ +responseBody; + } +} diff --git a/src/Driver/OpenSearch/Exception/HttpException.php b/src/Driver/OpenSearch/Exception/HttpException.php new file mode 100644 index 0000000..d0bf5d7 --- /dev/null +++ b/src/Driver/OpenSearch/Exception/HttpException.php @@ -0,0 +1,8 @@ +hosts = []; + foreach ($hosts as $host) { + $this->hosts[] = new OpenSearchHost( + $host, + $this->client, + $this->requestFactory, + $this->streamFactory, + $this->authentication + ); + } + } + + /** + * @param string $method + * @param string $uri + * @param mixed|null $body + * @return stdClass + * @throws HttpErrorResponseException + * @throws HttpTransportException + * @throws SerializeException + * @throws OpenSearchException + */ + protected function request(string $method, string $uri, mixed $body = null): stdClass + { + $offset = array_rand($this->hosts); + $lastError = null; + for ($i = 0; $i < $this->maxRetries; $i++) { + $host = $this->hosts[$offset + $i % count($this->hosts)]; + try { + return $host->request($method, $uri, $body); + } catch (OpenSearchException $e) { + $lastError = $e; + if ($e instanceof HttpTransportException) { + continue; + } + if ($e instanceof HttpErrorResponseException && $e->getCode() >= 500 || in_array($e->getCode(), [404, 408])) { + continue; + } + throw $e; + } + } + throw $lastError; + } + + /** + * @param string ...$path + * @return string + */ + protected function buildUrl(string ...$path): string + { + return "/" . implode("/", array_map(rawurlencode(...), $path)); + } + + /** + * Save the model + * + * @param ModelInterface $model + * @return bool + * @throws OpenSearchException + */ + public function save(ModelInterface $model): bool + { + $this->request( + "PUT", + $this->buildUrl($model::getName(), "_doc", $model->getId()), + get_object_vars($model) + ); + return true; + } + + /** + * Get the model + * + * @param class-string $modelClass + * @param mixed $id + * @param ModelInterface|null $model + * @return ModelInterface|null + * @throws HttpErrorResponseException + * @throws HttpTransportException + * @throws OpenSearchException + * @throws SerializeException + */ + public function get(string $modelClass, mixed $id, ?ModelInterface $model = null): ?ModelInterface + { + try { + $response = $this->request( + "GET", + $this->buildUrl($modelClass::getName(), "_doc", $id) + ); + } catch (HttpErrorResponseException $e) { + if ($e->getCode() === 404) { + return null; + } + throw $e; + } + + if (!isset($response->_source) || !is_object($response->_source)) { + throw new SerializeException("Received invalid document _source from OpenSearch"); + } + if (!isset($response->_id) || !is_string($response->_id)) { + throw new SerializeException("Received invalid document _id from OpenSearch"); + } + + $data = get_object_vars($response->_source); + $data[$modelClass::getIdField()] = $response->_id; + + if ($model) { + return $model->applyData($data); + } + return $modelClass::getModelFromData($data); + } + + /** + * Delete the model + * + * @param ModelInterface $model + * @return bool + * @throws OpenSearchException + */ + public function delete(ModelInterface $model): bool + { + try { + $this->request( + "DELETE", + $this->buildUrl($model::getName(), "_doc", $model->getId()) + ); + } catch (HttpErrorResponseException $e) { + if ($e->getCode() === 404) { + return false; + } + throw $e; + } + + return true; + } + + /** + * @param Search $search + * @return SearchResult + * @throws HttpErrorResponseException + * @throws HttpTransportException + * @throws OpenSearchException + * @throws SerializeException + */ + public function search(Search $search): SearchResult + { + /** @var class-string $modelClassName */ + $modelClassName = $search->getModelClassName(); + + $response = $this->request( + "GET", + $this->buildUrl($modelClassName::getName(), "_search"), + $search->getSearchQuery() + ); + if (!isset($response->hits) || !is_object($response->hits) || !isset($response->hits->hits) || !is_array($response->hits->hits)) { + throw new SerializeException("Received invalid search response from OpenSearch"); + } + + $result = new SearchResult(true); + foreach ($response->hits->hits as $resultDocument) { + if (!isset($resultDocument->_source) || !is_object($resultDocument->_source)) { + throw new SerializeException("Received invalid document _source from OpenSearch"); + } + if (!isset($resultDocument->_id) || !is_string($resultDocument->_id)) { + throw new SerializeException("Received invalid document _id from OpenSearch"); + } + + $data = $resultDocument->_source; + $data->{$modelClassName::getIdField()} = $resultDocument->_id; + + /** @var ModelInterface $model */ + $model = new $modelClassName(); + $model->applyData(get_object_vars($resultDocument->_source)); + $result->add($model); + } + return $result; + } +} diff --git a/src/Driver/OpenSearch/OpenSearchHost.php b/src/Driver/OpenSearch/OpenSearchHost.php new file mode 100644 index 0000000..e5b33c1 --- /dev/null +++ b/src/Driver/OpenSearch/OpenSearchHost.php @@ -0,0 +1,133 @@ +requestFactory->createRequest($method, $this->baseUri . $uri); + if ($body !== null) { + $request = $request + ->withHeader("Content-Type", "application/json") + ->withBody($this->streamFactory->createStream($this->serialize($body))); + } + + if ($this->authentication !== null) { + $request = $this->authentication->applyTo($request); + } + + try { + $response = $this->client->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new HttpTransportException("OpenSearch request could not be sent", previous: $e); + } + + $statusCode = $response->getStatusCode(); + if ($statusCode < 200 || $statusCode > 299) { + $parsed = null; + try { + $parsed = $this->parseResponse($response); + } catch (Exception) {} + throw new HttpErrorResponseException($parsed, "OpenSearch returned status code " . $statusCode, $statusCode); + } + + return $this->parseResponse($response); + } + + /** + * @param ResponseInterface $response + * @return stdClass + * @throws HttpTransportException + * @throws SerializeException + */ + protected function parseResponse(ResponseInterface $response): stdClass + { + $contentType = $response->getHeaderLine('Content-Type'); + if (!str_contains(strtolower($contentType), 'application/json')) { + throw new HttpTransportException("OpenSearch response is not a JSON response"); + } + + try { + $responseBody = $response->getBody()->getContents(); + } catch (RuntimeException $e) { + throw new HttpTransportException("OpenSearch response could not be read", previous: $e); + } + + return $this->deserialize($responseBody); + } + + /** + * @param mixed $data + * @return string + * @throws SerializeException + */ + protected function serialize(mixed $data): string + { + try { + return json_encode($data, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + } catch (JsonException $e) { + throw new SerializeException("Could not serialize OpenSearch data", previous: $e); + } + } + + /** + * @param string $data + * @return stdClass + * @throws SerializeException + */ + protected function deserialize(string $data): stdClass + { + try { + $data = json_decode($data, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new SerializeException("Could not deserialize OpenSearch data", previous: $e); + } + + if (!is_object($data)) { + throw new SerializeException("Data must be an object"); + } + + return $data; + } +}