Newer
Older
TIASDataHub / tests / catalog / test_migrate.py
"""マイグレーションの適用(testing.md 必須対象 8)."""

import re
import sqlite3
from pathlib import Path

import pytest

from tiasdata import catalog
from tiasdata.catalog import migrate
from tiasdata.catalog.migrate import Migration, MigrationResult


def _tables(conn: sqlite3.Connection) -> set[str]:
    rows = conn.execute(
        "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
    )
    return {name for (name,) in rows}


def _schema(conn: sqlite3.Connection) -> list[tuple]:
    return conn.execute("SELECT type, name, sql FROM sqlite_master ORDER BY type, name").fetchall()


@pytest.mark.parametrize("series", ["catalog", "identity"])
def test_apply_to_empty_db_reaches_latest_version(tmp_path: Path, series: str) -> None:
    conn = catalog.connect(tmp_path / "db.sqlite", create=True)
    try:
        result = catalog.apply_migrations(conn, series)
        latest = catalog.latest_version(series)
        assert latest >= 1
        assert result == MigrationResult(0, latest)
        assert result.applied == latest
        assert conn.execute("PRAGMA user_version").fetchone()[0] == latest
        assert catalog.schema_version(conn) == latest
    finally:
        conn.close()


@pytest.mark.parametrize("series", ["catalog", "identity"])
def test_reapply_changes_nothing(tmp_path: Path, series: str) -> None:
    path = tmp_path / "db.sqlite"
    conn = catalog.connect(path, create=True)
    catalog.apply_migrations(conn, series)
    schema_before = _schema(conn)
    conn.close()

    # 別の接続から再適用しても何も変わらない
    conn = catalog.connect(path)
    try:
        result = catalog.apply_migrations(conn, series)
        latest = catalog.latest_version(series)
        assert result == MigrationResult(latest, latest)
        assert result.applied == 0
        assert _schema(conn) == schema_before
        assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
    finally:
        conn.close()


def test_reapply_keeps_kinds_rows_and_data(catalog_conn: sqlite3.Connection) -> None:
    catalog_conn.execute(
        "INSERT INTO subjects (subject_id, created_at) VALUES ('S000001', '2026-10-06T00:00:00Z')"
    )
    catalog_conn.commit()
    catalog.apply_migrations(catalog_conn, "catalog")
    assert catalog_conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 6
    assert catalog_conn.execute("SELECT COUNT(*) FROM subjects").fetchone()[0] == 1


def test_catalog_and_identity_are_separate_series(tmp_path: Path) -> None:
    cat = catalog.connect(tmp_path / "catalog.sqlite", create=True)
    ident = catalog.connect(tmp_path / "identity.sqlite", create=True)
    try:
        catalog.apply_migrations(cat, "catalog")
        catalog.apply_migrations(ident, "identity")
        # 目録に対応表の表は無く,対応表に目録の表は無い
        assert "identity_map" not in _tables(cat)
        assert _tables(ident) == {"identity_map"}
    finally:
        cat.close()
        ident.close()


def test_db_newer_than_library_is_rejected(tmp_path: Path) -> None:
    conn = catalog.connect(tmp_path / "db.sqlite", create=True)
    try:
        catalog.apply_migrations(conn, "catalog")
        newer = catalog.latest_version("catalog") + 1
        conn.execute(f"PRAGMA user_version = {newer}")
        with pytest.raises(ValueError):
            catalog.apply_migrations(conn, "catalog")
        # 版は書き換わらない
        assert catalog.schema_version(conn) == newer
    finally:
        conn.close()


def test_failed_migration_leaves_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    # 1 ファイル目は正常,2 ファイル目は途中で失敗するマイグレーション
    good = Migration(1, "0001_good.sql", "CREATE TABLE a (x INTEGER);")
    bad = Migration(
        2,
        "0002_bad.sql",
        "CREATE TABLE b (x INTEGER);\nINSERT INTO a VALUES (1);\nTHIS IS NOT SQL;",
    )
    monkeypatch.setattr(migrate, "load_migrations", lambda series: [good, bad])

    conn = catalog.connect(tmp_path / "db.sqlite", create=True)
    try:
        with pytest.raises(sqlite3.Error):
            catalog.apply_migrations(conn, "catalog")
        # 失敗したファイルの変更は残らず,前のファイルまでは適用済み
        assert catalog.schema_version(conn) == 1
        assert _tables(conn) == {"a"}
        assert conn.execute("SELECT COUNT(*) FROM a").fetchone()[0] == 0
        assert not conn.in_transaction
    finally:
        conn.close()

    # 別の接続から見ても同じ(確定した内容だけが残っている)
    conn = catalog.connect(tmp_path / "db.sqlite")
    try:
        assert catalog.schema_version(conn) == 1
        assert _tables(conn) == {"a"}
    finally:
        conn.close()


