@saptools/cf-hana 0.2.1 → 0.3.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 +6 -0
- package/README.md +47 -15
- package/dist/cli.js +1025 -85
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.js +531 -57
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { formatCurrentCfAppSelector, readCurrentCfTarget } from "@saptools/cf-sync";
|
|
5
4
|
import { Command } from "commander";
|
|
6
5
|
|
|
7
6
|
// src/backup.ts
|
|
8
|
-
import { createHash, randomUUID } from "crypto";
|
|
9
7
|
import { mkdir, writeFile } from "fs/promises";
|
|
10
8
|
import { homedir } from "os";
|
|
11
9
|
import { join } from "path";
|
|
@@ -53,6 +51,15 @@ function errorMessage(error) {
|
|
|
53
51
|
return String(error);
|
|
54
52
|
}
|
|
55
53
|
|
|
54
|
+
// src/lob.ts
|
|
55
|
+
var TEXT_LOB_TYPES = /* @__PURE__ */ new Set(["CLOB", "NCLOB"]);
|
|
56
|
+
function normalizeTypeName(typeName2) {
|
|
57
|
+
return typeName2?.trim().toUpperCase() ?? "";
|
|
58
|
+
}
|
|
59
|
+
function isTextLobType(typeName2) {
|
|
60
|
+
return TEXT_LOB_TYPES.has(normalizeTypeName(typeName2));
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
// src/result-preview.ts
|
|
57
64
|
function normalizePreviewChar(value) {
|
|
58
65
|
return value === "\r" || value === "\n" || value === " " ? " " : value;
|
|
@@ -93,12 +100,12 @@ function scalarText(value) {
|
|
|
93
100
|
}
|
|
94
101
|
return value.toString();
|
|
95
102
|
}
|
|
96
|
-
function previewCell(value, limit) {
|
|
103
|
+
function previewCell(value, limit, typeName2) {
|
|
97
104
|
if (value === null) {
|
|
98
105
|
return { text: "", truncated: false, originalLength: 0, unit: "chars" };
|
|
99
106
|
}
|
|
100
107
|
if (Buffer.isBuffer(value)) {
|
|
101
|
-
return previewBuffer(value, limit);
|
|
108
|
+
return isTextLobType(typeName2) ? previewText(value.toString("utf8"), limit) : previewBuffer(value, limit);
|
|
102
109
|
}
|
|
103
110
|
if (typeof value === "string") {
|
|
104
111
|
return previewText(value, limit);
|
|
@@ -107,7 +114,7 @@ function previewCell(value, limit) {
|
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
// src/format.ts
|
|
110
|
-
function cellText(value, nullText) {
|
|
117
|
+
function cellText(value, nullText, column) {
|
|
111
118
|
if (value === null) {
|
|
112
119
|
return nullText;
|
|
113
120
|
}
|
|
@@ -115,14 +122,14 @@ function cellText(value, nullText) {
|
|
|
115
122
|
return value.toISOString();
|
|
116
123
|
}
|
|
117
124
|
if (Buffer.isBuffer(value)) {
|
|
118
|
-
return `0x${value.toString("hex")}`;
|
|
125
|
+
return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
|
|
119
126
|
}
|
|
120
127
|
if (typeof value === "boolean") {
|
|
121
128
|
return value ? "true" : "false";
|
|
122
129
|
}
|
|
123
130
|
return typeof value === "number" ? value.toString() : value;
|
|
124
131
|
}
|
|
125
|
-
function serializeCell(value) {
|
|
132
|
+
function serializeCell(value, column) {
|
|
126
133
|
if (value === null) {
|
|
127
134
|
return null;
|
|
128
135
|
}
|
|
@@ -130,7 +137,7 @@ function serializeCell(value) {
|
|
|
130
137
|
return value.toISOString();
|
|
131
138
|
}
|
|
132
139
|
if (Buffer.isBuffer(value)) {
|
|
133
|
-
return `0x${value.toString("hex")}`;
|
|
140
|
+
return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
|
|
134
141
|
}
|
|
135
142
|
return value;
|
|
136
143
|
}
|
|
@@ -146,7 +153,7 @@ function formatTable(result) {
|
|
|
146
153
|
}
|
|
147
154
|
const headers = result.columns.map((column) => column.name);
|
|
148
155
|
const rows = result.rows.map(
|
|
149
|
-
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
|
|
156
|
+
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL", column))
|
|
150
157
|
);
|
|
151
158
|
const widths = headers.map((header, index) => {
|
|
152
159
|
const widest = rows.reduce(
|
|
@@ -164,7 +171,8 @@ function formatJson(result) {
|
|
|
164
171
|
const rows = result.rows.map((row) => {
|
|
165
172
|
const serialized = {};
|
|
166
173
|
for (const [key, value] of Object.entries(row)) {
|
|
167
|
-
|
|
174
|
+
const column = result.columns.find((item) => item.name === key);
|
|
175
|
+
serialized[key] = serializeCell(value, column);
|
|
168
176
|
}
|
|
169
177
|
return serialized;
|
|
170
178
|
});
|
|
@@ -175,7 +183,7 @@ function formatCsv(result) {
|
|
|
175
183
|
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
176
184
|
for (const row of result.rows) {
|
|
177
185
|
lines.push(
|
|
178
|
-
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
|
|
186
|
+
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, "", column))).join(",")
|
|
179
187
|
);
|
|
180
188
|
}
|
|
181
189
|
return lines.join("\r\n");
|
|
@@ -186,7 +194,7 @@ function formatCompactCsv(result, cellLimit) {
|
|
|
186
194
|
let truncatedCells = 0;
|
|
187
195
|
for (const row of result.rows) {
|
|
188
196
|
const cells = result.columns.map((column) => {
|
|
189
|
-
const preview = previewCell(row[column.name] ?? null, cellLimit);
|
|
197
|
+
const preview = previewCell(row[column.name] ?? null, cellLimit, column.typeName);
|
|
190
198
|
if (preview.truncated) {
|
|
191
199
|
truncatedCells += 1;
|
|
192
200
|
}
|
|
@@ -375,6 +383,28 @@ function findTopLevelKeyword(sql, keyword, startIndex = 0) {
|
|
|
375
383
|
}
|
|
376
384
|
return void 0;
|
|
377
385
|
}
|
|
386
|
+
function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
387
|
+
let index = startIndex;
|
|
388
|
+
let depth = 0;
|
|
389
|
+
while (index < endIndex) {
|
|
390
|
+
const skipped = skipNonCode(sql, index);
|
|
391
|
+
if (skipped !== void 0) {
|
|
392
|
+
index = skipped;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const char = sql[index];
|
|
396
|
+
if (char === target && depth === 0) {
|
|
397
|
+
return index;
|
|
398
|
+
}
|
|
399
|
+
if (char === "(") {
|
|
400
|
+
depth += 1;
|
|
401
|
+
} else if (char === ")" && depth > 0) {
|
|
402
|
+
depth -= 1;
|
|
403
|
+
}
|
|
404
|
+
index += 1;
|
|
405
|
+
}
|
|
406
|
+
return void 0;
|
|
407
|
+
}
|
|
378
408
|
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
379
409
|
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
380
410
|
}
|
|
@@ -412,6 +442,24 @@ function buildUpdateBackupPlan(statementSql, params) {
|
|
|
412
442
|
params
|
|
413
443
|
);
|
|
414
444
|
}
|
|
445
|
+
function buildUpsertBackupPlan(statementSql, params) {
|
|
446
|
+
const upsertIndex = findTopLevelKeyword(statementSql, "UPSERT");
|
|
447
|
+
const valuesIndex = upsertIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "VALUES", upsertIndex + "UPSERT".length);
|
|
448
|
+
if (upsertIndex === void 0 || valuesIndex === void 0) {
|
|
449
|
+
throw new QueryError("UPSERT backup requires UPSERT <target> VALUES syntax");
|
|
450
|
+
}
|
|
451
|
+
const targetStart = upsertIndex + "UPSERT".length;
|
|
452
|
+
const columnListIndex = findTopLevelChar(statementSql, "(", targetStart, valuesIndex);
|
|
453
|
+
const targetEnd = columnListIndex ?? valuesIndex;
|
|
454
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
|
|
455
|
+
return buildSelectPlan(
|
|
456
|
+
"upsert",
|
|
457
|
+
statementSql,
|
|
458
|
+
statementSql.slice(targetStart, targetEnd),
|
|
459
|
+
whereIndex,
|
|
460
|
+
params
|
|
461
|
+
);
|
|
462
|
+
}
|
|
415
463
|
function buildDeleteBackupPlan(statementSql, params) {
|
|
416
464
|
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
417
465
|
const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
|
|
@@ -431,8 +479,18 @@ function buildDeleteBackupPlan(statementSql, params) {
|
|
|
431
479
|
function backupTimestamp(now) {
|
|
432
480
|
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
433
481
|
}
|
|
434
|
-
function
|
|
435
|
-
|
|
482
|
+
function backupMonth(now) {
|
|
483
|
+
const year = String(now.getUTCFullYear());
|
|
484
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
485
|
+
return `${year}${month}`;
|
|
486
|
+
}
|
|
487
|
+
function sanitizePathPart(value) {
|
|
488
|
+
const trimmed = value?.trim() ?? "";
|
|
489
|
+
const normalized = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
490
|
+
return normalized.length > 0 ? normalized : "unknown-target";
|
|
491
|
+
}
|
|
492
|
+
function backupBaseName(input, now) {
|
|
493
|
+
return [sanitizePathPart(input.selector), input.operation, backupTimestamp(now)].join("-");
|
|
436
494
|
}
|
|
437
495
|
function cfHanaBackupRoot(saptoolsRoot) {
|
|
438
496
|
return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
@@ -440,33 +498,49 @@ function cfHanaBackupRoot(saptoolsRoot) {
|
|
|
440
498
|
function buildWriteBackupPlan(sql, params = []) {
|
|
441
499
|
const statementSql = trimStatementSql(sql);
|
|
442
500
|
const keyword = firstKeyword(statementSql);
|
|
443
|
-
if (keyword !== "UPDATE" && keyword !== "DELETE") {
|
|
501
|
+
if (keyword !== "UPDATE" && keyword !== "UPSERT" && keyword !== "DELETE") {
|
|
444
502
|
return void 0;
|
|
445
503
|
}
|
|
446
504
|
assertParamArity(statementSql, params);
|
|
447
505
|
if (keyword === "UPDATE") {
|
|
448
506
|
return buildUpdateBackupPlan(statementSql, params);
|
|
449
507
|
}
|
|
508
|
+
if (keyword === "UPSERT") {
|
|
509
|
+
return buildUpsertBackupPlan(statementSql, params);
|
|
510
|
+
}
|
|
450
511
|
return buildDeleteBackupPlan(statementSql, params);
|
|
451
512
|
}
|
|
452
513
|
async function writeSqlBackup(input, options = {}) {
|
|
453
514
|
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
454
|
-
const directory = join(
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
);
|
|
458
|
-
const
|
|
459
|
-
const
|
|
515
|
+
const directory = join(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
|
|
516
|
+
const baseName = backupBaseName(input, now);
|
|
517
|
+
const statementPath = join(directory, `${baseName}.statement.sql`);
|
|
518
|
+
const backupPath = join(directory, `${baseName}.sql`);
|
|
519
|
+
const metadataPath = join(directory, `${baseName}.json`);
|
|
520
|
+
const metadata = {
|
|
521
|
+
selector: input.selector ?? null,
|
|
522
|
+
operation: input.operation,
|
|
523
|
+
statementPath,
|
|
524
|
+
backupPath,
|
|
525
|
+
rowCount: input.result.rowCount,
|
|
526
|
+
createdAt: now.toISOString()
|
|
527
|
+
};
|
|
460
528
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
461
529
|
await Promise.all([
|
|
462
530
|
writeFile(statementPath, `${input.statementSql}
|
|
463
531
|
`, { encoding: "utf8", mode: 384 }),
|
|
464
|
-
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 })
|
|
532
|
+
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 }),
|
|
533
|
+
writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
|
|
534
|
+
`, {
|
|
535
|
+
encoding: "utf8",
|
|
536
|
+
mode: 384
|
|
537
|
+
})
|
|
465
538
|
]);
|
|
466
539
|
return {
|
|
467
540
|
directory,
|
|
468
541
|
statementPath,
|
|
469
542
|
backupPath,
|
|
543
|
+
metadataPath,
|
|
470
544
|
rowCount: input.result.rowCount
|
|
471
545
|
};
|
|
472
546
|
}
|
|
@@ -570,6 +644,18 @@ async function listTables(connection, schema) {
|
|
|
570
644
|
rowCount: void 0
|
|
571
645
|
}));
|
|
572
646
|
}
|
|
647
|
+
async function listCatalogObjects(connection, schema) {
|
|
648
|
+
const result = await connection.query(
|
|
649
|
+
"SELECT SCHEMA_NAME, TABLE_NAME AS OBJECT_NAME, 'TABLE' AS OBJECT_TYPE FROM SYS.TABLES WHERE SCHEMA_NAME = ? UNION ALL SELECT SCHEMA_NAME, VIEW_NAME AS OBJECT_NAME, 'VIEW' AS OBJECT_TYPE FROM SYS.VIEWS WHERE SCHEMA_NAME = ? ORDER BY OBJECT_NAME",
|
|
650
|
+
[schema, schema],
|
|
651
|
+
{ autoLimit: false }
|
|
652
|
+
);
|
|
653
|
+
return result.rows.map((row) => ({
|
|
654
|
+
schema: row.SCHEMA_NAME,
|
|
655
|
+
name: row.OBJECT_NAME,
|
|
656
|
+
type: row.OBJECT_TYPE
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
573
659
|
async function listColumns(connection, schema, table) {
|
|
574
660
|
const result = await connection.query(
|
|
575
661
|
"SELECT COLUMN_NAME, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE, POSITION FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION",
|
|
@@ -588,7 +674,7 @@ async function listColumns(connection, schema, table) {
|
|
|
588
674
|
|
|
589
675
|
// src/config.ts
|
|
590
676
|
var CLI_NAME = "cf-hana";
|
|
591
|
-
var CLI_VERSION = "0.
|
|
677
|
+
var CLI_VERSION = "0.3.0";
|
|
592
678
|
var ENV_PREFIX = "CF_HANA";
|
|
593
679
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
594
680
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -620,44 +706,381 @@ function readSapCredentials(overrides) {
|
|
|
620
706
|
return { email, password };
|
|
621
707
|
}
|
|
622
708
|
|
|
623
|
-
// src/
|
|
624
|
-
import {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
709
|
+
// src/cf.ts
|
|
710
|
+
import { execFile } from "child_process";
|
|
711
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
712
|
+
import { tmpdir } from "os";
|
|
713
|
+
import { join as join2 } from "path";
|
|
714
|
+
import { promisify } from "util";
|
|
715
|
+
var execFileAsync = promisify(execFile);
|
|
716
|
+
var MAX_BUFFER = 16 * 1024 * 1024;
|
|
717
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
718
|
+
var CF_RETRY_ATTEMPTS = 3;
|
|
719
|
+
var CF_RETRY_BASE_DELAY_MS = 120;
|
|
720
|
+
var REGION_API_MAP = {
|
|
721
|
+
ae01: "https://api.cf.ae01.hana.ondemand.com",
|
|
722
|
+
ap01: "https://api.cf.ap01.hana.ondemand.com",
|
|
723
|
+
ap10: "https://api.cf.ap10.hana.ondemand.com",
|
|
724
|
+
ap11: "https://api.cf.ap11.hana.ondemand.com",
|
|
725
|
+
ap12: "https://api.cf.ap12.hana.ondemand.com",
|
|
726
|
+
ap20: "https://api.cf.ap20.hana.ondemand.com",
|
|
727
|
+
ap21: "https://api.cf.ap21.hana.ondemand.com",
|
|
728
|
+
ap30: "https://api.cf.ap30.hana.ondemand.com",
|
|
729
|
+
br10: "https://api.cf.br10.hana.ondemand.com",
|
|
730
|
+
br20: "https://api.cf.br20.hana.ondemand.com",
|
|
731
|
+
br30: "https://api.cf.br30.hana.ondemand.com",
|
|
732
|
+
ca10: "https://api.cf.ca10.hana.ondemand.com",
|
|
733
|
+
ca20: "https://api.cf.ca20.hana.ondemand.com",
|
|
734
|
+
ch20: "https://api.cf.ch20.hana.ondemand.com",
|
|
735
|
+
eu10: "https://api.cf.eu10.hana.ondemand.com",
|
|
736
|
+
eu11: "https://api.cf.eu11.hana.ondemand.com",
|
|
737
|
+
eu12: "https://api.cf.eu12.hana.ondemand.com",
|
|
738
|
+
eu20: "https://api.cf.eu20.hana.ondemand.com",
|
|
739
|
+
eu21: "https://api.cf.eu21.hana.ondemand.com",
|
|
740
|
+
eu30: "https://api.cf.eu30.hana.ondemand.com",
|
|
741
|
+
"eu10-002": "https://api.cf.eu10-002.hana.ondemand.com",
|
|
742
|
+
jp10: "https://api.cf.jp10.hana.ondemand.com",
|
|
743
|
+
jp20: "https://api.cf.jp20.hana.ondemand.com",
|
|
744
|
+
jp30: "https://api.cf.jp30.hana.ondemand.com",
|
|
745
|
+
us10: "https://api.cf.us10.hana.ondemand.com",
|
|
746
|
+
us11: "https://api.cf.us11.hana.ondemand.com",
|
|
747
|
+
us20: "https://api.cf.us20.hana.ondemand.com",
|
|
748
|
+
us21: "https://api.cf.us21.hana.ondemand.com",
|
|
749
|
+
us30: "https://api.cf.us30.hana.ondemand.com",
|
|
750
|
+
in30: "https://api.cf.in30.hana.ondemand.com"
|
|
751
|
+
};
|
|
752
|
+
function getApiEndpointForRegion(regionKey) {
|
|
753
|
+
return REGION_API_MAP[regionKey];
|
|
754
|
+
}
|
|
755
|
+
function getRegionKeyForApi(apiEndpoint) {
|
|
756
|
+
const norm2 = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
|
|
757
|
+
for (const [key, endpoint] of Object.entries(REGION_API_MAP)) {
|
|
758
|
+
if (endpoint.toLowerCase() === norm2) {
|
|
759
|
+
return key;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return void 0;
|
|
763
|
+
}
|
|
764
|
+
async function withCfSession(work) {
|
|
765
|
+
const cfHome = await mkdtemp(join2(tmpdir(), "saptools-cf-hana-"));
|
|
766
|
+
const ctx = { cfHome };
|
|
767
|
+
try {
|
|
768
|
+
return await work(ctx);
|
|
769
|
+
} finally {
|
|
770
|
+
await rm(cfHome, { recursive: true, force: true });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function resolveCfBin() {
|
|
774
|
+
const raw = process.env["CF_HANA_CF_BIN"] ?? "cf";
|
|
775
|
+
if (/\.(?:c|m)?js$/i.test(raw)) {
|
|
776
|
+
return { bin: process.execPath, argsPrefix: [raw] };
|
|
777
|
+
}
|
|
778
|
+
return { bin: raw, argsPrefix: [] };
|
|
779
|
+
}
|
|
780
|
+
function buildEnv(ctx, overrides = {}) {
|
|
781
|
+
const env = { ...process.env, ...overrides };
|
|
782
|
+
delete env["SAP_EMAIL"];
|
|
783
|
+
delete env["SAP_PASSWORD"];
|
|
784
|
+
env["CF_HOME"] = ctx.cfHome;
|
|
785
|
+
return env;
|
|
786
|
+
}
|
|
787
|
+
async function runCf(args, ctx, overrides = {}) {
|
|
788
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
789
|
+
const env = buildEnv(ctx, overrides);
|
|
790
|
+
let lastErr;
|
|
791
|
+
for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
|
|
792
|
+
try {
|
|
793
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, ...args], {
|
|
794
|
+
env,
|
|
795
|
+
maxBuffer: MAX_BUFFER,
|
|
796
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
797
|
+
});
|
|
798
|
+
return stdout;
|
|
799
|
+
} catch (err) {
|
|
800
|
+
lastErr = err;
|
|
801
|
+
if (attempt < CF_RETRY_ATTEMPTS - 1) {
|
|
802
|
+
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
803
|
+
}
|
|
635
804
|
}
|
|
636
805
|
}
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
806
|
+
const e = lastErr;
|
|
807
|
+
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
808
|
+
const cmd = args[0] === "auth" ? "cf auth" : `cf ${args.join(" ")}`;
|
|
809
|
+
throw new Error(`${cmd} failed: ${detail}`.trim(), { cause: lastErr });
|
|
810
|
+
}
|
|
811
|
+
async function cfApi(apiEndpoint, ctx) {
|
|
812
|
+
await runCf(["api", apiEndpoint], ctx);
|
|
813
|
+
}
|
|
814
|
+
async function cfAuth(email, password, ctx) {
|
|
815
|
+
await runCf(["auth"], ctx, {
|
|
816
|
+
CF_USERNAME: email,
|
|
817
|
+
CF_PASSWORD: password
|
|
640
818
|
});
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
819
|
+
}
|
|
820
|
+
async function cfTargetSpace(orgName, spaceName, ctx) {
|
|
821
|
+
await runCf(["target", "-o", orgName, "-s", spaceName], ctx);
|
|
822
|
+
}
|
|
823
|
+
async function cfEnv(appName, ctx) {
|
|
824
|
+
return await runCf(["env", appName], ctx);
|
|
825
|
+
}
|
|
826
|
+
async function cfEnvDirect(appName) {
|
|
827
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
828
|
+
const env = { ...process.env };
|
|
829
|
+
delete env["SAP_EMAIL"];
|
|
830
|
+
delete env["SAP_PASSWORD"];
|
|
831
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, "env", appName], {
|
|
832
|
+
env,
|
|
833
|
+
maxBuffer: MAX_BUFFER,
|
|
834
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
835
|
+
});
|
|
836
|
+
return stdout;
|
|
837
|
+
}
|
|
838
|
+
async function readCurrentCfTarget() {
|
|
839
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
840
|
+
const env = { ...process.env };
|
|
841
|
+
delete env["SAP_EMAIL"];
|
|
842
|
+
delete env["SAP_PASSWORD"];
|
|
843
|
+
try {
|
|
844
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, "target"], {
|
|
845
|
+
env,
|
|
846
|
+
maxBuffer: MAX_BUFFER,
|
|
847
|
+
timeout: 1e4
|
|
848
|
+
});
|
|
849
|
+
return parseCfTargetOutput(stdout);
|
|
850
|
+
} catch {
|
|
851
|
+
return void 0;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
function parseCfTargetOutput(stdout) {
|
|
855
|
+
const fields = parseTargetFields(stdout);
|
|
856
|
+
const api = fields.get("api endpoint");
|
|
857
|
+
const org = fields.get("org");
|
|
858
|
+
const space = fields.get("space");
|
|
859
|
+
if (!api || !org || !space) {
|
|
860
|
+
return void 0;
|
|
861
|
+
}
|
|
862
|
+
const regionKey = getRegionKeyForApi(api);
|
|
863
|
+
return {
|
|
864
|
+
apiEndpoint: api,
|
|
865
|
+
orgName: org,
|
|
866
|
+
spaceName: space,
|
|
867
|
+
...regionKey ? { regionKey } : {}
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function parseTargetFields(stdout) {
|
|
871
|
+
const map = /* @__PURE__ */ new Map();
|
|
872
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
873
|
+
const idx = line.indexOf(":");
|
|
874
|
+
if (idx < 0) {
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
878
|
+
const val = line.slice(idx + 1).trim();
|
|
879
|
+
if (key && val) {
|
|
880
|
+
map.set(key, val);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return map;
|
|
884
|
+
}
|
|
885
|
+
function formatCurrentCfAppSelector(target, appName) {
|
|
886
|
+
const name = appName.trim();
|
|
887
|
+
if (!name) {
|
|
888
|
+
throw new Error("App name is required.");
|
|
889
|
+
}
|
|
890
|
+
if (!target.regionKey) {
|
|
891
|
+
throw new Error(
|
|
892
|
+
`Current CF API endpoint "${target.apiEndpoint}" does not match a known SAP region. Pass a full region/org/space/app selector.`
|
|
644
893
|
);
|
|
645
894
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
895
|
+
return `${target.regionKey}/${target.orgName}/${target.spaceName}/${name}`;
|
|
896
|
+
}
|
|
897
|
+
function extractVcapSection(stdout) {
|
|
898
|
+
const start = stdout.indexOf("VCAP_SERVICES:");
|
|
899
|
+
if (start === -1) {
|
|
900
|
+
throw new Error("VCAP_SERVICES section not found in cf env output");
|
|
901
|
+
}
|
|
902
|
+
const after = stdout.slice(start + "VCAP_SERVICES:".length);
|
|
903
|
+
const end = after.indexOf("VCAP_APPLICATION:");
|
|
904
|
+
const block = end === -1 ? after : after.slice(0, end);
|
|
905
|
+
return block.trim();
|
|
906
|
+
}
|
|
907
|
+
function isRecord(v) {
|
|
908
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
909
|
+
}
|
|
910
|
+
function assertCreds(raw) {
|
|
911
|
+
if (!isRecord(raw)) {
|
|
912
|
+
throw new Error("HANA credentials must be an object");
|
|
913
|
+
}
|
|
914
|
+
const req = ["host", "port", "user", "password", "schema", "hdi_user", "hdi_password", "url", "database_id", "certificate"];
|
|
915
|
+
for (const k of req) {
|
|
916
|
+
if (typeof raw[k] !== "string") {
|
|
917
|
+
throw new Error(`Missing/invalid HANA credential field: "${k}"`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return raw;
|
|
921
|
+
}
|
|
922
|
+
function mapCreds(raw) {
|
|
923
|
+
return {
|
|
924
|
+
host: raw.host,
|
|
925
|
+
port: raw.port,
|
|
926
|
+
user: raw.user,
|
|
927
|
+
password: raw.password,
|
|
928
|
+
schema: raw.schema,
|
|
929
|
+
hdiUser: raw.hdi_user,
|
|
930
|
+
hdiPassword: raw.hdi_password,
|
|
931
|
+
...raw.url ? { url: raw.url } : {},
|
|
932
|
+
databaseId: raw.database_id,
|
|
933
|
+
certificate: raw.certificate
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
function extractHanaBindingsFromCfEnv(stdout) {
|
|
937
|
+
const jsonText = extractVcapSection(stdout);
|
|
938
|
+
let vcap;
|
|
939
|
+
try {
|
|
940
|
+
vcap = JSON.parse(jsonText);
|
|
941
|
+
} catch {
|
|
942
|
+
throw new Error("VCAP_SERVICES is not valid JSON");
|
|
943
|
+
}
|
|
944
|
+
if (!isRecord(vcap)) {
|
|
945
|
+
throw new Error("VCAP_SERVICES must be an object");
|
|
946
|
+
}
|
|
947
|
+
const hana = vcap["hana"];
|
|
948
|
+
if (hana === void 0) {
|
|
949
|
+
return [];
|
|
950
|
+
}
|
|
951
|
+
if (!Array.isArray(hana)) {
|
|
952
|
+
throw new Error("VCAP_SERVICES.hana must be an array when present");
|
|
953
|
+
}
|
|
954
|
+
return hana.map((b) => {
|
|
955
|
+
if (!isRecord(b)) {
|
|
956
|
+
throw new Error("HANA binding must be an object");
|
|
957
|
+
}
|
|
958
|
+
const credsRaw = assertCreds(b["credentials"]);
|
|
959
|
+
const name = typeof b["name"] === "string" ? b["name"] : void 0;
|
|
960
|
+
return {
|
|
961
|
+
...name ? { name } : {},
|
|
962
|
+
credentials: mapCreds(credsRaw)
|
|
963
|
+
};
|
|
650
964
|
});
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
965
|
+
}
|
|
966
|
+
function classifyCfError(stderr = "", stdout = "") {
|
|
967
|
+
const text = `${stderr} ${stdout}`.toLowerCase();
|
|
968
|
+
if (text.includes("not logged in") || text.includes("unauthorized") || text.includes("forbidden") || text.includes("credentials were rejected") || text.includes("authentication failed") || text.includes("cf auth") || text.includes("token") || text.includes("expired") || text.includes("session")) {
|
|
969
|
+
return { isAuthError: true, reason: "auth/session issue" };
|
|
970
|
+
}
|
|
971
|
+
return { isAuthError: false, reason: "" };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// src/credentials.ts
|
|
975
|
+
async function resolveAppBindings(rawSelector, options) {
|
|
976
|
+
const selector = rawSelector.trim();
|
|
977
|
+
if (!selector) {
|
|
978
|
+
throw new CfHanaError("CONFIG", "App selector is required");
|
|
979
|
+
}
|
|
980
|
+
let target;
|
|
981
|
+
const isBare = !selector.includes("/");
|
|
982
|
+
if (isBare) {
|
|
983
|
+
const current = await readCurrentCfTarget();
|
|
984
|
+
if (!current) {
|
|
985
|
+
throw new CfHanaError(
|
|
986
|
+
"CONFIG",
|
|
987
|
+
"No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
const displaySelector = current.regionKey ? formatCurrentCfAppSelector(current, selector) : `current/${current.orgName}/${current.spaceName}/${selector}`;
|
|
991
|
+
target = {
|
|
992
|
+
selector: displaySelector,
|
|
993
|
+
apiEndpoint: current.regionKey ? getApiEndpointForRegion(current.regionKey) : void 0,
|
|
994
|
+
orgName: current.orgName,
|
|
995
|
+
spaceName: current.spaceName,
|
|
996
|
+
appName: selector
|
|
997
|
+
};
|
|
998
|
+
} else {
|
|
999
|
+
const parts = selector.split("/").map((p) => p.trim());
|
|
1000
|
+
if (parts.length !== 4 || !parts[0] || !parts[1] || !parts[2] || !parts[3]) {
|
|
1001
|
+
throw new CfHanaError(
|
|
1002
|
+
"CONFIG",
|
|
1003
|
+
`Invalid selector "${selector}". Use region/org/space/app or a bare app name.`
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
const [regionKey, orgName, spaceName, appName] = parts;
|
|
1007
|
+
const apiEndpoint = getApiEndpointForRegion(regionKey);
|
|
1008
|
+
if (!apiEndpoint) {
|
|
1009
|
+
throw new CfHanaError(
|
|
1010
|
+
"CONFIG",
|
|
1011
|
+
`Unknown region key "${regionKey}". Use a known region or the current CF target.`
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
target = { selector, apiEndpoint, orgName, spaceName, appName };
|
|
1015
|
+
}
|
|
1016
|
+
let bindings;
|
|
1017
|
+
const source = "live";
|
|
1018
|
+
if (isBare) {
|
|
1019
|
+
try {
|
|
1020
|
+
const stdout = await cfEnvDirect(target.appName);
|
|
1021
|
+
bindings = extractHanaBindingsFromCfEnv(stdout);
|
|
1022
|
+
} catch (directError) {
|
|
1023
|
+
let stderr = "";
|
|
1024
|
+
if (directError && typeof directError === "object") {
|
|
1025
|
+
const e = directError;
|
|
1026
|
+
stderr = (typeof e.stderr === "string" ? e.stderr : "") || (typeof e.message === "string" ? e.message : "");
|
|
1027
|
+
}
|
|
1028
|
+
const classified = classifyCfError(stderr);
|
|
1029
|
+
if (classified.isAuthError) {
|
|
1030
|
+
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
1031
|
+
if (!sap) {
|
|
1032
|
+
throw new CredentialsNotFoundError(
|
|
1033
|
+
`Current CF session problem for bare app "${target.appName}" (${classified.reason}).
|
|
1034
|
+
Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
|
|
1035
|
+
{ cause: directError }
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
if (!target.apiEndpoint) {
|
|
1039
|
+
throw new CfHanaError("CONFIG", "Cannot determine API endpoint for fallback auth.");
|
|
1040
|
+
}
|
|
1041
|
+
const api = target.apiEndpoint;
|
|
1042
|
+
bindings = await withCfSession(async (ctx) => {
|
|
1043
|
+
await cfApi(api, ctx);
|
|
1044
|
+
await cfAuth(sap.email, sap.password, ctx);
|
|
1045
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
1046
|
+
const stdout = await cfEnv(target.appName, ctx);
|
|
1047
|
+
return extractHanaBindingsFromCfEnv(stdout);
|
|
1048
|
+
});
|
|
1049
|
+
} else {
|
|
1050
|
+
throw new CfHanaError(
|
|
1051
|
+
"CONFIG",
|
|
1052
|
+
`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}` : ""}`,
|
|
1053
|
+
{ cause: directError }
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
} else {
|
|
1058
|
+
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
1059
|
+
if (!sap) {
|
|
1060
|
+
throw new CredentialsNotFoundError(
|
|
1061
|
+
`SAP_EMAIL and SAP_PASSWORD are required to fetch HANA bindings for explicit selector "${selector}".`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
if (!target.apiEndpoint) {
|
|
1065
|
+
throw new CfHanaError("CONFIG", "Invalid explicit selector.");
|
|
1066
|
+
}
|
|
1067
|
+
const api = target.apiEndpoint;
|
|
1068
|
+
bindings = await withCfSession(async (ctx) => {
|
|
1069
|
+
await cfApi(api, ctx);
|
|
1070
|
+
await cfAuth(sap.email, sap.password, ctx);
|
|
1071
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
1072
|
+
const stdout = await cfEnv(target.appName, ctx);
|
|
1073
|
+
return extractHanaBindingsFromCfEnv(stdout);
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
if (bindings.length === 0) {
|
|
1077
|
+
throw new CredentialsNotFoundError(`App "${target.selector}" has no HANA service binding.`);
|
|
655
1078
|
}
|
|
656
1079
|
return {
|
|
657
|
-
selector:
|
|
658
|
-
appName:
|
|
659
|
-
bindings
|
|
660
|
-
source
|
|
1080
|
+
selector: target.selector,
|
|
1081
|
+
appName: target.appName,
|
|
1082
|
+
bindings,
|
|
1083
|
+
source
|
|
661
1084
|
};
|
|
662
1085
|
}
|
|
663
1086
|
function selectBinding(bindings, selector) {
|
|
@@ -707,18 +1130,63 @@ function toConnectionTarget(binding, role) {
|
|
|
707
1130
|
password: role === "hdi" ? credentials.hdiPassword : credentials.password,
|
|
708
1131
|
schema: credentials.schema,
|
|
709
1132
|
certificate: credentials.certificate,
|
|
710
|
-
databaseId: credentials.databaseId
|
|
1133
|
+
databaseId: credentials.databaseId ?? ""
|
|
711
1134
|
};
|
|
712
1135
|
}
|
|
713
1136
|
|
|
714
1137
|
// src/driver/fake.ts
|
|
715
1138
|
import { appendFile } from "fs/promises";
|
|
1139
|
+
var catalogFailureInjected = false;
|
|
1140
|
+
function catalogObjects() {
|
|
1141
|
+
return [
|
|
1142
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
|
|
1143
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_FIXED", OBJECT_TYPE: "TABLE" },
|
|
1144
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_VIEW", OBJECT_TYPE: "VIEW" },
|
|
1145
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "STATUS_ITEMS", OBJECT_TYPE: "TABLE" }
|
|
1146
|
+
];
|
|
1147
|
+
}
|
|
716
1148
|
function fakeExec(sql) {
|
|
717
1149
|
const kind = classifyStatement(sql);
|
|
718
1150
|
const forcedFailure = readEnv(envName("FAKE_FAIL_STATEMENT"))?.toLowerCase();
|
|
719
1151
|
if (forcedFailure === kind) {
|
|
720
1152
|
throw new Error(`fake driver forced ${kind.toUpperCase()} failure`);
|
|
721
1153
|
}
|
|
1154
|
+
const upperSql = sql.toUpperCase();
|
|
1155
|
+
if (upperSql.includes("SYS.TABLES") && upperSql.includes("SYS.VIEWS")) {
|
|
1156
|
+
if (readEnv(envName("FAKE_FAIL_CATALOG_ONCE")) === "1" && !catalogFailureInjected) {
|
|
1157
|
+
catalogFailureInjected = true;
|
|
1158
|
+
throw new QueryError("fake transient catalog metadata failure");
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
rows: catalogObjects(),
|
|
1162
|
+
columns: [
|
|
1163
|
+
{ name: "SCHEMA_NAME", typeName: "NVARCHAR" },
|
|
1164
|
+
{ name: "OBJECT_NAME", typeName: "NVARCHAR" },
|
|
1165
|
+
{ name: "OBJECT_TYPE", typeName: "NVARCHAR" }
|
|
1166
|
+
],
|
|
1167
|
+
affectedRows: 0
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
if (upperSql.includes("MISSING_TABLE") || upperSql.includes("MISSING_TABLES")) {
|
|
1171
|
+
throw new QueryError("invalid table name: MISSING_TABLE", { sqlState: "42S02" });
|
|
1172
|
+
}
|
|
1173
|
+
if (sql.toUpperCase().includes("LOB_FIXTURE")) {
|
|
1174
|
+
return {
|
|
1175
|
+
rows: [
|
|
1176
|
+
{
|
|
1177
|
+
LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
|
|
1178
|
+
CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
|
|
1179
|
+
PAYLOAD: Buffer.from([0, 1, 2, 255])
|
|
1180
|
+
}
|
|
1181
|
+
],
|
|
1182
|
+
columns: [
|
|
1183
|
+
{ name: "LOG_CONTENT", typeName: "NCLOB" },
|
|
1184
|
+
{ name: "CLOB_CONTENT", typeName: "CLOB" },
|
|
1185
|
+
{ name: "PAYLOAD", typeName: "BLOB" }
|
|
1186
|
+
],
|
|
1187
|
+
affectedRows: 0
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
722
1190
|
if (sql.toUpperCase().includes("DUMMY")) {
|
|
723
1191
|
return {
|
|
724
1192
|
rows: [{ "1": 1 }],
|
|
@@ -1034,16 +1502,16 @@ function createDriver(name) {
|
|
|
1034
1502
|
}
|
|
1035
1503
|
|
|
1036
1504
|
// src/history.ts
|
|
1037
|
-
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm } from "fs/promises";
|
|
1505
|
+
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm as rm2 } from "fs/promises";
|
|
1038
1506
|
import { homedir as homedir2 } from "os";
|
|
1039
|
-
import { join as
|
|
1507
|
+
import { join as join3 } from "path";
|
|
1040
1508
|
var SAPTOOLS_DIR_NAME2 = ".saptools";
|
|
1041
1509
|
var CF_HANA_DIR_NAME2 = "cf-hana";
|
|
1042
1510
|
var HISTORIES_DIR_NAME = "histories";
|
|
1043
1511
|
var HISTORY_RETENTION_DAYS = 5;
|
|
1044
1512
|
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
1045
1513
|
function defaultSaptoolsRoot2() {
|
|
1046
|
-
return
|
|
1514
|
+
return join3(homedir2(), SAPTOOLS_DIR_NAME2);
|
|
1047
1515
|
}
|
|
1048
1516
|
function padDatePart(value) {
|
|
1049
1517
|
return value.toString().padStart(2, "0");
|
|
@@ -1065,10 +1533,10 @@ function resolveSaptoolsRoot(root) {
|
|
|
1065
1533
|
return root ?? defaultSaptoolsRoot2();
|
|
1066
1534
|
}
|
|
1067
1535
|
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
1068
|
-
return
|
|
1536
|
+
return join3(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
|
|
1069
1537
|
}
|
|
1070
1538
|
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
1071
|
-
return
|
|
1539
|
+
return join3(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
1072
1540
|
}
|
|
1073
1541
|
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
1074
1542
|
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
@@ -1084,7 +1552,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
|
|
|
1084
1552
|
const cutoffKey = retentionCutoffKey(now);
|
|
1085
1553
|
await Promise.all(
|
|
1086
1554
|
files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
|
|
1087
|
-
await
|
|
1555
|
+
await rm2(join3(historyDir, file), { force: true });
|
|
1088
1556
|
})
|
|
1089
1557
|
);
|
|
1090
1558
|
}
|
|
@@ -1655,7 +2123,8 @@ var HanaClient = class _HanaClient {
|
|
|
1655
2123
|
return await writeSqlBackup({
|
|
1656
2124
|
operation: plan.operation,
|
|
1657
2125
|
statementSql: plan.statementSql,
|
|
1658
|
-
result
|
|
2126
|
+
result,
|
|
2127
|
+
selector: this.info.selector
|
|
1659
2128
|
});
|
|
1660
2129
|
});
|
|
1661
2130
|
}
|
|
@@ -1717,6 +2186,10 @@ var HanaClient = class _HanaClient {
|
|
|
1717
2186
|
async listTables(schema) {
|
|
1718
2187
|
return await this.pool.withConnection((connection) => listTables(connection, schema));
|
|
1719
2188
|
}
|
|
2189
|
+
/** List table and view names in a schema for typo suggestions. */
|
|
2190
|
+
async listCatalogObjects(schema) {
|
|
2191
|
+
return await this.pool.withConnection((connection) => listCatalogObjects(connection, schema));
|
|
2192
|
+
}
|
|
1720
2193
|
/** List the columns of a table. */
|
|
1721
2194
|
async listColumns(schema, table) {
|
|
1722
2195
|
return await this.pool.withConnection(
|
|
@@ -1842,12 +2315,15 @@ function textRange(value, offset, length) {
|
|
|
1842
2315
|
}
|
|
1843
2316
|
return { type: "text", originalLength: index, offset, value: selected };
|
|
1844
2317
|
}
|
|
1845
|
-
function readCellWindow(value, offset, length) {
|
|
2318
|
+
function readCellWindow(value, offset, length, typeName2) {
|
|
1846
2319
|
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
1847
2320
|
throw new CfHanaError("CONFIG", "offset must be a non-negative safe integer");
|
|
1848
2321
|
}
|
|
1849
2322
|
assertPositiveInteger("length", length);
|
|
1850
2323
|
if (Buffer.isBuffer(value)) {
|
|
2324
|
+
if (isTextLobType(typeName2)) {
|
|
2325
|
+
return textRange(value.toString("utf8"), offset, length);
|
|
2326
|
+
}
|
|
1851
2327
|
return {
|
|
1852
2328
|
type: "binary",
|
|
1853
2329
|
originalLength: value.length,
|
|
@@ -2019,18 +2495,19 @@ function jsonSearchMatches(root, term, row, column, limit, previewLength) {
|
|
|
2019
2495
|
}
|
|
2020
2496
|
return matches;
|
|
2021
2497
|
}
|
|
2022
|
-
function searchCell(value, term, row, column, limit, previewLength) {
|
|
2023
|
-
|
|
2498
|
+
function searchCell(value, term, row, column, typeName2, limit, previewLength) {
|
|
2499
|
+
const text = Buffer.isBuffer(value) && isTextLobType(typeName2) ? value.toString("utf8") : value;
|
|
2500
|
+
if (typeof text !== "string") {
|
|
2024
2501
|
return [];
|
|
2025
2502
|
}
|
|
2026
2503
|
try {
|
|
2027
|
-
const parsed = JSON.parse(
|
|
2504
|
+
const parsed = JSON.parse(text);
|
|
2028
2505
|
if (typeof parsed === "object" && parsed !== null) {
|
|
2029
2506
|
return jsonSearchMatches(parsed, term, row, column, limit, previewLength);
|
|
2030
2507
|
}
|
|
2031
2508
|
} catch {
|
|
2032
2509
|
}
|
|
2033
|
-
return plainTextMatches(
|
|
2510
|
+
return plainTextMatches(text, term, row, column, limit, previewLength);
|
|
2034
2511
|
}
|
|
2035
2512
|
function searchResultSession(session, searchTerm, options) {
|
|
2036
2513
|
assertPositiveInteger("limit", options.limit);
|
|
@@ -2052,6 +2529,7 @@ function searchResultSession(session, searchTerm, options) {
|
|
|
2052
2529
|
term,
|
|
2053
2530
|
item.rowNumber,
|
|
2054
2531
|
column.name,
|
|
2532
|
+
column.typeName,
|
|
2055
2533
|
remaining,
|
|
2056
2534
|
previewLength
|
|
2057
2535
|
)
|
|
@@ -2066,19 +2544,19 @@ function searchResultSession(session, searchTerm, options) {
|
|
|
2066
2544
|
|
|
2067
2545
|
// src/result-store.ts
|
|
2068
2546
|
import { randomBytes } from "crypto";
|
|
2069
|
-
import { mkdir as mkdir3, readFile, readdir as readdir2, rename, rm as
|
|
2547
|
+
import { mkdir as mkdir3, readFile, readdir as readdir2, rename, rm as rm3, writeFile as writeFile2 } from "fs/promises";
|
|
2070
2548
|
import { homedir as homedir3 } from "os";
|
|
2071
|
-
import { join as
|
|
2549
|
+
import { join as join4 } from "path";
|
|
2072
2550
|
var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
|
|
2073
2551
|
var MANIFEST_FILE_NAME = "manifest.json";
|
|
2074
2552
|
function resultsRoot(saptoolsRoot) {
|
|
2075
|
-
return
|
|
2553
|
+
return join4(saptoolsRoot ?? join4(homedir3(), ".saptools"), "cf-hana", "results");
|
|
2076
2554
|
}
|
|
2077
2555
|
function sessionDirectory(ref, saptoolsRoot) {
|
|
2078
|
-
return
|
|
2556
|
+
return join4(resultsRoot(saptoolsRoot), ref);
|
|
2079
2557
|
}
|
|
2080
2558
|
function manifestPath(ref, saptoolsRoot) {
|
|
2081
|
-
return
|
|
2559
|
+
return join4(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
|
|
2082
2560
|
}
|
|
2083
2561
|
function encodeCell(value) {
|
|
2084
2562
|
if (value === null) {
|
|
@@ -2177,11 +2655,11 @@ function toStoredSession(input, ref, now) {
|
|
|
2177
2655
|
result: encodeResult(input.result)
|
|
2178
2656
|
};
|
|
2179
2657
|
}
|
|
2180
|
-
function
|
|
2658
|
+
function isRecord2(value) {
|
|
2181
2659
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2182
2660
|
}
|
|
2183
2661
|
function isStoredSession(value) {
|
|
2184
|
-
if (!
|
|
2662
|
+
if (!isRecord2(value) || !isRecord2(value["result"]) || !isRecord2(value["info"])) {
|
|
2185
2663
|
return false;
|
|
2186
2664
|
}
|
|
2187
2665
|
const result = value["result"];
|
|
@@ -2230,16 +2708,16 @@ async function createResultSession(input, options = {}) {
|
|
|
2230
2708
|
const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
|
|
2231
2709
|
const tempDirectory = `${finalDirectory}.tmp-${process.pid.toString()}`;
|
|
2232
2710
|
await mkdir3(root, { recursive: true, mode: 448 });
|
|
2233
|
-
await
|
|
2711
|
+
await rm3(tempDirectory, { recursive: true, force: true });
|
|
2234
2712
|
await mkdir3(tempDirectory, { mode: 448 });
|
|
2235
2713
|
try {
|
|
2236
|
-
await writeFile2(
|
|
2714
|
+
await writeFile2(join4(tempDirectory, MANIFEST_FILE_NAME), serialized, {
|
|
2237
2715
|
encoding: "utf8",
|
|
2238
2716
|
mode: 384
|
|
2239
2717
|
});
|
|
2240
2718
|
await rename(tempDirectory, finalDirectory);
|
|
2241
2719
|
} catch (error) {
|
|
2242
|
-
await
|
|
2720
|
+
await rm3(tempDirectory, { recursive: true, force: true });
|
|
2243
2721
|
throw error;
|
|
2244
2722
|
}
|
|
2245
2723
|
return toSession(stored, options.saptoolsRoot);
|
|
@@ -2275,7 +2753,7 @@ async function pruneResultSessions(options = {}) {
|
|
|
2275
2753
|
for (const ref of refs) {
|
|
2276
2754
|
const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
|
|
2277
2755
|
if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
|
|
2278
|
-
await
|
|
2756
|
+
await rm3(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2279
2757
|
removed += 1;
|
|
2280
2758
|
}
|
|
2281
2759
|
}
|
|
@@ -2285,7 +2763,7 @@ async function clearResultSessions(options = {}) {
|
|
|
2285
2763
|
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
2286
2764
|
await Promise.all(
|
|
2287
2765
|
refs.map(async (ref) => {
|
|
2288
|
-
await
|
|
2766
|
+
await rm3(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2289
2767
|
})
|
|
2290
2768
|
);
|
|
2291
2769
|
return refs.length;
|
|
@@ -2373,7 +2851,12 @@ async function runShow(ref, options) {
|
|
|
2373
2851
|
print(csv(rows, ["PATH", "TYPE", "VALUE"], length));
|
|
2374
2852
|
return;
|
|
2375
2853
|
}
|
|
2376
|
-
const window = readCellWindow(
|
|
2854
|
+
const window = readCellWindow(
|
|
2855
|
+
selected.value,
|
|
2856
|
+
nonNegative("--offset", options.offset),
|
|
2857
|
+
length,
|
|
2858
|
+
selected.typeName
|
|
2859
|
+
);
|
|
2377
2860
|
print(
|
|
2378
2861
|
csv(
|
|
2379
2862
|
[
|
|
@@ -2426,9 +2909,9 @@ async function runSearch(ref, text, options) {
|
|
|
2426
2909
|
}));
|
|
2427
2910
|
print(csv(rows, ["ROW", "COLUMN", "OFFSET", "PATH", "PREVIEW"], length));
|
|
2428
2911
|
}
|
|
2429
|
-
function cellExportValue(value) {
|
|
2912
|
+
function cellExportValue(value, typeName2) {
|
|
2430
2913
|
if (Buffer.isBuffer(value)) {
|
|
2431
|
-
return value;
|
|
2914
|
+
return isTextLobType(typeName2) ? value.toString("utf8") : value;
|
|
2432
2915
|
}
|
|
2433
2916
|
if (value instanceof Date) {
|
|
2434
2917
|
return value.toISOString();
|
|
@@ -2450,7 +2933,9 @@ async function runExport(ref, options) {
|
|
|
2450
2933
|
}
|
|
2451
2934
|
const session = await readResultSession(ref);
|
|
2452
2935
|
const selected = selectResultCell(session, options.row, options.column);
|
|
2453
|
-
await writeFile3(options.output, cellExportValue(selected.value), {
|
|
2936
|
+
await writeFile3(options.output, cellExportValue(selected.value, selected.typeName), {
|
|
2937
|
+
mode: 384
|
|
2938
|
+
});
|
|
2454
2939
|
print(`wrote=${options.output}`);
|
|
2455
2940
|
}
|
|
2456
2941
|
async function runList() {
|
|
@@ -2492,6 +2977,419 @@ function registerResultCommands(program) {
|
|
|
2492
2977
|
});
|
|
2493
2978
|
}
|
|
2494
2979
|
|
|
2980
|
+
// src/metadata-cache.ts
|
|
2981
|
+
import { createHash } from "crypto";
|
|
2982
|
+
import { mkdir as mkdir4, readFile as readFile2, rename as rename2, rm as rm4, writeFile as writeFile4 } from "fs/promises";
|
|
2983
|
+
import { homedir as homedir4 } from "os";
|
|
2984
|
+
import { join as join5 } from "path";
|
|
2985
|
+
var METADATA_CACHE_TTL_MS = 30 * 6e4;
|
|
2986
|
+
function metadataCacheRoot(saptoolsRoot) {
|
|
2987
|
+
return join5(saptoolsRoot ?? join5(homedir4(), ".saptools"), "cf-hana", "metadata");
|
|
2988
|
+
}
|
|
2989
|
+
function toMetadataCacheScope(info) {
|
|
2990
|
+
return {
|
|
2991
|
+
selector: info.selector,
|
|
2992
|
+
appName: info.appName,
|
|
2993
|
+
host: info.host,
|
|
2994
|
+
schema: info.schema,
|
|
2995
|
+
role: info.role,
|
|
2996
|
+
driver: info.driver
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
function metadataCacheKey(scope) {
|
|
3000
|
+
return createHash("sha256").update(JSON.stringify(scope)).digest("hex");
|
|
3001
|
+
}
|
|
3002
|
+
function metadataCachePath(scope, saptoolsRoot) {
|
|
3003
|
+
return join5(metadataCacheRoot(saptoolsRoot), `${metadataCacheKey(scope)}.json`);
|
|
3004
|
+
}
|
|
3005
|
+
function isRecord3(value) {
|
|
3006
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3007
|
+
}
|
|
3008
|
+
function isCatalogObject(value) {
|
|
3009
|
+
return isRecord3(value) && typeof value["schema"] === "string" && typeof value["name"] === "string" && (value["type"] === "TABLE" || value["type"] === "VIEW");
|
|
3010
|
+
}
|
|
3011
|
+
function isScope(value) {
|
|
3012
|
+
return isRecord3(value) && typeof value["selector"] === "string" && typeof value["appName"] === "string" && typeof value["host"] === "string" && typeof value["schema"] === "string" && typeof value["role"] === "string" && typeof value["driver"] === "string";
|
|
3013
|
+
}
|
|
3014
|
+
function scopesEqual(left, right) {
|
|
3015
|
+
return metadataCacheKey(left) === metadataCacheKey(right);
|
|
3016
|
+
}
|
|
3017
|
+
function isStored(value) {
|
|
3018
|
+
return isRecord3(value) && value["version"] === 1 && typeof value["createdAt"] === "string" && isScope(value["scope"]) && Array.isArray(value["objects"]) && value["objects"].every(isCatalogObject);
|
|
3019
|
+
}
|
|
3020
|
+
async function readMetadataCache(scope, options = {}) {
|
|
3021
|
+
try {
|
|
3022
|
+
const parsed = JSON.parse(
|
|
3023
|
+
await readFile2(metadataCachePath(scope, options.saptoolsRoot), "utf8")
|
|
3024
|
+
);
|
|
3025
|
+
if (!isStored(parsed) || !scopesEqual(parsed.scope, scope)) {
|
|
3026
|
+
return void 0;
|
|
3027
|
+
}
|
|
3028
|
+
const createdAt = Date.parse(parsed.createdAt);
|
|
3029
|
+
const now = options.now?.() ?? /* @__PURE__ */ new Date();
|
|
3030
|
+
const ageMs = now.getTime() - createdAt;
|
|
3031
|
+
if (!Number.isFinite(createdAt) || ageMs < 0 || ageMs >= METADATA_CACHE_TTL_MS) {
|
|
3032
|
+
return void 0;
|
|
3033
|
+
}
|
|
3034
|
+
return parsed.objects;
|
|
3035
|
+
} catch {
|
|
3036
|
+
return void 0;
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
async function writeMetadataCache(scope, objects, options = {}) {
|
|
3040
|
+
const root = metadataCacheRoot(options.saptoolsRoot);
|
|
3041
|
+
const path = metadataCachePath(scope, options.saptoolsRoot);
|
|
3042
|
+
const tempPath = `${path}.tmp-${process.pid.toString()}`;
|
|
3043
|
+
const stored = {
|
|
3044
|
+
version: 1,
|
|
3045
|
+
createdAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
3046
|
+
scope,
|
|
3047
|
+
objects
|
|
3048
|
+
};
|
|
3049
|
+
await mkdir4(root, { recursive: true, mode: 448 });
|
|
3050
|
+
await rm4(tempPath, { force: true });
|
|
3051
|
+
await writeFile4(tempPath, `${JSON.stringify(stored)}
|
|
3052
|
+
`, { encoding: "utf8", mode: 384 });
|
|
3053
|
+
await rename2(tempPath, path);
|
|
3054
|
+
}
|
|
3055
|
+
async function loadCatalogObjectsWithCache(scope, refresh, loader, options = {}) {
|
|
3056
|
+
if (!refresh) {
|
|
3057
|
+
const cached = await readMetadataCache(scope, options);
|
|
3058
|
+
if (cached !== void 0) {
|
|
3059
|
+
return cached;
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
const objects = await loader();
|
|
3063
|
+
try {
|
|
3064
|
+
await writeMetadataCache(scope, objects, options);
|
|
3065
|
+
} catch {
|
|
3066
|
+
}
|
|
3067
|
+
return objects;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
// src/suggestions.ts
|
|
3071
|
+
var INVALID_OBJECT_PATTERNS = [
|
|
3072
|
+
/invalid\s+(?:table|view|object)\s+name/i,
|
|
3073
|
+
/(?:table|view|object)\s+[^\n]*does\s+not\s+exist/i,
|
|
3074
|
+
/could\s+not\s+find\s+(?:table|view|object)/i
|
|
3075
|
+
];
|
|
3076
|
+
var REF_KEYWORDS = /* @__PURE__ */ new Set(["FROM", "JOIN", "UPDATE", "INTO", "TABLE"]);
|
|
3077
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
3078
|
+
"AS",
|
|
3079
|
+
"ON",
|
|
3080
|
+
"WHERE",
|
|
3081
|
+
"SET",
|
|
3082
|
+
"VALUES",
|
|
3083
|
+
"USING",
|
|
3084
|
+
"WHEN",
|
|
3085
|
+
"INNER",
|
|
3086
|
+
"LEFT",
|
|
3087
|
+
"RIGHT",
|
|
3088
|
+
"FULL",
|
|
3089
|
+
"OUTER",
|
|
3090
|
+
"CROSS",
|
|
3091
|
+
"JOIN"
|
|
3092
|
+
]);
|
|
3093
|
+
var CTE_FOLLOWERS = /* @__PURE__ */ new Set(["AS"]);
|
|
3094
|
+
function isInvalidCatalogObjectError(error) {
|
|
3095
|
+
if (!(error instanceof QueryError)) {
|
|
3096
|
+
return false;
|
|
3097
|
+
}
|
|
3098
|
+
if (error.sqlState === "42S02" || error.sqlState === "42S01") {
|
|
3099
|
+
return true;
|
|
3100
|
+
}
|
|
3101
|
+
return INVALID_OBJECT_PATTERNS.some((pattern) => pattern.test(error.message));
|
|
3102
|
+
}
|
|
3103
|
+
function skipLineComment3(sql, index) {
|
|
3104
|
+
let cursor = index + 2;
|
|
3105
|
+
while (cursor < sql.length && sql[cursor] !== "\n") {
|
|
3106
|
+
cursor += 1;
|
|
3107
|
+
}
|
|
3108
|
+
return cursor;
|
|
3109
|
+
}
|
|
3110
|
+
function skipBlockComment3(sql, index) {
|
|
3111
|
+
let cursor = index + 2;
|
|
3112
|
+
while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
|
|
3113
|
+
cursor += 1;
|
|
3114
|
+
}
|
|
3115
|
+
return Math.min(cursor + 2, sql.length);
|
|
3116
|
+
}
|
|
3117
|
+
function skipStringLiteral(sql, index) {
|
|
3118
|
+
let cursor = index + 1;
|
|
3119
|
+
while (cursor < sql.length) {
|
|
3120
|
+
if (sql[cursor] === "'" && sql[cursor + 1] === "'") {
|
|
3121
|
+
cursor += 2;
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
if (sql[cursor] === "'") {
|
|
3125
|
+
return cursor + 1;
|
|
3126
|
+
}
|
|
3127
|
+
cursor += 1;
|
|
3128
|
+
}
|
|
3129
|
+
return cursor;
|
|
3130
|
+
}
|
|
3131
|
+
function readQuotedIdentifier(sql, index) {
|
|
3132
|
+
let cursor = index + 1;
|
|
3133
|
+
let text = "";
|
|
3134
|
+
while (cursor < sql.length) {
|
|
3135
|
+
if (sql[cursor] === '"' && sql[cursor + 1] === '"') {
|
|
3136
|
+
text += '"';
|
|
3137
|
+
cursor += 2;
|
|
3138
|
+
continue;
|
|
3139
|
+
}
|
|
3140
|
+
if (sql[cursor] === '"') {
|
|
3141
|
+
return { text, next: cursor + 1 };
|
|
3142
|
+
}
|
|
3143
|
+
text += sql.charAt(cursor);
|
|
3144
|
+
cursor += 1;
|
|
3145
|
+
}
|
|
3146
|
+
return { text, next: cursor };
|
|
3147
|
+
}
|
|
3148
|
+
function readBareWord(sql, index) {
|
|
3149
|
+
let cursor = index;
|
|
3150
|
+
let text = "";
|
|
3151
|
+
while (cursor < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(cursor))) {
|
|
3152
|
+
text += sql.charAt(cursor);
|
|
3153
|
+
cursor += 1;
|
|
3154
|
+
}
|
|
3155
|
+
return { text, next: cursor };
|
|
3156
|
+
}
|
|
3157
|
+
function tokenize(sql) {
|
|
3158
|
+
const tokens = [];
|
|
3159
|
+
let index = 0;
|
|
3160
|
+
while (index < sql.length) {
|
|
3161
|
+
const char = sql.charAt(index);
|
|
3162
|
+
if (/\s/.test(char)) {
|
|
3163
|
+
index += 1;
|
|
3164
|
+
continue;
|
|
3165
|
+
}
|
|
3166
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
3167
|
+
index = skipLineComment3(sql, index);
|
|
3168
|
+
continue;
|
|
3169
|
+
}
|
|
3170
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
3171
|
+
index = skipBlockComment3(sql, index);
|
|
3172
|
+
continue;
|
|
3173
|
+
}
|
|
3174
|
+
if (char === "'") {
|
|
3175
|
+
index = skipStringLiteral(sql, index);
|
|
3176
|
+
continue;
|
|
3177
|
+
}
|
|
3178
|
+
if (char === '"') {
|
|
3179
|
+
const quoted = readQuotedIdentifier(sql, index);
|
|
3180
|
+
tokens.push({ kind: "quoted", text: quoted.text });
|
|
3181
|
+
index = quoted.next;
|
|
3182
|
+
continue;
|
|
3183
|
+
}
|
|
3184
|
+
if (/[A-Za-z_#$]/.test(char)) {
|
|
3185
|
+
const word = readBareWord(sql, index);
|
|
3186
|
+
tokens.push({ kind: "word", text: word.text });
|
|
3187
|
+
index = word.next;
|
|
3188
|
+
continue;
|
|
3189
|
+
}
|
|
3190
|
+
if (char === ".") {
|
|
3191
|
+
tokens.push({ kind: "dot", text: char });
|
|
3192
|
+
} else if (char === ",") {
|
|
3193
|
+
tokens.push({ kind: "comma", text: char });
|
|
3194
|
+
} else if (char === "(") {
|
|
3195
|
+
tokens.push({ kind: "open", text: char });
|
|
3196
|
+
} else if (char === ")") {
|
|
3197
|
+
tokens.push({ kind: "close", text: char });
|
|
3198
|
+
} else {
|
|
3199
|
+
tokens.push({ kind: "other", text: char });
|
|
3200
|
+
}
|
|
3201
|
+
index += 1;
|
|
3202
|
+
}
|
|
3203
|
+
return tokens;
|
|
3204
|
+
}
|
|
3205
|
+
function upper(token) {
|
|
3206
|
+
return token?.text.toUpperCase() ?? "";
|
|
3207
|
+
}
|
|
3208
|
+
function isIdentifier(token) {
|
|
3209
|
+
return token?.kind === "word" || token?.kind === "quoted";
|
|
3210
|
+
}
|
|
3211
|
+
function readName(tokens, start, allowFollowingParens = false) {
|
|
3212
|
+
const first = tokens[start];
|
|
3213
|
+
if (!isIdentifier(first)) {
|
|
3214
|
+
return void 0;
|
|
3215
|
+
}
|
|
3216
|
+
let next = start + 1;
|
|
3217
|
+
let schema;
|
|
3218
|
+
let name = first.text;
|
|
3219
|
+
const maybeObject = tokens[next + 1];
|
|
3220
|
+
if (tokens[next]?.kind === "dot" && isIdentifier(maybeObject)) {
|
|
3221
|
+
schema = name;
|
|
3222
|
+
name = maybeObject.text;
|
|
3223
|
+
next += 2;
|
|
3224
|
+
}
|
|
3225
|
+
if (!allowFollowingParens && tokens[next]?.kind === "open") {
|
|
3226
|
+
return void 0;
|
|
3227
|
+
}
|
|
3228
|
+
return { name: schema === void 0 ? { name } : { schema, name }, next };
|
|
3229
|
+
}
|
|
3230
|
+
function firstNameFromText(text) {
|
|
3231
|
+
const tokens = tokenize(text);
|
|
3232
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
3233
|
+
const read = readName(tokens, index, true);
|
|
3234
|
+
if (read !== void 0) {
|
|
3235
|
+
return read.name;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
return void 0;
|
|
3239
|
+
}
|
|
3240
|
+
function extractMissingObjectNameFromError(error) {
|
|
3241
|
+
if (!(error instanceof QueryError)) {
|
|
3242
|
+
return void 0;
|
|
3243
|
+
}
|
|
3244
|
+
const message = error.message;
|
|
3245
|
+
const invalidName = /invalid\s+(?:table|view|object)\s+name\s*:?\s*(.+)$/i.exec(message);
|
|
3246
|
+
if (invalidName?.[1] !== void 0) {
|
|
3247
|
+
return firstNameFromText(invalidName[1]);
|
|
3248
|
+
}
|
|
3249
|
+
const missingObject = /(?:table|view|object)\s+(.+?)\s+(?:does\s+not\s+exist|not\s+found)/i.exec(message);
|
|
3250
|
+
if (missingObject?.[1] !== void 0) {
|
|
3251
|
+
return firstNameFromText(missingObject[1]);
|
|
3252
|
+
}
|
|
3253
|
+
return void 0;
|
|
3254
|
+
}
|
|
3255
|
+
function cteNames(tokens) {
|
|
3256
|
+
const names = /* @__PURE__ */ new Set();
|
|
3257
|
+
if (upper(tokens[0]) !== "WITH") {
|
|
3258
|
+
return names;
|
|
3259
|
+
}
|
|
3260
|
+
let depth = 0;
|
|
3261
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
3262
|
+
const token = tokens[index];
|
|
3263
|
+
if (depth === 0 && upper(token) === "SELECT") {
|
|
3264
|
+
break;
|
|
3265
|
+
}
|
|
3266
|
+
if (depth === 0 && isIdentifier(token) && CTE_FOLLOWERS.has(upper(tokens[index + 1]))) {
|
|
3267
|
+
names.add(token.text.toUpperCase());
|
|
3268
|
+
}
|
|
3269
|
+
if (token?.kind === "open") {
|
|
3270
|
+
depth += 1;
|
|
3271
|
+
} else if (token?.kind === "close" && depth > 0) {
|
|
3272
|
+
depth -= 1;
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
return names;
|
|
3276
|
+
}
|
|
3277
|
+
function extractMissingObjectName(sql) {
|
|
3278
|
+
const tokens = tokenize(sql.replace(/^;+|;+$/g, ""));
|
|
3279
|
+
const ctes = cteNames(tokens);
|
|
3280
|
+
let candidate;
|
|
3281
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
3282
|
+
const word = upper(tokens[index]);
|
|
3283
|
+
if (!REF_KEYWORDS.has(word)) {
|
|
3284
|
+
continue;
|
|
3285
|
+
}
|
|
3286
|
+
if (word === "TABLE" && upper(tokens[index - 1]) !== "TRUNCATE") {
|
|
3287
|
+
continue;
|
|
3288
|
+
}
|
|
3289
|
+
if (word === "INTO" && !(upper(tokens[index - 1]) === "INSERT" || upper(tokens[index - 1]) === "MERGE")) {
|
|
3290
|
+
continue;
|
|
3291
|
+
}
|
|
3292
|
+
const read = readName(tokens, index + 1, word === "INTO");
|
|
3293
|
+
if (read === void 0) {
|
|
3294
|
+
continue;
|
|
3295
|
+
}
|
|
3296
|
+
if (read.name.schema === void 0 && ctes.has(read.name.name.toUpperCase())) {
|
|
3297
|
+
continue;
|
|
3298
|
+
}
|
|
3299
|
+
if (STOP_WORDS.has(read.name.name.toUpperCase())) {
|
|
3300
|
+
continue;
|
|
3301
|
+
}
|
|
3302
|
+
candidate = read.name;
|
|
3303
|
+
let next = read.next;
|
|
3304
|
+
if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
|
|
3305
|
+
next += 1;
|
|
3306
|
+
}
|
|
3307
|
+
while (tokens[next]?.kind === "comma") {
|
|
3308
|
+
const commaRead = readName(tokens, next + 1);
|
|
3309
|
+
if (commaRead === void 0) {
|
|
3310
|
+
break;
|
|
3311
|
+
}
|
|
3312
|
+
if (!(commaRead.name.schema === void 0 && ctes.has(commaRead.name.name.toUpperCase()))) {
|
|
3313
|
+
candidate = commaRead.name;
|
|
3314
|
+
}
|
|
3315
|
+
next = commaRead.next;
|
|
3316
|
+
if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
|
|
3317
|
+
next += 1;
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
return candidate;
|
|
3322
|
+
}
|
|
3323
|
+
function singular(value) {
|
|
3324
|
+
if (value.endsWith("ES")) {
|
|
3325
|
+
return value.slice(0, -2);
|
|
3326
|
+
}
|
|
3327
|
+
if (value.endsWith("S")) {
|
|
3328
|
+
return value.slice(0, -1);
|
|
3329
|
+
}
|
|
3330
|
+
return value;
|
|
3331
|
+
}
|
|
3332
|
+
function norm(value) {
|
|
3333
|
+
return value.toUpperCase().replace(/[^A-Z0-9]+/g, "");
|
|
3334
|
+
}
|
|
3335
|
+
function distance(a, b) {
|
|
3336
|
+
const previous = Array.from({ length: b.length + 1 }, (_value, index) => index);
|
|
3337
|
+
for (let leftIndex = 1; leftIndex <= a.length; leftIndex += 1) {
|
|
3338
|
+
let last = leftIndex - 1;
|
|
3339
|
+
previous[0] = leftIndex;
|
|
3340
|
+
for (let rightIndex = 1; rightIndex <= b.length; rightIndex += 1) {
|
|
3341
|
+
const old = previous[rightIndex] ?? 0;
|
|
3342
|
+
previous[rightIndex] = Math.min(
|
|
3343
|
+
(previous[rightIndex] ?? 0) + 1,
|
|
3344
|
+
(previous[rightIndex - 1] ?? 0) + 1,
|
|
3345
|
+
last + (a.charAt(leftIndex - 1) === b.charAt(rightIndex - 1) ? 0 : 1)
|
|
3346
|
+
);
|
|
3347
|
+
last = old;
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
return previous[b.length] ?? 0;
|
|
3351
|
+
}
|
|
3352
|
+
function score(requested, candidate) {
|
|
3353
|
+
const requestedName = norm(requested.name);
|
|
3354
|
+
const candidateName = norm(candidate.name);
|
|
3355
|
+
if (requested.schema !== void 0 && norm(requested.schema) !== norm(candidate.schema)) {
|
|
3356
|
+
return 0;
|
|
3357
|
+
}
|
|
3358
|
+
if (requestedName === candidateName) {
|
|
3359
|
+
return 100;
|
|
3360
|
+
}
|
|
3361
|
+
if (singular(requestedName) === singular(candidateName)) {
|
|
3362
|
+
return 92;
|
|
3363
|
+
}
|
|
3364
|
+
let value = 0;
|
|
3365
|
+
if (candidateName.startsWith(requestedName) || requestedName.startsWith(candidateName)) {
|
|
3366
|
+
value = Math.max(value, 80 - Math.abs(candidateName.length - requestedName.length));
|
|
3367
|
+
}
|
|
3368
|
+
if (candidateName.endsWith(requestedName) || requestedName.endsWith(candidateName)) {
|
|
3369
|
+
value = Math.max(value, 70 - Math.abs(candidateName.length - requestedName.length));
|
|
3370
|
+
}
|
|
3371
|
+
const editDistance = distance(requestedName, candidateName);
|
|
3372
|
+
const max = Math.max(requestedName.length, candidateName.length, 1);
|
|
3373
|
+
if (editDistance <= Math.max(3, Math.floor(max * 0.35))) {
|
|
3374
|
+
value = Math.max(value, Math.round(75 * (1 - editDistance / max)));
|
|
3375
|
+
}
|
|
3376
|
+
return value;
|
|
3377
|
+
}
|
|
3378
|
+
function rankCatalogSuggestions(requested, candidates, limit = 5) {
|
|
3379
|
+
return candidates.map((candidate) => ({ candidate, score: score(requested, candidate) })).filter((item) => item.score >= 45).sort(
|
|
3380
|
+
(left, right) => right.score - left.score || left.candidate.schema.localeCompare(right.candidate.schema) || (left.candidate.type === right.candidate.type ? 0 : left.candidate.type === "TABLE" ? -1 : 1) || left.candidate.name.localeCompare(right.candidate.name)
|
|
3381
|
+
).slice(0, limit).map((item) => item.candidate);
|
|
3382
|
+
}
|
|
3383
|
+
function formatSuggestions(suggestions) {
|
|
3384
|
+
if (suggestions.length === 0) {
|
|
3385
|
+
return void 0;
|
|
3386
|
+
}
|
|
3387
|
+
return [
|
|
3388
|
+
"Did you mean:",
|
|
3389
|
+
...suggestions.map((item) => ` ${item.schema}.${item.name} (${item.type})`)
|
|
3390
|
+
].join("\n");
|
|
3391
|
+
}
|
|
3392
|
+
|
|
2495
3393
|
// src/cli.ts
|
|
2496
3394
|
function print2(text) {
|
|
2497
3395
|
process.stdout.write(`${text}
|
|
@@ -2618,7 +3516,7 @@ function formatInfo(info) {
|
|
|
2618
3516
|
].join("\n");
|
|
2619
3517
|
}
|
|
2620
3518
|
function withConnectionOptions(command) {
|
|
2621
|
-
return command.option("--refresh", "bypass cached credentials and fetch them live", false).option("--role <role>", "HANA user role: runtime or hdi", "runtime").option("--binding <name>", "select a HANA binding by service name").option("--binding-index <n>", "select a HANA binding by index", parseIntOption2).option("--read-only", "block every DML and DDL statement", false).option("--allow-destructive", "permit destructive statements", false).option("--timeout <ms>", "connection and query timeout in milliseconds", parseIntOption2).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption2).option("--no-auto-limit", "disable the automatic SELECT row cap");
|
|
3519
|
+
return command.option("--refresh", "bypass cached credentials and fetch them live", false).option("--role <role>", "HANA user role: runtime or hdi", "runtime").option("--binding <name>", "select a HANA binding by service name").option("--binding-index <n>", "select a HANA binding by index", parseIntOption2).option("--read-only", "block every DML and DDL statement", false).option("--allow-destructive", "permit destructive statements", false).option("--timeout <ms>", "connection and query timeout in milliseconds", parseIntOption2).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption2).option("--no-auto-limit", "disable the automatic SELECT row cap").option("--refresh-metadata", "bypass the 30-minute table/view metadata suggestion cache", false);
|
|
2622
3520
|
}
|
|
2623
3521
|
function withFormattedConnectionOptions(command) {
|
|
2624
3522
|
return withConnectionOptions(command).option(
|
|
@@ -2627,6 +3525,36 @@ function withFormattedConnectionOptions(command) {
|
|
|
2627
3525
|
"table"
|
|
2628
3526
|
);
|
|
2629
3527
|
}
|
|
3528
|
+
async function loadSuggestionCatalogObjects(client, refresh) {
|
|
3529
|
+
try {
|
|
3530
|
+
return await loadCatalogObjectsWithCache(
|
|
3531
|
+
toMetadataCacheScope(client.info),
|
|
3532
|
+
refresh,
|
|
3533
|
+
async () => await client.listCatalogObjects(client.info.schema)
|
|
3534
|
+
);
|
|
3535
|
+
} catch {
|
|
3536
|
+
return await client.listCatalogObjects(client.info.schema);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
async function enrichAndRethrowQueryError(error, client, sql, refresh) {
|
|
3540
|
+
if (!isInvalidCatalogObjectError(error)) {
|
|
3541
|
+
throw error;
|
|
3542
|
+
}
|
|
3543
|
+
const requested = extractMissingObjectNameFromError(error) ?? extractMissingObjectName(sql);
|
|
3544
|
+
if (requested === void 0) {
|
|
3545
|
+
throw error;
|
|
3546
|
+
}
|
|
3547
|
+
try {
|
|
3548
|
+
const objects = await loadSuggestionCatalogObjects(client, refresh);
|
|
3549
|
+
const text = formatSuggestions(rankCatalogSuggestions(requested, objects));
|
|
3550
|
+
if (text !== void 0) {
|
|
3551
|
+
process.stderr.write(`${text}
|
|
3552
|
+
`);
|
|
3553
|
+
}
|
|
3554
|
+
} catch {
|
|
3555
|
+
}
|
|
3556
|
+
throw error;
|
|
3557
|
+
}
|
|
2630
3558
|
async function runQuery(selector, sql, command) {
|
|
2631
3559
|
const opts = command.opts();
|
|
2632
3560
|
assertQueryOptions(sql, opts);
|
|
@@ -2634,12 +3562,24 @@ async function runQuery(selector, sql, command) {
|
|
|
2634
3562
|
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
2635
3563
|
try {
|
|
2636
3564
|
const params = opts.param ?? [];
|
|
2637
|
-
|
|
3565
|
+
let backup;
|
|
3566
|
+
try {
|
|
3567
|
+
backup = await client.backupWriteStatement(sql, params);
|
|
3568
|
+
} catch (error) {
|
|
3569
|
+
await enrichAndRethrowQueryError(error, client, sql, opts.refreshMetadata || opts.refresh);
|
|
3570
|
+
}
|
|
2638
3571
|
if (backup !== void 0) {
|
|
2639
3572
|
process.stderr.write(`${CLI_NAME}: backup saved to ${backup.directory}
|
|
2640
3573
|
`);
|
|
2641
3574
|
}
|
|
2642
|
-
const result = await client.query(sql, params)
|
|
3575
|
+
const result = await client.query(sql, params).catch(async (error) => {
|
|
3576
|
+
return await enrichAndRethrowQueryError(
|
|
3577
|
+
error,
|
|
3578
|
+
client,
|
|
3579
|
+
sql,
|
|
3580
|
+
opts.refreshMetadata || opts.refresh
|
|
3581
|
+
);
|
|
3582
|
+
});
|
|
2643
3583
|
if (result.statement === "select") {
|
|
2644
3584
|
const compact = formatCompactCsv(result, cellLimit);
|
|
2645
3585
|
if (opts.save) {
|