Handling cacheability for computed Drupal entity references

2 minute read

The data model for Meet Kinksters’ backend requires us to keep track of many entity relationships, e.g. users who have shown interest in each other. Many relationships are appropriate to maintain in a database entry, because they are integral to the entity itself (matches are a good example.)

Other relationships are more ephemeral, and their value is the result of some business rule computation or introspection. One such example is enabling dating profile entities to reference the user’s default photo. Users may change their default photo in the UI, and the reference “out” to the photo isn’t integral to the dating profile itself. And, due to rules around representing relationships in JSON:API (“full linkage”) sometimes it is convenient to create and reveal these computed relationships to the API layer.

One of Drupal’s best features is its robust cache API; I’ve written about this previously. How can we express cache metadata about a computed entity reference field?

Setting cache metadata on a field item

Drupal’s developer documentation often boils down to “read the code,” and that was true in this case. I didn’t see much by way of inspiration in any core modules, as computed entity reference fields are a bit of a power-user feature.

I was aided by a 2021 issue and bugfix which made JSON:API module’s searializers cache aware. The test coverage for that change provided me the example I needed to express cache metadata on a computed field.

Due to the nature of computed fields, you’ll need to write custom code to ensure the serialization respects whatever tags or cache contexts apply to your computation.

Implementing the cacheable field property

<?php

declare(strict_types=1);

namespace Drupal\my_module\Plugin\Field\FieldType;

use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem as CoreEntityReferenceItem;
use Drupal\Core\TypedData\DataReferenceTargetDefinition;
use Drupal\my_module\Plugin\DataType\CacheableEntityReference;

/**
 * Overridden entity reference item class.
 */
final class EntityReferenceItem extends CoreEntityReferenceItem {

  /**
   * {@inheritDoc}
   */
  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
    $definitions = parent::propertyDefinitions($field_definition);
    $definitions['entity']->setClass(CacheableEntityReference::class); // This is the key
    return $definitions;
  }

}
<?php

declare(strict_types=1);

namespace Drupal\my_module\Plugin\DataType;

use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
use Drupal\Core\Entity\Plugin\DataType\EntityReference;

final class CacheableEntityReference extends EntityReference implements RefinableCacheableDependencyInterface {

  use RefinableCacheableDependencyTrait; // This adds default cache handling methods/parameters.

}

Create a computed field definition and implement a list class to perform the computation.

/**
 * Implements hook_entity_bundle_field_info().
 */
function my_module_entity_bundle_field_info(EntityTypeInterface $entity_type, $bundle, array $base_field_definitions) {
  if ($entity_type->id() === 'entity_type' && $bundle === 'bundle') {
    return [
      'computed_er_field' => BundleFieldDefinition::create('entity_reference')
        ->setLabel('Related Entity')
        ->setComputed(TRUE)
        ->setCardinality(1)
        ->setDescription('This is a pretty cool way to expose data in JSON:API.')
        ->setSetting('target_type', 'target_entity_type')
        ->setClass(MyModuleComputedFieldItemList::class),
    ];
  }
}
<?php

declare(strict_types=1);

namespace Drupal\my_module;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Field\EntityReferenceFieldItemList;
use Drupal\Core\TypedData\ComputedItemListTrait;
use Drupal\profile\Entity\ProfileInterface;

final class MyModuleComputedFieldItemList extends EntityReferenceFieldItemList {

  use ComputedItemListTrait;

  /**
   * {@inheritDoc}
   */
  public function getConstraints() {
    // This is purely computed.
    return [];
  }

  /**
   * {@inheritDoc}
   */
  protected function computeValue() {
    // Dependency injection is unavailable here.
    // @see https://www.drupal.org/project/drupal/issues/2053415

    $entity = LoadYourEntity::here();
    $item = $this->createItem(0, ['target_id' => $entity]);
    $item->get('entity')->addCacheableDependency(
      /* Add whatever logic is necessary, here. */
    );
    // @phpstan-ignore-next-line
    $this->list[0] = $item;
  }

}

Making it work for entity references

That’s pretty cool, but the metadata doesn’t yet serialize properly for entity references. There is an MR/patch on this issue which replicates the serialization logic from the issue referenced above, but for entity references.

Happy reference computation!

Updated: