sqlide 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.
- sqlide/__init__.py +3 -0
- sqlide/__main__.py +5 -0
- sqlide/app.py +46 -0
- sqlide/cli.py +64 -0
- sqlide/clipboard.py +65 -0
- sqlide/config/__init__.py +1 -0
- sqlide/config/_toml.py +41 -0
- sqlide/config/connections.py +76 -0
- sqlide/config/keymap.py +25 -0
- sqlide/config/paths.py +48 -0
- sqlide/config/secrets.py +82 -0
- sqlide/config/settings.py +32 -0
- sqlide/consoles.py +100 -0
- sqlide/db/__init__.py +1 -0
- sqlide/db/completion.py +127 -0
- sqlide/db/factory.py +37 -0
- sqlide/db/metadata.py +174 -0
- sqlide/db/result.py +52 -0
- sqlide/db/session.py +339 -0
- sqlide/db/types.py +84 -0
- sqlide/doctor.py +60 -0
- sqlide/drivers/__init__.py +1 -0
- sqlide/drivers/catalog.toml +103 -0
- sqlide/drivers/cli.py +74 -0
- sqlide/drivers/custom.py +69 -0
- sqlide/drivers/loader.py +75 -0
- sqlide/drivers/maven.py +108 -0
- sqlide/drivers/registry.py +117 -0
- sqlide/export/__init__.py +12 -0
- sqlide/export/base.py +86 -0
- sqlide/export/csv_.py +40 -0
- sqlide/export/html.py +44 -0
- sqlide/export/json_.py +47 -0
- sqlide/export/markdown.py +27 -0
- sqlide/export/service.py +34 -0
- sqlide/export/sql_insert.py +37 -0
- sqlide/export/xlsx.py +109 -0
- sqlide/grid/__init__.py +1 -0
- sqlide/grid/copyfmt.py +97 -0
- sqlide/grid/formatting.py +43 -0
- sqlide/grid/model.py +109 -0
- sqlide/history/__init__.py +5 -0
- sqlide/history/store.py +117 -0
- sqlide/jvm/__init__.py +1 -0
- sqlide/jvm/locate.py +98 -0
- sqlide/jvm/runtime.py +44 -0
- sqlide/sql/__init__.py +1 -0
- sqlide/sql/context.py +195 -0
- sqlide/sql/dialects.py +93 -0
- sqlide/sql/format.py +50 -0
- sqlide/sql/keywords.py +143 -0
- sqlide/sql/lexer.py +148 -0
- sqlide/sql/snippets.py +44 -0
- sqlide/sql/splitter.py +325 -0
- sqlide/ui/__init__.py +1 -0
- sqlide/ui/app.tcss +67 -0
- sqlide/ui/commands.py +59 -0
- sqlide/ui/keymap.py +32 -0
- sqlide/ui/screens/__init__.py +0 -0
- sqlide/ui/screens/connection_editor.py +113 -0
- sqlide/ui/screens/dialogs.py +110 -0
- sqlide/ui/screens/driver_manager.py +195 -0
- sqlide/ui/screens/export_dialog.py +158 -0
- sqlide/ui/screens/grid_dialogs.py +92 -0
- sqlide/ui/screens/history.py +130 -0
- sqlide/ui/screens/main.py +383 -0
- sqlide/ui/screens/settings.py +77 -0
- sqlide/ui/widgets/__init__.py +0 -0
- sqlide/ui/widgets/completion_popup.py +81 -0
- sqlide/ui/widgets/connections_list.py +54 -0
- sqlide/ui/widgets/console_export.py +124 -0
- sqlide/ui/widgets/console_tab.py +335 -0
- sqlide/ui/widgets/console_tabs.py +117 -0
- sqlide/ui/widgets/result_grid.py +435 -0
- sqlide/ui/widgets/result_panel.py +73 -0
- sqlide/ui/widgets/result_view.py +50 -0
- sqlide/ui/widgets/schema_tree.py +145 -0
- sqlide/ui/widgets/sql_editor.py +310 -0
- sqlide/ui/widgets/status_bar.py +32 -0
- sqlide/workspace.py +97 -0
- sqlide-0.1.0.dist-info/METADATA +151 -0
- sqlide-0.1.0.dist-info/RECORD +85 -0
- sqlide-0.1.0.dist-info/WHEEL +4 -0
- sqlide-0.1.0.dist-info/entry_points.txt +2 -0
- sqlide-0.1.0.dist-info/licenses/LICENSE +21 -0
sqlide/sql/context.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""What is the user typing? Pure analysis of the statement around the cursor.
|
|
2
|
+
|
|
3
|
+
No metadata, no UI: it only says what *kind* of name is expected and which tables the
|
|
4
|
+
statement mentions, so a provider can fetch candidates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from sqlide.sql.dialects import RULES, Rules
|
|
12
|
+
from sqlide.sql.lexer import (
|
|
13
|
+
BLOCK_COMMENT,
|
|
14
|
+
LINE_COMMENT,
|
|
15
|
+
LPAREN,
|
|
16
|
+
QIDENT,
|
|
17
|
+
RPAREN,
|
|
18
|
+
SEMI,
|
|
19
|
+
STRING,
|
|
20
|
+
WORD,
|
|
21
|
+
WS,
|
|
22
|
+
Token,
|
|
23
|
+
tokenize,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
TABLE_KEYWORDS = frozenset({"FROM", "JOIN", "INTO", "UPDATE", "TABLE", "DESCRIBE", "TRUNCATE"})
|
|
27
|
+
CLAUSE_KEYWORDS = TABLE_KEYWORDS | {
|
|
28
|
+
"SELECT", "WHERE", "ON", "GROUP", "ORDER", "HAVING", "SET", "VALUES", "BY", "AND", "OR",
|
|
29
|
+
"WHEN", "THEN", "ELSE", "USING", "RETURNING",
|
|
30
|
+
} # fmt: skip
|
|
31
|
+
# a word that follows a table reference but is not its alias
|
|
32
|
+
NOT_ALIAS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", "UNION", "INTERSECT", "EXCEPT",
|
|
35
|
+
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "OUTER", "NATURAL", "ON", "USING",
|
|
36
|
+
"SET", "VALUES", "SELECT", "RETURNING", "FETCH", "WINDOW", "FOR", "LATERAL", "FINAL",
|
|
37
|
+
"SAMPLE", "PREWHERE", "SETTINGS", "FORMAT", "AS",
|
|
38
|
+
}
|
|
39
|
+
) # fmt: skip
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class TableRef:
|
|
44
|
+
parts: tuple[str, ...] # ("schema", "table") or ("table",)
|
|
45
|
+
alias: str = ""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def name(self) -> str:
|
|
49
|
+
return self.parts[-1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class Context:
|
|
54
|
+
# "table": a table name is expected; "column": a column/keyword; "qualified": after "x."
|
|
55
|
+
kind: str
|
|
56
|
+
prefix: str # the partial word before the cursor ("" when none)
|
|
57
|
+
qualifier: tuple[str, ...] = () # names before the last dot
|
|
58
|
+
tables: list[TableRef] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def replace_len(self) -> int:
|
|
62
|
+
return len(self.prefix)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _unquote(s: str) -> str:
|
|
66
|
+
if len(s) >= 2 and s[0] in '"`[' and s[-1] in '"`]':
|
|
67
|
+
return s[1:-1]
|
|
68
|
+
return s
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _statement_bounds(text: str, offset: int, toks: list[Token]) -> tuple[int, int]:
|
|
72
|
+
"""Cheap bounds: nearest ';' tokens around the cursor (blank-line splitting is ignored)."""
|
|
73
|
+
start, end = 0, len(text)
|
|
74
|
+
for t in toks:
|
|
75
|
+
if t.kind == SEMI:
|
|
76
|
+
if t.end <= offset:
|
|
77
|
+
start = t.end
|
|
78
|
+
elif t.start >= offset:
|
|
79
|
+
end = t.start
|
|
80
|
+
break
|
|
81
|
+
return start, end
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _significant(toks: list[Token], lo: int, hi: int) -> list[Token]:
|
|
85
|
+
skip = {WS, LINE_COMMENT, BLOCK_COMMENT}
|
|
86
|
+
return [t for t in toks if t.start >= lo and t.end <= hi and t.kind not in skip]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _table_refs(text: str, toks: list[Token]) -> list[TableRef]:
|
|
90
|
+
refs: list[TableRef] = []
|
|
91
|
+
i, n = 0, len(toks)
|
|
92
|
+
while i < n:
|
|
93
|
+
t = toks[i]
|
|
94
|
+
if t.kind == WORD and text[t.start : t.end].upper() in {"FROM", "JOIN", "UPDATE", "INTO"}:
|
|
95
|
+
i += 1
|
|
96
|
+
while i < n:
|
|
97
|
+
ref, i = _read_ref(text, toks, i)
|
|
98
|
+
if ref is None:
|
|
99
|
+
break
|
|
100
|
+
refs.append(ref)
|
|
101
|
+
if i < n and text[toks[i].start : toks[i].end] == ",":
|
|
102
|
+
i += 1
|
|
103
|
+
continue
|
|
104
|
+
break
|
|
105
|
+
else:
|
|
106
|
+
i += 1
|
|
107
|
+
return refs
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_ref(text: str, toks: list[Token], i: int) -> tuple[TableRef | None, int]:
|
|
111
|
+
n = len(toks)
|
|
112
|
+
parts: list[str] = []
|
|
113
|
+
while i < n and toks[i].kind in (WORD, QIDENT):
|
|
114
|
+
parts.append(_unquote(text[toks[i].start : toks[i].end]))
|
|
115
|
+
if i + 1 < n and text[toks[i + 1].start : toks[i + 1].end] == "." and i + 2 < n:
|
|
116
|
+
i += 2
|
|
117
|
+
else:
|
|
118
|
+
i += 1
|
|
119
|
+
break
|
|
120
|
+
if not parts:
|
|
121
|
+
return None, i
|
|
122
|
+
alias = ""
|
|
123
|
+
if i < n and toks[i].kind == WORD and text[toks[i].start : toks[i].end].upper() == "AS":
|
|
124
|
+
i += 1
|
|
125
|
+
if i < n and toks[i].kind in (WORD, QIDENT):
|
|
126
|
+
word = text[toks[i].start : toks[i].end]
|
|
127
|
+
if toks[i].kind == QIDENT or word.upper() not in NOT_ALIAS:
|
|
128
|
+
alias = _unquote(word)
|
|
129
|
+
i += 1
|
|
130
|
+
return TableRef(tuple(parts), alias), i
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def analyze(text: str, offset: int, dialect: str = "generic") -> Context | None:
|
|
134
|
+
"""Context at `offset`, or None where completion makes no sense (strings, comments)."""
|
|
135
|
+
rules: Rules = RULES.get(dialect, RULES["generic"])
|
|
136
|
+
toks = tokenize(text, rules)
|
|
137
|
+
for t in toks:
|
|
138
|
+
if t.kind not in (STRING, LINE_COMMENT, BLOCK_COMMENT) or not t.start < offset <= t.end:
|
|
139
|
+
continue
|
|
140
|
+
closed = {
|
|
141
|
+
STRING: text[t.start : t.end].endswith("'") and t.end - t.start >= 2,
|
|
142
|
+
BLOCK_COMMENT: text[t.start : t.end].endswith("*/") and t.end - t.start >= 4,
|
|
143
|
+
LINE_COMMENT: False, # a line comment runs to the newline, cursor at its end is inside
|
|
144
|
+
}[t.kind]
|
|
145
|
+
if offset < t.end or not closed:
|
|
146
|
+
return None
|
|
147
|
+
lo, hi = _statement_bounds(text, offset, toks)
|
|
148
|
+
stmt = _significant(toks, lo, hi)
|
|
149
|
+
|
|
150
|
+
# partial word under the cursor
|
|
151
|
+
p = offset
|
|
152
|
+
while p > 0 and (text[p - 1].isalnum() or text[p - 1] in "_$"):
|
|
153
|
+
p -= 1
|
|
154
|
+
prefix = text[p:offset]
|
|
155
|
+
|
|
156
|
+
# qualifier: name(.name)* followed by a dot, directly before the prefix
|
|
157
|
+
qual: list[str] = []
|
|
158
|
+
q = p
|
|
159
|
+
while q > 0 and text[q - 1] == ".":
|
|
160
|
+
q -= 1
|
|
161
|
+
end = q
|
|
162
|
+
if q > 0 and text[q - 1] in '"`]':
|
|
163
|
+
close = text[q - 1]
|
|
164
|
+
opener = {'"': '"', "`": "`", "]": "["}[close]
|
|
165
|
+
start = text.rfind(opener, 0, q - 1)
|
|
166
|
+
if start == -1:
|
|
167
|
+
break
|
|
168
|
+
qual.insert(0, text[start + 1 : q - 1])
|
|
169
|
+
q = start
|
|
170
|
+
else:
|
|
171
|
+
while q > 0 and (text[q - 1].isalnum() or text[q - 1] in "_$"):
|
|
172
|
+
q -= 1
|
|
173
|
+
if q == end:
|
|
174
|
+
break
|
|
175
|
+
qual.insert(0, text[q:end])
|
|
176
|
+
refs = _table_refs(text, stmt)
|
|
177
|
+
if qual:
|
|
178
|
+
return Context("qualified", prefix, tuple(qual), refs)
|
|
179
|
+
|
|
180
|
+
# nearest clause keyword before the word being typed (ignoring closed sub-selects)
|
|
181
|
+
kind = "column"
|
|
182
|
+
depth = 0
|
|
183
|
+
for t in reversed([t for t in stmt if t.end <= p]):
|
|
184
|
+
if t.kind == RPAREN:
|
|
185
|
+
depth += 1
|
|
186
|
+
elif t.kind == LPAREN:
|
|
187
|
+
if depth == 0:
|
|
188
|
+
continue
|
|
189
|
+
depth -= 1
|
|
190
|
+
elif t.kind == WORD and depth == 0:
|
|
191
|
+
word = text[t.start : t.end].upper()
|
|
192
|
+
if word in CLAUSE_KEYWORDS:
|
|
193
|
+
kind = "table" if word in TABLE_KEYWORDS else "column"
|
|
194
|
+
break
|
|
195
|
+
return Context(kind, prefix, (), refs)
|
sqlide/sql/dialects.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Per-dialect lexing and block rules. Dialect ids match DriverDef.dialect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class BlockRules:
|
|
10
|
+
"""Procedural blocks (BEGIN..END). Only active inside DECLARE/BEGIN/CREATE ROUTINE."""
|
|
11
|
+
|
|
12
|
+
openers: frozenset[str] = frozenset() # statement-level words that open an END-closed block
|
|
13
|
+
inline_openers: frozenset[str] = frozenset() # same, but valid anywhere (FOR..LOOP)
|
|
14
|
+
routines: frozenset[str] = frozenset() # CREATE <kind> that enters block context
|
|
15
|
+
expect_begin_for: frozenset[str] = frozenset() # ';' does not end these until their BEGIN
|
|
16
|
+
slash_only_for: frozenset[str] = frozenset() # only '/' ends these (oracle packages)
|
|
17
|
+
declare_starts_block: bool = False
|
|
18
|
+
top_begin_block: bool = False # statement-initial BEGIN is a block, not a transaction
|
|
19
|
+
tx_words: frozenset[str] = frozenset() # BEGIN <word> is a transaction start
|
|
20
|
+
# CREATE <kind> whose body runs to the end of the batch (GO) unless it is a BEGIN..END block
|
|
21
|
+
batch_scoped: frozenset[str] = frozenset()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class Rules:
|
|
26
|
+
backslash_escapes: bool = False
|
|
27
|
+
dollar_quotes: bool = False
|
|
28
|
+
nested_comments: bool = False
|
|
29
|
+
backtick: bool = False
|
|
30
|
+
brackets: bool = False # [ident]
|
|
31
|
+
hash_comment: bool = False
|
|
32
|
+
q_quote: bool = False # oracle q'[...]'
|
|
33
|
+
e_strings: bool = False # postgres E'..\n..'
|
|
34
|
+
slash_lines: bool = False # '/' alone on a line ends a statement
|
|
35
|
+
delimiter_command: bool = False # mysql client 'DELIMITER x' lines
|
|
36
|
+
go_batches: bool = False # 'GO' alone on a line ends a batch
|
|
37
|
+
block: BlockRules | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_F = frozenset
|
|
41
|
+
|
|
42
|
+
RULES: dict[str, Rules] = {
|
|
43
|
+
"generic": Rules(dollar_quotes=True, e_strings=True),
|
|
44
|
+
"postgres": Rules(dollar_quotes=True, nested_comments=True, e_strings=True),
|
|
45
|
+
"clickhouse": Rules(backslash_escapes=True, backtick=True, hash_comment=True),
|
|
46
|
+
"mysql": Rules(
|
|
47
|
+
backslash_escapes=True,
|
|
48
|
+
backtick=True,
|
|
49
|
+
hash_comment=True,
|
|
50
|
+
delimiter_command=True,
|
|
51
|
+
block=BlockRules(
|
|
52
|
+
openers=_F({"IF", "WHILE", "REPEAT"}),
|
|
53
|
+
inline_openers=_F({"LOOP"}),
|
|
54
|
+
routines=_F({"PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"}),
|
|
55
|
+
tx_words=_F({"WORK"}),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
"oracle": Rules(
|
|
59
|
+
q_quote=True,
|
|
60
|
+
slash_lines=True,
|
|
61
|
+
block=BlockRules(
|
|
62
|
+
openers=_F({"IF"}),
|
|
63
|
+
inline_openers=_F({"LOOP"}),
|
|
64
|
+
routines=_F({"PROCEDURE", "FUNCTION", "TRIGGER", "PACKAGE", "TYPE"}),
|
|
65
|
+
expect_begin_for=_F({"PROCEDURE", "FUNCTION", "TRIGGER"}),
|
|
66
|
+
slash_only_for=_F({"PACKAGE", "TYPE"}), # TYPE only when followed by BODY
|
|
67
|
+
declare_starts_block=True,
|
|
68
|
+
top_begin_block=True,
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
"mssql": Rules(
|
|
72
|
+
brackets=True,
|
|
73
|
+
go_batches=True,
|
|
74
|
+
block=BlockRules(
|
|
75
|
+
routines=_F({"PROCEDURE", "PROC", "FUNCTION", "TRIGGER"}),
|
|
76
|
+
top_begin_block=True,
|
|
77
|
+
tx_words=_F({"TRAN", "TRANSACTION", "DISTRIBUTED"}),
|
|
78
|
+
batch_scoped=_F({"PROCEDURE", "PROC", "FUNCTION", "TRIGGER"}),
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
"sqlite": Rules(
|
|
82
|
+
backtick=True,
|
|
83
|
+
brackets=True,
|
|
84
|
+
block=BlockRules(
|
|
85
|
+
routines=_F({"TRIGGER"}),
|
|
86
|
+
tx_words=_F({"TRANSACTION", "DEFERRED", "IMMEDIATE", "EXCLUSIVE"}),
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def rules_for(dialect: str) -> Rules:
|
|
93
|
+
return RULES.get(dialect, RULES["generic"])
|
sqlide/sql/format.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""SQL pretty-printing (via sqlglot) and line-comment toggling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlglot
|
|
6
|
+
from sqlglot.errors import SqlglotError
|
|
7
|
+
|
|
8
|
+
from sqlide.sql.dialects import RULES
|
|
9
|
+
from sqlide.sql.splitter import split
|
|
10
|
+
|
|
11
|
+
_SQLGLOT = {"mssql": "tsql", "generic": None, "h2": None}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FormatError(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_sql(sql: str, dialect: str = "generic") -> str:
|
|
19
|
+
"""Pretty-print one or more statements; the text is left alone if it cannot be parsed."""
|
|
20
|
+
name = _SQLGLOT.get(dialect, dialect)
|
|
21
|
+
parts = [s.text(sql) for s in split(sql, dialect if dialect in RULES else "generic")]
|
|
22
|
+
try:
|
|
23
|
+
out = [
|
|
24
|
+
sqlglot.transpile(part, read=name, write=name, pretty=True)[0]
|
|
25
|
+
for part in parts
|
|
26
|
+
if part.strip()
|
|
27
|
+
]
|
|
28
|
+
except SqlglotError as e:
|
|
29
|
+
raise FormatError(str(e).splitlines()[0]) from e
|
|
30
|
+
return ";\n\n".join(out)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def toggle_line_comments(lines: list[str]) -> list[str]:
|
|
34
|
+
"""Comment all lines, or uncomment them when every non-blank line is already commented."""
|
|
35
|
+
body = [ln for ln in lines if ln.strip()]
|
|
36
|
+
if not body:
|
|
37
|
+
return lines
|
|
38
|
+
if all(ln.lstrip().startswith("--") for ln in body):
|
|
39
|
+
out = []
|
|
40
|
+
for ln in lines:
|
|
41
|
+
stripped = ln.lstrip()
|
|
42
|
+
if stripped.startswith("--"):
|
|
43
|
+
indent = ln[: len(ln) - len(stripped)]
|
|
44
|
+
rest = stripped[2:]
|
|
45
|
+
out.append(indent + (rest[1:] if rest.startswith(" ") else rest))
|
|
46
|
+
else:
|
|
47
|
+
out.append(ln)
|
|
48
|
+
return out
|
|
49
|
+
indent = min(len(ln) - len(ln.lstrip()) for ln in body)
|
|
50
|
+
return [ln if not ln.strip() else ln[:indent] + "-- " + ln[indent:] for ln in lines]
|
sqlide/sql/keywords.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Keyword and function names offered by autocomplete, per dialect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
COMMON_KEYWORDS = [
|
|
6
|
+
"SELECT",
|
|
7
|
+
"DISTINCT",
|
|
8
|
+
"FROM",
|
|
9
|
+
"WHERE",
|
|
10
|
+
"GROUP",
|
|
11
|
+
"BY",
|
|
12
|
+
"HAVING",
|
|
13
|
+
"ORDER",
|
|
14
|
+
"LIMIT",
|
|
15
|
+
"OFFSET",
|
|
16
|
+
"UNION",
|
|
17
|
+
"ALL",
|
|
18
|
+
"INTERSECT",
|
|
19
|
+
"EXCEPT",
|
|
20
|
+
"JOIN",
|
|
21
|
+
"INNER",
|
|
22
|
+
"LEFT",
|
|
23
|
+
"RIGHT",
|
|
24
|
+
"FULL",
|
|
25
|
+
"OUTER",
|
|
26
|
+
"CROSS",
|
|
27
|
+
"ON",
|
|
28
|
+
"USING",
|
|
29
|
+
"AS",
|
|
30
|
+
"AND",
|
|
31
|
+
"OR",
|
|
32
|
+
"NOT",
|
|
33
|
+
"IN",
|
|
34
|
+
"IS",
|
|
35
|
+
"NULL",
|
|
36
|
+
"LIKE",
|
|
37
|
+
"BETWEEN",
|
|
38
|
+
"EXISTS",
|
|
39
|
+
"CASE",
|
|
40
|
+
"WHEN",
|
|
41
|
+
"THEN",
|
|
42
|
+
"ELSE",
|
|
43
|
+
"END",
|
|
44
|
+
"ASC",
|
|
45
|
+
"DESC",
|
|
46
|
+
"NULLS",
|
|
47
|
+
"FIRST",
|
|
48
|
+
"LAST",
|
|
49
|
+
"INSERT",
|
|
50
|
+
"INTO",
|
|
51
|
+
"VALUES",
|
|
52
|
+
"UPDATE",
|
|
53
|
+
"SET",
|
|
54
|
+
"DELETE",
|
|
55
|
+
"MERGE",
|
|
56
|
+
"RETURNING",
|
|
57
|
+
"CREATE",
|
|
58
|
+
"ALTER",
|
|
59
|
+
"DROP",
|
|
60
|
+
"TRUNCATE",
|
|
61
|
+
"TABLE",
|
|
62
|
+
"VIEW",
|
|
63
|
+
"INDEX",
|
|
64
|
+
"SCHEMA",
|
|
65
|
+
"SEQUENCE",
|
|
66
|
+
"DATABASE",
|
|
67
|
+
"ADD",
|
|
68
|
+
"COLUMN",
|
|
69
|
+
"CONSTRAINT",
|
|
70
|
+
"PRIMARY",
|
|
71
|
+
"KEY",
|
|
72
|
+
"FOREIGN",
|
|
73
|
+
"REFERENCES",
|
|
74
|
+
"UNIQUE",
|
|
75
|
+
"CHECK",
|
|
76
|
+
"DEFAULT",
|
|
77
|
+
"BEGIN",
|
|
78
|
+
"COMMIT",
|
|
79
|
+
"ROLLBACK",
|
|
80
|
+
"SAVEPOINT",
|
|
81
|
+
"WITH",
|
|
82
|
+
"RECURSIVE",
|
|
83
|
+
"EXPLAIN",
|
|
84
|
+
"TRUE",
|
|
85
|
+
"FALSE",
|
|
86
|
+
"CAST",
|
|
87
|
+
"OVER",
|
|
88
|
+
"PARTITION",
|
|
89
|
+
"ROWS",
|
|
90
|
+
"RANGE",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
COMMON_FUNCTIONS = [
|
|
94
|
+
"COUNT",
|
|
95
|
+
"SUM",
|
|
96
|
+
"AVG",
|
|
97
|
+
"MIN",
|
|
98
|
+
"MAX",
|
|
99
|
+
"COALESCE",
|
|
100
|
+
"NULLIF",
|
|
101
|
+
"ABS",
|
|
102
|
+
"ROUND",
|
|
103
|
+
"FLOOR",
|
|
104
|
+
"CEIL",
|
|
105
|
+
"LOWER",
|
|
106
|
+
"UPPER",
|
|
107
|
+
"LENGTH",
|
|
108
|
+
"TRIM",
|
|
109
|
+
"SUBSTRING",
|
|
110
|
+
"REPLACE",
|
|
111
|
+
"CONCAT",
|
|
112
|
+
"ROW_NUMBER",
|
|
113
|
+
"RANK",
|
|
114
|
+
"DENSE_RANK",
|
|
115
|
+
"LAG",
|
|
116
|
+
"LEAD",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
_DIALECT_KEYWORDS = {
|
|
120
|
+
"postgres": "ILIKE RETURNING LATERAL MATERIALIZED VACUUM ANALYZE COPY SERIAL JSONB ARRAY",
|
|
121
|
+
"mysql": "SHOW DESCRIBE USE ENGINE AUTO_INCREMENT REPLACE IGNORE STRAIGHT_JOIN",
|
|
122
|
+
"clickhouse": "FINAL SAMPLE PREWHERE ENGINE SETTINGS FORMAT ARRAY ATTACH DETACH OPTIMIZE",
|
|
123
|
+
"oracle": "ROWNUM DUAL CONNECT PRIOR MINUS FETCH NEXT ONLY SYSDATE",
|
|
124
|
+
"mssql": "TOP GO NOLOCK IDENTITY EXEC PROCEDURE DECLARE",
|
|
125
|
+
"sqlite": "PRAGMA AUTOINCREMENT VACUUM GLOB",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_DIALECT_FUNCTIONS = {
|
|
129
|
+
"postgres": "NOW GENERATE_SERIES STRING_AGG ARRAY_AGG TO_CHAR DATE_TRUNC JSONB_BUILD_OBJECT",
|
|
130
|
+
"mysql": "NOW IFNULL GROUP_CONCAT DATE_FORMAT CURDATE",
|
|
131
|
+
"clickhouse": "toDate toDateTime toString arrayJoin uniq groupArray now today",
|
|
132
|
+
"oracle": "NVL TO_CHAR TO_DATE SYSDATE DECODE LISTAGG",
|
|
133
|
+
"mssql": "GETDATE ISNULL DATEADD DATEDIFF STRING_AGG",
|
|
134
|
+
"sqlite": "IFNULL DATE DATETIME STRFTIME GROUP_CONCAT",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def keywords(dialect: str) -> list[str]:
|
|
139
|
+
return sorted({*COMMON_KEYWORDS, *_DIALECT_KEYWORDS.get(dialect, "").split()})
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def functions(dialect: str) -> list[str]:
|
|
143
|
+
return sorted({*COMMON_FUNCTIONS, *_DIALECT_FUNCTIONS.get(dialect, "").split()})
|
sqlide/sql/lexer.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tolerant SQL tokenizer. Never raises: unterminated strings/comments run to EOF."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
from sqlide.sql.dialects import Rules
|
|
9
|
+
|
|
10
|
+
WS, LINE_COMMENT, BLOCK_COMMENT, STRING, QIDENT, WORD, PUNCT, SEMI, LPAREN, RPAREN = range(10)
|
|
11
|
+
|
|
12
|
+
_WS = re.compile(r"\s+")
|
|
13
|
+
_WORD = re.compile(r"[\w$#@]+")
|
|
14
|
+
_DOLLAR = re.compile(r"\$(?:[^\W\d]\w*)?\$")
|
|
15
|
+
_Q_CLOSE = {"[": "]", "{": "}", "<": ">", "(": ")"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Token(NamedTuple):
|
|
19
|
+
kind: int
|
|
20
|
+
start: int
|
|
21
|
+
end: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _scan_quoted(text: str, i: int, quote: str, backslash: bool) -> int:
|
|
25
|
+
"""`i` is on the opening quote; doubled quote escapes it. Returns end (exclusive)."""
|
|
26
|
+
n, j = len(text), i + 1
|
|
27
|
+
if backslash:
|
|
28
|
+
while j < n:
|
|
29
|
+
ch = text[j]
|
|
30
|
+
if ch == "\\":
|
|
31
|
+
j += 2
|
|
32
|
+
elif ch == quote:
|
|
33
|
+
if text[j + 1 : j + 2] == quote:
|
|
34
|
+
j += 2
|
|
35
|
+
else:
|
|
36
|
+
return j + 1
|
|
37
|
+
else:
|
|
38
|
+
j += 1
|
|
39
|
+
return n
|
|
40
|
+
while True:
|
|
41
|
+
k = text.find(quote, j)
|
|
42
|
+
if k == -1:
|
|
43
|
+
return n
|
|
44
|
+
if text[k + 1 : k + 2] == quote:
|
|
45
|
+
j = k + 2
|
|
46
|
+
continue
|
|
47
|
+
return k + 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def tokenize(text: str, rules: Rules) -> list[Token]:
|
|
51
|
+
out: list[Token] = []
|
|
52
|
+
n, i = len(text), 0
|
|
53
|
+
add = out.append
|
|
54
|
+
while i < n:
|
|
55
|
+
c = text[i]
|
|
56
|
+
two = text[i : i + 2]
|
|
57
|
+
if c.isspace():
|
|
58
|
+
j = _WS.match(text, i).end() # type: ignore[union-attr]
|
|
59
|
+
add(Token(WS, i, j))
|
|
60
|
+
elif two == "--" or (c == "#" and rules.hash_comment):
|
|
61
|
+
j = text.find("\n", i)
|
|
62
|
+
j = n if j == -1 else j
|
|
63
|
+
add(Token(LINE_COMMENT, i, j))
|
|
64
|
+
elif two == "/*":
|
|
65
|
+
j = _scan_block_comment(text, i, rules.nested_comments)
|
|
66
|
+
add(Token(BLOCK_COMMENT, i, j))
|
|
67
|
+
elif c == "'":
|
|
68
|
+
j = _scan_quoted(text, i, "'", rules.backslash_escapes)
|
|
69
|
+
add(Token(STRING, i, j))
|
|
70
|
+
elif c in "eE" and rules.e_strings and text[i + 1 : i + 2] == "'":
|
|
71
|
+
j = _scan_quoted(text, i + 1, "'", True)
|
|
72
|
+
add(Token(STRING, i, j))
|
|
73
|
+
elif c in "qQ" and rules.q_quote and text[i + 1 : i + 2] == "'" and _q_ok(text, i + 2):
|
|
74
|
+
j = _scan_q(text, i)
|
|
75
|
+
add(Token(STRING, i, j))
|
|
76
|
+
elif c == '"':
|
|
77
|
+
j = _scan_quoted(text, i, '"', rules.backslash_escapes)
|
|
78
|
+
add(Token(QIDENT, i, j))
|
|
79
|
+
elif c == "`" and rules.backtick:
|
|
80
|
+
add(Token(QIDENT, i, _scan_quoted(text, i, "`", False)))
|
|
81
|
+
elif c == "[" and rules.brackets:
|
|
82
|
+
add(Token(QIDENT, i, _scan_bracket(text, i)))
|
|
83
|
+
elif c == "$" and rules.dollar_quotes and (m := _DOLLAR.match(text, i)):
|
|
84
|
+
tag = m.group(0)
|
|
85
|
+
k = text.find(tag, m.end())
|
|
86
|
+
add(Token(STRING, i, n if k == -1 else k + len(tag)))
|
|
87
|
+
i = out[-1].end
|
|
88
|
+
continue
|
|
89
|
+
elif c == ";":
|
|
90
|
+
add(Token(SEMI, i, i + 1))
|
|
91
|
+
i += 1
|
|
92
|
+
continue
|
|
93
|
+
elif c == "(":
|
|
94
|
+
add(Token(LPAREN, i, i + 1))
|
|
95
|
+
i += 1
|
|
96
|
+
continue
|
|
97
|
+
elif c == ")":
|
|
98
|
+
add(Token(RPAREN, i, i + 1))
|
|
99
|
+
i += 1
|
|
100
|
+
continue
|
|
101
|
+
elif m := _WORD.match(text, i):
|
|
102
|
+
add(Token(WORD, i, m.end()))
|
|
103
|
+
else:
|
|
104
|
+
add(Token(PUNCT, i, i + 1))
|
|
105
|
+
i += 1
|
|
106
|
+
continue
|
|
107
|
+
i = out[-1].end
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _scan_block_comment(text: str, i: int, nested: bool) -> int:
|
|
112
|
+
n, depth, j = len(text), 1, i + 2
|
|
113
|
+
while j < n:
|
|
114
|
+
if text.startswith("*/", j):
|
|
115
|
+
depth -= 1
|
|
116
|
+
j += 2
|
|
117
|
+
if depth == 0:
|
|
118
|
+
return j
|
|
119
|
+
elif nested and text.startswith("/*", j):
|
|
120
|
+
depth += 1
|
|
121
|
+
j += 2
|
|
122
|
+
else:
|
|
123
|
+
j += 1
|
|
124
|
+
return n
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _scan_bracket(text: str, i: int) -> int:
|
|
128
|
+
n, j = len(text), i + 1
|
|
129
|
+
while j < n:
|
|
130
|
+
if text[j] == "]":
|
|
131
|
+
if text[j + 1 : j + 2] == "]":
|
|
132
|
+
j += 2
|
|
133
|
+
continue
|
|
134
|
+
return j + 1
|
|
135
|
+
j += 1
|
|
136
|
+
return n
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _q_ok(text: str, i: int) -> bool:
|
|
140
|
+
return i < len(text) and not text[i].isspace()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _scan_q(text: str, i: int) -> int:
|
|
144
|
+
"""q'[ ... ]' style quoting; `i` is on the q."""
|
|
145
|
+
d = text[i + 2]
|
|
146
|
+
closing = _Q_CLOSE.get(d, d) + "'"
|
|
147
|
+
k = text.find(closing, i + 3)
|
|
148
|
+
return len(text) if k == -1 else k + 2
|
sqlide/sql/snippets.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Small SQL generators for the UI: identifier quoting and "select from table" statements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_SIMPLE = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
|
|
8
|
+
|
|
9
|
+
# How an unquoted identifier is case-folded, per dialect. None: case is preserved/ignored.
|
|
10
|
+
_FOLD = {"postgres": str.lower, "oracle": str.upper, "generic": str.upper}
|
|
11
|
+
_QUOTES = {"mysql": ("`", "`"), "clickhouse": ("`", "`"), "mssql": ("[", "]")}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def quote_ident(name: str, dialect: str) -> str:
|
|
15
|
+
"""Quote `name` only when leaving it bare would change its meaning."""
|
|
16
|
+
fold = _FOLD.get(dialect)
|
|
17
|
+
if _SIMPLE.match(name) and (fold is None or fold(name) == name):
|
|
18
|
+
return name
|
|
19
|
+
left, right = _QUOTES.get(dialect, ('"', '"'))
|
|
20
|
+
return left + name.replace(right, right * 2) + right
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def qualified_name(parts: list[str], dialect: str) -> str:
|
|
24
|
+
return ".".join(quote_ident(p, dialect) for p in parts if p)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def select_all(table: str, dialect: str, limit: int = 100) -> str:
|
|
28
|
+
"""`table` is already qualified/quoted."""
|
|
29
|
+
if dialect == "mssql":
|
|
30
|
+
return f"SELECT TOP {limit} * FROM {table}"
|
|
31
|
+
if dialect == "oracle":
|
|
32
|
+
return f"SELECT * FROM {table} FETCH FIRST {limit} ROWS ONLY"
|
|
33
|
+
return f"SELECT * FROM {table} LIMIT {limit}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_LEADING_NOISE = re.compile(r"^(?:\s+|--[^\n]*(?:\n|$)|/\*.*?\*/)*", re.S)
|
|
37
|
+
_DDL_WORDS = frozenset({"CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME", "COMMENT"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_ddl(sql: str) -> bool:
|
|
41
|
+
"""True when the statement changes the schema (so cached metadata is stale)."""
|
|
42
|
+
rest = _LEADING_NOISE.sub("", sql, count=1)
|
|
43
|
+
word = re.match(r"[A-Za-z]+", rest)
|
|
44
|
+
return bool(word) and word.group().upper() in _DDL_WORDS # type: ignore[union-attr]
|