Dynamic/Virtual field values using computed field property classes

Last updated on
6 April 2017

Sometimes it is necessary to have "computed" properties in a field, alongside actual values that are stored in the database. A good example of this in Drupal core is found in the text field, which stores both the raw text value entered by the user, as well as a "processed" version that has been filtered through a text format. The benefit of doing this is that the text only needs to be filtered (or "computed") once. It can then be saved in the field cache for later use.

Drupal 7 - the old way

In Drupal 7, adding computed properties to fields was achieved with hook_field_load(). You can see this implemented in text_field_load() for the text field's format processing.

Drupal 8 - the new way

In Drupal 8, hook_field_load() has been removed in favor of computed field properties (see http://drupal.org/node/2064123).

-- Computed Field Item Property

The new TextItemBase class provides the base class for text fields. It defines three field properties:

  • value - the raw text value stored in the database
  • format - the input format that should be used to filter the value
  • processed - a computed property that stores the text after it has been passed through the selected filter

Looking at the TextItemBase::propertyDefinitions() method, you'll notice that the "processed" property is defined a little differently than the others:

<?php

  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
    $properties['value'] = DataDefinition::create('string')
      ->setLabel(t('Text'))
      ->setRequired(TRUE);

    $properties['format'] = DataDefinition::create('filter_format')
      ->setLabel(t('Text format'));

    $properties['processed'] = DataDefinition::create('string')
      ->setLabel(t('Processed text'))
      ->setDescription(t('The text with the text format applied.'))
      ->setComputed(TRUE)
      ->setClass('\Drupal\text\TextProcessed')
      ->setSetting('text source', 'value');

    return $properties;
  }

?>

The main additions are:

  • setComputed(TRUE) - This tells the Field API that this field is computed so that it doesn't look for it in the database.
  • setClass('\Drupal\text\TextProcessed') - This defines the class to use for generating the property value. This class should implement TypedDataInterface.
  • ->setSetting('text source', 'value') - This defines a setting for the field property.

The main purpose of the TextProcessed class is to define how the value of the "processed" field property is computed. This is done in the TextProcessed::getValue() method (see below). Part of this process involves loading the parent field item and extracting values from it, then using those to generate the computed property value.

/**
 * A computed property for processing text with a format.
 *
 * Required settings (below the definition's 'settings' key) are:
 *  - text source: The text property containing the to be processed text.
 */
class TextProcessed extends TypedData {

  /**
   * Cached processed text.
   *
   * @var string|null
   */
  protected $processed = NULL;

...

  /**
   * Implements \Drupal\Core\TypedData\TypedDataInterface::getValue().
   */
  public function getValue($langcode = NULL) {
    if ($this->processed !== NULL) {
      return $this->processed;
    }

    $item = $this->getParent();
    $text = $item->{($this->definition->getSetting('text source'))};

    // Avoid running check_markup() or
    // \Drupal\Component\Utility\SafeMarkup::checkPlain() on empty strings.
    if (!isset($text) || $text === '') {
      $this->processed = '';
    }
    else {
      $this->processed = check_markup($text, $item->format, $item->getLangcode());
    }
    return $this->processed;
  }

...

}

The TextProcessed class also overrides TypedData::__construct() to enforce the necessary setting for it. That is specific to the TextProcessed class requirements, and are not necessary for simple computed field implementations.

-- Computed field Item

Here gives an example of using hook_entity_base_field_info() to define computed field items (Note: not field item properties)

/**
 * Implements hook_entity_base_field_info().
 */
function mymodule_entity_base_field_info(EntityTypeInterface $entity_type) {
  if ($entity_type->id() === 'profile') {
    $fields = [];

    // Add a field that shows the completeness of the user profile.
    // This is computed whenever the profile changes, and then saved
    // to the database.
    $fields['completeness'] = BaseFieldDefinition::create('float')
      ->setLabel(t('Complete profile'))
      ->setDescription(t('User profile complete percentage, such as 0.40, i.e, 40%'))
      ->setDisplayOptions('view', [
        'label' => 'above',
        'weight' => -5,
      ]);

    // Add a field that shows a link to the user's current company.
    $fields['current_company'] = BaseFieldDefinition::create('current_company_link')
      ->setName('current_company')
      ->setLabel(t('Current company'))
      ->setComputed(TRUE)
      ->setClass('\Drupal\mymodule\CurrentCompanyLinkItemList')
      ->setDisplayConfigurable('view', TRUE)
      ->setDisplayOptions('view', [
        'label' => 'hidden',
        'weight' => -5,
      ]);

    return $fields;
  }
}

