Initial Codes
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# ui package
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Column definition dialog — used for both Add Column and Rename Column operations
|
||||
in the Table Designer.
|
||||
"""
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QLineEdit, QComboBox, QCheckBox, QDialogButtonBox, QLabel,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
# Common SQL types offered in the dropdown (user can type anything)
|
||||
_COMMON_TYPES = [
|
||||
"INT",
|
||||
"BIGINT",
|
||||
"SMALLINT",
|
||||
"TINYINT",
|
||||
"BOOLEAN",
|
||||
"FLOAT",
|
||||
"DOUBLE",
|
||||
"DECIMAL(10,2)",
|
||||
"VARCHAR(255)",
|
||||
"VARCHAR(100)",
|
||||
"CHAR(36)",
|
||||
"TEXT",
|
||||
"LONGTEXT",
|
||||
"DATE",
|
||||
"DATETIME",
|
||||
"TIMESTAMP",
|
||||
"TIME",
|
||||
"JSON",
|
||||
"BLOB",
|
||||
"BINARY(16)",
|
||||
]
|
||||
|
||||
|
||||
class ColumnDialog(QDialog):
|
||||
"""
|
||||
Dialog for adding a new column or renaming an existing one.
|
||||
|
||||
In rename mode only the Name field is shown; in add mode all fields appear.
|
||||
"""
|
||||
|
||||
def __init__(self, mode: str = "add", column_name: str = "",
|
||||
db_type: str = "", parent=None):
|
||||
"""
|
||||
Args:
|
||||
mode: "add" | "rename"
|
||||
column_name: pre-filled name (for rename, the old name)
|
||||
db_type: driver db_type string — used to tailor type suggestions
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._mode = mode
|
||||
self._db_type = db_type
|
||||
|
||||
self.setWindowTitle("Add Column" if mode == "add" else "Rename Column")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
self._build_ui(column_name)
|
||||
|
||||
# ── Properties ────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def column_name(self) -> str:
|
||||
return self._name_edit.text().strip()
|
||||
|
||||
@property
|
||||
def column_type(self) -> str:
|
||||
return self._type_combo.currentText().strip()
|
||||
|
||||
@property
|
||||
def nullable(self) -> bool:
|
||||
return self._nullable_cb.isChecked()
|
||||
|
||||
@property
|
||||
def default_value(self) -> str:
|
||||
return self._default_edit.text().strip()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self, column_name: str):
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# Name
|
||||
self._name_edit = QLineEdit(column_name)
|
||||
self._name_edit.setPlaceholderText("column_name")
|
||||
form.addRow("Column name:", self._name_edit)
|
||||
|
||||
if self._mode == "add":
|
||||
# Type
|
||||
self._type_combo = QComboBox()
|
||||
self._type_combo.setEditable(True)
|
||||
self._type_combo.addItems(_COMMON_TYPES)
|
||||
self._type_combo.setCurrentText("VARCHAR(255)")
|
||||
form.addRow("Data type:", self._type_combo)
|
||||
|
||||
# Nullable
|
||||
self._nullable_cb = QCheckBox()
|
||||
self._nullable_cb.setChecked(True)
|
||||
form.addRow("Allow NULL:", self._nullable_cb)
|
||||
|
||||
# Default
|
||||
self._default_edit = QLineEdit()
|
||||
self._default_edit.setPlaceholderText("optional default value")
|
||||
form.addRow("Default:", self._default_edit)
|
||||
else:
|
||||
# Stub widgets so properties don't crash in rename mode
|
||||
self._type_combo = QComboBox()
|
||||
self._nullable_cb = QCheckBox()
|
||||
self._nullable_cb.setChecked(True)
|
||||
self._default_edit = QLineEdit()
|
||||
|
||||
root.addLayout(form)
|
||||
|
||||
if self._mode == "add":
|
||||
note = QLabel(
|
||||
"<i>Changes are applied immediately via ALTER TABLE.</i>"
|
||||
)
|
||||
note.setWordWrap(True)
|
||||
root.addWidget(note)
|
||||
|
||||
# Buttons
|
||||
btns = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok |
|
||||
QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
btns.accepted.connect(self._accept)
|
||||
btns.rejected.connect(self.reject)
|
||||
root.addWidget(btns)
|
||||
|
||||
def _accept(self):
|
||||
if not self.column_name:
|
||||
self._name_edit.setFocus()
|
||||
return
|
||||
if self._mode == "add" and not self.column_type:
|
||||
self._type_combo.setFocus()
|
||||
return
|
||||
self.accept()
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
New / Edit connection dialog.
|
||||
Supports MySQL, PostgreSQL, SQLite, and SQL Server.
|
||||
"""
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QVBoxLayout,
|
||||
QLabel, QLineEdit, QComboBox, QSpinBox, QCheckBox, QPushButton,
|
||||
QTabWidget, QWidget, QFileDialog, QMessageBox, QFrame, QColorDialog,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QColor
|
||||
|
||||
from app.models.connection_model import ConnectionProfile
|
||||
from app.config.connections import save_profile
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
"""A button that shows a solid colour and opens a colour picker."""
|
||||
|
||||
def __init__(self, color: str = "#89b4fa", parent=None):
|
||||
super().__init__(parent)
|
||||
self._color = color
|
||||
self.setFixedSize(32, 24)
|
||||
self._refresh()
|
||||
self.clicked.connect(self._pick)
|
||||
|
||||
def _refresh(self):
|
||||
self.setStyleSheet(
|
||||
f"background-color:{self._color}; border:1px solid #45475a; border-radius:4px;"
|
||||
)
|
||||
|
||||
def _pick(self):
|
||||
col = QColorDialog.getColor(QColor(self._color), self, "Pick a colour")
|
||||
if col.isValid():
|
||||
self._color = col.name()
|
||||
self._refresh()
|
||||
|
||||
@property
|
||||
def color(self) -> str:
|
||||
return self._color
|
||||
|
||||
@color.setter
|
||||
def color(self, value: str):
|
||||
self._color = value
|
||||
self._refresh()
|
||||
|
||||
|
||||
class ConnectionDialog(QDialog):
|
||||
"""Dialog for creating or editing a ConnectionProfile."""
|
||||
|
||||
DB_TYPES = [
|
||||
("MySQL", "mysql", 3306),
|
||||
("PostgreSQL", "postgresql", 5432),
|
||||
("SQLite", "sqlite", 0),
|
||||
("SQL Server", "mssql", 1433),
|
||||
]
|
||||
|
||||
def __init__(self, profile: ConnectionProfile = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._profile = profile
|
||||
self._editing = profile is not None
|
||||
self.setWindowTitle("Edit Connection" if self._editing else "New Connection")
|
||||
self.setMinimumWidth(520)
|
||||
self.setModal(True)
|
||||
self._build_ui()
|
||||
if self._editing:
|
||||
self._populate(profile)
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(0)
|
||||
|
||||
# ── Tabs ──────────────────────────────────────────────────────────────
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._build_general_tab(), "General")
|
||||
tabs.addTab(self._build_ssl_tab(), "SSL / Advanced")
|
||||
root.addWidget(tabs)
|
||||
|
||||
# ── Buttons ───────────────────────────────────────────────────────────
|
||||
self._test_btn = QPushButton("Test Connection")
|
||||
self._test_btn.clicked.connect(self._test_connection)
|
||||
|
||||
bbox = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok |
|
||||
QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
bbox.accepted.connect(self._accept)
|
||||
bbox.rejected.connect(self.reject)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addWidget(self._test_btn)
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(bbox)
|
||||
root.addSpacing(8)
|
||||
root.addLayout(btn_row)
|
||||
|
||||
def _build_general_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
form = QFormLayout(w)
|
||||
form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.DontWrapRows)
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
form.setSpacing(10)
|
||||
form.setContentsMargins(16, 16, 16, 8)
|
||||
|
||||
# Connection name + colour
|
||||
name_row = QHBoxLayout()
|
||||
self._name = QLineEdit()
|
||||
self._name.setPlaceholderText("My Database")
|
||||
self._color_btn = ColorButton()
|
||||
name_row.addWidget(self._name, 1)
|
||||
name_row.addWidget(self._color_btn)
|
||||
form.addRow("Name:", name_row)
|
||||
|
||||
# DB type selector
|
||||
self._db_type = QComboBox()
|
||||
for label, _, _ in self.DB_TYPES:
|
||||
self._db_type.addItem(label)
|
||||
self._db_type.currentIndexChanged.connect(self._on_type_changed)
|
||||
form.addRow("Type:", self._db_type)
|
||||
|
||||
# Separator line
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
form.addRow(line)
|
||||
|
||||
# Host / port
|
||||
hp = QHBoxLayout()
|
||||
self._host = QLineEdit()
|
||||
self._host.setPlaceholderText("localhost")
|
||||
self._port = QSpinBox()
|
||||
self._port.setRange(1, 65535)
|
||||
self._port.setValue(3306)
|
||||
self._port.setFixedWidth(90)
|
||||
hp.addWidget(self._host, 1)
|
||||
hp.addWidget(QLabel("Port:"))
|
||||
hp.addWidget(self._port)
|
||||
form.addRow("Host:", hp)
|
||||
|
||||
# Database / file path
|
||||
db_row = QHBoxLayout()
|
||||
self._database = QLineEdit()
|
||||
self._database.setPlaceholderText("database name or file path")
|
||||
self._browse_btn = QPushButton("Browse…")
|
||||
self._browse_btn.setFixedWidth(80)
|
||||
self._browse_btn.clicked.connect(self._browse_file)
|
||||
self._browse_btn.setVisible(False)
|
||||
db_row.addWidget(self._database, 1)
|
||||
db_row.addWidget(self._browse_btn)
|
||||
form.addRow("Database:", db_row)
|
||||
|
||||
# Username / password
|
||||
self._username = QLineEdit()
|
||||
self._username.setPlaceholderText("username")
|
||||
form.addRow("Username:", self._username)
|
||||
|
||||
self._password = QLineEdit()
|
||||
self._password.setPlaceholderText("password")
|
||||
self._password.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
form.addRow("Password:", self._password)
|
||||
|
||||
# Timeout
|
||||
self._timeout = QSpinBox()
|
||||
self._timeout.setRange(1, 300)
|
||||
self._timeout.setValue(30)
|
||||
self._timeout.setSuffix(" sec")
|
||||
form.addRow("Timeout:", self._timeout)
|
||||
|
||||
return w
|
||||
|
||||
def _build_ssl_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
form = QFormLayout(w)
|
||||
form.setSpacing(10)
|
||||
form.setContentsMargins(16, 16, 16, 8)
|
||||
|
||||
self._ssl = QCheckBox("Use SSL / TLS")
|
||||
form.addRow(self._ssl)
|
||||
|
||||
self._ssl_ca = self._file_row(form, "CA Certificate:")
|
||||
self._ssl_cert = self._file_row(form, "Client Certificate:")
|
||||
self._ssl_key = self._file_row(form, "Client Key:")
|
||||
return w
|
||||
|
||||
def _file_row(self, form: QFormLayout, label: str) -> QLineEdit:
|
||||
row = QHBoxLayout()
|
||||
le = QLineEdit()
|
||||
le.setPlaceholderText("(optional) path to file")
|
||||
btn = QPushButton("…")
|
||||
btn.setFixedWidth(32)
|
||||
btn.clicked.connect(lambda: self._choose_file(le))
|
||||
row.addWidget(le, 1)
|
||||
row.addWidget(btn)
|
||||
form.addRow(label, row)
|
||||
return le
|
||||
|
||||
# ── Slots ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_type_changed(self, idx: int):
|
||||
_, db_type, default_port = self.DB_TYPES[idx]
|
||||
is_sqlite = (db_type == "sqlite")
|
||||
self._host.setEnabled(not is_sqlite)
|
||||
self._port.setEnabled(not is_sqlite)
|
||||
self._username.setEnabled(not is_sqlite)
|
||||
self._password.setEnabled(not is_sqlite)
|
||||
self._browse_btn.setVisible(is_sqlite)
|
||||
if default_port:
|
||||
self._port.setValue(default_port)
|
||||
|
||||
def _browse_file(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select SQLite File", "",
|
||||
"SQLite Databases (*.db *.sqlite *.sqlite3);;All Files (*)"
|
||||
)
|
||||
if path:
|
||||
self._database.setText(path)
|
||||
|
||||
def _choose_file(self, target: QLineEdit):
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "All Files (*)")
|
||||
if path:
|
||||
target.setText(path)
|
||||
|
||||
def _test_connection(self):
|
||||
p = self._build_profile()
|
||||
from app.drivers import get_driver
|
||||
try:
|
||||
driver = get_driver(p.db_type, self._driver_config(p))
|
||||
ok, msg = driver.test_connection()
|
||||
except Exception as e:
|
||||
ok, msg = False, str(e)
|
||||
|
||||
icon = "✅" if ok else "❌"
|
||||
QMessageBox.information(self, "Test Connection", f"{icon} {msg}")
|
||||
|
||||
def _accept(self):
|
||||
if not self._name.text().strip():
|
||||
QMessageBox.warning(self, "Validation", "Connection name is required.")
|
||||
return
|
||||
profile = self._build_profile()
|
||||
save_profile(profile)
|
||||
self._profile = profile
|
||||
self.accept()
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_profile(self) -> ConnectionProfile:
|
||||
idx = self._db_type.currentIndex()
|
||||
_, db_type, _ = self.DB_TYPES[idx]
|
||||
base = self._profile if self._editing else ConnectionProfile(
|
||||
name="", db_type=db_type
|
||||
)
|
||||
base.name = self._name.text().strip()
|
||||
base.db_type = db_type
|
||||
base.host = self._host.text().strip()
|
||||
base.port = self._port.value()
|
||||
base.database = self._database.text().strip()
|
||||
base.username = self._username.text().strip()
|
||||
base.password = self._password.text()
|
||||
base.color = self._color_btn.color
|
||||
base.ssl = self._ssl.isChecked()
|
||||
base.ssl_ca = self._ssl_ca.text().strip()
|
||||
base.ssl_cert = self._ssl_cert.text().strip()
|
||||
base.ssl_key = self._ssl_key.text().strip()
|
||||
base.connection_timeout = self._timeout.value()
|
||||
return base
|
||||
|
||||
@staticmethod
|
||||
def _driver_config(p: ConnectionProfile) -> dict:
|
||||
return dict(
|
||||
host=p.host, port=p.port, database=p.database,
|
||||
user=p.username, password=p.password,
|
||||
connection_timeout=p.connection_timeout,
|
||||
)
|
||||
|
||||
def _populate(self, p: ConnectionProfile):
|
||||
self._name.setText(p.name)
|
||||
self._color_btn.color = p.color
|
||||
# Set db type combo
|
||||
for i, (_, db_type, _) in enumerate(self.DB_TYPES):
|
||||
if db_type == p.db_type:
|
||||
self._db_type.setCurrentIndex(i)
|
||||
break
|
||||
self._host.setText(p.host)
|
||||
self._port.setValue(p.port)
|
||||
self._database.setText(p.database)
|
||||
self._username.setText(p.username)
|
||||
self._password.setText(p.password)
|
||||
self._timeout.setValue(p.connection_timeout)
|
||||
self._ssl.setChecked(p.ssl)
|
||||
self._ssl_ca.setText(p.ssl_ca)
|
||||
self._ssl_cert.setText(p.ssl_cert)
|
||||
self._ssl_key.setText(p.ssl_key)
|
||||
self._on_type_changed(self._db_type.currentIndex())
|
||||
|
||||
# ── Result ────────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def profile(self) -> ConnectionProfile:
|
||||
return self._profile
|
||||
@@ -0,0 +1,497 @@
|
||||
"""
|
||||
Database Dump Export dialog.
|
||||
|
||||
Supports three dump modes:
|
||||
• Schema only — CREATE TABLE / CREATE VIEW / CREATE INDEX DDL
|
||||
• Data only — INSERT INTO … VALUES (…) for every row
|
||||
• Schema + Data — both of the above, in dependency order
|
||||
|
||||
Output is a single UTF-8 .sql file that can be re-run on the same
|
||||
(or a compatible) DB engine.
|
||||
|
||||
Uses a background QThread so the UI stays responsive for large databases.
|
||||
Progress is reported per-table via Qt signals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import os
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QLabel, QComboBox, QPushButton, QListWidget,
|
||||
QListWidgetItem, QCheckBox, QFileDialog, QLineEdit,
|
||||
QProgressBar, QDialogButtonBox, QMessageBox,
|
||||
QGroupBox, QRadioButton, QButtonGroup, QAbstractItemView,
|
||||
QSplitter, QWidget, QPlainTextEdit,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
|
||||
# ── Background dump worker ────────────────────────────────────────────────────
|
||||
|
||||
class _DumpWorker(QThread):
|
||||
"""Generates the dump SQL in a background thread.
|
||||
|
||||
Signals
|
||||
-------
|
||||
progress(current, total, table_name) — emitted after each table
|
||||
finished(sql_text) — full SQL as a string
|
||||
error(message) — something went wrong
|
||||
"""
|
||||
|
||||
progress = pyqtSignal(int, int, str) # (done, total, current_table)
|
||||
finished = pyqtSignal(str)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, database: str, tables: list[str],
|
||||
mode: str, batch_size: int = 500, parent=None):
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
driver : BaseDriver
|
||||
database : str
|
||||
tables : list of table names to dump
|
||||
mode : 'schema' | 'data' | 'both'
|
||||
batch_size : rows per INSERT batch (multi-row VALUES)
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._tables = tables
|
||||
self._mode = mode
|
||||
self._batch_size = batch_size
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _escape(val) -> str:
|
||||
"""Very minimal SQL string escaping for dump output."""
|
||||
if val is None:
|
||||
return "NULL"
|
||||
s = str(val)
|
||||
s = s.replace("\\", "\\\\").replace("'", "\\'")
|
||||
return f"'{s}'"
|
||||
|
||||
def _dump_schema(self, table: str) -> str:
|
||||
try:
|
||||
ddl = self._driver.get_table_ddl(self._database, table)
|
||||
return f"{ddl.rstrip(';')};\n"
|
||||
except Exception as e:
|
||||
return f"-- ERROR fetching DDL for {table}: {e}\n"
|
||||
|
||||
def _dump_data(self, table: str) -> str:
|
||||
lines: list[str] = []
|
||||
offset = 0
|
||||
limit = 1000
|
||||
|
||||
# Fetch first page to get column names
|
||||
try:
|
||||
cols, rows, total = self._driver.get_table_data(
|
||||
self._database, table, limit=limit, offset=offset
|
||||
)
|
||||
except Exception as e:
|
||||
return f"-- ERROR fetching data for {table}: {e}\n"
|
||||
|
||||
if not rows:
|
||||
lines.append(f"-- (no rows in {table})\n")
|
||||
return "".join(lines)
|
||||
|
||||
col_list = ", ".join(f"`{c}`" if not c.startswith("`") else c
|
||||
for c in cols)
|
||||
lines.append(f"-- Data for table `{table}` ({total} rows)\n")
|
||||
lines.append(f"LOCK TABLES `{table}` WRITE;\n")
|
||||
|
||||
def flush_batch(batch):
|
||||
value_groups = []
|
||||
for row in batch:
|
||||
vals = ", ".join(self._escape(v) for v in row)
|
||||
value_groups.append(f" ({vals})")
|
||||
lines.append(
|
||||
f"INSERT INTO `{table}` ({col_list}) VALUES\n"
|
||||
+ ",\n".join(value_groups) + ";\n"
|
||||
)
|
||||
|
||||
batch: list = list(rows)
|
||||
|
||||
while True:
|
||||
# flush when batch is full
|
||||
if len(batch) >= self._batch_size:
|
||||
flush_batch(batch[: self._batch_size])
|
||||
batch = batch[self._batch_size :]
|
||||
|
||||
offset += limit
|
||||
if offset >= total:
|
||||
break
|
||||
try:
|
||||
_, rows, _ = self._driver.get_table_data(
|
||||
self._database, table, limit=limit, offset=offset
|
||||
)
|
||||
batch.extend(rows)
|
||||
except Exception as e:
|
||||
lines.append(f"-- ERROR reading {table} at offset {offset}: {e}\n")
|
||||
break
|
||||
|
||||
if batch:
|
||||
flush_batch(batch)
|
||||
|
||||
lines.append(f"UNLOCK TABLES;\n")
|
||||
return "".join(lines)
|
||||
|
||||
# ── main run ──────────────────────────────────────────────────────────────
|
||||
|
||||
def run(self):
|
||||
parts: list[str] = []
|
||||
db_type = getattr(self._driver, "db_type", "unknown")
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
parts.append(
|
||||
f"-- DBClient Dump\n"
|
||||
f"-- Database: {self._database}\n"
|
||||
f"-- DB Type : {db_type}\n"
|
||||
f"-- Generated: {now}\n"
|
||||
f"-- Mode: {self._mode}\n"
|
||||
f"-- --------------------------------------------------------\n\n"
|
||||
)
|
||||
|
||||
total = len(self._tables)
|
||||
try:
|
||||
for i, table in enumerate(self._tables):
|
||||
self.progress.emit(i, total, table)
|
||||
|
||||
if self._mode in ("schema", "both"):
|
||||
parts.append(f"\n-- Table structure: `{table}`\n")
|
||||
parts.append(f"DROP TABLE IF EXISTS `{table}`;\n")
|
||||
parts.append(self._dump_schema(table))
|
||||
|
||||
if self._mode in ("data", "both"):
|
||||
parts.append("\n")
|
||||
parts.append(self._dump_data(table))
|
||||
|
||||
parts.append("\n")
|
||||
|
||||
self.progress.emit(total, total, "Done")
|
||||
self.finished.emit("".join(parts))
|
||||
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
# ── Dialog ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class DumpDialog(QDialog):
|
||||
"""
|
||||
Configure and run a database dump.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
driver : BaseDriver — must already be connected
|
||||
database : str — default database (can be changed in UI)
|
||||
parent : QWidget
|
||||
"""
|
||||
|
||||
def __init__(self, driver, database: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._worker: _DumpWorker | None = None
|
||||
self._sql_output = ""
|
||||
|
||||
self.setWindowTitle("Export Database Dump")
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(700, 560)
|
||||
self._build_ui()
|
||||
# Defer DB load until the dialog's event loop is running so that
|
||||
# any reconnection the driver performs doesn't block __init__.
|
||||
from PyQt6.QtCore import QTimer
|
||||
QTimer.singleShot(0, self._populate_databases)
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(10)
|
||||
|
||||
# ── Top form: database + output file ─────────────────────────────────
|
||||
form = QFormLayout()
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
form.setSpacing(8)
|
||||
|
||||
# Database selector
|
||||
self._db_combo = QComboBox()
|
||||
self._db_combo.setMinimumWidth(220)
|
||||
self._db_combo.currentTextChanged.connect(self._on_db_changed)
|
||||
form.addRow("Database:", self._db_combo)
|
||||
|
||||
# Output file
|
||||
file_row = QHBoxLayout()
|
||||
self._out_path = QLineEdit()
|
||||
self._out_path.setPlaceholderText("Select output .sql file…")
|
||||
self._out_path.setReadOnly(True)
|
||||
browse_btn = QPushButton("Browse…")
|
||||
browse_btn.setFixedWidth(80)
|
||||
browse_btn.clicked.connect(self._browse_output)
|
||||
file_row.addWidget(self._out_path)
|
||||
file_row.addWidget(browse_btn)
|
||||
form.addRow("Output file:", file_row)
|
||||
|
||||
root.addLayout(form)
|
||||
|
||||
# ── Mode selection ────────────────────────────────────────────────────
|
||||
mode_box = QGroupBox("Dump mode")
|
||||
mode_lay = QHBoxLayout(mode_box)
|
||||
self._mode_group = QButtonGroup(self)
|
||||
self._rb_both = QRadioButton("Schema + Data")
|
||||
self._rb_schema = QRadioButton("Schema only")
|
||||
self._rb_data = QRadioButton("Data only")
|
||||
self._rb_both.setChecked(True)
|
||||
for rb in (self._rb_both, self._rb_schema, self._rb_data):
|
||||
self._mode_group.addButton(rb)
|
||||
mode_lay.addWidget(rb)
|
||||
mode_lay.addStretch()
|
||||
root.addWidget(mode_box)
|
||||
|
||||
# ── Table selector ────────────────────────────────────────────────────
|
||||
tbl_box = QGroupBox("Tables to include")
|
||||
tbl_lay = QVBoxLayout(tbl_box)
|
||||
|
||||
sel_row = QHBoxLayout()
|
||||
sel_all = QPushButton("Select All")
|
||||
sel_all.setFixedWidth(90)
|
||||
sel_all.clicked.connect(self._select_all)
|
||||
sel_none = QPushButton("Select None")
|
||||
sel_none.setFixedWidth(90)
|
||||
sel_none.clicked.connect(self._select_none)
|
||||
self._tbl_count_lbl = QLabel("0 tables")
|
||||
sel_row.addWidget(sel_all)
|
||||
sel_row.addWidget(sel_none)
|
||||
sel_row.addStretch()
|
||||
sel_row.addWidget(self._tbl_count_lbl)
|
||||
tbl_lay.addLayout(sel_row)
|
||||
|
||||
self._tbl_list = QListWidget()
|
||||
self._tbl_list.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.NoSelection)
|
||||
self._tbl_list.setAlternatingRowColors(True)
|
||||
self._tbl_list.itemChanged.connect(self._update_table_count)
|
||||
tbl_lay.addWidget(self._tbl_list)
|
||||
root.addWidget(tbl_box, 1)
|
||||
|
||||
# ── Options ───────────────────────────────────────────────────────────
|
||||
opts_row = QHBoxLayout()
|
||||
self._drop_cb = QCheckBox("Add DROP TABLE IF EXISTS before each CREATE")
|
||||
self._drop_cb.setChecked(True)
|
||||
opts_row.addWidget(self._drop_cb)
|
||||
opts_row.addStretch()
|
||||
root.addLayout(opts_row)
|
||||
|
||||
# ── Progress ──────────────────────────────────────────────────────────
|
||||
self._progress_lbl = QLabel("")
|
||||
root.addWidget(self._progress_lbl)
|
||||
|
||||
self._progress = QProgressBar()
|
||||
self._progress.setVisible(False)
|
||||
root.addWidget(self._progress)
|
||||
|
||||
# ── Buttons ───────────────────────────────────────────────────────────
|
||||
self._bbox = QDialogButtonBox()
|
||||
self._dump_btn = self._bbox.addButton(
|
||||
"Export Dump", QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
self._close_btn = self._bbox.addButton(
|
||||
QDialogButtonBox.StandardButton.Close)
|
||||
self._dump_btn.setEnabled(False)
|
||||
self._dump_btn.clicked.connect(self._start_dump)
|
||||
self._close_btn.clicked.connect(self.reject)
|
||||
root.addWidget(self._bbox)
|
||||
|
||||
# ── Error helper ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _readable_error(e: Exception) -> str:
|
||||
"""Unwrap pymysql / psycopg2 exception tuples into plain English."""
|
||||
args = getattr(e, "args", ())
|
||||
# pymysql: args = (error_code: int, message: str)
|
||||
if args and isinstance(args[0], int):
|
||||
code = args[0]
|
||||
msg = str(args[1]) if len(args) > 1 else ""
|
||||
if code == 0 and not msg:
|
||||
return ("Lost connection to the database server.\n"
|
||||
"The server may have closed an idle connection.\n"
|
||||
"Try reconnecting via the sidebar.")
|
||||
if msg:
|
||||
return f"Database error {code}: {msg}"
|
||||
return f"Database error code {code}"
|
||||
return str(e)
|
||||
|
||||
# ── Database population ───────────────────────────────────────────────────
|
||||
|
||||
def _populate_databases(self):
|
||||
self._progress_lbl.setText("Loading databases…")
|
||||
try:
|
||||
dbs = self._driver.get_databases()
|
||||
if not dbs:
|
||||
self._progress_lbl.setText(
|
||||
"No databases found — check your connection permissions."
|
||||
)
|
||||
return
|
||||
self._db_combo.blockSignals(True)
|
||||
self._db_combo.clear()
|
||||
for db in dbs:
|
||||
self._db_combo.addItem(db)
|
||||
# Pre-select the passed-in database
|
||||
if self._database and self._database in dbs:
|
||||
self._db_combo.setCurrentText(self._database)
|
||||
self._db_combo.blockSignals(False)
|
||||
self._progress_lbl.setText("")
|
||||
# Trigger table load for current selection
|
||||
self._on_db_changed(self._db_combo.currentText())
|
||||
except Exception as e:
|
||||
human = self._readable_error(e)
|
||||
self._progress_lbl.setText(f"⚠ Could not load databases.")
|
||||
QMessageBox.critical(
|
||||
self, "Connection Error",
|
||||
f"Could not load the database list:\n\n{human}"
|
||||
)
|
||||
|
||||
def _on_db_changed(self, db_name: str):
|
||||
self._database = db_name
|
||||
self._tbl_list.clear()
|
||||
if not db_name:
|
||||
return
|
||||
try:
|
||||
table_infos = self._driver.get_tables(db_name)
|
||||
for ti in table_infos:
|
||||
item = QListWidgetItem(ti.name)
|
||||
item.setFlags(
|
||||
item.flags() | Qt.ItemFlag.ItemIsUserCheckable
|
||||
)
|
||||
item.setCheckState(Qt.CheckState.Checked)
|
||||
self._tbl_list.addItem(item)
|
||||
self._update_table_count()
|
||||
except Exception as e:
|
||||
self._progress_lbl.setText(
|
||||
f"Error loading tables: {self._readable_error(e)}"
|
||||
)
|
||||
self._refresh_dump_btn()
|
||||
|
||||
# ── Table selection helpers ───────────────────────────────────────────────
|
||||
|
||||
def _select_all(self):
|
||||
for i in range(self._tbl_list.count()):
|
||||
self._tbl_list.item(i).setCheckState(Qt.CheckState.Checked)
|
||||
|
||||
def _select_none(self):
|
||||
for i in range(self._tbl_list.count()):
|
||||
self._tbl_list.item(i).setCheckState(Qt.CheckState.Unchecked)
|
||||
|
||||
def _checked_tables(self) -> list[str]:
|
||||
result = []
|
||||
for i in range(self._tbl_list.count()):
|
||||
item = self._tbl_list.item(i)
|
||||
if item.checkState() == Qt.CheckState.Checked:
|
||||
result.append(item.text())
|
||||
return result
|
||||
|
||||
def _update_table_count(self):
|
||||
checked = len(self._checked_tables())
|
||||
total = self._tbl_list.count()
|
||||
self._tbl_count_lbl.setText(f"{checked} / {total} selected")
|
||||
self._refresh_dump_btn()
|
||||
|
||||
# ── File output ───────────────────────────────────────────────────────────
|
||||
|
||||
def _browse_output(self):
|
||||
default = f"{self._database or 'dump'}_{datetime.date.today()}.sql"
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save SQL Dump", default,
|
||||
"SQL Files (*.sql);;All Files (*)"
|
||||
)
|
||||
if path:
|
||||
self._out_path.setText(path)
|
||||
self._refresh_dump_btn()
|
||||
|
||||
def _refresh_dump_btn(self):
|
||||
ok = bool(
|
||||
self._out_path.text()
|
||||
and self._database
|
||||
and self._checked_tables()
|
||||
)
|
||||
self._dump_btn.setEnabled(ok)
|
||||
|
||||
# ── Dump mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_mode(self) -> str:
|
||||
if self._rb_schema.isChecked():
|
||||
return "schema"
|
||||
if self._rb_data.isChecked():
|
||||
return "data"
|
||||
return "both"
|
||||
|
||||
# ── Start dump ────────────────────────────────────────────────────────────
|
||||
|
||||
def _start_dump(self):
|
||||
tables = self._checked_tables()
|
||||
if not tables:
|
||||
QMessageBox.information(
|
||||
self, "No Tables", "Select at least one table to dump."
|
||||
)
|
||||
return
|
||||
|
||||
out_path = self._out_path.text().strip()
|
||||
if not out_path:
|
||||
return
|
||||
|
||||
self._dump_btn.setEnabled(False)
|
||||
self._progress.setMaximum(len(tables))
|
||||
self._progress.setValue(0)
|
||||
self._progress.setVisible(True)
|
||||
self._progress_lbl.setText("Starting dump…")
|
||||
|
||||
self._worker = _DumpWorker(
|
||||
self._driver, self._database, tables,
|
||||
mode=self._get_mode(),
|
||||
parent=self,
|
||||
)
|
||||
self._worker.progress.connect(self._on_progress)
|
||||
self._worker.finished.connect(
|
||||
lambda sql: self._on_finished(sql, out_path)
|
||||
)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_progress(self, done: int, total: int, table: str):
|
||||
self._progress.setValue(done)
|
||||
if table != "Done":
|
||||
self._progress_lbl.setText(f"Dumping table {done + 1}/{total}: {table}")
|
||||
|
||||
def _on_finished(self, sql: str, out_path: str):
|
||||
try:
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(sql)
|
||||
size_kb = os.path.getsize(out_path) / 1024
|
||||
self._progress.setValue(self._progress.maximum())
|
||||
self._progress_lbl.setText(
|
||||
f"✅ Dump complete — {size_kb:.1f} KB written to {os.path.basename(out_path)}"
|
||||
)
|
||||
QMessageBox.information(
|
||||
self, "Dump Complete",
|
||||
f"Database dump saved successfully.\n\n"
|
||||
f"File: {out_path}\n"
|
||||
f"Size: {size_kb:.1f} KB\n"
|
||||
f"Tables: {self._progress.maximum()}"
|
||||
)
|
||||
except OSError as e:
|
||||
QMessageBox.critical(
|
||||
self, "Write Error", f"Could not write file:\n{e}"
|
||||
)
|
||||
finally:
|
||||
self._dump_btn.setEnabled(True)
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
self._progress.setVisible(False)
|
||||
self._dump_btn.setEnabled(True)
|
||||
self._progress_lbl.setText(f"❌ Error: {msg[:120]}")
|
||||
QMessageBox.critical(
|
||||
self, "Dump Error", f"Dump failed:\n\n{msg}"
|
||||
)
|
||||
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
EXPLAIN Plan Viewer
|
||||
===================
|
||||
Shows a database query's execution plan in two panes:
|
||||
|
||||
Left — Visual tree of nodes (parsed from each DB engine's EXPLAIN output).
|
||||
Right — Raw EXPLAIN results table (columns + rows from the driver).
|
||||
|
||||
Supports:
|
||||
• MySQL / MariaDB : tabular EXPLAIN rows (id, select_type, table, type, …)
|
||||
• PostgreSQL : EXPLAIN ANALYZE text (indented plan lines)
|
||||
• SQLite : EXPLAIN QUERY PLAN (id, parent, notused, detail)
|
||||
• MSSQL : raw rows (minimal support)
|
||||
|
||||
Open from EditorTab via the "🔎 Explain" toolbar button.
|
||||
The ExplainPanel is embeddable as a workspace tab.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
||||
QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem,
|
||||
QLabel, QPushButton, QHeaderView, QPlainTextEdit, QStackedWidget,
|
||||
QAbstractItemView,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QColor, QFont, QBrush
|
||||
|
||||
from app.ui.syntax_highlighter import SQLHighlighter
|
||||
|
||||
|
||||
# ── Cost-metric colour helpers ────────────────────────────────────────────────
|
||||
|
||||
def _cost_color(val_str: str) -> QColor:
|
||||
"""Return a colour (green→yellow→red) based on a cost value string."""
|
||||
try:
|
||||
v = float(str(val_str).replace(",", ""))
|
||||
except (ValueError, TypeError):
|
||||
return QColor("#cdd6f4") # neutral (Catppuccin text)
|
||||
if v <= 10:
|
||||
return QColor("#a6e3a1") # green — cheap
|
||||
if v <= 1_000:
|
||||
return QColor("#f9e2af") # yellow — moderate
|
||||
return QColor("#f38ba8") # red — expensive
|
||||
|
||||
|
||||
# ── Background worker ─────────────────────────────────────────────────────────
|
||||
|
||||
class _ExplainWorker(QThread):
|
||||
finished = pyqtSignal(list, list) # (cols, rows)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, sql: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._sql = sql
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
cols, rows = self._driver.explain_query(self._sql)
|
||||
self.finished.emit(list(cols), [tuple(r) for r in rows])
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
# ── Tree builders per DB engine ───────────────────────────────────────────────
|
||||
|
||||
def _build_mysql_tree(tree: QTreeWidget, cols: list, rows: list):
|
||||
"""
|
||||
MySQL EXPLAIN columns:
|
||||
id | select_type | table | partitions | type | possible_keys |
|
||||
key | key_len | ref | rows | filtered | Extra
|
||||
"""
|
||||
tree.setColumnCount(5)
|
||||
tree.setHeaderLabels(["Table / Step", "Type", "Key", "Rows est.", "Extra"])
|
||||
tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
tree.header().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
||||
|
||||
col = {name.lower(): i for i, name in enumerate(cols)}
|
||||
prev_items: dict[int, QTreeWidgetItem] = {}
|
||||
|
||||
for row in rows:
|
||||
def g(name: str) -> str:
|
||||
i = col.get(name)
|
||||
return "" if i is None else (str(row[i]) if row[i] is not None else "NULL")
|
||||
|
||||
step_id = g("id") or "?"
|
||||
sel_type = g("select_type")
|
||||
table = g("table")
|
||||
join_type = g("type")
|
||||
key = g("key")
|
||||
est_rows = g("rows")
|
||||
extra = g("extra")
|
||||
filtered = g("filtered")
|
||||
|
||||
label = f"[{step_id}] {table}" if table else f"[{step_id}] {sel_type}"
|
||||
item = QTreeWidgetItem([label, join_type, key, est_rows, extra])
|
||||
|
||||
# Colour the join-type column
|
||||
bad_types = {"all", "index", "range"}
|
||||
good_types = {"const", "eq_ref", "ref", "system"}
|
||||
jt_lower = join_type.lower()
|
||||
if jt_lower in bad_types:
|
||||
item.setForeground(1, QBrush(QColor("#f38ba8")))
|
||||
elif jt_lower in good_types:
|
||||
item.setForeground(1, QBrush(QColor("#a6e3a1")))
|
||||
|
||||
# Colour rows estimate
|
||||
item.setForeground(3, QBrush(_cost_color(est_rows)))
|
||||
|
||||
# Nest: use select_id as parent key (simplified: flat for now)
|
||||
try:
|
||||
sid = int(step_id)
|
||||
except ValueError:
|
||||
sid = 0
|
||||
parent_item = prev_items.get(sid - 1)
|
||||
if parent_item:
|
||||
parent_item.addChild(item)
|
||||
else:
|
||||
tree.addTopLevelItem(item)
|
||||
prev_items[sid] = item
|
||||
|
||||
tree.expandAll()
|
||||
|
||||
|
||||
def _build_postgres_tree(tree: QTreeWidget, cols: list, rows: list):
|
||||
"""
|
||||
PostgreSQL EXPLAIN ANALYZE returns text lines in a single 'Plan' column.
|
||||
Parse indentation to build the tree.
|
||||
"""
|
||||
tree.setColumnCount(2)
|
||||
tree.setHeaderLabels(["Plan Node", "Cost / Hint"])
|
||||
tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
|
||||
# rows is list of 1-tuples of text lines
|
||||
lines = [row[0] for row in rows if row]
|
||||
|
||||
stack: list[tuple[int, QTreeWidgetItem]] = [] # (indent, item)
|
||||
|
||||
for line in lines:
|
||||
if isinstance(line, (list, tuple)):
|
||||
line = str(line[0]) if line else ""
|
||||
text = str(line)
|
||||
stripped = text.lstrip("-> ").lstrip()
|
||||
indent = len(text) - len(text.lstrip())
|
||||
|
||||
# Split "Node (cost=x..y rows=z ...)"
|
||||
hint = ""
|
||||
if "(cost=" in stripped or "(actual" in stripped:
|
||||
split_at = stripped.find("(")
|
||||
hint = stripped[split_at:]
|
||||
stripped = stripped[:split_at].strip()
|
||||
|
||||
item = QTreeWidgetItem([stripped.strip("->").strip(), hint])
|
||||
|
||||
# Colour based on cost if available
|
||||
if "cost=" in hint:
|
||||
try:
|
||||
cost_str = hint.split("cost=")[1].split("..")[1].split()[0].rstrip(")")
|
||||
item.setForeground(1, QBrush(_cost_color(cost_str)))
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
# Find parent by indent level
|
||||
while stack and stack[-1][0] >= indent:
|
||||
stack.pop()
|
||||
|
||||
if stack:
|
||||
stack[-1][1].addChild(item)
|
||||
else:
|
||||
tree.addTopLevelItem(item)
|
||||
|
||||
stack.append((indent, item))
|
||||
|
||||
tree.expandAll()
|
||||
|
||||
|
||||
def _build_sqlite_tree(tree: QTreeWidget, cols: list, rows: list):
|
||||
"""
|
||||
SQLite EXPLAIN QUERY PLAN columns: id, parent, notused, detail
|
||||
(older SQLite: selectid, order, from, detail)
|
||||
"""
|
||||
tree.setColumnCount(2)
|
||||
tree.setHeaderLabels(["Step", "Detail"])
|
||||
tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
|
||||
col = {name.lower(): i for i, name in enumerate(cols)}
|
||||
|
||||
# Try modern format first (id, parent, notused, detail)
|
||||
id_col = col.get("id", col.get("selectid", 0))
|
||||
parent_col = col.get("parent", col.get("order", 1))
|
||||
detail_col = col.get("detail", col.get("from", 3))
|
||||
|
||||
items: dict = {} # id → QTreeWidgetItem
|
||||
|
||||
for row in rows:
|
||||
rid = row[id_col] if id_col < len(row) else 0
|
||||
parent = row[parent_col] if parent_col < len(row) else 0
|
||||
detail = row[detail_col] if detail_col < len(row) else str(row)
|
||||
|
||||
item = QTreeWidgetItem([str(rid), str(detail)])
|
||||
# Colour SCAN (bad) vs SEARCH/INDEX (good)
|
||||
detail_str = str(detail).upper()
|
||||
if "SCAN" in detail_str and "INDEX" not in detail_str:
|
||||
item.setForeground(1, QBrush(QColor("#f38ba8")))
|
||||
elif "INDEX" in detail_str or "SEARCH" in detail_str:
|
||||
item.setForeground(1, QBrush(QColor("#a6e3a1")))
|
||||
|
||||
items[rid] = item
|
||||
parent_item = items.get(parent)
|
||||
if parent_item and parent != rid:
|
||||
parent_item.addChild(item)
|
||||
else:
|
||||
tree.addTopLevelItem(item)
|
||||
|
||||
tree.expandAll()
|
||||
|
||||
|
||||
def _build_generic_tree(tree: QTreeWidget, cols: list, rows: list):
|
||||
"""Fallback: show rows flat."""
|
||||
tree.setColumnCount(len(cols))
|
||||
tree.setHeaderLabels(cols)
|
||||
for row in rows:
|
||||
item = QTreeWidgetItem([str(v) if v is not None else "NULL" for v in row])
|
||||
tree.addTopLevelItem(item)
|
||||
tree.expandAll()
|
||||
|
||||
|
||||
# ── Raw table helper ──────────────────────────────────────────────────────────
|
||||
|
||||
def _populate_raw_table(table: QTableWidget, cols: list, rows: list):
|
||||
table.setColumnCount(len(cols))
|
||||
table.setHorizontalHeaderLabels(cols)
|
||||
table.setRowCount(len(rows))
|
||||
for r, row in enumerate(rows):
|
||||
for c, val in enumerate(row):
|
||||
item = QTableWidgetItem(
|
||||
"NULL" if val is None else str(val)
|
||||
)
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
table.setItem(r, c, item)
|
||||
table.resizeColumnsToContents()
|
||||
table.horizontalHeader().setStretchLastSection(True)
|
||||
|
||||
|
||||
# ── Main panel ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ExplainPanel(QWidget):
|
||||
"""
|
||||
Embeddable widget that runs EXPLAIN on a SQL query and displays
|
||||
a visual tree (left) + raw tabular output (right).
|
||||
"""
|
||||
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, sql: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._sql = sql
|
||||
self._worker: _ExplainWorker | None = None
|
||||
self._build_ui()
|
||||
self._run_explain()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# ── Header bar ────────────────────────────────────────────────────────
|
||||
hdr = QHBoxLayout()
|
||||
hdr.setContentsMargins(8, 6, 8, 4)
|
||||
hdr.setSpacing(8)
|
||||
|
||||
title = QLabel("🔎 Query Execution Plan")
|
||||
f = title.font()
|
||||
f.setBold(True)
|
||||
f.setPointSize(11)
|
||||
title.setFont(f)
|
||||
title.setObjectName("structureTitle")
|
||||
|
||||
self._refresh_btn = QPushButton("🔄 Re-run")
|
||||
self._refresh_btn.setFixedWidth(80)
|
||||
self._refresh_btn.clicked.connect(self._run_explain)
|
||||
|
||||
self._status_lbl = QLabel("Running…")
|
||||
self._status_lbl.setObjectName("rowCountLbl")
|
||||
|
||||
hdr.addWidget(title)
|
||||
hdr.addStretch()
|
||||
hdr.addWidget(self._status_lbl)
|
||||
hdr.addWidget(self._refresh_btn)
|
||||
root.addLayout(hdr)
|
||||
|
||||
# ── SQL preview (collapsed) ───────────────────────────────────────────
|
||||
sql_lbl = QLabel("SQL:")
|
||||
sql_lbl.setContentsMargins(8, 0, 8, 0)
|
||||
self._sql_preview = QPlainTextEdit(self._sql)
|
||||
self._sql_preview.setReadOnly(True)
|
||||
self._sql_preview.setMaximumHeight(52)
|
||||
mono = QFont("Consolas", 10)
|
||||
self._sql_preview.setFont(mono)
|
||||
SQLHighlighter(self._sql_preview.document())
|
||||
root.addWidget(sql_lbl)
|
||||
root.addWidget(self._sql_preview)
|
||||
|
||||
# ── Main splitter: tree / raw ─────────────────────────────────────────
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
# Left — visual plan tree
|
||||
left = QWidget()
|
||||
ll = QVBoxLayout(left)
|
||||
ll.setContentsMargins(0, 0, 0, 0)
|
||||
ll.setSpacing(0)
|
||||
|
||||
tree_hdr = QLabel(" Visual Plan")
|
||||
tree_hdr.setObjectName("sidebarTitle")
|
||||
tree_hdr.setContentsMargins(8, 4, 0, 4)
|
||||
f2 = tree_hdr.font()
|
||||
f2.setBold(True)
|
||||
tree_hdr.setFont(f2)
|
||||
ll.addWidget(tree_hdr)
|
||||
|
||||
self._tree = QTreeWidget()
|
||||
self._tree.setAlternatingRowColors(True)
|
||||
self._tree.setAnimated(True)
|
||||
self._tree.setSelectionBehavior(
|
||||
QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
ll.addWidget(self._tree)
|
||||
|
||||
# Legend row
|
||||
legend_row = QHBoxLayout()
|
||||
legend_row.setContentsMargins(8, 2, 0, 4)
|
||||
for colour, label in [
|
||||
("#a6e3a1", "Efficient"), ("#f9e2af", "Moderate"),
|
||||
("#f38ba8", "Expensive / SCAN"),
|
||||
]:
|
||||
dot = QLabel("●")
|
||||
dot.setStyleSheet(f"color: {colour};")
|
||||
legend_row.addWidget(dot)
|
||||
legend_row.addWidget(QLabel(label))
|
||||
legend_row.addSpacing(12)
|
||||
legend_row.addStretch()
|
||||
ll.addLayout(legend_row)
|
||||
|
||||
splitter.addWidget(left)
|
||||
|
||||
# Right — raw results table
|
||||
right = QWidget()
|
||||
rl = QVBoxLayout(right)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
rl.setSpacing(0)
|
||||
|
||||
raw_hdr = QLabel(" Raw EXPLAIN Output")
|
||||
raw_hdr.setObjectName("sidebarTitle")
|
||||
raw_hdr.setContentsMargins(8, 4, 0, 4)
|
||||
f3 = raw_hdr.font()
|
||||
f3.setBold(True)
|
||||
raw_hdr.setFont(f3)
|
||||
rl.addWidget(raw_hdr)
|
||||
|
||||
self._raw_table = QTableWidget()
|
||||
self._raw_table.setAlternatingRowColors(True)
|
||||
self._raw_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self._raw_table.setSelectionBehavior(
|
||||
QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self._raw_table.verticalHeader().setDefaultSectionSize(24)
|
||||
rl.addWidget(self._raw_table)
|
||||
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([450, 450])
|
||||
|
||||
root.addWidget(splitter, 1)
|
||||
|
||||
# ── Explain execution ─────────────────────────────────────────────────────
|
||||
|
||||
def _run_explain(self):
|
||||
self._status_lbl.setText("Running EXPLAIN…")
|
||||
self._tree.clear()
|
||||
self._raw_table.setRowCount(0)
|
||||
self._refresh_btn.setEnabled(False)
|
||||
|
||||
self._worker = _ExplainWorker(self._driver, self._sql, parent=self)
|
||||
self._worker.finished.connect(self._on_result)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_result(self, cols: list, rows: list):
|
||||
self._refresh_btn.setEnabled(True)
|
||||
|
||||
# ── Populate raw table ────────────────────────────────────────────────
|
||||
_populate_raw_table(self._raw_table, cols, rows)
|
||||
|
||||
# ── Build visual tree based on DB type ────────────────────────────────
|
||||
db_type = getattr(self._driver, "db_type", "").lower()
|
||||
|
||||
if db_type in ("mysql", "mariadb"):
|
||||
_build_mysql_tree(self._tree, cols, rows)
|
||||
elif db_type == "postgresql":
|
||||
_build_postgres_tree(self._tree, cols, rows)
|
||||
elif db_type == "sqlite":
|
||||
_build_sqlite_tree(self._tree, cols, rows)
|
||||
else:
|
||||
_build_generic_tree(self._tree, cols, rows)
|
||||
|
||||
n = len(rows)
|
||||
self._status_lbl.setText(f"{n} plan node{'s' if n != 1 else ''}")
|
||||
self.status_message.emit(f"EXPLAIN complete — {n} nodes")
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
self._refresh_btn.setEnabled(True)
|
||||
self._status_lbl.setText(f"Error: {msg[:80]}")
|
||||
self._tree.clear()
|
||||
err_item = QTreeWidgetItem([f"⚠ {msg}"])
|
||||
err_item.setForeground(0, QBrush(QColor("#f38ba8")))
|
||||
self._tree.setColumnCount(1)
|
||||
self._tree.setHeaderLabels(["Error"])
|
||||
self._tree.addTopLevelItem(err_item)
|
||||
self.status_message.emit(f"EXPLAIN error: {msg}")
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Import CSV / JSON into a database table.
|
||||
|
||||
Flow:
|
||||
1. User picks a file (.csv or .json)
|
||||
2. A preview of the first N rows is shown
|
||||
3. User confirms → rows are inserted via the driver's insert_row()
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QLabel, QLineEdit, QPushButton, QComboBox,
|
||||
QTableWidget, QTableWidgetItem, QHeaderView,
|
||||
QDialogButtonBox, QFileDialog, QProgressBar,
|
||||
QMessageBox, QCheckBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
|
||||
_PREVIEW_ROWS = 50
|
||||
|
||||
|
||||
# ── Background import worker ──────────────────────────────────────────────────
|
||||
|
||||
class _ImportWorker(QThread):
|
||||
progress = pyqtSignal(int) # rows inserted so far
|
||||
finished = pyqtSignal(int) # total rows inserted
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, database: str, table: str,
|
||||
rows: list, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._rows = rows # list of dicts {col: value}
|
||||
|
||||
def run(self):
|
||||
inserted = 0
|
||||
try:
|
||||
for row in self._rows:
|
||||
self._driver.insert_row(self._database, self._table, row)
|
||||
inserted += 1
|
||||
if inserted % 50 == 0:
|
||||
self.progress.emit(inserted)
|
||||
self.finished.emit(inserted)
|
||||
except Exception as e:
|
||||
self.error.emit(f"Row {inserted + 1}: {e}")
|
||||
|
||||
|
||||
# ── Dialog ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ImportDialog(QDialog):
|
||||
"""Select a CSV or JSON file and import its contents into a table."""
|
||||
|
||||
def __init__(self, driver, database: str, table: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._rows: list = [] # parsed rows ready for import
|
||||
self._worker = None
|
||||
|
||||
self.setWindowTitle(f"Import into {table}")
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(640, 480)
|
||||
self._build_ui()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# File picker row
|
||||
file_row = QHBoxLayout()
|
||||
self._path_edit = QLineEdit()
|
||||
self._path_edit.setReadOnly(True)
|
||||
self._path_edit.setPlaceholderText("Select a .csv or .json file…")
|
||||
browse_btn = QPushButton("Browse…")
|
||||
browse_btn.setFixedWidth(80)
|
||||
browse_btn.clicked.connect(self._browse)
|
||||
file_row.addWidget(self._path_edit)
|
||||
file_row.addWidget(browse_btn)
|
||||
root.addLayout(file_row)
|
||||
|
||||
# CSV options (hidden until a CSV is selected)
|
||||
self._csv_opts = QHBoxLayout()
|
||||
self._csv_opts_widget = self._build_csv_opts()
|
||||
root.addWidget(self._csv_opts_widget)
|
||||
self._csv_opts_widget.setVisible(False)
|
||||
|
||||
# Skip-header checkbox
|
||||
self._header_cb = QCheckBox("First row is a header (CSV only)")
|
||||
self._header_cb.setChecked(True)
|
||||
self._header_cb.toggled.connect(self._reload_preview)
|
||||
root.addWidget(self._header_cb)
|
||||
|
||||
# Preview table
|
||||
root.addWidget(QLabel("Preview (first 50 rows):"))
|
||||
self._preview = QTableWidget(0, 0)
|
||||
self._preview.setAlternatingRowColors(True)
|
||||
self._preview.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self._preview.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
self._preview.verticalHeader().setDefaultSectionSize(22)
|
||||
self._preview.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||
root.addWidget(self._preview, 1)
|
||||
|
||||
# Status / progress
|
||||
self._status_lbl = QLabel("")
|
||||
root.addWidget(self._status_lbl)
|
||||
self._progress = QProgressBar()
|
||||
self._progress.setVisible(False)
|
||||
root.addWidget(self._progress)
|
||||
|
||||
# Buttons
|
||||
self._btns = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok |
|
||||
QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
self._ok_btn = self._btns.button(QDialogButtonBox.StandardButton.Ok)
|
||||
self._ok_btn.setText("Import")
|
||||
self._ok_btn.setEnabled(False)
|
||||
self._btns.accepted.connect(self._start_import)
|
||||
self._btns.rejected.connect(self.reject)
|
||||
root.addWidget(self._btns)
|
||||
|
||||
def _build_csv_opts(self) -> QHBoxLayout:
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
w = QWidget()
|
||||
lay = QHBoxLayout(w)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(QLabel("Delimiter:"))
|
||||
self._delim_combo = QComboBox()
|
||||
self._delim_combo.addItems([", (comma)", "; (semicolon)",
|
||||
"\\t (tab)", "| (pipe)"])
|
||||
self._delim_combo.setFixedWidth(140)
|
||||
self._delim_combo.currentIndexChanged.connect(self._reload_preview)
|
||||
lay.addWidget(self._delim_combo)
|
||||
lay.addStretch()
|
||||
return w
|
||||
|
||||
# ── File loading ──────────────────────────────────────────────────────────
|
||||
|
||||
def _browse(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Open file", "",
|
||||
"CSV / JSON files (*.csv *.json);;All files (*)"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
self._path_edit.setText(path)
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
self._csv_opts_widget.setVisible(ext == ".csv")
|
||||
self._header_cb.setVisible(ext == ".csv")
|
||||
self._reload_preview()
|
||||
|
||||
def _delimiter(self) -> str:
|
||||
mapping = {0: ",", 1: ";", 2: "\t", 3: "|"}
|
||||
return mapping.get(self._delim_combo.currentIndex(), ",")
|
||||
|
||||
def _reload_preview(self):
|
||||
path = self._path_edit.text()
|
||||
if not path:
|
||||
return
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
try:
|
||||
if ext == ".csv":
|
||||
self._rows = self._parse_csv(path)
|
||||
elif ext == ".json":
|
||||
self._rows = self._parse_json(path)
|
||||
else:
|
||||
self._status_lbl.setText("Unsupported file type.")
|
||||
return
|
||||
except Exception as e:
|
||||
self._status_lbl.setText(f"Parse error: {e}")
|
||||
self._rows = []
|
||||
self._ok_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
self._populate_preview(self._rows[:_PREVIEW_ROWS])
|
||||
self._status_lbl.setText(
|
||||
f"{len(self._rows)} row(s) ready to import into "
|
||||
f"'{self._database}'.'{self._table}'"
|
||||
)
|
||||
self._ok_btn.setEnabled(bool(self._rows))
|
||||
|
||||
def _parse_csv(self, path: str) -> list:
|
||||
rows = []
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f, delimiter=self._delimiter())
|
||||
all_rows = list(reader)
|
||||
if not all_rows:
|
||||
return []
|
||||
if self._header_cb.isChecked():
|
||||
headers = all_rows[0]
|
||||
data_rows = all_rows[1:]
|
||||
else:
|
||||
headers = [f"col{i+1}" for i in range(len(all_rows[0]))]
|
||||
data_rows = all_rows
|
||||
for row in data_rows:
|
||||
# Pad short rows, truncate long ones
|
||||
padded = (row + [""] * len(headers))[: len(headers)]
|
||||
rows.append(dict(zip(headers, padded)))
|
||||
return rows
|
||||
|
||||
def _parse_json(self, path: str) -> list:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
return [r for r in data if isinstance(r, dict)]
|
||||
if isinstance(data, dict):
|
||||
# Support {rows: [...]} or {data: [...]} wrappers
|
||||
for key in ("rows", "data", "records", "items"):
|
||||
if isinstance(data.get(key), list):
|
||||
return data[key]
|
||||
raise ValueError("JSON must be an array of objects or {rows: [...]}")
|
||||
|
||||
# ── Preview ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _populate_preview(self, rows: list):
|
||||
if not rows:
|
||||
self._preview.setRowCount(0)
|
||||
self._preview.setColumnCount(0)
|
||||
return
|
||||
headers = list(rows[0].keys())
|
||||
self._preview.setColumnCount(len(headers))
|
||||
self._preview.setHorizontalHeaderLabels(headers)
|
||||
self._preview.setRowCount(len(rows))
|
||||
for r, row in enumerate(rows):
|
||||
for c, key in enumerate(headers):
|
||||
val = row.get(key, "")
|
||||
self._preview.setItem(
|
||||
r, c, QTableWidgetItem("" if val is None else str(val))
|
||||
)
|
||||
|
||||
# ── Import ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _start_import(self):
|
||||
if not self._rows:
|
||||
return
|
||||
self._ok_btn.setEnabled(False)
|
||||
self._progress.setMaximum(len(self._rows))
|
||||
self._progress.setValue(0)
|
||||
self._progress.setVisible(True)
|
||||
|
||||
self._worker = _ImportWorker(
|
||||
self._driver, self._database, self._table, self._rows, parent=self
|
||||
)
|
||||
self._worker.progress.connect(self._progress.setValue)
|
||||
self._worker.finished.connect(self._on_done)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_done(self, count: int):
|
||||
self._progress.setValue(count)
|
||||
QMessageBox.information(
|
||||
self, "Import Complete",
|
||||
f"Successfully imported {count} row(s) into '{self._table}'."
|
||||
)
|
||||
self.accept()
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
self._progress.setVisible(False)
|
||||
self._ok_btn.setEnabled(True)
|
||||
QMessageBox.critical(self, "Import Error", f"Import failed:\n{msg}")
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
In-app log viewer.
|
||||
|
||||
Opens as a non-modal window (or a workspace tab) and shows the contents
|
||||
of ~/.dbclient/logs/dbclient.log with:
|
||||
• Level filter buttons (ALL / DEBUG / INFO / WARNING / ERROR / CRITICAL)
|
||||
• Auto-tail mode (follows the file like `tail -f`)
|
||||
• Colour coding per level
|
||||
• Search / highlight
|
||||
• "Open folder" button — opens the logs directory in Explorer/Finder
|
||||
• "Copy" and "Clear view" (does NOT delete the log file)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QPlainTextEdit,
|
||||
QLineEdit, QCheckBox, QButtonGroup, QAbstractButton,
|
||||
QFileDialog, QApplication, QSizePolicy,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import (
|
||||
QColor, QTextCharFormat, QFont,
|
||||
QSyntaxHighlighter, QTextDocument,
|
||||
QTextCursor,
|
||||
)
|
||||
|
||||
from app.utils.logger import get_log_path
|
||||
|
||||
# ── Level colours (Catppuccin Mocha palette) ──────────────────────────────────
|
||||
|
||||
_LEVEL_COLOURS: dict[str, str] = {
|
||||
"DEBUG": "#6c7086", # surface2 / dimmed
|
||||
"INFO": "#cdd6f4", # text (default)
|
||||
"WARNING": "#f9e2af", # yellow
|
||||
"ERROR": "#f38ba8", # red
|
||||
"CRITICAL": "#ff79c6", # pink / bright red
|
||||
}
|
||||
|
||||
_LEVEL_ORDER = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
_LEVEL_RANK = {lv: i for i, lv in enumerate(_LEVEL_ORDER)}
|
||||
|
||||
|
||||
# ── Syntax highlighter ────────────────────────────────────────────────────────
|
||||
|
||||
class _LogHighlighter(QSyntaxHighlighter):
|
||||
"""Colour-code log lines by severity level."""
|
||||
|
||||
def __init__(self, document: QTextDocument, min_level: str = "DEBUG"):
|
||||
super().__init__(document)
|
||||
self._min_rank = _LEVEL_RANK.get(min_level, 0)
|
||||
self._search: str = ""
|
||||
|
||||
def set_min_level(self, level: str):
|
||||
self._min_rank = _LEVEL_RANK.get(level, 0)
|
||||
self.rehighlight()
|
||||
|
||||
def set_search(self, text: str):
|
||||
self._search = text.lower()
|
||||
self.rehighlight()
|
||||
|
||||
def highlightBlock(self, text: str):
|
||||
# Detect level from the bracketed token e.g. [WARNING ]
|
||||
level_colour = _LEVEL_COLOURS["INFO"]
|
||||
matched_level = "INFO"
|
||||
for level in _LEVEL_ORDER:
|
||||
if f"[{level}" in text:
|
||||
matched_level = level
|
||||
level_colour = _LEVEL_COLOURS[level]
|
||||
break
|
||||
|
||||
# Apply base colour for the whole line
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setForeground(QColor(level_colour))
|
||||
if matched_level in ("ERROR", "CRITICAL"):
|
||||
# Bold for high-severity lines
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
fmt.setFont(f)
|
||||
self.setFormat(0, len(text), fmt)
|
||||
|
||||
# Highlight search matches in bright yellow
|
||||
if self._search:
|
||||
search_fmt = QTextCharFormat()
|
||||
search_fmt.setBackground(QColor("#f9e2af"))
|
||||
search_fmt.setForeground(QColor("#1e1e2e"))
|
||||
idx = text.lower().find(self._search)
|
||||
while idx != -1:
|
||||
self.setFormat(idx, len(self._search), search_fmt)
|
||||
idx = text.lower().find(self._search, idx + 1)
|
||||
|
||||
|
||||
# ── Level filter button ───────────────────────────────────────────────────────
|
||||
|
||||
class _LevelBtn(QPushButton):
|
||||
def __init__(self, level: str, colour: str, parent=None):
|
||||
label = "ALL" if level == "DEBUG" else level
|
||||
super().__init__(label, parent)
|
||||
self.setCheckable(True)
|
||||
self.setFixedHeight(26)
|
||||
self._colour = colour
|
||||
self._apply_style(False)
|
||||
|
||||
def _apply_style(self, checked: bool):
|
||||
if checked:
|
||||
self.setStyleSheet(
|
||||
f"QPushButton {{ background: {self._colour}; color: #1e1e2e; "
|
||||
f"border: none; border-radius: 3px; font-weight: bold; }}"
|
||||
)
|
||||
else:
|
||||
self.setStyleSheet(
|
||||
f"QPushButton {{ background: transparent; color: {self._colour}; "
|
||||
f"border: 1px solid {self._colour}; border-radius: 3px; }}"
|
||||
f"QPushButton:hover {{ background: {self._colour}22; }}"
|
||||
)
|
||||
|
||||
# Override to auto-apply style on toggle
|
||||
def setChecked(self, v: bool):
|
||||
super().setChecked(v)
|
||||
self._apply_style(v)
|
||||
|
||||
|
||||
# ── Main Log Viewer widget ────────────────────────────────────────────────────
|
||||
|
||||
class LogViewer(QWidget):
|
||||
"""
|
||||
Non-modal log viewer window.
|
||||
|
||||
Can be used stand-alone (as a top-level window) or embedded
|
||||
as a workspace tab — it's just a QWidget.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._log_path = get_log_path()
|
||||
self._file_pos = 0 # byte offset for tail mode
|
||||
self._min_level = "DEBUG" # currently selected filter
|
||||
self._auto_tail = True
|
||||
self._build_ui()
|
||||
self._load_full()
|
||||
|
||||
# Auto-tail timer (500 ms poll)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(500)
|
||||
self._timer.timeout.connect(self._tail)
|
||||
self._timer.start()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(6, 6, 6, 6)
|
||||
root.setSpacing(6)
|
||||
|
||||
# ── Top bar ───────────────────────────────────────────────────────────
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(6)
|
||||
|
||||
title = QLabel("📋 Application Log")
|
||||
f = title.font()
|
||||
f.setBold(True)
|
||||
f.setPointSize(11)
|
||||
title.setFont(f)
|
||||
title.setObjectName("structureTitle")
|
||||
top.addWidget(title)
|
||||
top.addStretch()
|
||||
|
||||
self._path_lbl = QLabel(str(self._log_path))
|
||||
self._path_lbl.setObjectName("rowCountLbl")
|
||||
self._path_lbl.setWordWrap(False)
|
||||
top.addWidget(self._path_lbl)
|
||||
|
||||
open_dir_btn = QPushButton("📁 Open Folder")
|
||||
open_dir_btn.setFixedHeight(26)
|
||||
open_dir_btn.clicked.connect(self._open_log_folder)
|
||||
top.addWidget(open_dir_btn)
|
||||
|
||||
root.addLayout(top)
|
||||
|
||||
# ── Toolbar row ───────────────────────────────────────────────────────
|
||||
tb = QHBoxLayout()
|
||||
tb.setSpacing(6)
|
||||
|
||||
# Level filter buttons
|
||||
tb.addWidget(QLabel("Filter:"))
|
||||
self._level_btns: dict[str, _LevelBtn] = {}
|
||||
self._btn_group = QButtonGroup(self)
|
||||
self._btn_group.setExclusive(True)
|
||||
|
||||
for level in _LEVEL_ORDER:
|
||||
colour = _LEVEL_COLOURS[level]
|
||||
btn = _LevelBtn(level, colour, self)
|
||||
btn.setFixedWidth(76 if level == "DEBUG" else 82)
|
||||
self._btn_group.addButton(btn)
|
||||
self._level_btns[level] = btn
|
||||
tb.addWidget(btn)
|
||||
btn.clicked.connect(lambda checked, lv=level: self._set_level(lv))
|
||||
|
||||
self._level_btns["DEBUG"].setChecked(True) # "ALL" starts checked
|
||||
|
||||
tb.addSpacing(10)
|
||||
|
||||
# Search
|
||||
tb.addWidget(QLabel("Search:"))
|
||||
self._search_box = QLineEdit()
|
||||
self._search_box.setPlaceholderText("Highlight text…")
|
||||
self._search_box.setFixedWidth(180)
|
||||
self._search_box.setFixedHeight(26)
|
||||
self._search_box.textChanged.connect(self._on_search)
|
||||
tb.addWidget(self._search_box)
|
||||
|
||||
tb.addStretch()
|
||||
|
||||
# Auto-tail toggle
|
||||
self._tail_cb = QCheckBox("Auto-scroll")
|
||||
self._tail_cb.setChecked(True)
|
||||
self._tail_cb.toggled.connect(self._on_tail_toggle)
|
||||
tb.addWidget(self._tail_cb)
|
||||
|
||||
# Action buttons
|
||||
copy_btn = QPushButton("📋 Copy All")
|
||||
copy_btn.setFixedHeight(26)
|
||||
copy_btn.clicked.connect(self._copy_all)
|
||||
tb.addWidget(copy_btn)
|
||||
|
||||
clear_btn = QPushButton("🗑 Clear View")
|
||||
clear_btn.setFixedHeight(26)
|
||||
clear_btn.setToolTip("Clears the viewer only — does not delete the log file")
|
||||
clear_btn.clicked.connect(self._clear_view)
|
||||
tb.addWidget(clear_btn)
|
||||
|
||||
refresh_btn = QPushButton("🔄 Refresh")
|
||||
refresh_btn.setFixedHeight(26)
|
||||
refresh_btn.clicked.connect(self._load_full)
|
||||
tb.addWidget(refresh_btn)
|
||||
|
||||
root.addLayout(tb)
|
||||
|
||||
# ── Log text area ─────────────────────────────────────────────────────
|
||||
self._text = QPlainTextEdit()
|
||||
self._text.setReadOnly(True)
|
||||
mono = QFont("Consolas", 10)
|
||||
mono.setFixedPitch(True)
|
||||
self._text.setFont(mono)
|
||||
self._text.setMaximumBlockCount(20_000) # cap at 20k lines in view
|
||||
root.addWidget(self._text, 1)
|
||||
|
||||
self._highlighter = _LogHighlighter(self._text.document())
|
||||
|
||||
# ── Status bar ────────────────────────────────────────────────────────
|
||||
self._status_lbl = QLabel("Ready")
|
||||
self._status_lbl.setObjectName("rowCountLbl")
|
||||
root.addWidget(self._status_lbl)
|
||||
|
||||
# ── Data loading ──────────────────────────────────────────────────────────
|
||||
|
||||
def _load_full(self):
|
||||
"""Read the entire log file and populate the viewer."""
|
||||
self._text.clear()
|
||||
if not self._log_path.exists():
|
||||
self._text.setPlainText("No log file found yet.\n"
|
||||
f"Expected location: {self._log_path}")
|
||||
self._file_pos = 0
|
||||
return
|
||||
try:
|
||||
with open(self._log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
self._file_pos = f.tell()
|
||||
|
||||
lines = self._filter_lines(content.splitlines())
|
||||
self._text.setPlainText("\n".join(lines))
|
||||
self._status_lbl.setText(
|
||||
f"{len(lines)} lines | {self._log_path}"
|
||||
)
|
||||
if self._auto_tail:
|
||||
self._scroll_to_bottom()
|
||||
except OSError as e:
|
||||
self._text.setPlainText(f"Cannot read log file:\n{e}")
|
||||
|
||||
def _tail(self):
|
||||
"""Append any new lines written since last poll."""
|
||||
if not self._log_path.exists():
|
||||
return
|
||||
try:
|
||||
size = self._log_path.stat().st_size
|
||||
if size < self._file_pos:
|
||||
# File was rotated / truncated — reload from scratch
|
||||
self._load_full()
|
||||
return
|
||||
if size == self._file_pos:
|
||||
return
|
||||
|
||||
with open(self._log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(self._file_pos)
|
||||
new_text = f.read()
|
||||
self._file_pos = f.tell()
|
||||
|
||||
if not new_text:
|
||||
return
|
||||
|
||||
lines = self._filter_lines(new_text.splitlines())
|
||||
if not lines:
|
||||
return
|
||||
|
||||
cursor = self._text.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
cursor.insertText(("\n" if self._text.toPlainText() else "") +
|
||||
"\n".join(lines))
|
||||
self._text.setTextCursor(cursor)
|
||||
|
||||
if self._auto_tail:
|
||||
self._scroll_to_bottom()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _filter_lines(self, lines: list[str]) -> list[str]:
|
||||
"""Keep only lines at or above self._min_level, plus continuation lines."""
|
||||
if self._min_level == "DEBUG":
|
||||
return lines
|
||||
min_rank = _LEVEL_RANK[self._min_level]
|
||||
kept = []
|
||||
include_next = False
|
||||
for line in lines:
|
||||
# Detect the level tag in the [LEVEL ] bracket
|
||||
matched = False
|
||||
for level in _LEVEL_ORDER:
|
||||
if f"[{level}" in line:
|
||||
include_next = (_LEVEL_RANK[level] >= min_rank)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
# Continuation line (traceback etc.) — follow parent's decision
|
||||
pass
|
||||
if include_next:
|
||||
kept.append(line)
|
||||
return kept
|
||||
|
||||
# ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _set_level(self, level: str):
|
||||
self._min_level = level
|
||||
self._highlighter.set_min_level(level)
|
||||
self._load_full()
|
||||
|
||||
def _on_search(self, text: str):
|
||||
self._highlighter.set_search(text)
|
||||
|
||||
def _on_tail_toggle(self, checked: bool):
|
||||
self._auto_tail = checked
|
||||
if checked:
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _scroll_to_bottom(self):
|
||||
sb = self._text.verticalScrollBar()
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
def _copy_all(self):
|
||||
QApplication.clipboard().setText(self._text.toPlainText())
|
||||
self._status_lbl.setText("Copied to clipboard.")
|
||||
|
||||
def _clear_view(self):
|
||||
self._text.clear()
|
||||
self._status_lbl.setText("View cleared (log file is untouched).")
|
||||
|
||||
def _open_log_folder(self):
|
||||
folder = str(self._log_path.parent)
|
||||
try:
|
||||
import subprocess, platform
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
subprocess.Popen(["explorer", folder])
|
||||
elif system == "Darwin":
|
||||
subprocess.Popen(["open", folder])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
except Exception as e:
|
||||
self._status_lbl.setText(f"Could not open folder: {e}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._timer.stop()
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Process list viewer — shows running server processes with kill capability.
|
||||
Auto-refresh support with configurable interval.
|
||||
"""
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QHeaderView, QCheckBox,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
||||
|
||||
from app.utils.worker import SchemaWorker
|
||||
|
||||
|
||||
class ProcessListPanel(QWidget):
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, connection_name: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._connection_name = connection_name
|
||||
self._refresh_timer = QTimer(self)
|
||||
self._refresh_timer.timeout.connect(self._refresh)
|
||||
self._build_ui()
|
||||
self._refresh()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Header bar
|
||||
hdr = QHBoxLayout()
|
||||
hdr.setContentsMargins(8, 6, 8, 4)
|
||||
|
||||
title = QLabel(f"⚙️ Process List — {self._connection_name}")
|
||||
title.setObjectName("structureTitle")
|
||||
f = title.font()
|
||||
f.setBold(True)
|
||||
f.setPointSize(12)
|
||||
title.setFont(f)
|
||||
|
||||
self._auto_cb = QCheckBox("Auto-refresh (5s)")
|
||||
self._auto_cb.toggled.connect(self._toggle_auto_refresh)
|
||||
|
||||
self._refresh_btn = QPushButton("🔄 Refresh")
|
||||
self._refresh_btn.setFixedWidth(90)
|
||||
self._refresh_btn.clicked.connect(self._refresh)
|
||||
|
||||
self._kill_btn = QPushButton("🛑 Kill Process")
|
||||
self._kill_btn.setObjectName("deleteBtn")
|
||||
self._kill_btn.setEnabled(False)
|
||||
self._kill_btn.clicked.connect(self._kill_selected)
|
||||
|
||||
hdr.addWidget(title)
|
||||
hdr.addStretch()
|
||||
hdr.addWidget(self._auto_cb)
|
||||
hdr.addWidget(self._refresh_btn)
|
||||
hdr.addWidget(self._kill_btn)
|
||||
root.addLayout(hdr)
|
||||
|
||||
# Process table
|
||||
self._table = QTableWidget(0, 0)
|
||||
self._table.setAlternatingRowColors(True)
|
||||
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self._table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
self._table.verticalHeader().setDefaultSectionSize(24)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
self._table.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
root.addWidget(self._table)
|
||||
|
||||
# ── Data loading ──────────────────────────────────────────────────────────
|
||||
|
||||
def _refresh(self):
|
||||
self._refresh_btn.setEnabled(False)
|
||||
w = SchemaWorker(self._driver.get_process_list, parent=self)
|
||||
w.result.connect(self._populate)
|
||||
w.error.connect(self._on_error)
|
||||
w.finished.connect(lambda: self._refresh_btn.setEnabled(True))
|
||||
w.start()
|
||||
|
||||
def _populate(self, data):
|
||||
cols, rows = data
|
||||
self._table.setColumnCount(len(cols))
|
||||
self._table.setHorizontalHeaderLabels([c.upper() for c in cols])
|
||||
self._table.setRowCount(0)
|
||||
for row in rows:
|
||||
r = self._table.rowCount()
|
||||
self._table.insertRow(r)
|
||||
for c, val in enumerate(row):
|
||||
item = QTableWidgetItem("" if val is None else str(val))
|
||||
item.setTextAlignment(
|
||||
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
|
||||
)
|
||||
self._table.setItem(r, c, item)
|
||||
self.status_message.emit(
|
||||
f"Process list: {len(rows)} process(es)"
|
||||
)
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
self._table.setRowCount(1)
|
||||
self._table.setColumnCount(1)
|
||||
self._table.setHorizontalHeaderLabels(["Error"])
|
||||
self._table.setItem(0, 0, QTableWidgetItem(msg))
|
||||
self.status_message.emit(f"Process list error: {msg}")
|
||||
|
||||
# ── Kill ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_selection_changed(self):
|
||||
self._kill_btn.setEnabled(bool(self._table.selectedItems()))
|
||||
|
||||
def _kill_selected(self):
|
||||
row = self._table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
|
||||
# Process ID is always the first column
|
||||
pid_item = self._table.item(row, 0)
|
||||
if not pid_item or not pid_item.text():
|
||||
return
|
||||
|
||||
try:
|
||||
pid = int(pid_item.text())
|
||||
except ValueError:
|
||||
self.status_message.emit("Could not determine process ID.")
|
||||
return
|
||||
|
||||
btn = QMessageBox.warning(
|
||||
self, "Kill Process",
|
||||
f"Kill process {pid}?\n\nThis will immediately terminate the running query.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if btn != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
w = SchemaWorker(self._driver.kill_process, pid, parent=self)
|
||||
w.result.connect(lambda _: (
|
||||
self.status_message.emit(f"Process {pid} killed."),
|
||||
self._refresh(),
|
||||
))
|
||||
w.error.connect(
|
||||
lambda e: QMessageBox.warning(self, "Kill Error",
|
||||
f"Could not kill process {pid}:\n{e}")
|
||||
)
|
||||
w.start()
|
||||
|
||||
# ── Auto-refresh ──────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_auto_refresh(self, checked: bool):
|
||||
if checked:
|
||||
self._refresh_timer.start(5000)
|
||||
else:
|
||||
self._refresh_timer.stop()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._refresh_timer.stop()
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Query history panel — logs every executed query with timestamp and status.
|
||||
Persists to ~/.dbclient/history.db (SQLite).
|
||||
"""
|
||||
import sqlite3
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
|
||||
QLineEdit, QPushButton, QHeaderView, QAbstractItemView, QMenu,
|
||||
QLabel,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
|
||||
_HISTORY_DB = Path.home() / ".dbclient" / "history.db"
|
||||
_MAX_HISTORY = 500
|
||||
|
||||
|
||||
def _open_db() -> sqlite3.Connection:
|
||||
_HISTORY_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(_HISTORY_DB)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
db_type TEXT,
|
||||
database TEXT,
|
||||
sql TEXT,
|
||||
duration REAL,
|
||||
status TEXT
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def record_query(db_type: str, database: str, sql: str,
|
||||
duration: float, status: str = "OK") -> None:
|
||||
"""Insert a history record (called from query worker result slot)."""
|
||||
try:
|
||||
conn = _open_db()
|
||||
conn.execute("""
|
||||
INSERT INTO history (ts, db_type, database, sql, duration, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
db_type, database, sql[:2000], round(duration, 4), status
|
||||
))
|
||||
# Prune
|
||||
conn.execute(f"""
|
||||
DELETE FROM history WHERE id NOT IN (
|
||||
SELECT id FROM history ORDER BY id DESC LIMIT {_MAX_HISTORY}
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class QueryHistoryPanel(QWidget):
|
||||
"""Shows query history and emits signals to replay queries."""
|
||||
|
||||
run_query = pyqtSignal(str) # emitted when user re-runs a history item
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._build_ui()
|
||||
self.refresh()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# Toolbar
|
||||
tb = QHBoxLayout()
|
||||
tb.setContentsMargins(6, 6, 6, 4)
|
||||
|
||||
self._search = QLineEdit()
|
||||
self._search.setPlaceholderText("🔍 Search history…")
|
||||
self._search.textChanged.connect(self._filter)
|
||||
|
||||
refresh_btn = QPushButton("🔄")
|
||||
refresh_btn.setFixedWidth(34)
|
||||
refresh_btn.setToolTip("Refresh")
|
||||
refresh_btn.clicked.connect(self.refresh)
|
||||
|
||||
clear_btn = QPushButton("🧹")
|
||||
clear_btn.setFixedWidth(34)
|
||||
clear_btn.setToolTip("Clear all history")
|
||||
clear_btn.clicked.connect(self._clear_history)
|
||||
|
||||
tb.addWidget(self._search, 1)
|
||||
tb.addWidget(refresh_btn)
|
||||
tb.addWidget(clear_btn)
|
||||
root.addLayout(tb)
|
||||
|
||||
# Table
|
||||
self._table = QTableWidget(0, 5)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["Timestamp", "Database", "Duration", "Status", "SQL"])
|
||||
self._table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.ResizeToContents)
|
||||
self._table.horizontalHeader().setSectionResizeMode(
|
||||
4, QHeaderView.ResizeMode.Stretch)
|
||||
self._table.verticalHeader().setDefaultSectionSize(24)
|
||||
self._table.setSelectionBehavior(
|
||||
QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self._table.setAlternatingRowColors(True)
|
||||
self._table.setContextMenuPolicy(
|
||||
Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._table.customContextMenuRequested.connect(self._context_menu)
|
||||
self._table.doubleClicked.connect(self._on_double_click)
|
||||
root.addWidget(self._table)
|
||||
|
||||
# ── Data ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def refresh(self):
|
||||
self._load(self._search.text())
|
||||
|
||||
def _load(self, search: str = ""):
|
||||
try:
|
||||
conn = _open_db()
|
||||
if search:
|
||||
rows = conn.execute("""
|
||||
SELECT ts, database, duration, status, sql
|
||||
FROM history WHERE sql LIKE ? ORDER BY id DESC
|
||||
""", (f"%{search}%",)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("""
|
||||
SELECT ts, database, duration, status, sql
|
||||
FROM history ORDER BY id DESC
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
except Exception:
|
||||
rows = []
|
||||
|
||||
self._table.setRowCount(0)
|
||||
for ts, db, dur, status, sql in rows:
|
||||
r = self._table.rowCount()
|
||||
self._table.insertRow(r)
|
||||
items = [
|
||||
ts or "",
|
||||
db or "",
|
||||
f"{dur:.3f}s" if dur else "",
|
||||
status or "",
|
||||
(sql or "").replace("\n", " ")[:200],
|
||||
]
|
||||
for c, val in enumerate(items):
|
||||
item = QTableWidgetItem(val)
|
||||
if c == 3 and status == "ERROR":
|
||||
item.setForeground(QColor("#f38ba8"))
|
||||
self._table.setItem(r, c, item)
|
||||
|
||||
def _filter(self, text: str):
|
||||
self._load(text)
|
||||
|
||||
def _clear_history(self):
|
||||
try:
|
||||
conn = _open_db()
|
||||
conn.execute("DELETE FROM history")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._table.setRowCount(0)
|
||||
|
||||
# ── Interactions ──────────────────────────────────────────────────────────
|
||||
|
||||
def _on_double_click(self, idx):
|
||||
row = idx.row()
|
||||
sql_item = self._table.item(row, 4)
|
||||
if sql_item:
|
||||
self.run_query.emit(sql_item.text())
|
||||
|
||||
def _context_menu(self, pos):
|
||||
row = self._table.rowAt(pos.y())
|
||||
if row < 0:
|
||||
return
|
||||
sql = self._table.item(row, 4)
|
||||
if not sql:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
menu.addAction("▶ Run Query", lambda: self.run_query.emit(sql.text()))
|
||||
menu.addAction("📋 Copy SQL", lambda: self._copy(sql.text()))
|
||||
menu.exec(self._table.viewport().mapToGlobal(pos))
|
||||
|
||||
@staticmethod
|
||||
def _copy(text: str):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
QApplication.clipboard().setText(text)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Results panel — shows query result data, DML messages, errors, and export controls.
|
||||
"""
|
||||
import os
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTableView, QLabel, QPushButton,
|
||||
QHeaderView, QAbstractItemView, QFileDialog, QMessageBox, QStackedWidget,
|
||||
QPlainTextEdit, QProgressBar,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QSortFilterProxyModel
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
|
||||
from app.models.result_table_model import ResultTableModel
|
||||
|
||||
|
||||
class ResultsPanel(QWidget):
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._model = ResultTableModel()
|
||||
self._build_ui()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# ── Status / toolbar ─────────────────────────────────────────────────
|
||||
self._toolbar = QHBoxLayout()
|
||||
self._toolbar.setContentsMargins(8, 4, 8, 4)
|
||||
self._toolbar.setSpacing(8)
|
||||
|
||||
self._status_lbl = QLabel("Ready")
|
||||
self._status_lbl.setObjectName("statusLabel")
|
||||
|
||||
self._export_csv = QPushButton("⬇ CSV")
|
||||
self._export_json = QPushButton("⬇ JSON")
|
||||
self._export_sql = QPushButton("⬇ SQL")
|
||||
self._export_csv.setFixedWidth(72)
|
||||
self._export_json.setFixedWidth(72)
|
||||
self._export_sql.setFixedWidth(72)
|
||||
self._export_csv.clicked.connect(self._do_export_csv)
|
||||
self._export_json.clicked.connect(self._do_export_json)
|
||||
self._export_sql.clicked.connect(self._do_export_sql)
|
||||
|
||||
self._toolbar.addWidget(self._status_lbl, 1)
|
||||
self._toolbar.addWidget(self._export_csv)
|
||||
self._toolbar.addWidget(self._export_json)
|
||||
self._toolbar.addWidget(self._export_sql)
|
||||
root.addLayout(self._toolbar)
|
||||
|
||||
# ── Stacked pages ─────────────────────────────────────────────────────
|
||||
self._stack = QStackedWidget()
|
||||
|
||||
# Page 0 — table
|
||||
self._table = QTableView()
|
||||
self._proxy = QSortFilterProxyModel()
|
||||
self._proxy.setSourceModel(self._model)
|
||||
self._table.setModel(self._proxy)
|
||||
self._table.setSortingEnabled(True)
|
||||
self._table.setAlternatingRowColors(True)
|
||||
self._table.setSelectionBehavior(
|
||||
QAbstractItemView.SelectionBehavior.SelectItems)
|
||||
self._table.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self._table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Interactive)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
self._table.verticalHeader().setDefaultSectionSize(24)
|
||||
self._table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._table.customContextMenuRequested.connect(self._table_context_menu)
|
||||
self._stack.addWidget(self._table) # idx 0
|
||||
|
||||
# Page 1 — message / log
|
||||
self._msg_view = QPlainTextEdit()
|
||||
self._msg_view.setReadOnly(True)
|
||||
self._msg_view.setFont(QFont("Consolas", 11))
|
||||
self._stack.addWidget(self._msg_view) # idx 1
|
||||
|
||||
# Page 2 — loading spinner
|
||||
loading = QWidget()
|
||||
ll = QVBoxLayout(loading)
|
||||
ll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
bar = QProgressBar()
|
||||
bar.setMaximum(0)
|
||||
bar.setFixedWidth(200)
|
||||
ll.addWidget(QLabel("Executing query…"))
|
||||
ll.addWidget(bar)
|
||||
self._stack.addWidget(loading) # idx 2
|
||||
|
||||
root.addWidget(self._stack, 1)
|
||||
self._set_export_visible(False)
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def show_loading(self):
|
||||
self._model.clear()
|
||||
self._stack.setCurrentIndex(2)
|
||||
self._status_lbl.setText("Running…")
|
||||
self._set_export_visible(False)
|
||||
|
||||
def show_data(self, cols: list, rows: list, count: int, elapsed: float):
|
||||
self._model.set_data(cols, rows)
|
||||
self._stack.setCurrentIndex(0)
|
||||
t = f"{elapsed:.3f}s" if elapsed else ""
|
||||
self._status_lbl.setText(f"{count:,} row(s) {t}")
|
||||
self.status_message.emit(f"Fetched {count:,} rows {t}")
|
||||
self._set_export_visible(bool(cols))
|
||||
self._auto_resize()
|
||||
|
||||
def show_message(self, msg: str):
|
||||
self._msg_view.appendPlainText(msg)
|
||||
self._stack.setCurrentIndex(1)
|
||||
self._status_lbl.setText(msg)
|
||||
self._set_export_visible(False)
|
||||
|
||||
def show_error(self, msg: str):
|
||||
self._msg_view.setPlainText(f"❌ {msg}")
|
||||
self._stack.setCurrentIndex(1)
|
||||
self._status_lbl.setText(f"Error: {msg[:80]}")
|
||||
self.status_message.emit(f"Error: {msg[:80]}")
|
||||
self._set_export_visible(False)
|
||||
|
||||
def export_dialog(self):
|
||||
self._do_export_csv()
|
||||
|
||||
# ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _set_export_visible(self, v: bool):
|
||||
self._export_csv.setVisible(v)
|
||||
self._export_json.setVisible(v)
|
||||
self._export_sql.setVisible(v)
|
||||
|
||||
def _auto_resize(self):
|
||||
header = self._table.horizontalHeader()
|
||||
for i in range(self._model.columnCount()):
|
||||
header.resizeSection(
|
||||
i, min(self._table.columnWidth(i) + 20, 300)
|
||||
)
|
||||
self._table.resizeColumnsToContents()
|
||||
|
||||
def _do_export_csv(self):
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export CSV", "results.csv", "CSV Files (*.csv)")
|
||||
if path:
|
||||
try:
|
||||
self._model.export_csv(path)
|
||||
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def _do_export_json(self):
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export JSON", "results.json", "JSON Files (*.json)")
|
||||
if path:
|
||||
try:
|
||||
self._model.export_json(path)
|
||||
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def _do_export_sql(self):
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export SQL", "results.sql", "SQL Files (*.sql)")
|
||||
if path:
|
||||
try:
|
||||
self._model.export_sql(path)
|
||||
QMessageBox.information(self, "Exported", f"Saved to {path}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def _table_context_menu(self, pos):
|
||||
from PyQt6.QtWidgets import QMenu
|
||||
from PyQt6.QtGui import QClipboard
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
idx = self._table.indexAt(pos)
|
||||
if not idx.isValid():
|
||||
return
|
||||
menu = QMenu(self)
|
||||
menu.addAction("📋 Copy cell", lambda: self._copy_cell(idx))
|
||||
menu.addAction("📋 Copy row", lambda: self._copy_row(idx))
|
||||
menu.addAction("📋 Copy all", lambda: self._copy_all())
|
||||
menu.exec(self._table.viewport().mapToGlobal(pos))
|
||||
|
||||
def _copy_cell(self, idx):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
val = self._proxy.data(idx, Qt.ItemDataRole.DisplayRole) or ""
|
||||
QApplication.clipboard().setText(str(val))
|
||||
|
||||
def _copy_row(self, idx):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
row = self._proxy.mapToSource(idx).row()
|
||||
vals = [str(v or "") for v in self._model.get_row(row)]
|
||||
QApplication.clipboard().setText("\t".join(vals))
|
||||
|
||||
def _copy_all(self):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
import csv, io
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(self._model.column_names())
|
||||
for r in range(self._model.rowCount()):
|
||||
writer.writerow(self._model.get_row(r))
|
||||
QApplication.clipboard().setText(buf.getvalue())
|
||||
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
Schema browser — left sidebar tree.
|
||||
|
||||
Node states:
|
||||
• saved_connection — profile saved to disk but not yet connected (grey, dashed)
|
||||
• connection — actively connected (coloured, expandable tree)
|
||||
|
||||
Tree structure when connected:
|
||||
Connection → Databases → (Tables, Views, Functions, Procedures, Triggers)
|
||||
→ Columns / Indexes / Foreign Keys
|
||||
"""
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem,
|
||||
QLineEdit, QMenu, QMessageBox, QInputDialog,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QColor, QBrush
|
||||
|
||||
from app.utils.icons import get_icon
|
||||
from app.utils.worker import SchemaWorker
|
||||
|
||||
# ── Node type codes stored in UserRole ────────────────────────────────────────
|
||||
NT = {
|
||||
# saved but not connected
|
||||
"saved_connection": -1,
|
||||
# connected
|
||||
"connection": 0, "databases_folder": 1, "database": 2,
|
||||
"tables_folder": 3, "views_folder": 4, "functions_folder": 5,
|
||||
"procedures_folder": 6, "triggers_folder": 7,
|
||||
"table": 8, "view": 9, "function": 10, "procedure": 11, "trigger": 12,
|
||||
"columns_folder": 13, "indexes_folder": 14, "fks_folder": 15,
|
||||
"column": 16, "index": 17, "fk": 18,
|
||||
}
|
||||
|
||||
|
||||
class SchemaBrowser(QWidget):
|
||||
# ── Signals ───────────────────────────────────────────────────────────────
|
||||
open_table_viewer = pyqtSignal(object, str, str) # driver, db, table
|
||||
open_table_structure = pyqtSignal(object, str, str)
|
||||
open_sql_editor = pyqtSignal(object, str) # driver, db
|
||||
run_query_requested = pyqtSignal(str) # SQL snippet
|
||||
|
||||
# Emitted so the main window can act on saved profile management
|
||||
connect_requested = pyqtSignal(str) # profile_id — user wants to connect
|
||||
edit_requested = pyqtSignal(str) # profile_id — user wants to edit
|
||||
delete_requested = pyqtSignal(str) # profile_id — user wants to delete
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._drivers: dict = {} # profile_id → driver (only connected)
|
||||
self._profiles: dict = {} # profile_id → ConnectionProfile (all)
|
||||
self._items: dict = {} # profile_id → top-level QTreeWidgetItem
|
||||
self._workers: list = []
|
||||
self._build_ui()
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
search_row = QHBoxLayout()
|
||||
search_row.setContentsMargins(6, 6, 6, 4)
|
||||
self._search = QLineEdit()
|
||||
self._search.setPlaceholderText("🔍 Filter objects…")
|
||||
self._search.textChanged.connect(self._filter)
|
||||
search_row.addWidget(self._search)
|
||||
layout.addLayout(search_row)
|
||||
|
||||
self._tree = QTreeWidget()
|
||||
self._tree.setHeaderHidden(True)
|
||||
self._tree.setAnimated(True)
|
||||
self._tree.setIndentation(16)
|
||||
self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._tree.customContextMenuRequested.connect(self._context_menu)
|
||||
self._tree.itemDoubleClicked.connect(self._on_double_click)
|
||||
self._tree.itemExpanded.connect(self._on_expanded)
|
||||
layout.addWidget(self._tree)
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def add_saved_profile(self, profile) -> None:
|
||||
"""Add a profile that is saved but not yet connected (greyed out)."""
|
||||
pid = profile.id
|
||||
self._profiles[pid] = profile
|
||||
|
||||
# If it already exists as a tree item (e.g., being refreshed), remove it first
|
||||
self._remove_item(pid)
|
||||
|
||||
item = QTreeWidgetItem()
|
||||
item.setText(0, f" {profile.name} · {profile.db_type_display}")
|
||||
item.setData(0, Qt.ItemDataRole.UserRole,
|
||||
(NT["saved_connection"], pid, "", ""))
|
||||
|
||||
# Dim colour — show the profile colour but muted
|
||||
color = QColor(profile.color)
|
||||
color.setAlpha(140)
|
||||
item.setForeground(0, QBrush(QColor("#6c7086"))) # greyed out
|
||||
item.setIcon(0, get_icon(profile.db_type, 16))
|
||||
item.setToolTip(0, self._connection_tooltip(profile))
|
||||
|
||||
f = item.font(0)
|
||||
f.setItalic(True)
|
||||
item.setFont(0, f)
|
||||
|
||||
self._tree.addTopLevelItem(item)
|
||||
self._items[pid] = item
|
||||
|
||||
def add_connection(self, profile, driver) -> None:
|
||||
"""
|
||||
Upgrade a saved (disconnected) profile to a live connected node,
|
||||
or add it fresh if it was never in the tree before.
|
||||
"""
|
||||
pid = profile.id
|
||||
self._drivers[pid] = driver
|
||||
self._profiles[pid] = profile
|
||||
|
||||
# Reuse existing item if already in tree (upgrade from saved_connection)
|
||||
existing = self._items.get(pid)
|
||||
if existing:
|
||||
self._tree.takeTopLevelItem(
|
||||
self._tree.indexOfTopLevelItem(existing))
|
||||
|
||||
item = QTreeWidgetItem()
|
||||
item.setText(0, f" {profile.name}")
|
||||
item.setData(0, Qt.ItemDataRole.UserRole, (NT["connection"], pid, "", ""))
|
||||
item.setForeground(0, QBrush(QColor(profile.color)))
|
||||
|
||||
f = item.font(0)
|
||||
f.setBold(True)
|
||||
f.setItalic(False)
|
||||
item.setFont(0, f)
|
||||
item.setIcon(0, get_icon(profile.db_type, 16))
|
||||
item.setToolTip(0, self._connection_tooltip(profile))
|
||||
item.addChild(QTreeWidgetItem(["Loading…"])) # triggers expand arrow
|
||||
|
||||
self._tree.addTopLevelItem(item)
|
||||
self._items[pid] = item
|
||||
item.setExpanded(True)
|
||||
|
||||
def remove_connection(self, profile_id: str, keep_saved: bool = True) -> None:
|
||||
"""
|
||||
Disconnect: revert the node to 'saved' state (keep_saved=True)
|
||||
or remove it entirely (keep_saved=False).
|
||||
"""
|
||||
driver = self._drivers.pop(profile_id, None)
|
||||
if driver:
|
||||
try:
|
||||
driver.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if keep_saved and profile_id in self._profiles:
|
||||
profile = self._profiles[profile_id]
|
||||
self._remove_item(profile_id)
|
||||
self.add_saved_profile(profile)
|
||||
else:
|
||||
self._profiles.pop(profile_id, None)
|
||||
self._remove_item(profile_id)
|
||||
|
||||
def update_saved_profile(self, profile) -> None:
|
||||
"""Called after an edit — refresh the sidebar label."""
|
||||
pid = profile.id
|
||||
self._profiles[pid] = profile
|
||||
if pid in self._drivers:
|
||||
# Currently connected — just update the label
|
||||
item = self._items.get(pid)
|
||||
if item:
|
||||
item.setText(0, f" {profile.name}")
|
||||
item.setForeground(0, QBrush(QColor(profile.color)))
|
||||
else:
|
||||
# Saved only — rebuild the item
|
||||
self._remove_item(pid)
|
||||
self.add_saved_profile(profile)
|
||||
|
||||
def is_connected(self, profile_id: str) -> bool:
|
||||
return profile_id in self._drivers
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _remove_item(self, profile_id: str) -> None:
|
||||
item = self._items.pop(profile_id, None)
|
||||
if item:
|
||||
idx = self._tree.indexOfTopLevelItem(item)
|
||||
if idx >= 0:
|
||||
self._tree.takeTopLevelItem(idx)
|
||||
|
||||
@staticmethod
|
||||
def _connection_tooltip(profile) -> str:
|
||||
if profile.db_type == "sqlite":
|
||||
return profile.database
|
||||
return (f"{profile.db_type_display} · "
|
||||
f"{profile.username}@{profile.host}:{profile.port}"
|
||||
+ (f"/{profile.database}" if profile.database else ""))
|
||||
|
||||
# ── Tree population (lazy-load on expand) ─────────────────────────────────
|
||||
|
||||
def _on_expanded(self, item: QTreeWidgetItem):
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not data:
|
||||
return
|
||||
node_type, pid, db, name = data
|
||||
|
||||
if node_type == NT["connection"]:
|
||||
self._load_databases(item, pid)
|
||||
elif node_type == NT["database"]:
|
||||
self._build_db_folders(item, pid, db)
|
||||
elif node_type == NT["tables_folder"]:
|
||||
self._load_tables(item, pid, db)
|
||||
elif node_type == NT["views_folder"]:
|
||||
self._load_views(item, pid, db)
|
||||
elif node_type == NT["functions_folder"]:
|
||||
self._load_generic(item, pid, db, "function",
|
||||
lambda d: self._drivers[pid].get_functions(d))
|
||||
elif node_type == NT["procedures_folder"]:
|
||||
self._load_generic(item, pid, db, "procedure",
|
||||
lambda d: self._drivers[pid].get_stored_procedures(d))
|
||||
elif node_type == NT["triggers_folder"]:
|
||||
self._load_generic(item, pid, db, "trigger",
|
||||
lambda d: self._drivers[pid].get_triggers(d))
|
||||
elif node_type == NT["table"]:
|
||||
self._build_table_folders(item, pid, db, name)
|
||||
elif node_type == NT["columns_folder"]:
|
||||
self._load_columns(item, pid, db, name)
|
||||
elif node_type == NT["indexes_folder"]:
|
||||
self._load_indexes(item, pid, db, name)
|
||||
elif node_type == NT["fks_folder"]:
|
||||
self._load_fks(item, pid, db, name)
|
||||
|
||||
def _placeholder(self, item: QTreeWidgetItem) -> bool:
|
||||
if item.childCount() == 1 and item.child(0).text(0) in ("Loading…", ""):
|
||||
item.takeChild(0)
|
||||
return True
|
||||
return False
|
||||
|
||||
# databases
|
||||
def _load_databases(self, item, pid):
|
||||
if not self._placeholder(item):
|
||||
return
|
||||
w = SchemaWorker(self._drivers[pid].get_databases, parent=self)
|
||||
w.result.connect(lambda dbs: self._populate_databases(item, pid, dbs))
|
||||
w.error.connect(lambda e: self._show_error(item, e))
|
||||
self._workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _populate_databases(self, item, pid, dbs):
|
||||
for db in dbs:
|
||||
child = QTreeWidgetItem([f" 🗄 {db}"])
|
||||
child.setData(0, Qt.ItemDataRole.UserRole, (NT["database"], pid, db, ""))
|
||||
child.addChild(QTreeWidgetItem(["Loading…"]))
|
||||
item.addChild(child)
|
||||
|
||||
# database folders
|
||||
def _build_db_folders(self, item, pid, db):
|
||||
if not self._placeholder(item):
|
||||
return
|
||||
for label, nt in [
|
||||
("📂 Tables", NT["tables_folder"]),
|
||||
("📂 Views", NT["views_folder"]),
|
||||
("📂 Functions", NT["functions_folder"]),
|
||||
("📂 Procedures", NT["procedures_folder"]),
|
||||
("📂 Triggers", NT["triggers_folder"]),
|
||||
]:
|
||||
child = QTreeWidgetItem([label])
|
||||
child.setData(0, Qt.ItemDataRole.UserRole, (nt, pid, db, ""))
|
||||
child.addChild(QTreeWidgetItem(["Loading…"]))
|
||||
item.addChild(child)
|
||||
|
||||
# tables
|
||||
def _load_tables(self, item, pid, db):
|
||||
if not self._placeholder(item):
|
||||
return
|
||||
w = SchemaWorker(self._drivers[pid].get_tables, db, parent=self)
|
||||
w.result.connect(lambda t: self._populate_tables(item, pid, db, t))
|
||||
w.error.connect(lambda e: self._show_error(item, e))
|
||||
self._workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _populate_tables(self, item, pid, db, tables):
|
||||
for t in tables:
|
||||
label = f" 📋 {t.name}"
|
||||
if t.row_count:
|
||||
label += f" ({t.row_count:,})"
|
||||
child = QTreeWidgetItem([label])
|
||||
child.setData(0, Qt.ItemDataRole.UserRole, (NT["table"], pid, db, t.name))
|
||||
child.setToolTip(0, t.comment or t.name)
|
||||
child.addChild(QTreeWidgetItem(["Loading…"]))
|
||||
item.addChild(child)
|
||||
|
||||
# views
|
||||
def _load_views(self, item, pid, db):
|
||||
if not self._placeholder(item):
|
||||
return
|
||||
w = SchemaWorker(self._drivers[pid].get_views, db, parent=self)
|
||||
w.result.connect(
|
||||
lambda v: self._populate_generic(item, pid, db, v, "view", "👁"))
|
||||
w.error.connect(lambda e: self._show_error(item, e))
|
||||
self._workers.append(w)
|
||||
w.start()
|
||||
|
||||
# generic (functions / procedures / triggers)
|
||||
def _load_generic(self, item, pid, db, kind, fn):
|
||||
if not self._placeholder(item):
|
||||
return
|
||||
icons = {"function": "⚡", "procedure": "📦", "trigger": "⚙"}
|
||||
icon = icons.get(kind, "📄")
|
||||
w = SchemaWorker(fn, db, parent=self)
|
||||
w.result.connect(
|
||||
lambda items: self._populate_generic(item, pid, db, items, kind, icon))
|
||||
w.error.connect(lambda e: self._show_error(item, e))
|
||||
self._workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _populate_generic(self, item, pid, db, names, kind, icon):
|
||||
nt = NT.get(kind, NT["view"])
|
||||
for name in names:
|
||||
child = QTreeWidgetItem([f" {icon} {name}"])
|
||||
child.setData(0, Qt.ItemDataRole.UserRole, (nt, pid, db, name))
|
||||
item.addChild(child)
|
||||
|
||||
# table sub-folders
|
||||
def _build_table_folders(self, item, pid, db, table):
|
||||
if not self._placeholder(item):
|
||||
return
|
||||
for label, nt in [
|
||||
("📊 Columns", NT["columns_folder"]),
|
||||
("🔍 Indexes", NT["indexes_folder"]),
|
||||
("🔗 Foreign Keys", NT["fks_folder"]),
|
||||
]:
|
||||
child = QTreeWidgetItem([label])
|
||||
child.setData(0, Qt.ItemDataRole.UserRole, (nt, pid, db, table))
|
||||
child.addChild(QTreeWidgetItem(["Loading…"]))
|
||||
item.addChild(child)
|
||||
|
||||
def _load_columns(self, item, pid, db, table):
|
||||
if not self._placeholder(item):
|
||||
return
|
||||
w = SchemaWorker(self._drivers[pid].get_columns, db, table, parent=self)
|
||||
w.result.connect(lambda c: self._populate_columns(item, c))
|
||||
w.error.connect(lambda e: self._show_error(item, e))
|
||||
self._workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _populate_columns(self, item, cols):
|
||||
for c in cols:
|
||||
icon = "🔑" if c.is_primary_key else ("🔗" if c.is_foreign_key else "·")
|
||||
null = "NULL" if c.nullable else "NOT NULL"
|
||||
label = f" {icon} {c.name} {c.data_type} {null}"
|
||||
child = QTreeWidgetItem([label])
|
||||
child.setData(0, Qt.ItemDataRole.UserRole, (NT["column"], "", "", c.name))
|
||||
if c.is_primary_key:
|
||||
child.setForeground(0, QBrush(QColor("#f9e2af")))
|
||||
elif c.is_foreign_key:
|
||||
child.setForeground(0, QBrush(QColor("#89dceb")))
|
||||
item.addChild(child)
|
||||
|
||||
def _load_indexes(self, item, pid, db, table):
|
||||
if not self._placeholder(item):
|
||||
return
|
||||
w = SchemaWorker(self._drivers[pid].get_indexes, db, table, parent=self)
|
||||
w.result.connect(lambda i: self._populate_indexes(item, i))
|
||||
w.error.connect(lambda e: self._show_error(item, e))
|
||||
self._workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _populate_indexes(self, item, indexes):
|
||||
for idx in indexes:
|
||||
u = " [UNIQUE]" if idx.is_unique else ""
|
||||
label = f" 🔍 {idx.name}{u} ({', '.join(idx.columns)})"
|
||||
item.addChild(QTreeWidgetItem([label]))
|
||||
|
||||
def _load_fks(self, item, pid, db, table):
|
||||
if not self._placeholder(item):
|
||||
return
|
||||
w = SchemaWorker(self._drivers[pid].get_foreign_keys, db, table, parent=self)
|
||||
w.result.connect(lambda f: self._populate_fks(item, f))
|
||||
w.error.connect(lambda e: self._show_error(item, e))
|
||||
self._workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _populate_fks(self, item, fks):
|
||||
for fk in fks:
|
||||
label = f" 🔗 {fk.column} → {fk.ref_table}.{fk.ref_column}"
|
||||
item.addChild(QTreeWidgetItem([label]))
|
||||
|
||||
def _show_error(self, item, msg):
|
||||
err = QTreeWidgetItem([f"❌ {msg}"])
|
||||
err.setForeground(0, QBrush(QColor("#f38ba8")))
|
||||
item.addChild(err)
|
||||
|
||||
# ── Context menu ──────────────────────────────────────────────────────────
|
||||
|
||||
def _context_menu(self, pos):
|
||||
item = self._tree.itemAt(pos)
|
||||
if not item:
|
||||
return
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not data:
|
||||
return
|
||||
node_type, pid, db, name = data
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
# ── Saved (disconnected) connection ───────────────────────────────────
|
||||
if node_type == NT["saved_connection"]:
|
||||
menu.addAction("🔌 Connect", lambda: self.connect_requested.emit(pid))
|
||||
menu.addSeparator()
|
||||
menu.addAction("✏️ Edit Connection…", lambda: self.edit_requested.emit(pid))
|
||||
menu.addAction("🗑️ Delete Connection", lambda: self.delete_requested.emit(pid))
|
||||
|
||||
# ── Active connection ─────────────────────────────────────────────────
|
||||
elif node_type == NT["connection"]:
|
||||
menu.addAction("🔄 Refresh", lambda: self._refresh_node(item))
|
||||
menu.addAction("🗄️ New Database…", lambda: self._create_db(pid))
|
||||
menu.addSeparator()
|
||||
menu.addAction("✏️ Edit Connection…", lambda: self.edit_requested.emit(pid))
|
||||
menu.addAction("🗑️ Delete Connection…", lambda: self.delete_requested.emit(pid))
|
||||
menu.addSeparator()
|
||||
menu.addAction("🔌 Disconnect", lambda: self._disconnect(pid))
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────
|
||||
elif node_type == NT["database"]:
|
||||
menu.addAction("✏️ New SQL Tab",
|
||||
lambda: self.open_sql_editor.emit(self._drivers[pid], db))
|
||||
menu.addAction("🔄 Refresh", lambda: self._refresh_node(item))
|
||||
|
||||
# ── Table ──────────────────────────────────────────────────────────────
|
||||
elif node_type == NT["table"]:
|
||||
d = self._drivers[pid]
|
||||
menu.addAction("📋 Open Data",
|
||||
lambda: self.open_table_viewer.emit(d, db, name))
|
||||
menu.addAction("🏗️ Table Structure",
|
||||
lambda: self.open_table_structure.emit(d, db, name))
|
||||
menu.addSeparator()
|
||||
menu.addAction("📑 Copy SELECT *",
|
||||
lambda: self.run_query_requested.emit(
|
||||
f"SELECT * FROM {name} LIMIT 100;"))
|
||||
menu.addAction("🔄 Refresh", lambda: self._refresh_node(item))
|
||||
menu.addSeparator()
|
||||
menu.addAction("🗑️ Drop Table…", lambda: self._drop_table(pid, db, name))
|
||||
|
||||
elif node_type in (NT["tables_folder"], NT["views_folder"]):
|
||||
menu.addAction("🔄 Refresh", lambda: self._refresh_node(item))
|
||||
|
||||
if menu.actions():
|
||||
menu.exec(self._tree.viewport().mapToGlobal(pos))
|
||||
|
||||
# ── Context menu actions ──────────────────────────────────────────────────
|
||||
|
||||
def _disconnect(self, pid: str):
|
||||
self.remove_connection(pid, keep_saved=True)
|
||||
|
||||
def _refresh_node(self, item: QTreeWidgetItem):
|
||||
item.takeChildren()
|
||||
item.addChild(QTreeWidgetItem(["Loading…"]))
|
||||
item.setExpanded(False)
|
||||
item.setExpanded(True)
|
||||
|
||||
def _create_db(self, pid: str):
|
||||
name, ok = QInputDialog.getText(self, "New Database", "Database name:")
|
||||
if ok and name.strip():
|
||||
try:
|
||||
self._drivers[pid].create_database(name.strip())
|
||||
QMessageBox.information(self, "Success",
|
||||
f"Database '{name.strip()}' created.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def _drop_table(self, pid: str, _db: str, table: str):
|
||||
btn = QMessageBox.warning(
|
||||
self, "Drop Table",
|
||||
f"Are you sure you want to DROP TABLE '{table}'?\nThis cannot be undone.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if btn == QMessageBox.StandardButton.Yes:
|
||||
try:
|
||||
self._drivers[pid].execute_query(f"DROP TABLE `{table}`")
|
||||
QMessageBox.information(self, "Dropped", f"Table '{table}' dropped.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
# ── Filter ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _filter(self, text: str):
|
||||
text = text.lower()
|
||||
for i in range(self._tree.topLevelItemCount()):
|
||||
self._filter_item(self._tree.topLevelItem(i), text)
|
||||
|
||||
def _filter_item(self, item: QTreeWidgetItem, text: str) -> bool:
|
||||
matches = text in item.text(0).lower()
|
||||
child_match = any(
|
||||
self._filter_item(item.child(i), text)
|
||||
for i in range(item.childCount())
|
||||
)
|
||||
visible = matches or child_match
|
||||
item.setHidden(not visible)
|
||||
return visible
|
||||
|
||||
# ── Double-click ──────────────────────────────────────────────────────────
|
||||
|
||||
def _on_double_click(self, item: QTreeWidgetItem, _col: int):
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not data:
|
||||
return
|
||||
node_type, pid, db, name = data
|
||||
|
||||
if node_type == NT["saved_connection"]:
|
||||
# Double-click on disconnected profile → try to connect
|
||||
self.connect_requested.emit(pid)
|
||||
|
||||
elif node_type == NT["table"]:
|
||||
driver = self._drivers.get(pid)
|
||||
if driver:
|
||||
self.open_table_viewer.emit(driver, db, name)
|
||||
|
||||
elif node_type == NT["database"]:
|
||||
driver = self._drivers.get(pid)
|
||||
if driver:
|
||||
self.open_sql_editor.emit(driver, db)
|
||||
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
Multi-tab SQL editor with syntax highlighting, line numbers, and run controls.
|
||||
"""
|
||||
import os
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QTextEdit,
|
||||
QTabWidget, QPushButton, QLabel, QSplitter, QTabBar, QSizePolicy,
|
||||
QFileDialog, QMessageBox, QToolButton, QComboBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer
|
||||
from PyQt6.QtGui import (
|
||||
QColor, QPainter, QTextFormat, QFont, QKeySequence, QShortcut,
|
||||
QFontMetrics, QTextCursor,
|
||||
)
|
||||
|
||||
from app.ui.syntax_highlighter import SQLHighlighter
|
||||
from app.ui.results_panel import ResultsPanel
|
||||
from app.utils.worker import QueryWorker
|
||||
|
||||
|
||||
# ── Line-number gutter ────────────────────────────────────────────────────────
|
||||
|
||||
class LineNumberArea(QWidget):
|
||||
def __init__(self, editor):
|
||||
super().__init__(editor)
|
||||
self._editor = editor
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(self._editor.line_number_area_width(), 0)
|
||||
|
||||
def paintEvent(self, event):
|
||||
self._editor.line_number_area_paint_event(event)
|
||||
|
||||
|
||||
class CodeEditor(QPlainTextEdit):
|
||||
"""QPlainTextEdit with line numbers, current-line highlight, and tab→spaces."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._line_area = LineNumberArea(self)
|
||||
|
||||
# Font
|
||||
font = QFont("Consolas", 13)
|
||||
font.setFixedPitch(True)
|
||||
self.setFont(font)
|
||||
self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4)
|
||||
|
||||
# Connect signals
|
||||
self.blockCountChanged.connect(self._update_line_area_width)
|
||||
self.updateRequest.connect(self._update_line_area)
|
||||
self.cursorPositionChanged.connect(self._highlight_current_line)
|
||||
|
||||
self._update_line_area_width(0)
|
||||
self._highlight_current_line()
|
||||
|
||||
def line_number_area_width(self) -> int:
|
||||
digits = max(3, len(str(self.blockCount())))
|
||||
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
|
||||
|
||||
def _update_line_area_width(self, _):
|
||||
self.setViewportMargins(self.line_number_area_width(), 0, 0, 0)
|
||||
|
||||
def _update_line_area(self, rect, dy):
|
||||
if dy:
|
||||
self._line_area.scroll(0, dy)
|
||||
else:
|
||||
self._line_area.update(0, rect.y(), self._line_area.width(), rect.height())
|
||||
if rect.contains(self.viewport().rect()):
|
||||
self._update_line_area_width(0)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
cr = self.contentsRect()
|
||||
self._line_area.setGeometry(
|
||||
QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())
|
||||
)
|
||||
|
||||
def _highlight_current_line(self):
|
||||
extra = []
|
||||
if not self.isReadOnly():
|
||||
sel = QTextEdit.ExtraSelection()
|
||||
sel.format.setBackground(QColor("#2a2a3c"))
|
||||
sel.format.setProperty(QTextFormat.Property.FullWidthSelection, True)
|
||||
sel.cursor = self.textCursor()
|
||||
sel.cursor.clearSelection()
|
||||
extra.append(sel)
|
||||
self.setExtraSelections(extra)
|
||||
|
||||
def line_number_area_paint_event(self, event):
|
||||
painter = QPainter(self._line_area)
|
||||
painter.fillRect(event.rect(), QColor("#1a1a2e"))
|
||||
|
||||
block = self.firstVisibleBlock()
|
||||
number = block.blockNumber()
|
||||
top = round(self.blockBoundingGeometry(block).translated(
|
||||
self.contentOffset()).top())
|
||||
bottom = top + round(self.blockBoundingRect(block).height())
|
||||
|
||||
while block.isValid() and top <= event.rect().bottom():
|
||||
if block.isVisible() and bottom >= event.rect().top():
|
||||
painter.setPen(QColor("#45475a"))
|
||||
painter.drawText(
|
||||
0, top, self._line_area.width() - 6,
|
||||
self.fontMetrics().height(),
|
||||
Qt.AlignmentFlag.AlignRight, str(number + 1)
|
||||
)
|
||||
block = block.next()
|
||||
top = bottom
|
||||
bottom = top + round(self.blockBoundingRect(block).height())
|
||||
number += 1
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
# Tab → 4 spaces
|
||||
if event.key() == Qt.Key.Key_Tab:
|
||||
cursor = self.textCursor()
|
||||
cursor.insertText(" ")
|
||||
return
|
||||
# Ctrl+/ → toggle comment
|
||||
if event.modifiers() == Qt.KeyboardModifier.ControlModifier \
|
||||
and event.key() == Qt.Key.Key_Slash:
|
||||
self._toggle_comment()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def _toggle_comment(self):
|
||||
cursor = self.textCursor()
|
||||
start = cursor.selectionStart()
|
||||
end = cursor.selectionEnd()
|
||||
cursor.setPosition(start)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
|
||||
cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock,
|
||||
QTextCursor.MoveMode.KeepAnchor)
|
||||
text = cursor.selectedText()
|
||||
lines = text.split("\u2029") # Qt paragraph separator
|
||||
if all(l.lstrip().startswith("--") for l in lines if l.strip()):
|
||||
new = [l.replace("--", "", 1) if l.lstrip().startswith("--") else l
|
||||
for l in lines]
|
||||
else:
|
||||
new = ["--" + l for l in lines]
|
||||
cursor.insertText("\u2029".join(new))
|
||||
|
||||
def selected_or_all(self) -> str:
|
||||
cursor = self.textCursor()
|
||||
text = cursor.selectedText().replace("\u2029", "\n")
|
||||
return text if text.strip() else self.toPlainText()
|
||||
|
||||
|
||||
# ── Single editor tab (editor + results splitter) ─────────────────────────────
|
||||
|
||||
class EditorTab(QWidget):
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, database: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._worker: QueryWorker | None = None
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# ── Editor toolbar ─────────────────────────────────────────────────────
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.setContentsMargins(6, 4, 6, 4)
|
||||
toolbar.setSpacing(4)
|
||||
|
||||
self._run_btn = QPushButton("▶ Run F5")
|
||||
self._run_btn.setObjectName("runBtn")
|
||||
self._run_btn.clicked.connect(self._run)
|
||||
|
||||
self._stop_btn = QPushButton("⏹ Stop")
|
||||
self._stop_btn.setObjectName("stopBtn")
|
||||
self._stop_btn.setEnabled(False)
|
||||
self._stop_btn.clicked.connect(self._stop)
|
||||
|
||||
self._explain_btn = QPushButton("🔎 Explain")
|
||||
self._explain_btn.clicked.connect(self._explain)
|
||||
|
||||
self._export_btn = QPushButton("📤 Export")
|
||||
self._export_btn.clicked.connect(self._export)
|
||||
|
||||
self._db_label = QLabel(f"DB: {self._database}" if self._database else "")
|
||||
self._db_label.setObjectName("dbLabel")
|
||||
|
||||
toolbar.addWidget(self._run_btn)
|
||||
toolbar.addWidget(self._stop_btn)
|
||||
toolbar.addWidget(self._explain_btn)
|
||||
toolbar.addWidget(self._export_btn)
|
||||
toolbar.addStretch()
|
||||
toolbar.addWidget(self._db_label)
|
||||
|
||||
root.addLayout(toolbar)
|
||||
|
||||
# ── Splitter: editor / results ──────────────────────────────────────
|
||||
self._splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
self._splitter.setHandleWidth(3)
|
||||
|
||||
self._editor = CodeEditor()
|
||||
SQLHighlighter(self._editor.document())
|
||||
|
||||
self._results = ResultsPanel()
|
||||
self._results.status_message.connect(self.status_message)
|
||||
|
||||
self._splitter.addWidget(self._editor)
|
||||
self._splitter.addWidget(self._results)
|
||||
self._splitter.setSizes([400, 250])
|
||||
|
||||
root.addWidget(self._splitter, 1)
|
||||
|
||||
# ── Shortcuts ──────────────────────────────────────────────────────
|
||||
QShortcut(QKeySequence("F5"), self, self._run)
|
||||
QShortcut(QKeySequence("Ctrl+Return"), self, self._run)
|
||||
|
||||
# ── Run logic ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _run(self):
|
||||
sql = self._editor.selected_or_all().strip()
|
||||
if not sql:
|
||||
return
|
||||
self._results.show_loading()
|
||||
self._run_btn.setEnabled(False)
|
||||
self._stop_btn.setEnabled(True)
|
||||
|
||||
is_script = ";" in sql[:-1] # multiple statements
|
||||
self._worker = QueryWorker(self._driver, sql, is_script=is_script)
|
||||
self._worker.finished.connect(self._on_result)
|
||||
self._worker.script_done.connect(self._on_script_done)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.finished.connect(lambda *_: self._reset_buttons())
|
||||
self._worker.script_done.connect(lambda *_: self._reset_buttons())
|
||||
self._worker.error.connect(lambda *_: self._reset_buttons())
|
||||
self._worker.start()
|
||||
|
||||
def _stop(self):
|
||||
if self._worker and self._worker.isRunning():
|
||||
self._worker.terminate()
|
||||
self._reset_buttons()
|
||||
|
||||
def _reset_buttons(self):
|
||||
self._run_btn.setEnabled(True)
|
||||
self._stop_btn.setEnabled(False)
|
||||
|
||||
def _explain(self):
|
||||
sql = self._editor.selected_or_all().strip()
|
||||
if not sql:
|
||||
return
|
||||
# Prefer opening a full ExplainPanel in the main window workspace
|
||||
from app.main_window import MainWindow
|
||||
win = self.window()
|
||||
if isinstance(win, MainWindow):
|
||||
win.open_explain_tab(self._driver, self._database, sql)
|
||||
else:
|
||||
# Fallback: show raw EXPLAIN in the inline results panel
|
||||
try:
|
||||
cols, rows = self._driver.explain_query(sql)
|
||||
self._results.show_data(cols, rows, len(rows), 0)
|
||||
except Exception as e:
|
||||
self._results.show_error(str(e))
|
||||
|
||||
def _export(self):
|
||||
self._results.export_dialog()
|
||||
|
||||
def _on_result(self, cols, rows, cnt, elapsed):
|
||||
self._results.show_data(cols, rows, cnt, elapsed)
|
||||
|
||||
def _on_script_done(self, results: list):
|
||||
# Show the last SELECT result; messages for DML
|
||||
for cols, rows, cnt, msg in results:
|
||||
if cols:
|
||||
self._results.show_data(cols, rows, cnt, 0)
|
||||
else:
|
||||
self._results.show_message(msg)
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
self._results.show_error(msg)
|
||||
|
||||
# ── Public ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def set_sql(self, sql: str):
|
||||
self._editor.setPlainText(sql)
|
||||
|
||||
def get_sql(self) -> str:
|
||||
return self._editor.toPlainText()
|
||||
|
||||
|
||||
# ── Tabbed SQL editor container ───────────────────────────────────────────────
|
||||
|
||||
class SQLEditorWidget(QWidget):
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._tabs = QTabWidget()
|
||||
self._tabs.setTabsClosable(True)
|
||||
self._tabs.setMovable(True)
|
||||
self._tabs.tabCloseRequested.connect(self._close_tab)
|
||||
|
||||
# New tab button
|
||||
new_btn = QToolButton()
|
||||
new_btn.setText("+")
|
||||
new_btn.setToolTip("New SQL Tab")
|
||||
new_btn.clicked.connect(lambda: self.new_tab())
|
||||
self._tabs.setCornerWidget(new_btn, Qt.Corner.TopRightCorner)
|
||||
|
||||
root.addWidget(self._tabs)
|
||||
|
||||
def new_tab(self, driver=None, database: str = "",
|
||||
sql: str = "", title: str = None) -> EditorTab:
|
||||
tab = EditorTab(driver, database)
|
||||
tab.status_message.connect(self.status_message)
|
||||
if sql:
|
||||
tab.set_sql(sql)
|
||||
label = title or (f"Query — {database}" if database else "Query")
|
||||
idx = self._tabs.addTab(tab, label)
|
||||
self._tabs.setCurrentIndex(idx)
|
||||
return tab
|
||||
|
||||
def _close_tab(self, idx: int):
|
||||
if self._tabs.count() > 1:
|
||||
self._tabs.removeTab(idx)
|
||||
|
||||
def current_tab(self) -> EditorTab | None:
|
||||
w = self._tabs.currentWidget()
|
||||
return w if isinstance(w, EditorTab) else None
|
||||
|
||||
def open_sql_for(self, driver, database: str, sql: str = ""):
|
||||
tab = self.new_tab(driver, database, sql,
|
||||
title=f"SQL — {database}")
|
||||
if sql:
|
||||
tab.set_sql(sql)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
SQL syntax highlighter for QPlainTextEdit.
|
||||
Highlights keywords, types, functions, strings, comments, and numbers.
|
||||
"""
|
||||
import re
|
||||
from PyQt6.QtCore import QRegularExpression, Qt
|
||||
from PyQt6.QtGui import (
|
||||
QSyntaxHighlighter, QTextCharFormat, QColor, QFont
|
||||
)
|
||||
|
||||
|
||||
def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharFormat:
|
||||
f = QTextCharFormat()
|
||||
f.setForeground(QColor(color))
|
||||
if bold: f.setFontWeight(QFont.Weight.Bold)
|
||||
if italic: f.setFontItalic(True)
|
||||
return f
|
||||
|
||||
|
||||
# ── Token categories (Catppuccin Mocha palette) ───────────────────────────────
|
||||
_KEYWORD_FMT = _fmt("#89b4fa", bold=True) # blue — DDL/DML/control
|
||||
_TYPE_FMT = _fmt("#fab387") # peach — data types
|
||||
_FUNCTION_FMT = _fmt("#a6e3a1") # green — functions
|
||||
_STRING_FMT = _fmt("#a6e3a1") # green — string literals
|
||||
_NUMBER_FMT = _fmt("#fab387") # orange — numeric literals
|
||||
_COMMENT_FMT = _fmt("#6c7086", italic=True) # grey — comments
|
||||
_OPERATOR_FMT = _fmt("#cba6f7") # mauve — operators/special
|
||||
_STAR_FMT = _fmt("#cba6f7", bold=True) # mauve — * wildcard
|
||||
|
||||
# ── Keyword lists ─────────────────────────────────────────────────────────────
|
||||
_KEYWORDS = [
|
||||
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
|
||||
"FULL", "CROSS", "ON", "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
|
||||
"IS", "NULL", "EXISTS", "CASE", "WHEN", "THEN", "ELSE", "END",
|
||||
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE",
|
||||
"CREATE", "TABLE", "VIEW", "INDEX", "DATABASE", "SCHEMA", "SEQUENCE",
|
||||
"DROP", "ALTER", "ADD", "COLUMN", "PRIMARY", "KEY", "FOREIGN",
|
||||
"REFERENCES", "UNIQUE", "DEFAULT", "CONSTRAINT", "CHECK",
|
||||
"IF", "EXISTS", "NOT", "TRUNCATE", "RENAME", "MODIFY", "CHANGE",
|
||||
"GRANT", "REVOKE", "COMMIT", "ROLLBACK", "SAVEPOINT", "BEGIN",
|
||||
"TRANSACTION", "START", "END", "LOCK", "UNLOCK", "EXPLAIN",
|
||||
"ANALYZE", "VACUUM", "SHOW", "DESCRIBE", "DESC", "ASC",
|
||||
"LIMIT", "OFFSET", "ORDER", "BY", "GROUP", "HAVING", "DISTINCT",
|
||||
"UNION", "ALL", "INTERSECT", "EXCEPT", "PARTITION", "OVER",
|
||||
"WITH", "RECURSIVE", "USE", "CALL", "EXEC", "EXECUTE",
|
||||
"PROCEDURE", "FUNCTION", "TRIGGER", "EVENT", "REPLACE",
|
||||
"TRUE", "FALSE", "RETURNING",
|
||||
]
|
||||
|
||||
_TYPES = [
|
||||
"INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT", "MEDIUMINT",
|
||||
"FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL", "MONEY",
|
||||
"VARCHAR", "CHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT",
|
||||
"BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", "BINARY", "VARBINARY",
|
||||
"DATE", "DATETIME", "TIMESTAMP", "TIME", "YEAR",
|
||||
"BOOLEAN", "BOOL", "BIT", "ENUM", "SET",
|
||||
"JSON", "UUID", "SERIAL", "BYTEA", "NVARCHAR", "NCHAR",
|
||||
"IMAGE", "XML", "CURSOR", "ROWVERSION", "UNIQUEIDENTIFIER",
|
||||
]
|
||||
|
||||
_FUNCTIONS = [
|
||||
"COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "IFNULL", "NULLIF",
|
||||
"NOW", "CURDATE", "CURTIME", "DATE", "YEAR", "MONTH", "DAY",
|
||||
"CONCAT", "SUBSTRING", "LENGTH", "TRIM", "UPPER", "LOWER",
|
||||
"REPLACE", "ROUND", "FLOOR", "CEIL", "ABS", "MOD", "POWER",
|
||||
"CAST", "CONVERT", "ISNULL", "ISNUMERIC",
|
||||
"ROW_NUMBER", "RANK", "DENSE_RANK", "LAG", "LEAD", "NTILE",
|
||||
"FIRST_VALUE", "LAST_VALUE", "NTH_VALUE",
|
||||
"STRING_AGG", "GROUP_CONCAT", "ARRAY_AGG",
|
||||
"TO_CHAR", "TO_DATE", "TO_NUMBER", "EXTRACT",
|
||||
"DATEDIFF", "DATEADD", "DATE_FORMAT", "DATE_TRUNC",
|
||||
"IF", "IIF", "DECODE", "GREATEST", "LEAST",
|
||||
]
|
||||
|
||||
|
||||
class SQLHighlighter(QSyntaxHighlighter):
|
||||
"""Applies syntax colouring to a SQL document."""
|
||||
|
||||
def __init__(self, document):
|
||||
super().__init__(document)
|
||||
self._rules: list[tuple] = []
|
||||
|
||||
def kw_pattern(words: list[str]) -> str:
|
||||
return r"\b(?:" + "|".join(words) + r")\b"
|
||||
|
||||
self._rules = [
|
||||
# Keywords (case-insensitive handled via flag)
|
||||
(QRegularExpression(kw_pattern(_KEYWORDS),
|
||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
||||
_KEYWORD_FMT),
|
||||
# Data types
|
||||
(QRegularExpression(kw_pattern(_TYPES),
|
||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
||||
_TYPE_FMT),
|
||||
# Functions
|
||||
(QRegularExpression(kw_pattern(_FUNCTIONS),
|
||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
||||
_FUNCTION_FMT),
|
||||
# Numbers
|
||||
(QRegularExpression(r"\b\d+(\.\d+)?\b"), _NUMBER_FMT),
|
||||
# Single-quoted strings
|
||||
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"), _STRING_FMT),
|
||||
# Double-quoted identifiers
|
||||
(QRegularExpression(r'"[^"]*"'), _fmt("#89dceb")),
|
||||
# Backtick identifiers (MySQL)
|
||||
(QRegularExpression(r"`[^`]*`"), _fmt("#89dceb")),
|
||||
# * wildcard
|
||||
(QRegularExpression(r"\bSELECT\s+\*|\*(?=\s*FROM)",
|
||||
QRegularExpression.PatternOption.CaseInsensitiveOption),
|
||||
_STAR_FMT),
|
||||
# Operators
|
||||
(QRegularExpression(r"[=<>!%&|^~+\-*/]"), _OPERATOR_FMT),
|
||||
# Single-line comment --
|
||||
(QRegularExpression(r"--[^\n]*"), _COMMENT_FMT),
|
||||
# Single-line comment #
|
||||
(QRegularExpression(r"#[^\n]*"), _COMMENT_FMT),
|
||||
]
|
||||
|
||||
# Multi-line block comment /* ... */
|
||||
self._block_comment_start = QRegularExpression(r"/\*")
|
||||
self._block_comment_end = QRegularExpression(r"\*/")
|
||||
|
||||
def highlightBlock(self, text: str) -> None:
|
||||
# Single-line rules
|
||||
for pattern, fmt in self._rules:
|
||||
it = pattern.globalMatch(text)
|
||||
while it.hasNext():
|
||||
m = it.next()
|
||||
self.setFormat(m.capturedStart(), m.capturedLength(), fmt)
|
||||
|
||||
# Multi-line block comments
|
||||
self.setCurrentBlockState(0)
|
||||
start_idx = 0
|
||||
if self.previousBlockState() != 1:
|
||||
m = self._block_comment_start.match(text)
|
||||
start_idx = m.capturedStart() if m.hasMatch() else -1
|
||||
|
||||
while start_idx >= 0:
|
||||
end_m = self._block_comment_end.match(text, start_idx)
|
||||
if end_m.hasMatch():
|
||||
end_idx = end_m.capturedStart() + end_m.capturedLength()
|
||||
self.setFormat(start_idx, end_idx - start_idx, _COMMENT_FMT)
|
||||
m = self._block_comment_start.match(text, end_idx)
|
||||
start_idx = m.capturedStart() if m.hasMatch() else -1
|
||||
else:
|
||||
self.setCurrentBlockState(1)
|
||||
self.setFormat(start_idx, len(text) - start_idx, _COMMENT_FMT)
|
||||
break
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Table structure viewer — shows columns, indexes, foreign keys, and DDL.
|
||||
"""
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QTableWidget,
|
||||
QTableWidgetItem, QPlainTextEdit, QLabel, QPushButton, QHeaderView,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
|
||||
from app.ui.syntax_highlighter import SQLHighlighter
|
||||
from app.ui.column_dialog import ColumnDialog
|
||||
from app.utils.worker import SchemaWorker
|
||||
|
||||
|
||||
class TableStructureView(QWidget):
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, database: str, table: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._build_ui()
|
||||
self._load_all()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Header
|
||||
hdr = QHBoxLayout()
|
||||
hdr.setContentsMargins(8, 6, 8, 4)
|
||||
title = QLabel(f"🏗️ {self._table}")
|
||||
title.setObjectName("structureTitle")
|
||||
f = title.font()
|
||||
f.setBold(True)
|
||||
f.setPointSize(12)
|
||||
title.setFont(f)
|
||||
|
||||
refresh_btn = QPushButton("🔄 Refresh")
|
||||
refresh_btn.setFixedWidth(90)
|
||||
refresh_btn.clicked.connect(self._load_all)
|
||||
|
||||
hdr.addWidget(title)
|
||||
hdr.addStretch()
|
||||
hdr.addWidget(refresh_btn)
|
||||
root.addLayout(hdr)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._build_columns_tab(), "Columns")
|
||||
tabs.addTab(self._build_indexes_tab(), "Indexes")
|
||||
tabs.addTab(self._build_fks_tab(), "Foreign Keys")
|
||||
tabs.addTab(self._build_ddl_tab(), "DDL")
|
||||
root.addWidget(tabs)
|
||||
|
||||
# ── Tab builders ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_columns_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
lay = QVBoxLayout(w)
|
||||
lay.setContentsMargins(0, 4, 0, 0)
|
||||
|
||||
# Designer toolbar
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.setContentsMargins(4, 0, 4, 4)
|
||||
|
||||
self._add_col_btn = QPushButton("+ Add Column")
|
||||
self._add_col_btn.clicked.connect(self._add_column)
|
||||
|
||||
self._rename_col_btn = QPushButton("✏️ Rename")
|
||||
self._rename_col_btn.setEnabled(False)
|
||||
self._rename_col_btn.clicked.connect(self._rename_column)
|
||||
|
||||
self._drop_col_btn = QPushButton("🗑 Drop Column")
|
||||
self._drop_col_btn.setObjectName("deleteBtn")
|
||||
self._drop_col_btn.setEnabled(False)
|
||||
self._drop_col_btn.clicked.connect(self._drop_column)
|
||||
|
||||
toolbar.addWidget(self._add_col_btn)
|
||||
toolbar.addWidget(self._rename_col_btn)
|
||||
toolbar.addWidget(self._drop_col_btn)
|
||||
toolbar.addStretch()
|
||||
lay.addLayout(toolbar)
|
||||
|
||||
self._col_table = self._make_table([
|
||||
"Column", "Type", "Nullable", "Default", "PK", "FK", "Extra"
|
||||
])
|
||||
self._col_table.itemSelectionChanged.connect(self._on_col_selection)
|
||||
lay.addWidget(self._col_table)
|
||||
return w
|
||||
|
||||
def _build_indexes_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
lay = QVBoxLayout(w)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._idx_table = self._make_table([
|
||||
"Name", "Columns", "Unique", "Type"
|
||||
])
|
||||
lay.addWidget(self._idx_table)
|
||||
return w
|
||||
|
||||
def _build_fks_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
lay = QVBoxLayout(w)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._fk_table = self._make_table([
|
||||
"Name", "Column", "References", "On Update", "On Delete"
|
||||
])
|
||||
lay.addWidget(self._fk_table)
|
||||
return w
|
||||
|
||||
def _build_ddl_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
lay = QVBoxLayout(w)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._ddl_view = QPlainTextEdit()
|
||||
self._ddl_view.setReadOnly(True)
|
||||
self._ddl_view.setFont(QFont("Consolas", 12))
|
||||
SQLHighlighter(self._ddl_view.document())
|
||||
lay.addWidget(self._ddl_view)
|
||||
return w
|
||||
|
||||
@staticmethod
|
||||
def _make_table(headers: list) -> QTableWidget:
|
||||
t = QTableWidget(0, len(headers))
|
||||
t.setHorizontalHeaderLabels(headers)
|
||||
t.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
t.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
t.verticalHeader().setDefaultSectionSize(24)
|
||||
t.setAlternatingRowColors(True)
|
||||
t.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
t.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
return t
|
||||
|
||||
# ── Data loading ──────────────────────────────────────────────────────────
|
||||
|
||||
def _load_all(self):
|
||||
self._load_columns()
|
||||
self._load_indexes()
|
||||
self._load_fks()
|
||||
self._load_ddl()
|
||||
|
||||
def _load_columns(self):
|
||||
w = SchemaWorker(self._driver.get_columns,
|
||||
self._database, self._table, parent=self)
|
||||
w.result.connect(self._populate_columns)
|
||||
w.error.connect(lambda e: self.status_message.emit(f"Error: {e}"))
|
||||
w.start()
|
||||
|
||||
def _populate_columns(self, cols: list):
|
||||
t = self._col_table
|
||||
t.setRowCount(0)
|
||||
for col in cols:
|
||||
r = t.rowCount()
|
||||
t.insertRow(r)
|
||||
items = [
|
||||
col.name,
|
||||
col.data_type,
|
||||
"YES" if col.nullable else "NO",
|
||||
col.default or "",
|
||||
"✔" if col.is_primary_key else "",
|
||||
"✔" if col.is_foreign_key else "",
|
||||
col.extra,
|
||||
]
|
||||
for c, val in enumerate(items):
|
||||
item = QTableWidgetItem(str(val))
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if col.is_primary_key and c == 0:
|
||||
item.setForeground(QColor("#f9e2af"))
|
||||
elif col.is_foreign_key and c == 0:
|
||||
item.setForeground(QColor("#89dceb"))
|
||||
t.setItem(r, c, item)
|
||||
|
||||
def _load_indexes(self):
|
||||
w = SchemaWorker(self._driver.get_indexes,
|
||||
self._database, self._table, parent=self)
|
||||
w.result.connect(self._populate_indexes)
|
||||
w.error.connect(lambda e: self.status_message.emit(f"Error: {e}"))
|
||||
w.start()
|
||||
|
||||
def _populate_indexes(self, indexes: list):
|
||||
t = self._idx_table
|
||||
t.setRowCount(0)
|
||||
for idx in indexes:
|
||||
r = t.rowCount()
|
||||
t.insertRow(r)
|
||||
vals = [
|
||||
idx.name,
|
||||
", ".join(idx.columns),
|
||||
"✔" if idx.is_unique else "",
|
||||
idx.index_type,
|
||||
]
|
||||
for c, val in enumerate(vals):
|
||||
item = QTableWidgetItem(str(val))
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
t.setItem(r, c, item)
|
||||
|
||||
def _load_fks(self):
|
||||
w = SchemaWorker(self._driver.get_foreign_keys,
|
||||
self._database, self._table, parent=self)
|
||||
w.result.connect(self._populate_fks)
|
||||
w.error.connect(lambda e: self.status_message.emit(f"Error: {e}"))
|
||||
w.start()
|
||||
|
||||
def _populate_fks(self, fks: list):
|
||||
t = self._fk_table
|
||||
t.setRowCount(0)
|
||||
for fk in fks:
|
||||
r = t.rowCount()
|
||||
t.insertRow(r)
|
||||
vals = [
|
||||
fk.name,
|
||||
fk.column,
|
||||
f"{fk.ref_table}.{fk.ref_column}",
|
||||
fk.on_update,
|
||||
fk.on_delete,
|
||||
]
|
||||
for c, val in enumerate(vals):
|
||||
item = QTableWidgetItem(str(val))
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
t.setItem(r, c, item)
|
||||
|
||||
def _load_ddl(self):
|
||||
w = SchemaWorker(self._driver.get_table_ddl,
|
||||
self._database, self._table, parent=self)
|
||||
w.result.connect(self._ddl_view.setPlainText)
|
||||
w.error.connect(lambda e: self._ddl_view.setPlainText(f"Error: {e}"))
|
||||
w.start()
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_col_selection(self):
|
||||
has_sel = bool(self._col_table.selectedItems())
|
||||
self._rename_col_btn.setEnabled(has_sel)
|
||||
self._drop_col_btn.setEnabled(has_sel)
|
||||
|
||||
def _selected_col_name(self) -> str:
|
||||
row = self._col_table.currentRow()
|
||||
if row < 0:
|
||||
return ""
|
||||
item = self._col_table.item(row, 0)
|
||||
return item.text() if item else ""
|
||||
|
||||
def _add_column(self):
|
||||
dlg = ColumnDialog(mode="add",
|
||||
db_type=getattr(self._driver, "db_type", ""),
|
||||
parent=self)
|
||||
if not dlg.exec():
|
||||
return
|
||||
w = SchemaWorker(
|
||||
self._driver.add_column,
|
||||
self._database, self._table,
|
||||
dlg.column_name, dlg.column_type,
|
||||
dlg.nullable,
|
||||
dlg.default_value or None,
|
||||
parent=self,
|
||||
)
|
||||
w.result.connect(lambda _: (
|
||||
self.status_message.emit(
|
||||
f"Column '{dlg.column_name}' added to {self._table}."
|
||||
),
|
||||
self._load_all(),
|
||||
))
|
||||
w.error.connect(lambda e: QMessageBox.critical(
|
||||
self, "Add Column Error", f"Could not add column:\n{e}"
|
||||
))
|
||||
w.start()
|
||||
|
||||
def _drop_column(self):
|
||||
col = self._selected_col_name()
|
||||
if not col:
|
||||
return
|
||||
btn = QMessageBox.warning(
|
||||
self, "Drop Column",
|
||||
f"Drop column '{col}' from '{self._table}'?\n\nThis cannot be undone.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if btn != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
w = SchemaWorker(
|
||||
self._driver.drop_column,
|
||||
self._database, self._table, col,
|
||||
parent=self,
|
||||
)
|
||||
w.result.connect(lambda _: (
|
||||
self.status_message.emit(f"Column '{col}' dropped."),
|
||||
self._load_all(),
|
||||
))
|
||||
w.error.connect(lambda e: QMessageBox.critical(
|
||||
self, "Drop Column Error", f"Could not drop column:\n{e}"
|
||||
))
|
||||
w.start()
|
||||
|
||||
def _rename_column(self):
|
||||
old_name = self._selected_col_name()
|
||||
if not old_name:
|
||||
return
|
||||
dlg = ColumnDialog(mode="rename", column_name=old_name, parent=self)
|
||||
if not dlg.exec():
|
||||
return
|
||||
new_name = dlg.column_name
|
||||
if new_name == old_name:
|
||||
return
|
||||
w = SchemaWorker(
|
||||
self._driver.rename_column,
|
||||
self._database, self._table, old_name, new_name,
|
||||
parent=self,
|
||||
)
|
||||
w.result.connect(lambda _: (
|
||||
self.status_message.emit(
|
||||
f"Column '{old_name}' renamed to '{new_name}'."
|
||||
),
|
||||
self._load_all(),
|
||||
))
|
||||
w.error.connect(lambda e: QMessageBox.critical(
|
||||
self, "Rename Column Error", f"Could not rename column:\n{e}"
|
||||
))
|
||||
w.start()
|
||||
@@ -0,0 +1,716 @@
|
||||
"""
|
||||
Full table data viewer — paginated grid with full CRUD support.
|
||||
|
||||
Features:
|
||||
• Page-size selector: 50 / 100 / 500 / All
|
||||
• Add Row — dialog pre-filled with column names
|
||||
• Edit Row — double-click any cell to edit inline; dirty cells highlighted
|
||||
• Delete Row(s) — delete selected rows with confirmation
|
||||
• Filter (WHERE clause), Refresh
|
||||
• Commit / Rollback pending changes
|
||||
"""
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTableView, QLabel, QPushButton,
|
||||
QLineEdit, QHeaderView, QAbstractItemView, QMessageBox, QDialog,
|
||||
QFormLayout, QDialogButtonBox, QComboBox, QScrollArea,
|
||||
QMenu, QApplication,
|
||||
)
|
||||
from PyQt6.QtCore import (
|
||||
Qt, QAbstractTableModel, QModelIndex, pyqtSignal,
|
||||
)
|
||||
from PyQt6.QtGui import QColor, QBrush, QFont, QKeySequence, QShortcut
|
||||
|
||||
from app.utils.worker import TableDataWorker
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Editable table model
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class EditableTableModel(QAbstractTableModel):
|
||||
"""
|
||||
Tracks inline cell edits (dirty cells highlighted orange),
|
||||
new rows (pending insert), and rows marked for deletion.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._columns: list = []
|
||||
self._original: list = [] # pristine tuples
|
||||
self._data: list = [] # mutable lists
|
||||
self._dirty: set = set() # (row, col) changed cells
|
||||
self._pending_new: list = [] # list of dicts {col: val}
|
||||
self._pending_delete: list = [] # list of original tuples
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def set_data(self, columns: list, rows: list) -> None:
|
||||
self.beginResetModel()
|
||||
self._columns = list(columns)
|
||||
self._original = [tuple(r) for r in rows]
|
||||
self._data = [list(r) for r in rows]
|
||||
self._dirty.clear()
|
||||
self._pending_new.clear()
|
||||
self._pending_delete.clear()
|
||||
self.endResetModel()
|
||||
|
||||
def clear(self) -> None:
|
||||
self.set_data([], [])
|
||||
|
||||
@property
|
||||
def has_changes(self) -> bool:
|
||||
return bool(self._dirty or self._pending_new or self._pending_delete)
|
||||
|
||||
def column_names(self) -> list:
|
||||
return list(self._columns)
|
||||
|
||||
def get_row_original(self, logical_row: int) -> tuple:
|
||||
return self._original[logical_row]
|
||||
|
||||
def get_row_current(self, logical_row: int) -> list:
|
||||
return self._data[logical_row]
|
||||
|
||||
def get_dirty_rows(self) -> list:
|
||||
"""Return list of (new_data_list, original_tuple) for modified rows.
|
||||
Rows pending deletion are excluded — they don't need an UPDATE."""
|
||||
rows_changed = set(r for r, _ in self._dirty)
|
||||
return [
|
||||
(self._data[r], self._original[r])
|
||||
for r in sorted(rows_changed)
|
||||
if not self._is_deleted_row(r)
|
||||
]
|
||||
|
||||
def get_pending_new(self) -> list:
|
||||
return list(self._pending_new)
|
||||
|
||||
def get_pending_delete(self) -> list:
|
||||
return list(self._pending_delete)
|
||||
|
||||
def discard_changes(self) -> None:
|
||||
for row, col in self._dirty:
|
||||
if row < len(self._data):
|
||||
self._data[row][col] = self._original[row][col]
|
||||
self._dirty.clear()
|
||||
self._pending_new.clear()
|
||||
self._pending_delete.clear()
|
||||
self.layoutChanged.emit()
|
||||
|
||||
def mark_rows_deleted(self, logical_rows: list) -> None:
|
||||
"""Mark rows for deletion (they stay visible but tinted red until commit)."""
|
||||
for r in sorted(logical_rows, reverse=True):
|
||||
self._pending_delete.append(self._original[r])
|
||||
# Mark every cell in that row dirty-red via a special sentinel
|
||||
for c in range(len(self._columns)):
|
||||
self._dirty.add((r, c)) # will be styled via _pending_delete set
|
||||
self.layoutChanged.emit()
|
||||
|
||||
def add_pending_new(self, row_dict: dict) -> None:
|
||||
"""Queue a new row for insert."""
|
||||
self._pending_new.append(row_dict)
|
||||
|
||||
# ── QAbstractTableModel interface ─────────────────────────────────────────
|
||||
|
||||
def rowCount(self, _parent=QModelIndex()) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def columnCount(self, _parent=QModelIndex()) -> int:
|
||||
return len(self._columns)
|
||||
|
||||
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if orientation == Qt.Orientation.Horizontal:
|
||||
return self._columns[section] if section < len(self._columns) else ""
|
||||
return str(section + 1)
|
||||
if role == Qt.ItemDataRole.FontRole and orientation == Qt.Orientation.Horizontal:
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
return f
|
||||
return None
|
||||
|
||||
def _is_deleted_row(self, row: int) -> bool:
|
||||
if row >= len(self._original):
|
||||
return False
|
||||
return self._original[row] in self._pending_delete
|
||||
|
||||
def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid():
|
||||
return None
|
||||
r, c = index.row(), index.column()
|
||||
if r >= len(self._data) or c >= len(self._columns):
|
||||
return None
|
||||
val = self._data[r][c]
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
return "NULL" if val is None else str(val)
|
||||
|
||||
if role == Qt.ItemDataRole.EditRole:
|
||||
return "" if val is None else str(val)
|
||||
|
||||
if role == Qt.ItemDataRole.ForegroundRole:
|
||||
if self._is_deleted_row(r):
|
||||
return QBrush(QColor("#f38ba8")) # red — pending delete
|
||||
if (r, c) in self._dirty:
|
||||
return QBrush(QColor("#fab387")) # orange — edited
|
||||
if val is None:
|
||||
return QBrush(QColor("#6c7086")) # grey — NULL
|
||||
|
||||
if role == Qt.ItemDataRole.BackgroundRole:
|
||||
if self._is_deleted_row(r):
|
||||
return QBrush(QColor("#2a1a1e"))
|
||||
if (r, c) in self._dirty:
|
||||
return QBrush(QColor("#2a1f1a"))
|
||||
|
||||
return None
|
||||
|
||||
def setData(self, index: QModelIndex, value, role=Qt.ItemDataRole.EditRole) -> bool:
|
||||
if not index.isValid() or role != Qt.ItemDataRole.EditRole:
|
||||
return False
|
||||
r, c = index.row(), index.column()
|
||||
if self._is_deleted_row(r):
|
||||
return False # don't allow editing a row queued for delete
|
||||
old = self._data[r][c]
|
||||
new = value if value.strip() != "" else None
|
||||
if str(old) == str(new):
|
||||
return False
|
||||
self._data[r][c] = new
|
||||
self._dirty.add((r, c))
|
||||
self.dataChanged.emit(index, index, [role])
|
||||
return True
|
||||
|
||||
def flags(self, _index: QModelIndex):
|
||||
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Password helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_PASSWORD_COL_NAMES = frozenset({
|
||||
"password", "passwd", "pwd", "pass",
|
||||
"password_hash", "hashed_password", "passhash", "pass_hash",
|
||||
"user_password", "account_password",
|
||||
})
|
||||
|
||||
def _is_password_col(col_name: str) -> bool:
|
||||
n = col_name.lower()
|
||||
return n in _PASSWORD_COL_NAMES or n.endswith(("_password", "_passwd", "_pwd"))
|
||||
|
||||
def _hash_password(plain: str) -> str:
|
||||
"""Hash a plain-text password with bcrypt (falls back to PBKDF2)."""
|
||||
try:
|
||||
import bcrypt
|
||||
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode()
|
||||
except ImportError:
|
||||
import hashlib, os, base64
|
||||
salt = os.urandom(16)
|
||||
key = hashlib.pbkdf2_hmac("sha256", plain.encode("utf-8"), salt, 260_000)
|
||||
return "pbkdf2:sha256:" + base64.b64encode(salt + key).decode()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Add / Edit row dialog
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class RowDialog(QDialog):
|
||||
"""
|
||||
Form dialog for adding or editing a single row.
|
||||
|
||||
Password-like columns (password, passwd, pwd, …) are shown with masked
|
||||
input. On save the plain text is hashed automatically with bcrypt.
|
||||
|
||||
In *edit* mode an empty password field means "keep the existing hash" —
|
||||
the column is omitted from the returned values dict entirely.
|
||||
"""
|
||||
|
||||
def __init__(self, columns: list, initial: dict = None,
|
||||
title: str = "Add Row", mode: str = "add", parent=None):
|
||||
super().__init__(parent)
|
||||
self._mode = mode # "add" | "edit"
|
||||
self.setWindowTitle(title)
|
||||
self.setMinimumWidth(420)
|
||||
self.setModal(True)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# Scrollable form area (for tables with many columns)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
form_widget = QWidget()
|
||||
form = QFormLayout(form_widget)
|
||||
form.setSpacing(8)
|
||||
form.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
self._fields: dict[str, QLineEdit] = {}
|
||||
for col in columns:
|
||||
le = QLineEdit()
|
||||
if _is_password_col(col):
|
||||
le.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
if mode == "edit":
|
||||
le.setPlaceholderText("Leave blank to keep current password")
|
||||
else:
|
||||
le.setPlaceholderText("Enter password")
|
||||
else:
|
||||
le.setPlaceholderText("NULL")
|
||||
if initial and col in initial and initial[col] is not None:
|
||||
le.setText(str(initial[col]))
|
||||
form.addRow(f"{col}:", le)
|
||||
self._fields[col] = le
|
||||
|
||||
scroll.setWidget(form_widget)
|
||||
root.addWidget(scroll)
|
||||
|
||||
bbox = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok |
|
||||
QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
bbox.accepted.connect(self.accept)
|
||||
bbox.rejected.connect(self.reject)
|
||||
root.addWidget(bbox)
|
||||
|
||||
@property
|
||||
def values(self) -> dict:
|
||||
"""Return {col: value_or_None} for all fields.
|
||||
|
||||
Rules for password columns:
|
||||
• Non-empty → hash the plain text and store the hash.
|
||||
• Empty + add mode → store None (let the DB default apply).
|
||||
• Empty + edit mode → column is *omitted* (caller must not update it).
|
||||
"""
|
||||
result = {}
|
||||
for col, le in self._fields.items():
|
||||
txt = le.text()
|
||||
if _is_password_col(col):
|
||||
if txt:
|
||||
result[col] = _hash_password(txt)
|
||||
elif self._mode == "add":
|
||||
result[col] = None
|
||||
# edit + empty → omit; caller preserves existing hash
|
||||
else:
|
||||
stripped = txt.strip()
|
||||
result[col] = stripped if stripped else None
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main TableViewer widget
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_PAGE_OPTIONS = [("50", 50), ("100", 100), ("All", 0)]
|
||||
|
||||
|
||||
class TableViewer(QWidget):
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, database: str, table: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._offset = 0
|
||||
self._total = 0
|
||||
self._page_size = 100 # default
|
||||
self._model = EditableTableModel()
|
||||
self._worker: TableDataWorker | None = None
|
||||
self._build_ui()
|
||||
self._load_page()
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# ── Top toolbar ────────────────────────────────────────────────────────
|
||||
tb = QHBoxLayout()
|
||||
tb.setContentsMargins(8, 6, 8, 4)
|
||||
tb.setSpacing(6)
|
||||
|
||||
# Filter input
|
||||
self._filter_input = QLineEdit()
|
||||
self._filter_input.setPlaceholderText("WHERE … (e.g. id > 100)")
|
||||
self._filter_input.setFixedWidth(260)
|
||||
self._filter_btn = QPushButton("🔍")
|
||||
self._filter_btn.setFixedWidth(34)
|
||||
self._filter_btn.setToolTip("Apply filter")
|
||||
self._filter_btn.clicked.connect(self._apply_filter)
|
||||
self._filter_input.returnPressed.connect(self._apply_filter)
|
||||
|
||||
clear_filter_btn = QPushButton("✕")
|
||||
clear_filter_btn.setFixedWidth(30)
|
||||
clear_filter_btn.setToolTip("Clear filter")
|
||||
clear_filter_btn.clicked.connect(self._clear_filter)
|
||||
|
||||
self._refresh_btn = QPushButton("🔄 Refresh")
|
||||
self._refresh_btn.clicked.connect(self._load_page)
|
||||
|
||||
# Separator
|
||||
sep = QLabel("|")
|
||||
sep.setStyleSheet("color:#45475a; padding:0 4px;")
|
||||
|
||||
# CRUD buttons
|
||||
self._add_btn = QPushButton("➕ Add")
|
||||
self._edit_btn = QPushButton("✏️ Edit")
|
||||
self._delete_btn = QPushButton("🗑️ Delete")
|
||||
self._add_btn.setObjectName("crudAddBtn")
|
||||
self._delete_btn.setObjectName("crudDeleteBtn")
|
||||
self._add_btn.setFixedWidth(74)
|
||||
self._edit_btn.setFixedWidth(74)
|
||||
self._delete_btn.setFixedWidth(80)
|
||||
self._edit_btn.setEnabled(False)
|
||||
self._delete_btn.setEnabled(False)
|
||||
self._add_btn.clicked.connect(self._add_row)
|
||||
self._edit_btn.clicked.connect(self._edit_selected)
|
||||
self._delete_btn.clicked.connect(self._delete_selected)
|
||||
|
||||
tb.addWidget(self._filter_input)
|
||||
tb.addWidget(self._filter_btn)
|
||||
tb.addWidget(clear_filter_btn)
|
||||
tb.addWidget(self._refresh_btn)
|
||||
tb.addWidget(sep)
|
||||
tb.addWidget(self._add_btn)
|
||||
tb.addWidget(self._edit_btn)
|
||||
tb.addWidget(self._delete_btn)
|
||||
tb.addStretch()
|
||||
root.addLayout(tb)
|
||||
|
||||
# ── Table view ─────────────────────────────────────────────────────────
|
||||
self._table_view = QTableView()
|
||||
self._table_view.setModel(self._model)
|
||||
self._table_view.setAlternatingRowColors(True)
|
||||
self._table_view.setSortingEnabled(False)
|
||||
self._table_view.setSelectionBehavior(
|
||||
QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self._table_view.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self._table_view.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Interactive)
|
||||
self._table_view.horizontalHeader().setStretchLastSection(True)
|
||||
self._table_view.verticalHeader().setDefaultSectionSize(24)
|
||||
self._table_view.setContextMenuPolicy(
|
||||
Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._table_view.customContextMenuRequested.connect(self._context_menu)
|
||||
self._table_view.doubleClicked.connect(self._on_double_click)
|
||||
# Track selection to enable/disable Edit/Delete buttons.
|
||||
# Use both signals: clicked covers mouse, selectionChanged covers keyboard.
|
||||
self._table_view.clicked.connect(lambda _: self._refresh_action_states())
|
||||
self._table_view.selectionModel().selectionChanged.connect(
|
||||
self._on_selection_changed)
|
||||
|
||||
# Keyboard shortcuts
|
||||
QShortcut(QKeySequence("Delete"), self._table_view,
|
||||
self._delete_selected)
|
||||
QShortcut(QKeySequence("Ins"), self._table_view, self._add_row)
|
||||
|
||||
root.addWidget(self._table_view, 1)
|
||||
|
||||
# ── Pagination bar (prominent, always visible) ─────────────────────────
|
||||
pg_frame = QWidget()
|
||||
pg_frame.setObjectName("paginationBar")
|
||||
pg = QHBoxLayout(pg_frame)
|
||||
pg.setContentsMargins(10, 6, 10, 6)
|
||||
pg.setSpacing(4)
|
||||
|
||||
self._first_btn = QPushButton("⏮ First")
|
||||
self._prev_btn = QPushButton("◀ Prev")
|
||||
self._next_btn = QPushButton("Next ▶")
|
||||
self._last_btn = QPushButton("Last ⏭")
|
||||
for b in (self._first_btn, self._prev_btn,
|
||||
self._next_btn, self._last_btn):
|
||||
b.setFixedWidth(80)
|
||||
b.setObjectName("pgBtn")
|
||||
self._first_btn.clicked.connect(self._first_page)
|
||||
self._prev_btn.clicked.connect(self._prev_page)
|
||||
self._next_btn.clicked.connect(self._next_page)
|
||||
self._last_btn.clicked.connect(self._last_page)
|
||||
|
||||
self._page_lbl = QLabel("Page 1 / ?")
|
||||
self._page_lbl.setObjectName("pageLbl")
|
||||
self._page_lbl.setMinimumWidth(110)
|
||||
self._page_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Page jump
|
||||
self._goto_input = QLineEdit()
|
||||
self._goto_input.setPlaceholderText("Jump to page…")
|
||||
self._goto_input.setFixedWidth(110)
|
||||
self._goto_input.returnPressed.connect(self._goto_page)
|
||||
goto_btn = QPushButton("Go")
|
||||
goto_btn.setFixedWidth(40)
|
||||
goto_btn.clicked.connect(self._goto_page)
|
||||
|
||||
# Page size selector
|
||||
self._page_size_combo = QComboBox()
|
||||
for label, _ in _PAGE_OPTIONS:
|
||||
self._page_size_combo.addItem(label)
|
||||
self._page_size_combo.setCurrentIndex(1) # default = 100
|
||||
self._page_size_combo.setFixedWidth(72)
|
||||
self._page_size_combo.setToolTip("Rows per page")
|
||||
self._page_size_combo.currentIndexChanged.connect(self._on_page_size_changed)
|
||||
|
||||
self._total_lbl = QLabel("")
|
||||
self._total_lbl.setObjectName("rowCountLbl")
|
||||
|
||||
pg.addWidget(self._first_btn)
|
||||
pg.addWidget(self._prev_btn)
|
||||
pg.addWidget(self._page_lbl)
|
||||
pg.addWidget(self._next_btn)
|
||||
pg.addWidget(self._last_btn)
|
||||
pg.addSpacing(10)
|
||||
pg.addWidget(self._goto_input)
|
||||
pg.addWidget(goto_btn)
|
||||
pg.addStretch()
|
||||
pg.addWidget(QLabel("Rows / page:"))
|
||||
pg.addWidget(self._page_size_combo)
|
||||
pg.addSpacing(8)
|
||||
pg.addWidget(self._total_lbl)
|
||||
root.addWidget(pg_frame)
|
||||
|
||||
# ── Page size ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_page_size_changed(self, idx: int):
|
||||
self._page_size = _PAGE_OPTIONS[idx][1]
|
||||
self._offset = 0
|
||||
self._load_page()
|
||||
|
||||
# ── Data loading ───────────────────────────────────────────────────────────
|
||||
|
||||
def _load_page(self):
|
||||
where = self._filter_input.text().strip()
|
||||
limit = self._page_size if self._page_size > 0 else 999_999_999
|
||||
self._worker = TableDataWorker(
|
||||
self._driver, self._database, self._table,
|
||||
where=where, limit=limit, offset=self._offset
|
||||
)
|
||||
self._worker.finished.connect(self._on_data)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_data(self, cols: list, rows: list, total: int):
|
||||
self._model.set_data(cols, rows)
|
||||
self._total = total
|
||||
self._refresh_pagination()
|
||||
self._refresh_action_states()
|
||||
self._table_view.resizeColumnsToContents()
|
||||
self._table_view.horizontalHeader().setStretchLastSection(True)
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
self._total_lbl.setText(f"Error: {msg[:80]}")
|
||||
self.status_message.emit(f"Error: {msg}")
|
||||
|
||||
def _refresh_pagination(self):
|
||||
ps = self._page_size if self._page_size > 0 else max(self._total, 1)
|
||||
rows_loaded = self._model.rowCount()
|
||||
current_page = (self._offset // ps + 1) if ps else 1
|
||||
|
||||
if self._total > 0:
|
||||
total_pages = max(1, (self._total + ps - 1) // ps)
|
||||
page_str = f"Page {current_page} / {total_pages}"
|
||||
# showing X – Y of N
|
||||
row_from = self._offset + 1
|
||||
row_to = min(self._offset + rows_loaded, self._total)
|
||||
info_str = f"Showing {row_from:,} – {row_to:,} of {self._total:,} rows"
|
||||
else:
|
||||
# total count unavailable – use row count as best guess
|
||||
total_pages = current_page # don't know max
|
||||
page_str = f"Page {current_page}"
|
||||
info_str = f"{rows_loaded:,} rows loaded"
|
||||
|
||||
self._page_lbl.setText(page_str)
|
||||
self._total_lbl.setText(info_str)
|
||||
|
||||
at_first = (self._offset == 0)
|
||||
# Next is allowed if: we have a known total and haven't reached it,
|
||||
# OR if a full page was returned (more rows might exist)
|
||||
if self._page_size > 0:
|
||||
if self._total > 0:
|
||||
at_last = (self._offset + ps) >= self._total
|
||||
else:
|
||||
at_last = rows_loaded < ps # partial page → must be last
|
||||
else:
|
||||
at_last = True # "All" mode
|
||||
|
||||
self._first_btn.setEnabled(not at_first)
|
||||
self._prev_btn.setEnabled(not at_first)
|
||||
self._next_btn.setEnabled(not at_last)
|
||||
self._last_btn.setEnabled(not at_last)
|
||||
|
||||
# ── Pagination controls ────────────────────────────────────────────────────
|
||||
|
||||
def _first_page(self):
|
||||
self._offset = 0
|
||||
self._load_page()
|
||||
|
||||
def _prev_page(self):
|
||||
ps = self._page_size if self._page_size > 0 else self._total
|
||||
self._offset = max(0, self._offset - ps)
|
||||
self._load_page()
|
||||
|
||||
def _next_page(self):
|
||||
ps = self._page_size if self._page_size > 0 else self._total
|
||||
self._offset = min(self._offset + ps,
|
||||
max(0, self._total - ps))
|
||||
self._load_page()
|
||||
|
||||
def _last_page(self):
|
||||
ps = self._page_size if self._page_size > 0 else self._total
|
||||
self._offset = max(0, ((self._total - 1) // ps) * ps) if ps else 0
|
||||
self._load_page()
|
||||
|
||||
def _goto_page(self):
|
||||
ps = self._page_size if self._page_size > 0 else max(self._total, 1)
|
||||
try:
|
||||
page = int(self._goto_input.text().strip())
|
||||
total_pages = max(1, (self._total + ps - 1) // ps)
|
||||
page = max(1, min(page, total_pages))
|
||||
self._offset = (page - 1) * ps
|
||||
self._goto_input.clear()
|
||||
self._load_page()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _apply_filter(self):
|
||||
self._offset = 0
|
||||
self._load_page()
|
||||
|
||||
def _clear_filter(self):
|
||||
self._filter_input.clear()
|
||||
self._offset = 0
|
||||
self._load_page()
|
||||
|
||||
# ── Selection tracking ─────────────────────────────────────────────────────
|
||||
|
||||
def _on_selection_changed(self, *_):
|
||||
has_sel = bool(self._table_view.selectionModel().selectedRows())
|
||||
self._edit_btn.setEnabled(has_sel)
|
||||
self._delete_btn.setEnabled(has_sel)
|
||||
|
||||
def _refresh_action_states(self, *_):
|
||||
self._on_selection_changed()
|
||||
|
||||
def _selected_logical_rows(self) -> list:
|
||||
"""Return sorted list of logical (source model) row indices."""
|
||||
return sorted(set(
|
||||
idx.row() for idx in self._table_view.selectionModel().selectedRows()
|
||||
))
|
||||
|
||||
# ── Context menu ───────────────────────────────────────────────────────────
|
||||
|
||||
def _context_menu(self, pos):
|
||||
rows = self._selected_logical_rows()
|
||||
menu = QMenu(self)
|
||||
menu.addAction("➕ Add Row", self._add_row)
|
||||
if rows:
|
||||
menu.addAction("✏️ Edit Row", self._edit_selected)
|
||||
menu.addAction("🗑️ Delete Row", self._delete_selected)
|
||||
menu.addSeparator()
|
||||
menu.addAction("📋 Copy cell value", lambda: self._copy_cell(pos))
|
||||
menu.addAction("📋 Copy row", self._copy_selected_rows)
|
||||
menu.exec(self._table_view.viewport().mapToGlobal(pos))
|
||||
|
||||
# ── Double-click: edit ────────────────────────────────────────────────────
|
||||
|
||||
def _on_double_click(self, index: QModelIndex):
|
||||
"""Open edit dialog for the double-clicked row."""
|
||||
row = index.row()
|
||||
cols = self._model.column_names()
|
||||
current = {cols[c]: self._model.get_row_current(row)[c]
|
||||
for c in range(len(cols))}
|
||||
dlg = RowDialog(cols, initial=current, title=f"Edit Row — {self._table}",
|
||||
mode="edit", parent=self)
|
||||
if dlg.exec():
|
||||
new_vals = dlg.values
|
||||
original = self._model.get_row_original(row)
|
||||
where = {cols[i]: original[i] for i in range(len(cols))}
|
||||
try:
|
||||
self._driver.update_row(
|
||||
self._database, self._table, new_vals, where
|
||||
)
|
||||
self.status_message.emit("Row updated.")
|
||||
self._load_page()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Update Error", str(e))
|
||||
|
||||
# ── CRUD actions ──────────────────────────────────────────────────────────
|
||||
|
||||
def _add_row(self):
|
||||
cols = self._model.column_names()
|
||||
if not cols:
|
||||
# No data loaded yet — try fetching column names
|
||||
try:
|
||||
col_infos = self._driver.get_columns(self._database, self._table)
|
||||
cols = [c.name for c in col_infos]
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
return
|
||||
|
||||
dlg = RowDialog(cols, title=f"Add Row — {self._table}", mode="add", parent=self)
|
||||
if dlg.exec():
|
||||
row_data = {k: v for k, v in dlg.values.items()}
|
||||
# Remove purely empty optional fields (let DB use defaults)
|
||||
row_data = {k: v for k, v in row_data.items() if v is not None}
|
||||
try:
|
||||
self._driver.insert_row(self._database, self._table, row_data)
|
||||
self.status_message.emit("Row inserted.")
|
||||
self._load_page()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Insert Error", str(e))
|
||||
|
||||
def _edit_selected(self):
|
||||
rows = self._selected_logical_rows()
|
||||
if not rows:
|
||||
return
|
||||
if len(rows) > 1:
|
||||
QMessageBox.information(self, "Edit Row",
|
||||
"Please select a single row to edit.")
|
||||
return
|
||||
self._on_double_click(self._model.index(rows[0], 0))
|
||||
|
||||
def _delete_selected(self):
|
||||
rows = self._selected_logical_rows()
|
||||
if not rows:
|
||||
return
|
||||
n = len(rows)
|
||||
btn = QMessageBox.warning(
|
||||
self, "Delete Row(s)",
|
||||
f"Are you sure you want to delete {n} row(s)?\n"
|
||||
"This action will be sent to the database immediately.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if btn != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
cols = self._model.column_names()
|
||||
errors = []
|
||||
for r in rows:
|
||||
original = self._model.get_row_original(r)
|
||||
where = {cols[i]: original[i] for i in range(len(cols))}
|
||||
try:
|
||||
self._driver.delete_row(self._database, self._table, where)
|
||||
except Exception as e:
|
||||
errors.append(str(e))
|
||||
|
||||
if errors:
|
||||
QMessageBox.critical(self, "Delete Error", "\n".join(errors))
|
||||
else:
|
||||
self.status_message.emit(f"{n} row(s) deleted.")
|
||||
self._load_page()
|
||||
|
||||
|
||||
# ── Copy helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _copy_cell(self, pos):
|
||||
idx = self._table_view.indexAt(pos)
|
||||
if idx.isValid():
|
||||
val = self._model.data(idx, Qt.ItemDataRole.DisplayRole) or ""
|
||||
QApplication.clipboard().setText(str(val))
|
||||
|
||||
def _copy_selected_rows(self):
|
||||
rows = self._selected_logical_rows()
|
||||
cols = self._model.column_names()
|
||||
lines = ["\t".join(cols)]
|
||||
for r in rows:
|
||||
row_data = self._model.get_row_current(r)
|
||||
lines.append("\t".join("" if v is None else str(v) for v in row_data))
|
||||
QApplication.clipboard().setText("\n".join(lines))
|
||||
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
User & Privilege Management panel.
|
||||
|
||||
Supports MySQL and PostgreSQL.
|
||||
SQLite and MSSQL show an informational message (no user management via this UI).
|
||||
|
||||
Features
|
||||
--------
|
||||
• List all database users with host (MySQL) or connection limit (PostgreSQL)
|
||||
• Create user (username + password + optional host for MySQL)
|
||||
• Drop user with confirmation
|
||||
• View per-database GRANT privileges for the selected user
|
||||
• GRANT / REVOKE individual privileges on a database
|
||||
|
||||
All DDL operations execute synchronously in a background QThread to keep
|
||||
the UI responsive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
||||
QTableWidget, QTableWidgetItem, QHeaderView,
|
||||
QLabel, QPushButton, QComboBox, QCheckBox,
|
||||
QDialog, QFormLayout, QLineEdit, QDialogButtonBox,
|
||||
QMessageBox, QGroupBox, QAbstractItemView,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
|
||||
|
||||
# ── Standard SQL privileges offered in the GRANT UI ──────────────────────────
|
||||
|
||||
_MYSQL_PRIVS = [
|
||||
"SELECT", "INSERT", "UPDATE", "DELETE",
|
||||
"CREATE", "DROP", "ALTER", "INDEX",
|
||||
"CREATE VIEW", "SHOW VIEW",
|
||||
"CREATE ROUTINE", "ALTER ROUTINE", "EXECUTE",
|
||||
"REFERENCES", "TRIGGER", "LOCK TABLES",
|
||||
"CREATE TEMPORARY TABLES",
|
||||
]
|
||||
|
||||
_PG_PRIVS = [
|
||||
"SELECT", "INSERT", "UPDATE", "DELETE",
|
||||
"TRUNCATE", "REFERENCES", "TRIGGER",
|
||||
"CREATE", "CONNECT", "TEMPORARY",
|
||||
"EXECUTE", "USAGE",
|
||||
]
|
||||
|
||||
|
||||
# ── Background worker ─────────────────────────────────────────────────────────
|
||||
|
||||
class _UserWorker(QThread):
|
||||
"""Run an arbitrary callable in a background thread."""
|
||||
result = pyqtSignal(object)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, fn, *args, parent=None):
|
||||
super().__init__(parent)
|
||||
self._fn = fn
|
||||
self._args = args
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.result.emit(self._fn(*self._args))
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
# ── Driver-level helpers (attached at runtime, no driver subclassing needed) ──
|
||||
|
||||
def _mysql_get_users(driver) -> list[dict]:
|
||||
"""Return list of {user, host, super} from MySQL.user table."""
|
||||
cols, rows, _ = driver.execute_query(
|
||||
"SELECT User, Host, Super_priv FROM mysql.user ORDER BY User, Host"
|
||||
)
|
||||
return [{"user": r[0], "host": r[1], "super": r[2]} for r in rows]
|
||||
|
||||
|
||||
def _mysql_get_grants(driver, user: str, host: str) -> list[str]:
|
||||
cols, rows, _ = driver.execute_query(
|
||||
f"SHOW GRANTS FOR %s@%s", (user, host) # type: ignore[arg-type]
|
||||
)
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _mysql_create_user(driver, user: str, host: str, password: str):
|
||||
driver.execute_query(
|
||||
"CREATE USER %s@%s IDENTIFIED BY %s", (user, host, password)
|
||||
)
|
||||
|
||||
|
||||
def _mysql_drop_user(driver, user: str, host: str):
|
||||
driver.execute_query(f"DROP USER %s@%s", (user, host))
|
||||
|
||||
|
||||
def _mysql_grant(driver, privs: list[str], database: str,
|
||||
user: str, host: str):
|
||||
priv_str = ", ".join(privs) if privs else "USAGE"
|
||||
driver.execute_query(
|
||||
f"GRANT {priv_str} ON `{database}`.* TO %s@%s", (user, host)
|
||||
)
|
||||
driver.execute_query("FLUSH PRIVILEGES")
|
||||
|
||||
|
||||
def _mysql_revoke(driver, privs: list[str], database: str,
|
||||
user: str, host: str):
|
||||
priv_str = ", ".join(privs) if privs else "USAGE"
|
||||
driver.execute_query(
|
||||
f"REVOKE {priv_str} ON `{database}`.* FROM %s@%s", (user, host)
|
||||
)
|
||||
driver.execute_query("FLUSH PRIVILEGES")
|
||||
|
||||
|
||||
# ── PostgreSQL helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _pg_get_users(driver) -> list[dict]:
|
||||
cols, rows, _ = driver.execute_query(
|
||||
"SELECT usename, usesuper, usecreatedb FROM pg_user ORDER BY usename"
|
||||
)
|
||||
return [{"user": r[0], "host": "", "super": "Y" if r[1] else "N",
|
||||
"createdb": "Y" if r[2] else "N"} for r in rows]
|
||||
|
||||
|
||||
def _pg_get_grants(driver, user: str, _host: str) -> list[str]:
|
||||
cols, rows, _ = driver.execute_query("""
|
||||
SELECT grantee, table_catalog, table_schema, table_name,
|
||||
string_agg(privilege_type, ', ') AS privileges
|
||||
FROM information_schema.role_table_grants
|
||||
WHERE grantee = %s
|
||||
GROUP BY grantee, table_catalog, table_schema, table_name
|
||||
ORDER BY table_catalog, table_schema, table_name
|
||||
""", (user,))
|
||||
return [
|
||||
f"GRANT {r[4]} ON {r[1]}.{r[2]}.{r[3]} TO {r[0]}"
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _pg_create_user(driver, user: str, _host: str, password: str):
|
||||
driver.execute_query(
|
||||
f"CREATE USER \"{user}\" WITH PASSWORD %s", (password,)
|
||||
)
|
||||
|
||||
|
||||
def _pg_drop_user(driver, user: str, _host: str):
|
||||
driver.execute_query(f'DROP USER "{user}"')
|
||||
|
||||
|
||||
def _pg_grant(driver, privs: list[str], database: str, user: str, _host: str):
|
||||
priv_str = ", ".join(privs) if privs else "CONNECT"
|
||||
driver.execute_query(
|
||||
f'GRANT {priv_str} ON DATABASE "{database}" TO "{user}"'
|
||||
)
|
||||
|
||||
|
||||
def _pg_revoke(driver, privs: list[str], database: str, user: str, _host: str):
|
||||
priv_str = ", ".join(privs) if privs else "CONNECT"
|
||||
driver.execute_query(
|
||||
f'REVOKE {priv_str} ON DATABASE "{database}" FROM "{user}"'
|
||||
)
|
||||
|
||||
|
||||
# ── Dialogs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class _CreateUserDialog(QDialog):
|
||||
def __init__(self, db_type: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Create User")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
self._db_type = db_type
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
form.setSpacing(8)
|
||||
|
||||
self._user_edit = QLineEdit()
|
||||
self._user_edit.setPlaceholderText("e.g. app_user")
|
||||
form.addRow("Username:", self._user_edit)
|
||||
|
||||
if db_type == "mysql":
|
||||
self._host_edit = QLineEdit()
|
||||
self._host_edit.setText("%")
|
||||
self._host_edit.setPlaceholderText("% = any host")
|
||||
form.addRow("Host:", self._host_edit)
|
||||
|
||||
self._pass_edit = QLineEdit()
|
||||
self._pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._pass_edit.setPlaceholderText("Password")
|
||||
form.addRow("Password:", self._pass_edit)
|
||||
|
||||
self._pass2_edit = QLineEdit()
|
||||
self._pass2_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._pass2_edit.setPlaceholderText("Confirm password")
|
||||
form.addRow("Confirm:", self._pass2_edit)
|
||||
|
||||
root.addLayout(form)
|
||||
|
||||
bbox = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok |
|
||||
QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
bbox.accepted.connect(self._validate)
|
||||
bbox.rejected.connect(self.reject)
|
||||
root.addWidget(bbox)
|
||||
|
||||
def _validate(self):
|
||||
if not self._user_edit.text().strip():
|
||||
QMessageBox.warning(self, "Validation", "Username is required.")
|
||||
return
|
||||
if self._pass_edit.text() != self._pass2_edit.text():
|
||||
QMessageBox.warning(self, "Validation", "Passwords do not match.")
|
||||
return
|
||||
self.accept()
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
return self._user_edit.text().strip()
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
if self._db_type == "mysql":
|
||||
return self._host_edit.text().strip() or "%"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def password(self) -> str:
|
||||
return self._pass_edit.text()
|
||||
|
||||
|
||||
class _GrantDialog(QDialog):
|
||||
"""Select privileges + target database and grant/revoke."""
|
||||
|
||||
def __init__(self, driver, db_type: str, databases: list[str],
|
||||
user: str, host: str, mode: str = "grant", parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._db_type = db_type
|
||||
self._user = user
|
||||
self._host = host
|
||||
self._mode = mode # "grant" | "revoke"
|
||||
self.setWindowTitle(f"{'Grant to' if mode == 'grant' else 'Revoke from'} {user}")
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(420, 480)
|
||||
self._build_ui(databases)
|
||||
|
||||
def _build_ui(self, databases: list[str]):
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
form = QFormLayout()
|
||||
self._db_combo = QComboBox()
|
||||
for db in databases:
|
||||
self._db_combo.addItem(db)
|
||||
form.addRow("Database:", self._db_combo)
|
||||
root.addLayout(form)
|
||||
|
||||
# Privilege checkboxes
|
||||
priv_box = QGroupBox("Privileges")
|
||||
pb_lay = QVBoxLayout(priv_box)
|
||||
privs = _MYSQL_PRIVS if self._db_type == "mysql" else _PG_PRIVS
|
||||
|
||||
self._priv_checks: list[QCheckBox] = []
|
||||
for priv in privs:
|
||||
cb = QCheckBox(priv)
|
||||
pb_lay.addWidget(cb)
|
||||
self._priv_checks.append(cb)
|
||||
|
||||
sel_row = QHBoxLayout()
|
||||
sel_all = QPushButton("All")
|
||||
sel_all.setFixedWidth(50)
|
||||
sel_all.clicked.connect(lambda: [cb.setChecked(True)
|
||||
for cb in self._priv_checks])
|
||||
sel_none = QPushButton("None")
|
||||
sel_none.setFixedWidth(50)
|
||||
sel_none.clicked.connect(lambda: [cb.setChecked(False)
|
||||
for cb in self._priv_checks])
|
||||
sel_row.addWidget(sel_all)
|
||||
sel_row.addWidget(sel_none)
|
||||
sel_row.addStretch()
|
||||
pb_lay.insertLayout(0, sel_row)
|
||||
|
||||
root.addWidget(priv_box, 1)
|
||||
|
||||
verb = "Grant" if self._mode == "grant" else "Revoke"
|
||||
bbox = QDialogButtonBox()
|
||||
ok = bbox.addButton(verb, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
ok.clicked.connect(self._execute)
|
||||
bbox.addButton(QDialogButtonBox.StandardButton.Cancel).clicked.connect(
|
||||
self.reject)
|
||||
root.addWidget(bbox)
|
||||
|
||||
def _execute(self):
|
||||
privs = [cb.text() for cb in self._priv_checks if cb.isChecked()]
|
||||
if not privs:
|
||||
QMessageBox.warning(self, "No Privilege",
|
||||
"Select at least one privilege.")
|
||||
return
|
||||
db = self._db_combo.currentText()
|
||||
|
||||
try:
|
||||
if self._db_type == "mysql":
|
||||
fn = _mysql_grant if self._mode == "grant" else _mysql_revoke
|
||||
else:
|
||||
fn = _pg_grant if self._mode == "grant" else _pg_revoke
|
||||
fn(self._driver, privs, db, self._user, self._host)
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
@property
|
||||
def selected_privs(self) -> list[str]:
|
||||
return [cb.text() for cb in self._priv_checks if cb.isChecked()]
|
||||
|
||||
|
||||
# ── Main Panel ────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserManagerPanel(QWidget):
|
||||
"""
|
||||
User & privilege management workspace tab.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
driver : BaseDriver — must already be connected
|
||||
parent : QWidget
|
||||
"""
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._db_type = getattr(driver, "db_type", "").lower()
|
||||
self._databases: list[str] = []
|
||||
self._current_user: str = ""
|
||||
self._current_host: str = ""
|
||||
self._worker: _UserWorker | None = None
|
||||
self._build_ui()
|
||||
self._load_users()
|
||||
self._load_databases()
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# ── Header bar ────────────────────────────────────────────────────────
|
||||
hdr = QHBoxLayout()
|
||||
hdr.setContentsMargins(8, 6, 8, 6)
|
||||
hdr.setSpacing(6)
|
||||
|
||||
title = QLabel("👤 User & Privilege Management")
|
||||
f = title.font()
|
||||
f.setBold(True)
|
||||
f.setPointSize(11)
|
||||
title.setFont(f)
|
||||
title.setObjectName("structureTitle")
|
||||
|
||||
self._refresh_btn = QPushButton("🔄 Refresh")
|
||||
self._refresh_btn.setFixedWidth(90)
|
||||
self._refresh_btn.clicked.connect(self._load_users)
|
||||
|
||||
hdr.addWidget(title)
|
||||
hdr.addStretch()
|
||||
hdr.addWidget(self._refresh_btn)
|
||||
root.addLayout(hdr)
|
||||
|
||||
# ── Unsupported banner (hidden for MySQL/PG) ──────────────────────────
|
||||
self._unsupported_lbl = QLabel(
|
||||
"ℹ️ User management is not available for this database type.\n"
|
||||
" (Supports MySQL and PostgreSQL only.)"
|
||||
)
|
||||
self._unsupported_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._unsupported_lbl.setObjectName("emptyLabel")
|
||||
root.addWidget(self._unsupported_lbl)
|
||||
|
||||
if self._db_type not in ("mysql", "postgresql"):
|
||||
self._unsupported_lbl.setVisible(True)
|
||||
return
|
||||
self._unsupported_lbl.setVisible(False)
|
||||
|
||||
# ── Main splitter: user list (left) / grants detail (right) ──────────
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
# ── Left: user table ──────────────────────────────────────────────────
|
||||
left = QWidget()
|
||||
ll = QVBoxLayout(left)
|
||||
ll.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
ll.addWidget(self._make_section_label("Users"))
|
||||
|
||||
# Toolbar
|
||||
tb = QHBoxLayout()
|
||||
tb.setContentsMargins(4, 0, 4, 4)
|
||||
self._add_user_btn = QPushButton("➕ Create User")
|
||||
self._drop_user_btn = QPushButton("🗑️ Drop User")
|
||||
self._drop_user_btn.setObjectName("crudDeleteBtn")
|
||||
self._drop_user_btn.setEnabled(False)
|
||||
self._add_user_btn.clicked.connect(self._create_user)
|
||||
self._drop_user_btn.clicked.connect(self._drop_user)
|
||||
tb.addWidget(self._add_user_btn)
|
||||
tb.addWidget(self._drop_user_btn)
|
||||
tb.addStretch()
|
||||
ll.addLayout(tb)
|
||||
|
||||
self._user_table = QTableWidget()
|
||||
self._user_table.setAlternatingRowColors(True)
|
||||
self._user_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self._user_table.setSelectionBehavior(
|
||||
QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self._user_table.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self._user_table.verticalHeader().setDefaultSectionSize(24)
|
||||
self._user_table.itemSelectionChanged.connect(self._on_user_selected)
|
||||
ll.addWidget(self._user_table)
|
||||
|
||||
splitter.addWidget(left)
|
||||
|
||||
# ── Right: grants panel ───────────────────────────────────────────────
|
||||
right = QWidget()
|
||||
rl = QVBoxLayout(right)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
rl.addWidget(self._make_section_label("Grants for selected user"))
|
||||
|
||||
# Grant toolbar
|
||||
gtb = QHBoxLayout()
|
||||
gtb.setContentsMargins(4, 0, 4, 4)
|
||||
self._grant_btn = QPushButton("+ Grant")
|
||||
self._revoke_btn = QPushButton("- Revoke")
|
||||
self._grant_btn.setEnabled(False)
|
||||
self._revoke_btn.setEnabled(False)
|
||||
self._grant_btn.clicked.connect(self._grant_privs)
|
||||
self._revoke_btn.clicked.connect(self._revoke_privs)
|
||||
gtb.addWidget(self._grant_btn)
|
||||
gtb.addWidget(self._revoke_btn)
|
||||
gtb.addStretch()
|
||||
rl.addLayout(gtb)
|
||||
|
||||
self._grants_table = QTableWidget(0, 1)
|
||||
self._grants_table.setHorizontalHeaderLabels(["GRANT Statement"])
|
||||
self._grants_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch)
|
||||
self._grants_table.setAlternatingRowColors(True)
|
||||
self._grants_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self._grants_table.verticalHeader().setDefaultSectionSize(24)
|
||||
rl.addWidget(self._grants_table)
|
||||
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([340, 560])
|
||||
|
||||
root.addWidget(splitter, 1)
|
||||
|
||||
# ── Status bar ────────────────────────────────────────────────────────
|
||||
self._status_lbl = QLabel("")
|
||||
self._status_lbl.setObjectName("rowCountLbl")
|
||||
self._status_lbl.setContentsMargins(8, 4, 0, 4)
|
||||
root.addWidget(self._status_lbl)
|
||||
|
||||
@staticmethod
|
||||
def _make_section_label(text: str) -> QLabel:
|
||||
lbl = QLabel(f" {text}")
|
||||
lbl.setObjectName("sidebarTitle")
|
||||
lbl.setContentsMargins(0, 4, 0, 4)
|
||||
f = lbl.font()
|
||||
f.setBold(True)
|
||||
lbl.setFont(f)
|
||||
return lbl
|
||||
|
||||
# ── Data loading ──────────────────────────────────────────────────────────
|
||||
|
||||
def _load_users(self):
|
||||
if self._db_type not in ("mysql", "postgresql"):
|
||||
return
|
||||
fn = _mysql_get_users if self._db_type == "mysql" else _pg_get_users
|
||||
self._worker = _UserWorker(fn, self._driver, parent=self)
|
||||
self._worker.result.connect(self._populate_users)
|
||||
self._worker.error.connect(
|
||||
lambda e: self._set_status(f"Error loading users: {e}"))
|
||||
self._worker.start()
|
||||
|
||||
def _load_databases(self):
|
||||
try:
|
||||
self._databases = self._driver.get_databases()
|
||||
except Exception:
|
||||
self._databases = []
|
||||
|
||||
def _populate_users(self, users: list[dict]):
|
||||
t = self._user_table
|
||||
t.clear()
|
||||
|
||||
if self._db_type == "mysql":
|
||||
t.setColumnCount(3)
|
||||
t.setHorizontalHeaderLabels(["Username", "Host", "Super"])
|
||||
else:
|
||||
t.setColumnCount(3)
|
||||
t.setHorizontalHeaderLabels(["Username", "Superuser", "CreateDB"])
|
||||
|
||||
t.setRowCount(len(users))
|
||||
t.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.ResizeToContents)
|
||||
t.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
|
||||
for r, u in enumerate(users):
|
||||
if self._db_type == "mysql":
|
||||
vals = [u["user"], u["host"], u.get("super", "")]
|
||||
else:
|
||||
vals = [u["user"], u.get("super", ""), u.get("createdb", "")]
|
||||
for c, v in enumerate(vals):
|
||||
item = QTableWidgetItem(str(v))
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
t.setItem(r, c, item)
|
||||
|
||||
self._set_status(f"{len(users)} user(s)")
|
||||
|
||||
def _on_user_selected(self):
|
||||
rows = self._user_table.selectionModel().selectedRows()
|
||||
if not rows:
|
||||
self._drop_user_btn.setEnabled(False)
|
||||
self._grant_btn.setEnabled(False)
|
||||
self._revoke_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
r = rows[0].row()
|
||||
self._current_user = self._user_table.item(r, 0).text()
|
||||
self._current_host = (
|
||||
self._user_table.item(r, 1).text()
|
||||
if self._db_type == "mysql"
|
||||
else ""
|
||||
)
|
||||
|
||||
self._drop_user_btn.setEnabled(True)
|
||||
self._grant_btn.setEnabled(True)
|
||||
self._revoke_btn.setEnabled(True)
|
||||
self._load_grants()
|
||||
|
||||
def _load_grants(self):
|
||||
if not self._current_user:
|
||||
return
|
||||
fn = _mysql_get_grants if self._db_type == "mysql" else _pg_get_grants
|
||||
self._worker = _UserWorker(
|
||||
fn, self._driver, self._current_user, self._current_host, parent=self
|
||||
)
|
||||
self._worker.result.connect(self._populate_grants)
|
||||
self._worker.error.connect(
|
||||
lambda e: self._set_status(f"Error loading grants: {e}"))
|
||||
self._worker.start()
|
||||
|
||||
def _populate_grants(self, grants: list[str]):
|
||||
t = self._grants_table
|
||||
t.setRowCount(len(grants))
|
||||
mono = QFont("Consolas", 10)
|
||||
for r, g in enumerate(grants):
|
||||
item = QTableWidgetItem(g)
|
||||
item.setFont(mono)
|
||||
t.setItem(r, 0, item)
|
||||
self._set_status(
|
||||
f"{len(grants)} grant statement(s) for {self._current_user}"
|
||||
)
|
||||
|
||||
# ── CRUD operations ───────────────────────────────────────────────────────
|
||||
|
||||
def _create_user(self):
|
||||
dlg = _CreateUserDialog(self._db_type, parent=self)
|
||||
if not dlg.exec():
|
||||
return
|
||||
fn = _mysql_create_user if self._db_type == "mysql" else _pg_create_user
|
||||
self._worker = _UserWorker(
|
||||
fn, self._driver, dlg.username, dlg.host, dlg.password, parent=self
|
||||
)
|
||||
self._worker.result.connect(lambda _: (
|
||||
self._set_status(f"User '{dlg.username}' created."),
|
||||
self._load_users(),
|
||||
))
|
||||
self._worker.error.connect(
|
||||
lambda e: QMessageBox.critical(self, "Create User Error", str(e))
|
||||
)
|
||||
self._worker.start()
|
||||
|
||||
def _drop_user(self):
|
||||
if not self._current_user:
|
||||
return
|
||||
btn = QMessageBox.warning(
|
||||
self, "Drop User",
|
||||
f"Drop user '{self._current_user}' permanently?\n\n"
|
||||
"All associated privileges will be revoked.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if btn != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
fn = _mysql_drop_user if self._db_type == "mysql" else _pg_drop_user
|
||||
self._worker = _UserWorker(
|
||||
fn, self._driver, self._current_user, self._current_host, parent=self
|
||||
)
|
||||
self._worker.result.connect(lambda _: (
|
||||
self._set_status(f"User '{self._current_user}' dropped."),
|
||||
self._load_users(),
|
||||
))
|
||||
self._worker.error.connect(
|
||||
lambda e: QMessageBox.critical(self, "Drop User Error", str(e))
|
||||
)
|
||||
self._worker.start()
|
||||
|
||||
def _grant_privs(self):
|
||||
dlg = _GrantDialog(
|
||||
self._driver, self._db_type, self._databases,
|
||||
self._current_user, self._current_host, mode="grant", parent=self,
|
||||
)
|
||||
if dlg.exec():
|
||||
self._set_status(
|
||||
f"Privileges granted to {self._current_user}."
|
||||
)
|
||||
self._load_grants()
|
||||
|
||||
def _revoke_privs(self):
|
||||
dlg = _GrantDialog(
|
||||
self._driver, self._db_type, self._databases,
|
||||
self._current_user, self._current_host, mode="revoke", parent=self,
|
||||
)
|
||||
if dlg.exec():
|
||||
self._set_status(
|
||||
f"Privileges revoked from {self._current_user}."
|
||||
)
|
||||
self._load_grants()
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _set_status(self, msg: str):
|
||||
self._status_lbl.setText(msg)
|
||||
self.status_message.emit(msg)
|
||||
Reference in New Issue
Block a user