sqlshell 0.1.8__py3-none-any.whl → 0.2.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.

Potentially problematic release.


This version of sqlshell might be problematic. Click here for more details.

sqlshell/editor.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from PyQt6.QtWidgets import QPlainTextEdit, QWidget, QCompleter
2
- from PyQt6.QtCore import Qt, QSize, QRect, QStringListModel
2
+ from PyQt6.QtCore import Qt, QSize, QRect, QStringListModel, QTimer
3
3
  from PyQt6.QtGui import QFont, QColor, QTextCursor, QPainter, QBrush
4
+ import re
4
5
 
5
6
  class LineNumberArea(QWidget):
6
7
  def __init__(self, editor):
@@ -36,31 +37,97 @@ class SQLEditor(QPlainTextEdit):
36
37
  # Set placeholder text
37
38
  self.setPlaceholderText("Enter your SQL query here...")
38
39
 
39
- # Initialize completer
40
- self.completer = None
40
+ # Set modern selection color
41
+ self.selection_color = QColor("#3498DB")
42
+ self.selection_color.setAlpha(50) # Make it semi-transparent
41
43
 
42
- # SQL Keywords for autocomplete
43
- self.sql_keywords = [
44
- "SELECT", "FROM", "WHERE", "AND", "OR", "INNER", "OUTER", "LEFT", "RIGHT", "JOIN",
45
- "ON", "GROUP", "BY", "HAVING", "ORDER", "LIMIT", "OFFSET", "UNION", "EXCEPT", "INTERSECT",
46
- "CREATE", "TABLE", "INDEX", "VIEW", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE",
47
- "TRUNCATE", "ALTER", "ADD", "DROP", "COLUMN", "CONSTRAINT", "PRIMARY", "KEY", "FOREIGN", "REFERENCES",
48
- "UNIQUE", "NOT", "NULL", "IS", "DISTINCT", "CASE", "WHEN", "THEN", "ELSE", "END",
49
- "AS", "WITH", "BETWEEN", "LIKE", "IN", "EXISTS", "ALL", "ANY", "SOME", "DESC", "ASC",
50
- "AVG", "COUNT", "SUM", "MAX", "MIN", "COALESCE", "CAST", "CONVERT"
44
+ # SQL keywords for syntax highlighting and autocompletion
45
+ self.sql_keywords = {
46
+ 'basic': [
47
+ "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP",
48
+ "ALTER", "TABLE", "VIEW", "INDEX", "TRIGGER", "PROCEDURE", "FUNCTION",
49
+ "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "IS NULL", "IS NOT NULL",
50
+ "ORDER BY", "GROUP BY", "HAVING", "LIMIT", "OFFSET", "TOP", "DISTINCT",
51
+ "ON", "SET", "VALUES", "INTO", "DEFAULT", "PRIMARY KEY", "FOREIGN KEY",
52
+ "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "FULL JOIN",
53
+ "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "BEGIN", "END", "COMMIT",
54
+ "ROLLBACK"
55
+ ],
56
+ 'aggregation': [
57
+ "SUM", "AVG", "COUNT", "MIN", "MAX", "STDDEV", "VARIANCE", "FIRST",
58
+ "LAST", "GROUP_CONCAT"
59
+ ],
60
+ 'join': [
61
+ "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", "CROSS JOIN",
62
+ "JOIN ... ON", "JOIN ... USING", "NATURAL JOIN"
63
+ ],
64
+ 'functions': [
65
+ "SUBSTR", "SUBSTRING", "UPPER", "LOWER", "TRIM", "LTRIM", "RTRIM",
66
+ "LENGTH", "CONCAT", "REPLACE", "INSTR", "CAST", "CONVERT", "COALESCE",
67
+ "NULLIF", "NVL", "IFNULL", "DECODE", "ROUND", "TRUNC", "FLOOR", "CEILING",
68
+ "ABS", "MOD", "DATE", "TIME", "DATETIME", "TIMESTAMP", "EXTRACT", "DATEADD",
69
+ "DATEDIFF", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP"
70
+ ],
71
+ 'table_ops': [
72
+ "INSERT INTO", "UPDATE", "DELETE FROM", "CREATE TABLE", "DROP TABLE",
73
+ "ALTER TABLE", "ADD COLUMN", "DROP COLUMN", "MODIFY COLUMN", "RENAME TO",
74
+ "TRUNCATE TABLE", "VACUUM"
75
+ ],
76
+ 'types': [
77
+ "INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT", "NUMERIC",
78
+ "DECIMAL", "FLOAT", "REAL", "DOUBLE", "BOOLEAN", "CHAR",
79
+ "VARCHAR", "TEXT", "DATE", "TIME", "TIMESTAMP", "INTERVAL",
80
+ "UUID", "JSON", "JSONB", "ARRAY", "BLOB"
81
+ ],
82
+ 'window': [
83
+ "OVER (", "PARTITION BY", "ORDER BY", "ROWS BETWEEN", "RANGE BETWEEN",
84
+ "UNBOUNDED PRECEDING", "CURRENT ROW", "UNBOUNDED FOLLOWING",
85
+ "ROW_NUMBER()", "RANK()", "DENSE_RANK()", "LEAD(", "LAG("
86
+ ],
87
+ 'other': [
88
+ "WITH", "UNION", "UNION ALL", "INTERSECT", "EXCEPT", "DISTINCT",
89
+ "ALL", "ANY", "SOME", "RECURSIVE", "GROUPING SETS", "CUBE", "ROLLUP"
90
+ ]
91
+ }
92
+
93
+ # Flattened list of all SQL keywords
94
+ self.all_sql_keywords = []
95
+ for category in self.sql_keywords.values():
96
+ self.all_sql_keywords.extend(category)
97
+
98
+ # Common SQL patterns with placeholders
99
+ self.sql_patterns = [
100
+ "SELECT * FROM $table WHERE $column = $value",
101
+ "SELECT $columns FROM $table GROUP BY $column HAVING $condition",
102
+ "SELECT $columns FROM $table ORDER BY $column $direction LIMIT $limit",
103
+ "SELECT $table1.$column1, $table2.$column2 FROM $table1 JOIN $table2 ON $table1.$column = $table2.$column",
104
+ "INSERT INTO $table ($columns) VALUES ($values)",
105
+ "UPDATE $table SET $column = $value WHERE $condition",
106
+ "DELETE FROM $table WHERE $condition",
107
+ "WITH $cte AS (SELECT * FROM $table) SELECT * FROM $cte WHERE $condition"
51
108
  ]
52
109
 
53
- # Initialize with SQL keywords
54
- self.set_completer(QCompleter(self.sql_keywords))
110
+ # Initialize completer with SQL keywords
111
+ self.completer = None
112
+ self.set_completer(QCompleter(self.all_sql_keywords))
55
113
 
56
- # Set modern selection color
57
- self.selection_color = QColor("#3498DB")
58
- self.selection_color.setAlpha(50) # Make it semi-transparent
114
+ # Track last key press for better completion behavior
115
+ self.last_key_was_tab = False
116
+
117
+ # Tables and columns cache for context-aware completion
118
+ self.tables_cache = {}
119
+ self.last_update_time = 0
120
+
121
+ # Enable drag and drop
122
+ self.setAcceptDrops(True)
59
123
 
60
124
  def set_completer(self, completer):
61
125
  """Set the completer for the editor"""
62
126
  if self.completer:
63
- self.completer.disconnect(self)
127
+ try:
128
+ self.completer.disconnect(self)
129
+ except Exception:
130
+ pass # Ignore errors when disconnecting
64
131
 
65
132
  self.completer = completer
66
133
 
@@ -72,48 +139,352 @@ class SQLEditor(QPlainTextEdit):
72
139
  self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
73
140
  self.completer.activated.connect(self.insert_completion)
74
141
 
75
- def update_completer_model(self, words):
76
- """Update the completer model with new words"""
142
+ def update_completer_model(self, words_or_model):
143
+ """Update the completer model with new words or a new model
144
+
145
+ Args:
146
+ words_or_model: Either a list of words or a QStringListModel
147
+ """
77
148
  if not self.completer:
149
+ # Create a completer if none exists
150
+ self.set_completer(QCompleter(self.all_sql_keywords))
151
+ if not self.completer:
152
+ return
153
+
154
+ # If a model is passed directly, use it
155
+ if isinstance(words_or_model, QStringListModel):
156
+ try:
157
+ # Update our tables and columns cache for context-aware completion
158
+ words = words_or_model.stringList()
159
+ self._update_tables_cache(words)
160
+ self.completer.setModel(words_or_model)
161
+ except Exception as e:
162
+ # If there's an error, fall back to just SQL keywords
163
+ model = QStringListModel()
164
+ model.setStringList(self.all_sql_keywords)
165
+ self.completer.setModel(model)
166
+ print(f"Error updating completer model: {e}")
167
+
78
168
  return
169
+
170
+ try:
171
+ # Update tables cache
172
+ self._update_tables_cache(words_or_model)
173
+
174
+ # Otherwise, combine SQL keywords with table/column names and create a new model
175
+ # Use set operations for efficiency
176
+ words_set = set(words_or_model) # Remove duplicates
177
+ sql_keywords_set = set(self.all_sql_keywords)
178
+ all_words = list(sql_keywords_set.union(words_set))
179
+
180
+ # Sort the combined words for better autocomplete experience
181
+ all_words.sort(key=lambda x: (not x.isupper(), x)) # Prioritize SQL keywords (all uppercase)
182
+
183
+ # Create an optimized model with all words
184
+ model = QStringListModel()
185
+ model.setStringList(all_words)
79
186
 
80
- # Combine SQL keywords with table/column names
81
- all_words = self.sql_keywords + words
187
+ # Set the model to the completer
188
+ self.completer.setModel(model)
189
+ except Exception as e:
190
+ # If there's an error, fall back to just SQL keywords
191
+ model = QStringListModel()
192
+ model.setStringList(self.all_sql_keywords)
193
+ self.completer.setModel(model)
194
+ print(f"Error updating completer with words: {e}")
82
195
 
83
- # Create a model with all words
84
- model = QStringListModel()
85
- model.setStringList(all_words)
196
+ def _update_tables_cache(self, words):
197
+ """Update internal tables and columns cache from word list"""
198
+ self.tables_cache = {}
86
199
 
87
- # Set the model to the completer
88
- self.completer.setModel(model)
200
+ # Create a map of tables to columns
201
+ for word in words:
202
+ if '.' in word:
203
+ # This is a qualified column (table.column)
204
+ parts = word.split('.')
205
+ if len(parts) == 2:
206
+ table, column = parts
207
+ if table not in self.tables_cache:
208
+ self.tables_cache[table] = []
209
+ if column not in self.tables_cache[table]:
210
+ self.tables_cache[table].append(column)
211
+ else:
212
+ # Could be a table or a standalone column
213
+ # We'll assume tables as being words that don't have special characters
214
+ if not any(c in word for c in ',;()[]+-*/=<>%|&!?:'):
215
+ # Add as potential table
216
+ if word not in self.tables_cache:
217
+ self.tables_cache[word] = []
89
218
 
90
- def text_under_cursor(self):
91
- """Get the text under the cursor for completion"""
219
+ def get_word_under_cursor(self):
220
+ """Get the complete word under the cursor for completion, handling dot notation"""
92
221
  tc = self.textCursor()
93
- tc.select(QTextCursor.SelectionType.WordUnderCursor)
94
- return tc.selectedText()
222
+ current_position = tc.position()
223
+
224
+ # Get the current line of text
225
+ tc.select(QTextCursor.SelectionType.LineUnderCursor)
226
+ line_text = tc.selectedText()
227
+
228
+ # Calculate cursor position within the line
229
+ start_of_line_pos = current_position - tc.selectionStart()
230
+
231
+ # Identify word boundaries including dots
232
+ start_pos = start_of_line_pos
233
+ while start_pos > 0 and (line_text[start_pos-1].isalnum() or line_text[start_pos-1] in '_$.'):
234
+ start_pos -= 1
235
+
236
+ end_pos = start_of_line_pos
237
+ while end_pos < len(line_text) and (line_text[end_pos].isalnum() or line_text[end_pos] in '_$'):
238
+ end_pos += 1
239
+
240
+ if start_pos == end_pos:
241
+ return ""
242
+
243
+ word = line_text[start_pos:end_pos]
244
+ return word
245
+
246
+ def text_under_cursor(self):
247
+ """Get the text under cursor for standard completion behavior"""
248
+ # Get the complete word including table prefixes
249
+ word = self.get_word_under_cursor()
250
+
251
+ # For table.col completions, only return portion after the dot
252
+ if '.' in word and word.endswith('.'):
253
+ # For "table." return empty to trigger whole column list
254
+ return ""
255
+ elif '.' in word:
256
+ # For "table.co", return "co" for completion
257
+ return word.split('.')[-1]
258
+
259
+ # Otherwise return the whole word
260
+ return word
95
261
 
96
262
  def insert_completion(self, completion):
97
- """Insert the completion text"""
263
+ """Insert the completion text with enhanced context awareness"""
98
264
  if self.completer.widget() != self:
99
265
  return
100
266
 
101
267
  tc = self.textCursor()
102
- extra = len(completion) - len(self.completer.completionPrefix())
103
- tc.movePosition(QTextCursor.MoveOperation.Left)
104
- tc.movePosition(QTextCursor.MoveOperation.EndOfWord)
105
- tc.insertText(completion[-extra:] + " ")
268
+
269
+ # Handle table.column completion differently
270
+ word = self.get_word_under_cursor()
271
+ if '.' in word and not word.endswith('.'):
272
+ # We're completing something like "table.co" to "table.column"
273
+ # Replace only the part after the last dot
274
+ prefix_parts = word.split('.')
275
+ prefix = '.'.join(prefix_parts[:-1]) + '.'
276
+ suffix = prefix_parts[-1]
277
+
278
+ # Get positions for text manipulation
279
+ cursor_pos = tc.position()
280
+ tc.setPosition(cursor_pos - len(suffix))
281
+ tc.setPosition(cursor_pos, QTextCursor.MoveMode.KeepAnchor)
282
+ tc.removeSelectedText()
283
+ tc.insertText(completion)
284
+ else:
285
+ # Standard completion behavior
286
+ current_prefix = self.completer.completionPrefix()
287
+
288
+ # When completing, replace the entire prefix with the completion
289
+ # This ensures exact matches are handled correctly
290
+ if current_prefix:
291
+ # Get positions for text manipulation
292
+ cursor_pos = tc.position()
293
+ tc.setPosition(cursor_pos - len(current_prefix))
294
+ tc.setPosition(cursor_pos, QTextCursor.MoveMode.KeepAnchor)
295
+ tc.removeSelectedText()
296
+
297
+ # Don't automatically add space when completing with Tab
298
+ # or when completion already ends with special characters
299
+ special_endings = ["(", ")", ",", ";", ".", "*"]
300
+ if any(completion.endswith(char) for char in special_endings):
301
+ tc.insertText(completion)
302
+ else:
303
+ # Add space for normal words, but only if activated with Enter/Return
304
+ # not when using Tab for completion
305
+ from_keyboard = self.sender() is None
306
+ add_space = from_keyboard or not self.last_key_was_tab
307
+ tc.insertText(completion + (" " if add_space else ""))
308
+
106
309
  self.setTextCursor(tc)
310
+
311
+ def get_context_at_cursor(self):
312
+ """Analyze the query to determine the current SQL context for smarter completions"""
313
+ # Get text up to cursor to analyze context
314
+ tc = self.textCursor()
315
+ position = tc.position()
316
+
317
+ # Select all text from start to cursor
318
+ doc = self.document()
319
+ tc_context = QTextCursor(doc)
320
+ tc_context.setPosition(0)
321
+ tc_context.setPosition(position, QTextCursor.MoveMode.KeepAnchor)
322
+ text_before_cursor = tc_context.selectedText().upper()
323
+
324
+ # Get the current line
325
+ tc.select(QTextCursor.SelectionType.LineUnderCursor)
326
+ current_line = tc.selectedText().strip().upper()
327
+
328
+ # Extract the last few keywords to determine context
329
+ words = re.findall(r'\b[A-Z_]+\b', text_before_cursor)
330
+ last_keywords = words[-3:] if words else []
107
331
 
332
+ # Get the current word being typed (including table prefixes)
333
+ current_word = self.get_word_under_cursor()
334
+
335
+ # Check for specific contexts
336
+ context = {
337
+ 'type': 'unknown',
338
+ 'table_prefix': None,
339
+ 'after_from': False,
340
+ 'after_join': False,
341
+ 'after_select': False,
342
+ 'after_where': False,
343
+ 'after_group_by': False,
344
+ 'after_order_by': False
345
+ }
346
+
347
+ # Check for table.column context
348
+ if '.' in current_word:
349
+ parts = current_word.split('.')
350
+ if len(parts) == 2:
351
+ context['type'] = 'column'
352
+ context['table_prefix'] = parts[0]
353
+
354
+ # FROM/JOIN context - likely to be followed by table names
355
+ if any(kw in last_keywords for kw in ['FROM', 'JOIN']):
356
+ context['type'] = 'table'
357
+ context['after_from'] = 'FROM' in last_keywords
358
+ context['after_join'] = any(k.endswith('JOIN') for k in last_keywords)
359
+
360
+ # WHERE/AND/OR context - likely to be followed by columns or expressions
361
+ elif any(kw in last_keywords for kw in ['WHERE', 'AND', 'OR']):
362
+ context['type'] = 'column_or_expression'
363
+ context['after_where'] = True
364
+
365
+ # SELECT context - likely to be followed by columns
366
+ elif 'SELECT' in last_keywords:
367
+ context['type'] = 'column'
368
+ context['after_select'] = True
369
+
370
+ # GROUP BY context
371
+ elif 'GROUP' in last_keywords or any('GROUP BY' in ' '.join(last_keywords[-2:]) for i in range(len(last_keywords)-1)):
372
+ context['type'] = 'column'
373
+ context['after_group_by'] = True
374
+
375
+ # ORDER BY context
376
+ elif 'ORDER' in last_keywords or any('ORDER BY' in ' '.join(last_keywords[-2:]) for i in range(len(last_keywords)-1)):
377
+ context['type'] = 'column'
378
+ context['after_order_by'] = True
379
+
380
+ # Check for function context (inside parentheses)
381
+ if '(' in text_before_cursor and text_before_cursor.count('(') > text_before_cursor.count(')'):
382
+ context['type'] = 'function_arg'
383
+
384
+ return context
385
+
386
+ def get_context_aware_completions(self, prefix):
387
+ """Get completions based on the current context in the query"""
388
+ import time
389
+
390
+ # Don't waste time on empty prefixes or if we don't have tables
391
+ if not prefix and not self.tables_cache:
392
+ return self.all_sql_keywords
393
+
394
+ # Get context information
395
+ context = self.get_context_at_cursor()
396
+
397
+ # Default completions - all keywords and names
398
+ all_completions = []
399
+
400
+ # Add keywords appropriate for the current context
401
+ if context['type'] == 'table' or prefix.upper() in [k.upper() for k in self.all_sql_keywords]:
402
+ # After FROM/JOIN, prioritize table keywords
403
+ all_completions.extend(self.sql_keywords['basic'])
404
+ all_completions.extend(self.sql_keywords['table_ops'])
405
+
406
+ # Also include table names
407
+ all_completions.extend(self.tables_cache.keys())
408
+
409
+ elif context['type'] == 'column' and context['table_prefix']:
410
+ # For "table." completions, only show columns from that table
411
+ table = context['table_prefix']
412
+ if table in self.tables_cache:
413
+ all_completions.extend(self.tables_cache[table])
414
+
415
+ elif context['type'] == 'column' or context['type'] == 'column_or_expression':
416
+ # Add column-related keywords
417
+ all_completions.extend(self.sql_keywords['basic'])
418
+ all_completions.extend(self.sql_keywords['aggregation'])
419
+ all_completions.extend(self.sql_keywords['functions'])
420
+
421
+ # Add all columns from all tables
422
+ for table, columns in self.tables_cache.items():
423
+ all_completions.extend(columns)
424
+ # Also add qualified columns (table.column)
425
+ all_completions.extend([f"{table}.{col}" for col in columns])
426
+
427
+ elif context['type'] == 'function_arg':
428
+ # Inside a function, suggest columns
429
+ for columns in self.tables_cache.values():
430
+ all_completions.extend(columns)
431
+
432
+ else:
433
+ # Default case - include everything
434
+ all_completions.extend(self.all_sql_keywords)
435
+
436
+ # Add all table and column names
437
+ all_completions.extend(self.tables_cache.keys())
438
+ for columns in self.tables_cache.values():
439
+ all_completions.extend(columns)
440
+
441
+ # If the prefix looks like the start of a SQL statement or clause
442
+ if prefix and len(prefix) > 2 and prefix.isupper():
443
+ # Check each category for matching keywords
444
+ for category, keywords in self.sql_keywords.items():
445
+ for keyword in keywords:
446
+ if keyword.startswith(prefix):
447
+ all_completions.append(keyword)
448
+
449
+ # If the prefix looks like the start of a JOIN
450
+ if prefix and "JOIN" in prefix.upper():
451
+ all_completions.extend(self.sql_keywords['join'])
452
+
453
+ # Filter duplicates while preserving order
454
+ seen = set()
455
+ filtered_completions = []
456
+ for item in all_completions:
457
+ if item not in seen:
458
+ seen.add(item)
459
+ filtered_completions.append(item)
460
+
461
+ return filtered_completions
462
+
108
463
  def complete(self):
109
- """Show completion popup"""
464
+ """Show improved completion popup with context awareness"""
465
+ import re
466
+
467
+ # Get the text under cursor
110
468
  prefix = self.text_under_cursor()
111
469
 
112
- if not prefix or len(prefix) < 2: # Only show completions for words with at least 2 characters
113
- if self.completer.popup().isVisible():
470
+ # Don't show popup for empty text or too short prefixes unless it's a table prefix
471
+ is_table_prefix = '.' in self.get_word_under_cursor() and self.get_word_under_cursor().endswith('.')
472
+ if not prefix and not is_table_prefix:
473
+ if self.completer and self.completer.popup().isVisible():
114
474
  self.completer.popup().hide()
115
475
  return
116
-
476
+
477
+ # Get context-aware completions
478
+ if self.tables_cache:
479
+ # Use our custom context-aware completion
480
+ completions = self.get_context_aware_completions(prefix)
481
+ if completions:
482
+ # Create a temporary model for the filtered completions
483
+ model = QStringListModel()
484
+ model.setStringList(completions)
485
+ self.completer.setModel(model)
486
+
487
+ # Set the completion prefix
117
488
  self.completer.setCompletionPrefix(prefix)
118
489
 
119
490
  # If no completions, hide popup
@@ -125,20 +496,107 @@ class SQLEditor(QPlainTextEdit):
125
496
  popup = self.completer.popup()
126
497
  popup.setCurrentIndex(self.completer.completionModel().index(0, 0))
127
498
 
128
- # Calculate position for the popup
129
- cr = self.cursorRect()
130
- cr.setWidth(self.completer.popup().sizeHintForColumn(0) +
131
- self.completer.popup().verticalScrollBar().sizeHint().width())
132
-
133
- # Show the popup
134
- self.completer.complete(cr)
499
+ try:
500
+ # Calculate position for the popup
501
+ cr = self.cursorRect()
502
+
503
+ # Ensure cursorRect is valid
504
+ if not cr.isValid() or cr.x() < 0 or cr.y() < 0:
505
+ # Try to recompute using the text cursor
506
+ cursor = self.textCursor()
507
+ cr = self.cursorRect(cursor)
508
+
509
+ # If still invalid, use a default position
510
+ if not cr.isValid() or cr.x() < 0 or cr.y() < 0:
511
+ pos = self.mapToGlobal(self.pos())
512
+ cr = QRect(pos.x() + 10, pos.y() + 10, 10, self.fontMetrics().height())
513
+
514
+ # Calculate width for the popup that fits the content
515
+ suggested_width = popup.sizeHintForColumn(0) + popup.verticalScrollBar().sizeHint().width()
516
+ # Ensure minimum width
517
+ popup_width = max(suggested_width, 200)
518
+ cr.setWidth(popup_width)
519
+
520
+ # Show the popup at the correct position
521
+ self.completer.complete(cr)
522
+ except Exception as e:
523
+ # In case of any error, try a more direct approach
524
+ print(f"Error positioning completion popup: {e}")
525
+ try:
526
+ cursor_pos = self.mapToGlobal(self.cursorRect().bottomLeft())
527
+ popup.move(cursor_pos)
528
+ popup.show()
529
+ except:
530
+ # Last resort - if all else fails, hide the popup to avoid showing it in the wrong place
531
+ popup.hide()
135
532
 
136
533
  def keyPressEvent(self, event):
534
+ # Check for Ctrl+Enter first, which should take precedence over other behaviors
535
+ if event.key() == Qt.Key.Key_Return and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
536
+ # If autocomplete popup is showing, hide it
537
+ if self.completer and self.completer.popup().isVisible():
538
+ self.completer.popup().hide()
539
+
540
+ # Cancel any pending autocomplete timers
541
+ if hasattr(self, '_completion_timer') and self._completion_timer.isActive():
542
+ self._completion_timer.stop()
543
+
544
+ # Let the main window handle query execution
545
+ # Important: We need to emit event to parent to trigger execution
546
+ # and prevent it from being treated as an autocomplete selection
547
+ event.accept() # Mark the event as handled
548
+
549
+ # Find the parent SQLShell instance and call its execute_query method
550
+ parent = self
551
+ while parent is not None:
552
+ if hasattr(parent, 'execute_query'):
553
+ parent.execute_query()
554
+ return
555
+ parent = parent.parent()
556
+
557
+ # If we couldn't find the execute_query method, pass the event up
558
+ super().keyPressEvent(event)
559
+ return
560
+
137
561
  # Handle completer popup navigation
138
562
  if self.completer and self.completer.popup().isVisible():
139
- # Handle navigation keys for the popup
140
- if event.key() in [Qt.Key.Key_Enter, Qt.Key.Key_Return, Qt.Key.Key_Tab,
141
- Qt.Key.Key_Escape, Qt.Key.Key_Up, Qt.Key.Key_Down]:
563
+ # Handle Tab key to complete the current selection
564
+ if event.key() == Qt.Key.Key_Tab:
565
+ # Get the SELECTED completion (not just the current one)
566
+ popup = self.completer.popup()
567
+ current_index = popup.currentIndex()
568
+ selected_completion = popup.model().data(current_index)
569
+
570
+ # Accept the selected completion and close popup
571
+ if selected_completion:
572
+ self.last_key_was_tab = True
573
+ self.completer.popup().hide()
574
+ self.insert_completion(selected_completion)
575
+ self.last_key_was_tab = False
576
+ return
577
+ event.ignore()
578
+ return
579
+
580
+ # Let Enter key escape/close the popup without completing
581
+ if event.key() in [Qt.Key.Key_Enter, Qt.Key.Key_Return]:
582
+ self.completer.popup().hide()
583
+ super().keyPressEvent(event)
584
+ return
585
+
586
+ # Let Space key escape/close the popup without completing
587
+ if event.key() == Qt.Key.Key_Space:
588
+ self.completer.popup().hide()
589
+ super().keyPressEvent(event)
590
+ return
591
+
592
+ # Hide popup on Escape
593
+ if event.key() == Qt.Key.Key_Escape:
594
+ self.completer.popup().hide()
595
+ event.ignore()
596
+ return
597
+
598
+ # Let Up/Down keys navigate the popup
599
+ if event.key() in [Qt.Key.Key_Up, Qt.Key.Key_Down]:
142
600
  event.ignore()
143
601
  return
144
602
 
@@ -183,13 +641,49 @@ class SQLEditor(QPlainTextEdit):
183
641
  # Comment/uncomment the selected lines
184
642
  self.toggle_comment()
185
643
  return
644
+ elif event.key() == Qt.Key.Key_Slash:
645
+ # Also allow Ctrl+/ for commenting (common shortcut in other editors)
646
+ self.toggle_comment()
647
+ return
186
648
 
187
649
  # For normal key presses
188
650
  super().keyPressEvent(event)
189
651
 
190
652
  # Check for autocomplete after typing
191
653
  if event.text() and not event.text().isspace():
192
- self.complete()
654
+ # Only show completion if user is actively typing
655
+ # Add slight delay to avoid excessive completions
656
+ if hasattr(self, '_completion_timer'):
657
+ try:
658
+ if self._completion_timer.isActive():
659
+ self._completion_timer.stop()
660
+ except:
661
+ pass
662
+
663
+ # Create a timer to trigger completion after a short delay
664
+ self._completion_timer = QTimer()
665
+ self._completion_timer.setSingleShot(True)
666
+ self._completion_timer.timeout.connect(self.complete)
667
+ self._completion_timer.start(250) # 250 ms delay for better user experience
668
+
669
+ elif event.key() == Qt.Key.Key_Backspace:
670
+ # Re-evaluate completion when backspacing, with a shorter delay
671
+ if hasattr(self, '_completion_timer'):
672
+ try:
673
+ if self._completion_timer.isActive():
674
+ self._completion_timer.stop()
675
+ except:
676
+ pass
677
+
678
+ self._completion_timer = QTimer()
679
+ self._completion_timer.setSingleShot(True)
680
+ self._completion_timer.timeout.connect(self.complete)
681
+ self._completion_timer.start(100) # 100 ms delay for backspace
682
+
683
+ else:
684
+ # Hide completion popup when inserting space or non-text characters
685
+ if self.completer and self.completer.popup().isVisible():
686
+ self.completer.popup().hide()
193
687
 
194
688
  def paintEvent(self, event):
195
689
  # Call the parent's paintEvent first
@@ -352,4 +846,68 @@ class SQLEditor(QPlainTextEdit):
352
846
  block = block.next()
353
847
  top = bottom
354
848
  bottom = top + round(self.blockBoundingRect(block).height())
355
- block_number += 1
849
+ block_number += 1
850
+
851
+ def dragEnterEvent(self, event):
852
+ """Handle drag enter events to allow dropping table names."""
853
+ # Accept text/plain mime data (used for table names)
854
+ if event.mimeData().hasText():
855
+ event.acceptProposedAction()
856
+ else:
857
+ event.ignore()
858
+
859
+ def dragMoveEvent(self, event):
860
+ """Handle drag move events to show valid drop locations."""
861
+ if event.mimeData().hasText():
862
+ event.acceptProposedAction()
863
+ else:
864
+ event.ignore()
865
+
866
+ def dropEvent(self, event):
867
+ """Handle drop event to insert table name at cursor position."""
868
+ if event.mimeData().hasText():
869
+ # Get table name from dropped text
870
+ text = event.mimeData().text()
871
+
872
+ # Try to extract table name from custom mime data if available
873
+ if event.mimeData().hasFormat('application/x-sqlshell-tablename'):
874
+ table_name = bytes(event.mimeData().data('application/x-sqlshell-tablename')).decode()
875
+ else:
876
+ # Extract actual table name (if it includes parentheses)
877
+ if " (" in text:
878
+ table_name = text.split(" (")[0]
879
+ else:
880
+ table_name = text
881
+
882
+ # Get current cursor position and surrounding text
883
+ cursor = self.textCursor()
884
+ document = self.document()
885
+ current_block = cursor.block()
886
+ block_text = current_block.text()
887
+ position_in_block = cursor.positionInBlock()
888
+
889
+ # Get text before cursor in current line
890
+ text_before = block_text[:position_in_block].strip().upper()
891
+
892
+ # Determine how to insert the table name based on context
893
+ if (text_before.endswith("FROM") or
894
+ text_before.endswith("JOIN") or
895
+ text_before.endswith("INTO") or
896
+ text_before.endswith("UPDATE") or
897
+ text_before.endswith(",")):
898
+ # Just insert the table name with a space before it
899
+ cursor.insertText(f" {table_name}")
900
+ elif text_before.endswith("FROM ") or text_before.endswith("JOIN ") or text_before.endswith("INTO ") or text_before.endswith(", "):
901
+ # Just insert the table name without a space
902
+ cursor.insertText(table_name)
903
+ elif not text_before and not block_text:
904
+ # If at empty line, insert a SELECT statement
905
+ cursor.insertText(f"SELECT * FROM {table_name}")
906
+ else:
907
+ # Default: just insert the table name at cursor position
908
+ cursor.insertText(table_name)
909
+
910
+ # Accept the action
911
+ event.acceptProposedAction()
912
+ else:
913
+ event.ignore()