diff --git a/search_api.api.php b/search_api.api.php index 0029319..696be2b 100644 --- a/search_api.api.php +++ b/search_api.api.php @@ -100,6 +100,10 @@ function hook_search_api_field_type_mapping_alter(array &$mapping) { /** * Alter the mapping of Search API data types to their default Views handlers. * + * Field handlers are not determined by these simplified (Search API) types, but + * by their actual property data types. For altering that mapping, see + * hook_search_api_views_field_handler_mapping_alter(). + * * @param array $mapping * An associative array with data types as the keys and Views table data * definition items as the values. In addition to all normally defined Search @@ -113,9 +117,6 @@ function hook_search_api_views_handler_mapping_alter(array &$mapping) { 'argument' => array( 'id' => 'my_entity_type', ), - 'field' => array( - 'id' => 'my_entity_type', - ), 'filter' => array( 'id' => 'my_entity_type', ), @@ -133,13 +134,19 @@ function hook_search_api_views_handler_mapping_alter(array &$mapping) { * API-specific field handlers for all properties of datasources and some entity * types. * + * In addition to the definition returned here, for Field API fields, the + * "field_name" will be set to the field's machine name. + * * @param array $mapping * An associative array with property data types as the keys and Views field * handler definitions as the values (i.e., just the inner "field" portion of * Views data definition items). In some cases the value might also be NULL * instead, to indicate that properties of this type shouldn't have field - * handlers. Any data types not listed will map to the "search_api" field - * handler. + * handlers. The data types in the keys might also contain asterisks (*) as + * wildcard characters. Data types with wildcards will be matched only if no + * specific type exists, and longer type patterns will be tried before shorter + * ones. The "*" mapping therefore is the default if no other match could be + * found. */ function hook_search_api_views_field_handler_mapping_alter(array &$mapping) { $mapping['field_item:string_long'] = array( diff --git a/search_api.views.inc b/search_api.views.inc index 3658060..7dd35b1 100644 --- a/search_api.views.inc +++ b/search_api.views.inc @@ -8,6 +8,7 @@ use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\TypedData\FieldItemDataDefinition; use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\Core\TypedData\DataReferenceDefinitionInterface; use Drupal\search_api\Datasource\DatasourceInterface; use Drupal\search_api\Entity\Index; use Drupal\search_api\Item\FieldInterface; @@ -57,7 +58,7 @@ function search_api_views_data() { // The field handler has to be extra, since it is a) determined by the // field's underlying property and b) needs a different "real field" // set. - $field_handler = _search_api_views_get_field_handler_for_property($field->getDataDefinition()); + $field_handler = _search_api_views_get_field_handler_for_property($field->getDataDefinition(), $field->getPropertyPath()); if ($field_handler) { $field_definition['field'] = $field_handler; $field_definition['field']['real field'] = $field->getCombinedPropertyPath(); @@ -428,6 +429,7 @@ function _search_api_views_datasource_table(DatasourceInterface $datasource, arr $entity_type_id = $datasource->getEntityTypeId(); if ($entity_type_id) { $table['table']['entity type'] = $entity_type_id; + $table['table']['entity revision'] = FALSE; } _search_api_views_add_handlers_for_properties($datasource->getPropertyDefinitions(), $table, $data); @@ -440,6 +442,14 @@ function _search_api_views_datasource_table(DatasourceInterface $datasource, arr $real_field = isset($definition['real field']) ? $definition['real field'] : $key; $table[$key]['real field'] = Utility::createCombinedId($datasource_id, $real_field); + + // Relationships sometimes have different real fields set, since they might + // also include the nested property that contains the actual reference. So, + // if a "real field" is set for that, we need to adapt it as well. + if (isset($definition['relationship']['real field'])) { + $real_field = $definition['relationship']['real field']; + $table[$key]['relationship']['real field'] = Utility::createCombinedId($datasource_id, $real_field); + } } return $table; @@ -467,6 +477,7 @@ function _search_api_views_entity_type_table($entity_type_id, array &$data) { 'table' => array( 'group' => $entity_type->getLabel(), 'entity type' => $entity_type_id, + 'entity revision' => FALSE, ), ); @@ -505,7 +516,7 @@ function _search_api_views_add_handlers_for_properties(array $properties, array $property = Utility::getInnerProperty($property); // Add a field handler, if applicable. - $definition = _search_api_views_get_field_handler_for_property($property); + $definition = _search_api_views_get_field_handler_for_property($property, $property_path); if ($definition) { $table[$key]['field'] = $definition; } @@ -517,13 +528,15 @@ function _search_api_views_add_handlers_for_properties(array $properties, array if ($entity_type_id) { $entity_type_table_key = 'search_api_entity_' . $entity_type_id; if (!isset($data[$entity_type_table_key])) { - // Initialize the table definition to avoid infinite recursion in this - // line of code. + // Initialize the table definition before calling + // _search_api_views_entity_type_table() to avoid an infinite + // recursion. $data[$entity_type_table_key] = array(); $data[$entity_type_table_key] = _search_api_views_entity_type_table($entity_type_id, $data); } // Add the relationship only if we have a non-empty table definition. if ($data[$entity_type_table_key]) { + // Get the entity type to determine the label for the relationship. $entity_type = \Drupal::entityTypeManager() ->getDefinition($entity_type_id); $entity_type_label = $entity_type ? $entity_type->getLabel() : $entity_type_id; @@ -531,6 +544,16 @@ function _search_api_views_add_handlers_for_properties(array $properties, array '@label' => $entity_type_label, '@field_name' => $original_property->getLabel(), ); + // Look through the child properties to find the data reference + // property that should be the "real field" for the relationship. + // (For Core entity references, this will usually be ":entity".) + $suffix = ''; + foreach ($property->getPropertyDefinitions() as $name => $nested_property) { + if ($nested_property instanceof DataReferenceDefinitionInterface) { + $suffix = ":$name"; + break; + } + } $table[$key]['relationship'] = array( 'title' => t('@label referenced from @field_name', $args), 'label' => t('@field_name: @label', $args), @@ -538,10 +561,9 @@ function _search_api_views_add_handlers_for_properties(array $properties, array 'id' => 'search_api', 'base' => $entity_type_table_key, 'entity type' => $entity_type_id, + 'entity revision' => FALSE, + 'real field' => $property_path . $suffix, ); - if ($key != $property_path) { - $field_definition['real field'] = $property_path; - } } } } @@ -549,6 +571,9 @@ function _search_api_views_add_handlers_for_properties(array $properties, array if (!empty($table[$key]) && empty($table[$key]['title'])) { $table[$key]['title'] = $original_property->getLabel(); $table[$key]['help'] = $original_property->getDescription(); + if ($key != $property_path) { + $table[$key]['real field'] = $property_path; + } } } } @@ -558,6 +583,9 @@ function _search_api_views_add_handlers_for_properties(array $properties, array * * @param \Drupal\Core\TypedData\DataDefinitionInterface $property * The property definition. + * @param string|null $property_path + * (optional) The property path of the property. If set, it will be used for + * Field API fields to set the "field_name" property of the definition. * * @return array|null * Either a Views field handler definition for this property, or NULL if the @@ -565,11 +593,16 @@ function _search_api_views_add_handlers_for_properties(array $properties, array * * @see hook_search_api_views_field_handler_mapping_alter() */ -function _search_api_views_get_field_handler_for_property(DataDefinitionInterface $property) { - $mapping = &drupal_static(__FUNCTION__); +function _search_api_views_get_field_handler_for_property(DataDefinitionInterface $property, $property_path = NULL) { + $mappings = &drupal_static(__FUNCTION__); - if (!isset($mapping)) { - $mapping = array(); + if (!isset($mappings)) { + // First create a plain mapping and pass it to the alter hook. + $plain_mapping = array(); + + $plain_mapping['*'] = array( + 'id' => 'search_api', + ); // $mapping['field_item:text_long'] = // $mapping['field_item:text_with_summary'] = array( @@ -597,14 +630,14 @@ function _search_api_views_get_field_handler_for_property(DataDefinitionInterfac // 'id' => 'search_api_numeric', // 'float' => TRUE, // ); -// -// $mapping['field_item:created'] = -// $mapping['field_item:changed'] = -// $mapping['datetime_iso8601'] = -// $mapping['timestamp'] = array( -// 'id' => 'search_api_date', -// ); -// + + $plain_mapping['field_item:created'] = + $plain_mapping['field_item:changed'] = + $plain_mapping['datetime_iso8601'] = + $plain_mapping['timestamp'] = array( + 'id' => 'search_api_date', + ); + // $mapping['boolean'] = // $mapping['field_item:boolean'] = array( // 'id' => 'search_api_boolean', @@ -616,17 +649,65 @@ function _search_api_views_get_field_handler_for_property(DataDefinitionInterfac // ); $alter_id = 'search_api_views_field_handler_mapping'; - \Drupal::moduleHandler()->alter($alter_id, $mapping); + \Drupal::moduleHandler()->alter($alter_id, $plain_mapping); + + // Then create a new, more practical structure, with the mappings grouped by + // mapping type. + $mappings = array( + 'simple' => array(), + 'regex' => array(), + 'default' => NULL, + ); + foreach ($plain_mapping as $type => $definition) { + if ($type == '*') { + $mappings['default'] = $definition; + } + elseif (strpos($type, '*') === FALSE) { + $mappings['simple'][$type] = $definition; + } + else { + // Transform the type into a PCRE regular expression, taking care to + // quote everything except for the wildcards. + $parts = explode('*', $type); + // Passing the second parameter to preg_quote() is a bit tricky with + // array_map(), we need to construct an array of slashes. + $slashes = array_fill(0, count($parts), '/'); + $parts = array_map('preg_quote', $parts, $slashes); + // Use the "S" modifier for closer analysis of the pattern, since it + // might be executed a lot. (The docs say this won't get us anything in + // our case, but this might change, or be different, e.g., in HHVM.) + $regex = '/^' . implode('.*', $parts) . '$/S'; + $mappings['regex'][$regex] = $definition; + } + } + // Finally, order the regular expressions descending by their lengths. + $compare = function ($a, $b) { + return strlen($b) - strlen($a); + }; + uksort($mappings['regex'], $compare); } + // First, look for an exact match. $data_type = $property->getDataType(); - if (array_key_exists($data_type, $mapping)) { - $definition = $mapping[$data_type]; + if (array_key_exists($data_type, $mappings['simple'])) { + $definition = $mappings['simple'][$data_type]; } else { - $definition = array( - 'id' => 'search_api', - ); + // Then check all the patterns defined by regular expressions, defaulting to + // the "default" definition. + $definition = $mappings['default']; + foreach (array_keys($mappings['regex']) as $regex) { + if (preg_match($regex, $data_type)) { + $definition = $mappings['regex'][$regex]; + } + } + } + + // If there is a definition and the property represents a Field API field, add + // the "field_name" key. + if (isset($definition) && $property instanceof FieldItemDataDefinition) { + list(, $field_name) = Utility::splitPropertyPath($property_path, TRUE); + $definition['field_name'] = $field_name; } return $definition; diff --git a/src/Plugin/views/SearchApiHandlerTrait.php b/src/Plugin/views/SearchApiHandlerTrait.php index 773b9c0..4293cc5 100644 --- a/src/Plugin/views/SearchApiHandlerTrait.php +++ b/src/Plugin/views/SearchApiHandlerTrait.php @@ -10,7 +10,7 @@ namespace Drupal\search_api\Plugin\views; use Drupal\search_api\Plugin\views\query\SearchApiQuery; /** - * Provides a trait to use for Search API Views filters. + * Provides a trait to use for Search API Views handlers. */ trait SearchApiHandlerTrait { diff --git a/src/Plugin/views/field/SearchApiDate.php b/src/Plugin/views/field/SearchApiDate.php new file mode 100644 index 0000000..62923d4 --- /dev/null +++ b/src/Plugin/views/field/SearchApiDate.php @@ -0,0 +1,24 @@ +definition['click sortable']); - } - - /** - * {@inheritdoc} - */ - public function query() { - $this->addRetrievedProperty($this->getCombinedPropertyPath()); - } - - /** - * Adds a property to be retrieved. - * - * @param string $combined_property_path - * The combined property path of the property that should be retrieved. - * "_object" can be used as a property name to indicate the loaded object is - * required. - * - * @return $this - */ - protected function addRetrievedProperty($combined_property_path) { - $this->getQuery()->addRetrievedProperty($combined_property_path); - - list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path); - $this->retrievedProperties[$datasource_id][$property_path] = $combined_property_path; - return $this; - } - - /** - * {@inheritdoc} - */ - public function preRender(&$values) { - // Determine which rows need to be considered (because they have the right - // datasource), which have or don't have all necessary properties already - // set and for which of them the items need to be loaded. - // @todo Should attempt to also multi-load related entities, not just the - // direct result objects. Much more complicated, though, since knowing the - // IDs of related entities requires the loaded parent objects. - $index = $this->getIndex(); - $items_to_load = array(); - $missing_properties = array(); - foreach ($values as $i => $row) { - if (!$this->isActiveForRow($row)) { - continue; - } - - $datasource_id = $row->search_api_datasource; - - $missing_properties[$i] = array(); - if (!empty($this->retrievedProperties[$datasource_id])) { - $missing_properties[$i] = array_flip($this->retrievedProperties[$datasource_id]); - } - $missing_properties[$i] = array_diff_key($missing_properties[$i], (array) $row); - - if ($missing_properties[$i] && empty($row->_object)) { - $items_to_load[$i] = $row->search_api_id; - } - } - - $items = $index->loadItemsMultiple($items_to_load); - foreach ($items_to_load as $i => $item_id) { - if (isset($items[$item_id])) { - $values[$i]->_object = $items[$item_id]; - $values[$i]->_item->setOriginalObject($items[$item_id]); - } - } - - foreach ($missing_properties as $i => $properties) { - if (empty($values[$i]->_object)) { - continue; - } - /** @var \Drupal\search_api\Item\FieldInterface[] $to_extract */ - $to_extract = array(); - foreach ($properties as $combined_property_path => $property_path) { - $field = Utility::createField($index, $combined_property_path) - ->setPropertyPath($property_path); - $to_extract[$property_path] = $field; - } - Utility::extractFields($values[$i]->_object, $to_extract); - foreach ($to_extract as $field) { - $combined_property_path = $field->getFieldIdentifier(); - $values[$i]->$combined_property_path = $field->getValues(); - } - } - } - - /** - * Determines whether this field is active for the given row. - * - * This is usually determined by the row's datasource. - * - * @param \Drupal\views\ResultRow $row - * The result row. - * - * @return bool - * TRUE if this field handler might produce output for the given row, FALSE - * otherwise. - */ - protected function isActiveForRow(ResultRow $row) { - $datasource_id = $this->getDatasourceId(); - return !$datasource_id || $row->search_api_datasource === $datasource_id; - } - - /** - * Retrieves the combined property path of this field. - * - * @return string - * The combined property path. - */ - protected function getCombinedPropertyPath() { - if (!isset($this->combinedPropertyPath)) { - // Add the property path of any relationships used to arrive at this - // field. - $path = $this->realField; - $relationships = $this->view->relationship; - $relationship = $this; - while (!empty($relationship->options['relationship'])) { - if (empty($relationships[$relationship->options['relationship']])) { - break; - } - $relationship = $relationships[$relationship->options['relationship']]; - // @todo Dirty hack. - $path = $relationship->realField . ':entity:' . $path; - } - $this->combinedPropertyPath = $path; - } - return $this->combinedPropertyPath; - } - - /** - * Retrieves the ID of the datasource to which this field belongs. - * - * @return string|null - * The datasource ID of this field, or NULL if it doesn't belong to a - * specific datasource. - */ - protected function getDatasourceId() { - if (!isset($this->datasourceId)) { - list($this->datasourceId) = Utility::splitCombinedId($this->getCombinedPropertyPath()); - } - return $this->datasourceId; - } + use SearchApiFieldTrait; /** * {@inheritdoc} */ public function render_item($count, $item) { - return $item['value']; - } - - /** - * {@inheritdoc} - */ - public function getItems(ResultRow $values) { - $property_path = $this->getCombinedPropertyPath(); - if (!empty($values->{$property_path})) { - // Although it's undocumented, the field handler base class assumes items - // will always be arrays. - $items = array(); - foreach ((array) $values->{$property_path} as $value) { - $items[] = array( - 'value' => $value, - ); - } - return $items; - } - return array(); + $type = !empty($this->definition['filter_type']) ? $this->definition['filter_type'] : 'plain'; + return $this->sanitizeValue($item['value'], $type); } } diff --git a/src/Plugin/views/field/SearchApiFieldTrait.php b/src/Plugin/views/field/SearchApiFieldTrait.php new file mode 100644 index 0000000..596ec68 --- /dev/null +++ b/src/Plugin/views/field/SearchApiFieldTrait.php @@ -0,0 +1,530 @@ + array( + * - 'default' => default value, + * - 'contains' => (optional) array of items this contains, with its own + * defaults, etc. If contains is set, the default will be ignored and + * assumed to be array(). + * ), + * @endcode + * + * @return array + * Returns the options of this handler/plugin. + * + * @see \Drupal\views\Plugin\views\PluginBase::defineOptions() + */ + protected function defineOptions() { + $options = parent::defineOptions(); + + $options['link_to_item'] = array('default' => FALSE); + + if ($this instanceof MultiItemsFieldHandlerInterface) { + $options['type'] = array('default' => 'separator'); + $options['separator'] = array('default' => ', '); + } + + return $options; + } + + /** + * Provide a form to edit options for this plugin. + * + * @param array $form + * The existing form structure, passed by reference. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current form state. + * + * @see \Drupal\views\Plugin\views\ViewsPluginInterface::buildOptionsForm() + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + + $form['link_to_item'] = array( + '#type' => 'checkbox', + '#title' => $this->t('Link this field to its item'), + '#description' => $this->t('Display this field as a link to its original entity or item.'), + '#default_value' => $this->options['link_to_item'], + ); + + if ($this instanceof MultiItemsFieldHandlerInterface) { + $form['multi_value_settings'] = array( + '#type' => 'details', + '#title' => $this->t('Multiple values handling'), + '#description' => $this->t('If this field contains multiple values for an item, these settings will determine how they are handled.'), + '#weight' => 80, + ); + + $form['type'] = array( + '#type' => 'radios', + '#title' => $this->t('Display type'), + '#options' => array( + 'ul' => $this->t('Unordered list'), + 'ol' => $this->t('Ordered list'), + 'separator' => $this->t('Simple separator'), + ), + '#default_value' => $this->options['type'], + '#fieldset' => 'multi_value_settings', + ); + $form['separator'] = array( + '#type' => 'textfield', + '#title' => $this->t('Separator'), + '#default_value' => $this->options['separator'], + '#states' => array( + 'visible' => array( + ':input[name="options[type]"]' => array('value' => 'separator'), + ), + ), + '#fieldset' => 'multi_value_settings', + ); + } + + // @todo Field API field handling. + } + + /** + * Determines if this field is click sortable. + * + * @return bool + * The value of "click sortable" from the plugin definition, this defaults + * to FALSE if not set. + * + * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::clickSortable() + */ + public function clickSortable() { + // Almost the same logic as in the parent class, but we want to default to + // FALSE. + return !empty($this->definition['click sortable']); + } + + /** + * Add anything to the query that we might need to. + * + * @see \Drupal\views\Plugin\views\ViewsPluginInterface::query() + */ + public function query() { + $combined_property_path = $this->getCombinedPropertyPath(); + $this->addRetrievedProperty($combined_property_path); + if ($this->options['link_to_item']) { + $this->addRetrievedProperty("$combined_property_path:_object"); + } + } + + /** + * Adds a property to be retrieved. + * + * @param string $combined_property_path + * The combined property path of the property that should be retrieved. + * "_object" can be used as a property name to indicate the loaded object is + * required. + * + * @return $this + */ + protected function addRetrievedProperty($combined_property_path) { + $this->getQuery()->addRetrievedProperty($combined_property_path); + + list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path); + $this->retrievedProperties[$datasource_id][$property_path] = $combined_property_path; + return $this; + } + + /** + * Gets the value that's supposed to be rendered. + * + * This API exists so that other modules can easily set the values of the + * field without having the need to change the render method as well. + * + * Overridden here to provide an easy way to let this method return arbitrary + * values, without actually touching the $values array. + * + * @param \Drupal\views\ResultRow $values + * An object containing all retrieved values. + * @param string $field + * Optional name of the field where the value is stored. + * + * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::getValue() + */ + public function getValue(ResultRow $values, $field = NULL) { + if (isset($this->overriddenValues[$field])) { + return $this->overriddenValues[$field]; + } + + return parent::getValue($values, $field); + } + + /** + * Runs before any fields are rendered. + * + * This gives the handlers some time to set up before any handler has + * been rendered. + * + * @param \Drupal\views\ResultRow[] $values + * An array of all ResultRow objects returned from the query. + * + * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::preRender() + */ + public function preRender(&$values) { + // @todo This works quite well, but will load each item/entity individually. + // Instead, we should exploit the workflow of proceeding by each property + // on its own to multi-load as much as possible (maybe even entities of + // the same type from different properties). + // @todo Also, this will unnecessarily load items/entities even if all + // required fields are provided in the results. However, to solve this, + // expandRequiredProperties() would have to + $required_properties = $this->expandRequiredProperties(); + /** @var \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $data_definitions */ + $data_definitions = array(); + + foreach ($required_properties as $datasource_id => $properties) { + foreach ($properties as $property_path => $combined_property_path) { + list($parent_path, $name) = Utility::splitPropertyPath($property_path); + if ($name == '_object') { + continue; + } + $combined_parent_path = Utility::createCombinedId($datasource_id, $parent_path); + $to_load = array(); + +// if ($parent_path) { +// if (empty($data_definitions[$parent_path])) { +// continue; +// } +// $parent_definitions = $data_definitions[$parent_path]->getPropertyDefinitions(); +// } +// else { +// $parent_definitions = $this->getIndex()->getPropertyDefinitions($datasource_id, FALSE); +// } +// if (!isset($parent_definitions[$name])) { +// continue; +// } +// $definition = Utility::getInnerProperty($parent_definitions[$name]); +// $data_definitions[$property_path] = $definition; + + foreach ($values as $i => $row) { + if ($datasource_id != $row->search_api_datasource || !$this->isActiveForRow($row)) { + continue; + } + if (isset($row->$combined_property_path)) { + continue; + } + if (empty($row->_relationship_objects[$parent_path])) { + if ($parent_path) { + continue; + } + else { + $row->_relationship_objects[$parent_path] = array($row->_item->getOriginalObject()); + } + } + + $row->$combined_property_path = array(); + foreach ($row->_relationship_objects[$parent_path] as $parent) { + while ($parent instanceof DataReferenceInterface) { + $parent = $parent->getTarget(); + } + if (!($parent instanceof ComplexDataInterface)) { + continue; + } + $typed_data = $parent->get($name); + if (isset($this->retrievedProperties[$datasource_id][$property_path])) { + $row->$combined_property_path[] = Utility::extractFieldValues($typed_data); + } + if ($typed_data instanceof ListInterface) { + foreach ($typed_data as $item) { + $row->_relationship_objects[$property_path][] = $item; + } + } + else { + $row->_relationship_objects[$property_path][] = $typed_data; + } + } + if ($row->$combined_property_path) { + $row->$combined_property_path = call_user_func_array('array_merge', $row->$combined_property_path); + } + } + } + } + } + + /** + * Expands the properties to retrieve for this field. + * + * The properties are taken from this object's $retrievedProperties property, + * with all their ancestors also added to the array, with the ancestor + * properties always ordered before their descendants. + * + * This will ensure, when dealing with these properties sequentially, that + * the parent object necessary to load the "child" property is always already + * loaded. + * + * @return string[][] + * The combined property paths to retrieve, keyed by their datasource ID and + * property path. + */ + protected function expandRequiredProperties() { + $required_properties = array(); + foreach ($this->retrievedProperties as $datasource_id => $properties) { + foreach (array_keys($properties) as $property_path) { + $path_to_add = ''; + foreach (explode(':', $property_path) as $component) { + $path_to_add .= ($path_to_add ? ':' : '') . $component; + if (!isset($required_properties[$path_to_add])) { + $required_properties[$datasource_id][$path_to_add] = Utility::createCombinedId($datasource_id, $path_to_add); + } + } + } + } + return $required_properties; + } + + /** + * Determines whether this field is active for the given row. + * + * This is usually determined by the row's datasource. + * + * @param \Drupal\views\ResultRow $row + * The result row. + * + * @return bool + * TRUE if this field handler might produce output for the given row, FALSE + * otherwise. + */ + protected function isActiveForRow(ResultRow $row) { + $datasource_id = $this->getDatasourceId(); + return !$datasource_id || $row->search_api_datasource === $datasource_id; + } + + /** + * Retrieves the combined property path of this field. + * + * @return string + * The combined property path. + */ + protected function getCombinedPropertyPath() { + if (!isset($this->combinedPropertyPath)) { + // Add the property path of any relationships used to arrive at this + // field. + $path = $this->realField; + $relationships = $this->view->relationship; + $relationship = $this; + while (!empty($relationship->options['relationship'])) { + if (empty($relationships[$relationship->options['relationship']])) { + break; + } + $relationship = $relationships[$relationship->options['relationship']]; + $path = $relationship->realField . ':' . $path; + } + $this->combinedPropertyPath = $path; + } + return $this->combinedPropertyPath; + } + + /** + * Retrieves the ID of the datasource to which this field belongs. + * + * @return string|null + * The datasource ID of this field, or NULL if it doesn't belong to a + * specific datasource. + */ + protected function getDatasourceId() { + if (!isset($this->datasourceId)) { + list($this->datasourceId) = Utility::splitCombinedId($this->getCombinedPropertyPath()); + } + return $this->datasourceId; + } + + /** + * Renders a single item of a row. + * + * @param int $count + * The index of the item inside the row. + * @param mixed $item + * The item for the field to render. + * + * @return string + * The rendered output. + * + * @see \Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface::render_item() + */ + public function render_item($count, $item) { + $this->overriddenValues[NULL] = $item['value']; + $render = $this->render(new ResultRow()); + $this->overriddenValues = array(); + return $render; + } + + /** + * Gets an array of items for the field. + * + * Items should be associative arrays with, if possible, "value" as the actual + * displayable value of the item, plus any items that might be found in the + * "alter" options array for creating links, etc., such as "path", "fragment", + * "query", etc. Additionally, items that might be turned into tokens should + * also be in this array. + * + * @param \Drupal\views\ResultRow $values + * The result row object containing the values. + * + * @return array[] + * An array of items for the field, with each item being an array itself. + * + * @see \Drupal\views\Plugin\views\field\PrerenderList::getItems() + */ + public function getItems(ResultRow $values) { + $property_path = $this->getCombinedPropertyPath(); + if (!empty($values->{$property_path})) { + // Although it's undocumented, the field handler base class assumes items + // will always be arrays. + $items = array(); + foreach ((array) $values->{$property_path} as $i => $value) { + $item = array( + 'value' => $value, + ); + + if ($this->options['link_to_item']) { + $item['make_link'] = TRUE; + $item['url'] = $this->getItemUrl($values, $i); + } + + $items[] = $item; + } + return $items; + } + return array(); + } + + /** + * Renders all items in this field together. + * + * @param array $items + * The items provided by getItems() for a single row. + * + * @return string + * The rendered items. + * + * @see \Drupal\views\Plugin\views\field\PrerenderList::renderItems() + */ + public function renderItems($items) { + if (!empty($items)) { + if ($this->options['type'] == 'separator') { + $render = array( + '#type' => 'inline_template', + '#template' => '{{ items|safe_join(separator) }}', + '#context' => array( + 'items' => $items, + 'separator' => $this->sanitizeValue($this->options['separator'], 'xss_admin'), + ), + ); + } + else { + $render = array( + '#theme' => 'item_list', + '#items' => $items, + '#title' => NULL, + '#list_type' => $this->options['type'], + ); + } + return $this->getRenderer()->render($render); + } + return ''; + } + + /** + * Retrieves an alter options array for linking the given value to its item. + * + * @param \Drupal\views\ResultRow $row + * The Views result row object. + * @param int $i + * The index in this field's values for which the item link should be + * retrieved. + * + * @return \Drupal\Core\Url|null + * The URL for the specified item, or NULL if it couldn't be found. + */ + protected function getItemUrl(ResultRow $row, $i) { + list($datasource_id, $property_path) = Utility::splitCombinedId($this->getCombinedPropertyPath()); + + // @todo This will work in most cases, but might fail when linking a multi- + // valued non-entity field's values to its containing entity (since then + // $i will be off one level up). Rare use case, though, probably. + while (!empty($row->_relationship_objects[$property_path][$i])) { + /** @var \Drupal\Core\TypedData\TypedDataInterface $object */ + $object = $row->_relationship_objects[$property_path][$i]; + if (!$property_path) { + return $this->getIndex() + ->getDatasource($datasource_id) + ->getItemUrl($object); + } + $value = $object->getValue(); + if ($value instanceof EntityInterface) { + return $value->toUrl('canonical'); + } + list($property_path) = Utility::splitPropertyPath($property_path); + } + + return NULL; + } + +} diff --git a/src/Plugin/views/query/SearchApiQuery.php b/src/Plugin/views/query/SearchApiQuery.php index 47ee9d7..d42cc94 100644 --- a/src/Plugin/views/query/SearchApiQuery.php +++ b/src/Plugin/views/query/SearchApiQuery.php @@ -483,7 +483,11 @@ class SearchApiQuery extends QueryPluginBase { foreach ($results as $item_id => $result) { $values = array(); $values['_item'] = $result; - $values['_object'] = $result->getOriginalObject(FALSE); + $object = $result->getOriginalObject(FALSE); + if ($object) { + $values['_object'] = $object; + $values['_relationship_objects'][NULL] = array($object); + } $values['search_api_id'] = $item_id; $values['search_api_datasource'] = $result->getDatasourceId(); $values['search_api_relevance'] = $result->getScore(); diff --git a/src/Utility.php b/src/Utility.php index 363dd93..26a9bfe 100644 --- a/src/Utility.php +++ b/src/Utility.php @@ -243,33 +243,53 @@ class Utility { * The field into which to put the extracted data. */ public static function extractField(TypedDataInterface $data, FieldInterface $field) { + $values = static::extractFieldValues($data); + + // If the data type of the field is a custom one, then the value can be + // altered by the data type plugin. + $data_type_manager = \Drupal::service('plugin.manager.search_api.data_type'); + if ($data_type_manager->hasDefinition($field->getType())) { + /** @var \Drupal\search_api\DataType\DataTypeInterface $data_type_plugin */ + $data_type_plugin = $data_type_manager->createInstance($field->getType()); + foreach ($values as $i => $value) { + $values[$i] = $data_type_plugin->getValue($value); + } + } + + $field->setValues($values); + $field->setOriginalType($data->getDataDefinition()->getDataType()); + } + + /** + * Extracts field values from a typed data object. + * + * @param \Drupal\Core\TypedData\TypedDataInterface $data + * The typed data object. + * + * @return array + * An array of values. + */ + public static function extractFieldValues(TypedDataInterface $data) { if ($data->getDataDefinition()->isList()) { + $values = array(); foreach ($data as $piece) { - self::extractField($piece, $field); + $values[] = self::extractFieldValues($piece); } - return; + return $values ? call_user_func_array('array_merge', $values) : array(); } + $value = $data->getValue(); $definition = $data->getDataDefinition(); if ($definition instanceof ComplexDataDefinitionInterface) { $property = $definition->getMainPropertyName(); if (isset($value[$property])) { - $value = $value[$property]; + return array($value[$property]); } } elseif (is_array($value)) { - $value = reset($value); - } - - // If the data type of the field is a custom one, then the value can be - // altered by the data type plugin. - $data_type_manager = \Drupal::service('plugin.manager.search_api.data_type'); - if ($data_type_manager->hasDefinition($field->getType())) { - $value = $data_type_manager->createInstance($field->getType())->getValue($value); + return array_values($value); } - - $field->addValue($value); - $field->setOriginalType($definition->getDataType()); + return array($value); } /** @@ -326,6 +346,41 @@ class Utility { } /** + * Splits a property path into two parts along a path separator (:). + * + * The path is split into one part with a single property name, and one part + * with the complete rest of the property path (which might be empty). + * Depending on $separate_last the returned single property key will be the + * first (FALSE) or last (TRUE) property of the path. + * + * @param string $property_path + * The property path to split. + * @param bool $separate_last + * (optional) If TRUE, separate the last (leaf) property of the path. By + * default, the first property is separated from the rest. + * @param string $separator + * (optional) The separator to use. + * + * @return string[] + * A string with indexes 0 and 1, 0 containing the first part of the + * property path and 1 the second. If $separate_last is FALSE, index 0 will + * always contain a single property name (without any colons) and index 1 + * might be NULL. If $separate_last is TRUE it's the exact other way round. + */ + public static function splitPropertyPath($property_path, $separate_last = TRUE, $separator = ':') { + $function = $separate_last ? 'strrpos' : 'strpos'; + $pos = $function($property_path, $separator); + if ($pos !== FALSE) { + return array( + substr($property_path, 0, $pos), + substr($property_path, $pos + 1), + ); + } + + return $separate_last ? array(NULL, $property_path) : array($property_path, NULL); + } + + /** * Determines whether a field ID is reserved for special use. * * This is the case for the "magic" pseudo-fields documented in