Initial Codes

This commit is contained in:
2026-05-21 15:46:41 -04:00
commit b01ad5ea40
40 changed files with 9102 additions and 0 deletions
+176
View File
@@ -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/
+114
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# DBClient package
+1
View File
@@ -0,0 +1 @@
# config package
+95
View File
@@ -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)
+70
View File
@@ -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
+23
View File
@@ -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}")
+209
View File
@@ -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', '')}"
+333
View File
@@ -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
+377
View File
@@ -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
+343
View File
@@ -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
+280
View File
@@ -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
+480
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# models package
+80
View File
@@ -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),
)
+112
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# ui package
+141
View File
@@ -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()
+300
View File
@@ -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
+497
View File
@@ -0,0 +1,497 @@
"""
Database Dump Export dialog.
Supports three dump modes:
• Schema only — CREATE TABLE / CREATE VIEW / CREATE INDEX DDL
• Data only — INSERT INTO … VALUES (…) for every row
• Schema + Data — both of the above, in dependency order
Output is a single UTF-8 .sql file that can be re-run on the same
(or a compatible) DB engine.
Uses a background QThread so the UI stays responsive for large databases.
Progress is reported per-table via Qt signals.
"""
from __future__ import annotations
import datetime
import os
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QComboBox, QPushButton, QListWidget,
QListWidgetItem, QCheckBox, QFileDialog, QLineEdit,
QProgressBar, QDialogButtonBox, QMessageBox,
QGroupBox, QRadioButton, QButtonGroup, QAbstractItemView,
QSplitter, QWidget, QPlainTextEdit,
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QFont
# ── Background dump worker ────────────────────────────────────────────────────
class _DumpWorker(QThread):
"""Generates the dump SQL in a background thread.
Signals
-------
progress(current, total, table_name) — emitted after each table
finished(sql_text) — full SQL as a string
error(message) — something went wrong
"""
progress = pyqtSignal(int, int, str) # (done, total, current_table)
finished = pyqtSignal(str)
error = pyqtSignal(str)
def __init__(self, driver, database: str, tables: list[str],
mode: str, batch_size: int = 500, parent=None):
"""
Parameters
----------
driver : BaseDriver
database : str
tables : list of table names to dump
mode : 'schema' | 'data' | 'both'
batch_size : rows per INSERT batch (multi-row VALUES)
"""
super().__init__(parent)
self._driver = driver
self._database = database
self._tables = tables
self._mode = mode
self._batch_size = batch_size
# ── helpers ───────────────────────────────────────────────────────────────
@staticmethod
def _escape(val) -> str:
"""Very minimal SQL string escaping for dump output."""
if val is None:
return "NULL"
s = str(val)
s = s.replace("\\", "\\\\").replace("'", "\\'")
return f"'{s}'"
def _dump_schema(self, table: str) -> str:
try:
ddl = self._driver.get_table_ddl(self._database, table)
return f"{ddl.rstrip(';')};\n"
except Exception as e:
return f"-- ERROR fetching DDL for {table}: {e}\n"
def _dump_data(self, table: str) -> str:
lines: list[str] = []
offset = 0
limit = 1000
# Fetch first page to get column names
try:
cols, rows, total = self._driver.get_table_data(
self._database, table, limit=limit, offset=offset
)
except Exception as e:
return f"-- ERROR fetching data for {table}: {e}\n"
if not rows:
lines.append(f"-- (no rows in {table})\n")
return "".join(lines)
col_list = ", ".join(f"`{c}`" if not c.startswith("`") else c
for c in cols)
lines.append(f"-- Data for table `{table}` ({total} rows)\n")
lines.append(f"LOCK TABLES `{table}` WRITE;\n")
def flush_batch(batch):
value_groups = []
for row in batch:
vals = ", ".join(self._escape(v) for v in row)
value_groups.append(f" ({vals})")
lines.append(
f"INSERT INTO `{table}` ({col_list}) VALUES\n"
+ ",\n".join(value_groups) + ";\n"
)
batch: list = list(rows)
while True:
# flush when batch is full
if len(batch) >= self._batch_size:
flush_batch(batch[: self._batch_size])
batch = batch[self._batch_size :]
offset += limit
if offset >= total:
break
try:
_, rows, _ = self._driver.get_table_data(
self._database, table, limit=limit, offset=offset
)
batch.extend(rows)
except Exception as e:
lines.append(f"-- ERROR reading {table} at offset {offset}: {e}\n")
break
if batch:
flush_batch(batch)
lines.append(f"UNLOCK TABLES;\n")
return "".join(lines)
# ── main run ──────────────────────────────────────────────────────────────
def run(self):
parts: list[str] = []
db_type = getattr(self._driver, "db_type", "unknown")
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
parts.append(
f"-- DBClient Dump\n"
f"-- Database: {self._database}\n"
f"-- DB Type : {db_type}\n"
f"-- Generated: {now}\n"
f"-- Mode: {self._mode}\n"
f"-- --------------------------------------------------------\n\n"
)
total = len(self._tables)
try:
for i, table in enumerate(self._tables):
self.progress.emit(i, total, table)
if self._mode in ("schema", "both"):
parts.append(f"\n-- Table structure: `{table}`\n")
parts.append(f"DROP TABLE IF EXISTS `{table}`;\n")
parts.append(self._dump_schema(table))
if self._mode in ("data", "both"):
parts.append("\n")
parts.append(self._dump_data(table))
parts.append("\n")
self.progress.emit(total, total, "Done")
self.finished.emit("".join(parts))
except Exception as e:
self.error.emit(str(e))
# ── Dialog ────────────────────────────────────────────────────────────────────
class DumpDialog(QDialog):
"""
Configure and run a database dump.
Parameters
----------
driver : BaseDriver — must already be connected
database : str — default database (can be changed in UI)
parent : QWidget
"""
def __init__(self, driver, database: str = "", parent=None):
super().__init__(parent)
self._driver = driver
self._database = database
self._worker: _DumpWorker | None = None
self._sql_output = ""
self.setWindowTitle("Export Database Dump")
self.setModal(True)
self.setMinimumSize(700, 560)
self._build_ui()
# Defer DB load until the dialog's event loop is running so that
# any reconnection the driver performs doesn't block __init__.
from PyQt6.QtCore import QTimer
QTimer.singleShot(0, self._populate_databases)
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self):
root = QVBoxLayout(self)
root.setSpacing(10)
# ── Top form: database + output file ─────────────────────────────────
form = QFormLayout()
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
form.setSpacing(8)
# Database selector
self._db_combo = QComboBox()
self._db_combo.setMinimumWidth(220)
self._db_combo.currentTextChanged.connect(self._on_db_changed)
form.addRow("Database:", self._db_combo)
# Output file
file_row = QHBoxLayout()
self._out_path = QLineEdit()
self._out_path.setPlaceholderText("Select output .sql file…")
self._out_path.setReadOnly(True)
browse_btn = QPushButton("Browse…")
browse_btn.setFixedWidth(80)
browse_btn.clicked.connect(self._browse_output)
file_row.addWidget(self._out_path)
file_row.addWidget(browse_btn)
form.addRow("Output file:", file_row)
root.addLayout(form)
# ── Mode selection ────────────────────────────────────────────────────
mode_box = QGroupBox("Dump mode")
mode_lay = QHBoxLayout(mode_box)
self._mode_group = QButtonGroup(self)
self._rb_both = QRadioButton("Schema + Data")
self._rb_schema = QRadioButton("Schema only")
self._rb_data = QRadioButton("Data only")
self._rb_both.setChecked(True)
for rb in (self._rb_both, self._rb_schema, self._rb_data):
self._mode_group.addButton(rb)
mode_lay.addWidget(rb)
mode_lay.addStretch()
root.addWidget(mode_box)
# ── Table selector ────────────────────────────────────────────────────
tbl_box = QGroupBox("Tables to include")
tbl_lay = QVBoxLayout(tbl_box)
sel_row = QHBoxLayout()
sel_all = QPushButton("Select All")
sel_all.setFixedWidth(90)
sel_all.clicked.connect(self._select_all)
sel_none = QPushButton("Select None")
sel_none.setFixedWidth(90)
sel_none.clicked.connect(self._select_none)
self._tbl_count_lbl = QLabel("0 tables")
sel_row.addWidget(sel_all)
sel_row.addWidget(sel_none)
sel_row.addStretch()
sel_row.addWidget(self._tbl_count_lbl)
tbl_lay.addLayout(sel_row)
self._tbl_list = QListWidget()
self._tbl_list.setSelectionMode(
QAbstractItemView.SelectionMode.NoSelection)
self._tbl_list.setAlternatingRowColors(True)
self._tbl_list.itemChanged.connect(self._update_table_count)
tbl_lay.addWidget(self._tbl_list)
root.addWidget(tbl_box, 1)
# ── Options ───────────────────────────────────────────────────────────
opts_row = QHBoxLayout()
self._drop_cb = QCheckBox("Add DROP TABLE IF EXISTS before each CREATE")
self._drop_cb.setChecked(True)
opts_row.addWidget(self._drop_cb)
opts_row.addStretch()
root.addLayout(opts_row)
# ── Progress ──────────────────────────────────────────────────────────
self._progress_lbl = QLabel("")
root.addWidget(self._progress_lbl)
self._progress = QProgressBar()
self._progress.setVisible(False)
root.addWidget(self._progress)
# ── Buttons ───────────────────────────────────────────────────────────
self._bbox = QDialogButtonBox()
self._dump_btn = self._bbox.addButton(
"Export Dump", QDialogButtonBox.ButtonRole.AcceptRole)
self._close_btn = self._bbox.addButton(
QDialogButtonBox.StandardButton.Close)
self._dump_btn.setEnabled(False)
self._dump_btn.clicked.connect(self._start_dump)
self._close_btn.clicked.connect(self.reject)
root.addWidget(self._bbox)
# ── Error helper ──────────────────────────────────────────────────────────
@staticmethod
def _readable_error(e: Exception) -> str:
"""Unwrap pymysql / psycopg2 exception tuples into plain English."""
args = getattr(e, "args", ())
# pymysql: args = (error_code: int, message: str)
if args and isinstance(args[0], int):
code = args[0]
msg = str(args[1]) if len(args) > 1 else ""
if code == 0 and not msg:
return ("Lost connection to the database server.\n"
"The server may have closed an idle connection.\n"
"Try reconnecting via the sidebar.")
if msg:
return f"Database error {code}: {msg}"
return f"Database error code {code}"
return str(e)
# ── Database population ───────────────────────────────────────────────────
def _populate_databases(self):
self._progress_lbl.setText("Loading databases…")
try:
dbs = self._driver.get_databases()
if not dbs:
self._progress_lbl.setText(
"No databases found — check your connection permissions."
)
return
self._db_combo.blockSignals(True)
self._db_combo.clear()
for db in dbs:
self._db_combo.addItem(db)
# Pre-select the passed-in database
if self._database and self._database in dbs:
self._db_combo.setCurrentText(self._database)
self._db_combo.blockSignals(False)
self._progress_lbl.setText("")
# Trigger table load for current selection
self._on_db_changed(self._db_combo.currentText())
except Exception as e:
human = self._readable_error(e)
self._progress_lbl.setText(f"⚠ Could not load databases.")
QMessageBox.critical(
self, "Connection Error",
f"Could not load the database list:\n\n{human}"
)
def _on_db_changed(self, db_name: str):
self._database = db_name
self._tbl_list.clear()
if not db_name:
return
try:
table_infos = self._driver.get_tables(db_name)
for ti in table_infos:
item = QListWidgetItem(ti.name)
item.setFlags(
item.flags() | Qt.ItemFlag.ItemIsUserCheckable
)
item.setCheckState(Qt.CheckState.Checked)
self._tbl_list.addItem(item)
self._update_table_count()
except Exception as e:
self._progress_lbl.setText(
f"Error loading tables: {self._readable_error(e)}"
)
self._refresh_dump_btn()
# ── Table selection helpers ───────────────────────────────────────────────
def _select_all(self):
for i in range(self._tbl_list.count()):
self._tbl_list.item(i).setCheckState(Qt.CheckState.Checked)
def _select_none(self):
for i in range(self._tbl_list.count()):
self._tbl_list.item(i).setCheckState(Qt.CheckState.Unchecked)
def _checked_tables(self) -> list[str]:
result = []
for i in range(self._tbl_list.count()):
item = self._tbl_list.item(i)
if item.checkState() == Qt.CheckState.Checked:
result.append(item.text())
return result
def _update_table_count(self):
checked = len(self._checked_tables())
total = self._tbl_list.count()
self._tbl_count_lbl.setText(f"{checked} / {total} selected")
self._refresh_dump_btn()
# ── File output ───────────────────────────────────────────────────────────
def _browse_output(self):
default = f"{self._database or 'dump'}_{datetime.date.today()}.sql"
path, _ = QFileDialog.getSaveFileName(
self, "Save SQL Dump", default,
"SQL Files (*.sql);;All Files (*)"
)
if path:
self._out_path.setText(path)
self._refresh_dump_btn()
def _refresh_dump_btn(self):
ok = bool(
self._out_path.text()
and self._database
and self._checked_tables()
)
self._dump_btn.setEnabled(ok)
# ── Dump mode ─────────────────────────────────────────────────────────────
def _get_mode(self) -> str:
if self._rb_schema.isChecked():
return "schema"
if self._rb_data.isChecked():
return "data"
return "both"
# ── Start dump ────────────────────────────────────────────────────────────
def _start_dump(self):
tables = self._checked_tables()
if not tables:
QMessageBox.information(
self, "No Tables", "Select at least one table to dump."
)
return
out_path = self._out_path.text().strip()
if not out_path:
return
self._dump_btn.setEnabled(False)
self._progress.setMaximum(len(tables))
self._progress.setValue(0)
self._progress.setVisible(True)
self._progress_lbl.setText("Starting dump…")
self._worker = _DumpWorker(
self._driver, self._database, tables,
mode=self._get_mode(),
parent=self,
)
self._worker.progress.connect(self._on_progress)
self._worker.finished.connect(
lambda sql: self._on_finished(sql, out_path)
)
self._worker.error.connect(self._on_error)
self._worker.start()
def _on_progress(self, done: int, total: int, table: str):
self._progress.setValue(done)
if table != "Done":
self._progress_lbl.setText(f"Dumping table {done + 1}/{total}: {table}")
def _on_finished(self, sql: str, out_path: str):
try:
with open(out_path, "w", encoding="utf-8") as f:
f.write(sql)
size_kb = os.path.getsize(out_path) / 1024
self._progress.setValue(self._progress.maximum())
self._progress_lbl.setText(
f"✅ Dump complete — {size_kb:.1f} KB written to {os.path.basename(out_path)}"
)
QMessageBox.information(
self, "Dump Complete",
f"Database dump saved successfully.\n\n"
f"File: {out_path}\n"
f"Size: {size_kb:.1f} KB\n"
f"Tables: {self._progress.maximum()}"
)
except OSError as e:
QMessageBox.critical(
self, "Write Error", f"Could not write file:\n{e}"
)
finally:
self._dump_btn.setEnabled(True)
def _on_error(self, msg: str):
self._progress.setVisible(False)
self._dump_btn.setEnabled(True)
self._progress_lbl.setText(f"❌ Error: {msg[:120]}")
QMessageBox.critical(
self, "Dump Error", f"Dump failed:\n\n{msg}"
)
+421
View File
@@ -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}")
+268
View File
@@ -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}")
+384
View File
@@ -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)
+163
View File
@@ -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)
+195
View File
@@ -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)
+207
View File
@@ -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())
+521
View File
@@ -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)
+340
View File
@@ -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)
+148
View File
@@ -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
+324
View File
@@ -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()
+716
View File
@@ -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))
+631
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# utils package
+85
View File
@@ -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)
+180
View File
@@ -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
+98
View File
@@ -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
View File
@@ -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()
+57
View File
@@ -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()
+10
View File
@@ -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
+599
View File
@@ -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;
}