@saptools/cf-hana 0.5.0 → 0.5.2
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/CHANGELOG.md +64 -0
- package/README.md +51 -3
- package/dist/cli.js +1763 -1507
- package/dist/cli.js.map +1 -1
- package/dist/index.js +380 -124
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
|
+
// src/backup.ts
|
|
7
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
|
|
6
11
|
// src/config.ts
|
|
7
12
|
var CLI_NAME = "cf-hana";
|
|
8
|
-
var CLI_VERSION = "0.5.
|
|
13
|
+
var CLI_VERSION = "0.5.2";
|
|
9
14
|
var ENV_PREFIX = "CF_HANA";
|
|
10
15
|
var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
|
|
11
16
|
var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
|
|
@@ -140,1404 +145,1041 @@ function errorMessage(error) {
|
|
|
140
145
|
return String(error);
|
|
141
146
|
}
|
|
142
147
|
|
|
143
|
-
// src/
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
import { join } from "path";
|
|
148
|
-
var METADATA_CACHE_TTL_MS = 30 * 6e4;
|
|
149
|
-
function metadataCacheRoot(saptoolsRoot) {
|
|
150
|
-
return join(saptoolsRoot ?? join(homedir(), ".saptools"), "cf-hana", "metadata");
|
|
151
|
-
}
|
|
152
|
-
function toMetadataCacheScope(info) {
|
|
153
|
-
return {
|
|
154
|
-
selector: info.selector,
|
|
155
|
-
appName: info.appName,
|
|
156
|
-
host: info.host,
|
|
157
|
-
schema: info.schema,
|
|
158
|
-
role: info.role,
|
|
159
|
-
driver: info.driver,
|
|
160
|
-
...info.bindingName === void 0 ? {} : { bindingName: info.bindingName },
|
|
161
|
-
...info.bindingIndex === void 0 ? {} : { bindingIndex: info.bindingIndex }
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
function metadataCacheKey(scope) {
|
|
165
|
-
return createHash("sha256").update(JSON.stringify(scope)).digest("hex");
|
|
166
|
-
}
|
|
167
|
-
function metadataCachePath(scope, saptoolsRoot) {
|
|
168
|
-
return join(metadataCacheRoot(saptoolsRoot), `${metadataCacheKey(scope)}.json`);
|
|
169
|
-
}
|
|
170
|
-
function isRecord(value) {
|
|
171
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
172
|
-
}
|
|
173
|
-
function isCatalogObject(value) {
|
|
174
|
-
return isRecord(value) && typeof value["schema"] === "string" && typeof value["name"] === "string" && (value["type"] === "TABLE" || value["type"] === "VIEW");
|
|
175
|
-
}
|
|
176
|
-
function hasRequiredScopeFields(value) {
|
|
177
|
-
return typeof value["selector"] === "string" && typeof value["appName"] === "string" && typeof value["host"] === "string" && typeof value["schema"] === "string" && typeof value["role"] === "string" && typeof value["driver"] === "string";
|
|
178
|
-
}
|
|
179
|
-
function hasOptionalScopeFields(value) {
|
|
180
|
-
return (value["bindingName"] === void 0 || typeof value["bindingName"] === "string") && (value["bindingIndex"] === void 0 || typeof value["bindingIndex"] === "number");
|
|
181
|
-
}
|
|
182
|
-
function isScope(value) {
|
|
183
|
-
return isRecord(value) && hasRequiredScopeFields(value) && hasOptionalScopeFields(value);
|
|
148
|
+
// src/lob.ts
|
|
149
|
+
var TEXT_LOB_TYPES = /* @__PURE__ */ new Set(["CLOB", "NCLOB"]);
|
|
150
|
+
function normalizeTypeName(typeName2) {
|
|
151
|
+
return typeName2?.trim().toUpperCase() ?? "";
|
|
184
152
|
}
|
|
185
|
-
function
|
|
186
|
-
return
|
|
153
|
+
function isTextLobType(typeName2) {
|
|
154
|
+
return TEXT_LOB_TYPES.has(normalizeTypeName(typeName2));
|
|
187
155
|
}
|
|
188
|
-
|
|
189
|
-
|
|
156
|
+
|
|
157
|
+
// src/result-preview.ts
|
|
158
|
+
function normalizePreviewChar(value) {
|
|
159
|
+
return value === "\r" || value === "\n" || value === " " ? " " : value;
|
|
190
160
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
return void 0;
|
|
198
|
-
}
|
|
199
|
-
const createdAt = Date.parse(parsed.createdAt);
|
|
200
|
-
const now = options.now?.() ?? /* @__PURE__ */ new Date();
|
|
201
|
-
const ageMs = now.getTime() - createdAt;
|
|
202
|
-
if (!Number.isFinite(createdAt) || ageMs < 0 || ageMs >= METADATA_CACHE_TTL_MS) {
|
|
203
|
-
return void 0;
|
|
161
|
+
function previewText(value, limit) {
|
|
162
|
+
let originalLength = 0;
|
|
163
|
+
let text = "";
|
|
164
|
+
for (const char of value) {
|
|
165
|
+
if (originalLength < limit) {
|
|
166
|
+
text += normalizePreviewChar(char);
|
|
204
167
|
}
|
|
205
|
-
|
|
206
|
-
} catch {
|
|
207
|
-
return void 0;
|
|
168
|
+
originalLength += 1;
|
|
208
169
|
}
|
|
170
|
+
return {
|
|
171
|
+
text,
|
|
172
|
+
truncated: originalLength > limit,
|
|
173
|
+
originalLength,
|
|
174
|
+
unit: "chars"
|
|
175
|
+
};
|
|
209
176
|
}
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
177
|
+
function previewBuffer(value, limit) {
|
|
178
|
+
const fullLength = 2 + value.length * 2;
|
|
179
|
+
const visibleBytes = Math.max(0, Math.floor((limit - 2) / 2));
|
|
180
|
+
const hex = value.subarray(0, visibleBytes).toString("hex");
|
|
181
|
+
return {
|
|
182
|
+
text: `0x${hex}`.slice(0, limit),
|
|
183
|
+
truncated: fullLength > limit,
|
|
184
|
+
originalLength: value.length,
|
|
185
|
+
unit: "bytes"
|
|
219
186
|
};
|
|
220
|
-
await mkdir(root, { recursive: true, mode: 448 });
|
|
221
|
-
await rm(tempPath, { force: true });
|
|
222
|
-
await writeFile(tempPath, `${JSON.stringify(stored)}
|
|
223
|
-
`, { encoding: "utf8", mode: 384 });
|
|
224
|
-
await rename(tempPath, path);
|
|
225
187
|
}
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
if (cached !== void 0) {
|
|
230
|
-
return cached;
|
|
231
|
-
}
|
|
188
|
+
function scalarText(value) {
|
|
189
|
+
if (value instanceof Date) {
|
|
190
|
+
return value.toISOString();
|
|
232
191
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
await writeMetadataCache(scope, objects, options);
|
|
236
|
-
} catch {
|
|
192
|
+
if (typeof value === "boolean") {
|
|
193
|
+
return value ? "true" : "false";
|
|
237
194
|
}
|
|
238
|
-
return
|
|
195
|
+
return value.toString();
|
|
239
196
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
/invalid\s+(?:table|view|object)\s+name/i,
|
|
244
|
-
/(?:table|view|object)\s+[^\n]*does\s+not\s+exist/i,
|
|
245
|
-
/could\s+not\s+find\s+(?:table|view|object)/i
|
|
246
|
-
];
|
|
247
|
-
var REF_KEYWORDS = /* @__PURE__ */ new Set(["FROM", "JOIN", "UPDATE", "INTO", "TABLE"]);
|
|
248
|
-
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
249
|
-
"AS",
|
|
250
|
-
"ON",
|
|
251
|
-
"WHERE",
|
|
252
|
-
"SET",
|
|
253
|
-
"VALUES",
|
|
254
|
-
"USING",
|
|
255
|
-
"WHEN",
|
|
256
|
-
"INNER",
|
|
257
|
-
"LEFT",
|
|
258
|
-
"RIGHT",
|
|
259
|
-
"FULL",
|
|
260
|
-
"OUTER",
|
|
261
|
-
"CROSS",
|
|
262
|
-
"JOIN"
|
|
263
|
-
]);
|
|
264
|
-
var CTE_FOLLOWERS = /* @__PURE__ */ new Set(["AS"]);
|
|
265
|
-
function isInvalidCatalogObjectError(error) {
|
|
266
|
-
if (!(error instanceof QueryError)) {
|
|
267
|
-
return false;
|
|
197
|
+
function previewCell(value, limit, typeName2) {
|
|
198
|
+
if (value === null) {
|
|
199
|
+
return { text: "", truncated: false, originalLength: 0, unit: "chars" };
|
|
268
200
|
}
|
|
269
|
-
if (
|
|
270
|
-
return
|
|
201
|
+
if (Buffer.isBuffer(value)) {
|
|
202
|
+
return isTextLobType(typeName2) ? previewText(value.toString("utf8"), limit) : previewBuffer(value, limit);
|
|
271
203
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
function skipLineComment(sql, index) {
|
|
275
|
-
let cursor = index + 2;
|
|
276
|
-
while (cursor < sql.length && sql[cursor] !== "\n") {
|
|
277
|
-
cursor += 1;
|
|
204
|
+
if (typeof value === "string") {
|
|
205
|
+
return previewText(value, limit);
|
|
278
206
|
}
|
|
279
|
-
return
|
|
207
|
+
return previewText(scalarText(value), limit);
|
|
280
208
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
209
|
+
|
|
210
|
+
// src/format.ts
|
|
211
|
+
function cellText(value, nullText, column) {
|
|
212
|
+
if (value === null) {
|
|
213
|
+
return nullText;
|
|
285
214
|
}
|
|
286
|
-
|
|
215
|
+
if (value instanceof Date) {
|
|
216
|
+
return value.toISOString();
|
|
217
|
+
}
|
|
218
|
+
if (Buffer.isBuffer(value)) {
|
|
219
|
+
return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
|
|
220
|
+
}
|
|
221
|
+
if (typeof value === "boolean") {
|
|
222
|
+
return value ? "true" : "false";
|
|
223
|
+
}
|
|
224
|
+
return typeof value === "number" ? value.toString() : value;
|
|
287
225
|
}
|
|
288
|
-
function
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (sql[cursor] === "'" && sql[cursor + 1] === "'") {
|
|
292
|
-
cursor += 2;
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
if (sql[cursor] === "'") {
|
|
296
|
-
return cursor + 1;
|
|
297
|
-
}
|
|
298
|
-
cursor += 1;
|
|
226
|
+
function serializeCell(value, column) {
|
|
227
|
+
if (value === null) {
|
|
228
|
+
return null;
|
|
299
229
|
}
|
|
300
|
-
|
|
230
|
+
if (value instanceof Date) {
|
|
231
|
+
return value.toISOString();
|
|
232
|
+
}
|
|
233
|
+
if (Buffer.isBuffer(value)) {
|
|
234
|
+
return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
301
237
|
}
|
|
302
|
-
function
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
while (cursor < sql.length) {
|
|
306
|
-
if (sql[cursor] === '"' && sql[cursor + 1] === '"') {
|
|
307
|
-
text += '"';
|
|
308
|
-
cursor += 2;
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
if (sql[cursor] === '"') {
|
|
312
|
-
return { text, next: cursor + 1 };
|
|
313
|
-
}
|
|
314
|
-
text += sql.charAt(cursor);
|
|
315
|
-
cursor += 1;
|
|
238
|
+
function csvEscape(text) {
|
|
239
|
+
if (/[",\r\n]/.test(text)) {
|
|
240
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
316
241
|
}
|
|
317
|
-
return
|
|
242
|
+
return text;
|
|
318
243
|
}
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
while (cursor < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(cursor))) {
|
|
323
|
-
text += sql.charAt(cursor);
|
|
324
|
-
cursor += 1;
|
|
244
|
+
function formatTable(result) {
|
|
245
|
+
if (result.columns.length === 0) {
|
|
246
|
+
return `(${String(result.rowCount)} row(s) affected)`;
|
|
325
247
|
}
|
|
326
|
-
|
|
248
|
+
const headers = result.columns.map((column) => column.name);
|
|
249
|
+
const rows = result.rows.map(
|
|
250
|
+
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL", column))
|
|
251
|
+
);
|
|
252
|
+
const widths = headers.map((header, index) => {
|
|
253
|
+
const widest = rows.reduce(
|
|
254
|
+
(max, cells) => Math.max(max, (cells[index] ?? "").length),
|
|
255
|
+
header.length
|
|
256
|
+
);
|
|
257
|
+
return widest;
|
|
258
|
+
});
|
|
259
|
+
const renderRow = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" | ");
|
|
260
|
+
const separator = widths.map((width) => "-".repeat(width)).join("-+-");
|
|
261
|
+
const body = rows.map((cells) => renderRow(cells));
|
|
262
|
+
return [renderRow(headers), separator, ...body].join("\n");
|
|
327
263
|
}
|
|
328
|
-
function
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
index += 1;
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
if (char === "-" && sql[index + 1] === "-") {
|
|
338
|
-
index = skipLineComment(sql, index);
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
341
|
-
if (char === "/" && sql[index + 1] === "*") {
|
|
342
|
-
index = skipBlockComment(sql, index);
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
if (char === "'") {
|
|
346
|
-
index = skipStringLiteral(sql, index);
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
if (char === '"') {
|
|
350
|
-
const quoted = readQuotedIdentifier(sql, index);
|
|
351
|
-
tokens.push({ kind: "quoted", text: quoted.text });
|
|
352
|
-
index = quoted.next;
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
if (/[A-Za-z_#$]/.test(char)) {
|
|
356
|
-
const word = readBareWord(sql, index);
|
|
357
|
-
tokens.push({ kind: "word", text: word.text });
|
|
358
|
-
index = word.next;
|
|
359
|
-
continue;
|
|
264
|
+
function formatJson(result) {
|
|
265
|
+
const rows = result.rows.map((row) => {
|
|
266
|
+
const serialized = {};
|
|
267
|
+
for (const [key, value] of Object.entries(row)) {
|
|
268
|
+
const column = result.columns.find((item) => item.name === key);
|
|
269
|
+
serialized[key] = serializeCell(value, column);
|
|
360
270
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
271
|
+
return serialized;
|
|
272
|
+
});
|
|
273
|
+
return JSON.stringify(rows, null, 2);
|
|
274
|
+
}
|
|
275
|
+
function formatJsonCompact(result, preferredColumn) {
|
|
276
|
+
const singleColumn = result.columns.length === 1 ? result.columns[0]?.name : void 0;
|
|
277
|
+
const columnName = preferredColumn ?? singleColumn;
|
|
278
|
+
if (columnName === void 0) {
|
|
279
|
+
return formatJson(result);
|
|
280
|
+
}
|
|
281
|
+
const column = result.columns.find((item) => item.name === columnName);
|
|
282
|
+
const values = result.rows.map((row) => serializeCell(row[columnName] ?? null, column));
|
|
283
|
+
return JSON.stringify(values, null, 2);
|
|
284
|
+
}
|
|
285
|
+
function formatCsv(result) {
|
|
286
|
+
const headers = result.columns.map((column) => column.name);
|
|
287
|
+
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
288
|
+
for (const row of result.rows) {
|
|
289
|
+
lines.push(
|
|
290
|
+
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, "", column))).join(",")
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return lines.join("\r\n");
|
|
294
|
+
}
|
|
295
|
+
function formatCompactCsv(result, cellLimit) {
|
|
296
|
+
const headers = result.columns.map((column) => column.name);
|
|
297
|
+
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
298
|
+
let truncatedCells = 0;
|
|
299
|
+
for (const row of result.rows) {
|
|
300
|
+
const cells = result.columns.map((column) => {
|
|
301
|
+
const preview = previewCell(row[column.name] ?? null, cellLimit, column.typeName);
|
|
302
|
+
if (preview.truncated) {
|
|
303
|
+
truncatedCells += 1;
|
|
304
|
+
}
|
|
305
|
+
return csvEscape(preview.text);
|
|
306
|
+
});
|
|
307
|
+
lines.push(cells.join(","));
|
|
308
|
+
}
|
|
309
|
+
return { text: lines.join("\r\n"), truncatedCells };
|
|
310
|
+
}
|
|
311
|
+
function formatResult(result, format, compactColumn) {
|
|
312
|
+
switch (format) {
|
|
313
|
+
case "table":
|
|
314
|
+
return formatTable(result);
|
|
315
|
+
case "json":
|
|
316
|
+
return formatJson(result);
|
|
317
|
+
case "json-compact":
|
|
318
|
+
return formatJsonCompact(result, compactColumn);
|
|
319
|
+
case "csv":
|
|
320
|
+
return formatCsv(result);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/backup-sql-parser.ts
|
|
325
|
+
function isIdentifierChar(char) {
|
|
326
|
+
return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
|
|
327
|
+
}
|
|
328
|
+
function skipQuotedText(sql, index) {
|
|
329
|
+
const quote = sql[index];
|
|
330
|
+
let cursor = index + 1;
|
|
331
|
+
while (cursor < sql.length) {
|
|
332
|
+
if (sql[cursor] === quote) {
|
|
333
|
+
if (sql[cursor + 1] === quote) {
|
|
334
|
+
cursor += 2;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
return cursor + 1;
|
|
371
338
|
}
|
|
372
|
-
|
|
339
|
+
cursor += 1;
|
|
373
340
|
}
|
|
374
|
-
return
|
|
341
|
+
return cursor;
|
|
375
342
|
}
|
|
376
|
-
function
|
|
377
|
-
|
|
343
|
+
function skipLineComment(sql, index) {
|
|
344
|
+
let cursor = index + 2;
|
|
345
|
+
while (cursor < sql.length && sql[cursor] !== "\n") {
|
|
346
|
+
cursor += 1;
|
|
347
|
+
}
|
|
348
|
+
return cursor;
|
|
378
349
|
}
|
|
379
|
-
function
|
|
380
|
-
|
|
350
|
+
function skipBlockComment(sql, index) {
|
|
351
|
+
let cursor = index + 2;
|
|
352
|
+
while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
|
|
353
|
+
cursor += 1;
|
|
354
|
+
}
|
|
355
|
+
return Math.min(cursor + 2, sql.length);
|
|
381
356
|
}
|
|
382
|
-
function
|
|
383
|
-
const
|
|
384
|
-
if (
|
|
385
|
-
return
|
|
357
|
+
function skipNonCode(sql, index) {
|
|
358
|
+
const char = sql[index];
|
|
359
|
+
if (char === "'" || char === '"') {
|
|
360
|
+
return skipQuotedText(sql, index);
|
|
386
361
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
let name = first.text;
|
|
390
|
-
const maybeObject = tokens[next + 1];
|
|
391
|
-
if (tokens[next]?.kind === "dot" && isIdentifier(maybeObject)) {
|
|
392
|
-
schema = name;
|
|
393
|
-
name = maybeObject.text;
|
|
394
|
-
next += 2;
|
|
362
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
363
|
+
return skipLineComment(sql, index);
|
|
395
364
|
}
|
|
396
|
-
if (
|
|
397
|
-
return
|
|
365
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
366
|
+
return skipBlockComment(sql, index);
|
|
398
367
|
}
|
|
399
|
-
return
|
|
368
|
+
return void 0;
|
|
400
369
|
}
|
|
401
|
-
function
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
370
|
+
function skipTrivia(sql, start, end) {
|
|
371
|
+
let index = start;
|
|
372
|
+
while (index < end) {
|
|
373
|
+
if (/\s/.test(sql.charAt(index))) {
|
|
374
|
+
index += 1;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (sql[index] === "-" && sql[index + 1] === "-") {
|
|
378
|
+
index = skipLineComment(sql, index);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (sql[index] === "/" && sql[index + 1] === "*") {
|
|
382
|
+
index = skipBlockComment(sql, index);
|
|
383
|
+
continue;
|
|
407
384
|
}
|
|
385
|
+
break;
|
|
408
386
|
}
|
|
409
|
-
return
|
|
387
|
+
return index;
|
|
410
388
|
}
|
|
411
|
-
function
|
|
412
|
-
|
|
413
|
-
|
|
389
|
+
function readIdentifierPart(sql, start, end) {
|
|
390
|
+
const index = skipTrivia(sql, start, end);
|
|
391
|
+
if (sql[index] === '"') {
|
|
392
|
+
const next2 = skipQuotedText(sql, index);
|
|
393
|
+
return next2 <= end && sql[next2 - 1] === '"' ? { sql: sql.slice(index, next2), end: next2 } : void 0;
|
|
414
394
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (invalidName?.[1] !== void 0) {
|
|
418
|
-
return firstNameFromText(invalidName[1]);
|
|
395
|
+
if (!/[A-Za-z_#$]/.test(sql.charAt(index))) {
|
|
396
|
+
return void 0;
|
|
419
397
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
398
|
+
let next = index + 1;
|
|
399
|
+
while (next < end && /[A-Za-z0-9_#$]/.test(sql.charAt(next))) {
|
|
400
|
+
next += 1;
|
|
423
401
|
}
|
|
424
|
-
return
|
|
402
|
+
return { sql: sql.slice(index, next), end: next };
|
|
425
403
|
}
|
|
426
|
-
function
|
|
427
|
-
const
|
|
428
|
-
if (
|
|
429
|
-
return
|
|
404
|
+
function readQualifiedTarget(sql, start, end) {
|
|
405
|
+
const first = readIdentifierPart(sql, start, end);
|
|
406
|
+
if (first === void 0) {
|
|
407
|
+
return void 0;
|
|
430
408
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
409
|
+
const parts = [first.sql];
|
|
410
|
+
let cursor = first.end;
|
|
411
|
+
while (parts.length < 3) {
|
|
412
|
+
const dot = skipTrivia(sql, cursor, end);
|
|
413
|
+
if (sql[dot] !== ".") {
|
|
435
414
|
break;
|
|
436
415
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (token?.kind === "open") {
|
|
441
|
-
depth += 1;
|
|
442
|
-
} else if (token?.kind === "close" && depth > 0) {
|
|
443
|
-
depth -= 1;
|
|
416
|
+
const part = readIdentifierPart(sql, dot + 1, end);
|
|
417
|
+
if (part === void 0) {
|
|
418
|
+
return void 0;
|
|
444
419
|
}
|
|
420
|
+
parts.push(part.sql);
|
|
421
|
+
cursor = part.end;
|
|
445
422
|
}
|
|
446
|
-
return
|
|
423
|
+
return { sql: parts.join("."), reference: parts.at(-1) ?? first.sql, end: cursor };
|
|
447
424
|
}
|
|
448
|
-
function
|
|
449
|
-
const tokens =
|
|
450
|
-
|
|
451
|
-
let
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
if (
|
|
425
|
+
function topLevelTokens(sql) {
|
|
426
|
+
const tokens = [];
|
|
427
|
+
let index = 0;
|
|
428
|
+
let depth = 0;
|
|
429
|
+
while (index < sql.length) {
|
|
430
|
+
const skipped = skipNonCode(sql, index);
|
|
431
|
+
if (skipped !== void 0) {
|
|
432
|
+
index = skipped;
|
|
455
433
|
continue;
|
|
456
434
|
}
|
|
457
|
-
|
|
435
|
+
const char = sql.charAt(index);
|
|
436
|
+
if (char === "(") {
|
|
437
|
+
depth += 1;
|
|
438
|
+
index += 1;
|
|
458
439
|
continue;
|
|
459
440
|
}
|
|
460
|
-
if (
|
|
441
|
+
if (char === ")") {
|
|
442
|
+
depth = Math.max(0, depth - 1);
|
|
443
|
+
index += 1;
|
|
461
444
|
continue;
|
|
462
445
|
}
|
|
463
|
-
|
|
464
|
-
|
|
446
|
+
if (!/[A-Za-z_#$]/.test(char)) {
|
|
447
|
+
index += 1;
|
|
465
448
|
continue;
|
|
466
449
|
}
|
|
467
|
-
|
|
468
|
-
|
|
450
|
+
let end = index + 1;
|
|
451
|
+
while (end < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(end))) {
|
|
452
|
+
end += 1;
|
|
469
453
|
}
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
candidate = read.name;
|
|
474
|
-
let next = read.next;
|
|
475
|
-
if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
|
|
476
|
-
next += 1;
|
|
477
|
-
}
|
|
478
|
-
while (tokens[next]?.kind === "comma") {
|
|
479
|
-
const commaRead = readName(tokens, next + 1);
|
|
480
|
-
if (commaRead === void 0) {
|
|
481
|
-
break;
|
|
482
|
-
}
|
|
483
|
-
if (!(commaRead.name.schema === void 0 && ctes.has(commaRead.name.name.toUpperCase()))) {
|
|
484
|
-
candidate = commaRead.name;
|
|
485
|
-
}
|
|
486
|
-
next = commaRead.next;
|
|
487
|
-
if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
|
|
488
|
-
next += 1;
|
|
489
|
-
}
|
|
454
|
+
if (depth === 0) {
|
|
455
|
+
tokens.push({ keyword: sql.slice(index, end).toUpperCase(), start: index, end });
|
|
490
456
|
}
|
|
457
|
+
index = end;
|
|
491
458
|
}
|
|
492
|
-
return
|
|
459
|
+
return tokens;
|
|
493
460
|
}
|
|
494
|
-
function
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
return value.slice(0, -1);
|
|
461
|
+
function findToken(tokens, keyword, start = 0) {
|
|
462
|
+
for (let index = start; index < tokens.length; index += 1) {
|
|
463
|
+
if (tokens[index]?.keyword === keyword) {
|
|
464
|
+
return index;
|
|
465
|
+
}
|
|
500
466
|
}
|
|
501
|
-
return
|
|
467
|
+
return void 0;
|
|
502
468
|
}
|
|
503
|
-
function
|
|
504
|
-
|
|
469
|
+
function keywordMatches(sql, index, keyword) {
|
|
470
|
+
const end = index + keyword.length;
|
|
471
|
+
return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
|
|
505
472
|
}
|
|
506
|
-
function
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
(previous[rightIndex] ?? 0) + 1,
|
|
515
|
-
(previous[rightIndex - 1] ?? 0) + 1,
|
|
516
|
-
last + (a.charAt(leftIndex - 1) === b.charAt(rightIndex - 1) ? 0 : 1)
|
|
517
|
-
);
|
|
518
|
-
last = old;
|
|
473
|
+
function findTopLevelKeyword(sql, keyword, startIndex = 0) {
|
|
474
|
+
let index = startIndex;
|
|
475
|
+
let depth = 0;
|
|
476
|
+
while (index < sql.length) {
|
|
477
|
+
const skipped = skipNonCode(sql, index);
|
|
478
|
+
if (skipped !== void 0) {
|
|
479
|
+
index = skipped;
|
|
480
|
+
continue;
|
|
519
481
|
}
|
|
482
|
+
const char = sql[index];
|
|
483
|
+
if (char === "(") {
|
|
484
|
+
depth += 1;
|
|
485
|
+
} else if (char === ")" && depth > 0) {
|
|
486
|
+
depth -= 1;
|
|
487
|
+
} else if (depth === 0 && keywordMatches(sql, index, keyword)) {
|
|
488
|
+
return index;
|
|
489
|
+
}
|
|
490
|
+
index += 1;
|
|
520
491
|
}
|
|
521
|
-
return
|
|
492
|
+
return void 0;
|
|
522
493
|
}
|
|
523
|
-
function
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
494
|
+
function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
495
|
+
let index = startIndex;
|
|
496
|
+
let depth = 0;
|
|
497
|
+
while (index < endIndex) {
|
|
498
|
+
const skipped = skipNonCode(sql, index);
|
|
499
|
+
if (skipped !== void 0) {
|
|
500
|
+
index = skipped;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const char = sql[index];
|
|
504
|
+
if (char === target && depth === 0) {
|
|
505
|
+
return index;
|
|
506
|
+
}
|
|
507
|
+
if (char === "(") {
|
|
508
|
+
depth += 1;
|
|
509
|
+
} else if (char === ")" && depth > 0) {
|
|
510
|
+
depth -= 1;
|
|
511
|
+
}
|
|
512
|
+
index += 1;
|
|
531
513
|
}
|
|
532
|
-
|
|
533
|
-
|
|
514
|
+
return void 0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/statements.ts
|
|
518
|
+
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
|
|
519
|
+
var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
|
|
520
|
+
var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
|
|
521
|
+
function firstKeyword(sql) {
|
|
522
|
+
let index = 0;
|
|
523
|
+
while (index < sql.length) {
|
|
524
|
+
const char = sql.charAt(index);
|
|
525
|
+
if (char.trim().length === 0) {
|
|
526
|
+
index += 1;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
530
|
+
index += 2;
|
|
531
|
+
while (index < sql.length && sql[index] !== "\n") {
|
|
532
|
+
index += 1;
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
537
|
+
const commentEnd = sql.indexOf("*/", index + 2);
|
|
538
|
+
if (commentEnd === -1) {
|
|
539
|
+
return "";
|
|
540
|
+
}
|
|
541
|
+
index = commentEnd + 2;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
break;
|
|
534
545
|
}
|
|
535
|
-
let
|
|
536
|
-
|
|
537
|
-
|
|
546
|
+
let keywordEnd = index;
|
|
547
|
+
while (keywordEnd < sql.length) {
|
|
548
|
+
const code = sql.charCodeAt(keywordEnd);
|
|
549
|
+
const isUpperCase = code >= 65 && code <= 90;
|
|
550
|
+
const isLowerCase = code >= 97 && code <= 122;
|
|
551
|
+
if (!isUpperCase && !isLowerCase) {
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
keywordEnd += 1;
|
|
538
555
|
}
|
|
539
|
-
|
|
540
|
-
|
|
556
|
+
return sql.slice(index, keywordEnd).toUpperCase();
|
|
557
|
+
}
|
|
558
|
+
function skipQuotedText2(sql, start) {
|
|
559
|
+
const quote = sql[start];
|
|
560
|
+
let index = start + 1;
|
|
561
|
+
while (index < sql.length) {
|
|
562
|
+
if (sql[index] === quote) {
|
|
563
|
+
if (sql[index + 1] === quote) {
|
|
564
|
+
index += 2;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
index += 1;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
index += 1;
|
|
541
571
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
572
|
+
return index;
|
|
573
|
+
}
|
|
574
|
+
function skipLineComment2(sql, start) {
|
|
575
|
+
let index = start + 2;
|
|
576
|
+
while (index < sql.length && sql[index] !== "\n") {
|
|
577
|
+
index += 1;
|
|
546
578
|
}
|
|
547
|
-
return
|
|
579
|
+
return index;
|
|
548
580
|
}
|
|
549
|
-
function
|
|
550
|
-
|
|
551
|
-
|
|
581
|
+
function skipBlockComment2(sql, start) {
|
|
582
|
+
let index = start + 2;
|
|
583
|
+
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
584
|
+
index += 1;
|
|
552
585
|
}
|
|
553
|
-
|
|
554
|
-
const name = match?.[1]?.trim();
|
|
555
|
-
return name === void 0 || name.length === 0 ? void 0 : name;
|
|
586
|
+
return Math.min(index + 2, sql.length);
|
|
556
587
|
}
|
|
557
|
-
function
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
588
|
+
function maskIgnoredSqlText(sql) {
|
|
589
|
+
let masked = "";
|
|
590
|
+
let index = 0;
|
|
591
|
+
while (index < sql.length) {
|
|
592
|
+
const char = sql[index];
|
|
593
|
+
if (char === "'" || char === '"') {
|
|
594
|
+
const end = skipQuotedText2(sql, index);
|
|
595
|
+
masked += " ".repeat(end - index);
|
|
596
|
+
index = end;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
600
|
+
const end = skipLineComment2(sql, index);
|
|
601
|
+
masked += " ".repeat(end - index);
|
|
602
|
+
index = end;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
606
|
+
const end = skipBlockComment2(sql, index);
|
|
607
|
+
masked += " ".repeat(end - index);
|
|
608
|
+
index = end;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
masked += char ?? "";
|
|
612
|
+
index += 1;
|
|
613
|
+
}
|
|
614
|
+
return masked;
|
|
561
615
|
}
|
|
562
|
-
function
|
|
563
|
-
|
|
564
|
-
|
|
616
|
+
function topLevelKeywordIndex(sql, keyword) {
|
|
617
|
+
const masked = maskIgnoredSqlText(sql);
|
|
618
|
+
let depth = 0;
|
|
619
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
620
|
+
const char = masked[index];
|
|
621
|
+
if (char === "(") {
|
|
622
|
+
depth += 1;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (char === ")") {
|
|
626
|
+
depth = Math.max(0, depth - 1);
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
|
|
630
|
+
return index;
|
|
631
|
+
}
|
|
565
632
|
}
|
|
566
|
-
return
|
|
567
|
-
"Did you mean:",
|
|
568
|
-
...suggestions.map((item) => ` ${item.schema}.${item.name} (${item.type})`)
|
|
569
|
-
].join("\n");
|
|
633
|
+
return void 0;
|
|
570
634
|
}
|
|
571
|
-
function
|
|
572
|
-
|
|
573
|
-
return candidates.map((candidate) => ({
|
|
574
|
-
candidate,
|
|
575
|
-
score: score(requestedName, { schema: "", name: candidate.name, type: "TABLE" })
|
|
576
|
-
})).filter((item) => item.score >= 45).sort(
|
|
577
|
-
(left, right) => right.score - left.score || left.candidate.name.localeCompare(right.candidate.name)
|
|
578
|
-
).slice(0, limit).map((item) => item.candidate);
|
|
635
|
+
function hasTopLevelKeyword(sql, keyword) {
|
|
636
|
+
return topLevelKeywordIndex(sql, keyword) !== void 0;
|
|
579
637
|
}
|
|
580
|
-
function
|
|
581
|
-
|
|
582
|
-
|
|
638
|
+
function isIdentifierChar2(char) {
|
|
639
|
+
return /[A-Za-z0-9_$#]/.test(char);
|
|
640
|
+
}
|
|
641
|
+
function skipWhitespace(masked, start) {
|
|
642
|
+
let index = start;
|
|
643
|
+
while (index < masked.length && /\s/.test(masked.charAt(index))) {
|
|
644
|
+
index += 1;
|
|
583
645
|
}
|
|
584
|
-
return
|
|
585
|
-
"Did you mean column:",
|
|
586
|
-
...suggestions.map((item) => ` ${item.name}`)
|
|
587
|
-
].join("\n");
|
|
646
|
+
return index;
|
|
588
647
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
}
|
|
602
|
-
function isLobSortOrGroupError(error) {
|
|
603
|
-
const code = databaseCode(error);
|
|
604
|
-
if (code !== 266 && code !== 274) {
|
|
605
|
-
return false;
|
|
606
|
-
}
|
|
607
|
-
return error instanceof QueryError && /LOB type is not allowed in (?:ORDER BY|GROUP BY) clause/i.test(error.message);
|
|
608
|
-
}
|
|
609
|
-
function printLobSortOrGroupHint() {
|
|
610
|
-
const lines = [
|
|
611
|
-
`${CLI_NAME}: HANA cannot ORDER BY or GROUP BY NCLOB/CLOB/BLOB columns directly.`,
|
|
612
|
-
`${CLI_NAME}: Remove the LOB column from ORDER BY/GROUP BY or wrap it as TO_VARCHAR(<column>).`
|
|
613
|
-
];
|
|
614
|
-
process.stderr.write(`${lines.join("\n")}
|
|
615
|
-
`);
|
|
616
|
-
}
|
|
617
|
-
function isInsufficientPrivilegeError(error) {
|
|
618
|
-
return databaseCode(error) === 258 || /\binsufficient privilege\b/i.test(errorMessage(error));
|
|
619
|
-
}
|
|
620
|
-
function printInsufficientPrivilegeHint(client, schema) {
|
|
621
|
-
const binding = client.info.bindingName ?? (client.info.bindingIndex === void 0 ? "unknown" : `#${String(client.info.bindingIndex)}`);
|
|
622
|
-
const otherBindings = (client.info.availableBindingNames ?? []).filter(
|
|
623
|
-
(name) => name !== client.info.bindingName
|
|
624
|
-
);
|
|
625
|
-
const retryBinding = otherBindings[0];
|
|
626
|
-
const lines = [
|
|
627
|
-
`${CLI_NAME}: insufficient privilege for schema ${schema} as database user ${client.databaseUser || "unknown"} (current binding: ${binding}).`
|
|
628
|
-
];
|
|
629
|
-
if (retryBinding !== void 0) {
|
|
630
|
-
lines.push(
|
|
631
|
-
`${CLI_NAME}: other HANA bindings on this app: ${otherBindings.join(", ")}; retry with --binding ${retryBinding}.`
|
|
632
|
-
);
|
|
648
|
+
function matchingCloseParenIndex(masked, openIndex) {
|
|
649
|
+
let depth = 0;
|
|
650
|
+
for (let index = openIndex; index < masked.length; index += 1) {
|
|
651
|
+
const char = masked.charAt(index);
|
|
652
|
+
if (char === "(") {
|
|
653
|
+
depth += 1;
|
|
654
|
+
} else if (char === ")") {
|
|
655
|
+
depth -= 1;
|
|
656
|
+
if (depth === 0) {
|
|
657
|
+
return index;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
633
660
|
}
|
|
634
|
-
|
|
635
|
-
`${CLI_NAME}: try another app or full selector whose binding has the grant; no automatic retry was attempted.`
|
|
636
|
-
);
|
|
637
|
-
process.stderr.write(`${lines.join("\n")}
|
|
638
|
-
`);
|
|
661
|
+
return void 0;
|
|
639
662
|
}
|
|
640
|
-
function
|
|
641
|
-
|
|
642
|
-
printInsufficientPrivilegeHint(client, schema);
|
|
643
|
-
}
|
|
644
|
-
throw error;
|
|
663
|
+
function isAsKeywordAt(masked, index) {
|
|
664
|
+
return masked.slice(index, index + 2).toUpperCase() === "AS" && !isIdentifierChar2(masked.charAt(index - 1)) && !isIdentifierChar2(masked.charAt(index + 2));
|
|
645
665
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
|
-
try {
|
|
656
|
-
const columns = await client.listColumns(
|
|
657
|
-
tableName.schema ?? client.info.schema,
|
|
658
|
-
tableName.name
|
|
659
|
-
);
|
|
660
|
-
const text = formatColumnSuggestions(rankNameSuggestions(columnName, columns));
|
|
661
|
-
if (text !== void 0) {
|
|
662
|
-
process.stderr.write(`${text}
|
|
663
|
-
`);
|
|
666
|
+
function skipCteName(sql, masked, start) {
|
|
667
|
+
let index = start;
|
|
668
|
+
while (index < sql.length) {
|
|
669
|
+
const char = sql.charAt(index);
|
|
670
|
+
if (char.trim().length === 0) {
|
|
671
|
+
index += 1;
|
|
672
|
+
continue;
|
|
664
673
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
async function printCatalogObjectSuggestions(error, client, sql, refresh) {
|
|
669
|
-
if (!isInvalidCatalogObjectError(error)) {
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
const requested = extractMissingObjectNameFromError(error) ?? extractMissingObjectName(sql);
|
|
673
|
-
if (requested === void 0) {
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
try {
|
|
677
|
-
const objects = await loadSuggestionCatalogObjects(client, refresh);
|
|
678
|
-
const text = formatSuggestions(rankCatalogSuggestions(requested, objects));
|
|
679
|
-
if (text !== void 0) {
|
|
680
|
-
process.stderr.write(`${text}
|
|
681
|
-
`);
|
|
674
|
+
if (char === "-" && sql.charAt(index + 1) === "-") {
|
|
675
|
+
index = skipLineComment2(sql, index);
|
|
676
|
+
continue;
|
|
682
677
|
}
|
|
683
|
-
|
|
678
|
+
if (char === "/" && sql.charAt(index + 1) === "*") {
|
|
679
|
+
index = skipBlockComment2(sql, index);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
break;
|
|
684
683
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
if (isInsufficientPrivilegeError(error)) {
|
|
688
|
-
const schema = extractMissingObjectName(sql)?.schema ?? client.info.schema;
|
|
689
|
-
printInsufficientPrivilegeHint(client, schema);
|
|
690
|
-
throw error;
|
|
684
|
+
if (sql.charAt(index) === "'" || sql.charAt(index) === '"') {
|
|
685
|
+
return skipQuotedText2(sql, index);
|
|
691
686
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
687
|
+
let end = index;
|
|
688
|
+
while (end < masked.length && isIdentifierChar2(masked.charAt(end))) {
|
|
689
|
+
end += 1;
|
|
695
690
|
}
|
|
696
|
-
|
|
697
|
-
await printCatalogObjectSuggestions(error, client, sql, refresh);
|
|
698
|
-
throw error;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
// src/backup.ts
|
|
702
|
-
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
703
|
-
import { homedir as homedir2 } from "os";
|
|
704
|
-
import { join as join2 } from "path";
|
|
705
|
-
|
|
706
|
-
// src/lob.ts
|
|
707
|
-
var TEXT_LOB_TYPES = /* @__PURE__ */ new Set(["CLOB", "NCLOB"]);
|
|
708
|
-
function normalizeTypeName(typeName2) {
|
|
709
|
-
return typeName2?.trim().toUpperCase() ?? "";
|
|
710
|
-
}
|
|
711
|
-
function isTextLobType(typeName2) {
|
|
712
|
-
return TEXT_LOB_TYPES.has(normalizeTypeName(typeName2));
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// src/result-preview.ts
|
|
716
|
-
function normalizePreviewChar(value) {
|
|
717
|
-
return value === "\r" || value === "\n" || value === " " ? " " : value;
|
|
691
|
+
return end;
|
|
718
692
|
}
|
|
719
|
-
function
|
|
720
|
-
let
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
693
|
+
function cteListEndIndex(sql, masked, afterWith) {
|
|
694
|
+
let index = afterWith;
|
|
695
|
+
for (; ; ) {
|
|
696
|
+
index = skipCteName(sql, masked, index);
|
|
697
|
+
index = skipWhitespace(masked, index);
|
|
698
|
+
if (masked.charAt(index) === "(") {
|
|
699
|
+
const columnListEnd = matchingCloseParenIndex(masked, index);
|
|
700
|
+
if (columnListEnd === void 0) {
|
|
701
|
+
return void 0;
|
|
702
|
+
}
|
|
703
|
+
index = skipWhitespace(masked, columnListEnd + 1);
|
|
725
704
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
unit: "bytes"
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
function scalarText(value) {
|
|
747
|
-
if (value instanceof Date) {
|
|
748
|
-
return value.toISOString();
|
|
749
|
-
}
|
|
750
|
-
if (typeof value === "boolean") {
|
|
751
|
-
return value ? "true" : "false";
|
|
705
|
+
if (!isAsKeywordAt(masked, index)) {
|
|
706
|
+
return void 0;
|
|
707
|
+
}
|
|
708
|
+
index = skipWhitespace(masked, index + 2);
|
|
709
|
+
if (masked.charAt(index) !== "(") {
|
|
710
|
+
return void 0;
|
|
711
|
+
}
|
|
712
|
+
const closeIndex = matchingCloseParenIndex(masked, index);
|
|
713
|
+
if (closeIndex === void 0) {
|
|
714
|
+
return void 0;
|
|
715
|
+
}
|
|
716
|
+
index = skipWhitespace(masked, closeIndex + 1);
|
|
717
|
+
if (masked.charAt(index) === ",") {
|
|
718
|
+
index += 1;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
return index;
|
|
752
722
|
}
|
|
753
|
-
return value.toString();
|
|
754
723
|
}
|
|
755
|
-
function
|
|
756
|
-
|
|
757
|
-
|
|
724
|
+
function resolveWithStatement(sql) {
|
|
725
|
+
const withIndex = topLevelKeywordIndex(sql, "WITH");
|
|
726
|
+
if (withIndex === void 0) {
|
|
727
|
+
return void 0;
|
|
758
728
|
}
|
|
759
|
-
|
|
760
|
-
|
|
729
|
+
const masked = maskIgnoredSqlText(sql);
|
|
730
|
+
const trailingIndex = cteListEndIndex(sql, masked, withIndex + "WITH".length);
|
|
731
|
+
if (trailingIndex === void 0) {
|
|
732
|
+
return void 0;
|
|
761
733
|
}
|
|
762
|
-
|
|
763
|
-
|
|
734
|
+
let keywordEnd = trailingIndex;
|
|
735
|
+
while (keywordEnd < masked.length && isIdentifierChar2(masked.charAt(keywordEnd))) {
|
|
736
|
+
keywordEnd += 1;
|
|
764
737
|
}
|
|
765
|
-
return
|
|
738
|
+
return { keyword: masked.slice(trailingIndex, keywordEnd).toUpperCase(), index: trailingIndex };
|
|
766
739
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
if (value === null) {
|
|
771
|
-
return nullText;
|
|
772
|
-
}
|
|
773
|
-
if (value instanceof Date) {
|
|
774
|
-
return value.toISOString();
|
|
740
|
+
function classifyByKeyword(keyword) {
|
|
741
|
+
if (SELECT_KEYWORDS.has(keyword)) {
|
|
742
|
+
return "select";
|
|
775
743
|
}
|
|
776
|
-
if (
|
|
777
|
-
return
|
|
744
|
+
if (DML_KEYWORDS.has(keyword)) {
|
|
745
|
+
return "dml";
|
|
778
746
|
}
|
|
779
|
-
if (
|
|
780
|
-
return
|
|
747
|
+
if (DDL_KEYWORDS.has(keyword)) {
|
|
748
|
+
return "ddl";
|
|
781
749
|
}
|
|
782
|
-
return
|
|
750
|
+
return "unknown";
|
|
783
751
|
}
|
|
784
|
-
function
|
|
785
|
-
|
|
786
|
-
|
|
752
|
+
function classifyStatement(sql) {
|
|
753
|
+
const keyword = firstKeyword(sql);
|
|
754
|
+
if (keyword === "WITH") {
|
|
755
|
+
const resolved = resolveWithStatement(sql);
|
|
756
|
+
return resolved === void 0 ? "unknown" : classifyByKeyword(resolved.keyword);
|
|
787
757
|
}
|
|
788
|
-
|
|
789
|
-
|
|
758
|
+
return classifyByKeyword(keyword);
|
|
759
|
+
}
|
|
760
|
+
function quoteIdentifier(identifier) {
|
|
761
|
+
if (identifier.length === 0) {
|
|
762
|
+
throw new QueryError("A SQL identifier must not be empty");
|
|
790
763
|
}
|
|
791
|
-
if (
|
|
792
|
-
|
|
764
|
+
if (identifier.includes("\0")) {
|
|
765
|
+
throw new QueryError("A SQL identifier must not contain a NUL character");
|
|
793
766
|
}
|
|
794
|
-
return
|
|
767
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
795
768
|
}
|
|
796
|
-
function
|
|
797
|
-
|
|
798
|
-
return `"${text.replace(/"/g, '""')}"`;
|
|
799
|
-
}
|
|
800
|
-
return text;
|
|
769
|
+
function qualifiedName(schema, table) {
|
|
770
|
+
return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
|
|
801
771
|
}
|
|
802
|
-
function
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
(
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
const rows = result.rows.map((row) => {
|
|
824
|
-
const serialized = {};
|
|
825
|
-
for (const [key, value] of Object.entries(row)) {
|
|
826
|
-
const column = result.columns.find((item) => item.name === key);
|
|
827
|
-
serialized[key] = serializeCell(value, column);
|
|
772
|
+
function countPlaceholders(sql) {
|
|
773
|
+
let count = 0;
|
|
774
|
+
let index = 0;
|
|
775
|
+
const length = sql.length;
|
|
776
|
+
while (index < length) {
|
|
777
|
+
const char = sql[index];
|
|
778
|
+
if (char === "'" || char === '"') {
|
|
779
|
+
const quote = char;
|
|
780
|
+
index += 1;
|
|
781
|
+
while (index < length) {
|
|
782
|
+
if (sql[index] === quote) {
|
|
783
|
+
if (sql[index + 1] === quote) {
|
|
784
|
+
index += 2;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
index += 1;
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
index += 1;
|
|
791
|
+
}
|
|
792
|
+
continue;
|
|
828
793
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
794
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
795
|
+
index += 2;
|
|
796
|
+
while (index < length && sql[index] !== "\n") {
|
|
797
|
+
index += 1;
|
|
798
|
+
}
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
802
|
+
index += 2;
|
|
803
|
+
while (index < length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
804
|
+
index += 1;
|
|
805
|
+
}
|
|
806
|
+
index += 2;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
if (char === "?") {
|
|
810
|
+
count += 1;
|
|
811
|
+
}
|
|
812
|
+
index += 1;
|
|
838
813
|
}
|
|
839
|
-
|
|
840
|
-
const values = result.rows.map((row) => serializeCell(row[columnName] ?? null, column));
|
|
841
|
-
return JSON.stringify(values, null, 2);
|
|
814
|
+
return count;
|
|
842
815
|
}
|
|
843
|
-
function
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, "", column))).join(",")
|
|
816
|
+
function assertParamArity(sql, params) {
|
|
817
|
+
const expected = countPlaceholders(sql);
|
|
818
|
+
if (expected !== params.length) {
|
|
819
|
+
throw new QueryError(
|
|
820
|
+
`SQL expects ${String(expected)} bound parameter(s) but received ${String(params.length)} value(s)`
|
|
849
821
|
);
|
|
850
822
|
}
|
|
851
|
-
return lines.join("\r\n");
|
|
852
823
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
824
|
+
|
|
825
|
+
// src/backup-planner.ts
|
|
826
|
+
var WRITE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
827
|
+
"UPDATE",
|
|
828
|
+
"UPSERT",
|
|
829
|
+
"REPLACE",
|
|
830
|
+
"MERGE",
|
|
831
|
+
"DELETE"
|
|
832
|
+
]);
|
|
833
|
+
var MERGE_MODIFY_ACTIONS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
834
|
+
function trimStatementSql(sql) {
|
|
835
|
+
const trimmed = sql.trim();
|
|
836
|
+
let end = trimmed.length;
|
|
837
|
+
while (end > 0 && trimmed.charAt(end - 1) === ";") {
|
|
838
|
+
end -= 1;
|
|
866
839
|
}
|
|
867
|
-
return
|
|
840
|
+
return trimmed.slice(0, end).trim();
|
|
868
841
|
}
|
|
869
|
-
function
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
return formatTable(result);
|
|
873
|
-
case "json":
|
|
874
|
-
return formatJson(result);
|
|
875
|
-
case "json-compact":
|
|
876
|
-
return formatJsonCompact(result, compactColumn);
|
|
877
|
-
case "csv":
|
|
878
|
-
return formatCsv(result);
|
|
842
|
+
function isMergeClauseStart(tokens, index) {
|
|
843
|
+
if (tokens[index]?.keyword !== "WHEN") {
|
|
844
|
+
return false;
|
|
879
845
|
}
|
|
846
|
+
return tokens[index + 1]?.keyword === "MATCHED" || tokens[index + 1]?.keyword === "NOT" && tokens[index + 2]?.keyword === "MATCHED";
|
|
880
847
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
function skipQuotedText(sql, index) {
|
|
887
|
-
const quote = sql[index];
|
|
888
|
-
let cursor = index + 1;
|
|
889
|
-
while (cursor < sql.length) {
|
|
890
|
-
if (sql[cursor] === quote) {
|
|
891
|
-
if (sql[cursor + 1] === quote) {
|
|
892
|
-
cursor += 2;
|
|
893
|
-
continue;
|
|
894
|
-
}
|
|
895
|
-
return cursor + 1;
|
|
848
|
+
function mergeClauseIndexes(tokens, start) {
|
|
849
|
+
const indexes = [];
|
|
850
|
+
for (let index = start; index < tokens.length; index += 1) {
|
|
851
|
+
if (isMergeClauseStart(tokens, index)) {
|
|
852
|
+
indexes.push(index);
|
|
896
853
|
}
|
|
897
|
-
cursor += 1;
|
|
898
854
|
}
|
|
899
|
-
return
|
|
855
|
+
return indexes;
|
|
900
856
|
}
|
|
901
|
-
function
|
|
902
|
-
|
|
903
|
-
while (cursor < sql.length && sql[cursor] !== "\n") {
|
|
904
|
-
cursor += 1;
|
|
905
|
-
}
|
|
906
|
-
return cursor;
|
|
857
|
+
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
858
|
+
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
907
859
|
}
|
|
908
|
-
function
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
860
|
+
function buildSelectPlan(operation, statementSql, targetSql, whereIndex, params) {
|
|
861
|
+
const target = targetSql.trim();
|
|
862
|
+
if (target.length === 0) {
|
|
863
|
+
throw new QueryError(`${operation.toUpperCase()} backup requires a target table`);
|
|
912
864
|
}
|
|
913
|
-
|
|
865
|
+
if (whereIndex === void 0) {
|
|
866
|
+
return { operation, statementSql, selectSql: `SELECT * FROM ${target}`, selectParams: [] };
|
|
867
|
+
}
|
|
868
|
+
const whereSql = statementSql.slice(whereIndex + "WHERE".length).trim();
|
|
869
|
+
if (whereSql.length === 0) {
|
|
870
|
+
throw new QueryError(`${operation.toUpperCase()} backup requires a non-empty WHERE clause`);
|
|
871
|
+
}
|
|
872
|
+
return {
|
|
873
|
+
operation,
|
|
874
|
+
statementSql,
|
|
875
|
+
selectSql: `SELECT * FROM ${target} WHERE ${whereSql}`,
|
|
876
|
+
selectParams: selectParamsAfterWhere(statementSql, whereIndex, params)
|
|
877
|
+
};
|
|
914
878
|
}
|
|
915
|
-
function
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
|
|
879
|
+
function buildUpdateBackupPlan(statementSql, params) {
|
|
880
|
+
const updateIndex = findTopLevelKeyword(statementSql, "UPDATE");
|
|
881
|
+
const setIndex = updateIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "SET", updateIndex + "UPDATE".length);
|
|
882
|
+
if (updateIndex === void 0 || setIndex === void 0) {
|
|
883
|
+
throw new QueryError("UPDATE backup requires UPDATE <target> SET syntax");
|
|
919
884
|
}
|
|
920
|
-
|
|
921
|
-
|
|
885
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", setIndex + "SET".length);
|
|
886
|
+
return buildSelectPlan(
|
|
887
|
+
"update",
|
|
888
|
+
statementSql,
|
|
889
|
+
statementSql.slice(updateIndex + "UPDATE".length, setIndex),
|
|
890
|
+
whereIndex,
|
|
891
|
+
params
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
function buildUpsertBackupPlan(statementSql, params, keyword) {
|
|
895
|
+
const operation = keyword === "UPSERT" ? "upsert" : "replace";
|
|
896
|
+
const keywordIndex = findTopLevelKeyword(statementSql, keyword);
|
|
897
|
+
const target = keywordIndex === void 0 ? void 0 : readQualifiedTarget(statementSql, keywordIndex + keyword.length, statementSql.length);
|
|
898
|
+
if (keywordIndex === void 0 || target === void 0) {
|
|
899
|
+
throw new BackupRequiredError(
|
|
900
|
+
`${keyword} write refused: cannot derive a trustworthy backup target`
|
|
901
|
+
);
|
|
922
902
|
}
|
|
923
|
-
|
|
924
|
-
|
|
903
|
+
const valuesIndex = findTopLevelKeyword(statementSql, "VALUES", target.end);
|
|
904
|
+
if (valuesIndex === void 0) {
|
|
905
|
+
const selectIndex = findTopLevelKeyword(statementSql, "SELECT", target.end);
|
|
906
|
+
const withIndex = findTopLevelKeyword(statementSql, "WITH", target.end);
|
|
907
|
+
if (selectIndex === void 0 && withIndex === void 0) {
|
|
908
|
+
throw new BackupRequiredError(
|
|
909
|
+
`${keyword} write refused: expected VALUES, WITH PRIMARY KEY, or a subquery`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
return buildSelectPlan(operation, statementSql, target.sql, void 0, params);
|
|
925
913
|
}
|
|
926
|
-
|
|
914
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
|
|
915
|
+
return buildSelectPlan(operation, statementSql, target.sql, whereIndex, params);
|
|
927
916
|
}
|
|
928
|
-
function
|
|
929
|
-
let
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
917
|
+
function mergeTargetReference(statementSql, target, usingStart) {
|
|
918
|
+
let suffix = statementSql.slice(target.end, usingStart).trim();
|
|
919
|
+
if (suffix.length === 0) {
|
|
920
|
+
return target.reference;
|
|
921
|
+
}
|
|
922
|
+
if (/^PARTITION\b/i.test(suffix)) {
|
|
923
|
+
const open2 = suffix.indexOf("(");
|
|
924
|
+
if (open2 === -1) {
|
|
925
|
+
return void 0;
|
|
934
926
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
927
|
+
const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
|
|
928
|
+
if (close === void 0) {
|
|
929
|
+
return void 0;
|
|
938
930
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
931
|
+
suffix = suffix.slice(close + 1).trim();
|
|
932
|
+
if (suffix.length === 0) {
|
|
933
|
+
return target.reference;
|
|
942
934
|
}
|
|
943
|
-
break;
|
|
944
|
-
}
|
|
945
|
-
return index;
|
|
946
|
-
}
|
|
947
|
-
function readIdentifierPart(sql, start, end) {
|
|
948
|
-
const index = skipTrivia(sql, start, end);
|
|
949
|
-
if (sql[index] === '"') {
|
|
950
|
-
const next2 = skipQuotedText(sql, index);
|
|
951
|
-
return next2 <= end && sql[next2 - 1] === '"' ? { sql: sql.slice(index, next2), end: next2 } : void 0;
|
|
952
935
|
}
|
|
953
|
-
|
|
936
|
+
suffix = suffix.replace(/^AS\b/i, "").trim();
|
|
937
|
+
const alias = readIdentifierPart(suffix, 0, suffix.length);
|
|
938
|
+
if (alias === void 0 || skipTrivia(suffix, alias.end, suffix.length) !== suffix.length) {
|
|
954
939
|
return void 0;
|
|
955
940
|
}
|
|
956
|
-
|
|
957
|
-
while (next < end && /[A-Za-z0-9_#$]/.test(sql.charAt(next))) {
|
|
958
|
-
next += 1;
|
|
959
|
-
}
|
|
960
|
-
return { sql: sql.slice(index, next), end: next };
|
|
941
|
+
return alias.sql;
|
|
961
942
|
}
|
|
962
|
-
function
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
}
|
|
967
|
-
const parts = [first.sql];
|
|
968
|
-
let cursor = first.end;
|
|
969
|
-
while (parts.length < 3) {
|
|
970
|
-
const dot = skipTrivia(sql, cursor, end);
|
|
971
|
-
if (sql[dot] !== ".") {
|
|
972
|
-
break;
|
|
973
|
-
}
|
|
974
|
-
const part = readIdentifierPart(sql, dot + 1, end);
|
|
975
|
-
if (part === void 0) {
|
|
976
|
-
return void 0;
|
|
977
|
-
}
|
|
978
|
-
parts.push(part.sql);
|
|
979
|
-
cursor = part.end;
|
|
980
|
-
}
|
|
981
|
-
return { sql: parts.join("."), reference: parts.at(-1) ?? first.sql, end: cursor };
|
|
943
|
+
function paramsInRange(sql, params, start, end) {
|
|
944
|
+
const offset = countPlaceholders(sql.slice(0, start));
|
|
945
|
+
const length = countPlaceholders(sql.slice(start, end));
|
|
946
|
+
return params.slice(offset, offset + length);
|
|
982
947
|
}
|
|
983
|
-
function
|
|
984
|
-
|
|
985
|
-
let index = 0;
|
|
986
|
-
let depth = 0;
|
|
987
|
-
while (index < sql.length) {
|
|
988
|
-
const skipped = skipNonCode(sql, index);
|
|
989
|
-
if (skipped !== void 0) {
|
|
990
|
-
index = skipped;
|
|
991
|
-
continue;
|
|
992
|
-
}
|
|
993
|
-
const char = sql.charAt(index);
|
|
994
|
-
if (char === "(") {
|
|
995
|
-
depth += 1;
|
|
996
|
-
index += 1;
|
|
997
|
-
continue;
|
|
998
|
-
}
|
|
999
|
-
if (char === ")") {
|
|
1000
|
-
depth = Math.max(0, depth - 1);
|
|
1001
|
-
index += 1;
|
|
1002
|
-
continue;
|
|
1003
|
-
}
|
|
1004
|
-
if (!/[A-Za-z_#$]/.test(char)) {
|
|
1005
|
-
index += 1;
|
|
1006
|
-
continue;
|
|
1007
|
-
}
|
|
1008
|
-
let end = index + 1;
|
|
1009
|
-
while (end < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(end))) {
|
|
1010
|
-
end += 1;
|
|
1011
|
-
}
|
|
1012
|
-
if (depth === 0) {
|
|
1013
|
-
tokens.push({ keyword: sql.slice(index, end).toUpperCase(), start: index, end });
|
|
1014
|
-
}
|
|
1015
|
-
index = end;
|
|
1016
|
-
}
|
|
1017
|
-
return tokens;
|
|
948
|
+
function wholeTargetPlan(operation, statementSql, target) {
|
|
949
|
+
return buildSelectPlan(operation, statementSql, target.sql, void 0, []);
|
|
1018
950
|
}
|
|
1019
|
-
function
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
951
|
+
function readRequiredMergeToken(tokens, keyword, start) {
|
|
952
|
+
const index = findToken(tokens, keyword, start);
|
|
953
|
+
const token = index === void 0 ? void 0 : tokens[index];
|
|
954
|
+
if (index === void 0 || token === void 0) {
|
|
955
|
+
throw new BackupRequiredError(
|
|
956
|
+
"MERGE write refused: cannot derive a trustworthy backup target"
|
|
957
|
+
);
|
|
1024
958
|
}
|
|
1025
|
-
return
|
|
1026
|
-
}
|
|
1027
|
-
function keywordMatches(sql, index, keyword) {
|
|
1028
|
-
const end = index + keyword.length;
|
|
1029
|
-
return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
|
|
959
|
+
return { index, token };
|
|
1030
960
|
}
|
|
1031
|
-
function
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
if (char === "(") {
|
|
1042
|
-
depth += 1;
|
|
1043
|
-
} else if (char === ")" && depth > 0) {
|
|
1044
|
-
depth -= 1;
|
|
1045
|
-
} else if (depth === 0 && keywordMatches(sql, index, keyword)) {
|
|
1046
|
-
return index;
|
|
1047
|
-
}
|
|
1048
|
-
index += 1;
|
|
961
|
+
function parseMerge(statementSql) {
|
|
962
|
+
const tokens = topLevelTokens(statementSql);
|
|
963
|
+
const into = readRequiredMergeToken(tokens, "INTO", 1);
|
|
964
|
+
const using = readRequiredMergeToken(tokens, "USING", into.index + 1);
|
|
965
|
+
const on = readRequiredMergeToken(tokens, "ON", using.index + 1);
|
|
966
|
+
const target = readQualifiedTarget(statementSql, into.token.end, using.token.start);
|
|
967
|
+
if (target === void 0) {
|
|
968
|
+
throw new BackupRequiredError(
|
|
969
|
+
"MERGE write refused: cannot derive a trustworthy backup target"
|
|
970
|
+
);
|
|
1049
971
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
let index = startIndex;
|
|
1054
|
-
let depth = 0;
|
|
1055
|
-
while (index < endIndex) {
|
|
1056
|
-
const skipped = skipNonCode(sql, index);
|
|
1057
|
-
if (skipped !== void 0) {
|
|
1058
|
-
index = skipped;
|
|
1059
|
-
continue;
|
|
1060
|
-
}
|
|
1061
|
-
const char = sql[index];
|
|
1062
|
-
if (char === target && depth === 0) {
|
|
1063
|
-
return index;
|
|
1064
|
-
}
|
|
1065
|
-
if (char === "(") {
|
|
1066
|
-
depth += 1;
|
|
1067
|
-
} else if (char === ")" && depth > 0) {
|
|
1068
|
-
depth -= 1;
|
|
1069
|
-
}
|
|
1070
|
-
index += 1;
|
|
972
|
+
const clauseIndexes = mergeClauseIndexes(tokens, on.index + 1);
|
|
973
|
+
if (clauseIndexes.length === 0) {
|
|
974
|
+
throw new BackupRequiredError("MERGE write refused: no supported WHEN clause was found");
|
|
1071
975
|
}
|
|
1072
|
-
|
|
976
|
+
const firstClause = tokens[clauseIndexes[0] ?? 0];
|
|
977
|
+
return {
|
|
978
|
+
tokens,
|
|
979
|
+
into: into.token,
|
|
980
|
+
using: using.token,
|
|
981
|
+
on: on.token,
|
|
982
|
+
target,
|
|
983
|
+
clauseIndexes,
|
|
984
|
+
matchedIndexes: clauseIndexes.filter((index) => tokens[index + 1]?.keyword === "MATCHED"),
|
|
985
|
+
targetClause: statementSql.slice(into.token.end, using.token.start).trim(),
|
|
986
|
+
sourceClause: statementSql.slice(using.token.end, on.token.start).trim(),
|
|
987
|
+
onCondition: statementSql.slice(on.token.end, firstClause?.start).trim()
|
|
988
|
+
};
|
|
1073
989
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
|
|
1079
|
-
function firstKeyword(sql) {
|
|
1080
|
-
let index = 0;
|
|
1081
|
-
while (index < sql.length) {
|
|
1082
|
-
const char = sql.charAt(index);
|
|
1083
|
-
if (char.trim().length === 0) {
|
|
1084
|
-
index += 1;
|
|
1085
|
-
continue;
|
|
1086
|
-
}
|
|
1087
|
-
if (char === "-" && sql[index + 1] === "-") {
|
|
1088
|
-
index += 2;
|
|
1089
|
-
while (index < sql.length && sql[index] !== "\n") {
|
|
1090
|
-
index += 1;
|
|
1091
|
-
}
|
|
1092
|
-
continue;
|
|
1093
|
-
}
|
|
1094
|
-
if (char === "/" && sql[index + 1] === "*") {
|
|
1095
|
-
const commentEnd = sql.indexOf("*/", index + 2);
|
|
1096
|
-
if (commentEnd === -1) {
|
|
1097
|
-
return "";
|
|
1098
|
-
}
|
|
1099
|
-
index = commentEnd + 2;
|
|
1100
|
-
continue;
|
|
1101
|
-
}
|
|
1102
|
-
break;
|
|
990
|
+
function readMatchedActionTokens(tokens, matchedIndex) {
|
|
991
|
+
const thenIndex = findToken(tokens, "THEN", matchedIndex + 2);
|
|
992
|
+
if (thenIndex === void 0) {
|
|
993
|
+
return void 0;
|
|
1103
994
|
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
if (!isUpperCase && !isLowerCase) {
|
|
1110
|
-
break;
|
|
1111
|
-
}
|
|
1112
|
-
keywordEnd += 1;
|
|
995
|
+
const thenToken = tokens[thenIndex];
|
|
996
|
+
const actionToken = tokens[thenIndex + 1];
|
|
997
|
+
const matchedToken = tokens[matchedIndex + 1];
|
|
998
|
+
if (thenToken === void 0 || actionToken === void 0 || matchedToken === void 0) {
|
|
999
|
+
return void 0;
|
|
1113
1000
|
}
|
|
1114
|
-
|
|
1001
|
+
if (!MERGE_MODIFY_ACTIONS.has(actionToken.keyword)) {
|
|
1002
|
+
return void 0;
|
|
1003
|
+
}
|
|
1004
|
+
const clauseToken = tokens[matchedIndex];
|
|
1005
|
+
return {
|
|
1006
|
+
thenToken,
|
|
1007
|
+
conditionToken: tokens[matchedIndex + 2],
|
|
1008
|
+
clauseStart: clauseToken === void 0 ? 0 : clauseToken.start
|
|
1009
|
+
};
|
|
1115
1010
|
}
|
|
1116
|
-
function
|
|
1117
|
-
const
|
|
1118
|
-
if (
|
|
1119
|
-
return
|
|
1011
|
+
function parseMatchedClause(statementSql, params, tokens, matchedIndex) {
|
|
1012
|
+
const action = readMatchedActionTokens(tokens, matchedIndex);
|
|
1013
|
+
if (action?.conditionToken === void 0) {
|
|
1014
|
+
return void 0;
|
|
1120
1015
|
}
|
|
1121
|
-
if (
|
|
1122
|
-
return "
|
|
1016
|
+
if (action.conditionToken.keyword === "THEN") {
|
|
1017
|
+
return { clauseStart: action.clauseStart, matchedCondition: "", conditionParams: [] };
|
|
1123
1018
|
}
|
|
1124
|
-
if (
|
|
1125
|
-
return
|
|
1019
|
+
if (action.conditionToken.keyword !== "AND") {
|
|
1020
|
+
return void 0;
|
|
1126
1021
|
}
|
|
1127
|
-
return
|
|
1022
|
+
return {
|
|
1023
|
+
clauseStart: action.clauseStart,
|
|
1024
|
+
matchedCondition: statementSql.slice(action.conditionToken.end, action.thenToken.start).trim(),
|
|
1025
|
+
conditionParams: paramsInRange(
|
|
1026
|
+
statementSql,
|
|
1027
|
+
params,
|
|
1028
|
+
action.conditionToken.end,
|
|
1029
|
+
action.thenToken.start
|
|
1030
|
+
)
|
|
1031
|
+
};
|
|
1128
1032
|
}
|
|
1129
|
-
function
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
throw new QueryError("A SQL identifier must not contain a NUL character");
|
|
1033
|
+
function buildExactMergePlan(statementSql, params, merge, matchedIndex) {
|
|
1034
|
+
const targetReference = mergeTargetReference(statementSql, merge.target, merge.using.start);
|
|
1035
|
+
const matchedClause = parseMatchedClause(statementSql, params, merge.tokens, matchedIndex);
|
|
1036
|
+
if (targetReference === void 0 || matchedClause === void 0) {
|
|
1037
|
+
return void 0;
|
|
1135
1038
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1039
|
+
const sourceParams = paramsInRange(
|
|
1040
|
+
statementSql,
|
|
1041
|
+
params,
|
|
1042
|
+
merge.using.end,
|
|
1043
|
+
matchedClause.clauseStart
|
|
1044
|
+
);
|
|
1045
|
+
const conditionSql = matchedClause.matchedCondition.length === 0 ? "" : ` AND (${matchedClause.matchedCondition})`;
|
|
1046
|
+
return {
|
|
1047
|
+
operation: "merge",
|
|
1048
|
+
statementSql,
|
|
1049
|
+
selectSql: `SELECT ${targetReference}.* FROM ${merge.targetClause} WHERE EXISTS (SELECT 1 FROM ${merge.sourceClause} WHERE (${merge.onCondition})${conditionSql})`,
|
|
1050
|
+
selectParams: [...sourceParams, ...matchedClause.conditionParams]
|
|
1051
|
+
};
|
|
1140
1052
|
}
|
|
1141
|
-
function
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
while (index < length) {
|
|
1146
|
-
const char = sql[index];
|
|
1147
|
-
if (char === "'" || char === '"') {
|
|
1148
|
-
const quote = char;
|
|
1149
|
-
index += 1;
|
|
1150
|
-
while (index < length) {
|
|
1151
|
-
if (sql[index] === quote) {
|
|
1152
|
-
if (sql[index + 1] === quote) {
|
|
1153
|
-
index += 2;
|
|
1154
|
-
continue;
|
|
1155
|
-
}
|
|
1156
|
-
index += 1;
|
|
1157
|
-
break;
|
|
1158
|
-
}
|
|
1159
|
-
index += 1;
|
|
1160
|
-
}
|
|
1161
|
-
continue;
|
|
1162
|
-
}
|
|
1163
|
-
if (char === "-" && sql[index + 1] === "-") {
|
|
1164
|
-
index += 2;
|
|
1165
|
-
while (index < length && sql[index] !== "\n") {
|
|
1166
|
-
index += 1;
|
|
1167
|
-
}
|
|
1168
|
-
continue;
|
|
1169
|
-
}
|
|
1170
|
-
if (char === "/" && sql[index + 1] === "*") {
|
|
1171
|
-
index += 2;
|
|
1172
|
-
while (index < length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
1173
|
-
index += 1;
|
|
1174
|
-
}
|
|
1175
|
-
index += 2;
|
|
1176
|
-
continue;
|
|
1177
|
-
}
|
|
1178
|
-
if (char === "?") {
|
|
1179
|
-
count += 1;
|
|
1180
|
-
}
|
|
1181
|
-
index += 1;
|
|
1182
|
-
}
|
|
1183
|
-
return count;
|
|
1184
|
-
}
|
|
1185
|
-
function assertParamArity(sql, params) {
|
|
1186
|
-
const expected = countPlaceholders(sql);
|
|
1187
|
-
if (expected !== params.length) {
|
|
1188
|
-
throw new QueryError(
|
|
1189
|
-
`SQL expects ${String(expected)} bound parameter(s) but received ${String(params.length)} value(s)`
|
|
1190
|
-
);
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
// src/001-backup-planner.ts
|
|
1195
|
-
var WRITE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1196
|
-
"UPDATE",
|
|
1197
|
-
"UPSERT",
|
|
1198
|
-
"REPLACE",
|
|
1199
|
-
"MERGE",
|
|
1200
|
-
"DELETE"
|
|
1201
|
-
]);
|
|
1202
|
-
var MERGE_MODIFY_ACTIONS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
1203
|
-
function trimStatementSql(sql) {
|
|
1204
|
-
const trimmed = sql.trim();
|
|
1205
|
-
let end = trimmed.length;
|
|
1206
|
-
while (end > 0 && trimmed.charAt(end - 1) === ";") {
|
|
1207
|
-
end -= 1;
|
|
1208
|
-
}
|
|
1209
|
-
return trimmed.slice(0, end).trim();
|
|
1210
|
-
}
|
|
1211
|
-
function isMergeClauseStart(tokens, index) {
|
|
1212
|
-
if (tokens[index]?.keyword !== "WHEN") {
|
|
1213
|
-
return false;
|
|
1214
|
-
}
|
|
1215
|
-
return tokens[index + 1]?.keyword === "MATCHED" || tokens[index + 1]?.keyword === "NOT" && tokens[index + 2]?.keyword === "MATCHED";
|
|
1216
|
-
}
|
|
1217
|
-
function mergeClauseIndexes(tokens, start) {
|
|
1218
|
-
const indexes = [];
|
|
1219
|
-
for (let index = start; index < tokens.length; index += 1) {
|
|
1220
|
-
if (isMergeClauseStart(tokens, index)) {
|
|
1221
|
-
indexes.push(index);
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
return indexes;
|
|
1225
|
-
}
|
|
1226
|
-
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
1227
|
-
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
1228
|
-
}
|
|
1229
|
-
function buildSelectPlan(operation, statementSql, targetSql, whereIndex, params) {
|
|
1230
|
-
const target = targetSql.trim();
|
|
1231
|
-
if (target.length === 0) {
|
|
1232
|
-
throw new QueryError(`${operation.toUpperCase()} backup requires a target table`);
|
|
1053
|
+
function buildMergeBackupPlan(statementSql, params) {
|
|
1054
|
+
const merge = parseMerge(statementSql);
|
|
1055
|
+
if (merge.matchedIndexes.length === 0) {
|
|
1056
|
+
return void 0;
|
|
1233
1057
|
}
|
|
1234
|
-
if (
|
|
1235
|
-
return
|
|
1058
|
+
if (merge.matchedIndexes.length > 1) {
|
|
1059
|
+
return wholeTargetPlan("merge", statementSql, merge.target);
|
|
1236
1060
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
throw new QueryError(`${operation.toUpperCase()} backup requires a non-empty WHERE clause`);
|
|
1061
|
+
if (merge.sourceClause.length === 0 || merge.onCondition.length === 0) {
|
|
1062
|
+
return wholeTargetPlan("merge", statementSql, merge.target);
|
|
1240
1063
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
statementSql,
|
|
1244
|
-
selectSql: `SELECT * FROM ${target} WHERE ${whereSql}`,
|
|
1245
|
-
selectParams: selectParamsAfterWhere(statementSql, whereIndex, params)
|
|
1246
|
-
};
|
|
1064
|
+
const matchedIndex = merge.matchedIndexes[0] ?? 0;
|
|
1065
|
+
return buildExactMergePlan(statementSql, params, merge, matchedIndex) ?? wholeTargetPlan("merge", statementSql, merge.target);
|
|
1247
1066
|
}
|
|
1248
|
-
function
|
|
1249
|
-
const
|
|
1250
|
-
const
|
|
1251
|
-
if (
|
|
1252
|
-
throw new QueryError("
|
|
1067
|
+
function buildDeleteBackupPlan(statementSql, params) {
|
|
1068
|
+
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
1069
|
+
const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
|
|
1070
|
+
if (deleteIndex === void 0 || fromIndex === void 0) {
|
|
1071
|
+
throw new QueryError("DELETE backup requires DELETE FROM <target> syntax");
|
|
1253
1072
|
}
|
|
1254
|
-
const whereIndex = findTopLevelKeyword(statementSql, "WHERE",
|
|
1073
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", fromIndex + "FROM".length);
|
|
1074
|
+
const targetEnd = whereIndex ?? statementSql.length;
|
|
1255
1075
|
return buildSelectPlan(
|
|
1256
|
-
"
|
|
1076
|
+
"delete",
|
|
1257
1077
|
statementSql,
|
|
1258
|
-
statementSql.slice(
|
|
1078
|
+
statementSql.slice(fromIndex + "FROM".length, targetEnd),
|
|
1259
1079
|
whereIndex,
|
|
1260
1080
|
params
|
|
1261
1081
|
);
|
|
1262
1082
|
}
|
|
1263
|
-
function
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
);
|
|
1083
|
+
function isWriteKeyword(keyword) {
|
|
1084
|
+
return WRITE_KEYWORDS.has(keyword);
|
|
1085
|
+
}
|
|
1086
|
+
function buildMergeWriteBackupPlan(statementSql, params) {
|
|
1087
|
+
const secondKeyword = topLevelTokens(statementSql)[1]?.keyword;
|
|
1088
|
+
if (secondKeyword === "DELTA") {
|
|
1089
|
+
return void 0;
|
|
1271
1090
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
const selectIndex = findTopLevelKeyword(statementSql, "SELECT", target.end);
|
|
1275
|
-
const withIndex = findTopLevelKeyword(statementSql, "WITH", target.end);
|
|
1276
|
-
if (selectIndex === void 0 && withIndex === void 0) {
|
|
1277
|
-
throw new BackupRequiredError(
|
|
1278
|
-
`${keyword} write refused: expected VALUES, WITH PRIMARY KEY, or a subquery`
|
|
1279
|
-
);
|
|
1280
|
-
}
|
|
1281
|
-
return buildSelectPlan(operation, statementSql, target.sql, void 0, params);
|
|
1091
|
+
if (secondKeyword !== "INTO") {
|
|
1092
|
+
throw new BackupRequiredError("MERGE write refused: expected MERGE INTO syntax");
|
|
1282
1093
|
}
|
|
1283
|
-
|
|
1284
|
-
return buildSelectPlan(operation, statementSql, target.sql, whereIndex, params);
|
|
1094
|
+
return buildMergeBackupPlan(statementSql, params);
|
|
1285
1095
|
}
|
|
1286
|
-
function
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1096
|
+
function dispatchWriteBackupPlan(keyword, statementSql, params) {
|
|
1097
|
+
switch (keyword) {
|
|
1098
|
+
case "UPDATE":
|
|
1099
|
+
return buildUpdateBackupPlan(statementSql, params);
|
|
1100
|
+
case "UPSERT":
|
|
1101
|
+
case "REPLACE":
|
|
1102
|
+
return buildUpsertBackupPlan(statementSql, params, keyword);
|
|
1103
|
+
case "MERGE":
|
|
1104
|
+
return buildMergeWriteBackupPlan(statementSql, params);
|
|
1105
|
+
case "DELETE":
|
|
1106
|
+
return buildDeleteBackupPlan(statementSql, params);
|
|
1290
1107
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
suffix = suffix.slice(close + 1).trim();
|
|
1301
|
-
if (suffix.length === 0) {
|
|
1302
|
-
return target.reference;
|
|
1303
|
-
}
|
|
1108
|
+
}
|
|
1109
|
+
function buildWriteBackupPlan(sql, params = []) {
|
|
1110
|
+
const trimmed = trimStatementSql(sql);
|
|
1111
|
+
const leading = firstKeyword(trimmed);
|
|
1112
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(trimmed) : void 0;
|
|
1113
|
+
if (leading === "WITH" && withResolved === void 0) {
|
|
1114
|
+
throw new BackupRequiredError(
|
|
1115
|
+
"WITH write refused: cannot determine the statement's real trailing keyword"
|
|
1116
|
+
);
|
|
1304
1117
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
if (alias === void 0 || skipTrivia(suffix, alias.end, suffix.length) !== suffix.length) {
|
|
1118
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
1119
|
+
if (!isWriteKeyword(keyword)) {
|
|
1308
1120
|
return void 0;
|
|
1309
1121
|
}
|
|
1310
|
-
|
|
1122
|
+
const startIndex = withResolved?.index ?? 0;
|
|
1123
|
+
const statementSql = trimmed.slice(startIndex);
|
|
1124
|
+
const slicedParams = params.slice(countPlaceholders(trimmed.slice(0, startIndex)));
|
|
1125
|
+
assertParamArity(statementSql, slicedParams);
|
|
1126
|
+
const plan = dispatchWriteBackupPlan(keyword, statementSql, slicedParams);
|
|
1127
|
+
return plan === void 0 ? void 0 : { ...plan, statementSql: trimmed };
|
|
1311
1128
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1129
|
+
|
|
1130
|
+
// src/backup.ts
|
|
1131
|
+
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
1132
|
+
var CF_HANA_DIR_NAME = "cf-hana";
|
|
1133
|
+
var BACKUPS_DIR_NAME = "backups";
|
|
1134
|
+
function defaultSaptoolsRoot() {
|
|
1135
|
+
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
1316
1136
|
}
|
|
1317
|
-
function
|
|
1318
|
-
return
|
|
1137
|
+
function backupTimestamp(now) {
|
|
1138
|
+
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
1319
1139
|
}
|
|
1320
|
-
function
|
|
1321
|
-
const
|
|
1322
|
-
const
|
|
1323
|
-
|
|
1324
|
-
throw new BackupRequiredError(
|
|
1325
|
-
"MERGE write refused: cannot derive a trustworthy backup target"
|
|
1326
|
-
);
|
|
1327
|
-
}
|
|
1328
|
-
return { index, token };
|
|
1140
|
+
function backupMonth(now) {
|
|
1141
|
+
const year = String(now.getUTCFullYear());
|
|
1142
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
1143
|
+
return `${year}${month}`;
|
|
1329
1144
|
}
|
|
1330
|
-
function
|
|
1331
|
-
const
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1145
|
+
function sanitizePathPart(value) {
|
|
1146
|
+
const trimmed = value?.trim() ?? "";
|
|
1147
|
+
const normalized = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1148
|
+
return normalized.length > 0 ? normalized : "unknown-target";
|
|
1149
|
+
}
|
|
1150
|
+
function backupBaseName(input, now) {
|
|
1151
|
+
return [sanitizePathPart(input.selector), input.operation, backupTimestamp(now)].join("-");
|
|
1152
|
+
}
|
|
1153
|
+
function cfHanaBackupRoot(saptoolsRoot) {
|
|
1154
|
+
return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
1155
|
+
}
|
|
1156
|
+
async function writeSqlBackup(input, options = {}) {
|
|
1157
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1158
|
+
const directory = join(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
|
|
1159
|
+
const baseName = backupBaseName(input, now);
|
|
1160
|
+
const statementPath = join(directory, `${baseName}.statement.sql`);
|
|
1161
|
+
const backupPath = join(directory, `${baseName}.sql`);
|
|
1162
|
+
const metadataPath = join(directory, `${baseName}.json`);
|
|
1163
|
+
const metadata = {
|
|
1164
|
+
selector: input.selector ?? null,
|
|
1165
|
+
operation: input.operation,
|
|
1166
|
+
statementPath,
|
|
1167
|
+
backupPath,
|
|
1168
|
+
rowCount: input.result.rowCount,
|
|
1169
|
+
createdAt: now.toISOString()
|
|
1170
|
+
};
|
|
1171
|
+
const csv2 = formatCsv(input.result);
|
|
1172
|
+
if (Buffer.byteLength(csv2) > (options.maxBytes ?? MAX_RESULT_STORE_BYTES)) {
|
|
1337
1173
|
throw new BackupRequiredError(
|
|
1338
|
-
"
|
|
1174
|
+
"Write backup exceeds the storage limit; the write was refused"
|
|
1339
1175
|
);
|
|
1340
1176
|
}
|
|
1341
|
-
|
|
1342
|
-
if (clauseIndexes.length === 0) {
|
|
1343
|
-
throw new BackupRequiredError("MERGE write refused: no supported WHEN clause was found");
|
|
1344
|
-
}
|
|
1345
|
-
const firstClause = tokens[clauseIndexes[0] ?? 0];
|
|
1346
|
-
return {
|
|
1347
|
-
tokens,
|
|
1348
|
-
into: into.token,
|
|
1349
|
-
using: using.token,
|
|
1350
|
-
on: on.token,
|
|
1351
|
-
target,
|
|
1352
|
-
clauseIndexes,
|
|
1353
|
-
matchedIndexes: clauseIndexes.filter((index) => tokens[index + 1]?.keyword === "MATCHED"),
|
|
1354
|
-
targetClause: statementSql.slice(into.token.end, using.token.start).trim(),
|
|
1355
|
-
sourceClause: statementSql.slice(using.token.end, on.token.start).trim(),
|
|
1356
|
-
onCondition: statementSql.slice(on.token.end, firstClause?.start).trim()
|
|
1357
|
-
};
|
|
1358
|
-
}
|
|
1359
|
-
function readMatchedActionTokens(tokens, matchedIndex) {
|
|
1360
|
-
const thenIndex = findToken(tokens, "THEN", matchedIndex + 2);
|
|
1361
|
-
if (thenIndex === void 0) {
|
|
1362
|
-
return void 0;
|
|
1363
|
-
}
|
|
1364
|
-
const thenToken = tokens[thenIndex];
|
|
1365
|
-
const actionToken = tokens[thenIndex + 1];
|
|
1366
|
-
const matchedToken = tokens[matchedIndex + 1];
|
|
1367
|
-
if (thenToken === void 0 || actionToken === void 0 || matchedToken === void 0) {
|
|
1368
|
-
return void 0;
|
|
1369
|
-
}
|
|
1370
|
-
if (!MERGE_MODIFY_ACTIONS.has(actionToken.keyword)) {
|
|
1371
|
-
return void 0;
|
|
1372
|
-
}
|
|
1373
|
-
const clauseToken = tokens[matchedIndex];
|
|
1374
|
-
return {
|
|
1375
|
-
thenToken,
|
|
1376
|
-
conditionToken: tokens[matchedIndex + 2],
|
|
1377
|
-
clauseStart: clauseToken === void 0 ? 0 : clauseToken.start
|
|
1378
|
-
};
|
|
1379
|
-
}
|
|
1380
|
-
function parseMatchedClause(statementSql, params, tokens, matchedIndex) {
|
|
1381
|
-
const action = readMatchedActionTokens(tokens, matchedIndex);
|
|
1382
|
-
if (action?.conditionToken === void 0) {
|
|
1383
|
-
return void 0;
|
|
1384
|
-
}
|
|
1385
|
-
if (action.conditionToken.keyword === "THEN") {
|
|
1386
|
-
return { clauseStart: action.clauseStart, matchedCondition: "", conditionParams: [] };
|
|
1387
|
-
}
|
|
1388
|
-
if (action.conditionToken.keyword !== "AND") {
|
|
1389
|
-
return void 0;
|
|
1390
|
-
}
|
|
1391
|
-
return {
|
|
1392
|
-
clauseStart: action.clauseStart,
|
|
1393
|
-
matchedCondition: statementSql.slice(action.conditionToken.end, action.thenToken.start).trim(),
|
|
1394
|
-
conditionParams: paramsInRange(
|
|
1395
|
-
statementSql,
|
|
1396
|
-
params,
|
|
1397
|
-
action.conditionToken.end,
|
|
1398
|
-
action.thenToken.start
|
|
1399
|
-
)
|
|
1400
|
-
};
|
|
1401
|
-
}
|
|
1402
|
-
function buildExactMergePlan(statementSql, params, merge, matchedIndex) {
|
|
1403
|
-
const targetReference = mergeTargetReference(statementSql, merge.target, merge.using.start);
|
|
1404
|
-
const matchedClause = parseMatchedClause(statementSql, params, merge.tokens, matchedIndex);
|
|
1405
|
-
if (targetReference === void 0 || matchedClause === void 0) {
|
|
1406
|
-
return void 0;
|
|
1407
|
-
}
|
|
1408
|
-
const sourceParams = paramsInRange(
|
|
1409
|
-
statementSql,
|
|
1410
|
-
params,
|
|
1411
|
-
merge.using.end,
|
|
1412
|
-
matchedClause.clauseStart
|
|
1413
|
-
);
|
|
1414
|
-
const conditionSql = matchedClause.matchedCondition.length === 0 ? "" : ` AND (${matchedClause.matchedCondition})`;
|
|
1415
|
-
return {
|
|
1416
|
-
operation: "merge",
|
|
1417
|
-
statementSql,
|
|
1418
|
-
selectSql: `SELECT ${targetReference}.* FROM ${merge.targetClause} WHERE EXISTS (SELECT 1 FROM ${merge.sourceClause} WHERE (${merge.onCondition})${conditionSql})`,
|
|
1419
|
-
selectParams: [...sourceParams, ...matchedClause.conditionParams]
|
|
1420
|
-
};
|
|
1421
|
-
}
|
|
1422
|
-
function buildMergeBackupPlan(statementSql, params) {
|
|
1423
|
-
const merge = parseMerge(statementSql);
|
|
1424
|
-
if (merge.matchedIndexes.length === 0) {
|
|
1425
|
-
return void 0;
|
|
1426
|
-
}
|
|
1427
|
-
if (merge.matchedIndexes.length > 1) {
|
|
1428
|
-
return wholeTargetPlan("merge", statementSql, merge.target);
|
|
1429
|
-
}
|
|
1430
|
-
if (merge.sourceClause.length === 0 || merge.onCondition.length === 0) {
|
|
1431
|
-
return wholeTargetPlan("merge", statementSql, merge.target);
|
|
1432
|
-
}
|
|
1433
|
-
const matchedIndex = merge.matchedIndexes[0] ?? 0;
|
|
1434
|
-
return buildExactMergePlan(statementSql, params, merge, matchedIndex) ?? wholeTargetPlan("merge", statementSql, merge.target);
|
|
1435
|
-
}
|
|
1436
|
-
function buildDeleteBackupPlan(statementSql, params) {
|
|
1437
|
-
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
1438
|
-
const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
|
|
1439
|
-
if (deleteIndex === void 0 || fromIndex === void 0) {
|
|
1440
|
-
throw new QueryError("DELETE backup requires DELETE FROM <target> syntax");
|
|
1441
|
-
}
|
|
1442
|
-
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", fromIndex + "FROM".length);
|
|
1443
|
-
const targetEnd = whereIndex ?? statementSql.length;
|
|
1444
|
-
return buildSelectPlan(
|
|
1445
|
-
"delete",
|
|
1446
|
-
statementSql,
|
|
1447
|
-
statementSql.slice(fromIndex + "FROM".length, targetEnd),
|
|
1448
|
-
whereIndex,
|
|
1449
|
-
params
|
|
1450
|
-
);
|
|
1451
|
-
}
|
|
1452
|
-
function isWriteKeyword(keyword) {
|
|
1453
|
-
return WRITE_KEYWORDS.has(keyword);
|
|
1454
|
-
}
|
|
1455
|
-
function buildMergeWriteBackupPlan(statementSql, params) {
|
|
1456
|
-
const secondKeyword = topLevelTokens(statementSql)[1]?.keyword;
|
|
1457
|
-
if (secondKeyword === "DELTA") {
|
|
1458
|
-
return void 0;
|
|
1459
|
-
}
|
|
1460
|
-
if (secondKeyword !== "INTO") {
|
|
1461
|
-
throw new BackupRequiredError("MERGE write refused: expected MERGE INTO syntax");
|
|
1462
|
-
}
|
|
1463
|
-
return buildMergeBackupPlan(statementSql, params);
|
|
1464
|
-
}
|
|
1465
|
-
function dispatchWriteBackupPlan(keyword, statementSql, params) {
|
|
1466
|
-
switch (keyword) {
|
|
1467
|
-
case "UPDATE":
|
|
1468
|
-
return buildUpdateBackupPlan(statementSql, params);
|
|
1469
|
-
case "UPSERT":
|
|
1470
|
-
case "REPLACE":
|
|
1471
|
-
return buildUpsertBackupPlan(statementSql, params, keyword);
|
|
1472
|
-
case "MERGE":
|
|
1473
|
-
return buildMergeWriteBackupPlan(statementSql, params);
|
|
1474
|
-
case "DELETE":
|
|
1475
|
-
return buildDeleteBackupPlan(statementSql, params);
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
function buildWriteBackupPlan(sql, params = []) {
|
|
1479
|
-
const statementSql = trimStatementSql(sql);
|
|
1480
|
-
const keyword = firstKeyword(statementSql);
|
|
1481
|
-
if (!isWriteKeyword(keyword)) {
|
|
1482
|
-
return void 0;
|
|
1483
|
-
}
|
|
1484
|
-
assertParamArity(statementSql, params);
|
|
1485
|
-
return dispatchWriteBackupPlan(keyword, statementSql, params);
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
// src/backup.ts
|
|
1489
|
-
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
1490
|
-
var CF_HANA_DIR_NAME = "cf-hana";
|
|
1491
|
-
var BACKUPS_DIR_NAME = "backups";
|
|
1492
|
-
function defaultSaptoolsRoot() {
|
|
1493
|
-
return join2(homedir2(), SAPTOOLS_DIR_NAME);
|
|
1494
|
-
}
|
|
1495
|
-
function backupTimestamp(now) {
|
|
1496
|
-
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
1497
|
-
}
|
|
1498
|
-
function backupMonth(now) {
|
|
1499
|
-
const year = String(now.getUTCFullYear());
|
|
1500
|
-
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
1501
|
-
return `${year}${month}`;
|
|
1502
|
-
}
|
|
1503
|
-
function sanitizePathPart(value) {
|
|
1504
|
-
const trimmed = value?.trim() ?? "";
|
|
1505
|
-
const normalized = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1506
|
-
return normalized.length > 0 ? normalized : "unknown-target";
|
|
1507
|
-
}
|
|
1508
|
-
function backupBaseName(input, now) {
|
|
1509
|
-
return [sanitizePathPart(input.selector), input.operation, backupTimestamp(now)].join("-");
|
|
1510
|
-
}
|
|
1511
|
-
function cfHanaBackupRoot(saptoolsRoot) {
|
|
1512
|
-
return join2(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
1513
|
-
}
|
|
1514
|
-
async function writeSqlBackup(input, options = {}) {
|
|
1515
|
-
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1516
|
-
const directory = join2(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
|
|
1517
|
-
const baseName = backupBaseName(input, now);
|
|
1518
|
-
const statementPath = join2(directory, `${baseName}.statement.sql`);
|
|
1519
|
-
const backupPath = join2(directory, `${baseName}.sql`);
|
|
1520
|
-
const metadataPath = join2(directory, `${baseName}.json`);
|
|
1521
|
-
const metadata = {
|
|
1522
|
-
selector: input.selector ?? null,
|
|
1523
|
-
operation: input.operation,
|
|
1524
|
-
statementPath,
|
|
1525
|
-
backupPath,
|
|
1526
|
-
rowCount: input.result.rowCount,
|
|
1527
|
-
createdAt: now.toISOString()
|
|
1528
|
-
};
|
|
1529
|
-
const csv2 = formatCsv(input.result);
|
|
1530
|
-
if (Buffer.byteLength(csv2) > (options.maxBytes ?? MAX_RESULT_STORE_BYTES)) {
|
|
1531
|
-
throw new BackupRequiredError(
|
|
1532
|
-
"Write backup exceeds the storage limit; the write was refused"
|
|
1533
|
-
);
|
|
1534
|
-
}
|
|
1535
|
-
await mkdir2(directory, { recursive: true, mode: 448 });
|
|
1177
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
1536
1178
|
await Promise.all([
|
|
1537
|
-
|
|
1179
|
+
writeFile(statementPath, `${input.statementSql}
|
|
1538
1180
|
`, { encoding: "utf8", mode: 384 }),
|
|
1539
|
-
|
|
1540
|
-
|
|
1181
|
+
writeFile(backupPath, csv2, { encoding: "utf8", mode: 384 }),
|
|
1182
|
+
writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
|
|
1541
1183
|
`, {
|
|
1542
1184
|
encoding: "utf8",
|
|
1543
1185
|
mode: 384
|
|
@@ -1681,9 +1323,9 @@ async function listColumns(connection, schema, table) {
|
|
|
1681
1323
|
|
|
1682
1324
|
// src/cf.ts
|
|
1683
1325
|
import { execFile } from "child_process";
|
|
1684
|
-
import { mkdtemp, rm
|
|
1326
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
1685
1327
|
import { tmpdir } from "os";
|
|
1686
|
-
import { join as
|
|
1328
|
+
import { join as join2 } from "path";
|
|
1687
1329
|
import { promisify } from "util";
|
|
1688
1330
|
var execFileAsync = promisify(execFile);
|
|
1689
1331
|
var MAX_BUFFER = 16 * 1024 * 1024;
|
|
@@ -1789,12 +1431,12 @@ function getRegionKeyForApi(apiEndpoint) {
|
|
|
1789
1431
|
return /^https:\/\/api\.cf\.([a-z]{2}\d{2}(?:-\d{3})?)\.hana\.ondemand\.com$/.exec(normalized)?.[1];
|
|
1790
1432
|
}
|
|
1791
1433
|
async function withCfSession(work) {
|
|
1792
|
-
const cfHome = await mkdtemp(
|
|
1434
|
+
const cfHome = await mkdtemp(join2(tmpdir(), "saptools-cf-hana-"));
|
|
1793
1435
|
const ctx = { cfHome };
|
|
1794
1436
|
try {
|
|
1795
1437
|
return await work(ctx);
|
|
1796
1438
|
} finally {
|
|
1797
|
-
await
|
|
1439
|
+
await rm(cfHome, { recursive: true, force: true });
|
|
1798
1440
|
}
|
|
1799
1441
|
}
|
|
1800
1442
|
function resolveCfBin() {
|
|
@@ -1811,9 +1453,9 @@ function buildEnv(ctx, overrides = {}) {
|
|
|
1811
1453
|
env["CF_HOME"] = ctx.cfHome;
|
|
1812
1454
|
return env;
|
|
1813
1455
|
}
|
|
1814
|
-
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1456
|
+
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = CF_RETRY_ATTEMPTS) {
|
|
1815
1457
|
let lastErr;
|
|
1816
|
-
for (let attempt = 0; attempt <
|
|
1458
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1817
1459
|
try {
|
|
1818
1460
|
const { stdout } = await execFileAsync(bin, args, {
|
|
1819
1461
|
env,
|
|
@@ -1832,18 +1474,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
1832
1474
|
if (isEnoent || !isTimeout && !isNetworkFlake) {
|
|
1833
1475
|
throw err;
|
|
1834
1476
|
}
|
|
1835
|
-
if (attempt <
|
|
1477
|
+
if (attempt < maxAttempts - 1) {
|
|
1836
1478
|
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
1837
1479
|
}
|
|
1838
1480
|
}
|
|
1839
1481
|
}
|
|
1840
1482
|
throw lastErr;
|
|
1841
1483
|
}
|
|
1842
|
-
async function runCf(args, ctx, overrides = {}) {
|
|
1484
|
+
async function runCf(args, ctx, overrides = {}, execOptions = {}) {
|
|
1843
1485
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1844
1486
|
const env = buildEnv(ctx, overrides);
|
|
1845
1487
|
try {
|
|
1846
|
-
return await execWithRetries(
|
|
1488
|
+
return await execWithRetries(
|
|
1489
|
+
bin,
|
|
1490
|
+
[...argsPrefix, ...args],
|
|
1491
|
+
env,
|
|
1492
|
+
execOptions.timeoutMs,
|
|
1493
|
+
execOptions.maxAttempts
|
|
1494
|
+
);
|
|
1847
1495
|
} catch (lastErr) {
|
|
1848
1496
|
const e = lastErr;
|
|
1849
1497
|
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
@@ -1873,15 +1521,26 @@ async function cfEnvDirect(appName) {
|
|
|
1873
1521
|
delete env["SAP_PASSWORD"];
|
|
1874
1522
|
return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
|
|
1875
1523
|
}
|
|
1876
|
-
async function cfApps(ctx) {
|
|
1877
|
-
return await runCf(
|
|
1524
|
+
async function cfApps(ctx, timeoutMs) {
|
|
1525
|
+
return await runCf(
|
|
1526
|
+
["apps"],
|
|
1527
|
+
ctx,
|
|
1528
|
+
{},
|
|
1529
|
+
timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
|
|
1530
|
+
);
|
|
1878
1531
|
}
|
|
1879
|
-
async function cfAppsDirect() {
|
|
1532
|
+
async function cfAppsDirect(timeoutMs) {
|
|
1880
1533
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1881
1534
|
const env = { ...process.env };
|
|
1882
1535
|
delete env["SAP_EMAIL"];
|
|
1883
1536
|
delete env["SAP_PASSWORD"];
|
|
1884
|
-
return await execWithRetries(
|
|
1537
|
+
return await execWithRetries(
|
|
1538
|
+
bin,
|
|
1539
|
+
[...argsPrefix, "apps"],
|
|
1540
|
+
env,
|
|
1541
|
+
timeoutMs,
|
|
1542
|
+
timeoutMs === void 0 ? void 0 : 1
|
|
1543
|
+
);
|
|
1885
1544
|
}
|
|
1886
1545
|
async function readCurrentCfTarget() {
|
|
1887
1546
|
const { bin, argsPrefix } = resolveCfBin();
|
|
@@ -2025,7 +1684,7 @@ function extractCfEnvApplicationIdentity(stdout) {
|
|
|
2025
1684
|
}
|
|
2026
1685
|
throw error;
|
|
2027
1686
|
}
|
|
2028
|
-
if (!
|
|
1687
|
+
if (!isRecord(application)) {
|
|
2029
1688
|
throw new Error("VCAP_APPLICATION must be an object");
|
|
2030
1689
|
}
|
|
2031
1690
|
return {
|
|
@@ -2045,11 +1704,11 @@ function extractVcapSection(stdout) {
|
|
|
2045
1704
|
const block = end === -1 ? after : after.slice(0, end);
|
|
2046
1705
|
return block.trim();
|
|
2047
1706
|
}
|
|
2048
|
-
function
|
|
1707
|
+
function isRecord(v) {
|
|
2049
1708
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2050
1709
|
}
|
|
2051
1710
|
function assertCreds(raw) {
|
|
2052
|
-
if (!
|
|
1711
|
+
if (!isRecord(raw)) {
|
|
2053
1712
|
throw new Error("HANA credentials must be an object");
|
|
2054
1713
|
}
|
|
2055
1714
|
const req = ["host", "port", "user", "password", "schema", "hdi_user", "hdi_password", "url", "database_id", "certificate"];
|
|
@@ -2082,7 +1741,7 @@ function extractHanaBindingsFromCfEnv(stdout) {
|
|
|
2082
1741
|
} catch {
|
|
2083
1742
|
throw new Error("VCAP_SERVICES is not valid JSON");
|
|
2084
1743
|
}
|
|
2085
|
-
if (!
|
|
1744
|
+
if (!isRecord(vcap)) {
|
|
2086
1745
|
throw new Error("VCAP_SERVICES must be an object");
|
|
2087
1746
|
}
|
|
2088
1747
|
const hana = vcap["hana"];
|
|
@@ -2093,7 +1752,7 @@ function extractHanaBindingsFromCfEnv(stdout) {
|
|
|
2093
1752
|
throw new Error("VCAP_SERVICES.hana must be an array when present");
|
|
2094
1753
|
}
|
|
2095
1754
|
return hana.map((b) => {
|
|
2096
|
-
if (!
|
|
1755
|
+
if (!isRecord(b)) {
|
|
2097
1756
|
throw new Error("HANA binding must be an object");
|
|
2098
1757
|
}
|
|
2099
1758
|
const credsRaw = assertCreds(b["credentials"]);
|
|
@@ -2788,6 +2447,13 @@ async function connectHdb(params) {
|
|
|
2788
2447
|
password: params.password,
|
|
2789
2448
|
ca: params.certificate,
|
|
2790
2449
|
useTLS: true,
|
|
2450
|
+
// HANA Cloud can reactively redirect a connection mid-auth to an
|
|
2451
|
+
// internal per-node hostname (pod-locality routing). That target is
|
|
2452
|
+
// often unreachable outside SAP's own network, and for a tunneled
|
|
2453
|
+
// connection it would silently abandon the SSH forward for a fresh,
|
|
2454
|
+
// untunneled socket. The original bound host is always a real, working
|
|
2455
|
+
// endpoint, so disabling the redirect is safe on the direct path too.
|
|
2456
|
+
disableCloudRedirect: true,
|
|
2791
2457
|
...params.servername === void 0 ? {} : { servername: params.servername }
|
|
2792
2458
|
});
|
|
2793
2459
|
await openClient(client, params.connectTimeoutMs);
|
|
@@ -2820,16 +2486,16 @@ function createDriver(name) {
|
|
|
2820
2486
|
}
|
|
2821
2487
|
|
|
2822
2488
|
// src/history.ts
|
|
2823
|
-
import { appendFile as appendFile2, mkdir as
|
|
2824
|
-
import { homedir as
|
|
2825
|
-
import { join as
|
|
2489
|
+
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm as rm2 } from "fs/promises";
|
|
2490
|
+
import { homedir as homedir2 } from "os";
|
|
2491
|
+
import { join as join3 } from "path";
|
|
2826
2492
|
var SAPTOOLS_DIR_NAME2 = ".saptools";
|
|
2827
2493
|
var CF_HANA_DIR_NAME2 = "cf-hana";
|
|
2828
2494
|
var HISTORIES_DIR_NAME = "histories";
|
|
2829
2495
|
var HISTORY_RETENTION_DAYS = 5;
|
|
2830
2496
|
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
2831
2497
|
function defaultSaptoolsRoot2() {
|
|
2832
|
-
return
|
|
2498
|
+
return join3(homedir2(), SAPTOOLS_DIR_NAME2);
|
|
2833
2499
|
}
|
|
2834
2500
|
function padDatePart(value) {
|
|
2835
2501
|
return value.toString().padStart(2, "0");
|
|
@@ -2851,10 +2517,10 @@ function resolveSaptoolsRoot(root) {
|
|
|
2851
2517
|
return root ?? defaultSaptoolsRoot2();
|
|
2852
2518
|
}
|
|
2853
2519
|
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
2854
|
-
return
|
|
2520
|
+
return join3(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
|
|
2855
2521
|
}
|
|
2856
2522
|
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
2857
|
-
return
|
|
2523
|
+
return join3(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
2858
2524
|
}
|
|
2859
2525
|
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
2860
2526
|
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
@@ -2870,7 +2536,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
|
|
|
2870
2536
|
const cutoffKey = retentionCutoffKey(now);
|
|
2871
2537
|
await Promise.all(
|
|
2872
2538
|
files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
|
|
2873
|
-
await
|
|
2539
|
+
await rm2(join3(historyDir, file), { force: true });
|
|
2874
2540
|
})
|
|
2875
2541
|
);
|
|
2876
2542
|
}
|
|
@@ -2881,7 +2547,7 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
2881
2547
|
at: now.toISOString(),
|
|
2882
2548
|
...input
|
|
2883
2549
|
};
|
|
2884
|
-
await
|
|
2550
|
+
await mkdir2(historyDir, { recursive: true, mode: 448 });
|
|
2885
2551
|
await appendFile2(sqlHistoryFilePath(now, options.saptoolsRoot), `${JSON.stringify(entry)}
|
|
2886
2552
|
`, {
|
|
2887
2553
|
encoding: "utf8",
|
|
@@ -2897,112 +2563,35 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
2897
2563
|
// src/safety.ts
|
|
2898
2564
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
2899
2565
|
var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
2900
|
-
function
|
|
2901
|
-
|
|
2902
|
-
let index = start + 1;
|
|
2903
|
-
while (index < sql.length) {
|
|
2904
|
-
if (sql[index] === quote) {
|
|
2905
|
-
if (sql[index + 1] === quote) {
|
|
2906
|
-
index += 2;
|
|
2907
|
-
continue;
|
|
2908
|
-
}
|
|
2909
|
-
index += 1;
|
|
2910
|
-
break;
|
|
2911
|
-
}
|
|
2912
|
-
index += 1;
|
|
2913
|
-
}
|
|
2914
|
-
return index;
|
|
2915
|
-
}
|
|
2916
|
-
function skipLineComment3(sql, start) {
|
|
2917
|
-
let index = start + 2;
|
|
2918
|
-
while (index < sql.length && sql[index] !== "\n") {
|
|
2919
|
-
index += 1;
|
|
2920
|
-
}
|
|
2921
|
-
return index;
|
|
2566
|
+
function isUnconditionalMergeDelete(sql) {
|
|
2567
|
+
return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
|
|
2922
2568
|
}
|
|
2923
|
-
function
|
|
2924
|
-
|
|
2925
|
-
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
2926
|
-
index += 1;
|
|
2927
|
-
}
|
|
2928
|
-
return Math.min(index + 2, sql.length);
|
|
2569
|
+
function isMalformedReplace(sql) {
|
|
2570
|
+
return !hasTopLevelKeyword(sql, "VALUES") && !hasTopLevelKeyword(sql, "SELECT") && !hasTopLevelKeyword(sql, "WITH");
|
|
2929
2571
|
}
|
|
2930
|
-
function
|
|
2931
|
-
let masked = "";
|
|
2572
|
+
function trailingLineCommentIndex(sql) {
|
|
2932
2573
|
let index = 0;
|
|
2933
2574
|
while (index < sql.length) {
|
|
2934
2575
|
const char = sql[index];
|
|
2935
2576
|
if (char === "'" || char === '"') {
|
|
2936
|
-
|
|
2937
|
-
masked += " ".repeat(end - index);
|
|
2938
|
-
index = end;
|
|
2577
|
+
index = skipQuotedText2(sql, index);
|
|
2939
2578
|
continue;
|
|
2940
2579
|
}
|
|
2941
|
-
if (char === "
|
|
2942
|
-
|
|
2943
|
-
masked += " ".repeat(end - index);
|
|
2944
|
-
index = end;
|
|
2580
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
2581
|
+
index = skipBlockComment2(sql, index);
|
|
2945
2582
|
continue;
|
|
2946
2583
|
}
|
|
2947
|
-
if (char === "
|
|
2948
|
-
const
|
|
2949
|
-
|
|
2950
|
-
|
|
2584
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
2585
|
+
const lineEnd = sql.indexOf("\n", index + 2);
|
|
2586
|
+
if (lineEnd === -1 || sql.slice(lineEnd + 1).trim().length === 0) {
|
|
2587
|
+
return index;
|
|
2588
|
+
}
|
|
2589
|
+
index = lineEnd + 1;
|
|
2951
2590
|
continue;
|
|
2952
2591
|
}
|
|
2953
|
-
masked += char ?? "";
|
|
2954
2592
|
index += 1;
|
|
2955
2593
|
}
|
|
2956
|
-
return
|
|
2957
|
-
}
|
|
2958
|
-
function hasTopLevelKeyword(sql, keyword) {
|
|
2959
|
-
const masked = maskIgnoredSqlText(sql);
|
|
2960
|
-
let depth = 0;
|
|
2961
|
-
for (let index = 0; index < masked.length; index += 1) {
|
|
2962
|
-
const char = masked[index];
|
|
2963
|
-
if (char === "(") {
|
|
2964
|
-
depth += 1;
|
|
2965
|
-
continue;
|
|
2966
|
-
}
|
|
2967
|
-
if (char === ")") {
|
|
2968
|
-
depth = Math.max(0, depth - 1);
|
|
2969
|
-
continue;
|
|
2970
|
-
}
|
|
2971
|
-
if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
|
|
2972
|
-
return true;
|
|
2973
|
-
}
|
|
2974
|
-
}
|
|
2975
|
-
return false;
|
|
2976
|
-
}
|
|
2977
|
-
function isUnconditionalMergeDelete(sql) {
|
|
2978
|
-
return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
|
|
2979
|
-
}
|
|
2980
|
-
function isMalformedReplace(sql) {
|
|
2981
|
-
return !hasTopLevelKeyword(sql, "VALUES") && !hasTopLevelKeyword(sql, "SELECT") && !hasTopLevelKeyword(sql, "WITH");
|
|
2982
|
-
}
|
|
2983
|
-
function trailingLineCommentIndex(sql) {
|
|
2984
|
-
let index = 0;
|
|
2985
|
-
while (index < sql.length) {
|
|
2986
|
-
const char = sql[index];
|
|
2987
|
-
if (char === "'" || char === '"') {
|
|
2988
|
-
index = skipQuotedText2(sql, index);
|
|
2989
|
-
continue;
|
|
2990
|
-
}
|
|
2991
|
-
if (char === "/" && sql[index + 1] === "*") {
|
|
2992
|
-
index = skipBlockComment3(sql, index);
|
|
2993
|
-
continue;
|
|
2994
|
-
}
|
|
2995
|
-
if (char === "-" && sql[index + 1] === "-") {
|
|
2996
|
-
const lineEnd = sql.indexOf("\n", index + 2);
|
|
2997
|
-
if (lineEnd === -1 || sql.slice(lineEnd + 1).trim().length === 0) {
|
|
2998
|
-
return index;
|
|
2999
|
-
}
|
|
3000
|
-
index = lineEnd + 1;
|
|
3001
|
-
continue;
|
|
3002
|
-
}
|
|
3003
|
-
index += 1;
|
|
3004
|
-
}
|
|
3005
|
-
return void 0;
|
|
2594
|
+
return void 0;
|
|
3006
2595
|
}
|
|
3007
2596
|
function appendLimit(sql, limit) {
|
|
3008
2597
|
const trimmed = sql.replace(/(?<![\s;])[\s;]+$/, "");
|
|
@@ -3016,17 +2605,24 @@ function appendLimit(sql, limit) {
|
|
|
3016
2605
|
}
|
|
3017
2606
|
function inspectStatement(sql) {
|
|
3018
2607
|
const kind = classifyStatement(sql);
|
|
3019
|
-
const
|
|
2608
|
+
const leading = firstKeyword(sql);
|
|
2609
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(sql) : void 0;
|
|
2610
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
2611
|
+
const tail = withResolved === void 0 ? sql : sql.slice(withResolved.index);
|
|
3020
2612
|
if (kind === "ddl") {
|
|
3021
2613
|
return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
|
|
3022
2614
|
}
|
|
3023
2615
|
if (kind === "dml") {
|
|
3024
|
-
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(
|
|
2616
|
+
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(tail, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(tail) || keyword === "REPLACE" && isMalformedReplace(tail);
|
|
3025
2617
|
return {
|
|
3026
2618
|
kind,
|
|
3027
2619
|
destructive
|
|
3028
2620
|
};
|
|
3029
2621
|
}
|
|
2622
|
+
if (kind === "unknown") {
|
|
2623
|
+
const unresolvedWith = leading === "WITH" && withResolved === void 0;
|
|
2624
|
+
return { kind, destructive: keyword === "CALL" || unresolvedWith };
|
|
2625
|
+
}
|
|
3030
2626
|
return { kind, destructive: false };
|
|
3031
2627
|
}
|
|
3032
2628
|
function evaluateGuard(sql, config) {
|
|
@@ -3070,17 +2666,17 @@ function applyAutoLimit(sql, limit) {
|
|
|
3070
2666
|
}
|
|
3071
2667
|
|
|
3072
2668
|
// src/tunnel/cache.ts
|
|
3073
|
-
import { createHash
|
|
3074
|
-
import { mkdir as
|
|
3075
|
-
import { homedir as
|
|
3076
|
-
import { join as
|
|
2669
|
+
import { createHash } from "crypto";
|
|
2670
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile2, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
2671
|
+
import { homedir as homedir3 } from "os";
|
|
2672
|
+
import { join as join5 } from "path";
|
|
3077
2673
|
|
|
3078
2674
|
// src/tunnel/process.ts
|
|
3079
2675
|
import { spawn } from "child_process";
|
|
3080
|
-
import { mkdtemp as mkdtemp2, rm as
|
|
2676
|
+
import { mkdtemp as mkdtemp2, open, readFile, rm as rm3 } from "fs/promises";
|
|
3081
2677
|
import { connect as netConnect, createServer } from "net";
|
|
3082
2678
|
import { tmpdir as tmpdir2 } from "os";
|
|
3083
|
-
import { join as
|
|
2679
|
+
import { join as join4 } from "path";
|
|
3084
2680
|
var PORT_POLL_INTERVAL_MS = 100;
|
|
3085
2681
|
var DEFAULT_PORT_PROBE_TIMEOUT_MS = 500;
|
|
3086
2682
|
function assertPositiveSafeInteger(label, value) {
|
|
@@ -3147,7 +2743,7 @@ async function withScopedCfSession(selectorSource, target, sapCredentials, work)
|
|
|
3147
2743
|
"SAP credentials are required to open a tunnel session for an explicit selector"
|
|
3148
2744
|
);
|
|
3149
2745
|
}
|
|
3150
|
-
const cfHome = await mkdtemp2(
|
|
2746
|
+
const cfHome = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-"));
|
|
3151
2747
|
try {
|
|
3152
2748
|
const ctx = { cfHome };
|
|
3153
2749
|
await cfApi(target.apiEndpoint, ctx);
|
|
@@ -3155,7 +2751,7 @@ async function withScopedCfSession(selectorSource, target, sapCredentials, work)
|
|
|
3155
2751
|
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
3156
2752
|
return await work(ctx);
|
|
3157
2753
|
} finally {
|
|
3158
|
-
await
|
|
2754
|
+
await rm3(cfHome, { recursive: true, force: true });
|
|
3159
2755
|
}
|
|
3160
2756
|
}
|
|
3161
2757
|
function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess) {
|
|
@@ -3184,8 +2780,8 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
|
|
|
3184
2780
|
child.on("exit", onAbort);
|
|
3185
2781
|
child.on("error", onAbort);
|
|
3186
2782
|
timers.poll = setInterval(() => {
|
|
3187
|
-
void probePort(localPort).then((
|
|
3188
|
-
if (
|
|
2783
|
+
void probePort(localPort).then((open2) => {
|
|
2784
|
+
if (open2 && child.pid !== void 0) {
|
|
3189
2785
|
finish({ localPort, pid: child.pid });
|
|
3190
2786
|
}
|
|
3191
2787
|
});
|
|
@@ -3193,6 +2789,36 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
|
|
|
3193
2789
|
timers.deadline = setTimeout(finish, boundMs);
|
|
3194
2790
|
});
|
|
3195
2791
|
}
|
|
2792
|
+
var STDERR_TAIL_MAX_CHARS = 4096;
|
|
2793
|
+
async function openStderrCapture() {
|
|
2794
|
+
try {
|
|
2795
|
+
const dir = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-log-"));
|
|
2796
|
+
const path = join4(dir, "stderr.log");
|
|
2797
|
+
const handle = await open(path, "w");
|
|
2798
|
+
return {
|
|
2799
|
+
path,
|
|
2800
|
+
fd: handle.fd,
|
|
2801
|
+
close: async () => {
|
|
2802
|
+
await handle.close().catch(() => {
|
|
2803
|
+
});
|
|
2804
|
+
await rm3(dir, { recursive: true, force: true });
|
|
2805
|
+
}
|
|
2806
|
+
};
|
|
2807
|
+
} catch {
|
|
2808
|
+
return void 0;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
async function readStderrTail(path) {
|
|
2812
|
+
try {
|
|
2813
|
+
const content = (await readFile(path, "utf8")).trim().replace(/\s+/g, " ");
|
|
2814
|
+
if (content.length === 0) {
|
|
2815
|
+
return void 0;
|
|
2816
|
+
}
|
|
2817
|
+
return content.length > STDERR_TAIL_MAX_CHARS ? content.slice(-STDERR_TAIL_MAX_CHARS) : content;
|
|
2818
|
+
} catch {
|
|
2819
|
+
return void 0;
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
3196
2822
|
async function spawnTunnel(params, deps = {}) {
|
|
3197
2823
|
assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
|
|
3198
2824
|
const remainingMs = params.deadline - Date.now();
|
|
@@ -3217,14 +2843,30 @@ async function spawnTunnel(params, deps = {}) {
|
|
|
3217
2843
|
`sleep ${String(params.keepaliveSeconds)}`
|
|
3218
2844
|
];
|
|
3219
2845
|
const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
|
|
3220
|
-
const
|
|
3221
|
-
|
|
2846
|
+
const openCapture = deps.openStderrCapture ?? openStderrCapture;
|
|
2847
|
+
const capture = await openCapture();
|
|
2848
|
+
const stdio = capture === void 0 ? "ignore" : ["ignore", "ignore", capture.fd];
|
|
2849
|
+
const child = spawnProcess(bin, args, { detached: true, stdio, env });
|
|
2850
|
+
const result = await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
|
|
2851
|
+
if (capture !== void 0) {
|
|
2852
|
+
if (result === void 0) {
|
|
2853
|
+
try {
|
|
2854
|
+
const tail = await readStderrTail(capture.path);
|
|
2855
|
+
if (tail !== void 0) {
|
|
2856
|
+
deps.onFailureDiagnostic?.(tail);
|
|
2857
|
+
}
|
|
2858
|
+
} catch {
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
await capture.close();
|
|
2862
|
+
}
|
|
2863
|
+
return result;
|
|
3222
2864
|
}
|
|
3223
2865
|
|
|
3224
2866
|
// src/tunnel/cache.ts
|
|
3225
2867
|
var DEFAULT_ESTABLISHING_STALE_MS = 2 * DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
|
|
3226
2868
|
var DEFAULT_POLL_INTERVAL_MS = 300;
|
|
3227
|
-
function
|
|
2869
|
+
function isRecord2(value) {
|
|
3228
2870
|
return typeof value === "object" && value !== null;
|
|
3229
2871
|
}
|
|
3230
2872
|
function defaultIsProcessAlive(pid) {
|
|
@@ -3232,7 +2874,7 @@ function defaultIsProcessAlive(pid) {
|
|
|
3232
2874
|
process.kill(pid, 0);
|
|
3233
2875
|
return true;
|
|
3234
2876
|
} catch (error) {
|
|
3235
|
-
return
|
|
2877
|
+
return isRecord2(error) && error["code"] !== "ESRCH";
|
|
3236
2878
|
}
|
|
3237
2879
|
}
|
|
3238
2880
|
function sleep(ms) {
|
|
@@ -3241,16 +2883,16 @@ function sleep(ms) {
|
|
|
3241
2883
|
});
|
|
3242
2884
|
}
|
|
3243
2885
|
function tunnelCacheRoot(saptoolsRoot) {
|
|
3244
|
-
return
|
|
2886
|
+
return join5(saptoolsRoot ?? join5(homedir3(), ".saptools"), "cf-hana", "tunnel");
|
|
3245
2887
|
}
|
|
3246
2888
|
function tunnelCacheKey(host) {
|
|
3247
|
-
return
|
|
2889
|
+
return createHash("sha256").update(host).digest("hex");
|
|
3248
2890
|
}
|
|
3249
2891
|
function tunnelCachePath(host, saptoolsRoot) {
|
|
3250
|
-
return
|
|
2892
|
+
return join5(tunnelCacheRoot(saptoolsRoot), `${tunnelCacheKey(host)}.json`);
|
|
3251
2893
|
}
|
|
3252
2894
|
function isOrgKey(value) {
|
|
3253
|
-
return
|
|
2895
|
+
return isRecord2(value) && typeof value["apiEndpoint"] === "string" && typeof value["orgName"] === "string";
|
|
3254
2896
|
}
|
|
3255
2897
|
function hasEstablishingFields(value) {
|
|
3256
2898
|
return value["status"] === "establishing" && typeof value["host"] === "string" && typeof value["startedAt"] === "string" && typeof value["ownerPid"] === "number" && isOrgKey(value["orgKey"]);
|
|
@@ -3259,7 +2901,7 @@ function hasReadyFields(value) {
|
|
|
3259
2901
|
return value["status"] === "ready" && typeof value["host"] === "string" && typeof value["localPort"] === "number" && typeof value["pid"] === "number" && typeof value["app"] === "string" && typeof value["expiresAt"] === "string" && isOrgKey(value["orgKey"]);
|
|
3260
2902
|
}
|
|
3261
2903
|
function isTunnelCacheRecord(value) {
|
|
3262
|
-
return
|
|
2904
|
+
return isRecord2(value) && value["version"] === 1 && (hasEstablishingFields(value) || hasReadyFields(value));
|
|
3263
2905
|
}
|
|
3264
2906
|
async function parseTunnelCacheFile(path) {
|
|
3265
2907
|
try {
|
|
@@ -3303,7 +2945,7 @@ function isEstablishingStale(entry, options) {
|
|
|
3303
2945
|
}
|
|
3304
2946
|
async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
|
|
3305
2947
|
const root = tunnelCacheRoot(options.saptoolsRoot);
|
|
3306
|
-
await
|
|
2948
|
+
await mkdir3(root, { recursive: true, mode: 448 });
|
|
3307
2949
|
const record = {
|
|
3308
2950
|
version: 1,
|
|
3309
2951
|
status: "establishing",
|
|
@@ -3313,7 +2955,7 @@ async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
|
|
|
3313
2955
|
orgKey
|
|
3314
2956
|
};
|
|
3315
2957
|
try {
|
|
3316
|
-
await
|
|
2958
|
+
await writeFile2(tunnelCachePath(host, options.saptoolsRoot), `${JSON.stringify(record)}
|
|
3317
2959
|
`, {
|
|
3318
2960
|
encoding: "utf8",
|
|
3319
2961
|
mode: 384,
|
|
@@ -3321,7 +2963,7 @@ async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
|
|
|
3321
2963
|
});
|
|
3322
2964
|
return true;
|
|
3323
2965
|
} catch (error) {
|
|
3324
|
-
if (
|
|
2966
|
+
if (isRecord2(error) && error["code"] === "EEXIST") {
|
|
3325
2967
|
return false;
|
|
3326
2968
|
}
|
|
3327
2969
|
throw error;
|
|
@@ -3341,7 +2983,7 @@ async function claimEstablishing(host, ownerPid, orgKey, options = {}) {
|
|
|
3341
2983
|
if (!isEstablishingStale(existing, options)) {
|
|
3342
2984
|
return { outcome: "wait" };
|
|
3343
2985
|
}
|
|
3344
|
-
await
|
|
2986
|
+
await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
|
|
3345
2987
|
return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
|
|
3346
2988
|
}
|
|
3347
2989
|
async function waitForEstablishment(host, deadline, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, options = {}) {
|
|
@@ -3365,17 +3007,17 @@ async function finalizeEstablishingReady(host, record, options = {}) {
|
|
|
3365
3007
|
const path = tunnelCachePath(host, options.saptoolsRoot);
|
|
3366
3008
|
const tempPath = `${path}.tmp-${String(process.pid)}`;
|
|
3367
3009
|
const stored = { version: 1, status: "ready", host, ...record };
|
|
3368
|
-
await
|
|
3369
|
-
await
|
|
3370
|
-
await
|
|
3010
|
+
await mkdir3(root, { recursive: true, mode: 448 });
|
|
3011
|
+
await rm4(tempPath, { force: true });
|
|
3012
|
+
await writeFile2(tempPath, `${JSON.stringify(stored)}
|
|
3371
3013
|
`, { encoding: "utf8", mode: 384 });
|
|
3372
|
-
await
|
|
3014
|
+
await rename(tempPath, path);
|
|
3373
3015
|
}
|
|
3374
3016
|
async function finalizeEstablishingFailed(host, options = {}) {
|
|
3375
|
-
await
|
|
3017
|
+
await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
|
|
3376
3018
|
}
|
|
3377
3019
|
async function evictTunnelCache(host, options = {}) {
|
|
3378
|
-
await
|
|
3020
|
+
await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
|
|
3379
3021
|
}
|
|
3380
3022
|
function sameOrg(a, b) {
|
|
3381
3023
|
return a.apiEndpoint === b.apiEndpoint && a.orgName === b.orgName;
|
|
@@ -3388,19 +3030,19 @@ async function reapOneFile(path, currentOrgKey, options) {
|
|
|
3388
3030
|
const killProcess = options.killProcess ?? killTunnelProcess;
|
|
3389
3031
|
if (entry.status === "establishing") {
|
|
3390
3032
|
if (isEstablishingStale(entry, options)) {
|
|
3391
|
-
await
|
|
3033
|
+
await rm4(path, { force: true });
|
|
3392
3034
|
}
|
|
3393
3035
|
return;
|
|
3394
3036
|
}
|
|
3395
3037
|
const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
|
|
3396
3038
|
if (!isAlive(entry.pid)) {
|
|
3397
|
-
await
|
|
3039
|
+
await rm4(path, { force: true });
|
|
3398
3040
|
return;
|
|
3399
3041
|
}
|
|
3400
3042
|
const now = options.now?.() ?? /* @__PURE__ */ new Date();
|
|
3401
3043
|
if (isExpired(entry, now) || !sameOrg(entry.orgKey, currentOrgKey)) {
|
|
3402
3044
|
killProcess(entry.pid);
|
|
3403
|
-
await
|
|
3045
|
+
await rm4(path, { force: true });
|
|
3404
3046
|
}
|
|
3405
3047
|
}
|
|
3406
3048
|
async function reapStaleAndCrossOrgTunnels(currentOrgKey, options = {}) {
|
|
@@ -3413,7 +3055,7 @@ async function reapStaleAndCrossOrgTunnels(currentOrgKey, options = {}) {
|
|
|
3413
3055
|
}
|
|
3414
3056
|
await Promise.all(
|
|
3415
3057
|
files.filter((file) => file.endsWith(".json")).map(async (file) => {
|
|
3416
|
-
await reapOneFile(
|
|
3058
|
+
await reapOneFile(join5(root, file), currentOrgKey, options);
|
|
3417
3059
|
})
|
|
3418
3060
|
);
|
|
3419
3061
|
}
|
|
@@ -3481,6 +3123,9 @@ function isConnectivityFailure(error) {
|
|
|
3481
3123
|
}
|
|
3482
3124
|
return hasOpenConnectionCause(error.cause, 0);
|
|
3483
3125
|
}
|
|
3126
|
+
function isUnattributedQueryFailure(error) {
|
|
3127
|
+
return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
|
|
3128
|
+
}
|
|
3484
3129
|
|
|
3485
3130
|
// src/tunnel/fallback.ts
|
|
3486
3131
|
var EXPIRY_SAFETY_MARGIN_MS = 3e4;
|
|
@@ -3510,30 +3155,40 @@ async function discardQuietly(work) {
|
|
|
3510
3155
|
await work.catch(() => {
|
|
3511
3156
|
});
|
|
3512
3157
|
}
|
|
3158
|
+
function shouldDiscardTunnel(error) {
|
|
3159
|
+
return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
|
|
3160
|
+
}
|
|
3513
3161
|
async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
|
|
3514
3162
|
try {
|
|
3515
3163
|
const connection = await driver.connect(tunneledParams(directParams, record.localPort));
|
|
3516
3164
|
config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
|
|
3517
3165
|
return connection;
|
|
3518
3166
|
} catch (error) {
|
|
3519
|
-
if (
|
|
3167
|
+
if (shouldDiscardTunnel(error)) {
|
|
3520
3168
|
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
3521
3169
|
return void 0;
|
|
3522
3170
|
}
|
|
3523
3171
|
throw error;
|
|
3524
3172
|
}
|
|
3525
3173
|
}
|
|
3526
|
-
|
|
3174
|
+
function knownCandidates(targetAppName, hintApp) {
|
|
3175
|
+
return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
|
|
3176
|
+
}
|
|
3177
|
+
async function discoverAppsStdout(ctx, timeoutMs) {
|
|
3527
3178
|
try {
|
|
3528
|
-
return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
|
|
3179
|
+
return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
|
|
3529
3180
|
} catch {
|
|
3530
3181
|
return;
|
|
3531
3182
|
}
|
|
3532
3183
|
}
|
|
3533
|
-
async function
|
|
3534
|
-
const
|
|
3184
|
+
async function discoverExtraCandidates(config, ctx, alreadyTried, deadline) {
|
|
3185
|
+
const remainingMs = deadline - Date.now();
|
|
3186
|
+
if (remainingMs <= 0) {
|
|
3187
|
+
return [];
|
|
3188
|
+
}
|
|
3189
|
+
const stdout = await discoverAppsStdout(ctx, remainingMs);
|
|
3535
3190
|
const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
|
|
3536
|
-
return
|
|
3191
|
+
return base.filter((app) => !alreadyTried.includes(app));
|
|
3537
3192
|
}
|
|
3538
3193
|
async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
|
|
3539
3194
|
const readyRecord = {
|
|
@@ -3549,7 +3204,7 @@ async function finalizeCandidateConnection(app, spawned, driver, config, directP
|
|
|
3549
3204
|
config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
|
|
3550
3205
|
return connection;
|
|
3551
3206
|
} catch (error) {
|
|
3552
|
-
if (
|
|
3207
|
+
if (shouldDiscardTunnel(error)) {
|
|
3553
3208
|
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
3554
3209
|
killTunnelProcess(spawned.pid);
|
|
3555
3210
|
return void 0;
|
|
@@ -3566,7 +3221,8 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
3566
3221
|
return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
|
|
3567
3222
|
}
|
|
3568
3223
|
if (claim.outcome === "wait") {
|
|
3569
|
-
const
|
|
3224
|
+
const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
|
|
3225
|
+
const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
|
|
3570
3226
|
return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
|
|
3571
3227
|
}
|
|
3572
3228
|
const spawned = await spawnTunnel(
|
|
@@ -3579,7 +3235,12 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
3579
3235
|
deadline,
|
|
3580
3236
|
candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
|
|
3581
3237
|
},
|
|
3582
|
-
|
|
3238
|
+
{
|
|
3239
|
+
...overrides.process,
|
|
3240
|
+
onFailureDiagnostic: (message) => {
|
|
3241
|
+
config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3583
3244
|
);
|
|
3584
3245
|
if (spawned === void 0) {
|
|
3585
3246
|
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
@@ -3589,7 +3250,11 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
3589
3250
|
}
|
|
3590
3251
|
async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
|
|
3591
3252
|
if (config.refreshTunnel) {
|
|
3253
|
+
const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
3592
3254
|
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
3255
|
+
if (superseded?.status === "ready") {
|
|
3256
|
+
(cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
|
|
3257
|
+
}
|
|
3593
3258
|
return { connection: void 0, hintApp: void 0 };
|
|
3594
3259
|
}
|
|
3595
3260
|
const entry = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
@@ -3607,8 +3272,12 @@ async function tryDirectConnect(driver, config, directParams) {
|
|
|
3607
3272
|
if (config.tunnelMode !== "auto") {
|
|
3608
3273
|
return { connection: void 0, directError: void 0 };
|
|
3609
3274
|
}
|
|
3275
|
+
const boundedDirectParams = {
|
|
3276
|
+
...directParams,
|
|
3277
|
+
connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
|
|
3278
|
+
};
|
|
3610
3279
|
try {
|
|
3611
|
-
return { connection: await driver.connect(
|
|
3280
|
+
return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
|
|
3612
3281
|
} catch (error) {
|
|
3613
3282
|
if (!isConnectivityFailure(error)) {
|
|
3614
3283
|
throw error;
|
|
@@ -3625,18 +3294,47 @@ function buildExhaustionError(host, directError, candidates) {
|
|
|
3625
3294
|
`Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
|
|
3626
3295
|
);
|
|
3627
3296
|
}
|
|
3628
|
-
async function
|
|
3629
|
-
const
|
|
3630
|
-
for (const app of candidatesTried) {
|
|
3297
|
+
async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
|
|
3298
|
+
for (const app of apps) {
|
|
3631
3299
|
if (Date.now() >= deadline) {
|
|
3632
|
-
|
|
3300
|
+
return void 0;
|
|
3633
3301
|
}
|
|
3302
|
+
tried.push(app);
|
|
3634
3303
|
const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
|
|
3635
3304
|
if (connection !== void 0) {
|
|
3636
|
-
return
|
|
3305
|
+
return connection;
|
|
3637
3306
|
}
|
|
3638
3307
|
}
|
|
3639
|
-
return
|
|
3308
|
+
return void 0;
|
|
3309
|
+
}
|
|
3310
|
+
async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
|
|
3311
|
+
const tried = [];
|
|
3312
|
+
const known = knownCandidates(config.appName, hintApp);
|
|
3313
|
+
const knownConnection = await tryCandidateList(
|
|
3314
|
+
known,
|
|
3315
|
+
ctx,
|
|
3316
|
+
driver,
|
|
3317
|
+
config,
|
|
3318
|
+
directParams,
|
|
3319
|
+
deadline,
|
|
3320
|
+
overrides,
|
|
3321
|
+
tried
|
|
3322
|
+
);
|
|
3323
|
+
if (knownConnection !== void 0) {
|
|
3324
|
+
return { connection: knownConnection, candidatesTried: tried };
|
|
3325
|
+
}
|
|
3326
|
+
const discovered = await discoverExtraCandidates(config, ctx, tried, deadline);
|
|
3327
|
+
const discoveredConnection = await tryCandidateList(
|
|
3328
|
+
discovered,
|
|
3329
|
+
ctx,
|
|
3330
|
+
driver,
|
|
3331
|
+
config,
|
|
3332
|
+
directParams,
|
|
3333
|
+
deadline,
|
|
3334
|
+
overrides,
|
|
3335
|
+
tried
|
|
3336
|
+
);
|
|
3337
|
+
return { connection: discoveredConnection, candidatesTried: tried };
|
|
3640
3338
|
}
|
|
3641
3339
|
async function connectWithTunnelFallback(driver, config, overrides = {}) {
|
|
3642
3340
|
const directParams = directParamsOf(config);
|
|
@@ -4164,131 +3862,689 @@ var HanaClient = class _HanaClient {
|
|
|
4164
3862
|
const built = buildInsert(schema, table, values);
|
|
4165
3863
|
return await this.runExecute(built.sql, built.params);
|
|
4166
3864
|
}
|
|
4167
|
-
/** Update rows matching a non-empty `where` filter. */
|
|
4168
|
-
async update(schema, table, values, where) {
|
|
4169
|
-
const built = buildUpdate(schema, table, values, where);
|
|
4170
|
-
return await this.runExecute(built.sql, built.params);
|
|
3865
|
+
/** Update rows matching a non-empty `where` filter. */
|
|
3866
|
+
async update(schema, table, values, where) {
|
|
3867
|
+
const built = buildUpdate(schema, table, values, where);
|
|
3868
|
+
return await this.runExecute(built.sql, built.params);
|
|
3869
|
+
}
|
|
3870
|
+
/** Delete rows matching a non-empty `where` filter. */
|
|
3871
|
+
async deleteFrom(schema, table, where) {
|
|
3872
|
+
const built = buildDelete(schema, table, where);
|
|
3873
|
+
return await this.runExecute(built.sql, built.params);
|
|
3874
|
+
}
|
|
3875
|
+
/** Run `work` inside a transaction, auto-committing on success. */
|
|
3876
|
+
async transaction(work) {
|
|
3877
|
+
return await this.pool.withConnection(async (connection) => {
|
|
3878
|
+
await connection.setAutoCommit(false);
|
|
3879
|
+
const tx = new Transaction(connection);
|
|
3880
|
+
try {
|
|
3881
|
+
const result = await work(tx);
|
|
3882
|
+
if (!tx.isFinished) {
|
|
3883
|
+
await tx.commit();
|
|
3884
|
+
}
|
|
3885
|
+
return result;
|
|
3886
|
+
} catch (error) {
|
|
3887
|
+
if (!tx.isFinished) {
|
|
3888
|
+
try {
|
|
3889
|
+
await tx.rollback();
|
|
3890
|
+
} catch {
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
throw error;
|
|
3894
|
+
} finally {
|
|
3895
|
+
await connection.setAutoCommit(true);
|
|
3896
|
+
}
|
|
3897
|
+
});
|
|
3898
|
+
}
|
|
3899
|
+
/** List every schema visible to the connected user. */
|
|
3900
|
+
async listSchemas() {
|
|
3901
|
+
return await this.pool.withConnection((connection) => listSchemas(connection));
|
|
3902
|
+
}
|
|
3903
|
+
/** List the tables in a schema. */
|
|
3904
|
+
async listTables(schema) {
|
|
3905
|
+
return await this.pool.withConnection((connection) => listTables(connection, schema));
|
|
3906
|
+
}
|
|
3907
|
+
/** List table and view names in a schema for typo suggestions. */
|
|
3908
|
+
async listCatalogObjects(schema) {
|
|
3909
|
+
return await this.pool.withConnection((connection) => listCatalogObjects(connection, schema));
|
|
3910
|
+
}
|
|
3911
|
+
/** List the columns of a table. */
|
|
3912
|
+
async listColumns(schema, table) {
|
|
3913
|
+
return await this.pool.withConnection(
|
|
3914
|
+
(connection) => listColumns(connection, schema, table)
|
|
3915
|
+
);
|
|
3916
|
+
}
|
|
3917
|
+
/** Return the HANA execution plan for a statement. */
|
|
3918
|
+
async explain(sql, params) {
|
|
3919
|
+
return await this.pool.withConnection(async (connection) => {
|
|
3920
|
+
connection.assertAllowed(sql);
|
|
3921
|
+
const statementName = nextExplainStatementName();
|
|
3922
|
+
await connection.executeInternal(
|
|
3923
|
+
`EXPLAIN PLAN SET STATEMENT_NAME = '${statementName}' FOR ${sql}`,
|
|
3924
|
+
params
|
|
3925
|
+
);
|
|
3926
|
+
const cleanup = async () => {
|
|
3927
|
+
await connection.executeInternal(
|
|
3928
|
+
"DELETE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ?",
|
|
3929
|
+
[statementName]
|
|
3930
|
+
);
|
|
3931
|
+
};
|
|
3932
|
+
let queryCompleted = false;
|
|
3933
|
+
try {
|
|
3934
|
+
const result = await connection.query(
|
|
3935
|
+
"SELECT OPERATOR_NAME, TABLE_NAME, TABLE_TYPE, EXECUTION_ENGINE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ? ORDER BY OPERATOR_ID",
|
|
3936
|
+
[statementName]
|
|
3937
|
+
);
|
|
3938
|
+
queryCompleted = true;
|
|
3939
|
+
await cleanup();
|
|
3940
|
+
return result;
|
|
3941
|
+
} catch (error) {
|
|
3942
|
+
if (!queryCompleted) {
|
|
3943
|
+
try {
|
|
3944
|
+
await cleanup();
|
|
3945
|
+
} catch {
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
throw error;
|
|
3949
|
+
}
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3952
|
+
/** Close every pooled connection. The client must not be used afterwards. */
|
|
3953
|
+
async close() {
|
|
3954
|
+
await this.pool.drain();
|
|
3955
|
+
}
|
|
3956
|
+
async runQuery(sql, params, options) {
|
|
3957
|
+
return await this.pool.withConnection(
|
|
3958
|
+
(connection) => connection.query(sql, params, options)
|
|
3959
|
+
);
|
|
3960
|
+
}
|
|
3961
|
+
async runExecute(sql, params, options) {
|
|
3962
|
+
return await this.pool.withConnection(
|
|
3963
|
+
(connection) => connection.execute(sql, params, options)
|
|
3964
|
+
);
|
|
3965
|
+
}
|
|
3966
|
+
async recordSqlHistory(operation, sql, params, result) {
|
|
3967
|
+
try {
|
|
3968
|
+
await appendSqlHistory({
|
|
3969
|
+
version: CLI_VERSION,
|
|
3970
|
+
operation,
|
|
3971
|
+
selector: this.info.selector,
|
|
3972
|
+
appName: this.info.appName,
|
|
3973
|
+
schema: this.info.schema,
|
|
3974
|
+
role: this.info.role,
|
|
3975
|
+
statement: result.statement,
|
|
3976
|
+
sql,
|
|
3977
|
+
paramCount: params.length,
|
|
3978
|
+
rowCount: result.rowCount,
|
|
3979
|
+
truncated: result.truncated,
|
|
3980
|
+
elapsedMs: result.elapsedMs
|
|
3981
|
+
});
|
|
3982
|
+
} catch {
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
};
|
|
3986
|
+
|
|
3987
|
+
// src/api.ts
|
|
3988
|
+
async function connect(selector, options) {
|
|
3989
|
+
return await HanaClient.connect(selector, options);
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
// src/metadata-cache.ts
|
|
3993
|
+
import { createHash as createHash2 } from "crypto";
|
|
3994
|
+
import { mkdir as mkdir4, readFile as readFile3, rename as rename2, rm as rm5, writeFile as writeFile3 } from "fs/promises";
|
|
3995
|
+
import { homedir as homedir4 } from "os";
|
|
3996
|
+
import { join as join6 } from "path";
|
|
3997
|
+
var METADATA_CACHE_TTL_MS = 30 * 6e4;
|
|
3998
|
+
function metadataCacheRoot(saptoolsRoot) {
|
|
3999
|
+
return join6(saptoolsRoot ?? join6(homedir4(), ".saptools"), "cf-hana", "metadata");
|
|
4000
|
+
}
|
|
4001
|
+
function toMetadataCacheScope(info) {
|
|
4002
|
+
return {
|
|
4003
|
+
selector: info.selector,
|
|
4004
|
+
appName: info.appName,
|
|
4005
|
+
host: info.host,
|
|
4006
|
+
schema: info.schema,
|
|
4007
|
+
role: info.role,
|
|
4008
|
+
driver: info.driver,
|
|
4009
|
+
...info.bindingName === void 0 ? {} : { bindingName: info.bindingName },
|
|
4010
|
+
...info.bindingIndex === void 0 ? {} : { bindingIndex: info.bindingIndex }
|
|
4011
|
+
};
|
|
4012
|
+
}
|
|
4013
|
+
function metadataCacheKey(scope) {
|
|
4014
|
+
return createHash2("sha256").update(JSON.stringify(scope)).digest("hex");
|
|
4015
|
+
}
|
|
4016
|
+
function metadataCachePath(scope, saptoolsRoot) {
|
|
4017
|
+
return join6(metadataCacheRoot(saptoolsRoot), `${metadataCacheKey(scope)}.json`);
|
|
4018
|
+
}
|
|
4019
|
+
function isRecord3(value) {
|
|
4020
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4021
|
+
}
|
|
4022
|
+
function isCatalogObject(value) {
|
|
4023
|
+
return isRecord3(value) && typeof value["schema"] === "string" && typeof value["name"] === "string" && (value["type"] === "TABLE" || value["type"] === "VIEW");
|
|
4024
|
+
}
|
|
4025
|
+
function hasRequiredScopeFields(value) {
|
|
4026
|
+
return typeof value["selector"] === "string" && typeof value["appName"] === "string" && typeof value["host"] === "string" && typeof value["schema"] === "string" && typeof value["role"] === "string" && typeof value["driver"] === "string";
|
|
4027
|
+
}
|
|
4028
|
+
function hasOptionalScopeFields(value) {
|
|
4029
|
+
return (value["bindingName"] === void 0 || typeof value["bindingName"] === "string") && (value["bindingIndex"] === void 0 || typeof value["bindingIndex"] === "number");
|
|
4030
|
+
}
|
|
4031
|
+
function isScope(value) {
|
|
4032
|
+
return isRecord3(value) && hasRequiredScopeFields(value) && hasOptionalScopeFields(value);
|
|
4033
|
+
}
|
|
4034
|
+
function scopesEqual(left, right) {
|
|
4035
|
+
return metadataCacheKey(left) === metadataCacheKey(right);
|
|
4036
|
+
}
|
|
4037
|
+
function isStored(value) {
|
|
4038
|
+
return isRecord3(value) && value["version"] === 1 && typeof value["createdAt"] === "string" && isScope(value["scope"]) && Array.isArray(value["objects"]) && value["objects"].every(isCatalogObject);
|
|
4039
|
+
}
|
|
4040
|
+
async function readMetadataCache(scope, options = {}) {
|
|
4041
|
+
try {
|
|
4042
|
+
const parsed = JSON.parse(
|
|
4043
|
+
await readFile3(metadataCachePath(scope, options.saptoolsRoot), "utf8")
|
|
4044
|
+
);
|
|
4045
|
+
if (!isStored(parsed) || !scopesEqual(parsed.scope, scope)) {
|
|
4046
|
+
return void 0;
|
|
4047
|
+
}
|
|
4048
|
+
const createdAt = Date.parse(parsed.createdAt);
|
|
4049
|
+
const now = options.now?.() ?? /* @__PURE__ */ new Date();
|
|
4050
|
+
const ageMs = now.getTime() - createdAt;
|
|
4051
|
+
if (!Number.isFinite(createdAt) || ageMs < 0 || ageMs >= METADATA_CACHE_TTL_MS) {
|
|
4052
|
+
return void 0;
|
|
4053
|
+
}
|
|
4054
|
+
return parsed.objects;
|
|
4055
|
+
} catch {
|
|
4056
|
+
return void 0;
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
async function writeMetadataCache(scope, objects, options = {}) {
|
|
4060
|
+
const root = metadataCacheRoot(options.saptoolsRoot);
|
|
4061
|
+
const path = metadataCachePath(scope, options.saptoolsRoot);
|
|
4062
|
+
const tempPath = `${path}.tmp-${process.pid.toString()}`;
|
|
4063
|
+
const stored = {
|
|
4064
|
+
version: 1,
|
|
4065
|
+
createdAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
4066
|
+
scope,
|
|
4067
|
+
objects
|
|
4068
|
+
};
|
|
4069
|
+
await mkdir4(root, { recursive: true, mode: 448 });
|
|
4070
|
+
await rm5(tempPath, { force: true });
|
|
4071
|
+
await writeFile3(tempPath, `${JSON.stringify(stored)}
|
|
4072
|
+
`, { encoding: "utf8", mode: 384 });
|
|
4073
|
+
await rename2(tempPath, path);
|
|
4074
|
+
}
|
|
4075
|
+
async function loadCatalogObjectsWithCache(scope, refresh, loader, options = {}) {
|
|
4076
|
+
if (!refresh) {
|
|
4077
|
+
const cached = await readMetadataCache(scope, options);
|
|
4078
|
+
if (cached !== void 0) {
|
|
4079
|
+
return cached;
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
const objects = await loader();
|
|
4083
|
+
try {
|
|
4084
|
+
await writeMetadataCache(scope, objects, options);
|
|
4085
|
+
} catch {
|
|
4086
|
+
}
|
|
4087
|
+
return objects;
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
// src/suggestions.ts
|
|
4091
|
+
var INVALID_OBJECT_PATTERNS = [
|
|
4092
|
+
/invalid\s+(?:table|view|object)\s+name/i,
|
|
4093
|
+
/(?:table|view|object)\s+[^\n]*does\s+not\s+exist/i,
|
|
4094
|
+
/could\s+not\s+find\s+(?:table|view|object)/i
|
|
4095
|
+
];
|
|
4096
|
+
var REF_KEYWORDS = /* @__PURE__ */ new Set(["FROM", "JOIN", "UPDATE", "INTO", "TABLE"]);
|
|
4097
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
4098
|
+
"AS",
|
|
4099
|
+
"ON",
|
|
4100
|
+
"WHERE",
|
|
4101
|
+
"SET",
|
|
4102
|
+
"VALUES",
|
|
4103
|
+
"USING",
|
|
4104
|
+
"WHEN",
|
|
4105
|
+
"INNER",
|
|
4106
|
+
"LEFT",
|
|
4107
|
+
"RIGHT",
|
|
4108
|
+
"FULL",
|
|
4109
|
+
"OUTER",
|
|
4110
|
+
"CROSS",
|
|
4111
|
+
"JOIN"
|
|
4112
|
+
]);
|
|
4113
|
+
var CTE_FOLLOWERS = /* @__PURE__ */ new Set(["AS"]);
|
|
4114
|
+
function isInvalidCatalogObjectError(error) {
|
|
4115
|
+
if (!(error instanceof QueryError)) {
|
|
4116
|
+
return false;
|
|
4117
|
+
}
|
|
4118
|
+
if (error.sqlState === "42S02" || error.sqlState === "42S01") {
|
|
4119
|
+
return true;
|
|
4120
|
+
}
|
|
4121
|
+
return INVALID_OBJECT_PATTERNS.some((pattern) => pattern.test(error.message));
|
|
4122
|
+
}
|
|
4123
|
+
function skipLineComment3(sql, index) {
|
|
4124
|
+
let cursor = index + 2;
|
|
4125
|
+
while (cursor < sql.length && sql[cursor] !== "\n") {
|
|
4126
|
+
cursor += 1;
|
|
4127
|
+
}
|
|
4128
|
+
return cursor;
|
|
4129
|
+
}
|
|
4130
|
+
function skipBlockComment3(sql, index) {
|
|
4131
|
+
let cursor = index + 2;
|
|
4132
|
+
while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
|
|
4133
|
+
cursor += 1;
|
|
4134
|
+
}
|
|
4135
|
+
return Math.min(cursor + 2, sql.length);
|
|
4136
|
+
}
|
|
4137
|
+
function skipStringLiteral(sql, index) {
|
|
4138
|
+
let cursor = index + 1;
|
|
4139
|
+
while (cursor < sql.length) {
|
|
4140
|
+
if (sql[cursor] === "'" && sql[cursor + 1] === "'") {
|
|
4141
|
+
cursor += 2;
|
|
4142
|
+
continue;
|
|
4143
|
+
}
|
|
4144
|
+
if (sql[cursor] === "'") {
|
|
4145
|
+
return cursor + 1;
|
|
4146
|
+
}
|
|
4147
|
+
cursor += 1;
|
|
4148
|
+
}
|
|
4149
|
+
return cursor;
|
|
4150
|
+
}
|
|
4151
|
+
function readQuotedIdentifier(sql, index) {
|
|
4152
|
+
let cursor = index + 1;
|
|
4153
|
+
let text = "";
|
|
4154
|
+
while (cursor < sql.length) {
|
|
4155
|
+
if (sql[cursor] === '"' && sql[cursor + 1] === '"') {
|
|
4156
|
+
text += '"';
|
|
4157
|
+
cursor += 2;
|
|
4158
|
+
continue;
|
|
4159
|
+
}
|
|
4160
|
+
if (sql[cursor] === '"') {
|
|
4161
|
+
return { text, next: cursor + 1 };
|
|
4162
|
+
}
|
|
4163
|
+
text += sql.charAt(cursor);
|
|
4164
|
+
cursor += 1;
|
|
4165
|
+
}
|
|
4166
|
+
return { text, next: cursor };
|
|
4167
|
+
}
|
|
4168
|
+
function readBareWord(sql, index) {
|
|
4169
|
+
let cursor = index;
|
|
4170
|
+
let text = "";
|
|
4171
|
+
while (cursor < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(cursor))) {
|
|
4172
|
+
text += sql.charAt(cursor);
|
|
4173
|
+
cursor += 1;
|
|
4174
|
+
}
|
|
4175
|
+
return { text, next: cursor };
|
|
4176
|
+
}
|
|
4177
|
+
function tokenize(sql) {
|
|
4178
|
+
const tokens = [];
|
|
4179
|
+
let index = 0;
|
|
4180
|
+
while (index < sql.length) {
|
|
4181
|
+
const char = sql.charAt(index);
|
|
4182
|
+
if (/\s/.test(char)) {
|
|
4183
|
+
index += 1;
|
|
4184
|
+
continue;
|
|
4185
|
+
}
|
|
4186
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
4187
|
+
index = skipLineComment3(sql, index);
|
|
4188
|
+
continue;
|
|
4189
|
+
}
|
|
4190
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
4191
|
+
index = skipBlockComment3(sql, index);
|
|
4192
|
+
continue;
|
|
4193
|
+
}
|
|
4194
|
+
if (char === "'") {
|
|
4195
|
+
index = skipStringLiteral(sql, index);
|
|
4196
|
+
continue;
|
|
4197
|
+
}
|
|
4198
|
+
if (char === '"') {
|
|
4199
|
+
const quoted = readQuotedIdentifier(sql, index);
|
|
4200
|
+
tokens.push({ kind: "quoted", text: quoted.text });
|
|
4201
|
+
index = quoted.next;
|
|
4202
|
+
continue;
|
|
4203
|
+
}
|
|
4204
|
+
if (/[A-Za-z_#$]/.test(char)) {
|
|
4205
|
+
const word = readBareWord(sql, index);
|
|
4206
|
+
tokens.push({ kind: "word", text: word.text });
|
|
4207
|
+
index = word.next;
|
|
4208
|
+
continue;
|
|
4209
|
+
}
|
|
4210
|
+
if (char === ".") {
|
|
4211
|
+
tokens.push({ kind: "dot", text: char });
|
|
4212
|
+
} else if (char === ",") {
|
|
4213
|
+
tokens.push({ kind: "comma", text: char });
|
|
4214
|
+
} else if (char === "(") {
|
|
4215
|
+
tokens.push({ kind: "open", text: char });
|
|
4216
|
+
} else if (char === ")") {
|
|
4217
|
+
tokens.push({ kind: "close", text: char });
|
|
4218
|
+
} else {
|
|
4219
|
+
tokens.push({ kind: "other", text: char });
|
|
4220
|
+
}
|
|
4221
|
+
index += 1;
|
|
4222
|
+
}
|
|
4223
|
+
return tokens;
|
|
4224
|
+
}
|
|
4225
|
+
function upper(token) {
|
|
4226
|
+
return token?.text.toUpperCase() ?? "";
|
|
4227
|
+
}
|
|
4228
|
+
function isIdentifier(token) {
|
|
4229
|
+
return token?.kind === "word" || token?.kind === "quoted";
|
|
4230
|
+
}
|
|
4231
|
+
function readName(tokens, start, allowFollowingParens = false) {
|
|
4232
|
+
const first = tokens[start];
|
|
4233
|
+
if (!isIdentifier(first)) {
|
|
4234
|
+
return void 0;
|
|
4235
|
+
}
|
|
4236
|
+
let next = start + 1;
|
|
4237
|
+
let schema;
|
|
4238
|
+
let name = first.text;
|
|
4239
|
+
const maybeObject = tokens[next + 1];
|
|
4240
|
+
if (tokens[next]?.kind === "dot" && isIdentifier(maybeObject)) {
|
|
4241
|
+
schema = name;
|
|
4242
|
+
name = maybeObject.text;
|
|
4243
|
+
next += 2;
|
|
4244
|
+
}
|
|
4245
|
+
if (!allowFollowingParens && tokens[next]?.kind === "open") {
|
|
4246
|
+
return void 0;
|
|
4247
|
+
}
|
|
4248
|
+
return { name: schema === void 0 ? { name } : { schema, name }, next };
|
|
4249
|
+
}
|
|
4250
|
+
function firstNameFromText(text) {
|
|
4251
|
+
const tokens = tokenize(text);
|
|
4252
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
4253
|
+
const read = readName(tokens, index, true);
|
|
4254
|
+
if (read !== void 0) {
|
|
4255
|
+
return read.name;
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
return void 0;
|
|
4259
|
+
}
|
|
4260
|
+
function extractMissingObjectNameFromError(error) {
|
|
4261
|
+
if (!(error instanceof QueryError)) {
|
|
4262
|
+
return void 0;
|
|
4263
|
+
}
|
|
4264
|
+
const message = error.message;
|
|
4265
|
+
const invalidName = /invalid\s+(?:table|view|object)\s+name\s*:?\s*(.+)$/i.exec(message);
|
|
4266
|
+
if (invalidName?.[1] !== void 0) {
|
|
4267
|
+
return firstNameFromText(invalidName[1]);
|
|
4268
|
+
}
|
|
4269
|
+
const missingObject = /(?:table|view|object)\s+(.+?)\s+(?:does\s+not\s+exist|not\s+found)/i.exec(message);
|
|
4270
|
+
if (missingObject?.[1] !== void 0) {
|
|
4271
|
+
return firstNameFromText(missingObject[1]);
|
|
4272
|
+
}
|
|
4273
|
+
return void 0;
|
|
4274
|
+
}
|
|
4275
|
+
function cteNames(tokens) {
|
|
4276
|
+
const names = /* @__PURE__ */ new Set();
|
|
4277
|
+
if (upper(tokens[0]) !== "WITH") {
|
|
4278
|
+
return names;
|
|
4279
|
+
}
|
|
4280
|
+
let depth = 0;
|
|
4281
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
4282
|
+
const token = tokens[index];
|
|
4283
|
+
if (depth === 0 && upper(token) === "SELECT") {
|
|
4284
|
+
break;
|
|
4285
|
+
}
|
|
4286
|
+
if (depth === 0 && isIdentifier(token) && CTE_FOLLOWERS.has(upper(tokens[index + 1]))) {
|
|
4287
|
+
names.add(token.text.toUpperCase());
|
|
4288
|
+
}
|
|
4289
|
+
if (token?.kind === "open") {
|
|
4290
|
+
depth += 1;
|
|
4291
|
+
} else if (token?.kind === "close" && depth > 0) {
|
|
4292
|
+
depth -= 1;
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
return names;
|
|
4296
|
+
}
|
|
4297
|
+
function extractMissingObjectName(sql) {
|
|
4298
|
+
const tokens = tokenize(sql.replace(/^;+|;+$/g, ""));
|
|
4299
|
+
const ctes = cteNames(tokens);
|
|
4300
|
+
let candidate;
|
|
4301
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
4302
|
+
const word = upper(tokens[index]);
|
|
4303
|
+
if (!REF_KEYWORDS.has(word)) {
|
|
4304
|
+
continue;
|
|
4305
|
+
}
|
|
4306
|
+
if (word === "TABLE" && upper(tokens[index - 1]) !== "TRUNCATE") {
|
|
4307
|
+
continue;
|
|
4308
|
+
}
|
|
4309
|
+
if (word === "INTO" && !(upper(tokens[index - 1]) === "INSERT" || upper(tokens[index - 1]) === "MERGE")) {
|
|
4310
|
+
continue;
|
|
4311
|
+
}
|
|
4312
|
+
const read = readName(tokens, index + 1, word === "INTO");
|
|
4313
|
+
if (read === void 0) {
|
|
4314
|
+
continue;
|
|
4315
|
+
}
|
|
4316
|
+
if (read.name.schema === void 0 && ctes.has(read.name.name.toUpperCase())) {
|
|
4317
|
+
continue;
|
|
4318
|
+
}
|
|
4319
|
+
if (STOP_WORDS.has(read.name.name.toUpperCase())) {
|
|
4320
|
+
continue;
|
|
4321
|
+
}
|
|
4322
|
+
candidate = read.name;
|
|
4323
|
+
let next = read.next;
|
|
4324
|
+
if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
|
|
4325
|
+
next += 1;
|
|
4326
|
+
}
|
|
4327
|
+
while (tokens[next]?.kind === "comma") {
|
|
4328
|
+
const commaRead = readName(tokens, next + 1);
|
|
4329
|
+
if (commaRead === void 0) {
|
|
4330
|
+
break;
|
|
4331
|
+
}
|
|
4332
|
+
if (!(commaRead.name.schema === void 0 && ctes.has(commaRead.name.name.toUpperCase()))) {
|
|
4333
|
+
candidate = commaRead.name;
|
|
4334
|
+
}
|
|
4335
|
+
next = commaRead.next;
|
|
4336
|
+
if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
|
|
4337
|
+
next += 1;
|
|
4338
|
+
}
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
return candidate;
|
|
4342
|
+
}
|
|
4343
|
+
function singular(value) {
|
|
4344
|
+
if (value.endsWith("ES")) {
|
|
4345
|
+
return value.slice(0, -2);
|
|
4346
|
+
}
|
|
4347
|
+
if (value.endsWith("S")) {
|
|
4348
|
+
return value.slice(0, -1);
|
|
4349
|
+
}
|
|
4350
|
+
return value;
|
|
4351
|
+
}
|
|
4352
|
+
function norm(value) {
|
|
4353
|
+
return value.toUpperCase().replace(/[^A-Z0-9]+/g, "");
|
|
4354
|
+
}
|
|
4355
|
+
function distance(a, b) {
|
|
4356
|
+
const previous = Array.from({ length: b.length + 1 }, (_value, index) => index);
|
|
4357
|
+
for (let leftIndex = 1; leftIndex <= a.length; leftIndex += 1) {
|
|
4358
|
+
let last = leftIndex - 1;
|
|
4359
|
+
previous[0] = leftIndex;
|
|
4360
|
+
for (let rightIndex = 1; rightIndex <= b.length; rightIndex += 1) {
|
|
4361
|
+
const old = previous[rightIndex] ?? 0;
|
|
4362
|
+
previous[rightIndex] = Math.min(
|
|
4363
|
+
(previous[rightIndex] ?? 0) + 1,
|
|
4364
|
+
(previous[rightIndex - 1] ?? 0) + 1,
|
|
4365
|
+
last + (a.charAt(leftIndex - 1) === b.charAt(rightIndex - 1) ? 0 : 1)
|
|
4366
|
+
);
|
|
4367
|
+
last = old;
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
return previous[b.length] ?? 0;
|
|
4371
|
+
}
|
|
4372
|
+
function score(requested, candidate) {
|
|
4373
|
+
const requestedName = norm(requested.name);
|
|
4374
|
+
const candidateName = norm(candidate.name);
|
|
4375
|
+
if (requested.schema !== void 0 && norm(requested.schema) !== norm(candidate.schema)) {
|
|
4376
|
+
return 0;
|
|
4377
|
+
}
|
|
4378
|
+
if (requestedName === candidateName) {
|
|
4379
|
+
return 100;
|
|
4380
|
+
}
|
|
4381
|
+
if (singular(requestedName) === singular(candidateName)) {
|
|
4382
|
+
return 92;
|
|
4383
|
+
}
|
|
4384
|
+
let value = 0;
|
|
4385
|
+
if (candidateName.startsWith(requestedName) || requestedName.startsWith(candidateName)) {
|
|
4386
|
+
value = Math.max(value, 80 - Math.abs(candidateName.length - requestedName.length));
|
|
4387
|
+
}
|
|
4388
|
+
if (candidateName.endsWith(requestedName) || requestedName.endsWith(candidateName)) {
|
|
4389
|
+
value = Math.max(value, 70 - Math.abs(candidateName.length - requestedName.length));
|
|
4171
4390
|
}
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4391
|
+
const editDistance = distance(requestedName, candidateName);
|
|
4392
|
+
const max = Math.max(requestedName.length, candidateName.length, 1);
|
|
4393
|
+
if (editDistance <= Math.max(3, Math.floor(max * 0.35))) {
|
|
4394
|
+
value = Math.max(value, Math.round(75 * (1 - editDistance / max)));
|
|
4176
4395
|
}
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
try {
|
|
4183
|
-
const result = await work(tx);
|
|
4184
|
-
if (!tx.isFinished) {
|
|
4185
|
-
await tx.commit();
|
|
4186
|
-
}
|
|
4187
|
-
return result;
|
|
4188
|
-
} catch (error) {
|
|
4189
|
-
if (!tx.isFinished) {
|
|
4190
|
-
try {
|
|
4191
|
-
await tx.rollback();
|
|
4192
|
-
} catch {
|
|
4193
|
-
}
|
|
4194
|
-
}
|
|
4195
|
-
throw error;
|
|
4196
|
-
} finally {
|
|
4197
|
-
await connection.setAutoCommit(true);
|
|
4198
|
-
}
|
|
4199
|
-
});
|
|
4396
|
+
return value;
|
|
4397
|
+
}
|
|
4398
|
+
function extractInvalidColumnNameFromError(error) {
|
|
4399
|
+
if (!(error instanceof QueryError)) {
|
|
4400
|
+
return void 0;
|
|
4200
4401
|
}
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4402
|
+
const match = /invalid column name:\s*([^:]+)/i.exec(error.message);
|
|
4403
|
+
const name = match?.[1]?.trim();
|
|
4404
|
+
return name === void 0 || name.length === 0 ? void 0 : name;
|
|
4405
|
+
}
|
|
4406
|
+
function rankCatalogSuggestions(requested, candidates, limit = 5) {
|
|
4407
|
+
return candidates.map((candidate) => ({ candidate, score: score(requested, candidate) })).filter((item) => item.score >= 45).sort(
|
|
4408
|
+
(left, right) => right.score - left.score || left.candidate.schema.localeCompare(right.candidate.schema) || (left.candidate.type === right.candidate.type ? 0 : left.candidate.type === "TABLE" ? -1 : 1) || left.candidate.name.localeCompare(right.candidate.name)
|
|
4409
|
+
).slice(0, limit).map((item) => item.candidate);
|
|
4410
|
+
}
|
|
4411
|
+
function formatSuggestions(suggestions) {
|
|
4412
|
+
if (suggestions.length === 0) {
|
|
4413
|
+
return void 0;
|
|
4204
4414
|
}
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4415
|
+
return [
|
|
4416
|
+
"Did you mean:",
|
|
4417
|
+
...suggestions.map((item) => ` ${item.schema}.${item.name} (${item.type})`)
|
|
4418
|
+
].join("\n");
|
|
4419
|
+
}
|
|
4420
|
+
function rankNameSuggestions(requested, candidates, limit = 5) {
|
|
4421
|
+
const requestedName = { name: requested };
|
|
4422
|
+
return candidates.map((candidate) => ({
|
|
4423
|
+
candidate,
|
|
4424
|
+
score: score(requestedName, { schema: "", name: candidate.name, type: "TABLE" })
|
|
4425
|
+
})).filter((item) => item.score >= 45).sort(
|
|
4426
|
+
(left, right) => right.score - left.score || left.candidate.name.localeCompare(right.candidate.name)
|
|
4427
|
+
).slice(0, limit).map((item) => item.candidate);
|
|
4428
|
+
}
|
|
4429
|
+
function formatColumnSuggestions(suggestions) {
|
|
4430
|
+
if (suggestions.length === 0) {
|
|
4431
|
+
return void 0;
|
|
4208
4432
|
}
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4433
|
+
return [
|
|
4434
|
+
"Did you mean column:",
|
|
4435
|
+
...suggestions.map((item) => ` ${item.name}`)
|
|
4436
|
+
].join("\n");
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
// src/cli-query-hints.ts
|
|
4440
|
+
async function loadSuggestionCatalogObjects(client, refresh) {
|
|
4441
|
+
try {
|
|
4442
|
+
return await loadCatalogObjectsWithCache(
|
|
4443
|
+
toMetadataCacheScope(client.info),
|
|
4444
|
+
refresh,
|
|
4445
|
+
async () => await client.listCatalogObjects(client.info.schema)
|
|
4446
|
+
);
|
|
4447
|
+
} catch {
|
|
4448
|
+
return await client.listCatalogObjects(client.info.schema);
|
|
4212
4449
|
}
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4450
|
+
}
|
|
4451
|
+
function isLobSortOrGroupError(error) {
|
|
4452
|
+
const code = databaseCode(error);
|
|
4453
|
+
if (code !== 266 && code !== 274) {
|
|
4454
|
+
return false;
|
|
4455
|
+
}
|
|
4456
|
+
return error instanceof QueryError && /LOB type is not allowed in (?:ORDER BY|GROUP BY) clause/i.test(error.message);
|
|
4457
|
+
}
|
|
4458
|
+
function printLobSortOrGroupHint() {
|
|
4459
|
+
const lines = [
|
|
4460
|
+
`${CLI_NAME}: HANA cannot ORDER BY or GROUP BY NCLOB/CLOB/BLOB columns directly.`,
|
|
4461
|
+
`${CLI_NAME}: Remove the LOB column from ORDER BY/GROUP BY or wrap it as TO_VARCHAR(<column>).`
|
|
4462
|
+
];
|
|
4463
|
+
process.stderr.write(`${lines.join("\n")}
|
|
4464
|
+
`);
|
|
4465
|
+
}
|
|
4466
|
+
function isInsufficientPrivilegeError(error) {
|
|
4467
|
+
return databaseCode(error) === 258 || /\binsufficient privilege\b/i.test(errorMessage(error));
|
|
4468
|
+
}
|
|
4469
|
+
function printInsufficientPrivilegeHint(client, schema) {
|
|
4470
|
+
const binding = client.info.bindingName ?? (client.info.bindingIndex === void 0 ? "unknown" : `#${String(client.info.bindingIndex)}`);
|
|
4471
|
+
const otherBindings = (client.info.availableBindingNames ?? []).filter(
|
|
4472
|
+
(name) => name !== client.info.bindingName
|
|
4473
|
+
);
|
|
4474
|
+
const retryBinding = otherBindings[0];
|
|
4475
|
+
const lines = [
|
|
4476
|
+
`${CLI_NAME}: insufficient privilege for schema ${schema} as database user ${client.databaseUser || "unknown"} (current binding: ${binding}).`
|
|
4477
|
+
];
|
|
4478
|
+
if (retryBinding !== void 0) {
|
|
4479
|
+
lines.push(
|
|
4480
|
+
`${CLI_NAME}: other HANA bindings on this app: ${otherBindings.join(", ")}; retry with --binding ${retryBinding}.`
|
|
4217
4481
|
);
|
|
4218
4482
|
}
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
const cleanup = async () => {
|
|
4229
|
-
await connection.executeInternal(
|
|
4230
|
-
"DELETE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ?",
|
|
4231
|
-
[statementName]
|
|
4232
|
-
);
|
|
4233
|
-
};
|
|
4234
|
-
let queryCompleted = false;
|
|
4235
|
-
try {
|
|
4236
|
-
const result = await connection.query(
|
|
4237
|
-
"SELECT OPERATOR_NAME, TABLE_NAME, TABLE_TYPE, EXECUTION_ENGINE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ? ORDER BY OPERATOR_ID",
|
|
4238
|
-
[statementName]
|
|
4239
|
-
);
|
|
4240
|
-
queryCompleted = true;
|
|
4241
|
-
await cleanup();
|
|
4242
|
-
return result;
|
|
4243
|
-
} catch (error) {
|
|
4244
|
-
if (!queryCompleted) {
|
|
4245
|
-
try {
|
|
4246
|
-
await cleanup();
|
|
4247
|
-
} catch {
|
|
4248
|
-
}
|
|
4249
|
-
}
|
|
4250
|
-
throw error;
|
|
4251
|
-
}
|
|
4252
|
-
});
|
|
4483
|
+
lines.push(
|
|
4484
|
+
`${CLI_NAME}: try another app or full selector whose binding has the grant; no automatic retry was attempted.`
|
|
4485
|
+
);
|
|
4486
|
+
process.stderr.write(`${lines.join("\n")}
|
|
4487
|
+
`);
|
|
4488
|
+
}
|
|
4489
|
+
function rethrowWithPrivilegeHint(error, client, schema) {
|
|
4490
|
+
if (isInsufficientPrivilegeError(error)) {
|
|
4491
|
+
printInsufficientPrivilegeHint(client, schema);
|
|
4253
4492
|
}
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4493
|
+
throw error;
|
|
4494
|
+
}
|
|
4495
|
+
async function printColumnSuggestions(error, client, sql) {
|
|
4496
|
+
if (databaseCode(error) !== 260) {
|
|
4497
|
+
return;
|
|
4257
4498
|
}
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4499
|
+
const columnName = extractInvalidColumnNameFromError(error);
|
|
4500
|
+
const tableName = extractMissingObjectName(sql);
|
|
4501
|
+
if (columnName === void 0 || tableName === void 0) {
|
|
4502
|
+
return;
|
|
4262
4503
|
}
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4504
|
+
try {
|
|
4505
|
+
const columns = await client.listColumns(
|
|
4506
|
+
tableName.schema ?? client.info.schema,
|
|
4507
|
+
tableName.name
|
|
4266
4508
|
);
|
|
4509
|
+
const text = formatColumnSuggestions(rankNameSuggestions(columnName, columns));
|
|
4510
|
+
if (text !== void 0) {
|
|
4511
|
+
process.stderr.write(`${text}
|
|
4512
|
+
`);
|
|
4513
|
+
}
|
|
4514
|
+
} catch {
|
|
4267
4515
|
}
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
});
|
|
4284
|
-
} catch {
|
|
4516
|
+
}
|
|
4517
|
+
async function printCatalogObjectSuggestions(error, client, sql, refresh) {
|
|
4518
|
+
if (!isInvalidCatalogObjectError(error)) {
|
|
4519
|
+
return;
|
|
4520
|
+
}
|
|
4521
|
+
const requested = extractMissingObjectNameFromError(error) ?? extractMissingObjectName(sql);
|
|
4522
|
+
if (requested === void 0) {
|
|
4523
|
+
return;
|
|
4524
|
+
}
|
|
4525
|
+
try {
|
|
4526
|
+
const objects = await loadSuggestionCatalogObjects(client, refresh);
|
|
4527
|
+
const text = formatSuggestions(rankCatalogSuggestions(requested, objects));
|
|
4528
|
+
if (text !== void 0) {
|
|
4529
|
+
process.stderr.write(`${text}
|
|
4530
|
+
`);
|
|
4285
4531
|
}
|
|
4532
|
+
} catch {
|
|
4286
4533
|
}
|
|
4287
|
-
}
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4534
|
+
}
|
|
4535
|
+
async function enrichAndRethrowQueryError(error, client, sql, refresh) {
|
|
4536
|
+
if (isInsufficientPrivilegeError(error)) {
|
|
4537
|
+
const schema = extractMissingObjectName(sql)?.schema ?? client.info.schema;
|
|
4538
|
+
printInsufficientPrivilegeHint(client, schema);
|
|
4539
|
+
throw error;
|
|
4540
|
+
}
|
|
4541
|
+
if (isLobSortOrGroupError(error)) {
|
|
4542
|
+
printLobSortOrGroupHint();
|
|
4543
|
+
throw error;
|
|
4544
|
+
}
|
|
4545
|
+
await printColumnSuggestions(error, client, sql);
|
|
4546
|
+
await printCatalogObjectSuggestions(error, client, sql, refresh);
|
|
4547
|
+
throw error;
|
|
4292
4548
|
}
|
|
4293
4549
|
|
|
4294
4550
|
// src/cli-results.ts
|
|
@@ -4564,7 +4820,7 @@ function searchResultSession(session, searchTerm, options) {
|
|
|
4564
4820
|
|
|
4565
4821
|
// src/result-store.ts
|
|
4566
4822
|
import { randomBytes } from "crypto";
|
|
4567
|
-
import { mkdir as mkdir5, readFile as
|
|
4823
|
+
import { mkdir as mkdir5, readFile as readFile4, readdir as readdir3, rename as rename3, rm as rm6, writeFile as writeFile4 } from "fs/promises";
|
|
4568
4824
|
import { homedir as homedir5 } from "os";
|
|
4569
4825
|
import { join as join7 } from "path";
|
|
4570
4826
|
var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
|
|
@@ -4687,7 +4943,7 @@ function isStoredSession(value) {
|
|
|
4687
4943
|
}
|
|
4688
4944
|
async function readStoredSession(path) {
|
|
4689
4945
|
try {
|
|
4690
|
-
const parsed = JSON.parse(await
|
|
4946
|
+
const parsed = JSON.parse(await readFile4(path, "utf8"));
|
|
4691
4947
|
return isStoredSession(parsed) ? parsed : void 0;
|
|
4692
4948
|
} catch (error) {
|
|
4693
4949
|
if (error.code === "ENOENT") {
|