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
+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}"
)