diff --git a/composer.json b/composer.json index ade1661..70dca3c 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "EzSystems\\EzPlatformMatrixFieldtypeBundle\\": "src/bundle/", "EzSystems\\EzPlatformMatrixFieldtype\\": "src/lib/", "Ibexa\\FieldTypeMatrix\\": "src/lib/", - "Ibexa\\Bundle\\FieldTypeMatrix\\": "src/bundle/" + "Ibexa\\Bundle\\FieldTypeMatrix\\": "src/bundle/", + "Ibexa\\Contracts\\FieldTypeMatrix\\": "src/contracts/" } }, "autoload-dev": { @@ -45,6 +46,7 @@ "ibexa/http-cache": "~4.6.0@dev", "ibexa/design-engine": "~4.6.0@dev", "ibexa/code-style": "^1.0", + "ibexa/solr": "~4.6.0@dev", "friendsofphp/php-cs-fixer": "^3.0", "phpunit/phpunit": "^9.5" }, diff --git a/src/bundle/DependencyInjection/IbexaFieldTypeMatrixExtension.php b/src/bundle/DependencyInjection/IbexaFieldTypeMatrixExtension.php index da681aa..b99e17e 100644 --- a/src/bundle/DependencyInjection/IbexaFieldTypeMatrixExtension.php +++ b/src/bundle/DependencyInjection/IbexaFieldTypeMatrixExtension.php @@ -33,6 +33,14 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('default_parameters.yaml'); $loader->load('services.yaml'); + + if ($container->hasExtension('ibexa_solr')) { + $loader->load('services/solr.yaml'); + } + + if ($container->hasExtension('ibexa_elasticsearch')) { + $loader->load('services/elasticsearch.yaml'); + } } /** diff --git a/src/bundle/Resources/config/services.yaml b/src/bundle/Resources/config/services.yaml index acedc05..4d31677 100644 --- a/src/bundle/Resources/config/services.yaml +++ b/src/bundle/Resources/config/services.yaml @@ -2,3 +2,4 @@ imports: - { resource: services/fieldtype.yaml } - { resource: services/command.yaml } - { resource: services/graphql.yaml } + - { resource: services/search.yaml } diff --git a/src/bundle/Resources/config/services/elasticsearch.yaml b/src/bundle/Resources/config/services/elasticsearch.yaml new file mode 100644 index 0000000..53cf93c --- /dev/null +++ b/src/bundle/Resources/config/services/elasticsearch.yaml @@ -0,0 +1,14 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + Ibexa\FieldTypeMatrix\Search\Elasticsearch\IndexSubscriber: + tags: + - { name: kernel.event_subscriber } + + Ibexa\FieldTypeMatrix\Search\Elasticsearch\Criterion\ColumnCriterionVisitor: + tags: + - { name: ibexa.search.elasticsearch.query.content.criterion.visitor } + - { name: ibexa.search.elasticsearch.query.location.criterion.visitor } diff --git a/src/bundle/Resources/config/services/search.yaml b/src/bundle/Resources/config/services/search.yaml new file mode 100644 index 0000000..eab26ad --- /dev/null +++ b/src/bundle/Resources/config/services/search.yaml @@ -0,0 +1,7 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + Ibexa\FieldTypeMatrix\Search\Common\IndexDataProvider: ~ diff --git a/src/bundle/Resources/config/services/solr.yaml b/src/bundle/Resources/config/services/solr.yaml new file mode 100644 index 0000000..9c811ec --- /dev/null +++ b/src/bundle/Resources/config/services/solr.yaml @@ -0,0 +1,14 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + Ibexa\FieldTypeMatrix\Search\Solr\ContentFieldMapper: + tags: + - { name: ibexa.search.solr.field.mapper.content } + + Ibexa\FieldTypeMatrix\Search\Solr\ColumnCriterionVisitor: + tags: + - { name: ibexa.search.solr.query.content.criterion.visitor } + - { name: ibexa.search.solr.query.location.criterion.visitor } diff --git a/src/contracts/Search/Criterion/Column.php b/src/contracts/Search/Criterion/Column.php new file mode 100644 index 0000000..cacffd8 --- /dev/null +++ b/src/contracts/Search/Criterion/Column.php @@ -0,0 +1,51 @@ +fieldDefIdentifier = $fieldDefIdentifier; + $this->column = $column; + } + + public function getFieldDefIdentifier(): string + { + return $this->fieldDefIdentifier; + } + + public function getColumn(): string + { + return $this->column; + } + + public function getSpecifications(): array + { + return [ + new Specifications(Operator::IN, Specifications::FORMAT_ARRAY), + new Specifications(Operator::EQ, Specifications::FORMAT_SINGLE), + new Specifications(Operator::CONTAINS, Specifications::FORMAT_SINGLE), + ]; + } +} diff --git a/src/lib/FieldType/Type.php b/src/lib/FieldType/Type.php index f6be935..6fa00e1 100644 --- a/src/lib/FieldType/Type.php +++ b/src/lib/FieldType/Type.php @@ -17,6 +17,8 @@ class Type extends FieldType { + public const FIELD_TYPE_IDENTIFIER = 'matrix'; + /** * {@inheritdoc} */ diff --git a/src/lib/Search/Common/IndexDataProvider.php b/src/lib/Search/Common/IndexDataProvider.php new file mode 100644 index 0000000..d30553b --- /dev/null +++ b/src/lib/Search/Common/IndexDataProvider.php @@ -0,0 +1,66 @@ +contentTypeHandler = $contentTypeHandler; + } + + public function getSearchData(SPIContent $content): array + { + $searchFields = []; + + $contentType = $this->contentTypeHandler->load( + $content->versionInfo->contentInfo->contentTypeId + ); + + foreach ($content->fields as $field) { + $definition = $this->findDefintion($contentType, $field); + if ($definition === null || $definition->fieldType !== Type::FIELD_TYPE_IDENTIFIER) { + continue; + } + + $columns = array_column($definition->fieldTypeConstraints->fieldSettings['columns'], 'identifier'); + + $data = $field->value->data; + foreach ($data['entries'] as $column => $value) { + $searchFields[] = new Search\Field( + $definition->identifier . '_col_' . $columns[$column] . '_value', + $value, + new Search\FieldType\MultipleStringField() + ); + } + } + + return $searchFields; + } + + private function findDefintion(SPIContent\Type $contentType, Field $field): ?FieldDefinition + { + foreach ($contentType->fieldDefinitions as $definition) { + if ($field->fieldDefinitionId === $definition->id) { + return $definition; + } + } + + return null; + } +} diff --git a/src/lib/Search/Elasticsearch/Criterion/ColumnCriterionVisitor.php b/src/lib/Search/Elasticsearch/Criterion/ColumnCriterionVisitor.php new file mode 100644 index 0000000..122f02f --- /dev/null +++ b/src/lib/Search/Elasticsearch/Criterion/ColumnCriterionVisitor.php @@ -0,0 +1,50 @@ +getFieldDefIdentifier() . '_col_' . $criterion->getColumn() . '_value_ms'; + + if ($criterion->operator === Criterion\Operator::CONTAINS) { + $bool = new BoolQuery(); + foreach ((array) $criterion->value as $value) { + $wildcard = new WildcardQuery(); + $wildcard->withField($name); + $wildcard->withValue('*' . $value . '*'); + + $bool->addShould($wildcard); + } + } else { + $terms = new TermsQuery(); + $terms->withField($name); + $terms->withValue((array)$criterion->value); + + return $terms->toArray(); + } + } +} diff --git a/src/lib/Search/Elasticsearch/IndexSubscriber.php b/src/lib/Search/Elasticsearch/IndexSubscriber.php new file mode 100644 index 0000000..faa31f2 --- /dev/null +++ b/src/lib/Search/Elasticsearch/IndexSubscriber.php @@ -0,0 +1,60 @@ +contentHandler = $contentHandler; + $this->indexDataProvider = $indexDataProvider; + } + + public static function getSubscribedEvents(): array + { + return [ + ContentIndexCreateEvent::class => 'onContentIndexCreate', + LocationIndexCreateEvent::class => 'onLocationIndexCreate', + ]; + } + + public function onContentIndexCreate(ContentIndexCreateEvent $event): void + { + $this->appendSearchFields($event->getDocument(), $event->getContent()); + } + + public function onLocationIndexCreate(LocationIndexCreateEvent $event): void + { + $content = $this->contentHandler->load( + $event->getLocation()->contentId + ); + + $this->appendSearchFields($event->getDocument(), $content); + } + + private function appendSearchFields(Document $document, Content $content): void + { + $data = $this->indexDataProvider->getSearchData($content); + foreach ($data as $field) { + $document->fields[] = $field; + } + } +} diff --git a/src/lib/Search/Solr/ContentFieldMapper.php b/src/lib/Search/Solr/ContentFieldMapper.php new file mode 100644 index 0000000..ae2331b --- /dev/null +++ b/src/lib/Search/Solr/ContentFieldMapper.php @@ -0,0 +1,33 @@ +indexDataProvider = $indexDataProvider; + } + + public function accept(SPIContent $content): bool + { + return true; + } + + public function mapFields(SPIContent $content): array + { + return $this->indexDataProvider->getSearchData($content); + } +} diff --git a/src/lib/Search/Solr/Criterion/ColumnCriterionVisitor.php b/src/lib/Search/Solr/Criterion/ColumnCriterionVisitor.php new file mode 100644 index 0000000..4a445f3 --- /dev/null +++ b/src/lib/Search/Solr/Criterion/ColumnCriterionVisitor.php @@ -0,0 +1,41 @@ +getFieldDefIdentifier() . '_col_' . $criterion->getColumn() . '_value_ms'; + + $queries = []; + foreach ((array)$criterion->value as $value) { + if ($criterion->operator === Operator::CONTAINS) { + $queries[] = $name . ':*' . $this->escapeExpressions($value) . '*'; + } else { + $queries[] = $name . ':"' . $this->escapeQuote($value, true) . '"'; + } + } + + return '(' . implode(' OR ', $queries) . ')'; + } +} diff --git a/tests/lib/Repository/SearchServiceTest.php b/tests/lib/Repository/SearchServiceTest.php index f7c2456..ee5d86c 100644 --- a/tests/lib/Repository/SearchServiceTest.php +++ b/tests/lib/Repository/SearchServiceTest.php @@ -11,6 +11,7 @@ use Ibexa\Contracts\Core\Repository\Values\Content\Content; use Ibexa\Contracts\Core\Repository\Values\Content\Query; use Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType; +use Ibexa\Contracts\FieldTypeMatrix\Search\Criterion\Column; use Ibexa\FieldTypeMatrix\FieldType\Value; use Ibexa\FieldTypeMatrix\FieldType\Value\Row; use Ibexa\Tests\Integration\Core\Repository\BaseTest; @@ -42,6 +43,35 @@ public function testFindContentWithMatrixFieldType(): void $this->assertEquals($content->id, $searchResults->searchHits[0]->valueObject->id); } + public function testFindContentWithMatrixColumnValue(): void + { + if (!in_array(getenv('SEARCH_ENGINE'), ['solr', 'elasticsearch'], true)) { + $this->markTestSkipped(Column::class . ' criterion is not supported by legacy search engine'); + } + + $content = $this->createAndPublishContentWithMatrixFieldType( + 'Content with table', + new Value([ + new Row([ + 'foo' => 'Foo', + 'bar' => 'Bar', + 'baz' => 'Baz', + ]), + ]) + ); + + $searchService = $this->getRepository()->getSearchService(); + + $searchResults = $searchService->findContent( + new Query([ + 'filter' => new Column('table', 'foo', 'Foo'), + ]) + ); + + $this->assertEquals(1, $searchResults->totalCount); + $this->assertEquals($content->id, $searchResults->searchHits[0]->valueObject->id); + } + private function createAndPublishContentWithMatrixFieldType(string $title, Value $table): Content { $contentType = $this->createContentTypeWithMatrixFieldType('content_with_table'); @@ -50,14 +80,8 @@ private function createAndPublishContentWithMatrixFieldType(string $title, Value $locationService = $this->getRepository()->getLocationService(); $contentCreateStruct = $contentService->newContentCreateStruct($contentType, 'eng-GB'); - $contentCreateStruct->setField('title', 'Content with table'); - $contentCreateStruct->setField('table', new Value([ - new Row([ - 'foo' => 'Foo', - 'bar' => 'Bar', - 'baz' => 'Baz', - ]), - ])); + $contentCreateStruct->setField('title', $title); + $contentCreateStruct->setField('table', $table); $contentCreateStruct->remoteId = 'abcdef0123456789abcdef0123456789'; $contentCreateStruct->alwaysAvailable = true;