@saptools/cf-hana 0.1.6 → 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 +13 -0
- package/README.md +57 -9
- package/dist/cli.js +867 -21
- package/dist/cli.js.map +1 -1
- package/dist/index.js +34 -9
- 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
|
}
|
|
@@ -1174,10 +1252,28 @@ function applyAutoLimit(sql, limit) {
|
|
|
1174
1252
|
if (/\blimit\b/i.test(stripped) || /\btop\s+\d/i.test(stripped)) {
|
|
1175
1253
|
return { sql, applied: false };
|
|
1176
1254
|
}
|
|
1177
|
-
return {
|
|
1255
|
+
return {
|
|
1256
|
+
sql: appendLimit(sql, limit + 1),
|
|
1257
|
+
applied: true,
|
|
1258
|
+
requestedLimit: limit
|
|
1259
|
+
};
|
|
1178
1260
|
}
|
|
1179
1261
|
|
|
1180
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
|
+
}
|
|
1181
1277
|
async function withTimeout(work, timeoutMs, onTimeout) {
|
|
1182
1278
|
let timer;
|
|
1183
1279
|
const timeout = new Promise((_resolve, reject) => {
|
|
@@ -1251,6 +1347,7 @@ var Connection = class _Connection {
|
|
|
1251
1347
|
}
|
|
1252
1348
|
const kind = classifyStatement(sql);
|
|
1253
1349
|
const autoLimit = options.autoLimit ?? this.config.autoLimit;
|
|
1350
|
+
assertValidAutoLimit(autoLimit);
|
|
1254
1351
|
const limited = kind === "select" ? applyAutoLimit(sql, autoLimit) : { sql, applied: false };
|
|
1255
1352
|
const timeoutMs = options.timeoutMs ?? this.config.queryTimeoutMs;
|
|
1256
1353
|
const started = Date.now();
|
|
@@ -1262,12 +1359,13 @@ var Connection = class _Connection {
|
|
|
1262
1359
|
}
|
|
1263
1360
|
);
|
|
1264
1361
|
const elapsedMs = Date.now() - started;
|
|
1362
|
+
const selected = boundedRows(execResult.rows, limited.requestedLimit);
|
|
1265
1363
|
return {
|
|
1266
|
-
rows:
|
|
1364
|
+
rows: selected.rows,
|
|
1267
1365
|
columns: execResult.columns,
|
|
1268
|
-
rowCount: kind === "select" ?
|
|
1366
|
+
rowCount: kind === "select" ? selected.rows.length : execResult.affectedRows,
|
|
1269
1367
|
statement: kind,
|
|
1270
|
-
truncated:
|
|
1368
|
+
truncated: selected.truncated,
|
|
1271
1369
|
elapsedMs
|
|
1272
1370
|
};
|
|
1273
1371
|
}
|
|
@@ -1699,17 +1797,711 @@ async function connect(selector, options) {
|
|
|
1699
1797
|
return await HanaClient.connect(selector, options);
|
|
1700
1798
|
}
|
|
1701
1799
|
|
|
1702
|
-
// 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
|
|
1703
2294
|
function print(text) {
|
|
1704
2295
|
process.stdout.write(`${text}
|
|
1705
2296
|
`);
|
|
1706
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
|
+
}
|
|
1707
2499
|
function fail(message) {
|
|
1708
2500
|
process.stderr.write(`${CLI_NAME}: ${message}
|
|
1709
2501
|
`);
|
|
1710
2502
|
process.exit(1);
|
|
1711
2503
|
}
|
|
1712
|
-
function
|
|
2504
|
+
function parseIntOption2(value) {
|
|
1713
2505
|
const trimmed = value.trim();
|
|
1714
2506
|
if (!/^-?\d+$/.test(trimmed)) {
|
|
1715
2507
|
throw new CfHanaError("CONFIG", `Expected an integer but received "${value}"`);
|
|
@@ -1767,6 +2559,21 @@ function toConnectOptions(opts) {
|
|
|
1767
2559
|
...opts.timeout === void 0 ? {} : { queryTimeoutMs: opts.timeout, connectTimeoutMs: opts.timeout }
|
|
1768
2560
|
};
|
|
1769
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
|
+
}
|
|
1770
2577
|
function rowsToResult(rows) {
|
|
1771
2578
|
const first = rows[0];
|
|
1772
2579
|
const columns = first === void 0 ? [] : Object.keys(first).map((name) => ({ name, typeName: "" }));
|
|
@@ -1791,10 +2598,19 @@ function formatInfo(info) {
|
|
|
1791
2598
|
].join("\n");
|
|
1792
2599
|
}
|
|
1793
2600
|
function withConnectionOptions(command) {
|
|
1794
|
-
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
|
+
);
|
|
1795
2609
|
}
|
|
1796
2610
|
async function runQuery(selector, sql, command) {
|
|
1797
2611
|
const opts = command.opts();
|
|
2612
|
+
assertQueryOptions(sql, opts);
|
|
2613
|
+
const cellLimit = resolveCellLimit(opts.cellLimit);
|
|
1798
2614
|
const client = await connect(selector, toConnectOptions(opts));
|
|
1799
2615
|
try {
|
|
1800
2616
|
const params = opts.param ?? [];
|
|
@@ -1804,7 +2620,32 @@ async function runQuery(selector, sql, command) {
|
|
|
1804
2620
|
`);
|
|
1805
2621
|
}
|
|
1806
2622
|
const result = await client.query(sql, params);
|
|
1807
|
-
|
|
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));
|
|
1808
2649
|
} finally {
|
|
1809
2650
|
await client.close();
|
|
1810
2651
|
}
|
|
@@ -1819,7 +2660,7 @@ async function runTables(selector, schema, command) {
|
|
|
1819
2660
|
TABLE: table.name,
|
|
1820
2661
|
TYPE: table.type
|
|
1821
2662
|
}));
|
|
1822
|
-
|
|
2663
|
+
print2(formatResult(rowsToResult(rows), parseFormat(opts.format)));
|
|
1823
2664
|
} finally {
|
|
1824
2665
|
await client.close();
|
|
1825
2666
|
}
|
|
@@ -1837,7 +2678,7 @@ async function runColumns(selector, target, command) {
|
|
|
1837
2678
|
NULLABLE: column.nullable,
|
|
1838
2679
|
POSITION: column.position
|
|
1839
2680
|
}));
|
|
1840
|
-
|
|
2681
|
+
print2(formatResult(rowsToResult(rows), parseFormat(opts.format)));
|
|
1841
2682
|
} finally {
|
|
1842
2683
|
await client.close();
|
|
1843
2684
|
}
|
|
@@ -1848,7 +2689,7 @@ async function runCount(selector, target, command) {
|
|
|
1848
2689
|
const client = await connect(selector, toConnectOptions(opts));
|
|
1849
2690
|
try {
|
|
1850
2691
|
const total = await client.count({ schema, table });
|
|
1851
|
-
|
|
2692
|
+
print2(String(total));
|
|
1852
2693
|
} finally {
|
|
1853
2694
|
await client.close();
|
|
1854
2695
|
}
|
|
@@ -1859,7 +2700,7 @@ async function runPing(selector, command) {
|
|
|
1859
2700
|
try {
|
|
1860
2701
|
const started = Date.now();
|
|
1861
2702
|
await client.query("SELECT 1 FROM DUMMY");
|
|
1862
|
-
|
|
2703
|
+
print2(
|
|
1863
2704
|
`OK ${client.info.host} schema=${client.info.schema} ${String(Date.now() - started)}ms`
|
|
1864
2705
|
);
|
|
1865
2706
|
} finally {
|
|
@@ -1870,7 +2711,7 @@ async function runInfo(selector, command) {
|
|
|
1870
2711
|
const opts = command.opts();
|
|
1871
2712
|
const client = await connect(selector, toConnectOptions(opts));
|
|
1872
2713
|
try {
|
|
1873
|
-
|
|
2714
|
+
print2(formatInfo(client.info));
|
|
1874
2715
|
} finally {
|
|
1875
2716
|
await client.close();
|
|
1876
2717
|
}
|
|
@@ -1879,18 +2720,22 @@ function buildProgram() {
|
|
|
1879
2720
|
const program = new Command();
|
|
1880
2721
|
program.name(CLI_NAME).description("Run SQL against SAP HANA Cloud databases bound to a Cloud Foundry app").version(CLI_VERSION);
|
|
1881
2722
|
withConnectionOptions(
|
|
1882
|
-
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
|
+
)
|
|
1883
2728
|
).action(async (selector, sql, _options, command) => {
|
|
1884
2729
|
await runQuery(selector, sql, command);
|
|
1885
2730
|
});
|
|
1886
|
-
|
|
2731
|
+
withFormattedConnectionOptions(
|
|
1887
2732
|
program.command("tables <selector> [schema]").description("list tables in a schema")
|
|
1888
2733
|
).action(
|
|
1889
2734
|
async (selector, schema, _options, command) => {
|
|
1890
2735
|
await runTables(selector, schema, command);
|
|
1891
2736
|
}
|
|
1892
2737
|
);
|
|
1893
|
-
|
|
2738
|
+
withFormattedConnectionOptions(
|
|
1894
2739
|
program.command("columns <selector> <schema.table>").description("list the columns of a table")
|
|
1895
2740
|
).action(async (selector, target, _options, command) => {
|
|
1896
2741
|
await runColumns(selector, target, command);
|
|
@@ -1910,6 +2755,7 @@ function buildProgram() {
|
|
|
1910
2755
|
).action(async (selector, _options, command) => {
|
|
1911
2756
|
await runInfo(selector, command);
|
|
1912
2757
|
});
|
|
2758
|
+
registerResultCommands(program);
|
|
1913
2759
|
return program;
|
|
1914
2760
|
}
|
|
1915
2761
|
try {
|