@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/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.2";
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 skipQuotedText(sql, start) {
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 skipLineComment(sql, start) {
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 skipBlockComment(sql, start) {
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 = skipQuotedText(sql, index);
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 = skipLineComment(sql, index);
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 = skipBlockComment(sql, index);
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 = skipQuotedText(sql, index);
1083
+ index = skipQuotedText2(sql, index);
742
1084
  continue;
743
1085
  }
744
1086
  if (char === "/" && sql[index + 1] === "*") {
745
- index = skipBlockComment(sql, 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
- return await this.pool.withConnection(
1171
- (connection) => connection.query(sql, params, options)
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
- return await this.pool.withConnection(
1177
- (connection) => connection.execute(sql, params, options)
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.query(built.sql, built.params);
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.query(built.sql, built.params);
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.execute(built.sql, built.params);
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.execute(built.sql, built.params);
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.execute(built.sql, built.params);
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 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);
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
  });