diff --git a/app/config/connections.py b/app/config/connections.py index 894493a..fad1919 100644 --- a/app/config/connections.py +++ b/app/config/connections.py @@ -8,6 +8,9 @@ from pathlib import Path from typing import Optional from app.models.connection_model import ConnectionProfile +from app.utils.logger import get_logger + +_log = get_logger(__name__) _CONN_FILE = Path.home() / ".dbclient" / "connections.json" @@ -23,8 +26,15 @@ _SERVICE = "DBClient" # ── Keyring helpers ────────────────────────────────────────────────────────── def save_password(profile_id: str, password: str) -> None: - if _KEYRING_OK and password: - keyring.set_password(_SERVICE, profile_id, password) + if not _KEYRING_OK: + return + if password: + try: + keyring.set_password(_SERVICE, profile_id, password) + except Exception: + _log.warning("keyring: failed to save password for %s", profile_id, exc_info=True) + else: + delete_password(profile_id) def load_password(profile_id: str) -> str: @@ -32,6 +42,7 @@ def load_password(profile_id: str) -> str: try: return keyring.get_password(_SERVICE, profile_id) or "" except Exception: + _log.warning("keyring: failed to load password for %s", profile_id, exc_info=True) return "" return "" @@ -41,7 +52,7 @@ def delete_password(profile_id: str) -> None: try: keyring.delete_password(_SERVICE, profile_id) except Exception: - pass + pass # Already absent is fine # ── Profile CRUD ───────────────────────────────────────────────────────────── diff --git a/app/drivers/base.py b/app/drivers/base.py index c80c060..92e4df4 100644 --- a/app/drivers/base.py +++ b/app/drivers/base.py @@ -197,6 +197,52 @@ class BaseDriver(ABC): """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 diff --git a/app/drivers/mssql_driver.py b/app/drivers/mssql_driver.py index 9a983c1..0340a3e 100644 --- a/app/drivers/mssql_driver.py +++ b/app/drivers/mssql_driver.py @@ -233,21 +233,23 @@ class MSSQLDriver(BaseDriver): return [], [], c.rowcount def execute_script(self, sql: str) -> list: + import re results = [] - stmts = [s.strip() for s in sql.split(';') if s.strip()] - for stmt in stmts: - try: - with self._cur() as c: - c.execute(stmt) - if c.description: - cols = [d[0] for d in c.description] - rows = [tuple(r) for r in c.fetchall()] - results.append((cols, rows, len(rows), "")) - else: - results.append(([], [], c.rowcount, - f"{c.rowcount} row(s) affected")) - except Exception as e: - results.append(([], [], 0, f"Error: {e}")) + batches = re.split(r'^\s*GO\s*$', sql, flags=re.IGNORECASE | re.MULTILINE) + for batch in batches: + for stmt in self._split_statements(batch): + try: + with self._cur() as c: + c.execute(stmt) + if c.description: + cols = [d[0] for d in c.description] + rows = [tuple(r) for r in c.fetchall()] + results.append((cols, rows, len(rows), "")) + else: + results.append(([], [], c.rowcount, + f"{c.rowcount} row(s) affected")) + except Exception as e: + results.append(([], [], 0, f"Error: {e}")) return results # ── Table data CRUD ─────────────────────────────────────────────────────── @@ -305,9 +307,20 @@ class MSSQLDriver(BaseDriver): # ── Server tools ────────────────────────────────────────────────────────── def explain_query(self, sql: str) -> tuple: - with self._cur() as c: - c.execute(f"SET SHOWPLAN_TEXT ON; {sql}; SET SHOWPLAN_TEXT OFF") - return ["Plan"], c.fetchall() + with self._lock: + cur = self._connection.cursor() + try: + cur.execute("SET SHOWPLAN_TEXT ON") + cur.execute(sql) + cols = [d[0] for d in cur.description] if cur.description else ["Plan"] + rows = cur.fetchall() + cur.execute("SET SHOWPLAN_TEXT OFF") + finally: + try: + cur.close() + except Exception: + pass + return cols, rows def get_process_list(self) -> tuple: with self._cur() as c: diff --git a/app/drivers/mysql_driver.py b/app/drivers/mysql_driver.py index 8729812..517770c 100644 --- a/app/drivers/mysql_driver.py +++ b/app/drivers/mysql_driver.py @@ -254,9 +254,8 @@ class MySQLDriver(BaseDriver): def execute_script(self, sql: str) -> list: results = [] - stmts = [s.strip() for s in sql.split(';') if s.strip()] with self._cur() as c: - for stmt in stmts: + for stmt in self._split_statements(sql): try: c.execute(stmt) if c.description: diff --git a/app/drivers/postgres_driver.py b/app/drivers/postgres_driver.py index 4aac641..e809320 100644 --- a/app/drivers/postgres_driver.py +++ b/app/drivers/postgres_driver.py @@ -1,6 +1,5 @@ """PostgreSQL driver implementation using psycopg2.""" import psycopg2 -import psycopg2.extras from contextlib import contextmanager from typing import Optional from app.drivers.base import ( @@ -230,9 +229,8 @@ class PostgreSQLDriver(BaseDriver): def execute_script(self, sql: str) -> list: results = [] - stmts = [s.strip() for s in sql.split(';') if s.strip()] with self._cur() as c: - for stmt in stmts: + for stmt in self._split_statements(sql): try: c.execute(stmt) if c.description: diff --git a/app/drivers/sqlite_driver.py b/app/drivers/sqlite_driver.py index 2fa4b86..29b41ce 100644 --- a/app/drivers/sqlite_driver.py +++ b/app/drivers/sqlite_driver.py @@ -69,15 +69,14 @@ class SQLiteDriver(BaseDriver): ORDER BY name """) names = [r[0] for r in c.fetchall()] - tables = [] - for name in names: - try: - with self._cur() as rc: - rc.execute(f'SELECT COUNT(*) FROM "{name}"') - row_count = rc.fetchone()[0] - except Exception: - row_count = 0 - tables.append(TableInfo(name=name, schema="main", row_count=row_count)) + tables = [] + for name in names: + try: + c.execute(f'SELECT COUNT(*) FROM "{name}"') + row_count = c.fetchone()[0] + except Exception: + row_count = 0 + tables.append(TableInfo(name=name, schema="main", row_count=row_count)) return tables def get_views(self, database: str = "") -> list: @@ -164,8 +163,7 @@ class SQLiteDriver(BaseDriver): def execute_script(self, sql: str) -> list: results = [] - stmts = [s.strip() for s in sql.split(';') if s.strip()] - for stmt in stmts: + for stmt in self._split_statements(sql): try: with self._cur() as c: c.execute(stmt)