@saptools/cf-hana 0.1.6 → 0.2.1
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 +893 -27
- package/dist/cli.js.map +1 -1
- package/dist/index.js +34 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import { formatCurrentCfAppSelector, readCurrentCfTarget } from "@saptools/cf-sync";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
|
|
6
7
|
// src/backup.ts
|
|
@@ -52,6 +53,59 @@ function errorMessage(error) {
|
|
|
52
53
|
return String(error);
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// src/result-preview.ts
|
|
57
|
+
function normalizePreviewChar(value) {
|
|
58
|
+
return value === "\r" || value === "\n" || value === " " ? " " : value;
|
|
59
|
+
}
|
|
60
|
+
function previewText(value, limit) {
|
|
61
|
+
let originalLength = 0;
|
|
62
|
+
let text = "";
|
|
63
|
+
for (const char of value) {
|
|
64
|
+
if (originalLength < limit) {
|
|
65
|
+
text += normalizePreviewChar(char);
|
|
66
|
+
}
|
|
67
|
+
originalLength += 1;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
text,
|
|
71
|
+
truncated: originalLength > limit,
|
|
72
|
+
originalLength,
|
|
73
|
+
unit: "chars"
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function previewBuffer(value, limit) {
|
|
77
|
+
const fullLength = 2 + value.length * 2;
|
|
78
|
+
const visibleBytes = Math.max(0, Math.floor((limit - 2) / 2));
|
|
79
|
+
const hex = value.subarray(0, visibleBytes).toString("hex");
|
|
80
|
+
return {
|
|
81
|
+
text: `0x${hex}`.slice(0, limit),
|
|
82
|
+
truncated: fullLength > limit,
|
|
83
|
+
originalLength: value.length,
|
|
84
|
+
unit: "bytes"
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function scalarText(value) {
|
|
88
|
+
if (value instanceof Date) {
|
|
89
|
+
return value.toISOString();
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === "boolean") {
|
|
92
|
+
return value ? "true" : "false";
|
|
93
|
+
}
|
|
94
|
+
return value.toString();
|
|
95
|
+
}
|
|
96
|
+
function previewCell(value, limit) {
|
|
97
|
+
if (value === null) {
|
|
98
|
+
return { text: "", truncated: false, originalLength: 0, unit: "chars" };
|
|
99
|
+
}
|
|
100
|
+
if (Buffer.isBuffer(value)) {
|
|
101
|
+
return previewBuffer(value, limit);
|
|
102
|
+
}
|
|
103
|
+
if (typeof value === "string") {
|
|
104
|
+
return previewText(value, limit);
|
|
105
|
+
}
|
|
106
|
+
return previewText(scalarText(value), limit);
|
|
107
|
+
}
|
|
108
|
+
|
|
55
109
|
// src/format.ts
|
|
56
110
|
function cellText(value, nullText) {
|
|
57
111
|
if (value === null) {
|
|
@@ -126,6 +180,22 @@ function formatCsv(result) {
|
|
|
126
180
|
}
|
|
127
181
|
return lines.join("\r\n");
|
|
128
182
|
}
|
|
183
|
+
function formatCompactCsv(result, cellLimit) {
|
|
184
|
+
const headers = result.columns.map((column) => column.name);
|
|
185
|
+
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
186
|
+
let truncatedCells = 0;
|
|
187
|
+
for (const row of result.rows) {
|
|
188
|
+
const cells = result.columns.map((column) => {
|
|
189
|
+
const preview = previewCell(row[column.name] ?? null, cellLimit);
|
|
190
|
+
if (preview.truncated) {
|
|
191
|
+
truncatedCells += 1;
|
|
192
|
+
}
|
|
193
|
+
return csvEscape(preview.text);
|
|
194
|
+
});
|
|
195
|
+
lines.push(cells.join(","));
|
|
196
|
+
}
|
|
197
|
+
return { text: lines.join("\r\n"), truncatedCells };
|
|
198
|
+
}
|
|
129
199
|
function formatResult(result, format) {
|
|
130
200
|
switch (format) {
|
|
131
201
|
case "table":
|
|
@@ -481,14 +551,17 @@ function buildDelete(schema, table, where) {
|
|
|
481
551
|
// src/catalog.ts
|
|
482
552
|
async function listSchemas(connection) {
|
|
483
553
|
const result = await connection.query(
|
|
484
|
-
"SELECT SCHEMA_NAME FROM SYS.SCHEMAS ORDER BY SCHEMA_NAME"
|
|
554
|
+
"SELECT SCHEMA_NAME FROM SYS.SCHEMAS ORDER BY SCHEMA_NAME",
|
|
555
|
+
[],
|
|
556
|
+
{ autoLimit: false }
|
|
485
557
|
);
|
|
486
558
|
return result.rows.map((row) => row.SCHEMA_NAME);
|
|
487
559
|
}
|
|
488
560
|
async function listTables(connection, schema) {
|
|
489
561
|
const result = await connection.query(
|
|
490
562
|
"SELECT SCHEMA_NAME, TABLE_NAME, TABLE_TYPE FROM SYS.TABLES WHERE SCHEMA_NAME = ? ORDER BY TABLE_NAME",
|
|
491
|
-
[schema]
|
|
563
|
+
[schema],
|
|
564
|
+
{ autoLimit: false }
|
|
492
565
|
);
|
|
493
566
|
return result.rows.map((row) => ({
|
|
494
567
|
schema: row.SCHEMA_NAME,
|
|
@@ -500,7 +573,8 @@ async function listTables(connection, schema) {
|
|
|
500
573
|
async function listColumns(connection, schema, table) {
|
|
501
574
|
const result = await connection.query(
|
|
502
575
|
"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]
|
|
576
|
+
[schema, table],
|
|
577
|
+
{ autoLimit: false }
|
|
504
578
|
);
|
|
505
579
|
return result.rows.map((row) => ({
|
|
506
580
|
name: row.COLUMN_NAME,
|
|
@@ -514,13 +588,18 @@ async function listColumns(connection, schema, table) {
|
|
|
514
588
|
|
|
515
589
|
// src/config.ts
|
|
516
590
|
var CLI_NAME = "cf-hana";
|
|
517
|
-
var CLI_VERSION = "0.1
|
|
591
|
+
var CLI_VERSION = "0.2.1";
|
|
518
592
|
var ENV_PREFIX = "CF_HANA";
|
|
519
593
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
520
594
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
521
595
|
var DEFAULT_POOL_MAX = 4;
|
|
522
596
|
var DEFAULT_POOL_IDLE_MS = 6e4;
|
|
523
|
-
var DEFAULT_AUTO_LIMIT =
|
|
597
|
+
var DEFAULT_AUTO_LIMIT = 100;
|
|
598
|
+
var DEFAULT_CELL_LIMIT = 128;
|
|
599
|
+
var MAX_CELL_LIMIT = 1e4;
|
|
600
|
+
var DEFAULT_RESULT_TTL_MINUTES = 60;
|
|
601
|
+
var DEFAULT_RESULT_SEARCH_LIMIT = 20;
|
|
602
|
+
var MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
|
|
524
603
|
function envName(suffix) {
|
|
525
604
|
return `${ENV_PREFIX}_${suffix}`;
|
|
526
605
|
}
|
|
@@ -1174,10 +1253,28 @@ function applyAutoLimit(sql, limit) {
|
|
|
1174
1253
|
if (/\blimit\b/i.test(stripped) || /\btop\s+\d/i.test(stripped)) {
|
|
1175
1254
|
return { sql, applied: false };
|
|
1176
1255
|
}
|
|
1177
|
-
return {
|
|
1256
|
+
return {
|
|
1257
|
+
sql: appendLimit(sql, limit + 1),
|
|
1258
|
+
applied: true,
|
|
1259
|
+
requestedLimit: limit
|
|
1260
|
+
};
|
|
1178
1261
|
}
|
|
1179
1262
|
|
|
1180
1263
|
// src/connection.ts
|
|
1264
|
+
function assertValidAutoLimit(value) {
|
|
1265
|
+
if (value !== false && (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER)) {
|
|
1266
|
+
throw new CfHanaError("CONFIG", "autoLimit must be a positive safe integer");
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
function boundedRows(rows, requestedLimit) {
|
|
1270
|
+
if (requestedLimit === void 0) {
|
|
1271
|
+
return { rows, truncated: false };
|
|
1272
|
+
}
|
|
1273
|
+
return {
|
|
1274
|
+
rows: rows.slice(0, requestedLimit),
|
|
1275
|
+
truncated: rows.length > requestedLimit
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1181
1278
|
async function withTimeout(work, timeoutMs, onTimeout) {
|
|
1182
1279
|
let timer;
|
|
1183
1280
|
const timeout = new Promise((_resolve, reject) => {
|
|
@@ -1251,6 +1348,7 @@ var Connection = class _Connection {
|
|
|
1251
1348
|
}
|
|
1252
1349
|
const kind = classifyStatement(sql);
|
|
1253
1350
|
const autoLimit = options.autoLimit ?? this.config.autoLimit;
|
|
1351
|
+
assertValidAutoLimit(autoLimit);
|
|
1254
1352
|
const limited = kind === "select" ? applyAutoLimit(sql, autoLimit) : { sql, applied: false };
|
|
1255
1353
|
const timeoutMs = options.timeoutMs ?? this.config.queryTimeoutMs;
|
|
1256
1354
|
const started = Date.now();
|
|
@@ -1262,12 +1360,13 @@ var Connection = class _Connection {
|
|
|
1262
1360
|
}
|
|
1263
1361
|
);
|
|
1264
1362
|
const elapsedMs = Date.now() - started;
|
|
1363
|
+
const selected = boundedRows(execResult.rows, limited.requestedLimit);
|
|
1265
1364
|
return {
|
|
1266
|
-
rows:
|
|
1365
|
+
rows: selected.rows,
|
|
1267
1366
|
columns: execResult.columns,
|
|
1268
|
-
rowCount: kind === "select" ?
|
|
1367
|
+
rowCount: kind === "select" ? selected.rows.length : execResult.affectedRows,
|
|
1269
1368
|
statement: kind,
|
|
1270
|
-
truncated:
|
|
1369
|
+
truncated: selected.truncated,
|
|
1271
1370
|
elapsedMs
|
|
1272
1371
|
};
|
|
1273
1372
|
}
|
|
@@ -1699,17 +1798,711 @@ async function connect(selector, options) {
|
|
|
1699
1798
|
return await HanaClient.connect(selector, options);
|
|
1700
1799
|
}
|
|
1701
1800
|
|
|
1702
|
-
// src/cli.ts
|
|
1801
|
+
// src/cli-results.ts
|
|
1802
|
+
import { writeFile as writeFile3 } from "fs/promises";
|
|
1803
|
+
|
|
1804
|
+
// src/result-inspect.ts
|
|
1805
|
+
function assertPositiveInteger(name, value) {
|
|
1806
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1807
|
+
throw new CfHanaError(
|
|
1808
|
+
"CONFIG",
|
|
1809
|
+
`${name} ${String(value)} must be a positive safe integer`
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
function selectResultRow(session, rowNumber) {
|
|
1814
|
+
assertPositiveInteger("row", rowNumber);
|
|
1815
|
+
const row = session.result.rows[rowNumber - 1];
|
|
1816
|
+
if (row === void 0) {
|
|
1817
|
+
throw new CfHanaError("QUERY", `Saved result row ${String(rowNumber)} not found`);
|
|
1818
|
+
}
|
|
1819
|
+
return row;
|
|
1820
|
+
}
|
|
1821
|
+
function selectResultCell(session, rowNumber, columnName) {
|
|
1822
|
+
const row = selectResultRow(session, rowNumber);
|
|
1823
|
+
const column = session.result.columns.find((item) => item.name === columnName);
|
|
1824
|
+
if (column === void 0) {
|
|
1825
|
+
throw new CfHanaError("QUERY", `Saved result column "${columnName}" not found`);
|
|
1826
|
+
}
|
|
1827
|
+
return {
|
|
1828
|
+
row: rowNumber,
|
|
1829
|
+
column: column.name,
|
|
1830
|
+
typeName: column.typeName,
|
|
1831
|
+
value: row[column.name] ?? null
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
function textRange(value, offset, length) {
|
|
1835
|
+
let index = 0;
|
|
1836
|
+
let selected = "";
|
|
1837
|
+
for (const char of value) {
|
|
1838
|
+
if (index >= offset && index < offset + length) {
|
|
1839
|
+
selected += char;
|
|
1840
|
+
}
|
|
1841
|
+
index += 1;
|
|
1842
|
+
}
|
|
1843
|
+
return { type: "text", originalLength: index, offset, value: selected };
|
|
1844
|
+
}
|
|
1845
|
+
function readCellWindow(value, offset, length) {
|
|
1846
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
1847
|
+
throw new CfHanaError("CONFIG", "offset must be a non-negative safe integer");
|
|
1848
|
+
}
|
|
1849
|
+
assertPositiveInteger("length", length);
|
|
1850
|
+
if (Buffer.isBuffer(value)) {
|
|
1851
|
+
return {
|
|
1852
|
+
type: "binary",
|
|
1853
|
+
originalLength: value.length,
|
|
1854
|
+
offset,
|
|
1855
|
+
value: `0x${value.subarray(offset, offset + length).toString("hex")}`
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
if (typeof value === "string") {
|
|
1859
|
+
return textRange(value, offset, length);
|
|
1860
|
+
}
|
|
1861
|
+
const text = previewCell(value, Number.MAX_SAFE_INTEGER).text;
|
|
1862
|
+
const window = textRange(text, offset, length);
|
|
1863
|
+
return { ...window, type: "scalar" };
|
|
1864
|
+
}
|
|
1865
|
+
function decodePointerToken(token) {
|
|
1866
|
+
if (/~(?:[^01]|$)/.test(token)) {
|
|
1867
|
+
throw new CfHanaError("CONFIG", "Invalid JSON Pointer escape");
|
|
1868
|
+
}
|
|
1869
|
+
return token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
1870
|
+
}
|
|
1871
|
+
function pointerTokens(pointer) {
|
|
1872
|
+
if (pointer === "") {
|
|
1873
|
+
return [];
|
|
1874
|
+
}
|
|
1875
|
+
if (!pointer.startsWith("/")) {
|
|
1876
|
+
throw new CfHanaError("CONFIG", "JSON Pointer must be empty or start with /");
|
|
1877
|
+
}
|
|
1878
|
+
return pointer.slice(1).split("/").map(decodePointerToken);
|
|
1879
|
+
}
|
|
1880
|
+
function resolvePointer(root, pointer) {
|
|
1881
|
+
let current = root;
|
|
1882
|
+
for (const token of pointerTokens(pointer)) {
|
|
1883
|
+
if (Array.isArray(current)) {
|
|
1884
|
+
const index = Number(token);
|
|
1885
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= current.length) {
|
|
1886
|
+
throw new CfHanaError("QUERY", `JSON Pointer path "${pointer}" not found`);
|
|
1887
|
+
}
|
|
1888
|
+
current = current[index];
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
if (typeof current === "object" && current !== null && token in current) {
|
|
1892
|
+
current = current[token];
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
throw new CfHanaError("QUERY", `JSON Pointer path "${pointer}" not found`);
|
|
1896
|
+
}
|
|
1897
|
+
return current;
|
|
1898
|
+
}
|
|
1899
|
+
function escapePointerToken(token) {
|
|
1900
|
+
return token.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
1901
|
+
}
|
|
1902
|
+
function jsonType(value) {
|
|
1903
|
+
if (value === null) {
|
|
1904
|
+
return "null";
|
|
1905
|
+
}
|
|
1906
|
+
if (Array.isArray(value)) {
|
|
1907
|
+
return "array";
|
|
1908
|
+
}
|
|
1909
|
+
return typeof value;
|
|
1910
|
+
}
|
|
1911
|
+
function jsonValueText(value, limit) {
|
|
1912
|
+
if (Array.isArray(value)) {
|
|
1913
|
+
return `items=${String(value.length)}`;
|
|
1914
|
+
}
|
|
1915
|
+
if (typeof value === "object" && value !== null) {
|
|
1916
|
+
return `keys=${String(Object.keys(value).length)}`;
|
|
1917
|
+
}
|
|
1918
|
+
if (typeof value === "string") {
|
|
1919
|
+
return previewCell(value, limit).text;
|
|
1920
|
+
}
|
|
1921
|
+
if (value === null) {
|
|
1922
|
+
return "null";
|
|
1923
|
+
}
|
|
1924
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
1925
|
+
return String(value);
|
|
1926
|
+
}
|
|
1927
|
+
return "";
|
|
1928
|
+
}
|
|
1929
|
+
function jsonSearchText(value) {
|
|
1930
|
+
if (typeof value === "string") {
|
|
1931
|
+
return value;
|
|
1932
|
+
}
|
|
1933
|
+
if (value === null) {
|
|
1934
|
+
return "null";
|
|
1935
|
+
}
|
|
1936
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
1937
|
+
return String(value);
|
|
1938
|
+
}
|
|
1939
|
+
return "";
|
|
1940
|
+
}
|
|
1941
|
+
function inspectionRow(path, value, limit) {
|
|
1942
|
+
return { path, type: jsonType(value), value: jsonValueText(value, limit) };
|
|
1943
|
+
}
|
|
1944
|
+
function inspectJsonCell(value, pointer, limit) {
|
|
1945
|
+
if (typeof value !== "string") {
|
|
1946
|
+
throw new CfHanaError("QUERY", "JSON Pointer requires a text cell");
|
|
1947
|
+
}
|
|
1948
|
+
let parsed;
|
|
1949
|
+
try {
|
|
1950
|
+
parsed = JSON.parse(value);
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
throw new CfHanaError("QUERY", "Saved result cell does not contain valid JSON", {
|
|
1953
|
+
cause: error
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
const selected = resolvePointer(parsed, pointer);
|
|
1957
|
+
if (Array.isArray(selected)) {
|
|
1958
|
+
return selected.map(
|
|
1959
|
+
(item, index) => inspectionRow(`${pointer}/${String(index)}`, item, limit)
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
if (typeof selected === "object" && selected !== null) {
|
|
1963
|
+
return Object.entries(selected).map(
|
|
1964
|
+
([key, item]) => inspectionRow(`${pointer}/${escapePointerToken(key)}`, item, limit)
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
return [inspectionRow(pointer, selected, limit)];
|
|
1968
|
+
}
|
|
1969
|
+
function plainTextMatches(text, term, row, column, limit, previewLength) {
|
|
1970
|
+
const matches = [];
|
|
1971
|
+
const lowerText = text.toLowerCase();
|
|
1972
|
+
let offset = lowerText.indexOf(term);
|
|
1973
|
+
while (offset >= 0 && matches.length < limit) {
|
|
1974
|
+
const start = Math.max(0, offset - 32);
|
|
1975
|
+
matches.push({
|
|
1976
|
+
row,
|
|
1977
|
+
column,
|
|
1978
|
+
offset,
|
|
1979
|
+
path: "",
|
|
1980
|
+
preview: textRange(text, start, previewLength).value.replaceAll(/[\r\n\t]/g, " ")
|
|
1981
|
+
});
|
|
1982
|
+
offset = lowerText.indexOf(term, offset + Math.max(1, term.length));
|
|
1983
|
+
}
|
|
1984
|
+
return matches;
|
|
1985
|
+
}
|
|
1986
|
+
function jsonSearchMatches(root, term, row, column, limit, previewLength) {
|
|
1987
|
+
const matches = [];
|
|
1988
|
+
const stack = [
|
|
1989
|
+
{ path: "", value: root }
|
|
1990
|
+
];
|
|
1991
|
+
while (stack.length > 0 && matches.length < limit) {
|
|
1992
|
+
const entry = stack.pop();
|
|
1993
|
+
if (entry === void 0) {
|
|
1994
|
+
break;
|
|
1995
|
+
}
|
|
1996
|
+
if (typeof entry.value === "object" && entry.value !== null) {
|
|
1997
|
+
const entries = Array.isArray(entry.value) ? entry.value.map((item, index) => [String(index), item]) : Object.entries(entry.value);
|
|
1998
|
+
for (const [key, value] of entries.reverse()) {
|
|
1999
|
+
const path = `${entry.path}/${escapePointerToken(key)}`;
|
|
2000
|
+
if (key.toLowerCase().includes(term)) {
|
|
2001
|
+
matches.push({ row, column, path, preview: jsonValueText(value, previewLength) });
|
|
2002
|
+
if (matches.length >= limit) {
|
|
2003
|
+
break;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
stack.push({ path, value });
|
|
2007
|
+
}
|
|
2008
|
+
continue;
|
|
2009
|
+
}
|
|
2010
|
+
const text = jsonSearchText(entry.value);
|
|
2011
|
+
if (text.toLowerCase().includes(term)) {
|
|
2012
|
+
matches.push({
|
|
2013
|
+
row,
|
|
2014
|
+
column,
|
|
2015
|
+
path: entry.path,
|
|
2016
|
+
preview: previewCell(text, previewLength).text
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
return matches;
|
|
2021
|
+
}
|
|
2022
|
+
function searchCell(value, term, row, column, limit, previewLength) {
|
|
2023
|
+
if (typeof value !== "string") {
|
|
2024
|
+
return [];
|
|
2025
|
+
}
|
|
2026
|
+
try {
|
|
2027
|
+
const parsed = JSON.parse(value);
|
|
2028
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
2029
|
+
return jsonSearchMatches(parsed, term, row, column, limit, previewLength);
|
|
2030
|
+
}
|
|
2031
|
+
} catch {
|
|
2032
|
+
}
|
|
2033
|
+
return plainTextMatches(value, term, row, column, limit, previewLength);
|
|
2034
|
+
}
|
|
2035
|
+
function searchResultSession(session, searchTerm, options) {
|
|
2036
|
+
assertPositiveInteger("limit", options.limit);
|
|
2037
|
+
const term = searchTerm.trim().toLowerCase();
|
|
2038
|
+
if (term.length === 0) {
|
|
2039
|
+
throw new CfHanaError("CONFIG", "search text must not be empty");
|
|
2040
|
+
}
|
|
2041
|
+
const previewLength = options.previewLength ?? 128;
|
|
2042
|
+
assertPositiveInteger("preview length", previewLength);
|
|
2043
|
+
const rows = options.row === void 0 ? session.result.rows.map((row, index) => ({ row, rowNumber: index + 1 })) : [{ row: selectResultRow(session, options.row), rowNumber: options.row }];
|
|
2044
|
+
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 }));
|
|
2045
|
+
const matches = [];
|
|
2046
|
+
for (const item of rows) {
|
|
2047
|
+
for (const column of columns) {
|
|
2048
|
+
const remaining = options.limit - matches.length;
|
|
2049
|
+
matches.push(
|
|
2050
|
+
...searchCell(
|
|
2051
|
+
item.row[column.name] ?? null,
|
|
2052
|
+
term,
|
|
2053
|
+
item.rowNumber,
|
|
2054
|
+
column.name,
|
|
2055
|
+
remaining,
|
|
2056
|
+
previewLength
|
|
2057
|
+
)
|
|
2058
|
+
);
|
|
2059
|
+
if (matches.length >= options.limit) {
|
|
2060
|
+
return matches;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
return matches;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// src/result-store.ts
|
|
2068
|
+
import { randomBytes } from "crypto";
|
|
2069
|
+
import { mkdir as mkdir3, readFile, readdir as readdir2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
2070
|
+
import { homedir as homedir3 } from "os";
|
|
2071
|
+
import { join as join3 } from "path";
|
|
2072
|
+
var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
|
|
2073
|
+
var MANIFEST_FILE_NAME = "manifest.json";
|
|
2074
|
+
function resultsRoot(saptoolsRoot) {
|
|
2075
|
+
return join3(saptoolsRoot ?? join3(homedir3(), ".saptools"), "cf-hana", "results");
|
|
2076
|
+
}
|
|
2077
|
+
function sessionDirectory(ref, saptoolsRoot) {
|
|
2078
|
+
return join3(resultsRoot(saptoolsRoot), ref);
|
|
2079
|
+
}
|
|
2080
|
+
function manifestPath(ref, saptoolsRoot) {
|
|
2081
|
+
return join3(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
|
|
2082
|
+
}
|
|
2083
|
+
function encodeCell(value) {
|
|
2084
|
+
if (value === null) {
|
|
2085
|
+
return { kind: "null" };
|
|
2086
|
+
}
|
|
2087
|
+
if (Buffer.isBuffer(value)) {
|
|
2088
|
+
return { kind: "buffer", value: value.toString("base64") };
|
|
2089
|
+
}
|
|
2090
|
+
if (value instanceof Date) {
|
|
2091
|
+
return { kind: "date", value: value.toISOString() };
|
|
2092
|
+
}
|
|
2093
|
+
if (typeof value === "string") {
|
|
2094
|
+
return { kind: "string", value };
|
|
2095
|
+
}
|
|
2096
|
+
if (typeof value === "number") {
|
|
2097
|
+
return { kind: "number", value };
|
|
2098
|
+
}
|
|
2099
|
+
return { kind: "boolean", value };
|
|
2100
|
+
}
|
|
2101
|
+
function decodeCell(cell) {
|
|
2102
|
+
switch (cell.kind) {
|
|
2103
|
+
case "null":
|
|
2104
|
+
return null;
|
|
2105
|
+
case "buffer":
|
|
2106
|
+
return Buffer.from(cell.value, "base64");
|
|
2107
|
+
case "date":
|
|
2108
|
+
return new Date(cell.value);
|
|
2109
|
+
case "string":
|
|
2110
|
+
return cell.value;
|
|
2111
|
+
case "number":
|
|
2112
|
+
return cell.value;
|
|
2113
|
+
case "boolean":
|
|
2114
|
+
return cell.value;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
function assertUniqueColumns(columns) {
|
|
2118
|
+
const names = /* @__PURE__ */ new Set();
|
|
2119
|
+
for (const column of columns) {
|
|
2120
|
+
if (names.has(column.name)) {
|
|
2121
|
+
throw new CfHanaError(
|
|
2122
|
+
"CONFIG",
|
|
2123
|
+
`Saved results require unique SQL aliases; duplicate column "${column.name}"`
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
names.add(column.name);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
function encodeResult(result) {
|
|
2130
|
+
assertUniqueColumns(result.columns);
|
|
2131
|
+
return {
|
|
2132
|
+
columns: result.columns,
|
|
2133
|
+
rows: result.rows.map(
|
|
2134
|
+
(row) => result.columns.map((column) => encodeCell(row[column.name] ?? null))
|
|
2135
|
+
),
|
|
2136
|
+
rowCount: result.rowCount,
|
|
2137
|
+
statement: result.statement,
|
|
2138
|
+
truncated: result.truncated,
|
|
2139
|
+
elapsedMs: result.elapsedMs
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
function decodeResult(result) {
|
|
2143
|
+
const rows = result.rows.map((cells) => {
|
|
2144
|
+
const row = {};
|
|
2145
|
+
let index = 0;
|
|
2146
|
+
for (const column of result.columns) {
|
|
2147
|
+
row[column.name] = decodeCell(cells[index] ?? { kind: "null" });
|
|
2148
|
+
index += 1;
|
|
2149
|
+
}
|
|
2150
|
+
return row;
|
|
2151
|
+
});
|
|
2152
|
+
return { ...result, rows };
|
|
2153
|
+
}
|
|
2154
|
+
function resolveRef(value) {
|
|
2155
|
+
const ref = value ?? `q${randomBytes(4).toString("hex")}`;
|
|
2156
|
+
if (!RESULT_REF_PATTERN.test(ref)) {
|
|
2157
|
+
throw new CfHanaError("CONFIG", "Invalid saved result ref");
|
|
2158
|
+
}
|
|
2159
|
+
return ref;
|
|
2160
|
+
}
|
|
2161
|
+
function resolveTtl(value) {
|
|
2162
|
+
const ttl = value ?? DEFAULT_RESULT_TTL_MINUTES;
|
|
2163
|
+
if (!Number.isSafeInteger(ttl) || ttl <= 0) {
|
|
2164
|
+
throw new CfHanaError("CONFIG", "Result TTL must be a positive safe integer");
|
|
2165
|
+
}
|
|
2166
|
+
return ttl;
|
|
2167
|
+
}
|
|
2168
|
+
function toStoredSession(input, ref, now) {
|
|
2169
|
+
const ttlMinutes = resolveTtl(input.ttlMinutes);
|
|
2170
|
+
return {
|
|
2171
|
+
version: 1,
|
|
2172
|
+
ref,
|
|
2173
|
+
createdAt: now.toISOString(),
|
|
2174
|
+
expiresAt: new Date(now.getTime() + ttlMinutes * 6e4).toISOString(),
|
|
2175
|
+
ttlMinutes,
|
|
2176
|
+
info: input.info,
|
|
2177
|
+
result: encodeResult(input.result)
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
function isRecord(value) {
|
|
2181
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2182
|
+
}
|
|
2183
|
+
function isStoredSession(value) {
|
|
2184
|
+
if (!isRecord(value) || !isRecord(value["result"]) || !isRecord(value["info"])) {
|
|
2185
|
+
return false;
|
|
2186
|
+
}
|
|
2187
|
+
const result = value["result"];
|
|
2188
|
+
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"]);
|
|
2189
|
+
}
|
|
2190
|
+
async function readStoredSession(path) {
|
|
2191
|
+
try {
|
|
2192
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
2193
|
+
return isStoredSession(parsed) ? parsed : void 0;
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
if (error.code === "ENOENT") {
|
|
2196
|
+
return void 0;
|
|
2197
|
+
}
|
|
2198
|
+
return void 0;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
function toSession(stored, saptoolsRoot) {
|
|
2202
|
+
return {
|
|
2203
|
+
...stored,
|
|
2204
|
+
result: decodeResult(stored.result),
|
|
2205
|
+
directory: sessionDirectory(stored.ref, saptoolsRoot),
|
|
2206
|
+
path: manifestPath(stored.ref, saptoolsRoot)
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
async function listSessionRefs(saptoolsRoot) {
|
|
2210
|
+
try {
|
|
2211
|
+
const entries = await readdir2(resultsRoot(saptoolsRoot), { withFileTypes: true });
|
|
2212
|
+
return entries.filter((entry) => entry.isDirectory() && RESULT_REF_PATTERN.test(entry.name)).map((entry) => entry.name);
|
|
2213
|
+
} catch (error) {
|
|
2214
|
+
if (error.code === "ENOENT") {
|
|
2215
|
+
return [];
|
|
2216
|
+
}
|
|
2217
|
+
throw error;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
async function createResultSession(input, options = {}) {
|
|
2221
|
+
await pruneResultSessions(options);
|
|
2222
|
+
const ref = resolveRef(options.ref);
|
|
2223
|
+
const stored = toStoredSession(input, ref, options.now?.() ?? /* @__PURE__ */ new Date());
|
|
2224
|
+
const serialized = `${JSON.stringify(stored)}
|
|
2225
|
+
`;
|
|
2226
|
+
if (Buffer.byteLength(serialized) > (options.maxBytes ?? MAX_RESULT_STORE_BYTES)) {
|
|
2227
|
+
throw new CfHanaError("CONFIG", "Saved result exceeds the storage limit");
|
|
2228
|
+
}
|
|
2229
|
+
const root = resultsRoot(options.saptoolsRoot);
|
|
2230
|
+
const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
|
|
2231
|
+
const tempDirectory = `${finalDirectory}.tmp-${process.pid.toString()}`;
|
|
2232
|
+
await mkdir3(root, { recursive: true, mode: 448 });
|
|
2233
|
+
await rm2(tempDirectory, { recursive: true, force: true });
|
|
2234
|
+
await mkdir3(tempDirectory, { mode: 448 });
|
|
2235
|
+
try {
|
|
2236
|
+
await writeFile2(join3(tempDirectory, MANIFEST_FILE_NAME), serialized, {
|
|
2237
|
+
encoding: "utf8",
|
|
2238
|
+
mode: 384
|
|
2239
|
+
});
|
|
2240
|
+
await rename(tempDirectory, finalDirectory);
|
|
2241
|
+
} catch (error) {
|
|
2242
|
+
await rm2(tempDirectory, { recursive: true, force: true });
|
|
2243
|
+
throw error;
|
|
2244
|
+
}
|
|
2245
|
+
return toSession(stored, options.saptoolsRoot);
|
|
2246
|
+
}
|
|
2247
|
+
async function readResultSession(ref, options = {}) {
|
|
2248
|
+
const resolvedRef = resolveRef(ref);
|
|
2249
|
+
await pruneResultSessions(options);
|
|
2250
|
+
const stored = await readStoredSession(manifestPath(resolvedRef, options.saptoolsRoot));
|
|
2251
|
+
if (stored === void 0) {
|
|
2252
|
+
throw new CfHanaError("QUERY", "Saved result not found or expired");
|
|
2253
|
+
}
|
|
2254
|
+
return toSession(stored, options.saptoolsRoot);
|
|
2255
|
+
}
|
|
2256
|
+
async function listResultSessions(options = {}) {
|
|
2257
|
+
await pruneResultSessions(options);
|
|
2258
|
+
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
2259
|
+
const stored = await Promise.all(
|
|
2260
|
+
refs.map(async (ref) => await readStoredSession(manifestPath(ref, options.saptoolsRoot)))
|
|
2261
|
+
);
|
|
2262
|
+
return stored.filter((item) => item !== void 0).map((item) => ({
|
|
2263
|
+
ref: item.ref,
|
|
2264
|
+
createdAt: item.createdAt,
|
|
2265
|
+
expiresAt: item.expiresAt,
|
|
2266
|
+
rowCount: item.result.rowCount,
|
|
2267
|
+
columnCount: item.result.columns.length,
|
|
2268
|
+
truncated: item.result.truncated
|
|
2269
|
+
})).sort((left, right) => left.ref.localeCompare(right.ref));
|
|
2270
|
+
}
|
|
2271
|
+
async function pruneResultSessions(options = {}) {
|
|
2272
|
+
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
2273
|
+
const now = (options.now?.() ?? /* @__PURE__ */ new Date()).getTime();
|
|
2274
|
+
let removed = 0;
|
|
2275
|
+
for (const ref of refs) {
|
|
2276
|
+
const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
|
|
2277
|
+
if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
|
|
2278
|
+
await rm2(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2279
|
+
removed += 1;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
return removed;
|
|
2283
|
+
}
|
|
2284
|
+
async function clearResultSessions(options = {}) {
|
|
2285
|
+
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
2286
|
+
await Promise.all(
|
|
2287
|
+
refs.map(async (ref) => {
|
|
2288
|
+
await rm2(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2289
|
+
})
|
|
2290
|
+
);
|
|
2291
|
+
return refs.length;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
// src/cli-results.ts
|
|
1703
2295
|
function print(text) {
|
|
1704
2296
|
process.stdout.write(`${text}
|
|
1705
2297
|
`);
|
|
1706
2298
|
}
|
|
2299
|
+
function parseIntOption(value) {
|
|
2300
|
+
const trimmed = value.trim();
|
|
2301
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
2302
|
+
throw new CfHanaError("CONFIG", `Expected an integer but received "${value}"`);
|
|
2303
|
+
}
|
|
2304
|
+
const parsed = Number(trimmed);
|
|
2305
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
2306
|
+
throw new CfHanaError("CONFIG", `Expected a safe integer but received "${value}"`);
|
|
2307
|
+
}
|
|
2308
|
+
return parsed;
|
|
2309
|
+
}
|
|
2310
|
+
function positive(name, value, fallback) {
|
|
2311
|
+
const resolved = value ?? fallback;
|
|
2312
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
2313
|
+
throw new CfHanaError("CONFIG", `${name} must be a positive safe integer`);
|
|
2314
|
+
}
|
|
2315
|
+
return resolved;
|
|
2316
|
+
}
|
|
2317
|
+
function boundedLength(value) {
|
|
2318
|
+
const resolved = positive("--length", value, DEFAULT_CELL_LIMIT);
|
|
2319
|
+
if (resolved > MAX_CELL_LIMIT) {
|
|
2320
|
+
throw new CfHanaError("CONFIG", `--length must be at most ${String(MAX_CELL_LIMIT)}`);
|
|
2321
|
+
}
|
|
2322
|
+
return resolved;
|
|
2323
|
+
}
|
|
2324
|
+
function nonNegative(name, value) {
|
|
2325
|
+
const resolved = value ?? 0;
|
|
2326
|
+
if (!Number.isSafeInteger(resolved) || resolved < 0) {
|
|
2327
|
+
throw new CfHanaError("CONFIG", `${name} must be a non-negative safe integer`);
|
|
2328
|
+
}
|
|
2329
|
+
return resolved;
|
|
2330
|
+
}
|
|
2331
|
+
function resultFromRows(rows, columns) {
|
|
2332
|
+
return {
|
|
2333
|
+
rows,
|
|
2334
|
+
columns,
|
|
2335
|
+
rowCount: rows.length,
|
|
2336
|
+
statement: "select",
|
|
2337
|
+
truncated: false,
|
|
2338
|
+
elapsedMs: 0
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
function csv(rows, columns, limit) {
|
|
2342
|
+
const result = resultFromRows(
|
|
2343
|
+
rows,
|
|
2344
|
+
columns.map((name) => ({ name, typeName: "" }))
|
|
2345
|
+
);
|
|
2346
|
+
return formatCompactCsv(result, limit).text;
|
|
2347
|
+
}
|
|
2348
|
+
async function runShow(ref, options) {
|
|
2349
|
+
const session = await readResultSession(ref);
|
|
2350
|
+
const length = boundedLength(options.length);
|
|
2351
|
+
if (options.row === void 0) {
|
|
2352
|
+
if (options.column !== void 0 || options.path !== void 0 || options.offset !== void 0) {
|
|
2353
|
+
throw new CfHanaError("CONFIG", "--column, --path, and --offset require --row");
|
|
2354
|
+
}
|
|
2355
|
+
print(summaryCsv(session, length));
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
if (options.column === void 0) {
|
|
2359
|
+
if (options.path !== void 0 || options.offset !== void 0) {
|
|
2360
|
+
throw new CfHanaError("CONFIG", "--path and --offset require --column");
|
|
2361
|
+
}
|
|
2362
|
+
const row = selectResultRow(session, options.row);
|
|
2363
|
+
print(formatCompactCsv(resultFromRows([row], session.result.columns), length).text);
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
const selected = selectResultCell(session, options.row, options.column);
|
|
2367
|
+
if (options.path !== void 0) {
|
|
2368
|
+
const rows = inspectJsonCell(selected.value, options.path, length).map((row) => ({
|
|
2369
|
+
PATH: row.path,
|
|
2370
|
+
TYPE: row.type,
|
|
2371
|
+
VALUE: row.value
|
|
2372
|
+
}));
|
|
2373
|
+
print(csv(rows, ["PATH", "TYPE", "VALUE"], length));
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
const window = readCellWindow(selected.value, nonNegative("--offset", options.offset), length);
|
|
2377
|
+
print(
|
|
2378
|
+
csv(
|
|
2379
|
+
[
|
|
2380
|
+
{
|
|
2381
|
+
ROW: selected.row,
|
|
2382
|
+
COLUMN: selected.column,
|
|
2383
|
+
TYPE: window.type,
|
|
2384
|
+
ORIGINAL_LENGTH: window.originalLength,
|
|
2385
|
+
OFFSET: window.offset,
|
|
2386
|
+
VALUE: window.value
|
|
2387
|
+
}
|
|
2388
|
+
],
|
|
2389
|
+
["ROW", "COLUMN", "TYPE", "ORIGINAL_LENGTH", "OFFSET", "VALUE"],
|
|
2390
|
+
length
|
|
2391
|
+
)
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2394
|
+
function summaryCsv(session, length) {
|
|
2395
|
+
const compact = formatCompactCsv(session.result, DEFAULT_CELL_LIMIT);
|
|
2396
|
+
return csv(
|
|
2397
|
+
[
|
|
2398
|
+
{
|
|
2399
|
+
REF: session.ref,
|
|
2400
|
+
ROWS: session.result.rowCount,
|
|
2401
|
+
COLUMNS: session.result.columns.length,
|
|
2402
|
+
ROW_TRUNCATED: session.result.truncated,
|
|
2403
|
+
TRUNCATED_CELLS: compact.truncatedCells,
|
|
2404
|
+
EXPIRES_AT: session.expiresAt
|
|
2405
|
+
}
|
|
2406
|
+
],
|
|
2407
|
+
["REF", "ROWS", "COLUMNS", "ROW_TRUNCATED", "TRUNCATED_CELLS", "EXPIRES_AT"],
|
|
2408
|
+
length
|
|
2409
|
+
);
|
|
2410
|
+
}
|
|
2411
|
+
async function runSearch(ref, text, options) {
|
|
2412
|
+
const session = await readResultSession(ref);
|
|
2413
|
+
const length = boundedLength(options.length);
|
|
2414
|
+
const matches = searchResultSession(session, text, {
|
|
2415
|
+
limit: positive("--limit", options.limit, DEFAULT_RESULT_SEARCH_LIMIT),
|
|
2416
|
+
previewLength: length,
|
|
2417
|
+
...options.row === void 0 ? {} : { row: options.row },
|
|
2418
|
+
...options.column === void 0 ? {} : { column: options.column }
|
|
2419
|
+
});
|
|
2420
|
+
const rows = matches.map((match) => ({
|
|
2421
|
+
ROW: match.row,
|
|
2422
|
+
COLUMN: match.column,
|
|
2423
|
+
OFFSET: match.offset ?? null,
|
|
2424
|
+
PATH: match.path,
|
|
2425
|
+
PREVIEW: match.preview
|
|
2426
|
+
}));
|
|
2427
|
+
print(csv(rows, ["ROW", "COLUMN", "OFFSET", "PATH", "PREVIEW"], length));
|
|
2428
|
+
}
|
|
2429
|
+
function cellExportValue(value) {
|
|
2430
|
+
if (Buffer.isBuffer(value)) {
|
|
2431
|
+
return value;
|
|
2432
|
+
}
|
|
2433
|
+
if (value instanceof Date) {
|
|
2434
|
+
return value.toISOString();
|
|
2435
|
+
}
|
|
2436
|
+
if (value === null) {
|
|
2437
|
+
return "";
|
|
2438
|
+
}
|
|
2439
|
+
if (typeof value === "boolean") {
|
|
2440
|
+
return value ? "true" : "false";
|
|
2441
|
+
}
|
|
2442
|
+
return value.toString();
|
|
2443
|
+
}
|
|
2444
|
+
async function runExport(ref, options) {
|
|
2445
|
+
if (options.row === void 0 || options.column === void 0) {
|
|
2446
|
+
throw new CfHanaError("CONFIG", "result export requires --row and --column");
|
|
2447
|
+
}
|
|
2448
|
+
if (options.output === void 0 || options.output.trim().length === 0) {
|
|
2449
|
+
throw new CfHanaError("CONFIG", "result export requires --output");
|
|
2450
|
+
}
|
|
2451
|
+
const session = await readResultSession(ref);
|
|
2452
|
+
const selected = selectResultCell(session, options.row, options.column);
|
|
2453
|
+
await writeFile3(options.output, cellExportValue(selected.value), { mode: 384 });
|
|
2454
|
+
print(`wrote=${options.output}`);
|
|
2455
|
+
}
|
|
2456
|
+
async function runList() {
|
|
2457
|
+
const summaries = await listResultSessions();
|
|
2458
|
+
const rows = summaries.map((summary) => ({
|
|
2459
|
+
REF: summary.ref,
|
|
2460
|
+
ROWS: summary.rowCount,
|
|
2461
|
+
COLUMNS: summary.columnCount,
|
|
2462
|
+
ROW_TRUNCATED: summary.truncated,
|
|
2463
|
+
EXPIRES_AT: summary.expiresAt
|
|
2464
|
+
}));
|
|
2465
|
+
print(csv(rows, ["REF", "ROWS", "COLUMNS", "ROW_TRUNCATED", "EXPIRES_AT"], DEFAULT_CELL_LIMIT));
|
|
2466
|
+
}
|
|
2467
|
+
async function runPrune() {
|
|
2468
|
+
print(`removed=${String(await pruneResultSessions())}`);
|
|
2469
|
+
}
|
|
2470
|
+
async function runClear() {
|
|
2471
|
+
print(`removed=${String(await clearResultSessions())}`);
|
|
2472
|
+
}
|
|
2473
|
+
function registerResultCommands(program) {
|
|
2474
|
+
const result = program.command("result").description("inspect saved query refs");
|
|
2475
|
+
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) => {
|
|
2476
|
+
await runShow(ref, command.opts());
|
|
2477
|
+
});
|
|
2478
|
+
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) => {
|
|
2479
|
+
await runSearch(ref, text, command.opts());
|
|
2480
|
+
});
|
|
2481
|
+
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) => {
|
|
2482
|
+
await runExport(ref, command.opts());
|
|
2483
|
+
});
|
|
2484
|
+
result.command("list").description("list active saved refs").action(async () => {
|
|
2485
|
+
await runList();
|
|
2486
|
+
});
|
|
2487
|
+
result.command("prune").description("remove expired saved refs").action(async () => {
|
|
2488
|
+
await runPrune();
|
|
2489
|
+
});
|
|
2490
|
+
result.command("clear").description("remove all saved refs").action(async () => {
|
|
2491
|
+
await runClear();
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// src/cli.ts
|
|
2496
|
+
function print2(text) {
|
|
2497
|
+
process.stdout.write(`${text}
|
|
2498
|
+
`);
|
|
2499
|
+
}
|
|
1707
2500
|
function fail(message) {
|
|
1708
2501
|
process.stderr.write(`${CLI_NAME}: ${message}
|
|
1709
2502
|
`);
|
|
1710
2503
|
process.exit(1);
|
|
1711
2504
|
}
|
|
1712
|
-
function
|
|
2505
|
+
function parseIntOption2(value) {
|
|
1713
2506
|
const trimmed = value.trim();
|
|
1714
2507
|
if (!/^-?\d+$/.test(trimmed)) {
|
|
1715
2508
|
throw new CfHanaError("CONFIG", `Expected an integer but received "${value}"`);
|
|
@@ -1752,6 +2545,25 @@ function parseQualifiedName(value) {
|
|
|
1752
2545
|
}
|
|
1753
2546
|
return { schema: value.slice(0, dot), table: value.slice(dot + 1) };
|
|
1754
2547
|
}
|
|
2548
|
+
async function resolveSelectorArgument(selector) {
|
|
2549
|
+
if (selector.includes("/")) {
|
|
2550
|
+
return selector;
|
|
2551
|
+
}
|
|
2552
|
+
const current = await readCurrentCfTarget().catch((error) => {
|
|
2553
|
+
throw new CfHanaError(
|
|
2554
|
+
"CONFIG",
|
|
2555
|
+
"No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector.",
|
|
2556
|
+
{ cause: error }
|
|
2557
|
+
);
|
|
2558
|
+
});
|
|
2559
|
+
if (current === void 0) {
|
|
2560
|
+
throw new CfHanaError(
|
|
2561
|
+
"CONFIG",
|
|
2562
|
+
"No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
return formatCurrentCfAppSelector(current, selector);
|
|
2566
|
+
}
|
|
1755
2567
|
function toConnectOptions(opts) {
|
|
1756
2568
|
assertPositiveOption("--limit", opts.limit);
|
|
1757
2569
|
assertPositiveOption("--timeout", opts.timeout);
|
|
@@ -1767,6 +2579,21 @@ function toConnectOptions(opts) {
|
|
|
1767
2579
|
...opts.timeout === void 0 ? {} : { queryTimeoutMs: opts.timeout, connectTimeoutMs: opts.timeout }
|
|
1768
2580
|
};
|
|
1769
2581
|
}
|
|
2582
|
+
function resolveCellLimit(value) {
|
|
2583
|
+
const limit = value ?? DEFAULT_CELL_LIMIT;
|
|
2584
|
+
assertPositiveOption("--cell-limit", limit);
|
|
2585
|
+
if (limit > MAX_CELL_LIMIT) {
|
|
2586
|
+
throw new CfHanaError("CONFIG", `--cell-limit must be at most ${String(MAX_CELL_LIMIT)}`);
|
|
2587
|
+
}
|
|
2588
|
+
return limit;
|
|
2589
|
+
}
|
|
2590
|
+
function assertQueryOptions(sql, opts) {
|
|
2591
|
+
resolveCellLimit(opts.cellLimit);
|
|
2592
|
+
assertPositiveOption("--result-ttl-minutes", opts.resultTtlMinutes);
|
|
2593
|
+
if (opts.save && classifyStatement(sql) !== "select") {
|
|
2594
|
+
throw new CfHanaError("CONFIG", "--save is only available for SELECT/WITH statements");
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
1770
2597
|
function rowsToResult(rows) {
|
|
1771
2598
|
const first = rows[0];
|
|
1772
2599
|
const columns = first === void 0 ? [] : Object.keys(first).map((name) => ({ name, typeName: "" }));
|
|
@@ -1791,11 +2618,20 @@ function formatInfo(info) {
|
|
|
1791
2618
|
].join("\n");
|
|
1792
2619
|
}
|
|
1793
2620
|
function withConnectionOptions(command) {
|
|
1794
|
-
return command.option("--
|
|
2621
|
+
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");
|
|
2622
|
+
}
|
|
2623
|
+
function withFormattedConnectionOptions(command) {
|
|
2624
|
+
return withConnectionOptions(command).option(
|
|
2625
|
+
"--format <format>",
|
|
2626
|
+
"output format: table, json, or csv",
|
|
2627
|
+
"table"
|
|
2628
|
+
);
|
|
1795
2629
|
}
|
|
1796
2630
|
async function runQuery(selector, sql, command) {
|
|
1797
2631
|
const opts = command.opts();
|
|
1798
|
-
|
|
2632
|
+
assertQueryOptions(sql, opts);
|
|
2633
|
+
const cellLimit = resolveCellLimit(opts.cellLimit);
|
|
2634
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
1799
2635
|
try {
|
|
1800
2636
|
const params = opts.param ?? [];
|
|
1801
2637
|
const backup = await client.backupWriteStatement(sql, params);
|
|
@@ -1804,14 +2640,39 @@ async function runQuery(selector, sql, command) {
|
|
|
1804
2640
|
`);
|
|
1805
2641
|
}
|
|
1806
2642
|
const result = await client.query(sql, params);
|
|
1807
|
-
|
|
2643
|
+
if (result.statement === "select") {
|
|
2644
|
+
const compact = formatCompactCsv(result, cellLimit);
|
|
2645
|
+
if (opts.save) {
|
|
2646
|
+
const session = await createResultSession({
|
|
2647
|
+
result,
|
|
2648
|
+
info: client.info,
|
|
2649
|
+
...opts.resultTtlMinutes === void 0 ? {} : { ttlMinutes: opts.resultTtlMinutes }
|
|
2650
|
+
});
|
|
2651
|
+
print2(`ref=${session.ref}`);
|
|
2652
|
+
process.stderr.write(`${CLI_NAME}: saved result expires at ${session.expiresAt}
|
|
2653
|
+
`);
|
|
2654
|
+
}
|
|
2655
|
+
print2(compact.text);
|
|
2656
|
+
if (result.truncated) {
|
|
2657
|
+
process.stderr.write(`${CLI_NAME}: row limit reached; rerun with --limit for more rows
|
|
2658
|
+
`);
|
|
2659
|
+
}
|
|
2660
|
+
if (compact.truncatedCells > 0) {
|
|
2661
|
+
process.stderr.write(
|
|
2662
|
+
`${CLI_NAME}: compacted ${String(compact.truncatedCells)} cell(s); use --save to inspect exact values by ref
|
|
2663
|
+
`
|
|
2664
|
+
);
|
|
2665
|
+
}
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
print2(formatTable(result));
|
|
1808
2669
|
} finally {
|
|
1809
2670
|
await client.close();
|
|
1810
2671
|
}
|
|
1811
2672
|
}
|
|
1812
2673
|
async function runTables(selector, schema, command) {
|
|
1813
2674
|
const opts = command.opts();
|
|
1814
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
2675
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
1815
2676
|
try {
|
|
1816
2677
|
const tables = await client.listTables(schema ?? client.info.schema);
|
|
1817
2678
|
const rows = tables.map((table) => ({
|
|
@@ -1819,7 +2680,7 @@ async function runTables(selector, schema, command) {
|
|
|
1819
2680
|
TABLE: table.name,
|
|
1820
2681
|
TYPE: table.type
|
|
1821
2682
|
}));
|
|
1822
|
-
|
|
2683
|
+
print2(formatResult(rowsToResult(rows), parseFormat(opts.format)));
|
|
1823
2684
|
} finally {
|
|
1824
2685
|
await client.close();
|
|
1825
2686
|
}
|
|
@@ -1827,7 +2688,7 @@ async function runTables(selector, schema, command) {
|
|
|
1827
2688
|
async function runColumns(selector, target, command) {
|
|
1828
2689
|
const opts = command.opts();
|
|
1829
2690
|
const { schema, table } = parseQualifiedName(target);
|
|
1830
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
2691
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
1831
2692
|
try {
|
|
1832
2693
|
const columns = await client.listColumns(schema, table);
|
|
1833
2694
|
const rows = columns.map((column) => ({
|
|
@@ -1837,7 +2698,7 @@ async function runColumns(selector, target, command) {
|
|
|
1837
2698
|
NULLABLE: column.nullable,
|
|
1838
2699
|
POSITION: column.position
|
|
1839
2700
|
}));
|
|
1840
|
-
|
|
2701
|
+
print2(formatResult(rowsToResult(rows), parseFormat(opts.format)));
|
|
1841
2702
|
} finally {
|
|
1842
2703
|
await client.close();
|
|
1843
2704
|
}
|
|
@@ -1845,21 +2706,21 @@ async function runColumns(selector, target, command) {
|
|
|
1845
2706
|
async function runCount(selector, target, command) {
|
|
1846
2707
|
const opts = command.opts();
|
|
1847
2708
|
const { schema, table } = parseQualifiedName(target);
|
|
1848
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
2709
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
1849
2710
|
try {
|
|
1850
2711
|
const total = await client.count({ schema, table });
|
|
1851
|
-
|
|
2712
|
+
print2(String(total));
|
|
1852
2713
|
} finally {
|
|
1853
2714
|
await client.close();
|
|
1854
2715
|
}
|
|
1855
2716
|
}
|
|
1856
2717
|
async function runPing(selector, command) {
|
|
1857
2718
|
const opts = command.opts();
|
|
1858
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
2719
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
1859
2720
|
try {
|
|
1860
2721
|
const started = Date.now();
|
|
1861
2722
|
await client.query("SELECT 1 FROM DUMMY");
|
|
1862
|
-
|
|
2723
|
+
print2(
|
|
1863
2724
|
`OK ${client.info.host} schema=${client.info.schema} ${String(Date.now() - started)}ms`
|
|
1864
2725
|
);
|
|
1865
2726
|
} finally {
|
|
@@ -1868,9 +2729,9 @@ async function runPing(selector, command) {
|
|
|
1868
2729
|
}
|
|
1869
2730
|
async function runInfo(selector, command) {
|
|
1870
2731
|
const opts = command.opts();
|
|
1871
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
2732
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
1872
2733
|
try {
|
|
1873
|
-
|
|
2734
|
+
print2(formatInfo(client.info));
|
|
1874
2735
|
} finally {
|
|
1875
2736
|
await client.close();
|
|
1876
2737
|
}
|
|
@@ -1879,18 +2740,22 @@ function buildProgram() {
|
|
|
1879
2740
|
const program = new Command();
|
|
1880
2741
|
program.name(CLI_NAME).description("Run SQL against SAP HANA Cloud databases bound to a Cloud Foundry app").version(CLI_VERSION);
|
|
1881
2742
|
withConnectionOptions(
|
|
1882
|
-
program.command("query <selector> <sql>").description("run a single SQL statement").option("--param <value>", "bind a SQL parameter (repeatable)", collectParam, [])
|
|
2743
|
+
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(
|
|
2744
|
+
"--result-ttl-minutes <n>",
|
|
2745
|
+
"minutes before a saved result expires",
|
|
2746
|
+
parseIntOption2
|
|
2747
|
+
)
|
|
1883
2748
|
).action(async (selector, sql, _options, command) => {
|
|
1884
2749
|
await runQuery(selector, sql, command);
|
|
1885
2750
|
});
|
|
1886
|
-
|
|
2751
|
+
withFormattedConnectionOptions(
|
|
1887
2752
|
program.command("tables <selector> [schema]").description("list tables in a schema")
|
|
1888
2753
|
).action(
|
|
1889
2754
|
async (selector, schema, _options, command) => {
|
|
1890
2755
|
await runTables(selector, schema, command);
|
|
1891
2756
|
}
|
|
1892
2757
|
);
|
|
1893
|
-
|
|
2758
|
+
withFormattedConnectionOptions(
|
|
1894
2759
|
program.command("columns <selector> <schema.table>").description("list the columns of a table")
|
|
1895
2760
|
).action(async (selector, target, _options, command) => {
|
|
1896
2761
|
await runColumns(selector, target, command);
|
|
@@ -1910,6 +2775,7 @@ function buildProgram() {
|
|
|
1910
2775
|
).action(async (selector, _options, command) => {
|
|
1911
2776
|
await runInfo(selector, command);
|
|
1912
2777
|
});
|
|
2778
|
+
registerResultCommands(program);
|
|
1913
2779
|
return program;
|
|
1914
2780
|
}
|
|
1915
2781
|
try {
|