@yejiming/dsh-data-agent 0.0.2 → 0.0.5
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 +12 -12
- package/README.md +12 -12
- package/lib/client.js +313 -257
- package/lib/client.js.map +1 -1
- package/lib/{defaults-Dgu2B2Yq.js → defaults-Bac6QvNt.js} +469 -7
- package/lib/index.js +4 -4
- package/lib/{query-vK9dr7Z6.js → query-CmhTFklw.js} +2 -2
- package/lib/routes.js +3 -2
- package/lib/tool.js +335 -18
- package/lib/types/client/locales.d.ts +4 -2
- package/lib/types/clients.d.ts +22 -2
- package/lib/types/defaults.d.ts +3 -3
- package/lib/types/index.d.ts +6 -6
- package/lib/types/query.d.ts +6 -2
- package/lib/types/sql.d.ts +26 -0
- package/lib/types/structured.d.ts +29 -0
- package/lib/types/tool.d.ts +10 -4
- package/package.json +1 -1
- package/preset/data-agent/agent.cordis.yml +11 -7
- package/preset/data-agent/preset.yml +1 -1
|
@@ -1,4 +1,322 @@
|
|
|
1
1
|
import z from "schemastery";
|
|
2
|
+
//#region src/sql.ts
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight SQL-text scanning helpers shared by the sqlcmd tool half and
|
|
5
|
+
* the /query route. This is intentionally NOT a SQL parser: the scanner only
|
|
6
|
+
* understands lexical boundaries (strings, quoted identifiers, comments and
|
|
7
|
+
* parenthesis depth) well enough to make the two agent-loop guarantees from
|
|
8
|
+
* docs/optimization-opportunities.md:
|
|
9
|
+
*
|
|
10
|
+
* - a single tool call carries at most ONE SQL statement;
|
|
11
|
+
* - `maxRows` can be enforced with a real top-level LIMIT, not just a prompt.
|
|
12
|
+
*
|
|
13
|
+
* @module @yejiming/dsh-data-agent/sql
|
|
14
|
+
*/
|
|
15
|
+
const IDENT_CHAR = /[A-Za-z0-9_$]/;
|
|
16
|
+
function isWhitespace(char) {
|
|
17
|
+
return /\s/.test(char);
|
|
18
|
+
}
|
|
19
|
+
function isIdentChar(char) {
|
|
20
|
+
return IDENT_CHAR.test(char);
|
|
21
|
+
}
|
|
22
|
+
function skipQuoted(sql, start) {
|
|
23
|
+
const quote = sql[start];
|
|
24
|
+
let index = start + 1;
|
|
25
|
+
while (index < sql.length) {
|
|
26
|
+
const char = sql[index];
|
|
27
|
+
if (char === "\\" && index + 1 < sql.length && quote !== "`") {
|
|
28
|
+
index += 2;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (char === quote) {
|
|
32
|
+
if (sql[index + 1] === quote) {
|
|
33
|
+
index += 2;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
return index + 1;
|
|
37
|
+
}
|
|
38
|
+
index += 1;
|
|
39
|
+
}
|
|
40
|
+
return sql.length;
|
|
41
|
+
}
|
|
42
|
+
function skipDollarQuoted(sql, start) {
|
|
43
|
+
const match = sql.slice(start).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
|
|
44
|
+
if (match === null) return -1;
|
|
45
|
+
const delimiter = match[0];
|
|
46
|
+
const end = sql.indexOf(delimiter, start + delimiter.length);
|
|
47
|
+
return end === -1 ? sql.length : end + delimiter.length;
|
|
48
|
+
}
|
|
49
|
+
function skipOracleQuoted(sql, start) {
|
|
50
|
+
if (!/^q'/i.test(sql.slice(start, start + 2))) return -1;
|
|
51
|
+
const open = sql[start + 2];
|
|
52
|
+
if (open === void 0) return sql.length;
|
|
53
|
+
const close = {
|
|
54
|
+
"[": "]",
|
|
55
|
+
"{": "}",
|
|
56
|
+
"(": ")",
|
|
57
|
+
"<": ">"
|
|
58
|
+
}[open] ?? open;
|
|
59
|
+
let index = start + 3;
|
|
60
|
+
while (index < sql.length) {
|
|
61
|
+
if (sql[index] === close && sql[index + 1] === "'") return index + 2;
|
|
62
|
+
index += 1;
|
|
63
|
+
}
|
|
64
|
+
return sql.length;
|
|
65
|
+
}
|
|
66
|
+
function skipBlockComment(sql, start) {
|
|
67
|
+
let depth = 1;
|
|
68
|
+
let index = start + 2;
|
|
69
|
+
while (index < sql.length) {
|
|
70
|
+
if (sql.startsWith("/*", index)) {
|
|
71
|
+
depth += 1;
|
|
72
|
+
index += 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (sql.startsWith("*/", index)) {
|
|
76
|
+
depth -= 1;
|
|
77
|
+
index += 2;
|
|
78
|
+
if (depth === 0) return index;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
index += 1;
|
|
82
|
+
}
|
|
83
|
+
return sql.length;
|
|
84
|
+
}
|
|
85
|
+
function skipLineComment(sql, start) {
|
|
86
|
+
const newline = sql.indexOf("\n", start);
|
|
87
|
+
return newline === -1 ? sql.length : newline + 1;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Walk the SQL text, invoking `onSemicolon` for every top-level statement
|
|
91
|
+
* separator (parenthesis depth zero, outside strings, quoted identifiers and
|
|
92
|
+
* comments).
|
|
93
|
+
*/
|
|
94
|
+
function scanTopLevelSemicolons(sql, onSemicolon) {
|
|
95
|
+
let depth = 0;
|
|
96
|
+
let index = 0;
|
|
97
|
+
while (index < sql.length) {
|
|
98
|
+
const char = sql[index];
|
|
99
|
+
if (isWhitespace(char)) {
|
|
100
|
+
index += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (sql.startsWith("--", index)) {
|
|
104
|
+
index = skipLineComment(sql, index + 2);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (sql.startsWith("/*", index)) {
|
|
108
|
+
index = skipBlockComment(sql, index);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
112
|
+
index = skipQuoted(sql, index);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (char === "$") {
|
|
116
|
+
const dollarEnd = skipDollarQuoted(sql, index);
|
|
117
|
+
if (dollarEnd !== -1) {
|
|
118
|
+
index = dollarEnd;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const oracleEnd = skipOracleQuoted(sql, index);
|
|
123
|
+
if (oracleEnd !== -1) {
|
|
124
|
+
index = oracleEnd;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (char === "(") {
|
|
128
|
+
depth += 1;
|
|
129
|
+
index += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (char === ")") {
|
|
133
|
+
depth = Math.max(0, depth - 1);
|
|
134
|
+
index += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (char === ";" && depth === 0) onSemicolon(index);
|
|
138
|
+
index += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Whether meaningful SQL content exists after `index` (trailing `;`/comments ignored). */
|
|
142
|
+
function hasContentAfter(sql, index) {
|
|
143
|
+
let cursor = index;
|
|
144
|
+
while (cursor < sql.length) {
|
|
145
|
+
const char = sql[cursor];
|
|
146
|
+
if (isWhitespace(char)) {
|
|
147
|
+
cursor += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (char === ";") {
|
|
151
|
+
cursor += 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (sql.startsWith("--", cursor)) {
|
|
155
|
+
cursor = skipLineComment(sql, cursor + 2);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (sql.startsWith("/*", cursor)) {
|
|
159
|
+
cursor = skipBlockComment(sql, cursor);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Throw unless `sql` contains at most one statement. A single trailing
|
|
168
|
+
* semicolon (and any number of repeated trailing semicolons / comments) is
|
|
169
|
+
* accepted; a semicolon followed by real content is rejected.
|
|
170
|
+
*/
|
|
171
|
+
function assertSingleStatement(sql, label = "SQL") {
|
|
172
|
+
if (stripTrailingTerminator(sql).trim().length === 0) throw new Error(`${label}: SQL 不能为空`);
|
|
173
|
+
const semicolons = [];
|
|
174
|
+
scanTopLevelSemicolons(sql, (index) => {
|
|
175
|
+
semicolons.push(index);
|
|
176
|
+
});
|
|
177
|
+
const offending = semicolons.find((index) => hasContentAfter(sql, index + 1));
|
|
178
|
+
if (offending === void 0) return;
|
|
179
|
+
throw new Error(`${label}: 一次只允许执行一条 SQL 语句(第 ${offending + 1} 个字符后的分号不是末尾分号)。多条语句请拆成多次调用;客户端进程独立、自动提交,不支持在多次调用间保持事务。`);
|
|
180
|
+
}
|
|
181
|
+
/** Whether `keyword` appears at top level as a whole word in `sql`. */
|
|
182
|
+
function hasTopLevelKeyword(sql, keyword) {
|
|
183
|
+
const needle = keyword.toLowerCase();
|
|
184
|
+
let depth = 0;
|
|
185
|
+
let index = 0;
|
|
186
|
+
while (index < sql.length) {
|
|
187
|
+
const char = sql[index];
|
|
188
|
+
if (isWhitespace(char)) {
|
|
189
|
+
index += 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (sql.startsWith("--", index)) {
|
|
193
|
+
index = skipLineComment(sql, index + 2);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (sql.startsWith("/*", index)) {
|
|
197
|
+
index = skipBlockComment(sql, index);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
201
|
+
index = skipQuoted(sql, index);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (char === "$") {
|
|
205
|
+
const dollarEnd = skipDollarQuoted(sql, index);
|
|
206
|
+
if (dollarEnd !== -1) {
|
|
207
|
+
index = dollarEnd;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const oracleEnd = skipOracleQuoted(sql, index);
|
|
212
|
+
if (oracleEnd !== -1) {
|
|
213
|
+
index = oracleEnd;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (char === "(") {
|
|
217
|
+
depth += 1;
|
|
218
|
+
index += 1;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (char === ")") {
|
|
222
|
+
depth = Math.max(0, depth - 1);
|
|
223
|
+
index += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
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;
|
|
227
|
+
index += 1;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
function trailingLineCommentStart(sql, end) {
|
|
232
|
+
let index = sql.lastIndexOf("\n", end - 1) + 1;
|
|
233
|
+
while (index < end) {
|
|
234
|
+
const char = sql[index];
|
|
235
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
236
|
+
index = skipQuoted(sql, index);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (sql.startsWith("--", index)) {
|
|
240
|
+
const tail = sql.slice(index + 2, end);
|
|
241
|
+
return tail.length === 0 || isWhitespace(tail[0]) ? index : -1;
|
|
242
|
+
}
|
|
243
|
+
index += 1;
|
|
244
|
+
}
|
|
245
|
+
return -1;
|
|
246
|
+
}
|
|
247
|
+
function blockCommentEndingAt(sql, end) {
|
|
248
|
+
let candidate = -1;
|
|
249
|
+
let index = 0;
|
|
250
|
+
while (index < end) {
|
|
251
|
+
const char = sql[index];
|
|
252
|
+
if (isWhitespace(char)) {
|
|
253
|
+
index += 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
257
|
+
index = skipQuoted(sql, index);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (sql.startsWith("--", index)) {
|
|
261
|
+
index = skipLineComment(sql, index + 2);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (sql.startsWith("/*", index)) {
|
|
265
|
+
const start = index;
|
|
266
|
+
let commentDepth = 1;
|
|
267
|
+
index += 2;
|
|
268
|
+
while (index < end && commentDepth > 0) {
|
|
269
|
+
if (sql.startsWith("/*", index)) {
|
|
270
|
+
commentDepth += 1;
|
|
271
|
+
index += 2;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (sql.startsWith("*/", index)) {
|
|
275
|
+
commentDepth -= 1;
|
|
276
|
+
index += 2;
|
|
277
|
+
if (commentDepth === 0) {
|
|
278
|
+
if (index === end) candidate = start;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
index += 1;
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
index += 1;
|
|
288
|
+
}
|
|
289
|
+
return candidate;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Strip trailing whitespace, statement terminators and trailing comments so a
|
|
293
|
+
* limit clause can be appended to the actual statement text. Only comments
|
|
294
|
+
* that occupy the whole tail are removed; the preceding statement is kept.
|
|
295
|
+
*/
|
|
296
|
+
function stripTrailingTerminator(sql) {
|
|
297
|
+
let end = sql.length;
|
|
298
|
+
for (;;) {
|
|
299
|
+
while (end > 0 && isWhitespace(sql[end - 1])) end -= 1;
|
|
300
|
+
if (end > 0 && sql[end - 1] === ";") {
|
|
301
|
+
end -= 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (end >= 2 && sql.slice(end - 2, end) === "*/") {
|
|
305
|
+
const start = blockCommentEndingAt(sql, end);
|
|
306
|
+
if (start !== -1) {
|
|
307
|
+
end = start;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const lineComment = trailingLineCommentStart(sql, end);
|
|
312
|
+
if (lineComment !== -1) {
|
|
313
|
+
end = lineComment;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
return sql.slice(0, end);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
//#endregion
|
|
2
320
|
//#region src/clients.ts
|
|
3
321
|
/**
|
|
4
322
|
* Whitespace / comment stripping for {@link classifyStatement}: remove
|
|
@@ -119,7 +437,8 @@ function skipParens(sql, start) {
|
|
|
119
437
|
/**
|
|
120
438
|
* Classify a SQL text as a read or write statement by its FIRST effective
|
|
121
439
|
* token (a conservative read whitelist, not a parser). `with` is read only
|
|
122
|
-
* when its body's first token is `select`. `pragma` is read
|
|
440
|
+
* when its body's first token is `select`. SQLite `pragma` is read in its
|
|
441
|
+
* query form and write when a value is assigned.
|
|
123
442
|
*/
|
|
124
443
|
function classifyStatement(sql, type) {
|
|
125
444
|
const rest = stripLeadingComments(sql);
|
|
@@ -131,12 +450,116 @@ function classifyStatement(sql, type) {
|
|
|
131
450
|
case "describe":
|
|
132
451
|
case "desc":
|
|
133
452
|
case "explain": return "read";
|
|
134
|
-
case "pragma":
|
|
453
|
+
case "pragma": {
|
|
454
|
+
if (type !== "sqlite") return "write";
|
|
455
|
+
const afterPragma = rest.replace(/^pragma\b/i, "").trimStart();
|
|
456
|
+
return /^(?:[A-Za-z_][A-Za-z0-9_$]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_$]*)?|"[^"]+"|`[^`]+`)\s*=/.test(afterPragma) ? "write" : "read";
|
|
457
|
+
}
|
|
135
458
|
case "with": return stripWithBody(rest).match(/^[A-Za-z_]+/)?.[0]?.toLowerCase() === "select" ? "read" : "write";
|
|
136
459
|
default: return "write";
|
|
137
460
|
}
|
|
138
461
|
}
|
|
139
462
|
/**
|
|
463
|
+
* Enforce the configured `maxRows` on a read query instead of relying on the
|
|
464
|
+
* prompt. SELECT/CTE-read statements get a real top-level LIMIT (Oracle uses
|
|
465
|
+
* a ROWNUM wrapper because it has no LIMIT); SHOW/DESCRIBE/EXPLAIN/PRAGMA are
|
|
466
|
+
* left untouched here and are capped while parsing structured output.
|
|
467
|
+
*
|
|
468
|
+
* An existing numeric top-level LIMIT is rewritten when it is larger than
|
|
469
|
+
* `maxRows`; a smaller existing LIMIT is preserved, and a non-numeric or
|
|
470
|
+
* unparseable LIMIT is left for the client (structured tools still truncate).
|
|
471
|
+
*/
|
|
472
|
+
function enforceReadRowLimit(sql, type, maxRows) {
|
|
473
|
+
if (classifyStatement(sql, type) !== "read") return sql;
|
|
474
|
+
const first = stripLeadingComments(sql).match(/^[A-Za-z_]+/)?.[0]?.toLowerCase();
|
|
475
|
+
if (first !== "select" && first !== "with") return sql;
|
|
476
|
+
const hadTrailingSemicolon = /;\s*$/.test(sql);
|
|
477
|
+
if (!hasTopLevelKeyword(sql, "limit") && type !== "oracle") return `${stripTrailingTerminator(sql)} LIMIT ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
|
|
478
|
+
if (type === "oracle") return `SELECT * FROM (${stripTrailingTerminator(sql)}) dsh_limit WHERE ROWNUM <= ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
|
|
479
|
+
if (!hasTopLevelKeyword(sql, "limit")) return sql;
|
|
480
|
+
return rewriteTopLevelLimit(sql, maxRows);
|
|
481
|
+
}
|
|
482
|
+
/** Rewrite the first top-level `LIMIT n` / `LIMIT n, m` with a capped row count. */
|
|
483
|
+
function rewriteTopLevelLimit(sql, maxRows) {
|
|
484
|
+
let depth = 0;
|
|
485
|
+
let index = 0;
|
|
486
|
+
while (index < sql.length) {
|
|
487
|
+
const char = sql[index];
|
|
488
|
+
if (/\s/.test(char)) {
|
|
489
|
+
index += 1;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (sql.startsWith("--", index)) {
|
|
493
|
+
const newline = sql.indexOf("\n", index + 2);
|
|
494
|
+
index = newline === -1 ? sql.length : newline + 1;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (sql.startsWith("/*", index)) {
|
|
498
|
+
let depthComment = 1;
|
|
499
|
+
index += 2;
|
|
500
|
+
while (index < sql.length && depthComment > 0) {
|
|
501
|
+
if (sql.startsWith("/*", index)) {
|
|
502
|
+
depthComment += 1;
|
|
503
|
+
index += 2;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (sql.startsWith("*/", index)) {
|
|
507
|
+
depthComment -= 1;
|
|
508
|
+
index += 2;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
index += 1;
|
|
512
|
+
}
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (char === "'" || char === "\"" || char === "`") {
|
|
516
|
+
const quote = char;
|
|
517
|
+
index += 1;
|
|
518
|
+
while (index < sql.length) {
|
|
519
|
+
if (sql[index] === "\\" && index + 1 < sql.length && quote !== "`") {
|
|
520
|
+
index += 2;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (sql[index] === quote) {
|
|
524
|
+
if (sql[index + 1] === quote) {
|
|
525
|
+
index += 2;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
index += 1;
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
index += 1;
|
|
532
|
+
}
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (char === "(") {
|
|
536
|
+
depth += 1;
|
|
537
|
+
index += 1;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (char === ")") {
|
|
541
|
+
depth = Math.max(0, depth - 1);
|
|
542
|
+
index += 1;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
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]))) {
|
|
546
|
+
const match = sql.slice(index).match(/^LIMIT\s+(ALL|\d+)(\s*,\s*\d+)?/i);
|
|
547
|
+
if (match === null) return sql;
|
|
548
|
+
const firstValue = match[1];
|
|
549
|
+
const hasOffsetPart = match[2] !== void 0;
|
|
550
|
+
let replacement = "";
|
|
551
|
+
if (hasOffsetPart) {
|
|
552
|
+
const rowCount = Number(match[2].match(/\d+/)[0]);
|
|
553
|
+
replacement = `LIMIT ${firstValue === "ALL" ? "0" : firstValue}, ${Math.min(rowCount, maxRows)}`;
|
|
554
|
+
} else if (/^\d+$/.test(firstValue)) replacement = `LIMIT ${Math.min(Number(firstValue), maxRows)}`;
|
|
555
|
+
else replacement = `LIMIT ${maxRows}`;
|
|
556
|
+
return sql.slice(0, index) + replacement + sql.slice(index + match[0].length);
|
|
557
|
+
}
|
|
558
|
+
index += 1;
|
|
559
|
+
}
|
|
560
|
+
return sql;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
140
563
|
* Validate and quote one schema/table identifier for a safe metadata query.
|
|
141
564
|
* Identifiers are restricted to `[A-Za-z0-9_$]+` and then wrapped per type:
|
|
142
565
|
* backticks (mysql/hive/impala) or double quotes (postgres/oracle/sqlite),
|
|
@@ -191,6 +614,15 @@ const INTROSPECT_ARGS = {
|
|
|
191
614
|
hive: ["--silent=true", "--outputformat=tsv2"],
|
|
192
615
|
impala: ["-B"]
|
|
193
616
|
};
|
|
617
|
+
/** Structured `sql-query` flag arguments: header + one row per line. */
|
|
618
|
+
const STRUCTURED_QUERY_ARGS = {
|
|
619
|
+
mysql: ["--batch", "--raw"],
|
|
620
|
+
postgres: ["-A"],
|
|
621
|
+
sqlite: ["-header", "-csv"],
|
|
622
|
+
oracle: ["-S", "/nolog"],
|
|
623
|
+
hive: ["--silent=true", "--outputformat=tsv2"],
|
|
624
|
+
impala: ["-B", "--print_header"]
|
|
625
|
+
};
|
|
194
626
|
/** Default ports when the connection does not carry one. */
|
|
195
627
|
const DEFAULT_PORTS = {
|
|
196
628
|
mysql: 3306,
|
|
@@ -284,6 +716,23 @@ function stdinPrefix(type, connection) {
|
|
|
284
716
|
case "impala": return "";
|
|
285
717
|
}
|
|
286
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* Oracle structured-query prefix: same connect block as {@link stdinPrefix},
|
|
721
|
+
* but with HEADING ON and UNDERLINE OFF so `sql-query` can read the column
|
|
722
|
+
* names from the first output line.
|
|
723
|
+
*/
|
|
724
|
+
function structuredStdinPrefix(type, connection) {
|
|
725
|
+
if (type !== "oracle") return stdinPrefix(type, connection);
|
|
726
|
+
return `${[
|
|
727
|
+
"SET PAGESIZE 0",
|
|
728
|
+
"SET FEEDBACK OFF",
|
|
729
|
+
"SET HEADING ON",
|
|
730
|
+
"SET UNDERLINE OFF",
|
|
731
|
+
"SET COLSEP '|'",
|
|
732
|
+
"SET TRIMSPOOL ON",
|
|
733
|
+
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}` : ""
|
|
734
|
+
].filter((line) => line !== "").join("\n")}\n`;
|
|
735
|
+
}
|
|
287
736
|
/** Apply one deployment override's extra args in front of the built-in flags. */
|
|
288
737
|
function withOverrides(flags, override) {
|
|
289
738
|
if (override === void 0 || override.args === void 0) return flags;
|
|
@@ -312,6 +761,19 @@ function buildIntrospectTemplate(type, connection, override) {
|
|
|
312
761
|
};
|
|
313
762
|
}
|
|
314
763
|
/**
|
|
764
|
+
* Build one client invocation for the structured `sql-query` tool: every
|
|
765
|
+
* supported client prints a header row followed by one row per line (mysql
|
|
766
|
+
* tab, postgres pipe, sqlite csv, oracle pipe, hive/impala tsv).
|
|
767
|
+
*/
|
|
768
|
+
function buildStructuredQueryTemplate(type, connection, override) {
|
|
769
|
+
return {
|
|
770
|
+
command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
|
|
771
|
+
args: [...withOverrides(STRUCTURED_QUERY_ARGS[type], override), ...connectionArgs(type, connection)],
|
|
772
|
+
env: credentialEnv(type, connection),
|
|
773
|
+
stdinPrefix: structuredStdinPrefix(type, connection)
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
315
777
|
* The table-listing SQL per type, run at /connect time to verify
|
|
316
778
|
* connectivity: the connected database's own tables (mysql uses the
|
|
317
779
|
* connection's database as the schema; postgres lists `public`; oracle lists
|
|
@@ -329,7 +791,7 @@ function tableListingSql(type, connection) {
|
|
|
329
791
|
}
|
|
330
792
|
/**
|
|
331
793
|
* Metadata query per kind × type. `schema`/`table` are identifier whitelist
|
|
332
|
-
* validated by the caller (`[A-Za-z0-9_
|
|
794
|
+
* validated by the caller (`[A-Za-z0-9_$]`) before they reach here.
|
|
333
795
|
*/
|
|
334
796
|
function metadataQuery(kind, type, schema, table) {
|
|
335
797
|
switch (kind) {
|
|
@@ -429,7 +891,7 @@ function parseColumns(type, stdout) {
|
|
|
429
891
|
//#region src/defaults.ts
|
|
430
892
|
/**
|
|
431
893
|
* Package-wide defaults shared by the server half (`src/index.ts`) and the
|
|
432
|
-
*
|
|
894
|
+
* database tool half (`src/tool.ts`). Loader schemas carry these as their
|
|
433
895
|
* defaults so a deployment may override every one of them in cordis.yml.
|
|
434
896
|
* @module @yejiming/dsh-data-agent/defaults
|
|
435
897
|
*/
|
|
@@ -437,13 +899,13 @@ function parseColumns(type, stdout) {
|
|
|
437
899
|
const DEFAULT_PRESET_ID = "data-agent";
|
|
438
900
|
/** End-to-end deadline for one `/connect` connectivity check, milliseconds. */
|
|
439
901
|
const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
440
|
-
/** End-to-end deadline for one
|
|
902
|
+
/** End-to-end deadline for one database-tool query, milliseconds. */
|
|
441
903
|
const DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
442
|
-
/** In-memory cap on
|
|
904
|
+
/** In-memory cap on database-tool captured output (stdout and stderr each). */
|
|
443
905
|
const DEFAULT_MAX_RESULT_CHARS = 2e4;
|
|
444
906
|
/** Cap on one /query SQL text length (abuse guard; the wire body stays small). */
|
|
445
907
|
const DEFAULT_MAX_QUERY_CHARS = 65536;
|
|
446
908
|
/** Grace period for the subprocess terminate escalation. */
|
|
447
909
|
const DEFAULT_GRACE_MS = 5e3;
|
|
448
910
|
//#endregion
|
|
449
|
-
export { DEFAULT_PRESET_ID as a, buildIntrospectTemplate as c,
|
|
911
|
+
export { sanitizeIdentifier as _, DEFAULT_PRESET_ID as a, buildIntrospectTemplate as c, clientsSchema as d, enforceReadRowLimit as f, parseTableListing as g, parseListing as h, DEFAULT_MAX_RESULT_CHARS as i, buildStructuredQueryTemplate as l, parseColumns as m, DEFAULT_GRACE_MS as n, DEFAULT_QUERY_TIMEOUT_MS as o, metadataQuery as p, DEFAULT_MAX_QUERY_CHARS as r, buildClientTemplate as s, DEFAULT_CONNECT_TIMEOUT_MS as t, classifyStatement as u, tableListingSql as v, assertSingleStatement as y };
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as DEFAULT_PRESET_ID, i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, t as DEFAULT_CONNECT_TIMEOUT_MS
|
|
1
|
+
import { a as DEFAULT_PRESET_ID, d as clientsSchema, i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, t as DEFAULT_CONNECT_TIMEOUT_MS } from "./defaults-Bac6QvNt.js";
|
|
2
2
|
import { access, cp, mkdir } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
@@ -53,9 +53,9 @@ function createConnectionStore() {
|
|
|
53
53
|
*
|
|
54
54
|
* The HTTP routes live in the separate `./routes` entry
|
|
55
55
|
* (`@yejiming/dsh-data-agent/routes`, cordis row `data-agent-routes`) so
|
|
56
|
-
* this row keeps working in headless profiles without a webserver; the
|
|
57
|
-
*
|
|
58
|
-
* data-agent preset.
|
|
56
|
+
* this row keeps working in headless profiles without a webserver; the
|
|
57
|
+
* database tools themselves live in the `./tool` entry and are mounted only
|
|
58
|
+
* by the data-agent preset.
|
|
59
59
|
* @module @yejiming/dsh-data-agent
|
|
60
60
|
*/
|
|
61
61
|
/** Cordis plugin name (diagnostics only). */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as buildIntrospectTemplate, s as buildClientTemplate } from "./defaults-
|
|
1
|
+
import { c as buildIntrospectTemplate, l as buildStructuredQueryTemplate, s as buildClientTemplate } from "./defaults-Bac6QvNt.js";
|
|
2
2
|
//#region src/query.ts
|
|
3
3
|
/** Read one collected stream from offset 0. */
|
|
4
4
|
function readCaptured(reader) {
|
|
@@ -33,7 +33,7 @@ function readCaptured(reader) {
|
|
|
33
33
|
* @returns the captured outcome.
|
|
34
34
|
*/
|
|
35
35
|
async function runClientQuery(ctx, connection, sql, options, externalSignal, introspect = false) {
|
|
36
|
-
const template = introspect ? buildIntrospectTemplate(connection.type, connection, options.clients[connection.type]) : buildClientTemplate(connection.type, connection, options.clients[connection.type]);
|
|
36
|
+
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]);
|
|
37
37
|
const controller = new AbortController();
|
|
38
38
|
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`查询超过 ${options.timeoutMs}ms 未完成,已终止客户端进程`)), options.timeoutMs);
|
|
39
39
|
const onExternalAbort = () => {
|
package/lib/routes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as runClientQuery } from "./query-
|
|
1
|
+
import { _ as sanitizeIdentifier, g as parseTableListing, h as parseListing, i as DEFAULT_MAX_RESULT_CHARS, m as parseColumns, o as DEFAULT_QUERY_TIMEOUT_MS, p as metadataQuery, r as DEFAULT_MAX_QUERY_CHARS, t as DEFAULT_CONNECT_TIMEOUT_MS, u as classifyStatement, v as tableListingSql, y as assertSingleStatement } from "./defaults-Bac6QvNt.js";
|
|
2
|
+
import { t as runClientQuery } from "./query-CmhTFklw.js";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import z from "schemastery";
|
|
5
5
|
//#region src/routes.ts
|
|
@@ -231,6 +231,7 @@ function apply(ctx, config) {
|
|
|
231
231
|
const sql = body.sql;
|
|
232
232
|
if (typeof sql !== "string" || sql.trim().length === 0) throw new Error("sql 必须是非空字符串");
|
|
233
233
|
if (sql.length > config.maxQueryChars) throw new Error(`sql 超过长度上限(${config.maxQueryChars} 字符)`);
|
|
234
|
+
assertSingleStatement(sql, "/query");
|
|
234
235
|
const connection = requireConnection(sessionId);
|
|
235
236
|
if ((connection.readonly ?? config.readonly) && classifyStatement(sql, connection.type) === "write") throw new Error("当前连接为只读模式,拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/PRAGMA 等)");
|
|
236
237
|
writeJson(200, {
|