ADR 0001 — raw_data JSONB source de vérité + colonnes générées
Date : initial Statut : Accepté
Contexte
L'amont (Horizon) expose un endpoint /api/travels qui retourne des records voyage richement typés (~30-50 champs imbriqués, dont des collections comme occurrences[], travelRanges[], travelPictures[], minPrice{}). Le contrat de better-search est d'être drop-in compatible avec cet endpoint : le front consomme déjà la même forme JSON.
Deux options de modélisation côté Postgres :
- Schéma relationnel normalisé : une table par concept (
travel,occurrence,travel_range, …) avec des FK. Reconstruction de la réponse JSON via aggregation JSON côté SQL ou côté Python. - JSON document store : une table
travelsavec une colonneraw_data JSONBqui contient le record complet tel quel.
Décision
Option 2 — raw_data JSONB comme source de vérité unique, complétée par des colonnes générées (GENERATED ALWAYS AS … STORED) pour les champs indexables ou filtrables :
sql
CREATE TABLE travels (
raw_data JSONB NOT NULL,
embedding vector(3584) NOT NULL,
id TEXT GENERATED ALWAYS AS (raw_data->>'id') STORED PRIMARY KEY,
destination VARCHAR(2) GENERATED ALWAYS AS (raw_data->'country'->>'code') STORED,
travel_ranges TEXT[] GENERATED ALWAYS AS (jsonb_to_text_array(raw_data, '$.travelRanges[*].slug')) STORED,
departure_dates TIMESTAMP[] GENERATED ALWAYS AS (jsonb_to_timestamp_array(raw_data, '$.occurrences[*].start')) STORED,
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('french', COALESCE(raw_data->>'name', '')), 'A') ||
setweight(to_tsvector('french', COALESCE(raw_data->>'subtitle', '')), 'B') ||
setweight(to_tsvector('french', COALESCE(raw_data->>'description', '')), 'C')
) STORED,
-- + is_valid, discount_club, is_seaside, name
updated_at TIMESTAMP DEFAULT NOW()
);Conséquences
Positives
- ✅ Réponse JSON triviale.
SELECT raw_data FROM travelsrenvoie déjà la forme attendue par le front. Pas de mapping ORM, pas d'AutoMapper. - ✅ Une seule source de vérité. Pas de désynchro possible entre
raw_dataet les colonnes : Postgres recalcule les colonnes générées à chaque INSERT/UPDATE. - ✅ Schéma évolutif côté amont sans migration. Si Horizon ajoute un champ
raw_data.foo, on l'a immédiatement dans le payload renvoyé au front. Pas besoin de toucher au schéma. - ✅ UPSERT idempotent.
INSERT ... ON CONFLICT (id) DO UPDATE SET raw_data = EXCLUDED.raw_datarecalcule tout. Pas de gestion fastidieuse d'updates partiels. - ✅ Filtres rapides. Les colonnes générées sont indexables (BTREE, GIN). Les filtres
?destination=,?category=,?dates=ne touchent pas le JSONB à l'exécution. - ✅ FTS et embeddings sur les bons champs.
search_vectorextrait pondéré nom/sous-titre/description directement depuis JSONB.
Négatives
- ❌ Ajouter un filtre = migration SQL. Pas juste un changement applicatif. Voir
modules/migrations.mdpour le workflow. - ❌ Colonnes générées immutables. Modifier la formule (ex: changer la pondération FTS, changer la JSONPath de
travel_ranges) demande unDROP COLUMN+ recreation + reindex complet. Pas trivial. - ❌ Pas de validation typée par Postgres. Si Horizon retourne un
bookingStateen string au lieu d'int, la colonne généréeis_validressortfalsesilencieusement. La validation reste de la responsabilité de l'amont. - ❌ JSONB lourd en stockage. Chaque record voyage = quelques Ko, dupliqué entre
raw_dataet chaque colonne générée. Acceptable à l'échelle actuelle (< 1000 records). - ❌
jsonb_to_*_arraydoivent resterIMMUTABLE. Toute modification de ces helpers casse les colonnes générées qui s'en servent.
Alternatives écartées
- Schéma relationnel : on aurait du gain en validation et lisibilité, mais le coût en mapping JSON↔relations et en migrations est élevé pour un service de pure recherche dont la persistance est un cache de l'amont.
- Champs duplicate-but-not-generated (colonne
destination TEXTremplie par l'application) : abandonné, source de désynchro. LeGENERATED ALWAYS AS … STOREDest une vraie killer feature de Postgres pour ce cas. - MongoDB / Elasticsearch : auraient résolu le problème document-oriented + recherche, mais (a) ajouterait un service à opérer, (b) on perdrait pgvector, (c) FTS français costaud est natif en Postgres.
Implications opérationnelles
- Toute évolution du modèle Horizon qui change la JSONPath d'un champ filtré (
country.code,travelRanges[*].slug,occurrences[*].start,occurrences[*].bookingState,minPrice.pricePerPersonWith…) casse silencieusement les filtres. Tests d'intégration surtests/test_db.jsondétectent les régressions sur la forme. - Les colonnes générées sont visibles dans
\d travelscôté psql — utile au debug. - Le seul writer dans
travelsestsrc/reindex.py. Aucun autre code ne doit faire d'INSERT/UPDATE direct.
Contributors
No contributors
Changelog
No recent changes

