"""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]