@yejiming/dsh-data-agent 0.0.6 → 0.0.10
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/README.en.md +142 -119
- package/README.md +138 -118
- package/cordis.patch.yml +8 -9
- package/lib/client.js +42423 -524
- package/lib/client.js.map +1 -1
- package/lib/command-LFgLb6el.js +875 -0
- package/lib/command.js +2 -0
- package/lib/connections-WmjuUrDj.js +1608 -0
- package/lib/defaults-DP4RyRh1.js +21 -0
- package/lib/index.js +265 -68
- package/lib/routes.js +94 -170
- package/lib/tool-Dka6RyEp.js +1128 -0
- package/lib/tool.js +1 -426
- package/lib/types/analysis.d.ts +1071 -0
- package/lib/types/client/AnalysisChart.d.ts +26 -0
- package/lib/types/client/AnalysisDashboard.d.ts +30 -0
- package/lib/types/client/DataAgentWorkbench.d.ts +2 -2
- package/lib/types/client/analysis-charts.d.ts +40 -0
- package/lib/types/client/analysis-view-model.d.ts +44 -0
- package/lib/types/client/index.d.ts +3 -4
- package/lib/types/client/locales.d.ts +66 -0
- package/lib/types/client/persistence.d.ts +6 -1
- package/lib/types/client-discovery.d.ts +45 -0
- package/lib/types/clients.d.ts +17 -10
- package/lib/types/command.d.ts +41 -0
- package/lib/types/connections.d.ts +115 -40
- package/lib/types/defaults.d.ts +2 -0
- package/lib/types/index.d.ts +109 -63
- package/lib/types/routes.d.ts +25 -91
- package/lib/types/sql.d.ts +1 -1
- package/lib/types/storage.d.ts +70 -0
- package/lib/types/structured-read.d.ts +50 -0
- package/lib/types/tool.d.ts +29 -20
- package/lib/types/tui-connection-form.d.ts +98 -0
- package/package.json +65 -4
- package/preset/data-agent/agent.cordis.yml +22 -25
- package/preset/data-agent/preset.yml +1 -1
- package/lib/defaults-Bac6QvNt.js +0 -911
- package/lib/query-CmhTFklw.js +0 -86
|
@@ -0,0 +1,1608 @@
|
|
|
1
|
+
import "./defaults-DP4RyRh1.js";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { posix, resolve, win32 } from "node:path";
|
|
5
|
+
import z from "schemastery";
|
|
6
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
7
|
+
//#region src/sql.ts
|
|
8
|
+
/**
|
|
9
|
+
* Lightweight SQL-text scanning helpers shared by the sql-cmd tool half and
|
|
10
|
+
* the /query route. This is intentionally NOT a SQL parser: the scanner only
|
|
11
|
+
* understands lexical boundaries (strings, quoted identifiers, comments and
|
|
12
|
+
* parenthesis depth) well enough to make the two agent-loop guarantees from
|
|
13
|
+
* docs/optimization-opportunities.md:
|
|
14
|
+
*
|
|
15
|
+
* - a single tool call carries at most ONE SQL statement;
|
|
16
|
+
* - `maxRows` can be enforced with a real top-level LIMIT, not just a prompt.
|
|
17
|
+
*
|
|
18
|
+
* @module @yejiming/dsh-data-agent/sql
|
|
19
|
+
*/
|
|
20
|
+
const IDENT_CHAR = /[A-Za-z0-9_$]/;
|
|
21
|
+
function isWhitespace(char) {
|
|
22
|
+
return /\s/.test(char);
|
|
23
|
+
}
|
|
24
|
+
function isIdentChar(char) {
|
|
25
|
+
return IDENT_CHAR.test(char);
|
|
26
|
+
}
|
|
27
|
+
function skipQuoted(sql, start) {
|
|
28
|
+
const quote = sql[start];
|
|
29
|
+
let index = start + 1;
|
|
30
|
+
while (index < sql.length) {
|
|
31
|
+
const char = sql[index];
|
|
32
|
+
if (char === "\\" && index + 1 < sql.length && quote !== "`") {
|
|
33
|
+
index += 2;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (char === quote) {
|
|
37
|
+
if (sql[index + 1] === quote) {
|
|
38
|
+
index += 2;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
return index + 1;
|
|
42
|
+
}
|
|
43
|
+
index += 1;
|
|
44
|
+
}
|
|
45
|
+
return sql.length;
|
|
46
|
+
}
|
|
47
|
+
function skipDollarQuoted(sql, start) {
|
|
48
|
+
const match = sql.slice(start).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
|
|
49
|
+
if (match === null) return -1;
|
|
50
|
+
const delimiter = match[0];
|
|
51
|
+
const end = sql.indexOf(delimiter, start + delimiter.length);
|
|
52
|
+
return end === -1 ? sql.length : end + delimiter.length;
|
|
53
|
+
}
|
|
54
|
+
function skipOracleQuoted(sql, start) {
|
|
55
|
+
if (!/^q'/i.test(sql.slice(start, start + 2))) return -1;
|
|
56
|
+
const open = sql[start + 2];
|
|
57
|
+
if (open === void 0) return sql.length;
|
|
58
|
+
const close = {
|
|
59
|
+
"[": "]",
|
|
60
|
+
"{": "}",
|
|
61
|
+
"(": ")",
|
|
62
|
+
"<": ">"
|
|
63
|
+
}[open] ?? open;
|
|
64
|
+
let index = start + 3;
|
|
65
|
+
while (index < sql.length) {
|
|
66
|
+
if (sql[index] === close && sql[index + 1] === "'") return index + 2;
|
|
67
|
+
index += 1;
|
|
68
|
+
}
|
|
69
|
+
return sql.length;
|
|
70
|
+
}
|
|
71
|
+
function skipBlockComment(sql, start) {
|
|
72
|
+
let depth = 1;
|
|
73
|
+
let index = start + 2;
|
|
74
|
+
while (index < sql.length) {
|
|
75
|
+
if (sql.startsWith("/*", index)) {
|
|
76
|
+
depth += 1;
|
|
77
|
+
index += 2;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (sql.startsWith("*/", index)) {
|
|
81
|
+
depth -= 1;
|
|
82
|
+
index += 2;
|
|
83
|
+
if (depth === 0) return index;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
index += 1;
|
|
87
|
+
}
|
|
88
|
+
return sql.length;
|
|
89
|
+
}
|
|
90
|
+
function skipLineComment(sql, start) {
|
|
91
|
+
const newline = sql.indexOf("\n", start);
|
|
92
|
+
return newline === -1 ? sql.length : newline + 1;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Walk the SQL text, invoking `onSemicolon` for every top-level statement
|
|
96
|
+
* separator (parenthesis depth zero, outside strings, quoted identifiers and
|
|
97
|
+
* comments).
|
|
98
|
+
*/
|
|
99
|
+
function scanTopLevelSemicolons(sql, onSemicolon) {
|
|
100
|
+
let depth = 0;
|
|
101
|
+
let index = 0;
|
|
102
|
+
while (index < sql.length) {
|
|
103
|
+
const char = sql[index];
|
|
104
|
+
if (isWhitespace(char)) {
|
|
105
|
+
index += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (sql.startsWith("--", index)) {
|
|
109
|
+
index = skipLineComment(sql, index + 2);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (sql.startsWith("/*", index)) {
|
|
113
|
+
index = skipBlockComment(sql, index);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
117
|
+
index = skipQuoted(sql, index);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (char === "$") {
|
|
121
|
+
const dollarEnd = skipDollarQuoted(sql, index);
|
|
122
|
+
if (dollarEnd !== -1) {
|
|
123
|
+
index = dollarEnd;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const oracleEnd = skipOracleQuoted(sql, index);
|
|
128
|
+
if (oracleEnd !== -1) {
|
|
129
|
+
index = oracleEnd;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (char === "(") {
|
|
133
|
+
depth += 1;
|
|
134
|
+
index += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (char === ")") {
|
|
138
|
+
depth = Math.max(0, depth - 1);
|
|
139
|
+
index += 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (char === ";" && depth === 0) onSemicolon(index);
|
|
143
|
+
index += 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Whether meaningful SQL content exists after `index` (trailing `;`/comments ignored). */
|
|
147
|
+
function hasContentAfter(sql, index) {
|
|
148
|
+
let cursor = index;
|
|
149
|
+
while (cursor < sql.length) {
|
|
150
|
+
const char = sql[cursor];
|
|
151
|
+
if (isWhitespace(char)) {
|
|
152
|
+
cursor += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (char === ";") {
|
|
156
|
+
cursor += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (sql.startsWith("--", cursor)) {
|
|
160
|
+
cursor = skipLineComment(sql, cursor + 2);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (sql.startsWith("/*", cursor)) {
|
|
164
|
+
cursor = skipBlockComment(sql, cursor);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Throw unless `sql` contains at most one statement. A single trailing
|
|
173
|
+
* semicolon (and any number of repeated trailing semicolons / comments) is
|
|
174
|
+
* accepted; a semicolon followed by real content is rejected.
|
|
175
|
+
*/
|
|
176
|
+
function assertSingleStatement(sql, label = "SQL") {
|
|
177
|
+
if (stripTrailingTerminator(sql).trim().length === 0) throw new Error(`${label}: SQL 不能为空`);
|
|
178
|
+
const semicolons = [];
|
|
179
|
+
scanTopLevelSemicolons(sql, (index) => {
|
|
180
|
+
semicolons.push(index);
|
|
181
|
+
});
|
|
182
|
+
const offending = semicolons.find((index) => hasContentAfter(sql, index + 1));
|
|
183
|
+
if (offending === void 0) return;
|
|
184
|
+
throw new Error(`${label}: 一次只允许执行一条 SQL 语句(第 ${offending + 1} 个字符后的分号不是末尾分号)。多条语句请拆成多次调用;客户端进程独立、自动提交,不支持在多次调用间保持事务。`);
|
|
185
|
+
}
|
|
186
|
+
/** Whether `keyword` appears at top level as a whole word in `sql`. */
|
|
187
|
+
function hasTopLevelKeyword(sql, keyword) {
|
|
188
|
+
const needle = keyword.toLowerCase();
|
|
189
|
+
let depth = 0;
|
|
190
|
+
let index = 0;
|
|
191
|
+
while (index < sql.length) {
|
|
192
|
+
const char = sql[index];
|
|
193
|
+
if (isWhitespace(char)) {
|
|
194
|
+
index += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (sql.startsWith("--", index)) {
|
|
198
|
+
index = skipLineComment(sql, index + 2);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (sql.startsWith("/*", index)) {
|
|
202
|
+
index = skipBlockComment(sql, index);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
206
|
+
index = skipQuoted(sql, index);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (char === "$") {
|
|
210
|
+
const dollarEnd = skipDollarQuoted(sql, index);
|
|
211
|
+
if (dollarEnd !== -1) {
|
|
212
|
+
index = dollarEnd;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const oracleEnd = skipOracleQuoted(sql, index);
|
|
217
|
+
if (oracleEnd !== -1) {
|
|
218
|
+
index = oracleEnd;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (char === "(") {
|
|
222
|
+
depth += 1;
|
|
223
|
+
index += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (char === ")") {
|
|
227
|
+
depth = Math.max(0, depth - 1);
|
|
228
|
+
index += 1;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (depth === 0 && sql.slice(index, index + needle.length).toLowerCase() === needle && (index === 0 || !isIdentChar(sql[index - 1])) && (index + needle.length >= sql.length || !isIdentChar(sql[index + needle.length]))) return true;
|
|
232
|
+
index += 1;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
function trailingLineCommentStart(sql, end) {
|
|
237
|
+
let index = sql.lastIndexOf("\n", end - 1) + 1;
|
|
238
|
+
while (index < end) {
|
|
239
|
+
const char = sql[index];
|
|
240
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
241
|
+
index = skipQuoted(sql, index);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (sql.startsWith("--", index)) {
|
|
245
|
+
const tail = sql.slice(index + 2, end);
|
|
246
|
+
return tail.length === 0 || isWhitespace(tail[0]) ? index : -1;
|
|
247
|
+
}
|
|
248
|
+
index += 1;
|
|
249
|
+
}
|
|
250
|
+
return -1;
|
|
251
|
+
}
|
|
252
|
+
function blockCommentEndingAt(sql, end) {
|
|
253
|
+
let candidate = -1;
|
|
254
|
+
let index = 0;
|
|
255
|
+
while (index < end) {
|
|
256
|
+
const char = sql[index];
|
|
257
|
+
if (isWhitespace(char)) {
|
|
258
|
+
index += 1;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
262
|
+
index = skipQuoted(sql, index);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (sql.startsWith("--", index)) {
|
|
266
|
+
index = skipLineComment(sql, index + 2);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (sql.startsWith("/*", index)) {
|
|
270
|
+
const start = index;
|
|
271
|
+
let commentDepth = 1;
|
|
272
|
+
index += 2;
|
|
273
|
+
while (index < end && commentDepth > 0) {
|
|
274
|
+
if (sql.startsWith("/*", index)) {
|
|
275
|
+
commentDepth += 1;
|
|
276
|
+
index += 2;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (sql.startsWith("*/", index)) {
|
|
280
|
+
commentDepth -= 1;
|
|
281
|
+
index += 2;
|
|
282
|
+
if (commentDepth === 0) {
|
|
283
|
+
if (index === end) candidate = start;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
index += 1;
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
index += 1;
|
|
293
|
+
}
|
|
294
|
+
return candidate;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Strip trailing whitespace, statement terminators and trailing comments so a
|
|
298
|
+
* limit clause can be appended to the actual statement text. Only comments
|
|
299
|
+
* that occupy the whole tail are removed; the preceding statement is kept.
|
|
300
|
+
*/
|
|
301
|
+
function stripTrailingTerminator(sql) {
|
|
302
|
+
let end = sql.length;
|
|
303
|
+
for (;;) {
|
|
304
|
+
while (end > 0 && isWhitespace(sql[end - 1])) end -= 1;
|
|
305
|
+
if (end > 0 && sql[end - 1] === ";") {
|
|
306
|
+
end -= 1;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (end >= 2 && sql.slice(end - 2, end) === "*/") {
|
|
310
|
+
const start = blockCommentEndingAt(sql, end);
|
|
311
|
+
if (start !== -1) {
|
|
312
|
+
end = start;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const lineComment = trailingLineCommentStart(sql, end);
|
|
317
|
+
if (lineComment !== -1) {
|
|
318
|
+
end = lineComment;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
return sql.slice(0, end);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/clients.ts
|
|
326
|
+
/**
|
|
327
|
+
* Whitespace / comment stripping for {@link classifyStatement}: remove
|
|
328
|
+
* leading whitespace, `--` line comments, and nested `/* ... */` block
|
|
329
|
+
* comments so the first meaningful token can be read reliably.
|
|
330
|
+
*/
|
|
331
|
+
function stripLeadingComments(sql) {
|
|
332
|
+
let rest = sql;
|
|
333
|
+
for (;;) {
|
|
334
|
+
let changed = false;
|
|
335
|
+
const trimmed = rest.replace(/^\s+/, "");
|
|
336
|
+
if (trimmed !== rest) {
|
|
337
|
+
rest = trimmed;
|
|
338
|
+
changed = true;
|
|
339
|
+
}
|
|
340
|
+
if (rest.startsWith("--")) {
|
|
341
|
+
const newline = rest.indexOf("\n");
|
|
342
|
+
rest = newline === -1 ? "" : rest.slice(newline + 1);
|
|
343
|
+
changed = true;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (rest.startsWith("/*")) {
|
|
347
|
+
const end = scanBlockCommentEnd(rest, 2);
|
|
348
|
+
rest = end === -1 ? "" : rest.slice(end);
|
|
349
|
+
changed = true;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!changed) {
|
|
353
|
+
const retrim = rest.replace(/^\s+/, "");
|
|
354
|
+
if (retrim !== rest) {
|
|
355
|
+
rest = retrim;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return rest;
|
|
362
|
+
}
|
|
363
|
+
/** Find the index just past a `/* ... */` block starting at `start` (nesting-aware). */
|
|
364
|
+
function scanBlockCommentEnd(sql, start) {
|
|
365
|
+
let depth = 1;
|
|
366
|
+
let i = start;
|
|
367
|
+
while (i < sql.length) {
|
|
368
|
+
if (sql.startsWith("/*", i)) {
|
|
369
|
+
depth += 1;
|
|
370
|
+
i += 2;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (sql.startsWith("*/", i)) {
|
|
374
|
+
depth -= 1;
|
|
375
|
+
i += 2;
|
|
376
|
+
if (depth === 0) return i;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
i += 1;
|
|
380
|
+
}
|
|
381
|
+
return -1;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Strip a `WITH` prefix down to the main query: remove `WITH [RECURSIVE]`,
|
|
385
|
+
* then consume successive `name [ (cols) ] AS ( ... )` clauses (comma
|
|
386
|
+
* separated, parenthesis-aware) until the leading keyword of the main
|
|
387
|
+
* statement. Falls back to the whole (comment-stripped) input when the CTE
|
|
388
|
+
* shape does not parse cleanly, in which case {@link classifyStatement} treats
|
|
389
|
+
* it as a write (conservative).
|
|
390
|
+
*/
|
|
391
|
+
function stripWithBody(sql) {
|
|
392
|
+
let rest = stripLeadingComments(sql).replace(/^[A-Za-z_]+/, "");
|
|
393
|
+
rest = stripLeadingComments(rest);
|
|
394
|
+
if (/^RECURSIVE\b/i.test(rest)) rest = stripLeadingComments(rest.replace(/^[A-Za-z_]+/, ""));
|
|
395
|
+
for (;;) {
|
|
396
|
+
rest = stripLeadingComments(rest);
|
|
397
|
+
if (rest === "" || !/^[A-Za-z_][A-Za-z0-9_$]*/.test(rest)) break;
|
|
398
|
+
rest = stripLeadingComments(rest.replace(/^[A-Za-z_][A-Za-z0-9_$]*/, ""));
|
|
399
|
+
rest = stripLeadingComments(rest);
|
|
400
|
+
if (rest.startsWith("(")) {
|
|
401
|
+
const afterCols = skipParens(rest, 0);
|
|
402
|
+
rest = stripLeadingComments(afterCols === -1 ? rest : rest.slice(afterCols));
|
|
403
|
+
}
|
|
404
|
+
rest = stripLeadingComments(rest);
|
|
405
|
+
if (!/^AS\b/i.test(rest)) break;
|
|
406
|
+
rest = stripLeadingComments(rest.replace(/^[A-Za-z_]+/, ""));
|
|
407
|
+
rest = stripLeadingComments(rest);
|
|
408
|
+
if (!rest.startsWith("(")) break;
|
|
409
|
+
const afterBody = skipParens(rest, 0);
|
|
410
|
+
if (afterBody === -1) return sql;
|
|
411
|
+
rest = stripLeadingComments(rest.slice(afterBody));
|
|
412
|
+
rest = stripLeadingComments(rest);
|
|
413
|
+
if (rest.startsWith(",")) {
|
|
414
|
+
rest = stripLeadingComments(rest.slice(1));
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
return stripLeadingComments(rest);
|
|
420
|
+
}
|
|
421
|
+
/** Index just past a balanced parenthesis group starting at `start` (0-based). */
|
|
422
|
+
function skipParens(sql, start) {
|
|
423
|
+
let depth = 0;
|
|
424
|
+
let i = start;
|
|
425
|
+
while (i < sql.length) {
|
|
426
|
+
const ch = sql[i];
|
|
427
|
+
if (ch === "(") {
|
|
428
|
+
depth += 1;
|
|
429
|
+
i += 1;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (ch === ")") {
|
|
433
|
+
depth -= 1;
|
|
434
|
+
i += 1;
|
|
435
|
+
if (depth === 0) return i;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
i += 1;
|
|
439
|
+
}
|
|
440
|
+
return -1;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Classify a SQL text as a read or write statement by its FIRST effective
|
|
444
|
+
* token (a conservative read whitelist, not a parser). `with` is read only
|
|
445
|
+
* when its body's first token is `select`. SQLite `pragma` is read in its
|
|
446
|
+
* query form and write when a value is assigned.
|
|
447
|
+
*/
|
|
448
|
+
function classifyStatement(sql, type) {
|
|
449
|
+
const rest = stripLeadingComments(sql);
|
|
450
|
+
const tokenMatch = rest.match(/^[A-Za-z_]+/);
|
|
451
|
+
if (tokenMatch === null) return "write";
|
|
452
|
+
switch (tokenMatch[0].toLowerCase()) {
|
|
453
|
+
case "select":
|
|
454
|
+
case "show":
|
|
455
|
+
case "describe":
|
|
456
|
+
case "desc":
|
|
457
|
+
case "explain": return "read";
|
|
458
|
+
case "pragma": {
|
|
459
|
+
if (type !== "sqlite") return "write";
|
|
460
|
+
const afterPragma = rest.replace(/^pragma\b/i, "").trimStart();
|
|
461
|
+
return /^(?:[A-Za-z_][A-Za-z0-9_$]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_$]*)?|"[^"]+"|`[^`]+`)\s*=/.test(afterPragma) ? "write" : "read";
|
|
462
|
+
}
|
|
463
|
+
case "with": return stripWithBody(rest).match(/^[A-Za-z_]+/)?.[0]?.toLowerCase() === "select" ? "read" : "write";
|
|
464
|
+
default: return "write";
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Enforce the configured `maxRows` on a read query instead of relying on the
|
|
469
|
+
* prompt. SELECT/CTE-read statements get a real top-level LIMIT (Oracle uses
|
|
470
|
+
* a ROWNUM wrapper because it has no LIMIT); SHOW/DESCRIBE/EXPLAIN/PRAGMA are
|
|
471
|
+
* left untouched here and are capped while parsing structured output.
|
|
472
|
+
*
|
|
473
|
+
* An existing numeric top-level LIMIT is rewritten when it is larger than
|
|
474
|
+
* `maxRows`; a smaller existing LIMIT is preserved, and a non-numeric or
|
|
475
|
+
* unparseable LIMIT is left for the client (structured tools still truncate).
|
|
476
|
+
*/
|
|
477
|
+
function enforceReadRowLimit(sql, type, maxRows) {
|
|
478
|
+
if (classifyStatement(sql, type) !== "read") return sql;
|
|
479
|
+
const first = stripLeadingComments(sql).match(/^[A-Za-z_]+/)?.[0]?.toLowerCase();
|
|
480
|
+
if (first !== "select" && first !== "with") return sql;
|
|
481
|
+
const hadTrailingSemicolon = /;\s*$/.test(sql);
|
|
482
|
+
if (!hasTopLevelKeyword(sql, "limit") && type !== "oracle") return `${stripTrailingTerminator(sql)} LIMIT ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
|
|
483
|
+
if (type === "oracle") return `SELECT * FROM (${stripTrailingTerminator(sql)}) dsh_limit WHERE ROWNUM <= ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
|
|
484
|
+
if (!hasTopLevelKeyword(sql, "limit")) return sql;
|
|
485
|
+
return rewriteTopLevelLimit(sql, maxRows);
|
|
486
|
+
}
|
|
487
|
+
/** Rewrite the first top-level `LIMIT n` / `LIMIT n, m` with a capped row count. */
|
|
488
|
+
function rewriteTopLevelLimit(sql, maxRows) {
|
|
489
|
+
let depth = 0;
|
|
490
|
+
let index = 0;
|
|
491
|
+
while (index < sql.length) {
|
|
492
|
+
const char = sql[index];
|
|
493
|
+
if (/\s/.test(char)) {
|
|
494
|
+
index += 1;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (sql.startsWith("--", index)) {
|
|
498
|
+
const newline = sql.indexOf("\n", index + 2);
|
|
499
|
+
index = newline === -1 ? sql.length : newline + 1;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (sql.startsWith("/*", index)) {
|
|
503
|
+
let depthComment = 1;
|
|
504
|
+
index += 2;
|
|
505
|
+
while (index < sql.length && depthComment > 0) {
|
|
506
|
+
if (sql.startsWith("/*", index)) {
|
|
507
|
+
depthComment += 1;
|
|
508
|
+
index += 2;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (sql.startsWith("*/", index)) {
|
|
512
|
+
depthComment -= 1;
|
|
513
|
+
index += 2;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
index += 1;
|
|
517
|
+
}
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
521
|
+
const quote = char;
|
|
522
|
+
index += 1;
|
|
523
|
+
while (index < sql.length) {
|
|
524
|
+
if (sql[index] === "\\" && index + 1 < sql.length && quote !== "`") {
|
|
525
|
+
index += 2;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (sql[index] === quote) {
|
|
529
|
+
if (sql[index + 1] === quote) {
|
|
530
|
+
index += 2;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
index += 1;
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
index += 1;
|
|
537
|
+
}
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (char === "(") {
|
|
541
|
+
depth += 1;
|
|
542
|
+
index += 1;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (char === ")") {
|
|
546
|
+
depth = Math.max(0, depth - 1);
|
|
547
|
+
index += 1;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (depth === 0 && sql.slice(index, index + 5).toLowerCase() === "limit" && (index === 0 || !/[A-Za-z0-9_$]/.test(sql[index - 1])) && (index + 5 >= sql.length || !/[A-Za-z0-9_$]/.test(sql[index + 5]))) {
|
|
551
|
+
const match = sql.slice(index).match(/^LIMIT\s+(ALL|\d+)(\s*,\s*\d+)?/i);
|
|
552
|
+
if (match === null) return sql;
|
|
553
|
+
const firstValue = match[1];
|
|
554
|
+
const hasOffsetPart = match[2] !== void 0;
|
|
555
|
+
let replacement = "";
|
|
556
|
+
if (hasOffsetPart) {
|
|
557
|
+
const rowCount = Number(match[2].match(/\d+/)[0]);
|
|
558
|
+
replacement = `LIMIT ${firstValue === "ALL" ? "0" : firstValue}, ${Math.min(rowCount, maxRows)}`;
|
|
559
|
+
} else if (/^\d+$/.test(firstValue)) replacement = `LIMIT ${Math.min(Number(firstValue), maxRows)}`;
|
|
560
|
+
else replacement = `LIMIT ${maxRows}`;
|
|
561
|
+
return sql.slice(0, index) + replacement + sql.slice(index + match[0].length);
|
|
562
|
+
}
|
|
563
|
+
index += 1;
|
|
564
|
+
}
|
|
565
|
+
return sql;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Validate and quote one schema/table identifier for a safe metadata query.
|
|
569
|
+
* Identifiers are restricted to `[A-Za-z0-9_$]+` and then wrapped per type:
|
|
570
|
+
* backticks (mysql/hive/impala) or double quotes (postgres/oracle/sqlite),
|
|
571
|
+
* with the wrapping quote doubled for any interior occurrence. Rejects any
|
|
572
|
+
* input that could cross the identifier boundary (`#`, `--`, `;`, `'`, `` ` ``,
|
|
573
|
+
* `"`, `.`, `-` are all refused).
|
|
574
|
+
*/
|
|
575
|
+
function sanitizeIdentifier(type, identifier) {
|
|
576
|
+
if (!/^[A-Za-z0-9_$]+$/.test(identifier)) throw new Error(`标识符含非法字符(仅允许字母、数字与 _ $):${identifier}`);
|
|
577
|
+
switch (type) {
|
|
578
|
+
case "mysql":
|
|
579
|
+
case "hive":
|
|
580
|
+
case "impala": return "`" + identifier.replace(/`/g, "``") + "`";
|
|
581
|
+
case "postgres":
|
|
582
|
+
case "oracle":
|
|
583
|
+
case "sqlite": return "\"" + identifier.replace(/"/g, "\"\"") + "\"";
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Quote one identifier-shaped value as a SQL string literal (single quotes,
|
|
588
|
+
* interior `'` doubled). postgres/oracle metadata queries filter system
|
|
589
|
+
* catalogs by NAME (a string value), not by identifier, so those positions
|
|
590
|
+
* need a quoted literal — not {@link sanitizeIdentifier}'s identifier quoting.
|
|
591
|
+
* The whitelist already excludes `'`, so doubling is a defense-in-depth no-op
|
|
592
|
+
* here but keeps the helper correct for any future widened charset.
|
|
593
|
+
*/
|
|
594
|
+
function quoteStringLiteral(value) {
|
|
595
|
+
return "'" + value.replace(/'/g, "''") + "'";
|
|
596
|
+
}
|
|
597
|
+
/** Loader schema for one client override (all fields optional at input). */
|
|
598
|
+
const clientConfigSchema = z.object({
|
|
599
|
+
command: z.string(),
|
|
600
|
+
args: z.array(z.string()),
|
|
601
|
+
searchPaths: z.array(z.string())
|
|
602
|
+
});
|
|
603
|
+
/** Loader schema for the whole `clients` config object (any type key). */
|
|
604
|
+
const clientsSchema = z.dict(clientConfigSchema).default({});
|
|
605
|
+
/** Query-mode flag arguments per type (plain/human output). */
|
|
606
|
+
const QUERY_ARGS = {
|
|
607
|
+
mysql: ["--batch", "--raw"],
|
|
608
|
+
postgres: ["-A"],
|
|
609
|
+
sqlite: ["-header", "-column"],
|
|
610
|
+
oracle: ["-S", "/nolog"],
|
|
611
|
+
hive: ["--silent=true", "--outputformat=tsv2"],
|
|
612
|
+
impala: ["-B"]
|
|
613
|
+
};
|
|
614
|
+
/** Introspection-mode flag arguments per type (machine-readable listing). */
|
|
615
|
+
const INTROSPECT_ARGS = {
|
|
616
|
+
mysql: ["--batch", "--raw"],
|
|
617
|
+
postgres: ["-t", "-A"],
|
|
618
|
+
sqlite: ["-noheader", "-list"],
|
|
619
|
+
oracle: ["-S", "/nolog"],
|
|
620
|
+
hive: ["--silent=true", "--outputformat=tsv2"],
|
|
621
|
+
impala: ["-B"]
|
|
622
|
+
};
|
|
623
|
+
/** Structured `sql-query` flag arguments: header + one row per line. */
|
|
624
|
+
const STRUCTURED_QUERY_ARGS = {
|
|
625
|
+
mysql: ["--batch", "--raw"],
|
|
626
|
+
postgres: ["-A"],
|
|
627
|
+
sqlite: ["-header", "-csv"],
|
|
628
|
+
oracle: ["-S", "/nolog"],
|
|
629
|
+
hive: ["--silent=true", "--outputformat=tsv2"],
|
|
630
|
+
impala: ["-B", "--print_header"]
|
|
631
|
+
};
|
|
632
|
+
/** Default ports when the connection does not carry one. */
|
|
633
|
+
const DEFAULT_PORTS = {
|
|
634
|
+
mysql: 3306,
|
|
635
|
+
postgres: 5432,
|
|
636
|
+
sqlite: 0,
|
|
637
|
+
oracle: 1521,
|
|
638
|
+
hive: 1e4,
|
|
639
|
+
impala: 21050
|
|
640
|
+
};
|
|
641
|
+
/** Built-in commands per type (also the loader defaults; see `src/defaults.ts`). */
|
|
642
|
+
const DEFAULT_CLIENTS_COMMAND = {
|
|
643
|
+
mysql: "mysql",
|
|
644
|
+
postgres: "psql",
|
|
645
|
+
sqlite: "sqlite3",
|
|
646
|
+
oracle: "sqlplus",
|
|
647
|
+
hive: "beeline",
|
|
648
|
+
impala: "impala-shell"
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* Connection flags for one type. Oracle and Hive carry NO connection flags:
|
|
652
|
+
* their endpoint + credentials travel in the stdin prefix; Impala takes
|
|
653
|
+
* `-i host:port -d db` on the argv. SQLite's `database` file is positional
|
|
654
|
+
* and must come AFTER the flags.
|
|
655
|
+
*/
|
|
656
|
+
function connectionArgs(type, connection) {
|
|
657
|
+
switch (type) {
|
|
658
|
+
case "mysql": return [
|
|
659
|
+
"-h",
|
|
660
|
+
connection.host ?? "127.0.0.1",
|
|
661
|
+
"-P",
|
|
662
|
+
String(connection.port ?? DEFAULT_PORTS.mysql),
|
|
663
|
+
"-u",
|
|
664
|
+
connection.user ?? "root",
|
|
665
|
+
"-D",
|
|
666
|
+
connection.database
|
|
667
|
+
];
|
|
668
|
+
case "postgres": return [
|
|
669
|
+
"-h",
|
|
670
|
+
connection.host ?? "127.0.0.1",
|
|
671
|
+
"-p",
|
|
672
|
+
String(connection.port ?? DEFAULT_PORTS.postgres),
|
|
673
|
+
"-U",
|
|
674
|
+
connection.user ?? "postgres",
|
|
675
|
+
"-d",
|
|
676
|
+
connection.database
|
|
677
|
+
];
|
|
678
|
+
case "sqlite": return [connection.database];
|
|
679
|
+
case "impala": return [
|
|
680
|
+
"-i",
|
|
681
|
+
`${connection.host ?? "127.0.0.1"}:${connection.port ?? DEFAULT_PORTS.impala}`,
|
|
682
|
+
"-d",
|
|
683
|
+
connection.database
|
|
684
|
+
];
|
|
685
|
+
case "oracle":
|
|
686
|
+
case "hive": return [];
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/** Credential environment entries per type; absent password yields an empty env. */
|
|
690
|
+
function credentialEnv(type, connection) {
|
|
691
|
+
const password = connection.password;
|
|
692
|
+
if (password === void 0) return {};
|
|
693
|
+
switch (type) {
|
|
694
|
+
case "mysql": return { MYSQL_PWD: password };
|
|
695
|
+
case "postgres": return { PGPASSWORD: password };
|
|
696
|
+
case "sqlite":
|
|
697
|
+
case "oracle":
|
|
698
|
+
case "hive":
|
|
699
|
+
case "impala": return {};
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The stdin prefix per type: Oracle and Hive establish the session here, so
|
|
704
|
+
* their credentials never appear in argv. Oracle also silences sqlplus
|
|
705
|
+
* decoration (PAGESIZE/FEEDBACK/HEADING) and pins the column separator to
|
|
706
|
+
* `|` for the describe parser; Hive connects through beeline's `!connect`.
|
|
707
|
+
*/
|
|
708
|
+
function stdinPrefix(type, connection) {
|
|
709
|
+
switch (type) {
|
|
710
|
+
case "oracle": return `${[
|
|
711
|
+
"SET PAGESIZE 0",
|
|
712
|
+
"SET FEEDBACK OFF",
|
|
713
|
+
"SET HEADING OFF",
|
|
714
|
+
"SET COLSEP '|'",
|
|
715
|
+
"SET TRIMSPOOL ON",
|
|
716
|
+
connection.user !== void 0 ? `connect ${connection.user}${connection.password !== void 0 ? `/${connection.password}` : ""}@${connection.host ?? "127.0.0.1"}:${connection.port ?? DEFAULT_PORTS.oracle}/${connection.database}` : ""
|
|
717
|
+
].filter((line) => line !== "").join("\n")}\n`;
|
|
718
|
+
case "hive": return connection.user !== void 0 ? `!connect jdbc:hive2://${connection.host ?? "127.0.0.1"}:${connection.port ?? DEFAULT_PORTS.hive}/${connection.database} ${connection.user} ${connection.password ?? ""}\n` : "";
|
|
719
|
+
case "mysql":
|
|
720
|
+
case "postgres":
|
|
721
|
+
case "sqlite":
|
|
722
|
+
case "impala": return "";
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Oracle structured-query prefix: same connect block as {@link stdinPrefix},
|
|
727
|
+
* but with HEADING ON and UNDERLINE OFF so `sql-query` can read the column
|
|
728
|
+
* names from the first output line.
|
|
729
|
+
*/
|
|
730
|
+
function structuredStdinPrefix(type, connection) {
|
|
731
|
+
if (type !== "oracle") return stdinPrefix(type, connection);
|
|
732
|
+
return `${[
|
|
733
|
+
"SET PAGESIZE 0",
|
|
734
|
+
"SET FEEDBACK OFF",
|
|
735
|
+
"SET HEADING ON",
|
|
736
|
+
"SET UNDERLINE OFF",
|
|
737
|
+
"SET COLSEP '|'",
|
|
738
|
+
"SET TRIMSPOOL ON",
|
|
739
|
+
connection.user !== void 0 ? `connect ${connection.user}${connection.password !== void 0 ? `/${connection.password}` : ""}@${connection.host ?? "127.0.0.1"}:${connection.port ?? DEFAULT_PORTS.oracle}/${connection.database}` : ""
|
|
740
|
+
].filter((line) => line !== "").join("\n")}\n`;
|
|
741
|
+
}
|
|
742
|
+
/** Apply one deployment override's extra args in front of the built-in flags. */
|
|
743
|
+
function withOverrides(flags, override) {
|
|
744
|
+
if (override === void 0 || override.args === void 0) return flags;
|
|
745
|
+
return [...override.args, ...flags];
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Build one client invocation for a query execution (plain output). Flags
|
|
749
|
+
* come BEFORE the connection arguments everywhere: sqlite3 takes
|
|
750
|
+
* `[options] <database>`, and putting flags first is harmless for the others.
|
|
751
|
+
*/
|
|
752
|
+
function buildClientTemplate(type, connection, override) {
|
|
753
|
+
return {
|
|
754
|
+
command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
|
|
755
|
+
args: [...withOverrides(QUERY_ARGS[type], override), ...connectionArgs(type, connection)],
|
|
756
|
+
env: credentialEnv(type, connection),
|
|
757
|
+
stdinPrefix: stdinPrefix(type, connection)
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
/** Build one client invocation for metadata runs (machine-readable flags). */
|
|
761
|
+
function buildIntrospectTemplate(type, connection, override) {
|
|
762
|
+
return {
|
|
763
|
+
command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
|
|
764
|
+
args: [...withOverrides(INTROSPECT_ARGS[type], override), ...connectionArgs(type, connection)],
|
|
765
|
+
env: credentialEnv(type, connection),
|
|
766
|
+
stdinPrefix: stdinPrefix(type, connection)
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Build one client invocation for the structured `sql-query` tool: every
|
|
771
|
+
* supported client prints a header row followed by one row per line (mysql
|
|
772
|
+
* tab, postgres pipe, sqlite csv, oracle pipe, hive/impala tsv).
|
|
773
|
+
*/
|
|
774
|
+
function buildStructuredQueryTemplate(type, connection, override) {
|
|
775
|
+
return {
|
|
776
|
+
command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
|
|
777
|
+
args: [...withOverrides(STRUCTURED_QUERY_ARGS[type], override), ...connectionArgs(type, connection)],
|
|
778
|
+
env: credentialEnv(type, connection),
|
|
779
|
+
stdinPrefix: structuredStdinPrefix(type, connection)
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* The table-listing SQL per type, run at /connect time to verify
|
|
784
|
+
* connectivity: the connected database's own tables (mysql uses the
|
|
785
|
+
* connection's database as the schema; postgres lists `public`; oracle lists
|
|
786
|
+
* the connected user's tables; hive/impala list the default database).
|
|
787
|
+
*/
|
|
788
|
+
function tableListingSql(type, connection) {
|
|
789
|
+
switch (type) {
|
|
790
|
+
case "mysql": return `SHOW TABLES FROM \`${connection?.database ?? ""}\`;`;
|
|
791
|
+
case "postgres": return "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY 1;";
|
|
792
|
+
case "sqlite": return "SELECT name FROM sqlite_master WHERE type='table' ORDER BY 1;";
|
|
793
|
+
case "oracle": return "SELECT table_name FROM user_tables ORDER BY 1;";
|
|
794
|
+
case "hive":
|
|
795
|
+
case "impala": return "SHOW TABLES;";
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Metadata query per kind × type. `schema`/`table` are identifier whitelist
|
|
800
|
+
* validated by the caller (`[A-Za-z0-9_$]`) before they reach here.
|
|
801
|
+
*/
|
|
802
|
+
function metadataQuery(kind, type, schema, table) {
|
|
803
|
+
switch (kind) {
|
|
804
|
+
case "schemas": switch (type) {
|
|
805
|
+
case "mysql": return "SHOW DATABASES;";
|
|
806
|
+
case "postgres": return "SELECT schema_name FROM information_schema.schemata ORDER BY 1;";
|
|
807
|
+
case "sqlite": return "SELECT 'main';";
|
|
808
|
+
case "oracle": return "SELECT username FROM all_users ORDER BY 1;";
|
|
809
|
+
case "hive":
|
|
810
|
+
case "impala": return "SHOW DATABASES;";
|
|
811
|
+
}
|
|
812
|
+
case "tables": switch (type) {
|
|
813
|
+
case "mysql": return `SHOW TABLES FROM ${sanitizeIdentifier(type, schema)};`;
|
|
814
|
+
case "postgres": return `SELECT tablename FROM pg_tables WHERE schemaname=${quoteStringLiteral(schema)} ORDER BY 1;`;
|
|
815
|
+
case "sqlite": return "SELECT name FROM sqlite_master WHERE type='table' ORDER BY 1;";
|
|
816
|
+
case "oracle": return `SELECT table_name FROM all_tables WHERE owner=${quoteStringLiteral(schema)} ORDER BY 1;`;
|
|
817
|
+
case "hive":
|
|
818
|
+
case "impala": return `SHOW TABLES IN ${sanitizeIdentifier(type, schema)};`;
|
|
819
|
+
}
|
|
820
|
+
case "describe": switch (type) {
|
|
821
|
+
case "mysql": return `DESCRIBE ${sanitizeIdentifier(type, schema)}.${sanitizeIdentifier(type, table)};`;
|
|
822
|
+
case "postgres": return `SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema=${quoteStringLiteral(schema)} AND table_name=${quoteStringLiteral(table)} ORDER BY ordinal_position;`;
|
|
823
|
+
case "sqlite": return `PRAGMA table_info(${sanitizeIdentifier(type, table)});`;
|
|
824
|
+
case "oracle": return `SELECT column_name, data_type, nullable FROM all_tab_columns WHERE owner=${quoteStringLiteral(schema)} AND table_name=${quoteStringLiteral(table)} ORDER BY column_id;`;
|
|
825
|
+
case "hive":
|
|
826
|
+
case "impala": return `DESCRIBE ${sanitizeIdentifier(type, schema)}.${sanitizeIdentifier(type, table)};`;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Split one type's machine-readable listing output into trimmed lines.
|
|
832
|
+
* Header lines are stripped per type: mysql `--batch` prints a header row
|
|
833
|
+
* (skip 1); postgres `-t`, sqlite `-noheader`, oracle `SET HEADING OFF`,
|
|
834
|
+
* hive/impala batch modes print none (skip 0).
|
|
835
|
+
*/
|
|
836
|
+
function parseListing(type, stdout) {
|
|
837
|
+
const lines = stdout.split("\n");
|
|
838
|
+
const start = type === "mysql" ? 1 : 0;
|
|
839
|
+
const items = [];
|
|
840
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
841
|
+
const name = lines[index].trim();
|
|
842
|
+
if (name.length > 0) items.push(name);
|
|
843
|
+
}
|
|
844
|
+
return items;
|
|
845
|
+
}
|
|
846
|
+
/** Parse one type's table-listing output (the /connect connectivity check). */
|
|
847
|
+
function parseTableListing(type, stdout) {
|
|
848
|
+
return parseListing(type, stdout);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Parse one type's describe output into columns. Formats:
|
|
852
|
+
* - mysql `--batch`: `Field\tType\tNull\tKey\t...` (skip header);
|
|
853
|
+
* - postgres `-t -A`: `name|type|is_nullable`;
|
|
854
|
+
* - sqlite `-noheader -list`: `cid|name|type|notnull|dflt|pk` (name is part 1);
|
|
855
|
+
* - oracle (`SET COLSEP '|'`, heading off): `NAME|TYPE|NULLABLE`;
|
|
856
|
+
* - hive/impala batch: `name\ttype\tcomment`.
|
|
857
|
+
*/
|
|
858
|
+
function parseColumns(type, stdout) {
|
|
859
|
+
const lines = stdout.split("\n");
|
|
860
|
+
const start = type === "mysql" ? 1 : 0;
|
|
861
|
+
const columns = [];
|
|
862
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
863
|
+
const line = lines[index].trim();
|
|
864
|
+
if (line.length === 0) continue;
|
|
865
|
+
const parts = line.includes(" ") ? line.split(" ") : line.split("|");
|
|
866
|
+
const nameIndex = type === "sqlite" ? 1 : 0;
|
|
867
|
+
const name = parts[nameIndex]?.trim() ?? "";
|
|
868
|
+
const columnType = parts[nameIndex + 1]?.trim() ?? "";
|
|
869
|
+
if (name.length === 0) continue;
|
|
870
|
+
const rawNullable = parts[nameIndex + 2]?.trim().toLowerCase();
|
|
871
|
+
let nullable;
|
|
872
|
+
switch (type) {
|
|
873
|
+
case "mysql":
|
|
874
|
+
nullable = rawNullable === "yes";
|
|
875
|
+
break;
|
|
876
|
+
case "postgres":
|
|
877
|
+
nullable = rawNullable === "yes";
|
|
878
|
+
break;
|
|
879
|
+
case "sqlite":
|
|
880
|
+
nullable = rawNullable !== "1";
|
|
881
|
+
break;
|
|
882
|
+
case "oracle":
|
|
883
|
+
nullable = rawNullable === "y";
|
|
884
|
+
break;
|
|
885
|
+
case "hive":
|
|
886
|
+
case "impala": nullable = void 0;
|
|
887
|
+
}
|
|
888
|
+
columns.push({
|
|
889
|
+
name,
|
|
890
|
+
type: columnType,
|
|
891
|
+
...nullable !== void 0 ? { nullable } : {}
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
return columns;
|
|
895
|
+
}
|
|
896
|
+
//#endregion
|
|
897
|
+
//#region src/client-discovery.ts
|
|
898
|
+
/**
|
|
899
|
+
* Cross-platform database CLI discovery.
|
|
900
|
+
*
|
|
901
|
+
* The subprocess provider remains the authority for executable validation.
|
|
902
|
+
* This module only builds a bounded, platform-aware PATH fallback when the
|
|
903
|
+
* provider cannot resolve the configured/default bare command from its
|
|
904
|
+
* current execution environment. No shell, registry, or recursive scan is
|
|
905
|
+
* involved, and the exact discovery environment is returned for spawn.
|
|
906
|
+
* @module @yejiming/dsh-data-agent/client-discovery
|
|
907
|
+
*/
|
|
908
|
+
/** Maximum child names consumed from one known version/formula directory. */
|
|
909
|
+
const MAX_DYNAMIC_ENTRIES = 64;
|
|
910
|
+
/** Production host facts. */
|
|
911
|
+
const DEFAULT_SYSTEM = {
|
|
912
|
+
platform: process.platform,
|
|
913
|
+
env: process.env,
|
|
914
|
+
homeDir: homedir(),
|
|
915
|
+
cwd: process.cwd(),
|
|
916
|
+
async readDirectory(directory) {
|
|
917
|
+
return await readdir(directory);
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
const HOME_ENV_BY_TYPE = {
|
|
921
|
+
mysql: ["MYSQL_HOME"],
|
|
922
|
+
postgres: ["PGHOME", "PGROOT"],
|
|
923
|
+
sqlite: ["SQLITE_HOME"],
|
|
924
|
+
oracle: ["ORACLE_HOME"],
|
|
925
|
+
hive: ["HIVE_HOME"],
|
|
926
|
+
impala: ["IMPALA_HOME"]
|
|
927
|
+
};
|
|
928
|
+
function pathApi(platform) {
|
|
929
|
+
return platform === "win32" ? win32 : posix;
|
|
930
|
+
}
|
|
931
|
+
function environmentValue(env, name, platform) {
|
|
932
|
+
if (platform !== "win32") return env[name];
|
|
933
|
+
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
934
|
+
return key === void 0 ? void 0 : env[key];
|
|
935
|
+
}
|
|
936
|
+
function expandHome(directory, system, paths) {
|
|
937
|
+
const trimmed = directory.trim();
|
|
938
|
+
if (trimmed === "~") return system.homeDir;
|
|
939
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return paths.join(system.homeDir, trimmed.slice(2));
|
|
940
|
+
return paths.isAbsolute(trimmed) ? paths.normalize(trimmed) : paths.resolve(system.cwd, trimmed);
|
|
941
|
+
}
|
|
942
|
+
function normalizeDirectories(directories, system, paths) {
|
|
943
|
+
const result = [];
|
|
944
|
+
const seen = /* @__PURE__ */ new Set();
|
|
945
|
+
for (const raw of directories) {
|
|
946
|
+
if (raw.trim() === "") continue;
|
|
947
|
+
const directory = expandHome(raw, system, paths);
|
|
948
|
+
const key = system.platform === "win32" ? directory.toLowerCase() : directory;
|
|
949
|
+
if (seen.has(key)) continue;
|
|
950
|
+
seen.add(key);
|
|
951
|
+
result.push(directory);
|
|
952
|
+
}
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
function clientHomeDirectories(type, system, paths) {
|
|
956
|
+
const result = [];
|
|
957
|
+
for (const name of HOME_ENV_BY_TYPE[type]) {
|
|
958
|
+
const value = environmentValue(system.env, name, system.platform)?.trim();
|
|
959
|
+
if (value === void 0 || value === "") continue;
|
|
960
|
+
result.push(paths.join(value, "bin"), value);
|
|
961
|
+
}
|
|
962
|
+
return result;
|
|
963
|
+
}
|
|
964
|
+
function macFixedDirectories(type) {
|
|
965
|
+
return [
|
|
966
|
+
"/opt/homebrew/bin",
|
|
967
|
+
...{
|
|
968
|
+
mysql: [
|
|
969
|
+
"/opt/homebrew/opt/mysql-client/bin",
|
|
970
|
+
"/opt/homebrew/opt/mysql/bin",
|
|
971
|
+
"/usr/local/opt/mysql-client/bin",
|
|
972
|
+
"/usr/local/opt/mysql/bin",
|
|
973
|
+
"/usr/local/mysql/bin"
|
|
974
|
+
],
|
|
975
|
+
postgres: [
|
|
976
|
+
"/opt/homebrew/opt/libpq/bin",
|
|
977
|
+
"/usr/local/opt/libpq/bin",
|
|
978
|
+
"/Applications/Postgres.app/Contents/Versions/latest/bin"
|
|
979
|
+
],
|
|
980
|
+
sqlite: ["/opt/homebrew/opt/sqlite/bin", "/usr/local/opt/sqlite/bin"],
|
|
981
|
+
oracle: [],
|
|
982
|
+
hive: ["/opt/homebrew/opt/hive/bin", "/usr/local/opt/hive/bin"],
|
|
983
|
+
impala: ["/opt/homebrew/opt/impala/bin", "/usr/local/opt/impala/bin"]
|
|
984
|
+
}[type],
|
|
985
|
+
"/usr/local/bin",
|
|
986
|
+
"/opt/local/bin",
|
|
987
|
+
"/usr/bin"
|
|
988
|
+
];
|
|
989
|
+
}
|
|
990
|
+
function linuxFixedDirectories(system, paths) {
|
|
991
|
+
return [
|
|
992
|
+
paths.join(system.homeDir, ".local", "bin"),
|
|
993
|
+
"/home/linuxbrew/.linuxbrew/bin",
|
|
994
|
+
paths.join(system.homeDir, ".linuxbrew", "bin"),
|
|
995
|
+
"/usr/local/bin",
|
|
996
|
+
"/usr/bin",
|
|
997
|
+
"/snap/bin",
|
|
998
|
+
paths.join(system.homeDir, ".nix-profile", "bin"),
|
|
999
|
+
"/nix/var/nix/profiles/default/bin"
|
|
1000
|
+
];
|
|
1001
|
+
}
|
|
1002
|
+
function windowsFixedDirectories(type, system, paths) {
|
|
1003
|
+
const localAppData = environmentValue(system.env, "LOCALAPPDATA", system.platform);
|
|
1004
|
+
const userProfile = environmentValue(system.env, "USERPROFILE", system.platform) ?? system.homeDir;
|
|
1005
|
+
const chocolatey = environmentValue(system.env, "ChocolateyInstall", system.platform);
|
|
1006
|
+
const programData = environmentValue(system.env, "ProgramData", system.platform) ?? "C:\\ProgramData";
|
|
1007
|
+
const programFiles = environmentValue(system.env, "ProgramFiles", system.platform) ?? "C:\\Program Files";
|
|
1008
|
+
const typeSpecific = {
|
|
1009
|
+
mysql: [],
|
|
1010
|
+
postgres: [],
|
|
1011
|
+
sqlite: [paths.join("C:\\", "sqlite"), paths.join(programFiles, "SQLite")],
|
|
1012
|
+
oracle: [],
|
|
1013
|
+
hive: [],
|
|
1014
|
+
impala: []
|
|
1015
|
+
};
|
|
1016
|
+
return [
|
|
1017
|
+
...localAppData === void 0 ? [] : [paths.join(localAppData, "Microsoft", "WinGet", "Links")],
|
|
1018
|
+
paths.join(userProfile, "scoop", "shims"),
|
|
1019
|
+
...chocolatey === void 0 ? [] : [paths.join(chocolatey, "bin")],
|
|
1020
|
+
paths.join(programData, "chocolatey", "bin"),
|
|
1021
|
+
...typeSpecific[type]
|
|
1022
|
+
];
|
|
1023
|
+
}
|
|
1024
|
+
function formulaPattern(type) {
|
|
1025
|
+
switch (type) {
|
|
1026
|
+
case "mysql": return /^(?:mysql|mysql-client)(?:@.+)?$/i;
|
|
1027
|
+
case "postgres": return /^(?:postgresql(?:@.+)?|libpq)$/i;
|
|
1028
|
+
case "sqlite": return /^sqlite(?:@.+)?$/i;
|
|
1029
|
+
case "oracle": return /^(?:oracle|instantclient)(?:@.+)?$/i;
|
|
1030
|
+
case "hive": return /^hive(?:@.+)?$/i;
|
|
1031
|
+
case "impala": return /^impala(?:@.+)?$/i;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
function dynamicDirectories(type, system, paths) {
|
|
1035
|
+
const result = [];
|
|
1036
|
+
if (system.platform === "darwin") {
|
|
1037
|
+
const pattern = formulaPattern(type);
|
|
1038
|
+
result.push({
|
|
1039
|
+
root: "/opt/homebrew/opt",
|
|
1040
|
+
accepts: (name) => pattern.test(name),
|
|
1041
|
+
suffix: ["bin"]
|
|
1042
|
+
}, {
|
|
1043
|
+
root: "/usr/local/opt",
|
|
1044
|
+
accepts: (name) => pattern.test(name),
|
|
1045
|
+
suffix: ["bin"]
|
|
1046
|
+
});
|
|
1047
|
+
if (type === "postgres") result.push({
|
|
1048
|
+
root: "/Library/PostgreSQL",
|
|
1049
|
+
accepts: () => true,
|
|
1050
|
+
suffix: ["bin"]
|
|
1051
|
+
}, {
|
|
1052
|
+
root: "/Applications/Postgres.app/Contents/Versions",
|
|
1053
|
+
accepts: (name) => name !== "latest",
|
|
1054
|
+
suffix: ["bin"]
|
|
1055
|
+
});
|
|
1056
|
+
if (type === "oracle") result.push({
|
|
1057
|
+
root: "/opt/oracle",
|
|
1058
|
+
accepts: (name) => /^instantclient/i.test(name),
|
|
1059
|
+
suffix: []
|
|
1060
|
+
});
|
|
1061
|
+
} else if (system.platform === "linux") {
|
|
1062
|
+
const pattern = formulaPattern(type);
|
|
1063
|
+
result.push({
|
|
1064
|
+
root: "/home/linuxbrew/.linuxbrew/opt",
|
|
1065
|
+
accepts: (name) => pattern.test(name),
|
|
1066
|
+
suffix: ["bin"]
|
|
1067
|
+
}, {
|
|
1068
|
+
root: paths.join(system.homeDir, ".linuxbrew", "opt"),
|
|
1069
|
+
accepts: (name) => pattern.test(name),
|
|
1070
|
+
suffix: ["bin"]
|
|
1071
|
+
});
|
|
1072
|
+
} else if (system.platform === "win32") {
|
|
1073
|
+
const roots = [environmentValue(system.env, "ProgramFiles", system.platform) ?? "C:\\Program Files", environmentValue(system.env, "ProgramFiles(x86)", system.platform) ?? "C:\\Program Files (x86)"];
|
|
1074
|
+
for (const root of roots) if (type === "mysql") result.push({
|
|
1075
|
+
root: paths.join(root, "MySQL"),
|
|
1076
|
+
accepts: () => true,
|
|
1077
|
+
suffix: ["bin"]
|
|
1078
|
+
}, {
|
|
1079
|
+
root,
|
|
1080
|
+
accepts: (name) => /^MariaDB/i.test(name),
|
|
1081
|
+
suffix: ["bin"]
|
|
1082
|
+
});
|
|
1083
|
+
else if (type === "postgres") result.push({
|
|
1084
|
+
root: paths.join(root, "PostgreSQL"),
|
|
1085
|
+
accepts: () => true,
|
|
1086
|
+
suffix: ["bin"]
|
|
1087
|
+
});
|
|
1088
|
+
else if (type === "oracle") result.push({
|
|
1089
|
+
root: paths.join(root, "Oracle"),
|
|
1090
|
+
accepts: () => true,
|
|
1091
|
+
suffix: ["bin"]
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
return result;
|
|
1095
|
+
}
|
|
1096
|
+
async function expandDynamicDirectories(descriptors, system, paths, signal) {
|
|
1097
|
+
return (await Promise.all(descriptors.map(async (descriptor) => {
|
|
1098
|
+
signal.throwIfAborted();
|
|
1099
|
+
let names;
|
|
1100
|
+
try {
|
|
1101
|
+
names = await system.readDirectory(descriptor.root);
|
|
1102
|
+
} catch {
|
|
1103
|
+
return [];
|
|
1104
|
+
}
|
|
1105
|
+
signal.throwIfAborted();
|
|
1106
|
+
return names.filter((name) => descriptor.accepts(name)).sort((left, right) => right.localeCompare(left, void 0, {
|
|
1107
|
+
numeric: true,
|
|
1108
|
+
sensitivity: "base"
|
|
1109
|
+
})).slice(0, MAX_DYNAMIC_ENTRIES).map((name) => paths.join(descriptor.root, name, ...descriptor.suffix));
|
|
1110
|
+
}))).flat();
|
|
1111
|
+
}
|
|
1112
|
+
/** Build ordered fallback directories without recursively scanning the host. */
|
|
1113
|
+
async function buildClientSearchDirectories(type, config, signal, system = DEFAULT_SYSTEM) {
|
|
1114
|
+
const paths = pathApi(system.platform);
|
|
1115
|
+
const configured = config?.searchPaths ?? [];
|
|
1116
|
+
const homes = clientHomeDirectories(type, system, paths);
|
|
1117
|
+
const fixed = system.platform === "win32" ? windowsFixedDirectories(type, system, paths) : system.platform === "darwin" ? macFixedDirectories(type) : linuxFixedDirectories(system, paths);
|
|
1118
|
+
const dynamic = await expandDynamicDirectories(dynamicDirectories(type, system, paths), system, paths, signal);
|
|
1119
|
+
signal.throwIfAborted();
|
|
1120
|
+
return normalizeDirectories([
|
|
1121
|
+
...configured,
|
|
1122
|
+
...homes,
|
|
1123
|
+
...fixed,
|
|
1124
|
+
...dynamic
|
|
1125
|
+
], system, paths);
|
|
1126
|
+
}
|
|
1127
|
+
function hasPathSeparator(command) {
|
|
1128
|
+
return command.includes("/") || command.includes("\\");
|
|
1129
|
+
}
|
|
1130
|
+
function withSearchPath(explicitEnv, directories, system) {
|
|
1131
|
+
const pathName = system.platform === "win32" ? Object.keys(system.env).find((name) => name.toLowerCase() === "path") ?? "Path" : "PATH";
|
|
1132
|
+
const explicitPathName = Object.keys(explicitEnv).find((name) => system.platform === "win32" ? name.toLowerCase() === "path" : name === "PATH");
|
|
1133
|
+
const parentPath = explicitPathName === void 0 ? environmentValue(system.env, "PATH", system.platform) : explicitEnv[explicitPathName];
|
|
1134
|
+
const separator = system.platform === "win32" ? ";" : ":";
|
|
1135
|
+
const prefix = directories.join(separator);
|
|
1136
|
+
const combined = parentPath === void 0 || parentPath === "" ? prefix : `${prefix}${separator}${parentPath}`;
|
|
1137
|
+
const result = { ...explicitEnv };
|
|
1138
|
+
if (explicitPathName !== void 0 && explicitPathName !== pathName) delete result[explicitPathName];
|
|
1139
|
+
result[pathName] = combined;
|
|
1140
|
+
return result;
|
|
1141
|
+
}
|
|
1142
|
+
function errorText(error) {
|
|
1143
|
+
return error instanceof Error ? error.message : String(error);
|
|
1144
|
+
}
|
|
1145
|
+
function checkedDirectoriesText(directories) {
|
|
1146
|
+
const visible = directories.slice(0, 16);
|
|
1147
|
+
const suffix = directories.length > visible.length ? `,另有${directories.length - visible.length}个目录` : "";
|
|
1148
|
+
return visible.length === 0 ? "无补充目录" : `${visible.join("、")}${suffix}`;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Resolve one configured/default client. Current PATH (or an explicit path)
|
|
1152
|
+
* always wins. Only a missing bare command activates bounded PATH discovery.
|
|
1153
|
+
*/
|
|
1154
|
+
async function resolveClientExecutable(options) {
|
|
1155
|
+
const system = options.system ?? DEFAULT_SYSTEM;
|
|
1156
|
+
let initialError;
|
|
1157
|
+
try {
|
|
1158
|
+
return {
|
|
1159
|
+
executable: await options.resolveExecutable(options.command, options.env, options.signal),
|
|
1160
|
+
env: options.env,
|
|
1161
|
+
searchedDirectories: []
|
|
1162
|
+
};
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
options.signal.throwIfAborted();
|
|
1165
|
+
initialError = error;
|
|
1166
|
+
}
|
|
1167
|
+
if (pathApi(system.platform).isAbsolute(options.command) || hasPathSeparator(options.command)) throw new Error(`无法解析数据库客户端 "${options.command}"(类型 ${options.type}:${errorText(initialError)});该显式路径不会回退到默认命令,请检查 clients.${options.type}.command`);
|
|
1168
|
+
const directories = await buildClientSearchDirectories(options.type, options.config, options.signal, system);
|
|
1169
|
+
const discoveryEnv = withSearchPath(options.env, directories, system);
|
|
1170
|
+
try {
|
|
1171
|
+
return {
|
|
1172
|
+
executable: await options.resolveExecutable(options.command, discoveryEnv, options.signal),
|
|
1173
|
+
env: discoveryEnv,
|
|
1174
|
+
searchedDirectories: directories
|
|
1175
|
+
};
|
|
1176
|
+
} catch (fallbackError) {
|
|
1177
|
+
options.signal.throwIfAborted();
|
|
1178
|
+
throw new Error(`无法解析数据库客户端 "${options.command}"(类型 ${options.type};当前PATH:${errorText(initialError)};补充PATH:${errorText(fallbackError)})。已检查:${checkedDirectoriesText(directories)};请确认客户端已安装,或配置 clients.${options.type}.command / clients.${options.type}.searchPaths`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
//#endregion
|
|
1182
|
+
//#region src/query.ts
|
|
1183
|
+
/** Read one collected stream from offset 0. */
|
|
1184
|
+
function readCaptured(reader) {
|
|
1185
|
+
if (reader === void 0) return {
|
|
1186
|
+
text: "",
|
|
1187
|
+
truncated: false
|
|
1188
|
+
};
|
|
1189
|
+
const read = reader.readFrom(0);
|
|
1190
|
+
return {
|
|
1191
|
+
text: read.text,
|
|
1192
|
+
truncated: read.lossy
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Run one SQL text through the type's CLI client. The SQL is written to the
|
|
1197
|
+
* child's stdin (`{ data }` batch disposition) so it never appears in argv;
|
|
1198
|
+
* passwords travel in the env entries built by the template.
|
|
1199
|
+
*
|
|
1200
|
+
* Failure classification:
|
|
1201
|
+
* - the caller's external signal (e.g. the tool exec signal) aborts → the
|
|
1202
|
+
* abort reason propagates;
|
|
1203
|
+
* - the internal timeout fires → an Error naming the deadline is thrown;
|
|
1204
|
+
* - the executable cannot be resolved → an Error naming the command is thrown;
|
|
1205
|
+
* - the process runs to completion → `{ exitCode, stdout, stderr, truncated }`
|
|
1206
|
+
* is returned even for a non-zero exit (the caller decides what that means).
|
|
1207
|
+
* @param ctx - context exposing the subprocess service.
|
|
1208
|
+
* @param connection - the stored connection (password included).
|
|
1209
|
+
* @param sql - the SQL text (or client command) to run.
|
|
1210
|
+
* @param options - timeouts, caps, client overrides.
|
|
1211
|
+
* @param externalSignal - caller-owned cancellation (the tool exec signal).
|
|
1212
|
+
* @param introspect - use the machine-readable introspection flag set.
|
|
1213
|
+
* @returns the captured outcome.
|
|
1214
|
+
*/
|
|
1215
|
+
async function runClientQuery(ctx, connection, sql, options, externalSignal, introspect = false) {
|
|
1216
|
+
const template = options.mode === "structured" ? buildStructuredQueryTemplate(connection.type, connection, options.clients[connection.type]) : options.mode === "introspect" || introspect ? buildIntrospectTemplate(connection.type, connection, options.clients[connection.type]) : buildClientTemplate(connection.type, connection, options.clients[connection.type]);
|
|
1217
|
+
const controller = new AbortController();
|
|
1218
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`查询超过 ${options.timeoutMs}ms 未完成,已终止客户端进程`)), options.timeoutMs);
|
|
1219
|
+
const onExternalAbort = () => {
|
|
1220
|
+
controller.abort(externalSignal.reason);
|
|
1221
|
+
};
|
|
1222
|
+
if (externalSignal.aborted) controller.abort(externalSignal.reason);
|
|
1223
|
+
else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
1224
|
+
try {
|
|
1225
|
+
const resolution = await resolveClientExecutable({
|
|
1226
|
+
type: connection.type,
|
|
1227
|
+
command: template.command,
|
|
1228
|
+
config: options.clients[connection.type],
|
|
1229
|
+
env: template.env,
|
|
1230
|
+
signal: controller.signal,
|
|
1231
|
+
resolveExecutable: ctx.subprocess.resolveExecutable.bind(ctx.subprocess)
|
|
1232
|
+
});
|
|
1233
|
+
const handle = ctx.subprocess.spawn({
|
|
1234
|
+
argv: [resolution.executable, ...template.args],
|
|
1235
|
+
cwd: process.cwd(),
|
|
1236
|
+
stdio: {
|
|
1237
|
+
stdin: { data: `${template.stdinPrefix}${sql}\n` },
|
|
1238
|
+
stdout: { maxBytes: options.maxResultChars },
|
|
1239
|
+
stderr: { maxBytes: options.maxResultChars }
|
|
1240
|
+
},
|
|
1241
|
+
graceMs: options.graceMs ?? 5e3,
|
|
1242
|
+
signal: controller.signal,
|
|
1243
|
+
env: resolution.env
|
|
1244
|
+
});
|
|
1245
|
+
let outcome;
|
|
1246
|
+
try {
|
|
1247
|
+
outcome = await handle.done;
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
controller.signal.throwIfAborted();
|
|
1250
|
+
throw new Error(`启动数据库客户端失败:${error instanceof Error ? error.message : String(error)}`);
|
|
1251
|
+
}
|
|
1252
|
+
if (controller.signal.aborted) controller.signal.throwIfAborted();
|
|
1253
|
+
const stdout = readCaptured(handle.collected.stdout);
|
|
1254
|
+
const stderr = readCaptured(handle.collected.stderr);
|
|
1255
|
+
return {
|
|
1256
|
+
exitCode: outcome.exitCode,
|
|
1257
|
+
stdout: stdout.text,
|
|
1258
|
+
stderr: stderr.text,
|
|
1259
|
+
truncated: stdout.truncated || stderr.truncated
|
|
1260
|
+
};
|
|
1261
|
+
} finally {
|
|
1262
|
+
clearTimeout(timer);
|
|
1263
|
+
externalSignal.removeEventListener("abort", onExternalAbort);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
/** Build a password-stripped copy of one connection. */
|
|
1267
|
+
function summarize(connection) {
|
|
1268
|
+
const summary = {
|
|
1269
|
+
type: connection.type,
|
|
1270
|
+
database: connection.database
|
|
1271
|
+
};
|
|
1272
|
+
if (connection.host !== void 0) summary.host = connection.host;
|
|
1273
|
+
if (connection.port !== void 0) summary.port = connection.port;
|
|
1274
|
+
if (connection.user !== void 0) summary.user = connection.user;
|
|
1275
|
+
if (connection.passwordRef !== void 0) summary.passwordRef = connection.passwordRef;
|
|
1276
|
+
if (connection.readonly !== void 0) summary.readonly = connection.readonly;
|
|
1277
|
+
if (connection.profileId !== void 0) summary.profileId = connection.profileId;
|
|
1278
|
+
if (connection.name !== void 0) summary.name = connection.name;
|
|
1279
|
+
if (connection.tables !== void 0) summary.tables = [...connection.tables];
|
|
1280
|
+
return summary;
|
|
1281
|
+
}
|
|
1282
|
+
/** Replace every occurrence of a resolved secret before crossing a public seam. */
|
|
1283
|
+
function redactSecretText(text, secrets) {
|
|
1284
|
+
let redacted = text;
|
|
1285
|
+
for (const secret of secrets) if (secret !== void 0 && secret.length > 0) redacted = redacted.split(secret).join("[REDACTED]");
|
|
1286
|
+
return redacted;
|
|
1287
|
+
}
|
|
1288
|
+
/** Redact a client result without mutating the runner-owned object. */
|
|
1289
|
+
function redactQueryResult(result, connection) {
|
|
1290
|
+
const secrets = [connection.password];
|
|
1291
|
+
return {
|
|
1292
|
+
...result,
|
|
1293
|
+
stdout: redactSecretText(result.stdout, secrets),
|
|
1294
|
+
stderr: redactSecretText(result.stderr, secrets)
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
/** Validate/normalize a shared connect input before any I/O. */
|
|
1298
|
+
function normalizeConnectionInput(input, cwd = process.cwd()) {
|
|
1299
|
+
if (!isDatabaseType(input.type)) throw new Error("数据库类型无效");
|
|
1300
|
+
if (typeof input.database !== "string" || input.database.trim().length === 0) throw new Error("database 必须是非空字符串");
|
|
1301
|
+
if (input.password !== void 0 && input.passwordRef !== void 0) throw new Error("password 与 passwordRef 不能同时提供");
|
|
1302
|
+
if (input.passwordRef !== void 0) validatePasswordRef(input.passwordRef);
|
|
1303
|
+
if (input.port !== void 0 && (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535)) throw new Error("port 必须是 1-65535 的整数");
|
|
1304
|
+
if (input.profileId !== void 0 && input.profileId.trim().length === 0) throw new Error("profileId 不能为空");
|
|
1305
|
+
if (input.name !== void 0 && input.name.trim().length === 0) throw new Error("name 不能为空");
|
|
1306
|
+
const connection = {
|
|
1307
|
+
type: input.type,
|
|
1308
|
+
database: input.type === "sqlite" ? resolve(cwd, input.database) : input.database
|
|
1309
|
+
};
|
|
1310
|
+
if (input.type !== "sqlite") {
|
|
1311
|
+
if (input.host !== void 0 && input.host.length > 0) connection.host = input.host;
|
|
1312
|
+
if (input.port !== void 0) connection.port = input.port;
|
|
1313
|
+
if (input.user !== void 0 && input.user.length > 0) connection.user = input.user;
|
|
1314
|
+
if (input.password !== void 0 && input.password.length > 0) connection.password = input.password;
|
|
1315
|
+
if (input.passwordRef !== void 0) connection.passwordRef = input.passwordRef;
|
|
1316
|
+
}
|
|
1317
|
+
if (input.readonly !== void 0) connection.readonly = input.readonly;
|
|
1318
|
+
if (input.profileId !== void 0) connection.profileId = input.profileId;
|
|
1319
|
+
if (input.name !== void 0) connection.name = input.name;
|
|
1320
|
+
return connection;
|
|
1321
|
+
}
|
|
1322
|
+
/** Create the surface-independent service. */
|
|
1323
|
+
function createConnectionService(ctx, options, persistence) {
|
|
1324
|
+
const resolvedOptions = options ?? {
|
|
1325
|
+
connectTimeoutMs: 15e3,
|
|
1326
|
+
queryTimeoutMs: 3e4,
|
|
1327
|
+
maxResultChars: 2e5,
|
|
1328
|
+
maxQueryChars: 65536,
|
|
1329
|
+
introspectMaxTables: 500,
|
|
1330
|
+
readonly: false,
|
|
1331
|
+
clients: {}
|
|
1332
|
+
};
|
|
1333
|
+
const runtime = /* @__PURE__ */ new Map();
|
|
1334
|
+
const formDrafts = /* @__PURE__ */ new Map();
|
|
1335
|
+
const profileConnection = (sessionId) => {
|
|
1336
|
+
if (persistence === void 0) return void 0;
|
|
1337
|
+
const binding = persistence.getBinding(sessionId);
|
|
1338
|
+
if (binding === void 0) return void 0;
|
|
1339
|
+
const profile = persistence.getProfile(binding.profileId);
|
|
1340
|
+
return profile === void 0 ? void 0 : connectionFromProfile(binding.profileId, profile);
|
|
1341
|
+
};
|
|
1342
|
+
/** Required precedence: exact runtime, exact binding, wildcard runtime, wildcard binding. */
|
|
1343
|
+
const rawConnection = (sessionId) => runtime.get(sessionId) ?? profileConnection(sessionId) ?? runtime.get("*") ?? profileConnection("*");
|
|
1344
|
+
const requireContext = () => {
|
|
1345
|
+
if (ctx === void 0) throw new Error("数据库执行服务尚未配置");
|
|
1346
|
+
return ctx;
|
|
1347
|
+
};
|
|
1348
|
+
const resolveCredential = async (connection) => {
|
|
1349
|
+
if (connection.passwordRef === void 0) return {
|
|
1350
|
+
...connection,
|
|
1351
|
+
tables: copyTables(connection.tables)
|
|
1352
|
+
};
|
|
1353
|
+
const ref = validatedCredentialRef(connection.passwordRef);
|
|
1354
|
+
const hit = await requireContext().credentials.resolve(ref);
|
|
1355
|
+
if (hit === void 0 || hit.value.length === 0) throw new Error(`凭据引用 "${connection.passwordRef}" 未配置`);
|
|
1356
|
+
return {
|
|
1357
|
+
...connection,
|
|
1358
|
+
password: hit.value,
|
|
1359
|
+
tables: copyTables(connection.tables)
|
|
1360
|
+
};
|
|
1361
|
+
};
|
|
1362
|
+
const queryOptions = (mode, connect = false) => ({
|
|
1363
|
+
clients: resolvedOptions.clients,
|
|
1364
|
+
timeoutMs: connect ? resolvedOptions.connectTimeoutMs : resolvedOptions.queryTimeoutMs,
|
|
1365
|
+
maxResultChars: resolvedOptions.maxResultChars,
|
|
1366
|
+
...mode !== void 0 ? { mode } : {}
|
|
1367
|
+
});
|
|
1368
|
+
const run = async (connection, sql, signal, introspection = false, connect = false) => {
|
|
1369
|
+
try {
|
|
1370
|
+
return redactQueryResult(await runClientQuery(requireContext(), connection, sql, queryOptions(void 0, connect), signal, introspection), connection);
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
const message = redactSecretText(error instanceof Error ? error.message : String(error), [connection.password]);
|
|
1373
|
+
throw new Error(message, error instanceof Error ? { cause: error } : void 0);
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
const verify = async (connection, signal, connect = false) => {
|
|
1377
|
+
const result = await run(connection, tableListingSql(connection.type, connection), signal, true, connect);
|
|
1378
|
+
if (result.exitCode !== 0) {
|
|
1379
|
+
const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
|
|
1380
|
+
throw new Error(`数据库连接验证失败(exit ${result.exitCode}):${detail}`);
|
|
1381
|
+
}
|
|
1382
|
+
return parseTableListing(connection.type, result.stdout).slice(0, resolvedOptions.introspectMaxTables);
|
|
1383
|
+
};
|
|
1384
|
+
const persistAtomically = async (sessionId, profileId, profile) => {
|
|
1385
|
+
if (persistence === void 0) return;
|
|
1386
|
+
const previousProfile = persistence.getProfile(profileId);
|
|
1387
|
+
const previousBinding = persistence.getBinding(sessionId);
|
|
1388
|
+
await persistence.putProfile(profileId, profile);
|
|
1389
|
+
try {
|
|
1390
|
+
await persistence.putBinding(sessionId, {
|
|
1391
|
+
profileId,
|
|
1392
|
+
updatedAt: profile.updatedAt
|
|
1393
|
+
});
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
if (previousProfile === void 0) await persistence.deleteProfile(profileId);
|
|
1396
|
+
else await persistence.putProfile(profileId, previousProfile);
|
|
1397
|
+
if (previousBinding === void 0) await persistence.deleteBinding(sessionId);
|
|
1398
|
+
else await persistence.putBinding(sessionId, previousBinding);
|
|
1399
|
+
throw error;
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
const credentialSummary = async (connection) => {
|
|
1403
|
+
if (connection.type === "sqlite") return void 0;
|
|
1404
|
+
if (connection.password !== void 0) return {
|
|
1405
|
+
configured: true,
|
|
1406
|
+
source: "memory"
|
|
1407
|
+
};
|
|
1408
|
+
if (connection.passwordRef === void 0) return { configured: false };
|
|
1409
|
+
const info = await requireContext().credentials.describe(validatedCredentialRef(connection.passwordRef));
|
|
1410
|
+
return {
|
|
1411
|
+
configured: info.configured,
|
|
1412
|
+
...info.source !== void 0 ? { source: info.source } : {}
|
|
1413
|
+
};
|
|
1414
|
+
};
|
|
1415
|
+
const service = {
|
|
1416
|
+
set(sessionId, connection) {
|
|
1417
|
+
if (connection.password !== void 0 && connection.passwordRef !== void 0) throw new Error("password 与 passwordRef 不能同时提供");
|
|
1418
|
+
if (connection.passwordRef !== void 0) validatePasswordRef(connection.passwordRef);
|
|
1419
|
+
runtime.set(sessionId, {
|
|
1420
|
+
...connection,
|
|
1421
|
+
tables: copyTables(connection.tables)
|
|
1422
|
+
});
|
|
1423
|
+
},
|
|
1424
|
+
get(sessionId) {
|
|
1425
|
+
const connection = rawConnection(sessionId);
|
|
1426
|
+
return connection === void 0 ? void 0 : summarize(connection);
|
|
1427
|
+
},
|
|
1428
|
+
getWithSecret(sessionId) {
|
|
1429
|
+
const connection = rawConnection(sessionId);
|
|
1430
|
+
return connection === void 0 ? void 0 : {
|
|
1431
|
+
...connection,
|
|
1432
|
+
tables: copyTables(connection.tables)
|
|
1433
|
+
};
|
|
1434
|
+
},
|
|
1435
|
+
has(sessionId) {
|
|
1436
|
+
return rawConnection(sessionId) !== void 0;
|
|
1437
|
+
},
|
|
1438
|
+
clear(sessionId) {
|
|
1439
|
+
runtime.delete(sessionId);
|
|
1440
|
+
},
|
|
1441
|
+
getFormDraft(sessionId) {
|
|
1442
|
+
const draft = persistence?.getDraft?.(sessionId) ?? formDrafts.get(sessionId);
|
|
1443
|
+
return draft === void 0 ? void 0 : copyFormDraft(draft);
|
|
1444
|
+
},
|
|
1445
|
+
async saveFormDraft(sessionId, draft) {
|
|
1446
|
+
if (sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
|
|
1447
|
+
const safe = normalizeFormDraft(draft);
|
|
1448
|
+
if (persistence?.putDraft !== void 0) await persistence.putDraft(sessionId, {
|
|
1449
|
+
...safe,
|
|
1450
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1451
|
+
});
|
|
1452
|
+
else formDrafts.set(sessionId, safe);
|
|
1453
|
+
},
|
|
1454
|
+
async status(sessionId) {
|
|
1455
|
+
const connection = rawConnection(sessionId);
|
|
1456
|
+
if (connection === void 0) return void 0;
|
|
1457
|
+
const summary = summarize(connection);
|
|
1458
|
+
summary.credential = await credentialSummary(connection);
|
|
1459
|
+
return summary;
|
|
1460
|
+
},
|
|
1461
|
+
async connect(sessionId, input, signal) {
|
|
1462
|
+
if (sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
|
|
1463
|
+
const normalized = normalizeConnectionInput(input, resolvedOptions.cwd);
|
|
1464
|
+
const execution = await resolveCredential(normalized);
|
|
1465
|
+
const tables = await verify(execution, signal, true);
|
|
1466
|
+
const profileId = normalized.profileId ?? `session:${sessionId}`;
|
|
1467
|
+
const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1468
|
+
await persistAtomically(sessionId, profileId, profileFromConnection(normalized, updatedAt));
|
|
1469
|
+
const published = {
|
|
1470
|
+
...normalized,
|
|
1471
|
+
profileId,
|
|
1472
|
+
tables
|
|
1473
|
+
};
|
|
1474
|
+
runtime.set(sessionId, published);
|
|
1475
|
+
const summary = summarize(published);
|
|
1476
|
+
summary.credential = await credentialSummary(published);
|
|
1477
|
+
return {
|
|
1478
|
+
tables,
|
|
1479
|
+
summary
|
|
1480
|
+
};
|
|
1481
|
+
},
|
|
1482
|
+
async disconnect(sessionId) {
|
|
1483
|
+
runtime.delete(sessionId);
|
|
1484
|
+
if (persistence !== void 0) await persistence.deleteBinding(sessionId);
|
|
1485
|
+
},
|
|
1486
|
+
async test(sessionId, signal) {
|
|
1487
|
+
const connection = await service.resolveForExecution(sessionId);
|
|
1488
|
+
const tables = await verify(connection, signal);
|
|
1489
|
+
const published = {
|
|
1490
|
+
...rawConnection(sessionId),
|
|
1491
|
+
tables
|
|
1492
|
+
};
|
|
1493
|
+
runtime.set(sessionId, published);
|
|
1494
|
+
const summary = summarize(published);
|
|
1495
|
+
summary.credential = await credentialSummary(published);
|
|
1496
|
+
return {
|
|
1497
|
+
tables,
|
|
1498
|
+
summary
|
|
1499
|
+
};
|
|
1500
|
+
},
|
|
1501
|
+
async resolveForExecution(sessionId) {
|
|
1502
|
+
const connection = rawConnection(sessionId);
|
|
1503
|
+
if (connection === void 0) throw new Error("请先在 Web「数据库」标签页连接数据库,或在 TUI 运行 /database connect(未找到当前会话的连接)");
|
|
1504
|
+
return resolveCredential(connection);
|
|
1505
|
+
},
|
|
1506
|
+
async listSchemas(sessionId, signal) {
|
|
1507
|
+
const connection = await service.resolveForExecution(sessionId);
|
|
1508
|
+
const stdout = await runMetadata(connection, "schemas", signal);
|
|
1509
|
+
return parseListing(connection.type, stdout).slice(0, resolvedOptions.introspectMaxTables);
|
|
1510
|
+
},
|
|
1511
|
+
async listTables(sessionId, schema, signal) {
|
|
1512
|
+
const connection = await service.resolveForExecution(sessionId);
|
|
1513
|
+
if (connection.type !== "sqlite") requireIdentifier(connection.type, schema, "schema");
|
|
1514
|
+
const stdout = await runMetadata(connection, "tables", signal, schema);
|
|
1515
|
+
return parseListing(connection.type, stdout).slice(0, resolvedOptions.introspectMaxTables);
|
|
1516
|
+
},
|
|
1517
|
+
async describe(sessionId, schema, table, signal) {
|
|
1518
|
+
const connection = await service.resolveForExecution(sessionId);
|
|
1519
|
+
if (connection.type !== "sqlite") requireIdentifier(connection.type, schema, "schema");
|
|
1520
|
+
requireIdentifier(connection.type, table, "table");
|
|
1521
|
+
const stdout = await runMetadata(connection, "describe", signal, schema, table);
|
|
1522
|
+
return parseColumns(connection.type, stdout);
|
|
1523
|
+
},
|
|
1524
|
+
async query(sessionId, sql, signal) {
|
|
1525
|
+
if (sql.trim().length === 0) throw new Error("sql 必须是非空字符串");
|
|
1526
|
+
const maxQueryChars = resolvedOptions.maxQueryChars ?? 65536;
|
|
1527
|
+
if (sql.length > maxQueryChars) throw new Error(`sql 超过长度上限(${maxQueryChars} 字符)`);
|
|
1528
|
+
assertSingleStatement(sql, "/query");
|
|
1529
|
+
const connection = await service.resolveForExecution(sessionId);
|
|
1530
|
+
if ((connection.readonly ?? resolvedOptions.readonly) && classifyStatement(sql, connection.type) === "write") throw new Error("当前连接为只读模式,拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/PRAGMA 等)");
|
|
1531
|
+
return run(connection, sql, signal);
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
async function runMetadata(connection, kind, signal, schema, table) {
|
|
1535
|
+
const result = await run(connection, metadataQuery(kind, connection.type, schema, table), signal, true);
|
|
1536
|
+
if (result.exitCode !== 0) {
|
|
1537
|
+
const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
|
|
1538
|
+
throw new Error(`元数据查询失败(exit ${result.exitCode}):${detail}`);
|
|
1539
|
+
}
|
|
1540
|
+
return result.stdout;
|
|
1541
|
+
}
|
|
1542
|
+
return service;
|
|
1543
|
+
}
|
|
1544
|
+
function copyTables(tables) {
|
|
1545
|
+
return tables === void 0 ? void 0 : [...tables];
|
|
1546
|
+
}
|
|
1547
|
+
function normalizeFormDraft(draft) {
|
|
1548
|
+
if (!isDatabaseType(draft.type)) throw new Error("数据库类型无效");
|
|
1549
|
+
if (typeof draft.host !== "string" || typeof draft.port !== "string" || typeof draft.user !== "string" || typeof draft.database !== "string" || typeof draft.readonly !== "boolean") throw new Error("数据库表单草稿无效");
|
|
1550
|
+
return copyFormDraft(draft);
|
|
1551
|
+
}
|
|
1552
|
+
function copyFormDraft(draft) {
|
|
1553
|
+
return {
|
|
1554
|
+
type: draft.type,
|
|
1555
|
+
host: draft.host,
|
|
1556
|
+
port: draft.port,
|
|
1557
|
+
user: draft.user,
|
|
1558
|
+
database: draft.database,
|
|
1559
|
+
readonly: draft.readonly
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
function isDatabaseType(value) {
|
|
1563
|
+
return value === "mysql" || value === "postgres" || value === "sqlite" || value === "oracle" || value === "hive" || value === "impala";
|
|
1564
|
+
}
|
|
1565
|
+
function validatePasswordRef(value) {
|
|
1566
|
+
try {
|
|
1567
|
+
credentialRef(value);
|
|
1568
|
+
} catch {
|
|
1569
|
+
throw new Error(`passwordRef "${value}" 无效;必须是 POSIX 环境变量形式的名称`);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
function validatedCredentialRef(value) {
|
|
1573
|
+
validatePasswordRef(value);
|
|
1574
|
+
return credentialRef(value);
|
|
1575
|
+
}
|
|
1576
|
+
function connectionFromProfile(profileId, profile) {
|
|
1577
|
+
return {
|
|
1578
|
+
type: profile.type,
|
|
1579
|
+
database: profile.database,
|
|
1580
|
+
profileId,
|
|
1581
|
+
...profile.name !== void 0 ? { name: profile.name } : {},
|
|
1582
|
+
...profile.host !== void 0 ? { host: profile.host } : {},
|
|
1583
|
+
...profile.port !== void 0 ? { port: profile.port } : {},
|
|
1584
|
+
...profile.user !== void 0 ? { user: profile.user } : {},
|
|
1585
|
+
...profile.readonly !== void 0 ? { readonly: profile.readonly } : {},
|
|
1586
|
+
...profile.passwordRef !== void 0 ? { passwordRef: profile.passwordRef } : {}
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
function profileFromConnection(connection, updatedAt) {
|
|
1590
|
+
return {
|
|
1591
|
+
type: connection.type,
|
|
1592
|
+
database: connection.database,
|
|
1593
|
+
updatedAt,
|
|
1594
|
+
...connection.name !== void 0 ? { name: connection.name } : {},
|
|
1595
|
+
...connection.host !== void 0 ? { host: connection.host } : {},
|
|
1596
|
+
...connection.port !== void 0 ? { port: connection.port } : {},
|
|
1597
|
+
...connection.user !== void 0 ? { user: connection.user } : {},
|
|
1598
|
+
...connection.readonly !== void 0 ? { readonly: connection.readonly } : {},
|
|
1599
|
+
...connection.passwordRef !== void 0 ? { passwordRef: connection.passwordRef } : {}
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
function requireIdentifier(type, value, label) {
|
|
1603
|
+
if (value === void 0 || value.length === 0) throw new Error(`${label} 不能为空`);
|
|
1604
|
+
sanitizeIdentifier(type, value);
|
|
1605
|
+
return value;
|
|
1606
|
+
}
|
|
1607
|
+
//#endregion
|
|
1608
|
+
export { classifyStatement as a, assertSingleStatement as c, runClientQuery as i, redactQueryResult as n, clientsSchema as o, redactSecretText as r, enforceReadRowLimit as s, createConnectionService as t };
|