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.
- package/LICENSE +202 -0
- package/README.md +326 -0
- package/client.js +2939 -0
- package/cordis.patch.yml +6 -0
- package/docs/requirements.md +960 -0
- package/icon.svg +8 -0
- package/index.js +119 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -0
- package/server/engines.py +617 -0
- package/server/main.py +260 -0
- package/server/requirements.txt +10 -0
- package/server/store.py +98 -0
- package/src/host.js +1507 -0
- package/src/mcp.js +264 -0
- package/src/tools.js +372 -0
|
@@ -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}
|