---
title: "Modello EAV in PostgreSQL: query corrette prima delle prestazioni"
source: "https://www.matteovincenti.com/blog/eav-postgresql-prestazioni-prima-della-cache"
language: it
author: "Matteo Vincenti"
published: 2026-10-21
updated: 2026-10-23
category: "Performance"
tags: ["EAV", "PostgreSQL", "Database"]
---

# Modello EAV in PostgreSQL: query corrette prima delle prestazioni

Campi configurabili in EAV, colonne o JSONB? Un esempio SQL riproducibile in Docker mostra come evitare falsi positivi e preparare i test di prestazione.

![Un divisore regolabile viene inserito in uno schedario ordinato: campi configurabili e struttura dei dati.](https://cdn.sanity.io/images/xrqkwry7/production/cb5e84d8eb550367beffee65de30a4b4c2d8c3ad-1729x910.png?w=1400&fit=max&auto=format)

Categoria: Performance · Tag: EAV, PostgreSQL, Database

In un elenco di fornitori compare una nuova esigenza: registrare il tempo di consegna di ogni sede e cercare chi consegna entro 48 ore. Il responsabile vuole aggiungere quel campo dalla configurazione, senza aspettare una modifica del software. Poco dopo chiede di limitare la ricerca alle sedi attive.

È una richiesta ragionevole. La complicazione emerge quando un fornitore ha una sede attiva ma lenta e un’altra rapida ma sospesa: cercare separatamente le due caratteristiche lo fa comparire per errore.

Sto studiando un’architettura per campi configurabili di questo tipo. La ricerca sulle prestazioni è ancora aperta; qui il risultato concreto è una dimostrazione SQL della correttezza del filtro, riproducibile su PostgreSQL in Docker. Prima di confrontare indici o aggiungere una cache, voglio fissare quali fornitori devono comparire.

## Che cosa cambia con EAV

EAV significa Entity–Attribute–Value: entità, attributo e valore. Per la nostra sede, il tempo di consegna può essere rappresentato così:

_Che cosa cambia con EAV_

| Entità | Attributo | Valore |
| --- | --- | --- |
| Sede 11 | Tempo di consegna, in ore | 72 |

In una tabella ordinaria, il tempo di consegna occupa una colonna. Con EAV, la definizione del campo è un record e i valori sono righe collegate all’entità e a quella definizione. Un’organizzazione può quindi aggiungere un campo attraverso i metadati, senza creare una colonna per ciascun attributo nuovo.

Una variante tipizzata conserva numeri, testi e date in strutture distinte. Il numero 72 resta un numero: il filtro sulle ore non richiede di convertire una colonna che contiene anche descrizioni e date. [Laurenz Albe descrive questo modello](https://www.cybertec-postgresql.com/en/entity-attribute-value-eav-design-in-postgresql-dont-do-it/) e ne critica i costi di ricostruzione delle schede e delle query complesse. È un’obiezione da prendere sul serio quando la configurabilità diventa il criterio dominante.

Per questo caso terrei identità, appartenenza e stato in colonne ordinarie. Il loro significato è stabile. Il tempo di consegna rappresenta invece il campo numerico configurabile del laboratorio.

_Che cosa cambia con EAV_

| Requisito | Soluzione da confrontare | Lavoro da considerare |
| --- | --- | --- |
| Informazione stabile, usata nelle regole centrali | Colonna relazionale | Evoluzione dello schema |
| Dati variabili letti soprattutto con la scheda | Colonne e JSONB | Validazione e accesso ai valori cercati |
| Campi con definizione propria, tipo e uso autonomo nei filtri | EAV tipizzato | Query, gestione delle definizioni e ricostruzione dei dati |

JSONB rimane un’alternativa anche quando servono ricerche: PostgreSQL supporta indici GIN e indici su espressioni, con capacità diverse in base agli operatori usati. La [documentazione sugli indici JSONB](https://www.postgresql.org/docs/18/datatype-json.html#JSON-INDEXING) mostra perché conviene partire dalla query concreta anziché liquidarlo come un contenitore da leggere sempre per intero.

La scelta riguarda quindi anche chi manterrà i filtri. Aggiungere il ventesimo campo dalla configurazione è utile se il team riesce ancora a spiegare e controllare la ricerca che lo usa.

## Il fornitore che deve restare fuori

Usiamo un piccolo elenco didattico. La richiesta è: **fornitori dell’organizzazione 1 con almeno una sede attiva che consegni entro 48 ore**.

_Il fornitore che deve restare fuori_

| Organizzazione | Fornitore | Sede | Stato | Consegna, ore |
| --- | --- | --- | --- | --- |
| 1 | 1 | 11 | Attiva | 72 |
| 1 | 1 | 12 | Sospesa | 24 |
| 1 | 2 | 21 | Attiva | 24 |
| 1 | 3 | 31 | Attiva | 36 |
| 1 | 3 | 32 | Attiva | 48 |
| 1 | 4 | 41 | Attiva | 72 |
| 2 | 4 | 41 | Attiva | 12 |

Il risultato atteso contiene i fornitori **2 e 3**. Il fornitore 1 non ha una sede che soddisfi entrambe le condizioni. Il 3 compare una sola volta anche se ha due sedi valide. Il 4 dell’organizzazione 1 è lento; l’omonimo dell’organizzazione 2 non deve influire sulla sua selezione.

L’errore nasce da due domande indipendenti: «esiste una sede attiva?» e «esiste una sede rapida?». Per il fornitore 1 la risposta è sì in entrambi i casi, ma le sedi sono diverse. Questo errore di correlazione può verificarsi anche con un modello relazionale ordinario o con JSONB; con i valori EAV distribuiti su righe separate diventa particolarmente importante esplicitare a quale entità appartiene ogni condizione.

## Riprodurre il caso in Docker

Servono Docker Engine o Docker Desktop avviato, un terminale Bash o Zsh e un editor di testo. Non occorre installare PostgreSQL sul computer.

Crea una cartella vuota per il laboratorio e apri il terminale in quella cartella. Il database userà solo dati didattici in memoria: la rimozione del container li elimina. Scegli un nome libero; se hai già un container chiamato `eav-demo`, cambia quel nome in tutti i comandi.

Avvia l’immagine ufficiale fissata a PostgreSQL 18.6 e al digest usato per la prova:

```bash
docker run --detach --name eav-demo \
  --network none \
  --tmpfs /var/lib/postgresql:rw,size=256m \
  --env POSTGRES_PASSWORD=eav-demo-only \
  --env POSTGRES_DB=eav_demo \
  postgres:18.6-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
```

La password è dimostrativa. Il container non ha accesso alla rete e non pubblica porte sul computer: entreremo con `docker exec`. Il percorso tmpfs segue la struttura dell’[immagine ufficiale per PostgreSQL 18](https://hub.docker.com/_/postgres).

Controlla che il database sia pronto:

```bash
docker exec eav-demo pg_isready -U postgres -d eav_demo
```

Se l’inizializzazione è ancora in corso, ripeti il comando fino a leggere `accepting connections`. Ora crea, con l’editor, il file `demo.sql` nella cartella aperta e incolla **tutto** il contenuto seguente. Il file crea le tabelle, inserisce i dati, definisce le due query e controlla i risultati.

```sql
\set ON_ERROR_STOP on
BEGIN;

CREATE TABLE supplier (
  tenant_id integer,
  id integer,
  name text NOT NULL,
  PRIMARY KEY (tenant_id, id)
);

CREATE TABLE site (
  tenant_id integer,
  id integer,
  supplier_id integer NOT NULL,
  status text NOT NULL CHECK (status IN ('active', 'suspended')),
  PRIMARY KEY (tenant_id, id),
  FOREIGN KEY (tenant_id, supplier_id) REFERENCES supplier (tenant_id, id)
);

CREATE TABLE number_field (
  tenant_id integer,
  id integer,
  key text NOT NULL,
  unit text NOT NULL,
  PRIMARY KEY (tenant_id, id),
  UNIQUE (tenant_id, key)
);

CREATE TABLE site_number_value (
  tenant_id integer,
  site_id integer,
  field_id integer,
  value numeric NOT NULL CHECK (value >= 0),
  PRIMARY KEY (tenant_id, site_id, field_id),
  FOREIGN KEY (tenant_id, site_id) REFERENCES site (tenant_id, id),
  FOREIGN KEY (tenant_id, field_id) REFERENCES number_field (tenant_id, id)
);

INSERT INTO supplier VALUES
  (1, 1, 'Split conditions'),
  (1, 2, 'One qualifying site'),
  (1, 3, 'Two qualifying sites'),
  (1, 4, 'Slow local site'),
  (2, 4, 'Another organisation');

INSERT INTO site VALUES
  (1, 11, 1, 'active'),
  (1, 12, 1, 'suspended'),
  (1, 21, 2, 'active'),
  (1, 31, 3, 'active'),
  (1, 32, 3, 'active'),
  (1, 41, 4, 'active'),
  (2, 41, 4, 'active');

INSERT INTO number_field VALUES
  (1, 1, 'delivery_hours', 'hour'),
  (2, 1, 'delivery_hours', 'hour');

INSERT INTO site_number_value VALUES
  (1, 11, 1, 72),
  (1, 12, 1, 24),
  (1, 21, 1, 24),
  (1, 31, 1, 36),
  (1, 32, 1, 48),
  (1, 41, 1, 72),
  (2, 41, 1, 12);

CREATE VIEW wrong_result AS
SELECT s.id
FROM supplier AS s
WHERE s.tenant_id = 1
  AND EXISTS (
    SELECT 1
    FROM site AS x
    WHERE x.tenant_id = s.tenant_id
      AND x.supplier_id = s.id
      AND x.status = 'active'
  )
  AND EXISTS (
    SELECT 1
    FROM site AS x
    JOIN site_number_value AS v
      ON (v.tenant_id, v.site_id) = (x.tenant_id, x.id)
    JOIN number_field AS f
      ON (f.tenant_id, f.id) = (v.tenant_id, v.field_id)
    WHERE x.tenant_id = s.tenant_id
      AND x.supplier_id = s.id
      AND f.key = 'delivery_hours'
      AND f.unit = 'hour'
      AND v.value <= 48
  );

CREATE VIEW correct_result AS
SELECT s.id
FROM supplier AS s
WHERE s.tenant_id = 1
  AND EXISTS (
    SELECT 1
    FROM site AS x
    JOIN site_number_value AS v
      ON (v.tenant_id, v.site_id) = (x.tenant_id, x.id)
    JOIN number_field AS f
      ON (f.tenant_id, f.id) = (v.tenant_id, v.field_id)
    WHERE x.tenant_id = s.tenant_id
      AND x.supplier_id = s.id
      AND x.status = 'active'
      AND f.key = 'delivery_hours'
      AND f.unit = 'hour'
      AND v.value <= 48
  );

SELECT 'wrong' AS query, array_agg(id ORDER BY id) AS supplier_ids
FROM wrong_result
UNION ALL
SELECT 'correct', array_agg(id ORDER BY id)
FROM correct_result;

DO $$
BEGIN
  IF ARRAY(SELECT id FROM wrong_result ORDER BY id)
       IS DISTINCT FROM ARRAY[1,2,3] THEN
    RAISE EXCEPTION 'Unexpected wrong-query result';
  END IF;
  IF ARRAY(SELECT id FROM correct_result ORDER BY id)
       IS DISTINCT FROM ARRAY[2,3] THEN
    RAISE EXCEPTION 'Correct query failed';
  END IF;
END $$;

\echo PASS: wrong={1,2,3}; correct={2,3}
COMMIT;
```

Eseguilo dal terminale rimasto nella stessa cartella:

```bash
docker exec -i eav-demo psql -X -U postgres -d eav_demo \
  -v ON_ERROR_STOP=1 < demo.sql
```

Tra i messaggi di creazione delle tabelle compaiono:

```txt
  query  | supplier_ids
---------+--------------
 wrong   | {1,2,3}
 correct | {2,3}

PASS: wrong={1,2,3}; correct={2,3}
```

I controlli confrontano gli ID con due risultati scritti esplicitamente: un esito diverso solleva un errore e `ON_ERROR_STOP` interrompe l’esecuzione. Il file è pensato per un database vuoto. Per ripartire, rimuovi il container del laboratorio e ricrealo, poi esegui nuovamente il file.

La prova è stata eseguita con PostgreSQL 18.6, immagine Debian su Linux arm64. Verifica questi risultati logici; sette sedi non sono un carico con cui scegliere l’indice più veloce.

## Dove si vede EAV, e dove si corregge la query

`number_field` definisce i campi numerici; `site_number_value` conserva la relazione sede–campo–valore. Il tempo di consegna è identificato da `field_id`, mentre la sua chiave leggibile è `delivery_hours`. La tabella delle definizioni è già limitata ai numeri: testi e date richiederebbero strutture corrispondenti. Nel laboratorio conserviamo un solo valore corrente per sede e campo, senza storico o traduzioni.

La chiave primaria `(tenant_id, site_id, field_id)` impedisce di inserire due valori correnti per la stessa combinazione. Le chiavi esterne composte collegano ciascun valore a una sede e a una definizione della stessa organizzazione. Sono vincoli di integrità; il tenant della richiesta va comunque autenticato e autorizzato nell’applicazione. La [documentazione PostgreSQL sui vincoli](https://www.postgresql.org/docs/18/ddl-constraints.html) spiega il ruolo delle chiavi composte.

Nella vista `wrong_result`, il primo `EXISTS` cerca una sede attiva. Il secondo cerca una sede con consegna entro 48 ore. Sono entrambi collegati al fornitore, ma nessun legame impone che usino la stessa sede.

In `correct_result`, lo stato e il tempo di consegna appartengono invece alla stessa riga `x`, dentro un solo `EXISTS`. Il controllo chiede se esiste almeno una sede valida; perciò le due sedi del fornitore 3 non duplicano la riga esterna. Questo è il comportamento descritto dalla [documentazione di EXISTS](https://www.postgresql.org/docs/18/functions-subquery.html#FUNCTIONS-SUBQUERY-EXISTS).

Conta anche il join `(v.tenant_id, v.site_id) = (x.tenant_id, x.id)`. Se togli il tenant e colleghi solo gli ID delle sedi, il valore di 12 ore dell’organizzazione 2 può qualificare la sede 41 dell’organizzazione 1: compare erroneamente anche il fornitore 4. L’identità locale da sola è insufficiente in un archivio condiviso.

Puoi interrogare nuovamente le due viste, che rieseguono la rispettiva query sui dati correnti:

```bash
docker exec eav-demo psql -X -U postgres -d eav_demo \
  -c 'SELECT * FROM correct_result ORDER BY id;'
```

Quando hai finito, elimina **il container creato per questo laboratorio**:

```bash
docker rm -f eav-demo
```

L’immagine resta nella cache di Docker per un eventuale riuso.

## Le prestazioni vengono dopo questo risultato

La domanda successiva è quanto lavoro serve per trovare quei fornitori quando i dati crescono. Si può partire dai fornitori e controllarne le sedi oppure trovare prima le sedi valide e risalire ai fornitori. La convenienza dipende dalla distribuzione dei valori e dai collegamenti attraversati.

Per esempio, 20 sedi collegate a 20 zone ciascuna e 400 sedi collegate a una zona ciascuna comportano entrambe 400 collegamenti da attraversare. Contare solo le sedi trovate nasconde quel lavoro. Nel confronto fra EAV e JSONB userò gli stessi dati e gli stessi risultati attesi, includendo filtri, permessi e caricamento della pagina.

Su un dataset rappresentativo registrerei il piano con `EXPLAIN (ANALYZE, BUFFERS)`, le righe elaborate, lo spazio degli indici e il costo degli aggiornamenti. `ANALYZE` esegue la query: questa misura richiede un ambiente di prova adatto. La [guida PostgreSQL a EXPLAIN](https://www.postgresql.org/docs/18/using-explain.html) aiuta a leggere il piano; per la latenza del servizio servono anche richieste concorrenti, carico dichiarato e misure ripetute.

La pagina dei risultati fa parte dello stesso requisito. Filtri e permessi devono selezionare i fornitori prima della paginazione: prendere venti righe e scartare dopo quelle senza sedi valide può nascondere altri risultati disponibili. Anche l’ordinamento deve stabilire come risolvere le parità.

L’analisi delle query può diventare una [skill riproducibile](https://www.matteovincenti.com/blog/ottimizzare-skills-claude-codex): ricevere schema, versione, query, risultati attesi e piano reale, poi proporre una modifica e una verifica. La skill è ancora da costruire; la procedura partirebbe dal test di correttezza già disponibile.

Prima di introdurre una cache applicativa voglio quindi sapere quale operazione supera una soglia accettabile e perché. Il test su `correct_result` diventa il vincolo da conservare mentre cambia il piano: l’ottimizzazione deve continuare a restituire 2 e 3, escludere 1 e non prendere in prestito i dati dell’altra organizzazione.
