Première exécution réelle de la stack (build des images, PostgreSQL 16, parcours fonctionnels en HTTP) : 28 tables, 105 index, extension pg_trgm, et 181 assertions rejouées après correction. Santé : filtres enum invalides renvoyaient 500 au lieu d'une erreur française ; plan_line s'arrêtait à la fin de la fenêtre du graphique au lieu de la date d'atteinte de l'objectif ; deficit_target_kcal était recalculé après le plancher calorique ; « dernière pesée » affichait deux valeurs différentes selon l'endpoint ; objectif protéines absent. Vape : durée de vie moyenne des résistances incluait la résistance en cours ; archiver la recette active la laissait active ; coût théorique inventé avant la date d'arrêt ; économies projetées dans le futur ; €/ml arrondi à 2 décimales écrasait le modèle de coût DIY. Finances : le sankey compensait crédits et débits non catégorisés ; rows_total excluait les lignes filtrées, faussant l'arithmétique du rapport d'import. Socle : les erreurs HTTP du framework fuitaient en anglais dans l'enveloppe française ; nginx renvoyait sa page 413 HTML au lieu du JSON français ; fins de ligne normalisées en LF. 348 tests pytest (+7), ruff, tsc et vite build au vert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
347 lines
14 KiB
Python
347 lines
14 KiB
Python
"""Offline PostgreSQL DDL validation.
|
|
|
|
The pytest suite runs on SQLite (CONVENTIONS C8) and the production engine is
|
|
PostgreSQL 16: without this file the PostgreSQL DDL of the whole schema would
|
|
never be exercised at all until the first `docker compose up`.
|
|
|
|
Nothing here needs a server: every table, index and constraint of the *complete*
|
|
metadata (all modules loaded, `LIFETRACK_MODULES` ignored) is compiled with the
|
|
`postgresql` dialect, and the resulting statements are inspected for the traps
|
|
that SQLite silently forgives:
|
|
|
|
* native `ENUM` types (C8.1) — must stay VARCHAR + CHECK;
|
|
* `sa.Uuid` / JSON variants (C8.2, C8.3) — must become UUID / JSONB;
|
|
* partial unique indexes (C8.4) — must carry their `postgresql_where`;
|
|
* FK to `import_runs.id` (C8.5) — must cascade, the import rollback depends on it;
|
|
* identifiers over 63 characters, which PostgreSQL truncates *silently*, and
|
|
index/constraint names that collide once truncated or once put in the single
|
|
per-schema namespace PostgreSQL uses for them;
|
|
* reserved words used as table or column names.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.engine import Dialect
|
|
from sqlalchemy.schema import AddConstraint, CreateIndex, CreateTable
|
|
|
|
from app.core.database import Base
|
|
from app.core.module_loader import import_side_modules
|
|
|
|
|
|
def _load_every_module() -> None:
|
|
"""Populate the metadata with every module, whatever LIFETRACK_MODULES says."""
|
|
previous = os.environ.pop("LIFETRACK_MODULES", None)
|
|
try:
|
|
import_side_modules()
|
|
finally:
|
|
if previous is not None:
|
|
os.environ["LIFETRACK_MODULES"] = previous
|
|
|
|
|
|
_load_every_module()
|
|
|
|
METADATA = Base.metadata
|
|
PG: Dialect = postgresql.dialect()
|
|
|
|
#: PostgreSQL truncates any identifier longer than NAMEDATALEN-1 without a word.
|
|
MAX_IDENTIFIER = 63
|
|
|
|
#: Reserved key words of PostgreSQL 16 (appendix C, "reserved" column only):
|
|
#: those cannot name a table or a column without double quotes.
|
|
_PG_RESERVED_SOURCE = """
|
|
all analyse analyze and any array as asc asymmetric both case cast check
|
|
collate column constraint create current_catalog current_date current_role
|
|
current_time current_timestamp current_user default deferrable desc distinct
|
|
do else end except false fetch for foreign from grant group having in
|
|
initially intersect into lateral leading limit localtime localtimestamp not
|
|
null offset on only or order placing primary references returning select
|
|
session_user some symmetric table then to trailing true union unique user
|
|
using variadic when where window with
|
|
"""
|
|
PG_RESERVED_WORDS = frozenset(_PG_RESERVED_SOURCE.split())
|
|
|
|
|
|
def _tables() -> list[sa.Table]:
|
|
return list(METADATA.sorted_tables)
|
|
|
|
|
|
def _columns() -> list[tuple[sa.Table, sa.Column]]:
|
|
return [(table, column) for table in _tables() for column in table.columns]
|
|
|
|
|
|
def _implicit_constraint_name(table: sa.Table, constraint: sa.Constraint) -> str:
|
|
"""Name PostgreSQL generates for a constraint the models left unnamed."""
|
|
columns = "_".join(column.name for column in constraint.columns)
|
|
if isinstance(constraint, sa.PrimaryKeyConstraint):
|
|
return f"{table.name}_pkey"
|
|
if isinstance(constraint, sa.ForeignKeyConstraint):
|
|
return f"{table.name}_{columns}_fkey"
|
|
if isinstance(constraint, sa.UniqueConstraint):
|
|
return f"{table.name}_{columns}_key"
|
|
return f"{table.name}_{columns}_check"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Everything compiles
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_the_metadata_holds_every_module_table() -> None:
|
|
names = set(METADATA.tables)
|
|
assert {"users", "import_runs"} <= names # core
|
|
assert {"weight_entries", "workouts", "food_entries"} <= names # health
|
|
assert {"products", "mixes", "liquid_entries"} <= names # vape
|
|
assert {"fin_accounts", "fin_transactions", "fin_rules"} <= names # finance
|
|
assert len(names) >= 28
|
|
|
|
|
|
def test_every_table_compiles_to_postgresql_ddl() -> None:
|
|
for table in _tables():
|
|
statement = str(CreateTable(table).compile(dialect=PG))
|
|
assert statement.lstrip().startswith("CREATE TABLE"), table.name
|
|
assert table.name in statement
|
|
|
|
|
|
def test_every_index_compiles_to_postgresql_ddl() -> None:
|
|
count = 0
|
|
for table in _tables():
|
|
for index in table.indexes:
|
|
statement = str(CreateIndex(index).compile(dialect=PG))
|
|
assert "CREATE " in statement and "INDEX" in statement, index.name
|
|
count += 1
|
|
assert count >= 50
|
|
|
|
|
|
def test_every_constraint_compiles_to_postgresql_ddl() -> None:
|
|
for table in _tables():
|
|
for constraint in table.constraints:
|
|
# isolate_from_table=False: the default detaches the constraint from
|
|
# its CREATE TABLE (it is meant for ALTER-based schemas) and would
|
|
# silently strip it from the DDL every later test compiles.
|
|
statement = str(
|
|
AddConstraint(constraint, isolate_from_table=False).compile(dialect=PG)
|
|
)
|
|
assert statement.startswith(f"ALTER TABLE {table.name} ADD")
|
|
|
|
|
|
def test_create_all_emits_the_whole_schema_on_postgresql() -> None:
|
|
"""`create_all` (the v1 migration strategy, §9) against a mock PG engine."""
|
|
emitted: list[str] = []
|
|
|
|
def executor(sql: sa.ClauseElement, *args: object, **kwargs: object) -> None:
|
|
emitted.append(str(sql.compile(dialect=engine.dialect)))
|
|
|
|
engine = sa.create_mock_engine("postgresql+psycopg://", executor)
|
|
METADATA.create_all(engine, checkfirst=False)
|
|
|
|
tables = len(METADATA.tables)
|
|
indexes = sum(len(table.indexes) for table in _tables())
|
|
# tables + indexes + the pg_trgm hook of datamodel-finance §2.5
|
|
assert len(emitted) == tables + indexes + 1
|
|
assert sum(s.lstrip().startswith("CREATE TABLE") for s in emitted) == tables
|
|
assert not any("CREATE TYPE" in s for s in emitted) # no native enum (C8.1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Column types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_enum_columns_are_varchar_with_a_check_constraint() -> None:
|
|
"""C8.1: `native_enum=False` + `create_constraint=True` -> VARCHAR + CHECK."""
|
|
enum_columns = [
|
|
(table, column)
|
|
for table, column in _columns()
|
|
if isinstance(column.type, sa.Enum)
|
|
]
|
|
assert len(enum_columns) >= 16
|
|
for table, column in enum_columns:
|
|
enum_type: sa.Enum = column.type
|
|
assert enum_type.native_enum is False, f"{table.name}.{column.name}"
|
|
assert enum_type.create_constraint is True, f"{table.name}.{column.name}"
|
|
assert column.type.compile(dialect=PG).startswith("VARCHAR")
|
|
ddl = str(CreateTable(table).compile(dialect=PG))
|
|
assert f"CHECK ({column.name} IN (" in ddl, f"{table.name}.{column.name}"
|
|
|
|
|
|
def test_uuid_columns_use_the_native_postgresql_uuid_type() -> None:
|
|
uuid_columns = [
|
|
(table, column)
|
|
for table, column in _columns()
|
|
if isinstance(column.type, sa.Uuid)
|
|
]
|
|
assert len(uuid_columns) >= 15
|
|
for table, column in uuid_columns:
|
|
assert column.type.compile(dialect=PG) == "UUID", f"{table.name}.{column.name}"
|
|
|
|
|
|
def test_json_columns_compile_to_jsonb() -> None:
|
|
"""C8.3: JSON on SQLite, JSONB on PostgreSQL — always through JSONB_V."""
|
|
json_columns = [
|
|
(table, column)
|
|
for table, column in _columns()
|
|
if isinstance(column.type, sa.JSON)
|
|
]
|
|
assert len(json_columns) >= 12
|
|
for table, column in json_columns:
|
|
assert column.type.compile(dialect=PG) == "JSONB", f"{table.name}.{column.name}"
|
|
|
|
|
|
def test_every_datetime_column_is_timestamptz() -> None:
|
|
"""C2.3: instants are stored in UTC in a `timestamptz`, never a naive one."""
|
|
for table, column in _columns():
|
|
compiled = column.type.compile(dialect=PG)
|
|
if not compiled.startswith("TIMESTAMP"):
|
|
continue
|
|
assert compiled == "TIMESTAMP WITH TIME ZONE", f"{table.name}.{column.name}"
|
|
|
|
|
|
def test_numeric_columns_declare_precision_and_scale() -> None:
|
|
"""A bare NUMERIC on PostgreSQL stores any precision: money would drift."""
|
|
for table, column in _columns():
|
|
if not isinstance(column.type, sa.Numeric) or isinstance(column.type, sa.Float):
|
|
continue
|
|
assert column.type.precision is not None, f"{table.name}.{column.name}"
|
|
assert column.type.scale is not None, f"{table.name}.{column.name}"
|
|
assert re.fullmatch(r"NUMERIC\(\d+, \d+\)", column.type.compile(dialect=PG)), (
|
|
f"{table.name}.{column.name}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Indexes and constraints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_partial_indexes_carry_both_dialect_where_clauses() -> None:
|
|
"""C8.4: `sqlite_where` AND `postgresql_where`, same expression."""
|
|
partial = [
|
|
(table, index)
|
|
for table in _tables()
|
|
for index in table.indexes
|
|
if index.dialect_options["postgresql"]._non_defaults.get("where") is not None
|
|
or index.dialect_options["sqlite"]._non_defaults.get("where") is not None
|
|
]
|
|
assert len(partial) >= 5
|
|
for table, index in partial:
|
|
pg_where = index.dialect_options["postgresql"]._non_defaults.get("where")
|
|
sqlite_where = index.dialect_options["sqlite"]._non_defaults.get("where")
|
|
assert pg_where is not None, f"{index.name}: missing postgresql_where"
|
|
assert sqlite_where is not None, f"{index.name}: missing sqlite_where"
|
|
assert str(pg_where) == str(sqlite_where), index.name
|
|
statement = str(CreateIndex(index).compile(dialect=PG))
|
|
assert " WHERE " in statement, index.name
|
|
assert "nulls not distinct" not in statement.lower() # C8.4
|
|
|
|
|
|
def test_no_index_uses_nulls_not_distinct() -> None:
|
|
"""C8.4: unsupported by SQLite, and only available since PostgreSQL 15."""
|
|
for table in _tables():
|
|
for index in table.indexes:
|
|
options = index.dialect_options["postgresql"]._non_defaults
|
|
assert "nulls_not_distinct" not in options, index.name
|
|
|
|
|
|
def test_every_fk_to_import_runs_cascades() -> None:
|
|
"""C8.5: `DELETE /api/imports/{id}` relies on the database cascade."""
|
|
seen = 0
|
|
for table in _tables():
|
|
for fk in table.foreign_keys:
|
|
if fk.column.table.name != "import_runs":
|
|
continue
|
|
seen += 1
|
|
assert fk.ondelete == "CASCADE", f"{table.name}.{fk.parent.name}"
|
|
ddl = str(CreateTable(table).compile(dialect=PG))
|
|
assert "REFERENCES import_runs (id) ON DELETE CASCADE" in ddl, table.name
|
|
assert seen >= 9
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Identifiers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_identifiers_fit_the_63_character_limit() -> None:
|
|
"""PostgreSQL truncates longer identifiers silently (NAMEDATALEN = 64)."""
|
|
too_long: list[str] = []
|
|
for table in _tables():
|
|
candidates = [table.name, *(column.name for column in table.columns)]
|
|
candidates += [index.name or "" for index in table.indexes]
|
|
for constraint in table.constraints:
|
|
candidates.append(
|
|
str(constraint.name)
|
|
if constraint.name is not None
|
|
and not str(constraint.name).startswith("_unnamed_")
|
|
else _implicit_constraint_name(table, constraint)
|
|
)
|
|
too_long += [name for name in candidates if len(name) > MAX_IDENTIFIER]
|
|
assert too_long == []
|
|
|
|
|
|
def test_no_table_or_column_is_a_postgresql_reserved_word() -> None:
|
|
offenders = [
|
|
f"{table.name}.{column.name}"
|
|
for table, column in _columns()
|
|
if column.name.lower() in PG_RESERVED_WORDS
|
|
] + [table.name for table in _tables() if table.name.lower() in PG_RESERVED_WORDS]
|
|
assert offenders == []
|
|
|
|
|
|
def test_index_and_unique_constraint_names_are_unique_schema_wide() -> None:
|
|
"""PostgreSQL keeps indexes and their backing constraints in one namespace."""
|
|
names: dict[str, str] = {}
|
|
for table in _tables():
|
|
owners = [(index.name, f"index of {table.name}") for index in table.indexes]
|
|
owners += [
|
|
(str(constraint.name), f"constraint of {table.name}")
|
|
for constraint in table.constraints
|
|
if constraint.name is not None
|
|
and not str(constraint.name).startswith("_unnamed_")
|
|
and isinstance(constraint, sa.UniqueConstraint | sa.PrimaryKeyConstraint)
|
|
]
|
|
for name, owner in owners:
|
|
assert name is not None
|
|
assert name not in names, f"{name}: {owner} clashes with {names.get(name)}"
|
|
names[name] = owner
|
|
|
|
|
|
def test_constraint_names_are_unique_within_a_table() -> None:
|
|
for table in _tables():
|
|
named = [
|
|
str(constraint.name)
|
|
for constraint in table.constraints
|
|
if constraint.name is not None
|
|
and not str(constraint.name).startswith("_unnamed_")
|
|
]
|
|
assert len(named) == len(set(named)), table.name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. pg_trgm (datamodel-finance §2.5)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("url", "expected"),
|
|
[("postgresql+psycopg://", True), ("sqlite://", False)],
|
|
)
|
|
def test_trigram_index_is_emitted_on_postgresql_only(url: str, expected: bool) -> None:
|
|
emitted: list[str] = []
|
|
|
|
def executor(sql: sa.ClauseElement, *args: object, **kwargs: object) -> None:
|
|
emitted.append(str(sql.compile(dialect=engine.dialect)))
|
|
|
|
engine = sa.create_mock_engine(url, executor)
|
|
METADATA.create_all(engine, checkfirst=False)
|
|
|
|
trigram = [statement for statement in emitted if "gin_trgm_ops" in statement]
|
|
assert bool(trigram) is expected
|
|
if expected:
|
|
assert "CREATE EXTENSION IF NOT EXISTS pg_trgm" in trigram[0]
|
|
assert "ix_fin_transactions_label_trgm" in trigram[0]
|
|
assert "label_clean" in trigram[0]
|