Module — Migrations SQL
Source de vérité du schéma : les fichiers SQL sous migrations/. Gérés via yoyo-migrations. ADR : architecture/adr/0004-yoyo-migrations.md.
Inventaire
migrations/
├── 0001_baseline.sql — schéma initial, helpers, table, indexes, vue v1
├── 0002_add_next_departure_view.sql — vue v2 (next_departure calculé)
├── 0003_update_travels_view.sql — vue v3 (filtre WHERE next_departure > NOW)
└── 0004_add_seaside_filter.sql — colonne is_seaside + index + vue v4Chaque fichier commence par -- depends: <previous> (sauf 0001 qui a -- depends: vide). yoyo applique dans l'ordre des dépendances et trace en table _yoyo_migration.
Workflow d'application
En conteneur (entrypoint.sh)
yoyo apply --database "$DATABASE_URL" ./migrations --batch- Tourne à chaque démarrage du conteneur. yoyo skip les migrations déjà appliquées.
--batchdésactive l'interaction (indispensable hors TTY).- Si une migration échoue, l'
entrypointplante avecset -e→ FastAPI ne démarre pas.
En local (dev)
# Migration de la DB dev (sur port 5432)
yoyo apply --database "postgresql://better_search:better_search_pwd@localhost:5432/better_search" ./migrations --batch
# Migration de la DB test (sur port 5433)
yoyo apply --database "postgresql://better_search:better_search_pwd@localhost:5433/better_search_test" ./migrations --batchAvec uv : uv run yoyo apply ….
En CI
.gitlab-ci.yml job test :
- uv run yoyo apply --database "$TEST_DATABASE_URL" ./migrations --batchAvant pytest.
Ajouter une migration
- Choisir le numéro :
000N_*.sqloùNest le prochain entier libre. Préfixe à 4 chiffres pour le tri lexical. - Première ligne :
-- depends: 000(N-1)_*(sans l'extension.sql). yoyo applique ce fichier seulement après le précédent. - Écrire le SQL. Idempotent si possible (
CREATE INDEX IF NOT EXISTS,DROP VIEW IF EXISTS, etc.) — facilite les rejouages sur des bases hétéroclites. - Tester en local sur la DB dev :sh
yoyo apply --database "$DATABASE_URL" ./migrations --batch - Vérifier le résultat :
\d travelscôté psql doit montrer la nouvelle colonne / index / contrainte. - Commit + PR. Le job
testde CI applique la migration sur la DB de test puis lancepytest.
Exemple : ajouter un filtre ?promo=true
Hypothèse : un voyage est en promo si raw_data->'minPrice'->>'priceWithSpecialOffers' < raw_data->'minPrice'->>'price'.
1. Migration (migrations/0005_add_promo_filter.sql) :
-- Add is_promo generated column + index
-- depends: 0004_add_seaside_filter
ALTER TABLE travels
ADD COLUMN is_promo BOOLEAN
GENERATED ALWAYS AS (
CASE
WHEN (raw_data->'minPrice'->>'priceWithSpecialOffers')::NUMERIC
< (raw_data->'minPrice'->>'price')::NUMERIC
THEN TRUE ELSE FALSE
END
) STORED;
CREATE INDEX IF NOT EXISTS idx_travels_is_promo ON travels (is_promo);
-- Recréer la vue pour exposer la nouvelle colonne (sinon SELECT * stocké au plan)
DROP VIEW IF EXISTS travels_view;
CREATE VIEW travels_view AS
WITH with_departure_dates AS (
SELECT
raw_data, embedding, id, name, destination,
discount_club, is_valid, is_seaside, is_promo,
travel_ranges, departure_dates, search_vector, updated_at,
(SELECT MIN(d) FROM unnest(departure_dates) d
WHERE d > CURRENT_DATE) AS next_departure
FROM travels
)
SELECT * FROM with_departure_dates
WHERE next_departure > CURRENT_DATE;2. Filtre côté src/search_query.py :
if promo is not None:
where_parts.append("is_promo = %(promo)s")
params["promo"] = promo3. Param côté src/server.py :
promo: bool = None,
# ...
records, total_count = db.search(..., promo=promo)Et db.search doit forwarder promo à build_search_query.
4. Test côté tests/test_search.py : nouvelle classe TestSearchByPromo avec fixtures qui setent priceWithSpecialOffers < price.
Recréer la vue ou pas ?
Quand vous ajoutez une colonne à travels, vous devez recréer travels_view si vous voulez que la nouvelle colonne soit exposée par SELECT * FROM travels_view. Postgres mémorise les colonnes du SELECT * au moment du CREATE VIEW.
Recommandé : DROP VIEW IF EXISTS travels_view; CREATE VIEW travels_view AS … dans la même migration. Faire un seul fichier qui contient les deux DDL.
Marquer une migration comme appliquée (rattrapage)
Pour les bases existantes pré-yoyo, marquer la baseline comme appliquée :
yoyo mark 0001_baseline --database "$DATABASE_URL"yoyo mark ajoute l'entrée dans _yoyo_migration sans exécuter le SQL. À utiliser quand le schéma est déjà en place.
Rollback
Aujourd'hui aucune migration n'a de section __rollback__ (ou from yoyo import step côté Python). Pour rollback :
- Soit ajouter un rollback explicite à la migration (et tester en local).
- Soit restaurer depuis un backup Postgres.
Pas de procédure « yoyo rollback-en-prod-au-prochain-incident » documentée.
Helpers SQL utilisés dans les migrations
Définis dans 0001_baseline.sql :
CREATE OR REPLACE FUNCTION jsonb_to_text_array(j JSONB, path TEXT)
RETURNS TEXT[] AS $$ ... $$ LANGUAGE SQL IMMUTABLE;
CREATE OR REPLACE FUNCTION jsonb_to_timestamp_array(j JSONB, path TEXT)
RETURNS TIMESTAMP[] AS $$ ... $$ LANGUAGE SQL IMMUTABLE;IMMUTABLE: prérequis pour usage dans uneGENERATED ALWAYS AS … STORED.- Ne pas modifier ces fonctions une fois en prod. Postgres les caches dans la définition des colonnes ; une modif peut casser ces colonnes silencieusement. Pour les modifier, il faut : DROP des colonnes qui s'en servent → recréer la fonction → recréer les colonnes → reindex complet.
Anti-patterns
- ❌
yoyo applysur la mauvaise DB en prod. Toujours vérifier$DATABASE_URLavant. - ❌ Renommer une migration appliquée. yoyo refusera (l'entrée existe mais le fichier source a changé).
- ❌ Sauter un numéro (passer de
0004à0006). Pas bloquant techniquement, mais déroutant pour la review. - ❌ Mettre du DML dans une migration (un
INSERT INTO travels). Le reindex est le seul writer. - ❌ Ajouter un
IF EXISTSà unALTER TABLE(Postgres ne le supporte pas pour ALTER). Préférer un test côtépg_attributeou utiliser des migrations idempotentes simples (ADD COLUMN IF NOT EXISTS).
Métadonnées yoyo
yoyo crée et maintient :
_yoyo_migration— registre des migrations appliquées._yoyo_log— historique des opérations._yoyo_lock— verrou (anti-race) pendant qu'une migration tourne.
Visible côté psql via \dt _yoyo_*. Ne pas modifier à la main.

