@saptools/cf-hana 0.1.2 → 0.1.4
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 +18 -0
- package/README.md +66 -12
- package/dist/cli.js +422 -107
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +32 -1
- package/dist/index.js +418 -105
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
|
+
// src/backup.ts
|
|
7
|
+
import { createHash, randomUUID } from "crypto";
|
|
8
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
|
|
6
12
|
// src/errors.ts
|
|
7
13
|
var CfHanaError = class extends Error {
|
|
8
14
|
code;
|
|
@@ -46,6 +52,91 @@ function errorMessage(error) {
|
|
|
46
52
|
return String(error);
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
// src/format.ts
|
|
56
|
+
function cellText(value, nullText) {
|
|
57
|
+
if (value === null) {
|
|
58
|
+
return nullText;
|
|
59
|
+
}
|
|
60
|
+
if (value instanceof Date) {
|
|
61
|
+
return value.toISOString();
|
|
62
|
+
}
|
|
63
|
+
if (Buffer.isBuffer(value)) {
|
|
64
|
+
return `0x${value.toString("hex")}`;
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === "boolean") {
|
|
67
|
+
return value ? "true" : "false";
|
|
68
|
+
}
|
|
69
|
+
return typeof value === "number" ? value.toString() : value;
|
|
70
|
+
}
|
|
71
|
+
function serializeCell(value) {
|
|
72
|
+
if (value === null) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (value instanceof Date) {
|
|
76
|
+
return value.toISOString();
|
|
77
|
+
}
|
|
78
|
+
if (Buffer.isBuffer(value)) {
|
|
79
|
+
return `0x${value.toString("hex")}`;
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function csvEscape(text) {
|
|
84
|
+
if (/[",\r\n]/.test(text)) {
|
|
85
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
86
|
+
}
|
|
87
|
+
return text;
|
|
88
|
+
}
|
|
89
|
+
function formatTable(result) {
|
|
90
|
+
if (result.columns.length === 0) {
|
|
91
|
+
return `(${String(result.rowCount)} row(s) affected)`;
|
|
92
|
+
}
|
|
93
|
+
const headers = result.columns.map((column) => column.name);
|
|
94
|
+
const rows = result.rows.map(
|
|
95
|
+
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
|
|
96
|
+
);
|
|
97
|
+
const widths = headers.map((header, index) => {
|
|
98
|
+
const widest = rows.reduce(
|
|
99
|
+
(max, cells) => Math.max(max, (cells[index] ?? "").length),
|
|
100
|
+
header.length
|
|
101
|
+
);
|
|
102
|
+
return widest;
|
|
103
|
+
});
|
|
104
|
+
const renderRow = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" | ");
|
|
105
|
+
const separator = widths.map((width) => "-".repeat(width)).join("-+-");
|
|
106
|
+
const body = rows.map((cells) => renderRow(cells));
|
|
107
|
+
return [renderRow(headers), separator, ...body].join("\n");
|
|
108
|
+
}
|
|
109
|
+
function formatJson(result) {
|
|
110
|
+
const rows = result.rows.map((row) => {
|
|
111
|
+
const serialized = {};
|
|
112
|
+
for (const [key, value] of Object.entries(row)) {
|
|
113
|
+
serialized[key] = serializeCell(value);
|
|
114
|
+
}
|
|
115
|
+
return serialized;
|
|
116
|
+
});
|
|
117
|
+
return JSON.stringify(rows, null, 2);
|
|
118
|
+
}
|
|
119
|
+
function formatCsv(result) {
|
|
120
|
+
const headers = result.columns.map((column) => column.name);
|
|
121
|
+
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
122
|
+
for (const row of result.rows) {
|
|
123
|
+
lines.push(
|
|
124
|
+
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return lines.join("\r\n");
|
|
128
|
+
}
|
|
129
|
+
function formatResult(result, format) {
|
|
130
|
+
switch (format) {
|
|
131
|
+
case "table":
|
|
132
|
+
return formatTable(result);
|
|
133
|
+
case "json":
|
|
134
|
+
return formatJson(result);
|
|
135
|
+
case "csv":
|
|
136
|
+
return formatCsv(result);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
49
140
|
// src/statements.ts
|
|
50
141
|
var LEADING_NOISE = /^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/;
|
|
51
142
|
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
|
|
@@ -134,6 +225,182 @@ function assertParamArity(sql, params) {
|
|
|
134
225
|
}
|
|
135
226
|
}
|
|
136
227
|
|
|
228
|
+
// src/backup.ts
|
|
229
|
+
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
230
|
+
var CF_HANA_DIR_NAME = "cf-hana";
|
|
231
|
+
var BACKUPS_DIR_NAME = "backups";
|
|
232
|
+
function defaultSaptoolsRoot() {
|
|
233
|
+
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
234
|
+
}
|
|
235
|
+
function trimStatementSql(sql) {
|
|
236
|
+
return sql.trim().replace(/;+\s*$/, "").trim();
|
|
237
|
+
}
|
|
238
|
+
function isIdentifierChar(char) {
|
|
239
|
+
return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
|
|
240
|
+
}
|
|
241
|
+
function skipQuotedText(sql, index) {
|
|
242
|
+
const quote = sql[index];
|
|
243
|
+
let cursor = index + 1;
|
|
244
|
+
while (cursor < sql.length) {
|
|
245
|
+
if (sql[cursor] === quote) {
|
|
246
|
+
if (sql[cursor + 1] === quote) {
|
|
247
|
+
cursor += 2;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
return cursor + 1;
|
|
251
|
+
}
|
|
252
|
+
cursor += 1;
|
|
253
|
+
}
|
|
254
|
+
return cursor;
|
|
255
|
+
}
|
|
256
|
+
function skipLineComment(sql, index) {
|
|
257
|
+
let cursor = index + 2;
|
|
258
|
+
while (cursor < sql.length && sql[cursor] !== "\n") {
|
|
259
|
+
cursor += 1;
|
|
260
|
+
}
|
|
261
|
+
return cursor;
|
|
262
|
+
}
|
|
263
|
+
function skipBlockComment(sql, index) {
|
|
264
|
+
let cursor = index + 2;
|
|
265
|
+
while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
|
|
266
|
+
cursor += 1;
|
|
267
|
+
}
|
|
268
|
+
return Math.min(cursor + 2, sql.length);
|
|
269
|
+
}
|
|
270
|
+
function skipNonCode(sql, index) {
|
|
271
|
+
const char = sql[index];
|
|
272
|
+
if (char === "'" || char === '"') {
|
|
273
|
+
return skipQuotedText(sql, index);
|
|
274
|
+
}
|
|
275
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
276
|
+
return skipLineComment(sql, index);
|
|
277
|
+
}
|
|
278
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
279
|
+
return skipBlockComment(sql, index);
|
|
280
|
+
}
|
|
281
|
+
return void 0;
|
|
282
|
+
}
|
|
283
|
+
function keywordMatches(sql, index, keyword) {
|
|
284
|
+
const end = index + keyword.length;
|
|
285
|
+
return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
|
|
286
|
+
}
|
|
287
|
+
function findTopLevelKeyword(sql, keyword, startIndex = 0) {
|
|
288
|
+
let index = startIndex;
|
|
289
|
+
let depth = 0;
|
|
290
|
+
while (index < sql.length) {
|
|
291
|
+
const skipped = skipNonCode(sql, index);
|
|
292
|
+
if (skipped !== void 0) {
|
|
293
|
+
index = skipped;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const char = sql[index];
|
|
297
|
+
if (char === "(") {
|
|
298
|
+
depth += 1;
|
|
299
|
+
} else if (char === ")" && depth > 0) {
|
|
300
|
+
depth -= 1;
|
|
301
|
+
} else if (depth === 0 && keywordMatches(sql, index, keyword)) {
|
|
302
|
+
return index;
|
|
303
|
+
}
|
|
304
|
+
index += 1;
|
|
305
|
+
}
|
|
306
|
+
return void 0;
|
|
307
|
+
}
|
|
308
|
+
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
309
|
+
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
310
|
+
}
|
|
311
|
+
function buildSelectPlan(operation, statementSql, targetSql, whereIndex, params) {
|
|
312
|
+
const target = targetSql.trim();
|
|
313
|
+
if (target.length === 0) {
|
|
314
|
+
throw new QueryError(`${operation.toUpperCase()} backup requires a target table`);
|
|
315
|
+
}
|
|
316
|
+
if (whereIndex === void 0) {
|
|
317
|
+
return { operation, statementSql, selectSql: `SELECT * FROM ${target}`, selectParams: [] };
|
|
318
|
+
}
|
|
319
|
+
const whereSql = statementSql.slice(whereIndex + "WHERE".length).trim();
|
|
320
|
+
if (whereSql.length === 0) {
|
|
321
|
+
throw new QueryError(`${operation.toUpperCase()} backup requires a non-empty WHERE clause`);
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
operation,
|
|
325
|
+
statementSql,
|
|
326
|
+
selectSql: `SELECT * FROM ${target} WHERE ${whereSql}`,
|
|
327
|
+
selectParams: selectParamsAfterWhere(statementSql, whereIndex, params)
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function buildUpdateBackupPlan(statementSql, params) {
|
|
331
|
+
const updateIndex = findTopLevelKeyword(statementSql, "UPDATE");
|
|
332
|
+
const setIndex = updateIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "SET", updateIndex + "UPDATE".length);
|
|
333
|
+
if (updateIndex === void 0 || setIndex === void 0) {
|
|
334
|
+
throw new QueryError("UPDATE backup requires UPDATE <target> SET syntax");
|
|
335
|
+
}
|
|
336
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", setIndex + "SET".length);
|
|
337
|
+
return buildSelectPlan(
|
|
338
|
+
"update",
|
|
339
|
+
statementSql,
|
|
340
|
+
statementSql.slice(updateIndex + "UPDATE".length, setIndex),
|
|
341
|
+
whereIndex,
|
|
342
|
+
params
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
function buildDeleteBackupPlan(statementSql, params) {
|
|
346
|
+
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
347
|
+
const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
|
|
348
|
+
if (deleteIndex === void 0 || fromIndex === void 0) {
|
|
349
|
+
throw new QueryError("DELETE backup requires DELETE FROM <target> syntax");
|
|
350
|
+
}
|
|
351
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", fromIndex + "FROM".length);
|
|
352
|
+
const targetEnd = whereIndex ?? statementSql.length;
|
|
353
|
+
return buildSelectPlan(
|
|
354
|
+
"delete",
|
|
355
|
+
statementSql,
|
|
356
|
+
statementSql.slice(fromIndex + "FROM".length, targetEnd),
|
|
357
|
+
whereIndex,
|
|
358
|
+
params
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
function backupTimestamp(now) {
|
|
362
|
+
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
363
|
+
}
|
|
364
|
+
function backupHash(statementSql) {
|
|
365
|
+
return createHash("sha256").update(statementSql).update("\0").update(randomUUID()).digest("hex").slice(0, 12);
|
|
366
|
+
}
|
|
367
|
+
function cfHanaBackupRoot(saptoolsRoot) {
|
|
368
|
+
return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
369
|
+
}
|
|
370
|
+
function buildWriteBackupPlan(sql, params = []) {
|
|
371
|
+
const statementSql = trimStatementSql(sql);
|
|
372
|
+
const keyword = firstKeyword(statementSql);
|
|
373
|
+
if (keyword !== "UPDATE" && keyword !== "DELETE") {
|
|
374
|
+
return void 0;
|
|
375
|
+
}
|
|
376
|
+
assertParamArity(statementSql, params);
|
|
377
|
+
if (keyword === "UPDATE") {
|
|
378
|
+
return buildUpdateBackupPlan(statementSql, params);
|
|
379
|
+
}
|
|
380
|
+
return buildDeleteBackupPlan(statementSql, params);
|
|
381
|
+
}
|
|
382
|
+
async function writeSqlBackup(input, options = {}) {
|
|
383
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
384
|
+
const directory = join(
|
|
385
|
+
cfHanaBackupRoot(options.saptoolsRoot),
|
|
386
|
+
`${backupTimestamp(now)}-${input.operation}-${backupHash(input.statementSql)}`
|
|
387
|
+
);
|
|
388
|
+
const statementPath = join(directory, "statement.sql");
|
|
389
|
+
const backupPath = join(directory, "backup.csv");
|
|
390
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
391
|
+
await Promise.all([
|
|
392
|
+
writeFile(statementPath, `${input.statementSql}
|
|
393
|
+
`, { encoding: "utf8", mode: 384 }),
|
|
394
|
+
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 })
|
|
395
|
+
]);
|
|
396
|
+
return {
|
|
397
|
+
directory,
|
|
398
|
+
statementPath,
|
|
399
|
+
backupPath,
|
|
400
|
+
rowCount: input.result.rowCount
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
137
404
|
// src/builder.ts
|
|
138
405
|
function buildWhere(where) {
|
|
139
406
|
if (where === void 0) {
|
|
@@ -247,7 +514,7 @@ async function listColumns(connection, schema, table) {
|
|
|
247
514
|
|
|
248
515
|
// src/config.ts
|
|
249
516
|
var CLI_NAME = "cf-hana";
|
|
250
|
-
var CLI_VERSION = "0.1.
|
|
517
|
+
var CLI_VERSION = "0.1.4";
|
|
251
518
|
var ENV_PREFIX = "CF_HANA";
|
|
252
519
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
253
520
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -669,10 +936,85 @@ function createDriver(name) {
|
|
|
669
936
|
}
|
|
670
937
|
}
|
|
671
938
|
|
|
939
|
+
// src/history.ts
|
|
940
|
+
import { appendFile, mkdir as mkdir2, readdir, rm } from "fs/promises";
|
|
941
|
+
import { homedir as homedir2 } from "os";
|
|
942
|
+
import { join as join2 } from "path";
|
|
943
|
+
var SAPTOOLS_DIR_NAME2 = ".saptools";
|
|
944
|
+
var CF_HANA_DIR_NAME2 = "cf-hana";
|
|
945
|
+
var HISTORIES_DIR_NAME = "histories";
|
|
946
|
+
var HISTORY_RETENTION_DAYS = 5;
|
|
947
|
+
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
948
|
+
function defaultSaptoolsRoot2() {
|
|
949
|
+
return join2(homedir2(), SAPTOOLS_DIR_NAME2);
|
|
950
|
+
}
|
|
951
|
+
function padDatePart(value) {
|
|
952
|
+
return value.toString().padStart(2, "0");
|
|
953
|
+
}
|
|
954
|
+
function dateKey(date) {
|
|
955
|
+
return [
|
|
956
|
+
date.getFullYear().toString(),
|
|
957
|
+
padDatePart(date.getMonth() + 1),
|
|
958
|
+
padDatePart(date.getDate())
|
|
959
|
+
].join("-");
|
|
960
|
+
}
|
|
961
|
+
function retentionCutoffKey(now) {
|
|
962
|
+
const cutoff = new Date(now);
|
|
963
|
+
cutoff.setHours(0, 0, 0, 0);
|
|
964
|
+
cutoff.setDate(cutoff.getDate() - HISTORY_RETENTION_DAYS);
|
|
965
|
+
return dateKey(cutoff);
|
|
966
|
+
}
|
|
967
|
+
function resolveSaptoolsRoot(root) {
|
|
968
|
+
return root ?? defaultSaptoolsRoot2();
|
|
969
|
+
}
|
|
970
|
+
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
971
|
+
return join2(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
|
|
972
|
+
}
|
|
973
|
+
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
974
|
+
return join2(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
975
|
+
}
|
|
976
|
+
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
977
|
+
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
978
|
+
let files;
|
|
979
|
+
try {
|
|
980
|
+
files = await readdir(historyDir);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
if (error.code === "ENOENT") {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
throw error;
|
|
986
|
+
}
|
|
987
|
+
const cutoffKey = retentionCutoffKey(now);
|
|
988
|
+
await Promise.all(
|
|
989
|
+
files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
|
|
990
|
+
await rm(join2(historyDir, file), { force: true });
|
|
991
|
+
})
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
async function appendSqlHistory(input, options = {}) {
|
|
995
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
996
|
+
const historyDir = cfHanaHistoryDirectory(options.saptoolsRoot);
|
|
997
|
+
const entry = {
|
|
998
|
+
at: now.toISOString(),
|
|
999
|
+
...input
|
|
1000
|
+
};
|
|
1001
|
+
await mkdir2(historyDir, { recursive: true, mode: 448 });
|
|
1002
|
+
await appendFile(sqlHistoryFilePath(now, options.saptoolsRoot), `${JSON.stringify(entry)}
|
|
1003
|
+
`, {
|
|
1004
|
+
encoding: "utf8",
|
|
1005
|
+
mode: 384
|
|
1006
|
+
});
|
|
1007
|
+
try {
|
|
1008
|
+
await pruneSqlHistory(now, options.saptoolsRoot);
|
|
1009
|
+
} catch {
|
|
1010
|
+
}
|
|
1011
|
+
return entry;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
672
1014
|
// src/safety.ts
|
|
673
1015
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
674
1016
|
var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
675
|
-
function
|
|
1017
|
+
function skipQuotedText2(sql, start) {
|
|
676
1018
|
const quote = sql[start];
|
|
677
1019
|
let index = start + 1;
|
|
678
1020
|
while (index < sql.length) {
|
|
@@ -688,14 +1030,14 @@ function skipQuotedText(sql, start) {
|
|
|
688
1030
|
}
|
|
689
1031
|
return index;
|
|
690
1032
|
}
|
|
691
|
-
function
|
|
1033
|
+
function skipLineComment2(sql, start) {
|
|
692
1034
|
let index = start + 2;
|
|
693
1035
|
while (index < sql.length && sql[index] !== "\n") {
|
|
694
1036
|
index += 1;
|
|
695
1037
|
}
|
|
696
1038
|
return index;
|
|
697
1039
|
}
|
|
698
|
-
function
|
|
1040
|
+
function skipBlockComment2(sql, start) {
|
|
699
1041
|
let index = start + 2;
|
|
700
1042
|
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
701
1043
|
index += 1;
|
|
@@ -708,19 +1050,19 @@ function maskIgnoredSqlText(sql) {
|
|
|
708
1050
|
while (index < sql.length) {
|
|
709
1051
|
const char = sql[index];
|
|
710
1052
|
if (char === "'" || char === '"') {
|
|
711
|
-
const end =
|
|
1053
|
+
const end = skipQuotedText2(sql, index);
|
|
712
1054
|
masked += " ".repeat(end - index);
|
|
713
1055
|
index = end;
|
|
714
1056
|
continue;
|
|
715
1057
|
}
|
|
716
1058
|
if (char === "-" && sql[index + 1] === "-") {
|
|
717
|
-
const end =
|
|
1059
|
+
const end = skipLineComment2(sql, index);
|
|
718
1060
|
masked += " ".repeat(end - index);
|
|
719
1061
|
index = end;
|
|
720
1062
|
continue;
|
|
721
1063
|
}
|
|
722
1064
|
if (char === "/" && sql[index + 1] === "*") {
|
|
723
|
-
const end =
|
|
1065
|
+
const end = skipBlockComment2(sql, index);
|
|
724
1066
|
masked += " ".repeat(end - index);
|
|
725
1067
|
index = end;
|
|
726
1068
|
continue;
|
|
@@ -738,11 +1080,11 @@ function trailingLineCommentIndex(sql) {
|
|
|
738
1080
|
while (index < sql.length) {
|
|
739
1081
|
const char = sql[index];
|
|
740
1082
|
if (char === "'" || char === '"') {
|
|
741
|
-
index =
|
|
1083
|
+
index = skipQuotedText2(sql, index);
|
|
742
1084
|
continue;
|
|
743
1085
|
}
|
|
744
1086
|
if (char === "/" && sql[index + 1] === "*") {
|
|
745
|
-
index =
|
|
1087
|
+
index = skipBlockComment2(sql, index);
|
|
746
1088
|
continue;
|
|
747
1089
|
}
|
|
748
1090
|
if (char === "-" && sql[index + 1] === "-") {
|
|
@@ -1167,41 +1509,64 @@ var HanaClient = class _HanaClient {
|
|
|
1167
1509
|
}
|
|
1168
1510
|
/** Run a SELECT (or any read) statement and return typed rows. */
|
|
1169
1511
|
async query(sql, params, options) {
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
);
|
|
1512
|
+
const resolvedParams = params ?? [];
|
|
1513
|
+
const result = await this.runQuery(sql, resolvedParams, options);
|
|
1514
|
+
await this.recordSqlHistory("query", sql, resolvedParams, result);
|
|
1515
|
+
return result;
|
|
1173
1516
|
}
|
|
1174
1517
|
/** Run a DML/DDL statement and return its affected-row count. */
|
|
1175
1518
|
async execute(sql, params, options) {
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
);
|
|
1519
|
+
const resolvedParams = params ?? [];
|
|
1520
|
+
const result = await this.runExecute(sql, resolvedParams, options);
|
|
1521
|
+
await this.recordSqlHistory("execute", sql, resolvedParams, result);
|
|
1522
|
+
return result;
|
|
1523
|
+
}
|
|
1524
|
+
/** Back up rows matched by an UPDATE or DELETE before the caller runs it. */
|
|
1525
|
+
async backupWriteStatement(sql, params, options) {
|
|
1526
|
+
const resolvedParams = params ?? [];
|
|
1527
|
+
const plan = buildWriteBackupPlan(sql, resolvedParams);
|
|
1528
|
+
if (plan === void 0) {
|
|
1529
|
+
return void 0;
|
|
1530
|
+
}
|
|
1531
|
+
return await this.pool.withConnection(async (connection) => {
|
|
1532
|
+
connection.assertAllowed(plan.statementSql, options);
|
|
1533
|
+
const backupQueryOptions = {
|
|
1534
|
+
autoLimit: false,
|
|
1535
|
+
...options?.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
|
|
1536
|
+
};
|
|
1537
|
+
const result = await connection.query(plan.selectSql, plan.selectParams, backupQueryOptions);
|
|
1538
|
+
return await writeSqlBackup({
|
|
1539
|
+
operation: plan.operation,
|
|
1540
|
+
statementSql: plan.statementSql,
|
|
1541
|
+
result
|
|
1542
|
+
});
|
|
1543
|
+
});
|
|
1179
1544
|
}
|
|
1180
1545
|
/** Run a typed `SELECT` built from a spec. */
|
|
1181
1546
|
async selectFrom(spec) {
|
|
1182
1547
|
const built = buildSelect(spec);
|
|
1183
|
-
return await this.
|
|
1548
|
+
return await this.runQuery(built.sql, built.params);
|
|
1184
1549
|
}
|
|
1185
1550
|
/** Count rows in a table, optionally filtered. */
|
|
1186
1551
|
async count(spec) {
|
|
1187
1552
|
const built = buildCount(spec);
|
|
1188
|
-
const result = await this.
|
|
1553
|
+
const result = await this.runQuery(built.sql, built.params);
|
|
1189
1554
|
return result.rows[0]?.COUNT ?? 0;
|
|
1190
1555
|
}
|
|
1191
1556
|
/** Insert a single row. */
|
|
1192
1557
|
async insertInto(schema, table, values) {
|
|
1193
1558
|
const built = buildInsert(schema, table, values);
|
|
1194
|
-
return await this.
|
|
1559
|
+
return await this.runExecute(built.sql, built.params);
|
|
1195
1560
|
}
|
|
1196
1561
|
/** Update rows matching a non-empty `where` filter. */
|
|
1197
1562
|
async update(schema, table, values, where) {
|
|
1198
1563
|
const built = buildUpdate(schema, table, values, where);
|
|
1199
|
-
return await this.
|
|
1564
|
+
return await this.runExecute(built.sql, built.params);
|
|
1200
1565
|
}
|
|
1201
1566
|
/** Delete rows matching a non-empty `where` filter. */
|
|
1202
1567
|
async deleteFrom(schema, table, where) {
|
|
1203
1568
|
const built = buildDelete(schema, table, where);
|
|
1204
|
-
return await this.
|
|
1569
|
+
return await this.runExecute(built.sql, built.params);
|
|
1205
1570
|
}
|
|
1206
1571
|
/** Run `work` inside a transaction, auto-committing on success. */
|
|
1207
1572
|
async transaction(work) {
|
|
@@ -1280,6 +1645,35 @@ var HanaClient = class _HanaClient {
|
|
|
1280
1645
|
async close() {
|
|
1281
1646
|
await this.pool.drain();
|
|
1282
1647
|
}
|
|
1648
|
+
async runQuery(sql, params, options) {
|
|
1649
|
+
return await this.pool.withConnection(
|
|
1650
|
+
(connection) => connection.query(sql, params, options)
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
async runExecute(sql, params, options) {
|
|
1654
|
+
return await this.pool.withConnection(
|
|
1655
|
+
(connection) => connection.execute(sql, params, options)
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
async recordSqlHistory(operation, sql, params, result) {
|
|
1659
|
+
try {
|
|
1660
|
+
await appendSqlHistory({
|
|
1661
|
+
version: CLI_VERSION,
|
|
1662
|
+
operation,
|
|
1663
|
+
selector: this.info.selector,
|
|
1664
|
+
appName: this.info.appName,
|
|
1665
|
+
schema: this.info.schema,
|
|
1666
|
+
role: this.info.role,
|
|
1667
|
+
statement: result.statement,
|
|
1668
|
+
sql,
|
|
1669
|
+
paramCount: params.length,
|
|
1670
|
+
rowCount: result.rowCount,
|
|
1671
|
+
truncated: result.truncated,
|
|
1672
|
+
elapsedMs: result.elapsedMs
|
|
1673
|
+
});
|
|
1674
|
+
} catch {
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1283
1677
|
};
|
|
1284
1678
|
|
|
1285
1679
|
// src/api.ts
|
|
@@ -1287,91 +1681,6 @@ async function connect(selector, options) {
|
|
|
1287
1681
|
return await HanaClient.connect(selector, options);
|
|
1288
1682
|
}
|
|
1289
1683
|
|
|
1290
|
-
// src/format.ts
|
|
1291
|
-
function cellText(value, nullText) {
|
|
1292
|
-
if (value === null) {
|
|
1293
|
-
return nullText;
|
|
1294
|
-
}
|
|
1295
|
-
if (value instanceof Date) {
|
|
1296
|
-
return value.toISOString();
|
|
1297
|
-
}
|
|
1298
|
-
if (Buffer.isBuffer(value)) {
|
|
1299
|
-
return `0x${value.toString("hex")}`;
|
|
1300
|
-
}
|
|
1301
|
-
if (typeof value === "boolean") {
|
|
1302
|
-
return value ? "true" : "false";
|
|
1303
|
-
}
|
|
1304
|
-
return typeof value === "number" ? value.toString() : value;
|
|
1305
|
-
}
|
|
1306
|
-
function serializeCell(value) {
|
|
1307
|
-
if (value === null) {
|
|
1308
|
-
return null;
|
|
1309
|
-
}
|
|
1310
|
-
if (value instanceof Date) {
|
|
1311
|
-
return value.toISOString();
|
|
1312
|
-
}
|
|
1313
|
-
if (Buffer.isBuffer(value)) {
|
|
1314
|
-
return `0x${value.toString("hex")}`;
|
|
1315
|
-
}
|
|
1316
|
-
return value;
|
|
1317
|
-
}
|
|
1318
|
-
function csvEscape(text) {
|
|
1319
|
-
if (/[",\r\n]/.test(text)) {
|
|
1320
|
-
return `"${text.replace(/"/g, '""')}"`;
|
|
1321
|
-
}
|
|
1322
|
-
return text;
|
|
1323
|
-
}
|
|
1324
|
-
function formatTable(result) {
|
|
1325
|
-
if (result.columns.length === 0) {
|
|
1326
|
-
return `(${String(result.rowCount)} row(s) affected)`;
|
|
1327
|
-
}
|
|
1328
|
-
const headers = result.columns.map((column) => column.name);
|
|
1329
|
-
const rows = result.rows.map(
|
|
1330
|
-
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
|
|
1331
|
-
);
|
|
1332
|
-
const widths = headers.map((header, index) => {
|
|
1333
|
-
const widest = rows.reduce(
|
|
1334
|
-
(max, cells) => Math.max(max, (cells[index] ?? "").length),
|
|
1335
|
-
header.length
|
|
1336
|
-
);
|
|
1337
|
-
return widest;
|
|
1338
|
-
});
|
|
1339
|
-
const renderRow = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" | ");
|
|
1340
|
-
const separator = widths.map((width) => "-".repeat(width)).join("-+-");
|
|
1341
|
-
const body = rows.map((cells) => renderRow(cells));
|
|
1342
|
-
return [renderRow(headers), separator, ...body].join("\n");
|
|
1343
|
-
}
|
|
1344
|
-
function formatJson(result) {
|
|
1345
|
-
const rows = result.rows.map((row) => {
|
|
1346
|
-
const serialized = {};
|
|
1347
|
-
for (const [key, value] of Object.entries(row)) {
|
|
1348
|
-
serialized[key] = serializeCell(value);
|
|
1349
|
-
}
|
|
1350
|
-
return serialized;
|
|
1351
|
-
});
|
|
1352
|
-
return JSON.stringify(rows, null, 2);
|
|
1353
|
-
}
|
|
1354
|
-
function formatCsv(result) {
|
|
1355
|
-
const headers = result.columns.map((column) => column.name);
|
|
1356
|
-
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
1357
|
-
for (const row of result.rows) {
|
|
1358
|
-
lines.push(
|
|
1359
|
-
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
|
|
1360
|
-
);
|
|
1361
|
-
}
|
|
1362
|
-
return lines.join("\r\n");
|
|
1363
|
-
}
|
|
1364
|
-
function formatResult(result, format) {
|
|
1365
|
-
switch (format) {
|
|
1366
|
-
case "table":
|
|
1367
|
-
return formatTable(result);
|
|
1368
|
-
case "json":
|
|
1369
|
-
return formatJson(result);
|
|
1370
|
-
case "csv":
|
|
1371
|
-
return formatCsv(result);
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
1684
|
// src/cli.ts
|
|
1376
1685
|
function print(text) {
|
|
1377
1686
|
process.stdout.write(`${text}
|
|
@@ -1470,7 +1779,13 @@ async function runQuery(selector, sql, command) {
|
|
|
1470
1779
|
const opts = command.opts();
|
|
1471
1780
|
const client = await connect(selector, toConnectOptions(opts));
|
|
1472
1781
|
try {
|
|
1473
|
-
const
|
|
1782
|
+
const params = opts.param ?? [];
|
|
1783
|
+
const backup = opts.backup ? await client.backupWriteStatement(sql, params) : void 0;
|
|
1784
|
+
if (backup !== void 0) {
|
|
1785
|
+
process.stderr.write(`${CLI_NAME}: backup saved to ${backup.directory}
|
|
1786
|
+
`);
|
|
1787
|
+
}
|
|
1788
|
+
const result = await client.query(sql, params);
|
|
1474
1789
|
print(formatResult(result, parseFormat(opts.format)));
|
|
1475
1790
|
} finally {
|
|
1476
1791
|
await client.close();
|
|
@@ -1546,7 +1861,7 @@ function buildProgram() {
|
|
|
1546
1861
|
const program = new Command();
|
|
1547
1862
|
program.name(CLI_NAME).description("Run SQL against SAP HANA Cloud databases bound to a Cloud Foundry app").version(CLI_VERSION);
|
|
1548
1863
|
withConnectionOptions(
|
|
1549
|
-
program.command("query <selector> <sql>").description("run a single SQL statement").option("--param <value>", "bind a SQL parameter (repeatable)", collectParam, [])
|
|
1864
|
+
program.command("query <selector> <sql>").description("run a single SQL statement").option("--param <value>", "bind a SQL parameter (repeatable)", collectParam, []).option("--no-backup", "skip the local CSV backup before UPDATE or DELETE")
|
|
1550
1865
|
).action(async (selector, sql, _options, command) => {
|
|
1551
1866
|
await runQuery(selector, sql, command);
|
|
1552
1867
|
});
|