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.
Files changed (85) hide show
  1. sqlide/__init__.py +3 -0
  2. sqlide/__main__.py +5 -0
  3. sqlide/app.py +46 -0
  4. sqlide/cli.py +64 -0
  5. sqlide/clipboard.py +65 -0
  6. sqlide/config/__init__.py +1 -0
  7. sqlide/config/_toml.py +41 -0
  8. sqlide/config/connections.py +76 -0
  9. sqlide/config/keymap.py +25 -0
  10. sqlide/config/paths.py +48 -0
  11. sqlide/config/secrets.py +82 -0
  12. sqlide/config/settings.py +32 -0
  13. sqlide/consoles.py +100 -0
  14. sqlide/db/__init__.py +1 -0
  15. sqlide/db/completion.py +127 -0
  16. sqlide/db/factory.py +37 -0
  17. sqlide/db/metadata.py +174 -0
  18. sqlide/db/result.py +52 -0
  19. sqlide/db/session.py +339 -0
  20. sqlide/db/types.py +84 -0
  21. sqlide/doctor.py +60 -0
  22. sqlide/drivers/__init__.py +1 -0
  23. sqlide/drivers/catalog.toml +103 -0
  24. sqlide/drivers/cli.py +74 -0
  25. sqlide/drivers/custom.py +69 -0
  26. sqlide/drivers/loader.py +75 -0
  27. sqlide/drivers/maven.py +108 -0
  28. sqlide/drivers/registry.py +117 -0
  29. sqlide/export/__init__.py +12 -0
  30. sqlide/export/base.py +86 -0
  31. sqlide/export/csv_.py +40 -0
  32. sqlide/export/html.py +44 -0
  33. sqlide/export/json_.py +47 -0
  34. sqlide/export/markdown.py +27 -0
  35. sqlide/export/service.py +34 -0
  36. sqlide/export/sql_insert.py +37 -0
  37. sqlide/export/xlsx.py +109 -0
  38. sqlide/grid/__init__.py +1 -0
  39. sqlide/grid/copyfmt.py +97 -0
  40. sqlide/grid/formatting.py +43 -0
  41. sqlide/grid/model.py +109 -0
  42. sqlide/history/__init__.py +5 -0
  43. sqlide/history/store.py +117 -0
  44. sqlide/jvm/__init__.py +1 -0
  45. sqlide/jvm/locate.py +98 -0
  46. sqlide/jvm/runtime.py +44 -0
  47. sqlide/sql/__init__.py +1 -0
  48. sqlide/sql/context.py +195 -0
  49. sqlide/sql/dialects.py +93 -0
  50. sqlide/sql/format.py +50 -0
  51. sqlide/sql/keywords.py +143 -0
  52. sqlide/sql/lexer.py +148 -0
  53. sqlide/sql/snippets.py +44 -0
  54. sqlide/sql/splitter.py +325 -0
  55. sqlide/ui/__init__.py +1 -0
  56. sqlide/ui/app.tcss +67 -0
  57. sqlide/ui/commands.py +59 -0
  58. sqlide/ui/keymap.py +32 -0
  59. sqlide/ui/screens/__init__.py +0 -0
  60. sqlide/ui/screens/connection_editor.py +113 -0
  61. sqlide/ui/screens/dialogs.py +110 -0
  62. sqlide/ui/screens/driver_manager.py +195 -0
  63. sqlide/ui/screens/export_dialog.py +158 -0
  64. sqlide/ui/screens/grid_dialogs.py +92 -0
  65. sqlide/ui/screens/history.py +130 -0
  66. sqlide/ui/screens/main.py +383 -0
  67. sqlide/ui/screens/settings.py +77 -0
  68. sqlide/ui/widgets/__init__.py +0 -0
  69. sqlide/ui/widgets/completion_popup.py +81 -0
  70. sqlide/ui/widgets/connections_list.py +54 -0
  71. sqlide/ui/widgets/console_export.py +124 -0
  72. sqlide/ui/widgets/console_tab.py +335 -0
  73. sqlide/ui/widgets/console_tabs.py +117 -0
  74. sqlide/ui/widgets/result_grid.py +435 -0
  75. sqlide/ui/widgets/result_panel.py +73 -0
  76. sqlide/ui/widgets/result_view.py +50 -0
  77. sqlide/ui/widgets/schema_tree.py +145 -0
  78. sqlide/ui/widgets/sql_editor.py +310 -0
  79. sqlide/ui/widgets/status_bar.py +32 -0
  80. sqlide/workspace.py +97 -0
  81. sqlide-0.1.0.dist-info/METADATA +151 -0
  82. sqlide-0.1.0.dist-info/RECORD +85 -0
  83. sqlide-0.1.0.dist-info/WHEEL +4 -0
  84. sqlide-0.1.0.dist-info/entry_points.txt +2 -0
  85. 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]