Initial Codes

This commit is contained in:
2026-05-21 15:46:41 -04:00
commit b01ad5ea40
40 changed files with 9102 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# ui package
+141
View File
@@ -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()
+300
View File
@@ -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
+497
View File
@@ -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}"
)
+421
View File
@@ -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}")
+268
View File
@@ -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}")
+384
View File
@@ -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)
+163
View File
@@ -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)
+195
View File
@@ -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)
+207
View File
@@ -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())
+521
View File
@@ -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)
+340
View File
@@ -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)
+148
View File
@@ -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
+324
View File
@@ -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()
+716
View File
@@ -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))
+631
View File
@@ -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)