def test_failed_first_migration_leaves_empty_db(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    bad = Migration(1, "0001_bad.sql", "CREATE TABLE a (x INTEGER);\nTHIS IS NOT SQL;")
    monkeypatch.setattr(migrate, "load_migrations", lambda series: [bad])
    conn = catalog.connect(tmp_path / "db.sqlite", create=True)
    try:
        with pytest.raises(sqlite3.Error):
            catalog.apply_migrations(conn, "catalog")
        assert catalog.schema_version(conn) == 0
        assert _tables(conn) == set()
    finally:
        conn.close()


@pytest.mark.parametrize("series", ["catalog", "identity"])
def test_bundled_migrations_are_numbered_from_one(series: str) -> None:
    migrations = migrate.load_migrations(series)
    assert [m.version for m in migrations] == list(range(1, len(migrations) + 1))
    assert catalog.latest_version(series) == len(migrations)


@pytest.mark.parametrize("series", ["catalog", "identity"])
def test_bundled_migrations_do_not_manage_transaction_or_version(series: str) -> None:
    # apply_migrations() が BEGIN・COMMIT・user_version を付けるため,ファイル側に書かない
    for m in migrate.load_migrations(series):
        code = "\n".join(
            line for line in m.sql.splitlines() if not line.lstrip().startswith("--")
        ).upper()
        assert "USER_VERSION" not in code, m.name
        # トリガ本体の BEGIN … END; は対象外(トランザクションの文だけを見る)
        pattern = (
            r"^\s*(BEGIN(\s+(DEFERRED|IMMEDIATE|EXCLUSIVE))?(\s+TRANSACTION)?"
            r"|COMMIT(\s+TRANSACTION)?|END\s+TRANSACTION|ROLLBACK)\s*;"
        )
        assert re.search(pattern, code, re.M) is None, m.name


# ---------------------------------------------------------------------------
# catalog/0002: 所見の種類 review(2026-10-08 のユーザー判断)
# ---------------------------------------------------------------------------
def _review_kind(conn: sqlite3.Connection) -> tuple | None:
    return conn.execute(
        "SELECT kind, category, value_schema_version FROM kinds WHERE kind = 'review'"
    ).fetchone()


def test_catalog_latest_version_includes_review_kind() -> None:
    names = [m.name for m in migrate.load_migrations("catalog")]
    assert len(names) >= 2
    assert names[1].startswith("0002_")


def test_review_kind_from_empty_db(tmp_path: Path) -> None:
    # 0 → 最新: review は annotation・value_schema_version=1
    conn = catalog.connect(tmp_path / "db.sqlite", create=True)
    try:
        result = catalog.apply_migrations(conn, "catalog")
        assert result.before == 0
        assert result.after == catalog.latest_version("catalog")
        assert _review_kind(conn) == ("review", "annotation", 1)
    finally:
        conn.close()


def test_review_kind_from_version_1_keeps_existing_rows(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    # 1 → 最新: 研究室 PC の目録(0001 だけ適用・取り込み済み)に 0002 を足す
    path = tmp_path / "db.sqlite"
    first_only = migrate.load_migrations("catalog")[:1]
    with monkeypatch.context() as m:
        m.setattr(migrate, "load_migrations", lambda series: first_only)
        conn = catalog.connect(path, create=True)
        catalog.apply_migrations(conn, "catalog")
        assert catalog.schema_version(conn) == 1
        assert _review_kind(conn) is None
        conn.execute(
            "INSERT INTO subjects (subject_id, created_at)"
            " VALUES ('S000001', '2026-10-06T00:00:00Z')"
        )
        conn.execute("INSERT INTO captures (capture_id, subject_id) VALUES ('C000001', 'S000001')")
        conn.execute(
            "INSERT INTO annotations (kind, capture_id, value, source, created_at) VALUES"
            " ('tongue_color', 'C000001', '{\"label\":\"red\",\"severity\":\"mild\"}', 'x',"
            " '2026-10-06T00:00:00Z')"
        )
        conn.commit()
        kinds_before = conn.execute("SELECT * FROM kinds ORDER BY kind").fetchall()
        conn.close()

    conn = catalog.connect(path)
    try:
        result = catalog.apply_migrations(conn, "catalog")
        latest = catalog.latest_version("catalog")
        assert result == MigrationResult(1, latest)
        assert catalog.schema_version(conn) == latest
        assert _review_kind(conn) == ("review", "annotation", 1)
        kinds_after = conn.execute("SELECT * FROM kinds WHERE kind != 'review' ORDER BY kind")
        assert kinds_after.fetchall() == kinds_before
        assert conn.execute("SELECT COUNT(*) FROM subjects").fetchone()[0] == 1
        assert conn.execute("SELECT COUNT(*) FROM captures").fetchone()[0] == 1
        assert conn.execute("SELECT COUNT(*) FROM annotations").fetchone()[0] == 1
        # review の所見を付けられる(外部キー kinds を満たす)
        conn.execute(
            "INSERT INTO annotations (kind, capture_id, value, source, created_at) VALUES"
            " ('review', 'C000001', '{}', 'review_list', '2026-10-08T00:00:00Z')"
        )
        conn.commit()
        assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok"

        # 再適用しても review の行は 1 つのまま
        again = catalog.apply_migrations(conn, "catalog")
        assert again.applied == 0
        assert conn.execute("SELECT COUNT(*) FROM kinds WHERE kind = 'review'").fetchone()[0] == 1
    finally:
        conn.close()