""" 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', '')}"