Torna al blog

Computed Field in Drupal: Campi Calcolati al Volo senza Toccare il Database

In Drupal ogni campo ha una colonna nel database. Ma ci sono dati che non ha senso salvare: valori derivati da altri campi, informazioni che dipendono dall'utente corrente, dati che arrivano da fonti esterne. Per tutti questi casi esistono i computed field: campi senza storage, il cui valore viene calcolato al volo.

Un computed field si dichiara con setComputed(TRUE) nella definizione. Drupal ignora completamente lo storage — nessuna tabella, nessuna migrazione — e delega il calcolo a una classe PHP con un singolo metodo: computeValue(). Il campo risultante è indistinguibile da uno con storage: appare nelle risposte JSON:API, è accessibile nei template Twig, funziona nei view mode. L'unica differenza è che non può essere usato per filtrare o ordinare le query.

Casi d'uso

  • Dati per-utente — "è tra i preferiti?", "ha già acquistato?", "è iscritto?". Informazioni che cambiano in base a chi guarda.
  • Valori derivati — tempo di lettura, età da una data di nascita, nome completo da nome + cognome.
  • Fonti esterne — prezzo da un ERP, stock da un magazzino, rating da un'API terza.
  • Aggregazioni — conteggio di commenti, somma di valori da entity reference.

Come implementarne uno

Servono due cose: la definizione del campo (via hook) e la classe che lo calcola.

Definire il campo

Drupal offre due hook: hook_entity_base_field_info() (aggiunge il campo a tutte le entità di un tipo) e hook_entity_bundle_field_info() (solo a specifici bundle). Il secondo è quasi sempre preferibile.

use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\mymodule\Field\IsNewFieldItemList;

function mymodule_entity_bundle_field_info(
  EntityTypeInterface $entity_type,
  string $bundle,
  array $base_field_definitions
) {
  if ($entity_type->id() === 'node' && $bundle === 'article') {
    $fields['is_new'] = BaseFieldDefinition::create('boolean')
      ->setLabel(t('Is new'))
      ->setDescription(t('Whether this article was published in the last 7 days.'))
      // Nessuno storage: il campo esiste solo in memoria.
      ->setComputed(TRUE)
      // La classe che implementa computeValue().
      ->setClass(IsNewFieldItemList::class)
      // Non mostrare widget nel form — non c'è nulla da salvare.
      ->setDisplayConfigurable('form', FALSE)
      ->setDisplayConfigurable('view', FALSE);

    return $fields;
  }
}

setComputed(TRUE) disattiva lo storage. setClass() indica la classe responsabile del calcolo. Il tipo può essere qualsiasi field type Drupal: boolean, string, integer, entity_reference, ecc.

Scrivere la classe di calcolo

La classe estende FieldItemList, usa il trait ComputedItemListTrait e implementa computeValue():

<?php

namespace Drupal\mymodule\Field;

use Drupal\Core\Field\FieldItemList;
use Drupal\Core\TypedData\ComputedItemListTrait;

class IsNewFieldItemList extends FieldItemList {

  use ComputedItemListTrait;

  protected function computeValue(): void {
    $entity = $this->getEntity();
    $created = (int) $entity->getCreatedTime();
    $is_new = $created > strtotime('-7 days');

    // createItem(delta, valore): crea un FieldItem alla posizione 0.
    // Ogni campo Drupal è una lista, anche con cardinalità 1.
    $this->list[0] = $this->createItem(0, $is_new);
  }

}

Il calcolo non avviene quando Drupal carica l'entità. Avviene dopo, solo se qualcuno legge il campo.

Quando Drupal carica un nodo, il computed field esiste ma è vuoto — una lista senza valori. Il trait ComputedItemListTrait intercetta qualsiasi tentativo di leggere quella lista: una chiamata a $node->get('is_new')->value in un template Twig, la serializzazione JSON:API, un getValue() nel codice. A quel punto — e solo a quel punto — esegue computeValue() per popolare la lista, e poi restituisce il risultato. Le letture successive usano il valore già calcolato senza rieseguire il metodo.

Se nessuno legge il campo, computeValue() non viene mai chiamato. Una pagina che carica 50 nodi ma non usa is_new in nessun template o serializer non paga alcun costo per quel campo.

Cache

Un computed field che restituisce lo stesso valore per tutti — un reading_time calcolato dal body, un full_name che concatena due campi — non ha problemi con la cache. Drupal cacha la risposta e la serve a chiunque: il valore è identico per ogni visitatore.

