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/LICENSE +21 -0
- sqlshell/MANIFEST.in +6 -0
- sqlshell/README.md +59 -0
- sqlshell/__init__.py +1 -1
- sqlshell/context_suggester.py +765 -0
- sqlshell/create_test_data.py +106 -30
- sqlshell/db/__init__.py +5 -0
- sqlshell/db/database_manager.py +837 -0
- sqlshell/editor.py +610 -52
- sqlshell/main.py +2657 -1164
- sqlshell/menus.py +171 -0
- sqlshell/query_tab.py +201 -0
- sqlshell/resources/create_icon.py +106 -28
- sqlshell/resources/create_splash.py +41 -11
- sqlshell/resources/icon.png +0 -0
- sqlshell/resources/logo_large.png +0 -0
- sqlshell/resources/logo_medium.png +0 -0
- sqlshell/resources/logo_small.png +0 -0
- sqlshell/splash_screen.py +276 -48
- sqlshell/styles.py +257 -0
- sqlshell/suggester_integration.py +275 -0
- sqlshell/table_list.py +907 -0
- sqlshell/ui/__init__.py +6 -0
- sqlshell/ui/bar_chart_delegate.py +49 -0
- sqlshell/ui/filter_header.py +403 -0
- sqlshell/utils/__init__.py +8 -0
- sqlshell/utils/profile_entropy.py +347 -0
- sqlshell/utils/profile_keys.py +356 -0
- sqlshell-0.2.0.dist-info/METADATA +198 -0
- sqlshell-0.2.0.dist-info/RECORD +41 -0
- {sqlshell-0.1.8.dist-info → sqlshell-0.2.0.dist-info}/WHEEL +1 -1
- sqlshell/setup.py +0 -42
- sqlshell-0.1.8.dist-info/METADATA +0 -120
- sqlshell-0.1.8.dist-info/RECORD +0 -21
- {sqlshell-0.1.8.dist-info → sqlshell-0.2.0.dist-info}/entry_points.txt +0 -0
- {sqlshell-0.1.8.dist-info → sqlshell-0.2.0.dist-info}/top_level.txt +0 -0
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
|
-
#
|
|
40
|
-
self.
|
|
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
|
|
43
|
-
self.sql_keywords =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
110
|
+
# Initialize completer with SQL keywords
|
|
111
|
+
self.completer = None
|
|
112
|
+
self.set_completer(QCompleter(self.all_sql_keywords))
|
|
55
113
|
|
|
56
|
-
#
|
|
57
|
-
self.
|
|
58
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
196
|
+
def _update_tables_cache(self, words):
|
|
197
|
+
"""Update internal tables and columns cache from word list"""
|
|
198
|
+
self.tables_cache = {}
|
|
86
199
|
|
|
87
|
-
#
|
|
88
|
-
|
|
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
|
|
91
|
-
"""Get the
|
|
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.
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
140
|
-
if event.key()
|
|
141
|
-
|
|
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
|
-
|
|
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()
|