Finance-Accounts/ar-aging-app/backend/migrate_sqlite_to_mysql.py

389 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Migrate the legacy SQLite database into MySQL.
The app stored everything in `backend/data/ar_aging.db` before the MySQL move. That file
still holds the real month-end closings (Jan-2026: 3,399,517 transaction rows), and MySQL
starts empty, so the closings have to be copied across once.
python3 migrate_sqlite_to_mysql.py --dry-run # inspect the source, touch nothing
python3 migrate_sqlite_to_mysql.py # migrate
python3 migrate_sqlite_to_mysql.py --force # migrate into a non-empty MySQL
What it does
* copies every table in foreign-key order, so a child row never precedes its session
* converts SQLite's text dates / 0-1 booleans to real MySQL DATE, DATETIME and BOOLEAN
* copies only the columns both schemas share, and reports any it had to skip
* streams in batches, so 3.4M rows never sit in memory
* verifies afterwards: row counts per table AND financial checksums (Σ transaction
totals, per-marketplace receivable) must match the source exactly
It never deletes anything from SQLite — the file is opened read-only.
"""
from __future__ import annotations
import argparse
import datetime as dt
import os
import sqlite3
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parent
sys.path.insert(0, str(BACKEND))
BATCH = 5000
# Tables whose contents are re-derivable by re-processing, but copied anyway so the
# migrated database is byte-identical in what the dashboard shows.
SKIP_TABLES: set[str] = set()
def log(msg: str = "") -> None:
print(msg, flush=True)
# --------------------------------------------------------------------------- source
def sqlite_tables(conn: sqlite3.Connection) -> set[str]:
return {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")}
def sqlite_columns(conn: sqlite3.Connection, table: str) -> list[str]:
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})")]
def sqlite_count(conn: sqlite3.Connection, table: str) -> int:
return conn.execute(f"SELECT COUNT(*) FROM `{table}`").fetchone()[0]
def open_sqlite(path: Path) -> sqlite3.Connection:
"""Open read-only. A stale -wal is checkpointed into a COPY, never the original."""
if not path.exists():
raise SystemExit(f"SQLite file not found: {path}")
# immutable=0 so an existing -wal is still applied; mode=ro keeps us from writing.
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
# --------------------------------------------------------------------------- convert
def make_converter(col_type) -> "callable":
"""Return a function turning a SQLite value into something MySQL accepts."""
name = col_type.__class__.__name__
if name == "Date":
def conv(v):
if v in (None, ""):
return None
if isinstance(v, dt.date) and not isinstance(v, dt.datetime):
return v
if isinstance(v, dt.datetime):
return v.date()
try:
return dt.date.fromisoformat(str(v)[:10])
except ValueError:
return None
return conv
if name == "DateTime":
def conv(v):
if v in (None, ""):
return None
if isinstance(v, dt.datetime):
return v
s = str(v).replace("T", " ")
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
return dt.datetime.strptime(s[:26], fmt)
except ValueError:
continue
return None
return conv
if name == "Boolean":
def conv(v):
if v is None:
return None
if isinstance(v, bool):
return v
return bool(int(v)) if str(v).strip() in ("0", "1") else bool(v)
return conv
if name in ("Integer", "BigInteger", "SmallInteger"):
def conv(v):
if v in (None, ""):
return None
try:
return int(v)
except (TypeError, ValueError):
return None
return conv
if name in ("Float", "Numeric"):
def conv(v):
if v in (None, ""):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
return conv
# String / Text: MySQL columns are sized, so over-long values would be truncated or
# rejected. Trim to the declared length and report it rather than failing the batch.
length = getattr(col_type, "length", None)
def conv(v):
if v is None:
return None
s = v if isinstance(v, str) else str(v)
return s[:length] if length and len(s) > length else s
return conv
# --------------------------------------------------------------------------- migrate
def migrate() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--sqlite", default=str(BACKEND / "data" / "ar_aging.db"),
help="path to the legacy SQLite file")
ap.add_argument("--dry-run", action="store_true",
help="inspect the source and print the plan; do not connect to MySQL")
ap.add_argument("--force", action="store_true",
help="migrate even if the MySQL tables already contain rows")
args = ap.parse_args()
src_path = Path(args.sqlite)
src = open_sqlite(src_path)
have = sqlite_tables(src)
log("")
log(" Migrate SQLite → MySQL")
log(f" source: {src_path} ({src_path.stat().st_size / 1e9:.2f} GB)")
wal = src_path.with_name(src_path.name + "-wal")
if wal.exists() and wal.stat().st_size > 0:
log(f" note: a {wal.stat().st_size / 1e6:.0f} MB write-ahead log is present and "
f"will be read as part of the database")
log("")
# ---- source inventory (works with no MySQL at all) ----
log(" Source contents")
src_counts: dict[str, int] = {}
for t in sorted(have):
src_counts[t] = sqlite_count(src, t)
for t, n in sorted(src_counts.items(), key=lambda kv: -kv[1]):
if n:
log(f" {t:22} {n:>10,}")
empty = [t for t, n in src_counts.items() if not n]
if empty:
log(f" ({len(empty)} empty: {', '.join(sorted(empty))})")
src_checks = financial_checksums_sqlite(src)
log("")
log(" Financial checksums to preserve")
for k, v in src_checks.items():
log(f" {k:34} {v}")
if args.dry_run:
log("")
log(" Dry run — MySQL was not contacted and nothing was written.")
log(" Fill in ar-aging-app/.env, then re-run without --dry-run.")
return 0
# ---- target ----
try:
from app.db.database import ENGINE, init_db
from app.db import models # noqa: F401
except Exception as e: # noqa: BLE001
log("")
log(f" Could not connect to MySQL: {e}")
log(" Check MYSQL_* in ar-aging-app/.env and that the server is reachable.")
return 1
log("")
log(" Creating the MySQL schema (safe if it already exists)…")
init_db()
meta = models.Base.metadata
ordered = [t for t in meta.sorted_tables if t.name in have and t.name not in SKIP_TABLES]
missing_in_sqlite = [t.name for t in meta.sorted_tables if t.name not in have]
if missing_in_sqlite:
log(f" tables absent from the SQLite file (created empty): "
f"{', '.join(missing_in_sqlite)}")
raw = ENGINE.raw_connection()
cur = raw.cursor()
# Existing rows?
non_empty = []
for t in ordered:
cur.execute(f"SELECT COUNT(*) FROM `{t.name}`")
n = cur.fetchone()[0]
if n:
non_empty.append((t.name, n))
if non_empty and not args.force:
log("")
log(" MySQL already contains data — refusing to migrate on top of it:")
for name, n in non_empty:
log(f" {name:22} {n:>10,} rows")
log("")
log(" Re-run with --force to add these rows anyway (duplicates are possible),")
log(" or empty the MySQL database first.")
return 1
log("")
log(" Copying tables (foreign-key order)")
cur.execute("SET FOREIGN_KEY_CHECKS=0")
cur.execute("SET UNIQUE_CHECKS=0")
truncated: list[str] = []
copied: dict[str, int] = {}
try:
for table in ordered:
name = table.name
total = src_counts.get(name, 0)
if not total:
copied[name] = 0
continue
sq_cols = set(sqlite_columns(src, name))
cols = [c for c in table.columns if c.name in sq_cols]
dropped = [c.name for c in table.columns if c.name not in sq_cols]
extra = sq_cols - {c.name for c in table.columns}
convs = [make_converter(c.type) for c in cols]
names = [c.name for c in cols]
placeholders = ", ".join(["%s"] * len(names))
collist = ", ".join(f"`{n}`" for n in names)
sql = f"INSERT INTO `{name}` ({collist}) VALUES ({placeholders})"
done = 0
batch: list[tuple] = []
for row in src.execute(f"SELECT {', '.join(f'`{n}`' for n in names)} "
f"FROM `{name}`"):
vals = []
for i, conv in enumerate(convs):
v = conv(row[i])
vals.append(v)
batch.append(tuple(vals))
if len(batch) >= BATCH:
cur.executemany(sql, batch)
raw.commit()
done += len(batch)
batch.clear()
if total > 50000:
pct = 100.0 * done / total
print(f" {name:22} {done:>10,} / {total:,} ({pct:5.1f}%)",
end="\r", flush=True)
if batch:
cur.executemany(sql, batch)
raw.commit()
done += len(batch)
copied[name] = done
note = ""
if dropped:
note += f" [not in source: {', '.join(dropped)}]"
if extra:
note += f" [source-only, skipped: {', '.join(sorted(extra))}]"
truncated.append(name)
print(" " * 78, end="\r")
log(f" {name:22} {done:>10,}{note}")
finally:
cur.execute("SET FOREIGN_KEY_CHECKS=1")
cur.execute("SET UNIQUE_CHECKS=1")
raw.commit()
# ---- verify ----
log("")
log(" Verifying")
ok = True
for name, n in sorted(copied.items()):
cur.execute(f"SELECT COUNT(*) FROM `{name}`")
got = cur.fetchone()[0]
want = src_counts.get(name, 0)
if got != want:
ok = False
log(f"{name:22} MySQL {got:,} != SQLite {want:,}")
if ok:
log(f" ✓ row counts match on all {len(copied)} tables")
dst_checks = financial_checksums_mysql(cur)
for k, want in src_checks.items():
got = dst_checks.get(k)
if str(got) != str(want):
ok = False
log(f"{k}: MySQL {got} != SQLite {want}")
if ok:
log(" ✓ financial checksums match")
cur.close()
raw.close()
src.close()
log("")
if ok:
log(" Migration complete. Start the dashboard with start.command.")
return 0
log(" Migration finished with MISMATCHES — do not rely on the MySQL data until")
log(" they are explained. The SQLite file is untouched.")
return 1
# --------------------------------------------------------------------------- checksums
def financial_checksums_sqlite(conn: sqlite3.Connection) -> dict[str, str]:
out: dict[str, str] = {}
tables = sqlite_tables(conn)
def one(sql: str, default="") -> str:
try:
r = conn.execute(sql).fetchone()
return "" if r is None or r[0] is None else str(r[0])
except sqlite3.Error:
return default
if "sessions" in tables:
out["sessions"] = one("SELECT COUNT(*) FROM sessions")
if "transactions" in tables:
out["transaction rows"] = one("SELECT COUNT(*) FROM transactions")
out["Σ transactions.total"] = one("SELECT ROUND(SUM(total),2) FROM transactions")
out["receivable-flagged rows"] = one(
"SELECT COUNT(*) FROM transactions WHERE receivable_flag=1")
if "receivable_results" in tables:
out["USA receivable_local (TOTAL)"] = one(
"SELECT ROUND(receivable_local) FROM receivable_results "
"WHERE marketplace='USA' AND account_type='TOTAL'")
out["Σ receivable_usd (TOTAL rows)"] = one(
"SELECT ROUND(SUM(receivable_usd),2) FROM receivable_results "
"WHERE account_type='TOTAL'")
return out
def financial_checksums_mysql(cur) -> dict[str, str]:
out: dict[str, str] = {}
def one(sql: str) -> str:
try:
cur.execute(sql)
r = cur.fetchone()
return "" if r is None or r[0] is None else str(r[0])
except Exception: # noqa: BLE001
return ""
out["sessions"] = one("SELECT COUNT(*) FROM sessions")
out["transaction rows"] = one("SELECT COUNT(*) FROM transactions")
out["Σ transactions.total"] = one("SELECT ROUND(SUM(total),2) FROM transactions")
out["receivable-flagged rows"] = one(
"SELECT COUNT(*) FROM transactions WHERE receivable_flag=1")
out["USA receivable_local (TOTAL)"] = one(
"SELECT ROUND(receivable_local) FROM receivable_results "
"WHERE marketplace='USA' AND account_type='TOTAL'")
out["Σ receivable_usd (TOTAL rows)"] = one(
"SELECT ROUND(SUM(receivable_usd),2) FROM receivable_results "
"WHERE account_type='TOTAL'")
return out
if __name__ == "__main__":
try:
raise SystemExit(migrate())
except KeyboardInterrupt:
log("\n Interrupted. The SQLite source is unchanged.")
raise SystemExit(130)