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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ minesql = minesql.cli:main
@@ -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