@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/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
- serialized[key] = serializeCell(value);
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.2.1";
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 norm = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
756
+ const norm2 = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
735
757
  for (const [key, endpoint] of Object.entries(REGION_API_MAP)) {
736
- if (endpoint.toLowerCase() === norm) {
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
- if (typeof value !== "string") {
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(value);
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(value, term, row, column, limit, previewLength);
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(selected.value, nonNegative("--offset", options.offset), length);
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), { mode: 384 });
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
- const backup = await client.backupWriteStatement(sql, params);
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) {