In above example, two fields are defined. The first one is NOT a computed field, but you may think it as a "computed" or dynamical field as well. The main difference between 'completeness' and 'current_company' is 'completeness' needs db storage, but 'current_company' doesn't. You may make decision at your convenience to decide whether you want to store the field in database.

Here's the code to update db table 'profile' to have one extra column to store field 'completeness'. Put the following code in the .install file.

/**
 * Implements hook_install().
 */
function mymodule_install() {
  // Different approaches for this update, see https://www.drupal.org/node/2078241.
  // Create field storage for the 'completeness' base field.
  $entity_manager = \Drupal::entityManager();
  $definition = $entity_manager->getFieldStorageDefinitions('profile')['completeness'];
  $entity_manager->onFieldStorageDefinitionCreate($definition);
}

/**
 * Implements hook_uninstall().
 */
function mymodule_uninstall() {
  $entity_manager = \Drupal::entityManager();
  $definition = $entity_manager->getLastInstalledFieldStorageDefinitions('profile')['completeness'];
  $entity_manager->onFieldStorageDefinitionDelete($definition);
}

Here's the code to calculated the 'completeness' field when a profile entity is saved.

/**
 * Implements hook_ENTITY_TYPE_presave().
 */
function mymodule_profile_presave(Drupal\Core\Entity\EntityInterface $entity) {
  if ($entity->bundle() === 'profile') {
    // Add the profile entity's completeness field value.
    $completeness = 0;

    // Check expertise field.
    $field_profile_expertise = $entity->get('field_profile_expertise')->isEmpty();
    if (!$field_profile_expertise) {
      $completeness += 0.2;
    }

    // Other field calculations.

    $entity->set('completeness', $completeness);
  }
}

For the real 'Computed' field, 'current_company', here is an example class \Drupal\mymodule\Plugin\Field|FieldType\CurrentCompanyLinkItem. It shows a link to the page of the "current company" associated with the entity that contains the field. This is not stored in the database but dynamically generated from other available data on the entity.

<?php

namespace Drupal\mymodule\Plugin\Field\FieldType;

use Drupal\link\Plugin\Field\FieldType\LinkItem;

/**
 * Variant of the 'link' field that links to the current company.
 *
 * @FieldType(
 *   id = "current_company_link",
 *   label = @Translation("Current company"),
 *   description = @Translation("A link to the current company that is associated with the entity."),
 *   default_widget = "link_default",
 *   default_formatter = "link",
 *   constraints = {"LinkType" = {}, "LinkAccess" = {}, "LinkExternalProtocols" = {}, "LinkNotExistingInternal" = {}}
 * )
 */
class CurrentCompanyLinkItem extends LinkItem {

  /**
   * Whether or not the value has been calculated.
   *
   * @var bool
   */
  protected $isCalculated = FALSE;

  /**
   * {@inheritdoc}
   */
  public function __get($name) {
    $this->ensureCalculated();
    return parent::__get($name);
  }
  /**
   * {@inheritdoc}
   */
  public function isEmpty() {
    $this->ensureCalculated();
    return parent::isEmpty();
  }

  /**
   * {@inheritdoc}
   */
  public function getValue() {
    $this->ensureCalculated();
    return parent::getValue();
  }

  /**
   * Calculates the value of the field and sets it.
   */
  protected function ensureCalculated() {
    if (!$this->isCalculated) {
      $entity = $this->getEntity();
      if (!$entity->isNew()) {
        // Some custom code that retrieves the current company.
        $company = mymodule_get_company($this->getEntity());
        $url = $company->toUrl()->toString();
        $value = [
          'uri' => 'internal:/' . $url,
          'title' => t('Current company'),
        ];
        $this->setValue($value);
      }
      $this->isCalculated = TRUE;
    }
  }

}

