ghostbase-cli 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.
- app.py +856 -0
- db_engine.py +396 -0
- exporter.py +55 -0
- ghostbase_cli-0.1.0.dist-info/METADATA +165 -0
- ghostbase_cli-0.1.0.dist-info/RECORD +10 -0
- ghostbase_cli-0.1.0.dist-info/WHEEL +5 -0
- ghostbase_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ghostbase_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ghostbase_cli-0.1.0.dist-info/top_level.txt +4 -0
- main.py +117 -0
db_engine.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import time
|
|
3
|
+
import urllib.parse
|
|
4
|
+
import re
|
|
5
|
+
from typing import List, Dict, Any, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DatabaseEngine:
|
|
9
|
+
"""Database connection and query handler for SQLite, Postgres, and MySQL."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, connection_string: str = None):
|
|
12
|
+
self.connection_string = (connection_string or "sample.db").strip()
|
|
13
|
+
self.db_type = "sqlite"
|
|
14
|
+
self.db_name = ""
|
|
15
|
+
self.conn = None
|
|
16
|
+
self._parse_and_connect()
|
|
17
|
+
|
|
18
|
+
# --- Connection handling ---
|
|
19
|
+
|
|
20
|
+
def _parse_and_connect(self):
|
|
21
|
+
cs = self.connection_string
|
|
22
|
+
if cs.startswith(("postgresql://", "postgres://")):
|
|
23
|
+
self.db_type = "postgresql"
|
|
24
|
+
parsed = urllib.parse.urlparse(cs)
|
|
25
|
+
self.db_name = parsed.path.lstrip("/")
|
|
26
|
+
self._connect_postgres(cs)
|
|
27
|
+
elif cs.startswith("mysql://"):
|
|
28
|
+
self.db_type = "mysql"
|
|
29
|
+
parsed = urllib.parse.urlparse(cs)
|
|
30
|
+
self.db_name = parsed.path.lstrip("/")
|
|
31
|
+
self._connect_mysql(cs)
|
|
32
|
+
else:
|
|
33
|
+
self.db_type = "sqlite"
|
|
34
|
+
path = cs.removeprefix("sqlite:///")
|
|
35
|
+
self.db_name = path
|
|
36
|
+
self._connect_sqlite(path)
|
|
37
|
+
|
|
38
|
+
def _connect_sqlite(self, path: str):
|
|
39
|
+
self.conn = sqlite3.connect(path, check_same_thread=False)
|
|
40
|
+
self.conn.row_factory = sqlite3.Row
|
|
41
|
+
|
|
42
|
+
def _connect_postgres(self, url: str):
|
|
43
|
+
try:
|
|
44
|
+
import psycopg2
|
|
45
|
+
except ImportError:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
"psycopg2 is not installed.\n"
|
|
48
|
+
"Run: pip install psycopg2-binary"
|
|
49
|
+
)
|
|
50
|
+
self.conn = psycopg2.connect(url)
|
|
51
|
+
self.conn.autocommit = False
|
|
52
|
+
|
|
53
|
+
def _connect_mysql(self, url: str):
|
|
54
|
+
try:
|
|
55
|
+
import pymysql
|
|
56
|
+
import pymysql.cursors
|
|
57
|
+
except ImportError:
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"pymysql is not installed.\n"
|
|
60
|
+
"Run: pip install pymysql"
|
|
61
|
+
)
|
|
62
|
+
parsed = urllib.parse.urlparse(url)
|
|
63
|
+
self.conn = pymysql.connect(
|
|
64
|
+
host=parsed.hostname or "localhost",
|
|
65
|
+
port=parsed.port or 3306,
|
|
66
|
+
user=parsed.username or "root",
|
|
67
|
+
password=parsed.password or "",
|
|
68
|
+
database=parsed.path.lstrip("/"),
|
|
69
|
+
cursorclass=pymysql.cursors.DictCursor,
|
|
70
|
+
autocommit=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# --- Schema discovery ---
|
|
74
|
+
|
|
75
|
+
def get_tables(self) -> List[str]:
|
|
76
|
+
try:
|
|
77
|
+
cur = self.conn.cursor()
|
|
78
|
+
if self.db_type == "sqlite":
|
|
79
|
+
cur.execute(
|
|
80
|
+
"SELECT name FROM sqlite_master "
|
|
81
|
+
"WHERE type='table' AND name NOT LIKE 'sqlite_%' "
|
|
82
|
+
"ORDER BY name;"
|
|
83
|
+
)
|
|
84
|
+
return [r[0] for r in cur.fetchall()]
|
|
85
|
+
elif self.db_type == "postgresql":
|
|
86
|
+
cur.execute(
|
|
87
|
+
"SELECT table_name FROM information_schema.tables "
|
|
88
|
+
"WHERE table_schema='public' AND table_type='BASE TABLE' "
|
|
89
|
+
"ORDER BY table_name;"
|
|
90
|
+
)
|
|
91
|
+
return [r[0] for r in cur.fetchall()]
|
|
92
|
+
elif self.db_type == "mysql":
|
|
93
|
+
cur.execute("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE';")
|
|
94
|
+
rows = cur.fetchall()
|
|
95
|
+
return [list(r.values())[0] if isinstance(r, dict) else r[0] for r in rows]
|
|
96
|
+
except Exception:
|
|
97
|
+
return []
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
def get_views(self) -> List[str]:
|
|
101
|
+
try:
|
|
102
|
+
cur = self.conn.cursor()
|
|
103
|
+
if self.db_type == "sqlite":
|
|
104
|
+
cur.execute(
|
|
105
|
+
"SELECT name FROM sqlite_master WHERE type='view' ORDER BY name;"
|
|
106
|
+
)
|
|
107
|
+
return [r[0] for r in cur.fetchall()]
|
|
108
|
+
elif self.db_type == "postgresql":
|
|
109
|
+
cur.execute(
|
|
110
|
+
"SELECT table_name FROM information_schema.views "
|
|
111
|
+
"WHERE table_schema='public' ORDER BY table_name;"
|
|
112
|
+
)
|
|
113
|
+
return [r[0] for r in cur.fetchall()]
|
|
114
|
+
elif self.db_type == "mysql":
|
|
115
|
+
cur.execute("SHOW FULL TABLES WHERE Table_type = 'VIEW';")
|
|
116
|
+
rows = cur.fetchall()
|
|
117
|
+
return [list(r.values())[0] if isinstance(r, dict) else r[0] for r in rows]
|
|
118
|
+
except Exception:
|
|
119
|
+
return []
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
def get_table_schema(self, table_name: str) -> List[Dict[str, Any]]:
|
|
123
|
+
try:
|
|
124
|
+
cur = self.conn.cursor()
|
|
125
|
+
if self.db_type == "sqlite":
|
|
126
|
+
cur.execute(f"PRAGMA table_info([{table_name}]);")
|
|
127
|
+
return [
|
|
128
|
+
{"name": r[1], "type": r[2], "notnull": r[3],
|
|
129
|
+
"dflt_value": r[4], "pk": r[5]}
|
|
130
|
+
for r in cur.fetchall()
|
|
131
|
+
]
|
|
132
|
+
elif self.db_type == "postgresql":
|
|
133
|
+
cur.execute(
|
|
134
|
+
"""
|
|
135
|
+
SELECT
|
|
136
|
+
c.column_name,
|
|
137
|
+
c.data_type,
|
|
138
|
+
CASE WHEN c.is_nullable = 'NO' THEN 1 ELSE 0 END,
|
|
139
|
+
c.column_default,
|
|
140
|
+
CASE WHEN kcu.column_name IS NOT NULL THEN 1 ELSE 0 END
|
|
141
|
+
FROM information_schema.columns c
|
|
142
|
+
LEFT JOIN information_schema.table_constraints tc
|
|
143
|
+
ON tc.table_name = c.table_name
|
|
144
|
+
AND tc.constraint_type = 'PRIMARY KEY'
|
|
145
|
+
AND tc.table_schema = 'public'
|
|
146
|
+
LEFT JOIN information_schema.key_column_usage kcu
|
|
147
|
+
ON kcu.constraint_name = tc.constraint_name
|
|
148
|
+
AND kcu.column_name = c.column_name
|
|
149
|
+
WHERE c.table_name = %s AND c.table_schema = 'public'
|
|
150
|
+
ORDER BY c.ordinal_position;
|
|
151
|
+
""",
|
|
152
|
+
(table_name,),
|
|
153
|
+
)
|
|
154
|
+
return [
|
|
155
|
+
{"name": r[0], "type": r[1], "notnull": r[2],
|
|
156
|
+
"dflt_value": r[3], "pk": r[4]}
|
|
157
|
+
for r in cur.fetchall()
|
|
158
|
+
]
|
|
159
|
+
elif self.db_type == "mysql":
|
|
160
|
+
cur.execute(f"DESCRIBE `{table_name}`;")
|
|
161
|
+
rows = cur.fetchall()
|
|
162
|
+
result = []
|
|
163
|
+
for r in rows:
|
|
164
|
+
d = r if isinstance(r, dict) else dict(
|
|
165
|
+
zip(["Field", "Type", "Null", "Key", "Default", "Extra"], r)
|
|
166
|
+
)
|
|
167
|
+
result.append({
|
|
168
|
+
"name": d.get("Field", ""),
|
|
169
|
+
"type": d.get("Type", ""),
|
|
170
|
+
"notnull": 1 if d.get("Null") == "NO" else 0,
|
|
171
|
+
"dflt_value": d.get("Default"),
|
|
172
|
+
"pk": 1 if d.get("Key") == "PRI" else 0,
|
|
173
|
+
})
|
|
174
|
+
return result
|
|
175
|
+
except Exception:
|
|
176
|
+
return []
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
def get_foreign_keys(self, table_name: str) -> List[Dict[str, str]]:
|
|
180
|
+
try:
|
|
181
|
+
cur = self.conn.cursor()
|
|
182
|
+
if self.db_type == "sqlite":
|
|
183
|
+
cur.execute(f"PRAGMA foreign_key_list([{table_name}]);")
|
|
184
|
+
return [
|
|
185
|
+
{"from": r[3], "to_table": r[2], "to_col": r[4]}
|
|
186
|
+
for r in cur.fetchall()
|
|
187
|
+
]
|
|
188
|
+
elif self.db_type == "postgresql":
|
|
189
|
+
cur.execute(
|
|
190
|
+
"""
|
|
191
|
+
SELECT kcu.column_name,
|
|
192
|
+
ccu.table_name AS to_table,
|
|
193
|
+
ccu.column_name AS to_col
|
|
194
|
+
FROM information_schema.table_constraints tc
|
|
195
|
+
JOIN information_schema.key_column_usage kcu
|
|
196
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
197
|
+
AND tc.table_schema = kcu.table_schema
|
|
198
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
199
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
200
|
+
AND ccu.table_schema = tc.table_schema
|
|
201
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
202
|
+
AND tc.table_name = %s;
|
|
203
|
+
""",
|
|
204
|
+
(table_name,),
|
|
205
|
+
)
|
|
206
|
+
return [
|
|
207
|
+
{"from": r[0], "to_table": r[1], "to_col": r[2]}
|
|
208
|
+
for r in cur.fetchall()
|
|
209
|
+
]
|
|
210
|
+
elif self.db_type == "mysql":
|
|
211
|
+
cur.execute("SELECT DATABASE();")
|
|
212
|
+
row = cur.fetchone()
|
|
213
|
+
db = list(row.values())[0] if isinstance(row, dict) else row[0]
|
|
214
|
+
cur.execute(
|
|
215
|
+
"""
|
|
216
|
+
SELECT COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
|
217
|
+
FROM information_schema.KEY_COLUMN_USAGE
|
|
218
|
+
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
|
219
|
+
AND REFERENCED_TABLE_NAME IS NOT NULL;
|
|
220
|
+
""",
|
|
221
|
+
(db, table_name),
|
|
222
|
+
)
|
|
223
|
+
rows = cur.fetchall()
|
|
224
|
+
return [
|
|
225
|
+
{
|
|
226
|
+
"from": (r.get("COLUMN_NAME") if isinstance(r, dict) else r[0]),
|
|
227
|
+
"to_table": (r.get("REFERENCED_TABLE_NAME") if isinstance(r, dict) else r[1]),
|
|
228
|
+
"to_col": (r.get("REFERENCED_COLUMN_NAME") if isinstance(r, dict) else r[2]),
|
|
229
|
+
}
|
|
230
|
+
for r in rows
|
|
231
|
+
]
|
|
232
|
+
except Exception:
|
|
233
|
+
return []
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
def get_row_count(self, table_name: str) -> int:
|
|
237
|
+
try:
|
|
238
|
+
cur = self.conn.cursor()
|
|
239
|
+
if self.db_type == "sqlite":
|
|
240
|
+
cur.execute(f"SELECT COUNT(*) FROM [{table_name}];")
|
|
241
|
+
elif self.db_type == "postgresql":
|
|
242
|
+
cur.execute(f'SELECT COUNT(*) FROM "{table_name}";')
|
|
243
|
+
else:
|
|
244
|
+
cur.execute(f"SELECT COUNT(*) FROM `{table_name}`;")
|
|
245
|
+
row = cur.fetchone()
|
|
246
|
+
val = list(row.values())[0] if isinstance(row, dict) else row[0]
|
|
247
|
+
return int(val)
|
|
248
|
+
except Exception:
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
# --- Query safety and formatting ---
|
|
252
|
+
|
|
253
|
+
def check_safety(self, query: str) -> Tuple[bool, str]:
|
|
254
|
+
"""Detect destructive operations without WHERE clauses or schema drops."""
|
|
255
|
+
q_clean = re.sub(r"--.*$", "", query, flags=re.MULTILINE)
|
|
256
|
+
q_upper = q_clean.strip().upper()
|
|
257
|
+
|
|
258
|
+
if re.search(r"\bDROP\s+(TABLE|DATABASE|SCHEMA|VIEW)\b", q_upper):
|
|
259
|
+
return True, "DROP operation detected"
|
|
260
|
+
if re.search(r"\bTRUNCATE\b", q_upper):
|
|
261
|
+
return True, "TRUNCATE operation detected"
|
|
262
|
+
if re.search(r"\bDELETE\s+FROM\b", q_upper) and not re.search(r"\bWHERE\b", q_upper):
|
|
263
|
+
return True, "DELETE without WHERE clause (will delete all rows)"
|
|
264
|
+
if re.search(r"\bUPDATE\b", q_upper) and not re.search(r"\bWHERE\b", q_upper):
|
|
265
|
+
return True, "UPDATE without WHERE clause (will modify all rows)"
|
|
266
|
+
if re.search(r"\bALTER\s+TABLE\s+.*\bDROP\b", q_upper):
|
|
267
|
+
return True, "ALTER TABLE DROP detected"
|
|
268
|
+
|
|
269
|
+
return False, ""
|
|
270
|
+
|
|
271
|
+
def explain_query(self, query: str) -> Dict[str, Any]:
|
|
272
|
+
clean_q = re.sub(r"--.*$", "", query, flags=re.MULTILINE).strip().rstrip(";")
|
|
273
|
+
if not clean_q:
|
|
274
|
+
return {"success": False, "error": "Query is empty"}
|
|
275
|
+
|
|
276
|
+
if self.db_type == "sqlite":
|
|
277
|
+
explain_sql = f"EXPLAIN QUERY PLAN {clean_q};"
|
|
278
|
+
else:
|
|
279
|
+
explain_sql = f"EXPLAIN {clean_q};"
|
|
280
|
+
return self.execute_query(explain_sql)
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def format_sql(query: str) -> str:
|
|
284
|
+
"""Format and uppercase SQL keywords with line indents."""
|
|
285
|
+
if not query or not query.strip():
|
|
286
|
+
return query
|
|
287
|
+
|
|
288
|
+
lines = query.strip().splitlines()
|
|
289
|
+
comment_lines = []
|
|
290
|
+
sql_lines = []
|
|
291
|
+
for line in lines:
|
|
292
|
+
if line.strip().startswith("--"):
|
|
293
|
+
comment_lines.append(line.strip())
|
|
294
|
+
else:
|
|
295
|
+
sql_lines.append(line)
|
|
296
|
+
|
|
297
|
+
sql_text = " ".join(sql_lines)
|
|
298
|
+
sql_text = re.sub(r"\s+", " ", sql_text).strip()
|
|
299
|
+
|
|
300
|
+
keywords = [
|
|
301
|
+
"SELECT", "FROM", "WHERE", "AND", "OR", "ORDER BY",
|
|
302
|
+
"GROUP BY", "HAVING", "LEFT JOIN", "RIGHT JOIN",
|
|
303
|
+
"INNER JOIN", "CROSS JOIN", "JOIN", "ON", "INSERT INTO",
|
|
304
|
+
"VALUES", "UPDATE", "SET", "DELETE FROM", "DELETE",
|
|
305
|
+
"LIMIT", "OFFSET", "AS", "CREATE TABLE", "CREATE VIEW",
|
|
306
|
+
"DROP TABLE", "DROP VIEW", "ALTER TABLE", "PRIMARY KEY",
|
|
307
|
+
"FOREIGN KEY", "REFERENCES", "DEFAULT", "NOT NULL",
|
|
308
|
+
"AUTOINCREMENT", "DESC", "ASC", "UNION ALL", "UNION",
|
|
309
|
+
"DISTINCT", "EXPLAIN QUERY PLAN", "EXPLAIN", "IN", "IS NULL",
|
|
310
|
+
"IS NOT NULL", "LIKE", "BETWEEN", "CASE", "WHEN", "THEN", "ELSE", "END",
|
|
311
|
+
"COUNT", "SUM", "AVG", "MIN", "MAX"
|
|
312
|
+
]
|
|
313
|
+
keywords.sort(key=len, reverse=True)
|
|
314
|
+
|
|
315
|
+
for kw in keywords:
|
|
316
|
+
pattern = rf"\b{re.escape(kw)}\b"
|
|
317
|
+
sql_text = re.sub(pattern, kw, sql_text, flags=re.IGNORECASE)
|
|
318
|
+
|
|
319
|
+
major_clauses = [
|
|
320
|
+
"LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "CROSS JOIN",
|
|
321
|
+
"FROM", "WHERE", "GROUP BY", "ORDER BY", "HAVING", "LIMIT",
|
|
322
|
+
"OFFSET", "SET", "VALUES"
|
|
323
|
+
]
|
|
324
|
+
for clause in major_clauses:
|
|
325
|
+
sql_text = re.sub(rf"\b({clause})\b", r"\n\1", sql_text)
|
|
326
|
+
sql_text = re.sub(r"(?<!LEFT\s)(?<!RIGHT\s)(?<!INNER\s)(?<!CROSS\s)\bJOIN\b", r"\nJOIN", sql_text)
|
|
327
|
+
|
|
328
|
+
sql_text = re.sub(r"\b(AND|OR)\b", r"\n \1", sql_text)
|
|
329
|
+
|
|
330
|
+
result_parts = []
|
|
331
|
+
if comment_lines:
|
|
332
|
+
result_parts.append("\n".join(comment_lines))
|
|
333
|
+
result_parts.append(sql_text.strip())
|
|
334
|
+
return "\n".join(result_parts)
|
|
335
|
+
|
|
336
|
+
# --- Query execution ---
|
|
337
|
+
|
|
338
|
+
def execute_query(self, query: str) -> Dict[str, Any]:
|
|
339
|
+
start = time.perf_counter()
|
|
340
|
+
try:
|
|
341
|
+
cur = self.conn.cursor()
|
|
342
|
+
cur.execute(query)
|
|
343
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
344
|
+
|
|
345
|
+
if cur.description:
|
|
346
|
+
columns = [d[0] for d in cur.description]
|
|
347
|
+
raw = cur.fetchall()
|
|
348
|
+
rows = []
|
|
349
|
+
for r in raw:
|
|
350
|
+
if isinstance(r, dict):
|
|
351
|
+
rows.append(list(r.values()))
|
|
352
|
+
else:
|
|
353
|
+
rows.append(list(r))
|
|
354
|
+
return {
|
|
355
|
+
"success": True,
|
|
356
|
+
"columns": columns,
|
|
357
|
+
"rows": rows,
|
|
358
|
+
"row_count": len(rows),
|
|
359
|
+
"elapsed_ms": round(elapsed_ms, 2),
|
|
360
|
+
"is_select": True,
|
|
361
|
+
}
|
|
362
|
+
else:
|
|
363
|
+
if self.db_type in ("sqlite", "postgresql"):
|
|
364
|
+
self.conn.commit()
|
|
365
|
+
return {
|
|
366
|
+
"success": True,
|
|
367
|
+
"affected_rows": max(cur.rowcount, 0),
|
|
368
|
+
"elapsed_ms": round(elapsed_ms, 2),
|
|
369
|
+
"is_select": False,
|
|
370
|
+
}
|
|
371
|
+
except Exception as e:
|
|
372
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
373
|
+
if self.db_type == "postgresql":
|
|
374
|
+
try:
|
|
375
|
+
self.conn.rollback()
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
return {
|
|
379
|
+
"success": False,
|
|
380
|
+
"error": str(e),
|
|
381
|
+
"elapsed_ms": round(elapsed_ms, 2),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
# --- Helpers ---
|
|
385
|
+
|
|
386
|
+
def db_type_label(self) -> str:
|
|
387
|
+
return {"sqlite": "SQLite", "postgresql": "PostgreSQL", "mysql": "MySQL"}.get(
|
|
388
|
+
self.db_type, self.db_type.upper()
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def close(self):
|
|
392
|
+
if self.conn:
|
|
393
|
+
try:
|
|
394
|
+
self.conn.close()
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
exporter.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import json
|
|
3
|
+
from typing import List, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Exporter:
|
|
7
|
+
@staticmethod
|
|
8
|
+
def to_csv(columns: List[str], rows: List[List[Any]], output_path: str):
|
|
9
|
+
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
|
10
|
+
writer = csv.writer(f)
|
|
11
|
+
writer.writerow(columns)
|
|
12
|
+
writer.writerows(rows)
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def to_json(columns: List[str], rows: List[List[Any]], output_path: str):
|
|
16
|
+
data = []
|
|
17
|
+
for r in rows:
|
|
18
|
+
data.append(dict(zip(columns, r)))
|
|
19
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
20
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def to_markdown(columns: List[str], rows: List[List[Any]], output_path: str):
|
|
24
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
25
|
+
# Header
|
|
26
|
+
f.write("| " + " | ".join(str(c) for c in columns) + " |\n")
|
|
27
|
+
# Separator
|
|
28
|
+
f.write("| " + " | ".join("---" for _ in columns) + " |\n")
|
|
29
|
+
# Rows
|
|
30
|
+
for r in rows:
|
|
31
|
+
clean_cells = [
|
|
32
|
+
(str(val).replace("|", "\\|").replace("\n", " ") if val is not None else "NULL")
|
|
33
|
+
for val in r
|
|
34
|
+
]
|
|
35
|
+
f.write("| " + " | ".join(clean_cells) + " |\n")
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def to_sql_inserts(table_name: str, columns: List[str], rows: List[List[Any]], output_path: str):
|
|
39
|
+
table = table_name or "exported_table"
|
|
40
|
+
cols_str = ", ".join(columns)
|
|
41
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
42
|
+
f.write(f"-- GhostBase SQL Export: {table}\n")
|
|
43
|
+
f.write(f"-- Total: {len(rows)} row(s)\n\n")
|
|
44
|
+
for r in rows:
|
|
45
|
+
vals = []
|
|
46
|
+
for v in r:
|
|
47
|
+
if v is None:
|
|
48
|
+
vals.append("NULL")
|
|
49
|
+
elif isinstance(v, (int, float)):
|
|
50
|
+
vals.append(str(v))
|
|
51
|
+
else:
|
|
52
|
+
escaped = str(v).replace("'", "''")
|
|
53
|
+
vals.append(f"'{escaped}'")
|
|
54
|
+
vals_str = ", ".join(vals)
|
|
55
|
+
f.write(f"INSERT INTO {table} ({cols_str}) VALUES ({vals_str});\n")
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ghostbase-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL
|
|
5
|
+
Author-email: 4yk5 <contact.4yk5@proton.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/4yk5/GhostBase
|
|
8
|
+
Project-URL: Repository, https://github.com/4yk5/GhostBase
|
|
9
|
+
Project-URL: Issues, https://github.com/4yk5/GhostBase/issues
|
|
10
|
+
Keywords: database,sql,tui,terminal,cli,sqlite,postgres,postgresql,mysql,dbeaver,datagrip,query,developer-tools
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Topic :: Database
|
|
15
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: textual>=8.0.0
|
|
20
|
+
Requires-Dist: rich>=14.0.0
|
|
21
|
+
Requires-Dist: psycopg2-binary>=2.9.0
|
|
22
|
+
Requires-Dist: pymysql>=1.1.0
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
____ _ _ ____
|
|
27
|
+
/ ___| |__ ___ ___| |_| __ ) __ _ ___ ___
|
|
28
|
+
| | _| '_ \ / _ \/ __| __| _ \ / _` / __|/ _ \
|
|
29
|
+
| |_| | | | | (_) \__ \ |_| |_) | (_| \__ \ __/
|
|
30
|
+
\____|_| |_|\___/|___/\__|____/ \__,_|___/\___|
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
# GhostBase 👻🗄️
|
|
35
|
+
|
|
36
|
+
> **A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL, and MySQL.**
|
|
37
|
+
> *No Java runtimes. No 1.5GB RAM bloat. Built-in production safety guard, instant SQL beautifier & zero-config `.env` connect.*
|
|
38
|
+
|
|
39
|
+
[](https://github.com/4yk5/GhostBase/actions)
|
|
40
|
+
[](https://pypi.org/project/ghostbase-cli/)
|
|
41
|
+
[](https://www.python.org/)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+
[](https://github.com/Textualize/textual)
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## ⚡ Why GhostBase?
|
|
48
|
+
|
|
49
|
+
Opening **DBeaver** or **DataGrip** just to run a quick `SELECT`, inspect an index, or check foreign keys is painful:
|
|
50
|
+
- ❌ **1.5 GB+ RAM bloat:** Heavy Java/Eclipse runtimes that spin your laptop fans.
|
|
51
|
+
👉 **GhostBase idles at ~25 MB RAM.**
|
|
52
|
+
- ❌ **15-Second Cold Starts:** Splash screens, driver updates, and workspace indexing.
|
|
53
|
+
👉 **GhostBase starts in under 120 ms directly in your terminal.**
|
|
54
|
+
- ❌ **Accidental Destructive Queries on Production:** Running `DELETE` or `UPDATE` without a `WHERE` clause can wipe production tables.
|
|
55
|
+
👉 **GhostBase intercepts destructive queries automatically with double-confirmation and one-key Read-Only Safe Mode (`Ctrl + S`).**
|
|
56
|
+
- ❌ **Messy, Unformatted Queries:** Manually formatting and indenting long SQL statements.
|
|
57
|
+
👉 **GhostBase auto-formats and beautifies messy SQL queries with one keypress (`Ctrl + B`).**
|
|
58
|
+
- ❌ **Tedious Connection Setup:** Typing credentials into multi-tab dialogs.
|
|
59
|
+
👉 **GhostBase auto-detects `DATABASE_URL` from your local `.env` file.**
|
|
60
|
+
- ❌ **Seeding & Migrating Data:** Manually writing `INSERT INTO` statements to replicate test rows.
|
|
61
|
+
👉 **GhostBase exports query results directly into executable SQL `INSERT` statements (`Ctrl + I`).**
|
|
62
|
+
- ❌ **Exporting for GitHub / Jira / Notion:** Exporting CSV, then finding a website to convert to Markdown.
|
|
63
|
+
👉 **One-key instant Markdown table export (`Ctrl + M`) directly into clean GitHub Flavored Markdown.**
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 🚀 Killer Features
|
|
68
|
+
|
|
69
|
+
- 🛡️ **Production Safety Guard & Safe Mode (`Ctrl + S`):**
|
|
70
|
+
- Intercepts dangerous statements (`DELETE`/`UPDATE` without `WHERE`, `DROP TABLE`, `TRUNCATE`).
|
|
71
|
+
- In **Safe Mode**, all destructive operations are strictly blocked.
|
|
72
|
+
- In normal mode, requires explicit double-confirmation before execution.
|
|
73
|
+
- ✨ **1-Key SQL Beautifier (`Ctrl + B`):**
|
|
74
|
+
- Instantly cleans up whitespace, capitalizes SQL keywords, and indents sub-clauses (`FROM`, `WHERE`, `AND`, `OR`, `JOIN`, `ORDER BY`).
|
|
75
|
+
- ⚡ **1-Key Query Plan (`Ctrl + P`):**
|
|
76
|
+
- Run instant execution plans (`EXPLAIN QUERY PLAN` / `EXPLAIN`) on any query to see index usage and bottlenecks.
|
|
77
|
+
- 🔌 **Universal Database Support:** Connects natively to **SQLite**, **PostgreSQL**, and **MySQL**.
|
|
78
|
+
- 🔍 **Zero-Config `.env` Auto-Connect:** Just run `ghostbase` in any project folder; it automatically picks up `DATABASE_URL` or standard database environment variables.
|
|
79
|
+
- 🔎 **Real-time Table Search (`Ctrl + F`):** Type in the search bar to filter dozens of tables and views instantly.
|
|
80
|
+
- 📊 **Explorer with Tables & Views:** Browse tables and views with live row counts.
|
|
81
|
+
- 📐 **Instant Schema & Foreign Keys:** See primary keys, column types, nullability, and foreign key references at a glance.
|
|
82
|
+
- 💾 **Multi-Format Exports:** Instant SQL `INSERT`s (`Ctrl + I`), Markdown (`Ctrl + M`), CSV (`Ctrl + E`), and JSON (`Ctrl + J`).
|
|
83
|
+
- 📜 **Query History:** Cycle through your last 20 queries with `Ctrl + ↑` and `Ctrl + ↓`.
|
|
84
|
+
- 🎨 **Clean Cyberpunk Aesthetic:** Fast, high-contrast, zero-flicker terminal interface.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 📦 Installation & Quick Start
|
|
89
|
+
|
|
90
|
+
### Via pip
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install ghostbase-cli
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### From Source
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
git clone https://github.com/4yk5/GhostBase.git
|
|
100
|
+
cd GhostBase
|
|
101
|
+
pip install -r requirements.txt
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 💻 How to Use
|
|
107
|
+
|
|
108
|
+
### 1. Auto-connect to current project (`.env`)
|
|
109
|
+
```bash
|
|
110
|
+
# Automatically finds DATABASE_URL or DB_HOST in your current directory's .env
|
|
111
|
+
ghostbase
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 2. Connect to SQLite
|
|
115
|
+
```bash
|
|
116
|
+
ghostbase path/to/database.db
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 3. Connect to PostgreSQL
|
|
120
|
+
```bash
|
|
121
|
+
ghostbase postgresql://user:password@localhost:5432/my_database
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 4. Connect to MySQL
|
|
125
|
+
```bash
|
|
126
|
+
ghostbase mysql://user:password@localhost:3306/my_database
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 5. Launch in Safe / Read-Only Mode
|
|
130
|
+
```bash
|
|
131
|
+
ghostbase postgresql://prod-db:5432/main --read-only
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## ⌨️ Keyboard Shortcuts
|
|
137
|
+
|
|
138
|
+
| Shortcut | Description |
|
|
139
|
+
| :--- | :--- |
|
|
140
|
+
| `Ctrl + R` | **Execute SQL Query** (runs the query with safety validation) |
|
|
141
|
+
| `Ctrl + P` | **1-Key EXPLAIN** (instant query execution plan and performance inspector) |
|
|
142
|
+
| `Ctrl + B` | **Beautify SQL** (auto-formats, capitalizes keywords and indents clauses) |
|
|
143
|
+
| `Ctrl + S` | **Toggle Safe Mode** (enable/disable destructive query protection) |
|
|
144
|
+
| `Ctrl + N` | **New Query** (clears editor for a clean query) |
|
|
145
|
+
| `Ctrl + F` | **Find Table** (focuses table search filter) |
|
|
146
|
+
| `Ctrl + I` | **Export to SQL INSERTs** (generates executable `INSERT INTO` statements) |
|
|
147
|
+
| `Ctrl + M` | **Export to Markdown** (GitHub / Jira / Notion ready table) |
|
|
148
|
+
| `Ctrl + E` | **Export to CSV** (exports current table/query to CSV) |
|
|
149
|
+
| `Ctrl + J` | **Export to JSON** (exports current table/query to JSON) |
|
|
150
|
+
| `Ctrl + ↑` / `Ctrl + ↓` | **Query History** (cycle through previous queries) |
|
|
151
|
+
| `Tab` / `Shift + Tab` | Navigate between explorer, search, editor, and data table |
|
|
152
|
+
| `Ctrl + Q` | **Quit GhostBase** |
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 📬 Contact & Inquiries
|
|
157
|
+
|
|
158
|
+
For partnerships, feature requests, or business inquiries:
|
|
159
|
+
📧 **[contact.4yk5@proton.me](mailto:contact.4yk5@proton.me)**
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 📄 License
|
|
164
|
+
|
|
165
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
app.py,sha256=6ld3qVBcpzGV8TN_asTxCWN4Yky3WTHL9ygqPi1eUj0,26752
|
|
2
|
+
db_engine.py,sha256=ZTFwBn1ALGdZCqHijMm6HGc_Q9r1AgJn0d5sTdl4BUo,15774
|
|
3
|
+
exporter.py,sha256=LWP5aH5T1hfgv1S-3cUzznQe4QuWt5cPwl_A5P6h_PA,2200
|
|
4
|
+
main.py,sha256=Jds9cR2JS6hI4ZAHFyKcSCdqdojXybNI0ra7xMO_tS4,3949
|
|
5
|
+
ghostbase_cli-0.1.0.dist-info/licenses/LICENSE,sha256=bRo5mnl7qxXILhxEc0K1gp_mlFDLvbZN6ZxQyAyPtMQ,1061
|
|
6
|
+
ghostbase_cli-0.1.0.dist-info/METADATA,sha256=b6pkOhxKF6Fbh3tz6_58lYw2sYPnqONOmDA3IFPfqMQ,7346
|
|
7
|
+
ghostbase_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
ghostbase_cli-0.1.0.dist-info/entry_points.txt,sha256=MU-O2U7GG76Irs1c7hQmlG8YkQ40sOlPnpU0RKI8Kzc,40
|
|
9
|
+
ghostbase_cli-0.1.0.dist-info/top_level.txt,sha256=AUwoRm-j29Gjq6eOpfILcrpBYifVS0Pi2JCHvoi4Ufg,28
|
|
10
|
+
ghostbase_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 4yk5
|
|
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.
|