Files
2026-09-12 18:15:20 +08:00

264 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
跨库数据泵(scripts/migrate_db.py)单元测试
==========================================
用 sqlite 文件同时充当「源库」和「目标库」,验证泵逻辑全链路:
读→分块→executemany→显式 id 保真→自增延续→Text/日期/BigInteger 保真→
软删/空时间原样→only-tables→dry-run→逐表 count 校验→建库→序列重置 SQL。
不依赖真实 MySQL/PostgreSQL(不挂 integration marker,pytest 默认即跑)。
"""
import os
import sys
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine, select, text
# scripts/ 下无 __init__.py,把 scripts 目录加进 sys.path 以便直接 import
_SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
import migrate_db
def make_args(src_db: str, dst_db: str, **overrides) -> SimpleNamespace:
"""构造 sqlite→sqlite 迁移的 CLI 参数(等价 migrate_db.build_parser().parse_args)。"""
base = dict(
src_driver="sqlite", src_host="", src_port=None, src_db=src_db, src_user="", src_pass="",
dst_driver="sqlite", dst_host="", dst_port=None, dst_db=dst_db, dst_user="", dst_pass="",
drop_target_first=False, dry_run=False, chunk_size=1000, only_tables="",
skip_existing=False, skip_verify=False, single_transaction=False,
)
base.update(overrides)
return SimpleNamespace(**base)
@pytest.fixture(scope="module")
def loaded_metadata():
"""一次性加载全部模型填充 Base.metadata(与 migrate_db.run 同源)。"""
from core.app import create_app
from core.database import Base
create_app()
return Base
@pytest.fixture
def two_sqlite(tmp_path, loaded_metadata):
"""一对 sqlite 文件库:源库 + 目标库,均已按模型建好空结构。"""
src_path = tmp_path / "src.db"
dst_path = tmp_path / "dst.db"
src_engine = create_engine(f"sqlite:///{src_path}")
dst_engine = create_engine(f"sqlite:///{dst_path}")
loaded_metadata.metadata.create_all(src_engine)
loaded_metadata.metadata.create_all(dst_engine)
yield src_engine, dst_engine, src_path, dst_path
src_engine.dispose()
dst_engine.dispose()
def _table(loaded_metadata, name: str):
return loaded_metadata.metadata.tables["fastapi_" + name]
def test_make_url():
"""URL 形态与 core.config.database.build_database_url 逐字等价(特殊字符口令编码)。"""
assert migrate_db.make_url("mysql", "127.0.0.1", 3306, "db", "root", "p@ss") \
== "mysql+pymysql://root:p%40ss@127.0.0.1:3306/db?charset=utf8mb4"
assert migrate_db.make_url("postgresql", "127.0.0.1", 5432, "db", "root", "p@ss") \
== "postgresql+psycopg://root:p%40ss@127.0.0.1:5432/db"
assert migrate_db.make_url("mssql", "127.0.0.1", 1433, "db", "sa", "p@ss") \
== "mssql+pymssql://sa:p%40ss@127.0.0.1:1433/db?charset=utf8"
assert migrate_db.make_url("oracle", "127.0.0.1", 1521, "orcl", "hr", "p@ss") \
== "oracle+oracledb://hr:p%40ss@127.0.0.1:1521/?service_name=orcl"
assert migrate_db.make_url("sqlite", "x", None, "dev.db", "", "") == "sqlite:///./dev.db"
def test_pump_preserves_data_ids_and_autoincrement(two_sqlite, loaded_metadata):
"""泵后 id/父子引用/Text/BigInteger/软删/空时间全保真,且新插入自增延续。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
with src_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [
{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0, "sort": 1},
{"id": 2, "name": "研发部", "code": "RD", "parent_id": 1, "sort": 2},
])
conn.execute(_table(loaded_metadata, "user").insert(), [
{"id": 1, "username": "admin", "password": "$2b$12$abcdefghijklmnop", "salt": "salt1",
"realname": "管理员", "nickname": "管理员", "avatar": "", "mobile": "13800000000",
"email": "admin@test.com", "province_code": "110000", "city_code": "110100",
"district_code": "110101", "street_code": "", "address": "北京市", "is_delete": 0},
{"id": 2, "username": "softdel", "password": None, "salt": None,
"realname": "软删", "nickname": "软删", "avatar": "",
"mobile": "13900000000", "email": "s@test.com", "province_code": "", "city_code": "",
"district_code": "", "street_code": "", "address": "", "is_delete": 1},
])
# base_model 的 create_time 带 Python 默认值,Core insert 显式 None 也会被默认覆盖;
# 用 UPDATE 强制置 NULL,验证泵能原样搬空时间列
conn.execute(text("UPDATE fastapi_user SET create_time = NULL WHERE id = 2"))
conn.execute(_table(loaded_metadata, "city").insert(), [
{"id": 1, "name": "北京市", "short_name": "北京", "city_code": "010",
"area_code": "110000", "zip_code": "100000",
"parent_code": None, "parent_id": 0, "level": 1},
{"id": 2, "name": "东城区", "short_name": "东城", "city_code": "010",
"area_code": "110101", "zip_code": "100010",
"parent_code": "1101", "parent_id": 1, "level": 2},
])
conn.execute(_table(loaded_metadata, "article").insert(), [
{"id": 1, "title": "中文标题",
"content": "第一行\n第二行 with <b>html</b> 中文", "images": "a.jpg,b.jpg"},
])
conn.execute(_table(loaded_metadata, "job_log").insert(), [
{"id": 1, "job_name": "测试任务", "consume_time": 9223372036854775807}, # BIGINT 上限
])
assert migrate_db.run(make_args(str(src_path), str(dst_path))) == 0
# 逐表 count 相等(含空表)
for t in migrate_db.ordered_tables():
with src_engine.connect() as src, dst_engine.connect() as dst:
s = src.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
d = dst.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
assert s == d, t.name
with dst_engine.connect() as conn:
# 目标 id 与源逐行一致 + 子树 parent_id 有效
dept = [r for r in conn.execute(select(_table(loaded_metadata, "dept")).order_by(
_table(loaded_metadata, "dept").c.id))]
assert [(r.id, r.parent_id) for r in dept] == [(1, 0), (2, 1)]
# 软删行 + 空 create_time 原样;密码/salt 原样
users = [r for r in conn.execute(select(_table(loaded_metadata, "user")).order_by(
_table(loaded_metadata, "user").c.id))]
assert users[0].password == "$2b$12$abcdefghijklmnop"
assert users[1].is_delete == 1
assert users[1].create_time is None
# Text 逐字节相等(中文/换行/HTML)
content = conn.execute(select(_table(loaded_metadata, "article").c.content)).scalar_one()
assert content == "第一行\n第二行 with <b>html</b> 中文"
# BigInteger 保真
assert conn.execute(select(_table(loaded_metadata, "job_log").c.consume_time)).scalar_one() \
== 9223372036854775807
# 目标新插 dept 自增为 MAX(id)+1(sqlite rowid 自动跟随显式插 id)
with dst_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [{"name": "新部门", "code": "NEW", "parent_id": 0}])
with dst_engine.connect() as conn:
new_id = conn.execute(
select(_table(loaded_metadata, "dept").c.id).order_by(
_table(loaded_metadata, "dept").c.id.desc())).scalar()
assert new_id == 3
def test_chunking_roundtrip(two_sqlite, loaded_metadata):
"""chunk 循环边界:源 2500 行 city、chunk=1000,目标 count/ids 一致。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
city = _table(loaded_metadata, "city")
rows = [{"id": i, "name": f"区域{i}", "short_name": f"区域{i}", "city_code": "010",
"area_code": f"{i:06d}", "zip_code": "100000",
"parent_id": 0, "parent_code": None, "level": 1}
for i in range(1, 2501)]
with src_engine.begin() as conn:
conn.execute(city.insert(), rows)
assert migrate_db.run(make_args(str(src_path), str(dst_path), chunk_size=1000)) == 0
with dst_engine.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_city")).scalar() == 2500
ids = [r[0] for r in conn.execute(select(city.c.id).order_by(city.c.id))]
assert ids == list(range(1, 2501))
def test_only_tables_filter(two_sqlite, loaded_metadata):
"""--only-tables 只泵指定表(支持全名/短名),其余表保持空。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
with src_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0}])
conn.execute(_table(loaded_metadata, "city").insert(), [
{"id": 1, "name": "北京市", "short_name": "北京", "city_code": "010",
"area_code": "110000", "zip_code": "100000", "parent_id": 0}])
conn.execute(_table(loaded_metadata, "article").insert(), [{"id": 1, "title": "x"}])
assert migrate_db.run(make_args(str(src_path), str(dst_path),
only_tables="fastapi_dept,city")) == 0
with dst_engine.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_dept")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_city")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_article")).scalar() == 0
def test_dry_run_does_not_touch_target(two_sqlite, loaded_metadata):
"""--dry-run 只打印计划,目标库零写入。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
with src_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0}])
assert migrate_db.run(make_args(str(src_path), str(dst_path), dry_run=True)) == 0
with dst_engine.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_dept")).scalar() == 0
def test_pg_reset_sql():
sql = migrate_db.pg_reset_sql("fastapi_user")
assert "setval" in sql
assert "pg_get_serial_sequence('fastapi_user', 'id')" in sql
assert 'FROM "fastapi_user"' in sql
# 空表用 setval(seq, 1, false)(next=1),不能 setval(seq, 0)(PG 报 NumericValueOutOfRange)
assert "COALESCE((SELECT MAX(id) FROM \"fastapi_user\"), 1)" in sql
assert "(SELECT MAX(id) FROM \"fastapi_user\") IS NOT NULL" in sql
def test_oracle_autoincrement_sql():
"""Oracle 自增:sequence 从 MAX+1 起,触发器 id 为 NULL 时取 NEXTVAL。"""
seq = migrate_db.oracle_sequence_sql("fastapi_user", 27)
assert seq == "CREATE SEQUENCE fastapi_user_id_seq START WITH 27 INCREMENT BY 1 NOCACHE"
trg = migrate_db.oracle_trigger_sql("fastapi_user")
assert "BEFORE INSERT ON fastapi_user FOR EACH ROW" in trg
assert "WHEN (NEW.id IS NULL)" in trg
assert "SELECT fastapi_user_id_seq.NEXTVAL INTO :NEW.id FROM dual" in trg
def test_mssql_reset_sql():
assert migrate_db.mssql_reset_sql("fastapi_user", 5) == "DBCC CHECKIDENT ('fastapi_user', RESEED, 5)"
# 空表 max_id 为 None → RESEED 0,下一 id=1
assert migrate_db.mssql_reset_sql("fastapi_user", None) == "DBCC CHECKIDENT ('fastapi_user', RESEED, 0)"
def test_create_database_if_missing_sqlite(tmp_path):
"""sqlite 建库:父目录自动创建;drop_first 删除已存在文件。"""
db = tmp_path / "nested" / "new.db"
migrate_db.create_database_if_missing("sqlite", "", None, str(db), "", "", drop_first=False)
eng = create_engine(migrate_db.make_url("sqlite", "", None, str(db), "", ""))
with eng.connect():
pass # 触发 sqlite 建文件
eng.dispose()
assert db.exists()
migrate_db.create_database_if_missing("sqlite", "", None, str(db), "", "", drop_first=True)
assert not db.exists()
def test_verify_counts_reports_mismatch(two_sqlite, loaded_metadata):
"""count 校验能报出不一致的表。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
with src_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0}])
assert migrate_db.run(make_args(str(src_path), str(dst_path))) == 0
# 泵完后删一行制造不一致
with dst_engine.begin() as conn:
conn.execute(text("DELETE FROM fastapi_dept WHERE id = 1"))
with src_engine.connect() as src, dst_engine.connect() as dst:
failed = migrate_db.verify_counts(src, dst, migrate_db.ordered_tables())
assert any(name == "fastapi_dept" for name, _, _ in failed)