From b01ad5ea4036b7004c76c9bb0081ac284eba0954 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 21 May 2026 15:46:41 -0400 Subject: [PATCH] Initial Codes --- .gitignore | 176 ++++++++ CLAUDE.md | 114 +++++ app/__init__.py | 1 + app/config/__init__.py | 1 + app/config/connections.py | 95 ++++ app/config/settings.py | 70 +++ app/drivers/__init__.py | 23 + app/drivers/base.py | 209 +++++++++ app/drivers/mssql_driver.py | 333 ++++++++++++++ app/drivers/mysql_driver.py | 377 ++++++++++++++++ app/drivers/postgres_driver.py | 343 +++++++++++++++ app/drivers/sqlite_driver.py | 280 ++++++++++++ app/main_window.py | 480 +++++++++++++++++++++ app/models/__init__.py | 1 + app/models/connection_model.py | 80 ++++ app/models/result_table_model.py | 112 +++++ app/ui/__init__.py | 1 + app/ui/column_dialog.py | 141 ++++++ app/ui/connection_dialog.py | 300 +++++++++++++ app/ui/dump_dialog.py | 497 +++++++++++++++++++++ app/ui/explain_view.py | 421 ++++++++++++++++++ app/ui/import_dialog.py | 268 ++++++++++++ app/ui/log_viewer.py | 384 +++++++++++++++++ app/ui/process_list.py | 163 +++++++ app/ui/query_history.py | 195 +++++++++ app/ui/results_panel.py | 207 +++++++++ app/ui/schema_browser.py | 521 ++++++++++++++++++++++ app/ui/sql_editor.py | 340 +++++++++++++++ app/ui/syntax_highlighter.py | 148 +++++++ app/ui/table_structure.py | 324 ++++++++++++++ app/ui/table_viewer.py | 716 +++++++++++++++++++++++++++++++ app/ui/user_manager.py | 631 +++++++++++++++++++++++++++ app/utils/__init__.py | 1 + app/utils/icons.py | 85 ++++ app/utils/logger.py | 180 ++++++++ app/utils/worker.py | 98 +++++ build_app.py | 120 ++++++ main.py | 57 +++ requirements.txt | 10 + resources/style.qss | 599 ++++++++++++++++++++++++++ 40 files changed, 9102 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 app/__init__.py create mode 100644 app/config/__init__.py create mode 100644 app/config/connections.py create mode 100644 app/config/settings.py create mode 100644 app/drivers/__init__.py create mode 100644 app/drivers/base.py create mode 100644 app/drivers/mssql_driver.py create mode 100644 app/drivers/mysql_driver.py create mode 100644 app/drivers/postgres_driver.py create mode 100644 app/drivers/sqlite_driver.py create mode 100644 app/main_window.py create mode 100644 app/models/__init__.py create mode 100644 app/models/connection_model.py create mode 100644 app/models/result_table_model.py create mode 100644 app/ui/__init__.py create mode 100644 app/ui/column_dialog.py create mode 100644 app/ui/connection_dialog.py create mode 100644 app/ui/dump_dialog.py create mode 100644 app/ui/explain_view.py create mode 100644 app/ui/import_dialog.py create mode 100644 app/ui/log_viewer.py create mode 100644 app/ui/process_list.py create mode 100644 app/ui/query_history.py create mode 100644 app/ui/results_panel.py create mode 100644 app/ui/schema_browser.py create mode 100644 app/ui/sql_editor.py create mode 100644 app/ui/syntax_highlighter.py create mode 100644 app/ui/table_structure.py create mode 100644 app/ui/table_viewer.py create mode 100644 app/ui/user_manager.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/icons.py create mode 100644 app/utils/logger.py create mode 100644 app/utils/worker.py create mode 100644 build_app.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 resources/style.qss diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b81874f --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9cc4059 --- /dev/null +++ b/CLAUDE.md @@ -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) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..08bc7bd --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# DBClient package diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..f110f3c --- /dev/null +++ b/app/config/__init__.py @@ -0,0 +1 @@ +# config package diff --git a/app/config/connections.py b/app/config/connections.py new file mode 100644 index 0000000..894493a --- /dev/null +++ b/app/config/connections.py @@ -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) diff --git a/app/config/settings.py b/app/config/settings.py new file mode 100644 index 0000000..f4503bd --- /dev/null +++ b/app/config/settings.py @@ -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 diff --git a/app/drivers/__init__.py b/app/drivers/__init__.py new file mode 100644 index 0000000..2798488 --- /dev/null +++ b/app/drivers/__init__.py @@ -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}") diff --git a/app/drivers/base.py b/app/drivers/base.py new file mode 100644 index 0000000..c80c060 --- /dev/null +++ b/app/drivers/base.py @@ -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', '')}" diff --git a/app/drivers/mssql_driver.py b/app/drivers/mssql_driver.py new file mode 100644 index 0000000..9aa9a9a --- /dev/null +++ b/app/drivers/mssql_driver.py @@ -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 diff --git a/app/drivers/mysql_driver.py b/app/drivers/mysql_driver.py new file mode 100644 index 0000000..7242645 --- /dev/null +++ b/app/drivers/mysql_driver.py @@ -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 diff --git a/app/drivers/postgres_driver.py b/app/drivers/postgres_driver.py new file mode 100644 index 0000000..9a49b51 --- /dev/null +++ b/app/drivers/postgres_driver.py @@ -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 diff --git a/app/drivers/sqlite_driver.py b/app/drivers/sqlite_driver.py new file mode 100644 index 0000000..2fa4b86 --- /dev/null +++ b/app/drivers/sqlite_driver.py @@ -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 + diff --git a/app/main_window.py b/app/main_window.py new file mode 100644 index 0000000..3b5c271 --- /dev/null +++ b/app/main_window.py @@ -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", + "DBClient v1.0.0

