@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/dist/index.d.ts
CHANGED
|
@@ -96,6 +96,32 @@ interface HanaClientInfo {
|
|
|
96
96
|
readonly credentialSource: CredentialSource;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
type WriteBackupOperation = "update" | "delete";
|
|
100
|
+
interface WriteBackupPlan {
|
|
101
|
+
readonly operation: WriteBackupOperation;
|
|
102
|
+
readonly statementSql: string;
|
|
103
|
+
readonly selectSql: string;
|
|
104
|
+
readonly selectParams: readonly SqlParam[];
|
|
105
|
+
}
|
|
106
|
+
interface SqlBackupWriteInput {
|
|
107
|
+
readonly operation: WriteBackupOperation;
|
|
108
|
+
readonly statementSql: string;
|
|
109
|
+
readonly result: QueryResult;
|
|
110
|
+
}
|
|
111
|
+
interface SqlBackupWriteOptions {
|
|
112
|
+
readonly now?: Date;
|
|
113
|
+
readonly saptoolsRoot?: string;
|
|
114
|
+
}
|
|
115
|
+
interface SqlBackupRecord {
|
|
116
|
+
readonly directory: string;
|
|
117
|
+
readonly statementPath: string;
|
|
118
|
+
readonly backupPath: string;
|
|
119
|
+
readonly rowCount: number;
|
|
120
|
+
}
|
|
121
|
+
declare function cfHanaBackupRoot(saptoolsRoot?: string): string;
|
|
122
|
+
declare function buildWriteBackupPlan(sql: string, params?: readonly SqlParam[]): WriteBackupPlan | undefined;
|
|
123
|
+
declare function writeSqlBackup(input: SqlBackupWriteInput, options?: SqlBackupWriteOptions): Promise<SqlBackupRecord>;
|
|
124
|
+
|
|
99
125
|
interface DriverExecResult {
|
|
100
126
|
readonly rows: readonly Record<string, SqlParam>[];
|
|
101
127
|
readonly columns: readonly QueryResultColumn[];
|
|
@@ -219,6 +245,8 @@ declare class HanaClient {
|
|
|
219
245
|
query<TRow = QueryRow>(sql: string, params?: readonly SqlParam[], options?: QueryOptions): Promise<QueryResult<TRow>>;
|
|
220
246
|
/** Run a DML/DDL statement and return its affected-row count. */
|
|
221
247
|
execute(sql: string, params?: readonly SqlParam[], options?: QueryOptions): Promise<QueryResult>;
|
|
248
|
+
/** Back up rows matched by an UPDATE or DELETE before the caller runs it. */
|
|
249
|
+
backupWriteStatement(sql: string, params?: readonly SqlParam[], options?: QueryOptions): Promise<SqlBackupRecord | undefined>;
|
|
222
250
|
/** Run a typed `SELECT` built from a spec. */
|
|
223
251
|
selectFrom<TRow = QueryRow>(spec: SelectSpec): Promise<QueryResult<TRow>>;
|
|
224
252
|
/** Count rows in a table, optionally filtered. */
|
|
@@ -316,4 +344,4 @@ declare class DestructiveStatementError extends CfHanaError {
|
|
|
316
344
|
/** Narrow an unknown thrown value to a human-readable message. */
|
|
317
345
|
declare function errorMessage(error: unknown): string;
|
|
318
346
|
|
|
319
|
-
export { type BuiltStatement, CfHanaError, type CfHanaErrorCode, type ColumnInfo, type ConnectOptions, type CredentialSource, CredentialsNotFoundError, type DbUserRole, DestructiveStatementError, type DriverConnectParams, type DriverConnection, type DriverExecResult, HanaClient, type HanaClientInfo, type HanaDriver, type OutputFormat, type PoolOptions, QueryError, type QueryOptions, type QueryResult, type QueryResultColumn, type QueryRow, ReadOnlyViolationError, type SelectSpec, type SqlParam, type StatementKind, type TableInfo, Transaction, buildCount, buildDelete, buildInsert, buildSelect, buildUpdate, connect, createDriver, errorMessage, formatCsv, formatJson, formatResult, formatTable, query, withConnection };
|
|
347
|
+
export { type BuiltStatement, CfHanaError, type CfHanaErrorCode, type ColumnInfo, type ConnectOptions, type CredentialSource, CredentialsNotFoundError, type DbUserRole, DestructiveStatementError, type DriverConnectParams, type DriverConnection, type DriverExecResult, HanaClient, type HanaClientInfo, type HanaDriver, type OutputFormat, type PoolOptions, QueryError, type QueryOptions, type QueryResult, type QueryResultColumn, type QueryRow, ReadOnlyViolationError, type SelectSpec, type SqlBackupRecord, type SqlBackupWriteInput, type SqlBackupWriteOptions, type SqlParam, type StatementKind, type TableInfo, Transaction, type WriteBackupOperation, type WriteBackupPlan, buildCount, buildDelete, buildInsert, buildSelect, buildUpdate, buildWriteBackupPlan, cfHanaBackupRoot, connect, createDriver, errorMessage, formatCsv, formatJson, formatResult, formatTable, query, withConnection, writeSqlBackup };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/backup.ts
|
|
4
|
+
import { createHash, randomUUID } from "crypto";
|
|
5
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
|
|
3
9
|
// src/errors.ts
|
|
4
10
|
var CfHanaError = class extends Error {
|
|
5
11
|
code;
|
|
@@ -43,6 +49,91 @@ function errorMessage(error) {
|
|
|
43
49
|
return String(error);
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
// src/format.ts
|
|
53
|
+
function cellText(value, nullText) {
|
|
54
|
+
if (value === null) {
|
|
55
|
+
return nullText;
|
|
56
|
+
}
|
|
57
|
+
if (value instanceof Date) {
|
|
58
|
+
return value.toISOString();
|
|
59
|
+
}
|
|
60
|
+
if (Buffer.isBuffer(value)) {
|
|
61
|
+
return `0x${value.toString("hex")}`;
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === "boolean") {
|
|
64
|
+
return value ? "true" : "false";
|
|
65
|
+
}
|
|
66
|
+
return typeof value === "number" ? value.toString() : value;
|
|
67
|
+
}
|
|
68
|
+
function serializeCell(value) {
|
|
69
|
+
if (value === null) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (value instanceof Date) {
|
|
73
|
+
return value.toISOString();
|
|
74
|
+
}
|
|
75
|
+
if (Buffer.isBuffer(value)) {
|
|
76
|
+
return `0x${value.toString("hex")}`;
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function csvEscape(text) {
|
|
81
|
+
if (/[",\r\n]/.test(text)) {
|
|
82
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
83
|
+
}
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
function formatTable(result) {
|
|
87
|
+
if (result.columns.length === 0) {
|
|
88
|
+
return `(${String(result.rowCount)} row(s) affected)`;
|
|
89
|
+
}
|
|
90
|
+
const headers = result.columns.map((column) => column.name);
|
|
91
|
+
const rows = result.rows.map(
|
|
92
|
+
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
|
|
93
|
+
);
|
|
94
|
+
const widths = headers.map((header, index) => {
|
|
95
|
+
const widest = rows.reduce(
|
|
96
|
+
(max, cells) => Math.max(max, (cells[index] ?? "").length),
|
|
97
|
+
header.length
|
|
98
|
+
);
|
|
99
|
+
return widest;
|
|
100
|
+
});
|
|
101
|
+
const renderRow = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" | ");
|
|
102
|
+
const separator = widths.map((width) => "-".repeat(width)).join("-+-");
|
|
103
|
+
const body = rows.map((cells) => renderRow(cells));
|
|
104
|
+
return [renderRow(headers), separator, ...body].join("\n");
|
|
105
|
+
}
|
|
106
|
+
function formatJson(result) {
|
|
107
|
+
const rows = result.rows.map((row) => {
|
|
108
|
+
const serialized = {};
|
|
109
|
+
for (const [key, value] of Object.entries(row)) {
|
|
110
|
+
serialized[key] = serializeCell(value);
|
|
111
|
+
}
|
|
112
|
+
return serialized;
|
|
113
|
+
});
|
|
114
|
+
return JSON.stringify(rows, null, 2);
|
|
115
|
+
}
|
|
116
|
+
function formatCsv(result) {
|
|
117
|
+
const headers = result.columns.map((column) => column.name);
|
|
118
|
+
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
119
|
+
for (const row of result.rows) {
|
|
120
|
+
lines.push(
|
|
121
|
+
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return lines.join("\r\n");
|
|
125
|
+
}
|
|
126
|
+
function formatResult(result, format) {
|
|
127
|
+
switch (format) {
|
|
128
|
+
case "table":
|
|
129
|
+
return formatTable(result);
|
|
130
|
+
case "json":
|
|
131
|
+
return formatJson(result);
|
|
132
|
+
case "csv":
|
|
133
|
+
return formatCsv(result);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
46
137
|
// src/statements.ts
|
|
47
138
|
var LEADING_NOISE = /^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/;
|
|
48
139
|
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
|
|
@@ -131,6 +222,182 @@ function assertParamArity(sql, params) {
|
|
|
131
222
|
}
|
|
132
223
|
}
|
|
133
224
|
|
|
225
|
+
// src/backup.ts
|
|
226
|
+
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
227
|
+
var CF_HANA_DIR_NAME = "cf-hana";
|
|
228
|
+
var BACKUPS_DIR_NAME = "backups";
|
|
229
|
+
function defaultSaptoolsRoot() {
|
|
230
|
+
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
231
|
+
}
|
|
232
|
+
function trimStatementSql(sql) {
|
|
233
|
+
return sql.trim().replace(/;+\s*$/, "").trim();
|
|
234
|
+
}
|
|
235
|
+
function isIdentifierChar(char) {
|
|
236
|
+
return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
|
|
237
|
+
}
|
|
238
|
+
function skipQuotedText(sql, index) {
|
|
239
|
+
const quote = sql[index];
|
|
240
|
+
let cursor = index + 1;
|
|
241
|
+
while (cursor < sql.length) {
|
|
242
|
+
if (sql[cursor] === quote) {
|
|
243
|
+
if (sql[cursor + 1] === quote) {
|
|
244
|
+
cursor += 2;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
return cursor + 1;
|
|
248
|
+
}
|
|
249
|
+
cursor += 1;
|
|
250
|
+
}
|
|
251
|
+
return cursor;
|
|
252
|
+
}
|
|
253
|
+
function skipLineComment(sql, index) {
|
|
254
|
+
let cursor = index + 2;
|
|
255
|
+
while (cursor < sql.length && sql[cursor] !== "\n") {
|
|
256
|
+
cursor += 1;
|
|
257
|
+
}
|
|
258
|
+
return cursor;
|
|
259
|
+
}
|
|
260
|
+
function skipBlockComment(sql, index) {
|
|
261
|
+
let cursor = index + 2;
|
|
262
|
+
while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
|
|
263
|
+
cursor += 1;
|
|
264
|
+
}
|
|
265
|
+
return Math.min(cursor + 2, sql.length);
|
|
266
|
+
}
|
|
267
|
+
function skipNonCode(sql, index) {
|
|
268
|
+
const char = sql[index];
|
|
269
|
+
if (char === "'" || char === '"') {
|
|
270
|
+
return skipQuotedText(sql, index);
|
|
271
|
+
}
|
|
272
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
273
|
+
return skipLineComment(sql, index);
|
|
274
|
+
}
|
|
275
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
276
|
+
return skipBlockComment(sql, index);
|
|
277
|
+
}
|
|
278
|
+
return void 0;
|
|
279
|
+
}
|
|
280
|
+
function keywordMatches(sql, index, keyword) {
|
|
281
|
+
const end = index + keyword.length;
|
|
282
|
+
return sql.slice(index, end).toUpperCase() === keyword && !isIdentifierChar(sql[index - 1]) && !isIdentifierChar(sql[end]);
|
|
283
|
+
}
|
|
284
|
+
function findTopLevelKeyword(sql, keyword, startIndex = 0) {
|
|
285
|
+
let index = startIndex;
|
|
286
|
+
let depth = 0;
|
|
287
|
+
while (index < sql.length) {
|
|
288
|
+
const skipped = skipNonCode(sql, index);
|
|
289
|
+
if (skipped !== void 0) {
|
|
290
|
+
index = skipped;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const char = sql[index];
|
|
294
|
+
if (char === "(") {
|
|
295
|
+
depth += 1;
|
|
296
|
+
} else if (char === ")" && depth > 0) {
|
|
297
|
+
depth -= 1;
|
|
298
|
+
} else if (depth === 0 && keywordMatches(sql, index, keyword)) {
|
|
299
|
+
return index;
|
|
300
|
+
}
|
|
301
|
+
index += 1;
|
|
302
|
+
}
|
|
303
|
+
return void 0;
|
|
304
|
+
}
|
|
305
|
+
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
306
|
+
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
307
|
+
}
|
|
308
|
+
function buildSelectPlan(operation, statementSql, targetSql, whereIndex, params) {
|
|
309
|
+
const target = targetSql.trim();
|
|
310
|
+
if (target.length === 0) {
|
|
311
|
+
throw new QueryError(`${operation.toUpperCase()} backup requires a target table`);
|
|
312
|
+
}
|
|
313
|
+
if (whereIndex === void 0) {
|
|
314
|
+
return { operation, statementSql, selectSql: `SELECT * FROM ${target}`, selectParams: [] };
|
|
315
|
+
}
|
|
316
|
+
const whereSql = statementSql.slice(whereIndex + "WHERE".length).trim();
|
|
317
|
+
if (whereSql.length === 0) {
|
|
318
|
+
throw new QueryError(`${operation.toUpperCase()} backup requires a non-empty WHERE clause`);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
operation,
|
|
322
|
+
statementSql,
|
|
323
|
+
selectSql: `SELECT * FROM ${target} WHERE ${whereSql}`,
|
|
324
|
+
selectParams: selectParamsAfterWhere(statementSql, whereIndex, params)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function buildUpdateBackupPlan(statementSql, params) {
|
|
328
|
+
const updateIndex = findTopLevelKeyword(statementSql, "UPDATE");
|
|
329
|
+
const setIndex = updateIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "SET", updateIndex + "UPDATE".length);
|
|
330
|
+
if (updateIndex === void 0 || setIndex === void 0) {
|
|
331
|
+
throw new QueryError("UPDATE backup requires UPDATE <target> SET syntax");
|
|
332
|
+
}
|
|
333
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", setIndex + "SET".length);
|
|
334
|
+
return buildSelectPlan(
|
|
335
|
+
"update",
|
|
336
|
+
statementSql,
|
|
337
|
+
statementSql.slice(updateIndex + "UPDATE".length, setIndex),
|
|
338
|
+
whereIndex,
|
|
339
|
+
params
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
function buildDeleteBackupPlan(statementSql, params) {
|
|
343
|
+
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
344
|
+
const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
|
|
345
|
+
if (deleteIndex === void 0 || fromIndex === void 0) {
|
|
346
|
+
throw new QueryError("DELETE backup requires DELETE FROM <target> syntax");
|
|
347
|
+
}
|
|
348
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", fromIndex + "FROM".length);
|
|
349
|
+
const targetEnd = whereIndex ?? statementSql.length;
|
|
350
|
+
return buildSelectPlan(
|
|
351
|
+
"delete",
|
|
352
|
+
statementSql,
|
|
353
|
+
statementSql.slice(fromIndex + "FROM".length, targetEnd),
|
|
354
|
+
whereIndex,
|
|
355
|
+
params
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
function backupTimestamp(now) {
|
|
359
|
+
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
360
|
+
}
|
|
361
|
+
function backupHash(statementSql) {
|
|
362
|
+
return createHash("sha256").update(statementSql).update("\0").update(randomUUID()).digest("hex").slice(0, 12);
|
|
363
|
+
}
|
|
364
|
+
function cfHanaBackupRoot(saptoolsRoot) {
|
|
365
|
+
return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
366
|
+
}
|
|
367
|
+
function buildWriteBackupPlan(sql, params = []) {
|
|
368
|
+
const statementSql = trimStatementSql(sql);
|
|
369
|
+
const keyword = firstKeyword(statementSql);
|
|
370
|
+
if (keyword !== "UPDATE" && keyword !== "DELETE") {
|
|
371
|
+
return void 0;
|
|
372
|
+
}
|
|
373
|
+
assertParamArity(statementSql, params);
|
|
374
|
+
if (keyword === "UPDATE") {
|
|
375
|
+
return buildUpdateBackupPlan(statementSql, params);
|
|
376
|
+
}
|
|
377
|
+
return buildDeleteBackupPlan(statementSql, params);
|
|
378
|
+
}
|
|
379
|
+
async function writeSqlBackup(input, options = {}) {
|
|
380
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
381
|
+
const directory = join(
|
|
382
|
+
cfHanaBackupRoot(options.saptoolsRoot),
|
|
383
|
+
`${backupTimestamp(now)}-${input.operation}-${backupHash(input.statementSql)}`
|
|
384
|
+
);
|
|
385
|
+
const statementPath = join(directory, "statement.sql");
|
|
386
|
+
const backupPath = join(directory, "backup.csv");
|
|
387
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
388
|
+
await Promise.all([
|
|
389
|
+
writeFile(statementPath, `${input.statementSql}
|
|
390
|
+
`, { encoding: "utf8", mode: 384 }),
|
|
391
|
+
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 })
|
|
392
|
+
]);
|
|
393
|
+
return {
|
|
394
|
+
directory,
|
|
395
|
+
statementPath,
|
|
396
|
+
backupPath,
|
|
397
|
+
rowCount: input.result.rowCount
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
134
401
|
// src/builder.ts
|
|
135
402
|
function buildWhere(where) {
|
|
136
403
|
if (where === void 0) {
|
|
@@ -243,7 +510,7 @@ async function listColumns(connection, schema, table) {
|
|
|
243
510
|
}
|
|
244
511
|
|
|
245
512
|
// src/config.ts
|
|
246
|
-
var CLI_VERSION = "0.1.
|
|
513
|
+
var CLI_VERSION = "0.1.5";
|
|
247
514
|
var ENV_PREFIX = "CF_HANA";
|
|
248
515
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
249
516
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -666,16 +933,16 @@ function createDriver(name) {
|
|
|
666
933
|
}
|
|
667
934
|
|
|
668
935
|
// src/history.ts
|
|
669
|
-
import { appendFile, mkdir, readdir, rm } from "fs/promises";
|
|
670
|
-
import { homedir } from "os";
|
|
671
|
-
import { join } from "path";
|
|
672
|
-
var
|
|
673
|
-
var
|
|
936
|
+
import { appendFile, mkdir as mkdir2, readdir, rm } from "fs/promises";
|
|
937
|
+
import { homedir as homedir2 } from "os";
|
|
938
|
+
import { join as join2 } from "path";
|
|
939
|
+
var SAPTOOLS_DIR_NAME2 = ".saptools";
|
|
940
|
+
var CF_HANA_DIR_NAME2 = "cf-hana";
|
|
674
941
|
var HISTORIES_DIR_NAME = "histories";
|
|
675
942
|
var HISTORY_RETENTION_DAYS = 5;
|
|
676
943
|
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
677
|
-
function
|
|
678
|
-
return
|
|
944
|
+
function defaultSaptoolsRoot2() {
|
|
945
|
+
return join2(homedir2(), SAPTOOLS_DIR_NAME2);
|
|
679
946
|
}
|
|
680
947
|
function padDatePart(value) {
|
|
681
948
|
return value.toString().padStart(2, "0");
|
|
@@ -694,13 +961,13 @@ function retentionCutoffKey(now) {
|
|
|
694
961
|
return dateKey(cutoff);
|
|
695
962
|
}
|
|
696
963
|
function resolveSaptoolsRoot(root) {
|
|
697
|
-
return root ??
|
|
964
|
+
return root ?? defaultSaptoolsRoot2();
|
|
698
965
|
}
|
|
699
966
|
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
700
|
-
return
|
|
967
|
+
return join2(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
|
|
701
968
|
}
|
|
702
969
|
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
703
|
-
return
|
|
970
|
+
return join2(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
704
971
|
}
|
|
705
972
|
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
706
973
|
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
@@ -716,7 +983,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
|
|
|
716
983
|
const cutoffKey = retentionCutoffKey(now);
|
|
717
984
|
await Promise.all(
|
|
718
985
|
files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
|
|
719
|
-
await rm(
|
|
986
|
+
await rm(join2(historyDir, file), { force: true });
|
|
720
987
|
})
|
|
721
988
|
);
|
|
722
989
|
}
|
|
@@ -727,7 +994,7 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
727
994
|
at: now.toISOString(),
|
|
728
995
|
...input
|
|
729
996
|
};
|
|
730
|
-
await
|
|
997
|
+
await mkdir2(historyDir, { recursive: true, mode: 448 });
|
|
731
998
|
await appendFile(sqlHistoryFilePath(now, options.saptoolsRoot), `${JSON.stringify(entry)}
|
|
732
999
|
`, {
|
|
733
1000
|
encoding: "utf8",
|
|
@@ -743,7 +1010,7 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
743
1010
|
// src/safety.ts
|
|
744
1011
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
745
1012
|
var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
746
|
-
function
|
|
1013
|
+
function skipQuotedText2(sql, start) {
|
|
747
1014
|
const quote = sql[start];
|
|
748
1015
|
let index = start + 1;
|
|
749
1016
|
while (index < sql.length) {
|
|
@@ -759,14 +1026,14 @@ function skipQuotedText(sql, start) {
|
|
|
759
1026
|
}
|
|
760
1027
|
return index;
|
|
761
1028
|
}
|
|
762
|
-
function
|
|
1029
|
+
function skipLineComment2(sql, start) {
|
|
763
1030
|
let index = start + 2;
|
|
764
1031
|
while (index < sql.length && sql[index] !== "\n") {
|
|
765
1032
|
index += 1;
|
|
766
1033
|
}
|
|
767
1034
|
return index;
|
|
768
1035
|
}
|
|
769
|
-
function
|
|
1036
|
+
function skipBlockComment2(sql, start) {
|
|
770
1037
|
let index = start + 2;
|
|
771
1038
|
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
772
1039
|
index += 1;
|
|
@@ -779,19 +1046,19 @@ function maskIgnoredSqlText(sql) {
|
|
|
779
1046
|
while (index < sql.length) {
|
|
780
1047
|
const char = sql[index];
|
|
781
1048
|
if (char === "'" || char === '"') {
|
|
782
|
-
const end =
|
|
1049
|
+
const end = skipQuotedText2(sql, index);
|
|
783
1050
|
masked += " ".repeat(end - index);
|
|
784
1051
|
index = end;
|
|
785
1052
|
continue;
|
|
786
1053
|
}
|
|
787
1054
|
if (char === "-" && sql[index + 1] === "-") {
|
|
788
|
-
const end =
|
|
1055
|
+
const end = skipLineComment2(sql, index);
|
|
789
1056
|
masked += " ".repeat(end - index);
|
|
790
1057
|
index = end;
|
|
791
1058
|
continue;
|
|
792
1059
|
}
|
|
793
1060
|
if (char === "/" && sql[index + 1] === "*") {
|
|
794
|
-
const end =
|
|
1061
|
+
const end = skipBlockComment2(sql, index);
|
|
795
1062
|
masked += " ".repeat(end - index);
|
|
796
1063
|
index = end;
|
|
797
1064
|
continue;
|
|
@@ -809,11 +1076,11 @@ function trailingLineCommentIndex(sql) {
|
|
|
809
1076
|
while (index < sql.length) {
|
|
810
1077
|
const char = sql[index];
|
|
811
1078
|
if (char === "'" || char === '"') {
|
|
812
|
-
index =
|
|
1079
|
+
index = skipQuotedText2(sql, index);
|
|
813
1080
|
continue;
|
|
814
1081
|
}
|
|
815
1082
|
if (char === "/" && sql[index + 1] === "*") {
|
|
816
|
-
index =
|
|
1083
|
+
index = skipBlockComment2(sql, index);
|
|
817
1084
|
continue;
|
|
818
1085
|
}
|
|
819
1086
|
if (char === "-" && sql[index + 1] === "-") {
|
|
@@ -1250,6 +1517,27 @@ var HanaClient = class _HanaClient {
|
|
|
1250
1517
|
await this.recordSqlHistory("execute", sql, resolvedParams, result);
|
|
1251
1518
|
return result;
|
|
1252
1519
|
}
|
|
1520
|
+
/** Back up rows matched by an UPDATE or DELETE before the caller runs it. */
|
|
1521
|
+
async backupWriteStatement(sql, params, options) {
|
|
1522
|
+
const resolvedParams = params ?? [];
|
|
1523
|
+
const plan = buildWriteBackupPlan(sql, resolvedParams);
|
|
1524
|
+
if (plan === void 0) {
|
|
1525
|
+
return void 0;
|
|
1526
|
+
}
|
|
1527
|
+
return await this.pool.withConnection(async (connection) => {
|
|
1528
|
+
connection.assertAllowed(plan.statementSql, options);
|
|
1529
|
+
const backupQueryOptions = {
|
|
1530
|
+
autoLimit: false,
|
|
1531
|
+
...options?.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
|
|
1532
|
+
};
|
|
1533
|
+
const result = await connection.query(plan.selectSql, plan.selectParams, backupQueryOptions);
|
|
1534
|
+
return await writeSqlBackup({
|
|
1535
|
+
operation: plan.operation,
|
|
1536
|
+
statementSql: plan.statementSql,
|
|
1537
|
+
result
|
|
1538
|
+
});
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1253
1541
|
/** Run a typed `SELECT` built from a spec. */
|
|
1254
1542
|
async selectFrom(spec) {
|
|
1255
1543
|
const built = buildSelect(spec);
|
|
@@ -1404,91 +1692,6 @@ async function withConnection(selector, work, options) {
|
|
|
1404
1692
|
await client.close();
|
|
1405
1693
|
}
|
|
1406
1694
|
}
|
|
1407
|
-
|
|
1408
|
-
// src/format.ts
|
|
1409
|
-
function cellText(value, nullText) {
|
|
1410
|
-
if (value === null) {
|
|
1411
|
-
return nullText;
|
|
1412
|
-
}
|
|
1413
|
-
if (value instanceof Date) {
|
|
1414
|
-
return value.toISOString();
|
|
1415
|
-
}
|
|
1416
|
-
if (Buffer.isBuffer(value)) {
|
|
1417
|
-
return `0x${value.toString("hex")}`;
|
|
1418
|
-
}
|
|
1419
|
-
if (typeof value === "boolean") {
|
|
1420
|
-
return value ? "true" : "false";
|
|
1421
|
-
}
|
|
1422
|
-
return typeof value === "number" ? value.toString() : value;
|
|
1423
|
-
}
|
|
1424
|
-
function serializeCell(value) {
|
|
1425
|
-
if (value === null) {
|
|
1426
|
-
return null;
|
|
1427
|
-
}
|
|
1428
|
-
if (value instanceof Date) {
|
|
1429
|
-
return value.toISOString();
|
|
1430
|
-
}
|
|
1431
|
-
if (Buffer.isBuffer(value)) {
|
|
1432
|
-
return `0x${value.toString("hex")}`;
|
|
1433
|
-
}
|
|
1434
|
-
return value;
|
|
1435
|
-
}
|
|
1436
|
-
function csvEscape(text) {
|
|
1437
|
-
if (/[",\r\n]/.test(text)) {
|
|
1438
|
-
return `"${text.replace(/"/g, '""')}"`;
|
|
1439
|
-
}
|
|
1440
|
-
return text;
|
|
1441
|
-
}
|
|
1442
|
-
function formatTable(result) {
|
|
1443
|
-
if (result.columns.length === 0) {
|
|
1444
|
-
return `(${String(result.rowCount)} row(s) affected)`;
|
|
1445
|
-
}
|
|
1446
|
-
const headers = result.columns.map((column) => column.name);
|
|
1447
|
-
const rows = result.rows.map(
|
|
1448
|
-
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
|
|
1449
|
-
);
|
|
1450
|
-
const widths = headers.map((header, index) => {
|
|
1451
|
-
const widest = rows.reduce(
|
|
1452
|
-
(max, cells) => Math.max(max, (cells[index] ?? "").length),
|
|
1453
|
-
header.length
|
|
1454
|
-
);
|
|
1455
|
-
return widest;
|
|
1456
|
-
});
|
|
1457
|
-
const renderRow = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" | ");
|
|
1458
|
-
const separator = widths.map((width) => "-".repeat(width)).join("-+-");
|
|
1459
|
-
const body = rows.map((cells) => renderRow(cells));
|
|
1460
|
-
return [renderRow(headers), separator, ...body].join("\n");
|
|
1461
|
-
}
|
|
1462
|
-
function formatJson(result) {
|
|
1463
|
-
const rows = result.rows.map((row) => {
|
|
1464
|
-
const serialized = {};
|
|
1465
|
-
for (const [key, value] of Object.entries(row)) {
|
|
1466
|
-
serialized[key] = serializeCell(value);
|
|
1467
|
-
}
|
|
1468
|
-
return serialized;
|
|
1469
|
-
});
|
|
1470
|
-
return JSON.stringify(rows, null, 2);
|
|
1471
|
-
}
|
|
1472
|
-
function formatCsv(result) {
|
|
1473
|
-
const headers = result.columns.map((column) => column.name);
|
|
1474
|
-
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
1475
|
-
for (const row of result.rows) {
|
|
1476
|
-
lines.push(
|
|
1477
|
-
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
|
|
1478
|
-
);
|
|
1479
|
-
}
|
|
1480
|
-
return lines.join("\r\n");
|
|
1481
|
-
}
|
|
1482
|
-
function formatResult(result, format) {
|
|
1483
|
-
switch (format) {
|
|
1484
|
-
case "table":
|
|
1485
|
-
return formatTable(result);
|
|
1486
|
-
case "json":
|
|
1487
|
-
return formatJson(result);
|
|
1488
|
-
case "csv":
|
|
1489
|
-
return formatCsv(result);
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
1695
|
export {
|
|
1493
1696
|
CfHanaError,
|
|
1494
1697
|
CredentialsNotFoundError,
|
|
@@ -1502,6 +1705,8 @@ export {
|
|
|
1502
1705
|
buildInsert,
|
|
1503
1706
|
buildSelect,
|
|
1504
1707
|
buildUpdate,
|
|
1708
|
+
buildWriteBackupPlan,
|
|
1709
|
+
cfHanaBackupRoot,
|
|
1505
1710
|
connect,
|
|
1506
1711
|
createDriver,
|
|
1507
1712
|
errorMessage,
|
|
@@ -1510,6 +1715,7 @@ export {
|
|
|
1510
1715
|
formatResult,
|
|
1511
1716
|
formatTable,
|
|
1512
1717
|
query,
|
|
1513
|
-
withConnection
|
|
1718
|
+
withConnection,
|
|
1719
|
+
writeSqlBackup
|
|
1514
1720
|
};
|
|
1515
1721
|
//# sourceMappingURL=index.js.map
|