diff --git a/src/Utility/FieldsHelper.php b/src/Utility/FieldsHelper.php index c9ddc4c3..3caa4d86 100644 --- a/src/Utility/FieldsHelper.php +++ b/src/Utility/FieldsHelper.php @@ -257,9 +257,13 @@ public function extractItemValues(array $items, array $required_properties, $loa // already been processed in some way, or use a data type that // transformed their original value. But that will hopefully not be a // problem in most situations. - foreach ($this->filterForPropertyPath($item->getFields(FALSE), $datasource_id, $property_path) as $field) { + // In case of duplicates (for configurable fields, mostly) we prefer + // the one matching the given $combined_id, since several callers (for + // instance, the Highlight processor) pass the field ID there. + $field = $this->findField($item->getFields(FALSE), $datasource_id, $property_path, $combined_id); + if ($field) { $item_values[$combined_id] = $field->getValues(); - continue 2; + continue; } // There are no values present on the item for this property. If we @@ -288,9 +292,9 @@ public function extractItemValues(array $items, array $required_properties, $loa // If the index contains a field with that property, just use the // configuration from there instead of the default configuration. // This will probably be what users expect in most situations. - foreach ($this->filterForPropertyPath($index->getFields(), $datasource_id, $property_path) as $field) { + $field = $this->findField($index->getFields(), $datasource_id, $property_path, $combined_id); + if ($field) { $field_info['configuration'] = $field->getConfiguration(); - break; } } $processor_fields[] = $this->createField($index, $combined_id, $field_info); @@ -330,6 +334,36 @@ public function extractItemValues(array $items, array $required_properties, $loa return $extracted_values; } + /** + * Finds a field within an array of fields. + * + * @param \Drupal\search_api\Item\FieldInterface[] $fields + * The fields to search. + * @param string|null $datasource_id + * The datasource ID of the field that should be found. + * @param string $property_path + * The property path of the field that should be found. + * @param string|null $preferred_field_id + * (optional) The preferred field ID: if multiple fields are found matching + * the given datasource and property path, but one has this field ID, then + * that field is returned. Otherwise, the returned field is undefined. + * + * @return \Drupal\search_api\Item\FieldInterface|null + * The found field, or NULL if it couldn't be found. + */ + protected function findField(array $fields, ?string $datasource_id, string $property_path, ?string $preferred_field_id = NULL): ?FieldInterface { + $return = NULL; + foreach ($this->filterForPropertyPath($fields, $datasource_id, $property_path) as $field) { + if ($field->getFieldIdentifier() === $preferred_field_id) { + return $field; + } + elseif (!$return) { + $return = $field; + } + } + return $return; + } + /** * {@inheritdoc} */ diff --git a/tests/src/Kernel/Processor/HighlightExcerptTest.php b/tests/src/Kernel/Processor/HighlightExcerptTest.php new file mode 100644 index 00000000..d2d2ef1e --- /dev/null +++ b/tests/src/Kernel/Processor/HighlightExcerptTest.php @@ -0,0 +1,196 @@ + 'text_long', + 'entity_type' => 'node', + 'field_name' => 'body', + ])->save(); + $this->createContentType(['type' => 'article']); + FieldStorageConfig::create([ + 'type' => 'text_long', + 'entity_type' => 'comment', + 'field_name' => 'comment_body', + ])->save(); + $this->addDefaultCommentField('node', 'article'); + } + + /** + * Tests the generation of excerpts from multiple aggregated fields. + */ + public function testExcerptGenerationMultipleAggregatedFields(): void { + // Set up two aggregated fields, one date field that shouldn't matter and + // one field containing the fulltext contents. + $changed_field = (new Field($this->index, 'changed')) + ->setType('date') + ->setPropertyPath('aggregated_field') + ->setLabel('Changed') + ->setConfiguration([ + 'type' => 'union', + 'fields' => [ + Utility::createCombinedId('entity:comment', 'changed'), + Utility::createCombinedId('entity:node', 'changed'), + ], + ]); + $this->index->addField($changed_field); + + $body_field = (new Field($this->index, 'content')) + ->setType('text') + ->setPropertyPath('aggregated_field') + ->setLabel('Body') + ->setConfiguration([ + 'type' => 'union', + 'fields' => [ + Utility::createCombinedId('entity:comment', 'comment_body'), + Utility::createCombinedId('entity:node', 'body'), + ], + ]); + $this->index->addField($body_field); + + $this->index->save(); + + // Create a node and comment, both of which should contain the word "test" + // in the "content" aggregated field (but not anywhere else). + $node = Node::create([ + 'type' => 'article', + 'title' => 'Foo', + 'body' => 'This is a test for the excerpt.', + 'uid' => 0, + ]); + $node->save(); + $comment = Comment::create([ + 'entity_type' => 'node', + 'entity_id' => $node->id(), + 'name' => 'Carla', + 'mail' => 'foo@example.com', + 'comment_body' => 'Comment on the test node.', + 'comment_type' => 'comment', + 'field_name' => 'comment', + 'pid' => 0, + 'uid' => 0, + 'status' => 1, + ]); + $comment->save(); + + $this->indexItems(); + + // Now search for "test" and verify that the excerpt is not empty and + // contains the highlighted keyword as expected. + $results = $this->index->query() + ->keys('test') + ->execute(); + $this->assertEquals(2, $results->getResultCount()); + $items = $results->getResultItems(); + $this->assertCount(2, $items); + $node_item_id = "entity:node/{$node->id()}:en"; + $this->assertArrayHasKey($node_item_id, $items); + $comment_item_id = "entity:comment/{$comment->id()}:en"; + $this->assertArrayHasKey($comment_item_id, $items); + + $node_result = $items[$node_item_id]; + $excerpt = $node_result->getExcerpt(); + $this->assertNotEmpty($excerpt); + $this->assertStringContainsString('test', $excerpt); + + $comment_result = $items[$comment_item_id]; + $excerpt = $comment_result->getExcerpt(); + $this->assertNotEmpty($excerpt); + $this->assertStringContainsString('test', $excerpt); + + // Simulate a backend that includes the field values in the search results + // and verify that this works correctly in that scenario, too. + $processor = $this->createMock(ProcessorInterface::class); + $processor->method('getPluginId')->willReturn('test'); + $processor->method('supportsStage') + ->willReturnCallback(function (string $stage): bool { + return $stage === ProcessorInterface::STAGE_POSTPROCESS_QUERY; + }); + $processor->method('getWeight')->willReturn(-50); + $processor->method('postprocessSearchResults') + ->willReturnCallback(function (ResultSetInterface $results) use ($node_item_id, $comment_item_id): void { + foreach ($results->getResultItems() as $item_id => $item) { + // Add a flag so we can make sure this processor ran. + $item->setExtraData(static::class, TRUE); + + $changed_field = clone $this->index->getField('changed'); + $item->setField('changed', $changed_field); + $content_field = clone $this->index->getField('content'); + $item->setField('content', $content_field); + switch ($item_id) { + case $node_item_id: + $changed_field->addValue(1234567890); + $content_field->addValue('This is a test for the excerpt.'); + break; + case $comment_item_id: + $changed_field->addValue(1234567890); + $content_field->addValue('Comment on the test node.'); + break; + + default: + assert(FALSE, "Unexpected item ID \"$item_id\"."); + } + } + }); + $this->index->addProcessor($processor); + + // Repeat the search and checks, as above. + $results = $this->index->query() + ->keys('test') + ->execute(); + $this->assertEquals(2, $results->getResultCount()); + $items = $results->getResultItems(); + $this->assertCount(2, $items); + $this->assertArrayHasKey($node_item_id, $items); + $this->assertArrayHasKey($comment_item_id, $items); + + $node_result = $items[$node_item_id]; + $this->assertNotEmpty($node_result->getExtraData(static::class)); + $excerpt = $node_result->getExcerpt(); + $this->assertNotEmpty($excerpt); + $this->assertStringContainsString('test', $excerpt); + + $comment_result = $items[$comment_item_id]; + $this->assertNotEmpty($comment_result->getExtraData(static::class)); + $excerpt = $comment_result->getExcerpt(); + $this->assertNotEmpty($excerpt); + $this->assertStringContainsString('test', $excerpt); + } + +}