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);
+ }
+
+}