execute_script() semicolon splitter (all drivers)
- Add BaseDriver._split_statements() that tracks single/double-quoted
strings and -- / /* */ comments so semicolons inside procedure bodies
are not treated as statement boundaries.
- Replace the naive sql.split(';') in all four drivers with this helper.
- MSSQL execute_script() also pre-splits on GO (case-insensitive, own
line) so scripts pasted from SSMS work correctly.
MSSQL EXPLAIN
- SET SHOWPLAN_TEXT ON/execute/SET SHOWPLAN_TEXT OFF must be separate
execute() calls; pyodbc rejects multiple statements in one call.
Hold self._lock for the entire sequence to keep session state atomic.
Keyring (connections.py)
- Log warnings (with traceback) on load/save failures instead of
silently returning "".
- save_password() now calls delete_password() when password is empty,
so clearing a saved password actually removes the old keyring entry
rather than leaving a stale one behind.
SQLite get_tables() O(N) lock acquisitions
- Run all COUNT(*) queries inside the same `with self._cur() as c:`
block, reducing N+1 lock acquisitions to 1.
Unused import: remove psycopg2.extras from postgres_driver.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
256 lines
7.5 KiB
Python
256 lines
7.5 KiB
Python
"""
|
|
Abstract base driver interface.
|
|
All DB-specific drivers must implement this interface so the UI is fully DB-agnostic.
|
|
"""
|
|
import threading
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
|
|
@dataclass
|
|
class ColumnInfo:
|
|
name: str
|
|
data_type: str
|
|
nullable: bool
|
|
default: Optional[str]
|
|
is_primary_key: bool
|
|
is_foreign_key: bool
|
|
extra: str = ""
|
|
|
|
|
|
@dataclass
|
|
class IndexInfo:
|
|
name: str
|
|
columns: list
|
|
is_unique: bool
|
|
index_type: str = ""
|
|
|
|
|
|
@dataclass
|
|
class ForeignKeyInfo:
|
|
name: str
|
|
column: str
|
|
ref_table: str
|
|
ref_column: str
|
|
on_update: str = ""
|
|
on_delete: str = ""
|
|
|
|
|
|
@dataclass
|
|
class TableInfo:
|
|
name: str
|
|
schema: str
|
|
row_count: int = 0
|
|
size_bytes: int = 0
|
|
engine: str = ""
|
|
comment: str = ""
|
|
|
|
|
|
class BaseDriver(ABC):
|
|
"""Abstract base class for all database drivers.
|
|
|
|
Thread-safety
|
|
-------------
|
|
A single driver instance is shared across multiple ``SchemaWorker`` threads
|
|
(columns, indexes, FK, DDL all fire in parallel when a table is opened).
|
|
The ``_lock`` ``threading.RLock`` ensures that each subclass's ``_cur()``
|
|
context manager holds the lock for the *entire* execute → fetch sequence,
|
|
serialising concurrent access on the underlying (non-thread-safe) connection.
|
|
"""
|
|
|
|
def __init__(self, config: dict):
|
|
self.config = config
|
|
self._connection = None
|
|
self.db_type = ""
|
|
# Reentrant lock shared by all _cur() calls; prevents concurrent
|
|
# threads from interleaving reads/writes on the same socket.
|
|
self._lock = threading.RLock()
|
|
|
|
@abstractmethod
|
|
def connect(self) -> None:
|
|
"""Establish database connection."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def disconnect(self) -> None:
|
|
"""Close database connection."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def test_connection(self) -> tuple:
|
|
"""Test connection. Returns (bool success, str message)."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_databases(self) -> list:
|
|
"""Returns list of database name strings."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_tables(self, database: str) -> list:
|
|
"""Returns list of TableInfo for the given database."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_views(self, database: str) -> list:
|
|
"""Returns list of view name strings."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_columns(self, database: str, table: str) -> list:
|
|
"""Returns list of ColumnInfo for a table."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_indexes(self, database: str, table: str) -> list:
|
|
"""Returns list of IndexInfo for a table."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_foreign_keys(self, database: str, table: str) -> list:
|
|
"""Returns list of ForeignKeyInfo for a table."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_table_ddl(self, database: str, table: str) -> str:
|
|
"""Returns DDL CREATE TABLE statement."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
|
"""Execute a query. Returns (list[str] columns, list[tuple] rows, int rowcount)."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def execute_script(self, sql: str) -> list:
|
|
"""Execute multiple statements. Returns list of (columns, rows, rowcount, message) tuples."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_table_data(self, database: str, table: str,
|
|
where: str = "", order_by: str = "",
|
|
limit: int = 1000, offset: int = 0) -> tuple:
|
|
"""Returns (columns, rows, total_count) for paginated table data."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
|
"""Returns total row count for a table."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_functions(self, database: str) -> list:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_stored_procedures(self, database: str) -> list:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_triggers(self, database: str, table: str = "") -> list:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def explain_query(self, sql: str) -> tuple:
|
|
"""Returns (columns, rows) for EXPLAIN output."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_process_list(self) -> tuple:
|
|
"""Returns (columns, rows) of running processes."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def kill_process(self, process_id: int) -> bool:
|
|
pass
|
|
|
|
# ── Table designer ────────────────────────────────────────────────────────
|
|
|
|
@abstractmethod
|
|
def add_column(self, database: str, table: str, col_name: str,
|
|
col_type: str, nullable: bool = True,
|
|
default: Optional[str] = None) -> bool:
|
|
"""Add a column to an existing table via ALTER TABLE."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def drop_column(self, database: str, table: str, col_name: str) -> bool:
|
|
"""Drop a column from a table via ALTER TABLE."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def rename_column(self, database: str, table: str,
|
|
old_name: str, new_name: str) -> bool:
|
|
"""Rename a column via ALTER TABLE."""
|
|
pass
|
|
|
|
@staticmethod
|
|
def _split_statements(sql: str) -> list:
|
|
"""Split SQL text on unquoted semicolons, preserving quoted strings and comments."""
|
|
stmts, buf = [], []
|
|
i, n = 0, len(sql)
|
|
while i < n:
|
|
ch = sql[i]
|
|
if ch == '-' and i + 1 < n and sql[i + 1] == '-':
|
|
end = sql.find('\n', i)
|
|
end = end + 1 if end != -1 else n
|
|
buf.append(sql[i:end])
|
|
i = end
|
|
elif ch == '/' and i + 1 < n and sql[i + 1] == '*':
|
|
end = sql.find('*/', i + 2)
|
|
end = end + 2 if end != -1 else n
|
|
buf.append(sql[i:end])
|
|
i = end
|
|
elif ch in ("'", '"'):
|
|
q, j = ch, i + 1
|
|
while j < n:
|
|
if sql[j] == '\\':
|
|
j += 2
|
|
elif sql[j] == q:
|
|
j += 1
|
|
if j < n and sql[j] == q:
|
|
j += 1
|
|
continue
|
|
break
|
|
else:
|
|
j += 1
|
|
buf.append(sql[i:j])
|
|
i = j
|
|
elif ch == ';':
|
|
stmt = ''.join(buf).strip()
|
|
if stmt:
|
|
stmts.append(stmt)
|
|
buf = []
|
|
i += 1
|
|
else:
|
|
buf.append(ch)
|
|
i += 1
|
|
stmt = ''.join(buf).strip()
|
|
if stmt:
|
|
stmts.append(stmt)
|
|
return stmts
|
|
|
|
@property
|
|
def is_connected(self) -> bool:
|
|
return self._connection is not None
|
|
|
|
def get_connection_info(self) -> str:
|
|
"""Return human-readable connection info string."""
|
|
cfg = self.config
|
|
if self.db_type == "sqlite":
|
|
return cfg.get("database", "")
|
|
return f"{cfg.get('user', '')}@{cfg.get('host', '')}:{cfg.get('port', '')}"
|