ghostbase-cli 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {ghostbase_cli-0.1.0/ghostbase_cli.egg-info → ghostbase_cli-0.1.2}/PKG-INFO +11 -8
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/README.md +10 -7
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/app.py +187 -112
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/db_engine.py +222 -33
- ghostbase_cli-0.1.2/exporter.py +119 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2/ghostbase_cli.egg-info}/PKG-INFO +11 -8
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/ghostbase_cli.egg-info/SOURCES.txt +2 -1
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/main.py +6 -2
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/pyproject.toml +1 -1
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/tests/test_exhaustive.py +1 -1
- ghostbase_cli-0.1.2/tests/test_vulnerabilities_fixed.py +288 -0
- ghostbase_cli-0.1.0/exporter.py +0 -55
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/LICENSE +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/ghostbase_cli.egg-info/dependency_links.txt +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/ghostbase_cli.egg-info/entry_points.txt +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/ghostbase_cli.egg-info/requires.txt +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/ghostbase_cli.egg-info/top_level.txt +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/setup.cfg +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/tests/test_all.py +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.2}/tests/test_real_db.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ghostbase-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL
|
|
5
5
|
Author-email: 4yk5 <contact.4yk5@proton.me>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -31,7 +31,7 @@ Dynamic: license-file
|
|
|
31
31
|
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
# GhostBase
|
|
34
|
+
# GhostBase 👻
|
|
35
35
|
|
|
36
36
|
> **A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL, and MySQL.**
|
|
37
37
|
> *No Java runtimes. No 1.5GB RAM bloat. Built-in production safety guard, instant SQL beautifier & zero-config `.env` connect.*
|
|
@@ -42,6 +42,10 @@ Dynamic: license-file
|
|
|
42
42
|
[](LICENSE)
|
|
43
43
|
[](https://github.com/Textualize/textual)
|
|
44
44
|
|
|
45
|
+
<p align="center">
|
|
46
|
+
<img src="assets/ghostbase.png" alt="GhostBase Terminal Studio" width="100%">
|
|
47
|
+
</p>
|
|
48
|
+
|
|
45
49
|
---
|
|
46
50
|
|
|
47
51
|
## ⚡ Why GhostBase?
|
|
@@ -75,10 +79,10 @@ Opening **DBeaver** or **DataGrip** just to run a quick `SELECT`, inspect an ind
|
|
|
75
79
|
- ⚡ **1-Key Query Plan (`Ctrl + P`):**
|
|
76
80
|
- Run instant execution plans (`EXPLAIN QUERY PLAN` / `EXPLAIN`) on any query to see index usage and bottlenecks.
|
|
77
81
|
- 🔌 **Universal Database Support:** Connects natively to **SQLite**, **PostgreSQL**, and **MySQL**.
|
|
78
|
-
-
|
|
79
|
-
-
|
|
82
|
+
- 🔑 **Zero-Config `.env` Auto-Connect:** Just run `ghostbase` in any project folder; it automatically picks up `DATABASE_URL` or standard database environment variables.
|
|
83
|
+
- 🔍 **Real-time Table Search (`Ctrl + F`):** Type in the search bar to filter dozens of tables and views instantly.
|
|
80
84
|
- 📊 **Explorer with Tables & Views:** Browse tables and views with live row counts.
|
|
81
|
-
-
|
|
85
|
+
- 📋 **Instant Schema & Foreign Keys:** See primary keys, column types, nullability, and foreign key references at a glance.
|
|
82
86
|
- 💾 **Multi-Format Exports:** Instant SQL `INSERT`s (`Ctrl + I`), Markdown (`Ctrl + M`), CSV (`Ctrl + E`), and JSON (`Ctrl + J`).
|
|
83
87
|
- 📜 **Query History:** Cycle through your last 20 queries with `Ctrl + ↑` and `Ctrl + ↓`.
|
|
84
88
|
- 🎨 **Clean Cyberpunk Aesthetic:** Fast, high-contrast, zero-flicker terminal interface.
|
|
@@ -153,10 +157,9 @@ ghostbase postgresql://prod-db:5432/main --read-only
|
|
|
153
157
|
|
|
154
158
|
---
|
|
155
159
|
|
|
156
|
-
##
|
|
160
|
+
## 🤝 Contributing
|
|
157
161
|
|
|
158
|
-
|
|
159
|
-
📧 **[contact.4yk5@proton.me](mailto:contact.4yk5@proton.me)**
|
|
162
|
+
Contributions, feedback, and pull requests are very welcome! Feel free to open an issue for bug reports or feature requests.
|
|
160
163
|
|
|
161
164
|
---
|
|
162
165
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
# GhostBase
|
|
10
|
+
# GhostBase 👻
|
|
11
11
|
|
|
12
12
|
> **A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL, and MySQL.**
|
|
13
13
|
> *No Java runtimes. No 1.5GB RAM bloat. Built-in production safety guard, instant SQL beautifier & zero-config `.env` connect.*
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
[](LICENSE)
|
|
19
19
|
[](https://github.com/Textualize/textual)
|
|
20
20
|
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="assets/ghostbase.png" alt="GhostBase Terminal Studio" width="100%">
|
|
23
|
+
</p>
|
|
24
|
+
|
|
21
25
|
---
|
|
22
26
|
|
|
23
27
|
## ⚡ Why GhostBase?
|
|
@@ -51,10 +55,10 @@ Opening **DBeaver** or **DataGrip** just to run a quick `SELECT`, inspect an ind
|
|
|
51
55
|
- ⚡ **1-Key Query Plan (`Ctrl + P`):**
|
|
52
56
|
- Run instant execution plans (`EXPLAIN QUERY PLAN` / `EXPLAIN`) on any query to see index usage and bottlenecks.
|
|
53
57
|
- 🔌 **Universal Database Support:** Connects natively to **SQLite**, **PostgreSQL**, and **MySQL**.
|
|
54
|
-
-
|
|
55
|
-
-
|
|
58
|
+
- 🔑 **Zero-Config `.env` Auto-Connect:** Just run `ghostbase` in any project folder; it automatically picks up `DATABASE_URL` or standard database environment variables.
|
|
59
|
+
- 🔍 **Real-time Table Search (`Ctrl + F`):** Type in the search bar to filter dozens of tables and views instantly.
|
|
56
60
|
- 📊 **Explorer with Tables & Views:** Browse tables and views with live row counts.
|
|
57
|
-
-
|
|
61
|
+
- 📋 **Instant Schema & Foreign Keys:** See primary keys, column types, nullability, and foreign key references at a glance.
|
|
58
62
|
- 💾 **Multi-Format Exports:** Instant SQL `INSERT`s (`Ctrl + I`), Markdown (`Ctrl + M`), CSV (`Ctrl + E`), and JSON (`Ctrl + J`).
|
|
59
63
|
- 📜 **Query History:** Cycle through your last 20 queries with `Ctrl + ↑` and `Ctrl + ↓`.
|
|
60
64
|
- 🎨 **Clean Cyberpunk Aesthetic:** Fast, high-contrast, zero-flicker terminal interface.
|
|
@@ -129,10 +133,9 @@ ghostbase postgresql://prod-db:5432/main --read-only
|
|
|
129
133
|
|
|
130
134
|
---
|
|
131
135
|
|
|
132
|
-
##
|
|
136
|
+
## 🤝 Contributing
|
|
133
137
|
|
|
134
|
-
|
|
135
|
-
📧 **[contact.4yk5@proton.me](mailto:contact.4yk5@proton.me)**
|
|
138
|
+
Contributions, feedback, and pull requests are very welcome! Feel free to open an issue for bug reports or feature requests.
|
|
136
139
|
|
|
137
140
|
---
|
|
138
141
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
import os
|
|
3
|
+
import time
|
|
4
|
+
import re
|
|
3
5
|
|
|
4
6
|
if sys.platform == "win32":
|
|
5
7
|
try:
|
|
@@ -138,164 +140,127 @@ ListItem.section-header {
|
|
|
138
140
|
#schema-title {
|
|
139
141
|
color: #f0883e;
|
|
140
142
|
text-style: bold;
|
|
141
|
-
margin-bottom: 1;
|
|
142
143
|
}
|
|
143
144
|
#schema-content {
|
|
144
145
|
color: #8b949e;
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
/*
|
|
148
|
+
/* Content Area */
|
|
148
149
|
#content-area {
|
|
149
150
|
width: 1fr;
|
|
150
|
-
|
|
151
|
+
background: #0d1117;
|
|
151
152
|
}
|
|
153
|
+
|
|
154
|
+
/* Query Editor */
|
|
152
155
|
#query-box {
|
|
153
|
-
height:
|
|
154
|
-
border: solid #30363d;
|
|
156
|
+
height: 9;
|
|
155
157
|
background: #010409;
|
|
156
|
-
|
|
158
|
+
border-bottom: solid #30363d;
|
|
159
|
+
color: #e6edf3;
|
|
157
160
|
}
|
|
158
161
|
#query-box:focus {
|
|
159
|
-
border: solid #388bfd;
|
|
162
|
+
border-bottom: solid #388bfd;
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
/*
|
|
165
|
+
/* Toolbar */
|
|
163
166
|
#toolbar {
|
|
164
167
|
height: 3;
|
|
168
|
+
background: #161b22;
|
|
169
|
+
border-bottom: solid #30363d;
|
|
165
170
|
align: left middle;
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
padding: 0 1;
|
|
172
|
+
}
|
|
173
|
+
#toolbar Button {
|
|
174
|
+
margin-right: 1;
|
|
175
|
+
min-width: 8;
|
|
176
|
+
height: 1;
|
|
177
|
+
border: none;
|
|
168
178
|
}
|
|
169
179
|
#btn-run {
|
|
170
180
|
background: #238636;
|
|
171
181
|
color: #ffffff;
|
|
172
|
-
border: none;
|
|
173
|
-
min-width: 6;
|
|
174
|
-
margin-right: 1;
|
|
175
182
|
}
|
|
176
|
-
#btn-run:hover {
|
|
177
|
-
|
|
183
|
+
#btn-run:hover {
|
|
184
|
+
background: #2ea043;
|
|
185
|
+
}
|
|
178
186
|
#btn-explain {
|
|
179
|
-
background: #
|
|
187
|
+
background: #1f6feb;
|
|
180
188
|
color: #ffffff;
|
|
181
|
-
border: none;
|
|
182
|
-
min-width: 9;
|
|
183
|
-
margin-right: 1;
|
|
184
189
|
}
|
|
185
|
-
#btn-explain:hover {
|
|
186
|
-
|
|
190
|
+
#btn-explain:hover {
|
|
191
|
+
background: #388bfd;
|
|
192
|
+
}
|
|
187
193
|
#btn-format {
|
|
188
|
-
background: #
|
|
189
|
-
color: #
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
194
|
+
background: #21262d;
|
|
195
|
+
color: #c9d1d9;
|
|
196
|
+
}
|
|
197
|
+
#btn-format:hover {
|
|
198
|
+
background: #30363d;
|
|
193
199
|
}
|
|
194
|
-
#btn-format:hover { background: #388bfd; }
|
|
195
|
-
|
|
196
200
|
#btn-clear {
|
|
197
201
|
background: #21262d;
|
|
198
202
|
color: #8b949e;
|
|
199
|
-
border: none;
|
|
200
|
-
min-width: 7;
|
|
201
|
-
margin-right: 1;
|
|
202
203
|
}
|
|
203
|
-
#btn-clear:hover {
|
|
204
|
-
|
|
204
|
+
#btn-clear:hover {
|
|
205
|
+
background: #30363d;
|
|
206
|
+
color: #f85149;
|
|
207
|
+
}
|
|
205
208
|
#btn-safe {
|
|
209
|
+
background: #21262d;
|
|
210
|
+
color: #3fb950;
|
|
211
|
+
}
|
|
212
|
+
#btn-safe:hover {
|
|
206
213
|
background: #30363d;
|
|
207
|
-
color: #c9d1d9;
|
|
208
|
-
border: none;
|
|
209
|
-
min-width: 6;
|
|
210
|
-
margin-right: 1;
|
|
211
214
|
}
|
|
212
|
-
#btn-safe:hover { background: #484f58; }
|
|
213
|
-
|
|
214
215
|
#status-label {
|
|
216
|
+
margin-left: 2;
|
|
215
217
|
color: #8b949e;
|
|
216
|
-
text-overflow: ellipsis;
|
|
217
|
-
overflow: hidden;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
/* Results
|
|
220
|
+
/* Results Bar */
|
|
221
221
|
#results-bar {
|
|
222
222
|
height: 3;
|
|
223
|
+
background: #161b22;
|
|
224
|
+
border-bottom: solid #30363d;
|
|
223
225
|
align: left middle;
|
|
224
226
|
padding: 0 1;
|
|
225
|
-
background: #161b22;
|
|
226
|
-
border: solid #30363d;
|
|
227
|
-
border-bottom: none;
|
|
228
|
-
overflow-x: hidden;
|
|
229
227
|
}
|
|
230
228
|
#results-title {
|
|
231
|
-
color: #
|
|
229
|
+
color: #58a6ff;
|
|
232
230
|
text-style: bold;
|
|
233
|
-
margin-right: 1;
|
|
234
231
|
}
|
|
235
232
|
#results-count {
|
|
236
|
-
color: #
|
|
237
|
-
margin-
|
|
233
|
+
color: #484f58;
|
|
234
|
+
margin-left: 1;
|
|
238
235
|
}
|
|
239
236
|
.export-sep {
|
|
240
237
|
color: #30363d;
|
|
241
|
-
margin
|
|
238
|
+
margin: 0 1;
|
|
242
239
|
}
|
|
243
240
|
.export-label {
|
|
244
241
|
color: #484f58;
|
|
245
242
|
margin-right: 1;
|
|
246
243
|
}
|
|
247
|
-
#
|
|
248
|
-
background: #0d419d;
|
|
249
|
-
color: #79c0ff;
|
|
250
|
-
border: none;
|
|
251
|
-
min-width: 5;
|
|
244
|
+
#results-bar Button {
|
|
252
245
|
margin-right: 1;
|
|
253
|
-
}
|
|
254
|
-
#btn-csv:hover { background: #1158c7; }
|
|
255
|
-
|
|
256
|
-
#btn-json {
|
|
257
|
-
background: #5a1e02;
|
|
258
|
-
color: #ffa657;
|
|
259
|
-
border: none;
|
|
260
246
|
min-width: 6;
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
#btn-json:hover { background: #7d2d00; }
|
|
264
|
-
|
|
265
|
-
#btn-md {
|
|
266
|
-
background: #232a35;
|
|
267
|
-
color: #a5d6ff;
|
|
247
|
+
height: 1;
|
|
268
248
|
border: none;
|
|
269
|
-
|
|
270
|
-
|
|
249
|
+
background: #21262d;
|
|
250
|
+
color: #8b949e;
|
|
271
251
|
}
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
#
|
|
275
|
-
background: #3b2300;
|
|
276
|
-
color: #ffc680;
|
|
277
|
-
border: none;
|
|
278
|
-
min-width: 5;
|
|
279
|
-
margin-right: 1;
|
|
252
|
+
#results-bar Button:hover {
|
|
253
|
+
background: #30363d;
|
|
254
|
+
color: #c9d1d9;
|
|
280
255
|
}
|
|
281
|
-
#btn-sql:hover { background: #5a3500; }
|
|
282
256
|
|
|
283
|
-
/*
|
|
257
|
+
/* Data Table */
|
|
284
258
|
#data-table {
|
|
285
259
|
height: 1fr;
|
|
286
|
-
border: solid #30363d;
|
|
287
260
|
background: #0d1117;
|
|
288
261
|
}
|
|
289
|
-
DataTable > .datatable--header {
|
|
290
|
-
background: #161b22;
|
|
291
|
-
color: #79c0ff;
|
|
292
|
-
text-style: bold;
|
|
293
|
-
}
|
|
294
|
-
DataTable > .datatable--cursor {
|
|
295
|
-
background: #1c2128;
|
|
296
|
-
}
|
|
297
262
|
|
|
298
|
-
/* Status
|
|
263
|
+
/* Status Bar */
|
|
299
264
|
#status-bar {
|
|
300
265
|
height: 1;
|
|
301
266
|
dock: bottom;
|
|
@@ -344,18 +309,22 @@ class GhostBaseApp(App):
|
|
|
344
309
|
selected_table: reactive[str] = reactive("")
|
|
345
310
|
safe_mode: reactive[bool] = reactive(False)
|
|
346
311
|
|
|
347
|
-
def __init__(self, db_path: str = None, read_only: bool = False):
|
|
312
|
+
def __init__(self, db_path: str = None, read_only: bool = False, populate_sample: bool = False):
|
|
348
313
|
super().__init__()
|
|
349
314
|
self.db_path = db_path or "sample.db"
|
|
315
|
+
self.populate_sample = populate_sample
|
|
350
316
|
self.db = DatabaseEngine(self.db_path)
|
|
351
317
|
self.safe_mode = read_only
|
|
352
318
|
self.last_columns: list = []
|
|
353
319
|
self.last_rows: list = []
|
|
320
|
+
self.last_query_table: str = None
|
|
354
321
|
self.query_history: list = []
|
|
355
322
|
self.history_index: int = -1
|
|
356
323
|
self.all_tables: list = []
|
|
357
324
|
self.all_views: list = []
|
|
325
|
+
self._row_count_cache: dict = {}
|
|
358
326
|
self._pending_destructive_query = None
|
|
327
|
+
self._pending_destructive_time = 0.0
|
|
359
328
|
|
|
360
329
|
# Layout
|
|
361
330
|
|
|
@@ -373,7 +342,7 @@ class GhostBaseApp(App):
|
|
|
373
342
|
yield Label(f"[{self.db.db_type_label()}]", id="db-type-label")
|
|
374
343
|
yield Label(db_label, id="db-name-label")
|
|
375
344
|
yield Label(safe_text, id="safe-badge", classes=safe_class)
|
|
376
|
-
yield Label("v0.1.
|
|
345
|
+
yield Label("v0.1.2", id="version-label")
|
|
377
346
|
|
|
378
347
|
with Horizontal(id="main-container"):
|
|
379
348
|
with Vertical(id="sidebar"):
|
|
@@ -420,26 +389,49 @@ class GhostBaseApp(App):
|
|
|
420
389
|
# Lifecycle
|
|
421
390
|
|
|
422
391
|
async def on_mount(self):
|
|
392
|
+
# Configure database engine level Read-Only mode if safe_mode is enabled
|
|
393
|
+
if self.safe_mode:
|
|
394
|
+
self.db.set_read_only(True)
|
|
395
|
+
|
|
423
396
|
tables = self.db.get_tables()
|
|
424
|
-
|
|
397
|
+
# Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
|
|
398
|
+
is_default_sample = (
|
|
399
|
+
self.populate_sample
|
|
400
|
+
or self.db_path == ":memory:"
|
|
401
|
+
or os.path.basename(os.path.abspath(self.db_path)) == "sample.db"
|
|
402
|
+
)
|
|
403
|
+
if not tables and self.db.db_type == "sqlite" and is_default_sample and not self.safe_mode:
|
|
425
404
|
self._create_sample_data()
|
|
426
405
|
tables = self.db.get_tables()
|
|
427
406
|
|
|
428
407
|
self.all_tables = tables
|
|
429
408
|
self.all_views = self.db.get_views()
|
|
409
|
+
self._refresh_row_counts()
|
|
430
410
|
self._refresh_sidebar(self.all_tables, self.all_views)
|
|
431
411
|
|
|
432
412
|
if self.all_tables:
|
|
433
413
|
first = self.all_tables[0]
|
|
434
414
|
self.selected_table = first
|
|
415
|
+
quoted = self.db.quote_table_name(first)
|
|
435
416
|
self.query_one("#query-box", TextArea).text = (
|
|
436
|
-
f"SELECT * FROM {
|
|
417
|
+
f"SELECT * FROM {quoted} LIMIT 50;"
|
|
437
418
|
)
|
|
438
419
|
self.action_run_query()
|
|
439
420
|
self._show_schema(first)
|
|
421
|
+
else:
|
|
422
|
+
self.query_one("#query-box", TextArea).text = (
|
|
423
|
+
"-- Connected to database.\n-- Write a query and press Ctrl+R to execute.\n"
|
|
424
|
+
)
|
|
440
425
|
|
|
441
426
|
self._update_status_bar()
|
|
442
427
|
|
|
428
|
+
# Row count caching
|
|
429
|
+
|
|
430
|
+
def _refresh_row_counts(self):
|
|
431
|
+
self._row_count_cache = {}
|
|
432
|
+
for t in self.all_tables:
|
|
433
|
+
self._row_count_cache[t] = self.db.get_row_count(t)
|
|
434
|
+
|
|
443
435
|
# Sample schema
|
|
444
436
|
|
|
445
437
|
def _create_sample_data(self):
|
|
@@ -500,7 +492,7 @@ class GhostBaseApp(App):
|
|
|
500
492
|
lv.append(_sep_tables)
|
|
501
493
|
|
|
502
494
|
for t in tables:
|
|
503
|
-
rc = self.
|
|
495
|
+
rc = self._row_count_cache.get(t, 0)
|
|
504
496
|
item = ListItem(Label(f" {t} ({rc})"))
|
|
505
497
|
item.table_name = t # type: ignore
|
|
506
498
|
item.is_view = False # type: ignore
|
|
@@ -533,6 +525,11 @@ class GhostBaseApp(App):
|
|
|
533
525
|
filtered_views = [v for v in self.all_views if q in v.lower()]
|
|
534
526
|
self._refresh_sidebar(filtered_tables, filtered_views)
|
|
535
527
|
|
|
528
|
+
def on_text_area_changed(self, event: TextArea.Changed):
|
|
529
|
+
if event.text_area.id == "query-box" and self._pending_destructive_query is not None:
|
|
530
|
+
self._pending_destructive_query = None
|
|
531
|
+
self._pending_destructive_time = 0.0
|
|
532
|
+
|
|
536
533
|
# Schema panel
|
|
537
534
|
|
|
538
535
|
def _show_schema(self, table_name: str, is_view: bool = False):
|
|
@@ -591,8 +588,12 @@ class GhostBaseApp(App):
|
|
|
591
588
|
return
|
|
592
589
|
is_view = getattr(item, "is_view", False)
|
|
593
590
|
self.selected_table = t
|
|
591
|
+
self._pending_destructive_query = None
|
|
592
|
+
self._pending_destructive_time = 0.0
|
|
593
|
+
|
|
594
|
+
quoted = self.db.quote_table_name(t)
|
|
594
595
|
self.query_one("#query-box", TextArea).text = (
|
|
595
|
-
f"SELECT * FROM {
|
|
596
|
+
f"SELECT * FROM {quoted} LIMIT 50;"
|
|
596
597
|
)
|
|
597
598
|
self.action_run_query()
|
|
598
599
|
self._show_schema(t, is_view=is_view)
|
|
@@ -626,26 +627,32 @@ class GhostBaseApp(App):
|
|
|
626
627
|
status = self.query_one("#status-label", Label)
|
|
627
628
|
res_cnt = self.query_one("#results-count", Label)
|
|
628
629
|
|
|
629
|
-
# Safety guard check
|
|
630
|
-
is_destructive, reason = self.db.check_safety(query)
|
|
630
|
+
# Safety guard check (strict read-only in safe mode, destructive guard in normal mode)
|
|
631
|
+
is_destructive, reason = self.db.check_safety(query, is_safe_mode=self.safe_mode)
|
|
631
632
|
if is_destructive:
|
|
632
633
|
if self.safe_mode:
|
|
633
634
|
self.notify(f"BLOCKED BY SAFE MODE: {reason}", severity="error", timeout=5)
|
|
634
635
|
status.update(f"[bold red]BLOCKED BY SAFE MODE:[/] {reason}")
|
|
635
636
|
return
|
|
636
637
|
|
|
637
|
-
|
|
638
|
+
now = time.time()
|
|
639
|
+
if self._pending_destructive_query == query and (now - self._pending_destructive_time) <= 10.0:
|
|
640
|
+
# Double confirmation received within 10s window
|
|
641
|
+
self._pending_destructive_query = None
|
|
642
|
+
self._pending_destructive_time = 0.0
|
|
643
|
+
else:
|
|
638
644
|
self._pending_destructive_query = query
|
|
645
|
+
self._pending_destructive_time = now
|
|
639
646
|
self.notify(
|
|
640
|
-
f"DANGER: {reason}!\nPress Ctrl+R
|
|
647
|
+
f"DANGER: {reason}!\nPress Ctrl+R within 10s to confirm.",
|
|
641
648
|
severity="warning",
|
|
642
649
|
timeout=5,
|
|
643
650
|
)
|
|
644
651
|
status.update(f"[bold red]DANGER:[/] {reason} -> Press Ctrl+R to CONFIRM")
|
|
645
652
|
return
|
|
646
|
-
|
|
647
|
-
# Double confirmation received
|
|
653
|
+
else:
|
|
648
654
|
self._pending_destructive_query = None
|
|
655
|
+
self._pending_destructive_time = 0.0
|
|
649
656
|
|
|
650
657
|
if not self.query_history or self.query_history[-1] != query:
|
|
651
658
|
self.query_history.append(query)
|
|
@@ -658,6 +665,10 @@ class GhostBaseApp(App):
|
|
|
658
665
|
dt.clear(columns=True)
|
|
659
666
|
|
|
660
667
|
if not res["success"]:
|
|
668
|
+
# Clear export cache on error
|
|
669
|
+
self.last_columns = []
|
|
670
|
+
self.last_rows = []
|
|
671
|
+
self.last_query_table = None
|
|
661
672
|
err = res.get("error", "Unknown error")
|
|
662
673
|
status.update(f"[bold red]ERROR[/] {err}")
|
|
663
674
|
res_cnt.update("(Error)")
|
|
@@ -667,6 +678,12 @@ class GhostBaseApp(App):
|
|
|
667
678
|
if res["is_select"]:
|
|
668
679
|
self.last_columns = res["columns"]
|
|
669
680
|
self.last_rows = res["rows"]
|
|
681
|
+
|
|
682
|
+
# Detect target table for export from final SELECT statement (ignoring CTE definitions)
|
|
683
|
+
main_q = re.sub(r"^WITH\s+[\s\S]*?\)\s*", "", query, flags=re.IGNORECASE)
|
|
684
|
+
m = re.search(r"\bFROM\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", main_q, flags=re.IGNORECASE)
|
|
685
|
+
self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
|
|
686
|
+
|
|
670
687
|
dt.add_columns(*self.last_columns)
|
|
671
688
|
for row in self.last_rows:
|
|
672
689
|
styled = []
|
|
@@ -685,16 +702,66 @@ class GhostBaseApp(App):
|
|
|
685
702
|
rc = res["row_count"]
|
|
686
703
|
ms = res["elapsed_ms"]
|
|
687
704
|
status.update(f"[bold green]OK[/] ({ms}ms)")
|
|
688
|
-
|
|
705
|
+
|
|
706
|
+
if res.get("truncated"):
|
|
707
|
+
res_cnt.update(f"({rc}+ rows, capped at 1,000)")
|
|
708
|
+
self.notify("Displaying first 1,000 rows (use LIMIT / OFFSET for more)", severity="information", timeout=4)
|
|
709
|
+
else:
|
|
710
|
+
res_cnt.update(f"({rc} rows)")
|
|
711
|
+
|
|
712
|
+
self._update_status_bar(row_count=rc, ms=ms)
|
|
713
|
+
elif res.get("is_returning"):
|
|
714
|
+
# DML with RETURNING: Render returned rows in DataTable, but treat as write operation
|
|
715
|
+
self.last_columns = res["columns"]
|
|
716
|
+
self.last_rows = res["rows"]
|
|
717
|
+
|
|
718
|
+
# Extract target table from INSERT/UPDATE
|
|
719
|
+
m = re.search(r"\b(?:INTO|UPDATE)\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", query, flags=re.IGNORECASE)
|
|
720
|
+
self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
|
|
721
|
+
|
|
722
|
+
dt.add_columns(*self.last_columns)
|
|
723
|
+
for row in self.last_rows:
|
|
724
|
+
styled = []
|
|
725
|
+
for val in row:
|
|
726
|
+
if val is None:
|
|
727
|
+
styled.append(Text("NULL", style="dim #6e7681"))
|
|
728
|
+
elif isinstance(val, (int, float)):
|
|
729
|
+
styled.append(Text(str(val), style="#79c0ff"))
|
|
730
|
+
else:
|
|
731
|
+
s = str(val).replace("\r\n", " ").replace("\n", " ").replace("\t", " ")
|
|
732
|
+
if len(s) > 200:
|
|
733
|
+
s = s[:197] + "..."
|
|
734
|
+
styled.append(Text(s, style="#c9d1d9"))
|
|
735
|
+
dt.add_row(*styled)
|
|
736
|
+
|
|
737
|
+
aff = res.get("affected_rows", len(self.last_rows))
|
|
738
|
+
rc = res.get("row_count", len(self.last_rows))
|
|
739
|
+
ms = res["elapsed_ms"]
|
|
740
|
+
status.update(f"[bold green]OK[/] ({ms}ms)")
|
|
741
|
+
res_cnt.update(f"({rc} rows returned, {aff} affected)")
|
|
742
|
+
self.notify(f"Done — {aff} row(s) affected (RETURNING {rc} rows)")
|
|
743
|
+
|
|
744
|
+
# Refresh schema & row counts
|
|
745
|
+
self.all_tables = self.db.get_tables()
|
|
746
|
+
self.all_views = self.db.get_views()
|
|
747
|
+
self._refresh_row_counts()
|
|
748
|
+
self._refresh_sidebar(self.all_tables, self.all_views)
|
|
689
749
|
self._update_status_bar(row_count=rc, ms=ms)
|
|
690
750
|
else:
|
|
751
|
+
# Pure DML / DDL statement -> clear export cache so previous SELECT results are not mistakenly exported
|
|
752
|
+
self.last_columns = []
|
|
753
|
+
self.last_rows = []
|
|
754
|
+
self.last_query_table = None
|
|
755
|
+
|
|
691
756
|
aff = res.get("affected_rows", 0)
|
|
692
757
|
ms = res["elapsed_ms"]
|
|
693
758
|
status.update(f"[bold green]OK[/] ({ms}ms)")
|
|
694
759
|
res_cnt.update(f"({aff} rows affected)")
|
|
695
760
|
self.notify(f"Done — {aff} row(s) affected")
|
|
761
|
+
|
|
696
762
|
self.all_tables = self.db.get_tables()
|
|
697
763
|
self.all_views = self.db.get_views()
|
|
764
|
+
self._refresh_row_counts()
|
|
698
765
|
self._refresh_sidebar(self.all_tables, self.all_views)
|
|
699
766
|
self._update_status_bar(ms=ms)
|
|
700
767
|
|
|
@@ -745,21 +812,29 @@ class GhostBaseApp(App):
|
|
|
745
812
|
def action_toggle_safe_mode(self):
|
|
746
813
|
"""Toggle Production Safe Mode (Ctrl+S)"""
|
|
747
814
|
self.safe_mode = not self.safe_mode
|
|
815
|
+
self._pending_destructive_query = None
|
|
816
|
+
self._pending_destructive_time = 0.0
|
|
817
|
+
|
|
818
|
+
# Enforce/revoke database session level Read-Only
|
|
819
|
+
self.db.set_read_only(self.safe_mode)
|
|
820
|
+
|
|
748
821
|
badge = self.query_one("#safe-badge", Label)
|
|
749
822
|
if self.safe_mode:
|
|
750
823
|
badge.update("[SAFE: ON]")
|
|
751
824
|
badge.remove_class("unsafe")
|
|
752
|
-
self.notify("Safe Mode ENABLED:
|
|
825
|
+
self.notify("Safe Mode ENABLED: Modifications strictly blocked (Read-Only)", timeout=3)
|
|
753
826
|
else:
|
|
754
827
|
badge.update("[SAFE: OFF]")
|
|
755
828
|
badge.add_class("unsafe")
|
|
756
|
-
self.notify("Safe Mode DISABLED: Destructive queries
|
|
829
|
+
self.notify("Safe Mode DISABLED: Destructive queries require confirmation", severity="warning", timeout=3)
|
|
757
830
|
self._update_status_bar()
|
|
758
831
|
|
|
759
832
|
def action_focus_filter(self):
|
|
760
833
|
self.query_one("#table-search", Input).focus()
|
|
761
834
|
|
|
762
835
|
def action_clear_query(self):
|
|
836
|
+
self._pending_destructive_query = None
|
|
837
|
+
self._pending_destructive_time = 0.0
|
|
763
838
|
self.query_one("#query-box", TextArea).text = ""
|
|
764
839
|
self.query_one("#status-label", Label).update("Ready")
|
|
765
840
|
self.query_one("#query-box", TextArea).focus()
|
|
@@ -782,7 +857,7 @@ class GhostBaseApp(App):
|
|
|
782
857
|
|
|
783
858
|
def action_export_csv(self):
|
|
784
859
|
if not self.last_columns:
|
|
785
|
-
self.notify("Run a query first", severity="warning")
|
|
860
|
+
self.notify("Run a SELECT query first", severity="warning")
|
|
786
861
|
return
|
|
787
862
|
db_dir = (
|
|
788
863
|
os.path.dirname(os.path.abspath(self.db_path))
|
|
@@ -795,7 +870,7 @@ class GhostBaseApp(App):
|
|
|
795
870
|
|
|
796
871
|
def action_export_json(self):
|
|
797
872
|
if not self.last_columns:
|
|
798
|
-
self.notify("Run a query first", severity="warning")
|
|
873
|
+
self.notify("Run a SELECT query first", severity="warning")
|
|
799
874
|
return
|
|
800
875
|
db_dir = (
|
|
801
876
|
os.path.dirname(os.path.abspath(self.db_path))
|
|
@@ -808,7 +883,7 @@ class GhostBaseApp(App):
|
|
|
808
883
|
|
|
809
884
|
def action_export_markdown(self):
|
|
810
885
|
if not self.last_columns:
|
|
811
|
-
self.notify("Run a query first", severity="warning")
|
|
886
|
+
self.notify("Run a SELECT query first", severity="warning")
|
|
812
887
|
return
|
|
813
888
|
db_dir = (
|
|
814
889
|
os.path.dirname(os.path.abspath(self.db_path))
|
|
@@ -821,9 +896,9 @@ class GhostBaseApp(App):
|
|
|
821
896
|
|
|
822
897
|
def action_export_sql(self):
|
|
823
898
|
if not self.last_columns:
|
|
824
|
-
self.notify("Run a query first", severity="warning")
|
|
899
|
+
self.notify("Run a SELECT query first", severity="warning")
|
|
825
900
|
return
|
|
826
|
-
table_name = self.selected_table or "exported_table"
|
|
901
|
+
table_name = getattr(self, "last_query_table", None) or self.selected_table or "exported_table"
|
|
827
902
|
db_dir = (
|
|
828
903
|
os.path.dirname(os.path.abspath(self.db_path))
|
|
829
904
|
if (self.db.db_type == "sqlite" and self.db_path != ":memory:")
|