diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..95e9449 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 + +# Tab indentation (no size specified) +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48b8bf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..47b4187 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +test: ## Run test suite + ./vendor/bin/phpunit --bootstrap vendor/autoload.php --testdox --colors=always tests + +start: ## Start testing tools (Elasticsearch) + docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch-oss:7.0.0 + +kibana: ## Start debug tools + docker run -e "ELASTICSEARCH_HOSTS=http://127.0.0.1:9200/" --network host docker.elastic.co/kibana/kibana-oss:7.0.0 + +cs: ## Fix PHP CS + ./vendor/bin/php-cs-fixer fix --verbose --rules=@Symfony,ordered_imports src/ + ./vendor/bin/php-cs-fixer fix --verbose --rules=@Symfony,ordered_imports tests/ + +.PHONY: help + +help: ## Display this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md new file mode 100644 index 0000000..73fe37e --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# Elastically, **Elastica** based framework + +*This project is a work in progress.* + +![Under Construction](https://jolicode.com/media/original/2019/construction.gif "Optional title") + +**Feedback welcome!** + +Opinionated [Elastica](https://github.com/ruflin/Elastica) based framework to bootstrap PHP and Elasticsearch implementations. + +- DTO are first class citizen, you send object for documents, and get objects back, **like an ODM**; +- All indexes are versioned / aliased; +- Mappings are done in YAML; +- Analysis is separated from mappings; +- 100% compatibility with [ruflin/elastica](https://github.com/ruflin/Elastica); +- Designed for Elasticsearch 7+ (no types); +- Extra commands to monitor, update mapping, reindex... Commonly implemented tasks. + +## Demo + +Quick example of what the library do on top of Elastica: + +```php + './configs', + // What object to find in each index + Client::CONFIG_INDEX_CLASS_MAPPING => [ + 'beers' => AwesomeDTO::class, + ], +]); + +// Service to build Indexes +$indexBuilder = $client->getIndexBuilder(); + +// Create the Index in Elasticsearch +$index = $indexBuilder->createIndex('beers'); + +// Set the proper aliases +$indexBuilder->markAsLive($index, 'beers'); + +// Service to index DTO in an Index +$indexer = $client->getIndexer(); + +$dto = new AwesomeDTO(); +$dto->bar = 'American Pale Ale'; +$dto->foo = 'Hops from Alsace, France'; + +// Add a document to the queue +$indexer->scheduleIndex('beers', new Document('123', $dto)); +$indexer->flush(); + +// Force index refresh if needed +$indexer->refresh('beers'); + +// Get the Document (new!) +$results = $client->getIndex('beers')->getDocument('123'); + +// Get the DTO (new!) +$results = $client->getIndex('beers')->getModel('123'); + +// Perform a search +$results = $client->getIndex('beers')->search('alsace'); + +// Get the Elastic Document +$results->getDocuments()[0]; + +// Get the Elastica compatible Result +$results->getResults()[0]; + +// Get the DTO 🎉 (new!) +$results->getResults()[0]->getModel(); +``` + +*configs/beers.yaml* + +```yaml +# Anything you want, no validation +mappings: + properties: + foo: + type: text + analyzer: english + fields: + keyword: + type: keyword +``` + +## Configuration + +This library add custom configurations on top of Elastica's: + +### Client::CONFIG_MAPPINGS_DIRECTORY + +The directory Elastically is going to look for YAML. + +When creating a `foobar` index, a `foobar.yaml` file is expected. + +If an `analyzers.yaml` file is present, **all** the indices will get it. + +### Client::CONFIG_INDEX_CLASS_MAPPING + +An array of index name to class FQN. + +```php +[ + 'indexName' => '\My\AwesomeDTO' +] +``` + +### Client::CONFIG_SERIALIZER (optional) + +A `SerializerInterface` and `DenormalizerInterface` compatible object that will by used both on indexation and search. + +Default to Symfony Object Normalizer which can be slow. See below for Jane usage. + +*Todo: add custom demo?* + +### Client::CONFIG_BULK_SIZE (optional) + +When running indexation of lots of documents, this setting allow you to fine-tune the number of document threshold. Default to 100. + +## Using Jane for DTO and fast Normalizers + +To write. + +## To be done + +- some "todo" in the code +- optional Doctrine connector +- check Symfony 3.4 compatibility +- optional Symfony integration (DIC) +- scripts / commands for common tasks: + - auto-reindex when the mapping change, handle the aliases and everything + - micro monitoring for cluster / indexes + - health-check method + +## Sponsors + +[![JoliCode](https://jolicode.com/images/logo.svg)](https://jolicode.com) + +Open Source time sponsored by JoliCode. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..60276d6 --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "name": "jolicode/elastically", + "description": "Opinionated Elastica based framework to bootstrap PHP and Elasticsearch implementations.", + "keywords": [ + "Elasticsearch", + "Elastica", + "Symfony", + "Search" + ], + "license": "MIT", + "authors": [ + { + "name": "Damien Alexandre", + "homepage": "https://jolicode.com/" + } + ], + "require": { + "ruflin/elastica": "^6.1", + "symfony/yaml": "^4.2", + "symfony/serializer": "^4.2", + "symfony/property-access": "^4.2", + "symfony/property-info": "^4.2", + "ext-json": "*", + "php": "^7.2" + }, + "autoload": { + "psr-4": { + "JoliCode\\Elastically\\": "./src" + } + }, + "autoload-dev": { + "psr-4": { + "JoliCode\\Elastically\\Tests\\": "./tests" + } + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.14", + "phpunit/phpunit": "^8" + } +} diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..beef8fd --- /dev/null +++ b/src/Client.php @@ -0,0 +1,56 @@ +getConfig(self::CONFIG_MAPPINGS_DIRECTORY)); + } + + public function getIndexer(): Indexer + { + if (!$this->indexer) { + $this->indexer = new Indexer($this, $this->getSerializer(), $this->getConfigValue(self::CONFIG_BULK_SIZE, 100)); + } + + return $this->indexer; + } + + public function getIndex($name): Index + { + return new Index($this, $name); + } + + public function getSerializer(): SerializerInterface + { + $configSerializer = $this->getConfigValue(self::CONFIG_SERIALIZER, null); + if ($configSerializer) { + return $configSerializer; + } + + // Default + return new Serializer([ + new ArrayDenormalizer(), + new ObjectNormalizer(), + ], [ + new JsonEncoder(), + ]); + } +} diff --git a/src/Index.php b/src/Index.php new file mode 100644 index 0000000..96b56a6 --- /dev/null +++ b/src/Index.php @@ -0,0 +1,42 @@ +getType('_doc')->getDocument($id); + } + + public function getModel($id) + { + $document = $this->getDocument($id); + + return $this->getBuilder()->buildModelFromIndexAndData($document->getIndex(), $document->getData()); + } + + public function createSearch($query = '', $options = null, BuilderInterface $builder = null) + { + $builder = $builder ?? $this->getBuilder(); + + return parent::createSearch($query, $options, $builder); + } + + public function getBuilder(): ResultSetBuilder + { + if (!$this->builder) { + $this->builder = new ResultSetBuilder($this->getClient()); + } + + return $this->builder; + } +} diff --git a/src/IndexBuilder.php b/src/IndexBuilder.php new file mode 100644 index 0000000..2ce7686 --- /dev/null +++ b/src/IndexBuilder.php @@ -0,0 +1,122 @@ +client = $client; + $this->configurationDirectory = $configurationDirectory; + } + + public function createIndex($indexName): Index + { + $mappingFilePath = $this->configurationDirectory.DIRECTORY_SEPARATOR.$indexName.'_mapping.yaml'; + $analyzerFilePath = $this->configurationDirectory.'/analyzers.yaml'; + + if (!file_exists($mappingFilePath)) { + throw new InvalidException(sprintf('Mapping file %s not found.', $mappingFilePath)); + } + + $mapping = Yaml::parse(file_get_contents($this->configurationDirectory.DIRECTORY_SEPARATOR.$indexName.'_mapping.yaml')); + + if ($mapping && file_exists($analyzerFilePath)) { + $analyzer = Yaml::parse(file_get_contents($analyzerFilePath)); + $mapping['settings']['analysis'] = array_merge_recursive($mapping['settings']['analysis'] ?? [], $analyzer); + } + + $realName = sprintf('%s_%s', $indexName, date('Y-m-d-His')); + $index = $this->client->getIndex($realName); + + if ($index->exists()) { + throw new RuntimeException(sprintf('Index %s is already created, something is wrong.', $index->getName())); + } + + $index->create($mapping ?? []); + + return $index; + } + + public function markAsLive(Index $index, $indexName): Response + { + $data = ['actions' => []]; + + $data['actions'][] = ['remove' => ['index' => '*', 'alias' => $indexName]]; + $data['actions'][] = ['add' => ['index' => $index->getName(), 'alias' => $indexName]]; + + return $this->client->request('_aliases', Request::POST, $data); + } + + public function slowDownRefresh(Index $index) + { + $index->getSettings()->setRefreshInterval('60s'); + } + + public function speedUpRefresh(Index $index) + { + $index->getSettings()->setRefreshInterval('1s'); + } + + public static function getPureIndexName($indexName) + { + if (1 === preg_match('/(.+)_\d{4}-\d{2}-\d{2}-\d+/i', $indexName, $matches)) { + return $matches[1]; + } + + return $indexName; + } + + // TODO + public function migrate() + { + } + + // TODO: WIP + add tests + public function purgeOldIndices($index) + { + //$indexes = $this->client->requestEndpoint(new Get()); + $indexes = $indexes->getData(); + foreach ($indexes as $indexName => &$data) { + if (0 !== strpos($indexName, $index)) { + unset($indexes[$indexName]); + continue; + } + $date = \DateTime::createFromFormat('Y-m-d-His', str_replace($index.'_', '', $indexName)); + $data['date'] = $date; + $data['is_live'] = isset($data['aliases'][$this->getLiveSearchIndexName()]); + } + // Newest first + uasort($indexes, function ($a, $b) { + return $a['date'] < $b['date']; + }); + $afterLiveCounter = 0; + $livePassed = false; + $deleted = []; + foreach ($indexes as $indexName => $indexData) { + if ($livePassed) { + ++$afterLiveCounter; + } + if ($indexData['is_live']) { + $livePassed = true; + } + if ($livePassed && $afterLiveCounter > 2) { + // Remove! + $this->client->getIndex($indexName)->delete(); + $deleted[] = $indexName; + } + } + + return $deleted; + } +} diff --git a/src/Indexer.php b/src/Indexer.php new file mode 100644 index 0000000..22f0314 --- /dev/null +++ b/src/Indexer.php @@ -0,0 +1,152 @@ +client = $client; + $this->bulkMaxSize = $bulkMaxSize ?? 100; + $this->serializer = $serializer; + } + + public function scheduleIndex($index, Document $document) + { + $document->setIndex($index instanceof Index ? $index->getName() : $index); + if (!is_string($document->getData())) { + $document->setData($this->serializer->serialize($document->getData(), 'json')); + } + + $this->getCurrentBulk()->addDocument($document, Bulk\Action::OP_TYPE_INDEX); + + if ($this->getQueueSize() >= $this->bulkMaxSize) { + $this->flush(); + } + } + + public function scheduleDelete($index, $id) + { + $document = new Document($id); + $document->setIndex($index instanceof Index ? $index->getName() : $index); + $this->getCurrentBulk()->addAction(new Bulk\Action\DeleteDocument($document)); + + if ($this->getQueueSize() >= $this->bulkMaxSize) { + $this->flush(); + } + } + + public function scheduleUpdate($index, Document $document) + { + $document->setIndex($index instanceof Index ? $index->getName() : $index); + if (!is_string($document->getData())) { + $document->setData($this->serializer->serialize($document->getData(), 'json')); + } + + $this->getCurrentBulk()->addDocument($document, Bulk\Action::OP_TYPE_UPDATE); + + if ($this->getQueueSize() >= $this->bulkMaxSize) { + $this->flush(); + } + } + + public function scheduleCreate($index, Document $document) + { + $document->setIndex($index instanceof Index ? $index->getName() : $index); + if (!is_string($document->getData())) { + $document->setData($this->serializer->serialize($document->getData(), 'json')); + } + + $this->getCurrentBulk()->addDocument($document, Bulk\Action::OP_TYPE_CREATE); + + if ($this->getQueueSize() >= $this->bulkMaxSize) { + $this->flush(); + } + } + + public function flush(): ?Bulk\ResponseSet + { + if (null === $this->currentBulk) { + return null; + } + + if (0 === $this->getQueueSize()) { + return null; + } + + try { + $response = $this->getCurrentBulk()->send(); + } catch (ResponseException $exception) { + $this->currentBulk = null; + + throw $exception; + } + + $this->currentBulk = null; + + return $response; + } + + public function getQueueSize() + { + if (null === $this->currentBulk) { + return 0; + } + + return count($this->currentBulk->getActions()); + } + + public function refresh($index) + { + $indexName = $index instanceof Index ? $index->getName() : $index; + + $this->client->getIndex($indexName)->refresh(); + } + + protected function getCurrentBulk(): Bulk + { + if (!($this->currentBulk instanceof Bulk)) { + $this->currentBulk = new Bulk($this->client); + } + + return $this->currentBulk; + } + + public function setBulkMaxSize(int $bulkMaxSize): void + { + $this->bulkMaxSize = $bulkMaxSize; + + if ($this->getQueueSize() > $bulkMaxSize) { + $this->flush(); + } + } +} diff --git a/src/Result.php b/src/Result.php new file mode 100644 index 0000000..932c822 --- /dev/null +++ b/src/Result.php @@ -0,0 +1,40 @@ +model; + } + + /** + * @param \stdClass $model + */ + public function setModel($model) + { + $this->model = $model; + } + + /** + * Returns Document. + * + * @return Document + */ + public function getDocument() + { + $doc = parent::getDocument(); + $doc->setData($this->getModel()); + + return $doc; + } +} diff --git a/src/ResultSet.php b/src/ResultSet.php new file mode 100644 index 0000000..1f5efe4 --- /dev/null +++ b/src/ResultSet.php @@ -0,0 +1,23 @@ += 7 + * @see https://github.com/ruflin/Elastica/pull/1563 + */ + public function getTotalHits() + { + $data = $this->getResponse()->getData(); + + if (is_array($data['hits']['total'])) { + return (int) ($data['hits']['total']['value'] ?? 0); + } else { + return (int) ($data['hits']['total'] ?? 0); + } + } +} diff --git a/src/ResultSetBuilder.php b/src/ResultSetBuilder.php new file mode 100644 index 0000000..3330d7e --- /dev/null +++ b/src/ResultSetBuilder.php @@ -0,0 +1,70 @@ +client = $client; + } + + public function buildResultSet(Response $response, Query $query) + { + $results = $this->buildResults($response); + $resultSet = new ResultSet($response, $query, $results); + + return $resultSet; + } + + private function buildResults(Response $response) + { + $data = $response->getData(); + $results = []; + + if (!isset($data['hits']['hits'])) { + return $results; + } + + foreach ($data['hits']['hits'] as $hit) { + $result = new Result($hit); + $result->setModel($this->buildModel($result)); + $results[] = $result; + } + + return $results; + } + + private function buildModel(Result $result) + { + $source = $result->getSource(); + + if (empty($source)) { + return null; + } + + return $this->buildModelFromIndexAndData($result->getIndex(), $source); + } + + public function buildModelFromIndexAndData($indexName, $data) + { + $pureIndexName = IndexBuilder::getPureIndexName($indexName); + $indexToClass = $this->client->getConfig(Client::CONFIG_INDEX_CLASS_MAPPING); + + if (!isset($indexToClass[$pureIndexName])) { + throw new RuntimeException(sprintf('Unknown class for index %s, did you forgot to configure %s?', $pureIndexName, Client::CONFIG_INDEX_CLASS_MAPPING)); + } + + return $this->client->getSerializer()->denormalize($data, $indexToClass[$pureIndexName]); + } +} diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php new file mode 100644 index 0000000..6045311 --- /dev/null +++ b/tests/BaseTestCase.php @@ -0,0 +1,24 @@ +request('*', 'DELETE'); + } + + protected function getClient($path = null): Client + { + return new Client([ + Client::CONFIG_MAPPINGS_DIRECTORY => $path ?? __DIR__.'/configs', + 'log' => false, + ]); + } +} diff --git a/tests/IndexBuilderTest.php b/tests/IndexBuilderTest.php new file mode 100644 index 0000000..f424e04 --- /dev/null +++ b/tests/IndexBuilderTest.php @@ -0,0 +1,80 @@ +getClient($path)->getIndexBuilder(); + } + + public function testCannotCreateIndexWithoutMapping(): void + { + $indexBuilder = $this->getIndexBuilder(); + + $this->expectException(\Elastica\Exception\InvalidException::class); + $indexBuilder->createIndex('wrongname'); + } + + public function testCanCreateIndexWithEmptyMapping(): void + { + $indexBuilder = $this->getIndexBuilder(); + + $index = $indexBuilder->createIndex('empty'); + $this->assertInstanceOf(\Elastica\Index::class, $index); + + $mapping = $index->getMapping(); + $this->assertEmpty($mapping); + } + + public function testCanCreateIndexWithoutAnalysis(): void + { + $indexBuilder = $this->getIndexBuilder(); + + $index = $indexBuilder->createIndex('beers'); + $this->assertInstanceOf(\Elastica\Index::class, $index); + + $mapping = $index->getMapping(); + + $this->assertArrayHasKey('properties', $mapping); + $this->assertArrayHasKey('name', $mapping['properties']); + $this->assertSame('english', $mapping['properties']['name']['analyzer']); + + $aliases = $index->getAliases(); + $this->assertEmpty($aliases); + + $settings = $index->getSettings(); + $this->assertInstanceOf(\Elastica\Index\Settings::class, $settings); + $this->assertEmpty($settings->get('analysis')); + } + + public function testCanCreateIndexWithAnalysis(): void + { + $indexBuilder = $this->getIndexBuilder(__DIR__.'/configs_analysis'); + + $index = $indexBuilder->createIndex('hop'); + $this->assertInstanceOf(\Elastica\Index::class, $index); + + $settings = $index->getSettings(); + $this->assertInstanceOf(\Elastica\Index\Settings::class, $settings); + + $this->assertIsArray($settings->get('analysis')); + $this->assertNotEmpty($settings->get('analysis')); + } + + public function testGetBackThePureIndexName(): void + { + $indexBuilder = $this->getIndexBuilder(__DIR__.'/configs_analysis'); + + $index = $indexBuilder->createIndex('hop'); + $this->assertInstanceOf(\Elastica\Index::class, $index); + + $this->assertNotEquals('hop', $index->getName()); + $this->assertEquals('hop', IndexBuilder::getPureIndexName($index->getName())); + } +} diff --git a/tests/IndexerTest.php b/tests/IndexerTest.php new file mode 100644 index 0000000..923f109 --- /dev/null +++ b/tests/IndexerTest.php @@ -0,0 +1,138 @@ +getClient($path)->getIndexer(); + } + + public function testIndexOneDocument(): void + { + $indexName = mb_strtolower(__FUNCTION__); + + $dto = new TestDTO(); + $dto->bar = 'I like unicorns.'; + $dto->foo = 'Why is the sky blue?'; + + $indexer = $this->getIndexer(); + + $indexer->scheduleIndex($indexName, new Document('f', $dto)); + $indexer->flush(); + + $indexer->refresh($indexName); + + $client = $this->getClient(); + $document = $client->getIndex($indexName)->getDocument('f'); // @todo Document DATA is not the DTO here, call getModel? + + $this->assertInstanceOf(Document::class, $document); + $this->assertEquals('f', $document->getId()); + } + + public function testIndexMultipleDocuments(): void + { + $indexName = mb_strtolower(__FUNCTION__); + + $dto = new TestDTO(); + $dto->bar = 'I like unicorns.'; + $dto->foo = 'Why is the sky blue?'; + + $client = $this->getClient(); + $client->setConfigValue(Client::CONFIG_BULK_SIZE, 10); + $indexer = $client->getIndexer(); + + for ($i = 1; $i <= 31; ++$i) { + $indexer->scheduleIndex($indexName, new Document($i, $dto)); + } + + // 3 bulks should have been sent, leaving only one document + $this->assertEquals(1, $indexer->getQueueSize()); + + $indexer->flush(); + + $this->assertEquals(0, $indexer->getQueueSize()); + } + + public function testAllIndexerOperations(): void + { + $indexName = mb_strtolower(__FUNCTION__); + + $dto = new TestDTO(); + $dto->bar = 'I like unicorns.'; + $dto->foo = 'Why is the sky blue?'; + + $client = $this->getClient(); + $client->setConfigValue(Client::CONFIG_BULK_SIZE, 10); + $indexer = $client->getIndexer(); + + $indexer->scheduleCreate($indexName, new Document(1, $dto)); + $indexer->scheduleUpdate($indexName, new Document(1, $dto)); + $indexer->scheduleIndex($indexName, new Document(1, $dto)); + $indexer->scheduleDelete($indexName, 1); + + $response = $indexer->flush(); + + $this->assertInstanceOf(ResponseSet::class, $response); + $this->assertFalse($response->hasError()); + } + + public function testIndexingWithError(): void + { + $indexName = mb_strtolower(__FUNCTION__); + + $dto = new TestDTO(); + $dto->bar = 'I like unicorns.'; + $dto->foo = 'Why is the sky blue?'; + + $client = $this->getClient(); + $client->setConfigValue(Client::CONFIG_BULK_SIZE, 3); + $indexer = $client->getIndexer(); + + try { + $indexer->scheduleCreate($indexName, new Document(1, $dto)); + $indexer->scheduleCreate($indexName, new Document(1, $dto)); + $indexer->scheduleCreate($indexName, new Document(1, $dto)); + $indexer->scheduleCreate($indexName, new Document(1, $dto)); + + $this->assertFalse(true, 'Exception should have been thrown.'); + } catch (ResponseException $exception) { + $response = $exception->getResponseSet(); + } + + $this->assertInstanceOf(ResponseSet::class, $response); + $this->assertTrue($response->hasError()); + $this->assertEquals(0, $indexer->getQueueSize()); + } + + public function testIndexJsonString(): void + { + $indexName = mb_strtolower(__FUNCTION__); + + $indexer = $this->getIndexer(); + + $indexer->scheduleIndex($indexName, new Document('f', + json_encode(['foo' => 'I love unicorns.', 'bar' => 'I think PHP is better than butter.']) + )); + + $response = $indexer->flush(); + + $this->assertInstanceOf(ResponseSet::class, $response); + $this->assertFalse($response->hasError()); + } +} + +class TestDTO +{ + public $foo; + public $bar; +} diff --git a/tests/SearchTest.php b/tests/SearchTest.php new file mode 100644 index 0000000..0b29725 --- /dev/null +++ b/tests/SearchTest.php @@ -0,0 +1,146 @@ +getClient($path)->getIndexer(); + } + + public function testIndexAndSearch(): void + { + $indexName = mb_strtolower(__FUNCTION__); + + $indexer = $this->getIndexer(); + $dto = new SearchTestDto(); + $dto->bar = 'coucou unicorns'; + $dto->foo = '123'; + + $indexer->scheduleIndex($indexName, new Document('f', $dto)); + $indexer->flush(); + + $indexer->refresh($indexName); + + $client = $this->getClient(); + + // Give the class mapping + $client->setConfigValue(Client::CONFIG_INDEX_CLASS_MAPPING, [ + $indexName => SearchTestDto::class, + ]); + + $this->assertInstanceOf(Document::class, $client->getIndex($indexName)->getDocument('f')); + $this->assertInstanceOf(SearchTestDto::class, $client->getIndex($indexName)->getModel('f')); + + $results = $client->getIndex($indexName)->search('unicorns'); + + $this->assertEquals(1, $results->getTotalHits()); + + $this->assertInstanceOf(Result::class, $results->getResults()[0]); + $this->assertInstanceOf(Document::class, $results->getDocuments()[0]); + $this->assertInstanceOf(SearchTestDto::class, $results->getResults()[0]->getModel()); + } + + public function testSearchWithSourceFilter(): void + { + $indexName = mb_strtolower(__FUNCTION__); + + $indexer = $this->getIndexer(); + $dto = new SearchTestDto(); + $dto->bar = 'coucou unicorns'; + $dto->foo = '123'; + + $indexer->scheduleIndex($indexName, new Document('f', $dto)); + $indexer->flush(); + + $indexer->refresh($indexName); + + $client = $this->getClient(); + + // Give the class mapping + $client->setConfigValue(Client::CONFIG_INDEX_CLASS_MAPPING, [ + $indexName => SearchTestDto::class, + ]); + + $query = Query::create('coucou'); + $query->setSource(['foo']); + $results = $client->getIndex($indexName)->search($query); + + $this->assertEquals(1, $results->getTotalHits()); + + $this->assertInstanceOf(Result::class, $results->getResults()[0]); + $this->assertInstanceOf(Document::class, $results->getDocuments()[0]); + $this->assertInstanceOf(SearchTestDto::class, $results->getResults()[0]->getModel()); + $this->assertNull($results->getResults()[0]->getModel()->bar); + $this->assertNotNull($results->getResults()[0]->getModel()->foo); + } + + public function testMyOwnSerializer(): void + { + $serializer = $this->createMock(SearchTestDummySerializer::class); + $serializer->method('serialize')->willReturn('{"foo": "testMyOwnSerializer"}'); + $serializer->method('denormalize')->willReturn(new SearchTestDto()); + + $indexName = mb_strtolower(__FUNCTION__); + + $client = $this->getClient(); + $client->setConfigValue(Client::CONFIG_INDEX_CLASS_MAPPING, [ + $indexName => SearchTestDto::class, + ]); + $client->setConfigValue(Client::CONFIG_SERIALIZER, $serializer); + + $indexer = $client->getIndexer(); + $dto = new SearchTestDto(); + $dto->foo = 'testMyOwnSerializer'; + + $indexer->scheduleIndex($indexName, new Document('f', $dto)); + $indexer->flush(); + + $indexer->refresh($indexName); + + $results = $client->getIndex($indexName)->search('testMyOwnSerializer'); + + $this->assertEquals(1, $results->getTotalHits()); + + $this->assertInstanceOf(Result::class, $results->getResults()[0]); + $this->assertInstanceOf(Document::class, $results->getDocuments()[0]); + $this->assertInstanceOf(SearchTestDto::class, $results->getResults()[0]->getModel()); + } +} + +/* Needed to mock */ +class SearchTestDummySerializer implements SerializerInterface, DenormalizerInterface +{ + public function denormalize($data, $class, $format = null, array $context = []) + { + } + + public function supportsDenormalization($data, $type, $format = null) + { + } + + public function serialize($data, $format, array $context = []) + { + } + + public function deserialize($data, $type, $format, array $context = []) + { + } +} + +class SearchTestDto +{ + public $foo; + public $bar; +} diff --git a/tests/configs/beers_mapping.yaml b/tests/configs/beers_mapping.yaml new file mode 100644 index 0000000..72103bb --- /dev/null +++ b/tests/configs/beers_mapping.yaml @@ -0,0 +1,8 @@ +mappings: + properties: + name: + type: text + analyzer: english + fields: + keyword: + type: keyword diff --git a/tests/configs/empty_mapping.yaml b/tests/configs/empty_mapping.yaml new file mode 100644 index 0000000..e69de29 diff --git a/tests/configs_analysis/analyzers.yaml b/tests/configs_analysis/analyzers.yaml new file mode 100644 index 0000000..ab4d8bc --- /dev/null +++ b/tests/configs_analysis/analyzers.yaml @@ -0,0 +1,5 @@ +analyzers: + beer_name: + tokenizer: standard + filter: + - asciifolding diff --git a/tests/configs_analysis/hop_mapping.yaml b/tests/configs_analysis/hop_mapping.yaml new file mode 100644 index 0000000..72103bb --- /dev/null +++ b/tests/configs_analysis/hop_mapping.yaml @@ -0,0 +1,8 @@ +mappings: + properties: + name: + type: text + analyzer: english + fields: + keyword: + type: keyword