" + "Cross-platform desktop database client.
" + "MySQL · PostgreSQL · SQLite · SQL Server

" + "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() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..3c159a6 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +# models package diff --git a/app/models/connection_model.py b/app/models/connection_model.py new file mode 100644 index 0000000..57068eb --- /dev/null +++ b/app/models/connection_model.py @@ -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), + ) diff --git a/app/models/result_table_model.py b/app/models/result_table_model.py new file mode 100644 index 0000000..254ff9c --- /dev/null +++ b/app/models/result_table_model.py @@ -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 diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..67e55b5 --- /dev/null +++ b/app/ui/__init__.py @@ -0,0 +1 @@ +# ui package diff --git a/app/ui/column_dialog.py b/app/ui/column_dialog.py new file mode 100644 index 0000000..6222b03 --- /dev/null +++ b/app/ui/column_dialog.py @@ -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( + "Changes are applied immediately via ALTER TABLE." + ) + 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() diff --git a/app/ui/connection_dialog.py b/app/ui/connection_dialog.py new file mode 100644 index 0000000..d713c1a --- /dev/null +++ b/app/ui/connection_dialog.py @@ -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 diff --git a/app/ui/dump_dialog.py b/app/ui/dump_dialog.py new file mode 100644 index 0000000..6c2172e --- /dev/null +++ b/app/ui/dump_dialog.py @@ -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}" + ) diff --git a/app/ui/explain_view.py b/app/ui/explain_view.py new file mode 100644 index 0000000..657c4e8 --- /dev/null +++ b/app/ui/explain_view.py @@ -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}") diff --git a/app/ui/import_dialog.py b/app/ui/import_dialog.py new file mode 100644 index 0000000..cf9282d --- /dev/null +++ b/app/ui/import_dialog.py @@ -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}") diff --git a/app/ui/log_viewer.py b/app/ui/log_viewer.py new file mode 100644 index 0000000..f459d08 --- /dev/null +++ b/app/ui/log_viewer.py @@ -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) diff --git a/app/ui/process_list.py b/app/ui/process_list.py new file mode 100644 index 0000000..f476903 --- /dev/null +++ b/app/ui/process_list.py @@ -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) diff --git a/app/ui/query_history.py b/app/ui/query_history.py new file mode 100644 index 0000000..2fd175c --- /dev/null +++ b/app/ui/query_history.py @@ -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) diff --git a/app/ui/results_panel.py b/app/ui/results_panel.py new file mode 100644 index 0000000..6f108f8 --- /dev/null +++ b/app/ui/results_panel.py @@ -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()) diff --git a/app/ui/schema_browser.py b/app/ui/schema_browser.py new file mode 100644 index 0000000..8b9ca48 --- /dev/null +++ b/app/ui/schema_browser.py @@ -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) diff --git a/app/ui/sql_editor.py b/app/ui/sql_editor.py new file mode 100644 index 0000000..2ba4f72 --- /dev/null +++ b/app/ui/sql_editor.py @@ -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) diff --git a/app/ui/syntax_highlighter.py b/app/ui/syntax_highlighter.py new file mode 100644 index 0000000..345b7f5 --- /dev/null +++ b/app/ui/syntax_highlighter.py @@ -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 diff --git a/app/ui/table_structure.py b/app/ui/table_structure.py new file mode 100644 index 0000000..c8e4065 --- /dev/null +++ b/app/ui/table_structure.py @@ -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() diff --git a/app/ui/table_viewer.py b/app/ui/table_viewer.py new file mode 100644 index 0000000..daf1756 --- /dev/null +++ b/app/ui/table_viewer.py @@ -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)) diff --git a/app/ui/user_manager.py b/app/ui/user_manager.py new file mode 100644 index 0000000..8b25aec --- /dev/null +++ b/app/ui/user_manager.py @@ -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) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..db3e327 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# utils package diff --git a/app/utils/icons.py b/app/utils/icons.py new file mode 100644 index 0000000..4a06632 --- /dev/null +++ b/app/utils/icons.py @@ -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) diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..86cf94d --- /dev/null +++ b/app/utils/logger.py @@ -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 diff --git a/app/utils/worker.py b/app/utils/worker.py new file mode 100644 index 0000000..fd450c0 --- /dev/null +++ b/app/utils/worker.py @@ -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)) + diff --git a/build_app.py b/build_app.py new file mode 100644 index 0000000..17381aa --- /dev/null +++ b/build_app.py @@ -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() diff --git a/main.py b/main.py new file mode 100644 index 0000000..2f1213d --- /dev/null +++ b/main.py @@ -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() + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..398a573 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/resources/style.qss b/resources/style.qss new file mode 100644 index 0000000..5ca5722 --- /dev/null +++ b/resources/style.qss @@ -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; +}