Initial Codes
This commit is contained in:
+176
@@ -0,0 +1,176 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
.claude/
|
||||
@@ -0,0 +1,114 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
DBClient is a desktop database client built with Python + PyQt6, supporting MySQL, PostgreSQL, SQLite, and MSSQL. Inspired by DBeaver / TablePlus.
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
**Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Run the application:**
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
There is no test suite or linter config set up.
|
||||
|
||||
**Build distributable (Windows):**
|
||||
```bash
|
||||
pip install pyinstaller
|
||||
python build_app.py # one-folder bundle → dist/DBClient/
|
||||
python build_app.py --onefile # single .exe → dist/DBClient.exe
|
||||
python build_app.py --clean # wipe build/ and dist/ first
|
||||
```
|
||||
The PyInstaller spec is `DBClient.spec`; add new resource files / hidden imports there.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Driver Abstraction (`app/drivers/`)
|
||||
All database operations go through `BaseDriver` (`app/drivers/base.py`). The four concrete drivers (mysql, postgres, sqlite, mssql) each implement the same interface — the UI never imports a specific driver directly. Use the factory `get_driver(db_type, config)` from `app/drivers/__init__.py`. Adding a new DB type means subclassing `BaseDriver` and registering it in the factory.
|
||||
|
||||
Key data types defined in `base.py`: `ColumnInfo`, `IndexInfo`, `ForeignKeyInfo`, `TableInfo`.
|
||||
|
||||
### Async DB Operations (`app/utils/worker.py`)
|
||||
All database I/O runs in background threads. The pattern is:
|
||||
1. Create a `QueryWorker` / `SchemaWorker` / `TableDataWorker` (subclasses of `QRunnable`/`QThread`)
|
||||
2. Connect `signals.result` / `signals.error` to UI slots
|
||||
3. The worker calls the driver and emits results — never call driver methods directly from the GUI thread.
|
||||
|
||||
All worker errors are logged with `exc_info=True` (full traceback) via `app.utils.logger`.
|
||||
|
||||
### Logging (`app/utils/logger.py`)
|
||||
Call `get_logger(__name__)` anywhere to get a module logger. `setup_logging()` is called once in `main.py` and initialises:
|
||||
- `TimedRotatingFileHandler` → `~/.dbclient/logs/dbclient.log` (daily rotation, 7-day retention)
|
||||
- `StreamHandler` to stderr (WARNING+ only, for dev)
|
||||
- `sys.excepthook` and `threading.excepthook` to capture uncaught exceptions with full tracebacks
|
||||
- `qInstallMessageHandler` to capture Qt internal warnings
|
||||
|
||||
View logs inside the app via **Help → View App Logs (Ctrl+L)**.
|
||||
|
||||
### Signal/Slot Flow
|
||||
`SchemaBrowser` (left sidebar) emits signals when the user double-clicks a table or selects a database. `MainWindow` connects these to open workspace tabs (`TableViewer`, `TableStructure`, `SQLEditor`). New UI interactions should follow this pattern: sidebar emits, main window routes, tabs receive.
|
||||
|
||||
### Data Persistence
|
||||
All user data lives under `~/.dbclient/`:
|
||||
- `connections.json` — connection profiles (passwords omitted; stored in OS keychain via `keyring`)
|
||||
- `settings.json` — app-wide preferences (theme, font size, page size, timeouts)
|
||||
- `history.db` — SQLite store for query history
|
||||
|
||||
`app/config/connections.py` handles profile CRUD + keyring integration. `app/config/settings.py` is a settings singleton.
|
||||
|
||||
### UI Structure
|
||||
`MainWindow` (`app/main_window.py`) owns a `QSplitter` with `SchemaBrowser` on the left and a `QTabWidget` workspace on the right. Workspace tabs are created dynamically: `SQLEditor` for query tabs, `TableViewer` for data browsing, `TableStructure` for DDL inspection.
|
||||
|
||||
The `ResultTableModel` (`app/models/result_table_model.py`) is a `QAbstractTableModel` — query results should always go through it rather than populating `QTableWidget` directly.
|
||||
|
||||
### Styling
|
||||
`resources/style.qss` is a Catppuccin Mocha dark theme applied globally at startup in `main.py`. Widget-specific overrides belong here, not as inline `setStyleSheet()` calls.
|
||||
|
||||
---
|
||||
|
||||
## Known Quirks
|
||||
|
||||
### TableViewer selection signals
|
||||
`QItemSelectionModel.selectionChanged` becomes unreliable after `beginResetModel/endResetModel` cycles (triggered on every data load). Edit/Delete button enabling is driven by `QTableView.clicked` (primary, mouse) and `selectionChanged` (secondary, keyboard). Do not remove the `clicked` connection — removing it re-breaks the buttons.
|
||||
|
||||
### NULL-aware WHERE clauses
|
||||
All four drivers have a `_where(where: dict) → (clause_str, params)` static helper that emits `col IS NULL` for `None` values instead of `col = NULL`. All `update_row` and `delete_row` calls go through this helper. Do not bypass it.
|
||||
|
||||
### Password hashing in RowDialog
|
||||
`RowDialog` detects password-like column names (via `_is_password_col()`) and hashes plain-text input with bcrypt before storing. In edit mode, leaving a password field empty omits that column from the UPDATE so the existing hash is preserved.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### Complete
|
||||
- Connection manager (add/edit/delete, test, color coding, keyring storage)
|
||||
- Schema browser (live tree: connections → databases → tables/views/functions/procedures/triggers, search, context menus, delete connection from active node)
|
||||
- SQL editor (multi-tab, syntax highlighting, line numbers, Ctrl+/ comment toggle, F5/Ctrl+Enter execute)
|
||||
- Results panel (sortable `QAbstractTableModel`, export CSV/JSON/SQL INSERT, pagination, execution time)
|
||||
- Table viewer (paginated grid 50/100/All, add/edit/delete rows via dialog, WHERE filter, bcrypt password hashing)
|
||||
- Table structure view (columns, indexes, FKs, DDL with syntax highlighting, add/drop/rename column designer)
|
||||
- Query history (auto-log with timestamp/duration/status, search, replay, persisted in `history.db`)
|
||||
- All 4 DB drivers (MySQL, PostgreSQL, SQLite, MSSQL)
|
||||
- Process list viewer (`app/ui/process_list.py`) with 5 s auto-refresh and kill query
|
||||
- Import CSV/JSON dialog (`app/ui/import_dialog.py`) with preview and progress bar
|
||||
- Database dump export (`app/ui/dump_dialog.py`): schema/data/both, table selector, progress bar
|
||||
- Tools menu in MainWindow (Process List, Import CSV/JSON, Export Database Dump, User Management)
|
||||
- PyInstaller packaging (`DBClient.spec` + `build_app.py`)
|
||||
- User & privilege management (`app/ui/user_manager.py`): list/create/drop users, GRANT/REVOKE per-DB (MySQL + PostgreSQL)
|
||||
- EXPLAIN plan diagram (`app/ui/explain_view.py`): visual node tree + raw table; opened from SQL editor "🔎 Explain" button
|
||||
- Application logging (`app/utils/logger.py` + `app/ui/log_viewer.py`): rotating file logs, unhandled exception hooks, in-app log viewer (Help → View App Logs)
|
||||
|
||||
### Not Yet Implemented
|
||||
- (All planned features are now complete)
|
||||
@@ -0,0 +1 @@
|
||||
# DBClient package
|
||||
@@ -0,0 +1 @@
|
||||
# config package
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Connection profile persistence.
|
||||
Profiles are stored as JSON in ~/.dbclient/connections.json.
|
||||
Passwords are stored separately in the OS keychain via keyring.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.models.connection_model import ConnectionProfile
|
||||
|
||||
_CONN_FILE = Path.home() / ".dbclient" / "connections.json"
|
||||
|
||||
try:
|
||||
import keyring
|
||||
_KEYRING_OK = True
|
||||
except ImportError:
|
||||
_KEYRING_OK = False
|
||||
|
||||
_SERVICE = "DBClient"
|
||||
|
||||
|
||||
# ── Keyring helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def save_password(profile_id: str, password: str) -> None:
|
||||
if _KEYRING_OK and password:
|
||||
keyring.set_password(_SERVICE, profile_id, password)
|
||||
|
||||
|
||||
def load_password(profile_id: str) -> str:
|
||||
if _KEYRING_OK:
|
||||
try:
|
||||
return keyring.get_password(_SERVICE, profile_id) or ""
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def delete_password(profile_id: str) -> None:
|
||||
if _KEYRING_OK:
|
||||
try:
|
||||
keyring.delete_password(_SERVICE, profile_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Profile CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_raw() -> list:
|
||||
if _CONN_FILE.exists():
|
||||
try:
|
||||
with open(_CONN_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _save_raw(data: list) -> None:
|
||||
_CONN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_CONN_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def load_profiles() -> list:
|
||||
"""Return list of ConnectionProfile with passwords injected from keyring."""
|
||||
profiles = []
|
||||
for raw in _load_raw():
|
||||
p = ConnectionProfile.from_dict(raw)
|
||||
p.password = load_password(p.id)
|
||||
profiles.append(p)
|
||||
return profiles
|
||||
|
||||
|
||||
def save_profile(profile: ConnectionProfile) -> None:
|
||||
"""Upsert a profile (save or update)."""
|
||||
raw_list = _load_raw()
|
||||
# Replace existing or append
|
||||
found = False
|
||||
for i, raw in enumerate(raw_list):
|
||||
if raw.get("id") == profile.id:
|
||||
raw_list[i] = profile.to_dict()
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
raw_list.append(profile.to_dict())
|
||||
_save_raw(raw_list)
|
||||
save_password(profile.id, profile.password)
|
||||
|
||||
|
||||
def delete_profile(profile_id: str) -> None:
|
||||
"""Remove a profile by ID."""
|
||||
raw_list = [r for r in _load_raw() if r.get("id") != profile_id]
|
||||
_save_raw(raw_list)
|
||||
delete_password(profile_id)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
App-wide settings: persists to ~/.dbclient/settings.json
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
APP_DIR = Path.home() / ".dbclient"
|
||||
SETTINGS_FILE = APP_DIR / "settings.json"
|
||||
|
||||
DEFAULTS = {
|
||||
"theme": "dark",
|
||||
"font_family": "Consolas",
|
||||
"font_size": 13,
|
||||
"result_page_size": 1000,
|
||||
"query_timeout": 60,
|
||||
"auto_commit": True,
|
||||
"show_row_numbers": True,
|
||||
"word_wrap": False,
|
||||
"max_history": 500,
|
||||
}
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Thin wrapper around a JSON settings file."""
|
||||
|
||||
def __init__(self):
|
||||
self._data: dict = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
APP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if SETTINGS_FILE.exists():
|
||||
try:
|
||||
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||||
self._data = json.load(f)
|
||||
except Exception:
|
||||
self._data = {}
|
||||
# Fill in missing defaults
|
||||
for k, v in DEFAULTS.items():
|
||||
self._data.setdefault(k, v)
|
||||
|
||||
def save(self) -> None:
|
||||
APP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, indent=2)
|
||||
|
||||
def get(self, key: str, fallback=None):
|
||||
return self._data.get(key, DEFAULTS.get(key, fallback))
|
||||
|
||||
def set(self, key: str, value) -> None:
|
||||
self._data[key] = value
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.get(key)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.set(key, value)
|
||||
|
||||
|
||||
# Singleton
|
||||
_settings: Settings | None = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Driver factory — returns an instantiated driver for the given DB type.
|
||||
"""
|
||||
from app.drivers.base import BaseDriver
|
||||
|
||||
|
||||
def get_driver(db_type: str, config: dict) -> BaseDriver:
|
||||
"""Instantiate and return the correct driver for db_type."""
|
||||
db_type = db_type.lower()
|
||||
if db_type == "mysql":
|
||||
from app.drivers.mysql_driver import MySQLDriver
|
||||
return MySQLDriver(config)
|
||||
elif db_type in ("postgresql", "postgres"):
|
||||
from app.drivers.postgres_driver import PostgreSQLDriver
|
||||
return PostgreSQLDriver(config)
|
||||
elif db_type == "sqlite":
|
||||
from app.drivers.sqlite_driver import SQLiteDriver
|
||||
return SQLiteDriver(config)
|
||||
elif db_type == "mssql":
|
||||
from app.drivers.mssql_driver import MSSQLDriver
|
||||
return MSSQLDriver(config)
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type!r}")
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Abstract base driver interface.
|
||||
All DB-specific drivers must implement this interface so the UI is fully DB-agnostic.
|
||||
"""
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColumnInfo:
|
||||
name: str
|
||||
data_type: str
|
||||
nullable: bool
|
||||
default: Optional[str]
|
||||
is_primary_key: bool
|
||||
is_foreign_key: bool
|
||||
extra: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexInfo:
|
||||
name: str
|
||||
columns: list
|
||||
is_unique: bool
|
||||
index_type: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForeignKeyInfo:
|
||||
name: str
|
||||
column: str
|
||||
ref_table: str
|
||||
ref_column: str
|
||||
on_update: str = ""
|
||||
on_delete: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableInfo:
|
||||
name: str
|
||||
schema: str
|
||||
row_count: int = 0
|
||||
size_bytes: int = 0
|
||||
engine: str = ""
|
||||
comment: str = ""
|
||||
|
||||
|
||||
class BaseDriver(ABC):
|
||||
"""Abstract base class for all database drivers.
|
||||
|
||||
Thread-safety
|
||||
-------------
|
||||
A single driver instance is shared across multiple ``SchemaWorker`` threads
|
||||
(columns, indexes, FK, DDL all fire in parallel when a table is opened).
|
||||
The ``_lock`` ``threading.RLock`` ensures that each subclass's ``_cur()``
|
||||
context manager holds the lock for the *entire* execute → fetch sequence,
|
||||
serialising concurrent access on the underlying (non-thread-safe) connection.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self._connection = None
|
||||
self.db_type = ""
|
||||
# Reentrant lock shared by all _cur() calls; prevents concurrent
|
||||
# threads from interleaving reads/writes on the same socket.
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> None:
|
||||
"""Establish database connection."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""Close database connection."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def test_connection(self) -> tuple:
|
||||
"""Test connection. Returns (bool success, str message)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_databases(self) -> list:
|
||||
"""Returns list of database name strings."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_tables(self, database: str) -> list:
|
||||
"""Returns list of TableInfo for the given database."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_views(self, database: str) -> list:
|
||||
"""Returns list of view name strings."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_columns(self, database: str, table: str) -> list:
|
||||
"""Returns list of ColumnInfo for a table."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_indexes(self, database: str, table: str) -> list:
|
||||
"""Returns list of IndexInfo for a table."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_foreign_keys(self, database: str, table: str) -> list:
|
||||
"""Returns list of ForeignKeyInfo for a table."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_table_ddl(self, database: str, table: str) -> str:
|
||||
"""Returns DDL CREATE TABLE statement."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
||||
"""Execute a query. Returns (list[str] columns, list[tuple] rows, int rowcount)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def execute_script(self, sql: str) -> list:
|
||||
"""Execute multiple statements. Returns list of (columns, rows, rowcount, message) tuples."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_table_data(self, database: str, table: str,
|
||||
where: str = "", order_by: str = "",
|
||||
limit: int = 1000, offset: int = 0) -> tuple:
|
||||
"""Returns (columns, rows, total_count) for paginated table data."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
||||
"""Returns total row count for a table."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_functions(self, database: str) -> list:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_stored_procedures(self, database: str) -> list:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_triggers(self, database: str, table: str = "") -> list:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def explain_query(self, sql: str) -> tuple:
|
||||
"""Returns (columns, rows) for EXPLAIN output."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_process_list(self) -> tuple:
|
||||
"""Returns (columns, rows) of running processes."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def kill_process(self, process_id: int) -> bool:
|
||||
pass
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def add_column(self, database: str, table: str, col_name: str,
|
||||
col_type: str, nullable: bool = True,
|
||||
default: Optional[str] = None) -> bool:
|
||||
"""Add a column to an existing table via ALTER TABLE."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def drop_column(self, database: str, table: str, col_name: str) -> bool:
|
||||
"""Drop a column from a table via ALTER TABLE."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def rename_column(self, database: str, table: str,
|
||||
old_name: str, new_name: str) -> bool:
|
||||
"""Rename a column via ALTER TABLE."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connection is not None
|
||||
|
||||
def get_connection_info(self) -> str:
|
||||
"""Return human-readable connection info string."""
|
||||
cfg = self.config
|
||||
if self.db_type == "sqlite":
|
||||
return cfg.get("database", "")
|
||||
return f"{cfg.get('user', '')}@{cfg.get('host', '')}:{cfg.get('port', '')}"
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Microsoft SQL Server driver using pyodbc."""
|
||||
from typing import Optional
|
||||
from app.drivers.base import (
|
||||
BaseDriver, ColumnInfo, IndexInfo, ForeignKeyInfo, TableInfo
|
||||
)
|
||||
|
||||
try:
|
||||
import pyodbc
|
||||
PYODBC_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYODBC_AVAILABLE = False
|
||||
|
||||
|
||||
class MSSQLDriver(BaseDriver):
|
||||
"""SQL Server driver via pyodbc (ODBC Driver 17/18 for SQL Server required)."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.db_type = "mssql"
|
||||
|
||||
def _conn_str(self) -> str:
|
||||
host = self.config.get("host", "localhost")
|
||||
port = int(self.config.get("port", 1433))
|
||||
db = self.config.get("database", "master")
|
||||
user = self.config.get("user", "")
|
||||
pwd = self.config.get("password", "")
|
||||
# Try drivers in order of preference
|
||||
for driver in [
|
||||
"ODBC Driver 18 for SQL Server",
|
||||
"ODBC Driver 17 for SQL Server",
|
||||
"SQL Server",
|
||||
]:
|
||||
return (
|
||||
f"DRIVER={{{driver}}};"
|
||||
f"SERVER={host},{port};"
|
||||
f"DATABASE={db};"
|
||||
f"UID={user};PWD={pwd};"
|
||||
f"TrustServerCertificate=yes;"
|
||||
f"Connection Timeout={self.config.get('connection_timeout', 30)};"
|
||||
)
|
||||
|
||||
def connect(self) -> None:
|
||||
if not PYODBC_AVAILABLE:
|
||||
raise ImportError("pyodbc is not installed. Run: pip install pyodbc")
|
||||
self._connection = pyodbc.connect(self._conn_str(), autocommit=True)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
try:
|
||||
self._connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._connection = None
|
||||
|
||||
def test_connection(self) -> tuple:
|
||||
if not PYODBC_AVAILABLE:
|
||||
return False, "pyodbc is not installed. Run: pip install pyodbc"
|
||||
try:
|
||||
conn = pyodbc.connect(self._conn_str(), autocommit=True)
|
||||
conn.close()
|
||||
return True, "Connection successful"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _cur(self):
|
||||
return self._connection.cursor()
|
||||
|
||||
# ── Schema introspection ──────────────────────────────────────────────────
|
||||
|
||||
def get_databases(self) -> list:
|
||||
c = self._cur()
|
||||
c.execute("SELECT name FROM sys.databases ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_tables(self, database: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT t.name, s.name,
|
||||
COALESCE(p.rows, 0), 0, '', ''
|
||||
FROM [{database}].sys.tables t
|
||||
JOIN [{database}].sys.schemas s ON t.schema_id = s.schema_id
|
||||
LEFT JOIN (
|
||||
SELECT object_id, SUM(rows) AS rows
|
||||
FROM [{database}].sys.partitions WHERE index_id IN (0,1)
|
||||
GROUP BY object_id
|
||||
) p ON p.object_id = t.object_id
|
||||
ORDER BY t.name
|
||||
""")
|
||||
return [TableInfo(name=r[0], schema=r[1], row_count=r[2],
|
||||
size_bytes=r[3], engine=r[4], comment=r[5])
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_views(self, database: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"SELECT name FROM [{database}].sys.views ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_columns(self, database: str, table: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT c.name, tp.name, c.is_nullable, dc.definition,
|
||||
CASE WHEN pk.column_id IS NOT NULL THEN 1 ELSE 0 END,
|
||||
CASE WHEN fk.parent_column_id IS NOT NULL THEN 1 ELSE 0 END,
|
||||
CASE WHEN c.is_identity = 1 THEN 'auto_increment' ELSE '' END
|
||||
FROM [{database}].sys.columns c
|
||||
JOIN [{database}].sys.types tp ON tp.user_type_id = c.user_type_id
|
||||
JOIN [{database}].sys.tables t ON t.object_id = c.object_id
|
||||
LEFT JOIN [{database}].sys.default_constraints dc
|
||||
ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id
|
||||
LEFT JOIN (
|
||||
SELECT ic.column_id, ic.object_id
|
||||
FROM [{database}].sys.index_columns ic
|
||||
JOIN [{database}].sys.indexes i ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
||||
WHERE i.is_primary_key = 1
|
||||
) pk ON pk.object_id = c.object_id AND pk.column_id = c.column_id
|
||||
LEFT JOIN (
|
||||
SELECT fkc.parent_column_id, fkc.parent_object_id
|
||||
FROM [{database}].sys.foreign_key_columns fkc
|
||||
) fk ON fk.parent_object_id = c.object_id AND fk.parent_column_id = c.column_id
|
||||
WHERE t.name = ?
|
||||
ORDER BY c.column_id
|
||||
""", (table,))
|
||||
return [ColumnInfo(name=r[0], data_type=r[1], nullable=bool(r[2]),
|
||||
default=r[3], is_primary_key=bool(r[4]),
|
||||
is_foreign_key=bool(r[5]), extra=r[6] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_indexes(self, database: str, table: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT i.name, i.is_unique, STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal)
|
||||
FROM [{database}].sys.indexes i
|
||||
JOIN [{database}].sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
||||
JOIN [{database}].sys.columns c ON c.object_id = i.object_id AND c.column_id = ic.column_id
|
||||
JOIN [{database}].sys.tables t ON t.object_id = i.object_id
|
||||
WHERE t.name = ?
|
||||
GROUP BY i.name, i.is_unique
|
||||
""", (table,))
|
||||
return [IndexInfo(name=r[0], columns=r[2].split(','),
|
||||
is_unique=bool(r[1]))
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_foreign_keys(self, database: str, table: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT fk.name, pc.name, rt.name, rc.name,
|
||||
fk.update_referential_action_desc,
|
||||
fk.delete_referential_action_desc
|
||||
FROM [{database}].sys.foreign_keys fk
|
||||
JOIN [{database}].sys.tables pt ON pt.object_id = fk.parent_object_id
|
||||
JOIN [{database}].sys.tables rt ON rt.object_id = fk.referenced_object_id
|
||||
JOIN [{database}].sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
||||
JOIN [{database}].sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id
|
||||
JOIN [{database}].sys.columns rc ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id
|
||||
WHERE pt.name = ?
|
||||
""", (table,))
|
||||
return [ForeignKeyInfo(name=r[0], column=r[1],
|
||||
ref_table=r[2], ref_column=r[3],
|
||||
on_update=r[4] or "", on_delete=r[5] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_table_ddl(self, database: str, table: str) -> str:
|
||||
cols = self.get_columns(database, table)
|
||||
lines = [f"CREATE TABLE [{table}] ("]
|
||||
col_defs = []
|
||||
for col in cols:
|
||||
d = f" [{col.name}] {col.data_type}"
|
||||
if not col.nullable: d += " NOT NULL"
|
||||
if col.default: d += f" DEFAULT {col.default}"
|
||||
col_defs.append(d)
|
||||
lines.append(",\n".join(col_defs))
|
||||
lines.append(");")
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_functions(self, database: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT name FROM [{database}].sys.objects
|
||||
WHERE type IN ('FN','IF','TF') ORDER BY name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_stored_procedures(self, database: str) -> list:
|
||||
c = self._cur()
|
||||
c.execute(f"""
|
||||
SELECT name FROM [{database}].sys.procedures ORDER BY name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_triggers(self, database: str, table: str = "") -> list:
|
||||
c = self._cur()
|
||||
if table:
|
||||
c.execute(f"""
|
||||
SELECT t.name FROM [{database}].sys.triggers t
|
||||
JOIN [{database}].sys.tables tb ON tb.object_id = t.parent_id
|
||||
WHERE tb.name = ? ORDER BY t.name
|
||||
""", (table,))
|
||||
else:
|
||||
c.execute(f"SELECT name FROM [{database}].sys.triggers ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
# ── Query execution ───────────────────────────────────────────────────────
|
||||
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
||||
c = self._cur()
|
||||
c.execute(sql, params or ())
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
return cols, [tuple(r) for r in rows], len(rows)
|
||||
return [], [], c.rowcount
|
||||
|
||||
def execute_script(self, sql: str) -> list:
|
||||
results = []
|
||||
stmts = [s.strip() for s in sql.split(';') if s.strip()]
|
||||
for stmt in stmts:
|
||||
try:
|
||||
c = self._cur()
|
||||
c.execute(stmt)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = [tuple(r) for r in c.fetchall()]
|
||||
results.append((cols, rows, len(rows), ""))
|
||||
else:
|
||||
results.append(([], [], c.rowcount,
|
||||
f"{c.rowcount} row(s) affected"))
|
||||
except Exception as e:
|
||||
results.append(([], [], 0, f"Error: {e}"))
|
||||
return results
|
||||
|
||||
# ── Table data CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
def get_table_data(self, database: str, table: str,
|
||||
where: str = "", order_by: str = "",
|
||||
limit: int = 1000, offset: int = 0) -> tuple:
|
||||
sql = f"SELECT * FROM [{database}].[dbo].[{table}]"
|
||||
if where: sql += f" WHERE {where}"
|
||||
if order_by: sql += f" ORDER BY {order_by}"
|
||||
sql += f" OFFSET {offset} ROWS FETCH NEXT {limit} ROWS ONLY"
|
||||
return self.execute_query(sql)
|
||||
|
||||
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
||||
sql = f"SELECT COUNT(*) FROM [{database}].[dbo].[{table}]"
|
||||
if where: sql += f" WHERE {where}"
|
||||
c = self._cur()
|
||||
c.execute(sql)
|
||||
return c.fetchone()[0]
|
||||
|
||||
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
||||
cols = ", ".join(f"[{c}]" for c in data)
|
||||
ph = ", ".join(["?"] * len(data))
|
||||
c = self._cur()
|
||||
c.execute(f"INSERT INTO [{database}].[dbo].[{table}] ({cols}) VALUES ({ph})",
|
||||
tuple(data.values()))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _where(where: dict) -> tuple:
|
||||
parts, params = [], []
|
||||
for col, val in where.items():
|
||||
if val is None:
|
||||
parts.append(f"[{col}] IS NULL")
|
||||
else:
|
||||
parts.append(f"[{col}] = ?")
|
||||
params.append(val)
|
||||
return " AND ".join(parts), params
|
||||
|
||||
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
||||
set_cl = ", ".join(f"[{c}] = ?" for c in data)
|
||||
where_cl, where_params = self._where(where)
|
||||
c = self._cur()
|
||||
c.execute(f"UPDATE [{database}].[dbo].[{table}] SET {set_cl} WHERE {where_cl}",
|
||||
tuple(data.values()) + tuple(where_params))
|
||||
return True
|
||||
|
||||
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
||||
where_cl, where_params = self._where(where)
|
||||
c = self._cur()
|
||||
c.execute(f"DELETE FROM [{database}].[dbo].[{table}] WHERE {where_cl}",
|
||||
tuple(where_params))
|
||||
return True
|
||||
|
||||
# ── Server tools ──────────────────────────────────────────────────────────
|
||||
|
||||
def explain_query(self, sql: str) -> tuple:
|
||||
c = self._cur()
|
||||
c.execute(f"SET SHOWPLAN_TEXT ON; {sql}; SET SHOWPLAN_TEXT OFF")
|
||||
return ["Plan"], c.fetchall()
|
||||
|
||||
def get_process_list(self) -> tuple:
|
||||
c = self._cur()
|
||||
c.execute("""
|
||||
SELECT session_id, login_name, status, host_name,
|
||||
program_name, cpu_time, text
|
||||
FROM sys.dm_exec_sessions s
|
||||
CROSS APPLY sys.dm_exec_sql_text(s.most_recent_sql_handle) t
|
||||
WHERE s.is_user_process = 1
|
||||
""")
|
||||
cols = [d[0] for d in c.description]
|
||||
return cols, [tuple(r) for r in c.fetchall()]
|
||||
|
||||
def kill_process(self, process_id: int) -> bool:
|
||||
c = self._cur()
|
||||
c.execute(f"KILL {process_id}")
|
||||
return True
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
def add_column(self, database: str, table: str, col_name: str,
|
||||
col_type: str, nullable: bool = True,
|
||||
default=None) -> bool:
|
||||
null_clause = "NULL" if nullable else "NOT NULL"
|
||||
default_clause = f" DEFAULT {default}" if default is not None and default != "" else ""
|
||||
sql = (f"ALTER TABLE [{database}].[dbo].[{table}] "
|
||||
f"ADD [{col_name}] {col_type} {null_clause}{default_clause}")
|
||||
c = self._cur()
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def drop_column(self, database: str, table: str, col_name: str) -> bool:
|
||||
sql = f"ALTER TABLE [{database}].[dbo].[{table}] DROP COLUMN [{col_name}]"
|
||||
c = self._cur()
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def rename_column(self, database: str, table: str,
|
||||
old_name: str, new_name: str) -> bool:
|
||||
# sp_rename is the standard way in MSSQL
|
||||
sql = f"EXEC sp_rename '[{database}].[dbo].[{table}].[{old_name}]', '{new_name}', 'COLUMN'"
|
||||
c = self._cur()
|
||||
c.execute(sql)
|
||||
return True
|
||||
@@ -0,0 +1,377 @@
|
||||
"""MySQL driver implementation using pymysql."""
|
||||
import pymysql
|
||||
import pymysql.cursors
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from app.drivers.base import (
|
||||
BaseDriver, ColumnInfo, IndexInfo, ForeignKeyInfo, TableInfo
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
_log = get_logger(__name__)
|
||||
|
||||
|
||||
class MySQLDriver(BaseDriver):
|
||||
"""MySQL / MariaDB database driver."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.db_type = "mysql"
|
||||
|
||||
def _connect_kwargs(self) -> dict:
|
||||
kw = {
|
||||
"host": self.config.get("host", "localhost"),
|
||||
"port": int(self.config.get("port", 3306)),
|
||||
"user": self.config.get("user", ""),
|
||||
"password": self.config.get("password", ""),
|
||||
"connect_timeout": int(self.config.get("connection_timeout", 30)),
|
||||
"autocommit": True,
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
db = self.config.get("database", "")
|
||||
if db:
|
||||
kw["database"] = db
|
||||
return kw
|
||||
|
||||
def connect(self) -> None:
|
||||
host = self.config.get("host", "localhost")
|
||||
port = self.config.get("port", 3306)
|
||||
user = self.config.get("user", "")
|
||||
_log.info("MySQL connecting host=%s:%s user=%s", host, port, user)
|
||||
try:
|
||||
self._connection = pymysql.connect(**self._connect_kwargs())
|
||||
_log.info("MySQL connected host=%s:%s user=%s", host, port, user)
|
||||
except Exception:
|
||||
_log.error("MySQL connection failed host=%s:%s user=%s",
|
||||
host, port, user, exc_info=True)
|
||||
raise
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
try:
|
||||
self._connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._connection = None
|
||||
|
||||
def test_connection(self) -> tuple:
|
||||
try:
|
||||
conn = pymysql.connect(**self._connect_kwargs())
|
||||
conn.close()
|
||||
return True, "Connection successful"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _ensure_alive(self) -> None:
|
||||
"""Ping the server and silently reconnect if the connection has gone away."""
|
||||
if self._connection is None:
|
||||
_log.warning("MySQL connection is None — connecting now")
|
||||
self.connect()
|
||||
return
|
||||
try:
|
||||
self._connection.ping(reconnect=True)
|
||||
except Exception:
|
||||
_log.warning("MySQL ping failed — attempting full reconnect", exc_info=True)
|
||||
try:
|
||||
self.connect()
|
||||
_log.info("MySQL reconnected successfully")
|
||||
except Exception:
|
||||
_log.error("MySQL reconnect failed", exc_info=True)
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def _cur(self):
|
||||
"""Yield a cursor while holding the driver lock.
|
||||
|
||||
Using a contextmanager means the lock is held for the *entire*
|
||||
``with self._cur() as c: c.execute(); c.fetchall()`` block, which
|
||||
prevents concurrent SchemaWorker threads from interleaving on the same
|
||||
TCP socket (pymysql is not thread-safe).
|
||||
"""
|
||||
with self._lock:
|
||||
self._ensure_alive()
|
||||
cursor = self._connection.cursor(pymysql.cursors.Cursor)
|
||||
try:
|
||||
yield cursor
|
||||
finally:
|
||||
try:
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _fmt_err(e: Exception) -> str:
|
||||
"""Return a readable string for a pymysql exception.
|
||||
|
||||
pymysql errors carry (error_code, message) as args, so ``str(e)``
|
||||
prints something like ``(0, '')``. This helper unwraps that.
|
||||
"""
|
||||
args = getattr(e, "args", ())
|
||||
if args and isinstance(args[0], int):
|
||||
code, msg = args[0], args[1] if len(args) > 1 else ""
|
||||
if msg:
|
||||
return f"MySQL error {code}: {msg}"
|
||||
if code == 0:
|
||||
return "Lost connection to MySQL server (connection timed out or was reset)."
|
||||
return f"MySQL error {code}"
|
||||
return str(e)
|
||||
|
||||
# ── Schema introspection ──────────────────────────────────────────────────
|
||||
|
||||
def get_databases(self) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("SHOW DATABASES")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_tables(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT TABLE_NAME, TABLE_SCHEMA,
|
||||
COALESCE(TABLE_ROWS, 0),
|
||||
COALESCE(DATA_LENGTH + INDEX_LENGTH, 0),
|
||||
COALESCE(ENGINE, ''),
|
||||
COALESCE(TABLE_COMMENT, '')
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = %s AND TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY TABLE_NAME
|
||||
""", (database,))
|
||||
return [TableInfo(name=r[0], schema=r[1], row_count=r[2],
|
||||
size_bytes=r[3], engine=r[4], comment=r[5])
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_views(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT TABLE_NAME FROM information_schema.VIEWS
|
||||
WHERE TABLE_SCHEMA = %s ORDER BY TABLE_NAME
|
||||
""", (database,))
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_columns(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
|
||||
COLUMN_DEFAULT, COLUMN_KEY, EXTRA
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
||||
ORDER BY ORDINAL_POSITION
|
||||
""", (database, table))
|
||||
return [ColumnInfo(name=r[0], data_type=r[1],
|
||||
nullable=(r[2] == "YES"), default=r[3],
|
||||
is_primary_key=(r[4] == "PRI"),
|
||||
is_foreign_key=(r[4] == "MUL"),
|
||||
extra=r[5] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_indexes(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(f"SHOW INDEX FROM `{database}`.`{table}`")
|
||||
idx_map = {}
|
||||
for r in c.fetchall():
|
||||
name, non_unique, col, idx_type = r[2], r[1], r[4], r[10]
|
||||
if name not in idx_map:
|
||||
idx_map[name] = IndexInfo(name=name, columns=[col],
|
||||
is_unique=(non_unique == 0),
|
||||
index_type=idx_type)
|
||||
else:
|
||||
idx_map[name].columns.append(col)
|
||||
return list(idx_map.values())
|
||||
|
||||
def get_foreign_keys(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME,
|
||||
kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME,
|
||||
rc.UPDATE_RULE, rc.DELETE_RULE
|
||||
FROM information_schema.KEY_COLUMN_USAGE kcu
|
||||
JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
|
||||
ON rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
||||
AND rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
|
||||
WHERE kcu.TABLE_SCHEMA = %s AND kcu.TABLE_NAME = %s
|
||||
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
|
||||
""", (database, table))
|
||||
return [ForeignKeyInfo(name=r[0], column=r[1],
|
||||
ref_table=r[2], ref_column=r[3],
|
||||
on_update=r[4] or "", on_delete=r[5] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_table_ddl(self, database: str, table: str) -> str:
|
||||
with self._cur() as c:
|
||||
c.execute(f"SHOW CREATE TABLE `{database}`.`{table}`")
|
||||
row = c.fetchone()
|
||||
return row[1] if row else ""
|
||||
|
||||
def get_functions(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT ROUTINE_NAME FROM information_schema.ROUTINES
|
||||
WHERE ROUTINE_SCHEMA = %s AND ROUTINE_TYPE = 'FUNCTION'
|
||||
ORDER BY ROUTINE_NAME
|
||||
""", (database,))
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_stored_procedures(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT ROUTINE_NAME FROM information_schema.ROUTINES
|
||||
WHERE ROUTINE_SCHEMA = %s AND ROUTINE_TYPE = 'PROCEDURE'
|
||||
ORDER BY ROUTINE_NAME
|
||||
""", (database,))
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_triggers(self, database: str, table: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
if table:
|
||||
c.execute("""
|
||||
SELECT TRIGGER_NAME FROM information_schema.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = %s AND EVENT_OBJECT_TABLE = %s
|
||||
ORDER BY TRIGGER_NAME
|
||||
""", (database, table))
|
||||
else:
|
||||
c.execute("""
|
||||
SELECT TRIGGER_NAME FROM information_schema.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = %s ORDER BY TRIGGER_NAME
|
||||
""", (database,))
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
# ── Query execution ───────────────────────────────────────────────────────
|
||||
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(sql, params)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
return cols, rows, len(rows)
|
||||
return [], [], c.rowcount
|
||||
|
||||
def execute_script(self, sql: str) -> list:
|
||||
results = []
|
||||
stmts = [s.strip() for s in sql.split(';') if s.strip()]
|
||||
with self._cur() as c:
|
||||
for stmt in stmts:
|
||||
try:
|
||||
c.execute(stmt)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
results.append((cols, rows, len(rows), ""))
|
||||
else:
|
||||
results.append(([], [], c.rowcount,
|
||||
f"{c.rowcount} row(s) affected"))
|
||||
except Exception as e:
|
||||
results.append(([], [], 0, f"Error: {e}"))
|
||||
return results
|
||||
|
||||
# ── Table data CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
def get_table_data(self, database: str, table: str,
|
||||
where: str = "", order_by: str = "",
|
||||
limit: int = 1000, offset: int = 0) -> tuple:
|
||||
sql = f"SELECT * FROM `{database}`.`{table}`"
|
||||
if where: sql += f" WHERE {where}"
|
||||
if order_by: sql += f" ORDER BY {order_by}"
|
||||
sql += f" LIMIT {limit} OFFSET {offset}"
|
||||
return self.execute_query(sql)
|
||||
|
||||
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
||||
sql = f"SELECT COUNT(*) FROM `{database}`.`{table}`"
|
||||
if where: sql += f" WHERE {where}"
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return c.fetchone()[0]
|
||||
|
||||
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
||||
cols = ", ".join(f"`{c}`" for c in data)
|
||||
ph = ", ".join(["%s"] * len(data))
|
||||
with self._cur() as c:
|
||||
c.execute(f"INSERT INTO `{database}`.`{table}` ({cols}) VALUES ({ph})",
|
||||
tuple(data.values()))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _where(where: dict) -> tuple:
|
||||
parts, params = [], []
|
||||
for col, val in where.items():
|
||||
if val is None:
|
||||
parts.append(f"`{col}` IS NULL")
|
||||
else:
|
||||
parts.append(f"`{col}` = %s")
|
||||
params.append(val)
|
||||
return " AND ".join(parts), params
|
||||
|
||||
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
||||
set_cl = ", ".join(f"`{c}` = %s" for c in data)
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f"UPDATE `{database}`.`{table}` SET {set_cl} WHERE {where_cl}",
|
||||
tuple(data.values()) + tuple(where_params))
|
||||
return True
|
||||
|
||||
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f"DELETE FROM `{database}`.`{table}` WHERE {where_cl}",
|
||||
tuple(where_params))
|
||||
return True
|
||||
|
||||
# ── Server tools ──────────────────────────────────────────────────────────
|
||||
|
||||
def explain_query(self, sql: str) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(f"EXPLAIN {sql}")
|
||||
return [d[0] for d in c.description], c.fetchall()
|
||||
|
||||
def get_process_list(self) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute("SHOW FULL PROCESSLIST")
|
||||
return [d[0] for d in c.description], c.fetchall()
|
||||
|
||||
def kill_process(self, process_id: int) -> bool:
|
||||
# MUST use a dedicated connection, not self._cur().
|
||||
#
|
||||
# If a QueryWorker is running a long query it holds self._lock via
|
||||
# _cur(). kill_process is called from a *different* SchemaWorker
|
||||
# thread; using self._cur() here would block waiting for that lock,
|
||||
# so the KILL command would never reach MySQL and the server would
|
||||
# eventually raise error 1317 ("Query execution was interrupted") on
|
||||
# its own. A fresh connection bypasses the lock entirely, which is
|
||||
# exactly how MySQL's KILL is intended to work.
|
||||
conn = pymysql.connect(**self._connect_kwargs())
|
||||
try:
|
||||
with conn.cursor() as c:
|
||||
c.execute(f"KILL {process_id}")
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
def add_column(self, database: str, table: str, col_name: str,
|
||||
col_type: str, nullable: bool = True,
|
||||
default=None) -> bool:
|
||||
null_clause = "NULL" if nullable else "NOT NULL"
|
||||
default_clause = f" DEFAULT {default}" if default is not None and default != "" else ""
|
||||
sql = (f"ALTER TABLE `{database}`.`{table}` "
|
||||
f"ADD COLUMN `{col_name}` {col_type} {null_clause}{default_clause}")
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def drop_column(self, database: str, table: str, col_name: str) -> bool:
|
||||
sql = f"ALTER TABLE `{database}`.`{table}` DROP COLUMN `{col_name}`"
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def rename_column(self, database: str, table: str,
|
||||
old_name: str, new_name: str) -> bool:
|
||||
sql = (f"ALTER TABLE `{database}`.`{table}` "
|
||||
f"RENAME COLUMN `{old_name}` TO `{new_name}`")
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
@@ -0,0 +1,343 @@
|
||||
"""PostgreSQL driver implementation using psycopg2."""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from app.drivers.base import (
|
||||
BaseDriver, ColumnInfo, IndexInfo, ForeignKeyInfo, TableInfo
|
||||
)
|
||||
|
||||
|
||||
class PostgreSQLDriver(BaseDriver):
|
||||
"""PostgreSQL database driver."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.db_type = "postgresql"
|
||||
|
||||
def _dsn(self) -> dict:
|
||||
kw = {
|
||||
"host": self.config.get("host", "localhost"),
|
||||
"port": int(self.config.get("port", 5432)),
|
||||
"user": self.config.get("user", ""),
|
||||
"password": self.config.get("password", ""),
|
||||
"connect_timeout": int(self.config.get("connection_timeout", 30)),
|
||||
}
|
||||
db = self.config.get("database", "")
|
||||
if db:
|
||||
kw["dbname"] = db
|
||||
return kw
|
||||
|
||||
def connect(self) -> None:
|
||||
self._connection = psycopg2.connect(**self._dsn())
|
||||
self._connection.autocommit = True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
try:
|
||||
self._connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._connection = None
|
||||
|
||||
def test_connection(self) -> tuple:
|
||||
try:
|
||||
conn = psycopg2.connect(**self._dsn())
|
||||
conn.close()
|
||||
return True, "Connection successful"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@contextmanager
|
||||
def _cur(self):
|
||||
"""Yield a cursor while holding the driver lock (thread-safe execute→fetch)."""
|
||||
with self._lock:
|
||||
cursor = self._connection.cursor()
|
||||
try:
|
||||
yield cursor
|
||||
finally:
|
||||
try:
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Schema introspection ──────────────────────────────────────────────────
|
||||
|
||||
def get_databases(self) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(
|
||||
"SELECT datname FROM pg_database "
|
||||
"WHERE datistemplate = false ORDER BY datname"
|
||||
)
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_tables(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT t.table_name, t.table_schema,
|
||||
COALESCE(s.n_live_tup, 0),
|
||||
0,
|
||||
'',
|
||||
COALESCE(obj_description(
|
||||
(quote_ident(t.table_schema)||'.'||quote_ident(t.table_name))::regclass,
|
||||
'pg_class'), '')
|
||||
FROM information_schema.tables t
|
||||
LEFT JOIN pg_stat_user_tables s
|
||||
ON s.schemaname = t.table_schema AND s.relname = t.table_name
|
||||
WHERE t.table_schema NOT IN ('pg_catalog','information_schema')
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY t.table_name
|
||||
""")
|
||||
return [TableInfo(name=r[0], schema=r[1], row_count=r[2],
|
||||
size_bytes=r[3], engine=r[4], comment=r[5])
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_views(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT table_name FROM information_schema.views
|
||||
WHERE table_schema NOT IN ('pg_catalog','information_schema')
|
||||
ORDER BY table_name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_columns(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT c.column_name, c.data_type, c.is_nullable,
|
||||
c.column_default,
|
||||
(SELECT true FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY'
|
||||
AND kcu.table_name = c.table_name
|
||||
AND kcu.column_name = c.column_name
|
||||
LIMIT 1) IS NOT NULL,
|
||||
false
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_name = %s
|
||||
ORDER BY c.ordinal_position
|
||||
""", (table,))
|
||||
return [ColumnInfo(name=r[0], data_type=r[1],
|
||||
nullable=(r[2] == "YES"), default=r[3],
|
||||
is_primary_key=bool(r[4]),
|
||||
is_foreign_key=bool(r[5]))
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_indexes(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT i.relname, ix.indisunique,
|
||||
array_agg(a.attname ORDER BY k.n) AS cols
|
||||
FROM pg_class t
|
||||
JOIN pg_index ix ON t.oid = ix.indrelid
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n)
|
||||
ON TRUE
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
||||
WHERE t.relname = %s
|
||||
GROUP BY i.relname, ix.indisunique
|
||||
ORDER BY i.relname
|
||||
""", (table,))
|
||||
return [IndexInfo(name=r[0], columns=list(r[2]),
|
||||
is_unique=bool(r[1]))
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_foreign_keys(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT tc.constraint_name, kcu.column_name,
|
||||
ccu.table_name, ccu.column_name,
|
||||
rc.update_rule, rc.delete_rule
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON rc.constraint_name = tc.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_name = %s
|
||||
""", (table,))
|
||||
return [ForeignKeyInfo(name=r[0], column=r[1],
|
||||
ref_table=r[2], ref_column=r[3],
|
||||
on_update=r[4] or "", on_delete=r[5] or "")
|
||||
for r in c.fetchall()]
|
||||
|
||||
def get_table_ddl(self, database: str, table: str) -> str:
|
||||
cols = self.get_columns(database, table)
|
||||
lines = [f"CREATE TABLE {table} ("]
|
||||
col_defs = []
|
||||
for c in cols:
|
||||
d = f" {c.name} {c.data_type}"
|
||||
if not c.nullable: d += " NOT NULL"
|
||||
if c.default: d += f" DEFAULT {c.default}"
|
||||
col_defs.append(d)
|
||||
lines.append(",\n".join(col_defs))
|
||||
lines.append(");")
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_functions(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT routine_name FROM information_schema.routines
|
||||
WHERE routine_type = 'FUNCTION'
|
||||
AND routine_schema NOT IN ('pg_catalog','information_schema')
|
||||
ORDER BY routine_name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_stored_procedures(self, database: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT routine_name FROM information_schema.routines
|
||||
WHERE routine_type = 'PROCEDURE'
|
||||
AND routine_schema NOT IN ('pg_catalog','information_schema')
|
||||
ORDER BY routine_name
|
||||
""")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_triggers(self, database: str, table: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
if table:
|
||||
c.execute("""
|
||||
SELECT trigger_name FROM information_schema.triggers
|
||||
WHERE event_object_table = %s ORDER BY trigger_name
|
||||
""", (table,))
|
||||
else:
|
||||
c.execute(
|
||||
"SELECT trigger_name FROM information_schema.triggers "
|
||||
"ORDER BY trigger_name"
|
||||
)
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
# ── Query execution ───────────────────────────────────────────────────────
|
||||
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(sql, params)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
return cols, rows, len(rows)
|
||||
return [], [], c.rowcount
|
||||
|
||||
def execute_script(self, sql: str) -> list:
|
||||
results = []
|
||||
stmts = [s.strip() for s in sql.split(';') if s.strip()]
|
||||
with self._cur() as c:
|
||||
for stmt in stmts:
|
||||
try:
|
||||
c.execute(stmt)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
results.append((cols, rows, len(rows), ""))
|
||||
else:
|
||||
results.append(([], [], c.rowcount,
|
||||
f"{c.rowcount} row(s) affected"))
|
||||
except Exception as e:
|
||||
results.append(([], [], 0, f"Error: {e}"))
|
||||
return results
|
||||
|
||||
# ── Table data CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
def get_table_data(self, database: str, table: str,
|
||||
where: str = "", order_by: str = "",
|
||||
limit: int = 1000, offset: int = 0) -> tuple:
|
||||
sql = f'SELECT * FROM "{table}"'
|
||||
if where: sql += f" WHERE {where}"
|
||||
if order_by: sql += f" ORDER BY {order_by}"
|
||||
sql += f" LIMIT {limit} OFFSET {offset}"
|
||||
return self.execute_query(sql)
|
||||
|
||||
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
||||
sql = f'SELECT COUNT(*) FROM "{table}"'
|
||||
if where: sql += f" WHERE {where}"
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return c.fetchone()[0]
|
||||
|
||||
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
||||
cols = ", ".join(f'"{c}"' for c in data)
|
||||
ph = ", ".join(["%s"] * len(data))
|
||||
with self._cur() as c:
|
||||
c.execute(f'INSERT INTO "{table}" ({cols}) VALUES ({ph})',
|
||||
tuple(data.values()))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _where(where: dict) -> tuple:
|
||||
parts, params = [], []
|
||||
for col, val in where.items():
|
||||
if val is None:
|
||||
parts.append(f'"{col}" IS NULL')
|
||||
else:
|
||||
parts.append(f'"{col}" = %s')
|
||||
params.append(val)
|
||||
return " AND ".join(parts), params
|
||||
|
||||
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
||||
set_cl = ", ".join(f'"{c}" = %s' for c in data)
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f'UPDATE "{table}" SET {set_cl} WHERE {where_cl}',
|
||||
tuple(data.values()) + tuple(where_params))
|
||||
return True
|
||||
|
||||
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f'DELETE FROM "{table}" WHERE {where_cl}',
|
||||
tuple(where_params))
|
||||
return True
|
||||
|
||||
# ── Server tools ──────────────────────────────────────────────────────────
|
||||
|
||||
def explain_query(self, sql: str) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(f"EXPLAIN ANALYZE {sql}")
|
||||
return ["Plan"], c.fetchall()
|
||||
|
||||
def get_process_list(self) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT pid, usename, application_name, client_addr,
|
||||
state, query, query_start
|
||||
FROM pg_stat_activity WHERE state IS NOT NULL
|
||||
ORDER BY query_start DESC NULLS LAST
|
||||
""")
|
||||
cols = [d[0] for d in c.description]
|
||||
return cols, c.fetchall()
|
||||
|
||||
def kill_process(self, process_id: int) -> bool:
|
||||
with self._cur() as c:
|
||||
c.execute("SELECT pg_terminate_backend(%s)", (process_id,))
|
||||
return True
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
def add_column(self, _database: str, table: str, col_name: str,
|
||||
col_type: str, nullable: bool = True,
|
||||
default=None) -> bool:
|
||||
default_clause = f" DEFAULT {default}" if default is not None and default != "" else ""
|
||||
null_clause = "" if nullable else " NOT NULL"
|
||||
sql = (f'ALTER TABLE "{table}" '
|
||||
f'ADD COLUMN "{col_name}" {col_type}{default_clause}{null_clause}')
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def drop_column(self, _database: str, table: str, col_name: str) -> bool:
|
||||
sql = f'ALTER TABLE "{table}" DROP COLUMN "{col_name}"'
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
|
||||
def rename_column(self, _database: str, table: str,
|
||||
old_name: str, new_name: str) -> bool:
|
||||
sql = f'ALTER TABLE "{table}" RENAME COLUMN "{old_name}" TO "{new_name}"'
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return True
|
||||
@@ -0,0 +1,280 @@
|
||||
"""SQLite driver implementation using stdlib sqlite3."""
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from app.drivers.base import (
|
||||
BaseDriver, ColumnInfo, IndexInfo, ForeignKeyInfo, TableInfo
|
||||
)
|
||||
|
||||
|
||||
class SQLiteDriver(BaseDriver):
|
||||
"""SQLite database driver (uses stdlib sqlite3)."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.db_type = "sqlite"
|
||||
|
||||
def connect(self) -> None:
|
||||
db_path = self.config.get("database", ":memory:")
|
||||
self._connection = sqlite3.connect(
|
||||
db_path,
|
||||
check_same_thread=False,
|
||||
timeout=int(self.config.get("connection_timeout", 30)),
|
||||
)
|
||||
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
try:
|
||||
self._connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._connection = None
|
||||
|
||||
def test_connection(self) -> tuple:
|
||||
try:
|
||||
db_path = self.config.get("database", "")
|
||||
conn = sqlite3.connect(db_path, timeout=5)
|
||||
conn.execute("SELECT 1")
|
||||
conn.close()
|
||||
return True, "Connection successful"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@contextmanager
|
||||
def _cur(self):
|
||||
"""Yield a cursor while holding the driver lock (thread-safe execute→fetch)."""
|
||||
with self._lock:
|
||||
cursor = self._connection.cursor()
|
||||
try:
|
||||
yield cursor
|
||||
finally:
|
||||
try:
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Schema introspection ──────────────────────────────────────────────────
|
||||
|
||||
def get_databases(self) -> list:
|
||||
return [self.config.get("database", "main")]
|
||||
|
||||
def get_tables(self, database: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
""")
|
||||
names = [r[0] for r in c.fetchall()]
|
||||
tables = []
|
||||
for name in names:
|
||||
try:
|
||||
with self._cur() as rc:
|
||||
rc.execute(f'SELECT COUNT(*) FROM "{name}"')
|
||||
row_count = rc.fetchone()[0]
|
||||
except Exception:
|
||||
row_count = 0
|
||||
tables.append(TableInfo(name=name, schema="main", row_count=row_count))
|
||||
return tables
|
||||
|
||||
def get_views(self, database: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
c.execute("SELECT name FROM sqlite_master WHERE type='view' ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
def get_columns(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(f'PRAGMA table_info("{table}")')
|
||||
cols = []
|
||||
for r in c.fetchall():
|
||||
# cid, name, type, notnull, dflt_value, pk
|
||||
cols.append(ColumnInfo(
|
||||
name=r[1],
|
||||
data_type=r[2] or "TEXT",
|
||||
nullable=not bool(r[3]),
|
||||
default=str(r[4]) if r[4] is not None else None,
|
||||
is_primary_key=bool(r[5]),
|
||||
is_foreign_key=False,
|
||||
))
|
||||
return cols
|
||||
|
||||
def get_indexes(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(f'PRAGMA index_list("{table}")')
|
||||
index_rows = c.fetchall()
|
||||
indexes = []
|
||||
for r in index_rows:
|
||||
idx_name = r[1]
|
||||
is_unique = bool(r[2])
|
||||
with self._cur() as cc:
|
||||
cc.execute(f'PRAGMA index_info("{idx_name}")')
|
||||
cols = [row[2] for row in cc.fetchall()]
|
||||
indexes.append(IndexInfo(name=idx_name, columns=cols, is_unique=is_unique))
|
||||
return indexes
|
||||
|
||||
def get_foreign_keys(self, database: str, table: str) -> list:
|
||||
with self._cur() as c:
|
||||
c.execute(f'PRAGMA foreign_key_list("{table}")')
|
||||
return [ForeignKeyInfo(
|
||||
name=f"fk_{r[3]}",
|
||||
column=r[3], ref_table=r[2], ref_column=r[4],
|
||||
on_update=r[5] or "", on_delete=r[6] or "",
|
||||
) for r in c.fetchall()]
|
||||
|
||||
def get_table_ddl(self, database: str, table: str) -> str:
|
||||
with self._cur() as c:
|
||||
c.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE name = ? AND type = 'table'",
|
||||
(table,)
|
||||
)
|
||||
row = c.fetchone()
|
||||
return row[0] if row else ""
|
||||
|
||||
def get_functions(self, database: str) -> list:
|
||||
return []
|
||||
|
||||
def get_stored_procedures(self, database: str) -> list:
|
||||
return []
|
||||
|
||||
def get_triggers(self, database: str, table: str = "") -> list:
|
||||
with self._cur() as c:
|
||||
if table:
|
||||
c.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=?",
|
||||
(table,)
|
||||
)
|
||||
else:
|
||||
c.execute("SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name")
|
||||
return [r[0] for r in c.fetchall()]
|
||||
|
||||
# ── Query execution ───────────────────────────────────────────────────────
|
||||
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(sql, params or ())
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
return cols, rows, len(rows)
|
||||
self._connection.commit()
|
||||
return [], [], c.rowcount
|
||||
|
||||
def execute_script(self, sql: str) -> list:
|
||||
results = []
|
||||
stmts = [s.strip() for s in sql.split(';') if s.strip()]
|
||||
for stmt in stmts:
|
||||
try:
|
||||
with self._cur() as c:
|
||||
c.execute(stmt)
|
||||
if c.description:
|
||||
cols = [d[0] for d in c.description]
|
||||
rows = c.fetchall()
|
||||
results.append((cols, rows, len(rows), ""))
|
||||
else:
|
||||
self._connection.commit()
|
||||
results.append(([], [], c.rowcount,
|
||||
f"{c.rowcount} row(s) affected"))
|
||||
except Exception as e:
|
||||
results.append(([], [], 0, f"Error: {e}"))
|
||||
return results
|
||||
|
||||
# ── Table data CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
def get_table_data(self, database: str, table: str,
|
||||
where: str = "", order_by: str = "",
|
||||
limit: int = 1000, offset: int = 0) -> tuple:
|
||||
sql = f'SELECT * FROM "{table}"'
|
||||
if where: sql += f" WHERE {where}"
|
||||
if order_by: sql += f" ORDER BY {order_by}"
|
||||
sql += f" LIMIT {limit} OFFSET {offset}"
|
||||
return self.execute_query(sql)
|
||||
|
||||
def get_table_row_count(self, database: str, table: str, where: str = "") -> int:
|
||||
sql = f'SELECT COUNT(*) FROM "{table}"'
|
||||
if where: sql += f" WHERE {where}"
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
return c.fetchone()[0]
|
||||
|
||||
def insert_row(self, database: str, table: str, data: dict) -> bool:
|
||||
cols = ", ".join(f'"{c}"' for c in data)
|
||||
ph = ", ".join(["?"] * len(data))
|
||||
with self._cur() as c:
|
||||
c.execute(f'INSERT INTO "{table}" ({cols}) VALUES ({ph})',
|
||||
tuple(data.values()))
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _where(where: dict) -> tuple:
|
||||
parts, params = [], []
|
||||
for col, val in where.items():
|
||||
if val is None:
|
||||
parts.append(f'"{col}" IS NULL')
|
||||
else:
|
||||
parts.append(f'"{col}" = ?')
|
||||
params.append(val)
|
||||
return " AND ".join(parts), params
|
||||
|
||||
def update_row(self, database: str, table: str, data: dict, where: dict) -> bool:
|
||||
set_cl = ", ".join(f'"{c}" = ?' for c in data)
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f'UPDATE "{table}" SET {set_cl} WHERE {where_cl}',
|
||||
tuple(data.values()) + tuple(where_params))
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
def delete_row(self, database: str, table: str, where: dict) -> bool:
|
||||
where_cl, where_params = self._where(where)
|
||||
with self._cur() as c:
|
||||
c.execute(f'DELETE FROM "{table}" WHERE {where_cl}', tuple(where_params))
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
# ── Server tools ──────────────────────────────────────────────────────────
|
||||
|
||||
def explain_query(self, sql: str) -> tuple:
|
||||
with self._cur() as c:
|
||||
c.execute(f"EXPLAIN QUERY PLAN {sql}")
|
||||
cols = [d[0] for d in c.description]
|
||||
return cols, c.fetchall()
|
||||
|
||||
def get_process_list(self) -> tuple:
|
||||
return ["Info"], [("SQLite does not support process listing.",)]
|
||||
|
||||
def kill_process(self, process_id: int) -> bool:
|
||||
return False
|
||||
|
||||
# ── Table designer ────────────────────────────────────────────────────────
|
||||
|
||||
def add_column(self, _database: str, table: str, col_name: str,
|
||||
col_type: str, nullable: bool = True,
|
||||
default=None) -> bool:
|
||||
null_clause = "" if nullable else " NOT NULL"
|
||||
default_clause = f" DEFAULT {default}" if default is not None and default != "" else ""
|
||||
sql = (f'ALTER TABLE "{table}" '
|
||||
f'ADD COLUMN "{col_name}" {col_type}{default_clause}{null_clause}')
|
||||
with self._cur() as c:
|
||||
c.execute(sql)
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
def drop_column(self, _database: str, table: str, col_name: str) -> bool:
|
||||
# Requires SQLite 3.35.0+
|
||||
with self._cur() as c:
|
||||
c.execute(f'ALTER TABLE "{table}" DROP COLUMN "{col_name}"')
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
def rename_column(self, _database: str, table: str,
|
||||
old_name: str, new_name: str) -> bool:
|
||||
# Requires SQLite 3.25.0+
|
||||
with self._cur() as c:
|
||||
c.execute(f'ALTER TABLE "{table}" RENAME COLUMN "{old_name}" TO "{new_name}"')
|
||||
self._connection.commit()
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""
|
||||
Main application window.
|
||||
|
||||
On startup:
|
||||
• All saved connection profiles are loaded from ~/.dbclient/connections.json
|
||||
and shown in the sidebar in "disconnected" state.
|
||||
• Double-clicking or right-click → Connect instantly re-connects.
|
||||
• New Connection dialog saves the profile AND connects immediately.
|
||||
• Edit / Delete work on both connected and saved-only profiles.
|
||||
"""
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter,
|
||||
QTabWidget, QStatusBar, QLabel, QMessageBox, QDockWidget,
|
||||
QPushButton, QApplication,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QAction, QKeySequence
|
||||
|
||||
from app.ui.schema_browser import SchemaBrowser
|
||||
from app.ui.sql_editor import SQLEditorWidget
|
||||
from app.ui.table_viewer import TableViewer
|
||||
from app.ui.table_structure import TableStructureView
|
||||
from app.ui.query_history import QueryHistoryPanel
|
||||
from app.ui.process_list import ProcessListPanel
|
||||
from app.ui.import_dialog import ImportDialog
|
||||
from app.ui.dump_dialog import DumpDialog
|
||||
from app.ui.explain_view import ExplainPanel
|
||||
from app.ui.user_manager import UserManagerPanel
|
||||
from app.ui.log_viewer import LogViewer
|
||||
from app.ui.connection_dialog import ConnectionDialog
|
||||
from app.config.connections import load_profiles, delete_profile
|
||||
from app.models.connection_model import ConnectionProfile
|
||||
from app.drivers import get_driver
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
_log = get_logger(__name__)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("DBClient")
|
||||
self.resize(1400, 860)
|
||||
self.setMinimumSize(1024, 640)
|
||||
|
||||
# profile_id → driver (only currently connected ones)
|
||||
self._active_drivers: dict = {}
|
||||
# profile_id → ConnectionProfile (all loaded profiles, connected or not)
|
||||
self._all_profiles: dict = {}
|
||||
|
||||
self._build_ui()
|
||||
self._build_menus()
|
||||
self._build_status_bar()
|
||||
|
||||
# Load saved profiles after the window is shown
|
||||
QTimer.singleShot(0, self._load_saved_profiles)
|
||||
|
||||
# ── Layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
h = QHBoxLayout(central)
|
||||
h.setContentsMargins(0, 0, 0, 0)
|
||||
h.setSpacing(0)
|
||||
|
||||
self._h_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
self._h_splitter.setHandleWidth(2)
|
||||
|
||||
# ── Left sidebar ──────────────────────────────────────────────────────
|
||||
left = QWidget()
|
||||
left.setMinimumWidth(220)
|
||||
left.setMaximumWidth(420)
|
||||
ll = QVBoxLayout(left)
|
||||
ll.setContentsMargins(0, 0, 0, 0)
|
||||
ll.setSpacing(0)
|
||||
|
||||
hdr = QWidget()
|
||||
hdr.setObjectName("sidebarHeader")
|
||||
hdr_lay = QHBoxLayout(hdr)
|
||||
hdr_lay.setContentsMargins(8, 6, 8, 6)
|
||||
hdr_lay.setSpacing(4)
|
||||
|
||||
title = QLabel(" Connections")
|
||||
title.setObjectName("sidebarTitle")
|
||||
f = title.font()
|
||||
f.setBold(True)
|
||||
title.setFont(f)
|
||||
|
||||
self._new_conn_btn = QPushButton("+")
|
||||
self._new_conn_btn.setObjectName("newConnBtn")
|
||||
self._new_conn_btn.setFixedSize(28, 28)
|
||||
self._new_conn_btn.setToolTip("New Connection (Ctrl+N)")
|
||||
self._new_conn_btn.clicked.connect(self._new_connection)
|
||||
|
||||
hdr_lay.addWidget(title, 1)
|
||||
hdr_lay.addWidget(self._new_conn_btn)
|
||||
ll.addWidget(hdr)
|
||||
|
||||
self._schema_browser = SchemaBrowser()
|
||||
# Tree → workspace wiring
|
||||
self._schema_browser.open_table_viewer.connect(self._open_table_viewer)
|
||||
self._schema_browser.open_table_structure.connect(self._open_table_structure)
|
||||
self._schema_browser.open_sql_editor.connect(self._open_sql_editor)
|
||||
self._schema_browser.run_query_requested.connect(self._paste_query)
|
||||
# Saved-profile management signals
|
||||
self._schema_browser.connect_requested.connect(self._connect_by_id)
|
||||
self._schema_browser.edit_requested.connect(self._edit_connection)
|
||||
self._schema_browser.delete_requested.connect(self._delete_connection)
|
||||
ll.addWidget(self._schema_browser, 1)
|
||||
|
||||
# ── Right workspace ───────────────────────────────────────────────────
|
||||
right = QWidget()
|
||||
rl = QVBoxLayout(right)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
rl.setSpacing(0)
|
||||
|
||||
self._workspace = QTabWidget()
|
||||
self._workspace.setTabsClosable(True)
|
||||
self._workspace.setMovable(True)
|
||||
self._workspace.tabCloseRequested.connect(self._close_tab)
|
||||
self._workspace.setObjectName("workspace")
|
||||
|
||||
self._empty_label = QLabel(
|
||||
"🔌 Double-click a saved connection to connect\n\n"
|
||||
"Use + to add a new connection profile."
|
||||
)
|
||||
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._empty_label.setObjectName("emptyLabel")
|
||||
|
||||
rl.addWidget(self._empty_label)
|
||||
rl.addWidget(self._workspace)
|
||||
self._workspace.setVisible(False)
|
||||
|
||||
# ── History dock ──────────────────────────────────────────────────────
|
||||
self._history_panel = QueryHistoryPanel()
|
||||
self._history_panel.run_query.connect(self._paste_query)
|
||||
history_dock = QDockWidget("Query History", self)
|
||||
history_dock.setWidget(self._history_panel)
|
||||
history_dock.setMinimumHeight(120)
|
||||
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, history_dock)
|
||||
history_dock.setVisible(False)
|
||||
self._history_dock = history_dock
|
||||
|
||||
self._h_splitter.addWidget(left)
|
||||
self._h_splitter.addWidget(right)
|
||||
self._h_splitter.setSizes([260, 1100])
|
||||
h.addWidget(self._h_splitter)
|
||||
|
||||
# ── Menus ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_menus(self):
|
||||
mb = self.menuBar()
|
||||
|
||||
file_menu = mb.addMenu("&File")
|
||||
file_menu.addAction(self._act("+ New Connection…", self._new_connection, "Ctrl+N"))
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(self._act("Exit", QApplication.quit, "Ctrl+Q"))
|
||||
|
||||
view_menu = mb.addMenu("&View")
|
||||
view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+H"))
|
||||
view_menu.addAction(self._act("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
|
||||
|
||||
tools_menu = mb.addMenu("&Tools")
|
||||
tools_menu.addAction(self._act("Process List…", self._open_process_list, "Ctrl+P"))
|
||||
tools_menu.addSeparator()
|
||||
tools_menu.addAction(self._act("Import CSV / JSON…", self._open_import_dialog))
|
||||
tools_menu.addAction(self._act("Export Database Dump…", self._open_dump_dialog))
|
||||
tools_menu.addSeparator()
|
||||
tools_menu.addAction(self._act("User & Privilege Management…", self._open_user_manager, "Ctrl+U"))
|
||||
|
||||
help_menu = mb.addMenu("&Help")
|
||||
help_menu.addAction(self._act("Keyboard Shortcuts", self._show_shortcuts))
|
||||
help_menu.addAction(self._act("View App Logs", self._open_log_viewer, "Ctrl+L"))
|
||||
help_menu.addSeparator()
|
||||
help_menu.addAction(self._act("About DBClient", self._show_about))
|
||||
|
||||
def _act(self, text: str, slot, shortcut: str = None) -> QAction:
|
||||
"""Create a QAction parented to this window (prevents GC from killing it)."""
|
||||
a = QAction(text, self)
|
||||
a.triggered.connect(slot)
|
||||
if shortcut:
|
||||
a.setShortcut(QKeySequence(shortcut))
|
||||
return a
|
||||
|
||||
# ── Status bar ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_status_bar(self):
|
||||
sb = QStatusBar()
|
||||
self.setStatusBar(sb)
|
||||
self._status_lbl = QLabel("Ready")
|
||||
sb.addWidget(self._status_lbl, 1)
|
||||
|
||||
def _set_status(self, msg: str):
|
||||
self._status_lbl.setText(msg)
|
||||
|
||||
# ── Startup: load saved profiles ──────────────────────────────────────────
|
||||
|
||||
def _load_saved_profiles(self):
|
||||
"""
|
||||
Load all saved connection profiles from disk and add them to the
|
||||
sidebar as disconnected nodes. Called once after the window shows.
|
||||
"""
|
||||
profiles = load_profiles()
|
||||
for profile in profiles:
|
||||
self._all_profiles[profile.id] = profile
|
||||
self._schema_browser.add_saved_profile(profile)
|
||||
|
||||
if profiles:
|
||||
self._set_status(
|
||||
f"Loaded {len(profiles)} saved connection(s). "
|
||||
"Double-click to connect."
|
||||
)
|
||||
|
||||
# ── Connection management ─────────────────────────────────────────────────
|
||||
|
||||
def _new_connection(self):
|
||||
"""Open the New Connection dialog, save the profile, and connect."""
|
||||
dlg = ConnectionDialog(parent=self)
|
||||
if dlg.exec():
|
||||
profile = dlg.profile
|
||||
self._all_profiles[profile.id] = profile
|
||||
# Dialog already persisted it via save_profile()
|
||||
# Show it as saved first, then auto-connect
|
||||
self._schema_browser.add_saved_profile(profile)
|
||||
self._do_connect(profile)
|
||||
|
||||
def _connect_by_id(self, profile_id: str):
|
||||
"""Called when user double-clicks / right-clicks Connect on a saved node."""
|
||||
if self._schema_browser.is_connected(profile_id):
|
||||
self._set_status("Already connected.")
|
||||
return
|
||||
profile = self._all_profiles.get(profile_id)
|
||||
if not profile:
|
||||
return
|
||||
self._do_connect(profile)
|
||||
|
||||
def _do_connect(self, profile: ConnectionProfile):
|
||||
"""Build the driver, connect, and upgrade the sidebar node."""
|
||||
pid = profile.id
|
||||
config = {
|
||||
"host": profile.host,
|
||||
"port": profile.port,
|
||||
"database": profile.database,
|
||||
"user": profile.username,
|
||||
"password": profile.password,
|
||||
"connection_timeout": profile.connection_timeout,
|
||||
}
|
||||
_log.info("Connecting to '%s' type=%s host=%s",
|
||||
profile.name, profile.db_type, profile.host)
|
||||
try:
|
||||
driver = get_driver(profile.db_type, config)
|
||||
driver.connect()
|
||||
self._active_drivers[pid] = driver
|
||||
self._schema_browser.add_connection(profile, driver)
|
||||
self._show_workspace()
|
||||
self._set_status(f"✅ Connected: {profile.name}")
|
||||
_log.info("Connected to '%s' successfully", profile.name)
|
||||
except Exception as e:
|
||||
_log.error("Connection failed for '%s': %s", profile.name, e,
|
||||
exc_info=True)
|
||||
QMessageBox.critical(self, "Connection Error",
|
||||
f"Could not connect to '{profile.name}':\n\n{e}")
|
||||
|
||||
def _edit_connection(self, profile_id: str):
|
||||
"""Open edit dialog for a saved or connected profile."""
|
||||
profile = self._all_profiles.get(profile_id)
|
||||
if not profile:
|
||||
return
|
||||
|
||||
was_connected = self._schema_browser.is_connected(profile_id)
|
||||
|
||||
dlg = ConnectionDialog(profile=profile, parent=self)
|
||||
if not dlg.exec():
|
||||
return
|
||||
|
||||
updated = dlg.profile
|
||||
self._all_profiles[profile_id] = updated
|
||||
# save_profile() was already called inside the dialog
|
||||
|
||||
if was_connected:
|
||||
# Disconnect first, then reconnect with new credentials
|
||||
self._schema_browser.remove_connection(profile_id, keep_saved=True)
|
||||
self._active_drivers.pop(profile_id, None)
|
||||
self._do_connect(updated)
|
||||
else:
|
||||
self._schema_browser.update_saved_profile(updated)
|
||||
|
||||
self._set_status(f"Connection '{updated.name}' updated.")
|
||||
|
||||
def _delete_connection(self, profile_id: str):
|
||||
"""Delete a profile entirely from memory and disk."""
|
||||
profile = self._all_profiles.get(profile_id)
|
||||
if not profile:
|
||||
return
|
||||
|
||||
btn = QMessageBox.warning(
|
||||
self, "Delete Connection",
|
||||
f"Delete the connection profile '{profile.name}'?\n\n"
|
||||
"This removes it from the saved list. "
|
||||
"The database itself will NOT be affected.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if btn != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
# Disconnect if active
|
||||
if self._schema_browser.is_connected(profile_id):
|
||||
self._schema_browser.remove_connection(profile_id, keep_saved=False)
|
||||
self._active_drivers.pop(profile_id, None)
|
||||
else:
|
||||
self._schema_browser.remove_connection(profile_id, keep_saved=False)
|
||||
|
||||
self._all_profiles.pop(profile_id, None)
|
||||
delete_profile(profile_id)
|
||||
self._set_status(f"Connection '{profile.name}' deleted.")
|
||||
|
||||
# ── Workspace helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _show_workspace(self):
|
||||
self._empty_label.setVisible(False)
|
||||
self._workspace.setVisible(True)
|
||||
|
||||
def _open_table_viewer(self, driver, database: str, table: str):
|
||||
tab = TableViewer(driver, database, table)
|
||||
tab.status_message.connect(self._set_status)
|
||||
idx = self._workspace.addTab(tab, f"📋 {table}")
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
|
||||
def _open_table_structure(self, driver, database: str, table: str):
|
||||
tab = TableStructureView(driver, database, table)
|
||||
tab.status_message.connect(self._set_status)
|
||||
idx = self._workspace.addTab(tab, f"🏗️ {table}")
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
|
||||
def _open_sql_editor(self, driver, database: str):
|
||||
sql_widget = SQLEditorWidget()
|
||||
sql_widget.status_message.connect(self._set_status)
|
||||
sql_widget.new_tab(driver, database)
|
||||
label = f"✏️ SQL — {database}" if database else "✏️ SQL"
|
||||
idx = self._workspace.addTab(sql_widget, label)
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
|
||||
def _new_sql_tab(self):
|
||||
sql_widget = SQLEditorWidget()
|
||||
sql_widget.status_message.connect(self._set_status)
|
||||
sql_widget.new_tab()
|
||||
idx = self._workspace.addTab(sql_widget, "✏️ SQL")
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
|
||||
def _paste_query(self, sql: str):
|
||||
current = self._workspace.currentWidget()
|
||||
if isinstance(current, SQLEditorWidget):
|
||||
tab = current.current_tab()
|
||||
if tab:
|
||||
tab.set_sql(sql)
|
||||
return
|
||||
self._new_sql_tab()
|
||||
self._paste_query(sql)
|
||||
|
||||
def _close_tab(self, idx: int):
|
||||
self._workspace.removeTab(idx)
|
||||
if self._workspace.count() == 0:
|
||||
self._workspace.setVisible(False)
|
||||
self._empty_label.setVisible(True)
|
||||
|
||||
# ── Misc ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_history(self):
|
||||
self._history_dock.setVisible(not self._history_dock.isVisible())
|
||||
|
||||
def _show_about(self):
|
||||
QMessageBox.about(self, "About DBClient",
|
||||
"<b>DBClient</b> v1.0.0<br><br>"
|
||||
"Cross-platform desktop database client.<br>"
|
||||
"MySQL · PostgreSQL · SQLite · SQL Server<br><br>"
|
||||
"Built with Python + PyQt6.")
|
||||
|
||||
def _show_shortcuts(self):
|
||||
QMessageBox.information(self, "Keyboard Shortcuts",
|
||||
"F5 / Ctrl+Enter — Run query\n"
|
||||
"Ctrl+/ — Toggle comment\n"
|
||||
"Ctrl+N — New connection\n"
|
||||
"Ctrl+T — New SQL tab\n"
|
||||
"Ctrl+H — Toggle query history\n"
|
||||
"Ctrl+U — User management\n"
|
||||
"Ctrl+L — View app logs\n"
|
||||
"Delete — Delete selected row (table viewer)\n"
|
||||
"Ins — Add new row (table viewer)\n"
|
||||
"Ctrl+Q — Quit")
|
||||
|
||||
def _open_log_viewer(self):
|
||||
"""Open the application log viewer as a workspace tab."""
|
||||
viewer = LogViewer(parent=self)
|
||||
idx = self._workspace.addTab(viewer, "📋 App Logs")
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
self._show_workspace()
|
||||
|
||||
def _open_process_list(self):
|
||||
"""Open a Process List tab for the currently active connection."""
|
||||
driver, name = self._active_driver_for_tools()
|
||||
if driver is None:
|
||||
return
|
||||
panel = ProcessListPanel(driver, connection_name=name)
|
||||
panel.status_message.connect(self._set_status)
|
||||
idx = self._workspace.addTab(panel, f"⚙️ Processes — {name}")
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
self._show_workspace()
|
||||
|
||||
def _open_import_dialog(self):
|
||||
"""Open the Import CSV/JSON dialog targeting the active connection."""
|
||||
driver, name = self._active_driver_for_tools()
|
||||
if driver is None:
|
||||
return
|
||||
# Determine current database from the active workspace tab if possible
|
||||
database = ""
|
||||
current = self._workspace.currentWidget()
|
||||
if hasattr(current, "_database"):
|
||||
database = current._database or ""
|
||||
dlg = ImportDialog(driver, database=database, table="", parent=self)
|
||||
dlg.exec()
|
||||
|
||||
def _open_dump_dialog(self):
|
||||
"""Open the Export Database Dump dialog targeting the active connection."""
|
||||
driver, name = self._active_driver_for_tools()
|
||||
if driver is None:
|
||||
return
|
||||
# Pre-select the database visible in the current workspace tab
|
||||
database = ""
|
||||
current = self._workspace.currentWidget()
|
||||
if hasattr(current, "_database"):
|
||||
database = current._database or ""
|
||||
dlg = DumpDialog(driver, database=database, parent=self)
|
||||
dlg.exec()
|
||||
|
||||
def _open_user_manager(self):
|
||||
"""Open the User & Privilege Management tab."""
|
||||
driver, name = self._active_driver_for_tools()
|
||||
if driver is None:
|
||||
return
|
||||
panel = UserManagerPanel(driver, parent=self)
|
||||
panel.status_message.connect(self._set_status)
|
||||
idx = self._workspace.addTab(panel, f"👤 Users — {name}")
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
self._show_workspace()
|
||||
|
||||
def open_explain_tab(self, driver, database: str, sql: str):
|
||||
"""Open an EXPLAIN plan tab (called from SQLEditorWidget)."""
|
||||
panel = ExplainPanel(driver, sql, parent=self)
|
||||
panel.status_message.connect(self._set_status)
|
||||
short_sql = sql[:40].replace("\n", " ") + ("…" if len(sql) > 40 else "")
|
||||
idx = self._workspace.addTab(panel, f"🔎 EXPLAIN")
|
||||
self._workspace.setTabToolTip(idx, short_sql)
|
||||
self._workspace.setCurrentIndex(idx)
|
||||
self._show_workspace()
|
||||
|
||||
def _active_driver_for_tools(self):
|
||||
"""Return (driver, connection_name) for the first active connection,
|
||||
or show a warning and return (None, '') if none are connected."""
|
||||
if not self._active_drivers:
|
||||
QMessageBox.information(
|
||||
self, "No Active Connection",
|
||||
"Connect to a database first."
|
||||
)
|
||||
return None, ""
|
||||
pid = next(iter(self._active_drivers))
|
||||
driver = self._active_drivers[pid]
|
||||
profile = self._all_profiles.get(pid)
|
||||
name = profile.name if profile else pid
|
||||
return driver, name
|
||||
|
||||
def closeEvent(self, event):
|
||||
for driver in self._active_drivers.values():
|
||||
try:
|
||||
driver.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
event.accept()
|
||||
@@ -0,0 +1 @@
|
||||
# models package
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Connection profile dataclass and registry.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionProfile:
|
||||
name: str
|
||||
db_type: str # mysql | postgresql | sqlite | mssql
|
||||
host: str = "localhost"
|
||||
port: int = 3306
|
||||
database: str = ""
|
||||
username: str = ""
|
||||
color: str = "#89b4fa"
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
ssl: bool = False
|
||||
ssl_cert: str = ""
|
||||
ssl_key: str = ""
|
||||
ssl_ca: str = ""
|
||||
connection_timeout: int = 30
|
||||
|
||||
DB_PORTS = {
|
||||
"mysql": 3306,
|
||||
"postgresql": 5432,
|
||||
"sqlite": 0,
|
||||
"mssql": 1433,
|
||||
}
|
||||
|
||||
DB_DISPLAY = {
|
||||
"mysql": "MySQL",
|
||||
"postgresql": "PostgreSQL",
|
||||
"sqlite": "SQLite",
|
||||
"mssql": "SQL Server",
|
||||
}
|
||||
|
||||
@property
|
||||
def db_type_display(self) -> str:
|
||||
return self.DB_DISPLAY.get(self.db_type, self.db_type)
|
||||
|
||||
@property
|
||||
def default_port(self) -> int:
|
||||
return self.DB_PORTS.get(self.db_type, 0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"db_type": self.db_type,
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"database": self.database,
|
||||
"username": self.username,
|
||||
"color": self.color,
|
||||
"ssl": self.ssl,
|
||||
"ssl_cert": self.ssl_cert,
|
||||
"ssl_key": self.ssl_key,
|
||||
"ssl_ca": self.ssl_ca,
|
||||
"connection_timeout": self.connection_timeout,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ConnectionProfile":
|
||||
return cls(
|
||||
id=data.get("id", str(uuid.uuid4())),
|
||||
name=data.get("name", "Untitled"),
|
||||
db_type=data.get("db_type", "mysql"),
|
||||
host=data.get("host", "localhost"),
|
||||
port=data.get("port", 3306),
|
||||
database=data.get("database", ""),
|
||||
username=data.get("username", ""),
|
||||
color=data.get("color", "#89b4fa"),
|
||||
ssl=data.get("ssl", False),
|
||||
ssl_cert=data.get("ssl_cert", ""),
|
||||
ssl_key=data.get("ssl_key", ""),
|
||||
ssl_ca=data.get("ssl_ca", ""),
|
||||
connection_timeout=data.get("connection_timeout", 30),
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
QAbstractTableModel that wraps a list of plain tuples (rows) for display
|
||||
in a QTableView. Supports sorting and in-place data refresh.
|
||||
"""
|
||||
from PyQt6.QtCore import (
|
||||
QAbstractTableModel, QModelIndex, Qt, QSortFilterProxyModel
|
||||
)
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
|
||||
|
||||
class ResultTableModel(QAbstractTableModel):
|
||||
"""Immutable result-set model — replaces data via set_data()."""
|
||||
|
||||
NULL_COLOR = QColor("#6c7086") # muted grey for NULL
|
||||
NUM_ALIGN = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
TEXT_ALIGN = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
|
||||
_NUMERIC_TYPES = (int, float)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._columns: list = []
|
||||
self._rows: list = []
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def set_data(self, columns: list, rows: list) -> None:
|
||||
self.beginResetModel()
|
||||
self._columns = list(columns)
|
||||
self._rows = [tuple(r) for r in rows]
|
||||
self.endResetModel()
|
||||
|
||||
def clear(self) -> None:
|
||||
self.set_data([], [])
|
||||
|
||||
def get_row(self, row: int) -> tuple:
|
||||
return self._rows[row]
|
||||
|
||||
def column_names(self) -> list:
|
||||
return list(self._columns)
|
||||
|
||||
def export_csv(self, filepath: str, delimiter: str = ",") -> None:
|
||||
import csv
|
||||
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f, delimiter=delimiter)
|
||||
writer.writerow(self._columns)
|
||||
writer.writerows(self._rows)
|
||||
|
||||
def export_json(self, filepath: str) -> None:
|
||||
import json
|
||||
records = [dict(zip(self._columns, row)) for row in self._rows]
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(records, f, indent=2, default=str)
|
||||
|
||||
def export_sql(self, filepath: str, table_name: str = "table") -> None:
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
cols = ", ".join(self._columns)
|
||||
for row in self._rows:
|
||||
vals = ", ".join(
|
||||
"NULL" if v is None else f"'{str(v).replace(chr(39), chr(39)*2)}'"
|
||||
for v in row
|
||||
)
|
||||
f.write(f"INSERT INTO {table_name} ({cols}) VALUES ({vals});\n")
|
||||
|
||||
# ── QAbstractTableModel interface ─────────────────────────────────────────
|
||||
|
||||
def rowCount(self, parent=QModelIndex()) -> int:
|
||||
return len(self._rows)
|
||||
|
||||
def columnCount(self, parent=QModelIndex()) -> int:
|
||||
return len(self._columns)
|
||||
|
||||
def headerData(self, section: int, 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 ""
|
||||
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 data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid():
|
||||
return None
|
||||
row, col = index.row(), index.column()
|
||||
if row >= len(self._rows) or col >= len(self._columns):
|
||||
return None
|
||||
value = self._rows[row][col]
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if value is None:
|
||||
return "NULL"
|
||||
return str(value)
|
||||
|
||||
if role == Qt.ItemDataRole.ForegroundRole and value is None:
|
||||
return self.NULL_COLOR
|
||||
|
||||
if role == Qt.ItemDataRole.TextAlignmentRole:
|
||||
if isinstance(value, self._NUMERIC_TYPES):
|
||||
return int(self.NUM_ALIGN)
|
||||
return int(self.TEXT_ALIGN)
|
||||
|
||||
if role == Qt.ItemDataRole.UserRole:
|
||||
return value # raw Python value
|
||||
|
||||
return None
|
||||
|
||||
def flags(self, index: QModelIndex):
|
||||
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
|
||||
@@ -0,0 +1 @@
|
||||
# ui package
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# utils package
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Emoji/Unicode icon helpers — no external icon pack dependency.
|
||||
All icons are rendered from Unicode characters by Qt.
|
||||
"""
|
||||
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QFont, QColor
|
||||
from PyQt6.QtCore import Qt, QSize
|
||||
|
||||
|
||||
# ── Unicode glyph map ─────────────────────────────────────────────────────────
|
||||
ICONS = {
|
||||
# Connections
|
||||
"connection": "🔌",
|
||||
"connected": "🟢",
|
||||
"disconnected": "🔴",
|
||||
|
||||
# Schema tree
|
||||
"database": "🗄️",
|
||||
"table": "📋",
|
||||
"view": "👁️",
|
||||
"column": "📊",
|
||||
"index": "🔍",
|
||||
"primary_key": "🔑",
|
||||
"foreign_key": "🔗",
|
||||
"function": "⚡",
|
||||
"procedure": "📦",
|
||||
"trigger": "⚙️",
|
||||
"folder": "📁",
|
||||
"folder_open": "📂",
|
||||
|
||||
# Actions
|
||||
"run": "▶️",
|
||||
"stop": "⏹️",
|
||||
"explain": "🔎",
|
||||
"new_tab": "➕",
|
||||
"save": "💾",
|
||||
"open": "📂",
|
||||
"export": "📤",
|
||||
"import": "📥",
|
||||
"refresh": "🔄",
|
||||
"delete": "🗑️",
|
||||
"edit": "✏️",
|
||||
"add": "➕",
|
||||
"commit": "✅",
|
||||
"rollback": "↩️",
|
||||
"history": "📜",
|
||||
"filter": "🔍",
|
||||
"copy": "📋",
|
||||
"clear": "🧹",
|
||||
"settings": "⚙️",
|
||||
"help": "❓",
|
||||
"info": "ℹ️",
|
||||
"warning": "⚠️",
|
||||
"error": "❌",
|
||||
"success": "✅",
|
||||
"kill": "🛑",
|
||||
"disconnect": "🔌",
|
||||
|
||||
# DB Types
|
||||
"mysql": "🐬",
|
||||
"postgresql": "🐘",
|
||||
"sqlite": "📁",
|
||||
"mssql": "🪟",
|
||||
}
|
||||
|
||||
|
||||
def make_icon(glyph: str, size: int = 20,
|
||||
fg: str = "#cdd6f4", bg: str = "transparent") -> QIcon:
|
||||
"""Create a QIcon from a Unicode glyph."""
|
||||
px = QPixmap(QSize(size, size))
|
||||
px.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(px)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
font = QFont()
|
||||
font.setPointSize(int(size * 0.55))
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor(fg))
|
||||
painter.drawText(px.rect(), Qt.AlignmentFlag.AlignCenter, glyph)
|
||||
painter.end()
|
||||
return QIcon(px)
|
||||
|
||||
|
||||
def get_icon(name: str, size: int = 20) -> QIcon:
|
||||
"""Get a named icon. Falls back to a question mark if unknown."""
|
||||
glyph = ICONS.get(name, "❓")
|
||||
return make_icon(glyph, size)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Centralised application logging for DBClient.
|
||||
|
||||
Call ``setup_logging()`` once at startup (in main.py).
|
||||
Then anywhere in the codebase:
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
log = get_logger(__name__)
|
||||
log.info("Connected to %s", host)
|
||||
log.error("Query failed", exc_info=True) # includes full traceback
|
||||
|
||||
Log files are written to ~/.dbclient/logs/dbclient.log
|
||||
with daily rotation, keeping the last 7 files.
|
||||
|
||||
The module also installs:
|
||||
• sys.excepthook — logs every uncaught exception with full traceback
|
||||
• threading.excepthook — logs uncaught exceptions in background threads
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
APP_DIR = Path.home() / ".dbclient"
|
||||
LOG_DIR = APP_DIR / "logs"
|
||||
LOG_FILE = LOG_DIR / "dbclient.log"
|
||||
|
||||
# File: DEBUG and above | Console: WARNING and above
|
||||
FILE_LEVEL = logging.DEBUG
|
||||
CONSOLE_LEVEL = logging.WARNING
|
||||
|
||||
# Rotate at midnight, keep 7 days of log files
|
||||
BACKUP_COUNT = 7
|
||||
|
||||
LOG_FORMAT = (
|
||||
"[%(asctime)s] [%(levelname)-8s] [%(name)s:%(lineno)d] %(message)s"
|
||||
)
|
||||
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# ── Public helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Return a module-level logger. Always call as ``get_logger(__name__)``."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
def get_log_path() -> Path:
|
||||
"""Return the absolute path of the current log file."""
|
||||
return LOG_FILE
|
||||
|
||||
|
||||
# ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def setup_logging(level: int = FILE_LEVEL) -> None:
|
||||
"""
|
||||
Initialise the root logger. Safe to call multiple times (idempotent).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
level : int
|
||||
Minimum level written to the log file (default: DEBUG).
|
||||
The console handler always uses WARNING regardless of this setting.
|
||||
"""
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
root = logging.getLogger()
|
||||
if root.handlers:
|
||||
# Already configured — nothing to do
|
||||
return
|
||||
|
||||
root.setLevel(logging.DEBUG)
|
||||
|
||||
fmt = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
|
||||
|
||||
# ── Rotating file handler (midnight rollover, 7-day retention) ────────────
|
||||
try:
|
||||
fh = logging.handlers.TimedRotatingFileHandler(
|
||||
str(LOG_FILE),
|
||||
when="midnight",
|
||||
backupCount=BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
delay=False,
|
||||
)
|
||||
fh.setLevel(level)
|
||||
fh.setFormatter(fmt)
|
||||
root.addHandler(fh)
|
||||
except OSError as exc:
|
||||
# Can't write logs — at least print a warning
|
||||
print(f"[DBClient] WARNING: Could not open log file {LOG_FILE}: {exc}",
|
||||
file=sys.stderr)
|
||||
|
||||
# ── Console handler (WARNING+ only, for developers) ───────────────────────
|
||||
ch = logging.StreamHandler(sys.stderr)
|
||||
ch.setLevel(CONSOLE_LEVEL)
|
||||
ch.setFormatter(fmt)
|
||||
root.addHandler(ch)
|
||||
|
||||
# ── Silence noisy third-party libraries ───────────────────────────────────
|
||||
for noisy in ("pymysql", "psycopg2", "pyodbc", "urllib3", "PIL"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
_install_exception_hooks()
|
||||
|
||||
log = get_logger(__name__)
|
||||
log.info("=" * 60)
|
||||
log.info("DBClient started (Python %s)", sys.version.split()[0])
|
||||
log.info("Log file: %s", LOG_FILE)
|
||||
|
||||
|
||||
# ── Unhandled exception hooks ─────────────────────────────────────────────────
|
||||
|
||||
def _install_exception_hooks() -> None:
|
||||
"""Capture all uncaught exceptions — both in the main thread and workers."""
|
||||
_log = get_logger("dbclient.uncaught")
|
||||
|
||||
# Main thread
|
||||
def _excepthook(exc_type, exc_value, exc_tb):
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
||||
return
|
||||
_log.critical(
|
||||
"Uncaught exception:\n%s",
|
||||
"".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
|
||||
)
|
||||
# Still let Qt/Python print it to stderr so the developer sees it live
|
||||
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
||||
|
||||
sys.excepthook = _excepthook
|
||||
|
||||
# Background threads (Python 3.8+)
|
||||
def _thread_excepthook(args):
|
||||
if args.exc_type is SystemExit:
|
||||
return
|
||||
_log.critical(
|
||||
"Uncaught exception in thread '%s':\n%s",
|
||||
getattr(args.thread, "name", "?"),
|
||||
"".join(traceback.format_exception(
|
||||
args.exc_type, args.exc_value, args.exc_tb
|
||||
)),
|
||||
)
|
||||
|
||||
threading.excepthook = _thread_excepthook
|
||||
|
||||
|
||||
# ── Qt message handler ─────────────────────────────────────────────────────────
|
||||
|
||||
def install_qt_message_handler() -> None:
|
||||
"""
|
||||
Route Qt's own warning/critical messages into the Python log.
|
||||
Call AFTER QApplication is created.
|
||||
"""
|
||||
try:
|
||||
from PyQt6.QtCore import qInstallMessageHandler, QtMsgType
|
||||
|
||||
_log = get_logger("Qt")
|
||||
_level_map = {
|
||||
QtMsgType.QtDebugMsg: logging.DEBUG,
|
||||
QtMsgType.QtInfoMsg: logging.INFO,
|
||||
QtMsgType.QtWarningMsg: logging.WARNING,
|
||||
QtMsgType.QtCriticalMsg: logging.ERROR,
|
||||
QtMsgType.QtFatalMsg: logging.CRITICAL,
|
||||
}
|
||||
|
||||
def _handler(msg_type, context, message):
|
||||
lvl = _level_map.get(msg_type, logging.WARNING)
|
||||
loc = ""
|
||||
if context.file:
|
||||
loc = f" [{context.file}:{context.line}]"
|
||||
_log.log(lvl, "%s%s", message, loc)
|
||||
|
||||
qInstallMessageHandler(_handler)
|
||||
except Exception:
|
||||
pass # Non-fatal — Qt messages just won't be logged
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
QThread-based async worker for all database operations.
|
||||
Emits results/errors via Qt signals so the UI stays responsive.
|
||||
"""
|
||||
import time
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
_log = get_logger(__name__)
|
||||
|
||||
|
||||
class QueryWorker(QThread):
|
||||
"""Execute a SQL query in a background thread."""
|
||||
|
||||
finished = pyqtSignal(list, list, int, float) # cols, rows, rowcount, elapsed_sec
|
||||
error = pyqtSignal(str)
|
||||
script_done = pyqtSignal(list) # list of (cols, rows, cnt, msg)
|
||||
|
||||
def __init__(self, driver, sql: str, is_script: bool = False, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._sql = sql
|
||||
self._script = is_script
|
||||
|
||||
def run(self):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
if self._script:
|
||||
results = self._driver.execute_script(self._sql)
|
||||
self.script_done.emit(results)
|
||||
else:
|
||||
cols, rows, cnt = self._driver.execute_query(self._sql)
|
||||
elapsed = time.perf_counter() - t0
|
||||
_log.debug("Query OK (%.3f s, %d row(s)) SQL: %.200s",
|
||||
elapsed, cnt, self._sql.replace("\n", " "))
|
||||
self.finished.emit(cols, list(rows), cnt, elapsed)
|
||||
except Exception as e:
|
||||
_log.error("Query failed SQL: %.300s", self._sql.replace("\n", " "),
|
||||
exc_info=True)
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class SchemaWorker(QThread):
|
||||
"""Generic async loader for schema introspection calls."""
|
||||
|
||||
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:
|
||||
data = self._fn(*self._args)
|
||||
self.result.emit(data)
|
||||
except Exception as e:
|
||||
_log.error("SchemaWorker error fn=%s args=%s",
|
||||
getattr(self._fn, "__name__", "?"), self._args,
|
||||
exc_info=True)
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class TableDataWorker(QThread):
|
||||
"""Load paginated table data in background."""
|
||||
|
||||
finished = pyqtSignal(list, list, int) # cols, rows, total_count
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, database, table,
|
||||
where="", order_by="", limit=1000, offset=0, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._where = where
|
||||
self._order_by = order_by
|
||||
self._limit = limit
|
||||
self._offset = offset
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
cols, rows, cnt = self._driver.get_table_data(
|
||||
self._database, self._table,
|
||||
self._where, self._order_by, self._limit, self._offset
|
||||
)
|
||||
total = self._driver.get_table_row_count(
|
||||
self._database, self._table, self._where
|
||||
)
|
||||
self.finished.emit(cols, list(rows), total)
|
||||
except Exception as e:
|
||||
_log.error("TableDataWorker error table=%s.%s where=%r",
|
||||
self._database, self._table, self._where,
|
||||
exc_info=True)
|
||||
self.error.emit(str(e))
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
build_app.py — DBClient packaging helper
|
||||
|
||||
Run this script to build a distributable bundle of DBClient using PyInstaller.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python build_app.py [--onefile] [--clean] [--no-upx]
|
||||
|
||||
Options
|
||||
-------
|
||||
--onefile Build a single .exe instead of a one-folder bundle
|
||||
--clean Delete build/ and dist/ before building
|
||||
--no-upx Disable UPX compression (useful if UPX is not installed)
|
||||
|
||||
Output
|
||||
------
|
||||
dist/DBClient/ ← one-folder bundle (default)
|
||||
dist/DBClient.exe ← single-file bundle (--onefile)
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description="Build DBClient with PyInstaller")
|
||||
p.add_argument("--onefile", action="store_true",
|
||||
help="Build a single .exe (instead of one-folder)")
|
||||
p.add_argument("--clean", action="store_true",
|
||||
help="Remove build/ and dist/ directories first")
|
||||
p.add_argument("--no-upx", action="store_true",
|
||||
help="Disable UPX compression")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def check_pyinstaller():
|
||||
try:
|
||||
import PyInstaller # noqa: F401
|
||||
except ImportError:
|
||||
print("❌ PyInstaller is not installed.")
|
||||
print(" Run: pip install pyinstaller")
|
||||
sys.exit(1)
|
||||
print(f"✅ PyInstaller found: {PyInstaller.__version__}")
|
||||
|
||||
|
||||
def clean_dirs():
|
||||
for d in ("build", "dist", "__pycache__"):
|
||||
if os.path.exists(d):
|
||||
print(f"🗑 Removing {d}/")
|
||||
shutil.rmtree(d)
|
||||
|
||||
|
||||
def build(args):
|
||||
cmd = [sys.executable, "-m", "PyInstaller", "DBClient.spec", "--noconfirm"]
|
||||
|
||||
if args.onefile:
|
||||
# Patch the spec to use the single-file EXE (quick-and-dirty approach:
|
||||
# just pass --onefile to PyInstaller alongside the spec — PyInstaller
|
||||
# will override the COLLECT step).
|
||||
cmd.append("--onefile")
|
||||
print("📦 Building single-file executable…")
|
||||
else:
|
||||
print("📦 Building one-folder bundle…")
|
||||
|
||||
if args.no_upx:
|
||||
cmd.append("--noupx")
|
||||
|
||||
print(" Command:", " ".join(cmd))
|
||||
print()
|
||||
|
||||
result = subprocess.run(cmd, check=False)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def report(args, returncode):
|
||||
print()
|
||||
if returncode == 0:
|
||||
if args.onefile:
|
||||
out = os.path.join("dist", "DBClient.exe")
|
||||
else:
|
||||
out = os.path.join("dist", "DBClient")
|
||||
print(f"✅ Build succeeded!")
|
||||
print(f" Output: {os.path.abspath(out)}")
|
||||
if not args.onefile:
|
||||
print(f" Run: {os.path.join(out, 'DBClient.exe')}")
|
||||
else:
|
||||
print(f"❌ Build failed with exit code {returncode}.")
|
||||
print(" Check the output above for errors.")
|
||||
print()
|
||||
print("Common fixes:")
|
||||
print(" • pyodbc errors → ensure the ODBC runtime is installed")
|
||||
print(" • Missing module → add it to hiddenimports in DBClient.spec")
|
||||
print(" • UPX errors → run with --no-upx")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
# Make sure we're running from the repo root
|
||||
spec = "DBClient.spec"
|
||||
if not os.path.exists(spec):
|
||||
print(f"❌ {spec} not found. Run this script from the DBClient root directory.")
|
||||
sys.exit(1)
|
||||
|
||||
check_pyinstaller()
|
||||
|
||||
if args.clean:
|
||||
clean_dirs()
|
||||
|
||||
returncode = build(args)
|
||||
report(args, returncode)
|
||||
sys.exit(returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
DBClient — entry point.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from app.utils.logger import setup_logging, install_qt_message_handler, get_logger
|
||||
from app.main_window import MainWindow
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def load_stylesheet(app: QApplication) -> None:
|
||||
style_path = os.path.join(os.path.dirname(__file__), "resources", "style.qss")
|
||||
if os.path.exists(style_path):
|
||||
with open(style_path, "r", encoding="utf-8") as f:
|
||||
app.setStyleSheet(f.read())
|
||||
|
||||
|
||||
def main():
|
||||
# ── Logging must be set up before anything else ───────────────────────────
|
||||
setup_logging()
|
||||
|
||||
# High DPI
|
||||
os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("DBClient")
|
||||
app.setApplicationDisplayName("DBClient")
|
||||
app.setApplicationVersion("1.0.0")
|
||||
app.setOrganizationName("DBClient")
|
||||
|
||||
# Route Qt's own warning/critical messages into the Python log
|
||||
install_qt_message_handler()
|
||||
|
||||
# Default font
|
||||
font = QFont("Segoe UI", 10)
|
||||
app.setFont(font)
|
||||
|
||||
load_stylesheet(app)
|
||||
|
||||
log.info("Starting MainWindow")
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
log.info("Entering event loop")
|
||||
exit_code = app.exec()
|
||||
log.info("Application exited with code %d", exit_code)
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
PyQt6>=6.6.0
|
||||
pymysql>=1.1.0
|
||||
psycopg2-binary>=2.9.9
|
||||
pyodbc>=5.0.1
|
||||
keyring>=24.3.1
|
||||
cryptography>=42.0.0
|
||||
bcrypt>=4.0.0
|
||||
|
||||
# Packaging (dev dependency — only needed when building the distributable)
|
||||
pyinstaller>=6.0.0
|
||||
@@ -0,0 +1,599 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
DBClient — Dark Theme (Catppuccin Mocha palette)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Variables via flat tokens ─────────────────────────────────────────────
|
||||
base #1e1e2e crust #11111b
|
||||
surface0 #313244 surface1 #45475a surface2 #585b70
|
||||
overlay #6c7086 subtext #a6adc8 text #cdd6f4
|
||||
blue #89b4fa lavender #b4befe sapphire #74c7ec
|
||||
green #a6e3a1 teal #94e2d5 sky #89dceb
|
||||
mauve #cba6f7 pink #f5c2e7 red #f38ba8
|
||||
peach #fab387 yellow #f9e2af
|
||||
─────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Global reset ───────────────────────────────────────────────────────── */
|
||||
* {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog {
|
||||
background-color: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
QWidget {
|
||||
background-color: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
font-family: "Segoe UI", "Inter", sans-serif;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* ── Menu bar ───────────────────────────────────────────────────────────── */
|
||||
QMenuBar {
|
||||
background-color: #11111b;
|
||||
color: #cdd6f4;
|
||||
border-bottom: 1px solid #313244;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
QMenuBar::item {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QMenuBar::item:selected {
|
||||
background-color: #313244;
|
||||
}
|
||||
|
||||
QMenu {
|
||||
background-color: #181825;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
QMenu::item {
|
||||
padding: 6px 24px 6px 12px;
|
||||
border-radius: 4px;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background: #45475a;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ── Status bar ─────────────────────────────────────────────────────────── */
|
||||
QStatusBar {
|
||||
background-color: #11111b;
|
||||
border-top: 1px solid #313244;
|
||||
color: #a6adc8;
|
||||
font-size: 9pt;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* ── Scrollbars ─────────────────────────────────────────────────────────── */
|
||||
QScrollBar:vertical {
|
||||
background: #1e1e2e;
|
||||
width: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background: #45475a;
|
||||
border-radius: 5px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #585b70;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background: #1e1e2e;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #45475a;
|
||||
border-radius: 5px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background: #585b70;
|
||||
}
|
||||
|
||||
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; }
|
||||
QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
|
||||
|
||||
/* ── Splitter ────────────────────────────────────────────────────────────── */
|
||||
QSplitter::handle {
|
||||
background-color: #313244;
|
||||
}
|
||||
|
||||
QSplitter::handle:horizontal { width: 2px; }
|
||||
QSplitter::handle:vertical { height: 2px; }
|
||||
|
||||
QSplitter::handle:hover {
|
||||
background-color: #89b4fa;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
|
||||
#sidebarHeader {
|
||||
background-color: #181825;
|
||||
border-bottom: 1px solid #313244;
|
||||
}
|
||||
|
||||
#sidebarTitle {
|
||||
color: #89b4fa;
|
||||
font-size: 11pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#newConnBtn {
|
||||
background-color: #313244;
|
||||
color: #89b4fa;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
font-size: 14pt;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#newConnBtn:hover {
|
||||
background-color: #45475a;
|
||||
color: #b4befe;
|
||||
}
|
||||
|
||||
/* ── Tree widget ─────────────────────────────────────────────────────────── */
|
||||
QTreeWidget {
|
||||
background-color: #181825;
|
||||
border: none;
|
||||
color: #cdd6f4;
|
||||
font-size: 10pt;
|
||||
show-decoration-selected: 1;
|
||||
}
|
||||
|
||||
QTreeWidget::item {
|
||||
padding: 3px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QTreeWidget::item:hover {
|
||||
background-color: #2a2a3c;
|
||||
}
|
||||
|
||||
QTreeWidget::item:selected {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
QTreeWidget::branch {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── Tab widget ──────────────────────────────────────────────────────────── */
|
||||
QTabWidget::pane {
|
||||
border: none;
|
||||
border-top: 1px solid #313244;
|
||||
background-color: #1e1e2e;
|
||||
}
|
||||
|
||||
QTabBar {
|
||||
background-color: #181825;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #181825;
|
||||
color: #a6adc8;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-right: 1px solid #313244;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
border-bottom: 2px solid #89b4fa;
|
||||
}
|
||||
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: #252536;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
QTabBar::close-button {
|
||||
subcontrol-position: right;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* ── Table view ──────────────────────────────────────────────────────────── */
|
||||
QTableView, QTableWidget {
|
||||
background-color: #1e1e2e;
|
||||
alternate-background-color: #252538;
|
||||
gridline-color: #313244;
|
||||
color: #cdd6f4;
|
||||
border: none;
|
||||
selection-background-color: #313244;
|
||||
selection-color: #cdd6f4;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QTableView::item, QTableWidget::item {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
QHeaderView {
|
||||
background-color: #181825;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #181825;
|
||||
color: #89b4fa;
|
||||
border: none;
|
||||
border-right: 1px solid #313244;
|
||||
border-bottom: 1px solid #313244;
|
||||
padding: 4px 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QHeaderView::section:hover {
|
||||
background-color: #252536;
|
||||
}
|
||||
|
||||
/* ── Plain text edit (SQL editor body) ───────────────────────────────────── */
|
||||
QPlainTextEdit {
|
||||
background-color: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
border: none;
|
||||
selection-background-color: #45475a;
|
||||
font-family: "Consolas", "JetBrains Mono", "Courier New", monospace;
|
||||
font-size: 13pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Line number gutter ──────────────────────────────────────────────────── */
|
||||
LineNumberArea {
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
QPushButton {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
padding: 5px 14px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #45475a;
|
||||
border-color: #585b70;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #252536;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #585b70;
|
||||
background-color: #252536;
|
||||
border-color: #313244;
|
||||
}
|
||||
|
||||
#runBtn {
|
||||
background-color: #a6e3a1;
|
||||
color: #1e1e2e;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#runBtn:hover {
|
||||
background-color: #94e2d5;
|
||||
}
|
||||
|
||||
#stopBtn {
|
||||
background-color: #f38ba8;
|
||||
color: #1e1e2e;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#stopBtn:hover {
|
||||
background-color: #eba0ac;
|
||||
}
|
||||
|
||||
#commitBtn {
|
||||
background-color: #a6e3a1;
|
||||
color: #1e1e2e;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#rollbackBtn {
|
||||
background-color: #fab387;
|
||||
color: #1e1e2e;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#crudAddBtn {
|
||||
background-color: #313244;
|
||||
color: #a6e3a1;
|
||||
border: 1px solid #a6e3a1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#crudAddBtn:hover {
|
||||
background-color: #a6e3a1;
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
#crudDeleteBtn {
|
||||
background-color: #313244;
|
||||
color: #f38ba8;
|
||||
border: 1px solid #f38ba8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#crudDeleteBtn:hover {
|
||||
background-color: #f38ba8;
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
/* ── Line edit ───────────────────────────────────────────────────────────── */
|
||||
QLineEdit {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 10pt;
|
||||
selection-background-color: #89b4fa;
|
||||
selection-color: #1e1e2e;
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
QLineEdit::placeholder {
|
||||
color: #585b70;
|
||||
}
|
||||
|
||||
/* ── Combo box ───────────────────────────────────────────────────────────── */
|
||||
QComboBox {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
QComboBox:focus {
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #181825;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
selection-background-color: #313244;
|
||||
}
|
||||
|
||||
/* ── Spin box ────────────────────────────────────────────────────────────── */
|
||||
QSpinBox {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
QSpinBox:focus { border-color: #89b4fa; }
|
||||
|
||||
QSpinBox::up-button, QSpinBox::down-button {
|
||||
background-color: #45475a;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
/* ── Check box ───────────────────────────────────────────────────────────── */
|
||||
QCheckBox {
|
||||
color: #cdd6f4;
|
||||
spacing: 8px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #45475a;
|
||||
border-radius: 4px;
|
||||
background: #313244;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked {
|
||||
background-color: #89b4fa;
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
/* ── Dialog ──────────────────────────────────────────────────────────────── */
|
||||
QDialog {
|
||||
background-color: #1e1e2e;
|
||||
}
|
||||
|
||||
QDialogButtonBox QPushButton {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* ── Form layout labels ──────────────────────────────────────────────────── */
|
||||
QFormLayout QLabel {
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
/* ── Tab widget in dialogs ───────────────────────────────────────────────── */
|
||||
QTabWidget#dialogTabs::pane {
|
||||
border: 1px solid #313244;
|
||||
border-radius: 6px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
/* ── Group box ───────────────────────────────────────────────────────────── */
|
||||
QGroupBox {
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
margin-top: 1em;
|
||||
color: #a6adc8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────── */
|
||||
QProgressBar {
|
||||
background-color: #313244;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
height: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background-color: #89b4fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* ── Dock widget ─────────────────────────────────────────────────────────── */
|
||||
QDockWidget {
|
||||
color: #cdd6f4;
|
||||
titlebar-close-icon: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QDockWidget::title {
|
||||
background-color: #181825;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #313244;
|
||||
}
|
||||
|
||||
/* ── Tool button ─────────────────────────────────────────────────────────── */
|
||||
QToolButton {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 5px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13pt;
|
||||
}
|
||||
|
||||
QToolButton:hover {
|
||||
background-color: #45475a;
|
||||
}
|
||||
|
||||
/* ── Message box ─────────────────────────────────────────────────────────── */
|
||||
QMessageBox {
|
||||
background-color: #1e1e2e;
|
||||
}
|
||||
|
||||
QMessageBox QLabel {
|
||||
color: #cdd6f4;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* ── Empty workspace label ───────────────────────────────────────────────── */
|
||||
#emptyLabel {
|
||||
color: #585b70;
|
||||
font-size: 14pt;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* ── Status label in results toolbar ────────────────────────────────────── */
|
||||
#statusLabel {
|
||||
color: #a6adc8;
|
||||
font-size: 9pt;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* ── DB label in SQL editor toolbar ─────────────────────────────────────── */
|
||||
#dbLabel {
|
||||
color: #6c7086;
|
||||
font-size: 9pt;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* ── Workspace tab widget ────────────────────────────────────────────────── */
|
||||
#workspace > QTabBar::tab {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* ── Horizontal separator line in dialog ────────────────────────────────── */
|
||||
QFrame[frameShape="4"] { /* HLine */
|
||||
color: #313244;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Structure view title ────────────────────────────────────────────────── */
|
||||
#structureTitle {
|
||||
color: #b4befe;
|
||||
}
|
||||
|
||||
/* ── Pagination bar ──────────────────────────────────────────────────────── */
|
||||
#paginationBar {
|
||||
background-color: #181825;
|
||||
border-top: 1px solid #45475a;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
#pgBtn {
|
||||
background-color: #252536;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 5px;
|
||||
padding: 4px 10px;
|
||||
font-size: 9pt;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
#pgBtn:hover {
|
||||
background-color: #313244;
|
||||
border-color: #585b70;
|
||||
}
|
||||
|
||||
#pgBtn:disabled {
|
||||
color: #45475a;
|
||||
background-color: #1e1e2e;
|
||||
border-color: #313244;
|
||||
}
|
||||
|
||||
#pageLbl {
|
||||
color: #89b4fa;
|
||||
font-size: 9pt;
|
||||
font-weight: 600;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
#rowCountLbl {
|
||||
color: #a6adc8;
|
||||
font-size: 9pt;
|
||||
padding-left: 8px;
|
||||
}
|
||||
Reference in New Issue
Block a user