Initial Codes
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Driver factory — returns an instantiated driver for the given DB type.
|
||||
"""
|
||||
from app.drivers.base import BaseDriver
|
||||
|
||||
|
||||
def get_driver(db_type: str, config: dict) -> BaseDriver:
|
||||
"""Instantiate and return the correct driver for db_type."""
|
||||
db_type = db_type.lower()
|
||||
if db_type == "mysql":
|
||||
from app.drivers.mysql_driver import MySQLDriver
|
||||
return MySQLDriver(config)
|
||||
elif db_type in ("postgresql", "postgres"):
|
||||
from app.drivers.postgres_driver import PostgreSQLDriver
|
||||
return PostgreSQLDriver(config)
|
||||
elif db_type == "sqlite":
|
||||
from app.drivers.sqlite_driver import SQLiteDriver
|
||||
return SQLiteDriver(config)
|
||||
elif db_type == "mssql":
|
||||
from app.drivers.mssql_driver import MSSQLDriver
|
||||
return MSSQLDriver(config)
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type!r}")
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
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
|
||||
|
||||
@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', '')}"
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Microsoft SQL Server driver using pyodbc."""
|
||||
from typing import Optional
|
||||
from app.drivers.base import (
|
||||
BaseDriver, ColumnInfo, IndexInfo, ForeignKeyInfo, TableInfo
|
||||
)
|
||||
|
||||
try:
|
||||
import pyodbc
|
||||
PYODBC_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYODBC_AVAILABLE = False
|
||||
|
||||
|
||||
class MSSQLDriver(BaseDriver):
|
||||
"""SQL Server driver via pyodbc (ODBC Driver 17/18 for SQL Server required)."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.db_type = "mssql"
|
||||
|
||||
def _conn_str(self) -> str:
|
||||
host = self.config.get("host", "localhost")
|
||||
port = int(self.config.get("port", 1433))
|
||||
db = self.config.get("database", "master")
|
||||
user = self.config.get("user", "")
|
||||
pwd = self.config.get("password", "")
|
||||
# Try drivers in order of preference
|
||||
for driver in [
|
||||
"ODBC Driver 18 for SQL Server",
|
||||
"ODBC Driver 17 for SQL Server",
|
||||
"SQL Server",
|
||||
]:
|
||||
return (
|
||||
f"DRIVER={{{driver}}};"
|
||||
f"SERVER={host},{port};"
|
||||
f"DATABASE={db};"
|
||||
f"UID={user};PWD={pwd};"
|
||||
f"TrustServerCertificate=yes;"
|
||||
f"Connection Timeout={self.config.get('connection_timeout', 30)};"
|
||||
)
|
||||
|
||||
def connect(self) -> None:
|
||||
if not PYODBC_AVAILABLE:
|
||||
raise ImportError("pyodbc is not installed. Run: pip install pyodbc")
|
||||
self._connection = pyodbc.connect(self._conn_str(), autocommit=True)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
try:
|
||||
self._connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._connection = None
|
||||
|
||||
def test_connection(self) -> tuple:
|
||||
if not PYODBC_AVAILABLE:
|
||||
return False, "pyodbc is not installed. Run: pip install pyodbc"
|
||||
try:
|
||||
conn = pyodbc.connect(self._conn_str(), autocommit=True)
|
||||
conn.close()
|
||||
return True, "Connection successful"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _cur(self):
|
||||
return self._connection.cursor()
|
||||
|
||||
# ── Schema introspection ──────────────────────────────────────────────────
|
||||
|
||||
def get_databases(self) -> list:
|
||||
c = self._cur()
|
||||
c.execute("SELECT name FROM sys.databases ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_tables(self, database: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT t.name, s.name,
|
||||
COALESCE(p.rows, 0), 0, '', ''
|
||||
FROM [{database}].sys.tables t
|
||||
JOIN [{database}].sys.schemas s ON t.schema_id = s.schema_id
|
||||
LEFT JOIN (
|
||||
SELECT object_id, SUM(rows) AS rows
|
||||
FROM [{database}].sys.partitions WHERE index_id IN (0,1)
|
||||
GROUP BY object_id
|
||||
) p ON p.object_id = t.object_id
|
||||
ORDER BY t.name
|
||||
""")
|
||||
return [TableInfo(name=r[0], schema=r[1], row_count=r[2],
|
||||
size_bytes=r[3], engine=r[4], comment=r[5])
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_views(self, database: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"SELECT name FROM [{database}].sys.views ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_columns(self, database: str, table: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT c.name, tp.name, c.is_nullable, dc.definition,
|
||||
CASE WHEN pk.column_id IS NOT NULL THEN 1 ELSE 0 END,
|
||||
CASE WHEN fk.parent_column_id IS NOT NULL THEN 1 ELSE 0 END,
|
||||
CASE WHEN c.is_identity = 1 THEN 'auto_increment' ELSE '' END
|
||||
FROM [{database}].sys.columns c
|
||||
JOIN [{database}].sys.types tp ON tp.user_type_id = c.user_type_id
|
||||
JOIN [{database}].sys.tables t ON t.object_id = c.object_id
|
||||
LEFT JOIN [{database}].sys.default_constraints dc
|
||||
ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id
|
||||
LEFT JOIN (
|
||||
SELECT ic.column_id, ic.object_id
|
||||
FROM [{database}].sys.index_columns ic
|
||||
JOIN [{database}].sys.indexes i ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
||||
WHERE i.is_primary_key = 1
|
||||
) pk ON pk.object_id = c.object_id AND pk.column_id = c.column_id
|
||||
LEFT JOIN (
|
||||
SELECT fkc.parent_column_id, fkc.parent_object_id
|
||||
FROM [{database}].sys.foreign_key_columns fkc
|
||||
) fk ON fk.parent_object_id = c.object_id AND fk.parent_column_id = c.column_id
|
||||
WHERE t.name = ?
|
||||
ORDER BY c.column_id
|
||||
""", (table,))
|
||||
return [ColumnInfo(name=r[0], data_type=r[1], nullable=bool(r[2]),
|
||||
default=r[3], is_primary_key=bool(r[4]),
|
||||
is_foreign_key=bool(r[5]), extra=r[6] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_indexes(self, database: str, table: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT i.name, i.is_unique, STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal)
|
||||
FROM [{database}].sys.indexes i
|
||||
JOIN [{database}].sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
||||
JOIN [{database}].sys.columns c ON c.object_id = i.object_id AND c.column_id = ic.column_id
|
||||
JOIN [{database}].sys.tables t ON t.object_id = i.object_id
|
||||
WHERE t.name = ?
|
||||
GROUP BY i.name, i.is_unique
|
||||
""", (table,))
|
||||
return [IndexInfo(name=r[0], columns=r[2].split(','),
|
||||
is_unique=bool(r[1]))
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_foreign_keys(self, database: str, table: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT fk.name, pc.name, rt.name, rc.name,
|
||||
fk.update_referential_action_desc,
|
||||
fk.delete_referential_action_desc
|
||||
FROM [{database}].sys.foreign_keys fk
|
||||
JOIN [{database}].sys.tables pt ON pt.object_id = fk.parent_object_id
|
||||
JOIN [{database}].sys.tables rt ON rt.object_id = fk.referenced_object_id
|
||||
JOIN [{database}].sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
||||
JOIN [{database}].sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id
|
||||
JOIN [{database}].sys.columns rc ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id
|
||||
WHERE pt.name = ?
|
||||
""", (table,))
|
||||
return [ForeignKeyInfo(name=r[0], column=r[1],
|
||||
ref_table=r[2], ref_column=r[3],
|
||||
on_update=r[4] or "", on_delete=r[5] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_table_ddl(self, database: str, table: str) -> str:
|
||||
cols = self.get_columns(database, table)
|
||||
lines = [f"CREATE TABLE [{table}] ("]
|
||||
col_defs = []
|
||||
for col in cols:
|
||||
d = f" [{col.name}] {col.data_type}"
|
||||
if not col.nullable: d += " NOT NULL"
|
||||
if col.default: d += f" DEFAULT {col.default}"
|
||||
col_defs.append(d)
|
||||
lines.append(",\n".join(col_defs))
|
||||
lines.append(");")
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_functions(self, database: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT name FROM [{database}].sys.objects
|
||||
WHERE type IN ('FN','IF','TF') ORDER BY name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_stored_procedures(self, database: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT name FROM [{database}].sys.procedures ORDER BY name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_triggers(self, database: str, table: str = "") -> list:
|
||||
c = self._cur()
|
||||
if table:
|
||||
c.execute(f"""
|
||||
SELECT t.name FROM [{database}].sys.triggers t
|
||||
JOIN [{database}].sys.tables tb ON tb.object_id = t.parent_id
|
||||
WHERE tb.name = ? ORDER BY t.name
|
||||
""", (table,))
|
||||
else:
|
||||
c.execute(f"SELECT name FROM [{database}].sys.triggers ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
# ── Query execution ───────────────────────────────────────────────────────
|
||||
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
||||
c = self._cur()
|
||||
c.execute(sql, params or ())
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
return cols, [tuple(r) for r in rows], len(rows)
|
||||
return [], [], c.rowcount
|
||||
|
||||
def execute_script(self, sql: str) -> list:
|
||||
results = []
|
||||
stmts = [s.strip() for s in sql.split(';') if s.strip()]
|
||||
for stmt in stmts:
|
||||
try:
|
||||
c = self._cur()
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
def get_table_data(self, database: str, table: str,
|
||||
where: str = "", order_by: str = "",
|
||||
limit: int = 1000, offset: int = 0) -> tuple:
|
||||
sql = f"SELECT * FROM [{database}].[dbo].[{table}]"
|
||||
if where: sql += f" WHERE {where}"
|
||||
if order_by: sql += f" ORDER BY {order_by}"
|
||||
sql += f" OFFSET {offset} ROWS FETCH NEXT {limit} ROWS ONLY"
|
||||
return self.execute_query(sql)
|
||||
|
||||
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
||||
sql = f"SELECT COUNT(*) FROM [{database}].[dbo].[{table}]"
|
||||
if where: sql += f" WHERE {where}"
|
||||
c = self._cur()
|
||||
c.execute(sql)
|
||||
return c.fetchone()[0]
|
||||
|
||||
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
||||
cols = ", ".join(f"[{c}]" for c in data)
|
||||
ph = ", ".join(["?"] * len(data))
|
||||
c = self._cur()
|
||||
c.execute(f"INSERT INTO [{database}].[dbo].[{table}] ({cols}) VALUES ({ph})",
|
||||
tuple(data.values()))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _where(where: dict) -> tuple:
|
||||
parts, params = [], []
|
||||
for col, val in where.items():
|
||||
if val is None:
|
||||
parts.append(f"[{col}] IS NULL")
|
||||
else:
|
||||
parts.append(f"[{col}] = ?")
|
||||
params.append(val)
|
||||
return " AND ".join(parts), params
|
||||
|
||||
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
||||
set_cl = ", ".join(f"[{c}] = ?" for c in data)
|
||||
where_cl, where_params = self._where(where)
|
||||
c = self._cur()
|
||||
c.execute(f"UPDATE [{database}].[dbo].[{table}] SET {set_cl} WHERE {where_cl}",
|
||||
tuple(data.values()) + tuple(where_params))
|
||||
return True
|
||||
|
||||
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
||||
where_cl, where_params = self._where(where)
|
||||
c = self._cur()
|
||||
c.execute(f"DELETE FROM [{database}].[dbo].[{table}] WHERE {where_cl}",
|
||||
tuple(where_params))
|
||||
return True
|
||||
|
||||
# ── Server tools ──────────────────────────────────────────────────────────
|
||||
|
||||
def explain_query(self, sql: str) -> tuple:
|
||||
c = self._cur()
|
||||
c.execute(f"SET SHOWPLAN_TEXT ON; {sql}; SET SHOWPLAN_TEXT OFF")
|
||||
return ["Plan"], c.fetchall()
|
||||
|
||||
def get_process_list(self) -> tuple:
|
||||
c = self._cur()
|
||||
c.execute("""
|
||||
SELECT session_id, login_name, status, host_name,
|
||||
program_name, cpu_time, text
|
||||
FROM sys.dm_exec_sessions s
|
||||
CROSS APPLY sys.dm_exec_sql_text(s.most_recent_sql_handle) t
|
||||
WHERE s.is_user_process = 1
|
||||
""")
|
||||
cols = [d[0] for d in c.description]
|
||||
return cols, [tuple(r) for r in c.fetchall()]
|
||||
|
||||
def kill_process(self, process_id: int) -> bool:
|
||||
c = self._cur()
|
||||
c.execute(f"KILL {process_id}")
|
||||
return True
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
def add_column(self, database: str, table: str, col_name: str,
|
||||
col_type: str, nullable: bool = True,
|
||||
default=None) -> bool:
|
||||
null_clause = "NULL" if nullable else "NOT NULL"
|
||||
default_clause = f" DEFAULT {default}" if default is not None and default != "" else ""
|
||||
sql = (f"ALTER TABLE [{database}].[dbo].[{table}] "
|
||||
f"ADD [{col_name}] {col_type} {null_clause}{default_clause}")
|
||||
c = self._cur()
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def drop_column(self, database: str, table: str, col_name: str) -> bool:
|
||||
sql = f"ALTER TABLE [{database}].[dbo].[{table}] DROP COLUMN [{col_name}]"
|
||||
c = self._cur()
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def rename_column(self, database: str, table: str,
|
||||
old_name: str, new_name: str) -> bool:
|
||||
# sp_rename is the standard way in MSSQL
|
||||
sql = f"EXEC sp_rename '[{database}].[dbo].[{table}].[{old_name}]', '{new_name}', 'COLUMN'"
|
||||
c = self._cur()
|
||||
c.execute(sql)
|
||||
return True
|
||||
@@ -0,0 +1,377 @@
|
||||
"""MySQL driver implementation using pymysql."""
|
||||
import pymysql
|
||||
import pymysql.cursors
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from app.drivers.base import (
|
||||
BaseDriver, ColumnInfo, IndexInfo, ForeignKeyInfo, TableInfo
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
_log = get_logger(__name__)
|
||||
|
||||
|
||||
class MySQLDriver(BaseDriver):
|
||||
"""MySQL / MariaDB database driver."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.db_type = "mysql"
|
||||
|
||||
def _connect_kwargs(self) -> dict:
|
||||
kw = {
|
||||
"host": self.config.get("host", "localhost"),
|
||||
"port": int(self.config.get("port", 3306)),
|
||||
"user": self.config.get("user", ""),
|
||||
"password": self.config.get("password", ""),
|
||||
"connect_timeout": int(self.config.get("connection_timeout", 30)),
|
||||
"autocommit": True,
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
db = self.config.get("database", "")
|
||||
if db:
|
||||
kw["database"] = db
|
||||
return kw
|
||||
|
||||
def connect(self) -> None:
|
||||
host = self.config.get("host", "localhost")
|
||||
port = self.config.get("port", 3306)
|
||||
user = self.config.get("user", "")
|
||||
_log.info("MySQL connecting host=%s:%s user=%s", host, port, user)
|
||||
try:
|
||||
self._connection = pymysql.connect(**self._connect_kwargs())
|
||||
_log.info("MySQL connected host=%s:%s user=%s", host, port, user)
|
||||
except Exception:
|
||||
_log.error("MySQL connection failed host=%s:%s user=%s",
|
||||
host, port, user, exc_info=True)
|
||||
raise
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
try:
|
||||
self._connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._connection = None
|
||||
|
||||
def test_connection(self) -> tuple:
|
||||
try:
|
||||
conn = pymysql.connect(**self._connect_kwargs())
|
||||
conn.close()
|
||||
return True, "Connection successful"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _ensure_alive(self) -> None:
|
||||
"""Ping the server and silently reconnect if the connection has gone away."""
|
||||
if self._connection is None:
|
||||
_log.warning("MySQL connection is None — connecting now")
|
||||
self.connect()
|
||||
return
|
||||
try:
|
||||
self._connection.ping(reconnect=True)
|
||||
except Exception:
|
||||
_log.warning("MySQL ping failed — attempting full reconnect", exc_info=True)
|
||||
try:
|
||||
self.connect()
|
||||
_log.info("MySQL reconnected successfully")
|
||||
except Exception:
|
||||
_log.error("MySQL reconnect failed", exc_info=True)
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def _cur(self):
|
||||
"""Yield a cursor while holding the driver lock.
|
||||
|
||||
Using a contextmanager means the lock is held for the *entire*
|
||||
``with self._cur() as c: c.execute(); c.fetchall()`` block, which
|
||||
prevents concurrent SchemaWorker threads from interleaving on the same
|
||||
TCP socket (pymysql is not thread-safe).
|
||||
"""
|
||||
with self._lock:
|
||||
self._ensure_alive()
|
||||
cursor = self._connection.cursor(pymysql.cursors.Cursor)
|
||||
try:
|
||||
yield cursor
|
||||
finally:
|
||||
try:
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _fmt_err(e: Exception) -> str:
|
||||
"""Return a readable string for a pymysql exception.
|
||||
|
||||
pymysql errors carry (error_code, message) as args, so ``str(e)``
|
||||
prints something like ``(0, '')``. This helper unwraps that.
|
||||
"""
|
||||
args = getattr(e, "args", ())
|
||||
if args and isinstance(args[0], int):
|
||||
code, msg = args[0], args[1] if len(args) > 1 else ""
|
||||
if msg:
|
||||
return f"MySQL error {code}: {msg}"
|
||||
if code == 0:
|
||||
return "Lost connection to MySQL server (connection timed out or was reset)."
|
||||
return f"MySQL error {code}"
|
||||
return str(e)
|
||||
|
||||
# ── Schema introspection ──────────────────────────────────────────────────
|
||||
|
||||
def get_databases(self) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("SHOW DATABASES")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_tables(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT TABLE_NAME, TABLE_SCHEMA,
|
||||
COALESCE(TABLE_ROWS, 0),
|
||||
COALESCE(DATA_LENGTH + INDEX_LENGTH, 0),
|
||||
COALESCE(ENGINE, ''),
|
||||
COALESCE(TABLE_COMMENT, '')
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = %s AND TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY TABLE_NAME
|
||||
""", (database,))
|
||||
return [TableInfo(name=r[0], schema=r[1], row_count=r[2],
|
||||
size_bytes=r[3], engine=r[4], comment=r[5])
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_views(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT TABLE_NAME FROM information_schema.VIEWS
|
||||
WHERE TABLE_SCHEMA = %s ORDER BY TABLE_NAME
|
||||
""", (database,))
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_columns(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
|
||||
COLUMN_DEFAULT, COLUMN_KEY, EXTRA
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
||||
ORDER BY ORDINAL_POSITION
|
||||
""", (database, table))
|
||||
return [ColumnInfo(name=r[0], data_type=r[1],
|
||||
nullable=(r[2] == "YES"), default=r[3],
|
||||
is_primary_key=(r[4] == "PRI"),
|
||||
is_foreign_key=(r[4] == "MUL"),
|
||||
extra=r[5] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_indexes(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(f"SHOW INDEX FROM `{database}`.`{table}`")
|
||||
idx_map = {}
|
||||
for r in c.fetchall():
|
||||
name, non_unique, col, idx_type = r[2], r[1], r[4], r[10]
|
||||
if name not in idx_map:
|
||||
idx_map[name] = IndexInfo(name=name, columns=[col],
|
||||
is_unique=(non_unique == 0),
|
||||
index_type=idx_type)
|
||||
else:
|
||||
idx_map[name].columns.append(col)
|
||||
return list(idx_map.values())
|
||||
|
||||
def get_foreign_keys(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME,
|
||||
kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME,
|
||||
rc.UPDATE_RULE, rc.DELETE_RULE
|
||||
FROM information_schema.KEY_COLUMN_USAGE kcu
|
||||
JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
|
||||
ON rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
||||
AND rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
|
||||
WHERE kcu.TABLE_SCHEMA = %s AND kcu.TABLE_NAME = %s
|
||||
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
|
||||
""", (database, table))
|
||||
return [ForeignKeyInfo(name=r[0], column=r[1],
|
||||
ref_table=r[2], ref_column=r[3],
|
||||
on_update=r[4] or "", on_delete=r[5] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_table_ddl(self, database: str, table: str) -> str:
|
||||
with self._cur() as c:
|
||||
c.execute(f"SHOW CREATE TABLE `{database}`.`{table}`")
|
||||
row = c.fetchone()
|
||||
return row[1] if row else ""
|
||||
|
||||
def get_functions(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT ROUTINE_NAME FROM information_schema.ROUTINES
|
||||
WHERE ROUTINE_SCHEMA = %s AND ROUTINE_TYPE = 'FUNCTION'
|
||||
ORDER BY ROUTINE_NAME
|
||||
""", (database,))
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_stored_procedures(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT ROUTINE_NAME FROM information_schema.ROUTINES
|
||||
WHERE ROUTINE_SCHEMA = %s AND ROUTINE_TYPE = 'PROCEDURE'
|
||||
ORDER BY ROUTINE_NAME
|
||||
""", (database,))
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_triggers(self, database: str, table: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
if table:
|
||||
c.execute("""
|
||||
SELECT TRIGGER_NAME FROM information_schema.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = %s AND EVENT_OBJECT_TABLE = %s
|
||||
ORDER BY TRIGGER_NAME
|
||||
""", (database, table))
|
||||
else:
|
||||
c.execute("""
|
||||
SELECT TRIGGER_NAME FROM information_schema.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = %s ORDER BY TRIGGER_NAME
|
||||
""", (database,))
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
# ── Query execution ───────────────────────────────────────────────────────
|
||||
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(sql, params)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
return cols, rows, len(rows)
|
||||
return [], [], c.rowcount
|
||||
|
||||
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:
|
||||
try:
|
||||
c.execute(stmt)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = 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 ───────────────────────────────────────────────────────
|
||||
|
||||
def get_table_data(self, database: str, table: str,
|
||||
where: str = "", order_by: str = "",
|
||||
limit: int = 1000, offset: int = 0) -> tuple:
|
||||
sql = f"SELECT * FROM `{database}`.`{table}`"
|
||||
if where: sql += f" WHERE {where}"
|
||||
if order_by: sql += f" ORDER BY {order_by}"
|
||||
sql += f" LIMIT {limit} OFFSET {offset}"
|
||||
return self.execute_query(sql)
|
||||
|
||||
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
||||
sql = f"SELECT COUNT(*) FROM `{database}`.`{table}`"
|
||||
if where: sql += f" WHERE {where}"
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return c.fetchone()[0]
|
||||
|
||||
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
||||
cols = ", ".join(f"`{c}`" for c in data)
|
||||
ph = ", ".join(["%s"] * len(data))
|
||||
with self._cur() as c:
|
||||
c.execute(f"INSERT INTO `{database}`.`{table}` ({cols}) VALUES ({ph})",
|
||||
tuple(data.values()))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _where(where: dict) -> tuple:
|
||||
parts, params = [], []
|
||||
for col, val in where.items():
|
||||
if val is None:
|
||||
parts.append(f"`{col}` IS NULL")
|
||||
else:
|
||||
parts.append(f"`{col}` = %s")
|
||||
params.append(val)
|
||||
return " AND ".join(parts), params
|
||||
|
||||
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
||||
set_cl = ", ".join(f"`{c}` = %s" for c in data)
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f"UPDATE `{database}`.`{table}` SET {set_cl} WHERE {where_cl}",
|
||||
tuple(data.values()) + tuple(where_params))
|
||||
return True
|
||||
|
||||
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f"DELETE FROM `{database}`.`{table}` WHERE {where_cl}",
|
||||
tuple(where_params))
|
||||
return True
|
||||
|
||||
# ── Server tools ──────────────────────────────────────────────────────────
|
||||
|
||||
def explain_query(self, sql: str) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(f"EXPLAIN {sql}")
|
||||
return [d[0] for d in c.description], c.fetchall()
|
||||
|
||||
def get_process_list(self) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute("SHOW FULL PROCESSLIST")
|
||||
return [d[0] for d in c.description], c.fetchall()
|
||||
|
||||
def kill_process(self, process_id: int) -> bool:
|
||||
# MUST use a dedicated connection, not self._cur().
|
||||
#
|
||||
# If a QueryWorker is running a long query it holds self._lock via
|
||||
# _cur(). kill_process is called from a *different* SchemaWorker
|
||||
# thread; using self._cur() here would block waiting for that lock,
|
||||
# so the KILL command would never reach MySQL and the server would
|
||||
# eventually raise error 1317 ("Query execution was interrupted") on
|
||||
# its own. A fresh connection bypasses the lock entirely, which is
|
||||
# exactly how MySQL's KILL is intended to work.
|
||||
conn = pymysql.connect(**self._connect_kwargs())
|
||||
try:
|
||||
with conn.cursor() as c:
|
||||
c.execute(f"KILL {process_id}")
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
def add_column(self, database: str, table: str, col_name: str,
|
||||
col_type: str, nullable: bool = True,
|
||||
default=None) -> bool:
|
||||
null_clause = "NULL" if nullable else "NOT NULL"
|
||||
default_clause = f" DEFAULT {default}" if default is not None and default != "" else ""
|
||||
sql = (f"ALTER TABLE `{database}`.`{table}` "
|
||||
f"ADD COLUMN `{col_name}` {col_type} {null_clause}{default_clause}")
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def drop_column(self, database: str, table: str, col_name: str) -> bool:
|
||||
sql = f"ALTER TABLE `{database}`.`{table}` DROP COLUMN `{col_name}`"
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def rename_column(self, database: str, table: str,
|
||||
old_name: str, new_name: str) -> bool:
|
||||
sql = (f"ALTER TABLE `{database}`.`{table}` "
|
||||
f"RENAME COLUMN `{old_name}` TO `{new_name}`")
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
@@ -0,0 +1,343 @@
|
||||
"""PostgreSQL driver implementation using psycopg2."""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from app.drivers.base import (
|
||||
BaseDriver, ColumnInfo, IndexInfo, ForeignKeyInfo, TableInfo
|
||||
)
|
||||
|
||||
|
||||
class PostgreSQLDriver(BaseDriver):
|
||||
"""PostgreSQL database driver."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.db_type = "postgresql"
|
||||
|
||||
def _dsn(self) -> dict:
|
||||
kw = {
|
||||
"host": self.config.get("host", "localhost"),
|
||||
"port": int(self.config.get("port", 5432)),
|
||||
"user": self.config.get("user", ""),
|
||||
"password": self.config.get("password", ""),
|
||||
"connect_timeout": int(self.config.get("connection_timeout", 30)),
|
||||
}
|
||||
db = self.config.get("database", "")
|
||||
if db:
|
||||
kw["dbname"] = db
|
||||
return kw
|
||||
|
||||
def connect(self) -> None:
|
||||
self._connection = psycopg2.connect(**self._dsn())
|
||||
self._connection.autocommit = True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
try:
|
||||
self._connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._connection = None
|
||||
|
||||
def test_connection(self) -> tuple:
|
||||
try:
|
||||
conn = psycopg2.connect(**self._dsn())
|
||||
conn.close()
|
||||
return True, "Connection successful"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@contextmanager
|
||||
def _cur(self):
|
||||
"""Yield a cursor while holding the driver lock (thread-safe execute→fetch)."""
|
||||
with self._lock:
|
||||
cursor = self._connection.cursor()
|
||||
try:
|
||||
yield cursor
|
||||
finally:
|
||||
try:
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Schema introspection ──────────────────────────────────────────────────
|
||||
|
||||
def get_databases(self) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(
|
||||
"SELECT datname FROM pg_database "
|
||||
"WHERE datistemplate = false ORDER BY datname"
|
||||
)
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_tables(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT t.table_name, t.table_schema,
|
||||
COALESCE(s.n_live_tup, 0),
|
||||
0,
|
||||
'',
|
||||
COALESCE(obj_description(
|
||||
(quote_ident(t.table_schema)||'.'||quote_ident(t.table_name))::regclass,
|
||||
'pg_class'), '')
|
||||
FROM information_schema.tables t
|
||||
LEFT JOIN pg_stat_user_tables s
|
||||
ON s.schemaname = t.table_schema AND s.relname = t.table_name
|
||||
WHERE t.table_schema NOT IN ('pg_catalog','information_schema')
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY t.table_name
|
||||
""")
|
||||
return [TableInfo(name=r[0], schema=r[1], row_count=r[2],
|
||||
size_bytes=r[3], engine=r[4], comment=r[5])
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_views(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT table_name FROM information_schema.views
|
||||
WHERE table_schema NOT IN ('pg_catalog','information_schema')
|
||||
ORDER BY table_name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_columns(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT c.column_name, c.data_type, c.is_nullable,
|
||||
c.column_default,
|
||||
(SELECT true FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY'
|
||||
AND kcu.table_name = c.table_name
|
||||
AND kcu.column_name = c.column_name
|
||||
LIMIT 1) IS NOT NULL,
|
||||
false
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_name = %s
|
||||
ORDER BY c.ordinal_position
|
||||
""", (table,))
|
||||
return [ColumnInfo(name=r[0], data_type=r[1],
|
||||
nullable=(r[2] == "YES"), default=r[3],
|
||||
is_primary_key=bool(r[4]),
|
||||
is_foreign_key=bool(r[5]))
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_indexes(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT i.relname, ix.indisunique,
|
||||
array_agg(a.attname ORDER BY k.n) AS cols
|
||||
FROM pg_class t
|
||||
JOIN pg_index ix ON t.oid = ix.indrelid
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n)
|
||||
ON TRUE
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
||||
WHERE t.relname = %s
|
||||
GROUP BY i.relname, ix.indisunique
|
||||
ORDER BY i.relname
|
||||
""", (table,))
|
||||
return [IndexInfo(name=r[0], columns=list(r[2]),
|
||||
is_unique=bool(r[1]))
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_foreign_keys(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT tc.constraint_name, kcu.column_name,
|
||||
ccu.table_name, ccu.column_name,
|
||||
rc.update_rule, rc.delete_rule
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON rc.constraint_name = tc.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_name = %s
|
||||
""", (table,))
|
||||
return [ForeignKeyInfo(name=r[0], column=r[1],
|
||||
ref_table=r[2], ref_column=r[3],
|
||||
on_update=r[4] or "", on_delete=r[5] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_table_ddl(self, database: str, table: str) -> str:
|
||||
cols = self.get_columns(database, table)
|
||||
lines = [f"CREATE TABLE {table} ("]
|
||||
col_defs = []
|
||||
for c in cols:
|
||||
d = f" {c.name} {c.data_type}"
|
||||
if not c.nullable: d += " NOT NULL"
|
||||
if c.default: d += f" DEFAULT {c.default}"
|
||||
col_defs.append(d)
|
||||
lines.append(",\n".join(col_defs))
|
||||
lines.append(");")
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_functions(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT routine_name FROM information_schema.routines
|
||||
WHERE routine_type = 'FUNCTION'
|
||||
AND routine_schema NOT IN ('pg_catalog','information_schema')
|
||||
ORDER BY routine_name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_stored_procedures(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT routine_name FROM information_schema.routines
|
||||
WHERE routine_type = 'PROCEDURE'
|
||||
AND routine_schema NOT IN ('pg_catalog','information_schema')
|
||||
ORDER BY routine_name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_triggers(self, database: str, table: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
if table:
|
||||
c.execute("""
|
||||
SELECT trigger_name FROM information_schema.triggers
|
||||
WHERE event_object_table = %s ORDER BY trigger_name
|
||||
""", (table,))
|
||||
else:
|
||||
c.execute(
|
||||
"SELECT trigger_name FROM information_schema.triggers "
|
||||
"ORDER BY trigger_name"
|
||||
)
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
# ── Query execution ───────────────────────────────────────────────────────
|
||||
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(sql, params)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
return cols, rows, len(rows)
|
||||
return [], [], c.rowcount
|
||||
|
||||
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:
|
||||
try:
|
||||
c.execute(stmt)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = 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 ───────────────────────────────────────────────────────
|
||||
|
||||
def get_table_data(self, database: str, table: str,
|
||||
where: str = "", order_by: str = "",
|
||||
limit: int = 1000, offset: int = 0) -> tuple:
|
||||
sql = f'SELECT * FROM "{table}"'
|
||||
if where: sql += f" WHERE {where}"
|
||||
if order_by: sql += f" ORDER BY {order_by}"
|
||||
sql += f" LIMIT {limit} OFFSET {offset}"
|
||||
return self.execute_query(sql)
|
||||
|
||||
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
||||
sql = f'SELECT COUNT(*) FROM "{table}"'
|
||||
if where: sql += f" WHERE {where}"
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return c.fetchone()[0]
|
||||
|
||||
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
||||
cols = ", ".join(f'"{c}"' for c in data)
|
||||
ph = ", ".join(["%s"] * len(data))
|
||||
with self._cur() as c:
|
||||
c.execute(f'INSERT INTO "{table}" ({cols}) VALUES ({ph})',
|
||||
tuple(data.values()))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _where(where: dict) -> tuple:
|
||||
parts, params = [], []
|
||||
for col, val in where.items():
|
||||
if val is None:
|
||||
parts.append(f'"{col}" IS NULL')
|
||||
else:
|
||||
parts.append(f'"{col}" = %s')
|
||||
params.append(val)
|
||||
return " AND ".join(parts), params
|
||||
|
||||
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
||||
set_cl = ", ".join(f'"{c}" = %s' for c in data)
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f'UPDATE "{table}" SET {set_cl} WHERE {where_cl}',
|
||||
tuple(data.values()) + tuple(where_params))
|
||||
return True
|
||||
|
||||
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f'DELETE FROM "{table}" WHERE {where_cl}',
|
||||
tuple(where_params))
|
||||
return True
|
||||
|
||||
# ── Server tools ──────────────────────────────────────────────────────────
|
||||
|
||||
def explain_query(self, sql: str) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(f"EXPLAIN ANALYZE {sql}")
|
||||
return ["Plan"], c.fetchall()
|
||||
|
||||
def get_process_list(self) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT pid, usename, application_name, client_addr,
|
||||
state, query, query_start
|
||||
FROM pg_stat_activity WHERE state IS NOT NULL
|
||||
ORDER BY query_start DESC NULLS LAST
|
||||
""")
|
||||
cols = [d[0] for d in c.description]
|
||||
return cols, c.fetchall()
|
||||
|
||||
def kill_process(self, process_id: int) -> bool:
|
||||
with self._cur() as c:
|
||||
c.execute("SELECT pg_terminate_backend(%s)", (process_id,))
|
||||
return True
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
def add_column(self, _database: str, table: str, col_name: str,
|
||||
col_type: str, nullable: bool = True,
|
||||
default=None) -> bool:
|
||||
default_clause = f" DEFAULT {default}" if default is not None and default != "" else ""
|
||||
null_clause = "" if nullable else " NOT NULL"
|
||||
sql = (f'ALTER TABLE "{table}" '
|
||||
f'ADD COLUMN "{col_name}" {col_type}{default_clause}{null_clause}')
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def drop_column(self, _database: str, table: str, col_name: str) -> bool:
|
||||
sql = f'ALTER TABLE "{table}" DROP COLUMN "{col_name}"'
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def rename_column(self, _database: str, table: str,
|
||||
old_name: str, new_name: str) -> bool:
|
||||
sql = f'ALTER TABLE "{table}" RENAME COLUMN "{old_name}" TO "{new_name}"'
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
@@ -0,0 +1,280 @@
|
||||
"""SQLite driver implementation using stdlib sqlite3."""
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from app.drivers.base import (
|
||||
BaseDriver, ColumnInfo, IndexInfo, ForeignKeyInfo, TableInfo
|
||||
)
|
||||
|
||||
|
||||
class SQLiteDriver(BaseDriver):
|
||||
"""SQLite database driver (uses stdlib sqlite3)."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.db_type = "sqlite"
|
||||
|
||||
def connect(self) -> None:
|
||||
db_path = self.config.get("database", ":memory:")
|
||||
self._connection = sqlite3.connect(
|
||||
db_path,
|
||||
check_same_thread=False,
|
||||
timeout=int(self.config.get("connection_timeout", 30)),
|
||||
)
|
||||
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
try:
|
||||
self._connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._connection = None
|
||||
|
||||
def test_connection(self) -> tuple:
|
||||
try:
|
||||
db_path = self.config.get("database", "")
|
||||
conn = sqlite3.connect(db_path, timeout=5)
|
||||
conn.execute("SELECT 1")
|
||||
conn.close()
|
||||
return True, "Connection successful"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@contextmanager
|
||||
def _cur(self):
|
||||
"""Yield a cursor while holding the driver lock (thread-safe execute→fetch)."""
|
||||
with self._lock:
|
||||
cursor = self._connection.cursor()
|
||||
try:
|
||||
yield cursor
|
||||
finally:
|
||||
try:
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Schema introspection ──────────────────────────────────────────────────
|
||||
|
||||
def get_databases(self) -> list:
|
||||
return [self.config.get("database", "main")]
|
||||
|
||||
def get_tables(self, database: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
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))
|
||||
return tables
|
||||
|
||||
def get_views(self, database: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("SELECT name FROM sqlite_master WHERE type='view' ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_columns(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(f'PRAGMA table_info("{table}")')
|
||||
cols = []
|
||||
for r in c.fetchall():
|
||||
# cid, name, type, notnull, dflt_value, pk
|
||||
cols.append(ColumnInfo(
|
||||
name=r[1],
|
||||
data_type=r[2] or "TEXT",
|
||||
nullable=not bool(r[3]),
|
||||
default=str(r[4]) if r[4] is not None else None,
|
||||
is_primary_key=bool(r[5]),
|
||||
is_foreign_key=False,
|
||||
))
|
||||
return cols
|
||||
|
||||
def get_indexes(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(f'PRAGMA index_list("{table}")')
|
||||
index_rows = c.fetchall()
|
||||
indexes = []
|
||||
for r in index_rows:
|
||||
idx_name = r[1]
|
||||
is_unique = bool(r[2])
|
||||
with self._cur() as cc:
|
||||
cc.execute(f'PRAGMA index_info("{idx_name}")')
|
||||
cols = [row[2] for row in cc.fetchall()]
|
||||
indexes.append(IndexInfo(name=idx_name, columns=cols, is_unique=is_unique))
|
||||
return indexes
|
||||
|
||||
def get_foreign_keys(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(f'PRAGMA foreign_key_list("{table}")')
|
||||
return [ForeignKeyInfo(
|
||||
name=f"fk_{r[3]}",
|
||||
column=r[3], ref_table=r[2], ref_column=r[4],
|
||||
on_update=r[5] or "", on_delete=r[6] or "",
|
||||
) for r in c.fetchall()]
|
||||
|
||||
def get_table_ddl(self, database: str, table: str) -> str:
|
||||
with self._cur() as c:
|
||||
c.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE name = ? AND type = 'table'",
|
||||
(table,)
|
||||
)
|
||||
row = c.fetchone()
|
||||
return row[0] if row else ""
|
||||
|
||||
def get_functions(self, database: str) -> list:
|
||||
return []
|
||||
|
||||
def get_stored_procedures(self, database: str) -> list:
|
||||
return []
|
||||
|
||||
def get_triggers(self, database: str, table: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
if table:
|
||||
c.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=?",
|
||||
(table,)
|
||||
)
|
||||
else:
|
||||
c.execute("SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
# ── Query execution ───────────────────────────────────────────────────────
|
||||
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(sql, params or ())
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
return cols, rows, len(rows)
|
||||
self._connection.commit()
|
||||
return [], [], c.rowcount
|
||||
|
||||
def execute_script(self, sql: str) -> list:
|
||||
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 = c.fetchall()
|
||||
results.append((cols, rows, len(rows), ""))
|
||||
else:
|
||||
self._connection.commit()
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
def get_table_data(self, database: str, table: str,
|
||||
where: str = "", order_by: str = "",
|
||||
limit: int = 1000, offset: int = 0) -> tuple:
|
||||
sql = f'SELECT * FROM "{table}"'
|
||||
if where: sql += f" WHERE {where}"
|
||||
if order_by: sql += f" ORDER BY {order_by}"
|
||||
sql += f" LIMIT {limit} OFFSET {offset}"
|
||||
return self.execute_query(sql)
|
||||
|
||||
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
||||
sql = f'SELECT COUNT(*) FROM "{table}"'
|
||||
if where: sql += f" WHERE {where}"
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return c.fetchone()[0]
|
||||
|
||||
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
||||
cols = ", ".join(f'"{c}"' for c in data)
|
||||
ph = ", ".join(["?"] * len(data))
|
||||
with self._cur() as c:
|
||||
c.execute(f'INSERT INTO "{table}" ({cols}) VALUES ({ph})',
|
||||
tuple(data.values()))
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _where(where: dict) -> tuple:
|
||||
parts, params = [], []
|
||||
for col, val in where.items():
|
||||
if val is None:
|
||||
parts.append(f'"{col}" IS NULL')
|
||||
else:
|
||||
parts.append(f'"{col}" = ?')
|
||||
params.append(val)
|
||||
return " AND ".join(parts), params
|
||||
|
||||
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
||||
set_cl = ", ".join(f'"{c}" = ?' for c in data)
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f'UPDATE "{table}" SET {set_cl} WHERE {where_cl}',
|
||||
tuple(data.values()) + tuple(where_params))
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f'DELETE FROM "{table}" WHERE {where_cl}', tuple(where_params))
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
# ── Server tools ──────────────────────────────────────────────────────────
|
||||
|
||||
def explain_query(self, sql: str) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(f"EXPLAIN QUERY PLAN {sql}")
|
||||
cols = [d[0] for d in c.description]
|
||||
return cols, c.fetchall()
|
||||
|
||||
def get_process_list(self) -> tuple:
|
||||
return ["Info"], [("SQLite does not support process listing.",)]
|
||||
|
||||
def kill_process(self, process_id: int) -> bool:
|
||||
return False
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
def add_column(self, _database: str, table: str, col_name: str,
|
||||
col_type: str, nullable: bool = True,
|
||||
default=None) -> bool:
|
||||
null_clause = "" if nullable else " NOT NULL"
|
||||
default_clause = f" DEFAULT {default}" if default is not None and default != "" else ""
|
||||
sql = (f'ALTER TABLE "{table}" '
|
||||
f'ADD COLUMN "{col_name}" {col_type}{default_clause}{null_clause}')
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
def drop_column(self, _database: str, table: str, col_name: str) -> bool:
|
||||
# Requires SQLite 3.35.0+
|
||||
with self._cur() as c:
|
||||
c.execute(f'ALTER TABLE "{table}" DROP COLUMN "{col_name}"')
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
def rename_column(self, _database: str, table: str,
|
||||
old_name: str, new_name: str) -> bool:
|
||||
# Requires SQLite 3.25.0+
|
||||
with self._cur() as c:
|
||||
c.execute(f'ALTER TABLE "{table}" RENAME COLUMN "{old_name}" TO "{new_name}"')
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user