"""Read Access change-log rows and full source rows via the ACE ODBC driver. Opened in shared/autocommit mode so it coexists with the live client that holds the ``.laccdb`` lock on the production ``.accdb`` files. The driver name comes from ``config.access.driver`` (no secret in the connection string — Windows auth / no password on the Access share). """ from __future__ import annotations import logging import time from dataclasses import dataclass import pyodbc from .serialize import to_jsonable log = logging.getLogger(__name__) @dataclass class LogRow: """A single row from the Access ``TableChangeLog`` table.""" id: int table_name: str record_id: str operate_type: str # "Insert" | "Update" | "Delete" time: object # raw Access datetime (serialized later by caller) class AccessReader: """Thin pyodbc wrapper around one ``.accdb`` file. The connection is lazily created and reused across calls. ``close()`` must be called when the reader is no longer needed. """ def __init__(self, db_path: str, driver: str): self.db_path = db_path self.driver = driver self._conn = None def _connect(self): if self._conn is None: # ReadOnly=0: shared read/write open. The capture path is read-only, # but the cleanup path (delete_log_ids) genuinely deletes processed # log rows. autocommit=True avoids a held transaction that would # block the live client. conn_str = f"Driver={self.driver};DBQ={self.db_path};ReadOnly=0;" self._conn = pyodbc.connect(conn_str, autocommit=True) return self._conn def read_log(self, batch_size: int) -> list[LogRow]: """Return up to ``batch_size`` oldest log rows ordered by ID.""" cur = self._connect().cursor() cur.execute( f"SELECT TOP {int(batch_size)} ID, TableName, RecordID, " f"OperateType, Time FROM TableChangeLog ORDER BY ID" ) return [ LogRow(r[0], r[1], str(r[2]), (r[3] or "").strip(), r[4]) for r in cur.fetchall() ] def read_row(self, table: str, record_id: str) -> dict | None: """Return the full source row for ``record_id`` as a JSONable dict. Returns ``None`` if the row no longer exists. Emits a warning when any string value exceeds 4000 chars (the downstream ``JSON_VALUE`` on SQL Server ``NVARCHAR(MAX)`` -> ``NVARCHAR(4000)`` would silently truncate). """ cur = self._connect().cursor() cur.execute(f'SELECT * FROM "{table}" WHERE ID = ?', record_id) cols = [c[0] for c in cur.description] row = cur.fetchone() if row is None: return None d = {cols[i]: to_jsonable(row[i]) for i in range(len(cols))} for k, v in d.items(): if isinstance(v, str) and len(v) > 4000: log.warning( "value >4000 chars in %s.ID=%s col=%s " "(JSON_VALUE will truncate)", table, record_id, k, ) return d def delete_log_ids( self, ids: list[int], batch_size: int, retries: int ) -> int: """Delete the given log-row IDs in chunks, retrying on lock contention. Returns the total number of rows actually deleted (sum of per-chunk ``cursor.rowcount``), so the caller can report honest counts. A no-op (returns 0) when ``ids`` is empty. Retries with linear backoff because the live client may briefly hold a page lock on ``TableChangeLog``. Raises on final lock failure. """ if not ids: return 0 total = 0 cur = self._connect().cursor() for i in range(0, len(ids), batch_size): chunk = ids[i:i + batch_size] placeholders = ",".join("?" * len(chunk)) for attempt in range(retries): try: cur.execute( f"DELETE FROM TableChangeLog WHERE ID IN ({placeholders})", *chunk, ) total += cur.rowcount break except pyodbc.Error as e: # ACE ODBC reports lock contention ("无法更新;当前被锁定。 # (-1102)") as a generic ``pyodbc.Error`` (HY000), NOT as # ``pyodbc.OperationalError`` — so the old handler silently # missed it and gave up after the first collision. Retry only # on lock messages; re-raise anything else so genuine errors # surface immediately instead of being retried 3×. msg = str(e) is_lock = "被锁定" in msg or "-1102" in msg if is_lock and attempt < retries - 1: time.sleep(0.2 * (attempt + 1)) else: raise return total def list_user_tables(self) -> list[str]: """Return user table names in this Access DB. Excludes system tables (``MSys*``) and temporary ``~*`` tables. The change-log table ``TableChangeLog`` is NOT auto-excluded here — callers apply their own ``FileMapping.exclude_tables`` / ``include_tables`` rules on top of this list (see ``sync.fullsync.resolve_tables``). """ cur = self._connect().cursor() names = [] for r in cur.tables(tableType="TABLE"): n = r.table_name if n.startswith("MSys") or n.startswith("~"): continue names.append(n) return names def read_all_rows(self, table: str): """Return ``(columns, rows)`` for every row in ``table``. ``columns`` is the ordered list of column names; ``rows`` is a list of tuples of **raw** pyodbc cell values (native Python types preserved) so the downstream bulk insert keeps correct SQL Server types. An empty table yields ``([...], [])`` with the real column list. """ cur = self._connect().cursor() cur.execute(f'SELECT * FROM "{table}"') cols = [c[0] for c in cur.description] rows = [tuple(row) for row in cur.fetchall()] return cols, rows def read_all_log_ids(self) -> list[int]: """Return every ``ID`` from this file's ``TableChangeLog``, ascending.""" cur = self._connect().cursor() cur.execute("SELECT ID FROM TableChangeLog ORDER BY ID") return [r[0] for r in cur.fetchall()] def close(self): if self._conn: self._conn.close() self._conn = None