"""マイグレーションの適用(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] == 5
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