Il discorso cambia quando il valore dipende dall'utente corrente — ed è il caso più interessante dei computed field. Prendiamo un campo is_favorite: per l'utente A vale true, per B vale false. Il problema è che Drupal ha la Dynamic Page Cache, un sistema che salva le risposte HTTP complete e le riutilizza. Se A è il primo a caricare /jsonapi/node/news, la risposta — incluso "is_favorite": true — viene cachata. Quando B fa la stessa richiesta, riceve la risposta di A. Dato sbagliato.

La soluzione è ragionare su chi ha bisogno di cosa. Gli utenti anonimi non possono avere preferiti: per loro is_favorite è sempre false. Questo significa che la risposta cachata è corretta per tutti gli anonimi — non serve distinguere tra un anonimo e l'altro. Gli utenti autenticati invece hanno ciascuno i propri preferiti, e la risposta deve essere calcolata ogni volta.

Da qui il pattern: lasciare la cache attiva per gli anonimi, disattivarla per gli autenticati.

function mymodule_node_load(array $entities): void {
  $uid = (int) \Drupal::currentUser()->id();

  // Anonimi (uid 0): is_favorite è sempre false, la risposta cachata
  // è corretta per tutti. Non si tocca nulla.
  if ($uid === 0) {
    return;
  }

  // Autenticati: ogni utente ha i propri preferiti, quindi la risposta
  // non può essere cachata. mergeCacheMaxAge(0) dice alla Dynamic Page
  // Cache di non salvare questa risposta.
  foreach ($entities as $entity) {
    $entity->mergeCacheMaxAge(0);
  }
}

mergeCacheMaxAge(0) imposta la durata della cache a zero secondi per quell'entità. La Dynamic Page Cache vede max-age = 0 e non salva la risposta. Alla prossima richiesta autenticata, Drupal riesegue tutto da zero — incluso computeValue() — e restituisce i dati corretti per quell'utente.

Il risultato: gli anonimi (tipicamente la maggioranza del traffico) hanno la cache piena, gli autenticati pagano il costo del calcolo ma vedono i propri dati. È il compromesso giusto per la maggior parte degli scenari con frontend decoupled.

JSON:API

JSON:API scopre i computed field automaticamente. Se il campo è registrato via hook, viene esposto senza configurare normalizer o resource type:

{
  "data": {
    "type": "node--article",
    "id": "a1b2c3d4-...",
    "attributes": {
      "title": "Il mio articolo",
      "is_new": true
    }
  }
}

Il campo rispetta gli sparse fieldset — se il frontend lo esclude con fields[node--article]=title,body, computeValue() non viene invocato.

Esempio reale: sistema di preferiti

Un caso concreto: un campo is_favorite che indica se l'utente corrente ha salvato un nodo tra i preferiti. La classe di calcolo:

class IsFavoriteFieldItemList extends FieldItemList {

  use ComputedItemListTrait;

  protected function computeValue(): void {
    $entity = $this->getEntity();
    $uid = (int) \Drupal::currentUser()->id();

    if ($uid === 0) {
      $this->list[0] = $this->createItem(0, FALSE);
      return;
    }

    /** @var FavoritesService $service */
    $service = \Drupal::service('mymodule.favorites');
    $this->list[0] = $this->createItem(0,
      $service->isFavorite($uid, (int) $entity->id())
    );
  }

}

Il servizio usa una static cache: la prima chiamata carica tutti i preferiti dell'utente con una query, le successive leggono dalla memoria. Se una pagina carica 20 nodi, computeValue() viene chiamato 20 volte ma la query al database avviene una volta sola. Pattern cruciale ogni volta che un computed field opera su liste di entità.

Quando usarli (e quando no)

La domanda da farsi è: questo dato ha senso come colonna nel database?

Se la risposta è no — perché cambia a seconda di chi guarda, perché è sempre derivabile da altri dati, perché arriva da una fonte esterna — il computed field è la scelta naturale. Il valore si calcola al momento, appare nelle API e nei template come qualsiasi altro campo, e non c'è nessuno schema da migrare.

Se la risposta è sì, probabilmente serve un campo con storage. In particolare quando:

  • Serve filtrare o ordinare per quel valore — un computed field non esiste nel database, quindi non si può usare in una WHERE o ORDER BY. Non funziona come filtro nelle Views, nelle query JSON:API o in entityQuery. Se hai bisogno di "mostrami tutti i nodi con is_featured = true", quel campo deve avere una colonna.
  • Il calcolo è costoso e il dato cambia raramente — se calcolare il valore richiede una chiamata API esterna o un'aggregazione pesante, e il risultato cambia una volta al giorno, ha più senso salvarlo e aggiornarlo via cron piuttosto che ricalcolarlo ad ogni richiesta.
  • Serve tracciare le revisioni — senza storage non c'è revision history. Se il valore deve essere auditabile nel tempo, serve un campo reale.