feat: Database migration system for automatic user DB upgrades (Issue #181) - #185
Conversation
Implement Issue #181: automatic detection and upgrade of user project.db databases when a new BSP version is released. New module modules/db_migration.py provides database-agnostic migration logic reusable by openstan: - fingerprint_data_scripts(): SHA-512 hashes of all data/*.py files - needs_upgrade(): checks db_meta version/hashes against current BSP - migrate_db(): creates fresh DB, copies raw data + user objects, archives old DB to database_archive/, replaces with upgraded DB - _get_user_objects(): detects user-added tables/views/triggers/indexes that are not part of BSP's known schema Changes to existing modules: - create_project_db.py: writes db_meta table with version + hashes - database.py: calls migrate_db() before _require_db() in update_db() - mock_project_data.py: writes db_meta with INSERT OR REPLACE - __init__.py: exports needs_upgrade and migrate_db Test coverage: - 22 new tests in tests/test_db_migration.py covering fingerprinting, upgrade detection, user object detection, and full migration flow - All 261 tests pass Signed-off-by: Jason Farrar <farrar.jason1@gmail.com>
There was a problem hiding this comment.
🟡 Changes recommended
There are confirmed correctness and API/docs issues (e.g., needs_upgrade() JSON decode crash path, internal SQLite object handling, and an unintended public API/doc removal) that should be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR introduces an automatic SQLite project.db migration system that detects schema drift (via BSP version + data-script fingerprints) and upgrades user databases in-place while attempting to preserve user-created objects.
Changes:
- Added
db_migrationmodule with script fingerprinting, upgrade detection, user-object discovery, and a full migration/archival flow. - Integrated migrations into the persistence path (
update_db()), and wrotedb_metaon database creation + mock-data seeding. - Updated the public API exports and Python API reference docs to expose migration helpers.
File summaries
| File | Description |
|---|---|
tests/test_db_migration.py |
Adds unit tests for fingerprinting, upgrade detection, user-object detection, and migration behavior. |
src/bank_statement_parser/modules/db_migration.py |
Implements the migration system (fingerprinting, needs_upgrade, migrate_db, user object preservation). |
src/bank_statement_parser/modules/database.py |
Calls migrate_db() before requiring/using the DB in update_db(). |
src/bank_statement_parser/data/mock_project_data.py |
Writes db_meta (version + hashes) into test DBs seeded with mock data. |
src/bank_statement_parser/data/create_project_db.py |
Writes db_meta (version + hashes) when creating a new project DB. |
src/bank_statement_parser/__init__.py |
Exports migrate_db / needs_upgrade via the package public API. |
docs/reference/python-api.md |
Documents bsp.migrate_db() and bsp.needs_upgrade(). |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 10
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Fix connection leak in migrate_db exception path (close old_conn/new_conn before cleanup) - Handle archive filename collision by appending timestamp on collision - Restore page_crop to __all__ in __init__.py - Move deferred import json to module level in mock_project_data.py - Remove unused _TEST_DB constant from test_db_migration.py - Rewrite misleading failure test to actually test failure path with monkeypatch - Remove unused noqa directives (non-enabled rules PLC0415, S608) - Simplify return expression (SIM103) in needs_upgrade() - Regenerate docs after __all__ fix Signed-off-by: Jason Farrar <farrar.jason1@gmail.com>
There was a problem hiding this comment.
🟡 Changes recommended
The migration logic has a few correctness and robustness issues (e.g., invalid script_hashes handling, SQLite internal object detection, index ownership heuristic) that can cause upgrade failures or silent loss of user objects.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (6)
Previously missed (3) — in code that hasn't changed since the last review.
src/bank_statement_parser/modules/db_migration.py:221
- Index ownership detection is too aggressive: any index on a BSP-owned table with an
idx_prefix is treated as BSP-owned and skipped. A user-created index likeidx_user_labelonstatement_headswould be silently dropped, contradicting the “user objects preserved” behavior.
# An index is BSP-owned if it sits on a BSP-owned table and
# follows the BSP naming convention (starts with ``idx_``).
if tbl_name in _BSP_OWNED_TABLES and name.startswith("idx_"):
continue
user_objects["indexes"].append({"name": name, "sql": sql})
src/bank_statement_parser/data/create_project_db.py:204
- Writing
db_metawith plainINSERTcan fail withUNIQUE constraint failed: db_meta.keyifmain()is ever re-run against an existing DB (e.g. partial upgrade DB left behind). UsingINSERT OR REPLACEmakes this idempotent (and matchesmock_project_data).
conn.execute("INSERT INTO db_meta (key, value) VALUES ('bsp_version', ?)", (__version__,))
conn.execute("INSERT INTO db_meta (key, value) VALUES ('script_hashes', ?)", (json.dumps(hashes),))
src/bank_statement_parser/modules/db_migration.py:82
_DDL_DB_METAis imported and used by other modules (create_project_db.py,mock_project_data.py), but its leading underscore marks it as private. Consider making it public (e.g.DDL_DB_META) or exposing a smallwrite_db_meta(...)helper to avoid cross-module reliance on a private symbol.
_DDL_DB_META = """
CREATE TABLE IF NOT EXISTS db_meta (
src/bank_statement_parser/modules/db_migration.py:188
needs_upgrade()will raise ifdb_meta.script_hashescontains invalid JSON (or a non-string), which can crash callers likeupdate_db(). Treat unreadable hashes as “needs upgrade” instead of propagating the exception.
stored_hashes = json.loads(meta.get("script_hashes", "{}"))
current_hashes = fingerprint_data_scripts()
return stored_hashes != current_hashes
src/bank_statement_parser/modules/db_migration.py:212
_get_user_objects()currently treats SQLite internal objects (e.g.sqlite_sequence,sqlite_stat1) as user tables/indexes. Copying these can break migrations or create irrelevant objects in the upgraded DB; they should be excluded.
for obj_type, name, tbl_name, sql in rows:
if obj_type == "table" and name not in _BSP_OWNED_TABLES:
user_objects["tables"].append({"name": name, "sql": sql})
elif obj_type == "view" and name not in _BSP_OWNED_VIEWS:
src/bank_statement_parser/modules/db_migration.py:244
- In
_copy_user_objects(), the column-name extraction viaSELECT ... LIMIT 0).fetchall()is incorrect (it always returns an empty list) and is immediately overwritten by the PRAGMA result. Removing it avoids confusion and makes the intent clearer.
cols = [desc[0] for desc in old_conn.execute(f'SELECT * FROM "{table_info["name"]}" LIMIT 0').fetchall() or []]
# Re-fetch column names via PRAGMA for robustness
pragma_rows = old_conn.execute(f'PRAGMA table_info("{table_info["name"]}")').fetchall()
cols = [r[1] for r in pragma_rows]
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jason Farrar <farrar.jason1@gmail.com>
Summary
Implements automatic detection and upgrade of user project.db databases when a new BSP version is released, as described in Issue #181.
What changed
New module: modules/db_migration.py
Database-agnostic migration logic reusable by openstan:
Changes to existing modules
Upgrade flow
User object preservation
Test coverage
22 new tests in tests/test_db_migration.py:
All 261 tests pass.
Checklist