From f680bfe3a31ef63c61826814f1d971e39fa6b9f1 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 21 May 2026 16:28:58 -0400 Subject: [PATCH] Add schema-aware SQL auto-complete to the SQL editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New file app/ui/sql_completer.py: - SqlCompleter (QObject) owns a _CompletionPopup (QListWidget) that floats above the editor as a ToolTip-style frameless window styled to match the Catppuccin Mocha theme. - SQL keyword pool sourced directly from syntax_highlighter._KEYWORDS, _TYPES, _FUNCTIONS so the two features stay in sync. - Schema data (tables, views, columns) loaded asynchronously via SchemaWorker; a generation counter discards stale results when the database changes rapidly. - Column loading is lazy: fires only when the user types "table." (dot-qualified prefix), avoiding upfront cost for wide schemas. - _extract_prefix() uses a regex to detect both bare words and "table.col" dot notation; guards against completing inside string literals by counting unescaped quotes to the left of the cursor. - _accept_completion() replaces only the typed prefix, preserving any qualifier already in the document (e.g. "orders.cu" → "orders.customer_id"). Changes to app/ui/sql_editor.py: - CodeEditor.set_completer() attaches the completer and stores it. - keyPressEvent priority order: 1. Popup visible → Esc hides, Enter/Tab accepts, Up/Down navigates. 2. Ctrl+Space → force-show completions (1-char minimum). 3. Tab (no popup) → existing 4-space insert behaviour preserved. 4. Ctrl+/ → existing comment-toggle preserved. 5. All other keys → super() then auto-trigger (2-char minimum). - focusOutEvent hides the popup when the editor loses focus. - EditorTab._build_ui() creates SqlCompleter, attaches it, and calls set_context() if a driver is already present. - EditorTab.set_context() updates driver/database and reloads schema. - _maybe_invalidate_schema() re-fetches schema after CREATE/DROP/ALTER so newly created tables appear in completions immediately. Co-Authored-By: Claude Sonnet 4.6 --- app/ui/sql_completer.py | 287 ++++++++++++++++++++++++++++++++++++++++ app/ui/sql_editor.py | 79 +++++++++-- 2 files changed, 357 insertions(+), 9 deletions(-) create mode 100644 app/ui/sql_completer.py diff --git a/app/ui/sql_completer.py b/app/ui/sql_completer.py new file mode 100644 index 0000000..9424332 --- /dev/null +++ b/app/ui/sql_completer.py @@ -0,0 +1,287 @@ +""" +Schema-aware SQL auto-completer for CodeEditor. + +Provides keyword completions from the syntax highlighter lists, plus +table/view/column names loaded asynchronously from the active driver. +Trigger: type 2+ characters, or press Ctrl+Space to force-show. +""" +import re +from PyQt6.QtCore import Qt, QObject +from PyQt6.QtGui import QFont, QFontMetrics, QTextCursor +from PyQt6.QtWidgets import QApplication, QListWidget, QListWidgetItem + +from app.ui.syntax_highlighter import _KEYWORDS, _TYPES, _FUNCTIONS +from app.utils.worker import SchemaWorker + +# Combined, deduplicated keyword list used as the static completion pool. +SQL_KEYWORDS: list[str] = list(dict.fromkeys(_KEYWORDS + _TYPES + _FUNCTIONS)) + +# Matches either "table.col_prefix" or bare "word" at end of a line fragment. +_PREFIX_RE = re.compile(r'([\w]+)\.([\w]*)$|([\w]+)$', re.IGNORECASE) + + +def _cursor_in_string(left: str) -> bool: + """Return True if an odd number of unescaped quotes precede the cursor.""" + count, i = 0, 0 + while i < len(left): + if left[i] == '\\': + i += 2 + continue + if left[i] in ("'", '"'): + count += 1 + i += 1 + return count % 2 == 1 + + +# ── Floating popup ───────────────────────────────────────────────────────────── + +class _CompletionPopup(QListWidget): + """Frameless floating list that shows completion candidates.""" + + def __init__(self, editor): + super().__init__(None) + self._editor = editor + + self.setWindowFlags( + Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint + ) + self.setFocusProxy(editor) + + font = QFont("Consolas", 11) + self.setFont(font) + self._row_h = QFontMetrics(font).height() + 6 + + self.setStyleSheet(""" + QListWidget { + background: #1e1e2e; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + padding: 2px; + outline: none; + } + QListWidget::item { padding: 2px 8px; } + QListWidget::item:selected { + background: #313244; + color: #cdd6f4; + } + QListWidget::item:hover { background: #2a2a3c; } + """) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + self.itemClicked.connect( + lambda item: self._editor._completer._accept_completion(item.text()) + ) + + def show_at(self, items: list[str], cursor_rect) -> None: + self.clear() + for word in items: + self.addItem(QListWidgetItem(word)) + + visible = min(len(items), 10) + fm = QFontMetrics(self.font()) + w = max(180, max(fm.horizontalAdvance(t) for t in items) + 28) + h = visible * self._row_h + 8 + + gp = self._editor.viewport().mapToGlobal(cursor_rect.bottomLeft()) + gx, gy = gp.x(), gp.y() + 2 + + screen = QApplication.primaryScreen().availableGeometry() + if gy + h > screen.bottom(): + top = self._editor.viewport().mapToGlobal(cursor_rect.topLeft()) + gy = top.y() - h - 2 + if gx + w > screen.right(): + gx = screen.right() - w + + self.setGeometry(gx, gy, w, h) + self.setCurrentRow(0) + self.show() + + def move_selection(self, delta: int) -> None: + n = self.count() + if n: + self.setCurrentRow((self.currentRow() + delta) % n) + + def current_text(self) -> str | None: + item = self.currentItem() + return item.text() if item else None + + def hide_popup(self) -> None: + self.hide() + self.clear() + + +# ── Main completer ───────────────────────────────────────────────────────────── + +class SqlCompleter(QObject): + """ + Attaches to a CodeEditor and provides schema-aware completions. + + Usage (from EditorTab): + self._completer = SqlCompleter(self._editor, parent=self) + self._editor.set_completer(self._completer) + self._completer.set_context(driver, database) + """ + + def __init__(self, editor, parent=None): + super().__init__(parent) + self._editor = editor + self._driver = None + self._database = "" + self._load_gen = 0 + self._workers: list[SchemaWorker] = [] + self._pending_cols: set[str] = set() + + # Schema cache + self._tables: list[str] = [] + self._views: list[str] = [] + self._cols: dict[str, list[str]] = {} + + self._popup = _CompletionPopup(editor) + + # ── Public API ──────────────────────────────────────────────────────────── + + def set_context(self, driver, database: str) -> None: + """Update the active driver/database and refresh schema cache.""" + self._driver = driver + self._database = database + self._reload_schema() + + def invalidate(self) -> None: + """Discard cached schema and reload — call after DDL statements.""" + self._tables.clear() + self._views.clear() + self._cols.clear() + self._pending_cols.clear() + if self._driver: + self._reload_schema() + + def trigger_completion(self, force: bool = False) -> None: + """Show/update the popup. force=True ignores the 2-char minimum.""" + prefix, qualifier = self._extract_prefix() + + if not force and len(prefix) < 2 and qualifier is None: + self._popup.hide_popup() + return + + candidates = self._build_candidates(prefix, qualifier) + if not candidates: + self._popup.hide_popup() + return + + self._popup.show_at(candidates, self._editor.cursorRect()) + + # ── Schema loading ──────────────────────────────────────────────────────── + + def _reload_schema(self) -> None: + self._load_gen += 1 + self._tables.clear() + self._views.clear() + self._cols.clear() + self._pending_cols.clear() + + if not self._driver or not self._database: + return + + gen = self._load_gen + for fn, cb in [ + (self._driver.get_tables, lambda d: self._on_tables(d, gen)), + (self._driver.get_views, lambda d: self._on_views(d, gen)), + ]: + w = SchemaWorker(fn, self._database) + w.result.connect(cb) + w.start() + self._workers.append(w) + + def _on_tables(self, data, gen: int) -> None: + if gen == self._load_gen: + self._tables = [t.name for t in data] + + def _on_views(self, data, gen: int) -> None: + if gen == self._load_gen: + self._views = list(data) + + def _load_cols_for(self, table: str) -> None: + if table in self._cols or table in self._pending_cols or not self._driver: + return + self._pending_cols.add(table) + gen = self._load_gen + w = SchemaWorker(self._driver.get_columns, self._database, table) + w.result.connect(lambda cols: self._on_cols(table, cols, gen)) + w.start() + self._workers.append(w) + + def _on_cols(self, table: str, cols, gen: int) -> None: + self._pending_cols.discard(table) + if gen == self._load_gen: + self._cols[table] = [c.name for c in cols] + + # ── Prefix extraction ───────────────────────────────────────────────────── + + def _extract_prefix(self) -> tuple[str, str | None]: + """ + Return (prefix, table_qualifier). + - "SELECT" typed → ("SELECT", None) + - "users.na" typed → ("na", "users") + - "users." typed → ("", "users") + - cursor inside string → ("", None) + """ + tc = self._editor.textCursor() + left = tc.block().text()[:tc.positionInBlock()] + + if _cursor_in_string(left): + return ("", None) + + m = _PREFIX_RE.search(left) + if not m: + return ("", None) + + if m.group(1) is not None: + return (m.group(2), m.group(1)) # col_prefix, table + return (m.group(3), None) # bare_prefix + + # ── Candidate building ──────────────────────────────────────────────────── + + def _build_candidates(self, prefix: str, qualifier: str | None) -> list[str]: + p = prefix.lower() + + if qualifier is not None: + # Dot-context: column completions for the qualifying table/view + canonical = next( + (t for t in self._tables + self._views + if t.lower() == qualifier.lower()), + qualifier, + ) + self._load_cols_for(canonical) + return [c for c in self._cols.get(canonical, []) + if c.lower().startswith(p)] + + # General: keywords + tables + views + all cached columns + pool = SQL_KEYWORDS + self._tables + self._views + for col_list in self._cols.values(): + pool.extend(col_list) + + seen: dict[str, None] = {} + for word in pool: + if word.lower().startswith(p): + seen[word] = None + return list(seen.keys()) + + # ── Insertion ───────────────────────────────────────────────────────────── + + def _accept_completion(self, text: str | None) -> None: + if not text: + return + self._popup.hide_popup() + + prefix, _ = self._extract_prefix() + tc = self._editor.textCursor() + tc.movePosition( + QTextCursor.MoveOperation.Left, + QTextCursor.MoveMode.KeepAnchor, + len(prefix), + ) + tc.insertText(text) + self._editor.setTextCursor(tc) + self._editor.setFocus() diff --git a/app/ui/sql_editor.py b/app/ui/sql_editor.py index 2ba4f72..c2c6264 100644 --- a/app/ui/sql_editor.py +++ b/app/ui/sql_editor.py @@ -15,6 +15,7 @@ from PyQt6.QtGui import ( from app.ui.syntax_highlighter import SQLHighlighter from app.ui.results_panel import ResultsPanel +from app.ui.sql_completer import SqlCompleter from app.utils.worker import QueryWorker @@ -33,11 +34,12 @@ class LineNumberArea(QWidget): class CodeEditor(QPlainTextEdit): - """QPlainTextEdit with line numbers, current-line highlight, and tab→spaces.""" + """QPlainTextEdit with line numbers, current-line highlight, tab→spaces, and auto-complete.""" def __init__(self, parent=None): super().__init__(parent) self._line_area = LineNumberArea(self) + self._completer: "SqlCompleter | None" = None # Font font = QFont("Consolas", 13) @@ -109,19 +111,61 @@ class CodeEditor(QPlainTextEdit): bottom = top + round(self.blockBoundingRect(block).height()) number += 1 + def set_completer(self, completer: "SqlCompleter") -> None: + self._completer = completer + + def _popup_visible(self) -> bool: + return (self._completer is not None + and self._completer._popup.isVisible()) + + def focusOutEvent(self, event): + super().focusOutEvent(event) + if self._completer: + self._completer._popup.hide_popup() + def keyPressEvent(self, event): - # Tab → 4 spaces - if event.key() == Qt.Key.Key_Tab: - cursor = self.textCursor() - cursor.insertText(" ") + # ── Popup navigation (highest priority) ────────────────────────────── + if self._popup_visible(): + key = event.key() + if key == Qt.Key.Key_Escape: + self._completer._popup.hide_popup() + return + if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Tab): + self._completer._accept_completion( + self._completer._popup.current_text() + ) + return + if key == Qt.Key.Key_Up: + self._completer._popup.move_selection(-1) + return + if key == Qt.Key.Key_Down: + self._completer._popup.move_selection(1) + return + + # ── Ctrl+Space → force-show completions ────────────────────────────── + if (event.modifiers() == Qt.KeyboardModifier.ControlModifier + and event.key() == Qt.Key.Key_Space): + if self._completer: + self._completer.trigger_completion(force=True) return - # Ctrl+/ → toggle comment - if event.modifiers() == Qt.KeyboardModifier.ControlModifier \ - and event.key() == Qt.Key.Key_Slash: + + # ── Tab → 4 spaces (only when popup is closed) ─────────────────────── + if event.key() == Qt.Key.Key_Tab: + self.textCursor().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) + # ── Auto-trigger completions after every regular keystroke ──────────── + if self._completer: + self._completer.trigger_completion(force=False) + def _toggle_comment(self): cursor = self.textCursor() start = cursor.selectionStart() @@ -201,6 +245,10 @@ class EditorTab(QWidget): self._editor = CodeEditor() SQLHighlighter(self._editor.document()) + self._completer = SqlCompleter(self._editor, parent=self) + self._editor.set_completer(self._completer) + if self._driver: + self._completer.set_context(self._driver, self._database) self._results = ResultsPanel() self._results.status_message.connect(self.status_message) @@ -264,20 +312,33 @@ class EditorTab(QWidget): def _export(self): self._results.export_dialog() + def set_context(self, driver, database: str) -> None: + """Update driver/database and refresh schema completions.""" + self._driver = driver + self._database = database + self._db_label.setText(f"DB: {database}" if database else "") + self._completer.set_context(driver, database) + def _on_result(self, cols, rows, cnt, elapsed): self._results.show_data(cols, rows, cnt, elapsed) + self._maybe_invalidate_schema(self._editor.toPlainText()) 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) + self._maybe_invalidate_schema(self._editor.toPlainText()) def _on_error(self, msg: str): self._results.show_error(msg) + def _maybe_invalidate_schema(self, sql: str) -> None: + if any(sql.lstrip().lower().startswith(kw) + for kw in ("create", "drop", "alter", "rename")): + self._completer.invalidate() + # ── Public ───────────────────────────────────────────────────────────────── def set_sql(self, sql: str):