search_api-8.x-1.15/src/Plugin/views/field/SearchApiFieldTrait.php
src/Plugin/views/field/SearchApiFieldTrait.php
<?php namespace Drupal\search_api\Plugin\views\field; use Drupal\Component\Render\MarkupInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\TypedData\ComplexDataInterface; use Drupal\Core\TypedData\DataReferenceInterface; use Drupal\Core\TypedData\ListInterface; use Drupal\Core\TypedData\TranslatableInterface; use Drupal\Core\TypedData\TypedDataManagerInterface; use Drupal\search_api\LoggerTrait; use Drupal\search_api\Plugin\views\SearchApiHandlerTrait; use Drupal\search_api\Processor\ConfigurablePropertyInterface; use Drupal\search_api\Processor\ProcessorPropertyInterface; use Drupal\search_api\SearchApiException; use Drupal\search_api\Utility\FieldsHelperInterface; use Drupal\search_api\Utility\Utility; use Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface; use Drupal\views\ResultRow; /** * Provides a trait to use for Search API Views field handlers. * * Multi-valued field handling is taken from * \Drupal\views\Plugin\views\field\PrerenderList. * * Note: Some method parameters are documented as type array|\ArrayAccess. This * is just done to avoid the code sniffer complaining about the missing "array" * type hint (since it's impossible to add it, due to the Views parent plugin * classes not having that type hint, either). */ trait SearchApiFieldTrait { use LoggerTrait; use SearchApiHandlerTrait; /** * Contains the properties needed by this field handler. * * The array is keyed by datasource ID (which might be NULL) and property * path, the values are the combined property paths. * * @var string[][] */ protected $retrievedProperties = []; /** * The combined property path of this field. * * @var string|null */ protected $combinedPropertyPath; /** * The datasource ID of this field, if any. * * @var string|null */ protected $datasourceId; /** * Contains overridden values to be returned on the next getValue() call. * * The values are keyed by the field given as $field in the call, so that it's * possible to return different values based on the field. * * @var array * * @see SearchApiFieldTrait::getValue() */ protected $overriddenValues = []; /** * Index in the current row's field values that is currently displayed. * * @var int * * @see SearchApiFieldTrait::getEntity() */ protected $valueIndex = 0; /** * The account to use for access checks for this search. * * @var \Drupal\Core\Session\AccountInterface|false|null * * @see \Drupal\search_api\Plugin\views\field\SearchApiFieldTrait::checkEntityAccess() */ protected $accessAccount; /** * Associative array keyed by property paths for which to skip access checks. * * Values are all TRUE. * * @var bool[] */ protected $skipAccessChecks = []; /** * Array of replacement property paths to use when getting field values. * * @var string[] * * @see \Drupal\search_api\Plugin\views\field\SearchApiFieldTrait::extractProcessorProperty() */ protected $propertyReplacements = []; /** * The fields helper. * * @var \Drupal\search_api\Utility\FieldsHelperInterface|null */ protected $fieldsHelper; /** * The typed data manager. * * @var \Drupal\Core\TypedData\TypedDataManagerInterface|null */ protected $typedDataManager; /** * The entity type manager. * * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ protected $entityTypeManager; /** * Retrieves the typed data manager. * * @return \Drupal\Core\TypedData\TypedDataManagerInterface * The typed data manager. */ public function getTypedDataManager() { return $this->typedDataManager ?: \Drupal::service('typed_data_manager'); } /** * Sets the typed data manager. * * @param \Drupal\Core\TypedData\TypedDataManagerInterface $typed_data_manager * The new typed data manager. * * @return $this */ public function setTypedDataManager(TypedDataManagerInterface $typed_data_manager) { $this->typedDataManager = $typed_data_manager; return $this; } /** * Retrieves the entity type manager. * * @return \Drupal\Core\Entity\EntityTypeManagerInterface * The entity type manager. */ public function getEntityTypeManager() { return $this->entityTypeManager ?: \Drupal::entityTypeManager(); } /** * Sets the entity type manager. * * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. * * @return $this */ public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) { $this->entityTypeManager = $entity_type_manager; return $this; } /** * Retrieves the fields helper. * * @return \Drupal\search_api\Utility\FieldsHelperInterface * The fields helper. */ public function getFieldsHelper() { return $this->fieldsHelper ?: \Drupal::service('search_api.fields_helper'); } /** * Sets the fields helper. * * @param \Drupal\search_api\Utility\FieldsHelperInterface $fields_helper * The new fields helper. * * @return $this */ public function setFieldsHelper(FieldsHelperInterface $fields_helper) { $this->fieldsHelper = $fields_helper; return $this; } /** * Determines whether this field can have multiple values. * * When this can't be reliably determined, the method defaults to TRUE. * * @return bool * TRUE if this field can have multiple values (or if it couldn't be * determined); FALSE otherwise. */ public function isMultiple() { return $this instanceof MultiItemsFieldHandlerInterface; } /** * Defines the options used by this plugin. * * @return array * Returns the options of this handler/plugin. * * @see \Drupal\views\Plugin\views\PluginBase::defineOptions() */ public function defineOptions() { $options = parent::defineOptions(); $options['link_to_item'] = ['default' => FALSE]; $options['use_highlighting'] = ['default' => FALSE]; if ($this->isMultiple()) { $options['multi_type'] = ['default' => 'separator']; $options['multi_separator'] = ['default' => ', ']; } return $options; } /** * Provide a form to edit options for this plugin. * * @param array|\ArrayAccess $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'] = [ '#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'], ]; $form['use_highlighting'] = [ '#type' => 'checkbox', '#title' => $this->t('Use highlighted field data'), '#description' => $this->t('Display field with matches of the search keywords highlighted, if available.'), '#default_value' => $this->options['use_highlighting'], ]; if ($this->isMultiple()) { $form['multi_value_settings'] = [ '#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['multi_type'] = [ '#type' => 'radios', '#title' => $this->t('Display type'), '#options' => [ 'ul' => $this->t('Unordered list'), 'ol' => $this->t('Ordered list'), 'separator' => $this->t('Simple separator'), ], '#default_value' => $this->options['multi_type'], '#fieldset' => 'multi_value_settings', '#weight' => 0, ]; $form['multi_separator'] = [ '#type' => 'textfield', '#title' => $this->t('Separator'), '#default_value' => $this->options['multi_separator'], '#states' => [ 'visible' => [ ':input[name="options[multi_type]"]' => ['value' => 'separator'], ], ], '#fieldset' => 'multi_value_settings', '#weight' => 1, ]; } } /** * Adds an ORDER BY clause to the query for click sort columns. * * @param string $order * Either "ASC" or "DESC". * * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::clickSort() */ public function clickSort($order) { $this->getQuery()->sort($this->definition['search_api field'], $order); } /** * Determines if this field is click sortable. * * This is the case if this Views field is linked to a certain Search API * field. * * @return bool * TRUE if this field is available for click-sorting, FALSE otherwise. * * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::clickSortable() */ public function clickSortable() { return !empty($this->definition['search_api field']); } /** * 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(); $field_id = NULL; if (!empty($this->definition['search_api field'])) { $field_id = $this->definition['search_api field']; } $this->addRetrievedProperty($combined_property_path, $field_id); if ($this->options['link_to_item']) { // @todo We don't actually know which object we need, might be from this // property or any of its parents – depending where the closest entity // ancestor is. To be 100% accurate, we'd have to somehow already // determine the correct property here. $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. * @param string|null $field_id * (optional) The ID of the field corresponding to this property, if any. * * @return $this */ protected function addRetrievedProperty($combined_property_path, $field_id = NULL) { if ($field_id) { $this->getQuery()->addRetrievedFieldValue($field_id); } list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path); $this->retrievedProperties[$datasource_id][$property_path] = $combined_property_path; return $this; } /** * Gets the entity matching the current row and relationship. * * @param \Drupal\views\ResultRow $values * An object containing all retrieved values. * * @return \Drupal\Core\Entity\EntityInterface * Returns the entity matching the values. * * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::getEntity() */ public function getEntity(ResultRow $values) { $combined_property_path = $this->getCombinedPropertyPath(); list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path); if ($values->search_api_datasource !== $datasource_id) { return NULL; } $value_index = $this->valueIndex; // Only try two levels. Otherwise, we might end up at an entirely different // entity, cause we go too far up. $levels = 2; while ($levels--) { if (!empty($values->_relationship_objects[$combined_property_path][$value_index])) { /** @var \Drupal\Core\TypedData\TypedDataInterface $object */ $object = $values->_relationship_objects[$combined_property_path][$value_index]; $value = $object->getValue(); if ($value instanceof EntityInterface) { return $value; } } if (!$property_path) { break; } // For multi-valued fields, the parent's index is not the same as the // field value's index. if (!empty($values->_relationship_parent_indices[$combined_property_path][$value_index])) { $value_index = $values->_relationship_parent_indices[$combined_property_path][$value_index]; } list($property_path) = Utility::splitPropertyPath($property_path); $combined_property_path = $this->createCombinedPropertyPath($datasource_id, $property_path); } return NULL; } /** * 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[]|\ArrayAccess $values * An array of all ResultRow objects returned from the query. * * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::preRender() */ public function preRender(&$values) { // We deal with the properties one by one, always loading the necessary // values for any nested properties coming afterwards. foreach ($this->expandRequiredProperties() as $datasource_id => $properties) { $datasource_id = $datasource_id ?: NULL; foreach ($properties as $property_path => $info) { $combined_property_path = $info['combined_property_path']; $dependents = $info['dependents']; if ($combined_property_path === NULL) { $this->preLoadResultItems($values, $dependents); continue; } $property_values = $this->getValuesToExtract($values, $datasource_id, $property_path, $combined_property_path, $dependents); $this->extractPropertyValues($values, $combined_property_path, $property_values, $dependents); $this->checkHighlighting($values, $datasource_id, $property_path, $combined_property_path); } } } /** * Expands the properties to retrieve for this field. * * The properties are taken from this object's $retrievedFieldValues 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 array[][] * The properties to retrieve, keyed by their datasource ID and property * path. The values are associative arrays with the following keys: * - combined_property_path: The "combined property path" of the retrieved * property. * - dependents: An array containing the originally required properties that * led to this property being required. */ protected function expandRequiredProperties() { $required_properties = []; foreach ($this->retrievedProperties as $datasource_id => $property_paths) { if ($datasource_id === '') { $datasource_id = NULL; } try { $index_properties = $this->getIndex()->getPropertyDefinitions($datasource_id); } catch (SearchApiException $e) { $this->logException($e); $index_properties = []; } foreach ($property_paths as $property_path => $combined_property_path) { // In case the property is configurable, create a new, unique combined // property path for this field so adding multiple fields based on the // same property works correctly. if (isset($index_properties[$property_path]) && $index_properties[$property_path] instanceof ConfigurablePropertyInterface && !empty($this->definition['search_api field'])) { $new_path = $combined_property_path . '|' . $this->definition['search_api field']; $this->propertyReplacements[$combined_property_path] = $new_path; $combined_property_path = $new_path; } $paths_to_add = [NULL]; $path_to_add = ''; foreach (explode(':', $property_path) as $component) { $path_to_add .= ($path_to_add ? ':' : '') . $component; $paths_to_add[] = $path_to_add; } foreach ($paths_to_add as $path_to_add) { if (!isset($required_properties[$datasource_id][$path_to_add])) { $path = $this->createCombinedPropertyPath($datasource_id, $path_to_add); if (isset($this->propertyReplacements[$path])) { $path = $this->propertyReplacements[$path]; } $required_properties[$datasource_id][$path_to_add] = [ 'combined_property_path' => $path, 'dependents' => [], ]; } $required_properties[$datasource_id][$path_to_add]['dependents'][] = $combined_property_path; } } } return $required_properties; } /** * Pre-loads the result objects, where necessary. * * @param \Drupal\views\ResultRow[] $values * The Views result rows for which result objects should be loaded. * @param string[] $dependents * The actually required properties (as combined property paths) that * depend on the result objects. */ protected function preLoadResultItems(array $values, array $dependents) { $to_load = []; foreach ($values as $i => $row) { // If the object is already set on the result row, we've got nothing to do // here. if (!empty($row->_object)) { continue; } // Same if the object was loaded on the result item already. $object = $row->_item->getOriginalObject(FALSE); if ($object) { $row->_object = $object; $row->_relationship_objects[NULL] = [$object]; continue; } // We also don't need to load the object if all field values that depend // on it are already present on the result row. $required = FALSE; foreach ($dependents as $dependent) { if (!isset($row->$dependent)) { $required = TRUE; break; } } if (!$required) { continue; } $to_load[$row->search_api_id] = $i; } if (!$to_load) { return; } $items = $this->getIndex()->loadItemsMultiple(array_keys($to_load)); foreach ($to_load as $item_id => $i) { if (!empty($items[$item_id])) { $values[$i]->_object = $items[$item_id]; $values[$i]->_relationship_objects[NULL] = [$items[$item_id]]; } } } /** * Determines and prepares the property values that need to be extracted. * * @param \Drupal\views\ResultRow[] $values * The Views result rows from which property values should be extracted. * @param string|null $datasource_id * The datasource ID of the property to extract (or NULL for datasource- * independent properties). * @param string $property_path * The property path of the property to extract. * @param string $combined_property_path * The combined property path of the property to extract. * @param string[] $dependents * The actually required properties (as combined property paths) that * depend on this property. * * @return \Drupal\Core\TypedData\TypedDataInterface[][] * The values of the property for each result row, keyed by result row * index. */ protected function getValuesToExtract(array $values, $datasource_id, $property_path, $combined_property_path, array $dependents) { // Determine the path of the parent property, and the property key to // take from it for this property. list($parent_path, $name) = Utility::splitPropertyPath($property_path); $combined_parent_path = $this->createCombinedPropertyPath($datasource_id, $parent_path); // For top-level properties, we need the definition to check whether its // a processor-generated property later. $property = NULL; if (!$parent_path) { $datasource_properties = $this->getIndex() ->getPropertyDefinitions($datasource_id); if (isset($datasource_properties[$name])) { $property = $datasource_properties[$name]; } } // Now go through all rows and add the property to them, if necessary. // We then extract the actual values in a second pass in order to be // able to use multi-loading for any encountered entities. /** @var \Drupal\Core\TypedData\TypedDataInterface[][] $property_values */ $property_values = []; $entities_to_load = []; foreach ($values as $i => $row) { // Bail for rows with the wrong datasource for this property, or for // which this field doesn't even apply (which will usually be the // same, though). if (($datasource_id && $datasource_id !== $row->search_api_datasource) || !$this->isActiveForRow($row)) { continue; } // Then, make sure we even need this property for the current row. (Will // not be the case if all required properties that depend on this property // were already set on the row previously.) $required = FALSE; foreach ($dependents as $dependent) { if (!isset($row->$dependent)) { $required = TRUE; break; } } if (!$required) { continue; } // Check whether there are parent objects present. Otherwise, nothing we // can do here. if (empty($row->_relationship_objects[$combined_parent_path])) { continue; } // If the property key is "_object", we only needed to load the parent // object(s), so we just copy those to the result row object and we're // done. if ($name === '_object') { // The $row->_object is special, since we also set it in // \Drupal\search_api\Plugin\views\query\SearchApiQuery::addResults() // (conditionally). To keep it consistent, we make it single-valued // here, too. if ($combined_property_path !== '_object') { $row->$combined_property_path = $row->_relationship_objects[$combined_parent_path]; } continue; } if (empty($row->_relationship_objects[$combined_property_path])) { // Check whether this is a processor-generated property and use // special code to retrieve it in that case. if ($property instanceof ProcessorPropertyInterface) { // Determine whether this property is required. $is_required = in_array($combined_property_path, $dependents); $this->extractProcessorProperty($property, $row, $datasource_id, $property_path, $combined_property_path, $is_required); continue; } foreach ($row->_relationship_objects[$combined_parent_path] as $j => $parent) { // Follow references. while ($parent instanceof DataReferenceInterface) { $parent = $parent->getTarget(); } // At this point we need the parent to be a complex item, // otherwise it can't have any children (and thus, our property // can't be present). if (!($parent instanceof ComplexDataInterface)) { continue; } try { // Retrieve the actual typed data for the property and add it to // our property values. $typed_data = $parent->get($name); $property_values[$i][$j] = $typed_data; // Remember any encountered entity references so we can // multi-load them. if ($typed_data instanceof DataReferenceInterface) { /** @var \Drupal\Core\TypedData\DataReferenceDefinitionInterface $definition */ $definition = $typed_data->getDataDefinition(); $definition = $definition->getTargetDefinition(); if ($definition instanceof EntityDataDefinitionInterface) { $entity_type_id = $definition->getEntityTypeId(); $entity_type = $this->getEntityTypeManager() ->getDefinition($entity_type_id); if ($entity_type->isStaticallyCacheable()) { $entity_id = $typed_data->getTargetIdentifier(); $entities_to_load[$entity_type_id][$entity_id] = $entity_id; } } } } catch (\InvalidArgumentException $e) { // This can easily happen, for example, when requesting a field // that only exists on a different bundle. Unfortunately, there // is no ComplexDataInterface::hasProperty() method, so we can // only catch and ignore the exception. } } } } // Multi-load all entities we encountered before. foreach ($entities_to_load as $entity_type_id => $ids) { $this->getEntityTypeManager() ->getStorage($entity_type_id) ->loadMultiple($ids); } return $property_values; } /** * Extracts a processor-based property from an item. * * @param \Drupal\search_api\Processor\ProcessorPropertyInterface $property * The property definition. * @param \Drupal\views\ResultRow $row * The Views result row. * @param string|null $datasource_id * The datasource ID of the property to extract (or NULL for datasource- * independent properties). * @param string $property_path * The property path of the property to extract. * @param string $combined_property_path * The combined property path of the property to set. * @param bool $is_required * TRUE if the property is directly required, FALSE if it should only be * extracted because some child/ancestor properties are required. */ protected function extractProcessorProperty(ProcessorPropertyInterface $property, ResultRow $row, $datasource_id, $property_path, $combined_property_path, $is_required) { $index = $this->getIndex(); $processor = $index->getProcessor($property->getProcessorId()); if (!$processor) { return; } // We need to call the processor's addFieldValues() method in order to get // the field value. We do this using a clone of the search item so as to // preserve the original state of the item. We also use a dummy field // object – either a clone of a fitting indexed field (to get its // configuration), or a newly created one. $property_fields = $this->getFieldsHelper() ->filterForPropertyPath($index->getFields(), $datasource_id, $property_path); if ($property_fields) { if (!empty($this->definition['search_api field']) && !empty($property_fields[$this->definition['search_api field']])) { $field_id = $this->definition['search_api field']; $dummy_field = $property_fields[$field_id]; } else { $dummy_field = reset($property_fields); } $dummy_field = clone $dummy_field; } else { $dummy_field = $this->getFieldsHelper() ->createFieldFromProperty($index, $property, $datasource_id, $property_path, 'tmp', 'string'); } /** @var \Drupal\search_api\Item\ItemInterface $dummy_item */ $dummy_item = clone $row->_item; $dummy_item->setFields([ 'tmp' => $dummy_field, ]); $dummy_item->setFieldsExtracted(TRUE); $processor->addFieldValues($dummy_item); $row->_relationship_objects[$combined_property_path] = []; $set_values = $is_required && !isset($row->{$combined_property_path}); if ($set_values) { $row->$combined_property_path = []; } foreach ($dummy_field->getValues() as $value) { if (!$this->checkEntityAccess($value, $combined_property_path)) { continue; } if ($set_values) { $row->{$combined_property_path}[] = $value; } $typed_data = $this->getTypedDataManager() ->create($property, $value); $row->_relationship_objects[$combined_property_path][] = $typed_data; // Processor-generated properties always have just a single parent: the // result item itself. Therefore, the parent's index is always 0. $row->_relationship_parent_indices[$combined_property_path][] = 0; } } /** * Places extracted property values and objects into the result row. * * @param \Drupal\views\ResultRow[] $values * The Views result rows from which property values should be extracted. * @param string $combined_property_path * The combined property path of the property to extract. * @param \Drupal\Core\TypedData\TypedDataInterface[][] $property_values * The values of the property for each result row, keyed by result row * index. * @param string[] $dependents * The actually required properties (as combined property paths) that * depend on this property. */ protected function extractPropertyValues(array $values, $combined_property_path, array $property_values, array $dependents) { // Now go through the rows a second time and actually add all objects // and (if necessary) properties. foreach ($values as $i => $row) { if (!empty($property_values[$i])) { // Add the typed data for the property to our relationship objects // for this property path. $row->_relationship_objects[$combined_property_path] = []; foreach ($property_values[$i] as $j => $typed_data) { // If the typed data is an entity, check whether the current // user can access it (and switch to the right translation, if // available). $value = $typed_data->getValue(); if ($value instanceof EntityInterface) { if (!$this->checkEntityAccess($value, $combined_property_path)) { continue; } if ($value instanceof TranslatableInterface && $value->hasTranslation($row->search_api_language)) { // PhpStorm isn't able to keep both interfaces in mind at the same // time, so we need to use a third interface here that combines // both. /** @var \Drupal\Core\Entity\ContentEntityInterface $value */ $typed_data = $value->getTranslation($row->search_api_language) ->getTypedData(); } } // To treat list properties correctly regarding possible child // properties, add all the list items individually. if ($typed_data instanceof ListInterface) { foreach ($typed_data as $item) { $row->_relationship_objects[$combined_property_path][] = $item; $row->_relationship_parent_indices[$combined_property_path][] = $j; } } else { $row->_relationship_objects[$combined_property_path][] = $typed_data; $row->_relationship_parent_indices[$combined_property_path][] = $j; } } } // Determine whether we want to set field values for this property on this // row. This is the case if the property is one of the explicitly // retrieved properties and not yet set on the result row object. Also, if // we have no objects for this property, we needn't bother anyways, of // course. if (!in_array($combined_property_path, $dependents) || isset($row->$combined_property_path) || empty($row->_relationship_objects[$combined_property_path])) { continue; } $row->$combined_property_path = []; // Iterate over the typed data objects, extract their values and set // the relationship objects for the next iteration of the outer loop // over properties. foreach ($row->_relationship_objects[$combined_property_path] as $typed_data) { $row->{$combined_property_path}[] = $this->getFieldsHelper() ->extractFieldValues($typed_data); } // If we just set any field values on the result row, clean them up // by merging them together (currently it's an array of arrays, but // it should be just a flat array). if ($row->$combined_property_path) { $row->$combined_property_path = call_user_func_array('array_merge', $row->$combined_property_path); } } } /** * Replaces extracted property values with highlighted field values. * * @param \Drupal\views\ResultRow[] $values * The Views result rows for which highlighted field values should be added * where applicable and possible. * @param string|null $datasource_id * The datasource ID of the property to extract (or NULL for datasource- * independent properties). * @param string $property_path * The property path of the property to extract. * @param string $combined_property_path * The combined property path of the property for which to add data. */ protected function checkHighlighting(array $values, $datasource_id, $property_path, $combined_property_path) { // If using highlighting data wasn't enabled, we can skip all of this // anyways. if (empty($this->options['use_highlighting'])) { return; } // Since (currently) only fields can be highlighted, not arbitrary // properties, we needn't even bother if there are no matching fields. $fields = $this->getFieldsHelper() ->filterForPropertyPath($this->getIndex()->getFields(), $datasource_id, $property_path); if (!$fields) { return; } foreach ($values as $row) { // We only want highlighting data if we even wanted (and, thus, extracted) // the property's values in the first place. if (!isset($row->$combined_property_path)) { continue; } $highlighted_data = $row->_item->getExtraData('highlighted_fields'); if (!$highlighted_data) { continue; } $highlighted_data = array_intersect_key($highlighted_data, $fields); if ($highlighted_data) { // There might be multiple fields with highlight data here, in rare // cases, but it's unclear how to combine them, or choose one over the // other, anyways, so just take the first one. $values = reset($highlighted_data); $values = $this->combineHighlightedValues($row->$combined_property_path, $values); $row->$combined_property_path = $values; } } } /** * Combines raw field values with highlighted ones to get a complete set. * * If highlighted field values are set on the result item, not all values * might be included, but only the ones with matches. Since we still want to * show all values, of course, we need to combine the highlighted values with * the ones we extracted. * * @param array $extracted_values * All values for a field. * @param array $highlighted_values * A subset of field values that are highlighted. * * @return array * An array of normal and highlighted field values, avoiding duplicates as * well as possible. */ protected function combineHighlightedValues(array $extracted_values, array $highlighted_values) { // Make sure the arrays have consecutive numeric indices. (Is always the // case for $extracted_values.) $highlighted_values = array_values($highlighted_values); // Pre-sanitize the highlighted values with a very permissive setting to // make sure the highlighting HTML won't be escaped later. foreach ($highlighted_values as $i => $value) { if (!($value instanceof MarkupInterface)) { $highlighted_values[$i] = $this->sanitizeValue($value, 'xss_admin'); } } $extracted_count = count($extracted_values); $highlight_count = count($highlighted_values); // If there are (at least) as many highlighted values as normal ones, we are // done here. if ($highlight_count >= $extracted_count) { return $highlighted_values; } // We now compute a "normalized" representation for all (extracted and // highlighted) values to be able to find duplicates. $normalize = function ($value) { $value = (string) $value; $value = strip_tags($value); $value = html_entity_decode($value); return $value; }; $normalized_extracted = array_map($normalize, $extracted_values); $normalized_highlighted = array_map($normalize, $highlighted_values); $normalized_extracted = array_diff($normalized_extracted, $normalized_highlighted); // Make sure that we have no more than $extracted_count values in total. while (count($normalized_extracted) + $highlight_count > $extracted_count) { array_pop($normalized_extracted); } // Now combine the two arrays, maintaining the original order by taking a // highlighted value only where the extracted value was removed (probably/ // hopefully by the array_diff()). $values = []; for ($i = 0; $i < $extracted_count; ++$i) { if (isset($normalized_extracted[$i])) { $values[] = $extracted_values[$i]; } else { $values[] = array_shift($highlighted_values); } } return $values; } /** * 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_ids = [NULL, $row->search_api_datasource]; return in_array($this->getDatasourceId(), $datasource_ids, TRUE); } /** * Checks whether the searching user has access to the given value. * * If the value is not an entity, this will always return TRUE. * * @param mixed $value * The value to check. * @param string $property_path * The property path of the value. * * @return bool * TRUE if the value is not an entity, or the searching user has access to * it; FALSE otherwise. */ protected function checkEntityAccess($value, $property_path) { if (!($value instanceof EntityInterface)) { return TRUE; } if (!empty($this->skipAccessChecks[$property_path])) { return TRUE; } if (!isset($this->accessAccount)) { $this->accessAccount = $this->getQuery()->getAccessAccount() ?: FALSE; } return $value->access('view', $this->accessAccount ?: NULL); } /** * Retrieves the combined property path of this field. * * @return string * The combined property path. */ public 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 doing this, also note which relationships are configured to skip // access checks. $skip_access = []; while (!empty($relationship->options['relationship'])) { if (empty($relationships[$relationship->options['relationship']])) { break; } $relationship = $relationships[$relationship->options['relationship']]; $path = $relationship->realField . ':' . $path; foreach ($skip_access as $i => $temp_path) { $skip_access[$i] = $relationship->realField . ':' . $temp_path; } if (!empty($relationship->options['skip_access'])) { $skip_access[] = $relationship->realField; } } $this->combinedPropertyPath = $path; // Set the field alias to the combined property path so that Views' code // can find the raw values, if necessary. $this->field_alias = $path; // Set the property paths that should skip access checks. $this->skipAccessChecks = array_fill_keys($skip_access, TRUE); } return $this->combinedPropertyPath; } /** * Creates a combined property path. * * A combined property path is similar to a "combined ID" in that it contains * information about both the datasource and the property path on that * datasource. * * The difference is that a combined property path, as used in this class, can * be NULL (to reference the original result item). * * @param string|null $datasource_id * The datasource ID, or NULL for a datasource-independent property. * @param string|null $property_path * The property path from the result item to the specified property, or NULL * to reference the result item. * * @return string|null * The combined property path. */ protected function createCombinedPropertyPath($datasource_id, $property_path) { if ($property_path === NULL) { return NULL; } return Utility::createCombinedId($datasource_id, $property_path); } /** * 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. */ public 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 = []; 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($this->propertyReplacements[$property_path])) { $property_path = $this->propertyReplacements[$property_path]; } if (!empty($values->$property_path)) { // Although it's undocumented, the field handler base class assumes items // will always be arrays. See #2648012 for documenting this. $items = []; foreach ((array) $values->$property_path as $i => $value) { $item = [ 'value' => $value, ]; if ($this->options['link_to_item']) { $item['make_link'] = TRUE; $item['url'] = $this->getItemUrl($values, $i); } $items[] = $item; } return $items; } return []; } /** * Renders all items in this field together. * * @param array|\ArrayAccess $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['multi_type'] == 'separator') { $render = [ '#type' => 'inline_template', '#template' => '{{ items|safe_join(separator) }}', '#context' => [ 'items' => $items, 'separator' => $this->sanitizeValue($this->options['multi_separator'], 'xss_admin'), ], ]; } else { $render = [ '#theme' => 'item_list', '#items' => $items, '#title' => NULL, '#list_type' => $this->options['multi_type'], ]; } return $this->getRenderer()->render($render); } return ''; } /** * Sanitizes the value for output. * * @param mixed $value * The value being rendered. * @param string|null $type * (optional) The type of sanitization needed. If not provided, * \Drupal\Component\Utility\Html::escape() is used. * * @return \Drupal\views\Render\ViewsRenderPipelineMarkup * Returns the safe value. * * @see \Drupal\views\Plugin\views\HandlerBase::sanitizeValue() */ public function sanitizeValue($value, $type = NULL) { // Pass-through values that are already markup objects. if ($value instanceof MarkupInterface) { return $value; } return parent::sanitizeValue($value, $type); } /** * 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) { $this->valueIndex = $i; if ($entity = $this->getEntity($row)) { return $entity->toUrl('canonical'); } if (!empty($row->_relationship_objects[NULL][0])) { return $this->getIndex() ->getDatasource($row->search_api_datasource) ->getItemUrl($row->_relationship_objects[NULL][0]); } return NULL; } /** * Returns the Render API renderer. * * @return \Drupal\Core\Render\RendererInterface * The renderer. * * @see \Drupal\views\Plugin\views\field\FieldPluginBase::getRenderer() */ abstract protected function getRenderer(); }