diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index f92dee9..e6312e9 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "hana-cli", - "version": "0.1.10", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hana-cli", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@sap/cds": "10.0.3", "@sap/cds-dk": "10.0.4", diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 6a29185..51cb275 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "hana-cli", "displayName": "hana-cli Tools for SAP HANA", "description": "Visual editors and database tools for SAP HANA powered by hana-cli", - "version": "0.1.10", + "version": "0.1.11", "publisher": "SAP-samples", "icon": "icon.png", "license": "Apache-2.0", diff --git a/vscode-extension/src/editors/runtimeName.ts b/vscode-extension/src/editors/runtimeName.ts index b0da759..d5d9030 100644 --- a/vscode-extension/src/editors/runtimeName.ts +++ b/vscode-extension/src/editors/runtimeName.ts @@ -80,7 +80,7 @@ function parseSequence(content: string): string | undefined { if (trimmed.startsWith('{')) { try { const obj = JSON.parse(trimmed) - if (typeof obj.name === 'string') return normalizeIdentifier(obj.name) + if (typeof obj.name === 'string') return normalizeJsonName(obj.name) // Fall through to top-level key if no explicit name return parseJsonObjectName(trimmed) } catch { @@ -102,28 +102,46 @@ function parseJsonObjectName(content: string): string | undefined { const topKey = keys[0] const val = obj[topKey] if (val && typeof val === 'object' && typeof val.name === 'string') { - return normalizeIdentifier(val.name) + return normalizeJsonName(val.name) } - return normalizeIdentifier(topKey) + return normalizeJsonName(topKey) } catch { return undefined } } /** - * Normalize a raw identifier token: strip surrounding double-quotes, take the - * last dot-qualified segment (object name, not schema), preserve inner case - * and any `::` namespace separator. + * Normalize a name authored in an HDI JSON config (synonym/role/sequence). + * These are literal object names, NOT SQL DDL identifiers, so their case is + * preserved as authored (HANA does not apply unquoted-identifier folding). + * Only the object portion is kept if the value is dot-qualified. + */ +function normalizeJsonName(raw: string): string { + const id = raw.trim() + const segments = id.split('.') + const last = segments[segments.length - 1] + return last.replace(/^"(.*)"$/, '$1') +} + +/** + * Normalize a raw identifier token to the name HANA actually deployed. + * + * Takes the last dot-qualified segment (object name, not schema) and applies + * SQL identifier folding: an UNQUOTED identifier is folded to UPPERCASE (HANA's + * default), while a double-QUOTED identifier keeps its authored case. CAP emits + * unquoted names, so the common case uppercases (e.g. `star_wars_eyeColors` -> + * `STAR_WARS_EYECOLORS`). Verified against a live HDI container. */ function normalizeIdentifier(raw: string): string { const id = raw.trim().replace(/;$/, '') // Split on dots to drop a schema qualifier; keep the last segment. // Handles both `name` and `"schema"."name"` forms. const segments = id.split('.') - let last = segments[segments.length - 1] - // Strip surrounding double quotes. - last = last.replace(/^"(.*)"$/, '$1') - return last + const last = segments[segments.length - 1] + // A quoted segment preserves case; an unquoted one folds to uppercase. + const quoted = /^".*"$/.test(last) + const unquoted = last.replace(/^"(.*)"$/, '$1') + return quoted ? unquoted : unquoted.toUpperCase() } /** @@ -134,9 +152,12 @@ function fallbackName(fsPath: string): string { const base = path.basename(fsPath) const dot = base.lastIndexOf('.') const stem = dot > 0 ? base.substring(0, dot) : base - // Replace '.' with '_' per HDI naming, then strip anything outside the safe - // grammar so the fallback can never emit an injectable string. - const runtime = stripUnsafe(stem.replace(/\./g, '_')) + // The design-time filename maps to an UNQUOTED SQL identifier: replace '.' + // with '_' per HDI naming, strip anything outside the safe grammar, then + // fold to uppercase to match how HANA deploys unquoted names. + const runtime = stripUnsafe(stem.replace(/\./g, '_')).toUpperCase() + // The namespace prefix is case-sensitive as authored (reverse-DNS style); + // only the local object name folds. So prepend it verbatim. const ns = readNamespace(path.dirname(fsPath)) return ns ? `${ns}::${runtime}` : runtime } diff --git a/vscode-extension/test/suite/runtimeName.test.ts b/vscode-extension/test/suite/runtimeName.test.ts index 97d4f96..014445e 100644 --- a/vscode-extension/test/suite/runtimeName.test.ts +++ b/vscode-extension/test/suite/runtimeName.test.ts @@ -23,62 +23,78 @@ suite('resolveRuntimeName', () => { } // --- Parse path, one per kind --- + // HANA folds UNQUOTED DDL identifiers to UPPERCASE at deploy time; only + // double-quoted identifiers keep their authored case. CAP emits unquoted + // names, so the common case uppercases. Verified against a live HDI + // container: `COLUMN TABLE cds_outbox_Messages` deploys as + // CDS_OUTBOX_MESSAGES; `star_wars_eyeColors` deploys as STAR_WARS_EYECOLORS. - test('table: COLUMN TABLE', () => { + test('table: COLUMN TABLE (unquoted -> uppercase)', () => { const name = resolve( 'cds.outbox.Messages.hdbtable', 'COLUMN TABLE cds_outbox_Messages (\n ID NVARCHAR(36) NOT NULL,\n PRIMARY KEY(ID)\n)', 'table' ) - assert.strictEqual(name, 'cds_outbox_Messages') + assert.strictEqual(name, 'CDS_OUTBOX_MESSAGES') }) - test('table: ROW TABLE', () => { + test('table: ROW TABLE (unquoted -> uppercase)', () => { const name = resolve( 'my.Row.hdbtable', 'ROW TABLE my_Row (ID INTEGER)', 'table' ) - assert.strictEqual(name, 'my_Row') + assert.strictEqual(name, 'MY_ROW') }) - test('view: VIEW ... AS SELECT', () => { + test('view: VIEW ... AS SELECT (unquoted -> uppercase)', () => { const name = resolve( 'star.wars.Order.hdbview', 'VIEW star_wars_CloneWarsChronologicalOrder AS SELECT\n Episode_0.ID\nFROM x', 'view' ) - assert.strictEqual(name, 'star_wars_CloneWarsChronologicalOrder') + assert.strictEqual(name, 'STAR_WARS_CLONEWARSCHRONOLOGICALORDER') + }) + + test('view: real-world mixed-case eyeColors -> uppercase', () => { + // Regression for the reported bug: file DDL is `star_wars_eyeColors`, + // deployed runtime name is STAR_WARS_EYECOLORS. + const name = resolve( + 'star.wars.eyeColors.hdbview', + 'VIEW star_wars_eyeColors AS SELECT eye_color FROM x', + 'view' + ) + assert.strictEqual(name, 'STAR_WARS_EYECOLORS') }) - test('procedure: PROCEDURE', () => { + test('procedure: PROCEDURE (unquoted -> uppercase)', () => { const name = resolve( 'my.Proc.hdbprocedure', 'PROCEDURE my_Proc (IN a INTEGER) AS BEGIN END', 'procedure' ) - assert.strictEqual(name, 'my_Proc') + assert.strictEqual(name, 'MY_PROC') }) - test('function: FUNCTION', () => { + test('function: FUNCTION (unquoted -> uppercase)', () => { const name = resolve( 'my.Func.hdbfunction', 'FUNCTION my_Func (IN a INTEGER) RETURNS INTEGER AS BEGIN END', 'function' ) - assert.strictEqual(name, 'my_Func') + assert.strictEqual(name, 'MY_FUNC') }) - test('sequence: DDL SEQUENCE', () => { + test('sequence: DDL SEQUENCE (unquoted -> uppercase)', () => { const name = resolve( 'my.Seq.hdbsequence', 'SEQUENCE my_Seq START WITH 1 INCREMENT BY 1', 'sequence' ) - assert.strictEqual(name, 'my_Seq') + assert.strictEqual(name, 'MY_SEQ') }) - test('sequence: JSON form', () => { + test('sequence: JSON form (explicit name, case preserved)', () => { const name = resolve( 'my.Seq.hdbsequence', '{ "name": "my_Seq", "start_with": 1 }', @@ -87,7 +103,7 @@ suite('resolveRuntimeName', () => { assert.strictEqual(name, 'my_Seq') }) - test('synonym: JSON top-level key', () => { + test('synonym: JSON top-level key (case preserved)', () => { const name = resolve( 'my.Syn.hdbsynonym', '{ "my_Syn": { "target": { "object": "FOO", "schema": "BAR" } } }', @@ -96,7 +112,7 @@ suite('resolveRuntimeName', () => { assert.strictEqual(name, 'my_Syn') }) - test('role: JSON top-level key', () => { + test('role: JSON top-level key (case preserved)', () => { const name = resolve( 'my.Role.hdbrole', '{ "role": { "name": "my_Role" } }', @@ -117,7 +133,7 @@ suite('resolveRuntimeName', () => { assert.strictEqual(name, 'cds_outbox_Messages') }) - test('schema-qualified DDL: last dot segment', () => { + test('schema-qualified quoted DDL: last segment, quoted -> case preserved', () => { const name = resolve( 'q.hdbtable', 'COLUMN TABLE "MYSCHEMA"."cds_outbox_Messages" (ID INTEGER)', @@ -126,56 +142,60 @@ suite('resolveRuntimeName', () => { assert.strictEqual(name, 'cds_outbox_Messages') }) - test('mixed case preserved', () => { + test('unquoted mixed case folds to uppercase', () => { const name = resolve( 'q.hdbtable', 'COLUMN TABLE MixedCase_Table (ID INTEGER)', 'table' ) - assert.strictEqual(name, 'MixedCase_Table') + assert.strictEqual(name, 'MIXEDCASE_TABLE') }) // --- Fallback rule --- + // The design-time filename maps to an UNQUOTED SQL identifier, so the + // fallback also folds the object portion to uppercase. - test('fallback: empty file uses filename naming rule', () => { + test('fallback: empty file uses filename naming rule (uppercased)', () => { const name = resolve('cds.outbox.Messages.hdbtable', '', 'table') - assert.strictEqual(name, 'cds_outbox_Messages') + assert.strictEqual(name, 'CDS_OUTBOX_MESSAGES') }) test('fallback: unknown kind uses filename naming rule', () => { const name = resolve('a.b.C.hdbxyz', 'whatever', 'mystery') - assert.strictEqual(name, 'a_b_C') + assert.strictEqual(name, 'A_B_C') }) test('fallback: unparseable content uses filename naming rule', () => { const name = resolve('a.b.C.hdbtable', 'not a table definition', 'table') - assert.strictEqual(name, 'a_b_C') + assert.strictEqual(name, 'A_B_C') }) test('fallback: missing file uses filename naming rule (no throw)', () => { const p = path.join(tempDir, 'does.not.Exist.hdbtable') const name = resolveRuntimeName(p, 'table') - assert.strictEqual(name, 'does_not_Exist') + assert.strictEqual(name, 'DOES_NOT_EXIST') }) - test('fallback: .hdinamespace name is prepended', () => { + test('fallback: .hdinamespace name prepended as-authored, local part uppercased', () => { const nsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hana-cli-ns-')) fs.writeFileSync(path.join(nsDir, '.hdinamespace'), '{ "name": "com.acme", "subfolder": "ignore" }') const p = path.join(nsDir, 'a.b.hdbtable') fs.writeFileSync(p, '') // empty -> fallback const name = resolveRuntimeName(p, 'table') fs.rmSync(nsDir, { recursive: true, force: true }) - assert.strictEqual(name, 'com.acme::a_b') + // Namespace prefix is case-sensitive as authored; only the local object + // name folds to uppercase (unquoted SQL identifier rule). + assert.strictEqual(name, 'com.acme::A_B') }) - test('fallback: empty .hdinamespace name adds no prefix', () => { + test('fallback: empty .hdinamespace name adds no prefix (uppercased)', () => { const nsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hana-cli-ns2-')) fs.writeFileSync(path.join(nsDir, '.hdinamespace'), '{ "name": "", "subfolder": "ignore" }') const p = path.join(nsDir, 'a.b.hdbtable') fs.writeFileSync(p, '') const name = resolveRuntimeName(p, 'table') fs.rmSync(nsDir, { recursive: true, force: true }) - assert.strictEqual(name, 'a_b') + assert.strictEqual(name, 'A_B') }) // --- Security: never return a name that could break out of the webview @@ -201,8 +221,8 @@ suite('resolveRuntimeName', () => { 'table' ) assert.ok(SAFE_IDENTIFIER.test(name), `unsafe name returned: ${name}`) - // Falls back to the filename-derived name. - assert.strictEqual(name, 'evil2') + // Falls back to the filename-derived name (uppercased, unquoted rule). + assert.strictEqual(name, 'EVIL2') }) test('security: filename containing a quote is sanitized in fallback', () => { @@ -220,6 +240,6 @@ suite('resolveRuntimeName', () => { const name = resolveRuntimeName(p, 'table') fs.rmSync(nsDir, { recursive: true, force: true }) assert.ok(SAFE_IDENTIFIER.test(name), `unsafe name returned: ${name}`) - assert.strictEqual(name, 'a_b') // malicious prefix dropped + assert.strictEqual(name, 'A_B') // malicious prefix dropped }) })