325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""
|
||
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()
|