498 lines
20 KiB
Python
498 lines
20 KiB
Python
"""
|
|
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}"
|
|
)
|