sqlbuddy-nwi 0.3.1__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.
- _version.py +10 -0
- cli.py +1164 -0
- connections/__init__.py +31 -0
- connections/config_util.py +159 -0
- connections/manager.py +205 -0
- connections/readonly.py +45 -0
- connections/runtime.py +51 -0
- connections/wrappers.py +105 -0
- db_query.py +735 -0
- db_security.py +146 -0
- init.py +528 -0
- server.py +275 -0
- sqlbuddy_nwi-0.3.1.dist-info/METADATA +705 -0
- sqlbuddy_nwi-0.3.1.dist-info/RECORD +20 -0
- sqlbuddy_nwi-0.3.1.dist-info/WHEEL +5 -0
- sqlbuddy_nwi-0.3.1.dist-info/entry_points.txt +3 -0
- sqlbuddy_nwi-0.3.1.dist-info/licenses/LICENSE +21 -0
- sqlbuddy_nwi-0.3.1.dist-info/top_level.txt +9 -0
- test_vendor.py +50 -0
- tunnel.py +345 -0
db_query.py
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""SQL Buddy - Database query logic for the MCP server."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import difflib
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
logging.basicConfig(
|
|
15
|
+
level=logging.INFO,
|
|
16
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
|
|
21
|
+
import db_security as _security
|
|
22
|
+
|
|
23
|
+
from connections import LockedDBManager as DBManager, connection_type
|
|
24
|
+
from connections.config_util import config_path, cache_dir_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def clean_db_error(msg: str) -> str:
|
|
28
|
+
"""Strip a DBMS exception's embedded stack trace, keeping the actionable message.
|
|
29
|
+
|
|
30
|
+
ClickHouse embeds a C++ server trace directly in the exception text, e.g.:
|
|
31
|
+
Code: 47. DB::Exception: Missing columns: 'x' ... required columns: 'x'. Stack trace:
|
|
32
|
+
|
|
33
|
+
0. DB::Exception::Exception(...) @ 0x... in /usr/bin/clickhouse
|
|
34
|
+
1. ...
|
|
35
|
+
Those frames are internal server symbols (hex addresses in /usr/bin/clickhouse)
|
|
36
|
+
that never reference the caller's SQL, column, or table; the message before
|
|
37
|
+
'Stack trace:' already names the offending column/table/query, so we keep it
|
|
38
|
+
and drop the trace. No-op for errors with no embedded trace (MySQL/pymysql,
|
|
39
|
+
config errors, transport errors). Never raises.
|
|
40
|
+
"""
|
|
41
|
+
if not msg:
|
|
42
|
+
return msg
|
|
43
|
+
marker = msg.find('Stack trace:')
|
|
44
|
+
if marker == -1:
|
|
45
|
+
return msg
|
|
46
|
+
cleaned = msg[:marker].rstrip()
|
|
47
|
+
return cleaned or msg
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# LIMIT enforcement for SELECT/WITH queries
|
|
51
|
+
DEFAULT_LIMIT = 10
|
|
52
|
+
MAX_LIMIT = 2000
|
|
53
|
+
|
|
54
|
+
# Cache directories. The root honors $DB_QUERY_HISTORY_DIR, else the per-user
|
|
55
|
+
# cache dir when installed (the package tree is read-only under uvx/pip), else
|
|
56
|
+
# the checkout-relative `database/` dir. See connections.config_util.cache_dir_path.
|
|
57
|
+
ROOT_DIR = cache_dir_path()
|
|
58
|
+
QUERY_HISTORY_DIR = os.path.join(ROOT_DIR, 'query_history')
|
|
59
|
+
DB_INFO_CACHE_DIR = os.path.join(ROOT_DIR, 'db_info_cache')
|
|
60
|
+
DB_SCHEMA_CACHE_DIR = os.path.join(ROOT_DIR, 'db_schema_cache')
|
|
61
|
+
DB_TABLE_LIST_CACHE_DIR = os.path.join(ROOT_DIR, 'table_list_cache')
|
|
62
|
+
|
|
63
|
+
CACHE_EXPIRE_DAYS = 3
|
|
64
|
+
MAX_HISTORY_ENTRIES = 1000
|
|
65
|
+
CONFIG_FILE = config_path()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def init_dirs():
|
|
69
|
+
for d in [QUERY_HISTORY_DIR, DB_INFO_CACHE_DIR, DB_SCHEMA_CACHE_DIR, DB_TABLE_LIST_CACHE_DIR]:
|
|
70
|
+
os.makedirs(d, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_cache_file_path(cache_type: str, connection: str) -> str:
|
|
74
|
+
if cache_type == 'query':
|
|
75
|
+
return os.path.join(QUERY_HISTORY_DIR, f'{connection}_query_history.json')
|
|
76
|
+
elif cache_type == 'info':
|
|
77
|
+
return os.path.join(DB_INFO_CACHE_DIR, f'{connection}_table_info.json')
|
|
78
|
+
elif cache_type == 'schema':
|
|
79
|
+
return os.path.join(DB_SCHEMA_CACHE_DIR, f'{connection}_schema.json')
|
|
80
|
+
elif cache_type == 'tables':
|
|
81
|
+
return os.path.join(DB_TABLE_LIST_CACHE_DIR, f'{connection}_tables.json')
|
|
82
|
+
raise ValueError(f"Unknown cache type: {cache_type}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_json_safe(file_path: str) -> dict:
|
|
86
|
+
try:
|
|
87
|
+
if os.path.exists(file_path):
|
|
88
|
+
with open(file_path, 'r') as f:
|
|
89
|
+
return json.load(f)
|
|
90
|
+
except (json.JSONDecodeError, IOError):
|
|
91
|
+
pass
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _save_json_safe(file_path: str, data: dict):
|
|
96
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
97
|
+
with open(file_path, 'w') as f:
|
|
98
|
+
json.dump(data, f, indent=2, default=str)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_cache_expired(timestamp_str: str, expire_days: int = CACHE_EXPIRE_DAYS) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
timestamp = datetime.fromisoformat(timestamp_str)
|
|
104
|
+
return datetime.now() > timestamp + timedelta(days=expire_days)
|
|
105
|
+
except (ValueError, TypeError):
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _get_table_name_from_sql(sql: str) -> Optional[str]:
|
|
110
|
+
sql_upper = sql.upper()
|
|
111
|
+
if 'FROM' in sql_upper:
|
|
112
|
+
parts = sql_upper.split('FROM')[1].split()
|
|
113
|
+
if parts:
|
|
114
|
+
tbl = parts[0].strip('();,')
|
|
115
|
+
if '.' in tbl:
|
|
116
|
+
tbl = tbl.split('.')[-1]
|
|
117
|
+
return tbl
|
|
118
|
+
if 'TABLE' in sql_upper:
|
|
119
|
+
parts = sql_upper.split('TABLE')
|
|
120
|
+
if len(parts) > 1:
|
|
121
|
+
return parts[1].strip('();,')
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _generate_query_hash(sql: str) -> str:
|
|
126
|
+
return hashlib.md5(sql.strip().encode()).hexdigest()[:8]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_query_allowed(sql: str) -> bool:
|
|
130
|
+
sql_trimmed = sql.strip().upper()
|
|
131
|
+
allowed = (
|
|
132
|
+
'SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN',
|
|
133
|
+
'USE', 'WITH', 'EXISTS',
|
|
134
|
+
)
|
|
135
|
+
return any(sql_trimmed.startswith(p) for p in allowed)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _result_to_json(client, sql: str) -> str:
|
|
139
|
+
"""Execute SQL and return JSON string with columns + rows. Decimals cast to float."""
|
|
140
|
+
if hasattr(client, 'query_dataframe'):
|
|
141
|
+
df = client.query_dataframe(sql)
|
|
142
|
+
if df.empty:
|
|
143
|
+
return json.dumps({"columns": [], "rows": [], "row_count": 0})
|
|
144
|
+
rows = [_sanitize_row(r) for r in df.to_dict(orient='records')]
|
|
145
|
+
return json.dumps({
|
|
146
|
+
"columns": list(df.columns),
|
|
147
|
+
"rows": rows,
|
|
148
|
+
"row_count": len(rows),
|
|
149
|
+
}, default=str)
|
|
150
|
+
else:
|
|
151
|
+
# ClickHouse
|
|
152
|
+
result = client.execute(sql, with_column_types=True)
|
|
153
|
+
if not result or not result[0]:
|
|
154
|
+
return json.dumps({"columns": [], "rows": [], "row_count": 0})
|
|
155
|
+
data, columns = result
|
|
156
|
+
col_names = [c[0] for c in columns]
|
|
157
|
+
rows = [_sanitize_row(dict(zip(col_names, row))) for row in data]
|
|
158
|
+
return json.dumps({
|
|
159
|
+
"columns": col_names,
|
|
160
|
+
"rows": rows,
|
|
161
|
+
"row_count": len(rows),
|
|
162
|
+
}, default=str)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- Schema ---
|
|
166
|
+
|
|
167
|
+
def _parse_create_table(schema: str) -> dict:
|
|
168
|
+
schema_clean = schema.replace('\\n', '\n')
|
|
169
|
+
result = {
|
|
170
|
+
'table_name': '', 'engine': '', 'partition': '',
|
|
171
|
+
'order_by': '', 'columns': [], 'projections': []
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
m = re.search(r'CREATE\s+TABLE\s+([\w.`]+)', schema_clean, re.IGNORECASE)
|
|
175
|
+
if m:
|
|
176
|
+
result['table_name'] = m.group(1).strip('`')
|
|
177
|
+
|
|
178
|
+
m = re.search(r'ENGINE\s*=\s*(\w+)', schema_clean, re.IGNORECASE)
|
|
179
|
+
if m:
|
|
180
|
+
result['engine'] = m.group(1)
|
|
181
|
+
|
|
182
|
+
m = re.search(
|
|
183
|
+
r'PARTITION\s+BY\s+([^\n]+?)(?=\n\s*(?:ORDER|PRIMARY|KEY|ENGINE|SETTINGS)|\n\))',
|
|
184
|
+
schema_clean, re.IGNORECASE
|
|
185
|
+
)
|
|
186
|
+
if m:
|
|
187
|
+
result['partition'] = m.group(1).strip()
|
|
188
|
+
|
|
189
|
+
m = re.search(
|
|
190
|
+
r'ORDER\s+BY\s+([^\n]+?)(?=\n\s*(?:PRIMARY|KEY|SETTINGS)|\n\))',
|
|
191
|
+
schema_clean, re.IGNORECASE
|
|
192
|
+
)
|
|
193
|
+
if m:
|
|
194
|
+
result['order_by'] = m.group(1).strip()
|
|
195
|
+
|
|
196
|
+
for match in re.finditer(
|
|
197
|
+
r'`(\w+)`\s+(\w+(?:\([^)]*\))?)\s*(?:DEFAULT\s+[^\s,]+)?\s*(?:COMMENT\s+\'[^\']*\')?',
|
|
198
|
+
schema_clean, re.IGNORECASE
|
|
199
|
+
):
|
|
200
|
+
col_name, col_type = match.group(1), match.group(2)
|
|
201
|
+
cm = re.search(rf'`{col_name}`[^,]*COMMENT\s+\'([^\']*)\'', schema_clean, re.IGNORECASE)
|
|
202
|
+
result['columns'].append({
|
|
203
|
+
'name': col_name, 'type': col_type,
|
|
204
|
+
'comment': cm.group(1) if cm else ''
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
for match in re.finditer(r'PROJECTION\s+(\w+)\s*\((.*?)\)', schema_clean, re.IGNORECASE | re.DOTALL):
|
|
208
|
+
result['projections'].append({
|
|
209
|
+
'name': match.group(1), 'definition': match.group(2).strip()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _extract_time_columns(schema: str) -> list:
|
|
216
|
+
time_cols = []
|
|
217
|
+
schema_lower = schema.lower()
|
|
218
|
+
for kw in ['create_time', 'insert_time', 'update_time', 'date', 'datetime', 'timestamp']:
|
|
219
|
+
if kw in schema_lower:
|
|
220
|
+
idx = schema_lower.find(kw)
|
|
221
|
+
segment = schema[max(0, idx - 30):idx]
|
|
222
|
+
parts = segment.replace(',', ' ').split()
|
|
223
|
+
if parts:
|
|
224
|
+
col = parts[-1].strip()
|
|
225
|
+
if col and col not in time_cols:
|
|
226
|
+
time_cols.append(col)
|
|
227
|
+
return time_cols
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def table_schema_query(tbl_name: str, connection: str = 'ck', force_refresh: bool = False) -> dict:
|
|
231
|
+
cache_file = _get_cache_file_path('schema', connection)
|
|
232
|
+
cache = _load_json_safe(cache_file)
|
|
233
|
+
|
|
234
|
+
if not force_refresh and tbl_name in cache:
|
|
235
|
+
entry = cache[tbl_name]
|
|
236
|
+
if not _is_cache_expired(entry.get('cached_at', '')):
|
|
237
|
+
return entry
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
db = DBManager()
|
|
241
|
+
client = getattr(db, connection)
|
|
242
|
+
|
|
243
|
+
if hasattr(client, 'query_dataframe'):
|
|
244
|
+
result = client.query_dataframe(f"SHOW CREATE TABLE {tbl_name}")
|
|
245
|
+
schema = result.to_string() if not result.empty else ""
|
|
246
|
+
else:
|
|
247
|
+
result = client.execute(f"SHOW CREATE TABLE {tbl_name}")
|
|
248
|
+
schema = str(result[0][0]) if result else ""
|
|
249
|
+
|
|
250
|
+
schema = schema.strip().replace('statement\n', '').strip()
|
|
251
|
+
if schema and schema[0].isdigit():
|
|
252
|
+
idx = schema.find(' ')
|
|
253
|
+
if idx > 0:
|
|
254
|
+
schema = schema[idx:].strip()
|
|
255
|
+
|
|
256
|
+
schema_info = {
|
|
257
|
+
'table_name': tbl_name,
|
|
258
|
+
'schema': schema,
|
|
259
|
+
'parsed': _parse_create_table(schema),
|
|
260
|
+
'cached_at': datetime.now().isoformat(),
|
|
261
|
+
'time_columns': _extract_time_columns(schema),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
cache[tbl_name] = schema_info
|
|
265
|
+
_save_json_safe(cache_file, cache)
|
|
266
|
+
return schema_info
|
|
267
|
+
|
|
268
|
+
except Exception as e:
|
|
269
|
+
if tbl_name in cache:
|
|
270
|
+
return cache[tbl_name]
|
|
271
|
+
return {'error': clean_db_error(str(e))}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --- Table Info ---
|
|
275
|
+
|
|
276
|
+
def table_info_query(tbl_name: str, connection: str = 'ck', force_refresh: bool = False) -> dict:
|
|
277
|
+
cache_file = _get_cache_file_path('info', connection)
|
|
278
|
+
cache = _load_json_safe(cache_file)
|
|
279
|
+
|
|
280
|
+
if not force_refresh and tbl_name in cache:
|
|
281
|
+
entry = cache[tbl_name]
|
|
282
|
+
if not _is_cache_expired(entry.get('cached_at', '')):
|
|
283
|
+
return entry
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
db = DBManager()
|
|
287
|
+
client = getattr(db, connection)
|
|
288
|
+
info = {'table_name': tbl_name, 'cached_at': datetime.now().isoformat()}
|
|
289
|
+
|
|
290
|
+
if hasattr(client, 'query_dataframe'):
|
|
291
|
+
try:
|
|
292
|
+
result = client.query_dataframe(f"SELECT COUNT(*) as cnt FROM {tbl_name}")
|
|
293
|
+
info['row_count'] = int(result['cnt'].iloc[0]) if not result.empty else 0
|
|
294
|
+
except Exception:
|
|
295
|
+
info['row_count'] = None
|
|
296
|
+
try:
|
|
297
|
+
idx_result = client.query_dataframe(f"SHOW INDEX FROM {tbl_name}")
|
|
298
|
+
if not idx_result.empty:
|
|
299
|
+
info['indexes'] = idx_result.to_dict('records')
|
|
300
|
+
except Exception:
|
|
301
|
+
info['indexes'] = []
|
|
302
|
+
else:
|
|
303
|
+
try:
|
|
304
|
+
result = client.execute(f"SELECT count() as cnt FROM {tbl_name}")
|
|
305
|
+
info['row_count'] = result[0][0] if result else 0
|
|
306
|
+
except Exception:
|
|
307
|
+
info['row_count'] = None
|
|
308
|
+
try:
|
|
309
|
+
part_result = client.execute(f"SHOW PARTITIONS FROM {tbl_name}")
|
|
310
|
+
info['partitions'] = str(part_result[:5])
|
|
311
|
+
except Exception:
|
|
312
|
+
info['partitions'] = None
|
|
313
|
+
|
|
314
|
+
cache[tbl_name] = info
|
|
315
|
+
_save_json_safe(cache_file, cache)
|
|
316
|
+
return info
|
|
317
|
+
|
|
318
|
+
except Exception as e:
|
|
319
|
+
if tbl_name in cache:
|
|
320
|
+
return cache[tbl_name]
|
|
321
|
+
return {'error': clean_db_error(str(e))}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# --- History ---
|
|
325
|
+
|
|
326
|
+
def _load_query_history(connection: str) -> dict:
|
|
327
|
+
return _load_json_safe(_get_cache_file_path('query', connection))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _save_query_history(connection: str, history: dict):
|
|
331
|
+
if len(history) > MAX_HISTORY_ENTRIES:
|
|
332
|
+
sorted_items = sorted(
|
|
333
|
+
history.items(),
|
|
334
|
+
key=lambda x: x[1].get('executed_at', ''),
|
|
335
|
+
reverse=True
|
|
336
|
+
)
|
|
337
|
+
history = dict(sorted_items[:MAX_HISTORY_ENTRIES])
|
|
338
|
+
_save_json_safe(_get_cache_file_path('query', connection), history)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_history_queries(connection: str = 'ck', tbl_name: Optional[str] = None) -> dict:
|
|
342
|
+
history = _load_query_history(connection)
|
|
343
|
+
if tbl_name:
|
|
344
|
+
return {
|
|
345
|
+
h: e for h, e in history.items()
|
|
346
|
+
if e.get('table_name') == tbl_name
|
|
347
|
+
}
|
|
348
|
+
return history
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --- Database Discovery ---
|
|
352
|
+
|
|
353
|
+
def list_connections() -> str:
|
|
354
|
+
"""List available connection options from config. Returns JSON."""
|
|
355
|
+
try:
|
|
356
|
+
cfg = DBManager.load_config(CONFIG_FILE)
|
|
357
|
+
except FileNotFoundError as e:
|
|
358
|
+
return json.dumps({"connections": [], "count": 0, "error": clean_db_error(str(e))}, default=str)
|
|
359
|
+
connections = []
|
|
360
|
+
for section in cfg.sections():
|
|
361
|
+
if section.lower().startswith('ssh:'):
|
|
362
|
+
continue # [ssh:*] blocks are bastion defs, not DB connections
|
|
363
|
+
connections.append({
|
|
364
|
+
'name': section,
|
|
365
|
+
'type': connection_type(cfg, section),
|
|
366
|
+
})
|
|
367
|
+
return json.dumps({"connections": connections, "count": len(connections)}, default=str)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def check_connection_query(connection: str = 'ck') -> str:
|
|
371
|
+
"""Lightweight SELECT 1 round-trip to verify a connection is reachable.
|
|
372
|
+
|
|
373
|
+
Returns JSON {connection, status:'ok', latency_ms} on success. Raises on
|
|
374
|
+
failure so the caller (the MCP tool) can wrap it with an agent-actionable
|
|
375
|
+
hint. Triggers the self-managed SSH tunnel for Auto-mode connections.
|
|
376
|
+
"""
|
|
377
|
+
start = time.time()
|
|
378
|
+
db = DBManager()
|
|
379
|
+
client = getattr(db, connection) # raises AttributeError if unknown; starts tunnel if Auto
|
|
380
|
+
if hasattr(client, 'query_dataframe'):
|
|
381
|
+
client.query_dataframe('SELECT 1')
|
|
382
|
+
else:
|
|
383
|
+
client.execute('SELECT 1')
|
|
384
|
+
return json.dumps({
|
|
385
|
+
"connection": connection,
|
|
386
|
+
"status": "ok",
|
|
387
|
+
"latency_ms": round((time.time() - start) * 1000, 1),
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def list_tables(connection: str = 'ck', database: str | None = None, like: str | None = None) -> str:
|
|
392
|
+
"""List tables in a database. Returns JSON with table names."""
|
|
393
|
+
try:
|
|
394
|
+
db = DBManager()
|
|
395
|
+
client = getattr(db, connection)
|
|
396
|
+
|
|
397
|
+
if hasattr(client, 'query_dataframe'):
|
|
398
|
+
# MySQL
|
|
399
|
+
sql = "SHOW TABLES"
|
|
400
|
+
if database:
|
|
401
|
+
sql = f"SHOW TABLES FROM `{database}`"
|
|
402
|
+
if like:
|
|
403
|
+
sql += f" LIKE '{like}'"
|
|
404
|
+
result = client.query_dataframe(sql)
|
|
405
|
+
if result.empty:
|
|
406
|
+
return json.dumps({"tables": [], "count": 0})
|
|
407
|
+
col = result.columns[0]
|
|
408
|
+
tables = result[col].tolist()
|
|
409
|
+
else:
|
|
410
|
+
# ClickHouse
|
|
411
|
+
if database:
|
|
412
|
+
sql = f"SHOW TABLES FROM `{database}`"
|
|
413
|
+
else:
|
|
414
|
+
sql = "SHOW TABLES"
|
|
415
|
+
if like:
|
|
416
|
+
sql += f" LIKE '{like}'"
|
|
417
|
+
result = client.execute(sql)
|
|
418
|
+
tables = [row[0] for row in result] if result else []
|
|
419
|
+
|
|
420
|
+
return json.dumps({"tables": tables, "count": len(tables)}, default=str)
|
|
421
|
+
except Exception as e:
|
|
422
|
+
return json.dumps({"error": clean_db_error(str(e))})
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# --- Cross-Connection Table Locator ---
|
|
426
|
+
|
|
427
|
+
# Namespaces excluded from the global catalog scan (engine-internal, not user data).
|
|
428
|
+
_CK_SYSTEM_DBS = "('system','INFORMATION_SCHEMA','information_schema')"
|
|
429
|
+
_MYSQL_SYSTEM_SCHEMAS = "('information_schema','mysql','performance_schema','sys')"
|
|
430
|
+
|
|
431
|
+
# Minimum similarity to surface as a fuzzy (typo) match.
|
|
432
|
+
_FUZZY_MIN_SCORE = 0.4
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _sanitize_search(s: str) -> str:
|
|
436
|
+
"""Strip a search term to safe alphanumerics before embedding in catalog SQL."""
|
|
437
|
+
return re.sub(r'[^\w]', '', s or '')
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _score_table_name(search: str, candidate: str) -> tuple:
|
|
441
|
+
"""Rank how close `candidate` is to `search`. Returns (score 0..1, match_type)."""
|
|
442
|
+
s = search.lower()
|
|
443
|
+
c = candidate.lower()
|
|
444
|
+
if s == c:
|
|
445
|
+
return 1.0, 'exact'
|
|
446
|
+
if s in c:
|
|
447
|
+
return 0.9, 'substring'
|
|
448
|
+
if c in s:
|
|
449
|
+
return 0.8, 'superstring'
|
|
450
|
+
s_tokens = {t for t in re.split(r'[\W_]+', s) if t}
|
|
451
|
+
c_tokens = {t for t in re.split(r'[\W_]+', c) if t}
|
|
452
|
+
overlap = len(s_tokens & c_tokens)
|
|
453
|
+
ratio = difflib.SequenceMatcher(None, s, c).ratio()
|
|
454
|
+
if overlap:
|
|
455
|
+
return min(0.89, 0.7 + 0.1 * overlap + 0.1 * ratio), 'token'
|
|
456
|
+
return ratio, 'fuzzy'
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _catalog_query(ctype: str, search: str) -> str:
|
|
460
|
+
"""Fast-path SQL: server-side case-insensitive substring filter on table name."""
|
|
461
|
+
if ctype == 'clickhouse':
|
|
462
|
+
return (
|
|
463
|
+
"SELECT database AS db_name, name AS tbl_name, engine "
|
|
464
|
+
"FROM system.tables "
|
|
465
|
+
f"WHERE positionCaseInsensitive(name, '{search}') > 0 "
|
|
466
|
+
f"AND database NOT IN {_CK_SYSTEM_DBS}"
|
|
467
|
+
)
|
|
468
|
+
return (
|
|
469
|
+
"SELECT table_schema AS db_name, table_name AS tbl_name, engine "
|
|
470
|
+
"FROM information_schema.tables "
|
|
471
|
+
f"WHERE LOWER(table_name) LIKE '%{search}%' "
|
|
472
|
+
f"AND table_schema NOT IN {_MYSQL_SYSTEM_SCHEMAS}"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _full_catalog_query(ctype: str) -> str:
|
|
477
|
+
"""Unfiltered catalog for fuzzy fallback (typo recovery). System namespaces excluded."""
|
|
478
|
+
if ctype == 'clickhouse':
|
|
479
|
+
return (
|
|
480
|
+
"SELECT database AS db_name, name AS tbl_name, engine "
|
|
481
|
+
"FROM system.tables "
|
|
482
|
+
f"WHERE database NOT IN {_CK_SYSTEM_DBS}"
|
|
483
|
+
)
|
|
484
|
+
return (
|
|
485
|
+
"SELECT table_schema AS db_name, table_name AS tbl_name, engine "
|
|
486
|
+
"FROM information_schema.tables "
|
|
487
|
+
f"WHERE table_schema NOT IN {_MYSQL_SYSTEM_SCHEMAS}"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _exec_catalog(client, sql: str) -> list:
|
|
492
|
+
"""Run a catalog SELECT; return list of (database, table, engine) tuples."""
|
|
493
|
+
if hasattr(client, 'query_dataframe'):
|
|
494
|
+
df = client.query_dataframe(sql)
|
|
495
|
+
if df is None or getattr(df, 'empty', True):
|
|
496
|
+
return []
|
|
497
|
+
out = []
|
|
498
|
+
for row in df.itertuples(index=False, name=None):
|
|
499
|
+
db_name = str(row[0]) if len(row) > 0 and row[0] is not None else ''
|
|
500
|
+
tbl = str(row[1]) if len(row) > 1 and row[1] is not None else ''
|
|
501
|
+
eng = str(row[2]) if len(row) > 2 and row[2] is not None else ''
|
|
502
|
+
out.append((db_name, tbl, eng))
|
|
503
|
+
return out
|
|
504
|
+
result = client.execute(sql, with_column_types=True)
|
|
505
|
+
if not result or not result[0]:
|
|
506
|
+
return []
|
|
507
|
+
data, _ = result
|
|
508
|
+
return [
|
|
509
|
+
(str(r[0]), str(r[1]), str(r[2]) if len(r) > 2 and r[2] is not None else '')
|
|
510
|
+
for r in data
|
|
511
|
+
]
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _load_table_list_cache(connection: str) -> dict:
|
|
515
|
+
return _load_json_safe(_get_cache_file_path('tables', connection))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _save_table_list_cache(connection: str, tables: list):
|
|
519
|
+
_save_json_safe(
|
|
520
|
+
_get_cache_file_path('tables', connection),
|
|
521
|
+
{'tables': tables, 'cached_at': datetime.now().isoformat()},
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def find_table_query(search: str, limit: int = 10) -> str:
|
|
526
|
+
"""Locate the table closest to `search` across ALL configured connections.
|
|
527
|
+
|
|
528
|
+
Fans out over every connection, ranks candidates by name closeness
|
|
529
|
+
(exact > substring > token > fuzzy), and returns each hit with its
|
|
530
|
+
`connection` + `database` + `table` (+ `qualified_name`) so the caller can
|
|
531
|
+
query the right place. Use this when unsure which connection holds a table,
|
|
532
|
+
or when a query/definition lookup failed on a guessed connection.
|
|
533
|
+
|
|
534
|
+
Fast path: server-side name filter (cheap). Fuzzy fallback: cached full
|
|
535
|
+
table list scored client-side for typo recovery. Per-connection errors are
|
|
536
|
+
swallowed and reported in `searched_connections` (one dead endpoint never
|
|
537
|
+
blanks the result).
|
|
538
|
+
"""
|
|
539
|
+
import configparser
|
|
540
|
+
|
|
541
|
+
term = _sanitize_search(search)
|
|
542
|
+
if not term:
|
|
543
|
+
return json.dumps({"error": "search must contain alphanumerics", "matches": []})
|
|
544
|
+
|
|
545
|
+
cfg = DBManager.load_config(CONFIG_FILE)
|
|
546
|
+
|
|
547
|
+
matches: list = []
|
|
548
|
+
searched: list = []
|
|
549
|
+
|
|
550
|
+
for section in cfg.sections():
|
|
551
|
+
if section.lower().startswith('ssh:'):
|
|
552
|
+
continue # [ssh:*] blocks are bastion defs, not DB connections
|
|
553
|
+
try:
|
|
554
|
+
ctype = connection_type(cfg, section)
|
|
555
|
+
except Exception as e:
|
|
556
|
+
searched.append({"name": section, "type": None,
|
|
557
|
+
"status": "error", "error": clean_db_error(str(e))})
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
client = getattr(DBManager(), section)
|
|
562
|
+
except Exception as e:
|
|
563
|
+
searched.append({"name": section, "type": ctype,
|
|
564
|
+
"status": "error", "error": clean_db_error(str(e))})
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
conn_err = None
|
|
568
|
+
try:
|
|
569
|
+
rows = _exec_catalog(client, _catalog_query(ctype, term))
|
|
570
|
+
except Exception as e:
|
|
571
|
+
rows = []
|
|
572
|
+
conn_err = clean_db_error(str(e))
|
|
573
|
+
|
|
574
|
+
# Fast path empty -> fuzzy fallback over cached full table list
|
|
575
|
+
if not rows:
|
|
576
|
+
cache = _load_table_list_cache(section)
|
|
577
|
+
full = cache.get('tables') if (
|
|
578
|
+
cache and not _is_cache_expired(cache.get('cached_at', ''))
|
|
579
|
+
) else None
|
|
580
|
+
if full is None:
|
|
581
|
+
try:
|
|
582
|
+
full = _exec_catalog(client, _full_catalog_query(ctype))
|
|
583
|
+
_save_table_list_cache(section, full)
|
|
584
|
+
except Exception as e:
|
|
585
|
+
conn_err = conn_err or clean_db_error(str(e))
|
|
586
|
+
full = []
|
|
587
|
+
rows = full
|
|
588
|
+
|
|
589
|
+
scored = []
|
|
590
|
+
for db_, tbl, eng in rows:
|
|
591
|
+
score, mtype = _score_table_name(term, tbl)
|
|
592
|
+
if score < _FUZZY_MIN_SCORE:
|
|
593
|
+
continue
|
|
594
|
+
scored.append({
|
|
595
|
+
"connection": section,
|
|
596
|
+
"database": db_,
|
|
597
|
+
"table": tbl,
|
|
598
|
+
"engine": eng,
|
|
599
|
+
"score": round(score, 3),
|
|
600
|
+
"match_type": mtype,
|
|
601
|
+
"qualified_name": f"{db_}.{tbl}" if db_ else tbl,
|
|
602
|
+
})
|
|
603
|
+
matches.extend(scored)
|
|
604
|
+
searched.append({
|
|
605
|
+
"name": section, "type": ctype,
|
|
606
|
+
"status": "error" if (conn_err and not scored) else "ok",
|
|
607
|
+
"error": conn_err if not scored else None,
|
|
608
|
+
"match_count": len(scored),
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
matches.sort(key=lambda m: m["score"], reverse=True)
|
|
612
|
+
top = matches[: max(0, int(limit))]
|
|
613
|
+
|
|
614
|
+
return json.dumps({
|
|
615
|
+
"search": search,
|
|
616
|
+
"normalized_search": term,
|
|
617
|
+
"matches": top,
|
|
618
|
+
"total_matches": len(matches),
|
|
619
|
+
"searched_connections": searched,
|
|
620
|
+
}, default=str)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
# --- Explain ---
|
|
624
|
+
|
|
625
|
+
def explain_query(sql: str, connection: str = 'ck') -> str:
|
|
626
|
+
"""Run EXPLAIN on a query, return plan with cost estimates. Returns JSON."""
|
|
627
|
+
if not _is_query_allowed(sql):
|
|
628
|
+
raise AssertionError("Query must be a read-only operation.")
|
|
629
|
+
|
|
630
|
+
explain_sql = f"EXPLAIN {sql}"
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
db = DBManager()
|
|
634
|
+
client = getattr(db, connection)
|
|
635
|
+
|
|
636
|
+
if hasattr(client, 'query_dataframe'):
|
|
637
|
+
result = client.query_dataframe(explain_sql)
|
|
638
|
+
if result.empty:
|
|
639
|
+
return json.dumps({"plan": [], "raw": ""})
|
|
640
|
+
col = result.columns[0]
|
|
641
|
+
plan_lines = result[col].tolist()
|
|
642
|
+
else:
|
|
643
|
+
result = client.execute(explain_sql)
|
|
644
|
+
plan_lines = [row[0] for row in result] if result else []
|
|
645
|
+
|
|
646
|
+
plan_text = '\n'.join(str(line) for line in plan_lines)
|
|
647
|
+
|
|
648
|
+
cost_info = {}
|
|
649
|
+
for line in plan_lines:
|
|
650
|
+
line_str = str(line)
|
|
651
|
+
for metric in ['rows', 'bytes', 'memory', 'cost']:
|
|
652
|
+
m = re.search(rf'{metric}\s*[=:]\s*([\d.]+)', line_str, re.IGNORECASE)
|
|
653
|
+
if m:
|
|
654
|
+
cost_info[metric] = cost_info.get(metric, 0) + float(m.group(1))
|
|
655
|
+
|
|
656
|
+
return json.dumps({
|
|
657
|
+
"plan": plan_text,
|
|
658
|
+
"plan_lines": [str(l) for l in plan_lines],
|
|
659
|
+
"cost_estimates": cost_info if cost_info else None,
|
|
660
|
+
}, default=str)
|
|
661
|
+
except Exception as e:
|
|
662
|
+
return json.dumps({"error": clean_db_error(str(e))})
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# --- Decimal Handling ---
|
|
666
|
+
|
|
667
|
+
def _sanitize_row(row_dict: dict) -> dict:
|
|
668
|
+
"""Convert Decimal values to float for clean JSON serialization."""
|
|
669
|
+
from decimal import Decimal
|
|
670
|
+
cleaned = {}
|
|
671
|
+
for k, v in row_dict.items():
|
|
672
|
+
if isinstance(v, Decimal):
|
|
673
|
+
cleaned[k] = float(v)
|
|
674
|
+
else:
|
|
675
|
+
cleaned[k] = v
|
|
676
|
+
return cleaned
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# --- Query Execution ---
|
|
680
|
+
|
|
681
|
+
def execute_limit_query(sql: str, connection: str = 'ck') -> str:
|
|
682
|
+
"""Execute read-only query. Auto-adds LIMIT (default 10, max 2000). Returns JSON with metadata."""
|
|
683
|
+
sql_trimmed = sql.strip().upper()
|
|
684
|
+
|
|
685
|
+
if not _is_query_allowed(sql):
|
|
686
|
+
raise AssertionError(
|
|
687
|
+
f"Query must start with: SELECT, SHOW, DESCRIBE, DESC, EXPLAIN, "
|
|
688
|
+
f"USE, WITH, or EXISTS. Got: {sql_trimmed[:50]}"
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
# LIMIT enforcement for SELECT and WITH (CTE) queries
|
|
692
|
+
is_select_like = sql_trimmed.startswith('SELECT') or sql_trimmed.startswith('WITH')
|
|
693
|
+
if is_select_like and 'LIMIT' not in sql_trimmed:
|
|
694
|
+
sql = f"{sql.rstrip().rstrip(';')} LIMIT {DEFAULT_LIMIT}"
|
|
695
|
+
elif is_select_like:
|
|
696
|
+
match = re.search(r'LIMIT\s+(\d+)', sql, re.IGNORECASE)
|
|
697
|
+
if match and int(match.group(1)) > MAX_LIMIT:
|
|
698
|
+
sql = re.sub(r'LIMIT\s+\d+', f'LIMIT {MAX_LIMIT}', sql, flags=re.IGNORECASE)
|
|
699
|
+
|
|
700
|
+
return execute_query(sql, connection)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def execute_query(sql: str, connection: str = 'ck') -> str:
|
|
704
|
+
"""Execute a read-only query and record it in history. Returns JSON."""
|
|
705
|
+
if not _is_query_allowed(sql):
|
|
706
|
+
raise AssertionError("Query must be a read-only operation.")
|
|
707
|
+
|
|
708
|
+
start_time = time.time()
|
|
709
|
+
query_hash = _generate_query_hash(sql)
|
|
710
|
+
tbl_name = _get_table_name_from_sql(sql)
|
|
711
|
+
|
|
712
|
+
try:
|
|
713
|
+
db = DBManager()
|
|
714
|
+
client = getattr(db, connection)
|
|
715
|
+
result_json = _result_to_json(client, sql)
|
|
716
|
+
query_time = time.time() - start_time
|
|
717
|
+
|
|
718
|
+
# Inject query_time into result
|
|
719
|
+
result_data = json.loads(result_json)
|
|
720
|
+
result_data['query_time_ms'] = round(query_time * 1000, 1)
|
|
721
|
+
|
|
722
|
+
# Save to history
|
|
723
|
+
history = _load_query_history(connection)
|
|
724
|
+
history[query_hash] = {
|
|
725
|
+
'sql': _security.sanitize_text(sql),
|
|
726
|
+
'query_time': query_time,
|
|
727
|
+
'table_name': tbl_name,
|
|
728
|
+
'executed_at': datetime.now().isoformat(),
|
|
729
|
+
}
|
|
730
|
+
_save_query_history(connection, history)
|
|
731
|
+
|
|
732
|
+
return json.dumps(result_data, default=str)
|
|
733
|
+
|
|
734
|
+
except Exception as e:
|
|
735
|
+
return json.dumps({"error": clean_db_error(str(e))})
|