Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions vscode-extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 34 additions & 13 deletions vscode-extension/src/editors/runtimeName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
}

/**
Expand All @@ -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
}
Expand Down
80 changes: 50 additions & 30 deletions vscode-extension/test/suite/runtimeName.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }',
Expand All @@ -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" } } }',
Expand All @@ -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" } }',
Expand All @@ -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)',
Expand All @@ -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
Expand All @@ -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', () => {
Expand All @@ -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
})
})
Loading