@saptools/cf-hana 0.3.5 → 0.4.1
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 +15 -0
- package/README.md +97 -17
- package/dist/cli.js +3932 -3184
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +23 -8
- package/dist/index.js +922 -315
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -5,6 +5,44 @@ import { mkdir, writeFile } from "fs/promises";
|
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
import { join } from "path";
|
|
7
7
|
|
|
8
|
+
// src/config.ts
|
|
9
|
+
var CLI_VERSION = "0.4.0";
|
|
10
|
+
var ENV_PREFIX = "CF_HANA";
|
|
11
|
+
var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
|
|
12
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
|
|
13
|
+
var DEFAULT_POOL_MAX = 4;
|
|
14
|
+
var DEFAULT_POOL_IDLE_MS = 6e4;
|
|
15
|
+
var DEFAULT_AUTO_LIMIT = 100;
|
|
16
|
+
var DEFAULT_MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
|
|
17
|
+
var MAX_RESULT_STORE_BYTES = resolveMaxResultStoreBytes();
|
|
18
|
+
function resolveMaxResultStoreBytes() {
|
|
19
|
+
if (readEnv(envName("DRIVER")) !== "fake") {
|
|
20
|
+
return DEFAULT_MAX_RESULT_STORE_BYTES;
|
|
21
|
+
}
|
|
22
|
+
const raw = readEnv(envName("FAKE_MAX_STORE_BYTES"));
|
|
23
|
+
const parsed = raw === void 0 ? Number.NaN : Number(raw);
|
|
24
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_RESULT_STORE_BYTES;
|
|
25
|
+
}
|
|
26
|
+
function envName(suffix) {
|
|
27
|
+
return `${ENV_PREFIX}_${suffix}`;
|
|
28
|
+
}
|
|
29
|
+
function readEnv(name) {
|
|
30
|
+
const value = process.env[name];
|
|
31
|
+
if (value === void 0) {
|
|
32
|
+
return void 0;
|
|
33
|
+
}
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
return trimmed.length === 0 ? void 0 : trimmed;
|
|
36
|
+
}
|
|
37
|
+
function readSapCredentials(overrides) {
|
|
38
|
+
const email = overrides?.email ?? readEnv("SAP_EMAIL");
|
|
39
|
+
const password = overrides?.password ?? readEnv("SAP_PASSWORD");
|
|
40
|
+
if (email === void 0 || password === void 0) {
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
43
|
+
return { email, password };
|
|
44
|
+
}
|
|
45
|
+
|
|
8
46
|
// src/errors.ts
|
|
9
47
|
var CfHanaError = class extends Error {
|
|
10
48
|
code;
|
|
@@ -31,6 +69,12 @@ var QueryError = class extends CfHanaError {
|
|
|
31
69
|
this.databaseCode = options?.databaseCode;
|
|
32
70
|
}
|
|
33
71
|
};
|
|
72
|
+
var BackupRequiredError = class extends CfHanaError {
|
|
73
|
+
constructor(message, options) {
|
|
74
|
+
super("BACKUP_REQUIRED", message, options);
|
|
75
|
+
this.name = "BackupRequiredError";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
34
78
|
function isDatabaseErrorShape(error) {
|
|
35
79
|
return typeof error === "object" && error !== null && "code" in error;
|
|
36
80
|
}
|
|
@@ -146,6 +190,16 @@ function formatJson(result) {
|
|
|
146
190
|
});
|
|
147
191
|
return JSON.stringify(rows, null, 2);
|
|
148
192
|
}
|
|
193
|
+
function formatJsonCompact(result, preferredColumn) {
|
|
194
|
+
const singleColumn = result.columns.length === 1 ? result.columns[0]?.name : void 0;
|
|
195
|
+
const columnName = preferredColumn ?? singleColumn;
|
|
196
|
+
if (columnName === void 0) {
|
|
197
|
+
return formatJson(result);
|
|
198
|
+
}
|
|
199
|
+
const column = result.columns.find((item) => item.name === columnName);
|
|
200
|
+
const values = result.rows.map((row) => serializeCell(row[columnName] ?? null, column));
|
|
201
|
+
return JSON.stringify(values, null, 2);
|
|
202
|
+
}
|
|
149
203
|
function formatCsv(result) {
|
|
150
204
|
const headers = result.columns.map((column) => column.name);
|
|
151
205
|
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
@@ -156,26 +210,252 @@ function formatCsv(result) {
|
|
|
156
210
|
}
|
|
157
211
|
return lines.join("\r\n");
|
|
158
212
|
}
|
|
159
|
-
function formatResult(result, format) {
|
|
213
|
+
function formatResult(result, format, compactColumn) {
|
|
160
214
|
switch (format) {
|
|
161
215
|
case "table":
|
|
162
216
|
return formatTable(result);
|
|
163
217
|
case "json":
|
|
164
218
|
return formatJson(result);
|
|
219
|
+
case "json-compact":
|
|
220
|
+
return formatJsonCompact(result, compactColumn);
|
|
165
221
|
case "csv":
|
|
166
222
|
return formatCsv(result);
|
|
167
223
|
}
|
|
168
224
|
}
|
|
169
225
|
|
|
226
|
+
// src/003-backup-sql-parser.ts
|
|
227
|
+
function isIdentifierChar(char) {
|
|
228
|
+
return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
|
|
229
|
+
}
|
|
230
|
+
function skipQuotedText(sql, index) {
|
|
231
|
+
const quote = sql[index];
|
|
232
|
+
let cursor = index + 1;
|
|
233
|
+
while (cursor < sql.length) {
|
|
234
|
+
if (sql[cursor] === quote) {
|
|
235
|
+
if (sql[cursor + 1] === quote) {
|
|
236
|
+
cursor += 2;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
return cursor + 1;
|
|
240
|
+
}
|
|
241
|
+
cursor += 1;
|
|
242
|
+
}
|
|
243
|
+
return cursor;
|
|
244
|
+
}
|
|
245
|
+
function skipLineComment(sql, index) {
|
|
246
|
+
let cursor = index + 2;
|
|
247
|
+
while (cursor < sql.length && sql[cursor] !== "\n") {
|
|
248
|
+
cursor += 1;
|
|
249
|
+
}
|
|
250
|
+
return cursor;
|
|
251
|
+
}
|
|
252
|
+
function skipBlockComment(sql, index) {
|
|
253
|
+
let cursor = index + 2;
|
|
254
|
+
while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
|
|
255
|
+
cursor += 1;
|
|
256
|
+
}
|
|
257
|
+
return Math.min(cursor + 2, sql.length);
|
|
258
|
+
}
|
|
259
|
+
function skipNonCode(sql, index) {
|
|
260
|
+
const char = sql[index];
|
|
261
|
+
if (char === "'" || char === '"') {
|
|
262
|
+
return skipQuotedText(sql, index);
|
|
263
|
+
}
|
|
264
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
265
|
+
return skipLineComment(sql, index);
|
|
266
|
+
}
|
|
267
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
268
|
+
return skipBlockComment(sql, index);
|
|
269
|
+
}
|
|
270
|
+
return void 0;
|
|
271
|
+
}
|
|
272
|
+
function skipTrivia(sql, start, end) {
|
|
273
|
+
let index = start;
|
|
274
|
+
while (index < end) {
|
|
275
|
+
if (/\s/.test(sql.charAt(index))) {
|
|
276
|
+
index += 1;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (sql[index] === "-" && sql[index + 1] === "-") {
|
|
280
|
+
index = skipLineComment(sql, index);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (sql[index] === "/" && sql[index + 1] === "*") {
|
|
284
|
+
index = skipBlockComment(sql, index);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
return index;
|
|
290
|
+
}
|
|
291
|
+
function readIdentifierPart(sql, start, end) {
|
|
292
|
+
const index = skipTrivia(sql, start, end);
|
|
293
|
+
if (sql[index] === '"') {
|
|
294
|
+
const next2 = skipQuotedText(sql, index);
|
|
295
|
+
return next2 <= end && sql[next2 - 1] === '"' ? { sql: sql.slice(index, next2), end: next2 } : void 0;
|
|
296
|
+
}
|
|
297
|
+
if (!/[A-Za-z_#$]/.test(sql.charAt(index))) {
|
|
298
|
+
return void 0;
|
|
299
|
+
}
|
|
300
|
+
let next = index + 1;
|
|
301
|
+
while (next < end && /[A-Za-z0-9_#$]/.test(sql.charAt(next))) {
|
|
302
|
+
next += 1;
|
|
303
|
+
}
|
|
304
|
+
return { sql: sql.slice(index, next), end: next };
|
|
305
|
+
}
|
|
306
|
+
function readQualifiedTarget(sql, start, end) {
|
|
307
|
+
const first = readIdentifierPart(sql, start, end);
|
|
308
|
+
if (first === void 0) {
|
|
309
|
+
return void 0;
|
|
310
|
+
}
|
|
311
|
+
const parts = [first.sql];
|
|
312
|
+
let cursor = first.end;
|
|
313
|
+
while (parts.length < 3) {
|
|
314
|
+
const dot = skipTrivia(sql, cursor, end);
|
|
315
|
+
if (sql[dot] !== ".") {
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
const part = readIdentifierPart(sql, dot + 1, end);
|
|
319
|
+
if (part === void 0) {
|
|
320
|
+
return void 0;
|
|
321
|
+
}
|
|
322
|
+
parts.push(part.sql);
|
|
323
|
+
cursor = part.end;
|
|
324
|
+
}
|
|
325
|
+
return { sql: parts.join("."), reference: parts.at(-1) ?? first.sql, end: cursor };
|
|
326
|
+
}
|
|
327
|
+
function topLevelTokens(sql) {
|
|
328
|
+
const tokens = [];
|
|
329
|
+
let index = 0;
|
|
330
|
+
let depth = 0;
|
|
331
|
+
while (index < sql.length) {
|
|
332
|
+
const skipped = skipNonCode(sql, index);
|
|
333
|
+
if (skipped !== void 0) {
|
|
334
|
+
index = skipped;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const char = sql.charAt(index);
|
|
338
|
+
if (char === "(") {
|
|
339
|
+
depth += 1;
|
|
340
|
+
index += 1;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (char === ")") {
|
|
344
|
+
depth = Math.max(0, depth - 1);
|
|
345
|
+
index += 1;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (!/[A-Za-z_#$]/.test(char)) {
|
|
349
|
+
index += 1;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
let end = index + 1;
|
|
353
|
+
while (end < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(end))) {
|
|
354
|
+
end += 1;
|
|
355
|
+
}
|
|
356
|
+
if (depth === 0) {
|
|
357
|
+
tokens.push({ keyword: sql.slice(index, end).toUpperCase(), start: index, end });
|
|
358
|
+
}
|
|
359
|
+
index = end;
|
|
360
|
+
}
|
|
361
|
+
return tokens;
|
|
362
|
+
}
|
|
363
|
+
function findToken(tokens, keyword, start = 0) {
|
|
364
|
+
for (let index = start; index < tokens.length; index += 1) {
|
|
365
|
+
if (tokens[index]?.keyword === keyword) {
|
|
366
|
+
return index;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return void 0;
|
|
370
|
+
}
|
|
371
|
+
function keywordMatches(sql, index, keyword) {
|
|
372
|
+
const end = index + keyword.length;
|
|
373
|
+
return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
|
|
374
|
+
}
|
|
375
|
+
function findTopLevelKeyword(sql, keyword, startIndex = 0) {
|
|
376
|
+
let index = startIndex;
|
|
377
|
+
let depth = 0;
|
|
378
|
+
while (index < sql.length) {
|
|
379
|
+
const skipped = skipNonCode(sql, index);
|
|
380
|
+
if (skipped !== void 0) {
|
|
381
|
+
index = skipped;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const char = sql[index];
|
|
385
|
+
if (char === "(") {
|
|
386
|
+
depth += 1;
|
|
387
|
+
} else if (char === ")" && depth > 0) {
|
|
388
|
+
depth -= 1;
|
|
389
|
+
} else if (depth === 0 && keywordMatches(sql, index, keyword)) {
|
|
390
|
+
return index;
|
|
391
|
+
}
|
|
392
|
+
index += 1;
|
|
393
|
+
}
|
|
394
|
+
return void 0;
|
|
395
|
+
}
|
|
396
|
+
function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
397
|
+
let index = startIndex;
|
|
398
|
+
let depth = 0;
|
|
399
|
+
while (index < endIndex) {
|
|
400
|
+
const skipped = skipNonCode(sql, index);
|
|
401
|
+
if (skipped !== void 0) {
|
|
402
|
+
index = skipped;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const char = sql[index];
|
|
406
|
+
if (char === target && depth === 0) {
|
|
407
|
+
return index;
|
|
408
|
+
}
|
|
409
|
+
if (char === "(") {
|
|
410
|
+
depth += 1;
|
|
411
|
+
} else if (char === ")" && depth > 0) {
|
|
412
|
+
depth -= 1;
|
|
413
|
+
}
|
|
414
|
+
index += 1;
|
|
415
|
+
}
|
|
416
|
+
return void 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
170
419
|
// src/statements.ts
|
|
171
|
-
var LEADING_NOISE = /^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/;
|
|
172
420
|
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
|
|
173
421
|
var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
|
|
174
422
|
var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
|
|
175
423
|
function firstKeyword(sql) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
424
|
+
let index = 0;
|
|
425
|
+
while (index < sql.length) {
|
|
426
|
+
const char = sql.charAt(index);
|
|
427
|
+
if (char.trim().length === 0) {
|
|
428
|
+
index += 1;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
432
|
+
index += 2;
|
|
433
|
+
while (index < sql.length && sql[index] !== "\n") {
|
|
434
|
+
index += 1;
|
|
435
|
+
}
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
439
|
+
const commentEnd = sql.indexOf("*/", index + 2);
|
|
440
|
+
if (commentEnd === -1) {
|
|
441
|
+
return "";
|
|
442
|
+
}
|
|
443
|
+
index = commentEnd + 2;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
let keywordEnd = index;
|
|
449
|
+
while (keywordEnd < sql.length) {
|
|
450
|
+
const code = sql.charCodeAt(keywordEnd);
|
|
451
|
+
const isUpperCase = code >= 65 && code <= 90;
|
|
452
|
+
const isLowerCase = code >= 97 && code <= 122;
|
|
453
|
+
if (!isUpperCase && !isLowerCase) {
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
keywordEnd += 1;
|
|
457
|
+
}
|
|
458
|
+
return sql.slice(index, keywordEnd).toUpperCase();
|
|
179
459
|
}
|
|
180
460
|
function classifyStatement(sql) {
|
|
181
461
|
const keyword = firstKeyword(sql);
|
|
@@ -255,162 +535,247 @@ function assertParamArity(sql, params) {
|
|
|
255
535
|
}
|
|
256
536
|
}
|
|
257
537
|
|
|
258
|
-
// src/backup.ts
|
|
259
|
-
var
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
538
|
+
// src/001-backup-planner.ts
|
|
539
|
+
var WRITE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
540
|
+
"UPDATE",
|
|
541
|
+
"UPSERT",
|
|
542
|
+
"REPLACE",
|
|
543
|
+
"MERGE",
|
|
544
|
+
"DELETE"
|
|
545
|
+
]);
|
|
546
|
+
var MERGE_MODIFY_ACTIONS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
265
547
|
function trimStatementSql(sql) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
function skipQuotedText(sql, index) {
|
|
272
|
-
const quote = sql[index];
|
|
273
|
-
let cursor = index + 1;
|
|
274
|
-
while (cursor < sql.length) {
|
|
275
|
-
if (sql[cursor] === quote) {
|
|
276
|
-
if (sql[cursor + 1] === quote) {
|
|
277
|
-
cursor += 2;
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
return cursor + 1;
|
|
281
|
-
}
|
|
282
|
-
cursor += 1;
|
|
548
|
+
const trimmed = sql.trim();
|
|
549
|
+
let end = trimmed.length;
|
|
550
|
+
while (end > 0 && trimmed.charAt(end - 1) === ";") {
|
|
551
|
+
end -= 1;
|
|
283
552
|
}
|
|
284
|
-
return
|
|
553
|
+
return trimmed.slice(0, end).trim();
|
|
285
554
|
}
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
cursor += 1;
|
|
555
|
+
function isMergeClauseStart(tokens, index) {
|
|
556
|
+
if (tokens[index]?.keyword !== "WHEN") {
|
|
557
|
+
return false;
|
|
290
558
|
}
|
|
291
|
-
return
|
|
559
|
+
return tokens[index + 1]?.keyword === "MATCHED" || tokens[index + 1]?.keyword === "NOT" && tokens[index + 2]?.keyword === "MATCHED";
|
|
292
560
|
}
|
|
293
|
-
function
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
561
|
+
function mergeClauseIndexes(tokens, start) {
|
|
562
|
+
const indexes = [];
|
|
563
|
+
for (let index = start; index < tokens.length; index += 1) {
|
|
564
|
+
if (isMergeClauseStart(tokens, index)) {
|
|
565
|
+
indexes.push(index);
|
|
566
|
+
}
|
|
297
567
|
}
|
|
298
|
-
return
|
|
568
|
+
return indexes;
|
|
299
569
|
}
|
|
300
|
-
function
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
570
|
+
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
571
|
+
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
572
|
+
}
|
|
573
|
+
function buildSelectPlan(operation, statementSql, targetSql, whereIndex, params) {
|
|
574
|
+
const target = targetSql.trim();
|
|
575
|
+
if (target.length === 0) {
|
|
576
|
+
throw new QueryError(`${operation.toUpperCase()} backup requires a target table`);
|
|
304
577
|
}
|
|
305
|
-
if (
|
|
306
|
-
return
|
|
578
|
+
if (whereIndex === void 0) {
|
|
579
|
+
return { operation, statementSql, selectSql: `SELECT * FROM ${target}`, selectParams: [] };
|
|
307
580
|
}
|
|
308
|
-
|
|
309
|
-
|
|
581
|
+
const whereSql = statementSql.slice(whereIndex + "WHERE".length).trim();
|
|
582
|
+
if (whereSql.length === 0) {
|
|
583
|
+
throw new QueryError(`${operation.toUpperCase()} backup requires a non-empty WHERE clause`);
|
|
310
584
|
}
|
|
311
|
-
return
|
|
585
|
+
return {
|
|
586
|
+
operation,
|
|
587
|
+
statementSql,
|
|
588
|
+
selectSql: `SELECT * FROM ${target} WHERE ${whereSql}`,
|
|
589
|
+
selectParams: selectParamsAfterWhere(statementSql, whereIndex, params)
|
|
590
|
+
};
|
|
312
591
|
}
|
|
313
|
-
function
|
|
314
|
-
const
|
|
315
|
-
|
|
592
|
+
function buildUpdateBackupPlan(statementSql, params) {
|
|
593
|
+
const updateIndex = findTopLevelKeyword(statementSql, "UPDATE");
|
|
594
|
+
const setIndex = updateIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "SET", updateIndex + "UPDATE".length);
|
|
595
|
+
if (updateIndex === void 0 || setIndex === void 0) {
|
|
596
|
+
throw new QueryError("UPDATE backup requires UPDATE <target> SET syntax");
|
|
597
|
+
}
|
|
598
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", setIndex + "SET".length);
|
|
599
|
+
return buildSelectPlan(
|
|
600
|
+
"update",
|
|
601
|
+
statementSql,
|
|
602
|
+
statementSql.slice(updateIndex + "UPDATE".length, setIndex),
|
|
603
|
+
whereIndex,
|
|
604
|
+
params
|
|
605
|
+
);
|
|
316
606
|
}
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
607
|
+
function buildUpsertBackupPlan(statementSql, params, keyword) {
|
|
608
|
+
const operation = keyword === "UPSERT" ? "upsert" : "replace";
|
|
609
|
+
const keywordIndex = findTopLevelKeyword(statementSql, keyword);
|
|
610
|
+
const target = keywordIndex === void 0 ? void 0 : readQualifiedTarget(statementSql, keywordIndex + keyword.length, statementSql.length);
|
|
611
|
+
if (keywordIndex === void 0 || target === void 0) {
|
|
612
|
+
throw new BackupRequiredError(
|
|
613
|
+
`${keyword} write refused: cannot derive a trustworthy backup target`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
const valuesIndex = findTopLevelKeyword(statementSql, "VALUES", target.end);
|
|
617
|
+
if (valuesIndex === void 0) {
|
|
618
|
+
const selectIndex = findTopLevelKeyword(statementSql, "SELECT", target.end);
|
|
619
|
+
const withIndex = findTopLevelKeyword(statementSql, "WITH", target.end);
|
|
620
|
+
if (selectIndex === void 0 && withIndex === void 0) {
|
|
621
|
+
throw new BackupRequiredError(
|
|
622
|
+
`${keyword} write refused: expected VALUES, WITH PRIMARY KEY, or a subquery`
|
|
623
|
+
);
|
|
333
624
|
}
|
|
334
|
-
|
|
625
|
+
return buildSelectPlan(operation, statementSql, target.sql, void 0, params);
|
|
335
626
|
}
|
|
336
|
-
|
|
627
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
|
|
628
|
+
return buildSelectPlan(operation, statementSql, target.sql, whereIndex, params);
|
|
337
629
|
}
|
|
338
|
-
function
|
|
339
|
-
let
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
630
|
+
function mergeTargetReference(statementSql, target, usingStart) {
|
|
631
|
+
let suffix = statementSql.slice(target.end, usingStart).trim();
|
|
632
|
+
if (suffix.length === 0) {
|
|
633
|
+
return target.reference;
|
|
634
|
+
}
|
|
635
|
+
if (/^PARTITION\b/i.test(suffix)) {
|
|
636
|
+
const open = suffix.indexOf("(");
|
|
637
|
+
if (open === -1) {
|
|
638
|
+
return void 0;
|
|
346
639
|
}
|
|
347
|
-
const
|
|
348
|
-
if (
|
|
349
|
-
return
|
|
640
|
+
const close = findTopLevelChar(suffix, ")", open + 1, suffix.length);
|
|
641
|
+
if (close === void 0) {
|
|
642
|
+
return void 0;
|
|
350
643
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
depth -= 1;
|
|
644
|
+
suffix = suffix.slice(close + 1).trim();
|
|
645
|
+
if (suffix.length === 0) {
|
|
646
|
+
return target.reference;
|
|
355
647
|
}
|
|
356
|
-
index += 1;
|
|
357
648
|
}
|
|
358
|
-
|
|
649
|
+
suffix = suffix.replace(/^AS\b/i, "").trim();
|
|
650
|
+
const alias = readIdentifierPart(suffix, 0, suffix.length);
|
|
651
|
+
if (alias === void 0 || skipTrivia(suffix, alias.end, suffix.length) !== suffix.length) {
|
|
652
|
+
return void 0;
|
|
653
|
+
}
|
|
654
|
+
return alias.sql;
|
|
359
655
|
}
|
|
360
|
-
function
|
|
361
|
-
|
|
656
|
+
function paramsInRange(sql, params, start, end) {
|
|
657
|
+
const offset = countPlaceholders(sql.slice(0, start));
|
|
658
|
+
const length = countPlaceholders(sql.slice(start, end));
|
|
659
|
+
return params.slice(offset, offset + length);
|
|
362
660
|
}
|
|
363
|
-
function
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
661
|
+
function wholeTargetPlan(operation, statementSql, target) {
|
|
662
|
+
return buildSelectPlan(operation, statementSql, target.sql, void 0, []);
|
|
663
|
+
}
|
|
664
|
+
function readRequiredMergeToken(tokens, keyword, start) {
|
|
665
|
+
const index = findToken(tokens, keyword, start);
|
|
666
|
+
const token = index === void 0 ? void 0 : tokens[index];
|
|
667
|
+
if (index === void 0 || token === void 0) {
|
|
668
|
+
throw new BackupRequiredError(
|
|
669
|
+
"MERGE write refused: cannot derive a trustworthy backup target"
|
|
670
|
+
);
|
|
367
671
|
}
|
|
368
|
-
|
|
369
|
-
|
|
672
|
+
return { index, token };
|
|
673
|
+
}
|
|
674
|
+
function parseMerge(statementSql) {
|
|
675
|
+
const tokens = topLevelTokens(statementSql);
|
|
676
|
+
const into = readRequiredMergeToken(tokens, "INTO", 1);
|
|
677
|
+
const using = readRequiredMergeToken(tokens, "USING", into.index + 1);
|
|
678
|
+
const on = readRequiredMergeToken(tokens, "ON", using.index + 1);
|
|
679
|
+
const target = readQualifiedTarget(statementSql, into.token.end, using.token.start);
|
|
680
|
+
if (target === void 0) {
|
|
681
|
+
throw new BackupRequiredError(
|
|
682
|
+
"MERGE write refused: cannot derive a trustworthy backup target"
|
|
683
|
+
);
|
|
370
684
|
}
|
|
371
|
-
const
|
|
372
|
-
if (
|
|
373
|
-
throw new
|
|
685
|
+
const clauseIndexes = mergeClauseIndexes(tokens, on.index + 1);
|
|
686
|
+
if (clauseIndexes.length === 0) {
|
|
687
|
+
throw new BackupRequiredError("MERGE write refused: no supported WHEN clause was found");
|
|
688
|
+
}
|
|
689
|
+
const firstClause = tokens[clauseIndexes[0] ?? 0];
|
|
690
|
+
return {
|
|
691
|
+
tokens,
|
|
692
|
+
into: into.token,
|
|
693
|
+
using: using.token,
|
|
694
|
+
on: on.token,
|
|
695
|
+
target,
|
|
696
|
+
clauseIndexes,
|
|
697
|
+
matchedIndexes: clauseIndexes.filter((index) => tokens[index + 1]?.keyword === "MATCHED"),
|
|
698
|
+
targetClause: statementSql.slice(into.token.end, using.token.start).trim(),
|
|
699
|
+
sourceClause: statementSql.slice(using.token.end, on.token.start).trim(),
|
|
700
|
+
onCondition: statementSql.slice(on.token.end, firstClause?.start).trim()
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
function readMatchedActionTokens(tokens, matchedIndex) {
|
|
704
|
+
const thenIndex = findToken(tokens, "THEN", matchedIndex + 2);
|
|
705
|
+
if (thenIndex === void 0) {
|
|
706
|
+
return void 0;
|
|
707
|
+
}
|
|
708
|
+
const thenToken = tokens[thenIndex];
|
|
709
|
+
const actionToken = tokens[thenIndex + 1];
|
|
710
|
+
const matchedToken = tokens[matchedIndex + 1];
|
|
711
|
+
if (thenToken === void 0 || actionToken === void 0 || matchedToken === void 0) {
|
|
712
|
+
return void 0;
|
|
713
|
+
}
|
|
714
|
+
if (!MERGE_MODIFY_ACTIONS.has(actionToken.keyword)) {
|
|
715
|
+
return void 0;
|
|
716
|
+
}
|
|
717
|
+
const clauseToken = tokens[matchedIndex];
|
|
718
|
+
return {
|
|
719
|
+
thenToken,
|
|
720
|
+
conditionToken: tokens[matchedIndex + 2],
|
|
721
|
+
clauseStart: clauseToken === void 0 ? 0 : clauseToken.start
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function parseMatchedClause(statementSql, params, tokens, matchedIndex) {
|
|
725
|
+
const action = readMatchedActionTokens(tokens, matchedIndex);
|
|
726
|
+
if (action?.conditionToken === void 0) {
|
|
727
|
+
return void 0;
|
|
728
|
+
}
|
|
729
|
+
if (action.conditionToken.keyword === "THEN") {
|
|
730
|
+
return { clauseStart: action.clauseStart, matchedCondition: "", conditionParams: [] };
|
|
731
|
+
}
|
|
732
|
+
if (action.conditionToken.keyword !== "AND") {
|
|
733
|
+
return void 0;
|
|
374
734
|
}
|
|
375
735
|
return {
|
|
376
|
-
|
|
377
|
-
statementSql,
|
|
378
|
-
|
|
379
|
-
|
|
736
|
+
clauseStart: action.clauseStart,
|
|
737
|
+
matchedCondition: statementSql.slice(action.conditionToken.end, action.thenToken.start).trim(),
|
|
738
|
+
conditionParams: paramsInRange(
|
|
739
|
+
statementSql,
|
|
740
|
+
params,
|
|
741
|
+
action.conditionToken.end,
|
|
742
|
+
action.thenToken.start
|
|
743
|
+
)
|
|
380
744
|
};
|
|
381
745
|
}
|
|
382
|
-
function
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
if (
|
|
386
|
-
|
|
746
|
+
function buildExactMergePlan(statementSql, params, merge, matchedIndex) {
|
|
747
|
+
const targetReference = mergeTargetReference(statementSql, merge.target, merge.using.start);
|
|
748
|
+
const matchedClause = parseMatchedClause(statementSql, params, merge.tokens, matchedIndex);
|
|
749
|
+
if (targetReference === void 0 || matchedClause === void 0) {
|
|
750
|
+
return void 0;
|
|
387
751
|
}
|
|
388
|
-
const
|
|
389
|
-
return buildSelectPlan(
|
|
390
|
-
"update",
|
|
752
|
+
const sourceParams = paramsInRange(
|
|
391
753
|
statementSql,
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
754
|
+
params,
|
|
755
|
+
merge.using.end,
|
|
756
|
+
matchedClause.clauseStart
|
|
395
757
|
);
|
|
758
|
+
const conditionSql = matchedClause.matchedCondition.length === 0 ? "" : ` AND (${matchedClause.matchedCondition})`;
|
|
759
|
+
return {
|
|
760
|
+
operation: "merge",
|
|
761
|
+
statementSql,
|
|
762
|
+
selectSql: `SELECT ${targetReference}.* FROM ${merge.targetClause} WHERE EXISTS (SELECT 1 FROM ${merge.sourceClause} WHERE (${merge.onCondition})${conditionSql})`,
|
|
763
|
+
selectParams: [...sourceParams, ...matchedClause.conditionParams]
|
|
764
|
+
};
|
|
396
765
|
}
|
|
397
|
-
function
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
throw new QueryError("UPSERT backup requires UPSERT <target> VALUES syntax");
|
|
766
|
+
function buildMergeBackupPlan(statementSql, params) {
|
|
767
|
+
const merge = parseMerge(statementSql);
|
|
768
|
+
if (merge.matchedIndexes.length === 0) {
|
|
769
|
+
return void 0;
|
|
402
770
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
whereIndex,
|
|
412
|
-
params
|
|
413
|
-
);
|
|
771
|
+
if (merge.matchedIndexes.length > 1) {
|
|
772
|
+
return wholeTargetPlan("merge", statementSql, merge.target);
|
|
773
|
+
}
|
|
774
|
+
if (merge.sourceClause.length === 0 || merge.onCondition.length === 0) {
|
|
775
|
+
return wholeTargetPlan("merge", statementSql, merge.target);
|
|
776
|
+
}
|
|
777
|
+
const matchedIndex = merge.matchedIndexes[0] ?? 0;
|
|
778
|
+
return buildExactMergePlan(statementSql, params, merge, matchedIndex) ?? wholeTargetPlan("merge", statementSql, merge.target);
|
|
414
779
|
}
|
|
415
780
|
function buildDeleteBackupPlan(statementSql, params) {
|
|
416
781
|
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
@@ -428,6 +793,49 @@ function buildDeleteBackupPlan(statementSql, params) {
|
|
|
428
793
|
params
|
|
429
794
|
);
|
|
430
795
|
}
|
|
796
|
+
function isWriteKeyword(keyword) {
|
|
797
|
+
return WRITE_KEYWORDS.has(keyword);
|
|
798
|
+
}
|
|
799
|
+
function buildMergeWriteBackupPlan(statementSql, params) {
|
|
800
|
+
const secondKeyword = topLevelTokens(statementSql)[1]?.keyword;
|
|
801
|
+
if (secondKeyword === "DELTA") {
|
|
802
|
+
return void 0;
|
|
803
|
+
}
|
|
804
|
+
if (secondKeyword !== "INTO") {
|
|
805
|
+
throw new BackupRequiredError("MERGE write refused: expected MERGE INTO syntax");
|
|
806
|
+
}
|
|
807
|
+
return buildMergeBackupPlan(statementSql, params);
|
|
808
|
+
}
|
|
809
|
+
function dispatchWriteBackupPlan(keyword, statementSql, params) {
|
|
810
|
+
switch (keyword) {
|
|
811
|
+
case "UPDATE":
|
|
812
|
+
return buildUpdateBackupPlan(statementSql, params);
|
|
813
|
+
case "UPSERT":
|
|
814
|
+
case "REPLACE":
|
|
815
|
+
return buildUpsertBackupPlan(statementSql, params, keyword);
|
|
816
|
+
case "MERGE":
|
|
817
|
+
return buildMergeWriteBackupPlan(statementSql, params);
|
|
818
|
+
case "DELETE":
|
|
819
|
+
return buildDeleteBackupPlan(statementSql, params);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
function buildWriteBackupPlan(sql, params = []) {
|
|
823
|
+
const statementSql = trimStatementSql(sql);
|
|
824
|
+
const keyword = firstKeyword(statementSql);
|
|
825
|
+
if (!isWriteKeyword(keyword)) {
|
|
826
|
+
return void 0;
|
|
827
|
+
}
|
|
828
|
+
assertParamArity(statementSql, params);
|
|
829
|
+
return dispatchWriteBackupPlan(keyword, statementSql, params);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// src/backup.ts
|
|
833
|
+
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
834
|
+
var CF_HANA_DIR_NAME = "cf-hana";
|
|
835
|
+
var BACKUPS_DIR_NAME = "backups";
|
|
836
|
+
function defaultSaptoolsRoot() {
|
|
837
|
+
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
838
|
+
}
|
|
431
839
|
function backupTimestamp(now) {
|
|
432
840
|
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
433
841
|
}
|
|
@@ -447,21 +855,6 @@ function backupBaseName(input, now) {
|
|
|
447
855
|
function cfHanaBackupRoot(saptoolsRoot) {
|
|
448
856
|
return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
449
857
|
}
|
|
450
|
-
function buildWriteBackupPlan(sql, params = []) {
|
|
451
|
-
const statementSql = trimStatementSql(sql);
|
|
452
|
-
const keyword = firstKeyword(statementSql);
|
|
453
|
-
if (keyword !== "UPDATE" && keyword !== "UPSERT" && keyword !== "DELETE") {
|
|
454
|
-
return void 0;
|
|
455
|
-
}
|
|
456
|
-
assertParamArity(statementSql, params);
|
|
457
|
-
if (keyword === "UPDATE") {
|
|
458
|
-
return buildUpdateBackupPlan(statementSql, params);
|
|
459
|
-
}
|
|
460
|
-
if (keyword === "UPSERT") {
|
|
461
|
-
return buildUpsertBackupPlan(statementSql, params);
|
|
462
|
-
}
|
|
463
|
-
return buildDeleteBackupPlan(statementSql, params);
|
|
464
|
-
}
|
|
465
858
|
async function writeSqlBackup(input, options = {}) {
|
|
466
859
|
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
467
860
|
const directory = join(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
|
|
@@ -477,11 +870,17 @@ async function writeSqlBackup(input, options = {}) {
|
|
|
477
870
|
rowCount: input.result.rowCount,
|
|
478
871
|
createdAt: now.toISOString()
|
|
479
872
|
};
|
|
873
|
+
const csv = formatCsv(input.result);
|
|
874
|
+
if (Buffer.byteLength(csv) > (options.maxBytes ?? MAX_RESULT_STORE_BYTES)) {
|
|
875
|
+
throw new BackupRequiredError(
|
|
876
|
+
"Write backup exceeds the storage limit; the write was refused"
|
|
877
|
+
);
|
|
878
|
+
}
|
|
480
879
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
481
880
|
await Promise.all([
|
|
482
881
|
writeFile(statementPath, `${input.statementSql}
|
|
483
882
|
`, { encoding: "utf8", mode: 384 }),
|
|
484
|
-
writeFile(backupPath,
|
|
883
|
+
writeFile(backupPath, csv, { encoding: "utf8", mode: 384 }),
|
|
485
884
|
writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
|
|
486
885
|
`, {
|
|
487
886
|
encoding: "utf8",
|
|
@@ -624,35 +1023,6 @@ async function listColumns(connection, schema, table) {
|
|
|
624
1023
|
}));
|
|
625
1024
|
}
|
|
626
1025
|
|
|
627
|
-
// src/config.ts
|
|
628
|
-
var CLI_VERSION = "0.3.5";
|
|
629
|
-
var ENV_PREFIX = "CF_HANA";
|
|
630
|
-
var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
|
|
631
|
-
var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
|
|
632
|
-
var DEFAULT_POOL_MAX = 4;
|
|
633
|
-
var DEFAULT_POOL_IDLE_MS = 6e4;
|
|
634
|
-
var DEFAULT_AUTO_LIMIT = 100;
|
|
635
|
-
var MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
|
|
636
|
-
function envName(suffix) {
|
|
637
|
-
return `${ENV_PREFIX}_${suffix}`;
|
|
638
|
-
}
|
|
639
|
-
function readEnv(name) {
|
|
640
|
-
const value = process.env[name];
|
|
641
|
-
if (value === void 0) {
|
|
642
|
-
return void 0;
|
|
643
|
-
}
|
|
644
|
-
const trimmed = value.trim();
|
|
645
|
-
return trimmed.length === 0 ? void 0 : trimmed;
|
|
646
|
-
}
|
|
647
|
-
function readSapCredentials(overrides) {
|
|
648
|
-
const email = overrides?.email ?? readEnv("SAP_EMAIL");
|
|
649
|
-
const password = overrides?.password ?? readEnv("SAP_PASSWORD");
|
|
650
|
-
if (email === void 0 || password === void 0) {
|
|
651
|
-
return void 0;
|
|
652
|
-
}
|
|
653
|
-
return { email, password };
|
|
654
|
-
}
|
|
655
|
-
|
|
656
1026
|
// src/cf.ts
|
|
657
1027
|
import { execFile } from "child_process";
|
|
658
1028
|
import { mkdtemp, rm } from "fs/promises";
|
|
@@ -735,7 +1105,7 @@ function normalizeSapCfApiEndpoint(apiEndpoint) {
|
|
|
735
1105
|
if (/^https:\/\/[^/]*:\d+(?:[/?#]|$)/i.test(trimmed)) {
|
|
736
1106
|
throw new Error(`Invalid or untrusted CF API endpoint "${apiEndpoint}".`);
|
|
737
1107
|
}
|
|
738
|
-
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "" || parsed.port !== "" || parsed.pathname.replace(
|
|
1108
|
+
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "" || parsed.port !== "" || parsed.pathname.replace(/(?<!\/)\/+$/, "") !== "" || parsed.search !== "" || parsed.hash !== "") {
|
|
739
1109
|
throw new Error(`Invalid or untrusted CF API endpoint "${apiEndpoint}".`);
|
|
740
1110
|
}
|
|
741
1111
|
const hostname = parsed.hostname.toLowerCase();
|
|
@@ -904,6 +1274,81 @@ function formatCurrentCfAppSelector(target, appName) {
|
|
|
904
1274
|
const region = target.regionKey ?? "current";
|
|
905
1275
|
return `${region}/${target.orgName}/${target.spaceName}/${name}`;
|
|
906
1276
|
}
|
|
1277
|
+
function findJsonObjectEnd(source, startIndex) {
|
|
1278
|
+
let depth = 0;
|
|
1279
|
+
let inString = false;
|
|
1280
|
+
let escaped = false;
|
|
1281
|
+
for (let index = startIndex; index < source.length; index++) {
|
|
1282
|
+
const character = source[index];
|
|
1283
|
+
if (escaped) {
|
|
1284
|
+
escaped = false;
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (inString && character === "\\") {
|
|
1288
|
+
escaped = true;
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
if (character === '"') {
|
|
1292
|
+
inString = !inString;
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (inString) {
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
if (character === "{") {
|
|
1299
|
+
depth++;
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
if (character === "}" && --depth === 0) {
|
|
1303
|
+
return index;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return -1;
|
|
1307
|
+
}
|
|
1308
|
+
function extractVcapApplicationJson(stdout) {
|
|
1309
|
+
const marker = "VCAP_APPLICATION:";
|
|
1310
|
+
const markerIndex = stdout.indexOf(marker);
|
|
1311
|
+
if (markerIndex === -1) {
|
|
1312
|
+
throw new Error("VCAP_APPLICATION section not found in cf env output");
|
|
1313
|
+
}
|
|
1314
|
+
const afterMarker = stdout.slice(markerIndex + marker.length);
|
|
1315
|
+
const openIndex = afterMarker.indexOf("{");
|
|
1316
|
+
if (openIndex === -1) {
|
|
1317
|
+
throw new Error("VCAP_APPLICATION JSON payload not found in cf env output");
|
|
1318
|
+
}
|
|
1319
|
+
const closeIndex = findJsonObjectEnd(afterMarker, openIndex);
|
|
1320
|
+
if (closeIndex === -1) {
|
|
1321
|
+
throw new Error("Malformed VCAP_APPLICATION JSON in cf env output");
|
|
1322
|
+
}
|
|
1323
|
+
return afterMarker.slice(openIndex, closeIndex + 1);
|
|
1324
|
+
}
|
|
1325
|
+
function requireApplicationField(application, field) {
|
|
1326
|
+
const value = application[field];
|
|
1327
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
1328
|
+
throw new Error(`VCAP_APPLICATION.${field} must be a non-empty string`);
|
|
1329
|
+
}
|
|
1330
|
+
return value.trim();
|
|
1331
|
+
}
|
|
1332
|
+
function extractCfEnvApplicationIdentity(stdout) {
|
|
1333
|
+
let application;
|
|
1334
|
+
try {
|
|
1335
|
+
application = JSON.parse(extractVcapApplicationJson(stdout));
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
if (error instanceof SyntaxError) {
|
|
1338
|
+
throw new Error("VCAP_APPLICATION is not valid JSON", { cause: error });
|
|
1339
|
+
}
|
|
1340
|
+
throw error;
|
|
1341
|
+
}
|
|
1342
|
+
if (!isRecord(application)) {
|
|
1343
|
+
throw new Error("VCAP_APPLICATION must be an object");
|
|
1344
|
+
}
|
|
1345
|
+
return {
|
|
1346
|
+
appName: requireApplicationField(application, "application_name"),
|
|
1347
|
+
apiEndpoint: normalizeSapCfApiEndpoint(requireApplicationField(application, "cf_api")),
|
|
1348
|
+
orgName: requireApplicationField(application, "organization_name"),
|
|
1349
|
+
spaceName: requireApplicationField(application, "space_name")
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
907
1352
|
function extractVcapSection(stdout) {
|
|
908
1353
|
const start = stdout.indexOf("VCAP_SERVICES:");
|
|
909
1354
|
if (start === -1) {
|
|
@@ -985,107 +1430,143 @@ function classifyCfError(stderr = "", stdout = "") {
|
|
|
985
1430
|
function errorMessageFromUnknown(error) {
|
|
986
1431
|
return error instanceof Error ? error.message : "Invalid or untrusted CF API endpoint.";
|
|
987
1432
|
}
|
|
988
|
-
|
|
989
|
-
const
|
|
990
|
-
if (!
|
|
991
|
-
throw new CfHanaError(
|
|
1433
|
+
function parseExplicitTarget(selector) {
|
|
1434
|
+
const [regionKey, orgName, spaceName, appName, extra] = selector.split("/").map((part) => part.trim());
|
|
1435
|
+
if (extra !== void 0 || !regionKey || !orgName || !spaceName || !appName) {
|
|
1436
|
+
throw new CfHanaError(
|
|
1437
|
+
"CONFIG",
|
|
1438
|
+
`Invalid selector "${selector}". Use region/org/space/app or a bare app name.`
|
|
1439
|
+
);
|
|
992
1440
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1441
|
+
const apiEndpoint = getApiEndpointForRegion(regionKey);
|
|
1442
|
+
if (apiEndpoint === void 0) {
|
|
1443
|
+
throw new CfHanaError(
|
|
1444
|
+
"CONFIG",
|
|
1445
|
+
`Unknown SAP CF region "${regionKey}". Verify the current SAP region list or use the current CF target.`
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
try {
|
|
1449
|
+
return {
|
|
1450
|
+
selector,
|
|
1451
|
+
apiEndpoint: normalizeSapCfApiEndpoint(apiEndpoint),
|
|
1452
|
+
orgName,
|
|
1453
|
+
spaceName,
|
|
1454
|
+
appName,
|
|
1455
|
+
selectorSource: "explicit",
|
|
1456
|
+
regionConfirmed: true,
|
|
1457
|
+
selectorCanBePinned: true
|
|
1010
1458
|
};
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
throw new CfHanaError("CONFIG", errorMessageFromUnknown(error), { cause: error });
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
async function resolveTarget(selector) {
|
|
1464
|
+
if (selector.includes("/")) {
|
|
1465
|
+
return parseExplicitTarget(selector);
|
|
1466
|
+
}
|
|
1467
|
+
const current = await readCurrentCfTarget();
|
|
1468
|
+
if (current === void 0) {
|
|
1469
|
+
throw new CfHanaError(
|
|
1470
|
+
"CONFIG",
|
|
1471
|
+
"No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
return {
|
|
1475
|
+
selector: formatCurrentCfAppSelector(current, selector),
|
|
1476
|
+
apiEndpoint: current.apiEndpoint,
|
|
1477
|
+
orgName: current.orgName,
|
|
1478
|
+
spaceName: current.spaceName,
|
|
1479
|
+
appName: selector,
|
|
1480
|
+
selectorSource: "ambient",
|
|
1481
|
+
regionConfirmed: current.regionKey !== void 0,
|
|
1482
|
+
selectorCanBePinned: current.regionKey !== void 0 && getApiEndpointForRegion(current.regionKey) !== void 0
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
function commandErrorText(error) {
|
|
1486
|
+
if (typeof error !== "object" || error === null) {
|
|
1487
|
+
return "";
|
|
1488
|
+
}
|
|
1489
|
+
if ("stderr" in error && typeof error.stderr === "string" && error.stderr.length > 0) {
|
|
1490
|
+
return error.stderr;
|
|
1491
|
+
}
|
|
1492
|
+
return "message" in error && typeof error.message === "string" ? error.message : "";
|
|
1493
|
+
}
|
|
1494
|
+
function isSameTarget(target, current) {
|
|
1495
|
+
return current?.apiEndpoint === target.apiEndpoint && current.orgName === target.orgName && current.spaceName === target.spaceName;
|
|
1496
|
+
}
|
|
1497
|
+
async function assertAmbientTargetUnchanged(target) {
|
|
1498
|
+
const current = await readCurrentCfTarget();
|
|
1499
|
+
if (!isSameTarget(target, current)) {
|
|
1500
|
+
throw new CfHanaError(
|
|
1501
|
+
"CONFIG",
|
|
1502
|
+
"CF target changed during binding discovery; no database connection was opened. Retry or use an explicit region/org/space/app selector."
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
function readCfEnvApplicationIdentity(stdout) {
|
|
1507
|
+
try {
|
|
1508
|
+
return extractCfEnvApplicationIdentity(stdout);
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
throw new CfHanaError(
|
|
1511
|
+
"CONFIG",
|
|
1512
|
+
"Could not verify cf env application identity for the ambient target; no database connection was opened. Retry or use an explicit region/org/space/app selector.",
|
|
1513
|
+
{ cause: error }
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
function assertCfEnvApplicationMatchesTarget(target, application) {
|
|
1518
|
+
const matches = application.apiEndpoint === target.apiEndpoint && application.orgName === target.orgName && application.spaceName === target.spaceName && application.appName === target.appName;
|
|
1519
|
+
if (matches) {
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
throw new CfHanaError(
|
|
1523
|
+
"CONFIG",
|
|
1524
|
+
"CF env application identity did not match the resolved ambient target; no database connection was opened. Retry or use an explicit region/org/space/app selector."
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
async function readIsolatedBindings(target, sap) {
|
|
1528
|
+
return await withCfSession(async (ctx) => {
|
|
1529
|
+
await cfApi(target.apiEndpoint, ctx);
|
|
1530
|
+
await cfAuth(sap.email, sap.password, ctx);
|
|
1531
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
1532
|
+
return extractHanaBindingsFromCfEnv(await cfEnv(target.appName, ctx));
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
async function readBareBindings(target, options) {
|
|
1536
|
+
let stdout;
|
|
1537
|
+
try {
|
|
1538
|
+
stdout = await cfEnvDirect(target.appName);
|
|
1539
|
+
} catch (directError) {
|
|
1540
|
+
const classified = classifyCfError(commandErrorText(directError));
|
|
1541
|
+
if (!classified.isAuthError) {
|
|
1022
1542
|
throw new CfHanaError(
|
|
1023
1543
|
"CONFIG",
|
|
1024
|
-
`
|
|
1544
|
+
`Failed to get HANA bindings for bare app "${target.appName}" using current target (org=${target.orgName}, space=${target.spaceName}). Verify with "cf target" and "cf env ${target.appName}".`,
|
|
1545
|
+
{ cause: directError }
|
|
1025
1546
|
);
|
|
1026
1547
|
}
|
|
1027
|
-
let normalizedApiEndpoint;
|
|
1028
|
-
try {
|
|
1029
|
-
normalizedApiEndpoint = normalizeSapCfApiEndpoint(apiEndpoint);
|
|
1030
|
-
} catch (error) {
|
|
1031
|
-
throw new CfHanaError("CONFIG", errorMessageFromUnknown(error), { cause: error });
|
|
1032
|
-
}
|
|
1033
|
-
target = { selector, apiEndpoint: normalizedApiEndpoint, orgName, spaceName, appName };
|
|
1034
|
-
}
|
|
1035
|
-
let bindings;
|
|
1036
|
-
const source = "live";
|
|
1037
|
-
if (isBare) {
|
|
1038
|
-
try {
|
|
1039
|
-
const stdout = await cfEnvDirect(target.appName);
|
|
1040
|
-
bindings = extractHanaBindingsFromCfEnv(stdout);
|
|
1041
|
-
} catch (directError) {
|
|
1042
|
-
let stderr = "";
|
|
1043
|
-
if (directError && typeof directError === "object") {
|
|
1044
|
-
const e = directError;
|
|
1045
|
-
stderr = (typeof e.stderr === "string" ? e.stderr : "") || (typeof e.message === "string" ? e.message : "");
|
|
1046
|
-
}
|
|
1047
|
-
const classified = classifyCfError(stderr);
|
|
1048
|
-
if (classified.isAuthError) {
|
|
1049
|
-
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
1050
|
-
if (!sap) {
|
|
1051
|
-
throw new CredentialsNotFoundError(
|
|
1052
|
-
`Current CF session problem for bare app "${target.appName}" (${classified.reason}).
|
|
1053
|
-
Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
|
|
1054
|
-
{ cause: directError }
|
|
1055
|
-
);
|
|
1056
|
-
}
|
|
1057
|
-
const api = target.apiEndpoint;
|
|
1058
|
-
bindings = await withCfSession(async (ctx) => {
|
|
1059
|
-
await cfApi(api, ctx);
|
|
1060
|
-
await cfAuth(sap.email, sap.password, ctx);
|
|
1061
|
-
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
1062
|
-
const stdout = await cfEnv(target.appName, ctx);
|
|
1063
|
-
return extractHanaBindingsFromCfEnv(stdout);
|
|
1064
|
-
});
|
|
1065
|
-
} else {
|
|
1066
|
-
throw new CfHanaError(
|
|
1067
|
-
"CONFIG",
|
|
1068
|
-
`Failed to get HANA bindings for bare app "${target.appName}" using current target (org=${target.orgName}, space=${target.spaceName}). Verify with "cf target" and "cf env ${target.appName}". ${stderr ? `Details: ${stderr}` : ""}`,
|
|
1069
|
-
{ cause: directError }
|
|
1070
|
-
);
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
} else {
|
|
1074
1548
|
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
1075
|
-
if (
|
|
1549
|
+
if (sap === void 0) {
|
|
1076
1550
|
throw new CredentialsNotFoundError(
|
|
1077
|
-
`
|
|
1551
|
+
`Current CF session problem for bare app "${target.appName}" (${classified.reason}).
|
|
1552
|
+
Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
|
|
1553
|
+
{ cause: directError }
|
|
1078
1554
|
);
|
|
1079
1555
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1556
|
+
return await readIsolatedBindings(target, sap);
|
|
1557
|
+
}
|
|
1558
|
+
const application = readCfEnvApplicationIdentity(stdout);
|
|
1559
|
+
await assertAmbientTargetUnchanged(target);
|
|
1560
|
+
assertCfEnvApplicationMatchesTarget(target, application);
|
|
1561
|
+
return extractHanaBindingsFromCfEnv(stdout);
|
|
1562
|
+
}
|
|
1563
|
+
async function resolveAppBindings(rawSelector, options) {
|
|
1564
|
+
const selector = rawSelector.trim();
|
|
1565
|
+
if (selector.length === 0) {
|
|
1566
|
+
throw new CfHanaError("CONFIG", "App selector is required");
|
|
1088
1567
|
}
|
|
1568
|
+
const target = await resolveTarget(selector);
|
|
1569
|
+
const bindings = target.selectorSource === "ambient" ? await readBareBindings(target, options) : await readExplicitBindings(target, options);
|
|
1089
1570
|
if (bindings.length === 0) {
|
|
1090
1571
|
throw new CredentialsNotFoundError(`App "${target.selector}" has no HANA service binding.`);
|
|
1091
1572
|
}
|
|
@@ -1093,9 +1574,21 @@ Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
|
|
|
1093
1574
|
selector: target.selector,
|
|
1094
1575
|
appName: target.appName,
|
|
1095
1576
|
bindings,
|
|
1096
|
-
source
|
|
1577
|
+
source: "live",
|
|
1578
|
+
selectorSource: target.selectorSource,
|
|
1579
|
+
regionConfirmed: target.regionConfirmed,
|
|
1580
|
+
selectorCanBePinned: target.selectorCanBePinned
|
|
1097
1581
|
};
|
|
1098
1582
|
}
|
|
1583
|
+
async function readExplicitBindings(target, options) {
|
|
1584
|
+
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
1585
|
+
if (sap === void 0) {
|
|
1586
|
+
throw new CredentialsNotFoundError(
|
|
1587
|
+
`SAP_EMAIL and SAP_PASSWORD are required to fetch HANA bindings for explicit selector "${target.selector}".`
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
return await readIsolatedBindings(target, sap);
|
|
1591
|
+
}
|
|
1099
1592
|
function selectBinding(bindings, selector) {
|
|
1100
1593
|
if (selector.bindingName !== void 0) {
|
|
1101
1594
|
const match = bindings.find((binding) => binding.name === selector.bindingName);
|
|
@@ -1166,42 +1659,82 @@ function tableColumns() {
|
|
|
1166
1659
|
{ COLUMN_NAME: "SCOPE_NAME", DATA_TYPE_NAME: "NVARCHAR", LENGTH: 255, SCALE: null, IS_NULLABLE: "TRUE", POSITION: 3 }
|
|
1167
1660
|
];
|
|
1168
1661
|
}
|
|
1169
|
-
function
|
|
1170
|
-
const kind = classifyStatement(sql);
|
|
1662
|
+
function throwForcedFailure(kind) {
|
|
1171
1663
|
const forcedFailure = readEnv(envName("FAKE_FAIL_STATEMENT"))?.toLowerCase();
|
|
1172
1664
|
if (forcedFailure === kind) {
|
|
1173
1665
|
throw new Error(`fake driver forced ${kind.toUpperCase()} failure`);
|
|
1174
1666
|
}
|
|
1175
|
-
|
|
1667
|
+
}
|
|
1668
|
+
function throwEarlyFixtureError(upperSql) {
|
|
1669
|
+
if (upperSql.includes("PRIVILEGE_ERROR_CODE")) {
|
|
1670
|
+
throw new QueryError("insufficient privilege: not authorized", { databaseCode: 258 });
|
|
1671
|
+
}
|
|
1672
|
+
if (upperSql.includes("PRIVILEGE_ERROR_MESSAGE")) {
|
|
1673
|
+
throw new QueryError("insufficient privilege: grant is missing");
|
|
1674
|
+
}
|
|
1675
|
+
if (upperSql.includes("NON_PRIVILEGE_ERROR")) {
|
|
1676
|
+
throw new QueryError("fake unrelated query failure", { databaseCode: 999 });
|
|
1677
|
+
}
|
|
1678
|
+
if (readEnv(envName("FAKE_PRIVILEGE_CATALOG")) === "1" && (upperSql.includes("SYS.TABLES") || upperSql.includes("SYS.TABLE_COLUMNS"))) {
|
|
1679
|
+
throw new QueryError("insufficient privilege: catalog access denied", { databaseCode: 258 });
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
function catalogObjectsResult() {
|
|
1683
|
+
return {
|
|
1684
|
+
rows: catalogObjects(),
|
|
1685
|
+
columns: [
|
|
1686
|
+
{ name: "SCHEMA_NAME", typeName: "NVARCHAR" },
|
|
1687
|
+
{ name: "OBJECT_NAME", typeName: "NVARCHAR" },
|
|
1688
|
+
{ name: "OBJECT_TYPE", typeName: "NVARCHAR" }
|
|
1689
|
+
],
|
|
1690
|
+
affectedRows: 0
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
function tablesResult() {
|
|
1694
|
+
return {
|
|
1695
|
+
rows: [
|
|
1696
|
+
{ SCHEMA_NAME: "APP_SCHEMA", TABLE_NAME: "EXISTING_TABLE", TABLE_TYPE: "COLUMN TABLE" },
|
|
1697
|
+
{ SCHEMA_NAME: "APP_SCHEMA", TABLE_NAME: "STATUS_ITEMS", TABLE_TYPE: "ROW TABLE" }
|
|
1698
|
+
],
|
|
1699
|
+
columns: [
|
|
1700
|
+
{ name: "SCHEMA_NAME", typeName: "NVARCHAR" },
|
|
1701
|
+
{ name: "TABLE_NAME", typeName: "NVARCHAR" },
|
|
1702
|
+
{ name: "TABLE_TYPE", typeName: "NVARCHAR" }
|
|
1703
|
+
],
|
|
1704
|
+
affectedRows: 0
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
function tableColumnsResult() {
|
|
1708
|
+
return {
|
|
1709
|
+
rows: tableColumns(),
|
|
1710
|
+
columns: [
|
|
1711
|
+
{ name: "COLUMN_NAME", typeName: "NVARCHAR" },
|
|
1712
|
+
{ name: "DATA_TYPE_NAME", typeName: "NVARCHAR" },
|
|
1713
|
+
{ name: "LENGTH", typeName: "INTEGER" },
|
|
1714
|
+
{ name: "SCALE", typeName: "INTEGER" },
|
|
1715
|
+
{ name: "IS_NULLABLE", typeName: "NVARCHAR" },
|
|
1716
|
+
{ name: "POSITION", typeName: "INTEGER" }
|
|
1717
|
+
],
|
|
1718
|
+
affectedRows: 0
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
function matchCatalogFixture(upperSql) {
|
|
1176
1722
|
if (upperSql.includes("SYS.TABLES") && upperSql.includes("SYS.VIEWS")) {
|
|
1177
1723
|
if (readEnv(envName("FAKE_FAIL_CATALOG_ONCE")) === "1" && !catalogFailureInjected) {
|
|
1178
1724
|
catalogFailureInjected = true;
|
|
1179
1725
|
throw new QueryError("fake transient catalog metadata failure");
|
|
1180
1726
|
}
|
|
1181
|
-
return
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
{ name: "OBJECT_NAME", typeName: "NVARCHAR" },
|
|
1186
|
-
{ name: "OBJECT_TYPE", typeName: "NVARCHAR" }
|
|
1187
|
-
],
|
|
1188
|
-
affectedRows: 0
|
|
1189
|
-
};
|
|
1727
|
+
return catalogObjectsResult();
|
|
1728
|
+
}
|
|
1729
|
+
if (upperSql.includes("SYS.TABLES")) {
|
|
1730
|
+
return tablesResult();
|
|
1190
1731
|
}
|
|
1191
1732
|
if (upperSql.includes("SYS.TABLE_COLUMNS")) {
|
|
1192
|
-
return
|
|
1193
|
-
rows: tableColumns(),
|
|
1194
|
-
columns: [
|
|
1195
|
-
{ name: "COLUMN_NAME", typeName: "NVARCHAR" },
|
|
1196
|
-
{ name: "DATA_TYPE_NAME", typeName: "NVARCHAR" },
|
|
1197
|
-
{ name: "LENGTH", typeName: "INTEGER" },
|
|
1198
|
-
{ name: "SCALE", typeName: "INTEGER" },
|
|
1199
|
-
{ name: "IS_NULLABLE", typeName: "NVARCHAR" },
|
|
1200
|
-
{ name: "POSITION", typeName: "INTEGER" }
|
|
1201
|
-
],
|
|
1202
|
-
affectedRows: 0
|
|
1203
|
-
};
|
|
1733
|
+
return tableColumnsResult();
|
|
1204
1734
|
}
|
|
1735
|
+
return void 0;
|
|
1736
|
+
}
|
|
1737
|
+
function throwLateFixtureError(upperSql) {
|
|
1205
1738
|
if (upperSql.includes("MISSING_TABLE") || upperSql.includes("MISSING_TABLES")) {
|
|
1206
1739
|
throw new QueryError("invalid table name: MISSING_TABLE", { sqlState: "42S02" });
|
|
1207
1740
|
}
|
|
@@ -1214,30 +1747,45 @@ function fakeExec(sql) {
|
|
|
1214
1747
|
if (upperSql.includes("LOB_GROUP_ERROR")) {
|
|
1215
1748
|
throw new QueryError("inconsistent datatype: LOB type is not allowed in GROUP BY clause", { databaseCode: 274 });
|
|
1216
1749
|
}
|
|
1217
|
-
|
|
1750
|
+
}
|
|
1751
|
+
function lobFixtureResult() {
|
|
1752
|
+
return {
|
|
1753
|
+
rows: [
|
|
1754
|
+
{
|
|
1755
|
+
LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
|
|
1756
|
+
CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
|
|
1757
|
+
PAYLOAD: Buffer.from([0, 1, 2, 255])
|
|
1758
|
+
}
|
|
1759
|
+
],
|
|
1760
|
+
columns: [
|
|
1761
|
+
{ name: "LOG_CONTENT", typeName: "NCLOB" },
|
|
1762
|
+
{ name: "CLOB_CONTENT", typeName: "CLOB" },
|
|
1763
|
+
{ name: "PAYLOAD", typeName: "BLOB" }
|
|
1764
|
+
],
|
|
1765
|
+
affectedRows: 0
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
function matchDataFixture(upperSql) {
|
|
1769
|
+
if (upperSql.includes("LOB_FIXTURE")) {
|
|
1770
|
+
return lobFixtureResult();
|
|
1771
|
+
}
|
|
1772
|
+
if (upperSql.includes("SINGLE_COLUMN_FIXTURE")) {
|
|
1218
1773
|
return {
|
|
1219
|
-
rows: [
|
|
1220
|
-
|
|
1221
|
-
LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
|
|
1222
|
-
CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
|
|
1223
|
-
PAYLOAD: Buffer.from([0, 1, 2, 255])
|
|
1224
|
-
}
|
|
1225
|
-
],
|
|
1226
|
-
columns: [
|
|
1227
|
-
{ name: "LOG_CONTENT", typeName: "NCLOB" },
|
|
1228
|
-
{ name: "CLOB_CONTENT", typeName: "CLOB" },
|
|
1229
|
-
{ name: "PAYLOAD", typeName: "BLOB" }
|
|
1230
|
-
],
|
|
1774
|
+
rows: [{ VALUE: "alpha" }, { VALUE: "beta" }],
|
|
1775
|
+
columns: [{ name: "VALUE", typeName: "NVARCHAR" }],
|
|
1231
1776
|
affectedRows: 0
|
|
1232
1777
|
};
|
|
1233
1778
|
}
|
|
1234
|
-
if (
|
|
1779
|
+
if (upperSql.includes("DUMMY")) {
|
|
1235
1780
|
return {
|
|
1236
1781
|
rows: [{ "1": 1 }],
|
|
1237
1782
|
columns: [{ name: "1", typeName: "INTEGER" }],
|
|
1238
1783
|
affectedRows: 0
|
|
1239
1784
|
};
|
|
1240
1785
|
}
|
|
1786
|
+
return void 0;
|
|
1787
|
+
}
|
|
1788
|
+
function defaultResult(kind) {
|
|
1241
1789
|
if (kind === "select") {
|
|
1242
1790
|
return {
|
|
1243
1791
|
rows: [
|
|
@@ -1256,6 +1804,18 @@ function fakeExec(sql) {
|
|
|
1256
1804
|
}
|
|
1257
1805
|
return { rows: [], columns: [], affectedRows: 0 };
|
|
1258
1806
|
}
|
|
1807
|
+
function fakeExec(sql) {
|
|
1808
|
+
const kind = classifyStatement(sql);
|
|
1809
|
+
throwForcedFailure(kind);
|
|
1810
|
+
const upperSql = sql.toUpperCase();
|
|
1811
|
+
throwEarlyFixtureError(upperSql);
|
|
1812
|
+
const catalogFixture = matchCatalogFixture(upperSql);
|
|
1813
|
+
if (catalogFixture !== void 0) {
|
|
1814
|
+
return catalogFixture;
|
|
1815
|
+
}
|
|
1816
|
+
throwLateFixtureError(upperSql);
|
|
1817
|
+
return matchDataFixture(upperSql) ?? defaultResult(kind);
|
|
1818
|
+
}
|
|
1259
1819
|
async function traceFakeExec(sql, params) {
|
|
1260
1820
|
const tracePath = readEnv(envName("FAKE_TRACE_FILE"));
|
|
1261
1821
|
if (tracePath === void 0) {
|
|
@@ -1686,8 +2246,30 @@ function maskIgnoredSqlText(sql) {
|
|
|
1686
2246
|
}
|
|
1687
2247
|
return masked;
|
|
1688
2248
|
}
|
|
1689
|
-
function
|
|
1690
|
-
|
|
2249
|
+
function hasTopLevelKeyword(sql, keyword) {
|
|
2250
|
+
const masked = maskIgnoredSqlText(sql);
|
|
2251
|
+
let depth = 0;
|
|
2252
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
2253
|
+
const char = masked[index];
|
|
2254
|
+
if (char === "(") {
|
|
2255
|
+
depth += 1;
|
|
2256
|
+
continue;
|
|
2257
|
+
}
|
|
2258
|
+
if (char === ")") {
|
|
2259
|
+
depth = Math.max(0, depth - 1);
|
|
2260
|
+
continue;
|
|
2261
|
+
}
|
|
2262
|
+
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))) {
|
|
2263
|
+
return true;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
return false;
|
|
2267
|
+
}
|
|
2268
|
+
function isUnconditionalMergeDelete(sql) {
|
|
2269
|
+
return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
|
|
2270
|
+
}
|
|
2271
|
+
function isMalformedReplace(sql) {
|
|
2272
|
+
return !hasTopLevelKeyword(sql, "VALUES") && !hasTopLevelKeyword(sql, "SELECT") && !hasTopLevelKeyword(sql, "WITH");
|
|
1691
2273
|
}
|
|
1692
2274
|
function trailingLineCommentIndex(sql) {
|
|
1693
2275
|
let index = 0;
|
|
@@ -1714,12 +2296,12 @@ function trailingLineCommentIndex(sql) {
|
|
|
1714
2296
|
return void 0;
|
|
1715
2297
|
}
|
|
1716
2298
|
function appendLimit(sql, limit) {
|
|
1717
|
-
const trimmed = sql.replace(/[\s;]+$/, "");
|
|
2299
|
+
const trimmed = sql.replace(/(?<![\s;])[\s;]+$/, "");
|
|
1718
2300
|
const commentIndex = trailingLineCommentIndex(trimmed);
|
|
1719
2301
|
if (commentIndex === void 0) {
|
|
1720
2302
|
return `${trimmed} LIMIT ${String(limit)}`;
|
|
1721
2303
|
}
|
|
1722
|
-
const beforeComment = trimmed.slice(0, commentIndex).replace(/[\s;]+$/, "");
|
|
2304
|
+
const beforeComment = trimmed.slice(0, commentIndex).replace(/(?<![\s;])[\s;]+$/, "");
|
|
1723
2305
|
const comment = trimmed.slice(commentIndex);
|
|
1724
2306
|
return `${beforeComment} LIMIT ${String(limit)} ${comment}`;
|
|
1725
2307
|
}
|
|
@@ -1730,9 +2312,10 @@ function inspectStatement(sql) {
|
|
|
1730
2312
|
return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
|
|
1731
2313
|
}
|
|
1732
2314
|
if (kind === "dml") {
|
|
2315
|
+
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(sql, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(sql) || keyword === "REPLACE" && isMalformedReplace(sql);
|
|
1733
2316
|
return {
|
|
1734
2317
|
kind,
|
|
1735
|
-
destructive
|
|
2318
|
+
destructive
|
|
1736
2319
|
};
|
|
1737
2320
|
}
|
|
1738
2321
|
return { kind, destructive: false };
|
|
@@ -1752,7 +2335,7 @@ function evaluateGuard(sql, config) {
|
|
|
1752
2335
|
allowed: false,
|
|
1753
2336
|
destructive: true,
|
|
1754
2337
|
violation: "destructive",
|
|
1755
|
-
reason: "destructive statement blocked (DROP/TRUNCATE/ALTER
|
|
2338
|
+
reason: "destructive statement blocked (DROP/TRUNCATE/ALTER, unscoped UPDATE/DELETE, or unconditional matched MERGE DELETE); allow it explicitly to proceed"
|
|
1756
2339
|
};
|
|
1757
2340
|
}
|
|
1758
2341
|
return {
|
|
@@ -2154,18 +2737,37 @@ function nextExplainStatementName() {
|
|
|
2154
2737
|
explainStatementCounter = (explainStatementCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
2155
2738
|
return `cf_hana_${String(process.pid)}_${String(Date.now())}_${String(explainStatementCounter)}`;
|
|
2156
2739
|
}
|
|
2740
|
+
function toPoolOptions(options) {
|
|
2741
|
+
if (options.pool === false) {
|
|
2742
|
+
return { max: 1 };
|
|
2743
|
+
}
|
|
2744
|
+
return options.pool ?? {};
|
|
2745
|
+
}
|
|
2746
|
+
function toSelectedBindingInfo(bindings, selected) {
|
|
2747
|
+
const availableBindingNames = bindings.flatMap(
|
|
2748
|
+
(binding) => binding.name === void 0 ? [] : [binding.name]
|
|
2749
|
+
);
|
|
2750
|
+
return {
|
|
2751
|
+
...selected.name === void 0 ? {} : { bindingName: selected.name },
|
|
2752
|
+
bindingIndex: bindings.indexOf(selected),
|
|
2753
|
+
availableBindingNames
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2157
2756
|
var HanaClient = class _HanaClient {
|
|
2158
|
-
constructor(pool, info) {
|
|
2757
|
+
constructor(pool, info, databaseUser = "") {
|
|
2159
2758
|
this.pool = pool;
|
|
2160
2759
|
this.info = info;
|
|
2760
|
+
this.databaseUser = databaseUser;
|
|
2161
2761
|
}
|
|
2162
2762
|
pool;
|
|
2163
2763
|
info;
|
|
2764
|
+
databaseUser;
|
|
2164
2765
|
/** Open a client for a `region/org/space/app` selector (or a bare app name). */
|
|
2165
2766
|
static async connect(selector, options = {}) {
|
|
2166
2767
|
const resolved = await resolveAppBindings(selector, options);
|
|
2167
2768
|
const role = options.role ?? "runtime";
|
|
2168
2769
|
const binding = selectBinding(resolved.bindings, options);
|
|
2770
|
+
const bindingInfo = toSelectedBindingInfo(resolved.bindings, binding);
|
|
2169
2771
|
const target = toConnectionTarget(binding, role);
|
|
2170
2772
|
const driver = createDriver();
|
|
2171
2773
|
const config = {
|
|
@@ -2181,7 +2783,7 @@ var HanaClient = class _HanaClient {
|
|
|
2181
2783
|
allowDestructive: options.allowDestructive ?? false,
|
|
2182
2784
|
autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT
|
|
2183
2785
|
};
|
|
2184
|
-
const poolOptions = options
|
|
2786
|
+
const poolOptions = toPoolOptions(options);
|
|
2185
2787
|
const info = {
|
|
2186
2788
|
selector: resolved.selector,
|
|
2187
2789
|
appName: resolved.appName,
|
|
@@ -2189,9 +2791,13 @@ var HanaClient = class _HanaClient {
|
|
|
2189
2791
|
schema: target.schema,
|
|
2190
2792
|
role,
|
|
2191
2793
|
driver: driver.name,
|
|
2192
|
-
credentialSource: resolved.source
|
|
2794
|
+
credentialSource: resolved.source,
|
|
2795
|
+
selectorSource: resolved.selectorSource,
|
|
2796
|
+
regionConfirmed: resolved.regionConfirmed,
|
|
2797
|
+
selectorCanBePinned: resolved.selectorCanBePinned,
|
|
2798
|
+
...bindingInfo
|
|
2193
2799
|
};
|
|
2194
|
-
return new _HanaClient(new ConnectionPool(driver, config, poolOptions), info);
|
|
2800
|
+
return new _HanaClient(new ConnectionPool(driver, config, poolOptions), info, target.user);
|
|
2195
2801
|
}
|
|
2196
2802
|
/** Run a SELECT (or any read) statement and return typed rows. */
|
|
2197
2803
|
async query(sql, params, options) {
|
|
@@ -2207,7 +2813,7 @@ var HanaClient = class _HanaClient {
|
|
|
2207
2813
|
await this.recordSqlHistory("execute", sql, resolvedParams, result);
|
|
2208
2814
|
return result;
|
|
2209
2815
|
}
|
|
2210
|
-
/** Back up rows
|
|
2816
|
+
/** Back up pre-image rows required by a supported write before the caller runs it. */
|
|
2211
2817
|
async backupWriteStatement(sql, params, options) {
|
|
2212
2818
|
const resolvedParams = params ?? [];
|
|
2213
2819
|
const plan = buildWriteBackupPlan(sql, resolvedParams);
|
|
@@ -2388,6 +2994,7 @@ async function withConnection(selector, work, options) {
|
|
|
2388
2994
|
}
|
|
2389
2995
|
}
|
|
2390
2996
|
export {
|
|
2997
|
+
BackupRequiredError,
|
|
2391
2998
|
CfHanaError,
|
|
2392
2999
|
CredentialsNotFoundError,
|
|
2393
3000
|
DestructiveStatementError,
|