@saptools/cf-hana 0.1.3 → 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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 - 2026-06-23
4
+
5
+ - Add automatic local CSV backups before CLI `query` runs an `UPDATE` or
6
+ `DELETE`.
7
+ - Derive the backup `SELECT` from the write target and top-level `WHERE`
8
+ clause, preserving only the WHERE parameters for `UPDATE` statements.
9
+ - Save each backup in its own non-expiring folder with `statement.sql` and
10
+ `backup.csv`, and add `--no-backup` for explicit CLI opt-out.
11
+
3
12
  ## 0.1.3 - 2026-06-23
4
13
 
5
14
  - 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 and
92
+ `--no-backup` to skip the default write backup.
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,36 @@ 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
+
172
+ Use `--no-backup` when an external backup or transaction workflow already covers
173
+ the write:
174
+
175
+ ```bash
176
+ cf-hana query app-demo "DELETE FROM ORDERS WHERE ID = ?" --param 42 --no-backup
177
+ ```
178
+
144
179
  ## Safety
145
180
 
146
181
  - **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.3";
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;
@@ -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 SAPTOOLS_DIR_NAME = ".saptools";
677
- var CF_HANA_DIR_NAME = "cf-hana";
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 defaultSaptoolsRoot() {
682
- return join(homedir(), SAPTOOLS_DIR_NAME);
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 ?? defaultSaptoolsRoot();
968
+ return root ?? defaultSaptoolsRoot2();
702
969
  }
703
970
  function cfHanaHistoryDirectory(saptoolsRoot) {
704
- return join(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME, HISTORIES_DIR_NAME);
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 join(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
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(join(historyDir, file), { force: true });
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 mkdir(historyDir, { recursive: true, mode: 448 });
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 skipQuotedText(sql, start) {
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 skipLineComment(sql, start) {
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 skipBlockComment(sql, start) {
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 = skipQuotedText(sql, index);
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 = skipLineComment(sql, index);
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 = skipBlockComment(sql, index);
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 = skipQuotedText(sql, index);
1083
+ index = skipQuotedText2(sql, index);
817
1084
  continue;
818
1085
  }
819
1086
  if (char === "/" && sql[index + 1] === "*") {
820
- index = skipBlockComment(sql, 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 result = await client.query(sql, opts.param ?? []);
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);
1580
1789
  print(formatResult(result, parseFormat(opts.format)));
1581
1790
  } finally {
1582
1791
  await client.close();
@@ -1652,7 +1861,7 @@ function buildProgram() {
1652
1861
  const program = new Command();
1653
1862
  program.name(CLI_NAME).description("Run SQL against SAP HANA Cloud databases bound to a Cloud Foundry app").version(CLI_VERSION);
1654
1863
  withConnectionOptions(
1655
- 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")
1656
1865
  ).action(async (selector, sql, _options, command) => {
1657
1866
  await runQuery(selector, sql, command);
1658
1867
  });