@saptools/cf-hana 0.2.2 → 0.3.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 +6 -0
- package/README.md +32 -5
- package/dist/cli.js +563 -25
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +79 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -51,6 +51,15 @@ function errorMessage(error) {
|
|
|
51
51
|
return String(error);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// src/lob.ts
|
|
55
|
+
var TEXT_LOB_TYPES = /* @__PURE__ */ new Set(["CLOB", "NCLOB"]);
|
|
56
|
+
function normalizeTypeName(typeName2) {
|
|
57
|
+
return typeName2?.trim().toUpperCase() ?? "";
|
|
58
|
+
}
|
|
59
|
+
function isTextLobType(typeName2) {
|
|
60
|
+
return TEXT_LOB_TYPES.has(normalizeTypeName(typeName2));
|
|
61
|
+
}
|
|
62
|
+
|
|
54
63
|
// src/result-preview.ts
|
|
55
64
|
function normalizePreviewChar(value) {
|
|
56
65
|
return value === "\r" || value === "\n" || value === " " ? " " : value;
|
|
@@ -91,12 +100,12 @@ function scalarText(value) {
|
|
|
91
100
|
}
|
|
92
101
|
return value.toString();
|
|
93
102
|
}
|
|
94
|
-
function previewCell(value, limit) {
|
|
103
|
+
function previewCell(value, limit, typeName2) {
|
|
95
104
|
if (value === null) {
|
|
96
105
|
return { text: "", truncated: false, originalLength: 0, unit: "chars" };
|
|
97
106
|
}
|
|
98
107
|
if (Buffer.isBuffer(value)) {
|
|
99
|
-
return previewBuffer(value, limit);
|
|
108
|
+
return isTextLobType(typeName2) ? previewText(value.toString("utf8"), limit) : previewBuffer(value, limit);
|
|
100
109
|
}
|
|
101
110
|
if (typeof value === "string") {
|
|
102
111
|
return previewText(value, limit);
|
|
@@ -105,7 +114,7 @@ function previewCell(value, limit) {
|
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
// src/format.ts
|
|
108
|
-
function cellText(value, nullText) {
|
|
117
|
+
function cellText(value, nullText, column) {
|
|
109
118
|
if (value === null) {
|
|
110
119
|
return nullText;
|
|
111
120
|
}
|
|
@@ -113,14 +122,14 @@ function cellText(value, nullText) {
|
|
|
113
122
|
return value.toISOString();
|
|
114
123
|
}
|
|
115
124
|
if (Buffer.isBuffer(value)) {
|
|
116
|
-
return `0x${value.toString("hex")}`;
|
|
125
|
+
return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
|
|
117
126
|
}
|
|
118
127
|
if (typeof value === "boolean") {
|
|
119
128
|
return value ? "true" : "false";
|
|
120
129
|
}
|
|
121
130
|
return typeof value === "number" ? value.toString() : value;
|
|
122
131
|
}
|
|
123
|
-
function serializeCell(value) {
|
|
132
|
+
function serializeCell(value, column) {
|
|
124
133
|
if (value === null) {
|
|
125
134
|
return null;
|
|
126
135
|
}
|
|
@@ -128,7 +137,7 @@ function serializeCell(value) {
|
|
|
128
137
|
return value.toISOString();
|
|
129
138
|
}
|
|
130
139
|
if (Buffer.isBuffer(value)) {
|
|
131
|
-
return `0x${value.toString("hex")}`;
|
|
140
|
+
return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
|
|
132
141
|
}
|
|
133
142
|
return value;
|
|
134
143
|
}
|
|
@@ -144,7 +153,7 @@ function formatTable(result) {
|
|
|
144
153
|
}
|
|
145
154
|
const headers = result.columns.map((column) => column.name);
|
|
146
155
|
const rows = result.rows.map(
|
|
147
|
-
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
|
|
156
|
+
(row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL", column))
|
|
148
157
|
);
|
|
149
158
|
const widths = headers.map((header, index) => {
|
|
150
159
|
const widest = rows.reduce(
|
|
@@ -162,7 +171,8 @@ function formatJson(result) {
|
|
|
162
171
|
const rows = result.rows.map((row) => {
|
|
163
172
|
const serialized = {};
|
|
164
173
|
for (const [key, value] of Object.entries(row)) {
|
|
165
|
-
|
|
174
|
+
const column = result.columns.find((item) => item.name === key);
|
|
175
|
+
serialized[key] = serializeCell(value, column);
|
|
166
176
|
}
|
|
167
177
|
return serialized;
|
|
168
178
|
});
|
|
@@ -173,7 +183,7 @@ function formatCsv(result) {
|
|
|
173
183
|
const lines = [headers.map((header) => csvEscape(header)).join(",")];
|
|
174
184
|
for (const row of result.rows) {
|
|
175
185
|
lines.push(
|
|
176
|
-
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
|
|
186
|
+
result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, "", column))).join(",")
|
|
177
187
|
);
|
|
178
188
|
}
|
|
179
189
|
return lines.join("\r\n");
|
|
@@ -184,7 +194,7 @@ function formatCompactCsv(result, cellLimit) {
|
|
|
184
194
|
let truncatedCells = 0;
|
|
185
195
|
for (const row of result.rows) {
|
|
186
196
|
const cells = result.columns.map((column) => {
|
|
187
|
-
const preview = previewCell(row[column.name] ?? null, cellLimit);
|
|
197
|
+
const preview = previewCell(row[column.name] ?? null, cellLimit, column.typeName);
|
|
188
198
|
if (preview.truncated) {
|
|
189
199
|
truncatedCells += 1;
|
|
190
200
|
}
|
|
@@ -634,6 +644,18 @@ async function listTables(connection, schema) {
|
|
|
634
644
|
rowCount: void 0
|
|
635
645
|
}));
|
|
636
646
|
}
|
|
647
|
+
async function listCatalogObjects(connection, schema) {
|
|
648
|
+
const result = await connection.query(
|
|
649
|
+
"SELECT SCHEMA_NAME, TABLE_NAME AS OBJECT_NAME, 'TABLE' AS OBJECT_TYPE FROM SYS.TABLES WHERE SCHEMA_NAME = ? UNION ALL SELECT SCHEMA_NAME, VIEW_NAME AS OBJECT_NAME, 'VIEW' AS OBJECT_TYPE FROM SYS.VIEWS WHERE SCHEMA_NAME = ? ORDER BY OBJECT_NAME",
|
|
650
|
+
[schema, schema],
|
|
651
|
+
{ autoLimit: false }
|
|
652
|
+
);
|
|
653
|
+
return result.rows.map((row) => ({
|
|
654
|
+
schema: row.SCHEMA_NAME,
|
|
655
|
+
name: row.OBJECT_NAME,
|
|
656
|
+
type: row.OBJECT_TYPE
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
637
659
|
async function listColumns(connection, schema, table) {
|
|
638
660
|
const result = await connection.query(
|
|
639
661
|
"SELECT COLUMN_NAME, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE, POSITION FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION",
|
|
@@ -652,7 +674,7 @@ async function listColumns(connection, schema, table) {
|
|
|
652
674
|
|
|
653
675
|
// src/config.ts
|
|
654
676
|
var CLI_NAME = "cf-hana";
|
|
655
|
-
var CLI_VERSION = "0.
|
|
677
|
+
var CLI_VERSION = "0.3.0";
|
|
656
678
|
var ENV_PREFIX = "CF_HANA";
|
|
657
679
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
658
680
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -731,9 +753,9 @@ function getApiEndpointForRegion(regionKey) {
|
|
|
731
753
|
return REGION_API_MAP[regionKey];
|
|
732
754
|
}
|
|
733
755
|
function getRegionKeyForApi(apiEndpoint) {
|
|
734
|
-
const
|
|
756
|
+
const norm2 = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
|
|
735
757
|
for (const [key, endpoint] of Object.entries(REGION_API_MAP)) {
|
|
736
|
-
if (endpoint.toLowerCase() ===
|
|
758
|
+
if (endpoint.toLowerCase() === norm2) {
|
|
737
759
|
return key;
|
|
738
760
|
}
|
|
739
761
|
}
|
|
@@ -1114,12 +1136,57 @@ function toConnectionTarget(binding, role) {
|
|
|
1114
1136
|
|
|
1115
1137
|
// src/driver/fake.ts
|
|
1116
1138
|
import { appendFile } from "fs/promises";
|
|
1139
|
+
var catalogFailureInjected = false;
|
|
1140
|
+
function catalogObjects() {
|
|
1141
|
+
return [
|
|
1142
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
|
|
1143
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_FIXED", OBJECT_TYPE: "TABLE" },
|
|
1144
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_VIEW", OBJECT_TYPE: "VIEW" },
|
|
1145
|
+
{ SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "STATUS_ITEMS", OBJECT_TYPE: "TABLE" }
|
|
1146
|
+
];
|
|
1147
|
+
}
|
|
1117
1148
|
function fakeExec(sql) {
|
|
1118
1149
|
const kind = classifyStatement(sql);
|
|
1119
1150
|
const forcedFailure = readEnv(envName("FAKE_FAIL_STATEMENT"))?.toLowerCase();
|
|
1120
1151
|
if (forcedFailure === kind) {
|
|
1121
1152
|
throw new Error(`fake driver forced ${kind.toUpperCase()} failure`);
|
|
1122
1153
|
}
|
|
1154
|
+
const upperSql = sql.toUpperCase();
|
|
1155
|
+
if (upperSql.includes("SYS.TABLES") && upperSql.includes("SYS.VIEWS")) {
|
|
1156
|
+
if (readEnv(envName("FAKE_FAIL_CATALOG_ONCE")) === "1" && !catalogFailureInjected) {
|
|
1157
|
+
catalogFailureInjected = true;
|
|
1158
|
+
throw new QueryError("fake transient catalog metadata failure");
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
rows: catalogObjects(),
|
|
1162
|
+
columns: [
|
|
1163
|
+
{ name: "SCHEMA_NAME", typeName: "NVARCHAR" },
|
|
1164
|
+
{ name: "OBJECT_NAME", typeName: "NVARCHAR" },
|
|
1165
|
+
{ name: "OBJECT_TYPE", typeName: "NVARCHAR" }
|
|
1166
|
+
],
|
|
1167
|
+
affectedRows: 0
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
if (upperSql.includes("MISSING_TABLE") || upperSql.includes("MISSING_TABLES")) {
|
|
1171
|
+
throw new QueryError("invalid table name: MISSING_TABLE", { sqlState: "42S02" });
|
|
1172
|
+
}
|
|
1173
|
+
if (sql.toUpperCase().includes("LOB_FIXTURE")) {
|
|
1174
|
+
return {
|
|
1175
|
+
rows: [
|
|
1176
|
+
{
|
|
1177
|
+
LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
|
|
1178
|
+
CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
|
|
1179
|
+
PAYLOAD: Buffer.from([0, 1, 2, 255])
|
|
1180
|
+
}
|
|
1181
|
+
],
|
|
1182
|
+
columns: [
|
|
1183
|
+
{ name: "LOG_CONTENT", typeName: "NCLOB" },
|
|
1184
|
+
{ name: "CLOB_CONTENT", typeName: "CLOB" },
|
|
1185
|
+
{ name: "PAYLOAD", typeName: "BLOB" }
|
|
1186
|
+
],
|
|
1187
|
+
affectedRows: 0
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1123
1190
|
if (sql.toUpperCase().includes("DUMMY")) {
|
|
1124
1191
|
return {
|
|
1125
1192
|
rows: [{ "1": 1 }],
|
|
@@ -2119,6 +2186,10 @@ var HanaClient = class _HanaClient {
|
|
|
2119
2186
|
async listTables(schema) {
|
|
2120
2187
|
return await this.pool.withConnection((connection) => listTables(connection, schema));
|
|
2121
2188
|
}
|
|
2189
|
+
/** List table and view names in a schema for typo suggestions. */
|
|
2190
|
+
async listCatalogObjects(schema) {
|
|
2191
|
+
return await this.pool.withConnection((connection) => listCatalogObjects(connection, schema));
|
|
2192
|
+
}
|
|
2122
2193
|
/** List the columns of a table. */
|
|
2123
2194
|
async listColumns(schema, table) {
|
|
2124
2195
|
return await this.pool.withConnection(
|
|
@@ -2244,12 +2315,15 @@ function textRange(value, offset, length) {
|
|
|
2244
2315
|
}
|
|
2245
2316
|
return { type: "text", originalLength: index, offset, value: selected };
|
|
2246
2317
|
}
|
|
2247
|
-
function readCellWindow(value, offset, length) {
|
|
2318
|
+
function readCellWindow(value, offset, length, typeName2) {
|
|
2248
2319
|
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
2249
2320
|
throw new CfHanaError("CONFIG", "offset must be a non-negative safe integer");
|
|
2250
2321
|
}
|
|
2251
2322
|
assertPositiveInteger("length", length);
|
|
2252
2323
|
if (Buffer.isBuffer(value)) {
|
|
2324
|
+
if (isTextLobType(typeName2)) {
|
|
2325
|
+
return textRange(value.toString("utf8"), offset, length);
|
|
2326
|
+
}
|
|
2253
2327
|
return {
|
|
2254
2328
|
type: "binary",
|
|
2255
2329
|
originalLength: value.length,
|
|
@@ -2421,18 +2495,19 @@ function jsonSearchMatches(root, term, row, column, limit, previewLength) {
|
|
|
2421
2495
|
}
|
|
2422
2496
|
return matches;
|
|
2423
2497
|
}
|
|
2424
|
-
function searchCell(value, term, row, column, limit, previewLength) {
|
|
2425
|
-
|
|
2498
|
+
function searchCell(value, term, row, column, typeName2, limit, previewLength) {
|
|
2499
|
+
const text = Buffer.isBuffer(value) && isTextLobType(typeName2) ? value.toString("utf8") : value;
|
|
2500
|
+
if (typeof text !== "string") {
|
|
2426
2501
|
return [];
|
|
2427
2502
|
}
|
|
2428
2503
|
try {
|
|
2429
|
-
const parsed = JSON.parse(
|
|
2504
|
+
const parsed = JSON.parse(text);
|
|
2430
2505
|
if (typeof parsed === "object" && parsed !== null) {
|
|
2431
2506
|
return jsonSearchMatches(parsed, term, row, column, limit, previewLength);
|
|
2432
2507
|
}
|
|
2433
2508
|
} catch {
|
|
2434
2509
|
}
|
|
2435
|
-
return plainTextMatches(
|
|
2510
|
+
return plainTextMatches(text, term, row, column, limit, previewLength);
|
|
2436
2511
|
}
|
|
2437
2512
|
function searchResultSession(session, searchTerm, options) {
|
|
2438
2513
|
assertPositiveInteger("limit", options.limit);
|
|
@@ -2454,6 +2529,7 @@ function searchResultSession(session, searchTerm, options) {
|
|
|
2454
2529
|
term,
|
|
2455
2530
|
item.rowNumber,
|
|
2456
2531
|
column.name,
|
|
2532
|
+
column.typeName,
|
|
2457
2533
|
remaining,
|
|
2458
2534
|
previewLength
|
|
2459
2535
|
)
|
|
@@ -2775,7 +2851,12 @@ async function runShow(ref, options) {
|
|
|
2775
2851
|
print(csv(rows, ["PATH", "TYPE", "VALUE"], length));
|
|
2776
2852
|
return;
|
|
2777
2853
|
}
|
|
2778
|
-
const window = readCellWindow(
|
|
2854
|
+
const window = readCellWindow(
|
|
2855
|
+
selected.value,
|
|
2856
|
+
nonNegative("--offset", options.offset),
|
|
2857
|
+
length,
|
|
2858
|
+
selected.typeName
|
|
2859
|
+
);
|
|
2779
2860
|
print(
|
|
2780
2861
|
csv(
|
|
2781
2862
|
[
|
|
@@ -2828,9 +2909,9 @@ async function runSearch(ref, text, options) {
|
|
|
2828
2909
|
}));
|
|
2829
2910
|
print(csv(rows, ["ROW", "COLUMN", "OFFSET", "PATH", "PREVIEW"], length));
|
|
2830
2911
|
}
|
|
2831
|
-
function cellExportValue(value) {
|
|
2912
|
+
function cellExportValue(value, typeName2) {
|
|
2832
2913
|
if (Buffer.isBuffer(value)) {
|
|
2833
|
-
return value;
|
|
2914
|
+
return isTextLobType(typeName2) ? value.toString("utf8") : value;
|
|
2834
2915
|
}
|
|
2835
2916
|
if (value instanceof Date) {
|
|
2836
2917
|
return value.toISOString();
|
|
@@ -2852,7 +2933,9 @@ async function runExport(ref, options) {
|
|
|
2852
2933
|
}
|
|
2853
2934
|
const session = await readResultSession(ref);
|
|
2854
2935
|
const selected = selectResultCell(session, options.row, options.column);
|
|
2855
|
-
await writeFile3(options.output, cellExportValue(selected.value), {
|
|
2936
|
+
await writeFile3(options.output, cellExportValue(selected.value, selected.typeName), {
|
|
2937
|
+
mode: 384
|
|
2938
|
+
});
|
|
2856
2939
|
print(`wrote=${options.output}`);
|
|
2857
2940
|
}
|
|
2858
2941
|
async function runList() {
|
|
@@ -2894,6 +2977,419 @@ function registerResultCommands(program) {
|
|
|
2894
2977
|
});
|
|
2895
2978
|
}
|
|
2896
2979
|
|
|
2980
|
+
// src/metadata-cache.ts
|
|
2981
|
+
import { createHash } from "crypto";
|
|
2982
|
+
import { mkdir as mkdir4, readFile as readFile2, rename as rename2, rm as rm4, writeFile as writeFile4 } from "fs/promises";
|
|
2983
|
+
import { homedir as homedir4 } from "os";
|
|
2984
|
+
import { join as join5 } from "path";
|
|
2985
|
+
var METADATA_CACHE_TTL_MS = 30 * 6e4;
|
|
2986
|
+
function metadataCacheRoot(saptoolsRoot) {
|
|
2987
|
+
return join5(saptoolsRoot ?? join5(homedir4(), ".saptools"), "cf-hana", "metadata");
|
|
2988
|
+
}
|
|
2989
|
+
function toMetadataCacheScope(info) {
|
|
2990
|
+
return {
|
|
2991
|
+
selector: info.selector,
|
|
2992
|
+
appName: info.appName,
|
|
2993
|
+
host: info.host,
|
|
2994
|
+
schema: info.schema,
|
|
2995
|
+
role: info.role,
|
|
2996
|
+
driver: info.driver
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
function metadataCacheKey(scope) {
|
|
3000
|
+
return createHash("sha256").update(JSON.stringify(scope)).digest("hex");
|
|
3001
|
+
}
|
|
3002
|
+
function metadataCachePath(scope, saptoolsRoot) {
|
|
3003
|
+
return join5(metadataCacheRoot(saptoolsRoot), `${metadataCacheKey(scope)}.json`);
|
|
3004
|
+
}
|
|
3005
|
+
function isRecord3(value) {
|
|
3006
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3007
|
+
}
|
|
3008
|
+
function isCatalogObject(value) {
|
|
3009
|
+
return isRecord3(value) && typeof value["schema"] === "string" && typeof value["name"] === "string" && (value["type"] === "TABLE" || value["type"] === "VIEW");
|
|
3010
|
+
}
|
|
3011
|
+
function isScope(value) {
|
|
3012
|
+
return isRecord3(value) && typeof value["selector"] === "string" && typeof value["appName"] === "string" && typeof value["host"] === "string" && typeof value["schema"] === "string" && typeof value["role"] === "string" && typeof value["driver"] === "string";
|
|
3013
|
+
}
|
|
3014
|
+
function scopesEqual(left, right) {
|
|
3015
|
+
return metadataCacheKey(left) === metadataCacheKey(right);
|
|
3016
|
+
}
|
|
3017
|
+
function isStored(value) {
|
|
3018
|
+
return isRecord3(value) && value["version"] === 1 && typeof value["createdAt"] === "string" && isScope(value["scope"]) && Array.isArray(value["objects"]) && value["objects"].every(isCatalogObject);
|
|
3019
|
+
}
|
|
3020
|
+
async function readMetadataCache(scope, options = {}) {
|
|
3021
|
+
try {
|
|
3022
|
+
const parsed = JSON.parse(
|
|
3023
|
+
await readFile2(metadataCachePath(scope, options.saptoolsRoot), "utf8")
|
|
3024
|
+
);
|
|
3025
|
+
if (!isStored(parsed) || !scopesEqual(parsed.scope, scope)) {
|
|
3026
|
+
return void 0;
|
|
3027
|
+
}
|
|
3028
|
+
const createdAt = Date.parse(parsed.createdAt);
|
|
3029
|
+
const now = options.now?.() ?? /* @__PURE__ */ new Date();
|
|
3030
|
+
const ageMs = now.getTime() - createdAt;
|
|
3031
|
+
if (!Number.isFinite(createdAt) || ageMs < 0 || ageMs >= METADATA_CACHE_TTL_MS) {
|
|
3032
|
+
return void 0;
|
|
3033
|
+
}
|
|
3034
|
+
return parsed.objects;
|
|
3035
|
+
} catch {
|
|
3036
|
+
return void 0;
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
async function writeMetadataCache(scope, objects, options = {}) {
|
|
3040
|
+
const root = metadataCacheRoot(options.saptoolsRoot);
|
|
3041
|
+
const path = metadataCachePath(scope, options.saptoolsRoot);
|
|
3042
|
+
const tempPath = `${path}.tmp-${process.pid.toString()}`;
|
|
3043
|
+
const stored = {
|
|
3044
|
+
version: 1,
|
|
3045
|
+
createdAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
3046
|
+
scope,
|
|
3047
|
+
objects
|
|
3048
|
+
};
|
|
3049
|
+
await mkdir4(root, { recursive: true, mode: 448 });
|
|
3050
|
+
await rm4(tempPath, { force: true });
|
|
3051
|
+
await writeFile4(tempPath, `${JSON.stringify(stored)}
|
|
3052
|
+
`, { encoding: "utf8", mode: 384 });
|
|
3053
|
+
await rename2(tempPath, path);
|
|
3054
|
+
}
|
|
3055
|
+
async function loadCatalogObjectsWithCache(scope, refresh, loader, options = {}) {
|
|
3056
|
+
if (!refresh) {
|
|
3057
|
+
const cached = await readMetadataCache(scope, options);
|
|
3058
|
+
if (cached !== void 0) {
|
|
3059
|
+
return cached;
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
const objects = await loader();
|
|
3063
|
+
try {
|
|
3064
|
+
await writeMetadataCache(scope, objects, options);
|
|
3065
|
+
} catch {
|
|
3066
|
+
}
|
|
3067
|
+
return objects;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
// src/suggestions.ts
|
|
3071
|
+
var INVALID_OBJECT_PATTERNS = [
|
|
3072
|
+
/invalid\s+(?:table|view|object)\s+name/i,
|
|
3073
|
+
/(?:table|view|object)\s+[^\n]*does\s+not\s+exist/i,
|
|
3074
|
+
/could\s+not\s+find\s+(?:table|view|object)/i
|
|
3075
|
+
];
|
|
3076
|
+
var REF_KEYWORDS = /* @__PURE__ */ new Set(["FROM", "JOIN", "UPDATE", "INTO", "TABLE"]);
|
|
3077
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
3078
|
+
"AS",
|
|
3079
|
+
"ON",
|
|
3080
|
+
"WHERE",
|
|
3081
|
+
"SET",
|
|
3082
|
+
"VALUES",
|
|
3083
|
+
"USING",
|
|
3084
|
+
"WHEN",
|
|
3085
|
+
"INNER",
|
|
3086
|
+
"LEFT",
|
|
3087
|
+
"RIGHT",
|
|
3088
|
+
"FULL",
|
|
3089
|
+
"OUTER",
|
|
3090
|
+
"CROSS",
|
|
3091
|
+
"JOIN"
|
|
3092
|
+
]);
|
|
3093
|
+
var CTE_FOLLOWERS = /* @__PURE__ */ new Set(["AS"]);
|
|
3094
|
+
function isInvalidCatalogObjectError(error) {
|
|
3095
|
+
if (!(error instanceof QueryError)) {
|
|
3096
|
+
return false;
|
|
3097
|
+
}
|
|
3098
|
+
if (error.sqlState === "42S02" || error.sqlState === "42S01") {
|
|
3099
|
+
return true;
|
|
3100
|
+
}
|
|
3101
|
+
return INVALID_OBJECT_PATTERNS.some((pattern) => pattern.test(error.message));
|
|
3102
|
+
}
|
|
3103
|
+
function skipLineComment3(sql, index) {
|
|
3104
|
+
let cursor = index + 2;
|
|
3105
|
+
while (cursor < sql.length && sql[cursor] !== "\n") {
|
|
3106
|
+
cursor += 1;
|
|
3107
|
+
}
|
|
3108
|
+
return cursor;
|
|
3109
|
+
}
|
|
3110
|
+
function skipBlockComment3(sql, index) {
|
|
3111
|
+
let cursor = index + 2;
|
|
3112
|
+
while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
|
|
3113
|
+
cursor += 1;
|
|
3114
|
+
}
|
|
3115
|
+
return Math.min(cursor + 2, sql.length);
|
|
3116
|
+
}
|
|
3117
|
+
function skipStringLiteral(sql, index) {
|
|
3118
|
+
let cursor = index + 1;
|
|
3119
|
+
while (cursor < sql.length) {
|
|
3120
|
+
if (sql[cursor] === "'" && sql[cursor + 1] === "'") {
|
|
3121
|
+
cursor += 2;
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
if (sql[cursor] === "'") {
|
|
3125
|
+
return cursor + 1;
|
|
3126
|
+
}
|
|
3127
|
+
cursor += 1;
|
|
3128
|
+
}
|
|
3129
|
+
return cursor;
|
|
3130
|
+
}
|
|
3131
|
+
function readQuotedIdentifier(sql, index) {
|
|
3132
|
+
let cursor = index + 1;
|
|
3133
|
+
let text = "";
|
|
3134
|
+
while (cursor < sql.length) {
|
|
3135
|
+
if (sql[cursor] === '"' && sql[cursor + 1] === '"') {
|
|
3136
|
+
text += '"';
|
|
3137
|
+
cursor += 2;
|
|
3138
|
+
continue;
|
|
3139
|
+
}
|
|
3140
|
+
if (sql[cursor] === '"') {
|
|
3141
|
+
return { text, next: cursor + 1 };
|
|
3142
|
+
}
|
|
3143
|
+
text += sql.charAt(cursor);
|
|
3144
|
+
cursor += 1;
|
|
3145
|
+
}
|
|
3146
|
+
return { text, next: cursor };
|
|
3147
|
+
}
|
|
3148
|
+
function readBareWord(sql, index) {
|
|
3149
|
+
let cursor = index;
|
|
3150
|
+
let text = "";
|
|
3151
|
+
while (cursor < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(cursor))) {
|
|
3152
|
+
text += sql.charAt(cursor);
|
|
3153
|
+
cursor += 1;
|
|
3154
|
+
}
|
|
3155
|
+
return { text, next: cursor };
|
|
3156
|
+
}
|
|
3157
|
+
function tokenize(sql) {
|
|
3158
|
+
const tokens = [];
|
|
3159
|
+
let index = 0;
|
|
3160
|
+
while (index < sql.length) {
|
|
3161
|
+
const char = sql.charAt(index);
|
|
3162
|
+
if (/\s/.test(char)) {
|
|
3163
|
+
index += 1;
|
|
3164
|
+
continue;
|
|
3165
|
+
}
|
|
3166
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
3167
|
+
index = skipLineComment3(sql, index);
|
|
3168
|
+
continue;
|
|
3169
|
+
}
|
|
3170
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
3171
|
+
index = skipBlockComment3(sql, index);
|
|
3172
|
+
continue;
|
|
3173
|
+
}
|
|
3174
|
+
if (char === "'") {
|
|
3175
|
+
index = skipStringLiteral(sql, index);
|
|
3176
|
+
continue;
|
|
3177
|
+
}
|
|
3178
|
+
if (char === '"') {
|
|
3179
|
+
const quoted = readQuotedIdentifier(sql, index);
|
|
3180
|
+
tokens.push({ kind: "quoted", text: quoted.text });
|
|
3181
|
+
index = quoted.next;
|
|
3182
|
+
continue;
|
|
3183
|
+
}
|
|
3184
|
+
if (/[A-Za-z_#$]/.test(char)) {
|
|
3185
|
+
const word = readBareWord(sql, index);
|
|
3186
|
+
tokens.push({ kind: "word", text: word.text });
|
|
3187
|
+
index = word.next;
|
|
3188
|
+
continue;
|
|
3189
|
+
}
|
|
3190
|
+
if (char === ".") {
|
|
3191
|
+
tokens.push({ kind: "dot", text: char });
|
|
3192
|
+
} else if (char === ",") {
|
|
3193
|
+
tokens.push({ kind: "comma", text: char });
|
|
3194
|
+
} else if (char === "(") {
|
|
3195
|
+
tokens.push({ kind: "open", text: char });
|
|
3196
|
+
} else if (char === ")") {
|
|
3197
|
+
tokens.push({ kind: "close", text: char });
|
|
3198
|
+
} else {
|
|
3199
|
+
tokens.push({ kind: "other", text: char });
|
|
3200
|
+
}
|
|
3201
|
+
index += 1;
|
|
3202
|
+
}
|
|
3203
|
+
return tokens;
|
|
3204
|
+
}
|
|
3205
|
+
function upper(token) {
|
|
3206
|
+
return token?.text.toUpperCase() ?? "";
|
|
3207
|
+
}
|
|
3208
|
+
function isIdentifier(token) {
|
|
3209
|
+
return token?.kind === "word" || token?.kind === "quoted";
|
|
3210
|
+
}
|
|
3211
|
+
function readName(tokens, start, allowFollowingParens = false) {
|
|
3212
|
+
const first = tokens[start];
|
|
3213
|
+
if (!isIdentifier(first)) {
|
|
3214
|
+
return void 0;
|
|
3215
|
+
}
|
|
3216
|
+
let next = start + 1;
|
|
3217
|
+
let schema;
|
|
3218
|
+
let name = first.text;
|
|
3219
|
+
const maybeObject = tokens[next + 1];
|
|
3220
|
+
if (tokens[next]?.kind === "dot" && isIdentifier(maybeObject)) {
|
|
3221
|
+
schema = name;
|
|
3222
|
+
name = maybeObject.text;
|
|
3223
|
+
next += 2;
|
|
3224
|
+
}
|
|
3225
|
+
if (!allowFollowingParens && tokens[next]?.kind === "open") {
|
|
3226
|
+
return void 0;
|
|
3227
|
+
}
|
|
3228
|
+
return { name: schema === void 0 ? { name } : { schema, name }, next };
|
|
3229
|
+
}
|
|
3230
|
+
function firstNameFromText(text) {
|
|
3231
|
+
const tokens = tokenize(text);
|
|
3232
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
3233
|
+
const read = readName(tokens, index, true);
|
|
3234
|
+
if (read !== void 0) {
|
|
3235
|
+
return read.name;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
return void 0;
|
|
3239
|
+
}
|
|
3240
|
+
function extractMissingObjectNameFromError(error) {
|
|
3241
|
+
if (!(error instanceof QueryError)) {
|
|
3242
|
+
return void 0;
|
|
3243
|
+
}
|
|
3244
|
+
const message = error.message;
|
|
3245
|
+
const invalidName = /invalid\s+(?:table|view|object)\s+name\s*:?\s*(.+)$/i.exec(message);
|
|
3246
|
+
if (invalidName?.[1] !== void 0) {
|
|
3247
|
+
return firstNameFromText(invalidName[1]);
|
|
3248
|
+
}
|
|
3249
|
+
const missingObject = /(?:table|view|object)\s+(.+?)\s+(?:does\s+not\s+exist|not\s+found)/i.exec(message);
|
|
3250
|
+
if (missingObject?.[1] !== void 0) {
|
|
3251
|
+
return firstNameFromText(missingObject[1]);
|
|
3252
|
+
}
|
|
3253
|
+
return void 0;
|
|
3254
|
+
}
|
|
3255
|
+
function cteNames(tokens) {
|
|
3256
|
+
const names = /* @__PURE__ */ new Set();
|
|
3257
|
+
if (upper(tokens[0]) !== "WITH") {
|
|
3258
|
+
return names;
|
|
3259
|
+
}
|
|
3260
|
+
let depth = 0;
|
|
3261
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
3262
|
+
const token = tokens[index];
|
|
3263
|
+
if (depth === 0 && upper(token) === "SELECT") {
|
|
3264
|
+
break;
|
|
3265
|
+
}
|
|
3266
|
+
if (depth === 0 && isIdentifier(token) && CTE_FOLLOWERS.has(upper(tokens[index + 1]))) {
|
|
3267
|
+
names.add(token.text.toUpperCase());
|
|
3268
|
+
}
|
|
3269
|
+
if (token?.kind === "open") {
|
|
3270
|
+
depth += 1;
|
|
3271
|
+
} else if (token?.kind === "close" && depth > 0) {
|
|
3272
|
+
depth -= 1;
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
return names;
|
|
3276
|
+
}
|
|
3277
|
+
function extractMissingObjectName(sql) {
|
|
3278
|
+
const tokens = tokenize(sql.replace(/^;+|;+$/g, ""));
|
|
3279
|
+
const ctes = cteNames(tokens);
|
|
3280
|
+
let candidate;
|
|
3281
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
3282
|
+
const word = upper(tokens[index]);
|
|
3283
|
+
if (!REF_KEYWORDS.has(word)) {
|
|
3284
|
+
continue;
|
|
3285
|
+
}
|
|
3286
|
+
if (word === "TABLE" && upper(tokens[index - 1]) !== "TRUNCATE") {
|
|
3287
|
+
continue;
|
|
3288
|
+
}
|
|
3289
|
+
if (word === "INTO" && !(upper(tokens[index - 1]) === "INSERT" || upper(tokens[index - 1]) === "MERGE")) {
|
|
3290
|
+
continue;
|
|
3291
|
+
}
|
|
3292
|
+
const read = readName(tokens, index + 1, word === "INTO");
|
|
3293
|
+
if (read === void 0) {
|
|
3294
|
+
continue;
|
|
3295
|
+
}
|
|
3296
|
+
if (read.name.schema === void 0 && ctes.has(read.name.name.toUpperCase())) {
|
|
3297
|
+
continue;
|
|
3298
|
+
}
|
|
3299
|
+
if (STOP_WORDS.has(read.name.name.toUpperCase())) {
|
|
3300
|
+
continue;
|
|
3301
|
+
}
|
|
3302
|
+
candidate = read.name;
|
|
3303
|
+
let next = read.next;
|
|
3304
|
+
if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
|
|
3305
|
+
next += 1;
|
|
3306
|
+
}
|
|
3307
|
+
while (tokens[next]?.kind === "comma") {
|
|
3308
|
+
const commaRead = readName(tokens, next + 1);
|
|
3309
|
+
if (commaRead === void 0) {
|
|
3310
|
+
break;
|
|
3311
|
+
}
|
|
3312
|
+
if (!(commaRead.name.schema === void 0 && ctes.has(commaRead.name.name.toUpperCase()))) {
|
|
3313
|
+
candidate = commaRead.name;
|
|
3314
|
+
}
|
|
3315
|
+
next = commaRead.next;
|
|
3316
|
+
if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
|
|
3317
|
+
next += 1;
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
return candidate;
|
|
3322
|
+
}
|
|
3323
|
+
function singular(value) {
|
|
3324
|
+
if (value.endsWith("ES")) {
|
|
3325
|
+
return value.slice(0, -2);
|
|
3326
|
+
}
|
|
3327
|
+
if (value.endsWith("S")) {
|
|
3328
|
+
return value.slice(0, -1);
|
|
3329
|
+
}
|
|
3330
|
+
return value;
|
|
3331
|
+
}
|
|
3332
|
+
function norm(value) {
|
|
3333
|
+
return value.toUpperCase().replace(/[^A-Z0-9]+/g, "");
|
|
3334
|
+
}
|
|
3335
|
+
function distance(a, b) {
|
|
3336
|
+
const previous = Array.from({ length: b.length + 1 }, (_value, index) => index);
|
|
3337
|
+
for (let leftIndex = 1; leftIndex <= a.length; leftIndex += 1) {
|
|
3338
|
+
let last = leftIndex - 1;
|
|
3339
|
+
previous[0] = leftIndex;
|
|
3340
|
+
for (let rightIndex = 1; rightIndex <= b.length; rightIndex += 1) {
|
|
3341
|
+
const old = previous[rightIndex] ?? 0;
|
|
3342
|
+
previous[rightIndex] = Math.min(
|
|
3343
|
+
(previous[rightIndex] ?? 0) + 1,
|
|
3344
|
+
(previous[rightIndex - 1] ?? 0) + 1,
|
|
3345
|
+
last + (a.charAt(leftIndex - 1) === b.charAt(rightIndex - 1) ? 0 : 1)
|
|
3346
|
+
);
|
|
3347
|
+
last = old;
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
return previous[b.length] ?? 0;
|
|
3351
|
+
}
|
|
3352
|
+
function score(requested, candidate) {
|
|
3353
|
+
const requestedName = norm(requested.name);
|
|
3354
|
+
const candidateName = norm(candidate.name);
|
|
3355
|
+
if (requested.schema !== void 0 && norm(requested.schema) !== norm(candidate.schema)) {
|
|
3356
|
+
return 0;
|
|
3357
|
+
}
|
|
3358
|
+
if (requestedName === candidateName) {
|
|
3359
|
+
return 100;
|
|
3360
|
+
}
|
|
3361
|
+
if (singular(requestedName) === singular(candidateName)) {
|
|
3362
|
+
return 92;
|
|
3363
|
+
}
|
|
3364
|
+
let value = 0;
|
|
3365
|
+
if (candidateName.startsWith(requestedName) || requestedName.startsWith(candidateName)) {
|
|
3366
|
+
value = Math.max(value, 80 - Math.abs(candidateName.length - requestedName.length));
|
|
3367
|
+
}
|
|
3368
|
+
if (candidateName.endsWith(requestedName) || requestedName.endsWith(candidateName)) {
|
|
3369
|
+
value = Math.max(value, 70 - Math.abs(candidateName.length - requestedName.length));
|
|
3370
|
+
}
|
|
3371
|
+
const editDistance = distance(requestedName, candidateName);
|
|
3372
|
+
const max = Math.max(requestedName.length, candidateName.length, 1);
|
|
3373
|
+
if (editDistance <= Math.max(3, Math.floor(max * 0.35))) {
|
|
3374
|
+
value = Math.max(value, Math.round(75 * (1 - editDistance / max)));
|
|
3375
|
+
}
|
|
3376
|
+
return value;
|
|
3377
|
+
}
|
|
3378
|
+
function rankCatalogSuggestions(requested, candidates, limit = 5) {
|
|
3379
|
+
return candidates.map((candidate) => ({ candidate, score: score(requested, candidate) })).filter((item) => item.score >= 45).sort(
|
|
3380
|
+
(left, right) => right.score - left.score || left.candidate.schema.localeCompare(right.candidate.schema) || (left.candidate.type === right.candidate.type ? 0 : left.candidate.type === "TABLE" ? -1 : 1) || left.candidate.name.localeCompare(right.candidate.name)
|
|
3381
|
+
).slice(0, limit).map((item) => item.candidate);
|
|
3382
|
+
}
|
|
3383
|
+
function formatSuggestions(suggestions) {
|
|
3384
|
+
if (suggestions.length === 0) {
|
|
3385
|
+
return void 0;
|
|
3386
|
+
}
|
|
3387
|
+
return [
|
|
3388
|
+
"Did you mean:",
|
|
3389
|
+
...suggestions.map((item) => ` ${item.schema}.${item.name} (${item.type})`)
|
|
3390
|
+
].join("\n");
|
|
3391
|
+
}
|
|
3392
|
+
|
|
2897
3393
|
// src/cli.ts
|
|
2898
3394
|
function print2(text) {
|
|
2899
3395
|
process.stdout.write(`${text}
|
|
@@ -3020,7 +3516,7 @@ function formatInfo(info) {
|
|
|
3020
3516
|
].join("\n");
|
|
3021
3517
|
}
|
|
3022
3518
|
function withConnectionOptions(command) {
|
|
3023
|
-
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");
|
|
3519
|
+
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").option("--refresh-metadata", "bypass the 30-minute table/view metadata suggestion cache", false);
|
|
3024
3520
|
}
|
|
3025
3521
|
function withFormattedConnectionOptions(command) {
|
|
3026
3522
|
return withConnectionOptions(command).option(
|
|
@@ -3029,6 +3525,36 @@ function withFormattedConnectionOptions(command) {
|
|
|
3029
3525
|
"table"
|
|
3030
3526
|
);
|
|
3031
3527
|
}
|
|
3528
|
+
async function loadSuggestionCatalogObjects(client, refresh) {
|
|
3529
|
+
try {
|
|
3530
|
+
return await loadCatalogObjectsWithCache(
|
|
3531
|
+
toMetadataCacheScope(client.info),
|
|
3532
|
+
refresh,
|
|
3533
|
+
async () => await client.listCatalogObjects(client.info.schema)
|
|
3534
|
+
);
|
|
3535
|
+
} catch {
|
|
3536
|
+
return await client.listCatalogObjects(client.info.schema);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
async function enrichAndRethrowQueryError(error, client, sql, refresh) {
|
|
3540
|
+
if (!isInvalidCatalogObjectError(error)) {
|
|
3541
|
+
throw error;
|
|
3542
|
+
}
|
|
3543
|
+
const requested = extractMissingObjectNameFromError(error) ?? extractMissingObjectName(sql);
|
|
3544
|
+
if (requested === void 0) {
|
|
3545
|
+
throw error;
|
|
3546
|
+
}
|
|
3547
|
+
try {
|
|
3548
|
+
const objects = await loadSuggestionCatalogObjects(client, refresh);
|
|
3549
|
+
const text = formatSuggestions(rankCatalogSuggestions(requested, objects));
|
|
3550
|
+
if (text !== void 0) {
|
|
3551
|
+
process.stderr.write(`${text}
|
|
3552
|
+
`);
|
|
3553
|
+
}
|
|
3554
|
+
} catch {
|
|
3555
|
+
}
|
|
3556
|
+
throw error;
|
|
3557
|
+
}
|
|
3032
3558
|
async function runQuery(selector, sql, command) {
|
|
3033
3559
|
const opts = command.opts();
|
|
3034
3560
|
assertQueryOptions(sql, opts);
|
|
@@ -3036,12 +3562,24 @@ async function runQuery(selector, sql, command) {
|
|
|
3036
3562
|
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
3037
3563
|
try {
|
|
3038
3564
|
const params = opts.param ?? [];
|
|
3039
|
-
|
|
3565
|
+
let backup;
|
|
3566
|
+
try {
|
|
3567
|
+
backup = await client.backupWriteStatement(sql, params);
|
|
3568
|
+
} catch (error) {
|
|
3569
|
+
await enrichAndRethrowQueryError(error, client, sql, opts.refreshMetadata || opts.refresh);
|
|
3570
|
+
}
|
|
3040
3571
|
if (backup !== void 0) {
|
|
3041
3572
|
process.stderr.write(`${CLI_NAME}: backup saved to ${backup.directory}
|
|
3042
3573
|
`);
|
|
3043
3574
|
}
|
|
3044
|
-
const result = await client.query(sql, params)
|
|
3575
|
+
const result = await client.query(sql, params).catch(async (error) => {
|
|
3576
|
+
return await enrichAndRethrowQueryError(
|
|
3577
|
+
error,
|
|
3578
|
+
client,
|
|
3579
|
+
sql,
|
|
3580
|
+
opts.refreshMetadata || opts.refresh
|
|
3581
|
+
);
|
|
3582
|
+
});
|
|
3045
3583
|
if (result.statement === "select") {
|
|
3046
3584
|
const compact = formatCompactCsv(result, cellLimit);
|
|
3047
3585
|
if (opts.save) {
|