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;
}