@saptools/cf-hana 0.1.0 → 0.1.2
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 +12 -0
- package/README.md +8 -8
- package/dist/cli.js +197 -35
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +177 -32
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2 - 2026-06-23
|
|
4
|
+
|
|
5
|
+
- Harden connection pooling so queued callers continue after transient reconnect failures.
|
|
6
|
+
- Preserve query results when HANA statement cleanup fails and close partially opened clients on schema setup errors.
|
|
7
|
+
- Strengthen read-only and destructive-statement checks around comments, quoted identifiers, and unknown statements.
|
|
8
|
+
- Improve `explain()` statement isolation, cleanup, and read-only behavior.
|
|
9
|
+
- Validate CLI numeric options strictly and align E2E diagnostics with project defaults.
|
|
10
|
+
|
|
11
|
+
## 0.1.1 - 2026-05-22
|
|
12
|
+
|
|
13
|
+
- Patch release to publish via npm trusted publishing after the manual `0.1.0` bootstrap.
|
|
14
|
+
|
|
3
15
|
## 0.1.0 - 2026-05-22
|
|
4
16
|
|
|
5
17
|
- Initial release: run SQL directly against SAP HANA Cloud databases bound to a Cloud Foundry app, addressed by a `region/org/space/app` selector (or a bare app name).
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ driver is bundled as a dependency — there is no native build step.
|
|
|
44
44
|
import { connect, query } from "@saptools/cf-hana";
|
|
45
45
|
|
|
46
46
|
// Open a reusable, pooled client for one CF app's HANA database.
|
|
47
|
-
const db = await connect("eu10/acme/dev/orders-
|
|
47
|
+
const db = await connect("eu10/acme/dev/orders-api");
|
|
48
48
|
|
|
49
49
|
const open = await db.query("SELECT ID, STATUS FROM ORDERS WHERE STATUS = ?", ["OPEN"]);
|
|
50
50
|
console.log(open.rows);
|
|
@@ -58,16 +58,16 @@ await db.transaction(async (tx) => {
|
|
|
58
58
|
await db.close();
|
|
59
59
|
|
|
60
60
|
// One-shot: connect, run one query, close.
|
|
61
|
-
const rows = await query("orders-
|
|
61
|
+
const rows = await query("orders-api", "SELECT COUNT(*) AS N FROM ORDERS");
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
## The selector
|
|
65
65
|
|
|
66
66
|
Every entry point takes a selector as its first argument:
|
|
67
67
|
|
|
68
|
-
- **Explicit** — `region/org/space/app` (e.g. `eu10/acme/dev/orders-
|
|
68
|
+
- **Explicit** — `region/org/space/app` (e.g. `eu10/acme/dev/orders-api`). Works
|
|
69
69
|
without any cached topology.
|
|
70
|
-
- **Bare app name** — `orders-
|
|
70
|
+
- **Bare app name** — `orders-api`. Resolved against the topology cached by
|
|
71
71
|
`cf-sync sync`; throws if the name is ambiguous across spaces.
|
|
72
72
|
|
|
73
73
|
## CLI
|
|
@@ -87,11 +87,11 @@ Common options: `--format <table|json|csv>`, `--refresh`, `--role <runtime|hdi>`
|
|
|
87
87
|
accepts `--param <value>` (repeatable) to bind `?` placeholders.
|
|
88
88
|
|
|
89
89
|
```bash
|
|
90
|
-
cf-hana query eu10/acme/dev/orders-
|
|
90
|
+
cf-hana query eu10/acme/dev/orders-api "SELECT ID, STATUS FROM ORDERS WHERE STATUS = ?" \
|
|
91
91
|
--param OPEN --format json
|
|
92
|
-
cf-hana tables orders-
|
|
93
|
-
cf-hana columns orders-
|
|
94
|
-
cf-hana ping eu10/acme/dev/orders-
|
|
92
|
+
cf-hana tables orders-api
|
|
93
|
+
cf-hana columns orders-api ORDERS_APP.ORDERS
|
|
94
|
+
cf-hana ping eu10/acme/dev/orders-api
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
## Programmatic API
|
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.2";
|
|
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() {
|
|
@@ -647,11 +672,100 @@ function createDriver(name) {
|
|
|
647
672
|
// src/safety.ts
|
|
648
673
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
649
674
|
var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
650
|
-
function
|
|
651
|
-
|
|
675
|
+
function skipQuotedText(sql, start) {
|
|
676
|
+
const quote = sql[start];
|
|
677
|
+
let index = start + 1;
|
|
678
|
+
while (index < sql.length) {
|
|
679
|
+
if (sql[index] === quote) {
|
|
680
|
+
if (sql[index + 1] === quote) {
|
|
681
|
+
index += 2;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
index += 1;
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
index += 1;
|
|
688
|
+
}
|
|
689
|
+
return index;
|
|
690
|
+
}
|
|
691
|
+
function skipLineComment(sql, start) {
|
|
692
|
+
let index = start + 2;
|
|
693
|
+
while (index < sql.length && sql[index] !== "\n") {
|
|
694
|
+
index += 1;
|
|
695
|
+
}
|
|
696
|
+
return index;
|
|
697
|
+
}
|
|
698
|
+
function skipBlockComment(sql, start) {
|
|
699
|
+
let index = start + 2;
|
|
700
|
+
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
701
|
+
index += 1;
|
|
702
|
+
}
|
|
703
|
+
return Math.min(index + 2, sql.length);
|
|
704
|
+
}
|
|
705
|
+
function maskIgnoredSqlText(sql) {
|
|
706
|
+
let masked = "";
|
|
707
|
+
let index = 0;
|
|
708
|
+
while (index < sql.length) {
|
|
709
|
+
const char = sql[index];
|
|
710
|
+
if (char === "'" || char === '"') {
|
|
711
|
+
const end = skipQuotedText(sql, index);
|
|
712
|
+
masked += " ".repeat(end - index);
|
|
713
|
+
index = end;
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
717
|
+
const end = skipLineComment(sql, index);
|
|
718
|
+
masked += " ".repeat(end - index);
|
|
719
|
+
index = end;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
723
|
+
const end = skipBlockComment(sql, index);
|
|
724
|
+
masked += " ".repeat(end - index);
|
|
725
|
+
index = end;
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
masked += char ?? "";
|
|
729
|
+
index += 1;
|
|
730
|
+
}
|
|
731
|
+
return masked;
|
|
652
732
|
}
|
|
653
733
|
function hasWhereClause(sql) {
|
|
654
|
-
return /\bwhere\b/i.test(
|
|
734
|
+
return /\bwhere\b/i.test(maskIgnoredSqlText(sql));
|
|
735
|
+
}
|
|
736
|
+
function trailingLineCommentIndex(sql) {
|
|
737
|
+
let index = 0;
|
|
738
|
+
while (index < sql.length) {
|
|
739
|
+
const char = sql[index];
|
|
740
|
+
if (char === "'" || char === '"') {
|
|
741
|
+
index = skipQuotedText(sql, index);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
745
|
+
index = skipBlockComment(sql, index);
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
749
|
+
const lineEnd = sql.indexOf("\n", index + 2);
|
|
750
|
+
if (lineEnd === -1 || sql.slice(lineEnd + 1).trim().length === 0) {
|
|
751
|
+
return index;
|
|
752
|
+
}
|
|
753
|
+
index = lineEnd + 1;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
index += 1;
|
|
757
|
+
}
|
|
758
|
+
return void 0;
|
|
759
|
+
}
|
|
760
|
+
function appendLimit(sql, limit) {
|
|
761
|
+
const trimmed = sql.replace(/[\s;]+$/, "");
|
|
762
|
+
const commentIndex = trailingLineCommentIndex(trimmed);
|
|
763
|
+
if (commentIndex === void 0) {
|
|
764
|
+
return `${trimmed} LIMIT ${String(limit)}`;
|
|
765
|
+
}
|
|
766
|
+
const beforeComment = trimmed.slice(0, commentIndex).replace(/[\s;]+$/, "");
|
|
767
|
+
const comment = trimmed.slice(commentIndex);
|
|
768
|
+
return `${beforeComment} LIMIT ${String(limit)} ${comment}`;
|
|
655
769
|
}
|
|
656
770
|
function inspectStatement(sql) {
|
|
657
771
|
const kind = classifyStatement(sql);
|
|
@@ -669,12 +783,12 @@ function inspectStatement(sql) {
|
|
|
669
783
|
}
|
|
670
784
|
function evaluateGuard(sql, config) {
|
|
671
785
|
const inspection = inspectStatement(sql);
|
|
672
|
-
if (config.readOnly &&
|
|
786
|
+
if (config.readOnly && inspection.kind !== "select") {
|
|
673
787
|
return {
|
|
674
788
|
allowed: false,
|
|
675
789
|
destructive: inspection.destructive,
|
|
676
790
|
violation: "read-only",
|
|
677
|
-
reason: `read-only mode blocks ${inspection.kind.toUpperCase()} statements`
|
|
791
|
+
reason: inspection.kind === "unknown" ? "read-only mode only permits SELECT/WITH statements" : `read-only mode blocks ${inspection.kind.toUpperCase()} statements`
|
|
678
792
|
};
|
|
679
793
|
}
|
|
680
794
|
if (inspection.destructive && !config.allowDestructive) {
|
|
@@ -696,12 +810,11 @@ function applyAutoLimit(sql, limit) {
|
|
|
696
810
|
if (limit === false || classifyStatement(sql) !== "select") {
|
|
697
811
|
return { sql, applied: false };
|
|
698
812
|
}
|
|
699
|
-
const stripped =
|
|
813
|
+
const stripped = maskIgnoredSqlText(sql);
|
|
700
814
|
if (/\blimit\b/i.test(stripped) || /\btop\s+\d/i.test(stripped)) {
|
|
701
815
|
return { sql, applied: false };
|
|
702
816
|
}
|
|
703
|
-
|
|
704
|
-
return { sql: `${trimmed} LIMIT ${String(limit)}`, applied: true };
|
|
817
|
+
return { sql: appendLimit(sql, limit), applied: true };
|
|
705
818
|
}
|
|
706
819
|
|
|
707
820
|
// src/connection.ts
|
|
@@ -750,6 +863,12 @@ var Connection = class _Connection {
|
|
|
750
863
|
async execute(sql, params, options) {
|
|
751
864
|
return await this.run(sql, params ?? [], options ?? {});
|
|
752
865
|
}
|
|
866
|
+
assertAllowed(sql, options) {
|
|
867
|
+
this.evaluateSafety(sql, options ?? {});
|
|
868
|
+
}
|
|
869
|
+
async executeInternal(sql, params, options) {
|
|
870
|
+
return await this.run(sql, params ?? [], options ?? {}, { bypassSafety: true });
|
|
871
|
+
}
|
|
753
872
|
async setAutoCommit(enabled) {
|
|
754
873
|
await this.driverConnection.setAutoCommit(enabled);
|
|
755
874
|
}
|
|
@@ -765,21 +884,10 @@ var Connection = class _Connection {
|
|
|
765
884
|
get isClosed() {
|
|
766
885
|
return this.driverConnection.isClosed();
|
|
767
886
|
}
|
|
768
|
-
async run(sql, params, options) {
|
|
887
|
+
async run(sql, params, options, runOptions = {}) {
|
|
769
888
|
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
|
-
);
|
|
889
|
+
if (runOptions.bypassSafety !== true) {
|
|
890
|
+
this.evaluateSafety(sql, options);
|
|
783
891
|
}
|
|
784
892
|
const kind = classifyStatement(sql);
|
|
785
893
|
const autoLimit = options.autoLimit ?? this.config.autoLimit;
|
|
@@ -809,6 +917,23 @@ var Connection = class _Connection {
|
|
|
809
917
|
} catch {
|
|
810
918
|
}
|
|
811
919
|
}
|
|
920
|
+
evaluateSafety(sql, options) {
|
|
921
|
+
const decision = evaluateGuard(sql, {
|
|
922
|
+
readOnly: this.config.readOnly,
|
|
923
|
+
allowDestructive: options.allowDestructive ?? this.config.allowDestructive
|
|
924
|
+
});
|
|
925
|
+
if (decision.allowed) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (decision.violation === "read-only") {
|
|
929
|
+
throw new ReadOnlyViolationError(
|
|
930
|
+
decision.reason ?? "read-only mode blocks this statement"
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
throw new DestructiveStatementError(
|
|
934
|
+
decision.reason ?? "destructive statement blocked"
|
|
935
|
+
);
|
|
936
|
+
}
|
|
812
937
|
};
|
|
813
938
|
|
|
814
939
|
// src/pool.ts
|
|
@@ -949,6 +1074,7 @@ var ConnectionPool = class {
|
|
|
949
1074
|
(error) => {
|
|
950
1075
|
this.created -= 1;
|
|
951
1076
|
waiter.reject(error);
|
|
1077
|
+
this.servePendingWaiters();
|
|
952
1078
|
}
|
|
953
1079
|
);
|
|
954
1080
|
}
|
|
@@ -995,6 +1121,11 @@ var Transaction = class {
|
|
|
995
1121
|
};
|
|
996
1122
|
|
|
997
1123
|
// src/client.ts
|
|
1124
|
+
var explainStatementCounter = 0;
|
|
1125
|
+
function nextExplainStatementName() {
|
|
1126
|
+
explainStatementCounter = (explainStatementCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
1127
|
+
return `cf_hana_${String(process.pid)}_${String(Date.now())}_${String(explainStatementCounter)}`;
|
|
1128
|
+
}
|
|
998
1129
|
var HanaClient = class _HanaClient {
|
|
999
1130
|
constructor(pool, info) {
|
|
1000
1131
|
this.pool = pool;
|
|
@@ -1113,21 +1244,35 @@ var HanaClient = class _HanaClient {
|
|
|
1113
1244
|
/** Return the HANA execution plan for a statement. */
|
|
1114
1245
|
async explain(sql, params) {
|
|
1115
1246
|
return await this.pool.withConnection(async (connection) => {
|
|
1116
|
-
|
|
1117
|
-
|
|
1247
|
+
connection.assertAllowed(sql);
|
|
1248
|
+
const statementName = nextExplainStatementName();
|
|
1249
|
+
await connection.executeInternal(
|
|
1118
1250
|
`EXPLAIN PLAN SET STATEMENT_NAME = '${statementName}' FOR ${sql}`,
|
|
1119
1251
|
params
|
|
1120
1252
|
);
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
"
|
|
1253
|
+
const cleanup = async () => {
|
|
1254
|
+
await connection.executeInternal(
|
|
1255
|
+
"DELETE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ?",
|
|
1124
1256
|
[statementName]
|
|
1125
1257
|
);
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1258
|
+
};
|
|
1259
|
+
let queryCompleted = false;
|
|
1260
|
+
try {
|
|
1261
|
+
const result = await connection.query(
|
|
1262
|
+
"SELECT OPERATOR_NAME, TABLE_NAME, TABLE_TYPE, EXECUTION_ENGINE FROM EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = ? ORDER BY OPERATOR_ID",
|
|
1129
1263
|
[statementName]
|
|
1130
1264
|
);
|
|
1265
|
+
queryCompleted = true;
|
|
1266
|
+
await cleanup();
|
|
1267
|
+
return result;
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
if (!queryCompleted) {
|
|
1270
|
+
try {
|
|
1271
|
+
await cleanup();
|
|
1272
|
+
} catch {
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
throw error;
|
|
1131
1276
|
}
|
|
1132
1277
|
});
|
|
1133
1278
|
}
|
|
@@ -1238,12 +1383,26 @@ function fail(message) {
|
|
|
1238
1383
|
process.exit(1);
|
|
1239
1384
|
}
|
|
1240
1385
|
function parseIntOption(value) {
|
|
1241
|
-
const
|
|
1242
|
-
if (
|
|
1386
|
+
const trimmed = value.trim();
|
|
1387
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
1243
1388
|
throw new CfHanaError("CONFIG", `Expected an integer but received "${value}"`);
|
|
1244
1389
|
}
|
|
1390
|
+
const parsed = Number(trimmed);
|
|
1391
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
1392
|
+
throw new CfHanaError("CONFIG", `Expected a safe integer but received "${value}"`);
|
|
1393
|
+
}
|
|
1245
1394
|
return parsed;
|
|
1246
1395
|
}
|
|
1396
|
+
function assertPositiveOption(name, value) {
|
|
1397
|
+
if (value !== void 0 && value <= 0) {
|
|
1398
|
+
throw new CfHanaError("CONFIG", `${name} must be a positive integer`);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
function assertNonNegativeOption(name, value) {
|
|
1402
|
+
if (value !== void 0 && value < 0) {
|
|
1403
|
+
throw new CfHanaError("CONFIG", `${name} must be a non-negative integer`);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1247
1406
|
function collectParam(value, previous) {
|
|
1248
1407
|
return [...previous, value];
|
|
1249
1408
|
}
|
|
@@ -1267,6 +1426,9 @@ function parseQualifiedName(value) {
|
|
|
1267
1426
|
return { schema: value.slice(0, dot), table: value.slice(dot + 1) };
|
|
1268
1427
|
}
|
|
1269
1428
|
function toConnectOptions(opts) {
|
|
1429
|
+
assertPositiveOption("--limit", opts.limit);
|
|
1430
|
+
assertPositiveOption("--timeout", opts.timeout);
|
|
1431
|
+
assertNonNegativeOption("--binding-index", opts.bindingIndex);
|
|
1270
1432
|
return {
|
|
1271
1433
|
refresh: opts.refresh,
|
|
1272
1434
|
role: parseRole(opts.role),
|