minesql 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.
- minesql/__init__.py +11 -0
- minesql/cli.py +976 -0
- minesql-0.1.0.dist-info/METADATA +163 -0
- minesql-0.1.0.dist-info/RECORD +8 -0
- minesql-0.1.0.dist-info/WHEEL +5 -0
- minesql-0.1.0.dist-info/entry_points.txt +2 -0
- minesql-0.1.0.dist-info/licenses/LICENSE +21 -0
- minesql-0.1.0.dist-info/top_level.txt +1 -0
minesql/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
minesql - a MySQL terminal that only suggests the NEXT SQL piece.
|
|
3
|
+
|
|
4
|
+
Schema-aware ghost-text SQL suggestions for your MySQL CLI sessions,
|
|
5
|
+
with a tiny learned n-gram model that adapts to how you write queries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .cli import main
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["main"]
|
minesql/cli.py
ADDED
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
minesql - a MySQL terminal that only suggests the NEXT SQL piece.
|
|
4
|
+
|
|
5
|
+
* Gray "ghost" text after your cursor = the current best suggestion
|
|
6
|
+
* Tab -> accept the suggestion
|
|
7
|
+
* Shift+Tab -> show the next-best suggestion
|
|
8
|
+
* Ctrl+T -> toggle NEXT-piece mode <-> FULL inline mode (whole query predicted)
|
|
9
|
+
* Ctrl+O -> suggestions ON/OFF (OFF is the default at startup)
|
|
10
|
+
* Ctrl+Space -> LIST mode ON/OFF: a live suggestion dropdown that stays open and
|
|
11
|
+
updates as you type until you toggle it off (works even when ghost
|
|
12
|
+
suggestions are OFF). Alt+L is an alias if Ctrl+Space is grabbed.
|
|
13
|
+
* Alt+/ -> force ONE suggestion right here, right now (any cursor position)
|
|
14
|
+
* FULL mode: Tab accepts ONE word, Ctrl+E (or Ctrl+Tab, if your terminal sends it)
|
|
15
|
+
accepts the WHOLE suggestion
|
|
16
|
+
* Enter -> run the query
|
|
17
|
+
* Ctrl+D -> exit
|
|
18
|
+
|
|
19
|
+
Suggestions are schema-aware: database, table, column names (and sample values)
|
|
20
|
+
come from the live MySQL server via information_schema. A small n-gram model
|
|
21
|
+
learns from the queries you run (saved in ~/.minesql_model.json) and re-ranks
|
|
22
|
+
suggestions so your habits float to the top.
|
|
23
|
+
|
|
24
|
+
Install: pip install minesql
|
|
25
|
+
Run: minesql -H 127.0.0.1 -u root -d mydb
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
import getpass
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
from collections import defaultdict
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import pymysql
|
|
38
|
+
from prompt_toolkit import PromptSession
|
|
39
|
+
from prompt_toolkit.application import get_app
|
|
40
|
+
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
|
41
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
42
|
+
from prompt_toolkit.filters import Condition
|
|
43
|
+
from prompt_toolkit.history import FileHistory
|
|
44
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
45
|
+
from prompt_toolkit.styles import Style
|
|
46
|
+
|
|
47
|
+
MODEL_PATH = Path.home() / ".minesql_model.json"
|
|
48
|
+
HISTORY_PATH = Path.home() / ".minesql_history"
|
|
49
|
+
|
|
50
|
+
KEYWORDS = set("""
|
|
51
|
+
SELECT FROM WHERE AND OR NOT IN LIKE BETWEEN IS NULL JOIN LEFT RIGHT INNER OUTER CROSS ON
|
|
52
|
+
GROUP BY ORDER HAVING LIMIT OFFSET AS DISTINCT INSERT INTO VALUES VALUE UPDATE SET DELETE
|
|
53
|
+
CREATE DROP ALTER TABLE DATABASE SCHEMA USE SHOW DESCRIBE DESC ASC EXPLAIN TRUNCATE UNION
|
|
54
|
+
COUNT SUM AVG MIN MAX EXISTS ADD COLUMN MODIFY CHANGE INDEX RENAME TO VIEW
|
|
55
|
+
""".split())
|
|
56
|
+
|
|
57
|
+
START = ["SELECT", "SHOW", "INSERT INTO", "UPDATE", "DELETE FROM", "USE", "DESCRIBE",
|
|
58
|
+
"CREATE TABLE", "CREATE DATABASE", "DROP TABLE", "ALTER TABLE", "EXPLAIN",
|
|
59
|
+
"TRUNCATE TABLE"]
|
|
60
|
+
ONE = {"DELETE": ["FROM"], "INSERT": ["INTO"], "CREATE": ["TABLE", "DATABASE", "INDEX", "VIEW"],
|
|
61
|
+
"DROP": ["TABLE", "DATABASE", "VIEW", "INDEX"], "ALTER": ["TABLE"], "TRUNCATE": ["TABLE"]}
|
|
62
|
+
OPS = {"=", "<", ">", "<=", ">=", "!=", "<>", "<=>", "LIKE", "BETWEEN"}
|
|
63
|
+
OPERATORS = ["=", ">", "<", ">=", "<=", "!=", "LIKE", "IN (", "BETWEEN", "IS NULL", "IS NOT NULL"]
|
|
64
|
+
HELP = """
|
|
65
|
+
KEYS
|
|
66
|
+
Tab accept the gray suggestion (in FULL mode: just the next word/unit)
|
|
67
|
+
Ctrl+E accept the WHOLE suggestion (also Ctrl+Tab on terminals that send it,
|
|
68
|
+
e.g. kitty, WezTerm, iTerm2, xterm with modifyOtherKeys)
|
|
69
|
+
Shift+Tab cycle to the next-best suggestion
|
|
70
|
+
Ctrl+O suggestions ON / OFF (OFF by default; start ON with --suggest or --full)
|
|
71
|
+
Ctrl+Space LIST mode ON/OFF - a live dropdown of suggestions (with type labels:
|
|
72
|
+
keyword/table/column/value/snippet) that stays open and updates as you
|
|
73
|
+
type until toggled off. Works even while ghost suggestions are OFF.
|
|
74
|
+
Tab/Shift+Tab move in the list, Enter picks the highlighted item
|
|
75
|
+
(it does NOT run the query). Alt+L is an alias for Ctrl+Space.
|
|
76
|
+
Alt+/ force ONE suggestion at the cursor right now (ghost at end of line,
|
|
77
|
+
dropdown mid-line), even while OFF.
|
|
78
|
+
Ctrl+T switch suggestion mode NEXT <-> FULL (also turns suggestions on)
|
|
79
|
+
Right arrow accept without the trailing space
|
|
80
|
+
Up/Down command history
|
|
81
|
+
Enter run query Ctrl+C cancel line Ctrl+D exit
|
|
82
|
+
|
|
83
|
+
IN-APP COMMANDS
|
|
84
|
+
\\help show this help \\full toggle FULL/NEXT mode (turns suggestions on)
|
|
85
|
+
\\suggest toggle suggestions ON/OFF \\list toggle LIST mode
|
|
86
|
+
|
|
87
|
+
SAFETY
|
|
88
|
+
DELETE/UPDATE without WHERE, DROP and TRUNCATE ask for confirmation first
|
|
89
|
+
(disable with --no-confirm).
|
|
90
|
+
\\q / exit / quit leave
|
|
91
|
+
|
|
92
|
+
MODES
|
|
93
|
+
NEXT suggests only the next keyword / table / column / value.
|
|
94
|
+
FULL chains predictions into a whole query. Optional clauses (WHERE, AND,
|
|
95
|
+
ORDER BY, LIMIT ...) are only added once the model has learned you use
|
|
96
|
+
them after that context. Sample values are guesses - edit them.
|
|
97
|
+
|
|
98
|
+
SUGGESTION SOURCES
|
|
99
|
+
Live schema (SHOW DATABASES, information_schema), sample column values, and an
|
|
100
|
+
n-gram model learned from your queries (~/.minesql_model.json).
|
|
101
|
+
"""
|
|
102
|
+
TYPES = ["INT", "VARCHAR(255)", "VARCHAR(100)", "TEXT", "BIGINT", "DECIMAL(10,2)", "DATE",
|
|
103
|
+
"DATETIME", "TIMESTAMP", "BOOLEAN", "TINYINT"]
|
|
104
|
+
COL_TEMPLATES = ["id INT AUTO_INCREMENT PRIMARY KEY", "name VARCHAR(100) NOT NULL",
|
|
105
|
+
"email VARCHAR(255) UNIQUE", "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP",
|
|
106
|
+
"status TINYINT DEFAULT 1", "price DECIMAL(10,2)", "description TEXT"]
|
|
107
|
+
COL_CONSTRAINTS = ("NOT", "PRIMARY", "UNIQUE", "DEFAULT", "AUTO_INCREMENT")
|
|
108
|
+
OPTIONAL = {"ENGINE=INNODB", "NOT", "PRIMARY", "AUTO_INCREMENT", "UNIQUE", "DEFAULT", "WHERE", "JOIN", "LEFT", "INNER", "RIGHT", "GROUP", "ORDER", "LIMIT", "AND", "OR",
|
|
109
|
+
"AS", "HAVING", "OFFSET", "DISTINCT"}
|
|
110
|
+
NUMERIC = {"int", "bigint", "smallint", "tinyint", "mediumint", "decimal", "float", "double", "year"}
|
|
111
|
+
|
|
112
|
+
TOK = re.compile(
|
|
113
|
+
r"'(?:[^'\\]|\\.)*'?|\"(?:[^\"\\]|\\.)*\"?|[\w$]+(?:\.[\w$*]*)*|<=>|<=|>=|<>|!=|[=<>(),;*+\-/%]"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def kw(xs):
|
|
118
|
+
return [(x, True) for x in xs]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def nm(xs):
|
|
122
|
+
return [(x, False) for x in xs]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def norm(t):
|
|
126
|
+
if t[0] in "'\"":
|
|
127
|
+
return "<str>"
|
|
128
|
+
if re.fullmatch(r"\d+(\.\d+)?", t):
|
|
129
|
+
return "<num>"
|
|
130
|
+
return t.upper() if t.upper() in KEYWORDS else t.lower()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --------------------------------------------------------------------------- schema
|
|
134
|
+
class Schema:
|
|
135
|
+
"""Live view of the MySQL server: databases -> tables -> (column, type)."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, conn):
|
|
138
|
+
self.conn = conn
|
|
139
|
+
self.db = None
|
|
140
|
+
self.databases = []
|
|
141
|
+
self.cache = {}
|
|
142
|
+
self.value_cache = {}
|
|
143
|
+
self.refresh()
|
|
144
|
+
|
|
145
|
+
def q(self, sql, args=None):
|
|
146
|
+
with self.conn.cursor() as c:
|
|
147
|
+
c.execute(sql, args)
|
|
148
|
+
return c.fetchall()
|
|
149
|
+
|
|
150
|
+
def refresh(self):
|
|
151
|
+
self.db = self.q("SELECT DATABASE()")[0][0]
|
|
152
|
+
self.databases = [r[0] for r in self.q("SHOW DATABASES")]
|
|
153
|
+
self.cache.clear()
|
|
154
|
+
self.value_cache.clear()
|
|
155
|
+
|
|
156
|
+
def tbls(self, db=None):
|
|
157
|
+
db = db or self.db
|
|
158
|
+
if not db:
|
|
159
|
+
return {}
|
|
160
|
+
if db not in self.cache:
|
|
161
|
+
try:
|
|
162
|
+
t = defaultdict(list)
|
|
163
|
+
for tn, cn, dt in self.q(
|
|
164
|
+
"SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS "
|
|
165
|
+
"WHERE TABLE_SCHEMA=%s ORDER BY TABLE_NAME, ORDINAL_POSITION", (db,)):
|
|
166
|
+
t[tn].append((cn, str(dt).lower()))
|
|
167
|
+
self.cache[db] = dict(t)
|
|
168
|
+
except Exception:
|
|
169
|
+
self.cache[db] = {}
|
|
170
|
+
return self.cache[db]
|
|
171
|
+
|
|
172
|
+
def find_db(self, name):
|
|
173
|
+
return next((d for d in self.databases if d.lower() == name.lower()), None)
|
|
174
|
+
|
|
175
|
+
def find_table(self, name, db=None):
|
|
176
|
+
return next((t for t in self.tbls(db) if t.lower() == name.lower()), None)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------- engine
|
|
180
|
+
class Engine:
|
|
181
|
+
"""Context-aware next-token suggester + tiny learned n-gram ranker."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, schema, sample=True):
|
|
184
|
+
self.s = schema
|
|
185
|
+
self.sample = sample
|
|
186
|
+
try:
|
|
187
|
+
self.model = json.loads(MODEL_PATH.read_text())
|
|
188
|
+
except Exception:
|
|
189
|
+
self.model = {"uni": {}, "bi": {}, "tri": {}}
|
|
190
|
+
|
|
191
|
+
# ---- learning
|
|
192
|
+
def learn(self, sql):
|
|
193
|
+
toks = ["^"] + [norm(t) for t in TOK.findall(sql.replace("`", ""))]
|
|
194
|
+
m = self.model
|
|
195
|
+
for i in range(1, len(toks)):
|
|
196
|
+
for table, key in (("uni", toks[i]),
|
|
197
|
+
("bi", f"{toks[i-1]}|{toks[i]}"),
|
|
198
|
+
("tri", f"{toks[i-2]}|{toks[i-1]}|{toks[i]}" if i >= 2 else None)):
|
|
199
|
+
if key:
|
|
200
|
+
m[table][key] = m[table].get(key, 0) + 1
|
|
201
|
+
try:
|
|
202
|
+
MODEL_PATH.write_text(json.dumps(m))
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
def score(self, cand, p1, p2):
|
|
207
|
+
k = norm(cand.split()[0])
|
|
208
|
+
m = self.model
|
|
209
|
+
return (3 * m["tri"].get(f"{p2}|{p1}|{k}", 0) + 2 * m["bi"].get(f"{p1}|{k}", 0)
|
|
210
|
+
+ 0.2 * m["uni"].get(k, 0))
|
|
211
|
+
|
|
212
|
+
# ---- schema helpers
|
|
213
|
+
def query_tables(self, toks):
|
|
214
|
+
U = [t.upper() for t in toks]
|
|
215
|
+
out = []
|
|
216
|
+
for j, t in enumerate(U):
|
|
217
|
+
if t in ("FROM", "JOIN", "INTO") or (t == "UPDATE" and j == 0):
|
|
218
|
+
if j + 1 >= len(toks):
|
|
219
|
+
continue
|
|
220
|
+
name, db = toks[j + 1], None
|
|
221
|
+
if "." in name:
|
|
222
|
+
dbn, name = name.split(".", 1)
|
|
223
|
+
db = self.s.find_db(dbn)
|
|
224
|
+
real = self.s.find_table(name, db)
|
|
225
|
+
if not real:
|
|
226
|
+
continue
|
|
227
|
+
alias = real
|
|
228
|
+
if j + 2 < len(toks):
|
|
229
|
+
if U[j + 2] == "AS" and j + 3 < len(toks):
|
|
230
|
+
alias = toks[j + 3]
|
|
231
|
+
elif U[j + 2] not in KEYWORDS and re.match(r"[A-Za-z_]", toks[j + 2]):
|
|
232
|
+
alias = toks[j + 2]
|
|
233
|
+
out.append((alias, db or self.s.db, real))
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
def select_columns(self, toks):
|
|
237
|
+
"""Plain column names already written in the SELECT list (before the trailing FROM)."""
|
|
238
|
+
U = [t.upper() for t in toks]
|
|
239
|
+
if "SELECT" not in U:
|
|
240
|
+
return []
|
|
241
|
+
body = toks[len(U) - U[::-1].index("SELECT"): -1]
|
|
242
|
+
cols = []
|
|
243
|
+
for k, t in enumerate(body):
|
|
244
|
+
if not re.fullmatch(r"[A-Za-z_][\w$]*(\.[A-Za-z_][\w$]*)?", t) or t.upper() in KEYWORDS:
|
|
245
|
+
continue
|
|
246
|
+
if (k + 1 < len(body) and body[k + 1] == "(") or (k and body[k - 1].upper() == "AS"):
|
|
247
|
+
continue # function name or alias
|
|
248
|
+
cols.append(t.rpartition(".")[2].lower())
|
|
249
|
+
return cols
|
|
250
|
+
|
|
251
|
+
# ---- CREATE TABLE / CREATE DATABASE templates
|
|
252
|
+
@staticmethod
|
|
253
|
+
def demo_name(existing, base):
|
|
254
|
+
ex = {x.lower() for x in existing}
|
|
255
|
+
n = 1
|
|
256
|
+
while f"{base}_{n}" in ex:
|
|
257
|
+
n += 1
|
|
258
|
+
return f"{base}_{n}"
|
|
259
|
+
|
|
260
|
+
def create_bodies(self, existing):
|
|
261
|
+
out = [("(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, "
|
|
262
|
+
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", True),
|
|
263
|
+
("(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, "
|
|
264
|
+
"email VARCHAR(255) UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", True)]
|
|
265
|
+
for t in existing: # variant linked to a real table via foreign key
|
|
266
|
+
if any(c.lower() == "id" for c, _ in self.s.tbls()[t]):
|
|
267
|
+
out.append((f"(id INT AUTO_INCREMENT PRIMARY KEY, {t.lower()}_id INT NOT NULL, "
|
|
268
|
+
f"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "
|
|
269
|
+
f"FOREIGN KEY ({t.lower()}_id) REFERENCES {t}(id))", False))
|
|
270
|
+
break
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def create_state(rest):
|
|
275
|
+
"""Walk tokens after the table name -> (tokens of current column def, paren depth, column names)."""
|
|
276
|
+
depth, seg, names, started = 0, [], [], False
|
|
277
|
+
for t in rest:
|
|
278
|
+
if t == "(":
|
|
279
|
+
depth += 1
|
|
280
|
+
if depth == 1:
|
|
281
|
+
seg, started = [], True
|
|
282
|
+
continue
|
|
283
|
+
elif t == ")":
|
|
284
|
+
depth -= 1
|
|
285
|
+
if depth == 0:
|
|
286
|
+
continue
|
|
287
|
+
elif t == "," and depth == 1:
|
|
288
|
+
seg = []
|
|
289
|
+
continue
|
|
290
|
+
if started:
|
|
291
|
+
if (not seg and depth == 1 and re.match(r"[A-Za-z_]", t)
|
|
292
|
+
and t.upper() not in ("PRIMARY", "FOREIGN", "UNIQUE", "KEY", "INDEX", "CONSTRAINT")):
|
|
293
|
+
names.append(t)
|
|
294
|
+
seg.append(t)
|
|
295
|
+
return seg, depth, names
|
|
296
|
+
|
|
297
|
+
def create_table(self, since):
|
|
298
|
+
S = [t.upper() for t in since]
|
|
299
|
+
existing = list(self.s.tbls())
|
|
300
|
+
had_if = False
|
|
301
|
+
if S[:3] == ["IF", "NOT", "EXISTS"]:
|
|
302
|
+
since, S, had_if = since[3:], S[3:], True
|
|
303
|
+
elif S[:1] == ["IF"]:
|
|
304
|
+
return kw(["NOT EXISTS"] if len(S) == 1 else ["EXISTS"] if len(S) == 2 else [])
|
|
305
|
+
bodies = self.create_bodies(existing)
|
|
306
|
+
if not S: # demo name + complete structure
|
|
307
|
+
name = self.demo_name(existing, "table")
|
|
308
|
+
out = [(f"{name} {b}", k) for b, k in bodies]
|
|
309
|
+
if not had_if:
|
|
310
|
+
out.append((f"IF NOT EXISTS {name} {bodies[0][0]}", True))
|
|
311
|
+
return out
|
|
312
|
+
if len(S) == 1:
|
|
313
|
+
return bodies
|
|
314
|
+
seg, depth, names = self.create_state(since[1:])
|
|
315
|
+
if depth <= 0:
|
|
316
|
+
return kw(["ENGINE=InnoDB"]) if S[-1] == ")" else []
|
|
317
|
+
Sg = [t.upper() for t in seg]
|
|
318
|
+
inner = Sg.count("(") - Sg.count(")")
|
|
319
|
+
if Sg and Sg[0] in ("PRIMARY", "FOREIGN", "UNIQUE"): # table-level constraint
|
|
320
|
+
if Sg[-1] in ("PRIMARY", "FOREIGN", "UNIQUE") and len(Sg) == 1:
|
|
321
|
+
return kw(["KEY ("])
|
|
322
|
+
if Sg[-1] == "KEY":
|
|
323
|
+
return kw(["("])
|
|
324
|
+
if inner > 0:
|
|
325
|
+
return nm(names) if Sg[-1] in ("(", ",") else kw([")"])
|
|
326
|
+
if Sg[0] == "FOREIGN":
|
|
327
|
+
if "REFERENCES" not in Sg:
|
|
328
|
+
return kw(["REFERENCES"])
|
|
329
|
+
ri = Sg.index("REFERENCES")
|
|
330
|
+
if len(Sg) == ri + 1:
|
|
331
|
+
return nm(existing)
|
|
332
|
+
if len(Sg) == ri + 2:
|
|
333
|
+
t = self.s.find_table(seg[ri + 1])
|
|
334
|
+
return nm([f"({self.s.tbls()[t][0][0]})"]) if t else []
|
|
335
|
+
return kw([")"])
|
|
336
|
+
if not Sg:
|
|
337
|
+
used = {n.lower() for n in names}
|
|
338
|
+
cols = [c for c in COL_TEMPLATES if c.split()[0] not in used]
|
|
339
|
+
extra = ["PRIMARY KEY (", "FOREIGN KEY ("] if names else []
|
|
340
|
+
return kw(cols + extra)
|
|
341
|
+
if inner > 0:
|
|
342
|
+
return kw(["255)", "100)", "50)", "10,2)"]) if Sg[-1] == "(" else kw([")"])
|
|
343
|
+
if Sg[-1] == "PRIMARY":
|
|
344
|
+
return kw(["KEY"])
|
|
345
|
+
if Sg[-1] == "NOT":
|
|
346
|
+
return kw(["NULL"])
|
|
347
|
+
if Sg[-1] == "DEFAULT":
|
|
348
|
+
return kw(["NULL", "0", "1", "CURRENT_TIMESTAMP"])
|
|
349
|
+
if len(Sg) == 1: # column name typed -> guess a sensible type
|
|
350
|
+
n, types = seg[0].lower(), list(TYPES)
|
|
351
|
+
pref = ("INT" if n == "id" or n.endswith("_id") else
|
|
352
|
+
"TIMESTAMP" if n.endswith("_at") else
|
|
353
|
+
"DATE" if n.endswith("date") else
|
|
354
|
+
"DECIMAL(10,2)" if n in ("price", "amount", "salary", "total") else
|
|
355
|
+
"TEXT" if n in ("description", "notes", "content", "body") else None)
|
|
356
|
+
if pref:
|
|
357
|
+
types.remove(pref)
|
|
358
|
+
types.insert(0, pref)
|
|
359
|
+
return kw(types)
|
|
360
|
+
cons = [c for c in ("NOT NULL", "PRIMARY KEY", "AUTO_INCREMENT", "UNIQUE", "DEFAULT")
|
|
361
|
+
if c.split()[0] not in Sg]
|
|
362
|
+
has = any(w in Sg for w in COL_CONSTRAINTS)
|
|
363
|
+
return kw([")"] + cons) if has else kw(cons + [")"])
|
|
364
|
+
|
|
365
|
+
def columns(self, qt, qualified):
|
|
366
|
+
out = []
|
|
367
|
+
if qt:
|
|
368
|
+
for alias, db, t in qt:
|
|
369
|
+
for c, _ in self.s.tbls(db).get(t, []):
|
|
370
|
+
out.append(f"{alias}.{c}" if qualified else c)
|
|
371
|
+
else:
|
|
372
|
+
for cols in self.s.tbls().values():
|
|
373
|
+
out += [c for c, _ in cols]
|
|
374
|
+
return list(dict.fromkeys(out))
|
|
375
|
+
|
|
376
|
+
def resolve(self, tok, qt):
|
|
377
|
+
q, _, c = tok.rpartition(".")
|
|
378
|
+
for alias, db, t in qt:
|
|
379
|
+
if q and q.lower() not in (alias.lower(), t.lower()):
|
|
380
|
+
continue
|
|
381
|
+
for col, typ in self.s.tbls(db).get(t, []):
|
|
382
|
+
if col.lower() == c.lower():
|
|
383
|
+
return db, t, col, typ
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
def values(self, qt, tok):
|
|
387
|
+
if not self.sample:
|
|
388
|
+
return []
|
|
389
|
+
r = self.resolve(tok, qt)
|
|
390
|
+
if not r:
|
|
391
|
+
return []
|
|
392
|
+
db, t, col, typ = r
|
|
393
|
+
key = (db, t, col)
|
|
394
|
+
if key not in self.s.value_cache:
|
|
395
|
+
try:
|
|
396
|
+
rows = self.s.q(f"SELECT DISTINCT `{col}` FROM `{db}`.`{t}` "
|
|
397
|
+
f"WHERE `{col}` IS NOT NULL LIMIT 5")
|
|
398
|
+
except Exception:
|
|
399
|
+
rows = []
|
|
400
|
+
self.s.value_cache[key] = [x[0] for x in rows if not isinstance(x[0], (bytes, bytearray))]
|
|
401
|
+
out = []
|
|
402
|
+
for v in self.s.value_cache[key]:
|
|
403
|
+
s = str(v)
|
|
404
|
+
if len(s) <= 30:
|
|
405
|
+
out.append(s if typ in NUMERIC else "'" + s.replace("'", "''") + "'")
|
|
406
|
+
return out
|
|
407
|
+
|
|
408
|
+
# ---- grammar: what can come next?
|
|
409
|
+
def expect(self, toks, partial):
|
|
410
|
+
if "." in partial:
|
|
411
|
+
q = partial.rpartition(".")[0]
|
|
412
|
+
for alias, db, t in self.query_tables(toks):
|
|
413
|
+
if q.lower() in (alias.lower(), t.lower()):
|
|
414
|
+
return nm([f"{q}.{c}" for c, _ in self.s.tbls(db).get(t, [])])
|
|
415
|
+
d = self.s.find_db(q)
|
|
416
|
+
if d:
|
|
417
|
+
return nm([f"{q}.{t}" for t in self.s.tbls(d)])
|
|
418
|
+
t = self.s.find_table(q)
|
|
419
|
+
return nm([f"{q}.{c}" for c, _ in self.s.tbls()[t]]) if t else []
|
|
420
|
+
|
|
421
|
+
U = [t.upper() for t in toks]
|
|
422
|
+
if U[:1] == ["EXPLAIN"]:
|
|
423
|
+
toks, U = toks[1:], U[1:]
|
|
424
|
+
if not U:
|
|
425
|
+
return kw(["SELECT", "UPDATE", "DELETE FROM", "INSERT INTO"])
|
|
426
|
+
if not U:
|
|
427
|
+
return kw(START)
|
|
428
|
+
first, last = U[0], U[-1]
|
|
429
|
+
if len(U) == 1 and first in ONE:
|
|
430
|
+
return kw(ONE[first])
|
|
431
|
+
if last in ("LEFT", "RIGHT"):
|
|
432
|
+
return kw(["JOIN", "OUTER JOIN"])
|
|
433
|
+
if last in ("INNER", "CROSS", "OUTER"):
|
|
434
|
+
return kw(["JOIN"])
|
|
435
|
+
if last in ("GROUP", "ORDER"):
|
|
436
|
+
return kw(["BY"])
|
|
437
|
+
|
|
438
|
+
clause = head = copen = byname = None
|
|
439
|
+
since = []
|
|
440
|
+
for j, t in enumerate(U):
|
|
441
|
+
if first == "CREATE" and clause == "table":
|
|
442
|
+
since.append(toks[j])
|
|
443
|
+
continue
|
|
444
|
+
if t == "SELECT":
|
|
445
|
+
clause, since = "select", []
|
|
446
|
+
elif (t in ("FROM", "JOIN", "INTO") or (t in ("UPDATE", "DESCRIBE", "DESC") and j == 0)
|
|
447
|
+
or (t == "TABLE" and j > 0 and U[j - 1] in ("CREATE", "DROP", "ALTER", "TRUNCATE"))):
|
|
448
|
+
clause, head, since = "table", t, []
|
|
449
|
+
elif t in ("DATABASE", "SCHEMA", "USE"):
|
|
450
|
+
clause, since = "db", []
|
|
451
|
+
elif t == "SHOW" and j == 0:
|
|
452
|
+
clause, since = "show", []
|
|
453
|
+
elif t in ("WHERE", "HAVING", "ON"):
|
|
454
|
+
clause, copen, since = "cond", t, []
|
|
455
|
+
elif t in ("AND", "OR") and clause == "cond":
|
|
456
|
+
since = []
|
|
457
|
+
elif t == "BY":
|
|
458
|
+
clause, byname, since = "by", (U[j - 1] if j else ""), []
|
|
459
|
+
elif t == "LIMIT":
|
|
460
|
+
clause, since = "limit", []
|
|
461
|
+
elif t == "SET" and j > 0:
|
|
462
|
+
clause, since = "set", []
|
|
463
|
+
elif t in ("VALUES", "VALUE"):
|
|
464
|
+
clause, since = "values", []
|
|
465
|
+
else:
|
|
466
|
+
since.append(toks[j])
|
|
467
|
+
|
|
468
|
+
S = [s.upper() for s in since]
|
|
469
|
+
qt = self.query_tables(toks)
|
|
470
|
+
qual = len(qt) > 1
|
|
471
|
+
depth = since.count("(") - since.count(")")
|
|
472
|
+
is_col = lambda tok: self.resolve(tok, qt) is not None
|
|
473
|
+
cols = lambda: nm(self.columns(qt, qual))
|
|
474
|
+
tables = lambda: nm(list(self.s.tbls())) + (nm([d + "." for d in self.s.databases]) if partial else [])
|
|
475
|
+
|
|
476
|
+
if clause == "db":
|
|
477
|
+
if first == "CREATE":
|
|
478
|
+
return nm([self.demo_name(self.s.databases, "db")]) if not S else []
|
|
479
|
+
return [] if S else nm(self.s.databases)
|
|
480
|
+
|
|
481
|
+
if clause == "show":
|
|
482
|
+
if not S:
|
|
483
|
+
return kw(["DATABASES", "TABLES", "COLUMNS FROM", "CREATE TABLE", "INDEX FROM",
|
|
484
|
+
"PROCESSLIST", "STATUS", "VARIABLES", "TABLE STATUS"])
|
|
485
|
+
return kw(["TABLE"]) if S[-1] == "CREATE" else []
|
|
486
|
+
|
|
487
|
+
if clause == "table" and first == "CREATE":
|
|
488
|
+
return self.create_table(since)
|
|
489
|
+
|
|
490
|
+
if clause == "table":
|
|
491
|
+
if not S:
|
|
492
|
+
if head == "FROM" and first == "SELECT": # only tables that contain the chosen columns
|
|
493
|
+
need = set(self.select_columns(toks))
|
|
494
|
+
ok = [t for t, cl in self.s.tbls().items() if need <= {c.lower() for c, _ in cl}]
|
|
495
|
+
if need and ok:
|
|
496
|
+
return nm(ok) + (nm([d + "." for d in self.s.databases]) if partial else [])
|
|
497
|
+
return tables()
|
|
498
|
+
if first in ("DESCRIBE", "DESC", "TRUNCATE", "DROP", "CREATE"):
|
|
499
|
+
return []
|
|
500
|
+
if S[-1] == ",":
|
|
501
|
+
return tables()
|
|
502
|
+
if S[-1] == "AS":
|
|
503
|
+
return []
|
|
504
|
+
if first == "INSERT":
|
|
505
|
+
t = self.s.find_table(since[0].split(".")[-1])
|
|
506
|
+
tc = [c for c, _ in self.s.tbls().get(t, [])] if t else []
|
|
507
|
+
if "(" not in S:
|
|
508
|
+
return nm(["(" + ", ".join(tc) + ") VALUES ("]) if tc else kw(["("])
|
|
509
|
+
if ")" in S:
|
|
510
|
+
return kw(["VALUES"])
|
|
511
|
+
return nm(tc) if S[-1] in ("(", ",") else kw([")"])
|
|
512
|
+
if head == "JOIN":
|
|
513
|
+
return kw(["ON"])
|
|
514
|
+
if first in ("SELECT", "DELETE"):
|
|
515
|
+
return kw(["WHERE", "JOIN", "LEFT JOIN", "INNER JOIN", "RIGHT JOIN",
|
|
516
|
+
"GROUP BY", "ORDER BY", "LIMIT"])
|
|
517
|
+
if first == "UPDATE":
|
|
518
|
+
return kw(["SET"])
|
|
519
|
+
if first == "ALTER":
|
|
520
|
+
if len(S) == 1:
|
|
521
|
+
return kw(["ADD COLUMN", "DROP COLUMN", "MODIFY COLUMN", "ADD INDEX", "RENAME TO"])
|
|
522
|
+
if S[-1] == "COLUMN" and len(S) >= 2 and S[-2] in ("DROP", "MODIFY", "CHANGE"):
|
|
523
|
+
t = self.s.find_table(since[0])
|
|
524
|
+
return nm([c for c, _ in self.s.tbls().get(t, [])]) if t else []
|
|
525
|
+
return []
|
|
526
|
+
|
|
527
|
+
if clause == "select":
|
|
528
|
+
funcs = ["COUNT(*)", "COUNT(", "MAX(", "MIN(", "SUM(", "AVG("]
|
|
529
|
+
if not S:
|
|
530
|
+
return kw(["*", "DISTINCT"]) + cols() + kw(funcs)
|
|
531
|
+
if S[-1] in (",", "DISTINCT"):
|
|
532
|
+
return cols() + kw(funcs)
|
|
533
|
+
if S[-1] == "(":
|
|
534
|
+
return kw(["*"]) + cols()
|
|
535
|
+
if S[-1] == "*" and len(S) >= 2 and S[-2] == "(":
|
|
536
|
+
return kw([")"])
|
|
537
|
+
if depth > 0:
|
|
538
|
+
return kw([")"])
|
|
539
|
+
return [] if S[-1] == "AS" else kw(["FROM", "AS"])
|
|
540
|
+
|
|
541
|
+
if clause == "cond":
|
|
542
|
+
if not S or S[-1] in ("AND", "OR"):
|
|
543
|
+
return cols()
|
|
544
|
+
if S[-1] == "(":
|
|
545
|
+
if len(S) >= 3 and S[-2] == "IN":
|
|
546
|
+
return nm(self.values(qt, since[-3]))
|
|
547
|
+
return cols()
|
|
548
|
+
if S[-1] == "NOT":
|
|
549
|
+
return kw(["LIKE", "IN", "BETWEEN"]) if len(S) > 1 and is_col(since[-2]) else cols()
|
|
550
|
+
if S[-1] == "IS":
|
|
551
|
+
return kw(["NULL", "NOT NULL"])
|
|
552
|
+
if S[-1] == "IN":
|
|
553
|
+
return kw(["("])
|
|
554
|
+
if S[-1] in OPS and len(S) >= 2:
|
|
555
|
+
return nm(self.values(qt, since[-2]))
|
|
556
|
+
if is_col(since[-1]):
|
|
557
|
+
return kw(OPERATORS)
|
|
558
|
+
if depth > 0:
|
|
559
|
+
return kw([")"])
|
|
560
|
+
if copen == "ON":
|
|
561
|
+
return kw(["WHERE", "JOIN", "LEFT JOIN", "AND", "GROUP BY", "ORDER BY", "LIMIT"])
|
|
562
|
+
if copen == "WHERE" and first == "SELECT":
|
|
563
|
+
return kw(["AND", "OR", "GROUP BY", "ORDER BY", "LIMIT"])
|
|
564
|
+
return kw(["AND", "OR", "ORDER BY", "LIMIT"])
|
|
565
|
+
|
|
566
|
+
if clause == "by":
|
|
567
|
+
if not S or S[-1] == ",":
|
|
568
|
+
return cols()
|
|
569
|
+
if S[-1] in ("ASC", "DESC"):
|
|
570
|
+
return kw(["LIMIT"])
|
|
571
|
+
return kw(["HAVING", "ORDER BY", "LIMIT"]) if byname == "GROUP" else kw(["DESC", "ASC", "LIMIT"])
|
|
572
|
+
|
|
573
|
+
if clause == "limit":
|
|
574
|
+
return kw(["10", "100", "1"]) if not S else (kw(["OFFSET"]) if len(S) == 1 else [])
|
|
575
|
+
|
|
576
|
+
if clause == "set":
|
|
577
|
+
if not S or S[-1] == ",":
|
|
578
|
+
return nm(self.columns(qt, False))
|
|
579
|
+
if S[-1] == "=":
|
|
580
|
+
return nm(self.values(qt, since[-2])) if len(S) >= 2 else []
|
|
581
|
+
return kw(["="]) if is_col(since[-1]) else kw(["WHERE"])
|
|
582
|
+
|
|
583
|
+
if clause == "values":
|
|
584
|
+
return kw(["("]) if not S else []
|
|
585
|
+
|
|
586
|
+
return []
|
|
587
|
+
|
|
588
|
+
# ---- public: list of ghost-text suffixes, best first
|
|
589
|
+
def suggestions(self, text):
|
|
590
|
+
stmt = text.rsplit(";", 1)[-1].replace("`", "")
|
|
591
|
+
toks = TOK.findall(stmt)
|
|
592
|
+
if toks and toks[-1][0] in "'\"" and (len(toks[-1]) < 2 or toks[-1][-1] != toks[-1][0]):
|
|
593
|
+
return [] # inside an unfinished string literal
|
|
594
|
+
partial = ""
|
|
595
|
+
if toks and not stmt[-1].isspace() and re.match(r"[\w$]", toks[-1]):
|
|
596
|
+
partial = toks.pop()
|
|
597
|
+
lead = "" if (not text or text[-1].isspace() or text[-1] == "(") else " "
|
|
598
|
+
|
|
599
|
+
def pick(c, p):
|
|
600
|
+
return [(x, k) for x, k in c if x.lower().startswith(p.lower()) and x.lower() != p.lower()]
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
picked = pick(self.expect(toks, partial), partial)
|
|
604
|
+
if partial and not picked: # word is already complete -> suggest what follows it
|
|
605
|
+
toks.append(partial)
|
|
606
|
+
partial, lead = "", " "
|
|
607
|
+
picked = pick(self.expect(toks, ""), "")
|
|
608
|
+
except Exception:
|
|
609
|
+
return []
|
|
610
|
+
|
|
611
|
+
lower = partial.islower() if partial else any(t.islower() for t in toks if t.upper() in KEYWORDS)
|
|
612
|
+
p1 = norm(toks[-1]) if toks else "^"
|
|
613
|
+
p2 = norm(toks[-2]) if len(toks) > 1 else "^"
|
|
614
|
+
ranked = sorted(enumerate(picked), key=lambda p: (-self.score(p[1][0], p1, p2), p[0]))
|
|
615
|
+
out = []
|
|
616
|
+
for _, (c, is_kw) in ranked:
|
|
617
|
+
if is_kw and lower:
|
|
618
|
+
c = c.lower()
|
|
619
|
+
s = c[len(partial):] if partial else lead + c
|
|
620
|
+
if s not in out:
|
|
621
|
+
out.append(s)
|
|
622
|
+
return out[:30]
|
|
623
|
+
|
|
624
|
+
# ---- FULL mode: chain best suggestions into a whole query
|
|
625
|
+
def chain(self, text, first=None, max_steps=12):
|
|
626
|
+
out, cur, s = "", text, first
|
|
627
|
+
for step in range(max_steps):
|
|
628
|
+
if s is None:
|
|
629
|
+
opts = self.suggestions(cur)
|
|
630
|
+
if not opts:
|
|
631
|
+
break
|
|
632
|
+
s = opts[0]
|
|
633
|
+
word = s.strip().split()[0].upper() if s.strip() else ""
|
|
634
|
+
if word in OPTIONAL: # only add optional clauses you've used before
|
|
635
|
+
toks = TOK.findall(cur.rsplit(";", 1)[-1].replace("`", ""))
|
|
636
|
+
p1 = norm(toks[-1]) if toks else "^"
|
|
637
|
+
p2 = norm(toks[-2]) if len(toks) > 1 else "^"
|
|
638
|
+
if self.score(word, p1, p2) <= 0:
|
|
639
|
+
break
|
|
640
|
+
if s.startswith(")") and out.endswith(" "):
|
|
641
|
+
out, cur = out[:-1], cur[:-1]
|
|
642
|
+
piece = s + ("" if s.endswith(("(", ".")) else " ")
|
|
643
|
+
out += piece
|
|
644
|
+
cur += piece
|
|
645
|
+
if s.endswith("("): # needs user input here
|
|
646
|
+
break
|
|
647
|
+
s = None
|
|
648
|
+
return out.rstrip()
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# --------------------------------------------------------------------------- prompt_toolkit glue
|
|
652
|
+
class GhostSuggest(AutoSuggest):
|
|
653
|
+
def __init__(self, engine):
|
|
654
|
+
self.engine, self.idx, self.text, self.options = engine, 0, None, []
|
|
655
|
+
self.full = False
|
|
656
|
+
self.enabled = False # suggestions are OFF by default
|
|
657
|
+
self.list_mode = False # persistent dropdown list (Ctrl+Space)
|
|
658
|
+
self.forced = None # text for which the user forced a one-off suggestion
|
|
659
|
+
|
|
660
|
+
def get_suggestion(self, buffer, document):
|
|
661
|
+
text = document.text_before_cursor
|
|
662
|
+
if not self.enabled and self.forced != text:
|
|
663
|
+
self.options = []
|
|
664
|
+
return None
|
|
665
|
+
if text != self.text:
|
|
666
|
+
self.text, self.idx = text, 0
|
|
667
|
+
self.options = self.compute(text)
|
|
668
|
+
if not self.options:
|
|
669
|
+
return None
|
|
670
|
+
return Suggestion(self.options[self.idx % len(self.options)])
|
|
671
|
+
|
|
672
|
+
def compute(self, text):
|
|
673
|
+
opts = self.engine.suggestions(text)
|
|
674
|
+
if not self.full:
|
|
675
|
+
return opts
|
|
676
|
+
out = []
|
|
677
|
+
for o in opts[:8]:
|
|
678
|
+
f = self.engine.chain(text, first=o)
|
|
679
|
+
if f and f not in out:
|
|
680
|
+
out.append(f)
|
|
681
|
+
return out
|
|
682
|
+
|
|
683
|
+
def toggle_enabled(self, buffer):
|
|
684
|
+
self.enabled = not self.enabled
|
|
685
|
+
self.text = self.forced = None
|
|
686
|
+
buffer.suggestion = self.get_suggestion(buffer, buffer.document)
|
|
687
|
+
|
|
688
|
+
def force(self, buffer):
|
|
689
|
+
"""Suggest right now at the cursor, regardless of ON/OFF. Returns True if ghost text shown."""
|
|
690
|
+
doc = buffer.document
|
|
691
|
+
if doc.is_cursor_at_the_end and not (buffer.suggestion and buffer.suggestion.text):
|
|
692
|
+
self.forced, self.text = doc.text_before_cursor, None
|
|
693
|
+
buffer.suggestion = self.get_suggestion(buffer, doc)
|
|
694
|
+
return buffer.suggestion is not None
|
|
695
|
+
return False
|
|
696
|
+
|
|
697
|
+
def toggle(self, buffer):
|
|
698
|
+
self.full = not self.full
|
|
699
|
+
self.enabled = True
|
|
700
|
+
self.text = None
|
|
701
|
+
buffer.suggestion = self.get_suggestion(buffer, buffer.document)
|
|
702
|
+
|
|
703
|
+
def next(self, buffer):
|
|
704
|
+
if self.options:
|
|
705
|
+
self.idx = (self.idx + 1) % len(self.options)
|
|
706
|
+
buffer.suggestion = self.get_suggestion(buffer, buffer.document)
|
|
707
|
+
|
|
708
|
+
def reset(self):
|
|
709
|
+
self.text = self.forced = None
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
class GhostCompleter(Completer):
|
|
713
|
+
"""Live dropdown (LIST mode / force key); honours NEXT/FULL mode and labels each item."""
|
|
714
|
+
|
|
715
|
+
def __init__(self, ghost):
|
|
716
|
+
self.ghost = ghost
|
|
717
|
+
|
|
718
|
+
def kind(self, item):
|
|
719
|
+
w, sc = item.strip(), self.ghost.engine.s
|
|
720
|
+
if not w:
|
|
721
|
+
return ""
|
|
722
|
+
if w[0] in "'\"" or re.fullmatch(r"\d+(\.\d+)?", w):
|
|
723
|
+
return "value"
|
|
724
|
+
f = re.split(r"[\s(]", w)[0]
|
|
725
|
+
if f.upper() in KEYWORDS:
|
|
726
|
+
return "keyword"
|
|
727
|
+
g = f.rpartition(".")[2].lower()
|
|
728
|
+
if g in {t.lower() for t in sc.tbls()}:
|
|
729
|
+
return "table"
|
|
730
|
+
if g in {c.lower() for cl in sc.tbls().values() for c, _ in cl}:
|
|
731
|
+
return "column"
|
|
732
|
+
if g in {d.lower() for d in sc.databases}:
|
|
733
|
+
return "database"
|
|
734
|
+
return "snippet" if (len(w) > 25 or not f) else ""
|
|
735
|
+
|
|
736
|
+
def get_completions(self, document, complete_event):
|
|
737
|
+
text = document.text_before_cursor
|
|
738
|
+
m = re.search(r"[\w$.]+$", text)
|
|
739
|
+
partial = m.group(0) if m else ""
|
|
740
|
+
for o in self.ghost.compute(text)[:15]:
|
|
741
|
+
shown = (partial + o) if partial else (o.strip() or o)
|
|
742
|
+
yield Completion(o, start_position=0, display=shown, display_meta=self.kind(shown))
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def first_word(s):
|
|
746
|
+
"""First unit of a suggestion: a word, a quoted value, or a (...) group; keeps leading space."""
|
|
747
|
+
rest = s.lstrip()
|
|
748
|
+
if not rest:
|
|
749
|
+
return s
|
|
750
|
+
lead = s[:len(s) - len(rest)]
|
|
751
|
+
if rest[0] in "'\"" and rest.find(rest[0], 1) > 0:
|
|
752
|
+
end = rest.find(rest[0], 1) + 1
|
|
753
|
+
elif rest[0] == "(" and ")" in rest:
|
|
754
|
+
d, end = 0, len(rest)
|
|
755
|
+
for i, ch in enumerate(rest):
|
|
756
|
+
d += (ch == "(") - (ch == ")")
|
|
757
|
+
if d == 0:
|
|
758
|
+
end = i + 1
|
|
759
|
+
break
|
|
760
|
+
else:
|
|
761
|
+
end = re.match(r"\S+", rest).end()
|
|
762
|
+
return lead + rest[:end]
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def risky(stmt):
|
|
766
|
+
u = re.sub(r"\s+", " ", stmt.strip().upper())
|
|
767
|
+
if re.match(r"(DROP|TRUNCATE)\b", u):
|
|
768
|
+
return "this permanently removes data/structure"
|
|
769
|
+
if re.match(r"(DELETE|UPDATE)\b", u) and not re.search(r"\bWHERE\b", u):
|
|
770
|
+
return "no WHERE clause - this affects ALL rows"
|
|
771
|
+
return None
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def show(cur):
|
|
775
|
+
if not cur.description:
|
|
776
|
+
print(f"OK, {cur.rowcount} row(s) affected")
|
|
777
|
+
return
|
|
778
|
+
head = [d[0] for d in cur.description]
|
|
779
|
+
rows = [["NULL" if v is None else str(v)[:40] for v in r] for r in cur.fetchmany(200)]
|
|
780
|
+
w = [max([len(h)] + [len(r[i]) for r in rows]) for i, h in enumerate(head)]
|
|
781
|
+
line = "+" + "+".join("-" * (x + 2) for x in w) + "+"
|
|
782
|
+
fmt = lambda r: "| " + " | ".join(c.ljust(w[i]) for i, c in enumerate(r)) + " |"
|
|
783
|
+
print(line, fmt(head), line, *[fmt(r) for r in rows], line, sep="\n")
|
|
784
|
+
print(f"{len(rows)} row(s)")
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def main():
|
|
788
|
+
ap = argparse.ArgumentParser(
|
|
789
|
+
prog="minesql", formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
790
|
+
description="MySQL terminal with schema-aware ghost-text SQL suggestions.",
|
|
791
|
+
epilog=HELP + "\nEXAMPLES\n minesql -u root -d shop\n"
|
|
792
|
+
" minesql -H db.example.com -P 3307 -u app --full\n")
|
|
793
|
+
ap.add_argument("-H", "--host", default="127.0.0.1")
|
|
794
|
+
ap.add_argument("-P", "--port", type=int, default=3306)
|
|
795
|
+
ap.add_argument("-u", "--user", default="root")
|
|
796
|
+
ap.add_argument("-p", "--password", default=os.environ.get("MYSQL_PWD"))
|
|
797
|
+
ap.add_argument("-d", "--database", help="database to start in")
|
|
798
|
+
ap.add_argument("--suggest", action="store_true", help="start with suggestions ON (default is OFF)")
|
|
799
|
+
ap.add_argument("--full", action="store_true", help="start with FULL inline suggestions ON")
|
|
800
|
+
ap.add_argument("--no-confirm", dest="confirm", action="store_false",
|
|
801
|
+
help="don't ask before DELETE/UPDATE without WHERE, DROP, TRUNCATE")
|
|
802
|
+
ap.add_argument("--no-values", action="store_true",
|
|
803
|
+
help="don't sample column values for suggestions (faster on huge tables)")
|
|
804
|
+
a = ap.parse_args()
|
|
805
|
+
if a.password is None:
|
|
806
|
+
a.password = getpass.getpass("Password: ")
|
|
807
|
+
try:
|
|
808
|
+
conn = pymysql.connect(host=a.host, port=a.port, user=a.user, password=a.password,
|
|
809
|
+
database=a.database, autocommit=True, charset="utf8mb4")
|
|
810
|
+
except pymysql.MySQLError as e:
|
|
811
|
+
sys.exit(f"Could not connect: {e}")
|
|
812
|
+
|
|
813
|
+
schema = Schema(conn)
|
|
814
|
+
engine = Engine(schema, sample=not a.no_values)
|
|
815
|
+
ghost = GhostSuggest(engine)
|
|
816
|
+
ghost.full = a.full
|
|
817
|
+
ghost.enabled = a.full or a.suggest
|
|
818
|
+
|
|
819
|
+
kb = KeyBindings()
|
|
820
|
+
|
|
821
|
+
def insert(b, t):
|
|
822
|
+
if t.startswith(")") and b.text.endswith(" "):
|
|
823
|
+
b.delete_before_cursor(1)
|
|
824
|
+
b.insert_text(t + ("" if t.endswith(("(", ".")) else " "))
|
|
825
|
+
|
|
826
|
+
@kb.add("tab")
|
|
827
|
+
def _accept(event): # FULL mode: one word at a time
|
|
828
|
+
b = event.current_buffer
|
|
829
|
+
if b.complete_state: # dropdown open: move through it
|
|
830
|
+
b.complete_next()
|
|
831
|
+
elif b.suggestion and b.suggestion.text:
|
|
832
|
+
t = b.suggestion.text
|
|
833
|
+
insert(b, first_word(t) if ghost.full else t)
|
|
834
|
+
|
|
835
|
+
@kb.add("c-o")
|
|
836
|
+
def _onoff(event):
|
|
837
|
+
ghost.toggle_enabled(event.current_buffer)
|
|
838
|
+
event.app.invalidate()
|
|
839
|
+
|
|
840
|
+
@kb.add("escape", "/")
|
|
841
|
+
def _force(event): # one-off suggestion at the cursor
|
|
842
|
+
b = event.current_buffer
|
|
843
|
+
if not ghost.force(b):
|
|
844
|
+
b.start_completion(select_first=False)
|
|
845
|
+
event.app.invalidate()
|
|
846
|
+
|
|
847
|
+
@kb.add("c-space")
|
|
848
|
+
@kb.add("escape", "l")
|
|
849
|
+
def _list(event): # persistent LIST mode toggle
|
|
850
|
+
b = event.current_buffer
|
|
851
|
+
ghost.list_mode = not ghost.list_mode
|
|
852
|
+
if ghost.list_mode:
|
|
853
|
+
b.start_completion(select_first=False)
|
|
854
|
+
else:
|
|
855
|
+
b.complete_state = None
|
|
856
|
+
event.app.invalidate()
|
|
857
|
+
|
|
858
|
+
picked = Condition(lambda: bool(get_app().current_buffer.complete_state
|
|
859
|
+
and get_app().current_buffer.complete_state.current_completion))
|
|
860
|
+
|
|
861
|
+
@kb.add("enter", filter=picked)
|
|
862
|
+
def _pick(event): # Enter on a highlighted item = choose it, not run
|
|
863
|
+
b = event.current_buffer
|
|
864
|
+
b.complete_state = None
|
|
865
|
+
if not b.text.endswith((" ", "(", ".")):
|
|
866
|
+
b.insert_text(" ")
|
|
867
|
+
|
|
868
|
+
@kb.add("c-e")
|
|
869
|
+
@kb.add("f20") # f20 = Ctrl+Tab on terminals that report it
|
|
870
|
+
def _accept_all(event):
|
|
871
|
+
b = event.current_buffer
|
|
872
|
+
if b.suggestion and b.suggestion.text and b.document.is_cursor_at_the_end:
|
|
873
|
+
insert(b, b.suggestion.text)
|
|
874
|
+
else:
|
|
875
|
+
b.cursor_position = len(b.text) # normal Ctrl+E: jump to end of line
|
|
876
|
+
|
|
877
|
+
try: # teach prompt_toolkit common Ctrl+Tab sequences
|
|
878
|
+
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
|
|
879
|
+
from prompt_toolkit.keys import Keys
|
|
880
|
+
for seq in ("\x1b[27;5;9~", "\x1b[9;5u", "\x1b[1;5I"):
|
|
881
|
+
ANSI_SEQUENCES[seq] = Keys.F20
|
|
882
|
+
except Exception:
|
|
883
|
+
pass
|
|
884
|
+
|
|
885
|
+
@kb.add("c-t")
|
|
886
|
+
def _mode(event):
|
|
887
|
+
ghost.toggle(event.current_buffer)
|
|
888
|
+
event.app.invalidate()
|
|
889
|
+
|
|
890
|
+
@kb.add("s-tab")
|
|
891
|
+
def _next(event):
|
|
892
|
+
if event.current_buffer.complete_state:
|
|
893
|
+
event.current_buffer.complete_previous()
|
|
894
|
+
return
|
|
895
|
+
ghost.next(event.current_buffer)
|
|
896
|
+
event.app.invalidate()
|
|
897
|
+
|
|
898
|
+
def toolbar():
|
|
899
|
+
n = len(ghost.options)
|
|
900
|
+
pos = f" ({ghost.idx % n + 1}/{n})" if n else ""
|
|
901
|
+
mode = "OFF" if not ghost.enabled else ("FULL" if ghost.full else "NEXT")
|
|
902
|
+
if ghost.list_mode:
|
|
903
|
+
mode += "+LIST"
|
|
904
|
+
keys = ("Ctrl+O on" if not ghost.enabled else
|
|
905
|
+
"Tab 1 word | Ctrl+E all" if ghost.full else "Tab accept")
|
|
906
|
+
return (f" [{mode}] {keys} | Ctrl+Space list | Alt+/ force | Shift+Tab next{pos} "
|
|
907
|
+
f"| Ctrl+T mode | \\help")
|
|
908
|
+
|
|
909
|
+
style = Style.from_dict({"auto-suggestion": "fg:#808080", "bottom-toolbar": "bg:#222222 #aaaaaa"})
|
|
910
|
+
session = PromptSession(history=FileHistory(str(HISTORY_PATH)), auto_suggest=ghost,
|
|
911
|
+
key_bindings=kb, style=style, completer=GhostCompleter(ghost),
|
|
912
|
+
complete_while_typing=Condition(lambda: ghost.list_mode), bottom_toolbar=toolbar,
|
|
913
|
+
enable_history_search=False)
|
|
914
|
+
|
|
915
|
+
def prime(): # show a suggestion even on an empty prompt
|
|
916
|
+
b = get_app().current_buffer
|
|
917
|
+
b.suggestion = ghost.get_suggestion(b, b.document)
|
|
918
|
+
|
|
919
|
+
print(f"Connected to {a.host}:{a.port} as {a.user}. Current database: {schema.db}")
|
|
920
|
+
print("Suggestions " + ("ON" if ghost.enabled else "OFF (Ctrl+O to enable)")
|
|
921
|
+
+ " | Ctrl+Space list on/off | Alt+/ force one | Tab accept | Ctrl+T FULL/NEXT | \\help")
|
|
922
|
+
while True:
|
|
923
|
+
ghost.reset()
|
|
924
|
+
try:
|
|
925
|
+
sql = session.prompt(lambda: f"mysql [{schema.db or '-'}]> ", pre_run=prime).strip()
|
|
926
|
+
except KeyboardInterrupt:
|
|
927
|
+
continue
|
|
928
|
+
except EOFError:
|
|
929
|
+
break
|
|
930
|
+
if not sql:
|
|
931
|
+
continue
|
|
932
|
+
if sql.lower() in ("exit", "quit", "\\q"):
|
|
933
|
+
break
|
|
934
|
+
if sql.lower() == "\\help":
|
|
935
|
+
print(HELP)
|
|
936
|
+
continue
|
|
937
|
+
if sql.lower() == "\\full":
|
|
938
|
+
ghost.full, ghost.enabled = not ghost.full, True
|
|
939
|
+
print("Mode:", "FULL" if ghost.full else "NEXT")
|
|
940
|
+
continue
|
|
941
|
+
if sql.lower() == "\\list":
|
|
942
|
+
ghost.list_mode = not ghost.list_mode
|
|
943
|
+
print("List mode:", "ON" if ghost.list_mode else "OFF")
|
|
944
|
+
continue
|
|
945
|
+
if sql.lower() == "\\suggest":
|
|
946
|
+
ghost.enabled = not ghost.enabled
|
|
947
|
+
print("Suggestions:", "ON" if ghost.enabled else "OFF")
|
|
948
|
+
continue
|
|
949
|
+
ran = True
|
|
950
|
+
try:
|
|
951
|
+
with conn.cursor() as cur:
|
|
952
|
+
t0 = time.time()
|
|
953
|
+
for stmt in filter(None, (s.strip() for s in sql.split(";"))):
|
|
954
|
+
why = risky(stmt) if a.confirm else None
|
|
955
|
+
if why and input(f"\033[33mWARNING: {why}. Run anyway? [y/N] \033[0m"
|
|
956
|
+
).strip().lower() not in ("y", "yes"):
|
|
957
|
+
print("Cancelled.")
|
|
958
|
+
ran = False
|
|
959
|
+
break
|
|
960
|
+
cur.execute(stmt)
|
|
961
|
+
show(cur)
|
|
962
|
+
if ran:
|
|
963
|
+
print(f"({time.time() - t0:.3f}s)")
|
|
964
|
+
if ran:
|
|
965
|
+
engine.learn(sql)
|
|
966
|
+
if sql.split()[0].upper() in ("USE", "CREATE", "DROP", "ALTER", "RENAME", "TRUNCATE"):
|
|
967
|
+
schema.refresh()
|
|
968
|
+
except pymysql.MySQLError as e:
|
|
969
|
+
print(f"\033[31mERROR: {e}\033[0m")
|
|
970
|
+
except (KeyboardInterrupt, EOFError):
|
|
971
|
+
print("Cancelled.")
|
|
972
|
+
conn.close()
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
if __name__ == "__main__":
|
|
976
|
+
main()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: minesql
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A MySQL terminal that only suggests the NEXT SQL piece — schema-aware ghost-text autocomplete for your SQL sessions.
|
|
5
|
+
Author: IMApurbo
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/IMApurbo/minesql
|
|
8
|
+
Project-URL: Repository, https://github.com/IMApurbo/minesql
|
|
9
|
+
Project-URL: Issues, https://github.com/IMApurbo/minesql/issues
|
|
10
|
+
Keywords: mysql,sql,cli,autocomplete,repl,terminal,ghost-text,prompt_toolkit
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Database
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Terminals
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
28
|
+
Requires-Dist: PyMySQL>=1.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# minesql
|
|
36
|
+
|
|
37
|
+
A MySQL terminal that only suggests the **next** SQL piece.
|
|
38
|
+
|
|
39
|
+
* Gray "ghost" text after your cursor = the current best suggestion
|
|
40
|
+
* `Tab` — accept the suggestion
|
|
41
|
+
* `Shift+Tab` — show the next-best suggestion
|
|
42
|
+
* `Ctrl+T` — toggle NEXT-piece mode ↔ FULL inline mode (whole query predicted)
|
|
43
|
+
* `Ctrl+O` — suggestions ON/OFF (OFF is the default at startup)
|
|
44
|
+
* `Ctrl+Space` — LIST mode ON/OFF: a live suggestion dropdown that stays open
|
|
45
|
+
and updates as you type until you toggle it off (works even when ghost
|
|
46
|
+
suggestions are OFF). `Alt+L` is an alias if `Ctrl+Space` is grabbed by your
|
|
47
|
+
terminal/OS.
|
|
48
|
+
* `Alt+/` — force ONE suggestion right here, right now (any cursor position)
|
|
49
|
+
* FULL mode: `Tab` accepts ONE word, `Ctrl+E` (or `Ctrl+Tab`, if your terminal
|
|
50
|
+
sends it) accepts the WHOLE suggestion
|
|
51
|
+
* `Enter` — run the query
|
|
52
|
+
* `Ctrl+D` — exit
|
|
53
|
+
|
|
54
|
+
Suggestions are schema-aware: database, table, and column names (plus sample
|
|
55
|
+
values) come straight from the live MySQL server via `information_schema`. A
|
|
56
|
+
small n-gram model learns from the queries you run (saved in
|
|
57
|
+
`~/.minesql_model.json`) and re-ranks suggestions so your habits float to
|
|
58
|
+
the top.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install minesql
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
minesql -H 127.0.0.1 -u root -d mydb
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
usage: minesql [-h] [-H HOST] [-P PORT] [-u USER] [-p PASSWORD] [-d DATABASE]
|
|
74
|
+
[--suggest] [--full] [--no-confirm] [--no-values]
|
|
75
|
+
|
|
76
|
+
MySQL terminal with schema-aware ghost-text SQL suggestions.
|
|
77
|
+
|
|
78
|
+
options:
|
|
79
|
+
-h, --help show this help message and exit
|
|
80
|
+
-H HOST, --host HOST
|
|
81
|
+
-P PORT, --port PORT
|
|
82
|
+
-u USER, --user USER
|
|
83
|
+
-p PASSWORD, --password PASSWORD
|
|
84
|
+
-d DATABASE, --database DATABASE
|
|
85
|
+
database to start in
|
|
86
|
+
--suggest start with suggestions ON (default is OFF)
|
|
87
|
+
--full start with FULL inline suggestions ON
|
|
88
|
+
--no-confirm don't ask before DELETE/UPDATE without WHERE, DROP, TRUNCATE
|
|
89
|
+
--no-values don't sample column values for suggestions (faster on huge tables)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Examples
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
minesql -u root -d shop
|
|
96
|
+
minesql -H db.example.com -P 3307 -u app --full
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Keys (full reference)
|
|
100
|
+
|
|
101
|
+
| Key | Action |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `Tab` | accept the gray suggestion (in FULL mode: just the next word/unit) |
|
|
104
|
+
| `Ctrl+E` | accept the WHOLE suggestion (also `Ctrl+Tab` on terminals that send it, e.g. kitty, WezTerm, iTerm2, xterm with modifyOtherKeys) |
|
|
105
|
+
| `Shift+Tab` | cycle to the next-best suggestion |
|
|
106
|
+
| `Ctrl+O` | suggestions ON/OFF (OFF by default; start ON with `--suggest` or `--full`) |
|
|
107
|
+
| `Ctrl+Space` / `Alt+L` | LIST mode ON/OFF — a live dropdown of suggestions (with type labels: keyword/table/column/value/snippet) that stays open and updates as you type until toggled off. Works even while ghost suggestions are OFF. `Tab`/`Shift+Tab` move in the list, `Enter` picks the highlighted item (it does **not** run the query). |
|
|
108
|
+
| `Alt+/` | force ONE suggestion at the cursor right now (ghost at end of line, dropdown mid-line), even while OFF |
|
|
109
|
+
| `Ctrl+T` | switch suggestion mode NEXT ↔ FULL (also turns suggestions on) |
|
|
110
|
+
| Right arrow | accept without the trailing space |
|
|
111
|
+
| Up/Down | command history |
|
|
112
|
+
| `Enter` | run query |
|
|
113
|
+
| `Ctrl+C` | cancel line |
|
|
114
|
+
| `Ctrl+D` | exit |
|
|
115
|
+
|
|
116
|
+
### In-app commands
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
\help show the in-app help
|
|
120
|
+
\full toggle FULL/NEXT mode (turns suggestions on)
|
|
121
|
+
\suggest toggle suggestions ON/OFF
|
|
122
|
+
\list toggle LIST mode
|
|
123
|
+
\q / exit / quit leave
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Safety
|
|
127
|
+
|
|
128
|
+
`DELETE`/`UPDATE` without `WHERE`, `DROP`, and `TRUNCATE` ask for confirmation
|
|
129
|
+
first (disable with `--no-confirm`).
|
|
130
|
+
|
|
131
|
+
### Modes
|
|
132
|
+
|
|
133
|
+
* **NEXT** — suggests only the next keyword / table / column / value.
|
|
134
|
+
* **FULL** — chains predictions into a whole query. Optional clauses
|
|
135
|
+
(`WHERE`, `AND`, `ORDER BY`, `LIMIT`, ...) are only added once the model has
|
|
136
|
+
learned you use them after that context. Sample values are guesses — edit
|
|
137
|
+
them.
|
|
138
|
+
|
|
139
|
+
### Suggestion sources
|
|
140
|
+
|
|
141
|
+
Live schema (`SHOW DATABASES`, `information_schema`), sample column values,
|
|
142
|
+
and an n-gram model learned from your queries (`~/.minesql_model.json`).
|
|
143
|
+
|
|
144
|
+
## Requirements
|
|
145
|
+
|
|
146
|
+
* Python 3.8+
|
|
147
|
+
* [`prompt_toolkit`](https://pypi.org/project/prompt-toolkit/)
|
|
148
|
+
* [`PyMySQL`](https://pypi.org/project/PyMySQL/)
|
|
149
|
+
|
|
150
|
+
These are installed automatically with `pip install minesql`.
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
git clone https://github.com/IMApurbo/minesql.git
|
|
156
|
+
cd minesql
|
|
157
|
+
pip install -e .
|
|
158
|
+
minesql --help
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT © [IMApurbo](https://github.com/IMApurbo)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
minesql/__init__.py,sha256=PGhSMzVpRzX3FFM8EnsB0UVcHgQzuTxfeIdIUEEQHag,280
|
|
2
|
+
minesql/cli.py,sha256=1KjEzM7Ri50MIJCdOmZXOWiPyE1PZsXDrExX2Y_fHcM,40983
|
|
3
|
+
minesql-0.1.0.dist-info/licenses/LICENSE,sha256=pyj5MjYA5S_gXK9tPn2KmVBLK2UbgSjFsVXZKzc56nc,1076
|
|
4
|
+
minesql-0.1.0.dist-info/METADATA,sha256=lQSTzzopgoXGsHZD-jqfs4WyF-geRUPZDGxECYx0Waw,5874
|
|
5
|
+
minesql-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
minesql-0.1.0.dist-info/entry_points.txt,sha256=fbmninT3CK-XzRvW3X0W626o6WZ1TpbnsmHY5BU5J9Y,45
|
|
7
|
+
minesql-0.1.0.dist-info/top_level.txt,sha256=pSZgq3guCdmNJqOWm72NKC21xpjAH0ExBvMzGydXc3Y,8
|
|
8
|
+
minesql-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akm Korishee Apurbo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
minesql
|