You are here

trait SearchApiFieldTrait in Search API 8

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).

Hierarchy

1 file declares its use of SearchApiFieldTrait
ViewsTestField.php in tests/src/Kernel/ViewsTestField.php

File

src/Plugin/views/field/SearchApiFieldTrait.php, line 36

Namespace

Drupal\search_api\Plugin\views\field
View source
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;
  }

  /**
   * Retrieves 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.
   *
   * @return mixed
   *   The field value to use.
   *
   * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::getValue()
   */
  public function getValue(ResultRow $values, $field = NULL) {
    return $this->overriddenValues[$field] ?? 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 (($index_properties[$property_path] ?? NULL) 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();
                  if ($entity_id) {
                    $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 (to get them into the
    // static cache).
    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\Component\Render\MarkupInterface
   *   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)) {
      if ($entity
        ->hasLinkTemplate('canonical')) {
        return $entity
          ->toUrl('canonical');
      }
    }
    if (!empty($row->_relationship_objects[NULL][0])) {
      try {
        return $this
          ->getIndex()
          ->getDatasource($row->search_api_datasource)
          ->getItemUrl($row->_relationship_objects[NULL][0]);
      } catch (SearchApiException $e) {
      }
    }
    return NULL;
  }

  /**
   * Returns the Render API renderer.
   *
   * @return \Drupal\Core\Render\RendererInterface
   *   The renderer.
   *
   * @see \Drupal\views\Plugin\views\field\FieldPluginBase::getRenderer()
   */
  protected abstract function getRenderer();

}

Members

Namesort descending Modifiers Type Description Overrides
LoggerTrait::$logger protected property The logging channel to use.
LoggerTrait::getLogger public function Retrieves the logger.
LoggerTrait::logException protected function Logs an exception.
LoggerTrait::setLogger public function Sets the logger.
SearchApiFieldTrait::$accessAccount protected property The account to use for access checks for this search.
SearchApiFieldTrait::$combinedPropertyPath protected property The combined property path of this field.
SearchApiFieldTrait::$datasourceId protected property The datasource ID of this field, if any.
SearchApiFieldTrait::$entityTypeManager protected property The entity type manager.
SearchApiFieldTrait::$fieldsHelper protected property The fields helper.
SearchApiFieldTrait::$overriddenValues protected property Contains overridden values to be returned on the next getValue() call.
SearchApiFieldTrait::$propertyReplacements protected property Array of replacement property paths to use when getting field values.
SearchApiFieldTrait::$retrievedProperties protected property Contains the properties needed by this field handler.
SearchApiFieldTrait::$skipAccessChecks protected property Associative array keyed by property paths for which to skip access checks.
SearchApiFieldTrait::$typedDataManager protected property The typed data manager.
SearchApiFieldTrait::$valueIndex protected property Index in the current row's field values that is currently displayed.
SearchApiFieldTrait::addRetrievedProperty protected function Adds a property to be retrieved.
SearchApiFieldTrait::buildOptionsForm public function Provide a form to edit options for this plugin. 3
SearchApiFieldTrait::checkEntityAccess protected function Checks whether the searching user has access to the given value.
SearchApiFieldTrait::checkHighlighting protected function Replaces extracted property values with highlighted field values.
SearchApiFieldTrait::clickSort public function Adds an ORDER BY clause to the query for click sort columns.
SearchApiFieldTrait::clickSortable public function Determines if this field is click sortable.
SearchApiFieldTrait::combineHighlightedValues protected function Combines raw field values with highlighted ones to get a complete set.
SearchApiFieldTrait::createCombinedPropertyPath protected function Creates a combined property path.
SearchApiFieldTrait::defineOptions public function Defines the options used by this plugin. 3
SearchApiFieldTrait::expandRequiredProperties protected function Expands the properties to retrieve for this field.
SearchApiFieldTrait::extractProcessorProperty protected function Extracts a processor-based property from an item.
SearchApiFieldTrait::extractPropertyValues protected function Places extracted property values and objects into the result row.
SearchApiFieldTrait::getCombinedPropertyPath public function Retrieves the combined property path of this field.
SearchApiFieldTrait::getDatasourceId public function Retrieves the ID of the datasource to which this field belongs.
SearchApiFieldTrait::getEntity public function Gets the entity matching the current row and relationship. 1
SearchApiFieldTrait::getEntityTypeManager public function Retrieves the entity type manager.
SearchApiFieldTrait::getFieldsHelper public function Retrieves the fields helper.
SearchApiFieldTrait::getItems public function Gets an array of items for the field. 2
SearchApiFieldTrait::getItemUrl protected function Retrieves an alter options array for linking the given value to its item.
SearchApiFieldTrait::getRenderer abstract protected function Returns the Render API renderer.
SearchApiFieldTrait::getTypedDataManager public function Retrieves the typed data manager.
SearchApiFieldTrait::getValue public function Retrieves the value that's supposed to be rendered.
SearchApiFieldTrait::getValuesToExtract protected function Determines and prepares the property values that need to be extracted.
SearchApiFieldTrait::isActiveForRow protected function Determines whether this field is active for the given row. 1
SearchApiFieldTrait::isMultiple public function Determines whether this field can have multiple values.
SearchApiFieldTrait::preLoadResultItems protected function Pre-loads the result objects, where necessary.
SearchApiFieldTrait::preRender public function Runs before any fields are rendered. 1
SearchApiFieldTrait::query public function Add anything to the query that we might need to. 3
SearchApiFieldTrait::renderItems public function Renders all items in this field together. 1
SearchApiFieldTrait::render_item public function Renders a single item of a row. 2
SearchApiFieldTrait::sanitizeValue public function Sanitizes the value for output.
SearchApiFieldTrait::setEntityTypeManager public function Sets the entity type manager.
SearchApiFieldTrait::setFieldsHelper public function Sets the fields helper.
SearchApiFieldTrait::setTypedDataManager public function Sets the typed data manager.
SearchApiHandlerTrait::ensureMyTable public function Overrides the Views handlers' ensureMyTable() method.
SearchApiHandlerTrait::getEntityType public function Determines the entity type used by this handler. 1
SearchApiHandlerTrait::getIndex protected function Returns the active search index.
SearchApiHandlerTrait::getQuery public function Retrieves the query plugin.