uslap-query / Code_files /archive /migrate_v3_to_v4.py
uslap's picture
Upload folder using huggingface_hub
7cc8e29 verified
Raw
History Blame Contribute Delete
20.2 kB
#!/usr/bin/env python3
"""
USLaP V3 β†’ V4 Migration β€” PK/FK enforcement
بِسْمِ Ψ§Ω„Ω„ΩŽΩ‘Ω‡Ω Ψ§Ω„Ψ±ΩŽΩ‘Ψ­Ω’Ω…ΩŽΩ°Ω†Ω Ψ§Ω„Ψ±ΩŽΩ‘Ψ­ΩΩŠΩ…Ω
Starts from a COPY of V3. Rebuilds tables that need PRIMARY KEY.
Adds FOREIGN KEY constraints on core tables.
Preserves ALL triggers, indexes, views.
Verifies row counts.
Usage:
python3 Code_files/migrate_v3_to_v4.py check # dry-run analysis
python3 Code_files/migrate_v3_to_v4.py migrate # execute migration
"""
import sqlite3
import shutil
import sys
import os
from datetime import datetime
BASE = os.path.dirname(os.path.abspath(__file__))
V3_DB = os.path.join(BASE, "uslap_database_v3.db")
V4_DB = os.path.join(BASE, "uslap_database_v4.db")
# ============================================================================
# Tables that need PRIMARY KEY added/fixed
# Format: (table_name, pk_column, pk_type, needs_dedup)
# ============================================================================
PK_FIXES = [
# Core tables (imported from Excel without PK)
("entries", "entry_id", "INTEGER", False),
("bitig_a1_entries", "entry_id", "TEXT", False), # B001, B295, etc.
("a4_derivatives", "deriv_id", "TEXT", True), # 2 dups: D977, D978
("a5_cross_refs", "xref_id", "TEXT", True), # 9 dups: X100-X108
# RU mirror tables
("a2_\u0438\u043c\u0435\u043d\u0430_\u0430\u043b\u043b\u0430\u0445\u0430", "\u0438\u043c\u044f_id", "TEXT", False),
("a3_\u043a\u043e\u0440\u0430\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0435_\u0441\u0441\u044b\u043b\u043a\u0438", "\u0441\u0441\u044b\u043b\u043a\u0430_id", "TEXT", False),
("a4_\u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u043d\u044b\u0435", "\u043f\u0440\u043e\u0438\u0437\u0432_id", "TEXT", False),
("a5_\u043f\u0435\u0440\u0435\u043a\u0440\u0451\u0441\u0442\u043d\u044b\u0435_\u0441\u0441\u044b\u043b\u043a\u0438", "\u043f\u0435\u0440\u0435\u043a\u0440_id", "TEXT", False),
("a6_\u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f_\u0441\u0442\u0440\u0430\u043d", "\u0441\u0442\u0440\u0430\u043d\u0430_id", "TEXT", False),
]
# ============================================================================
# Tables that need FOREIGN KEY added (already have PK)
# These get rebuilt with FK constraints in CREATE TABLE
# Format: (table_name, {col: "REFERENCES target(col)"})
# ============================================================================
FK_ADDITIONS = {
"entries": {
"root_id": "REFERENCES roots(root_id)",
},
"european_a1_entries": {
"root_id": "REFERENCES roots(root_id)",
},
"latin_a1_entries": {
"root_id": "REFERENCES roots(root_id)",
},
# uzbek_vocabulary already has FKs defined
}
def get_table_info(conn, table_name):
"""Return list of (cid, name, type, notnull, dflt_value, pk)."""
return conn.execute(f'PRAGMA table_info("{table_name}")').fetchall()
def get_triggers_for_table(conn, table_name):
"""Return list of (name, sql) for triggers on this table."""
return conn.execute(
"SELECT name, sql FROM sqlite_master WHERE type='trigger' AND tbl_name=?",
(table_name,)
).fetchall()
def get_indexes_for_table(conn, table_name):
"""Return list of (name, sql) for non-auto indexes on this table."""
return conn.execute(
"SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL",
(table_name,)
).fetchall()
def get_row_count(conn, table_name):
"""Safe row count."""
try:
return conn.execute(f'SELECT count(*) FROM "{table_name}"').fetchone()[0]
except Exception:
return -1
def build_create_ddl(table_name, cols_info, pk_column, pk_type, fk_map=None):
"""Build CREATE TABLE DDL with PK on specified column and optional FKs."""
col_defs = []
for cid, name, ctype, notnull, dflt, pk in cols_info:
if name == pk_column:
col_defs.append(f'"{name}" {pk_type} PRIMARY KEY')
else:
parts = [f'"{name}" {ctype or "TEXT"}']
if notnull:
parts.append("NOT NULL")
if dflt is not None:
parts.append(f"DEFAULT {dflt}")
# Add FK if specified
if fk_map and name in fk_map:
parts.append(fk_map[name])
col_defs.append(" ".join(parts))
return f'CREATE TABLE "{table_name}" ({", ".join(col_defs)})'
def rebuild_table_with_pk(conn, table_name, pk_column, pk_type, dedup=False, fk_map=None):
"""Rebuild a table adding PRIMARY KEY (and optional FKs).
NOTE: All triggers must be dropped BEFORE calling this function."""
# 1. Get current schema
cols_info = get_table_info(conn, table_name)
col_names = [c[1] for c in cols_info]
col_list = ", ".join(f'"{c}"' for c in col_names)
# 2. Count before
count_before = get_row_count(conn, table_name)
# 3. Save indexes for this table
indexes = get_indexes_for_table(conn, table_name)
# 4. Create temp table with PK
temp_name = f"_v4_migrate_{table_name[:40]}"
new_ddl = build_create_ddl(temp_name, cols_info, pk_column, pk_type, fk_map)
conn.execute(f'DROP TABLE IF EXISTS "{temp_name}"')
conn.execute(new_ddl)
# 5. Copy data (with dedup if needed)
if dedup:
conn.execute(f'''
INSERT OR IGNORE INTO "{temp_name}" ({col_list})
SELECT {col_list} FROM "{table_name}" ORDER BY rowid
''')
else:
conn.execute(f'''
INSERT INTO "{temp_name}" ({col_list})
SELECT {col_list} FROM "{table_name}"
''')
count_after = get_row_count(conn, temp_name)
# 6. Drop indexes
for iname, _ in indexes:
conn.execute(f'DROP INDEX IF EXISTS "{iname}"')
# 7. Drop original, rename temp
conn.execute(f'DROP TABLE "{table_name}"')
conn.execute(f'ALTER TABLE "{temp_name}" RENAME TO "{table_name}"')
# 8. Recreate indexes
for iname, isql in indexes:
if isql:
try:
conn.execute(isql)
except Exception as e:
print(f" WARN: index {iname} failed: {e}")
conn.commit()
dedup_lost = count_before - count_after
return count_before, count_after, dedup_lost
def rebuild_table_with_fk_only(conn, table_name, fk_map):
"""Rebuild a table that already has PK but needs FK constraints added.
NOTE: All triggers must be dropped BEFORE calling this function."""
cols_info = get_table_info(conn, table_name)
col_names = [c[1] for c in cols_info]
col_list = ", ".join(f'"{c}"' for c in col_names)
# Find PK column
pk_col = None
pk_type = None
for cid, name, ctype, notnull, dflt, pk in cols_info:
if pk > 0:
pk_col = name
pk_type = ctype or "TEXT"
break
if not pk_col:
print(f" SKIP {table_name}: no PK found")
return 0, 0
count_before = get_row_count(conn, table_name)
indexes = get_indexes_for_table(conn, table_name)
temp_name = f"_v4_fk_{table_name[:40]}"
new_ddl = build_create_ddl(temp_name, cols_info, pk_col, pk_type, fk_map)
conn.execute(f'DROP TABLE IF EXISTS "{temp_name}"')
conn.execute(new_ddl)
conn.execute(f'''
INSERT INTO "{temp_name}" ({col_list})
SELECT {col_list} FROM "{table_name}"
''')
count_after = get_row_count(conn, temp_name)
for iname, _ in indexes:
conn.execute(f'DROP INDEX IF EXISTS "{iname}"')
conn.execute(f'DROP TABLE "{table_name}"')
conn.execute(f'ALTER TABLE "{temp_name}" RENAME TO "{table_name}"')
for iname, isql in indexes:
if isql:
try:
conn.execute(isql)
except Exception as e:
print(f" WARN: index {iname} failed: {e}")
conn.commit()
return count_before, count_after
def add_fts_table(conn):
"""Recreate FTS5 virtual table for entries if it was lost during rebuild."""
try:
conn.execute("DROP TABLE IF EXISTS entries_fts")
conn.execute("""
CREATE VIRTUAL TABLE entries_fts USING fts5(
entry_id UNINDEXED,
en_term,
ru_term,
fa_term,
ar_word,
content=entries
)
""")
# Populate FTS
conn.execute("""
INSERT INTO entries_fts(entry_id, en_term, ru_term, fa_term, ar_word)
SELECT entry_id, en_term, ru_term, fa_term, ar_word FROM entries
""")
conn.commit()
count = conn.execute("SELECT count(*) FROM entries_fts").fetchone()[0]
print(f" FTS5 rebuilt: {count} entries indexed")
except Exception as e:
print(f" WARN: FTS5 rebuild failed: {e}")
def recreate_views(conn):
"""Recreate all views (they may reference rebuilt tables)."""
views = conn.execute(
"SELECT name, sql FROM sqlite_master WHERE type='view'"
).fetchall()
for vname, vsql in views:
if vsql:
try:
conn.execute(f'DROP VIEW IF EXISTS "{vname}"')
conn.execute(vsql)
except Exception as e:
print(f" WARN: view {vname} failed: {e}")
conn.commit()
print(f" {len(views)} views verified")
def verify_counts(v3_conn, v4_conn):
"""Compare row counts between V3 and V4 for all tables."""
v3_tables = {r[0] for r in v3_conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_v4_%'"
).fetchall()}
v4_tables = {r[0] for r in v4_conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_v4_%'"
).fetchall()}
missing = v3_tables - v4_tables
if missing:
print(f"\n MISSING tables in V4: {missing}")
mismatches = []
for t in sorted(v3_tables & v4_tables):
c3 = get_row_count(v3_conn, t)
c4 = get_row_count(v4_conn, t)
if c3 != c4:
mismatches.append((t, c3, c4))
return missing, mismatches
def verify_pk_constraints(conn):
"""Verify all target tables now have PRIMARY KEY."""
results = []
for table_name, pk_col, _, _ in PK_FIXES:
info = get_table_info(conn, table_name)
has_pk = any(c[5] > 0 for c in info) # c[5] = pk flag
results.append((table_name, pk_col, has_pk))
return results
def verify_fk_enforcement(conn):
"""Run PRAGMA foreign_key_check and report violations."""
conn.execute("PRAGMA foreign_keys = ON")
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
return violations
# ============================================================================
# CHECK MODE
# ============================================================================
def run_check():
"""Dry run: analyze what migration will do."""
print("=" * 70)
print("USLaP V3 β†’ V4 Migration β€” DRY RUN CHECK")
print("=" * 70)
conn = sqlite3.connect(V3_DB)
# Table counts
table_count = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchone()[0]
trigger_count = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE type='trigger'"
).fetchone()[0]
index_count = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"
).fetchone()[0]
view_count = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE type='view'"
).fetchone()[0]
print(f"\nV3 DB: {table_count} tables, {trigger_count} triggers, {index_count} indexes, {view_count} views")
print(f"File size: {os.path.getsize(V3_DB) / 1024 / 1024:.1f} MB")
print(f"\n--- Tables needing PRIMARY KEY ({len(PK_FIXES)}) ---")
for table_name, pk_col, pk_type, dedup in PK_FIXES:
count = get_row_count(conn, table_name)
trg_count = len(get_triggers_for_table(conn, table_name))
idx_count = len(get_indexes_for_table(conn, table_name))
# Check for duplicates
dup_count = 0
if dedup:
dups = conn.execute(
f'SELECT count(*) FROM (SELECT "{pk_col}", count(*) as c FROM "{table_name}" GROUP BY "{pk_col}" HAVING c > 1)'
).fetchone()[0]
dup_count = dups
status = f"rows={count}, triggers={trg_count}, indexes={idx_count}"
if dup_count:
status += f", DUPS={dup_count} (will dedup)"
print(f" {table_name}: PK on {pk_col} ({pk_type}) β€” {status}")
print(f"\n--- Tables needing FOREIGN KEY ({len(FK_ADDITIONS)}) ---")
for table_name, fk_map in FK_ADDITIONS.items():
count = get_row_count(conn, table_name)
trg_count = len(get_triggers_for_table(conn, table_name))
for col, ref in fk_map.items():
# Check orphans
orphans = conn.execute(
f'SELECT count(*) FROM "{table_name}" WHERE "{col}" IS NOT NULL AND "{col}" != \'\' AND "{col}" NOT IN (SELECT root_id FROM roots)'
).fetchone()[0]
print(f" {table_name}.{col} β†’ {ref} β€” rows={count}, triggers={trg_count}, orphans={orphans}")
# Check current FK state
print("\n--- Current FK enforcement ---")
fk_state = conn.execute("PRAGMA foreign_keys").fetchone()[0]
print(f" PRAGMA foreign_keys = {fk_state} ({'ON' if fk_state else 'OFF'})")
conn.close()
print("\n--- Ready to migrate. Run: python3 migrate_v3_to_v4.py migrate ---")
# ============================================================================
# MIGRATE MODE
# ============================================================================
def run_migrate():
"""Execute the full V3 β†’ V4 migration."""
print("=" * 70)
print("USLaP V3 β†’ V4 Migration β€” EXECUTING")
print(f"Started: {datetime.now().isoformat()}")
print("=" * 70)
# Step 0: Copy V3 β†’ V4
if os.path.exists(V4_DB):
os.remove(V4_DB)
print(f"\n[STEP 0] Copying V3 β†’ V4...")
shutil.copy2(V3_DB, V4_DB)
print(f" Copied {os.path.getsize(V4_DB) / 1024 / 1024:.1f} MB")
conn = sqlite3.connect(V4_DB)
conn.execute("PRAGMA journal_mode = WAL")
# FK off during migration (we rebuild tables)
conn.execute("PRAGMA foreign_keys = OFF")
# Step 1a: Save and drop ALL views (cross-table dependencies during RENAME)
print(f"\n[STEP 1a] Saving and dropping ALL views...")
all_views = conn.execute(
"SELECT name, sql FROM sqlite_master WHERE type='view'"
).fetchall()
print(f" Found {len(all_views)} views")
for vname, _ in all_views:
conn.execute(f'DROP VIEW IF EXISTS "{vname}"')
conn.commit()
# Step 1b: Save and drop ALL triggers (cross-table dependencies)
print(f"\n[STEP 1b] Saving and dropping ALL triggers...")
all_triggers = conn.execute(
"SELECT name, sql FROM sqlite_master WHERE type='trigger'"
).fetchall()
print(f" Found {len(all_triggers)} triggers")
for tname, _ in all_triggers:
conn.execute(f'DROP TRIGGER IF EXISTS "{tname}"')
conn.commit()
remaining = conn.execute("SELECT count(*) FROM sqlite_master WHERE type='trigger'").fetchone()[0]
print(f" Dropped all. Remaining: {remaining}")
# Step 2: Fix PRIMARY KEYs
print(f"\n[STEP 2] Rebuilding {len(PK_FIXES)} tables with PRIMARY KEY...")
for table_name, pk_col, pk_type, dedup in PK_FIXES:
fk_map = FK_ADDITIONS.get(table_name)
before, after, lost = rebuild_table_with_pk(
conn, table_name, pk_col, pk_type, dedup, fk_map
)
status = f" {table_name}: {before}β†’{after} rows"
if lost:
status += f" ({lost} dups removed)"
print(status)
# Step 3: Add FOREIGN KEYs to tables that already have PK
fk_only_tables = {k: v for k, v in FK_ADDITIONS.items()
if k not in [t[0] for t in PK_FIXES]}
if fk_only_tables:
print(f"\n[STEP 3] Adding FK constraints to {len(fk_only_tables)} tables...")
for table_name, fk_map in fk_only_tables.items():
before, after = rebuild_table_with_fk_only(conn, table_name, fk_map)
print(f" {table_name}: {before}β†’{after} rows")
# Step 4: Recreate ALL triggers
print(f"\n[STEP 4] Recreating {len(all_triggers)} triggers...")
trigger_ok = 0
trigger_fail = []
for tname, tsql in all_triggers:
if tsql:
try:
conn.execute(tsql)
trigger_ok += 1
except Exception as e:
trigger_fail.append((tname, str(e)))
conn.commit()
print(f" OK: {trigger_ok}, Failed: {len(trigger_fail)}")
if trigger_fail:
for tname, err in trigger_fail[:10]:
print(f" {tname}: {err}")
# Step 5: Rebuild FTS
print(f"\n[STEP 5] Rebuilding FTS5 index...")
add_fts_table(conn)
# Step 6: Recreate views from saved list
print(f"\n[STEP 6] Recreating {len(all_views)} views...")
view_ok = 0
view_fail = []
for vname, vsql in all_views:
if vsql:
try:
conn.execute(f'DROP VIEW IF EXISTS "{vname}"')
conn.execute(vsql)
view_ok += 1
except Exception as e:
view_fail.append((vname, str(e)))
conn.commit()
print(f" OK: {view_ok}, Failed: {len(view_fail)}")
if view_fail:
for vname, err in view_fail:
print(f" {vname}: {err}")
# Step 7: Enable FK enforcement
print(f"\n[STEP 7] Enabling FK enforcement...")
conn.execute("PRAGMA foreign_keys = ON")
fk_state = conn.execute("PRAGMA foreign_keys").fetchone()[0]
print(f" PRAGMA foreign_keys = {fk_state}")
# Step 8: FK violation check
print(f"\n[STEP 8] Checking FK violations...")
violations = verify_fk_enforcement(conn)
if violations:
print(f" {len(violations)} FK violations found:")
for v in violations[:20]:
print(f" table={v[0]}, rowid={v[1]}, parent={v[2]}, fkid={v[3]}")
if len(violations) > 20:
print(f" ... and {len(violations) - 20} more")
else:
print(f" 0 FK violations β€” CLEAN")
# Step 9: Verify PK constraints
print(f"\n[STEP 9] Verifying PRIMARY KEY constraints...")
pk_results = verify_pk_constraints(conn)
for tname, pk_col, has_pk in pk_results:
status = "PK" if has_pk else "NO PK β€” FAILED"
print(f" {tname}: {status}")
# Step 10: Verify row counts against V3
print(f"\n[STEP 10] Verifying row counts against V3...")
v3_conn = sqlite3.connect(V3_DB)
missing, mismatches = verify_counts(v3_conn, conn)
if not missing and not mismatches:
print(" ALL tables match β€” CLEAN")
else:
for t, c3, c4 in mismatches:
diff = c4 - c3
print(f" {t}: V3={c3}, V4={c4} (diff={diff:+d})")
v3_conn.close()
# Step 11: Final stats
table_count = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchone()[0]
trigger_count = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE type='trigger'"
).fetchone()[0]
conn.close()
print(f"\n{'=' * 70}")
print(f"MIGRATION COMPLETE")
print(f" V4 DB: {V4_DB}")
print(f" Size: {os.path.getsize(V4_DB) / 1024 / 1024:.1f} MB")
print(f" Tables: {table_count}, Triggers: {trigger_count}")
if trigger_fail:
print(f"\n TRIGGER FAILURES ({len(trigger_fail)}):")
for tname, err in trigger_fail:
print(f" {tname}: {err}")
print(f"\nFinished: {datetime.now().isoformat()}")
print(f"{'=' * 70}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 migrate_v3_to_v4.py [check|migrate]")
sys.exit(1)
cmd = sys.argv[1].lower()
if cmd == "check":
run_check()
elif cmd == "migrate":
run_migrate()
else:
print(f"Unknown command: {cmd}")
print("Usage: python3 migrate_v3_to_v4.py [check|migrate]")
sys.exit(1)