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