diff --git a/bundle/DependencyInjection/Configuration.php b/bundle/DependencyInjection/Configuration.php index ce69b341..8fe099af 100644 --- a/bundle/DependencyInjection/Configuration.php +++ b/bundle/DependencyInjection/Configuration.php @@ -25,6 +25,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addIndexableFieldTypeSection($rootNode); $this->addSearchResultExtractorSection($rootNode); $this->addAsynchronousIndexingSection($rootNode); + $this->addFulltextBoostSection($rootNode); return $treeBuilder; } @@ -73,4 +74,61 @@ private function addAsynchronousIndexingSection(ArrayNodeDefinition $nodeDefinit ->end() ->end(); } + + private function addFulltextBoostSection(ArrayNodeDefinition $nodeDefinition): void + { + $nodeDefinition + ->children() + ->arrayNode('search_boost') + ->info('Search boost configuration') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('content_types') + ->info('Define boost value per content type') + ->useAttributeAsKey('name') + ->normalizeKeys(false) + ->arrayPrototype() + ->children() + ->integerNode('id') + ->info('Content type id') + ->isRequired() + ->end() + ->floatNode('boost_value') + ->info('Boost value for the content type') + ->isRequired() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('raw_fields') + ->info('Boost values for raw fields') + ->useAttributeAsKey('name') + ->normalizeKeys(false) + ->floatPrototype() + ->info('Boost value for the raw field') + ->end() + ->end() + ->arrayNode('meta_fields') + ->info('Boost values for meta fields') + ->useAttributeAsKey('name') + ->normalizeKeys(false) + ->floatPrototype() + ->info('Boost value for the meta field') + ->end() + ->end() + ->end() + ->end() + ->arrayNode('field_mapper_custom_fulltext_field_config') + ->info('Custom fulltext field mapping') + ->useAttributeAsKey('name') + ->normalizeKeys(false) + ->arrayPrototype() + ->scalarPrototype() + ->info('List of mapped fields') + ->end() + ->end() + ->end() + ->end(); + + } } diff --git a/bundle/DependencyInjection/NetgenIbexaSearchExtraExtension.php b/bundle/DependencyInjection/NetgenIbexaSearchExtraExtension.php index 729acae5..4a8e9a57 100644 --- a/bundle/DependencyInjection/NetgenIbexaSearchExtraExtension.php +++ b/bundle/DependencyInjection/NetgenIbexaSearchExtraExtension.php @@ -88,6 +88,7 @@ private function processExtensionConfiguration(array $configs, ContainerBuilder $this->processIndexableFieldTypeConfiguration($configuration, $container); $this->processSearchResultExtractorConfiguration($configuration, $container); $this->processAsynchronousIndexingConfiguration($configuration, $container); + $this->processFullTextBoostConfiguration($configuration, $container); } private function processSearchResultExtractorConfiguration(array $configuration, ContainerBuilder $container): void @@ -117,4 +118,34 @@ private function processAsynchronousIndexingConfiguration(array $configuration, $configuration['use_asynchronous_indexing'], ); } + + private function processFullTextBoostConfiguration(array $configuration, ContainerBuilder $container) + { + $fullTextBoostConfig = $container->getParameter('netgen_ibexa_search_extra')['search_boost']; + + $container->setParameter( + 'netgen_ibexa_search_extra.search_boost', + $configuration['search_boost'] ?? [], + ); + + $container->setParameter( + 'netgen_ibexa_search_extra.field_mapper_custom_fulltext_field_config', + $configuration['field_mapper_custom_fulltext_field_config'] ?? [], + ); + + if (!array_key_exists('content_types', $container->getParameter('netgen_ibexa_search_extra.search_boost'))) { + $fullTextBoostConfig['content_types'] = null; + } + if (!array_key_exists('raw_fields', $container->getParameter('netgen_ibexa_search_extra.search_boost'))) { + $fullTextBoostConfig['raw_fields'] = null; + } + if (!array_key_exists('meta_fields', $container->getParameter('netgen_ibexa_search_extra.search_boost'))) { + $fullTextBoostConfig['meta_fields'] = null; + } + + $container->setParameter( + 'netgen_ibexa_search_extra.search_boost', + $fullTextBoostConfig, + ); + } } diff --git a/lib/API/Values/Content/Query/Criterion/FullText.php b/lib/API/Values/Content/Query/Criterion/FullText.php new file mode 100644 index 00000000..8f0faca8 --- /dev/null +++ b/lib/API/Values/Content/Query/Criterion/FullText.php @@ -0,0 +1,142 @@ + + * array( + * 'title' => 2, + * … + * ) + * + * + * @var array + */ + public array $boost = []; + /** + * Boost for certain solr fields. + * + * Array of boosts to apply for certain fields – the array should look like + * this: + * + * + * array( + * 'meta_content__name_t' => 2, + * … + * ) + * + * + * @var array + */ + public array $solrFieldsBoost = []; + /** + * Boost for certain content types. + * + * Array of boosts to apply for certain content type – the array should look like + * this: + * + * + * array( + * 'content_type_identifier' => array( + * 'id' => 2, + * 'boost' => 3 + * ) + * … + * ) + * + * + * @var array + */ + public array $contentTypeBoost = []; + /** + * Boost for certain fulltext meta fields. + * + * Array of boosts to apply for certain meta fields – the array should look like + * this: + * + * + * array( + * 'meta_field_key' => 2, + * … + * ) + * + * + * @var array + */ + public array $metaFieldsBoost = []; + /** + * Analyzer configuration. + */ + public mixed $analyzers; + /** + * Analyzer wildcard handling configuration. + */ + public mixed $wildcards; + /** + * Custom field definitions to query instead of default field. + * + * @var array + */ + private array $customFields = []; + /** + * @param array $properties + */ + public function __construct(mixed $value, array $properties = []) + { + parent::__construct(null, Operator::LIKE, $value); + foreach ($properties as $name => $propertyValue) { + if (!property_exists($this, $name)) { + throw new InvalidArgumentException(sprintf('Unknown property %s.', $name)); + } + $this->{$name} = $propertyValue; + } + + } + public function getSpecifications(): array + { + return [ + new Specifications(Operator::LIKE, Specifications::FORMAT_SINGLE), + ]; + } + public function setCustomField(string $type, string $field, string $customField): void + { + $this->customFields[$type][$field] = $customField; + } + public function getCustomField(string $type, string $field): ?string + { + return $this->customFields[$type][$field] ?? null; + } + public function getSpellcheckQuery(): SpellcheckQuery + { + if (!is_string($this->value)) { + throw new RuntimeException( + sprintf('FullText criterion value should be a string, %s given', get_debug_type($this->value)), + ); + } + $spellcheckQuery = new SpellcheckQuery(); + $spellcheckQuery->query = $this->value; + $spellcheckQuery->count = 10; + return $spellcheckQuery; + } +} diff --git a/lib/Core/Search/Solr/FieldMapper/ContentTranslation/CustomFulltextFieldMapper.php b/lib/Core/Search/Solr/FieldMapper/ContentTranslation/CustomFulltextFieldMapper.php new file mode 100644 index 00000000..3d99c744 --- /dev/null +++ b/lib/Core/Search/Solr/FieldMapper/ContentTranslation/CustomFulltextFieldMapper.php @@ -0,0 +1,122 @@ + + */ + private array $fieldConfig = []; + public function __construct( + private readonly ContentTypeHandler $contentTypeHandler, + private readonly FieldRegistry $fieldRegistry, + private readonly ParameterBagInterface $parameterBag, + ) {} + /** + * @param string $languageCode + */ + public function accept(SPIContent $content, $languageCode): bool + { + $this->fieldConfig = $this->parameterBag->get('ibexa_search_extra.search_boost')['field_mapper_custom_fulltext_field_config']; + return count($this->fieldConfig) > 0; + } + public function mapFields(SPIContent $content, $languageCode): array + { + $fields = []; + try { + $contentType = $this->contentTypeHandler->load($content->versionInfo->contentInfo->contentTypeId); + } catch (NotFoundException) { + return $fields; + } + foreach ($content->fields as $field) { + if ($field->languageCode !== $languageCode) { + continue; + } + foreach ($contentType->fieldDefinitions as $fieldDefinition) { + if (!$fieldDefinition->isSearchable) { + continue; + } + if ($fieldDefinition->id !== $field->fieldDefinitionId) { + continue; + } + $fieldNames = $this->getFieldNames($fieldDefinition, $contentType); + if (count($fieldNames) === 0) { + continue; + } + $fieldType = $this->fieldRegistry->getType($field->type); + $indexFields = $fieldType->getIndexData($field, $fieldDefinition); + foreach ($indexFields as $indexField) { + if ($indexField->value === null) { + continue; + } + if (!$indexField->getType() instanceof FullTextField) { + continue; + } + $this->appendField($fields, $indexField, $fieldNames); + } + } + } + return $fields; + } + /** + * @param array $fields + * @param array $fieldNames + */ + private function appendField( + array &$fields, + Field $indexField, + array $fieldNames, + ): void { + foreach ($fieldNames as $fieldName) { + $fields[] = new Field( + sprintf('meta_%s__text', $fieldName), + (string) $indexField->value, + new TextField(), + ); + } + } + /** + * @return array + */ + private function getFieldNames(FieldDefinition $fieldDefinition, ContentType $contentType): array + { + $fieldNames = []; + foreach ($this->fieldConfig as $fieldName => $fieldIdentifiers) { + if ($this->isMapped($fieldDefinition, $contentType, $fieldIdentifiers)) { + $fieldNames[] = $fieldName; + } + } + return $fieldNames; + } + /** + * @param array $fieldIdentifiers + */ + private function isMapped(FieldDefinition $fieldDefinition, ContentType $contentType, array $fieldIdentifiers): bool + { + if (in_array($fieldDefinition->identifier, $fieldIdentifiers, true)) { + return true; + } + $needle = sprintf('%s/%s', $contentType->identifier, $fieldDefinition->identifier); + if (in_array($needle, $fieldIdentifiers, true)) { + return true; + } + return false; + } +} diff --git a/lib/Core/Search/Solr/Query/Content/CriterionVisitor/Factory/ContentFullTextFactory.php b/lib/Core/Search/Solr/Query/Content/CriterionVisitor/Factory/ContentFullTextFactory.php new file mode 100644 index 00000000..5c72fca3 --- /dev/null +++ b/lib/Core/Search/Solr/Query/Content/CriterionVisitor/Factory/ContentFullTextFactory.php @@ -0,0 +1,25 @@ +tokenizer, + $this->parser, + $this->generator, + ); + } +} diff --git a/lib/Core/Search/Solr/Query/Content/CriterionVisitor/FullText.php b/lib/Core/Search/Solr/Query/Content/CriterionVisitor/FullText.php new file mode 100644 index 00000000..887115cf --- /dev/null +++ b/lib/Core/Search/Solr/Query/Content/CriterionVisitor/FullText.php @@ -0,0 +1,87 @@ +value; + $tokenSequence = $this->tokenizer->tokenize($value); + $syntaxTree = $this->parser->parse($tokenSequence); + $options = []; + if ($criterion->fuzziness < 1) { + $options['fuzziness'] = $criterion->fuzziness; + } + $queryString = $this->generator->generate($syntaxTree, $options); + $queryStringEscaped = $this->escapeQuote($queryString); + $queryFields = $this->getQueryFields($criterion); + $boost = $this->getBoostParameter($criterion); + $queryParams = [ + 'v' => $queryStringEscaped, + 'qf' => $queryFields, + 'tie' => 0.1, + 'uf' => '-*', + 'boost' => $boost, + ]; + $queryParamsString = implode( + ' ', + array_map( + static fn ($key, $value) => sprintf("%s='%s'", $key, $value), + array_keys($queryParams), + $queryParams, + ), + ); + return sprintf('{!edismax %s}', $queryParamsString); + } + /** + * @param FullTextCriterion $criterion + */ + private function getBoostParameter(Criterion $criterion): string + { + $function = ''; + foreach ($criterion->contentTypeBoost as $contentTypeIdentifier) { + $function .= 'if(exists(query({!lucene v=\"content_type_id_id:' . $contentTypeIdentifier['id'] . '\"})),' . $contentTypeIdentifier['boost_value'] . ','; + } + $function .= '1' . str_repeat(')', count($criterion->contentTypeBoost)); + return $function; + } + /** + * @param FullTextCriterion $criterion + */ + private function getQueryFields(Criterion $criterion): string + { + $queryFields = ['meta_content__text_t']; + foreach ($criterion->solrFieldsBoost as $field => $boost) { + $queryFields[] = sprintf('%s^%s', $field, $boost); + } + foreach ($criterion->metaFieldsBoost as $fieldKey => $boost) { + $queryFields[] = sprintf('meta_%s__text_t^%s', $fieldKey, $boost); + } + return implode(' ', $queryFields); + } +} diff --git a/lib/Core/Search/Solr/Query/Location/CriterionVisitor/Factory/LocationFullTextFactory.php b/lib/Core/Search/Solr/Query/Location/CriterionVisitor/Factory/LocationFullTextFactory.php new file mode 100644 index 00000000..20be8ca1 --- /dev/null +++ b/lib/Core/Search/Solr/Query/Location/CriterionVisitor/Factory/LocationFullTextFactory.php @@ -0,0 +1,32 @@ +tokenizer, + $this->parser, + $this->generator, + ), + ); + } +} diff --git a/lib/Core/Search/Solr/Query/Location/CriterionVisitor/FullText.php b/lib/Core/Search/Solr/Query/Location/CriterionVisitor/FullText.php new file mode 100644 index 00000000..49ad3cff --- /dev/null +++ b/lib/Core/Search/Solr/Query/Location/CriterionVisitor/FullText.php @@ -0,0 +1,24 @@ +innerVisitor->canVisit($criterion); + } + + public function visit(Criterion $criterion, ?CriterionVisitor $subVisitor = null): string + { + $condition = $this->escapeQuote($this->innerVisitor->visit($criterion, $subVisitor)); + return sprintf("{!child of='document_type_id:content' v='document_type_id:content AND %s'}", $condition); + } +} diff --git a/lib/Resources/config/search/solr/criterion_visitors.yaml b/lib/Resources/config/search/solr/criterion_visitors.yaml index d1304944..259ffb1e 100644 --- a/lib/Resources/config/search/solr/criterion_visitors.yaml +++ b/lib/Resources/config/search/solr/criterion_visitors.yaml @@ -98,3 +98,15 @@ services: class: Netgen\IbexaSearchExtra\Core\Search\Solr\Query\Content\CriterionVisitor\UserEnabled tags: - { name: ibexa.search.solr.query.content.criterion.visitor } + + netgen.ibexa_search_extra.solr.query.content.criterion_visitor.full_text: + class: Netgen\IbexaSearchExtra\Core\Search\Solr\Query\Content\CriterionVisitor\FullText + factory: [ '@netgen.ibexa_search_extra.solr.query.content.criterion_visitor.full_text_factory', 'createCriterionVisitor' ] + tags: + - { name: ibexa.search.solr.query.content.criterion.visitor } + + netgen.ibexa_search_extra.solr.query.location.criterion_visitor.full_text: + class: Netgen\IbexaSearchExtra\Core\Search\Solr\Query\Location\CriterionVisitor\FullText + factory: [ '@netgen.ibexa_search_extra.solr.query.location.criterion_visitor.full_text_factory', 'createCriterionVisitor' ] + tags: + - { name: ibexa.search.solr.query.location.criterion.visitor } diff --git a/lib/Resources/config/search/solr/field_mappers.yaml b/lib/Resources/config/search/solr/field_mappers.yaml index 12e36eaa..26b9fa4f 100644 --- a/lib/Resources/config/search/solr/field_mappers.yaml +++ b/lib/Resources/config/search/solr/field_mappers.yaml @@ -32,3 +32,12 @@ services: class: Netgen\IbexaSearchExtra\Core\Search\Solr\FieldMapper\Content\UserEnabledFieldMapper tags: - { name: ibexa.search.solr.field.mapper.content } + + netgen.ibexa_search_extra.solr.field_mapper.content.full_text: + class: Netgen\IbexaSearchExtra\Core\Search\Solr\FieldMapper\ContentTranslation\CustomFulltextFieldMapper + arguments: + - '@Ibexa\Contracts\Core\Persistence\Content\Type\Handler' + - '@Ibexa\Core\Search\Common\FieldRegistry' + - '@parameter_bag' + tags: + - { name: ibexa.search.solr.field.mapper.content.translation } diff --git a/lib/Resources/config/search/solr_services.yaml b/lib/Resources/config/search/solr_services.yaml index 627744e0..049a8e23 100644 --- a/lib/Resources/config/search/solr_services.yaml +++ b/lib/Resources/config/search/solr_services.yaml @@ -53,3 +53,18 @@ services: - '@ibexa.solr.query.location.sort_clause_visitor.aggregate' - '@ibexa.solr.query.location.facet_builder_visitor.aggregate' - '@ibexa.solr.query.location.aggregation_visitor.dispatcher' + + netgen.ibexa_search_extra.solr.query.content.criterion_visitor.full_text_factory: + class: Netgen\IbexaSearchExtra\Core\Search\Solr\Query\Content\CriterionVisitor\Factory\ContentFullTextFactory + arguments: + - '@ibexa.solr.query.query_translator.galach.tokenizer' + - '@ibexa.solr.query.query_translator.galach.parser' + - '@ibexa.solr.query.query_translator.galach.generator.edismax' + + netgen.ibexa_search_extra.solr.query.location.criterion_visitor.full_text_factory: + class: Netgen\IbexaSearchExtra\Core\Search\Solr\Query\Location\CriterionVisitor\Factory\LocationFullTextFactory + arguments: + - '@ibexa.solr.query.query_translator.galach.tokenizer' + - '@ibexa.solr.query.query_translator.galach.parser' + - '@ibexa.solr.query.query_translator.galach.generator.edismax' +