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.
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))})