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.
@@ -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()