""" Query tab — SQL database interaction with editor, results grid, and export. """ import csv import io import time import threading from tkinter import ttk, filedialog import customtkinter as ctk from core.i18n import t from core.icons import icon_text from core.sql_client import SQLClient class QueryTab(ctk.CTkFrame): def __init__(self, master, store): super().__init__(master, fg_color="transparent") self._current_alias: str | None = None self.store = store self._client: SQLClient | None = None self._results: list[list] = [] self._columns: list[str] = [] self._executing = False self._build_ui() # ── UI construction ──────────────────────────────────────────── def _build_ui(self): # === Database selector row === db_row = ctk.CTkFrame(self, fg_color="transparent") db_row.pack(fill="x", padx=10, pady=(10, 5)) ctk.CTkLabel(db_row, text=t("query_database"), anchor="w").pack( side="left", padx=(0, 8) ) self._db_var = ctk.StringVar(value="") self._db_combo = ctk.CTkComboBox( db_row, variable=self._db_var, values=[], width=220, command=self._on_db_selected, ) self._db_combo.pack(side="left") # === SQL Editor === editor_frame = ctk.CTkFrame(self, fg_color="transparent") editor_frame.pack(fill="both", expand=True, padx=10, pady=5, side="top") # Give editor roughly 1/3 of space editor_frame.pack_configure(expand=False) self._editor = ctk.CTkTextbox( editor_frame, font=ctk.CTkFont(family="Consolas", size=13), height=160, wrap="none", ) self._editor.pack(fill="both", expand=True) self._editor.insert("0.0", t("query_editor_placeholder")) self._editor.bind("", self._on_editor_focus) # Bind keyboard shortcuts self._editor.bind("", lambda e: self._execute_query()) self._editor.bind("", lambda e: self._execute_query()) # === Button row === btn_row = ctk.CTkFrame(self, fg_color="transparent") btn_row.pack(fill="x", padx=10, pady=5) self._exec_btn = ctk.CTkButton( btn_row, text=icon_text("execute", t("query_execute")), command=self._execute_query, width=130, fg_color="#2563eb", hover_color="#1d4ed8", ) self._exec_btn.pack(side="left", padx=(0, 6)) self._clear_btn = ctk.CTkButton( btn_row, text=icon_text("clear", t("query_clear")), command=self._clear_all, width=80, fg_color="#6b7280", hover_color="#4b5563", ) self._clear_btn.pack(side="left", padx=(0, 6)) self._export_btn = ctk.CTkButton( btn_row, text=icon_text("save", t("query_export_csv")), command=self._export_csv, width=110, fg_color="#059669", hover_color="#047857", ) self._export_btn.pack(side="left") # === Results area (Treeview) === results_frame = ctk.CTkFrame(self, fg_color="transparent") results_frame.pack(fill="both", expand=True, padx=10, pady=(5, 5)) # Horizontal scrollbar self._tree_xscroll = ttk.Scrollbar(results_frame, orient="horizontal") self._tree_xscroll.pack(side="bottom", fill="x") # Vertical scrollbar self._tree_yscroll = ttk.Scrollbar(results_frame, orient="vertical") self._tree_yscroll.pack(side="right", fill="y") self._tree = ttk.Treeview( results_frame, show="headings", xscrollcommand=self._tree_xscroll.set, yscrollcommand=self._tree_yscroll.set, ) self._tree.pack(fill="both", expand=True) self._tree_xscroll.config(command=self._tree.xview) self._tree_yscroll.config(command=self._tree.yview) # === Status bar === self._status_label = ctk.CTkLabel( self, text="", anchor="w", font=ctk.CTkFont(size=12), text_color="#9ca3af", ) self._status_label.pack(fill="x", padx=12, pady=(0, 8)) # ── Editor placeholder logic ─────────────────────────────────── def _on_editor_focus(self, event=None): content = self._editor.get("0.0", "end").strip() placeholder = t("query_editor_placeholder") if content == placeholder: self._editor.delete("0.0", "end") # ── Server / database connection ─────────────────────────────── def set_server(self, alias: str | None): """Called when user selects a server in the sidebar.""" self._current_alias = alias self._disconnect() self._clear_results() self._set_status("") if not alias: self._db_combo.configure(values=[]) self._db_var.set("") return self._set_status(f"Connecting to {alias}...") threading.Thread( target=self._connect_and_list_dbs, args=(alias,), daemon=True, ).start() def _connect_and_list_dbs(self, alias: str): """Background: create SQLClient, fetch database list.""" try: server = self.store.get_server(alias) if not server: self._schedule(self._set_status, t("query_error"), error=True) return client = SQLClient(server) databases = client.list_databases() def _update(): if self._current_alias != alias: return # switched away self._client = client self._db_combo.configure(values=databases) if databases: self._db_var.set(databases[0]) self._switch_database(databases[0]) self._set_status("OK") self._schedule(_update) except Exception as exc: self._schedule(self._set_status, str(exc), error=True) def _on_db_selected(self, value: str): if value: self._switch_database(value) def _switch_database(self, db_name: str): """Switch active database on the current client.""" if not self._client: return try: self._client.use_database(db_name) self._set_status(f"Database: {db_name}") except Exception as exc: self._set_status(str(exc), error=True) def _disconnect(self): if self._client: try: self._client.close() except Exception: pass self._client = None # ── Query execution ──────────────────────────────────────────── def _execute_query(self): """Run the SQL query in a background thread.""" if self._executing or not self._client: return sql = self._editor.get("0.0", "end").strip() if not sql or sql == t("query_editor_placeholder"): return self._executing = True self._exec_btn.configure(state="disabled") self._set_status("Executing...") threading.Thread( target=self._run_query, args=(sql,), daemon=True, ).start() def _run_query(self, sql: str): """Background thread: execute SQL, measure time, post results.""" start = time.perf_counter() try: columns, rows = self._client.execute(sql) elapsed = time.perf_counter() - start def _update(): self._columns = columns self._results = rows self._populate_tree(columns, rows) row_count = len(rows) self._set_status( t("query_status_rows").format( rows=row_count, time=f"{elapsed:.3f}" ) ) self._executing = False self._exec_btn.configure(state="normal") self._schedule(_update) except Exception as exc: elapsed = time.perf_counter() - start def _update_err(): self._set_status( f"{t('query_error')}: {exc}", error=True ) self._executing = False self._exec_btn.configure(state="normal") self._schedule(_update_err) # ── Treeview population ──────────────────────────────────────── def _populate_tree(self, columns: list[str], rows: list[list]): """Clear and populate the Treeview with query results.""" self._tree.delete(*self._tree.get_children()) if not columns: self._tree["columns"] = () return self._tree["columns"] = columns for col in columns: self._tree.heading(col, text=col, anchor="w") self._tree.column(col, width=120, minwidth=60, anchor="w") for row in rows: display = [str(v) if v is not None else "NULL" for v in row] self._tree.insert("", "end", values=display) def _clear_results(self): """Remove all rows and columns from the Treeview.""" self._tree.delete(*self._tree.get_children()) self._tree["columns"] = () self._columns = [] self._results = [] # ── Button actions ───────────────────────────────────────────── def _clear_all(self): """Clear editor content and results.""" self._editor.delete("0.0", "end") self._clear_results() self._set_status("") def _export_csv(self): """Export current results to a CSV file via save dialog.""" if not self._columns or not self._results: return path = filedialog.asksaveasfilename( defaultextension=".csv", filetypes=[("CSV files", "*.csv"), ("All files", "*.*")], title=t("query_export_csv"), ) if not path: return try: with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(self._columns) for row in self._results: writer.writerow( [str(v) if v is not None else "" for v in row] ) self._set_status(f"Exported {len(self._results)} rows to {path}") except Exception as exc: self._set_status(f"{t('query_error')}: {exc}", error=True) # ── Status bar ───────────────────────────────────────────────── def _set_status(self, text: str, error: bool = False): color = "#ef4444" if error else "#9ca3af" self._status_label.configure(text=text, text_color=color) # ── Thread-safe scheduling ───────────────────────────────────── def _schedule(self, func, *args, **kwargs): """Schedule a function to run on the main (tkinter) thread.""" self.after(0, lambda: func(*args, **kwargs))