Until #2392845: Clarify the notion of "computed field" is fixed we also need to provide a custom field item list class, \Drupal\mymodule\CurrentCompanyLinkItemList:

<?php

namespace Drupal\mymodule;

use Drupal\Core\Field\FieldItemList;

/**
 * Item list for a computed field that displays the current company.
 *
 * @see \Drupal\mymodule\Plugin\Field\FieldType\CurrentCompanyLinkItem
 */
class CurrentCompanyLinkItemList extends FieldItemList {

  /**
   * {@inheritdoc}
   */
  public function getIterator() {
    $this->ensurePopulated();
    return new \ArrayIterator($this->list);
  }

  /**
   * {@inheritdoc}
   */
  public function getValue($include_computed = FALSE) {
    $this->ensurePopulated();
    return parent::getValue($include_computed);
  }

  /**
   * {@inheritdoc}
   */
  public function isEmpty() {
    $this->ensurePopulated();
    return parent::isEmpty();
  }

  /**
   * Makes sure that the item list is never empty.
   *
   * For 'normal' fields that use database storage the field item list is
   * initially empty, but since this is a computed field this always has a
   * value.
   * Make sure the item list is always populated, so this field is not skipped
   * for rendering in EntityViewDisplay and friends.
   *
   * @todo This will no longer be necessary once #2392845 is fixed.
   *
   * @see https://www.drupal.org/node/2392845
   */
  protected function ensurePopulated() {
    if (!isset($this->list[0])) {
      $this->list[0] = $this->createItem(0);
    }
  }

}

Since we are reusing the standard Link field we also need to add our custom computed field to the list of field formatters:

/**
 * Implements hook_field_formatter_info_alter().
 */
function mymodule_field_formatter_info_alter(array &$info) {
  $info['link']['field_types'][] = 'current_company_link';
}

You may get these virtual field values as usual. For example:

$profile = entity_load('profile', 23);
$company = $profile->get('current_company')->getvalue();
$completeness = $profile->get('completeness')->getvalue();

Views Integration

At this moment, views doesn't support real computed fields, i.e, it supports 'completeness', but it doesn't support the 'current_company' field. Here is an example for views integration.

Firstly, add this property to profile base table in views:

/**
 * Implements hook_views_data_alter().
 */
function mymodule_views_data_alter(array &$data) {
  if (isset($data['profile'])) {
    // Add the current company computed field to Views.
    $data['profile']['current_company'] = [
      'title' => t('Current company'),
      'field' => [
        'id' => 'mymodule_view_current_company',
      ],
    ];
  }
}

Here comes the views ID plugin. Important, the namespace must follow views convention.

namespace Drupal\mymodule\Plugin\views\field;

use Drupal\views\ResultRow;
use Drupal\views\Plugin\views\field\FieldPluginBase;

/**
 * A handler to provide proper displays for profile current company.
 *
 * @ingroup views_field_handlers
 *
 * @ViewsField("mymodule_view_current_company")
 */
class MyModuleViewCurrentCompany extends FieldPluginBase {

  /**
   * {@inheritdoc}
   */
  public function render(ResultRow $values) {
    $relationship_entities = $values->_relationship_entities;
    $company = '';
    // First check the referenced entity.
    if (isset($relationship_entities['profile'])) {
      $profile = $relationship_entities['profile'];
    }
    else {
      $profile = $values->_entity;
    }

    $type = get_class($profile);
    if ($type === 'Drupal\profile\Entity\Profile') {
      $company = $profile->get('current_company')->getvalue();
    }

    return $company;
  }

  /**
   * {@inheritdoc}
   */
  public function query() {
    // This function exists to override parent query function.
    // Do nothing.
  }
}

Two things:
a) function query() overrides parent query function so the sql query won't include this computed field 'current_company'. This field doesn't exist in db at all. The sql query will definitely give fatal error if this field exists in sql.

b). function render() considers two scenarios. This view field is rendered by profile relationship, and this field is rendered by profile base table. To the end, return whatever value this computed field should have.

Caching

In D7, hook_field_load() allowed developers to store computed field properties with the field information before it was stored in the field cache.

This ability was removed in Drupal 8 under the assumption that the render cache will prevent extensive processing and that the computed field value is no longer accessed for requests that can rely on the render cache of rendered entities.