generated from john/python-template
These changes all regenerate the same schema, so they land together and revert
together. A partially applied schema pass is not a valid state.
Remove hand-rolled migrations [HIGH-05]
- Delete upgrade_schema and the _upgrade_person_family_search_id /
_upgrade_v42_evidence_tables / _upgrade_v45_selection_columns chain, plus the
two tests that exercised them. The DDL was SQLite-shaped raw SQL that would
not have run on PostgreSQL. create_all now derives everything from metadata
and remains gated by Settings.should_bootstrap_schema. No raw ALTER TABLE or
CREATE INDEX string remains in src.
Break the foreign key cycle [HIGH-08]
- Declare Source.preferred_execution_attempt_id with use_alter=True and an
explicit constraint name. source / job_source / execution_attempt formed an
unresolvable cycle that made metadata.sorted_tables emit an SAWarning and
order execution_attempt before source, which would have been a hard
create_all failure on PostgreSQL and was invisible on SQLite.
- As a side effect the column is now a dialect-aware Uuid rather than the
hardcoded CHAR(32) the raw upgrade DDL produced, so it emits native UUID on
PostgreSQL.
Index the hot filters [HIGH-04]
- Add composite Index("ix_job_status_date_created", "status", "date_created")
for the worker poll, and index the foreign keys the worker and detail pages
filter on: job.document_id, source.document_id, job_source.job_id,
job_source.source_id, document.document_type_id, and the three
document_person foreign keys.
Stop preloading by default [CRIT-02]
- Flip 16 relationships from lazy="selectin" to lazy="raise". The bidirectional
selectin defaults meant loading one Job pulled a large connected subgraph.
- Three further relationships (ExecutionAttempt.job_source,
ProcessingArtifact.execution_attempt, ProcessingArtifact.source) declared no
lazy at all and defaulted to "select", which raises MissingGreenlet under
async. These are now "raise" as well.
- Only 5 of 262 tests failed under the flip; the service layer already carried
explicit eager loads. Fixes went into the service queries, never back into
the models:
- PeopleService._finalize_link refreshes document, person, and role_ref so
the DocumentPerson write endpoints can still project them.
- JobService.update_job_state loads job_sources -> source so the Job it
returns still answers .error_detail and .filename.
- Two tests that bypassed the service layer now load explicitly.
- Audited every UI relationship access against its feeding service method; all
resolve to *_detail / list_*_detail variants with complete eager loads.
Tests
- Assert the composite and hot foreign key indexes exist in a fresh schema.
- Assert metadata.sorted_tables raises no SAWarning and orders source before
execution_attempt.
- Assert preferred_execution_attempt_id is a Uuid that compiles to UUID on
PostgreSQL and that its foreign key carries use_alter.
- Guard CRIT-02 from regression: no mapped relationship may declare a lazy
strategy outside {raise, noload}.
The development database was rebuilt from metadata rather than upgraded; the
previous file is retained out of tree as the Phase 8 migration source.
Verified: 266 passed, 4 skipped; ruff check clean.
Co-authored-by: Copilot App <[email protected]>
187 lines
6.9 KiB
Python
187 lines
6.9 KiB
Python
"""Tests for the database runtime and V2 schema bootstrap behavior."""
|
|
|
|
import warnings
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import inspect
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlmodel import SQLModel
|
|
from sqlmodel import select
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from transcription.config import Settings
|
|
from transcription.config import SqliteSettings
|
|
from transcription.db import create_all
|
|
from transcription.db import dispose_database_runtime
|
|
from transcription.db import initialize_database_runtime
|
|
from transcription.db import session_scope
|
|
from transcription.db.models import DocumentType
|
|
from transcription.db.models import PersonRole
|
|
from transcription.db.models import Source
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_all_creates_expected_tables(tmp_path):
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "schema.db")),
|
|
environment="test",
|
|
)
|
|
runtime = initialize_database_runtime(settings=settings)
|
|
|
|
try:
|
|
await create_all(engine=runtime.engine)
|
|
async with runtime.engine.connect() as conn:
|
|
table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names()))
|
|
|
|
assert "document" in table_names
|
|
assert "document_type" in table_names
|
|
assert "person" in table_names
|
|
assert "person_role" in table_names
|
|
assert "document_person" in table_names
|
|
assert "job" in table_names
|
|
assert "source" in table_names
|
|
assert "job_source" in table_names
|
|
assert "execution_attempt" in table_names
|
|
assert "processing_artifact" in table_names
|
|
assert "revision" not in table_names
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_yields_async_session(tmp_path):
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "session.db")),
|
|
environment="test",
|
|
)
|
|
initialize_database_runtime(settings=settings)
|
|
|
|
try:
|
|
async with session_scope(settings=settings) as session:
|
|
assert session is not None
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_rejects_reinitialization_for_different_database(tmp_path):
|
|
"""An existing process runtime cannot silently switch database targets."""
|
|
first_settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "first.db")),
|
|
environment="test",
|
|
)
|
|
second_settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "second.db")),
|
|
environment="test",
|
|
)
|
|
initialize_database_runtime(settings=first_settings)
|
|
|
|
try:
|
|
with pytest.raises(RuntimeError, match="already initialized for a different database"):
|
|
initialize_database_runtime(settings=second_settings)
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_all_seeds_default_registry_rows(tmp_path):
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "seed.db")),
|
|
environment="test",
|
|
)
|
|
runtime = initialize_database_runtime(settings=settings)
|
|
|
|
try:
|
|
await create_all(engine=runtime.engine)
|
|
async with AsyncSession(runtime.engine, expire_on_commit=False) as session:
|
|
role_keys = set((await session.exec(select(PersonRole.semantic_key))).all())
|
|
type_keys = set((await session.exec(select(DocumentType.semantic_key))).all())
|
|
|
|
assert {"author", "recipient", "mentioned"}.issubset(role_keys)
|
|
assert {"book", "letter", "postcard", "photo", "journal", "form"}.issubset(type_keys)
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_all_declares_hot_path_indexes(tmp_path):
|
|
"""Worker and detail-page filters must be index-backed in a freshly created schema."""
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "indexes.db")),
|
|
environment="test",
|
|
)
|
|
runtime = initialize_database_runtime(settings=settings)
|
|
|
|
try:
|
|
await create_all(engine=runtime.engine)
|
|
async with runtime.engine.connect() as connection:
|
|
|
|
def collect(sync_connection) -> dict[str, list[list[str]]]:
|
|
database = inspect(sync_connection)
|
|
return {
|
|
table: [index["column_names"] for index in database.get_indexes(table)]
|
|
for table in ("job", "source", "job_source", "document", "document_person")
|
|
}
|
|
|
|
indexes = await connection.run_sync(collect)
|
|
|
|
assert ["status", "date_created"] in indexes["job"]
|
|
assert ["document_id"] in indexes["job"]
|
|
assert ["document_id"] in indexes["source"]
|
|
assert ["preferred_execution_attempt_id"] in indexes["source"]
|
|
assert ["job_id"] in indexes["job_source"]
|
|
assert ["source_id"] in indexes["job_source"]
|
|
assert ["document_type_id"] in indexes["document"]
|
|
for column in ("document_id", "person_id", "role_id"):
|
|
assert [column] in indexes["document_person"]
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
def test_metadata_has_no_unresolvable_table_cycle():
|
|
"""create_all must be able to order every table, including on PostgreSQL."""
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error", sa.exc.SAWarning)
|
|
ordered = [table.name for table in SQLModel.metadata.sorted_tables]
|
|
|
|
assert ordered.index("source") < ordered.index("execution_attempt")
|
|
|
|
|
|
def test_preferred_execution_attempt_id_column_matches_model_declaration():
|
|
"""The self-referential provenance column is a real UUID, not an opaque CHAR(32)."""
|
|
column = SQLModel.metadata.tables["source"].c["preferred_execution_attempt_id"]
|
|
|
|
assert isinstance(column.type, sa.Uuid)
|
|
assert column.type.compile(dialect=postgresql.dialect()) == "UUID"
|
|
assert Source.model_fields["preferred_execution_attempt_id"].annotation is not None
|
|
|
|
foreign_key = next(iter(column.foreign_keys))
|
|
assert foreign_key.use_alter is True
|
|
assert foreign_key.column is SQLModel.metadata.tables["execution_attempt"].c["id"]
|
|
|
|
|
|
def test_bootstrap_policy_production_defaults_false():
|
|
settings = Settings(openrouter_api_key="test-key", environment="production")
|
|
assert settings.should_bootstrap_schema is False
|
|
|
|
|
|
def test_bootstrap_policy_development_defaults_true():
|
|
settings = Settings(openrouter_api_key="test-key", environment="development")
|
|
assert settings.should_bootstrap_schema is True
|
|
|
|
|
|
def test_bootstrap_policy_explicit_override_true():
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
environment="production",
|
|
bootstrap_schema_on_startup=True,
|
|
)
|
|
assert settings.should_bootstrap_schema is True
|