@saptools/cf-hana 0.1.1 → 0.1.3
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 +17 -0
- package/README.md +29 -10
- package/dist/cli.js +314 -46
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +295 -43
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3 - 2026-06-23
|
|
4
|
+
|
|
5
|
+
- Add local SQL history for successful direct `query` and `execute` calls under
|
|
6
|
+
`~/.saptools/cf-hana/histories/YYYY-MM-DD.jsonl`.
|
|
7
|
+
- Rotate SQL history with five-day retention and keep parameter values,
|
|
8
|
+
credentials, certificates, and result rows out of the history file.
|
|
9
|
+
- Keep helper-driven catalog SQL out of user SQL history and document the new
|
|
10
|
+
local state behavior.
|
|
11
|
+
|
|
12
|
+
## 0.1.2 - 2026-06-23
|
|
13
|
+
|
|
14
|
+
- Harden connection pooling so queued callers continue after transient reconnect failures.
|
|
15
|
+
- Preserve query results when HANA statement cleanup fails and close partially opened clients on schema setup errors.
|
|
16
|
+
- Strengthen read-only and destructive-statement checks around comments, quoted identifiers, and unknown statements.
|
|
17
|
+
- Improve `explain()` statement isolation, cleanup, and read-only behavior.
|
|
18
|
+
- Validate CLI numeric options strictly and align E2E diagnostics with project defaults.
|
|
19
|
+
|
|
3
20
|
## 0.1.1 - 2026-05-22
|
|
4
21
|
|
|
5
22
|
- Patch release to publish via npm trusted publishing after the manual `0.1.0` bootstrap.
|
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ login on the hot path) and connections are pooled and reused within a process.
|
|
|
22
22
|
- **Query-builder shorthands** — `selectFrom`, `count`, `insertInto`, `update`,
|
|
23
23
|
`deleteFrom` — query a table by name without writing SQL.
|
|
24
24
|
- **Schema introspection** — list schemas, tables, and columns.
|
|
25
|
+
- **Local SQL history** — direct SQL calls are appended to dated JSONL files
|
|
26
|
+
under `~/.saptools/cf-hana/histories/` with five-day retention.
|
|
25
27
|
- **Safety guard** — opt-in read-only mode and a destructive-statement guard
|
|
26
28
|
(blocks `DROP`/`TRUNCATE`/`ALTER` and unscoped `UPDATE`/`DELETE`).
|
|
27
29
|
- **Typed results** — `query<TRow>()` returns typed rows.
|
|
@@ -44,7 +46,7 @@ driver is bundled as a dependency — there is no native build step.
|
|
|
44
46
|
import { connect, query } from "@saptools/cf-hana";
|
|
45
47
|
|
|
46
48
|
// Open a reusable, pooled client for one CF app's HANA database.
|
|
47
|
-
const db = await connect("eu10/
|
|
49
|
+
const db = await connect("eu10/example-org/space-demo/app-demo");
|
|
48
50
|
|
|
49
51
|
const open = await db.query("SELECT ID, STATUS FROM ORDERS WHERE STATUS = ?", ["OPEN"]);
|
|
50
52
|
console.log(open.rows);
|
|
@@ -58,16 +60,16 @@ await db.transaction(async (tx) => {
|
|
|
58
60
|
await db.close();
|
|
59
61
|
|
|
60
62
|
// One-shot: connect, run one query, close.
|
|
61
|
-
const rows = await query("
|
|
63
|
+
const rows = await query("app-demo", "SELECT COUNT(*) AS N FROM ORDERS");
|
|
62
64
|
```
|
|
63
65
|
|
|
64
66
|
## The selector
|
|
65
67
|
|
|
66
68
|
Every entry point takes a selector as its first argument:
|
|
67
69
|
|
|
68
|
-
- **Explicit** — `region/org/space/app` (e.g.
|
|
69
|
-
without any cached topology.
|
|
70
|
-
- **Bare app name** — `
|
|
70
|
+
- **Explicit** — `region/org/space/app` (e.g.
|
|
71
|
+
`eu10/example-org/space-demo/app-demo`). Works without any cached topology.
|
|
72
|
+
- **Bare app name** — `app-demo`. Resolved against the topology cached by
|
|
71
73
|
`cf-sync sync`; throws if the name is ambiguous across spaces.
|
|
72
74
|
|
|
73
75
|
## CLI
|
|
@@ -87,11 +89,11 @@ Common options: `--format <table|json|csv>`, `--refresh`, `--role <runtime|hdi>`
|
|
|
87
89
|
accepts `--param <value>` (repeatable) to bind `?` placeholders.
|
|
88
90
|
|
|
89
91
|
```bash
|
|
90
|
-
cf-hana query eu10/
|
|
92
|
+
cf-hana query eu10/example-org/space-demo/app-demo "SELECT ID, STATUS FROM ORDERS WHERE STATUS = ?" \
|
|
91
93
|
--param OPEN --format json
|
|
92
|
-
cf-hana tables
|
|
93
|
-
cf-hana columns
|
|
94
|
-
cf-hana ping eu10/
|
|
94
|
+
cf-hana tables app-demo
|
|
95
|
+
cf-hana columns app-demo ORDERS_APP.ORDERS
|
|
96
|
+
cf-hana ping eu10/example-org/space-demo/app-demo
|
|
95
97
|
```
|
|
96
98
|
|
|
97
99
|
## Programmatic API
|
|
@@ -117,11 +119,28 @@ Credentials are resolved **cache-first**:
|
|
|
117
119
|
live from Cloud Foundry. The live fetch needs `SAP_EMAIL` and `SAP_PASSWORD`
|
|
118
120
|
(or the `email` / `password` options) and never persists anything to disk.
|
|
119
121
|
|
|
120
|
-
|
|
122
|
+
Credential resolution writes nothing under `~/.saptools/` — it only reads what
|
|
121
123
|
`cf-sync` cached. The connection pool is in-process and in-memory only, so it is
|
|
122
124
|
safe to run many `cf-hana` processes in parallel and alongside any `cf-sync`
|
|
123
125
|
command.
|
|
124
126
|
|
|
127
|
+
## SQL history
|
|
128
|
+
|
|
129
|
+
Successful direct SQL calls are appended to daily JSONL files:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
~/.saptools/cf-hana/histories/YYYY-MM-DD.jsonl
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Each entry includes the timestamp, package version, selector, app name, schema,
|
|
136
|
+
role, operation (`query` or `execute`), statement kind, SQL text, parameter
|
|
137
|
+
count, row count, truncation flag, and elapsed time. Parameter values,
|
|
138
|
+
credentials, certificates, and result rows are not stored.
|
|
139
|
+
|
|
140
|
+
History retention runs opportunistically after each append and deletes dated
|
|
141
|
+
history files older than five days. Helper-driven catalog SQL such as `tables`
|
|
142
|
+
and `columns` is not recorded as user SQL history.
|
|
143
|
+
|
|
125
144
|
## Safety
|
|
126
145
|
|
|
127
146
|
- **Read-only mode** (`readOnly` / `--read-only`) rejects every DML and DDL statement.
|
package/dist/cli.js
CHANGED
|
@@ -247,7 +247,7 @@ async function listColumns(connection, schema, table) {
|
|
|
247
247
|
|
|
248
248
|
// src/config.ts
|
|
249
249
|
var CLI_NAME = "cf-hana";
|
|
250
|
-
var CLI_VERSION = "0.1.
|
|
250
|
+
var CLI_VERSION = "0.1.3";
|
|
251
251
|
var ENV_PREFIX = "CF_HANA";
|
|
252
252
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
253
253
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -498,7 +498,7 @@ var HdbConnection = class {
|
|
|
498
498
|
const raw = await this.executeStatement(statement, params);
|
|
499
499
|
return toExecResult(statement, raw);
|
|
500
500
|
} finally {
|
|
501
|
-
statement
|
|
501
|
+
dropStatementQuietly(statement);
|
|
502
502
|
}
|
|
503
503
|
}
|
|
504
504
|
setAutoCommit(enabled) {
|
|
@@ -565,6 +565,12 @@ var HdbConnection = class {
|
|
|
565
565
|
});
|
|
566
566
|
}
|
|
567
567
|
};
|
|
568
|
+
function dropStatementQuietly(statement) {
|
|
569
|
+
try {
|
|
570
|
+
statement.drop();
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
}
|
|
568
574
|
function openClient(client, timeoutMs) {
|
|
569
575
|
return new Promise((resolve, reject) => {
|
|
570
576
|
let settled = false;
|
|
@@ -600,6 +606,20 @@ function openClient(client, timeoutMs) {
|
|
|
600
606
|
});
|
|
601
607
|
});
|
|
602
608
|
}
|
|
609
|
+
async function closeClientQuietly(client) {
|
|
610
|
+
try {
|
|
611
|
+
await new Promise((resolve) => {
|
|
612
|
+
client.disconnect(() => {
|
|
613
|
+
resolve();
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
} catch {
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
client.close();
|
|
620
|
+
} catch {
|
|
621
|
+
}
|
|
622
|
+
}
|
|
603
623
|
function setCurrentSchema(client, schema) {
|
|
604
624
|
return new Promise((resolve, reject) => {
|
|
605
625
|
client.exec(`SET SCHEMA ${quoteIdentifier(schema)}`, (error) => {
|
|
@@ -621,7 +641,12 @@ async function connectHdb(params) {
|
|
|
621
641
|
useTLS: true
|
|
622
642
|
});
|
|
623
643
|
await openClient(client, params.connectTimeoutMs);
|
|
624
|
-
|
|
644
|
+
try {
|
|
645
|
+
await setCurrentSchema(client, params.schema);
|
|
646
|
+
} catch (error) {
|
|
647
|
+
await closeClientQuietly(client);
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
625
650
|
return new HdbConnection(client);
|
|
626
651
|
}
|
|
627
652
|
function createHdbDriver() {
|
|
@@ -644,14 +669,178 @@ function createDriver(name) {
|
|
|
644
669
|
}
|
|
645
670
|
}
|
|
646
671
|
|
|
672
|
+
// 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";
|
|
678
|
+
var HISTORIES_DIR_NAME = "histories";
|
|
679
|
+
var HISTORY_RETENTION_DAYS = 5;
|
|
680
|
+
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
681
|
+
function defaultSaptoolsRoot() {
|
|
682
|
+
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
683
|
+
}
|
|
684
|
+
function padDatePart(value) {
|
|
685
|
+
return value.toString().padStart(2, "0");
|
|
686
|
+
}
|
|
687
|
+
function dateKey(date) {
|
|
688
|
+
return [
|
|
689
|
+
date.getFullYear().toString(),
|
|
690
|
+
padDatePart(date.getMonth() + 1),
|
|
691
|
+
padDatePart(date.getDate())
|
|
692
|
+
].join("-");
|
|
693
|
+
}
|
|
694
|
+
function retentionCutoffKey(now) {
|
|
695
|
+
const cutoff = new Date(now);
|
|
696
|
+
cutoff.setHours(0, 0, 0, 0);
|
|
697
|
+
cutoff.setDate(cutoff.getDate() - HISTORY_RETENTION_DAYS);
|
|
698
|
+
return dateKey(cutoff);
|
|
699
|
+
}
|
|
700
|
+
function resolveSaptoolsRoot(root) {
|
|
701
|
+
return root ?? defaultSaptoolsRoot();
|
|
702
|
+
}
|
|
703
|
+
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
704
|
+
return join(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME, HISTORIES_DIR_NAME);
|
|
705
|
+
}
|
|
706
|
+
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
707
|
+
return join(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
708
|
+
}
|
|
709
|
+
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
710
|
+
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
711
|
+
let files;
|
|
712
|
+
try {
|
|
713
|
+
files = await readdir(historyDir);
|
|
714
|
+
} catch (error) {
|
|
715
|
+
if (error.code === "ENOENT") {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
const cutoffKey = retentionCutoffKey(now);
|
|
721
|
+
await Promise.all(
|
|
722
|
+
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 });
|
|
724
|
+
})
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
async function appendSqlHistory(input, options = {}) {
|
|
728
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
729
|
+
const historyDir = cfHanaHistoryDirectory(options.saptoolsRoot);
|
|
730
|
+
const entry = {
|
|
731
|
+
at: now.toISOString(),
|
|
732
|
+
...input
|
|
733
|
+
};
|
|
734
|
+
await mkdir(historyDir, { recursive: true, mode: 448 });
|
|
735
|
+
await appendFile(sqlHistoryFilePath(now, options.saptoolsRoot), `${JSON.stringify(entry)}
|
|
736
|
+
`, {
|
|
737
|
+
encoding: "utf8",
|
|
738
|
+
mode: 384
|
|
739
|
+
});
|
|
740
|
+
try {
|
|
741
|
+
await pruneSqlHistory(now, options.saptoolsRoot);
|
|
742
|
+
} catch {
|
|
743
|
+
}
|
|
744
|
+
return entry;
|
|
745
|
+
}
|
|
746
|
+
|
|
647
747
|
// src/safety.ts
|
|
648
748
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
649
749
|
var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
650
|
-
function
|
|
651
|
-
|
|
750
|
+
function skipQuotedText(sql, start) {
|
|
751
|
+
const quote = sql[start];
|
|
752
|
+
let index = start + 1;
|
|
753
|
+
while (index < sql.length) {
|
|
754
|
+
if (sql[index] === quote) {
|
|
755
|
+
if (sql[index + 1] === quote) {
|
|
756
|
+
index += 2;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
index += 1;
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
index += 1;
|
|
763
|
+
}
|
|
764
|
+
return index;
|
|
765
|
+
}
|
|
766
|
+
function skipLineComment(sql, start) {
|
|
767
|
+
let index = start + 2;
|
|
768
|
+
while (index < sql.length && sql[index] !== "\n") {
|
|
769
|
+
index += 1;
|
|
770
|
+
}
|
|
771
|
+
return index;
|
|
772
|
+
}
|
|
773
|
+
function skipBlockComment(sql, start) {
|
|
774
|
+
let index = start + 2;
|
|
775
|
+
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
776
|
+
index += 1;
|
|
777
|
+
}
|
|
778
|
+
return Math.min(index + 2, sql.length);
|
|
779
|
+
}
|
|
780
|
+
function maskIgnoredSqlText(sql) {
|
|
781
|
+
let masked = "";
|
|
782
|
+
let index = 0;
|
|
783
|
+
while (index < sql.length) {
|
|
784
|
+
const char = sql[index];
|
|
785
|
+
if (char === "'" || char === '"') {
|
|
786
|
+
const end = skipQuotedText(sql, index);
|
|
787
|
+
masked += " ".repeat(end - index);
|
|
788
|
+
index = end;
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
792
|
+
const end = skipLineComment(sql, index);
|
|
793
|
+
masked += " ".repeat(end - index);
|
|
794
|
+
index = end;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
798
|
+
const end = skipBlockComment(sql, index);
|
|
799
|
+
masked += " ".repeat(end - index);
|
|
800
|
+
index = end;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
masked += char ?? "";
|
|
804
|
+
index += 1;
|
|
805
|
+
}
|
|
806
|
+
return masked;
|
|
652
807
|
}
|
|
653
808
|
function hasWhereClause(sql) {
|
|
654
|
-
return /\bwhere\b/i.test(
|
|
809
|
+
return /\bwhere\b/i.test(maskIgnoredSqlText(sql));
|
|
810
|
+
}
|
|
811
|
+
function trailingLineCommentIndex(sql) {
|
|
812
|
+
let index = 0;
|
|
813
|
+
while (index < sql.length) {
|
|
814
|
+
const char = sql[index];
|
|
815
|
+
if (char === "'" || char === '"') {
|
|
816
|
+
index = skipQuotedText(sql, index);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
820
|
+
index = skipBlockComment(sql, index);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
824
|
+
const lineEnd = sql.indexOf("\n", index + 2);
|
|
825
|
+
if (lineEnd === -1 || sql.slice(lineEnd + 1).trim().length === 0) {
|
|
826
|
+
return index;
|
|
827
|
+
}
|
|
828
|
+
index = lineEnd + 1;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
index += 1;
|
|
832
|
+
}
|
|
833
|
+
return void 0;
|
|
834
|
+
}
|
|
835
|
+
function appendLimit(sql, limit) {
|
|
836
|
+
const trimmed = sql.replace(/[\s;]+$/, "");
|
|
837
|
+
const commentIndex = trailingLineCommentIndex(trimmed);
|
|
838
|
+
if (commentIndex === void 0) {
|
|
839
|
+
return `${trimmed} LIMIT ${String(limit)}`;
|
|
840
|
+
}
|
|
841
|
+
const beforeComment = trimmed.slice(0, commentIndex).replace(/[\s;]+$/, "");
|
|
842
|
+
const comment = trimmed.slice(commentIndex);
|
|
843
|
+
return `${beforeComment} LIMIT ${String(limit)} ${comment}`;
|
|
655
844
|
}
|
|
656
845
|
function inspectStatement(sql) {
|
|
657
846
|
const kind = classifyStatement(sql);
|
|
@@ -669,12 +858,12 @@ function inspectStatement(sql) {
|
|
|
669
858
|
}
|
|
670
859
|
function evaluateGuard(sql, config) {
|
|
671
860
|
const inspection = inspectStatement(sql);
|
|
672
|
-
if (config.readOnly &&
|
|
861
|
+
if (config.readOnly && inspection.kind !== "select") {
|
|
673
862
|
return {
|
|
674
863
|
allowed: false,
|
|
675
864
|
destructive: inspection.destructive,
|
|
676
865
|
violation: "read-only",
|
|
677
|
-
reason: `read-only mode blocks ${inspection.kind.toUpperCase()} statements`
|
|
866
|
+
reason: inspection.kind === "unknown" ? "read-only mode only permits SELECT/WITH statements" : `read-only mode blocks ${inspection.kind.toUpperCase()} statements`
|
|
678
867
|
};
|
|
679
868
|
}
|
|
680
869
|
if (inspection.destructive && !config.allowDestructive) {
|
|
@@ -696,12 +885,11 @@ function applyAutoLimit(sql, limit) {
|
|
|
696
885
|
if (limit === false || classifyStatement(sql) !== "select") {
|
|
697
886
|
return { sql, applied: false };
|
|
698
887
|
}
|
|
699
|
-
const stripped =
|
|
888
|
+
const stripped = maskIgnoredSqlText(sql);
|
|
700
889
|
if (/\blimit\b/i.test(stripped) || /\btop\s+\d/i.test(stripped)) {
|
|
701
890
|
return { sql, applied: false };
|
|
702
891
|
}
|
|
703
|
-
|
|
704
|
-
return { sql: `${trimmed} LIMIT ${String(limit)}`, applied: true };
|
|
892
|
+
return { sql: appendLimit(sql, limit), applied: true };
|
|
705
893
|
}
|
|
706
894
|
|
|
707
895
|
// src/connection.ts
|
|
@@ -750,6 +938,12 @@ var Connection = class _Connection {
|
|
|
750
938
|
async execute(sql, params, options) {
|
|
751
939
|
return await this.run(sql, params ?? [], options ?? {});
|
|
752
940
|
}
|
|
941
|
+
assertAllowed(sql, options) {
|
|
942
|
+
this.evaluateSafety(sql, options ?? {});
|
|
943
|
+
}
|
|
944
|
+
async executeInternal(sql, params, options) {
|
|
945
|
+
return await this.run(sql, params ?? [], options ?? {}, { bypassSafety: true });
|
|
946
|
+
}
|
|
753
947
|
async setAutoCommit(enabled) {
|
|
754
948
|
await this.driverConnection.setAutoCommit(enabled);
|
|
755
949
|
}
|
|
@@ -765,21 +959,10 @@ var Connection = class _Connection {
|
|
|
765
959
|
get isClosed() {
|
|
766
960
|
return this.driverConnection.isClosed();
|
|
767
961
|
}
|
|
768
|
-
async run(sql, params, options) {
|
|
962
|
+
async run(sql, params, options, runOptions = {}) {
|
|
769
963
|
assertParamArity(sql, params);
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
allowDestructive: options.allowDestructive ?? this.config.allowDestructive
|
|
773
|
-
});
|
|
774
|
-
if (!decision.allowed) {
|
|
775
|
-
if (decision.violation === "read-only") {
|
|
776
|
-
throw new ReadOnlyViolationError(
|
|
777
|
-
decision.reason ?? "read-only mode blocks this statement"
|
|
778
|
-
);
|
|
779
|
-
}
|
|
780
|
-
throw new DestructiveStatementError(
|
|
781
|
-
decision.reason ?? "destructive statement blocked"
|
|
782
|
-
);
|
|
964
|
+
if (runOptions.bypassSafety !== true) {
|
|
965
|
+
this.evaluateSafety(sql, options);
|
|
783
966
|
}
|
|
784
967
|
const kind = classifyStatement(sql);
|
|
785
968
|
const autoLimit = options.autoLimit ?? this.config.autoLimit;
|
|
@@ -809,6 +992,23 @@ var Connection = class _Connection {
|
|
|
809
992
|
} catch {
|
|
810
993
|
}
|
|
811
994
|
}
|
|
995
|
+
evaluateSafety(sql, options) {
|
|
996
|
+
const decision = evaluateGuard(sql, {
|
|
997
|
+
readOnly: this.config.readOnly,
|
|
998
|
+
allowDestructive: options.allowDestructive ?? this.config.allowDestructive
|
|
999
|
+
});
|
|
1000
|
+
if (decision.allowed) {
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (decision.violation === "read-only") {
|
|
1004
|
+
throw new ReadOnlyViolationError(
|
|
1005
|
+
decision.reason ?? "read-only mode blocks this statement"
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
throw new DestructiveStatementError(
|
|
1009
|
+
decision.reason ?? "destructive statement blocked"
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
812
1012
|
};
|
|
813
1013
|
|
|
814
1014
|
// src/pool.ts
|
|
@@ -949,6 +1149,7 @@ var ConnectionPool = class {
|
|
|
949
1149
|
(error) => {
|
|
950
1150
|
this.created -= 1;
|
|
951
1151
|
waiter.reject(error);
|
|
1152
|
+
this.servePendingWaiters();
|
|
952
1153
|
}
|
|
953
1154
|
);
|
|
954
1155
|
}
|
|
@@ -995,6 +1196,11 @@ var Transaction = class {
|
|
|
995
1196
|
};
|
|
996
1197
|
|
|
997
1198
|
// src/client.ts
|
|
1199
|
+
var explainStatementCounter = 0;
|
|
1200
|
+
function nextExplainStatementName() {
|
|
1201
|
+
explainStatementCounter = (explainStatementCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
1202
|
+
return `cf_hana_${String(process.pid)}_${String(Date.now())}_${String(explainStatementCounter)}`;
|
|
1203
|
+
}
|
|
998
1204
|
var HanaClient = class _HanaClient {
|
|
999
1205
|
constructor(pool, info) {
|
|
1000
1206
|
this.pool = pool;
|
|
@@ -1036,41 +1242,43 @@ var HanaClient = class _HanaClient {
|
|
|
1036
1242
|
}
|
|
1037
1243
|
/** Run a SELECT (or any read) statement and return typed rows. */
|
|
1038
1244
|
async query(sql, params, options) {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
);
|
|
1245
|
+
const resolvedParams = params ?? [];
|
|
1246
|
+
const result = await this.runQuery(sql, resolvedParams, options);
|
|
1247
|
+
await this.recordSqlHistory("query", sql, resolvedParams, result);
|
|
1248
|
+
return result;
|
|
1042
1249
|
}
|
|
1043
1250
|
/** Run a DML/DDL statement and return its affected-row count. */
|
|
1044
1251
|
async execute(sql, params, options) {
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
);
|
|
1252
|
+
const resolvedParams = params ?? [];
|
|
1253
|
+
const result = await this.runExecute(sql, resolvedParams, options);
|
|
1254
|
+
await this.recordSqlHistory("execute", sql, resolvedParams, result);
|
|
1255
|
+
return result;
|
|
1048
1256
|
}
|
|
1049
1257
|
/** Run a typed `SELECT` built from a spec. */
|
|
1050
1258
|
async selectFrom(spec) {
|
|
1051
1259
|
const built = buildSelect(spec);
|
|
1052
|
-
return await this.
|
|
1260
|
+
return await this.runQuery(built.sql, built.params);
|
|
1053
1261
|
}
|
|
1054
1262
|
/** Count rows in a table, optionally filtered. */
|
|
1055
1263
|
async count(spec) {
|
|
1056
1264
|
const built = buildCount(spec);
|
|
1057
|
-
const result = await this.
|
|
1265
|
+
const result = await this.runQuery(built.sql, built.params);
|
|
1058
1266
|
return result.rows[0]?.COUNT ?? 0;
|
|
1059
1267
|
}
|
|
1060
1268
|
/** Insert a single row. */
|
|
1061
1269
|
async insertInto(schema, table, values) {
|
|
1062
1270
|
const built = buildInsert(schema, table, values);
|
|
1063
|
-
return await this.
|
|
1271
|
+
return await this.runExecute(built.sql, built.params);
|
|
1064
1272
|
}
|
|
1065
1273
|
/** Update rows matching a non-empty `where` filter. */
|
|
1066
1274
|
async update(schema, table, values, where) {
|
|
1067
1275
|
const built = buildUpdate(schema, table, values, where);
|
|
1068
|
-
return await this.
|
|
1276
|
+
return await this.runExecute(built.sql, built.params);
|
|
1069
1277
|
}
|
|
1070
1278
|
/** Delete rows matching a non-empty `where` filter. */
|
|
1071
1279
|
async deleteFrom(schema, table, where) {
|
|
1072
1280
|
const built = buildDelete(schema, table, where);
|
|
1073
|
-
return await this.
|
|
1281
|
+
return await this.runExecute(built.sql, built.params);
|
|
1074
1282
|
}
|
|
1075
1283
|
/** Run `work` inside a transaction, auto-committing on success. */
|
|
1076
1284
|
async transaction(work) {
|
|
@@ -1113,21 +1321,35 @@ var HanaClient = class _HanaClient {
|
|
|
1113
1321
|
/** Return the HANA execution plan for a statement. */
|
|
1114
1322
|
async explain(sql, params) {
|
|
1115
1323
|
return await this.pool.withConnection(async (connection) => {
|
|
1116
|
-
|
|
1117
|
-
|
|
1324
|
+
connection.assertAllowed(sql);
|
|
1325
|
+
const statementName = nextExplainStatementName();
|
|
1326
|
+
await connection.executeInternal(
|
|
1118
1327
|
`EXPLAIN PLAN SET STATEMENT_NAME = '${statementName}' FOR ${sql}`,
|
|
1119
1328
|
params
|
|
1120
1329
|
);
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
"
|
|
1330
|
+
const cleanup = async () => {
|
|
1331
|
+
await connection.executeInternal(
|
|
1332
|
+
"DELETE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ?",
|
|
1124
1333
|
[statementName]
|
|
1125
1334
|
);
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1335
|
+
};
|
|
1336
|
+
let queryCompleted = false;
|
|
1337
|
+
try {
|
|
1338
|
+
const result = await connection.query(
|
|
1339
|
+
"SELECT OPERATOR_NAME, TABLE_NAME, TABLE_TYPE, EXECUTION_ENGINE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ? ORDER BY OPERATOR_ID",
|
|
1129
1340
|
[statementName]
|
|
1130
1341
|
);
|
|
1342
|
+
queryCompleted = true;
|
|
1343
|
+
await cleanup();
|
|
1344
|
+
return result;
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
if (!queryCompleted) {
|
|
1347
|
+
try {
|
|
1348
|
+
await cleanup();
|
|
1349
|
+
} catch {
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
throw error;
|
|
1131
1353
|
}
|
|
1132
1354
|
});
|
|
1133
1355
|
}
|
|
@@ -1135,6 +1357,35 @@ var HanaClient = class _HanaClient {
|
|
|
1135
1357
|
async close() {
|
|
1136
1358
|
await this.pool.drain();
|
|
1137
1359
|
}
|
|
1360
|
+
async runQuery(sql, params, options) {
|
|
1361
|
+
return await this.pool.withConnection(
|
|
1362
|
+
(connection) => connection.query(sql, params, options)
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
async runExecute(sql, params, options) {
|
|
1366
|
+
return await this.pool.withConnection(
|
|
1367
|
+
(connection) => connection.execute(sql, params, options)
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
async recordSqlHistory(operation, sql, params, result) {
|
|
1371
|
+
try {
|
|
1372
|
+
await appendSqlHistory({
|
|
1373
|
+
version: CLI_VERSION,
|
|
1374
|
+
operation,
|
|
1375
|
+
selector: this.info.selector,
|
|
1376
|
+
appName: this.info.appName,
|
|
1377
|
+
schema: this.info.schema,
|
|
1378
|
+
role: this.info.role,
|
|
1379
|
+
statement: result.statement,
|
|
1380
|
+
sql,
|
|
1381
|
+
paramCount: params.length,
|
|
1382
|
+
rowCount: result.rowCount,
|
|
1383
|
+
truncated: result.truncated,
|
|
1384
|
+
elapsedMs: result.elapsedMs
|
|
1385
|
+
});
|
|
1386
|
+
} catch {
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1138
1389
|
};
|
|
1139
1390
|
|
|
1140
1391
|
// src/api.ts
|
|
@@ -1238,12 +1489,26 @@ function fail(message) {
|
|
|
1238
1489
|
process.exit(1);
|
|
1239
1490
|
}
|
|
1240
1491
|
function parseIntOption(value) {
|
|
1241
|
-
const
|
|
1242
|
-
if (
|
|
1492
|
+
const trimmed = value.trim();
|
|
1493
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
1243
1494
|
throw new CfHanaError("CONFIG", `Expected an integer but received "${value}"`);
|
|
1244
1495
|
}
|
|
1496
|
+
const parsed = Number(trimmed);
|
|
1497
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
1498
|
+
throw new CfHanaError("CONFIG", `Expected a safe integer but received "${value}"`);
|
|
1499
|
+
}
|
|
1245
1500
|
return parsed;
|
|
1246
1501
|
}
|
|
1502
|
+
function assertPositiveOption(name, value) {
|
|
1503
|
+
if (value !== void 0 && value <= 0) {
|
|
1504
|
+
throw new CfHanaError("CONFIG", `${name} must be a positive integer`);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
function assertNonNegativeOption(name, value) {
|
|
1508
|
+
if (value !== void 0 && value < 0) {
|
|
1509
|
+
throw new CfHanaError("CONFIG", `${name} must be a non-negative integer`);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1247
1512
|
function collectParam(value, previous) {
|
|
1248
1513
|
return [...previous, value];
|
|
1249
1514
|
}
|
|
@@ -1267,6 +1532,9 @@ function parseQualifiedName(value) {
|
|
|
1267
1532
|
return { schema: value.slice(0, dot), table: value.slice(dot + 1) };
|
|
1268
1533
|
}
|
|
1269
1534
|
function toConnectOptions(opts) {
|
|
1535
|
+
assertPositiveOption("--limit", opts.limit);
|
|
1536
|
+
assertPositiveOption("--timeout", opts.timeout);
|
|
1537
|
+
assertNonNegativeOption("--binding-index", opts.bindingIndex);
|
|
1270
1538
|
return {
|
|
1271
1539
|
refresh: opts.refresh,
|
|
1272
1540
|
role: parseRole(opts.role),
|