@saptools/cf-hana 0.2.2 → 0.3.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/dist/cli.js CHANGED
@@ -26,12 +26,36 @@ var CredentialsNotFoundError = class extends CfHanaError {
26
26
  };
27
27
  var QueryError = class extends CfHanaError {
28
28
  sqlState;
29
+ databaseCode;
29
30
  constructor(message, options) {
30
31
  super("QUERY", message, options);
31
32
  this.name = "QueryError";
32
33
  this.sqlState = options?.sqlState;
34
+ this.databaseCode = options?.databaseCode;
33
35
  }
34
36
  };
37
+ function isDatabaseErrorShape(error) {
38
+ return typeof error === "object" && error !== null && "code" in error;
39
+ }
40
+ function safeDatabaseCode(value) {
41
+ if (typeof value === "number" && Number.isSafeInteger(value)) {
42
+ return value;
43
+ }
44
+ if (typeof value !== "string" || !/^\d+$/.test(value)) {
45
+ return void 0;
46
+ }
47
+ const parsed = Number(value);
48
+ return Number.isSafeInteger(parsed) ? parsed : void 0;
49
+ }
50
+ function databaseCode(error) {
51
+ if (error instanceof QueryError) {
52
+ return error.databaseCode ?? databaseCode(error.cause);
53
+ }
54
+ if (isDatabaseErrorShape(error)) {
55
+ return safeDatabaseCode(error.code);
56
+ }
57
+ return void 0;
58
+ }
35
59
  var ReadOnlyViolationError = class extends CfHanaError {
36
60
  constructor(message, options) {
37
61
  super("READ_ONLY_VIOLATION", message, options);
@@ -51,6 +75,15 @@ function errorMessage(error) {
51
75
  return String(error);
52
76
  }
53
77
 
78
+ // src/lob.ts
79
+ var TEXT_LOB_TYPES = /* @__PURE__ */ new Set(["CLOB", "NCLOB"]);
80
+ function normalizeTypeName(typeName2) {
81
+ return typeName2?.trim().toUpperCase() ?? "";
82
+ }
83
+ function isTextLobType(typeName2) {
84
+ return TEXT_LOB_TYPES.has(normalizeTypeName(typeName2));
85
+ }
86
+
54
87
  // src/result-preview.ts
55
88
  function normalizePreviewChar(value) {
56
89
  return value === "\r" || value === "\n" || value === " " ? " " : value;
@@ -91,12 +124,12 @@ function scalarText(value) {
91
124
  }
92
125
  return value.toString();
93
126
  }
94
- function previewCell(value, limit) {
127
+ function previewCell(value, limit, typeName2) {
95
128
  if (value === null) {
96
129
  return { text: "", truncated: false, originalLength: 0, unit: "chars" };
97
130
  }
98
131
  if (Buffer.isBuffer(value)) {
99
- return previewBuffer(value, limit);
132
+ return isTextLobType(typeName2) ? previewText(value.toString("utf8"), limit) : previewBuffer(value, limit);
100
133
  }
101
134
  if (typeof value === "string") {
102
135
  return previewText(value, limit);
@@ -105,7 +138,7 @@ function previewCell(value, limit) {
105
138
  }
106
139
 
107
140
  // src/format.ts
108
- function cellText(value, nullText) {
141
+ function cellText(value, nullText, column) {
109
142
  if (value === null) {
110
143
  return nullText;
111
144
  }
@@ -113,14 +146,14 @@ function cellText(value, nullText) {
113
146
  return value.toISOString();
114
147
  }
115
148
  if (Buffer.isBuffer(value)) {
116
- return `0x${value.toString("hex")}`;
149
+ return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
117
150
  }
118
151
  if (typeof value === "boolean") {
119
152
  return value ? "true" : "false";
120
153
  }
121
154
  return typeof value === "number" ? value.toString() : value;
122
155
  }
123
- function serializeCell(value) {
156
+ function serializeCell(value, column) {
124
157
  if (value === null) {
125
158
  return null;
126
159
  }
@@ -128,7 +161,7 @@ function serializeCell(value) {
128
161
  return value.toISOString();
129
162
  }
130
163
  if (Buffer.isBuffer(value)) {
131
- return `0x${value.toString("hex")}`;
164
+ return isTextLobType(column?.typeName) ? value.toString("utf8") : `0x${value.toString("hex")}`;
132
165
  }
133
166
  return value;
134
167
  }
@@ -144,7 +177,7 @@ function formatTable(result) {
144
177
  }
145
178
  const headers = result.columns.map((column) => column.name);
146
179
  const rows = result.rows.map(
147
- (row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL"))
180
+ (row) => result.columns.map((column) => cellText(row[column.name] ?? null, "NULL", column))
148
181
  );
149
182
  const widths = headers.map((header, index) => {
150
183
  const widest = rows.reduce(
@@ -162,7 +195,8 @@ function formatJson(result) {
162
195
  const rows = result.rows.map((row) => {
163
196
  const serialized = {};
164
197
  for (const [key, value] of Object.entries(row)) {
165
- serialized[key] = serializeCell(value);
198
+ const column = result.columns.find((item) => item.name === key);
199
+ serialized[key] = serializeCell(value, column);
166
200
  }
167
201
  return serialized;
168
202
  });
@@ -173,7 +207,7 @@ function formatCsv(result) {
173
207
  const lines = [headers.map((header) => csvEscape(header)).join(",")];
174
208
  for (const row of result.rows) {
175
209
  lines.push(
176
- result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, ""))).join(",")
210
+ result.columns.map((column) => csvEscape(cellText(row[column.name] ?? null, "", column))).join(",")
177
211
  );
178
212
  }
179
213
  return lines.join("\r\n");
@@ -184,7 +218,7 @@ function formatCompactCsv(result, cellLimit) {
184
218
  let truncatedCells = 0;
185
219
  for (const row of result.rows) {
186
220
  const cells = result.columns.map((column) => {
187
- const preview = previewCell(row[column.name] ?? null, cellLimit);
221
+ const preview = previewCell(row[column.name] ?? null, cellLimit, column.typeName);
188
222
  if (preview.truncated) {
189
223
  truncatedCells += 1;
190
224
  }
@@ -634,6 +668,18 @@ async function listTables(connection, schema) {
634
668
  rowCount: void 0
635
669
  }));
636
670
  }
671
+ async function listCatalogObjects(connection, schema) {
672
+ const result = await connection.query(
673
+ "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",
674
+ [schema, schema],
675
+ { autoLimit: false }
676
+ );
677
+ return result.rows.map((row) => ({
678
+ schema: row.SCHEMA_NAME,
679
+ name: row.OBJECT_NAME,
680
+ type: row.OBJECT_TYPE
681
+ }));
682
+ }
637
683
  async function listColumns(connection, schema, table) {
638
684
  const result = await connection.query(
639
685
  "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 +698,7 @@ async function listColumns(connection, schema, table) {
652
698
 
653
699
  // src/config.ts
654
700
  var CLI_NAME = "cf-hana";
655
- var CLI_VERSION = "0.2.1";
701
+ var CLI_VERSION = "0.3.1";
656
702
  var ENV_PREFIX = "CF_HANA";
657
703
  var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
658
704
  var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
@@ -661,7 +707,7 @@ var DEFAULT_POOL_IDLE_MS = 6e4;
661
707
  var DEFAULT_AUTO_LIMIT = 100;
662
708
  var DEFAULT_CELL_LIMIT = 128;
663
709
  var MAX_CELL_LIMIT = 1e4;
664
- var DEFAULT_RESULT_TTL_MINUTES = 60;
710
+ var DEFAULT_RESULT_TTL_MINUTES = 10080;
665
711
  var DEFAULT_RESULT_SEARCH_LIMIT = 20;
666
712
  var MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
667
713
  function envName(suffix) {
@@ -731,9 +777,9 @@ function getApiEndpointForRegion(regionKey) {
731
777
  return REGION_API_MAP[regionKey];
732
778
  }
733
779
  function getRegionKeyForApi(apiEndpoint) {
734
- const norm = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
780
+ const norm2 = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
735
781
  for (const [key, endpoint] of Object.entries(REGION_API_MAP)) {
736
- if (endpoint.toLowerCase() === norm) {
782
+ if (endpoint.toLowerCase() === norm2) {
737
783
  return key;
738
784
  }
739
785
  }
@@ -1114,12 +1160,88 @@ function toConnectionTarget(binding, role) {
1114
1160
 
1115
1161
  // src/driver/fake.ts
1116
1162
  import { appendFile } from "fs/promises";
1163
+ var catalogFailureInjected = false;
1164
+ function catalogObjects() {
1165
+ return [
1166
+ { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
1167
+ { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_FIXED", OBJECT_TYPE: "TABLE" },
1168
+ { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "MISSING_TABLE_VIEW", OBJECT_TYPE: "VIEW" },
1169
+ { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "STATUS_ITEMS", OBJECT_TYPE: "TABLE" },
1170
+ { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "CORE_AUTH_SCOPE", OBJECT_TYPE: "TABLE" }
1171
+ ];
1172
+ }
1173
+ function tableColumns() {
1174
+ return [
1175
+ { COLUMN_NAME: "ID", DATA_TYPE_NAME: "INTEGER", LENGTH: null, SCALE: null, IS_NULLABLE: "FALSE", POSITION: 1 },
1176
+ { COLUMN_NAME: "IS_ACTIVE", DATA_TYPE_NAME: "BOOLEAN", LENGTH: null, SCALE: null, IS_NULLABLE: "TRUE", POSITION: 2 },
1177
+ { COLUMN_NAME: "SCOPE_NAME", DATA_TYPE_NAME: "NVARCHAR", LENGTH: 255, SCALE: null, IS_NULLABLE: "TRUE", POSITION: 3 }
1178
+ ];
1179
+ }
1117
1180
  function fakeExec(sql) {
1118
1181
  const kind = classifyStatement(sql);
1119
1182
  const forcedFailure = readEnv(envName("FAKE_FAIL_STATEMENT"))?.toLowerCase();
1120
1183
  if (forcedFailure === kind) {
1121
1184
  throw new Error(`fake driver forced ${kind.toUpperCase()} failure`);
1122
1185
  }
1186
+ const upperSql = sql.toUpperCase();
1187
+ if (upperSql.includes("SYS.TABLES") && upperSql.includes("SYS.VIEWS")) {
1188
+ if (readEnv(envName("FAKE_FAIL_CATALOG_ONCE")) === "1" && !catalogFailureInjected) {
1189
+ catalogFailureInjected = true;
1190
+ throw new QueryError("fake transient catalog metadata failure");
1191
+ }
1192
+ return {
1193
+ rows: catalogObjects(),
1194
+ columns: [
1195
+ { name: "SCHEMA_NAME", typeName: "NVARCHAR" },
1196
+ { name: "OBJECT_NAME", typeName: "NVARCHAR" },
1197
+ { name: "OBJECT_TYPE", typeName: "NVARCHAR" }
1198
+ ],
1199
+ affectedRows: 0
1200
+ };
1201
+ }
1202
+ if (upperSql.includes("SYS.TABLE_COLUMNS")) {
1203
+ return {
1204
+ rows: tableColumns(),
1205
+ columns: [
1206
+ { name: "COLUMN_NAME", typeName: "NVARCHAR" },
1207
+ { name: "DATA_TYPE_NAME", typeName: "NVARCHAR" },
1208
+ { name: "LENGTH", typeName: "INTEGER" },
1209
+ { name: "SCALE", typeName: "INTEGER" },
1210
+ { name: "IS_NULLABLE", typeName: "NVARCHAR" },
1211
+ { name: "POSITION", typeName: "INTEGER" }
1212
+ ],
1213
+ affectedRows: 0
1214
+ };
1215
+ }
1216
+ if (upperSql.includes("MISSING_TABLE") || upperSql.includes("MISSING_TABLES")) {
1217
+ throw new QueryError("invalid table name: MISSING_TABLE", { sqlState: "42S02" });
1218
+ }
1219
+ if (upperSql.includes("ISACTIVE")) {
1220
+ throw new QueryError("invalid column name: ISACTIVE: line 1 col 8 (at pos 7)", { databaseCode: 260 });
1221
+ }
1222
+ if (upperSql.includes("LOB_ORDER_ERROR")) {
1223
+ throw new QueryError("inconsistent datatype: LOB type is not allowed in ORDER BY clause", { databaseCode: 266 });
1224
+ }
1225
+ if (upperSql.includes("LOB_GROUP_ERROR")) {
1226
+ throw new QueryError("inconsistent datatype: LOB type is not allowed in GROUP BY clause", { databaseCode: 274 });
1227
+ }
1228
+ if (sql.toUpperCase().includes("LOB_FIXTURE")) {
1229
+ return {
1230
+ rows: [
1231
+ {
1232
+ LOG_CONTENT: Buffer.from("Example log entry", "utf8"),
1233
+ CLOB_CONTENT: Buffer.from("Clob log entry", "utf8"),
1234
+ PAYLOAD: Buffer.from([0, 1, 2, 255])
1235
+ }
1236
+ ],
1237
+ columns: [
1238
+ { name: "LOG_CONTENT", typeName: "NCLOB" },
1239
+ { name: "CLOB_CONTENT", typeName: "CLOB" },
1240
+ { name: "PAYLOAD", typeName: "BLOB" }
1241
+ ],
1242
+ affectedRows: 0
1243
+ };
1244
+ }
1123
1245
  if (sql.toUpperCase().includes("DUMMY")) {
1124
1246
  return {
1125
1247
  rows: [{ "1": 1 }],
@@ -1227,7 +1349,12 @@ function extractSqlState(error) {
1227
1349
  }
1228
1350
  function toQueryError(error) {
1229
1351
  const sqlState = extractSqlState(error);
1230
- return sqlState === void 0 ? new QueryError(error.message, { cause: error }) : new QueryError(error.message, { cause: error, sqlState });
1352
+ const code = databaseCode(error);
1353
+ return new QueryError(error.message, {
1354
+ cause: error,
1355
+ ...sqlState === void 0 ? {} : { sqlState },
1356
+ ...code === void 0 ? {} : { databaseCode: code }
1357
+ });
1231
1358
  }
1232
1359
  function toColumns(metadata) {
1233
1360
  if (metadata === void 0) {
@@ -2119,6 +2246,10 @@ var HanaClient = class _HanaClient {
2119
2246
  async listTables(schema) {
2120
2247
  return await this.pool.withConnection((connection) => listTables(connection, schema));
2121
2248
  }
2249
+ /** List table and view names in a schema for typo suggestions. */
2250
+ async listCatalogObjects(schema) {
2251
+ return await this.pool.withConnection((connection) => listCatalogObjects(connection, schema));
2252
+ }
2122
2253
  /** List the columns of a table. */
2123
2254
  async listColumns(schema, table) {
2124
2255
  return await this.pool.withConnection(
@@ -2244,12 +2375,15 @@ function textRange(value, offset, length) {
2244
2375
  }
2245
2376
  return { type: "text", originalLength: index, offset, value: selected };
2246
2377
  }
2247
- function readCellWindow(value, offset, length) {
2378
+ function readCellWindow(value, offset, length, typeName2) {
2248
2379
  if (!Number.isSafeInteger(offset) || offset < 0) {
2249
2380
  throw new CfHanaError("CONFIG", "offset must be a non-negative safe integer");
2250
2381
  }
2251
2382
  assertPositiveInteger("length", length);
2252
2383
  if (Buffer.isBuffer(value)) {
2384
+ if (isTextLobType(typeName2)) {
2385
+ return textRange(value.toString("utf8"), offset, length);
2386
+ }
2253
2387
  return {
2254
2388
  type: "binary",
2255
2389
  originalLength: value.length,
@@ -2421,18 +2555,19 @@ function jsonSearchMatches(root, term, row, column, limit, previewLength) {
2421
2555
  }
2422
2556
  return matches;
2423
2557
  }
2424
- function searchCell(value, term, row, column, limit, previewLength) {
2425
- if (typeof value !== "string") {
2558
+ function searchCell(value, term, row, column, typeName2, limit, previewLength) {
2559
+ const text = Buffer.isBuffer(value) && isTextLobType(typeName2) ? value.toString("utf8") : value;
2560
+ if (typeof text !== "string") {
2426
2561
  return [];
2427
2562
  }
2428
2563
  try {
2429
- const parsed = JSON.parse(value);
2564
+ const parsed = JSON.parse(text);
2430
2565
  if (typeof parsed === "object" && parsed !== null) {
2431
2566
  return jsonSearchMatches(parsed, term, row, column, limit, previewLength);
2432
2567
  }
2433
2568
  } catch {
2434
2569
  }
2435
- return plainTextMatches(value, term, row, column, limit, previewLength);
2570
+ return plainTextMatches(text, term, row, column, limit, previewLength);
2436
2571
  }
2437
2572
  function searchResultSession(session, searchTerm, options) {
2438
2573
  assertPositiveInteger("limit", options.limit);
@@ -2454,6 +2589,7 @@ function searchResultSession(session, searchTerm, options) {
2454
2589
  term,
2455
2590
  item.rowNumber,
2456
2591
  column.name,
2592
+ column.typeName,
2457
2593
  remaining,
2458
2594
  previewLength
2459
2595
  )
@@ -2775,7 +2911,12 @@ async function runShow(ref, options) {
2775
2911
  print(csv(rows, ["PATH", "TYPE", "VALUE"], length));
2776
2912
  return;
2777
2913
  }
2778
- const window = readCellWindow(selected.value, nonNegative("--offset", options.offset), length);
2914
+ const window = readCellWindow(
2915
+ selected.value,
2916
+ nonNegative("--offset", options.offset),
2917
+ length,
2918
+ selected.typeName
2919
+ );
2779
2920
  print(
2780
2921
  csv(
2781
2922
  [
@@ -2828,9 +2969,9 @@ async function runSearch(ref, text, options) {
2828
2969
  }));
2829
2970
  print(csv(rows, ["ROW", "COLUMN", "OFFSET", "PATH", "PREVIEW"], length));
2830
2971
  }
2831
- function cellExportValue(value) {
2972
+ function cellExportValue(value, typeName2) {
2832
2973
  if (Buffer.isBuffer(value)) {
2833
- return value;
2974
+ return isTextLobType(typeName2) ? value.toString("utf8") : value;
2834
2975
  }
2835
2976
  if (value instanceof Date) {
2836
2977
  return value.toISOString();
@@ -2852,7 +2993,9 @@ async function runExport(ref, options) {
2852
2993
  }
2853
2994
  const session = await readResultSession(ref);
2854
2995
  const selected = selectResultCell(session, options.row, options.column);
2855
- await writeFile3(options.output, cellExportValue(selected.value), { mode: 384 });
2996
+ await writeFile3(options.output, cellExportValue(selected.value, selected.typeName), {
2997
+ mode: 384
2998
+ });
2856
2999
  print(`wrote=${options.output}`);
2857
3000
  }
2858
3001
  async function runList() {
@@ -2894,6 +3037,445 @@ function registerResultCommands(program) {
2894
3037
  });
2895
3038
  }
2896
3039
 
3040
+ // src/metadata-cache.ts
3041
+ import { createHash } from "crypto";
3042
+ import { mkdir as mkdir4, readFile as readFile2, rename as rename2, rm as rm4, writeFile as writeFile4 } from "fs/promises";
3043
+ import { homedir as homedir4 } from "os";
3044
+ import { join as join5 } from "path";
3045
+ var METADATA_CACHE_TTL_MS = 30 * 6e4;
3046
+ function metadataCacheRoot(saptoolsRoot) {
3047
+ return join5(saptoolsRoot ?? join5(homedir4(), ".saptools"), "cf-hana", "metadata");
3048
+ }
3049
+ function toMetadataCacheScope(info) {
3050
+ return {
3051
+ selector: info.selector,
3052
+ appName: info.appName,
3053
+ host: info.host,
3054
+ schema: info.schema,
3055
+ role: info.role,
3056
+ driver: info.driver
3057
+ };
3058
+ }
3059
+ function metadataCacheKey(scope) {
3060
+ return createHash("sha256").update(JSON.stringify(scope)).digest("hex");
3061
+ }
3062
+ function metadataCachePath(scope, saptoolsRoot) {
3063
+ return join5(metadataCacheRoot(saptoolsRoot), `${metadataCacheKey(scope)}.json`);
3064
+ }
3065
+ function isRecord3(value) {
3066
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3067
+ }
3068
+ function isCatalogObject(value) {
3069
+ return isRecord3(value) && typeof value["schema"] === "string" && typeof value["name"] === "string" && (value["type"] === "TABLE" || value["type"] === "VIEW");
3070
+ }
3071
+ function isScope(value) {
3072
+ 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";
3073
+ }
3074
+ function scopesEqual(left, right) {
3075
+ return metadataCacheKey(left) === metadataCacheKey(right);
3076
+ }
3077
+ function isStored(value) {
3078
+ return isRecord3(value) && value["version"] === 1 && typeof value["createdAt"] === "string" && isScope(value["scope"]) && Array.isArray(value["objects"]) && value["objects"].every(isCatalogObject);
3079
+ }
3080
+ async function readMetadataCache(scope, options = {}) {
3081
+ try {
3082
+ const parsed = JSON.parse(
3083
+ await readFile2(metadataCachePath(scope, options.saptoolsRoot), "utf8")
3084
+ );
3085
+ if (!isStored(parsed) || !scopesEqual(parsed.scope, scope)) {
3086
+ return void 0;
3087
+ }
3088
+ const createdAt = Date.parse(parsed.createdAt);
3089
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
3090
+ const ageMs = now.getTime() - createdAt;
3091
+ if (!Number.isFinite(createdAt) || ageMs < 0 || ageMs >= METADATA_CACHE_TTL_MS) {
3092
+ return void 0;
3093
+ }
3094
+ return parsed.objects;
3095
+ } catch {
3096
+ return void 0;
3097
+ }
3098
+ }
3099
+ async function writeMetadataCache(scope, objects, options = {}) {
3100
+ const root = metadataCacheRoot(options.saptoolsRoot);
3101
+ const path = metadataCachePath(scope, options.saptoolsRoot);
3102
+ const tempPath = `${path}.tmp-${process.pid.toString()}`;
3103
+ const stored = {
3104
+ version: 1,
3105
+ createdAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
3106
+ scope,
3107
+ objects
3108
+ };
3109
+ await mkdir4(root, { recursive: true, mode: 448 });
3110
+ await rm4(tempPath, { force: true });
3111
+ await writeFile4(tempPath, `${JSON.stringify(stored)}
3112
+ `, { encoding: "utf8", mode: 384 });
3113
+ await rename2(tempPath, path);
3114
+ }
3115
+ async function loadCatalogObjectsWithCache(scope, refresh, loader, options = {}) {
3116
+ if (!refresh) {
3117
+ const cached = await readMetadataCache(scope, options);
3118
+ if (cached !== void 0) {
3119
+ return cached;
3120
+ }
3121
+ }
3122
+ const objects = await loader();
3123
+ try {
3124
+ await writeMetadataCache(scope, objects, options);
3125
+ } catch {
3126
+ }
3127
+ return objects;
3128
+ }
3129
+
3130
+ // src/suggestions.ts
3131
+ var INVALID_OBJECT_PATTERNS = [
3132
+ /invalid\s+(?:table|view|object)\s+name/i,
3133
+ /(?:table|view|object)\s+[^\n]*does\s+not\s+exist/i,
3134
+ /could\s+not\s+find\s+(?:table|view|object)/i
3135
+ ];
3136
+ var REF_KEYWORDS = /* @__PURE__ */ new Set(["FROM", "JOIN", "UPDATE", "INTO", "TABLE"]);
3137
+ var STOP_WORDS = /* @__PURE__ */ new Set([
3138
+ "AS",
3139
+ "ON",
3140
+ "WHERE",
3141
+ "SET",
3142
+ "VALUES",
3143
+ "USING",
3144
+ "WHEN",
3145
+ "INNER",
3146
+ "LEFT",
3147
+ "RIGHT",
3148
+ "FULL",
3149
+ "OUTER",
3150
+ "CROSS",
3151
+ "JOIN"
3152
+ ]);
3153
+ var CTE_FOLLOWERS = /* @__PURE__ */ new Set(["AS"]);
3154
+ function isInvalidCatalogObjectError(error) {
3155
+ if (!(error instanceof QueryError)) {
3156
+ return false;
3157
+ }
3158
+ if (error.sqlState === "42S02" || error.sqlState === "42S01") {
3159
+ return true;
3160
+ }
3161
+ return INVALID_OBJECT_PATTERNS.some((pattern) => pattern.test(error.message));
3162
+ }
3163
+ function skipLineComment3(sql, index) {
3164
+ let cursor = index + 2;
3165
+ while (cursor < sql.length && sql[cursor] !== "\n") {
3166
+ cursor += 1;
3167
+ }
3168
+ return cursor;
3169
+ }
3170
+ function skipBlockComment3(sql, index) {
3171
+ let cursor = index + 2;
3172
+ while (cursor < sql.length && !(sql[cursor] === "*" && sql[cursor + 1] === "/")) {
3173
+ cursor += 1;
3174
+ }
3175
+ return Math.min(cursor + 2, sql.length);
3176
+ }
3177
+ function skipStringLiteral(sql, index) {
3178
+ let cursor = index + 1;
3179
+ while (cursor < sql.length) {
3180
+ if (sql[cursor] === "'" && sql[cursor + 1] === "'") {
3181
+ cursor += 2;
3182
+ continue;
3183
+ }
3184
+ if (sql[cursor] === "'") {
3185
+ return cursor + 1;
3186
+ }
3187
+ cursor += 1;
3188
+ }
3189
+ return cursor;
3190
+ }
3191
+ function readQuotedIdentifier(sql, index) {
3192
+ let cursor = index + 1;
3193
+ let text = "";
3194
+ while (cursor < sql.length) {
3195
+ if (sql[cursor] === '"' && sql[cursor + 1] === '"') {
3196
+ text += '"';
3197
+ cursor += 2;
3198
+ continue;
3199
+ }
3200
+ if (sql[cursor] === '"') {
3201
+ return { text, next: cursor + 1 };
3202
+ }
3203
+ text += sql.charAt(cursor);
3204
+ cursor += 1;
3205
+ }
3206
+ return { text, next: cursor };
3207
+ }
3208
+ function readBareWord(sql, index) {
3209
+ let cursor = index;
3210
+ let text = "";
3211
+ while (cursor < sql.length && /[A-Za-z0-9_#$]/.test(sql.charAt(cursor))) {
3212
+ text += sql.charAt(cursor);
3213
+ cursor += 1;
3214
+ }
3215
+ return { text, next: cursor };
3216
+ }
3217
+ function tokenize(sql) {
3218
+ const tokens = [];
3219
+ let index = 0;
3220
+ while (index < sql.length) {
3221
+ const char = sql.charAt(index);
3222
+ if (/\s/.test(char)) {
3223
+ index += 1;
3224
+ continue;
3225
+ }
3226
+ if (char === "-" && sql[index + 1] === "-") {
3227
+ index = skipLineComment3(sql, index);
3228
+ continue;
3229
+ }
3230
+ if (char === "/" && sql[index + 1] === "*") {
3231
+ index = skipBlockComment3(sql, index);
3232
+ continue;
3233
+ }
3234
+ if (char === "'") {
3235
+ index = skipStringLiteral(sql, index);
3236
+ continue;
3237
+ }
3238
+ if (char === '"') {
3239
+ const quoted = readQuotedIdentifier(sql, index);
3240
+ tokens.push({ kind: "quoted", text: quoted.text });
3241
+ index = quoted.next;
3242
+ continue;
3243
+ }
3244
+ if (/[A-Za-z_#$]/.test(char)) {
3245
+ const word = readBareWord(sql, index);
3246
+ tokens.push({ kind: "word", text: word.text });
3247
+ index = word.next;
3248
+ continue;
3249
+ }
3250
+ if (char === ".") {
3251
+ tokens.push({ kind: "dot", text: char });
3252
+ } else if (char === ",") {
3253
+ tokens.push({ kind: "comma", text: char });
3254
+ } else if (char === "(") {
3255
+ tokens.push({ kind: "open", text: char });
3256
+ } else if (char === ")") {
3257
+ tokens.push({ kind: "close", text: char });
3258
+ } else {
3259
+ tokens.push({ kind: "other", text: char });
3260
+ }
3261
+ index += 1;
3262
+ }
3263
+ return tokens;
3264
+ }
3265
+ function upper(token) {
3266
+ return token?.text.toUpperCase() ?? "";
3267
+ }
3268
+ function isIdentifier(token) {
3269
+ return token?.kind === "word" || token?.kind === "quoted";
3270
+ }
3271
+ function readName(tokens, start, allowFollowingParens = false) {
3272
+ const first = tokens[start];
3273
+ if (!isIdentifier(first)) {
3274
+ return void 0;
3275
+ }
3276
+ let next = start + 1;
3277
+ let schema;
3278
+ let name = first.text;
3279
+ const maybeObject = tokens[next + 1];
3280
+ if (tokens[next]?.kind === "dot" && isIdentifier(maybeObject)) {
3281
+ schema = name;
3282
+ name = maybeObject.text;
3283
+ next += 2;
3284
+ }
3285
+ if (!allowFollowingParens && tokens[next]?.kind === "open") {
3286
+ return void 0;
3287
+ }
3288
+ return { name: schema === void 0 ? { name } : { schema, name }, next };
3289
+ }
3290
+ function firstNameFromText(text) {
3291
+ const tokens = tokenize(text);
3292
+ for (let index = 0; index < tokens.length; index += 1) {
3293
+ const read = readName(tokens, index, true);
3294
+ if (read !== void 0) {
3295
+ return read.name;
3296
+ }
3297
+ }
3298
+ return void 0;
3299
+ }
3300
+ function extractMissingObjectNameFromError(error) {
3301
+ if (!(error instanceof QueryError)) {
3302
+ return void 0;
3303
+ }
3304
+ const message = error.message;
3305
+ const invalidName = /invalid\s+(?:table|view|object)\s+name\s*:?\s*(.+)$/i.exec(message);
3306
+ if (invalidName?.[1] !== void 0) {
3307
+ return firstNameFromText(invalidName[1]);
3308
+ }
3309
+ const missingObject = /(?:table|view|object)\s+(.+?)\s+(?:does\s+not\s+exist|not\s+found)/i.exec(message);
3310
+ if (missingObject?.[1] !== void 0) {
3311
+ return firstNameFromText(missingObject[1]);
3312
+ }
3313
+ return void 0;
3314
+ }
3315
+ function cteNames(tokens) {
3316
+ const names = /* @__PURE__ */ new Set();
3317
+ if (upper(tokens[0]) !== "WITH") {
3318
+ return names;
3319
+ }
3320
+ let depth = 0;
3321
+ for (let index = 1; index < tokens.length; index += 1) {
3322
+ const token = tokens[index];
3323
+ if (depth === 0 && upper(token) === "SELECT") {
3324
+ break;
3325
+ }
3326
+ if (depth === 0 && isIdentifier(token) && CTE_FOLLOWERS.has(upper(tokens[index + 1]))) {
3327
+ names.add(token.text.toUpperCase());
3328
+ }
3329
+ if (token?.kind === "open") {
3330
+ depth += 1;
3331
+ } else if (token?.kind === "close" && depth > 0) {
3332
+ depth -= 1;
3333
+ }
3334
+ }
3335
+ return names;
3336
+ }
3337
+ function extractMissingObjectName(sql) {
3338
+ const tokens = tokenize(sql.replace(/^;+|;+$/g, ""));
3339
+ const ctes = cteNames(tokens);
3340
+ let candidate;
3341
+ for (let index = 0; index < tokens.length; index += 1) {
3342
+ const word = upper(tokens[index]);
3343
+ if (!REF_KEYWORDS.has(word)) {
3344
+ continue;
3345
+ }
3346
+ if (word === "TABLE" && upper(tokens[index - 1]) !== "TRUNCATE") {
3347
+ continue;
3348
+ }
3349
+ if (word === "INTO" && !(upper(tokens[index - 1]) === "INSERT" || upper(tokens[index - 1]) === "MERGE")) {
3350
+ continue;
3351
+ }
3352
+ const read = readName(tokens, index + 1, word === "INTO");
3353
+ if (read === void 0) {
3354
+ continue;
3355
+ }
3356
+ if (read.name.schema === void 0 && ctes.has(read.name.name.toUpperCase())) {
3357
+ continue;
3358
+ }
3359
+ if (STOP_WORDS.has(read.name.name.toUpperCase())) {
3360
+ continue;
3361
+ }
3362
+ candidate = read.name;
3363
+ let next = read.next;
3364
+ if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
3365
+ next += 1;
3366
+ }
3367
+ while (tokens[next]?.kind === "comma") {
3368
+ const commaRead = readName(tokens, next + 1);
3369
+ if (commaRead === void 0) {
3370
+ break;
3371
+ }
3372
+ if (!(commaRead.name.schema === void 0 && ctes.has(commaRead.name.name.toUpperCase()))) {
3373
+ candidate = commaRead.name;
3374
+ }
3375
+ next = commaRead.next;
3376
+ if (isIdentifier(tokens[next]) && !STOP_WORDS.has(upper(tokens[next]))) {
3377
+ next += 1;
3378
+ }
3379
+ }
3380
+ }
3381
+ return candidate;
3382
+ }
3383
+ function singular(value) {
3384
+ if (value.endsWith("ES")) {
3385
+ return value.slice(0, -2);
3386
+ }
3387
+ if (value.endsWith("S")) {
3388
+ return value.slice(0, -1);
3389
+ }
3390
+ return value;
3391
+ }
3392
+ function norm(value) {
3393
+ return value.toUpperCase().replace(/[^A-Z0-9]+/g, "");
3394
+ }
3395
+ function distance(a, b) {
3396
+ const previous = Array.from({ length: b.length + 1 }, (_value, index) => index);
3397
+ for (let leftIndex = 1; leftIndex <= a.length; leftIndex += 1) {
3398
+ let last = leftIndex - 1;
3399
+ previous[0] = leftIndex;
3400
+ for (let rightIndex = 1; rightIndex <= b.length; rightIndex += 1) {
3401
+ const old = previous[rightIndex] ?? 0;
3402
+ previous[rightIndex] = Math.min(
3403
+ (previous[rightIndex] ?? 0) + 1,
3404
+ (previous[rightIndex - 1] ?? 0) + 1,
3405
+ last + (a.charAt(leftIndex - 1) === b.charAt(rightIndex - 1) ? 0 : 1)
3406
+ );
3407
+ last = old;
3408
+ }
3409
+ }
3410
+ return previous[b.length] ?? 0;
3411
+ }
3412
+ function score(requested, candidate) {
3413
+ const requestedName = norm(requested.name);
3414
+ const candidateName = norm(candidate.name);
3415
+ if (requested.schema !== void 0 && norm(requested.schema) !== norm(candidate.schema)) {
3416
+ return 0;
3417
+ }
3418
+ if (requestedName === candidateName) {
3419
+ return 100;
3420
+ }
3421
+ if (singular(requestedName) === singular(candidateName)) {
3422
+ return 92;
3423
+ }
3424
+ let value = 0;
3425
+ if (candidateName.startsWith(requestedName) || requestedName.startsWith(candidateName)) {
3426
+ value = Math.max(value, 80 - Math.abs(candidateName.length - requestedName.length));
3427
+ }
3428
+ if (candidateName.endsWith(requestedName) || requestedName.endsWith(candidateName)) {
3429
+ value = Math.max(value, 70 - Math.abs(candidateName.length - requestedName.length));
3430
+ }
3431
+ const editDistance = distance(requestedName, candidateName);
3432
+ const max = Math.max(requestedName.length, candidateName.length, 1);
3433
+ if (editDistance <= Math.max(3, Math.floor(max * 0.35))) {
3434
+ value = Math.max(value, Math.round(75 * (1 - editDistance / max)));
3435
+ }
3436
+ return value;
3437
+ }
3438
+ function extractInvalidColumnNameFromError(error) {
3439
+ if (!(error instanceof QueryError)) {
3440
+ return void 0;
3441
+ }
3442
+ const match = /invalid column name:\s*([^:]+)/i.exec(error.message);
3443
+ const name = match?.[1]?.trim();
3444
+ return name === void 0 || name.length === 0 ? void 0 : name;
3445
+ }
3446
+ function rankCatalogSuggestions(requested, candidates, limit = 5) {
3447
+ return candidates.map((candidate) => ({ candidate, score: score(requested, candidate) })).filter((item) => item.score >= 45).sort(
3448
+ (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)
3449
+ ).slice(0, limit).map((item) => item.candidate);
3450
+ }
3451
+ function formatSuggestions(suggestions) {
3452
+ if (suggestions.length === 0) {
3453
+ return void 0;
3454
+ }
3455
+ return [
3456
+ "Did you mean:",
3457
+ ...suggestions.map((item) => ` ${item.schema}.${item.name} (${item.type})`)
3458
+ ].join("\n");
3459
+ }
3460
+ function rankNameSuggestions(requested, candidates, limit = 5) {
3461
+ const requestedName = { name: requested };
3462
+ return candidates.map((candidate) => ({
3463
+ candidate,
3464
+ score: score(requestedName, { schema: "", name: candidate.name, type: "TABLE" })
3465
+ })).filter((item) => item.score >= 45).sort(
3466
+ (left, right) => right.score - left.score || left.candidate.name.localeCompare(right.candidate.name)
3467
+ ).slice(0, limit).map((item) => item.candidate);
3468
+ }
3469
+ function formatColumnSuggestions(suggestions) {
3470
+ if (suggestions.length === 0) {
3471
+ return void 0;
3472
+ }
3473
+ return [
3474
+ "Did you mean column:",
3475
+ ...suggestions.map((item) => ` ${item.name}`)
3476
+ ].join("\n");
3477
+ }
3478
+
2897
3479
  // src/cli.ts
2898
3480
  function print2(text) {
2899
3481
  process.stdout.write(`${text}
@@ -3020,7 +3602,7 @@ function formatInfo(info) {
3020
3602
  ].join("\n");
3021
3603
  }
3022
3604
  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");
3605
+ 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
3606
  }
3025
3607
  function withFormattedConnectionOptions(command) {
3026
3608
  return withConnectionOptions(command).option(
@@ -3029,6 +3611,81 @@ function withFormattedConnectionOptions(command) {
3029
3611
  "table"
3030
3612
  );
3031
3613
  }
3614
+ async function loadSuggestionCatalogObjects(client, refresh) {
3615
+ try {
3616
+ return await loadCatalogObjectsWithCache(
3617
+ toMetadataCacheScope(client.info),
3618
+ refresh,
3619
+ async () => await client.listCatalogObjects(client.info.schema)
3620
+ );
3621
+ } catch {
3622
+ return await client.listCatalogObjects(client.info.schema);
3623
+ }
3624
+ }
3625
+ function isLobSortOrGroupError(error) {
3626
+ const code = databaseCode(error);
3627
+ if (code !== 266 && code !== 274) {
3628
+ return false;
3629
+ }
3630
+ return error instanceof QueryError && /LOB type is not allowed in (?:ORDER BY|GROUP BY) clause/i.test(error.message);
3631
+ }
3632
+ function printLobSortOrGroupHint() {
3633
+ const lines = [
3634
+ `${CLI_NAME}: HANA cannot ORDER BY or GROUP BY NCLOB/CLOB/BLOB columns directly.`,
3635
+ `${CLI_NAME}: Remove the LOB column from ORDER BY/GROUP BY or wrap it as TO_VARCHAR(<column>).`
3636
+ ];
3637
+ process.stderr.write(`${lines.join("\n")}
3638
+ `);
3639
+ }
3640
+ async function printColumnSuggestions(error, client, sql) {
3641
+ if (databaseCode(error) !== 260) {
3642
+ return;
3643
+ }
3644
+ const columnName = extractInvalidColumnNameFromError(error);
3645
+ const tableName = extractMissingObjectName(sql);
3646
+ if (columnName === void 0 || tableName === void 0) {
3647
+ return;
3648
+ }
3649
+ try {
3650
+ const columns = await client.listColumns(
3651
+ tableName.schema ?? client.info.schema,
3652
+ tableName.name
3653
+ );
3654
+ const text = formatColumnSuggestions(rankNameSuggestions(columnName, columns));
3655
+ if (text !== void 0) {
3656
+ process.stderr.write(`${text}
3657
+ `);
3658
+ }
3659
+ } catch {
3660
+ }
3661
+ }
3662
+ async function printCatalogObjectSuggestions(error, client, sql, refresh) {
3663
+ if (!isInvalidCatalogObjectError(error)) {
3664
+ return;
3665
+ }
3666
+ const requested = extractMissingObjectNameFromError(error) ?? extractMissingObjectName(sql);
3667
+ if (requested === void 0) {
3668
+ return;
3669
+ }
3670
+ try {
3671
+ const objects = await loadSuggestionCatalogObjects(client, refresh);
3672
+ const text = formatSuggestions(rankCatalogSuggestions(requested, objects));
3673
+ if (text !== void 0) {
3674
+ process.stderr.write(`${text}
3675
+ `);
3676
+ }
3677
+ } catch {
3678
+ }
3679
+ }
3680
+ async function enrichAndRethrowQueryError(error, client, sql, refresh) {
3681
+ if (isLobSortOrGroupError(error)) {
3682
+ printLobSortOrGroupHint();
3683
+ throw error;
3684
+ }
3685
+ await printColumnSuggestions(error, client, sql);
3686
+ await printCatalogObjectSuggestions(error, client, sql, refresh);
3687
+ throw error;
3688
+ }
3032
3689
  async function runQuery(selector, sql, command) {
3033
3690
  const opts = command.opts();
3034
3691
  assertQueryOptions(sql, opts);
@@ -3036,12 +3693,24 @@ async function runQuery(selector, sql, command) {
3036
3693
  const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
3037
3694
  try {
3038
3695
  const params = opts.param ?? [];
3039
- const backup = await client.backupWriteStatement(sql, params);
3696
+ let backup;
3697
+ try {
3698
+ backup = await client.backupWriteStatement(sql, params);
3699
+ } catch (error) {
3700
+ await enrichAndRethrowQueryError(error, client, sql, opts.refreshMetadata || opts.refresh);
3701
+ }
3040
3702
  if (backup !== void 0) {
3041
3703
  process.stderr.write(`${CLI_NAME}: backup saved to ${backup.directory}
3042
3704
  `);
3043
3705
  }
3044
- const result = await client.query(sql, params);
3706
+ const result = await client.query(sql, params).catch(async (error) => {
3707
+ return await enrichAndRethrowQueryError(
3708
+ error,
3709
+ client,
3710
+ sql,
3711
+ opts.refreshMetadata || opts.refresh
3712
+ );
3713
+ });
3045
3714
  if (result.statement === "select") {
3046
3715
  const compact = formatCompactCsv(result, cellLimit);
3047
3716
  if (opts.save) {