@saptools/cf-hana 0.3.4 → 0.4.0
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 +20 -0
- package/README.md +97 -17
- package/dist/cli.js +3937 -3130
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +23 -8
- package/dist/index.js +984 -318
- 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,115 +210,20 @@ 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
|
|
|
170
|
-
// src/
|
|
171
|
-
var LEADING_NOISE = /^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/;
|
|
172
|
-
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
|
|
173
|
-
var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
|
|
174
|
-
var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
|
|
175
|
-
function firstKeyword(sql) {
|
|
176
|
-
const stripped = sql.replace(LEADING_NOISE, "");
|
|
177
|
-
const match = /^[A-Za-z]+/.exec(stripped);
|
|
178
|
-
return (match?.[0] ?? "").toUpperCase();
|
|
179
|
-
}
|
|
180
|
-
function classifyStatement(sql) {
|
|
181
|
-
const keyword = firstKeyword(sql);
|
|
182
|
-
if (SELECT_KEYWORDS.has(keyword)) {
|
|
183
|
-
return "select";
|
|
184
|
-
}
|
|
185
|
-
if (DML_KEYWORDS.has(keyword)) {
|
|
186
|
-
return "dml";
|
|
187
|
-
}
|
|
188
|
-
if (DDL_KEYWORDS.has(keyword)) {
|
|
189
|
-
return "ddl";
|
|
190
|
-
}
|
|
191
|
-
return "unknown";
|
|
192
|
-
}
|
|
193
|
-
function quoteIdentifier(identifier) {
|
|
194
|
-
if (identifier.length === 0) {
|
|
195
|
-
throw new QueryError("A SQL identifier must not be empty");
|
|
196
|
-
}
|
|
197
|
-
if (identifier.includes("\0")) {
|
|
198
|
-
throw new QueryError("A SQL identifier must not contain a NUL character");
|
|
199
|
-
}
|
|
200
|
-
return `"${identifier.replace(/"/g, '""')}"`;
|
|
201
|
-
}
|
|
202
|
-
function qualifiedName(schema, table) {
|
|
203
|
-
return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
|
|
204
|
-
}
|
|
205
|
-
function countPlaceholders(sql) {
|
|
206
|
-
let count = 0;
|
|
207
|
-
let index = 0;
|
|
208
|
-
const length = sql.length;
|
|
209
|
-
while (index < length) {
|
|
210
|
-
const char = sql[index];
|
|
211
|
-
if (char === "'" || char === '"') {
|
|
212
|
-
const quote = char;
|
|
213
|
-
index += 1;
|
|
214
|
-
while (index < length) {
|
|
215
|
-
if (sql[index] === quote) {
|
|
216
|
-
if (sql[index + 1] === quote) {
|
|
217
|
-
index += 2;
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
index += 1;
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
index += 1;
|
|
224
|
-
}
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (char === "-" && sql[index + 1] === "-") {
|
|
228
|
-
index += 2;
|
|
229
|
-
while (index < length && sql[index] !== "\n") {
|
|
230
|
-
index += 1;
|
|
231
|
-
}
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
if (char === "/" && sql[index + 1] === "*") {
|
|
235
|
-
index += 2;
|
|
236
|
-
while (index < length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
237
|
-
index += 1;
|
|
238
|
-
}
|
|
239
|
-
index += 2;
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
if (char === "?") {
|
|
243
|
-
count += 1;
|
|
244
|
-
}
|
|
245
|
-
index += 1;
|
|
246
|
-
}
|
|
247
|
-
return count;
|
|
248
|
-
}
|
|
249
|
-
function assertParamArity(sql, params) {
|
|
250
|
-
const expected = countPlaceholders(sql);
|
|
251
|
-
if (expected !== params.length) {
|
|
252
|
-
throw new QueryError(
|
|
253
|
-
`SQL expects ${String(expected)} bound parameter(s) but received ${String(params.length)} value(s)`
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// src/backup.ts
|
|
259
|
-
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
260
|
-
var CF_HANA_DIR_NAME = "cf-hana";
|
|
261
|
-
var BACKUPS_DIR_NAME = "backups";
|
|
262
|
-
function defaultSaptoolsRoot() {
|
|
263
|
-
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
264
|
-
}
|
|
265
|
-
function trimStatementSql(sql) {
|
|
266
|
-
return sql.trim().replace(/;+\s*$/, "").trim();
|
|
267
|
-
}
|
|
226
|
+
// src/003-backup-sql-parser.ts
|
|
268
227
|
function isIdentifierChar(char) {
|
|
269
228
|
return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
|
|
270
229
|
}
|
|
@@ -310,6 +269,105 @@ function skipNonCode(sql, index) {
|
|
|
310
269
|
}
|
|
311
270
|
return void 0;
|
|
312
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
|
+
}
|
|
313
371
|
function keywordMatches(sql, index, keyword) {
|
|
314
372
|
const end = index + keyword.length;
|
|
315
373
|
return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
|
|
@@ -357,6 +415,158 @@ function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
|
357
415
|
}
|
|
358
416
|
return void 0;
|
|
359
417
|
}
|
|
418
|
+
|
|
419
|
+
// src/statements.ts
|
|
420
|
+
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
|
|
421
|
+
var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
|
|
422
|
+
var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
|
|
423
|
+
function firstKeyword(sql) {
|
|
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();
|
|
459
|
+
}
|
|
460
|
+
function classifyStatement(sql) {
|
|
461
|
+
const keyword = firstKeyword(sql);
|
|
462
|
+
if (SELECT_KEYWORDS.has(keyword)) {
|
|
463
|
+
return "select";
|
|
464
|
+
}
|
|
465
|
+
if (DML_KEYWORDS.has(keyword)) {
|
|
466
|
+
return "dml";
|
|
467
|
+
}
|
|
468
|
+
if (DDL_KEYWORDS.has(keyword)) {
|
|
469
|
+
return "ddl";
|
|
470
|
+
}
|
|
471
|
+
return "unknown";
|
|
472
|
+
}
|
|
473
|
+
function quoteIdentifier(identifier) {
|
|
474
|
+
if (identifier.length === 0) {
|
|
475
|
+
throw new QueryError("A SQL identifier must not be empty");
|
|
476
|
+
}
|
|
477
|
+
if (identifier.includes("\0")) {
|
|
478
|
+
throw new QueryError("A SQL identifier must not contain a NUL character");
|
|
479
|
+
}
|
|
480
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
481
|
+
}
|
|
482
|
+
function qualifiedName(schema, table) {
|
|
483
|
+
return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
|
|
484
|
+
}
|
|
485
|
+
function countPlaceholders(sql) {
|
|
486
|
+
let count = 0;
|
|
487
|
+
let index = 0;
|
|
488
|
+
const length = sql.length;
|
|
489
|
+
while (index < length) {
|
|
490
|
+
const char = sql[index];
|
|
491
|
+
if (char === "'" || char === '"') {
|
|
492
|
+
const quote = char;
|
|
493
|
+
index += 1;
|
|
494
|
+
while (index < length) {
|
|
495
|
+
if (sql[index] === quote) {
|
|
496
|
+
if (sql[index + 1] === quote) {
|
|
497
|
+
index += 2;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
index += 1;
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
index += 1;
|
|
504
|
+
}
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
508
|
+
index += 2;
|
|
509
|
+
while (index < length && sql[index] !== "\n") {
|
|
510
|
+
index += 1;
|
|
511
|
+
}
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
515
|
+
index += 2;
|
|
516
|
+
while (index < length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
517
|
+
index += 1;
|
|
518
|
+
}
|
|
519
|
+
index += 2;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (char === "?") {
|
|
523
|
+
count += 1;
|
|
524
|
+
}
|
|
525
|
+
index += 1;
|
|
526
|
+
}
|
|
527
|
+
return count;
|
|
528
|
+
}
|
|
529
|
+
function assertParamArity(sql, params) {
|
|
530
|
+
const expected = countPlaceholders(sql);
|
|
531
|
+
if (expected !== params.length) {
|
|
532
|
+
throw new QueryError(
|
|
533
|
+
`SQL expects ${String(expected)} bound parameter(s) but received ${String(params.length)} value(s)`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
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"]);
|
|
547
|
+
function trimStatementSql(sql) {
|
|
548
|
+
const trimmed = sql.trim();
|
|
549
|
+
let end = trimmed.length;
|
|
550
|
+
while (end > 0 && trimmed.charAt(end - 1) === ";") {
|
|
551
|
+
end -= 1;
|
|
552
|
+
}
|
|
553
|
+
return trimmed.slice(0, end).trim();
|
|
554
|
+
}
|
|
555
|
+
function isMergeClauseStart(tokens, index) {
|
|
556
|
+
if (tokens[index]?.keyword !== "WHEN") {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
return tokens[index + 1]?.keyword === "MATCHED" || tokens[index + 1]?.keyword === "NOT" && tokens[index + 2]?.keyword === "MATCHED";
|
|
560
|
+
}
|
|
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
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return indexes;
|
|
569
|
+
}
|
|
360
570
|
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
361
571
|
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
362
572
|
}
|
|
@@ -394,23 +604,178 @@ function buildUpdateBackupPlan(statementSql, params) {
|
|
|
394
604
|
params
|
|
395
605
|
);
|
|
396
606
|
}
|
|
397
|
-
function buildUpsertBackupPlan(statementSql, params) {
|
|
398
|
-
const
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
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
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return buildSelectPlan(operation, statementSql, target.sql, void 0, params);
|
|
402
626
|
}
|
|
403
|
-
const targetStart = upsertIndex + "UPSERT".length;
|
|
404
|
-
const columnListIndex = findTopLevelChar(statementSql, "(", targetStart, valuesIndex);
|
|
405
|
-
const targetEnd = columnListIndex ?? valuesIndex;
|
|
406
627
|
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
|
|
407
|
-
return buildSelectPlan(
|
|
408
|
-
|
|
628
|
+
return buildSelectPlan(operation, statementSql, target.sql, whereIndex, params);
|
|
629
|
+
}
|
|
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;
|
|
639
|
+
}
|
|
640
|
+
const close = findTopLevelChar(suffix, ")", open + 1, suffix.length);
|
|
641
|
+
if (close === void 0) {
|
|
642
|
+
return void 0;
|
|
643
|
+
}
|
|
644
|
+
suffix = suffix.slice(close + 1).trim();
|
|
645
|
+
if (suffix.length === 0) {
|
|
646
|
+
return target.reference;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
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;
|
|
655
|
+
}
|
|
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);
|
|
660
|
+
}
|
|
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
|
+
);
|
|
671
|
+
}
|
|
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
|
+
);
|
|
684
|
+
}
|
|
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;
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
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
|
+
)
|
|
744
|
+
};
|
|
745
|
+
}
|
|
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;
|
|
751
|
+
}
|
|
752
|
+
const sourceParams = paramsInRange(
|
|
409
753
|
statementSql,
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
754
|
+
params,
|
|
755
|
+
merge.using.end,
|
|
756
|
+
matchedClause.clauseStart
|
|
413
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
|
+
};
|
|
765
|
+
}
|
|
766
|
+
function buildMergeBackupPlan(statementSql, params) {
|
|
767
|
+
const merge = parseMerge(statementSql);
|
|
768
|
+
if (merge.matchedIndexes.length === 0) {
|
|
769
|
+
return void 0;
|
|
770
|
+
}
|
|
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.4";
|
|
629
|
-
var ENV_PREFIX = "CF_HANA";
|
|
630
|
-
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
631
|
-
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
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";
|
|
@@ -661,9 +1031,9 @@ import { join as join2 } from "path";
|
|
|
661
1031
|
import { promisify } from "util";
|
|
662
1032
|
var execFileAsync = promisify(execFile);
|
|
663
1033
|
var MAX_BUFFER = 16 * 1024 * 1024;
|
|
664
|
-
var DEFAULT_TIMEOUT_MS =
|
|
1034
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
665
1035
|
var CF_RETRY_ATTEMPTS = 3;
|
|
666
|
-
var CF_RETRY_BASE_DELAY_MS =
|
|
1036
|
+
var CF_RETRY_BASE_DELAY_MS = 500;
|
|
667
1037
|
var REGION_API_MAP = {
|
|
668
1038
|
ae01: "https://api.cf.ae01.hana.ondemand.com",
|
|
669
1039
|
ap01: "https://api.cf.ap01.hana.ondemand.com",
|
|
@@ -785,29 +1155,45 @@ function buildEnv(ctx, overrides = {}) {
|
|
|
785
1155
|
env["CF_HOME"] = ctx.cfHome;
|
|
786
1156
|
return env;
|
|
787
1157
|
}
|
|
788
|
-
async function
|
|
789
|
-
const { bin, argsPrefix } = resolveCfBin();
|
|
790
|
-
const env = buildEnv(ctx, overrides);
|
|
1158
|
+
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
791
1159
|
let lastErr;
|
|
792
1160
|
for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
|
|
793
1161
|
try {
|
|
794
|
-
const { stdout } = await execFileAsync(bin,
|
|
1162
|
+
const { stdout } = await execFileAsync(bin, args, {
|
|
795
1163
|
env,
|
|
796
1164
|
maxBuffer: MAX_BUFFER,
|
|
797
|
-
timeout:
|
|
1165
|
+
timeout: timeoutMs,
|
|
1166
|
+
killSignal: "SIGKILL"
|
|
798
1167
|
});
|
|
799
1168
|
return stdout;
|
|
800
1169
|
} catch (err) {
|
|
801
1170
|
lastErr = err;
|
|
1171
|
+
const e = err;
|
|
1172
|
+
const output = `${e.message ?? ""} ${e.stderr ? String(e.stderr) : ""}`.toLowerCase();
|
|
1173
|
+
const isTimeout = e.killed === true;
|
|
1174
|
+
const isNetworkFlake = output.includes("error performing request") || output.includes("timeout") || output.includes("connection reset") || output.includes("connection refused") || output.includes("eof") || output.includes("502 bad gateway") || output.includes("503 service unavailable") || output.includes("504 gateway timeout") || output.includes("dial tcp") || output.includes("no such host");
|
|
1175
|
+
const isEnoent = e.code === "ENOENT";
|
|
1176
|
+
if (isEnoent || !isTimeout && !isNetworkFlake) {
|
|
1177
|
+
throw err;
|
|
1178
|
+
}
|
|
802
1179
|
if (attempt < CF_RETRY_ATTEMPTS - 1) {
|
|
803
1180
|
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
804
1181
|
}
|
|
805
1182
|
}
|
|
806
1183
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1184
|
+
throw lastErr;
|
|
1185
|
+
}
|
|
1186
|
+
async function runCf(args, ctx, overrides = {}) {
|
|
1187
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
1188
|
+
const env = buildEnv(ctx, overrides);
|
|
1189
|
+
try {
|
|
1190
|
+
return await execWithRetries(bin, [...argsPrefix, ...args], env);
|
|
1191
|
+
} catch (lastErr) {
|
|
1192
|
+
const e = lastErr;
|
|
1193
|
+
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
1194
|
+
const cmd = args[0] === "auth" ? "cf auth" : `cf ${args.join(" ")}`;
|
|
1195
|
+
throw new Error(`${cmd} failed: ${detail}`.trim(), { cause: lastErr });
|
|
1196
|
+
}
|
|
811
1197
|
}
|
|
812
1198
|
async function cfApi(apiEndpoint, ctx) {
|
|
813
1199
|
await runCf(["api", apiEndpoint], ctx);
|
|
@@ -829,12 +1215,7 @@ async function cfEnvDirect(appName) {
|
|
|
829
1215
|
const env = { ...process.env };
|
|
830
1216
|
delete env["SAP_EMAIL"];
|
|
831
1217
|
delete env["SAP_PASSWORD"];
|
|
832
|
-
|
|
833
|
-
env,
|
|
834
|
-
maxBuffer: MAX_BUFFER,
|
|
835
|
-
timeout: DEFAULT_TIMEOUT_MS
|
|
836
|
-
});
|
|
837
|
-
return stdout;
|
|
1218
|
+
return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
|
|
838
1219
|
}
|
|
839
1220
|
async function readCurrentCfTarget() {
|
|
840
1221
|
const { bin, argsPrefix } = resolveCfBin();
|
|
@@ -842,11 +1223,7 @@ async function readCurrentCfTarget() {
|
|
|
842
1223
|
delete env["SAP_EMAIL"];
|
|
843
1224
|
delete env["SAP_PASSWORD"];
|
|
844
1225
|
try {
|
|
845
|
-
const
|
|
846
|
-
env,
|
|
847
|
-
maxBuffer: MAX_BUFFER,
|
|
848
|
-
timeout: 1e4
|
|
849
|
-
});
|
|
1226
|
+
const stdout = await execWithRetries(bin, [...argsPrefix, "target"], env);
|
|
850
1227
|
return parseCfTargetOutput(stdout);
|
|
851
1228
|
} catch {
|
|
852
1229
|
return void 0;
|
|
@@ -897,6 +1274,81 @@ function formatCurrentCfAppSelector(target, appName) {
|
|
|
897
1274
|
const region = target.regionKey ?? "current";
|
|
898
1275
|
return `${region}/${target.orgName}/${target.spaceName}/${name}`;
|
|
899
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
|
+
}
|
|
900
1352
|
function extractVcapSection(stdout) {
|
|
901
1353
|
const start = stdout.indexOf("VCAP_SERVICES:");
|
|
902
1354
|
if (start === -1) {
|
|
@@ -978,107 +1430,143 @@ function classifyCfError(stderr = "", stdout = "") {
|
|
|
978
1430
|
function errorMessageFromUnknown(error) {
|
|
979
1431
|
return error instanceof Error ? error.message : "Invalid or untrusted CF API endpoint.";
|
|
980
1432
|
}
|
|
981
|
-
|
|
982
|
-
const
|
|
983
|
-
if (!
|
|
984
|
-
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
|
+
);
|
|
985
1440
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
|
1003
1458
|
};
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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) {
|
|
1015
1542
|
throw new CfHanaError(
|
|
1016
1543
|
"CONFIG",
|
|
1017
|
-
`
|
|
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 }
|
|
1018
1546
|
);
|
|
1019
1547
|
}
|
|
1020
|
-
let normalizedApiEndpoint;
|
|
1021
|
-
try {
|
|
1022
|
-
normalizedApiEndpoint = normalizeSapCfApiEndpoint(apiEndpoint);
|
|
1023
|
-
} catch (error) {
|
|
1024
|
-
throw new CfHanaError("CONFIG", errorMessageFromUnknown(error), { cause: error });
|
|
1025
|
-
}
|
|
1026
|
-
target = { selector, apiEndpoint: normalizedApiEndpoint, orgName, spaceName, appName };
|
|
1027
|
-
}
|
|
1028
|
-
let bindings;
|
|
1029
|
-
const source = "live";
|
|
1030
|
-
if (isBare) {
|
|
1031
|
-
try {
|
|
1032
|
-
const stdout = await cfEnvDirect(target.appName);
|
|
1033
|
-
bindings = extractHanaBindingsFromCfEnv(stdout);
|
|
1034
|
-
} catch (directError) {
|
|
1035
|
-
let stderr = "";
|
|
1036
|
-
if (directError && typeof directError === "object") {
|
|
1037
|
-
const e = directError;
|
|
1038
|
-
stderr = (typeof e.stderr === "string" ? e.stderr : "") || (typeof e.message === "string" ? e.message : "");
|
|
1039
|
-
}
|
|
1040
|
-
const classified = classifyCfError(stderr);
|
|
1041
|
-
if (classified.isAuthError) {
|
|
1042
|
-
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
1043
|
-
if (!sap) {
|
|
1044
|
-
throw new CredentialsNotFoundError(
|
|
1045
|
-
`Current CF session problem for bare app "${target.appName}" (${classified.reason}).
|
|
1046
|
-
Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
|
|
1047
|
-
{ cause: directError }
|
|
1048
|
-
);
|
|
1049
|
-
}
|
|
1050
|
-
const api = target.apiEndpoint;
|
|
1051
|
-
bindings = await withCfSession(async (ctx) => {
|
|
1052
|
-
await cfApi(api, ctx);
|
|
1053
|
-
await cfAuth(sap.email, sap.password, ctx);
|
|
1054
|
-
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
1055
|
-
const stdout = await cfEnv(target.appName, ctx);
|
|
1056
|
-
return extractHanaBindingsFromCfEnv(stdout);
|
|
1057
|
-
});
|
|
1058
|
-
} else {
|
|
1059
|
-
throw new CfHanaError(
|
|
1060
|
-
"CONFIG",
|
|
1061
|
-
`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}` : ""}`,
|
|
1062
|
-
{ cause: directError }
|
|
1063
|
-
);
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
} else {
|
|
1067
1548
|
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
1068
|
-
if (
|
|
1549
|
+
if (sap === void 0) {
|
|
1069
1550
|
throw new CredentialsNotFoundError(
|
|
1070
|
-
`
|
|
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 }
|
|
1071
1554
|
);
|
|
1072
1555
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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");
|
|
1081
1567
|
}
|
|
1568
|
+
const target = await resolveTarget(selector);
|
|
1569
|
+
const bindings = target.selectorSource === "ambient" ? await readBareBindings(target, options) : await readExplicitBindings(target, options);
|
|
1082
1570
|
if (bindings.length === 0) {
|
|
1083
1571
|
throw new CredentialsNotFoundError(`App "${target.selector}" has no HANA service binding.`);
|
|
1084
1572
|
}
|
|
@@ -1086,9 +1574,21 @@ Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
|
|
|
1086
1574
|
selector: target.selector,
|
|
1087
1575
|
appName: target.appName,
|
|
1088
1576
|
bindings,
|
|
1089
|
-
source
|
|
1577
|
+
source: "live",
|
|
1578
|
+
selectorSource: target.selectorSource,
|
|
1579
|
+
regionConfirmed: target.regionConfirmed,
|
|
1580
|
+
selectorCanBePinned: target.selectorCanBePinned
|
|
1090
1581
|
};
|
|
1091
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
|
+
}
|
|
1092
1592
|
function selectBinding(bindings, selector) {
|
|
1093
1593
|
if (selector.bindingName !== void 0) {
|
|
1094
1594
|
const match = bindings.find((binding) => binding.name === selector.bindingName);
|
|
@@ -1159,42 +1659,82 @@ function tableColumns() {
|
|
|
1159
1659
|
{ COLUMN_NAME: "SCOPE_NAME", DATA_TYPE_NAME: "NVARCHAR", LENGTH: 255, SCALE: null, IS_NULLABLE: "TRUE", POSITION: 3 }
|
|
1160
1660
|
];
|
|
1161
1661
|
}
|
|
1162
|
-
function
|
|
1163
|
-
const kind = classifyStatement(sql);
|
|
1662
|
+
function throwForcedFailure(kind) {
|
|
1164
1663
|
const forcedFailure = readEnv(envName("FAKE_FAIL_STATEMENT"))?.toLowerCase();
|
|
1165
1664
|
if (forcedFailure === kind) {
|
|
1166
1665
|
throw new Error(`fake driver forced ${kind.toUpperCase()} failure`);
|
|
1167
1666
|
}
|
|
1168
|
-
|
|
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) {
|
|
1169
1722
|
if (upperSql.includes("SYS.TABLES") && upperSql.includes("SYS.VIEWS")) {
|
|
1170
1723
|
if (readEnv(envName("FAKE_FAIL_CATALOG_ONCE")) === "1" && !catalogFailureInjected) {
|
|
1171
1724
|
catalogFailureInjected = true;
|
|
1172
1725
|
throw new QueryError("fake transient catalog metadata failure");
|
|
1173
1726
|
}
|
|
1174
|
-
return
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
{ name: "OBJECT_NAME", typeName: "NVARCHAR" },
|
|
1179
|
-
{ name: "OBJECT_TYPE", typeName: "NVARCHAR" }
|
|
1180
|
-
],
|
|
1181
|
-
affectedRows: 0
|
|
1182
|
-
};
|
|
1727
|
+
return catalogObjectsResult();
|
|
1728
|
+
}
|
|
1729
|
+
if (upperSql.includes("SYS.TABLES")) {
|
|
1730
|
+
return tablesResult();
|
|
1183
1731
|
}
|
|
1184
1732
|
if (upperSql.includes("SYS.TABLE_COLUMNS")) {
|
|
1185
|
-
return
|
|
1186
|
-
rows: tableColumns(),
|
|
1187
|
-
columns: [
|
|
1188
|
-
{ name: "COLUMN_NAME", typeName: "NVARCHAR" },
|
|
1189
|
-
{ name: "DATA_TYPE_NAME", typeName: "NVARCHAR" },
|
|
1190
|
-
{ name: "LENGTH", typeName: "INTEGER" },
|
|
1191
|
-
{ name: "SCALE", typeName: "INTEGER" },
|
|
1192
|
-
{ name: "IS_NULLABLE", typeName: "NVARCHAR" },
|
|
1193
|
-
{ name: "POSITION", typeName: "INTEGER" }
|
|
1194
|
-
],
|
|
1195
|
-
affectedRows: 0
|
|
1196
|
-
};
|
|
1733
|
+
return tableColumnsResult();
|
|
1197
1734
|
}
|
|
1735
|
+
return void 0;
|
|
1736
|
+
}
|
|
1737
|
+
function throwLateFixtureError(upperSql) {
|
|
1198
1738
|
if (upperSql.includes("MISSING_TABLE") || upperSql.includes("MISSING_TABLES")) {
|
|
1199
1739
|
throw new QueryError("invalid table name: MISSING_TABLE", { sqlState: "42S02" });
|
|
1200
1740
|
}
|
|
@@ -1207,30 +1747,45 @@ function fakeExec(sql) {
|
|
|
1207
1747
|
if (upperSql.includes("LOB_GROUP_ERROR")) {
|
|
1208
1748
|
throw new QueryError("inconsistent datatype: LOB type is not allowed in GROUP BY clause", { databaseCode: 274 });
|
|
1209
1749
|
}
|
|
1210
|
-
|
|
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")) {
|
|
1211
1773
|
return {
|
|
1212
|
-
rows: [
|
|
1213
|
-
|
|
1214
|
-
LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
|
|
1215
|
-
CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
|
|
1216
|
-
PAYLOAD: Buffer.from([0, 1, 2, 255])
|
|
1217
|
-
}
|
|
1218
|
-
],
|
|
1219
|
-
columns: [
|
|
1220
|
-
{ name: "LOG_CONTENT", typeName: "NCLOB" },
|
|
1221
|
-
{ name: "CLOB_CONTENT", typeName: "CLOB" },
|
|
1222
|
-
{ name: "PAYLOAD", typeName: "BLOB" }
|
|
1223
|
-
],
|
|
1774
|
+
rows: [{ VALUE: "alpha" }, { VALUE: "beta" }],
|
|
1775
|
+
columns: [{ name: "VALUE", typeName: "NVARCHAR" }],
|
|
1224
1776
|
affectedRows: 0
|
|
1225
1777
|
};
|
|
1226
1778
|
}
|
|
1227
|
-
if (
|
|
1779
|
+
if (upperSql.includes("DUMMY")) {
|
|
1228
1780
|
return {
|
|
1229
1781
|
rows: [{ "1": 1 }],
|
|
1230
1782
|
columns: [{ name: "1", typeName: "INTEGER" }],
|
|
1231
1783
|
affectedRows: 0
|
|
1232
1784
|
};
|
|
1233
1785
|
}
|
|
1786
|
+
return void 0;
|
|
1787
|
+
}
|
|
1788
|
+
function defaultResult(kind) {
|
|
1234
1789
|
if (kind === "select") {
|
|
1235
1790
|
return {
|
|
1236
1791
|
rows: [
|
|
@@ -1249,6 +1804,18 @@ function fakeExec(sql) {
|
|
|
1249
1804
|
}
|
|
1250
1805
|
return { rows: [], columns: [], affectedRows: 0 };
|
|
1251
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
|
+
}
|
|
1252
1819
|
async function traceFakeExec(sql, params) {
|
|
1253
1820
|
const tracePath = readEnv(envName("FAKE_TRACE_FILE"));
|
|
1254
1821
|
if (tracePath === void 0) {
|
|
@@ -1679,8 +2246,30 @@ function maskIgnoredSqlText(sql) {
|
|
|
1679
2246
|
}
|
|
1680
2247
|
return masked;
|
|
1681
2248
|
}
|
|
1682
|
-
function
|
|
1683
|
-
|
|
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");
|
|
1684
2273
|
}
|
|
1685
2274
|
function trailingLineCommentIndex(sql) {
|
|
1686
2275
|
let index = 0;
|
|
@@ -1723,9 +2312,10 @@ function inspectStatement(sql) {
|
|
|
1723
2312
|
return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
|
|
1724
2313
|
}
|
|
1725
2314
|
if (kind === "dml") {
|
|
2315
|
+
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(sql, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(sql) || keyword === "REPLACE" && isMalformedReplace(sql);
|
|
1726
2316
|
return {
|
|
1727
2317
|
kind,
|
|
1728
|
-
destructive
|
|
2318
|
+
destructive
|
|
1729
2319
|
};
|
|
1730
2320
|
}
|
|
1731
2321
|
return { kind, destructive: false };
|
|
@@ -1745,7 +2335,7 @@ function evaluateGuard(sql, config) {
|
|
|
1745
2335
|
allowed: false,
|
|
1746
2336
|
destructive: true,
|
|
1747
2337
|
violation: "destructive",
|
|
1748
|
-
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"
|
|
1749
2339
|
};
|
|
1750
2340
|
}
|
|
1751
2341
|
return {
|
|
@@ -1785,6 +2375,57 @@ function boundedRows(rows, requestedLimit) {
|
|
|
1785
2375
|
truncated: rows.length > requestedLimit
|
|
1786
2376
|
};
|
|
1787
2377
|
}
|
|
2378
|
+
function isBooleanColumn(column) {
|
|
2379
|
+
return column.typeName.trim().toUpperCase() === "BOOLEAN";
|
|
2380
|
+
}
|
|
2381
|
+
function normalizeBooleanCell(value) {
|
|
2382
|
+
if (value === 0) {
|
|
2383
|
+
return false;
|
|
2384
|
+
}
|
|
2385
|
+
if (value === 1) {
|
|
2386
|
+
return true;
|
|
2387
|
+
}
|
|
2388
|
+
if (typeof value !== "string") {
|
|
2389
|
+
return value;
|
|
2390
|
+
}
|
|
2391
|
+
const normalized = value.trim().toLowerCase();
|
|
2392
|
+
if (normalized === "0") {
|
|
2393
|
+
return false;
|
|
2394
|
+
}
|
|
2395
|
+
if (normalized === "1") {
|
|
2396
|
+
return true;
|
|
2397
|
+
}
|
|
2398
|
+
if (normalized === "false") {
|
|
2399
|
+
return false;
|
|
2400
|
+
}
|
|
2401
|
+
if (normalized === "true") {
|
|
2402
|
+
return true;
|
|
2403
|
+
}
|
|
2404
|
+
return value;
|
|
2405
|
+
}
|
|
2406
|
+
function normalizeBooleanRow(row, columns) {
|
|
2407
|
+
let normalizedRow;
|
|
2408
|
+
for (const column of columns) {
|
|
2409
|
+
const value = row[column.name];
|
|
2410
|
+
if (value === void 0) {
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2413
|
+
const normalizedValue = normalizeBooleanCell(value);
|
|
2414
|
+
if (normalizedValue === value) {
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
normalizedRow ??= { ...row };
|
|
2418
|
+
normalizedRow[column.name] = normalizedValue;
|
|
2419
|
+
}
|
|
2420
|
+
return normalizedRow ?? row;
|
|
2421
|
+
}
|
|
2422
|
+
function normalizeBooleanRows(rows, columns) {
|
|
2423
|
+
const booleanColumns = columns.filter(isBooleanColumn);
|
|
2424
|
+
if (booleanColumns.length === 0 || rows.length === 0) {
|
|
2425
|
+
return rows;
|
|
2426
|
+
}
|
|
2427
|
+
return rows.map((row) => normalizeBooleanRow(row, booleanColumns));
|
|
2428
|
+
}
|
|
1788
2429
|
async function withTimeout(work, timeoutMs, onTimeout) {
|
|
1789
2430
|
let timer;
|
|
1790
2431
|
const timeout = new Promise((_resolve, reject) => {
|
|
@@ -1870,7 +2511,8 @@ var Connection = class _Connection {
|
|
|
1870
2511
|
}
|
|
1871
2512
|
);
|
|
1872
2513
|
const elapsedMs = Date.now() - started;
|
|
1873
|
-
const
|
|
2514
|
+
const normalizedRows = normalizeBooleanRows(execResult.rows, execResult.columns);
|
|
2515
|
+
const selected = boundedRows(normalizedRows, limited.requestedLimit);
|
|
1874
2516
|
return {
|
|
1875
2517
|
rows: selected.rows,
|
|
1876
2518
|
columns: execResult.columns,
|
|
@@ -2095,18 +2737,37 @@ function nextExplainStatementName() {
|
|
|
2095
2737
|
explainStatementCounter = (explainStatementCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
2096
2738
|
return `cf_hana_${String(process.pid)}_${String(Date.now())}_${String(explainStatementCounter)}`;
|
|
2097
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
|
+
}
|
|
2098
2756
|
var HanaClient = class _HanaClient {
|
|
2099
|
-
constructor(pool, info) {
|
|
2757
|
+
constructor(pool, info, databaseUser = "") {
|
|
2100
2758
|
this.pool = pool;
|
|
2101
2759
|
this.info = info;
|
|
2760
|
+
this.databaseUser = databaseUser;
|
|
2102
2761
|
}
|
|
2103
2762
|
pool;
|
|
2104
2763
|
info;
|
|
2764
|
+
databaseUser;
|
|
2105
2765
|
/** Open a client for a `region/org/space/app` selector (or a bare app name). */
|
|
2106
2766
|
static async connect(selector, options = {}) {
|
|
2107
2767
|
const resolved = await resolveAppBindings(selector, options);
|
|
2108
2768
|
const role = options.role ?? "runtime";
|
|
2109
2769
|
const binding = selectBinding(resolved.bindings, options);
|
|
2770
|
+
const bindingInfo = toSelectedBindingInfo(resolved.bindings, binding);
|
|
2110
2771
|
const target = toConnectionTarget(binding, role);
|
|
2111
2772
|
const driver = createDriver();
|
|
2112
2773
|
const config = {
|
|
@@ -2122,7 +2783,7 @@ var HanaClient = class _HanaClient {
|
|
|
2122
2783
|
allowDestructive: options.allowDestructive ?? false,
|
|
2123
2784
|
autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT
|
|
2124
2785
|
};
|
|
2125
|
-
const poolOptions = options
|
|
2786
|
+
const poolOptions = toPoolOptions(options);
|
|
2126
2787
|
const info = {
|
|
2127
2788
|
selector: resolved.selector,
|
|
2128
2789
|
appName: resolved.appName,
|
|
@@ -2130,9 +2791,13 @@ var HanaClient = class _HanaClient {
|
|
|
2130
2791
|
schema: target.schema,
|
|
2131
2792
|
role,
|
|
2132
2793
|
driver: driver.name,
|
|
2133
|
-
credentialSource: resolved.source
|
|
2794
|
+
credentialSource: resolved.source,
|
|
2795
|
+
selectorSource: resolved.selectorSource,
|
|
2796
|
+
regionConfirmed: resolved.regionConfirmed,
|
|
2797
|
+
selectorCanBePinned: resolved.selectorCanBePinned,
|
|
2798
|
+
...bindingInfo
|
|
2134
2799
|
};
|
|
2135
|
-
return new _HanaClient(new ConnectionPool(driver, config, poolOptions), info);
|
|
2800
|
+
return new _HanaClient(new ConnectionPool(driver, config, poolOptions), info, target.user);
|
|
2136
2801
|
}
|
|
2137
2802
|
/** Run a SELECT (or any read) statement and return typed rows. */
|
|
2138
2803
|
async query(sql, params, options) {
|
|
@@ -2148,7 +2813,7 @@ var HanaClient = class _HanaClient {
|
|
|
2148
2813
|
await this.recordSqlHistory("execute", sql, resolvedParams, result);
|
|
2149
2814
|
return result;
|
|
2150
2815
|
}
|
|
2151
|
-
/** Back up rows
|
|
2816
|
+
/** Back up pre-image rows required by a supported write before the caller runs it. */
|
|
2152
2817
|
async backupWriteStatement(sql, params, options) {
|
|
2153
2818
|
const resolvedParams = params ?? [];
|
|
2154
2819
|
const plan = buildWriteBackupPlan(sql, resolvedParams);
|
|
@@ -2329,6 +2994,7 @@ async function withConnection(selector, work, options) {
|
|
|
2329
2994
|
}
|
|
2330
2995
|
}
|
|
2331
2996
|
export {
|
|
2997
|
+
BackupRequiredError,
|
|
2332
2998
|
CfHanaError,
|
|
2333
2999
|
CredentialsNotFoundError,
|
|
2334
3000
|
DestructiveStatementError,
|