@saptools/cf-hana 0.1.5 → 0.2.0
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 +23 -0
- package/README.md +57 -9
- package/dist/cli.js +890 -26
- package/dist/cli.js.map +1 -1
- package/dist/index.js +57 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -52,6 +52,59 @@ function errorMessage(error) {
|
|
|
52
52
|
return String(error);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// src/result-preview.ts
|
|
56
|
+
function normalizePreviewChar(value) {
|
|
57
|
+
return value === "\r" || value === "\n" || value === " " ? " " : value;
|
|
58
|
+
}
|
|
59
|
+
function previewText(value, limit) {
|
|
60
|
+
let originalLength = 0;
|
|
61
|
+
let text = "";
|
|
62
|
+
for (const char of value) {
|
|
63
|
+
if (originalLength < limit) {
|
|
64
|
+
text += normalizePreviewChar(char);
|
|
65
|
+
}
|
|
66
|
+
originalLength += 1;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
text,
|
|
70
|
+
truncated: originalLength > limit,
|
|
71
|
+
originalLength,
|
|
72
|
+
unit: "chars"
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function previewBuffer(value, limit) {
|
|
76
|
+
const fullLength = 2 + value.length * 2;
|
|
77
|
+
const visibleBytes = Math.max(0, Math.floor((limit - 2) / 2));
|
|
78
|
+
const hex = value.subarray(0, visibleBytes).toString("hex");
|
|
79
|
+
return {
|
|
80
|
+
text: `0x${hex}`.slice(0, limit),
|
|
81
|
+
truncated: fullLength > limit,
|
|
82
|
+
originalLength: value.length,
|
|
83
|
+
unit: "bytes"
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function scalarText(value) {
|
|
87
|
+
if (value instanceof Date) {
|
|
88
|
+
return value.toISOString();
|
|
89
|
+
}
|
|
90
|
+
if (typeof value === "boolean") {
|
|
91
|
+
return value ? "true" : "false";
|
|
92
|
+
}
|
|
93
|
+
return value.toString();
|
|
94
|
+
}
|
|
95
|
+
function previewCell(value, limit) {
|
|
96
|
+
if (value === null) {
|
|
97
|
+
return { text: "", truncated: false, originalLength: 0, unit: "chars" };
|
|
98
|
+
}
|
|
99
|
+
if (Buffer.isBuffer(value)) {
|
|
100
|
+
return previewBuffer(value, limit);
|
|
101
|
+
}
|
|
102
|
+
if (typeof value === "string") {
|
|
103
|
+
return previewText(value, limit);
|
|
104
|
+
}
|
|
105
|
+
return previewText(scalarText(value), limit);
|
|
106
|
+
}
|
|
107
|
+
|
|
55
108
|
// src/format.ts
|
|
56
109
|
function cellText(value, nullText) {
|
|
57
110
|
if (value === null) {
|
|
@@ -126,6 +179,22 @@ function formatCsv(result) {
|
|
|
126
179
|
}
|
|
127
180
|
return lines.join("\r\n");
|
|
128
181
|
}
|
|
182
|
+
function formatCompactCsv(result, cellLimit) {
|
|
183
|
+
const headers = result.columns.map((column) => column.name);
|
|
184
|
+
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
185
|
+
let truncatedCells = 0;
|
|
186
|
+
for (const row of result.rows) {
|
|
187
|
+
const cells = result.columns.map((column) => {
|
|
188
|
+
const preview = previewCell(row[column.name] ?? null, cellLimit);
|
|
189
|
+
if (preview.truncated) {
|
|
190
|
+
truncatedCells += 1;
|
|
191
|
+
}
|
|
192
|
+
return csvEscape(preview.text);
|
|
193
|
+
});
|
|
194
|
+
lines.push(cells.join(","));
|
|
195
|
+
}
|
|
196
|
+
return { text: lines.join("\r\n"), truncatedCells };
|
|
197
|
+
}
|
|
129
198
|
function formatResult(result, format) {
|
|
130
199
|
switch (format) {
|
|
131
200
|
case "table":
|
|
@@ -481,14 +550,17 @@ function buildDelete(schema, table, where) {
|
|
|
481
550
|
// src/catalog.ts
|
|
482
551
|
async function listSchemas(connection) {
|
|
483
552
|
const result = await connection.query(
|
|
484
|
-
"SELECT SCHEMA_NAME FROM SYS.SCHEMAS ORDER BY SCHEMA_NAME"
|
|
553
|
+
"SELECT SCHEMA_NAME FROM SYS.SCHEMAS ORDER BY SCHEMA_NAME",
|
|
554
|
+
[],
|
|
555
|
+
{ autoLimit: false }
|
|
485
556
|
);
|
|
486
557
|
return result.rows.map((row) => row.SCHEMA_NAME);
|
|
487
558
|
}
|
|
488
559
|
async function listTables(connection, schema) {
|
|
489
560
|
const result = await connection.query(
|
|
490
561
|
"SELECT SCHEMA_NAME, TABLE_NAME, TABLE_TYPE FROM SYS.TABLES WHERE SCHEMA_NAME = ? ORDER BY TABLE_NAME",
|
|
491
|
-
[schema]
|
|
562
|
+
[schema],
|
|
563
|
+
{ autoLimit: false }
|
|
492
564
|
);
|
|
493
565
|
return result.rows.map((row) => ({
|
|
494
566
|
schema: row.SCHEMA_NAME,
|
|
@@ -500,7 +572,8 @@ async function listTables(connection, schema) {
|
|
|
500
572
|
async function listColumns(connection, schema, table) {
|
|
501
573
|
const result = await connection.query(
|
|
502
574
|
"SELECT COLUMN_NAME, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE, POSITION FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION",
|
|
503
|
-
[schema, table]
|
|
575
|
+
[schema, table],
|
|
576
|
+
{ autoLimit: false }
|
|
504
577
|
);
|
|
505
578
|
return result.rows.map((row) => ({
|
|
506
579
|
name: row.COLUMN_NAME,
|
|
@@ -514,13 +587,18 @@ async function listColumns(connection, schema, table) {
|
|
|
514
587
|
|
|
515
588
|
// src/config.ts
|
|
516
589
|
var CLI_NAME = "cf-hana";
|
|
517
|
-
var CLI_VERSION = "0.
|
|
590
|
+
var CLI_VERSION = "0.2.0";
|
|
518
591
|
var ENV_PREFIX = "CF_HANA";
|
|
519
592
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
520
593
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
521
594
|
var DEFAULT_POOL_MAX = 4;
|
|
522
595
|
var DEFAULT_POOL_IDLE_MS = 6e4;
|
|
523
|
-
var DEFAULT_AUTO_LIMIT =
|
|
596
|
+
var DEFAULT_AUTO_LIMIT = 100;
|
|
597
|
+
var DEFAULT_CELL_LIMIT = 128;
|
|
598
|
+
var MAX_CELL_LIMIT = 1e4;
|
|
599
|
+
var DEFAULT_RESULT_TTL_MINUTES = 60;
|
|
600
|
+
var DEFAULT_RESULT_SEARCH_LIMIT = 20;
|
|
601
|
+
var MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
|
|
524
602
|
function envName(suffix) {
|
|
525
603
|
return `${ENV_PREFIX}_${suffix}`;
|
|
526
604
|
}
|
|
@@ -633,7 +711,13 @@ function toConnectionTarget(binding, role) {
|
|
|
633
711
|
}
|
|
634
712
|
|
|
635
713
|
// src/driver/fake.ts
|
|
714
|
+
import { appendFile } from "fs/promises";
|
|
636
715
|
function fakeExec(sql) {
|
|
716
|
+
const kind = classifyStatement(sql);
|
|
717
|
+
const forcedFailure = readEnv(envName("FAKE_FAIL_STATEMENT"))?.toLowerCase();
|
|
718
|
+
if (forcedFailure === kind) {
|
|
719
|
+
throw new Error(`fake driver forced ${kind.toUpperCase()} failure`);
|
|
720
|
+
}
|
|
637
721
|
if (sql.toUpperCase().includes("DUMMY")) {
|
|
638
722
|
return {
|
|
639
723
|
rows: [{ "1": 1 }],
|
|
@@ -641,7 +725,6 @@ function fakeExec(sql) {
|
|
|
641
725
|
affectedRows: 0
|
|
642
726
|
};
|
|
643
727
|
}
|
|
644
|
-
const kind = classifyStatement(sql);
|
|
645
728
|
if (kind === "select") {
|
|
646
729
|
return {
|
|
647
730
|
rows: [
|
|
@@ -660,10 +743,23 @@ function fakeExec(sql) {
|
|
|
660
743
|
}
|
|
661
744
|
return { rows: [], columns: [], affectedRows: 0 };
|
|
662
745
|
}
|
|
746
|
+
async function traceFakeExec(sql, params) {
|
|
747
|
+
const tracePath = readEnv(envName("FAKE_TRACE_FILE"));
|
|
748
|
+
if (tracePath === void 0) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const entry = { sql, paramCount: params.length };
|
|
752
|
+
await appendFile(tracePath, `${JSON.stringify(entry)}
|
|
753
|
+
`, {
|
|
754
|
+
encoding: "utf8",
|
|
755
|
+
mode: 384
|
|
756
|
+
});
|
|
757
|
+
}
|
|
663
758
|
var FakeConnection = class {
|
|
664
759
|
closed = false;
|
|
665
|
-
exec(sql,
|
|
666
|
-
|
|
760
|
+
async exec(sql, params) {
|
|
761
|
+
await traceFakeExec(sql, params);
|
|
762
|
+
return fakeExec(sql);
|
|
667
763
|
}
|
|
668
764
|
setAutoCommit(_enabled) {
|
|
669
765
|
return Promise.resolve();
|
|
@@ -937,7 +1033,7 @@ function createDriver(name) {
|
|
|
937
1033
|
}
|
|
938
1034
|
|
|
939
1035
|
// src/history.ts
|
|
940
|
-
import { appendFile, mkdir as mkdir2, readdir, rm } from "fs/promises";
|
|
1036
|
+
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm } from "fs/promises";
|
|
941
1037
|
import { homedir as homedir2 } from "os";
|
|
942
1038
|
import { join as join2 } from "path";
|
|
943
1039
|
var SAPTOOLS_DIR_NAME2 = ".saptools";
|
|
@@ -999,7 +1095,7 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
999
1095
|
...input
|
|
1000
1096
|
};
|
|
1001
1097
|
await mkdir2(historyDir, { recursive: true, mode: 448 });
|
|
1002
|
-
await
|
|
1098
|
+
await appendFile2(sqlHistoryFilePath(now, options.saptoolsRoot), `${JSON.stringify(entry)}
|
|
1003
1099
|
`, {
|
|
1004
1100
|
encoding: "utf8",
|
|
1005
1101
|
mode: 384
|
|
@@ -1156,10 +1252,28 @@ function applyAutoLimit(sql, limit) {
|
|
|
1156
1252
|
if (/\blimit\b/i.test(stripped) || /\btop\s+\d/i.test(stripped)) {
|
|
1157
1253
|
return { sql, applied: false };
|
|
1158
1254
|
}
|
|
1159
|
-
return {
|
|
1255
|
+
return {
|
|
1256
|
+
sql: appendLimit(sql, limit + 1),
|
|
1257
|
+
applied: true,
|
|
1258
|
+
requestedLimit: limit
|
|
1259
|
+
};
|
|
1160
1260
|
}
|
|
1161
1261
|
|
|
1162
1262
|
// src/connection.ts
|
|
1263
|
+
function assertValidAutoLimit(value) {
|
|
1264
|
+
if (value !== false && (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER)) {
|
|
1265
|
+
throw new CfHanaError("CONFIG", "autoLimit must be a positive safe integer");
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
function boundedRows(rows, requestedLimit) {
|
|
1269
|
+
if (requestedLimit === void 0) {
|
|
1270
|
+
return { rows, truncated: false };
|
|
1271
|
+
}
|
|
1272
|
+
return {
|
|
1273
|
+
rows: rows.slice(0, requestedLimit),
|
|
1274
|
+
truncated: rows.length > requestedLimit
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1163
1277
|
async function withTimeout(work, timeoutMs, onTimeout) {
|
|
1164
1278
|
let timer;
|
|
1165
1279
|
const timeout = new Promise((_resolve, reject) => {
|
|
@@ -1233,6 +1347,7 @@ var Connection = class _Connection {
|
|
|
1233
1347
|
}
|
|
1234
1348
|
const kind = classifyStatement(sql);
|
|
1235
1349
|
const autoLimit = options.autoLimit ?? this.config.autoLimit;
|
|
1350
|
+
assertValidAutoLimit(autoLimit);
|
|
1236
1351
|
const limited = kind === "select" ? applyAutoLimit(sql, autoLimit) : { sql, applied: false };
|
|
1237
1352
|
const timeoutMs = options.timeoutMs ?? this.config.queryTimeoutMs;
|
|
1238
1353
|
const started = Date.now();
|
|
@@ -1244,12 +1359,13 @@ var Connection = class _Connection {
|
|
|
1244
1359
|
}
|
|
1245
1360
|
);
|
|
1246
1361
|
const elapsedMs = Date.now() - started;
|
|
1362
|
+
const selected = boundedRows(execResult.rows, limited.requestedLimit);
|
|
1247
1363
|
return {
|
|
1248
|
-
rows:
|
|
1364
|
+
rows: selected.rows,
|
|
1249
1365
|
columns: execResult.columns,
|
|
1250
|
-
rowCount: kind === "select" ?
|
|
1366
|
+
rowCount: kind === "select" ? selected.rows.length : execResult.affectedRows,
|
|
1251
1367
|
statement: kind,
|
|
1252
|
-
truncated:
|
|
1368
|
+
truncated: selected.truncated,
|
|
1253
1369
|
elapsedMs
|
|
1254
1370
|
};
|
|
1255
1371
|
}
|
|
@@ -1681,17 +1797,711 @@ async function connect(selector, options) {
|
|
|
1681
1797
|
return await HanaClient.connect(selector, options);
|
|
1682
1798
|
}
|
|
1683
1799
|
|
|
1684
|
-
// src/cli.ts
|
|
1800
|
+
// src/cli-results.ts
|
|
1801
|
+
import { writeFile as writeFile3 } from "fs/promises";
|
|
1802
|
+
|
|
1803
|
+
// src/result-inspect.ts
|
|
1804
|
+
function assertPositiveInteger(name, value) {
|
|
1805
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1806
|
+
throw new CfHanaError(
|
|
1807
|
+
"CONFIG",
|
|
1808
|
+
`${name} ${String(value)} must be a positive safe integer`
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
function selectResultRow(session, rowNumber) {
|
|
1813
|
+
assertPositiveInteger("row", rowNumber);
|
|
1814
|
+
const row = session.result.rows[rowNumber - 1];
|
|
1815
|
+
if (row === void 0) {
|
|
1816
|
+
throw new CfHanaError("QUERY", `Saved result row ${String(rowNumber)} not found`);
|
|
1817
|
+
}
|
|
1818
|
+
return row;
|
|
1819
|
+
}
|
|
1820
|
+
function selectResultCell(session, rowNumber, columnName) {
|
|
1821
|
+
const row = selectResultRow(session, rowNumber);
|
|
1822
|
+
const column = session.result.columns.find((item) => item.name === columnName);
|
|
1823
|
+
if (column === void 0) {
|
|
1824
|
+
throw new CfHanaError("QUERY", `Saved result column "${columnName}" not found`);
|
|
1825
|
+
}
|
|
1826
|
+
return {
|
|
1827
|
+
row: rowNumber,
|
|
1828
|
+
column: column.name,
|
|
1829
|
+
typeName: column.typeName,
|
|
1830
|
+
value: row[column.name] ?? null
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
function textRange(value, offset, length) {
|
|
1834
|
+
let index = 0;
|
|
1835
|
+
let selected = "";
|
|
1836
|
+
for (const char of value) {
|
|
1837
|
+
if (index >= offset && index < offset + length) {
|
|
1838
|
+
selected += char;
|
|
1839
|
+
}
|
|
1840
|
+
index += 1;
|
|
1841
|
+
}
|
|
1842
|
+
return { type: "text", originalLength: index, offset, value: selected };
|
|
1843
|
+
}
|
|
1844
|
+
function readCellWindow(value, offset, length) {
|
|
1845
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
1846
|
+
throw new CfHanaError("CONFIG", "offset must be a non-negative safe integer");
|
|
1847
|
+
}
|
|
1848
|
+
assertPositiveInteger("length", length);
|
|
1849
|
+
if (Buffer.isBuffer(value)) {
|
|
1850
|
+
return {
|
|
1851
|
+
type: "binary",
|
|
1852
|
+
originalLength: value.length,
|
|
1853
|
+
offset,
|
|
1854
|
+
value: `0x${value.subarray(offset, offset + length).toString("hex")}`
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
if (typeof value === "string") {
|
|
1858
|
+
return textRange(value, offset, length);
|
|
1859
|
+
}
|
|
1860
|
+
const text = previewCell(value, Number.MAX_SAFE_INTEGER).text;
|
|
1861
|
+
const window = textRange(text, offset, length);
|
|
1862
|
+
return { ...window, type: "scalar" };
|
|
1863
|
+
}
|
|
1864
|
+
function decodePointerToken(token) {
|
|
1865
|
+
if (/~(?:[^01]|$)/.test(token)) {
|
|
1866
|
+
throw new CfHanaError("CONFIG", "Invalid JSON Pointer escape");
|
|
1867
|
+
}
|
|
1868
|
+
return token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
1869
|
+
}
|
|
1870
|
+
function pointerTokens(pointer) {
|
|
1871
|
+
if (pointer === "") {
|
|
1872
|
+
return [];
|
|
1873
|
+
}
|
|
1874
|
+
if (!pointer.startsWith("/")) {
|
|
1875
|
+
throw new CfHanaError("CONFIG", "JSON Pointer must be empty or start with /");
|
|
1876
|
+
}
|
|
1877
|
+
return pointer.slice(1).split("/").map(decodePointerToken);
|
|
1878
|
+
}
|
|
1879
|
+
function resolvePointer(root, pointer) {
|
|
1880
|
+
let current = root;
|
|
1881
|
+
for (const token of pointerTokens(pointer)) {
|
|
1882
|
+
if (Array.isArray(current)) {
|
|
1883
|
+
const index = Number(token);
|
|
1884
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= current.length) {
|
|
1885
|
+
throw new CfHanaError("QUERY", `JSON Pointer path "${pointer}" not found`);
|
|
1886
|
+
}
|
|
1887
|
+
current = current[index];
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
if (typeof current === "object" && current !== null && token in current) {
|
|
1891
|
+
current = current[token];
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
throw new CfHanaError("QUERY", `JSON Pointer path "${pointer}" not found`);
|
|
1895
|
+
}
|
|
1896
|
+
return current;
|
|
1897
|
+
}
|
|
1898
|
+
function escapePointerToken(token) {
|
|
1899
|
+
return token.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
1900
|
+
}
|
|
1901
|
+
function jsonType(value) {
|
|
1902
|
+
if (value === null) {
|
|
1903
|
+
return "null";
|
|
1904
|
+
}
|
|
1905
|
+
if (Array.isArray(value)) {
|
|
1906
|
+
return "array";
|
|
1907
|
+
}
|
|
1908
|
+
return typeof value;
|
|
1909
|
+
}
|
|
1910
|
+
function jsonValueText(value, limit) {
|
|
1911
|
+
if (Array.isArray(value)) {
|
|
1912
|
+
return `items=${String(value.length)}`;
|
|
1913
|
+
}
|
|
1914
|
+
if (typeof value === "object" && value !== null) {
|
|
1915
|
+
return `keys=${String(Object.keys(value).length)}`;
|
|
1916
|
+
}
|
|
1917
|
+
if (typeof value === "string") {
|
|
1918
|
+
return previewCell(value, limit).text;
|
|
1919
|
+
}
|
|
1920
|
+
if (value === null) {
|
|
1921
|
+
return "null";
|
|
1922
|
+
}
|
|
1923
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
1924
|
+
return String(value);
|
|
1925
|
+
}
|
|
1926
|
+
return "";
|
|
1927
|
+
}
|
|
1928
|
+
function jsonSearchText(value) {
|
|
1929
|
+
if (typeof value === "string") {
|
|
1930
|
+
return value;
|
|
1931
|
+
}
|
|
1932
|
+
if (value === null) {
|
|
1933
|
+
return "null";
|
|
1934
|
+
}
|
|
1935
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
1936
|
+
return String(value);
|
|
1937
|
+
}
|
|
1938
|
+
return "";
|
|
1939
|
+
}
|
|
1940
|
+
function inspectionRow(path, value, limit) {
|
|
1941
|
+
return { path, type: jsonType(value), value: jsonValueText(value, limit) };
|
|
1942
|
+
}
|
|
1943
|
+
function inspectJsonCell(value, pointer, limit) {
|
|
1944
|
+
if (typeof value !== "string") {
|
|
1945
|
+
throw new CfHanaError("QUERY", "JSON Pointer requires a text cell");
|
|
1946
|
+
}
|
|
1947
|
+
let parsed;
|
|
1948
|
+
try {
|
|
1949
|
+
parsed = JSON.parse(value);
|
|
1950
|
+
} catch (error) {
|
|
1951
|
+
throw new CfHanaError("QUERY", "Saved result cell does not contain valid JSON", {
|
|
1952
|
+
cause: error
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
const selected = resolvePointer(parsed, pointer);
|
|
1956
|
+
if (Array.isArray(selected)) {
|
|
1957
|
+
return selected.map(
|
|
1958
|
+
(item, index) => inspectionRow(`${pointer}/${String(index)}`, item, limit)
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
if (typeof selected === "object" && selected !== null) {
|
|
1962
|
+
return Object.entries(selected).map(
|
|
1963
|
+
([key, item]) => inspectionRow(`${pointer}/${escapePointerToken(key)}`, item, limit)
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
return [inspectionRow(pointer, selected, limit)];
|
|
1967
|
+
}
|
|
1968
|
+
function plainTextMatches(text, term, row, column, limit, previewLength) {
|
|
1969
|
+
const matches = [];
|
|
1970
|
+
const lowerText = text.toLowerCase();
|
|
1971
|
+
let offset = lowerText.indexOf(term);
|
|
1972
|
+
while (offset >= 0 && matches.length < limit) {
|
|
1973
|
+
const start = Math.max(0, offset - 32);
|
|
1974
|
+
matches.push({
|
|
1975
|
+
row,
|
|
1976
|
+
column,
|
|
1977
|
+
offset,
|
|
1978
|
+
path: "",
|
|
1979
|
+
preview: textRange(text, start, previewLength).value.replaceAll(/[\r\n\t]/g, " ")
|
|
1980
|
+
});
|
|
1981
|
+
offset = lowerText.indexOf(term, offset + Math.max(1, term.length));
|
|
1982
|
+
}
|
|
1983
|
+
return matches;
|
|
1984
|
+
}
|
|
1985
|
+
function jsonSearchMatches(root, term, row, column, limit, previewLength) {
|
|
1986
|
+
const matches = [];
|
|
1987
|
+
const stack = [
|
|
1988
|
+
{ path: "", value: root }
|
|
1989
|
+
];
|
|
1990
|
+
while (stack.length > 0 && matches.length < limit) {
|
|
1991
|
+
const entry = stack.pop();
|
|
1992
|
+
if (entry === void 0) {
|
|
1993
|
+
break;
|
|
1994
|
+
}
|
|
1995
|
+
if (typeof entry.value === "object" && entry.value !== null) {
|
|
1996
|
+
const entries = Array.isArray(entry.value) ? entry.value.map((item, index) => [String(index), item]) : Object.entries(entry.value);
|
|
1997
|
+
for (const [key, value] of entries.reverse()) {
|
|
1998
|
+
const path = `${entry.path}/${escapePointerToken(key)}`;
|
|
1999
|
+
if (key.toLowerCase().includes(term)) {
|
|
2000
|
+
matches.push({ row, column, path, preview: jsonValueText(value, previewLength) });
|
|
2001
|
+
if (matches.length >= limit) {
|
|
2002
|
+
break;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
stack.push({ path, value });
|
|
2006
|
+
}
|
|
2007
|
+
continue;
|
|
2008
|
+
}
|
|
2009
|
+
const text = jsonSearchText(entry.value);
|
|
2010
|
+
if (text.toLowerCase().includes(term)) {
|
|
2011
|
+
matches.push({
|
|
2012
|
+
row,
|
|
2013
|
+
column,
|
|
2014
|
+
path: entry.path,
|
|
2015
|
+
preview: previewCell(text, previewLength).text
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
return matches;
|
|
2020
|
+
}
|
|
2021
|
+
function searchCell(value, term, row, column, limit, previewLength) {
|
|
2022
|
+
if (typeof value !== "string") {
|
|
2023
|
+
return [];
|
|
2024
|
+
}
|
|
2025
|
+
try {
|
|
2026
|
+
const parsed = JSON.parse(value);
|
|
2027
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
2028
|
+
return jsonSearchMatches(parsed, term, row, column, limit, previewLength);
|
|
2029
|
+
}
|
|
2030
|
+
} catch {
|
|
2031
|
+
}
|
|
2032
|
+
return plainTextMatches(value, term, row, column, limit, previewLength);
|
|
2033
|
+
}
|
|
2034
|
+
function searchResultSession(session, searchTerm, options) {
|
|
2035
|
+
assertPositiveInteger("limit", options.limit);
|
|
2036
|
+
const term = searchTerm.trim().toLowerCase();
|
|
2037
|
+
if (term.length === 0) {
|
|
2038
|
+
throw new CfHanaError("CONFIG", "search text must not be empty");
|
|
2039
|
+
}
|
|
2040
|
+
const previewLength = options.previewLength ?? 128;
|
|
2041
|
+
assertPositiveInteger("preview length", previewLength);
|
|
2042
|
+
const rows = options.row === void 0 ? session.result.rows.map((row, index) => ({ row, rowNumber: index + 1 })) : [{ row: selectResultRow(session, options.row), rowNumber: options.row }];
|
|
2043
|
+
const columns = options.column === void 0 ? session.result.columns : [selectResultCell(session, rows[0]?.rowNumber ?? 1, options.column)].map((item) => ({ name: item.column, typeName: item.typeName }));
|
|
2044
|
+
const matches = [];
|
|
2045
|
+
for (const item of rows) {
|
|
2046
|
+
for (const column of columns) {
|
|
2047
|
+
const remaining = options.limit - matches.length;
|
|
2048
|
+
matches.push(
|
|
2049
|
+
...searchCell(
|
|
2050
|
+
item.row[column.name] ?? null,
|
|
2051
|
+
term,
|
|
2052
|
+
item.rowNumber,
|
|
2053
|
+
column.name,
|
|
2054
|
+
remaining,
|
|
2055
|
+
previewLength
|
|
2056
|
+
)
|
|
2057
|
+
);
|
|
2058
|
+
if (matches.length >= options.limit) {
|
|
2059
|
+
return matches;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return matches;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// src/result-store.ts
|
|
2067
|
+
import { randomBytes } from "crypto";
|
|
2068
|
+
import { mkdir as mkdir3, readFile, readdir as readdir2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
2069
|
+
import { homedir as homedir3 } from "os";
|
|
2070
|
+
import { join as join3 } from "path";
|
|
2071
|
+
var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
|
|
2072
|
+
var MANIFEST_FILE_NAME = "manifest.json";
|
|
2073
|
+
function resultsRoot(saptoolsRoot) {
|
|
2074
|
+
return join3(saptoolsRoot ?? join3(homedir3(), ".saptools"), "cf-hana", "results");
|
|
2075
|
+
}
|
|
2076
|
+
function sessionDirectory(ref, saptoolsRoot) {
|
|
2077
|
+
return join3(resultsRoot(saptoolsRoot), ref);
|
|
2078
|
+
}
|
|
2079
|
+
function manifestPath(ref, saptoolsRoot) {
|
|
2080
|
+
return join3(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
|
|
2081
|
+
}
|
|
2082
|
+
function encodeCell(value) {
|
|
2083
|
+
if (value === null) {
|
|
2084
|
+
return { kind: "null" };
|
|
2085
|
+
}
|
|
2086
|
+
if (Buffer.isBuffer(value)) {
|
|
2087
|
+
return { kind: "buffer", value: value.toString("base64") };
|
|
2088
|
+
}
|
|
2089
|
+
if (value instanceof Date) {
|
|
2090
|
+
return { kind: "date", value: value.toISOString() };
|
|
2091
|
+
}
|
|
2092
|
+
if (typeof value === "string") {
|
|
2093
|
+
return { kind: "string", value };
|
|
2094
|
+
}
|
|
2095
|
+
if (typeof value === "number") {
|
|
2096
|
+
return { kind: "number", value };
|
|
2097
|
+
}
|
|
2098
|
+
return { kind: "boolean", value };
|
|
2099
|
+
}
|
|
2100
|
+
function decodeCell(cell) {
|
|
2101
|
+
switch (cell.kind) {
|
|
2102
|
+
case "null":
|
|
2103
|
+
return null;
|
|
2104
|
+
case "buffer":
|
|
2105
|
+
return Buffer.from(cell.value, "base64");
|
|
2106
|
+
case "date":
|
|
2107
|
+
return new Date(cell.value);
|
|
2108
|
+
case "string":
|
|
2109
|
+
return cell.value;
|
|
2110
|
+
case "number":
|
|
2111
|
+
return cell.value;
|
|
2112
|
+
case "boolean":
|
|
2113
|
+
return cell.value;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
function assertUniqueColumns(columns) {
|
|
2117
|
+
const names = /* @__PURE__ */ new Set();
|
|
2118
|
+
for (const column of columns) {
|
|
2119
|
+
if (names.has(column.name)) {
|
|
2120
|
+
throw new CfHanaError(
|
|
2121
|
+
"CONFIG",
|
|
2122
|
+
`Saved results require unique SQL aliases; duplicate column "${column.name}"`
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
names.add(column.name);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
function encodeResult(result) {
|
|
2129
|
+
assertUniqueColumns(result.columns);
|
|
2130
|
+
return {
|
|
2131
|
+
columns: result.columns,
|
|
2132
|
+
rows: result.rows.map(
|
|
2133
|
+
(row) => result.columns.map((column) => encodeCell(row[column.name] ?? null))
|
|
2134
|
+
),
|
|
2135
|
+
rowCount: result.rowCount,
|
|
2136
|
+
statement: result.statement,
|
|
2137
|
+
truncated: result.truncated,
|
|
2138
|
+
elapsedMs: result.elapsedMs
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
function decodeResult(result) {
|
|
2142
|
+
const rows = result.rows.map((cells) => {
|
|
2143
|
+
const row = {};
|
|
2144
|
+
let index = 0;
|
|
2145
|
+
for (const column of result.columns) {
|
|
2146
|
+
row[column.name] = decodeCell(cells[index] ?? { kind: "null" });
|
|
2147
|
+
index += 1;
|
|
2148
|
+
}
|
|
2149
|
+
return row;
|
|
2150
|
+
});
|
|
2151
|
+
return { ...result, rows };
|
|
2152
|
+
}
|
|
2153
|
+
function resolveRef(value) {
|
|
2154
|
+
const ref = value ?? `q${randomBytes(4).toString("hex")}`;
|
|
2155
|
+
if (!RESULT_REF_PATTERN.test(ref)) {
|
|
2156
|
+
throw new CfHanaError("CONFIG", "Invalid saved result ref");
|
|
2157
|
+
}
|
|
2158
|
+
return ref;
|
|
2159
|
+
}
|
|
2160
|
+
function resolveTtl(value) {
|
|
2161
|
+
const ttl = value ?? DEFAULT_RESULT_TTL_MINUTES;
|
|
2162
|
+
if (!Number.isSafeInteger(ttl) || ttl <= 0) {
|
|
2163
|
+
throw new CfHanaError("CONFIG", "Result TTL must be a positive safe integer");
|
|
2164
|
+
}
|
|
2165
|
+
return ttl;
|
|
2166
|
+
}
|
|
2167
|
+
function toStoredSession(input, ref, now) {
|
|
2168
|
+
const ttlMinutes = resolveTtl(input.ttlMinutes);
|
|
2169
|
+
return {
|
|
2170
|
+
version: 1,
|
|
2171
|
+
ref,
|
|
2172
|
+
createdAt: now.toISOString(),
|
|
2173
|
+
expiresAt: new Date(now.getTime() + ttlMinutes * 6e4).toISOString(),
|
|
2174
|
+
ttlMinutes,
|
|
2175
|
+
info: input.info,
|
|
2176
|
+
result: encodeResult(input.result)
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
function isRecord(value) {
|
|
2180
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2181
|
+
}
|
|
2182
|
+
function isStoredSession(value) {
|
|
2183
|
+
if (!isRecord(value) || !isRecord(value["result"]) || !isRecord(value["info"])) {
|
|
2184
|
+
return false;
|
|
2185
|
+
}
|
|
2186
|
+
const result = value["result"];
|
|
2187
|
+
return value["version"] === 1 && typeof value["ref"] === "string" && typeof value["createdAt"] === "string" && typeof value["expiresAt"] === "string" && typeof value["ttlMinutes"] === "number" && Array.isArray(result["columns"]) && Array.isArray(result["rows"]);
|
|
2188
|
+
}
|
|
2189
|
+
async function readStoredSession(path) {
|
|
2190
|
+
try {
|
|
2191
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
2192
|
+
return isStoredSession(parsed) ? parsed : void 0;
|
|
2193
|
+
} catch (error) {
|
|
2194
|
+
if (error.code === "ENOENT") {
|
|
2195
|
+
return void 0;
|
|
2196
|
+
}
|
|
2197
|
+
return void 0;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
function toSession(stored, saptoolsRoot) {
|
|
2201
|
+
return {
|
|
2202
|
+
...stored,
|
|
2203
|
+
result: decodeResult(stored.result),
|
|
2204
|
+
directory: sessionDirectory(stored.ref, saptoolsRoot),
|
|
2205
|
+
path: manifestPath(stored.ref, saptoolsRoot)
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
async function listSessionRefs(saptoolsRoot) {
|
|
2209
|
+
try {
|
|
2210
|
+
const entries = await readdir2(resultsRoot(saptoolsRoot), { withFileTypes: true });
|
|
2211
|
+
return entries.filter((entry) => entry.isDirectory() && RESULT_REF_PATTERN.test(entry.name)).map((entry) => entry.name);
|
|
2212
|
+
} catch (error) {
|
|
2213
|
+
if (error.code === "ENOENT") {
|
|
2214
|
+
return [];
|
|
2215
|
+
}
|
|
2216
|
+
throw error;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
async function createResultSession(input, options = {}) {
|
|
2220
|
+
await pruneResultSessions(options);
|
|
2221
|
+
const ref = resolveRef(options.ref);
|
|
2222
|
+
const stored = toStoredSession(input, ref, options.now?.() ?? /* @__PURE__ */ new Date());
|
|
2223
|
+
const serialized = `${JSON.stringify(stored)}
|
|
2224
|
+
`;
|
|
2225
|
+
if (Buffer.byteLength(serialized) > (options.maxBytes ?? MAX_RESULT_STORE_BYTES)) {
|
|
2226
|
+
throw new CfHanaError("CONFIG", "Saved result exceeds the storage limit");
|
|
2227
|
+
}
|
|
2228
|
+
const root = resultsRoot(options.saptoolsRoot);
|
|
2229
|
+
const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
|
|
2230
|
+
const tempDirectory = `${finalDirectory}.tmp-${process.pid.toString()}`;
|
|
2231
|
+
await mkdir3(root, { recursive: true, mode: 448 });
|
|
2232
|
+
await rm2(tempDirectory, { recursive: true, force: true });
|
|
2233
|
+
await mkdir3(tempDirectory, { mode: 448 });
|
|
2234
|
+
try {
|
|
2235
|
+
await writeFile2(join3(tempDirectory, MANIFEST_FILE_NAME), serialized, {
|
|
2236
|
+
encoding: "utf8",
|
|
2237
|
+
mode: 384
|
|
2238
|
+
});
|
|
2239
|
+
await rename(tempDirectory, finalDirectory);
|
|
2240
|
+
} catch (error) {
|
|
2241
|
+
await rm2(tempDirectory, { recursive: true, force: true });
|
|
2242
|
+
throw error;
|
|
2243
|
+
}
|
|
2244
|
+
return toSession(stored, options.saptoolsRoot);
|
|
2245
|
+
}
|
|
2246
|
+
async function readResultSession(ref, options = {}) {
|
|
2247
|
+
const resolvedRef = resolveRef(ref);
|
|
2248
|
+
await pruneResultSessions(options);
|
|
2249
|
+
const stored = await readStoredSession(manifestPath(resolvedRef, options.saptoolsRoot));
|
|
2250
|
+
if (stored === void 0) {
|
|
2251
|
+
throw new CfHanaError("QUERY", "Saved result not found or expired");
|
|
2252
|
+
}
|
|
2253
|
+
return toSession(stored, options.saptoolsRoot);
|
|
2254
|
+
}
|
|
2255
|
+
async function listResultSessions(options = {}) {
|
|
2256
|
+
await pruneResultSessions(options);
|
|
2257
|
+
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
2258
|
+
const stored = await Promise.all(
|
|
2259
|
+
refs.map(async (ref) => await readStoredSession(manifestPath(ref, options.saptoolsRoot)))
|
|
2260
|
+
);
|
|
2261
|
+
return stored.filter((item) => item !== void 0).map((item) => ({
|
|
2262
|
+
ref: item.ref,
|
|
2263
|
+
createdAt: item.createdAt,
|
|
2264
|
+
expiresAt: item.expiresAt,
|
|
2265
|
+
rowCount: item.result.rowCount,
|
|
2266
|
+
columnCount: item.result.columns.length,
|
|
2267
|
+
truncated: item.result.truncated
|
|
2268
|
+
})).sort((left, right) => left.ref.localeCompare(right.ref));
|
|
2269
|
+
}
|
|
2270
|
+
async function pruneResultSessions(options = {}) {
|
|
2271
|
+
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
2272
|
+
const now = (options.now?.() ?? /* @__PURE__ */ new Date()).getTime();
|
|
2273
|
+
let removed = 0;
|
|
2274
|
+
for (const ref of refs) {
|
|
2275
|
+
const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
|
|
2276
|
+
if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
|
|
2277
|
+
await rm2(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2278
|
+
removed += 1;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
return removed;
|
|
2282
|
+
}
|
|
2283
|
+
async function clearResultSessions(options = {}) {
|
|
2284
|
+
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
2285
|
+
await Promise.all(
|
|
2286
|
+
refs.map(async (ref) => {
|
|
2287
|
+
await rm2(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2288
|
+
})
|
|
2289
|
+
);
|
|
2290
|
+
return refs.length;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
// src/cli-results.ts
|
|
1685
2294
|
function print(text) {
|
|
1686
2295
|
process.stdout.write(`${text}
|
|
1687
2296
|
`);
|
|
1688
2297
|
}
|
|
2298
|
+
function parseIntOption(value) {
|
|
2299
|
+
const trimmed = value.trim();
|
|
2300
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
2301
|
+
throw new CfHanaError("CONFIG", `Expected an integer but received "${value}"`);
|
|
2302
|
+
}
|
|
2303
|
+
const parsed = Number(trimmed);
|
|
2304
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
2305
|
+
throw new CfHanaError("CONFIG", `Expected a safe integer but received "${value}"`);
|
|
2306
|
+
}
|
|
2307
|
+
return parsed;
|
|
2308
|
+
}
|
|
2309
|
+
function positive(name, value, fallback) {
|
|
2310
|
+
const resolved = value ?? fallback;
|
|
2311
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
2312
|
+
throw new CfHanaError("CONFIG", `${name} must be a positive safe integer`);
|
|
2313
|
+
}
|
|
2314
|
+
return resolved;
|
|
2315
|
+
}
|
|
2316
|
+
function boundedLength(value) {
|
|
2317
|
+
const resolved = positive("--length", value, DEFAULT_CELL_LIMIT);
|
|
2318
|
+
if (resolved > MAX_CELL_LIMIT) {
|
|
2319
|
+
throw new CfHanaError("CONFIG", `--length must be at most ${String(MAX_CELL_LIMIT)}`);
|
|
2320
|
+
}
|
|
2321
|
+
return resolved;
|
|
2322
|
+
}
|
|
2323
|
+
function nonNegative(name, value) {
|
|
2324
|
+
const resolved = value ?? 0;
|
|
2325
|
+
if (!Number.isSafeInteger(resolved) || resolved < 0) {
|
|
2326
|
+
throw new CfHanaError("CONFIG", `${name} must be a non-negative safe integer`);
|
|
2327
|
+
}
|
|
2328
|
+
return resolved;
|
|
2329
|
+
}
|
|
2330
|
+
function resultFromRows(rows, columns) {
|
|
2331
|
+
return {
|
|
2332
|
+
rows,
|
|
2333
|
+
columns,
|
|
2334
|
+
rowCount: rows.length,
|
|
2335
|
+
statement: "select",
|
|
2336
|
+
truncated: false,
|
|
2337
|
+
elapsedMs: 0
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
function csv(rows, columns, limit) {
|
|
2341
|
+
const result = resultFromRows(
|
|
2342
|
+
rows,
|
|
2343
|
+
columns.map((name) => ({ name, typeName: "" }))
|
|
2344
|
+
);
|
|
2345
|
+
return formatCompactCsv(result, limit).text;
|
|
2346
|
+
}
|
|
2347
|
+
async function runShow(ref, options) {
|
|
2348
|
+
const session = await readResultSession(ref);
|
|
2349
|
+
const length = boundedLength(options.length);
|
|
2350
|
+
if (options.row === void 0) {
|
|
2351
|
+
if (options.column !== void 0 || options.path !== void 0 || options.offset !== void 0) {
|
|
2352
|
+
throw new CfHanaError("CONFIG", "--column, --path, and --offset require --row");
|
|
2353
|
+
}
|
|
2354
|
+
print(summaryCsv(session, length));
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
if (options.column === void 0) {
|
|
2358
|
+
if (options.path !== void 0 || options.offset !== void 0) {
|
|
2359
|
+
throw new CfHanaError("CONFIG", "--path and --offset require --column");
|
|
2360
|
+
}
|
|
2361
|
+
const row = selectResultRow(session, options.row);
|
|
2362
|
+
print(formatCompactCsv(resultFromRows([row], session.result.columns), length).text);
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
const selected = selectResultCell(session, options.row, options.column);
|
|
2366
|
+
if (options.path !== void 0) {
|
|
2367
|
+
const rows = inspectJsonCell(selected.value, options.path, length).map((row) => ({
|
|
2368
|
+
PATH: row.path,
|
|
2369
|
+
TYPE: row.type,
|
|
2370
|
+
VALUE: row.value
|
|
2371
|
+
}));
|
|
2372
|
+
print(csv(rows, ["PATH", "TYPE", "VALUE"], length));
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
const window = readCellWindow(selected.value, nonNegative("--offset", options.offset), length);
|
|
2376
|
+
print(
|
|
2377
|
+
csv(
|
|
2378
|
+
[
|
|
2379
|
+
{
|
|
2380
|
+
ROW: selected.row,
|
|
2381
|
+
COLUMN: selected.column,
|
|
2382
|
+
TYPE: window.type,
|
|
2383
|
+
ORIGINAL_LENGTH: window.originalLength,
|
|
2384
|
+
OFFSET: window.offset,
|
|
2385
|
+
VALUE: window.value
|
|
2386
|
+
}
|
|
2387
|
+
],
|
|
2388
|
+
["ROW", "COLUMN", "TYPE", "ORIGINAL_LENGTH", "OFFSET", "VALUE"],
|
|
2389
|
+
length
|
|
2390
|
+
)
|
|
2391
|
+
);
|
|
2392
|
+
}
|
|
2393
|
+
function summaryCsv(session, length) {
|
|
2394
|
+
const compact = formatCompactCsv(session.result, DEFAULT_CELL_LIMIT);
|
|
2395
|
+
return csv(
|
|
2396
|
+
[
|
|
2397
|
+
{
|
|
2398
|
+
REF: session.ref,
|
|
2399
|
+
ROWS: session.result.rowCount,
|
|
2400
|
+
COLUMNS: session.result.columns.length,
|
|
2401
|
+
ROW_TRUNCATED: session.result.truncated,
|
|
2402
|
+
TRUNCATED_CELLS: compact.truncatedCells,
|
|
2403
|
+
EXPIRES_AT: session.expiresAt
|
|
2404
|
+
}
|
|
2405
|
+
],
|
|
2406
|
+
["REF", "ROWS", "COLUMNS", "ROW_TRUNCATED", "TRUNCATED_CELLS", "EXPIRES_AT"],
|
|
2407
|
+
length
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
async function runSearch(ref, text, options) {
|
|
2411
|
+
const session = await readResultSession(ref);
|
|
2412
|
+
const length = boundedLength(options.length);
|
|
2413
|
+
const matches = searchResultSession(session, text, {
|
|
2414
|
+
limit: positive("--limit", options.limit, DEFAULT_RESULT_SEARCH_LIMIT),
|
|
2415
|
+
previewLength: length,
|
|
2416
|
+
...options.row === void 0 ? {} : { row: options.row },
|
|
2417
|
+
...options.column === void 0 ? {} : { column: options.column }
|
|
2418
|
+
});
|
|
2419
|
+
const rows = matches.map((match) => ({
|
|
2420
|
+
ROW: match.row,
|
|
2421
|
+
COLUMN: match.column,
|
|
2422
|
+
OFFSET: match.offset ?? null,
|
|
2423
|
+
PATH: match.path,
|
|
2424
|
+
PREVIEW: match.preview
|
|
2425
|
+
}));
|
|
2426
|
+
print(csv(rows, ["ROW", "COLUMN", "OFFSET", "PATH", "PREVIEW"], length));
|
|
2427
|
+
}
|
|
2428
|
+
function cellExportValue(value) {
|
|
2429
|
+
if (Buffer.isBuffer(value)) {
|
|
2430
|
+
return value;
|
|
2431
|
+
}
|
|
2432
|
+
if (value instanceof Date) {
|
|
2433
|
+
return value.toISOString();
|
|
2434
|
+
}
|
|
2435
|
+
if (value === null) {
|
|
2436
|
+
return "";
|
|
2437
|
+
}
|
|
2438
|
+
if (typeof value === "boolean") {
|
|
2439
|
+
return value ? "true" : "false";
|
|
2440
|
+
}
|
|
2441
|
+
return value.toString();
|
|
2442
|
+
}
|
|
2443
|
+
async function runExport(ref, options) {
|
|
2444
|
+
if (options.row === void 0 || options.column === void 0) {
|
|
2445
|
+
throw new CfHanaError("CONFIG", "result export requires --row and --column");
|
|
2446
|
+
}
|
|
2447
|
+
if (options.output === void 0 || options.output.trim().length === 0) {
|
|
2448
|
+
throw new CfHanaError("CONFIG", "result export requires --output");
|
|
2449
|
+
}
|
|
2450
|
+
const session = await readResultSession(ref);
|
|
2451
|
+
const selected = selectResultCell(session, options.row, options.column);
|
|
2452
|
+
await writeFile3(options.output, cellExportValue(selected.value), { mode: 384 });
|
|
2453
|
+
print(`wrote=${options.output}`);
|
|
2454
|
+
}
|
|
2455
|
+
async function runList() {
|
|
2456
|
+
const summaries = await listResultSessions();
|
|
2457
|
+
const rows = summaries.map((summary) => ({
|
|
2458
|
+
REF: summary.ref,
|
|
2459
|
+
ROWS: summary.rowCount,
|
|
2460
|
+
COLUMNS: summary.columnCount,
|
|
2461
|
+
ROW_TRUNCATED: summary.truncated,
|
|
2462
|
+
EXPIRES_AT: summary.expiresAt
|
|
2463
|
+
}));
|
|
2464
|
+
print(csv(rows, ["REF", "ROWS", "COLUMNS", "ROW_TRUNCATED", "EXPIRES_AT"], DEFAULT_CELL_LIMIT));
|
|
2465
|
+
}
|
|
2466
|
+
async function runPrune() {
|
|
2467
|
+
print(`removed=${String(await pruneResultSessions())}`);
|
|
2468
|
+
}
|
|
2469
|
+
async function runClear() {
|
|
2470
|
+
print(`removed=${String(await clearResultSessions())}`);
|
|
2471
|
+
}
|
|
2472
|
+
function registerResultCommands(program) {
|
|
2473
|
+
const result = program.command("result").description("inspect saved query refs");
|
|
2474
|
+
result.command("show <ref>").description("show a saved result, row, cell, or JSON path").option("--row <n>", "one-based result row", parseIntOption).option("--column <name>", "exact column name").option("--offset <n>", "text code-point or binary byte offset", parseIntOption).option("--length <n>", "maximum characters or bytes to print", parseIntOption).option("--path <pointer>", "JSON Pointer inside a saved text cell").action(async (ref, _options, command) => {
|
|
2475
|
+
await runShow(ref, command.opts());
|
|
2476
|
+
});
|
|
2477
|
+
result.command("search <ref> <text>").description("search saved text and JSON values").option("--row <n>", "one-based result row", parseIntOption).option("--column <name>", "exact column name").option("--limit <n>", "maximum matches to print", parseIntOption).option("--length <n>", "maximum preview characters", parseIntOption).action(async (ref, text, _options, command) => {
|
|
2478
|
+
await runSearch(ref, text, command.opts());
|
|
2479
|
+
});
|
|
2480
|
+
result.command("export <ref>").description("write one exact saved cell to a file").requiredOption("--output <path>", "output file path").option("--row <n>", "one-based result row", parseIntOption).option("--column <name>", "exact column name").action(async (ref, _options, command) => {
|
|
2481
|
+
await runExport(ref, command.opts());
|
|
2482
|
+
});
|
|
2483
|
+
result.command("list").description("list active saved refs").action(async () => {
|
|
2484
|
+
await runList();
|
|
2485
|
+
});
|
|
2486
|
+
result.command("prune").description("remove expired saved refs").action(async () => {
|
|
2487
|
+
await runPrune();
|
|
2488
|
+
});
|
|
2489
|
+
result.command("clear").description("remove all saved refs").action(async () => {
|
|
2490
|
+
await runClear();
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// src/cli.ts
|
|
2495
|
+
function print2(text) {
|
|
2496
|
+
process.stdout.write(`${text}
|
|
2497
|
+
`);
|
|
2498
|
+
}
|
|
1689
2499
|
function fail(message) {
|
|
1690
2500
|
process.stderr.write(`${CLI_NAME}: ${message}
|
|
1691
2501
|
`);
|
|
1692
2502
|
process.exit(1);
|
|
1693
2503
|
}
|
|
1694
|
-
function
|
|
2504
|
+
function parseIntOption2(value) {
|
|
1695
2505
|
const trimmed = value.trim();
|
|
1696
2506
|
if (!/^-?\d+$/.test(trimmed)) {
|
|
1697
2507
|
throw new CfHanaError("CONFIG", `Expected an integer but received "${value}"`);
|
|
@@ -1749,6 +2559,21 @@ function toConnectOptions(opts) {
|
|
|
1749
2559
|
...opts.timeout === void 0 ? {} : { queryTimeoutMs: opts.timeout, connectTimeoutMs: opts.timeout }
|
|
1750
2560
|
};
|
|
1751
2561
|
}
|
|
2562
|
+
function resolveCellLimit(value) {
|
|
2563
|
+
const limit = value ?? DEFAULT_CELL_LIMIT;
|
|
2564
|
+
assertPositiveOption("--cell-limit", limit);
|
|
2565
|
+
if (limit > MAX_CELL_LIMIT) {
|
|
2566
|
+
throw new CfHanaError("CONFIG", `--cell-limit must be at most ${String(MAX_CELL_LIMIT)}`);
|
|
2567
|
+
}
|
|
2568
|
+
return limit;
|
|
2569
|
+
}
|
|
2570
|
+
function assertQueryOptions(sql, opts) {
|
|
2571
|
+
resolveCellLimit(opts.cellLimit);
|
|
2572
|
+
assertPositiveOption("--result-ttl-minutes", opts.resultTtlMinutes);
|
|
2573
|
+
if (opts.save && classifyStatement(sql) !== "select") {
|
|
2574
|
+
throw new CfHanaError("CONFIG", "--save is only available for SELECT/WITH statements");
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
1752
2577
|
function rowsToResult(rows) {
|
|
1753
2578
|
const first = rows[0];
|
|
1754
2579
|
const columns = first === void 0 ? [] : Object.keys(first).map((name) => ({ name, typeName: "" }));
|
|
@@ -1773,10 +2598,19 @@ function formatInfo(info) {
|
|
|
1773
2598
|
].join("\n");
|
|
1774
2599
|
}
|
|
1775
2600
|
function withConnectionOptions(command) {
|
|
1776
|
-
return command.option("--
|
|
2601
|
+
return command.option("--refresh", "bypass cached credentials and fetch them live", false).option("--role <role>", "HANA user role: runtime or hdi", "runtime").option("--binding <name>", "select a HANA binding by service name").option("--binding-index <n>", "select a HANA binding by index", parseIntOption2).option("--read-only", "block every DML and DDL statement", false).option("--allow-destructive", "permit destructive statements", false).option("--timeout <ms>", "connection and query timeout in milliseconds", parseIntOption2).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption2).option("--no-auto-limit", "disable the automatic SELECT row cap");
|
|
2602
|
+
}
|
|
2603
|
+
function withFormattedConnectionOptions(command) {
|
|
2604
|
+
return withConnectionOptions(command).option(
|
|
2605
|
+
"--format <format>",
|
|
2606
|
+
"output format: table, json, or csv",
|
|
2607
|
+
"table"
|
|
2608
|
+
);
|
|
1777
2609
|
}
|
|
1778
2610
|
async function runQuery(selector, sql, command) {
|
|
1779
2611
|
const opts = command.opts();
|
|
2612
|
+
assertQueryOptions(sql, opts);
|
|
2613
|
+
const cellLimit = resolveCellLimit(opts.cellLimit);
|
|
1780
2614
|
const client = await connect(selector, toConnectOptions(opts));
|
|
1781
2615
|
try {
|
|
1782
2616
|
const params = opts.param ?? [];
|
|
@@ -1786,7 +2620,32 @@ async function runQuery(selector, sql, command) {
|
|
|
1786
2620
|
`);
|
|
1787
2621
|
}
|
|
1788
2622
|
const result = await client.query(sql, params);
|
|
1789
|
-
|
|
2623
|
+
if (result.statement === "select") {
|
|
2624
|
+
const compact = formatCompactCsv(result, cellLimit);
|
|
2625
|
+
if (opts.save) {
|
|
2626
|
+
const session = await createResultSession({
|
|
2627
|
+
result,
|
|
2628
|
+
info: client.info,
|
|
2629
|
+
...opts.resultTtlMinutes === void 0 ? {} : { ttlMinutes: opts.resultTtlMinutes }
|
|
2630
|
+
});
|
|
2631
|
+
print2(`ref=${session.ref}`);
|
|
2632
|
+
process.stderr.write(`${CLI_NAME}: saved result expires at ${session.expiresAt}
|
|
2633
|
+
`);
|
|
2634
|
+
}
|
|
2635
|
+
print2(compact.text);
|
|
2636
|
+
if (result.truncated) {
|
|
2637
|
+
process.stderr.write(`${CLI_NAME}: row limit reached; rerun with --limit for more rows
|
|
2638
|
+
`);
|
|
2639
|
+
}
|
|
2640
|
+
if (compact.truncatedCells > 0) {
|
|
2641
|
+
process.stderr.write(
|
|
2642
|
+
`${CLI_NAME}: compacted ${String(compact.truncatedCells)} cell(s); use --save to inspect exact values by ref
|
|
2643
|
+
`
|
|
2644
|
+
);
|
|
2645
|
+
}
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
print2(formatTable(result));
|
|
1790
2649
|
} finally {
|
|
1791
2650
|
await client.close();
|
|
1792
2651
|
}
|
|
@@ -1801,7 +2660,7 @@ async function runTables(selector, schema, command) {
|
|
|
1801
2660
|
TABLE: table.name,
|
|
1802
2661
|
TYPE: table.type
|
|
1803
2662
|
}));
|
|
1804
|
-
|
|
2663
|
+
print2(formatResult(rowsToResult(rows), parseFormat(opts.format)));
|
|
1805
2664
|
} finally {
|
|
1806
2665
|
await client.close();
|
|
1807
2666
|
}
|
|
@@ -1819,7 +2678,7 @@ async function runColumns(selector, target, command) {
|
|
|
1819
2678
|
NULLABLE: column.nullable,
|
|
1820
2679
|
POSITION: column.position
|
|
1821
2680
|
}));
|
|
1822
|
-
|
|
2681
|
+
print2(formatResult(rowsToResult(rows), parseFormat(opts.format)));
|
|
1823
2682
|
} finally {
|
|
1824
2683
|
await client.close();
|
|
1825
2684
|
}
|
|
@@ -1830,7 +2689,7 @@ async function runCount(selector, target, command) {
|
|
|
1830
2689
|
const client = await connect(selector, toConnectOptions(opts));
|
|
1831
2690
|
try {
|
|
1832
2691
|
const total = await client.count({ schema, table });
|
|
1833
|
-
|
|
2692
|
+
print2(String(total));
|
|
1834
2693
|
} finally {
|
|
1835
2694
|
await client.close();
|
|
1836
2695
|
}
|
|
@@ -1841,7 +2700,7 @@ async function runPing(selector, command) {
|
|
|
1841
2700
|
try {
|
|
1842
2701
|
const started = Date.now();
|
|
1843
2702
|
await client.query("SELECT 1 FROM DUMMY");
|
|
1844
|
-
|
|
2703
|
+
print2(
|
|
1845
2704
|
`OK ${client.info.host} schema=${client.info.schema} ${String(Date.now() - started)}ms`
|
|
1846
2705
|
);
|
|
1847
2706
|
} finally {
|
|
@@ -1852,7 +2711,7 @@ async function runInfo(selector, command) {
|
|
|
1852
2711
|
const opts = command.opts();
|
|
1853
2712
|
const client = await connect(selector, toConnectOptions(opts));
|
|
1854
2713
|
try {
|
|
1855
|
-
|
|
2714
|
+
print2(formatInfo(client.info));
|
|
1856
2715
|
} finally {
|
|
1857
2716
|
await client.close();
|
|
1858
2717
|
}
|
|
@@ -1861,18 +2720,22 @@ function buildProgram() {
|
|
|
1861
2720
|
const program = new Command();
|
|
1862
2721
|
program.name(CLI_NAME).description("Run SQL against SAP HANA Cloud databases bound to a Cloud Foundry app").version(CLI_VERSION);
|
|
1863
2722
|
withConnectionOptions(
|
|
1864
|
-
program.command("query <selector> <sql>").description("run a single SQL statement").option("--param <value>", "bind a SQL parameter (repeatable)", collectParam, [])
|
|
2723
|
+
program.command("query <selector> <sql>").description("run a single SQL statement").option("--param <value>", "bind a SQL parameter (repeatable)", collectParam, []).option("--save", "save exact returned rows for follow-up inspection", false).option("--cell-limit <n>", "maximum visible characters per data cell", parseIntOption2).option(
|
|
2724
|
+
"--result-ttl-minutes <n>",
|
|
2725
|
+
"minutes before a saved result expires",
|
|
2726
|
+
parseIntOption2
|
|
2727
|
+
)
|
|
1865
2728
|
).action(async (selector, sql, _options, command) => {
|
|
1866
2729
|
await runQuery(selector, sql, command);
|
|
1867
2730
|
});
|
|
1868
|
-
|
|
2731
|
+
withFormattedConnectionOptions(
|
|
1869
2732
|
program.command("tables <selector> [schema]").description("list tables in a schema")
|
|
1870
2733
|
).action(
|
|
1871
2734
|
async (selector, schema, _options, command) => {
|
|
1872
2735
|
await runTables(selector, schema, command);
|
|
1873
2736
|
}
|
|
1874
2737
|
);
|
|
1875
|
-
|
|
2738
|
+
withFormattedConnectionOptions(
|
|
1876
2739
|
program.command("columns <selector> <schema.table>").description("list the columns of a table")
|
|
1877
2740
|
).action(async (selector, target, _options, command) => {
|
|
1878
2741
|
await runColumns(selector, target, command);
|
|
@@ -1892,6 +2755,7 @@ function buildProgram() {
|
|
|
1892
2755
|
).action(async (selector, _options, command) => {
|
|
1893
2756
|
await runInfo(selector, command);
|
|
1894
2757
|
});
|
|
2758
|
+
registerResultCommands(program);
|
|
1895
2759
|
return program;
|
|
1896
2760
|
}
|
|
1897
2761
|
try {
|