sqlbench 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlbench/__init__.py +3 -0
- sqlbench/__main__.py +7 -0
- sqlbench/adapters.py +383 -0
- sqlbench/app.py +1398 -0
- sqlbench/database.py +215 -0
- sqlbench/dialogs/__init__.py +1 -0
- sqlbench/dialogs/connection_dialog.py +356 -0
- sqlbench/dialogs/regex_builder_dialog.py +542 -0
- sqlbench/tabs/__init__.py +1 -0
- sqlbench/tabs/spool_tab.py +572 -0
- sqlbench/tabs/sql_tab.py +1827 -0
- sqlbench-0.1.0.dist-info/METADATA +91 -0
- sqlbench-0.1.0.dist-info/RECORD +15 -0
- sqlbench-0.1.0.dist-info/WHEEL +4 -0
- sqlbench-0.1.0.dist-info/entry_points.txt +2 -0
sqlbench/tabs/sql_tab.py
ADDED
|
@@ -0,0 +1,1827 @@
|
|
|
1
|
+
"""SQL utility tab."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tkinter as tk
|
|
5
|
+
from tkinter import ttk, messagebox, simpledialog, filedialog
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SQLTab:
|
|
10
|
+
def __init__(self, parent, app, connection, conn_name, version, adapter):
|
|
11
|
+
self.app = app
|
|
12
|
+
self.connection = connection
|
|
13
|
+
self.conn_name = conn_name
|
|
14
|
+
self.version = version
|
|
15
|
+
self.adapter = adapter
|
|
16
|
+
self.frame = ttk.Frame(parent)
|
|
17
|
+
self.frame.pack(fill=tk.BOTH, expand=True)
|
|
18
|
+
self._running = False
|
|
19
|
+
self._cursor = None
|
|
20
|
+
|
|
21
|
+
# Pagination state
|
|
22
|
+
self._all_rows = []
|
|
23
|
+
self._columns = []
|
|
24
|
+
self._column_info = [] # Full column metadata
|
|
25
|
+
self._current_page = 0
|
|
26
|
+
self._rows_per_page = 1000
|
|
27
|
+
self._show_all = False
|
|
28
|
+
self._total_rows = 0 # Total rows in result set (from COUNT)
|
|
29
|
+
self._base_sql = "" # Original SQL without pagination
|
|
30
|
+
self._sort_column = None # Current sort column
|
|
31
|
+
self._sort_reverse = False # Sort direction
|
|
32
|
+
self._results_base_widths = {} # Base column widths (at font size 10)
|
|
33
|
+
|
|
34
|
+
self._create_widgets()
|
|
35
|
+
self._bind_keys()
|
|
36
|
+
|
|
37
|
+
def _bind_keys(self):
|
|
38
|
+
# Ensure standard text editing shortcuts work
|
|
39
|
+
self.sql_text.bind("<Control-a>", self._select_all)
|
|
40
|
+
self.sql_text.bind("<Control-A>", self._select_all)
|
|
41
|
+
self.sql_text.bind("<Control-Shift-f>", lambda e: self._format_sql())
|
|
42
|
+
self.sql_text.bind("<Control-Shift-F>", lambda e: self._format_sql())
|
|
43
|
+
|
|
44
|
+
def _select_all(self, event=None):
|
|
45
|
+
"""Select all text in SQL editor."""
|
|
46
|
+
self.sql_text.tag_add("sel", "1.0", "end-1c")
|
|
47
|
+
self.sql_text.mark_set("insert", "end-1c")
|
|
48
|
+
return "break"
|
|
49
|
+
|
|
50
|
+
def _setup_syntax_highlighting(self):
|
|
51
|
+
"""Configure syntax highlighting tags for the SQL editor."""
|
|
52
|
+
is_dark = self.app.dark_mode_var.get()
|
|
53
|
+
|
|
54
|
+
if is_dark:
|
|
55
|
+
keyword_color = "#CC7832" # Orange for keywords
|
|
56
|
+
function_color = "#FFC66D" # Yellow for functions
|
|
57
|
+
string_color = "#6A8759" # Green for strings
|
|
58
|
+
comment_color = "#808080" # Gray for comments
|
|
59
|
+
number_color = "#6897BB" # Blue for numbers
|
|
60
|
+
operator_color = "#A9B7C6" # Light gray for operators
|
|
61
|
+
else:
|
|
62
|
+
keyword_color = "#0000FF" # Blue for keywords
|
|
63
|
+
function_color = "#795E26" # Brown for functions
|
|
64
|
+
string_color = "#008000" # Green for strings
|
|
65
|
+
comment_color = "#808080" # Gray for comments
|
|
66
|
+
number_color = "#098658" # Teal for numbers
|
|
67
|
+
operator_color = "#000000" # Black for operators
|
|
68
|
+
|
|
69
|
+
self.sql_text.tag_configure("keyword", foreground=keyword_color, font=("TkFixedFont", self.app.font_size, "bold"))
|
|
70
|
+
self.sql_text.tag_configure("function", foreground=function_color)
|
|
71
|
+
self.sql_text.tag_configure("string", foreground=string_color)
|
|
72
|
+
self.sql_text.tag_configure("comment", foreground=comment_color, font=("TkFixedFont", self.app.font_size, "italic"))
|
|
73
|
+
self.sql_text.tag_configure("number", foreground=number_color)
|
|
74
|
+
self.sql_text.tag_configure("operator", foreground=operator_color)
|
|
75
|
+
|
|
76
|
+
# SQL keywords
|
|
77
|
+
self._sql_keywords = {
|
|
78
|
+
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL',
|
|
79
|
+
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'FULL', 'CROSS', 'ON',
|
|
80
|
+
'GROUP', 'BY', 'HAVING', 'ORDER', 'ASC', 'DESC', 'LIMIT', 'OFFSET',
|
|
81
|
+
'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE',
|
|
82
|
+
'TABLE', 'INDEX', 'VIEW', 'DROP', 'ALTER', 'ADD', 'COLUMN',
|
|
83
|
+
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'UNIQUE',
|
|
84
|
+
'DEFAULT', 'CHECK', 'CASCADE', 'RESTRICT', 'UNION', 'ALL', 'DISTINCT',
|
|
85
|
+
'AS', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'LIKE', 'BETWEEN',
|
|
86
|
+
'EXISTS', 'ANY', 'SOME', 'FETCH', 'FIRST', 'NEXT', 'ROWS', 'ONLY',
|
|
87
|
+
'WITH', 'RECURSIVE', 'OVER', 'PARTITION', 'ROW_NUMBER', 'RANK',
|
|
88
|
+
'TRUE', 'FALSE', 'BEGIN', 'COMMIT', 'ROLLBACK', 'TRANSACTION',
|
|
89
|
+
'TRUNCATE', 'GRANT', 'REVOKE', 'CALL', 'DECLARE', 'CURSOR', 'FOR'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# SQL functions
|
|
93
|
+
self._sql_functions = {
|
|
94
|
+
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'ABS', 'ROUND', 'FLOOR', 'CEIL',
|
|
95
|
+
'CEILING', 'COALESCE', 'NULLIF', 'IFNULL', 'NVL', 'CAST', 'CONVERT',
|
|
96
|
+
'UPPER', 'LOWER', 'TRIM', 'LTRIM', 'RTRIM', 'LENGTH', 'LEN', 'SUBSTR',
|
|
97
|
+
'SUBSTRING', 'CONCAT', 'REPLACE', 'INSTR', 'LOCATE', 'POSITION',
|
|
98
|
+
'LEFT', 'RIGHT', 'LPAD', 'RPAD', 'REVERSE', 'REPEAT',
|
|
99
|
+
'DATE', 'TIME', 'TIMESTAMP', 'YEAR', 'MONTH', 'DAY', 'HOUR', 'MINUTE',
|
|
100
|
+
'SECOND', 'NOW', 'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP',
|
|
101
|
+
'DATEADD', 'DATEDIFF', 'EXTRACT', 'TO_DATE', 'TO_CHAR', 'TO_NUMBER',
|
|
102
|
+
'DENSE_RANK', 'NTILE', 'LAG', 'LEAD', 'FIRST_VALUE', 'LAST_VALUE',
|
|
103
|
+
'LISTAGG', 'STRING_AGG', 'GROUP_CONCAT', 'JSON_VALUE', 'JSON_QUERY'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def _on_sql_key_release(self, event=None):
|
|
107
|
+
"""Handle key release in SQL editor - debounced highlighting."""
|
|
108
|
+
# Cancel any pending highlight job
|
|
109
|
+
if self._highlight_job:
|
|
110
|
+
self.sql_text.after_cancel(self._highlight_job)
|
|
111
|
+
# Schedule highlighting after 150ms of no typing
|
|
112
|
+
self._highlight_job = self.sql_text.after(150, self._highlight_sql)
|
|
113
|
+
|
|
114
|
+
def _highlight_sql(self):
|
|
115
|
+
"""Apply syntax highlighting to the SQL text."""
|
|
116
|
+
self._highlight_job = None
|
|
117
|
+
|
|
118
|
+
# Remove all existing tags
|
|
119
|
+
for tag in ("keyword", "function", "string", "comment", "number", "operator"):
|
|
120
|
+
self.sql_text.tag_remove(tag, "1.0", "end")
|
|
121
|
+
|
|
122
|
+
text = self.sql_text.get("1.0", "end-1c")
|
|
123
|
+
if not text.strip():
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# Order matters: strings and comments first (so keywords inside them aren't highlighted)
|
|
127
|
+
|
|
128
|
+
# Highlight single-line comments (-- ...)
|
|
129
|
+
for match in re.finditer(r'--[^\n]*', text):
|
|
130
|
+
self._apply_tag("comment", match.start(), match.end())
|
|
131
|
+
|
|
132
|
+
# Highlight multi-line comments (/* ... */)
|
|
133
|
+
for match in re.finditer(r'/\*[\s\S]*?\*/', text):
|
|
134
|
+
self._apply_tag("comment", match.start(), match.end())
|
|
135
|
+
|
|
136
|
+
# Highlight strings (single quotes)
|
|
137
|
+
for match in re.finditer(r"'(?:[^'\\]|\\.)*'", text):
|
|
138
|
+
self._apply_tag("string", match.start(), match.end())
|
|
139
|
+
|
|
140
|
+
# Highlight numbers
|
|
141
|
+
for match in re.finditer(r'\b\d+\.?\d*\b', text):
|
|
142
|
+
if not self._is_inside_string_or_comment(match.start()):
|
|
143
|
+
self._apply_tag("number", match.start(), match.end())
|
|
144
|
+
|
|
145
|
+
# Highlight keywords and functions (word boundaries)
|
|
146
|
+
for match in re.finditer(r'\b[A-Za-z_][A-Za-z0-9_]*\b', text):
|
|
147
|
+
word = match.group().upper()
|
|
148
|
+
start = match.start()
|
|
149
|
+
if self._is_inside_string_or_comment(start):
|
|
150
|
+
continue
|
|
151
|
+
if word in self._sql_keywords:
|
|
152
|
+
self._apply_tag("keyword", start, match.end())
|
|
153
|
+
elif word in self._sql_functions:
|
|
154
|
+
self._apply_tag("function", start, match.end())
|
|
155
|
+
|
|
156
|
+
def _apply_tag(self, tag, start_char, end_char):
|
|
157
|
+
"""Apply a tag to a character range."""
|
|
158
|
+
start_idx = f"1.0+{start_char}c"
|
|
159
|
+
end_idx = f"1.0+{end_char}c"
|
|
160
|
+
self.sql_text.tag_add(tag, start_idx, end_idx)
|
|
161
|
+
|
|
162
|
+
def _is_inside_string_or_comment(self, char_pos):
|
|
163
|
+
"""Check if a character position is inside a string or comment."""
|
|
164
|
+
idx = f"1.0+{char_pos}c"
|
|
165
|
+
tags = self.sql_text.tag_names(idx)
|
|
166
|
+
return "string" in tags or "comment" in tags
|
|
167
|
+
|
|
168
|
+
def _format_sql(self):
|
|
169
|
+
"""Format the SQL statement with consistent indentation and line breaks."""
|
|
170
|
+
sql = self.sql_text.get("1.0", "end-1c").strip()
|
|
171
|
+
if not sql:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# Preserve cursor position roughly
|
|
175
|
+
try:
|
|
176
|
+
cursor_pos = self.sql_text.index("insert")
|
|
177
|
+
except Exception:
|
|
178
|
+
cursor_pos = "1.0"
|
|
179
|
+
|
|
180
|
+
formatted = self._do_format_sql(sql)
|
|
181
|
+
|
|
182
|
+
# Update text
|
|
183
|
+
self.sql_text.delete("1.0", "end")
|
|
184
|
+
self.sql_text.insert("1.0", formatted)
|
|
185
|
+
|
|
186
|
+
# Restore cursor (roughly)
|
|
187
|
+
try:
|
|
188
|
+
self.sql_text.mark_set("insert", cursor_pos)
|
|
189
|
+
self.sql_text.see("insert")
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
# Re-highlight
|
|
194
|
+
self._highlight_sql()
|
|
195
|
+
self.app.statusbar.config(text="SQL formatted")
|
|
196
|
+
|
|
197
|
+
def _do_format_sql(self, sql):
|
|
198
|
+
"""Perform SQL formatting."""
|
|
199
|
+
# Remove extra whitespace
|
|
200
|
+
sql = re.sub(r'\s+', ' ', sql).strip()
|
|
201
|
+
|
|
202
|
+
# Keywords that should start a new line
|
|
203
|
+
newline_before = [
|
|
204
|
+
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'ORDER BY', 'GROUP BY',
|
|
205
|
+
'HAVING', 'LIMIT', 'OFFSET', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN',
|
|
206
|
+
'INNER JOIN', 'OUTER JOIN', 'FULL JOIN', 'CROSS JOIN', 'ON',
|
|
207
|
+
'UNION', 'UNION ALL', 'INSERT INTO', 'VALUES', 'UPDATE', 'SET',
|
|
208
|
+
'DELETE FROM', 'CREATE TABLE', 'CREATE INDEX', 'CREATE VIEW',
|
|
209
|
+
'DROP TABLE', 'ALTER TABLE', 'FETCH FIRST', 'WITH'
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
# Sort by length (longest first) to match multi-word keywords first
|
|
213
|
+
newline_before.sort(key=len, reverse=True)
|
|
214
|
+
|
|
215
|
+
result = sql
|
|
216
|
+
|
|
217
|
+
# Add newlines before major keywords
|
|
218
|
+
for keyword in newline_before:
|
|
219
|
+
# Pattern to match keyword (case insensitive, word boundary)
|
|
220
|
+
pattern = r'(?i)(?<!\n)\s+\b(' + re.escape(keyword) + r')\b'
|
|
221
|
+
result = re.sub(pattern, r'\n\1', result)
|
|
222
|
+
|
|
223
|
+
# Indent lines after SELECT, with commas on separate lines
|
|
224
|
+
lines = result.split('\n')
|
|
225
|
+
formatted_lines = []
|
|
226
|
+
indent = 0
|
|
227
|
+
|
|
228
|
+
for line in lines:
|
|
229
|
+
line = line.strip()
|
|
230
|
+
if not line:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
line_upper = line.upper()
|
|
234
|
+
|
|
235
|
+
# Adjust indentation
|
|
236
|
+
if line_upper.startswith(('SELECT', 'FROM', 'WHERE', 'GROUP BY', 'ORDER BY', 'HAVING')):
|
|
237
|
+
indent = 0
|
|
238
|
+
elif line_upper.startswith(('AND', 'OR')):
|
|
239
|
+
indent = 1
|
|
240
|
+
elif line_upper.startswith(('JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'FULL', 'CROSS', 'ON')):
|
|
241
|
+
indent = 1
|
|
242
|
+
elif line_upper.startswith(('UNION',)):
|
|
243
|
+
indent = 0
|
|
244
|
+
|
|
245
|
+
formatted_lines.append(' ' * indent + line)
|
|
246
|
+
|
|
247
|
+
return '\n'.join(formatted_lines)
|
|
248
|
+
|
|
249
|
+
def _add_tooltip(self, widget, text):
|
|
250
|
+
"""Add a tooltip to a widget."""
|
|
251
|
+
tooltip = None
|
|
252
|
+
|
|
253
|
+
def show(event):
|
|
254
|
+
nonlocal tooltip
|
|
255
|
+
x, y, _, _ = widget.bbox("insert") if hasattr(widget, 'bbox') else (0, 0, 0, 0)
|
|
256
|
+
x += widget.winfo_rootx() + 25
|
|
257
|
+
y += widget.winfo_rooty() + 25
|
|
258
|
+
tooltip = tk.Toplevel(widget)
|
|
259
|
+
tooltip.wm_overrideredirect(True)
|
|
260
|
+
tooltip.wm_geometry(f"+{x}+{y}")
|
|
261
|
+
label = tk.Label(tooltip, text=text, background="#ffffe0", relief="solid", borderwidth=1, padx=4, pady=2)
|
|
262
|
+
label.pack()
|
|
263
|
+
|
|
264
|
+
def hide(event):
|
|
265
|
+
nonlocal tooltip
|
|
266
|
+
if tooltip:
|
|
267
|
+
tooltip.destroy()
|
|
268
|
+
tooltip = None
|
|
269
|
+
|
|
270
|
+
widget.bind("<Enter>", show)
|
|
271
|
+
widget.bind("<Leave>", hide)
|
|
272
|
+
|
|
273
|
+
def _create_widgets(self):
|
|
274
|
+
# Top button bar
|
|
275
|
+
btn_frame = ttk.Frame(self.frame)
|
|
276
|
+
btn_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
|
|
277
|
+
|
|
278
|
+
self.run_btn = ttk.Button(btn_frame, text="Run", command=self._run_query)
|
|
279
|
+
self.run_btn.pack(side=tk.LEFT, padx=2)
|
|
280
|
+
self._add_tooltip(self.run_btn, "Run query (F5)")
|
|
281
|
+
|
|
282
|
+
self.cancel_btn = ttk.Button(btn_frame, text="Cancel", command=self._cancel_query, state=tk.DISABLED)
|
|
283
|
+
self.cancel_btn.pack(side=tk.LEFT, padx=2)
|
|
284
|
+
self._add_tooltip(self.cancel_btn, "Cancel query (Esc)")
|
|
285
|
+
|
|
286
|
+
save_btn = ttk.Button(btn_frame, text="Save Query", command=self._save_query)
|
|
287
|
+
save_btn.pack(side=tk.LEFT, padx=2)
|
|
288
|
+
self._add_tooltip(save_btn, "Save query to file (Ctrl+S)")
|
|
289
|
+
|
|
290
|
+
load_btn = ttk.Button(btn_frame, text="Load Query", command=self._load_query)
|
|
291
|
+
load_btn.pack(side=tk.LEFT, padx=2)
|
|
292
|
+
self._add_tooltip(load_btn, "Load query from file (Ctrl+O)")
|
|
293
|
+
|
|
294
|
+
ttk.Button(btn_frame, text="Clear", command=self._clear).pack(side=tk.LEFT, padx=2)
|
|
295
|
+
|
|
296
|
+
ttk.Separator(btn_frame, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=8)
|
|
297
|
+
|
|
298
|
+
format_btn = ttk.Button(btn_frame, text="Format", command=self._format_sql)
|
|
299
|
+
format_btn.pack(side=tk.LEFT, padx=2)
|
|
300
|
+
self._add_tooltip(format_btn, "Format SQL (Ctrl+Shift+F)")
|
|
301
|
+
|
|
302
|
+
# Connection info label
|
|
303
|
+
ttk.Label(btn_frame, text=f" [{self.conn_name}]").pack(side=tk.RIGHT, padx=5)
|
|
304
|
+
|
|
305
|
+
# Paned window for SQL entry and results
|
|
306
|
+
self.paned = ttk.PanedWindow(self.frame, orient=tk.VERTICAL)
|
|
307
|
+
self.paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
|
308
|
+
|
|
309
|
+
# SQL entry area
|
|
310
|
+
sql_frame = ttk.LabelFrame(self.paned, text="SQL Statement")
|
|
311
|
+
self.paned.add(sql_frame, weight=1)
|
|
312
|
+
|
|
313
|
+
font_spec = ("TkFixedFont", self.app.font_size)
|
|
314
|
+
self.sql_text = tk.Text(sql_frame, height=10, wrap=tk.NONE, font=font_spec)
|
|
315
|
+
sql_scroll_y = ttk.Scrollbar(sql_frame, orient=tk.VERTICAL, command=self.sql_text.yview)
|
|
316
|
+
sql_scroll_x = ttk.Scrollbar(sql_frame, orient=tk.HORIZONTAL, command=self.sql_text.xview)
|
|
317
|
+
self.sql_text.configure(yscrollcommand=sql_scroll_y.set, xscrollcommand=sql_scroll_x.set)
|
|
318
|
+
|
|
319
|
+
sql_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
|
|
320
|
+
sql_scroll_x.pack(side=tk.BOTTOM, fill=tk.X)
|
|
321
|
+
self.sql_text.pack(fill=tk.BOTH, expand=True)
|
|
322
|
+
|
|
323
|
+
# Setup syntax highlighting
|
|
324
|
+
self._setup_syntax_highlighting()
|
|
325
|
+
self._highlight_job = None # For debounced highlighting
|
|
326
|
+
self.sql_text.bind("<KeyRelease>", self._on_sql_key_release)
|
|
327
|
+
|
|
328
|
+
# Results area with tabs
|
|
329
|
+
results_frame = ttk.Frame(self.paned)
|
|
330
|
+
self.paned.add(results_frame, weight=2)
|
|
331
|
+
|
|
332
|
+
# Create notebook for Results/Fields/Statistics tabs
|
|
333
|
+
self.results_notebook = ttk.Notebook(results_frame)
|
|
334
|
+
self.results_notebook.pack(fill=tk.BOTH, expand=True)
|
|
335
|
+
|
|
336
|
+
# Tab 1: Results
|
|
337
|
+
self._create_results_tab()
|
|
338
|
+
|
|
339
|
+
# Tab 2: Fields
|
|
340
|
+
self._create_fields_tab()
|
|
341
|
+
|
|
342
|
+
# Tab 3: Statistics
|
|
343
|
+
self._create_statistics_tab()
|
|
344
|
+
|
|
345
|
+
def _create_results_tab(self):
|
|
346
|
+
"""Create the Results tab with data grid and pagination."""
|
|
347
|
+
results_tab = ttk.Frame(self.results_notebook)
|
|
348
|
+
self.results_notebook.add(results_tab, text="Results")
|
|
349
|
+
|
|
350
|
+
# Pagination controls at top
|
|
351
|
+
self._create_pagination_controls(results_tab)
|
|
352
|
+
|
|
353
|
+
# Status label at bottom (pack before tree so it stays at bottom)
|
|
354
|
+
self.page_label = ttk.Label(results_tab, text="No results")
|
|
355
|
+
self.page_label.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=3)
|
|
356
|
+
|
|
357
|
+
# Results treeview
|
|
358
|
+
tree_frame = ttk.Frame(results_tab)
|
|
359
|
+
tree_frame.pack(fill=tk.BOTH, expand=True)
|
|
360
|
+
|
|
361
|
+
self.results_tree = ttk.Treeview(tree_frame, show="headings")
|
|
362
|
+
results_scroll_y = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.results_tree.yview)
|
|
363
|
+
results_scroll_x = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL, command=self.results_tree.xview)
|
|
364
|
+
self.results_tree.configure(yscrollcommand=results_scroll_y.set, xscrollcommand=results_scroll_x.set)
|
|
365
|
+
|
|
366
|
+
results_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
|
|
367
|
+
results_scroll_x.pack(side=tk.BOTTOM, fill=tk.X)
|
|
368
|
+
self.results_tree.pack(fill=tk.BOTH, expand=True)
|
|
369
|
+
|
|
370
|
+
# Double-click handling (header separator vs data row)
|
|
371
|
+
self.results_tree.bind("<Double-1>", self._on_results_double_click)
|
|
372
|
+
|
|
373
|
+
def _create_fields_tab(self):
|
|
374
|
+
"""Create the Fields tab showing column metadata."""
|
|
375
|
+
fields_tab = ttk.Frame(self.results_notebook)
|
|
376
|
+
self.results_notebook.add(fields_tab, text="Fields")
|
|
377
|
+
|
|
378
|
+
# Fields treeview
|
|
379
|
+
columns = ("table", "name", "type", "display_size", "precision", "scale", "nullable")
|
|
380
|
+
self.fields_tree = ttk.Treeview(fields_tab, columns=columns, show="headings")
|
|
381
|
+
|
|
382
|
+
self.fields_tree.heading("table", text="Table")
|
|
383
|
+
self.fields_tree.heading("name", text="Column Name")
|
|
384
|
+
self.fields_tree.heading("type", text="Type")
|
|
385
|
+
self.fields_tree.heading("display_size", text="Display Size")
|
|
386
|
+
self.fields_tree.heading("precision", text="Precision")
|
|
387
|
+
self.fields_tree.heading("scale", text="Scale")
|
|
388
|
+
self.fields_tree.heading("nullable", text="Nullable")
|
|
389
|
+
|
|
390
|
+
# Base column widths (at font size 10)
|
|
391
|
+
self._fields_base_widths = {
|
|
392
|
+
"table": 120, "name": 150, "type": 120,
|
|
393
|
+
"display_size": 100, "precision": 80, "scale": 80, "nullable": 80
|
|
394
|
+
}
|
|
395
|
+
scale = self.app.font_size / 10.0
|
|
396
|
+
for col, base_w in self._fields_base_widths.items():
|
|
397
|
+
self.fields_tree.column(col, width=int(base_w * scale))
|
|
398
|
+
|
|
399
|
+
fields_scroll_y = ttk.Scrollbar(fields_tab, orient=tk.VERTICAL, command=self.fields_tree.yview)
|
|
400
|
+
self.fields_tree.configure(yscrollcommand=fields_scroll_y.set)
|
|
401
|
+
|
|
402
|
+
fields_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
|
|
403
|
+
self.fields_tree.pack(fill=tk.BOTH, expand=True)
|
|
404
|
+
|
|
405
|
+
def _create_statistics_tab(self):
|
|
406
|
+
"""Create the Statistics tab showing query execution info."""
|
|
407
|
+
stats_tab = ttk.Frame(self.results_notebook)
|
|
408
|
+
self.results_notebook.add(stats_tab, text="Statistics")
|
|
409
|
+
|
|
410
|
+
# Statistics text area
|
|
411
|
+
self.stats_text = tk.Text(stats_tab, wrap=tk.WORD, font=("Courier", self.app.font_size))
|
|
412
|
+
stats_scroll_y = ttk.Scrollbar(stats_tab, orient=tk.VERTICAL, command=self.stats_text.yview)
|
|
413
|
+
self.stats_text.configure(yscrollcommand=stats_scroll_y.set)
|
|
414
|
+
|
|
415
|
+
stats_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
|
|
416
|
+
self.stats_text.pack(fill=tk.BOTH, expand=True)
|
|
417
|
+
|
|
418
|
+
self.stats_text.insert("1.0", "Execute a query to see statistics.")
|
|
419
|
+
self.stats_text.config(state=tk.DISABLED)
|
|
420
|
+
|
|
421
|
+
def _create_pagination_controls(self, parent):
|
|
422
|
+
"""Create pagination controls above results."""
|
|
423
|
+
self.paging_frame = ttk.Frame(parent)
|
|
424
|
+
self.paging_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=3)
|
|
425
|
+
|
|
426
|
+
# Left side - navigation buttons
|
|
427
|
+
nav_frame = ttk.Frame(self.paging_frame)
|
|
428
|
+
nav_frame.pack(side=tk.LEFT)
|
|
429
|
+
|
|
430
|
+
self.first_btn = ttk.Button(nav_frame, text="◀◀", width=3, command=self._first_page, state=tk.DISABLED)
|
|
431
|
+
self.first_btn.pack(side=tk.LEFT, padx=1)
|
|
432
|
+
self.prev_btn = ttk.Button(nav_frame, text="◀", width=3, command=self._prev_page, state=tk.DISABLED)
|
|
433
|
+
self.prev_btn.pack(side=tk.LEFT, padx=1)
|
|
434
|
+
self.next_btn = ttk.Button(nav_frame, text="▶", width=3, command=self._next_page, state=tk.DISABLED)
|
|
435
|
+
self.next_btn.pack(side=tk.LEFT, padx=1)
|
|
436
|
+
self.last_btn = ttk.Button(nav_frame, text="▶▶", width=3, command=self._last_page, state=tk.DISABLED)
|
|
437
|
+
self.last_btn.pack(side=tk.LEFT, padx=1)
|
|
438
|
+
|
|
439
|
+
# Save To dropdown
|
|
440
|
+
self.save_menu = tk.Menu(nav_frame, tearoff=0)
|
|
441
|
+
self.save_menu.add_command(label="Copy to Clipboard", command=self._copy_to_clipboard)
|
|
442
|
+
self.save_menu.add_separator()
|
|
443
|
+
self.save_menu.add_command(label="Excel (.xlsx)", command=self._save_to_excel)
|
|
444
|
+
self.save_menu.add_command(label="CSV (.csv)", command=self._save_to_csv)
|
|
445
|
+
self.save_menu.add_command(label="JSON (.json)", command=self._save_to_json)
|
|
446
|
+
self.save_btn = ttk.Menubutton(nav_frame, text="Save To", menu=self.save_menu)
|
|
447
|
+
self.save_btn.pack(side=tk.LEFT, padx=(15, 0))
|
|
448
|
+
|
|
449
|
+
# Right side - rows per page and show all
|
|
450
|
+
settings_frame = ttk.Frame(self.paging_frame)
|
|
451
|
+
settings_frame.pack(side=tk.RIGHT)
|
|
452
|
+
|
|
453
|
+
# Show all checkbox
|
|
454
|
+
self.show_all_var = tk.BooleanVar(value=False)
|
|
455
|
+
self.show_all_cb = ttk.Checkbutton(
|
|
456
|
+
settings_frame, text="Show All",
|
|
457
|
+
variable=self.show_all_var,
|
|
458
|
+
command=self._on_show_all_changed
|
|
459
|
+
)
|
|
460
|
+
self.show_all_cb.pack(side=tk.RIGHT, padx=10)
|
|
461
|
+
|
|
462
|
+
# Rows per page
|
|
463
|
+
ttk.Label(settings_frame, text="Rows per page:").pack(side=tk.LEFT, padx=(0, 5))
|
|
464
|
+
self.rows_per_page_var = tk.StringVar(value="1000")
|
|
465
|
+
self.rows_per_page_spin = ttk.Spinbox(
|
|
466
|
+
settings_frame, from_=100, to=10000, increment=100,
|
|
467
|
+
width=7, textvariable=self.rows_per_page_var
|
|
468
|
+
)
|
|
469
|
+
self.rows_per_page_spin.pack(side=tk.LEFT)
|
|
470
|
+
self.rows_per_page_spin.bind("<Return>", self._on_rows_per_page_changed)
|
|
471
|
+
self.rows_per_page_spin.bind("<FocusOut>", self._on_rows_per_page_changed)
|
|
472
|
+
self.rows_per_page_spin.bind("<<Increment>>", self._on_rows_per_page_changed)
|
|
473
|
+
self.rows_per_page_spin.bind("<<Decrement>>", self._on_rows_per_page_changed)
|
|
474
|
+
|
|
475
|
+
def _on_show_all_changed(self):
|
|
476
|
+
"""Handle show all checkbox change - re-run query with new limit."""
|
|
477
|
+
self._show_all = self.show_all_var.get()
|
|
478
|
+
self._current_page = 0
|
|
479
|
+
|
|
480
|
+
# If we have a query and results, re-run to fetch with new limit
|
|
481
|
+
sql = self.sql_text.get("1.0", tk.END).strip()
|
|
482
|
+
if sql and self._all_rows:
|
|
483
|
+
self._run_query()
|
|
484
|
+
else:
|
|
485
|
+
self._refresh_display()
|
|
486
|
+
|
|
487
|
+
def _on_rows_per_page_changed(self, event=None):
|
|
488
|
+
"""Handle rows per page change."""
|
|
489
|
+
try:
|
|
490
|
+
new_value = int(self.rows_per_page_var.get())
|
|
491
|
+
if new_value < 1:
|
|
492
|
+
new_value = 100
|
|
493
|
+
self._rows_per_page = new_value
|
|
494
|
+
self._current_page = 0
|
|
495
|
+
if self._base_sql:
|
|
496
|
+
self._fetch_page()
|
|
497
|
+
except ValueError:
|
|
498
|
+
self.rows_per_page_var.set(str(self._rows_per_page))
|
|
499
|
+
|
|
500
|
+
def _first_page(self):
|
|
501
|
+
"""Go to first page."""
|
|
502
|
+
if self._current_page != 0:
|
|
503
|
+
self._current_page = 0
|
|
504
|
+
self._fetch_page()
|
|
505
|
+
|
|
506
|
+
def _prev_page(self):
|
|
507
|
+
"""Go to previous page."""
|
|
508
|
+
if self._current_page > 0:
|
|
509
|
+
self._current_page -= 1
|
|
510
|
+
self._fetch_page()
|
|
511
|
+
|
|
512
|
+
def _next_page(self):
|
|
513
|
+
"""Go to next page."""
|
|
514
|
+
max_page = self._get_max_page()
|
|
515
|
+
if self._current_page < max_page:
|
|
516
|
+
self._current_page += 1
|
|
517
|
+
self._fetch_page()
|
|
518
|
+
|
|
519
|
+
def _last_page(self):
|
|
520
|
+
"""Go to last page."""
|
|
521
|
+
max_page = self._get_max_page()
|
|
522
|
+
if self._current_page != max_page:
|
|
523
|
+
self._current_page = max_page
|
|
524
|
+
self._fetch_page()
|
|
525
|
+
|
|
526
|
+
def _get_max_page(self):
|
|
527
|
+
"""Get maximum page index."""
|
|
528
|
+
if self._total_rows <= 0 or self._rows_per_page <= 0:
|
|
529
|
+
return 0
|
|
530
|
+
return max(0, (self._total_rows - 1) // self._rows_per_page)
|
|
531
|
+
|
|
532
|
+
def _fetch_page(self):
|
|
533
|
+
"""Fetch the current page of results from the server."""
|
|
534
|
+
if not self._base_sql or self._running:
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
self._set_running(True)
|
|
538
|
+
self.app.statusbar.config(text=f"Fetching page {self._current_page + 1}...")
|
|
539
|
+
|
|
540
|
+
thread = threading.Thread(target=self._execute_page_query, daemon=True)
|
|
541
|
+
thread.start()
|
|
542
|
+
|
|
543
|
+
def _execute_page_query(self):
|
|
544
|
+
"""Execute query for current page only."""
|
|
545
|
+
import time
|
|
546
|
+
start_time = time.time()
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
cursor = self.connection.cursor()
|
|
550
|
+
|
|
551
|
+
# Build paginated query
|
|
552
|
+
offset = self._current_page * self._rows_per_page
|
|
553
|
+
if self._show_all:
|
|
554
|
+
paginated_sql = self._base_sql
|
|
555
|
+
else:
|
|
556
|
+
paginated_sql = self.adapter.add_pagination(self._base_sql, self._rows_per_page, offset)
|
|
557
|
+
|
|
558
|
+
cursor.execute(paginated_sql)
|
|
559
|
+
rows = cursor.fetchall()
|
|
560
|
+
cursor.close()
|
|
561
|
+
|
|
562
|
+
fetch_time = time.time() - start_time
|
|
563
|
+
|
|
564
|
+
self.app.root.after(0, self._display_page_results, rows, fetch_time)
|
|
565
|
+
except Exception as e:
|
|
566
|
+
self.app.root.after(0, self._query_error, str(e), {})
|
|
567
|
+
finally:
|
|
568
|
+
self.app.root.after(0, self._set_running, False)
|
|
569
|
+
|
|
570
|
+
def _display_page_results(self, rows, fetch_time):
|
|
571
|
+
"""Display page results."""
|
|
572
|
+
self._all_rows = list(rows)
|
|
573
|
+
self._refresh_display()
|
|
574
|
+
self.app.statusbar.config(
|
|
575
|
+
text=f"Page {self._current_page + 1} loaded ({len(rows)} rows, {fetch_time:.3f}s) from {self.conn_name}"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
def _refresh_display(self):
|
|
579
|
+
"""Refresh the results display with current page data."""
|
|
580
|
+
if not self._columns:
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
# Clear current display
|
|
584
|
+
self.results_tree.delete(*self.results_tree.get_children())
|
|
585
|
+
|
|
586
|
+
# Display all rows in _all_rows (which is the current page's data)
|
|
587
|
+
for row in self._all_rows:
|
|
588
|
+
clean_row = tuple(
|
|
589
|
+
str(v).strip() if isinstance(v, str) else (str(v) if v is not None else "")
|
|
590
|
+
for v in row
|
|
591
|
+
)
|
|
592
|
+
self.results_tree.insert("", tk.END, values=clean_row)
|
|
593
|
+
|
|
594
|
+
# Calculate display range based on server-side pagination
|
|
595
|
+
page_size = len(self._all_rows)
|
|
596
|
+
if self._show_all:
|
|
597
|
+
start_idx = 0
|
|
598
|
+
end_idx = self._total_rows
|
|
599
|
+
else:
|
|
600
|
+
start_idx = self._current_page * self._rows_per_page
|
|
601
|
+
end_idx = start_idx + page_size
|
|
602
|
+
|
|
603
|
+
# Update pagination label and buttons
|
|
604
|
+
self._update_pagination_ui(start_idx, end_idx, self._total_rows)
|
|
605
|
+
|
|
606
|
+
def _update_pagination_ui(self, start_idx, end_idx, total_rows):
|
|
607
|
+
"""Update pagination label and button states."""
|
|
608
|
+
if total_rows == 0:
|
|
609
|
+
self.page_label.config(text="No results")
|
|
610
|
+
self.first_btn.config(state=tk.DISABLED)
|
|
611
|
+
self.prev_btn.config(state=tk.DISABLED)
|
|
612
|
+
self.next_btn.config(state=tk.DISABLED)
|
|
613
|
+
self.last_btn.config(state=tk.DISABLED)
|
|
614
|
+
elif self._show_all:
|
|
615
|
+
self.page_label.config(text=f"Showing all {total_rows:,} row(s)")
|
|
616
|
+
self.first_btn.config(state=tk.DISABLED)
|
|
617
|
+
self.prev_btn.config(state=tk.DISABLED)
|
|
618
|
+
self.next_btn.config(state=tk.DISABLED)
|
|
619
|
+
self.last_btn.config(state=tk.DISABLED)
|
|
620
|
+
else:
|
|
621
|
+
max_page = self._get_max_page()
|
|
622
|
+
self.page_label.config(
|
|
623
|
+
text=f"Showing {start_idx + 1:,}-{end_idx:,} of {total_rows:,} row(s) "
|
|
624
|
+
f"(Page {self._current_page + 1} of {max_page + 1})"
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Update button states
|
|
628
|
+
has_prev = self._current_page > 0
|
|
629
|
+
has_next = self._current_page < max_page
|
|
630
|
+
|
|
631
|
+
self.first_btn.config(state=tk.NORMAL if has_prev else tk.DISABLED)
|
|
632
|
+
self.prev_btn.config(state=tk.NORMAL if has_prev else tk.DISABLED)
|
|
633
|
+
self.next_btn.config(state=tk.NORMAL if has_next else tk.DISABLED)
|
|
634
|
+
self.last_btn.config(state=tk.NORMAL if has_next else tk.DISABLED)
|
|
635
|
+
|
|
636
|
+
def _set_running(self, running):
|
|
637
|
+
"""Update UI state for running/not running."""
|
|
638
|
+
self._running = running
|
|
639
|
+
if running:
|
|
640
|
+
self.run_btn.config(state=tk.DISABLED)
|
|
641
|
+
self.cancel_btn.config(state=tk.NORMAL)
|
|
642
|
+
self.app.root.config(cursor="watch")
|
|
643
|
+
self.app.statusbar.config(text=f"Running query on {self.conn_name}...")
|
|
644
|
+
else:
|
|
645
|
+
self.run_btn.config(state=tk.NORMAL)
|
|
646
|
+
self.cancel_btn.config(state=tk.DISABLED)
|
|
647
|
+
self.app.root.config(cursor="")
|
|
648
|
+
|
|
649
|
+
def _cancel_query(self):
|
|
650
|
+
"""Cancel the running query."""
|
|
651
|
+
if self._cursor:
|
|
652
|
+
try:
|
|
653
|
+
self._cursor.cancel()
|
|
654
|
+
self.app.statusbar.config(text="Query cancelled")
|
|
655
|
+
except Exception:
|
|
656
|
+
pass
|
|
657
|
+
|
|
658
|
+
def _run_query(self):
|
|
659
|
+
if self._running:
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
sql = self.sql_text.get("1.0", tk.END).strip()
|
|
663
|
+
if not sql:
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
# Clear previous results
|
|
667
|
+
self._all_rows = []
|
|
668
|
+
self._columns = []
|
|
669
|
+
self._column_info = []
|
|
670
|
+
self._current_page = 0
|
|
671
|
+
self._base_sql = ""
|
|
672
|
+
self._total_rows = 0
|
|
673
|
+
self.results_tree.delete(*self.results_tree.get_children())
|
|
674
|
+
for col in self.results_tree["columns"]:
|
|
675
|
+
self.results_tree.heading(col, text="")
|
|
676
|
+
|
|
677
|
+
# Clear fields tab
|
|
678
|
+
self.fields_tree.delete(*self.fields_tree.get_children())
|
|
679
|
+
|
|
680
|
+
# Clear results label
|
|
681
|
+
self.page_label.config(text="")
|
|
682
|
+
|
|
683
|
+
# Clear statistics tab
|
|
684
|
+
self.stats_text.config(state=tk.NORMAL)
|
|
685
|
+
self.stats_text.delete("1.0", tk.END)
|
|
686
|
+
self.stats_text.insert("1.0", "Executing query...")
|
|
687
|
+
self.stats_text.config(state=tk.DISABLED)
|
|
688
|
+
|
|
689
|
+
self._set_running(True)
|
|
690
|
+
|
|
691
|
+
# Run query in thread
|
|
692
|
+
thread = threading.Thread(target=self._execute_query, args=(sql,), daemon=True)
|
|
693
|
+
thread.start()
|
|
694
|
+
|
|
695
|
+
def _add_row_limit(self, sql):
|
|
696
|
+
"""Add row limit to SELECT statements if not already limited."""
|
|
697
|
+
sql_stripped = sql.strip()
|
|
698
|
+
while sql_stripped.endswith(';'):
|
|
699
|
+
sql_stripped = sql_stripped[:-1].strip()
|
|
700
|
+
sql_upper = sql_stripped.upper()
|
|
701
|
+
|
|
702
|
+
# Only modify SELECT statements
|
|
703
|
+
if not sql_upper.startswith("SELECT"):
|
|
704
|
+
return sql
|
|
705
|
+
|
|
706
|
+
# Check if already has a row limit clause
|
|
707
|
+
limit_keywords = ["FETCH FIRST", "FETCH NEXT", "LIMIT ", "OFFSET "]
|
|
708
|
+
for keyword in limit_keywords:
|
|
709
|
+
if keyword in sql_upper:
|
|
710
|
+
return sql # Already has a limit
|
|
711
|
+
|
|
712
|
+
# Use rows_per_page as the limit (or default 1000 if showing all)
|
|
713
|
+
if self._show_all:
|
|
714
|
+
limit = 10000 # Cap at 10000 even for "show all"
|
|
715
|
+
else:
|
|
716
|
+
limit = self._rows_per_page
|
|
717
|
+
|
|
718
|
+
# Use adapter to add pagination
|
|
719
|
+
return self.adapter.add_pagination(sql_stripped, limit)
|
|
720
|
+
|
|
721
|
+
def _has_limit_clause(self, sql_upper):
|
|
722
|
+
"""Check if SQL already has a row limit clause."""
|
|
723
|
+
limit_keywords = ["FETCH FIRST", "FETCH NEXT", "LIMIT ", "OFFSET "]
|
|
724
|
+
return any(keyword in sql_upper for keyword in limit_keywords)
|
|
725
|
+
|
|
726
|
+
def _store_base_sql(self, sql, total_rows):
|
|
727
|
+
"""Store base SQL and total rows for pagination."""
|
|
728
|
+
self._base_sql = sql
|
|
729
|
+
self._total_rows = total_rows
|
|
730
|
+
|
|
731
|
+
def _execute_query(self, sql):
|
|
732
|
+
"""Execute query in background thread."""
|
|
733
|
+
import time
|
|
734
|
+
start_time = time.time()
|
|
735
|
+
stats_info = {
|
|
736
|
+
"sql": sql,
|
|
737
|
+
"start_time": start_time,
|
|
738
|
+
"fetch_time": 0,
|
|
739
|
+
"row_count": 0,
|
|
740
|
+
"total_rows": 0,
|
|
741
|
+
"column_count": 0,
|
|
742
|
+
"explain_info": None
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
try:
|
|
746
|
+
self._cursor = self.connection.cursor()
|
|
747
|
+
|
|
748
|
+
# Try to get explain/query plan info before executing
|
|
749
|
+
explain_info = self._get_explain_info(sql)
|
|
750
|
+
stats_info["explain_info"] = explain_info
|
|
751
|
+
|
|
752
|
+
# Strip whitespace and trailing semicolons (statement separators)
|
|
753
|
+
sql_stripped = sql.strip()
|
|
754
|
+
while sql_stripped.endswith(';'):
|
|
755
|
+
sql_stripped = sql_stripped[:-1].strip()
|
|
756
|
+
sql_upper = sql_stripped.upper()
|
|
757
|
+
is_select = sql_upper.startswith("SELECT")
|
|
758
|
+
|
|
759
|
+
# For SELECT statements, get total count first
|
|
760
|
+
total_rows = 0
|
|
761
|
+
if is_select and not self._has_limit_clause(sql_upper):
|
|
762
|
+
try:
|
|
763
|
+
count_sql = self.adapter.get_count_sql(sql_stripped)
|
|
764
|
+
self._cursor.execute(count_sql)
|
|
765
|
+
total_rows = self._cursor.fetchone()[0]
|
|
766
|
+
stats_info["total_rows"] = total_rows
|
|
767
|
+
except Exception:
|
|
768
|
+
# If count fails, we'll just show what we fetch
|
|
769
|
+
pass
|
|
770
|
+
|
|
771
|
+
# Build paginated query for SELECT
|
|
772
|
+
if is_select and not self._has_limit_clause(sql_upper):
|
|
773
|
+
if self._show_all:
|
|
774
|
+
executed_sql = sql_stripped
|
|
775
|
+
else:
|
|
776
|
+
offset = self._current_page * self._rows_per_page
|
|
777
|
+
executed_sql = self.adapter.add_pagination(sql_stripped, self._rows_per_page, offset)
|
|
778
|
+
stats_info["limited"] = True
|
|
779
|
+
# Store base SQL for pagination
|
|
780
|
+
self.app.root.after(0, self._store_base_sql, sql_stripped, total_rows)
|
|
781
|
+
else:
|
|
782
|
+
executed_sql = sql_stripped
|
|
783
|
+
stats_info["limited"] = False
|
|
784
|
+
|
|
785
|
+
# Execute the query
|
|
786
|
+
self._cursor.execute(executed_sql)
|
|
787
|
+
exec_time = time.time()
|
|
788
|
+
stats_info["exec_time"] = exec_time - start_time
|
|
789
|
+
|
|
790
|
+
# Check if this was a SELECT
|
|
791
|
+
if self._cursor.description:
|
|
792
|
+
columns = [desc[0] for desc in self._cursor.description]
|
|
793
|
+
column_info = list(self._cursor.description)
|
|
794
|
+
stats_info["column_count"] = len(columns)
|
|
795
|
+
|
|
796
|
+
rows = self._cursor.fetchall()
|
|
797
|
+
fetch_time = time.time()
|
|
798
|
+
stats_info["fetch_time"] = fetch_time - exec_time
|
|
799
|
+
stats_info["row_count"] = len(rows)
|
|
800
|
+
|
|
801
|
+
# If we couldn't get count earlier, use fetched row count
|
|
802
|
+
if total_rows == 0:
|
|
803
|
+
stats_info["total_rows"] = len(rows)
|
|
804
|
+
|
|
805
|
+
# Update UI in main thread
|
|
806
|
+
self.app.root.after(0, self._display_results, columns, rows, column_info, stats_info)
|
|
807
|
+
else:
|
|
808
|
+
self.connection.commit()
|
|
809
|
+
stats_info["exec_time"] = time.time() - start_time
|
|
810
|
+
self.app.root.after(0, self._query_done, "Statement executed successfully", stats_info)
|
|
811
|
+
|
|
812
|
+
self._cursor.close()
|
|
813
|
+
except Exception as e:
|
|
814
|
+
stats_info["error"] = str(e)
|
|
815
|
+
self.app.root.after(0, self._query_error, str(e), stats_info)
|
|
816
|
+
finally:
|
|
817
|
+
self._cursor = None
|
|
818
|
+
self.app.root.after(0, self._set_running, False)
|
|
819
|
+
|
|
820
|
+
def _get_explain_info(self, sql):
|
|
821
|
+
"""Try to get query explain/plan information."""
|
|
822
|
+
explain_data = []
|
|
823
|
+
|
|
824
|
+
# Only try to explain SELECT statements
|
|
825
|
+
sql_upper = sql.strip().upper()
|
|
826
|
+
if not sql_upper.startswith("SELECT"):
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
# Explain is currently only implemented for IBM i
|
|
830
|
+
if self.adapter.db_type != "ibmi":
|
|
831
|
+
return None
|
|
832
|
+
|
|
833
|
+
try:
|
|
834
|
+
# Use Visual Explain via QSYS2
|
|
835
|
+
explain_cursor = self.connection.cursor()
|
|
836
|
+
|
|
837
|
+
# First, try to use EXPLAIN
|
|
838
|
+
try:
|
|
839
|
+
# Set up explain tables
|
|
840
|
+
explain_cursor.execute("CALL QSYS2.OVERRIDE_QAQQINI(1, '', '')")
|
|
841
|
+
except Exception:
|
|
842
|
+
pass
|
|
843
|
+
|
|
844
|
+
# Try to get access plan info using SYSIBM.SQLSTATISTICS or explain
|
|
845
|
+
try:
|
|
846
|
+
# This queries index usage from the system catalog for tables in the query
|
|
847
|
+
# Extract table names from a simple SELECT (basic parsing)
|
|
848
|
+
tables = self._extract_tables_from_sql(sql)
|
|
849
|
+
if tables:
|
|
850
|
+
for table in tables[:5]: # Limit to 5 tables
|
|
851
|
+
try:
|
|
852
|
+
idx_cursor = self.connection.cursor()
|
|
853
|
+
idx_cursor.execute("""
|
|
854
|
+
SELECT INDEX_NAME, COLUMN_NAME, INDEX_TYPE, IS_UNIQUE
|
|
855
|
+
FROM QSYS2.SYSINDEXES I
|
|
856
|
+
JOIN QSYS2.SYSKEYS K ON I.INDEX_NAME = K.INDEX_NAME
|
|
857
|
+
AND I.INDEX_SCHEMA = K.INDEX_SCHEMA
|
|
858
|
+
WHERE I.TABLE_NAME = ?
|
|
859
|
+
ORDER BY I.INDEX_NAME, K.ORDINAL_POSITION
|
|
860
|
+
FETCH FIRST 20 ROWS ONLY
|
|
861
|
+
""", (table.upper(),))
|
|
862
|
+
indexes = idx_cursor.fetchall()
|
|
863
|
+
if indexes:
|
|
864
|
+
explain_data.append(f"\nIndexes on {table}:")
|
|
865
|
+
current_idx = None
|
|
866
|
+
for row in indexes:
|
|
867
|
+
idx_name, col_name, idx_type, is_unique = row
|
|
868
|
+
if idx_name != current_idx:
|
|
869
|
+
unique_str = "UNIQUE " if is_unique == 'Y' else ""
|
|
870
|
+
explain_data.append(f" {unique_str}{idx_name} ({idx_type})")
|
|
871
|
+
current_idx = idx_name
|
|
872
|
+
explain_data.append(f" - {col_name}")
|
|
873
|
+
idx_cursor.close()
|
|
874
|
+
except Exception:
|
|
875
|
+
pass
|
|
876
|
+
except Exception:
|
|
877
|
+
pass
|
|
878
|
+
|
|
879
|
+
explain_cursor.close()
|
|
880
|
+
except Exception:
|
|
881
|
+
pass
|
|
882
|
+
|
|
883
|
+
return "\n".join(explain_data) if explain_data else None
|
|
884
|
+
|
|
885
|
+
def _extract_tables_from_sql(self, sql):
|
|
886
|
+
"""Extract table names from SQL (basic parsing). Returns schema.table format."""
|
|
887
|
+
import re
|
|
888
|
+
tables = []
|
|
889
|
+
# Simple regex to find FROM and JOIN clauses
|
|
890
|
+
# This is a basic implementation - won't catch all cases
|
|
891
|
+
|
|
892
|
+
# Find tables after FROM (handles schema.table and library/table formats)
|
|
893
|
+
from_match = re.search(r'\bFROM\s+([A-Za-z0-9_.]+(?:/[A-Za-z0-9_]+)?)', sql, re.IGNORECASE)
|
|
894
|
+
if from_match:
|
|
895
|
+
table = from_match.group(1)
|
|
896
|
+
# Convert library/table to schema.table format
|
|
897
|
+
if '/' in table:
|
|
898
|
+
parts = table.split('/')
|
|
899
|
+
table = f"{parts[0]}.{parts[1]}"
|
|
900
|
+
tables.append(table)
|
|
901
|
+
|
|
902
|
+
# Find tables after JOIN
|
|
903
|
+
join_matches = re.findall(r'\bJOIN\s+([A-Za-z0-9_.]+(?:/[A-Za-z0-9_]+)?)', sql, re.IGNORECASE)
|
|
904
|
+
for match in join_matches:
|
|
905
|
+
table = match
|
|
906
|
+
if '/' in table:
|
|
907
|
+
parts = table.split('/')
|
|
908
|
+
table = f"{parts[0]}.{parts[1]}"
|
|
909
|
+
tables.append(table)
|
|
910
|
+
|
|
911
|
+
return tables
|
|
912
|
+
|
|
913
|
+
def _calculate_column_width(self, col_name, col_info):
|
|
914
|
+
"""Calculate appropriate column width based on field metadata and font size."""
|
|
915
|
+
scale = self.app.font_size / 10.0
|
|
916
|
+
# Base width on column name (approx 8 pixels per char at font size 10)
|
|
917
|
+
name_width = len(col_name) * 8 + 20
|
|
918
|
+
|
|
919
|
+
if not col_info:
|
|
920
|
+
return int(max(name_width, 100) * scale)
|
|
921
|
+
|
|
922
|
+
# Use adapter to get the best display size for this driver
|
|
923
|
+
char_count = self.adapter.get_column_display_size(col_info)
|
|
924
|
+
|
|
925
|
+
# Calculate data width (approx 8 pixels per character at font size 10)
|
|
926
|
+
if char_count <= 5:
|
|
927
|
+
data_width = 60
|
|
928
|
+
elif char_count <= 10:
|
|
929
|
+
data_width = 80
|
|
930
|
+
elif char_count <= 20:
|
|
931
|
+
data_width = 130
|
|
932
|
+
elif char_count <= 50:
|
|
933
|
+
data_width = 200
|
|
934
|
+
else:
|
|
935
|
+
data_width = min(char_count * 6, 300)
|
|
936
|
+
|
|
937
|
+
# Return the larger of name width or data width, scaled by font size
|
|
938
|
+
base_width = max(60, min(max(name_width, data_width), 350))
|
|
939
|
+
return int(base_width * scale)
|
|
940
|
+
|
|
941
|
+
def _get_column_anchor(self, col_info):
|
|
942
|
+
"""Determine text alignment based on column type."""
|
|
943
|
+
if not col_info:
|
|
944
|
+
return "w" # left align by default
|
|
945
|
+
|
|
946
|
+
type_code = col_info[1]
|
|
947
|
+
|
|
948
|
+
# Use adapter to check for numeric type
|
|
949
|
+
if self.adapter.is_numeric_type(type_code):
|
|
950
|
+
return "e" # right align numbers
|
|
951
|
+
|
|
952
|
+
# Also check type name string for common numeric types (fallback)
|
|
953
|
+
type_name = getattr(type_code, '__name__', '').lower() if type_code else ''
|
|
954
|
+
if type_name in ('int', 'float', 'decimal', 'numeric', 'integer',
|
|
955
|
+
'smallint', 'bigint', 'real', 'double'):
|
|
956
|
+
return "e"
|
|
957
|
+
|
|
958
|
+
return "w" # left align everything else
|
|
959
|
+
|
|
960
|
+
def _sort_by_column(self, col):
|
|
961
|
+
"""Sort results by the clicked column."""
|
|
962
|
+
if not self._all_rows or not self._columns:
|
|
963
|
+
return
|
|
964
|
+
|
|
965
|
+
# Toggle direction if same column, otherwise sort ascending
|
|
966
|
+
if self._sort_column == col:
|
|
967
|
+
self._sort_reverse = not self._sort_reverse
|
|
968
|
+
else:
|
|
969
|
+
self._sort_column = col
|
|
970
|
+
self._sort_reverse = False
|
|
971
|
+
|
|
972
|
+
# Get column index
|
|
973
|
+
try:
|
|
974
|
+
col_idx = self._columns.index(col)
|
|
975
|
+
except ValueError:
|
|
976
|
+
return
|
|
977
|
+
|
|
978
|
+
# Sort the data
|
|
979
|
+
def sort_key(row):
|
|
980
|
+
val = row[col_idx]
|
|
981
|
+
# Handle None values - sort them last
|
|
982
|
+
if val is None:
|
|
983
|
+
return (1, "")
|
|
984
|
+
# Try numeric comparison
|
|
985
|
+
if isinstance(val, (int, float)):
|
|
986
|
+
return (0, val)
|
|
987
|
+
try:
|
|
988
|
+
from decimal import Decimal
|
|
989
|
+
if isinstance(val, Decimal):
|
|
990
|
+
return (0, float(val))
|
|
991
|
+
except Exception:
|
|
992
|
+
pass
|
|
993
|
+
# String comparison
|
|
994
|
+
return (0, str(val).strip().lower())
|
|
995
|
+
|
|
996
|
+
self._all_rows.sort(key=sort_key, reverse=self._sort_reverse)
|
|
997
|
+
|
|
998
|
+
# Update column heading to show sort indicator
|
|
999
|
+
for c in self._columns:
|
|
1000
|
+
if c == col:
|
|
1001
|
+
indicator = " ▼" if self._sort_reverse else " ▲"
|
|
1002
|
+
self.results_tree.heading(c, text=c + indicator)
|
|
1003
|
+
else:
|
|
1004
|
+
self.results_tree.heading(c, text=c)
|
|
1005
|
+
|
|
1006
|
+
# Refresh display
|
|
1007
|
+
self._refresh_display()
|
|
1008
|
+
|
|
1009
|
+
def _display_results(self, columns, rows, column_info, stats_info):
|
|
1010
|
+
"""Display results in the treeview (called from main thread)."""
|
|
1011
|
+
# Store all results
|
|
1012
|
+
self._columns = columns
|
|
1013
|
+
self._all_rows = list(rows)
|
|
1014
|
+
self._column_info = column_info
|
|
1015
|
+
self._current_page = 0
|
|
1016
|
+
|
|
1017
|
+
# Setup columns in results tree
|
|
1018
|
+
self._sort_column = None
|
|
1019
|
+
self._sort_reverse = False
|
|
1020
|
+
self._results_base_widths = {} # Reset base widths for new query
|
|
1021
|
+
scale = self.app.font_size / 10.0
|
|
1022
|
+
self.results_tree["columns"] = columns
|
|
1023
|
+
for i, col in enumerate(columns):
|
|
1024
|
+
self.results_tree.heading(col, text=col, command=lambda c=col: self._sort_by_column(c))
|
|
1025
|
+
col_info = column_info[i] if i < len(column_info) else None
|
|
1026
|
+
width = self._calculate_column_width(col, col_info)
|
|
1027
|
+
# Store base width (unscaled) for later rescaling
|
|
1028
|
+
self._results_base_widths[col] = int(width / scale) if scale else width
|
|
1029
|
+
anchor = self._get_column_anchor(col_info)
|
|
1030
|
+
self.results_tree.column(col, width=width, minwidth=40, stretch=False, anchor=anchor)
|
|
1031
|
+
|
|
1032
|
+
# Display current page
|
|
1033
|
+
self._refresh_display()
|
|
1034
|
+
|
|
1035
|
+
# Update fields tab
|
|
1036
|
+
self._update_fields_tab(column_info)
|
|
1037
|
+
|
|
1038
|
+
# Update statistics tab
|
|
1039
|
+
self._update_statistics_tab(stats_info)
|
|
1040
|
+
|
|
1041
|
+
self.app.statusbar.config(text=f"{len(rows):,} row(s) returned from {self.conn_name}")
|
|
1042
|
+
|
|
1043
|
+
def _update_fields_tab(self, column_info):
|
|
1044
|
+
"""Update the Fields tab with column metadata."""
|
|
1045
|
+
self.fields_tree.delete(*self.fields_tree.get_children())
|
|
1046
|
+
|
|
1047
|
+
# Try to get table info for columns
|
|
1048
|
+
table_map = self._get_column_tables([desc[0] for desc in column_info])
|
|
1049
|
+
|
|
1050
|
+
# cursor.description format: (name, type_code, display_size, internal_size, precision, scale, null_ok)
|
|
1051
|
+
for desc in column_info:
|
|
1052
|
+
name = desc[0]
|
|
1053
|
+
type_code = desc[1]
|
|
1054
|
+
display_size = desc[2] if desc[2] else ""
|
|
1055
|
+
precision = desc[4] if desc[4] else ""
|
|
1056
|
+
scale = desc[5] if desc[5] else ""
|
|
1057
|
+
nullable = "Yes" if desc[6] else "No"
|
|
1058
|
+
|
|
1059
|
+
# Get table name for this column
|
|
1060
|
+
table_name = table_map.get(name.upper(), "")
|
|
1061
|
+
|
|
1062
|
+
# Try to get type name
|
|
1063
|
+
type_name = getattr(type_code, '__name__', str(type_code)) if type_code else "UNKNOWN"
|
|
1064
|
+
|
|
1065
|
+
self.fields_tree.insert("", tk.END, values=(
|
|
1066
|
+
table_name, name, type_name, display_size, precision, scale, nullable
|
|
1067
|
+
))
|
|
1068
|
+
|
|
1069
|
+
def _get_column_tables(self, column_names):
|
|
1070
|
+
"""Try to determine which table each column belongs to."""
|
|
1071
|
+
table_map = {}
|
|
1072
|
+
|
|
1073
|
+
if not self._base_sql:
|
|
1074
|
+
return table_map
|
|
1075
|
+
|
|
1076
|
+
# Extract table names from SQL
|
|
1077
|
+
tables = self._extract_tables_from_sql(self._base_sql)
|
|
1078
|
+
|
|
1079
|
+
if not tables:
|
|
1080
|
+
return table_map
|
|
1081
|
+
|
|
1082
|
+
# If only one table, all columns belong to it
|
|
1083
|
+
if len(tables) == 1:
|
|
1084
|
+
table_name = tables[0].split('.')[-1] # Get just table name, not schema
|
|
1085
|
+
for col in column_names:
|
|
1086
|
+
table_map[col.upper()] = table_name.upper()
|
|
1087
|
+
return table_map
|
|
1088
|
+
|
|
1089
|
+
# Multiple tables - try to look up each column in system catalog
|
|
1090
|
+
columns_query = self.adapter.get_columns_query(tables)
|
|
1091
|
+
if not columns_query:
|
|
1092
|
+
return table_map
|
|
1093
|
+
|
|
1094
|
+
try:
|
|
1095
|
+
cursor = self.connection.cursor()
|
|
1096
|
+
cursor.execute(columns_query)
|
|
1097
|
+
rows = cursor.fetchall()
|
|
1098
|
+
cursor.close()
|
|
1099
|
+
|
|
1100
|
+
# Build lookup of column -> table from results
|
|
1101
|
+
col_to_table = {}
|
|
1102
|
+
for row in rows:
|
|
1103
|
+
# Row format: (schema, table, column, type, length, scale)
|
|
1104
|
+
table_name = row[1]
|
|
1105
|
+
col_name = row[2]
|
|
1106
|
+
col_to_table[col_name.upper()] = table_name.upper()
|
|
1107
|
+
|
|
1108
|
+
# Map requested columns
|
|
1109
|
+
for col in column_names:
|
|
1110
|
+
col_upper = col.upper()
|
|
1111
|
+
if col_upper in col_to_table:
|
|
1112
|
+
table_map[col_upper] = col_to_table[col_upper]
|
|
1113
|
+
except Exception:
|
|
1114
|
+
pass
|
|
1115
|
+
|
|
1116
|
+
return table_map
|
|
1117
|
+
|
|
1118
|
+
def _update_statistics_tab(self, stats_info):
|
|
1119
|
+
"""Update the Statistics tab with query execution info."""
|
|
1120
|
+
self.stats_text.config(state=tk.NORMAL)
|
|
1121
|
+
self.stats_text.delete("1.0", tk.END)
|
|
1122
|
+
|
|
1123
|
+
lines = []
|
|
1124
|
+
lines.append("=" * 60)
|
|
1125
|
+
lines.append("QUERY EXECUTION STATISTICS")
|
|
1126
|
+
lines.append("=" * 60)
|
|
1127
|
+
lines.append("")
|
|
1128
|
+
|
|
1129
|
+
# Timing info
|
|
1130
|
+
lines.append("TIMING:")
|
|
1131
|
+
lines.append(f" Execution time: {stats_info.get('exec_time', 0):.3f} seconds")
|
|
1132
|
+
if stats_info.get('fetch_time'):
|
|
1133
|
+
lines.append(f" Fetch time: {stats_info.get('fetch_time', 0):.3f} seconds")
|
|
1134
|
+
total_time = stats_info.get('exec_time', 0) + stats_info.get('fetch_time', 0)
|
|
1135
|
+
lines.append(f" Total time: {total_time:.3f} seconds")
|
|
1136
|
+
lines.append("")
|
|
1137
|
+
|
|
1138
|
+
# Result info
|
|
1139
|
+
lines.append("RESULTS:")
|
|
1140
|
+
row_count = stats_info.get('row_count', 0)
|
|
1141
|
+
total_rows = stats_info.get('total_rows', row_count)
|
|
1142
|
+
if stats_info.get('limited') and total_rows > row_count:
|
|
1143
|
+
lines.append(f" Rows fetched: {row_count:,}")
|
|
1144
|
+
lines.append(f" Total rows: {total_rows:,}")
|
|
1145
|
+
else:
|
|
1146
|
+
lines.append(f" Rows returned: {row_count:,}")
|
|
1147
|
+
lines.append(f" Columns: {stats_info.get('column_count', 0)}")
|
|
1148
|
+
lines.append("")
|
|
1149
|
+
|
|
1150
|
+
# Query
|
|
1151
|
+
lines.append("QUERY:")
|
|
1152
|
+
lines.append("-" * 40)
|
|
1153
|
+
sql = stats_info.get('sql', '')
|
|
1154
|
+
lines.append(sql[:500] + ('...' if len(sql) > 500 else ''))
|
|
1155
|
+
lines.append("")
|
|
1156
|
+
|
|
1157
|
+
# Explain/Index info
|
|
1158
|
+
if stats_info.get('explain_info'):
|
|
1159
|
+
lines.append("INDEX INFORMATION:")
|
|
1160
|
+
lines.append("-" * 40)
|
|
1161
|
+
lines.append(stats_info['explain_info'])
|
|
1162
|
+
lines.append("")
|
|
1163
|
+
|
|
1164
|
+
self.stats_text.insert("1.0", "\n".join(lines))
|
|
1165
|
+
self.stats_text.config(state=tk.DISABLED)
|
|
1166
|
+
|
|
1167
|
+
def _query_done(self, message, stats_info=None):
|
|
1168
|
+
"""Called when non-SELECT query completes."""
|
|
1169
|
+
self._all_rows = []
|
|
1170
|
+
self._columns = []
|
|
1171
|
+
self._update_pagination_ui(0, 0, 0)
|
|
1172
|
+
|
|
1173
|
+
if stats_info:
|
|
1174
|
+
self._update_statistics_tab(stats_info)
|
|
1175
|
+
|
|
1176
|
+
self.app.statusbar.config(text=f"{message} on {self.conn_name}")
|
|
1177
|
+
|
|
1178
|
+
def _query_error(self, error, stats_info=None):
|
|
1179
|
+
"""Called when query fails."""
|
|
1180
|
+
if stats_info:
|
|
1181
|
+
stats_info['error'] = error
|
|
1182
|
+
self.stats_text.config(state=tk.NORMAL)
|
|
1183
|
+
self.stats_text.delete("1.0", tk.END)
|
|
1184
|
+
self.stats_text.insert("1.0", f"ERROR:\n{error}\n\nQuery:\n{stats_info.get('sql', '')}")
|
|
1185
|
+
self.stats_text.config(state=tk.DISABLED)
|
|
1186
|
+
|
|
1187
|
+
messagebox.showerror("SQL Error", error)
|
|
1188
|
+
|
|
1189
|
+
def _save_query(self):
|
|
1190
|
+
sql = self.sql_text.get("1.0", tk.END).strip()
|
|
1191
|
+
if not sql:
|
|
1192
|
+
messagebox.showwarning("Empty", "No SQL to save.")
|
|
1193
|
+
return
|
|
1194
|
+
|
|
1195
|
+
name = simpledialog.askstring("Save Query", "Enter a name for this query:")
|
|
1196
|
+
if name:
|
|
1197
|
+
self.app.db.save_query(name, sql, self.conn_name, self.adapter.db_type)
|
|
1198
|
+
messagebox.showinfo("Saved", f"Query '{name}' saved for {self.adapter.db_type}.")
|
|
1199
|
+
|
|
1200
|
+
def _load_query(self):
|
|
1201
|
+
db_type = self.adapter.db_type
|
|
1202
|
+
all_queries = self.app.db.get_saved_queries(db_type)
|
|
1203
|
+
|
|
1204
|
+
# Query management dialog
|
|
1205
|
+
load_win = tk.Toplevel(self.frame)
|
|
1206
|
+
load_win.title(f"Manage Queries ({db_type})")
|
|
1207
|
+
load_win.geometry("450x450")
|
|
1208
|
+
load_win.transient(self.frame.winfo_toplevel())
|
|
1209
|
+
load_win.grab_set()
|
|
1210
|
+
load_win.bind("<Escape>", lambda e: load_win.destroy())
|
|
1211
|
+
|
|
1212
|
+
# Apply theme to dialog
|
|
1213
|
+
is_dark = self.app.dark_mode_var.get()
|
|
1214
|
+
if is_dark:
|
|
1215
|
+
load_win.configure(bg="#2b2b2b")
|
|
1216
|
+
|
|
1217
|
+
# Filter frame
|
|
1218
|
+
filter_frame = ttk.Frame(load_win)
|
|
1219
|
+
filter_frame.pack(fill=tk.X, padx=10, pady=(10, 5))
|
|
1220
|
+
ttk.Label(filter_frame, text="Filter:").pack(side=tk.LEFT)
|
|
1221
|
+
filter_var = tk.StringVar()
|
|
1222
|
+
filter_entry = ttk.Entry(filter_frame, textvariable=filter_var)
|
|
1223
|
+
filter_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 0))
|
|
1224
|
+
|
|
1225
|
+
# Header
|
|
1226
|
+
ttk.Label(load_win, text=f"Queries for {db_type}:", font=("TkDefaultFont", 9, "bold")).pack(anchor=tk.W, padx=10)
|
|
1227
|
+
|
|
1228
|
+
# Listbox with scrollbar
|
|
1229
|
+
list_frame = ttk.Frame(load_win)
|
|
1230
|
+
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
|
1231
|
+
|
|
1232
|
+
scrollbar = ttk.Scrollbar(list_frame)
|
|
1233
|
+
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
1234
|
+
|
|
1235
|
+
listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set)
|
|
1236
|
+
listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
1237
|
+
scrollbar.config(command=listbox.yview)
|
|
1238
|
+
|
|
1239
|
+
# Apply theme colors
|
|
1240
|
+
is_dark = self.app.dark_mode_var.get()
|
|
1241
|
+
if is_dark:
|
|
1242
|
+
listbox.configure(bg="#313335", fg="#a9b7c6",
|
|
1243
|
+
selectbackground="#214283", selectforeground="#a9b7c6")
|
|
1244
|
+
|
|
1245
|
+
# Track filtered queries
|
|
1246
|
+
filtered_queries = []
|
|
1247
|
+
|
|
1248
|
+
def refresh_list(filter_text=""):
|
|
1249
|
+
nonlocal filtered_queries
|
|
1250
|
+
listbox.delete(0, tk.END)
|
|
1251
|
+
filtered_queries = []
|
|
1252
|
+
for q in all_queries:
|
|
1253
|
+
name = q["name"]
|
|
1254
|
+
if filter_text.lower() in name.lower():
|
|
1255
|
+
suffix = "" if q.get("db_type") else " (any)"
|
|
1256
|
+
listbox.insert(tk.END, f"{name}{suffix}")
|
|
1257
|
+
filtered_queries.append(q)
|
|
1258
|
+
if not filtered_queries and not all_queries:
|
|
1259
|
+
listbox.insert(tk.END, "(no saved queries)")
|
|
1260
|
+
|
|
1261
|
+
def on_filter(*args):
|
|
1262
|
+
refresh_list(filter_var.get())
|
|
1263
|
+
|
|
1264
|
+
filter_var.trace("w", on_filter)
|
|
1265
|
+
refresh_list()
|
|
1266
|
+
|
|
1267
|
+
def get_selected():
|
|
1268
|
+
sel = listbox.curselection()
|
|
1269
|
+
if sel and filtered_queries:
|
|
1270
|
+
return filtered_queries[sel[0]]
|
|
1271
|
+
return None
|
|
1272
|
+
|
|
1273
|
+
def on_load():
|
|
1274
|
+
query = get_selected()
|
|
1275
|
+
if query:
|
|
1276
|
+
self.sql_text.delete("1.0", tk.END)
|
|
1277
|
+
self.sql_text.insert("1.0", query["sql"])
|
|
1278
|
+
load_win.destroy()
|
|
1279
|
+
|
|
1280
|
+
def on_delete():
|
|
1281
|
+
query = get_selected()
|
|
1282
|
+
if query:
|
|
1283
|
+
if messagebox.askyesno("Delete Query", f"Delete '{query['name']}'?", parent=load_win):
|
|
1284
|
+
self.app.db.delete_query(query["id"])
|
|
1285
|
+
all_queries.remove(query)
|
|
1286
|
+
refresh_list(filter_var.get())
|
|
1287
|
+
|
|
1288
|
+
def on_export():
|
|
1289
|
+
if not all_queries:
|
|
1290
|
+
messagebox.showinfo("No Queries", "No queries to export.", parent=load_win)
|
|
1291
|
+
return
|
|
1292
|
+
filepath = filedialog.asksaveasfilename(
|
|
1293
|
+
parent=load_win,
|
|
1294
|
+
title="Export Queries",
|
|
1295
|
+
defaultextension=".json",
|
|
1296
|
+
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
|
|
1297
|
+
initialfile=f"queries_{db_type}.json"
|
|
1298
|
+
)
|
|
1299
|
+
if filepath:
|
|
1300
|
+
import json
|
|
1301
|
+
export_data = [{"name": q["name"], "sql": q["sql"], "db_type": q.get("db_type") or db_type}
|
|
1302
|
+
for q in all_queries]
|
|
1303
|
+
with open(filepath, "w") as f:
|
|
1304
|
+
json.dump(export_data, f, indent=2)
|
|
1305
|
+
messagebox.showinfo("Exported", f"Exported {len(export_data)} queries.", parent=load_win)
|
|
1306
|
+
|
|
1307
|
+
def on_import():
|
|
1308
|
+
nonlocal all_queries
|
|
1309
|
+
filepath = filedialog.askopenfilename(
|
|
1310
|
+
parent=load_win,
|
|
1311
|
+
title="Import Queries",
|
|
1312
|
+
filetypes=[("JSON files", "*.json"), ("All files", "*.*")]
|
|
1313
|
+
)
|
|
1314
|
+
if filepath:
|
|
1315
|
+
import json
|
|
1316
|
+
try:
|
|
1317
|
+
with open(filepath, "r") as f:
|
|
1318
|
+
import_data = json.load(f)
|
|
1319
|
+
count = 0
|
|
1320
|
+
for q in import_data:
|
|
1321
|
+
name = q.get("name")
|
|
1322
|
+
sql = q.get("sql")
|
|
1323
|
+
q_db_type = q.get("db_type", db_type)
|
|
1324
|
+
if name and sql:
|
|
1325
|
+
# Only import queries matching current db_type or untyped
|
|
1326
|
+
if q_db_type == db_type or q_db_type is None:
|
|
1327
|
+
self.app.db.save_query(name, sql, None, db_type)
|
|
1328
|
+
count += 1
|
|
1329
|
+
# Refresh query list
|
|
1330
|
+
all_queries = self.app.db.get_saved_queries(db_type)
|
|
1331
|
+
refresh_list(filter_var.get())
|
|
1332
|
+
messagebox.showinfo("Imported", f"Imported {count} queries.", parent=load_win)
|
|
1333
|
+
except Exception as e:
|
|
1334
|
+
messagebox.showerror("Import Error", str(e), parent=load_win)
|
|
1335
|
+
|
|
1336
|
+
listbox.bind("<Double-1>", lambda e: on_load())
|
|
1337
|
+
|
|
1338
|
+
# Button frame
|
|
1339
|
+
btn_frame = ttk.Frame(load_win)
|
|
1340
|
+
btn_frame.pack(fill=tk.X, padx=10, pady=10)
|
|
1341
|
+
|
|
1342
|
+
ttk.Button(btn_frame, text="Load", command=on_load).pack(side=tk.LEFT, padx=2)
|
|
1343
|
+
ttk.Button(btn_frame, text="Delete", command=on_delete).pack(side=tk.LEFT, padx=2)
|
|
1344
|
+
ttk.Separator(btn_frame, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)
|
|
1345
|
+
ttk.Button(btn_frame, text="Import", command=on_import).pack(side=tk.LEFT, padx=2)
|
|
1346
|
+
ttk.Button(btn_frame, text="Export", command=on_export).pack(side=tk.LEFT, padx=2)
|
|
1347
|
+
ttk.Button(btn_frame, text="Close", command=load_win.destroy).pack(side=tk.RIGHT, padx=2)
|
|
1348
|
+
|
|
1349
|
+
filter_entry.focus()
|
|
1350
|
+
|
|
1351
|
+
def _clear(self):
|
|
1352
|
+
self.sql_text.delete("1.0", tk.END)
|
|
1353
|
+
self._all_rows = []
|
|
1354
|
+
self._columns = []
|
|
1355
|
+
self._column_info = []
|
|
1356
|
+
self._current_page = 0
|
|
1357
|
+
self.results_tree.delete(*self.results_tree.get_children())
|
|
1358
|
+
self.fields_tree.delete(*self.fields_tree.get_children())
|
|
1359
|
+
self._update_pagination_ui(0, 0, 0)
|
|
1360
|
+
|
|
1361
|
+
self.stats_text.config(state=tk.NORMAL)
|
|
1362
|
+
self.stats_text.delete("1.0", tk.END)
|
|
1363
|
+
self.stats_text.insert("1.0", "Execute a query to see statistics.")
|
|
1364
|
+
self.stats_text.config(state=tk.DISABLED)
|
|
1365
|
+
|
|
1366
|
+
def _copy_to_clipboard(self):
|
|
1367
|
+
"""Copy current results to clipboard as tab-separated values."""
|
|
1368
|
+
if not self._all_rows:
|
|
1369
|
+
messagebox.showwarning("No Data", "No results to copy.")
|
|
1370
|
+
return
|
|
1371
|
+
|
|
1372
|
+
lines = []
|
|
1373
|
+
|
|
1374
|
+
# Add headers
|
|
1375
|
+
lines.append("\t".join(self._columns))
|
|
1376
|
+
|
|
1377
|
+
# Add data rows
|
|
1378
|
+
for row in self._all_rows:
|
|
1379
|
+
clean_row = []
|
|
1380
|
+
for value in row:
|
|
1381
|
+
if isinstance(value, str):
|
|
1382
|
+
clean_row.append(value.strip())
|
|
1383
|
+
elif value is None:
|
|
1384
|
+
clean_row.append("")
|
|
1385
|
+
else:
|
|
1386
|
+
clean_row.append(str(value))
|
|
1387
|
+
lines.append("\t".join(clean_row))
|
|
1388
|
+
|
|
1389
|
+
# Copy to clipboard
|
|
1390
|
+
text = "\n".join(lines)
|
|
1391
|
+
self.app.root.clipboard_clear()
|
|
1392
|
+
self.app.root.clipboard_append(text)
|
|
1393
|
+
self.app.statusbar.config(text=f"Copied {len(self._all_rows)} rows to clipboard")
|
|
1394
|
+
|
|
1395
|
+
def _save_to_excel(self):
|
|
1396
|
+
"""Save current results to Excel file."""
|
|
1397
|
+
if not self._all_rows:
|
|
1398
|
+
messagebox.showwarning("No Data", "No results to save.")
|
|
1399
|
+
return
|
|
1400
|
+
|
|
1401
|
+
file_path = filedialog.asksaveasfilename(
|
|
1402
|
+
defaultextension=".xlsx",
|
|
1403
|
+
filetypes=[("Excel files", "*.xlsx"), ("All files", "*.*")],
|
|
1404
|
+
initialfile="query_results.xlsx"
|
|
1405
|
+
)
|
|
1406
|
+
if not file_path:
|
|
1407
|
+
return
|
|
1408
|
+
|
|
1409
|
+
try:
|
|
1410
|
+
import openpyxl
|
|
1411
|
+
from openpyxl.utils import get_column_letter
|
|
1412
|
+
|
|
1413
|
+
wb = openpyxl.Workbook()
|
|
1414
|
+
ws = wb.active
|
|
1415
|
+
ws.title = "Results"
|
|
1416
|
+
|
|
1417
|
+
# Write headers
|
|
1418
|
+
for col_idx, col_name in enumerate(self._columns, 1):
|
|
1419
|
+
ws.cell(row=1, column=col_idx, value=col_name)
|
|
1420
|
+
|
|
1421
|
+
# Write data
|
|
1422
|
+
for row_idx, row in enumerate(self._all_rows, 2):
|
|
1423
|
+
for col_idx, value in enumerate(row, 1):
|
|
1424
|
+
# Strip strings, convert None to empty
|
|
1425
|
+
if isinstance(value, str):
|
|
1426
|
+
value = value.strip()
|
|
1427
|
+
elif value is None:
|
|
1428
|
+
value = ""
|
|
1429
|
+
ws.cell(row=row_idx, column=col_idx, value=value)
|
|
1430
|
+
|
|
1431
|
+
# Auto-adjust column widths
|
|
1432
|
+
for col_idx, col_name in enumerate(self._columns, 1):
|
|
1433
|
+
max_len = len(str(col_name))
|
|
1434
|
+
for row in ws.iter_rows(min_row=2, max_row=min(100, len(self._all_rows) + 1), min_col=col_idx, max_col=col_idx):
|
|
1435
|
+
for cell in row:
|
|
1436
|
+
if cell.value:
|
|
1437
|
+
max_len = max(max_len, len(str(cell.value)))
|
|
1438
|
+
ws.column_dimensions[get_column_letter(col_idx)].width = min(max_len + 2, 50)
|
|
1439
|
+
|
|
1440
|
+
wb.save(file_path)
|
|
1441
|
+
self.app.statusbar.config(text=f"Saved to {file_path}")
|
|
1442
|
+
messagebox.showinfo("Saved", f"Results saved to:\n{file_path}")
|
|
1443
|
+
except ImportError:
|
|
1444
|
+
messagebox.showerror("Error", "openpyxl not installed. Run: pip install openpyxl")
|
|
1445
|
+
except Exception as e:
|
|
1446
|
+
messagebox.showerror("Error", f"Could not save Excel file: {e}")
|
|
1447
|
+
|
|
1448
|
+
def _save_to_csv(self):
|
|
1449
|
+
"""Save current results to CSV file."""
|
|
1450
|
+
if not self._all_rows:
|
|
1451
|
+
messagebox.showwarning("No Data", "No results to save.")
|
|
1452
|
+
return
|
|
1453
|
+
|
|
1454
|
+
file_path = filedialog.asksaveasfilename(
|
|
1455
|
+
defaultextension=".csv",
|
|
1456
|
+
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
|
1457
|
+
initialfile="query_results.csv"
|
|
1458
|
+
)
|
|
1459
|
+
if not file_path:
|
|
1460
|
+
return
|
|
1461
|
+
|
|
1462
|
+
try:
|
|
1463
|
+
import csv
|
|
1464
|
+
|
|
1465
|
+
with open(file_path, 'w', newline='', encoding='utf-8') as f:
|
|
1466
|
+
writer = csv.writer(f)
|
|
1467
|
+
|
|
1468
|
+
# Write headers
|
|
1469
|
+
writer.writerow(self._columns)
|
|
1470
|
+
|
|
1471
|
+
# Write data
|
|
1472
|
+
for row in self._all_rows:
|
|
1473
|
+
clean_row = []
|
|
1474
|
+
for value in row:
|
|
1475
|
+
if isinstance(value, str):
|
|
1476
|
+
clean_row.append(value.strip())
|
|
1477
|
+
elif value is None:
|
|
1478
|
+
clean_row.append("")
|
|
1479
|
+
else:
|
|
1480
|
+
clean_row.append(value)
|
|
1481
|
+
writer.writerow(clean_row)
|
|
1482
|
+
|
|
1483
|
+
self.app.statusbar.config(text=f"Saved to {file_path}")
|
|
1484
|
+
messagebox.showinfo("Saved", f"Results saved to:\n{file_path}")
|
|
1485
|
+
except Exception as e:
|
|
1486
|
+
messagebox.showerror("Error", f"Could not save CSV file: {e}")
|
|
1487
|
+
|
|
1488
|
+
def _save_to_json(self):
|
|
1489
|
+
"""Save current results to JSON file."""
|
|
1490
|
+
if not self._all_rows:
|
|
1491
|
+
messagebox.showwarning("No Data", "No results to save.")
|
|
1492
|
+
return
|
|
1493
|
+
|
|
1494
|
+
file_path = filedialog.asksaveasfilename(
|
|
1495
|
+
defaultextension=".json",
|
|
1496
|
+
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
|
|
1497
|
+
initialfile="query_results.json"
|
|
1498
|
+
)
|
|
1499
|
+
if not file_path:
|
|
1500
|
+
return
|
|
1501
|
+
|
|
1502
|
+
try:
|
|
1503
|
+
import json
|
|
1504
|
+
from datetime import date, datetime
|
|
1505
|
+
from decimal import Decimal
|
|
1506
|
+
|
|
1507
|
+
def json_serializer(obj):
|
|
1508
|
+
if isinstance(obj, (datetime, date)):
|
|
1509
|
+
return obj.isoformat()
|
|
1510
|
+
elif isinstance(obj, Decimal):
|
|
1511
|
+
return float(obj)
|
|
1512
|
+
elif isinstance(obj, bytes):
|
|
1513
|
+
return obj.decode('utf-8', errors='replace')
|
|
1514
|
+
return str(obj)
|
|
1515
|
+
|
|
1516
|
+
# Convert rows to list of dicts
|
|
1517
|
+
data = []
|
|
1518
|
+
for row in self._all_rows:
|
|
1519
|
+
record = {}
|
|
1520
|
+
for i, col in enumerate(self._columns):
|
|
1521
|
+
value = row[i]
|
|
1522
|
+
if isinstance(value, str):
|
|
1523
|
+
value = value.strip()
|
|
1524
|
+
record[col] = value
|
|
1525
|
+
data.append(record)
|
|
1526
|
+
|
|
1527
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
1528
|
+
json.dump(data, f, indent=2, default=json_serializer)
|
|
1529
|
+
|
|
1530
|
+
self.app.statusbar.config(text=f"Saved to {file_path}")
|
|
1531
|
+
messagebox.showinfo("Saved", f"Results saved to:\n{file_path}")
|
|
1532
|
+
except Exception as e:
|
|
1533
|
+
messagebox.showerror("Error", f"Could not save JSON file: {e}")
|
|
1534
|
+
|
|
1535
|
+
def get_sql(self):
|
|
1536
|
+
"""Get current SQL text."""
|
|
1537
|
+
return self.sql_text.get("1.0", tk.END).strip()
|
|
1538
|
+
|
|
1539
|
+
def set_sql(self, sql):
|
|
1540
|
+
"""Set SQL text."""
|
|
1541
|
+
self.sql_text.delete("1.0", tk.END)
|
|
1542
|
+
self.sql_text.insert("1.0", sql)
|
|
1543
|
+
# Apply syntax highlighting
|
|
1544
|
+
self._highlight_sql()
|
|
1545
|
+
|
|
1546
|
+
def scale_columns(self, scale):
|
|
1547
|
+
"""Scale column widths based on font size ratio (scale = font_size / 10)."""
|
|
1548
|
+
# Scale fields_tree columns
|
|
1549
|
+
for col, base_w in self._fields_base_widths.items():
|
|
1550
|
+
try:
|
|
1551
|
+
self.fields_tree.column(col, width=int(base_w * scale))
|
|
1552
|
+
except Exception:
|
|
1553
|
+
pass
|
|
1554
|
+
|
|
1555
|
+
# Scale results_tree columns if they exist
|
|
1556
|
+
if hasattr(self, '_results_base_widths') and self._results_base_widths:
|
|
1557
|
+
for col, base_w in self._results_base_widths.items():
|
|
1558
|
+
try:
|
|
1559
|
+
self.results_tree.column(col, width=int(base_w * scale))
|
|
1560
|
+
except Exception:
|
|
1561
|
+
pass
|
|
1562
|
+
|
|
1563
|
+
def _on_results_double_click(self, event):
|
|
1564
|
+
"""Handle double-click on results treeview - autosize column or open record viewer."""
|
|
1565
|
+
region = self.results_tree.identify_region(event.x, event.y)
|
|
1566
|
+
|
|
1567
|
+
if region == "separator":
|
|
1568
|
+
# Double-click on header separator - autosize the column to the LEFT
|
|
1569
|
+
col_id = self.results_tree.identify_column(event.x)
|
|
1570
|
+
if col_id:
|
|
1571
|
+
# col_id is like "#1", "#2", etc. - get the column to the LEFT of separator
|
|
1572
|
+
col_num = int(col_id.replace("#", ""))
|
|
1573
|
+
if col_num > 0:
|
|
1574
|
+
columns = self.results_tree["columns"]
|
|
1575
|
+
if col_num <= len(columns):
|
|
1576
|
+
col_name = columns[col_num - 1]
|
|
1577
|
+
self._autosize_column(col_name)
|
|
1578
|
+
return "break" # Prevent other handlers
|
|
1579
|
+
elif region == "cell":
|
|
1580
|
+
# Double-click on data cell - open record viewer
|
|
1581
|
+
self._open_record_viewer(event)
|
|
1582
|
+
|
|
1583
|
+
def _autosize_column(self, col_name):
|
|
1584
|
+
"""Auto-size a column to fit the maximum content width."""
|
|
1585
|
+
if not self._all_rows or not self._columns:
|
|
1586
|
+
return
|
|
1587
|
+
|
|
1588
|
+
try:
|
|
1589
|
+
col_index = list(self._columns).index(col_name)
|
|
1590
|
+
except ValueError:
|
|
1591
|
+
return
|
|
1592
|
+
|
|
1593
|
+
# Calculate max width needed
|
|
1594
|
+
# Start with header width
|
|
1595
|
+
import tkinter.font as tkfont
|
|
1596
|
+
font = tkfont.nametofont("TkDefaultFont")
|
|
1597
|
+
max_width = font.measure(str(col_name)) + 20 # Add padding for header
|
|
1598
|
+
|
|
1599
|
+
# Check all rows (use displayed rows for performance with large datasets)
|
|
1600
|
+
rows_to_check = self._all_rows[:1000] # Limit to first 1000 rows for performance
|
|
1601
|
+
for row in rows_to_check:
|
|
1602
|
+
if col_index < len(row):
|
|
1603
|
+
value = row[col_index]
|
|
1604
|
+
if value is not None:
|
|
1605
|
+
text_width = font.measure(str(value)) + 20 # Add padding
|
|
1606
|
+
max_width = max(max_width, text_width)
|
|
1607
|
+
|
|
1608
|
+
# Clamp to reasonable bounds
|
|
1609
|
+
max_width = max(50, min(max_width, 600)) # Min 50, max 600 pixels
|
|
1610
|
+
|
|
1611
|
+
self.results_tree.column(col_name, width=max_width)
|
|
1612
|
+
|
|
1613
|
+
def _open_record_viewer(self, event=None):
|
|
1614
|
+
"""Open record viewer dialog for the selected row."""
|
|
1615
|
+
if not self._all_rows or not self._columns:
|
|
1616
|
+
return
|
|
1617
|
+
|
|
1618
|
+
# Get selected item
|
|
1619
|
+
selection = self.results_tree.selection()
|
|
1620
|
+
if not selection:
|
|
1621
|
+
return
|
|
1622
|
+
|
|
1623
|
+
# Calculate row index from position in treeview + page offset
|
|
1624
|
+
selected_item = selection[0]
|
|
1625
|
+
all_items = self.results_tree.get_children()
|
|
1626
|
+
try:
|
|
1627
|
+
row_in_page = list(all_items).index(selected_item)
|
|
1628
|
+
row_index = self._current_page * self._rows_per_page + row_in_page
|
|
1629
|
+
except ValueError:
|
|
1630
|
+
row_index = 0
|
|
1631
|
+
|
|
1632
|
+
# Open the dialog with callback to sync selection
|
|
1633
|
+
RecordViewerDialog(
|
|
1634
|
+
self.frame.winfo_toplevel(),
|
|
1635
|
+
self._columns,
|
|
1636
|
+
self._all_rows,
|
|
1637
|
+
row_index,
|
|
1638
|
+
self.app,
|
|
1639
|
+
on_navigate=self._sync_results_selection
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
def _sync_results_selection(self, row_index):
|
|
1643
|
+
"""Sync the results tree selection with the record viewer."""
|
|
1644
|
+
# Only update selection if row is on current page
|
|
1645
|
+
page = row_index // self._rows_per_page
|
|
1646
|
+
if page != self._current_page:
|
|
1647
|
+
return # Don't change pages, just skip
|
|
1648
|
+
|
|
1649
|
+
all_items = self.results_tree.get_children()
|
|
1650
|
+
row_in_page = row_index % self._rows_per_page
|
|
1651
|
+
if row_in_page < len(all_items):
|
|
1652
|
+
item = all_items[row_in_page]
|
|
1653
|
+
self.results_tree.selection_set(item)
|
|
1654
|
+
self.results_tree.see(item)
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
class RecordViewerDialog:
|
|
1658
|
+
"""Dialog to view a single record with navigation."""
|
|
1659
|
+
|
|
1660
|
+
def __init__(self, parent, columns, rows, initial_index, app, on_navigate=None):
|
|
1661
|
+
self.columns = columns
|
|
1662
|
+
self.rows = rows
|
|
1663
|
+
self.current_index = initial_index
|
|
1664
|
+
self.app = app
|
|
1665
|
+
self.on_navigate = on_navigate
|
|
1666
|
+
|
|
1667
|
+
self.top = tk.Toplevel(parent)
|
|
1668
|
+
self.top.title("Record Viewer")
|
|
1669
|
+
self.top.transient(parent)
|
|
1670
|
+
|
|
1671
|
+
# Size based on number of fields - larger for more fields
|
|
1672
|
+
num_fields = len(columns)
|
|
1673
|
+
width = 700
|
|
1674
|
+
height = min(max(400, num_fields * 28 + 100), 800)
|
|
1675
|
+
|
|
1676
|
+
# Size and position
|
|
1677
|
+
self.top.geometry(f"{width}x{height}")
|
|
1678
|
+
self.top.update_idletasks()
|
|
1679
|
+
x = parent.winfo_x() + (parent.winfo_width() - width) // 2
|
|
1680
|
+
y = parent.winfo_y() + (parent.winfo_height() - height) // 2
|
|
1681
|
+
self.top.geometry(f"+{x}+{y}")
|
|
1682
|
+
|
|
1683
|
+
# Apply theme
|
|
1684
|
+
self._apply_theme()
|
|
1685
|
+
|
|
1686
|
+
self._create_widgets()
|
|
1687
|
+
self._display_record()
|
|
1688
|
+
|
|
1689
|
+
# Key bindings
|
|
1690
|
+
self.top.bind("<Left>", lambda e: self._prev_record())
|
|
1691
|
+
self.top.bind("<Right>", lambda e: self._next_record())
|
|
1692
|
+
self.top.bind("<Home>", lambda e: self._first_record())
|
|
1693
|
+
self.top.bind("<End>", lambda e: self._last_record())
|
|
1694
|
+
self.top.bind("<Escape>", lambda e: self.top.destroy())
|
|
1695
|
+
|
|
1696
|
+
def _apply_theme(self):
|
|
1697
|
+
"""Apply dark/light theme."""
|
|
1698
|
+
is_dark = self.app.dark_mode_var.get()
|
|
1699
|
+
if is_dark:
|
|
1700
|
+
self.bg = "#2b2b2b"
|
|
1701
|
+
self.fg = "#a9b7c6"
|
|
1702
|
+
self.text_bg = "#313335"
|
|
1703
|
+
self.select_bg = "#214283"
|
|
1704
|
+
else:
|
|
1705
|
+
self.bg = "#f0f0f0"
|
|
1706
|
+
self.fg = "#000000"
|
|
1707
|
+
self.text_bg = "#ffffff"
|
|
1708
|
+
self.select_bg = "#0078d4"
|
|
1709
|
+
|
|
1710
|
+
self.top.configure(bg=self.bg)
|
|
1711
|
+
|
|
1712
|
+
def _create_widgets(self):
|
|
1713
|
+
"""Create dialog widgets."""
|
|
1714
|
+
# Navigation frame at top
|
|
1715
|
+
nav_frame = ttk.Frame(self.top)
|
|
1716
|
+
nav_frame.pack(fill=tk.X, padx=10, pady=5)
|
|
1717
|
+
|
|
1718
|
+
self.prev_btn = ttk.Button(nav_frame, text="< Prev", command=self._prev_record)
|
|
1719
|
+
self.prev_btn.pack(side=tk.LEFT)
|
|
1720
|
+
|
|
1721
|
+
self.next_btn = ttk.Button(nav_frame, text="Next >", command=self._next_record)
|
|
1722
|
+
self.next_btn.pack(side=tk.LEFT, padx=5)
|
|
1723
|
+
|
|
1724
|
+
ttk.Button(nav_frame, text="|<", width=3, command=self._first_record).pack(side=tk.LEFT)
|
|
1725
|
+
ttk.Button(nav_frame, text=">|", width=3, command=self._last_record).pack(side=tk.LEFT, padx=5)
|
|
1726
|
+
|
|
1727
|
+
self.position_label = ttk.Label(nav_frame, text="")
|
|
1728
|
+
self.position_label.pack(side=tk.RIGHT)
|
|
1729
|
+
|
|
1730
|
+
# Content frame with Text widget and scrollbars
|
|
1731
|
+
content_frame = ttk.Frame(self.top)
|
|
1732
|
+
content_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
|
1733
|
+
|
|
1734
|
+
font_size = self.app.font_size
|
|
1735
|
+
self.max_name_len = max(len(col) for col in self.columns) if self.columns else 10
|
|
1736
|
+
|
|
1737
|
+
# Text widget with scrollbars
|
|
1738
|
+
y_scroll = ttk.Scrollbar(content_frame, orient=tk.VERTICAL)
|
|
1739
|
+
x_scroll = ttk.Scrollbar(content_frame, orient=tk.HORIZONTAL)
|
|
1740
|
+
|
|
1741
|
+
self.content_text = tk.Text(content_frame, wrap=tk.NONE,
|
|
1742
|
+
font=("Courier", font_size),
|
|
1743
|
+
bg=self.text_bg, fg=self.fg,
|
|
1744
|
+
relief="sunken", borderwidth=1,
|
|
1745
|
+
cursor="arrow",
|
|
1746
|
+
yscrollcommand=y_scroll.set,
|
|
1747
|
+
xscrollcommand=x_scroll.set)
|
|
1748
|
+
|
|
1749
|
+
y_scroll.config(command=self.content_text.yview)
|
|
1750
|
+
x_scroll.config(command=self.content_text.xview)
|
|
1751
|
+
|
|
1752
|
+
# Grid layout for text + scrollbars
|
|
1753
|
+
self.content_text.grid(row=0, column=0, sticky="nsew")
|
|
1754
|
+
y_scroll.grid(row=0, column=1, sticky="ns")
|
|
1755
|
+
x_scroll.grid(row=1, column=0, sticky="ew")
|
|
1756
|
+
|
|
1757
|
+
content_frame.grid_rowconfigure(0, weight=1)
|
|
1758
|
+
content_frame.grid_columnconfigure(0, weight=1)
|
|
1759
|
+
|
|
1760
|
+
# Set tab stops for consistent alignment
|
|
1761
|
+
tab_width = (self.max_name_len + 2) * font_size # pixels
|
|
1762
|
+
self.content_text.configure(tabs=(tab_width,))
|
|
1763
|
+
|
|
1764
|
+
# Close button at bottom
|
|
1765
|
+
btn_frame = ttk.Frame(self.top)
|
|
1766
|
+
btn_frame.pack(fill=tk.X, padx=10, pady=5)
|
|
1767
|
+
ttk.Button(btn_frame, text="Close", command=self.top.destroy).pack(side=tk.RIGHT)
|
|
1768
|
+
|
|
1769
|
+
def _display_record(self):
|
|
1770
|
+
"""Display the current record in Text widget."""
|
|
1771
|
+
if not self.rows:
|
|
1772
|
+
return
|
|
1773
|
+
|
|
1774
|
+
row = self.rows[self.current_index]
|
|
1775
|
+
|
|
1776
|
+
# Build content and update Text widget
|
|
1777
|
+
self.content_text.configure(state="normal")
|
|
1778
|
+
self.content_text.delete("1.0", tk.END)
|
|
1779
|
+
|
|
1780
|
+
for i, col in enumerate(self.columns):
|
|
1781
|
+
value = row[i] if i < len(row) else ""
|
|
1782
|
+
if value is None:
|
|
1783
|
+
value_str = "<NULL>"
|
|
1784
|
+
else:
|
|
1785
|
+
value_str = str(value)
|
|
1786
|
+
|
|
1787
|
+
# Field name with tab for alignment
|
|
1788
|
+
self.content_text.insert(tk.END, f"{col}:\t{value_str}\n")
|
|
1789
|
+
|
|
1790
|
+
self.content_text.configure(state="disabled")
|
|
1791
|
+
|
|
1792
|
+
# Update position label
|
|
1793
|
+
self.position_label.config(text=f"Record {self.current_index + 1} of {len(self.rows)}")
|
|
1794
|
+
|
|
1795
|
+
# Update button states
|
|
1796
|
+
self.prev_btn.config(state=tk.NORMAL if self.current_index > 0 else tk.DISABLED)
|
|
1797
|
+
self.next_btn.config(state=tk.NORMAL if self.current_index < len(self.rows) - 1 else tk.DISABLED)
|
|
1798
|
+
|
|
1799
|
+
# Scroll to top
|
|
1800
|
+
self.content_text.yview_moveto(0)
|
|
1801
|
+
self.content_text.xview_moveto(0)
|
|
1802
|
+
|
|
1803
|
+
# Notify callback to sync results selection
|
|
1804
|
+
if self.on_navigate:
|
|
1805
|
+
self.on_navigate(self.current_index)
|
|
1806
|
+
|
|
1807
|
+
def _prev_record(self):
|
|
1808
|
+
"""Go to previous record."""
|
|
1809
|
+
if self.current_index > 0:
|
|
1810
|
+
self.current_index -= 1
|
|
1811
|
+
self._display_record()
|
|
1812
|
+
|
|
1813
|
+
def _next_record(self):
|
|
1814
|
+
"""Go to next record."""
|
|
1815
|
+
if self.current_index < len(self.rows) - 1:
|
|
1816
|
+
self.current_index += 1
|
|
1817
|
+
self._display_record()
|
|
1818
|
+
|
|
1819
|
+
def _first_record(self):
|
|
1820
|
+
"""Go to first record."""
|
|
1821
|
+
self.current_index = 0
|
|
1822
|
+
self._display_record()
|
|
1823
|
+
|
|
1824
|
+
def _last_record(self):
|
|
1825
|
+
"""Go to last record."""
|
|
1826
|
+
self.current_index = len(self.rows) - 1
|
|
1827
|
+
self._display_record()
|