dsh-db-mcp 0.6.4

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.
@@ -0,0 +1,617 @@
1
+ """Per-engine connection, introspection and query helpers.
2
+
3
+ Each public function takes a resolved datasource profile (the registry entry) and
4
+ returns plain JSON-able data. Nothing here keeps a connection alive between
5
+ calls: a tool call opens, uses and closes its own connection, which keeps the
6
+ process stateless and safe to restart.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as _dt
12
+ import decimal
13
+ import os
14
+ import re
15
+ import sqlite3
16
+ import time
17
+ from pathlib import Path
18
+
19
+ READ_STARTERS = ("select", "with", "show", "describe", "desc", "explain", "pragma", "values", "table", "set")
20
+
21
+
22
+ def allow_write() -> bool:
23
+ """Global write switch, exported by the host when it spawns this server."""
24
+ return os.environ.get("DB_MCP_ALLOW_WRITE", "0") == "1"
25
+ MAX_TEXT = 4000
26
+
27
+
28
+ class DbError(Exception):
29
+ def __init__(self, code: str, message: str, hint: str | None = None):
30
+ super().__init__(message)
31
+ self.code = code
32
+ self.message = message
33
+ self.hint = hint
34
+
35
+
36
+ # --------------------------------------------------------------------------- #
37
+ # helpers
38
+
39
+
40
+ def _first_keyword(sql: str) -> str:
41
+ text = sql or ""
42
+ # drop leading line/block comments so `-- hi\nselect 1` still reads as SELECT
43
+ while True:
44
+ stripped = text.lstrip()
45
+ if stripped.startswith("--"):
46
+ newline = stripped.find("\n")
47
+ text = "" if newline < 0 else stripped[newline + 1 :]
48
+ continue
49
+ if stripped.startswith("/*"):
50
+ end = stripped.find("*/")
51
+ text = "" if end < 0 else stripped[end + 2 :]
52
+ continue
53
+ text = stripped
54
+ break
55
+ match = re.match(r"[A-Za-z]+", text)
56
+ return match.group(0).lower() if match else ""
57
+
58
+
59
+ def ensure_readable(sql: str, profile: dict) -> None:
60
+ """Refuse changing statements on a read-only source, before touching the DB."""
61
+ keyword = _first_keyword(sql)
62
+ if keyword in READ_STARTERS:
63
+ return
64
+ if not allow_write():
65
+ raise DbError(
66
+ "user_error",
67
+ f"writes are disabled for the whole plugin; `{keyword or 'this'} ` statements are refused",
68
+ "turn on the global write switch in the Database panel first",
69
+ )
70
+ if profile.get("readOnly"):
71
+ raise DbError(
72
+ "user_error",
73
+ f"datasource {profile.get('id')} is read-only; `{keyword or 'this'} ` statements are refused",
74
+ "uncheck read-only for this source in the Database panel to allow writes",
75
+ )
76
+
77
+
78
+ def _cell(value):
79
+ if value is None or isinstance(value, (bool, int, float)):
80
+ return value
81
+ if isinstance(value, decimal.Decimal):
82
+ return str(value)
83
+ if isinstance(value, (_dt.datetime, _dt.date, _dt.time)):
84
+ return value.isoformat()
85
+ if isinstance(value, (bytes, bytearray, memoryview)):
86
+ raw = bytes(value)
87
+ return f"<{len(raw)} bytes> " + raw[:64].hex()
88
+ text = str(value)
89
+ return text if len(text) <= MAX_TEXT else text[:MAX_TEXT] + f"… (+{len(text) - MAX_TEXT})"
90
+
91
+
92
+ def _rows(cursor, max_rows: int) -> tuple[list[str], list[list], bool]:
93
+ columns = [str(description[0]) for description in (cursor.description or [])]
94
+ rows: list[list] = []
95
+ truncated = False
96
+ for index, row in enumerate(cursor):
97
+ if index >= max_rows:
98
+ truncated = True
99
+ break
100
+ rows.append([_cell(value) for value in row])
101
+ return columns, rows, truncated
102
+
103
+
104
+ def _quote(kind: str, name: str) -> str:
105
+ text = str(name)
106
+ if kind == "mysql":
107
+ return "`" + text.replace("`", "``") + "`"
108
+ if kind == "oracle":
109
+ # Oracle folds unquoted identifiers to upper case; quoting a lower-case
110
+ # name would look for a table that was never created that way.
111
+ return '"' + text.upper().replace('"', '""') + '"'
112
+ return '"' + text.replace('"', '""') + '"'
113
+
114
+
115
+ def qualified(profile: dict, table: str) -> str:
116
+ kind = profile.get("kind")
117
+ schema = (profile.get("config") or {}).get("schema") or None
118
+ if kind == "oracle":
119
+ schema = (schema or (profile.get("config") or {}).get("user") or "").upper() or None
120
+ if schema and kind in ("postgres", "oracle", "mysql"):
121
+ return f"{_quote(kind, schema)}.{_quote(kind, table)}"
122
+ return _quote(kind, table)
123
+
124
+
125
+ def _limit_clause(kind: str, limit: int) -> str:
126
+ if kind == "oracle":
127
+ return f" FETCH FIRST {int(limit)} ROWS ONLY"
128
+ return f" LIMIT {int(limit)}"
129
+
130
+
131
+ # --------------------------------------------------------------------------- #
132
+ # connections
133
+
134
+
135
+ def connect(profile: dict, password: str | None):
136
+ """Open one connection for `profile`. Returns (connection, close-callable)."""
137
+ kind = profile.get("kind")
138
+ config = profile.get("config") or {}
139
+ if kind == "sqlite":
140
+ return _connect_sqlite(profile, config)
141
+ if kind == "postgres":
142
+ return _connect_postgres(profile, config, password)
143
+ if kind == "mysql":
144
+ return _connect_mysql(profile, config, password)
145
+ if kind == "oracle":
146
+ return _connect_oracle(profile, config, password)
147
+ raise DbError("user_error", f"unsupported datasource kind: {kind}")
148
+
149
+
150
+ def _connect_sqlite(profile: dict, config: dict):
151
+ path = config.get("file")
152
+ if not path:
153
+ raise DbError("user_error", "this SQLite datasource has no file path")
154
+ target = Path(str(path)).expanduser()
155
+ if not target.exists():
156
+ raise DbError("db_error", f"SQLite file not found: {target}")
157
+ timeout = float(config.get("busyTimeoutMs") or 5000) / 1000.0
158
+ if profile.get("readOnly"):
159
+ uri = target.resolve().as_uri() + "?mode=ro"
160
+ connection = sqlite3.connect(uri, uri=True, timeout=timeout)
161
+ else:
162
+ connection = sqlite3.connect(str(target), timeout=timeout)
163
+ return connection, connection.close
164
+
165
+
166
+ def _connect_postgres(profile: dict, config: dict, password: str | None):
167
+ try:
168
+ import psycopg
169
+ except ImportError as error: # pragma: no cover - environment problem
170
+ raise DbError("infra_error", f"psycopg is not installed: {error}", "run the plugin's Python setup again")
171
+ options = "-c default_transaction_read_only=on" if profile.get("readOnly") else None
172
+ try:
173
+ connection = psycopg.connect(
174
+ host=config.get("host") or "localhost",
175
+ port=int(config.get("port") or 5432),
176
+ dbname=config.get("database") or None,
177
+ user=config.get("user") or None,
178
+ password=password,
179
+ connect_timeout=10,
180
+ options=options,
181
+ application_name=config.get("applicationName") or "dsh-db-mcp",
182
+ )
183
+ except Exception as error: # noqa: BLE001 - driver specific exception types
184
+ raise DbError("db_error", f"PostgreSQL connection failed: {error}")
185
+ return connection, connection.close
186
+
187
+
188
+ def _connect_mysql(profile: dict, config: dict, password: str | None):
189
+ try:
190
+ import pymysql
191
+ except ImportError as error: # pragma: no cover - environment problem
192
+ raise DbError("infra_error", f"PyMySQL is not installed: {error}", "run the plugin's Python setup again")
193
+ try:
194
+ connection = pymysql.connect(
195
+ host=config.get("host") or "localhost",
196
+ port=int(config.get("port") or 3306),
197
+ database=config.get("database") or None,
198
+ user=config.get("user") or None,
199
+ password=password or "",
200
+ connect_timeout=10,
201
+ charset=config.get("charset") or "utf8mb4",
202
+ autocommit=False,
203
+ )
204
+ except Exception as error: # noqa: BLE001 - driver specific exception types
205
+ raise DbError("db_error", f"MySQL connection failed: {error}")
206
+ if profile.get("readOnly"):
207
+ try:
208
+ with connection.cursor() as cursor:
209
+ cursor.execute("SET SESSION TRANSACTION READ ONLY")
210
+ except Exception: # noqa: BLE001 - older servers do not support the switch
211
+ pass
212
+ return connection, connection.close
213
+
214
+
215
+ def _connect_oracle(profile: dict, config: dict, password: str | None):
216
+ try:
217
+ import oracledb
218
+ except ImportError as error: # pragma: no cover - environment problem
219
+ raise DbError("infra_error", f"oracledb is not installed: {error}", "run the plugin's Python setup again")
220
+ host = config.get("host") or "localhost"
221
+ port = int(config.get("port") or 1521)
222
+ if config.get("serviceName"):
223
+ dsn = oracledb.makedsn(host, port, service_name=str(config["serviceName"]))
224
+ elif config.get("sid"):
225
+ dsn = oracledb.makedsn(host, port, sid=str(config["sid"]))
226
+ else:
227
+ raise DbError("user_error", "this Oracle datasource needs a service name or a SID")
228
+ try:
229
+ connection = oracledb.connect(user=config.get("user") or None, password=password, dsn=dsn)
230
+ except Exception as error: # noqa: BLE001 - driver specific exception types
231
+ raise DbError("db_error", f"Oracle connection failed: {error}")
232
+ return connection, connection.close
233
+
234
+
235
+ # --------------------------------------------------------------------------- #
236
+ # connection probe (used by the panel's Test button)
237
+
238
+
239
+ def _fetch(kind: str, connection, sql: str):
240
+ if kind == "sqlite":
241
+ return connection.execute(sql).fetchone()
242
+ cursor = connection.cursor()
243
+ try:
244
+ cursor.execute(sql)
245
+ return cursor.fetchone()
246
+ finally:
247
+ cursor.close()
248
+
249
+
250
+ def _probe_identity(kind: str, connection, profile: dict) -> dict:
251
+ config = profile.get("config") or {}
252
+ if kind == "sqlite":
253
+ row = _fetch(kind, connection, "select sqlite_version()")
254
+ return {"server": f"SQLite {row[0] if row else ''}".strip(), "database": config.get("file")}
255
+ if kind == "postgres":
256
+ row = _fetch(kind, connection, "select version()")
257
+ banner = str(row[0]) if row else "PostgreSQL"
258
+ row = _fetch(kind, connection, "select current_database(), current_user, current_schema()")
259
+ database, user, schema = row if row else (None, None, None)
260
+ return {
261
+ "server": banner.split(" on ")[0],
262
+ "database": database,
263
+ "user": user,
264
+ "schema": schema,
265
+ }
266
+ if kind == "mysql":
267
+ row = _fetch(kind, connection, "select version()")
268
+ version = str(row[0]) if row else ""
269
+ row = _fetch(kind, connection, "select database(), current_user()")
270
+ database, user = row if row else (None, None)
271
+ return {"server": f"MySQL {version}".strip(), "database": database, "user": user}
272
+ # oracle
273
+ banner = "Oracle"
274
+ try:
275
+ row = _fetch(kind, connection, "select banner from v$version where rownum = 1")
276
+ if row and row[0]:
277
+ banner = str(row[0]).split("\n")[0].split(" - ")[0].strip()
278
+ except DbError:
279
+ raise
280
+ except Exception: # noqa: BLE001 - v$version is not always readable
281
+ banner = "Oracle"
282
+ row = _fetch(
283
+ kind,
284
+ connection,
285
+ "select sys_context('USERENV','DB_NAME'), sys_context('USERENV','CURRENT_SCHEMA'), "
286
+ "sys_context('USERENV','SESSION_USER') from dual",
287
+ )
288
+ database, schema, user = row if row else (None, None, None)
289
+ return {"server": banner, "database": database, "user": user, "schema": schema}
290
+
291
+
292
+ def _probe_message(kind: str, identity: dict) -> str:
293
+ parts = [identity.get("server") or kind]
294
+ user = identity.get("user")
295
+ database = identity.get("database")
296
+ if user and database:
297
+ parts.append(f"{user}@{database}")
298
+ elif database:
299
+ parts.append(str(database))
300
+ example = {
301
+ "sqlite": "select 1",
302
+ "postgres": "select version()",
303
+ "mysql": "select version()",
304
+ "oracle": "select 1 from dual",
305
+ }[kind]
306
+ parts.append(f"{example} ok")
307
+ return " · ".join(parts)
308
+
309
+
310
+ def probe(profile: dict, password: str | None) -> dict:
311
+ """Log in for real, run one trivial query, and report server, user and schema."""
312
+ started = time.perf_counter()
313
+ kind = profile.get("kind")
314
+ connection, close = connect(profile, password)
315
+ try:
316
+ identity = _probe_identity(kind, connection, profile)
317
+ _fetch(kind, connection, "select 1 from dual" if kind == "oracle" else "select 1")
318
+ finally:
319
+ close()
320
+ latency = int((time.perf_counter() - started) * 1000)
321
+ detail = {"stage": "login"}
322
+ for key, value in identity.items():
323
+ if value is not None:
324
+ detail[key] = str(value)
325
+ return {
326
+ "ok": True,
327
+ "kind": kind,
328
+ "latencyMs": latency,
329
+ "message": _probe_message(kind, identity),
330
+ "detail": detail,
331
+ }
332
+
333
+
334
+ # --------------------------------------------------------------------------- #
335
+ # introspection
336
+
337
+
338
+ def list_tables(profile: dict, connection, schema: str | None, limit: int = 500) -> dict:
339
+ kind = profile.get("kind")
340
+ config = profile.get("config") or {}
341
+ effective = schema or config.get("schema") or None
342
+ cursor = connection.cursor()
343
+ try:
344
+ if kind == "sqlite":
345
+ cursor.execute(
346
+ "select name, type from sqlite_master where type in ('table','view') "
347
+ "and name not like 'sqlite_%' order by type, name"
348
+ )
349
+ rows = [[name, "view" if kind_of == "view" else "table", "main"] for name, kind_of in cursor.fetchall()]
350
+ elif kind == "postgres":
351
+ base = (
352
+ "select table_schema, table_name, table_type from information_schema.tables "
353
+ "where table_schema not in ('pg_catalog','information_schema')"
354
+ )
355
+ if effective:
356
+ cursor.execute(base + " and table_schema = %s order by table_schema, table_name", (str(effective),))
357
+ else:
358
+ cursor.execute(base + " order by table_schema, table_name")
359
+ rows = [[name, "view" if "VIEW" in str(kind_of).upper() else "table", owner] for owner, name, kind_of in cursor.fetchall()]
360
+ elif kind == "mysql":
361
+ cursor.execute(
362
+ "select table_schema, table_name, table_type from information_schema.tables "
363
+ "where table_schema = coalesce(%s, database()) order by table_name",
364
+ (effective,),
365
+ )
366
+ rows = [[name, "view" if "VIEW" in str(kind_of).upper() else "table", owner] for owner, name, kind_of in cursor.fetchall()]
367
+ elif kind == "oracle":
368
+ owner = (effective or config.get("user") or "").upper() or None
369
+ cursor.execute(
370
+ "select owner, table_name, 'TABLE' from all_tables where owner = :1 "
371
+ "union all select owner, view_name, 'VIEW' from all_views where owner = :2 "
372
+ "order by 1, 2",
373
+ [owner, owner],
374
+ )
375
+ rows = [[name, "view" if kind_of == "VIEW" else "table", owner_name] for owner_name, name, kind_of in cursor.fetchall()]
376
+ else:
377
+ raise DbError("user_error", f"unsupported datasource kind: {kind}")
378
+ finally:
379
+ cursor.close()
380
+ truncated = len(rows) > limit
381
+ return {
382
+ "schema": effective,
383
+ "tables": [{"schema": item[2], "name": item[0], "type": item[1]} for item in rows[:limit]],
384
+ "truncated": truncated,
385
+ "total": len(rows),
386
+ }
387
+
388
+
389
+ def describe_table(profile: dict, connection, table: str, schema: str | None) -> dict:
390
+ kind = profile.get("kind")
391
+ config = profile.get("config") or {}
392
+ effective = schema or config.get("schema") or None
393
+ columns: list[dict] = []
394
+ primary: list[str] = []
395
+ cursor = connection.cursor()
396
+ try:
397
+ if kind == "sqlite":
398
+ cursor.execute(f"PRAGMA table_info({_quote('sqlite', table)})")
399
+ for _cid, name, type_name, notnull, default, pk in cursor.fetchall():
400
+ columns.append(
401
+ {
402
+ "name": name,
403
+ "type": type_name or "",
404
+ "nullable": not bool(notnull),
405
+ "default": default,
406
+ "primaryKey": bool(pk),
407
+ }
408
+ )
409
+ if pk:
410
+ primary.append(name)
411
+ elif kind in ("postgres", "mysql"):
412
+ owner = effective or (config.get("database") if kind == "mysql" else "public")
413
+ placeholder = "%s"
414
+ cursor.execute(
415
+ "select column_name, data_type, is_nullable, column_default, character_maximum_length, numeric_precision, numeric_scale "
416
+ f"from information_schema.columns where table_name = {placeholder} and table_schema = {placeholder} "
417
+ "order by ordinal_position",
418
+ (table, owner),
419
+ )
420
+ for name, data_type, nullable, default, char_len, precision, scale in cursor.fetchall():
421
+ type_name = data_type
422
+ if char_len:
423
+ type_name = f"{data_type}({char_len})"
424
+ elif data_type in ("numeric", "decimal", "number") and precision:
425
+ type_name = f"{data_type}({precision},{scale or 0})"
426
+ columns.append(
427
+ {
428
+ "name": name,
429
+ "type": type_name,
430
+ "nullable": str(nullable).upper() == "YES",
431
+ "default": default,
432
+ "primaryKey": False,
433
+ }
434
+ )
435
+ cursor.execute(
436
+ "select kcu.column_name from information_schema.table_constraints tc "
437
+ "join information_schema.key_column_usage kcu "
438
+ " on kcu.constraint_name = tc.constraint_name and kcu.table_schema = tc.table_schema "
439
+ f"where tc.constraint_type = 'PRIMARY KEY' and tc.table_name = {placeholder} and tc.table_schema = {placeholder} "
440
+ "order by kcu.ordinal_position",
441
+ (table, owner),
442
+ )
443
+ primary = [row[0] for row in cursor.fetchall()]
444
+ for column in columns:
445
+ column["primaryKey"] = column["name"] in primary
446
+ elif kind == "oracle":
447
+ owner = (effective or config.get("user") or "").upper() or None
448
+ cursor.execute(
449
+ "select column_name, data_type, data_length, data_precision, data_scale, nullable, data_default "
450
+ "from all_tab_columns where owner = :1 and table_name = :2 order by column_id",
451
+ [owner, table.upper()],
452
+ )
453
+ for name, data_type, length, precision, scale, nullable, default in cursor.fetchall():
454
+ type_name = data_type
455
+ if data_type in ("VARCHAR2", "CHAR", "NVARCHAR2") and length:
456
+ type_name = f"{data_type}({length})"
457
+ elif data_type == "NUMBER" and precision:
458
+ type_name = f"NUMBER({precision},{scale or 0})"
459
+ columns.append(
460
+ {
461
+ "name": name,
462
+ "type": type_name,
463
+ "nullable": str(nullable).upper() == "Y",
464
+ "default": (default or "").strip() or None,
465
+ "primaryKey": False,
466
+ }
467
+ )
468
+ cursor.execute(
469
+ "select cc.column_name from all_constraints c join all_cons_columns cc "
470
+ " on c.owner = cc.owner and c.constraint_name = cc.constraint_name "
471
+ "where c.constraint_type = 'P' and c.owner = :1 and c.table_name = :2 order by cc.position",
472
+ [owner, table.upper()],
473
+ )
474
+ primary = [row[0] for row in cursor.fetchall()]
475
+ for column in columns:
476
+ column["primaryKey"] = column["name"] in primary
477
+ else:
478
+ raise DbError("user_error", f"unsupported datasource kind: {kind}")
479
+ finally:
480
+ cursor.close()
481
+ if not columns:
482
+ raise DbError("user_error", f"table not found: {table}", "call list_tables to see the available tables")
483
+ return {"table": table, "schema": effective, "columns": columns, "primaryKey": primary}
484
+
485
+
486
+ # --------------------------------------------------------------------------- #
487
+ # DDL
488
+
489
+
490
+ def table_ddl(profile: dict, connection, table: str, schema: str | None = None) -> dict:
491
+ """Return a CREATE TABLE statement for one table.
492
+
493
+ SQLite keeps the original text in `sqlite_master`, so that is returned
494
+ verbatim. Every other engine is rebuilt from the column metadata that
495
+ `describe_table` already reads, which works without extra privileges and
496
+ keeps the four engines consistent.
497
+ """
498
+ kind = profile.get("kind")
499
+ if kind == "sqlite":
500
+ cursor = connection.cursor()
501
+ try:
502
+ cursor.execute(
503
+ "select sql from sqlite_master where type in ('table', 'view') and name = ?",
504
+ (table,),
505
+ )
506
+ row = cursor.fetchone()
507
+ finally:
508
+ cursor.close()
509
+ if not row or not row[0]:
510
+ raise DbError(
511
+ "user_error",
512
+ f"no DDL recorded for {table}",
513
+ "virtual tables and internal objects do not keep their statement",
514
+ )
515
+ return {
516
+ "table": table,
517
+ "schema": None,
518
+ "ddl": row[0].strip().rstrip(";") + ";",
519
+ "source": "sqlite_master",
520
+ }
521
+
522
+ info = describe_table(profile, connection, table, schema)
523
+ effective = info.get("schema") or None
524
+ if effective and kind in ("postgres", "oracle", "mysql"):
525
+ target = f"{_quote(kind, effective)}.{_quote(kind, table)}"
526
+ else:
527
+ target = _quote(kind, table)
528
+
529
+ lines: list[str] = []
530
+ for column in info["columns"]:
531
+ piece = f" {_quote(kind, column['name'])} {column['type'] or 'text'}"
532
+ if not column["nullable"] and not column["primaryKey"]:
533
+ piece += " not null"
534
+ if column.get("default") not in (None, ""):
535
+ piece += f" default {column['default']}"
536
+ lines.append(piece)
537
+ if info["primaryKey"]:
538
+ keys = ", ".join(_quote(kind, name) for name in info["primaryKey"])
539
+ lines.append(f" primary key ({keys})")
540
+ return {
541
+ "table": table,
542
+ "schema": effective,
543
+ "ddl": f"create table {target} (\n" + ",\n".join(lines) + "\n);",
544
+ "source": "columns",
545
+ }
546
+
547
+
548
+ # --------------------------------------------------------------------------- #
549
+ # queries
550
+
551
+
552
+ def run_query(profile: dict, connection, sql: str, max_rows: int) -> dict:
553
+ ensure_readable(sql, profile)
554
+ kind = profile.get("kind")
555
+ started = time.perf_counter()
556
+ cursor = connection.cursor()
557
+ try:
558
+ if kind == "oracle" and profile.get("readOnly"):
559
+ try:
560
+ cursor.execute("SET TRANSACTION READ ONLY")
561
+ except Exception: # noqa: BLE001 - not every session allows the switch
562
+ pass
563
+ cursor.execute(sql)
564
+ columns, rows, truncated = _rows(cursor, max_rows)
565
+ if profile.get("readOnly") or not connection_autocommit(kind):
566
+ try:
567
+ connection.rollback()
568
+ except Exception: # noqa: BLE001 - nothing to roll back
569
+ pass
570
+ finally:
571
+ cursor.close()
572
+ return {
573
+ "sql": sql,
574
+ "columns": columns,
575
+ "rows": rows,
576
+ "rowCount": len(rows),
577
+ "truncated": truncated,
578
+ "elapsedMs": int((time.perf_counter() - started) * 1000),
579
+ }
580
+
581
+
582
+ def connection_autocommit(kind: str) -> bool:
583
+ return kind == "sqlite"
584
+
585
+
586
+ def sample_rows(profile: dict, connection, table: str, limit: int, schema: str | None) -> dict:
587
+ sql = f"SELECT * FROM {qualified(profile, table)}{_limit_clause(profile.get('kind'), limit)}"
588
+ result = run_query(profile, connection, sql, limit)
589
+ result["table"] = table
590
+ result["schema"] = schema or (profile.get("config") or {}).get("schema") or None
591
+ return result
592
+
593
+
594
+ def explain_query(profile: dict, connection, sql: str) -> dict:
595
+ ensure_readable(sql, profile)
596
+ kind = profile.get("kind")
597
+ cursor = connection.cursor()
598
+ try:
599
+ if kind == "sqlite":
600
+ cursor.execute(f"EXPLAIN QUERY PLAN {sql}")
601
+ plan = "\n".join(" | ".join(str(part) for part in row) for row in cursor.fetchall())
602
+ elif kind == "postgres":
603
+ cursor.execute(f"EXPLAIN {sql}")
604
+ plan = "\n".join(str(row[0]) for row in cursor.fetchall())
605
+ elif kind == "mysql":
606
+ cursor.execute(f"EXPLAIN {sql}")
607
+ header = [str(description[0]) for description in (cursor.description or [])]
608
+ plan = "\n".join(" | ".join(f"{key}={value}" for key, value in zip(header, row)) for row in cursor.fetchall())
609
+ elif kind == "oracle":
610
+ cursor.execute(f"EXPLAIN PLAN FOR {sql}")
611
+ cursor.execute("select plan_table_output from table(dbms_xplan.display(null, null, 'BASIC'))")
612
+ plan = "\n".join(str(row[0]) for row in cursor.fetchall())
613
+ else:
614
+ raise DbError("user_error", f"unsupported datasource kind: {kind}")
615
+ finally:
616
+ cursor.close()
617
+ return {"sql": sql, "plan": plan}