@saptools/cf-hana 0.1.3 → 0.1.5
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 +16 -0
- package/README.md +30 -2
- package/dist/cli.js +316 -107
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +313 -107
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.5 - 2026-06-23
|
|
4
|
+
|
|
5
|
+
- Remove the CLI `--no-backup` opt-out so `cf-hana query` always attempts a
|
|
6
|
+
local backup before running `UPDATE` or `DELETE`.
|
|
7
|
+
- Keep backup paths on stderr and keep stdout parseable for table, JSON, and CSV
|
|
8
|
+
output.
|
|
9
|
+
|
|
10
|
+
## 0.1.4 - 2026-06-23
|
|
11
|
+
|
|
12
|
+
- Add automatic local CSV backups before CLI `query` runs an `UPDATE` or
|
|
13
|
+
`DELETE`.
|
|
14
|
+
- Derive the backup `SELECT` from the write target and top-level `WHERE`
|
|
15
|
+
clause, preserving only the WHERE parameters for `UPDATE` statements.
|
|
16
|
+
- Save each backup in its own non-expiring folder with `statement.sql` and
|
|
17
|
+
`backup.csv`.
|
|
18
|
+
|
|
3
19
|
## 0.1.3 - 2026-06-23
|
|
4
20
|
|
|
5
21
|
- Add local SQL history for successful direct `query` and `execute` calls under
|
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ login on the hot path) and connections are pooled and reused within a process.
|
|
|
24
24
|
- **Schema introspection** — list schemas, tables, and columns.
|
|
25
25
|
- **Local SQL history** — direct SQL calls are appended to dated JSONL files
|
|
26
26
|
under `~/.saptools/cf-hana/histories/` with five-day retention.
|
|
27
|
+
- **Write backups** — CLI `UPDATE` and `DELETE` statements create a local CSV
|
|
28
|
+
backup of matching rows before the write runs.
|
|
27
29
|
- **Safety guard** — opt-in read-only mode and a destructive-statement guard
|
|
28
30
|
(blocks `DROP`/`TRUNCATE`/`ALTER` and unscoped `UPDATE`/`DELETE`).
|
|
29
31
|
- **Typed results** — `query<TRow>()` returns typed rows.
|
|
@@ -86,11 +88,14 @@ cf-hana info <selector> Print the resolved connection metada
|
|
|
86
88
|
Common options: `--format <table|json|csv>`, `--refresh`, `--role <runtime|hdi>`,
|
|
87
89
|
`--binding <name>` / `--binding-index <n>`, `--timeout <ms>`, `--read-only`,
|
|
88
90
|
`--allow-destructive`, `--limit <n>`, `--no-auto-limit`. The `query` command also
|
|
89
|
-
accepts `--param <value>` (repeatable) to bind `?` placeholders.
|
|
91
|
+
accepts `--param <value>` (repeatable) to bind `?` placeholders. CLI `UPDATE`
|
|
92
|
+
and `DELETE` statements are backed up automatically before the write runs.
|
|
90
93
|
|
|
91
94
|
```bash
|
|
92
95
|
cf-hana query eu10/example-org/space-demo/app-demo "SELECT ID, STATUS FROM ORDERS WHERE STATUS = ?" \
|
|
93
96
|
--param OPEN --format json
|
|
97
|
+
cf-hana query app-demo "UPDATE ORDERS SET STATUS = ? WHERE ID = ?" \
|
|
98
|
+
--param DONE --param 42 --format json
|
|
94
99
|
cf-hana tables app-demo
|
|
95
100
|
cf-hana columns app-demo ORDERS_APP.ORDERS
|
|
96
101
|
cf-hana ping eu10/example-org/space-demo/app-demo
|
|
@@ -103,7 +108,7 @@ cf-hana ping eu10/example-org/space-demo/app-demo
|
|
|
103
108
|
| `connect(selector, options?)` | Open a reusable, pooled `HanaClient`. |
|
|
104
109
|
| `query(selector, sql, params?, options?)` | One-shot: connect, query, close. |
|
|
105
110
|
| `withConnection(selector, work, options?)` | Run `work` with a client that auto-closes. |
|
|
106
|
-
| `HanaClient` | `query`, `execute`, `selectFrom`, `count`, `insertInto`, `update`, `deleteFrom`, `transaction`, `listSchemas`, `listTables`, `listColumns`, `explain`, `close`. |
|
|
111
|
+
| `HanaClient` | `query`, `execute`, `backupWriteStatement`, `selectFrom`, `count`, `insertInto`, `update`, `deleteFrom`, `transaction`, `listSchemas`, `listTables`, `listColumns`, `explain`, `close`. |
|
|
107
112
|
| `createDriver`, `formatResult`, `build*` | Lower-level building blocks. |
|
|
108
113
|
|
|
109
114
|
`ConnectOptions` highlights: `role` (`runtime` | `hdi`), `bindingName` /
|
|
@@ -141,6 +146,29 @@ History retention runs opportunistically after each append and deletes dated
|
|
|
141
146
|
history files older than five days. Helper-driven catalog SQL such as `tables`
|
|
142
147
|
and `columns` is not recorded as user SQL history.
|
|
143
148
|
|
|
149
|
+
## Write backups
|
|
150
|
+
|
|
151
|
+
When `cf-hana query` receives an `UPDATE` or `DELETE`, it first builds and runs a
|
|
152
|
+
matching `SELECT`:
|
|
153
|
+
|
|
154
|
+
- `UPDATE <target> SET ... WHERE ...` becomes
|
|
155
|
+
`SELECT * FROM <target> WHERE ...`.
|
|
156
|
+
- `DELETE FROM <target> WHERE ...` becomes
|
|
157
|
+
`SELECT * FROM <target> WHERE ...`.
|
|
158
|
+
|
|
159
|
+
The backup is saved before the write runs:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
~/.saptools/cf-hana/backups/<timestamp>-<operation>-<hash>/
|
|
163
|
+
statement.sql
|
|
164
|
+
backup.csv
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`statement.sql` contains the original write statement. `backup.csv` contains the
|
|
168
|
+
rows returned by the derived `SELECT`. Backup folders are not deleted by
|
|
169
|
+
`cf-hana`; clean them up manually when they are no longer needed. The backup path
|
|
170
|
+
is printed to stderr so JSON and CSV stdout remain parseable.
|
|
171
|
+
|
|
144
172
|
## Safety
|
|
145
173
|
|
|
146
174
|
- **Read-only mode** (`readOnly` / `--read-only`) rejects every DML and DDL statement.
|
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.5";
|
|
251
518
|
var ENV_PREFIX = "CF_HANA";
|
|
252
519
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
253
520
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -670,16 +937,16 @@ function createDriver(name) {
|
|
|
670
937
|
}
|
|
671
938
|
|
|
672
939
|
// src/history.ts
|
|
673
|
-
import { appendFile, mkdir, readdir, rm } from "fs/promises";
|
|
674
|
-
import { homedir } from "os";
|
|
675
|
-
import { join } from "path";
|
|
676
|
-
var
|
|
677
|
-
var
|
|
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";
|
|
678
945
|
var HISTORIES_DIR_NAME = "histories";
|
|
679
946
|
var HISTORY_RETENTION_DAYS = 5;
|
|
680
947
|
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
681
|
-
function
|
|
682
|
-
return
|
|
948
|
+
function defaultSaptoolsRoot2() {
|
|
949
|
+
return join2(homedir2(), SAPTOOLS_DIR_NAME2);
|
|
683
950
|
}
|
|
684
951
|
function padDatePart(value) {
|
|
685
952
|
return value.toString().padStart(2, "0");
|
|
@@ -698,13 +965,13 @@ function retentionCutoffKey(now) {
|
|
|
698
965
|
return dateKey(cutoff);
|
|
699
966
|
}
|
|
700
967
|
function resolveSaptoolsRoot(root) {
|
|
701
|
-
return root ??
|
|
968
|
+
return root ?? defaultSaptoolsRoot2();
|
|
702
969
|
}
|
|
703
970
|
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
704
|
-
return
|
|
971
|
+
return join2(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
|
|
705
972
|
}
|
|
706
973
|
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
707
|
-
return
|
|
974
|
+
return join2(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
708
975
|
}
|
|
709
976
|
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
710
977
|
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
@@ -720,7 +987,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
|
|
|
720
987
|
const cutoffKey = retentionCutoffKey(now);
|
|
721
988
|
await Promise.all(
|
|
722
989
|
files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
|
|
723
|
-
await rm(
|
|
990
|
+
await rm(join2(historyDir, file), { force: true });
|
|
724
991
|
})
|
|
725
992
|
);
|
|
726
993
|
}
|
|
@@ -731,7 +998,7 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
731
998
|
at: now.toISOString(),
|
|
732
999
|
...input
|
|
733
1000
|
};
|
|
734
|
-
await
|
|
1001
|
+
await mkdir2(historyDir, { recursive: true, mode: 448 });
|
|
735
1002
|
await appendFile(sqlHistoryFilePath(now, options.saptoolsRoot), `${JSON.stringify(entry)}
|
|
736
1003
|
`, {
|
|
737
1004
|
encoding: "utf8",
|
|
@@ -747,7 +1014,7 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
747
1014
|
// src/safety.ts
|
|
748
1015
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
749
1016
|
var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
750
|
-
function
|
|
1017
|
+
function skipQuotedText2(sql, start) {
|
|
751
1018
|
const quote = sql[start];
|
|
752
1019
|
let index = start + 1;
|
|
753
1020
|
while (index < sql.length) {
|
|
@@ -763,14 +1030,14 @@ function skipQuotedText(sql, start) {
|
|
|
763
1030
|
}
|
|
764
1031
|
return index;
|
|
765
1032
|
}
|
|
766
|
-
function
|
|
1033
|
+
function skipLineComment2(sql, start) {
|
|
767
1034
|
let index = start + 2;
|
|
768
1035
|
while (index < sql.length && sql[index] !== "\n") {
|
|
769
1036
|
index += 1;
|
|
770
1037
|
}
|
|
771
1038
|
return index;
|
|
772
1039
|
}
|
|
773
|
-
function
|
|
1040
|
+
function skipBlockComment2(sql, start) {
|
|
774
1041
|
let index = start + 2;
|
|
775
1042
|
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
776
1043
|
index += 1;
|
|
@@ -783,19 +1050,19 @@ function maskIgnoredSqlText(sql) {
|
|
|
783
1050
|
while (index < sql.length) {
|
|
784
1051
|
const char = sql[index];
|
|
785
1052
|
if (char === "'" || char === '"') {
|
|
786
|
-
const end =
|
|
1053
|
+
const end = skipQuotedText2(sql, index);
|
|
787
1054
|
masked += " ".repeat(end - index);
|
|
788
1055
|
index = end;
|
|
789
1056
|
continue;
|
|
790
1057
|
}
|
|
791
1058
|
if (char === "-" && sql[index + 1] === "-") {
|
|
792
|
-
const end =
|
|
1059
|
+
const end = skipLineComment2(sql, index);
|
|
793
1060
|
masked += " ".repeat(end - index);
|
|
794
1061
|
index = end;
|
|
795
1062
|
continue;
|
|
796
1063
|
}
|
|
797
1064
|
if (char === "/" && sql[index + 1] === "*") {
|
|
798
|
-
const end =
|
|
1065
|
+
const end = skipBlockComment2(sql, index);
|
|
799
1066
|
masked += " ".repeat(end - index);
|
|
800
1067
|
index = end;
|
|
801
1068
|
continue;
|
|
@@ -813,11 +1080,11 @@ function trailingLineCommentIndex(sql) {
|
|
|
813
1080
|
while (index < sql.length) {
|
|
814
1081
|
const char = sql[index];
|
|
815
1082
|
if (char === "'" || char === '"') {
|
|
816
|
-
index =
|
|
1083
|
+
index = skipQuotedText2(sql, index);
|
|
817
1084
|
continue;
|
|
818
1085
|
}
|
|
819
1086
|
if (char === "/" && sql[index + 1] === "*") {
|
|
820
|
-
index =
|
|
1087
|
+
index = skipBlockComment2(sql, index);
|
|
821
1088
|
continue;
|
|
822
1089
|
}
|
|
823
1090
|
if (char === "-" && sql[index + 1] === "-") {
|
|
@@ -1254,6 +1521,27 @@ var HanaClient = class _HanaClient {
|
|
|
1254
1521
|
await this.recordSqlHistory("execute", sql, resolvedParams, result);
|
|
1255
1522
|
return result;
|
|
1256
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
|
+
});
|
|
1544
|
+
}
|
|
1257
1545
|
/** Run a typed `SELECT` built from a spec. */
|
|
1258
1546
|
async selectFrom(spec) {
|
|
1259
1547
|
const built = buildSelect(spec);
|
|
@@ -1393,91 +1681,6 @@ async function connect(selector, options) {
|
|
|
1393
1681
|
return await HanaClient.connect(selector, options);
|
|
1394
1682
|
}
|
|
1395
1683
|
|
|
1396
|
-
// src/format.ts
|
|
1397
|
-
function cellText(value, nullText) {
|
|
1398
|
-
if (value === null) {
|
|
1399
|
-
return nullText;
|
|
1400
|
-
}
|
|
1401
|
-
if (value instanceof Date) {
|
|
1402
|
-
return value.toISOString();
|
|
1403
|
-
}
|
|
1404
|
-
if (Buffer.isBuffer(value)) {
|
|
1405
|
-
return `0x${value.toString("hex")}`;
|
|
1406
|
-
}
|
|
1407
|
-
if (typeof value === "boolean") {
|
|
1408
|
-
return value ? "true" : "false";
|
|
1409
|
-
}
|
|
1410
|
-
return typeof value === "number" ? value.toString() : value;
|
|
1411
|
-
}
|
|
1412
|
-
function serializeCell(value) {
|
|
1413
|
-
if (value === null) {
|
|
1414
|
-
return null;
|
|
1415
|
-
}
|
|
1416
|
-
if (value instanceof Date) {
|
|
1417
|
-
return value.toISOString();
|
|
1418
|
-
}
|
|
1419
|
-
if (Buffer.isBuffer(value)) {
|
|
1420
|
-
return `0x${value.toString("hex")}`;
|
|
1421
|
-
}
|
|
1422
|
-
return value;
|
|
1423
|
-
}
|
|
1424
|
-
function csvEscape(text) {
|
|
1425
|
-
if (/[",\r\n]/.test(text)) {
|
|
1426
|
-
return `"${text.replace(/"/g, '""')}"`;
|
|
1427
|
-
}
|
|
1428
|
-
return text;
|
|
1429
|
-
}
|
|
1430
|
-
function formatTable(result) {
|
|
1431
|
-
if (result.columns.length === 0) {
|
|
1432
|
-
return `(${String(result.rowCount)} row(s) affected)`;
|
|
1433
|
-
}
|
|
1434
|
-
const headers = result.columns.map((column) => column.name);
|
|
1435
|
-
const rows = result.rows.map(
|
|
1436
|
-
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
|
|
1437
|
-
);
|
|
1438
|
-
const widths = headers.map((header, index) => {
|
|
1439
|
-
const widest = rows.reduce(
|
|
1440
|
-
(max, cells) => Math.max(max, (cells[index] ?? "").length),
|
|
1441
|
-
header.length
|
|
1442
|
-
);
|
|
1443
|
-
return widest;
|
|
1444
|
-
});
|
|
1445
|
-
const renderRow = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" | ");
|
|
1446
|
-
const separator = widths.map((width) => "-".repeat(width)).join("-+-");
|
|
1447
|
-
const body = rows.map((cells) => renderRow(cells));
|
|
1448
|
-
return [renderRow(headers), separator, ...body].join("\n");
|
|
1449
|
-
}
|
|
1450
|
-
function formatJson(result) {
|
|
1451
|
-
const rows = result.rows.map((row) => {
|
|
1452
|
-
const serialized = {};
|
|
1453
|
-
for (const [key, value] of Object.entries(row)) {
|
|
1454
|
-
serialized[key] = serializeCell(value);
|
|
1455
|
-
}
|
|
1456
|
-
return serialized;
|
|
1457
|
-
});
|
|
1458
|
-
return JSON.stringify(rows, null, 2);
|
|
1459
|
-
}
|
|
1460
|
-
function formatCsv(result) {
|
|
1461
|
-
const headers = result.columns.map((column) => column.name);
|
|
1462
|
-
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
1463
|
-
for (const row of result.rows) {
|
|
1464
|
-
lines.push(
|
|
1465
|
-
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
|
|
1466
|
-
);
|
|
1467
|
-
}
|
|
1468
|
-
return lines.join("\r\n");
|
|
1469
|
-
}
|
|
1470
|
-
function formatResult(result, format) {
|
|
1471
|
-
switch (format) {
|
|
1472
|
-
case "table":
|
|
1473
|
-
return formatTable(result);
|
|
1474
|
-
case "json":
|
|
1475
|
-
return formatJson(result);
|
|
1476
|
-
case "csv":
|
|
1477
|
-
return formatCsv(result);
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
1684
|
// src/cli.ts
|
|
1482
1685
|
function print(text) {
|
|
1483
1686
|
process.stdout.write(`${text}
|
|
@@ -1576,7 +1779,13 @@ async function runQuery(selector, sql, command) {
|
|
|
1576
1779
|
const opts = command.opts();
|
|
1577
1780
|
const client = await connect(selector, toConnectOptions(opts));
|
|
1578
1781
|
try {
|
|
1579
|
-
const
|
|
1782
|
+
const params = opts.param ?? [];
|
|
1783
|
+
const backup = await client.backupWriteStatement(sql, params);
|
|
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);
|
|
1580
1789
|
print(formatResult(result, parseFormat(opts.format)));
|
|
1581
1790
|
} finally {
|
|
1582
1791
|
await client.close();
|