Add schema-aware SQL auto-complete to the SQL editor
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
+70
-9
@@ -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
|
||||
# Ctrl+/ → toggle comment
|
||||
if event.modifiers() == Qt.KeyboardModifier.ControlModifier \
|
||||
and event.key() == Qt.Key.Key_Slash:
|
||||
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
|
||||
|
||||
# ── 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):
|
||||
|
||||
Reference in New Issue
Block a user