diff --git a/hana/lib/HANAService.js b/hana/lib/HANAService.js index be4d7e51d..d02152eb5 100644 --- a/hana/lib/HANAService.js +++ b/hana/lib/HANAService.js @@ -819,24 +819,26 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE } where(xpr) { - xpr = { xpr } + xpr = { xpr, top: true } const suffix = this.is_comparator(xpr) - return `${this.xpr(xpr)}${suffix ? '' : ` = ${this.val({ val: true })}`}` + return `${this.xpr(xpr, true)}${suffix ? '' : ` = ${this.val({ val: true })}`}` } having(xpr) { return this.where(xpr) } - xpr(_xpr, caseSuffix = '') { - const { xpr, _internal } = _xpr + xpr(_xpr, iscompare) { + const { xpr, top, _internal } = _xpr // Maps the compare operators to what to return when both sides are null - const compareOperators = { + const compareTranslations = { '==': true, '!=': false, - - // These operators are not allowed in column expressions - /* REVISIT: Only adjust these operators when inside the column expression + } + const expressionTranslations = { // These operators are not allowed in column expressions + '==': true, + '!=': false, + '=': null, '>': null, '<': null, '<>': null, @@ -844,11 +846,10 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE '<=': null, '!<': null, '!>': null, - */ } - let endWithCompare = false if (!_internal) { + let curIsCompare = iscompare for (let i = 0; i < xpr.length; i++) { let x = xpr[i] if (typeof x === 'string') { @@ -879,11 +880,10 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE // const effective = x === '=' && xpr[i + 1]?.val === null ? '==' : x // HANA does not support comparators in all clauses (e.g. SELECT 1>0 FROM DUMMY) // HANA does not have an 'IS' or 'IS NOT' operator - if (x in compareOperators) { - endWithCompare = true + if (curIsCompare ? x in compareTranslations : x in expressionTranslations) { const left = xpr[i - 1] const right = xpr[i + 1] - const ifNull = compareOperators[x] + const ifNull = expressionTranslations[x] x = x === '==' ? '=' : x const compare = [left, x, right] @@ -915,31 +915,35 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE xpr[i - 1] = '' xpr[i] = expression - xpr[i + 1] = ' = TRUE' + xpr[i + 1] = curIsCompare ? ' = TRUE' : '' + } else { + const up = x.toUpperCase() + if (up in caseOperators) { + curIsCompare = caseOperators[up] + } } } } } const sql = [] + let curIsCompare = iscompare + let curIsTop = top for (let i = 0; i < xpr.length; ++i) { const x = xpr[i] if (typeof x === 'string') { const up = x.toUpperCase() - if (up in logicOperators) { - // Force current expression to end with a comparison - endWithCompare = true - } - if (endWithCompare && (up in caseOperators || up === ')')) { - endWithCompare = false + if (up in caseOperators) { + curIsCompare = caseOperators[up] + curIsTop = caseOperators[up] } - sql.push(this.operator(x, i, xpr)) - } else if (x.xpr) sql.push(`(${this.xpr(x, caseSuffix)})`) + sql.push(this.operator(x, i, xpr, curIsTop)) + } else if (x.xpr) sql.push(`(${this.xpr(x, curIsCompare)})`) // default else sql.push(this.expr(x)) } - if (endWithCompare) { + if (iscompare) { const suffix = this.operator('OR', xpr.length, xpr).slice(0, -3) if (suffix) { sql.push(suffix) @@ -949,12 +953,13 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE return `${sql.join(' ')}` } - operator(x, i, xpr) { + operator(x, i, xpr, top) { const up = x.toUpperCase() // Add "= TRUE" before THEN in case statements if ( up in logicOperators && - !this.is_comparator({ xpr }, i - 1) + logicOperators[up] && + this.is_comparator({ xpr, top }, i - 1) === false ) { return ` = ${this.val({ val: true })} ${x}` } @@ -973,7 +978,7 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE * @param {} xpr * @returns */ - is_comparator({ xpr }, start) { + is_comparator({ xpr, top }, start) { const local = start != null for (let i = start ?? xpr.length; i > -1; i--) { const cur = xpr[i] @@ -985,12 +990,25 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE if (up in logicOperators) { // ensure AND is not part of BETWEEN if (up === 'AND' && xpr[i - 2]?.toUpperCase?.() in { 'BETWEEN': 1, 'NOT BETWEEN': 1 }) return true + // ensure NOT is not part of a compare operator + if (up === 'NOT' && xpr[i - 1]?.toUpperCase?.() in compareOperators) return true return !local } // When a compare operator is found the expression is a comparison if (up in compareOperators) return true + // When there is an END of a case statement walk around it to keep checking + if (up === 'END') { + let casedepth = 0 + for (; i > -1; i--) { + const up = xpr[i]?.toUpperCase?.() + if (up === 'END') casedepth++ + if (up === 'CASE') casedepth-- + if (casedepth === 0) break + } + if (casedepth > 0) return false + } // When a case operator is found it is the start of the expression - if (up in caseOperators) break + if (up in caseOperators) return false continue } if (cur.func?.toUpperCase() === 'CONTAINS' && cur.args?.length > 2) return true @@ -1000,7 +1018,7 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE if (nested) return true } } - return false + return top ? false : 0 } list(list) { @@ -1357,16 +1375,19 @@ function _not_unique(err, code) { const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl const ObjectKeys = o => (o && [...ObjectKeys(o.__proto__), ...Object.keys(o)]) || [] +// All case key words and whether they start an comparison or an expression const caseOperators = { 'CASE': 1, 'WHEN': 1, - 'THEN': 1, - 'ELSE': 1, + 'THEN': 0, + 'ELSE': 0, } +// All logic operators and whether they have a left hand comparison const logicOperators = { 'THEN': 1, 'AND': 1, 'OR': 1, + 'NOT': 0, } const compareOperators = { '=': 1, diff --git a/test/compliance/SELECT.test.js b/test/compliance/SELECT.test.js index f585220ad..922a450bf 100644 --- a/test/compliance/SELECT.test.js +++ b/test/compliance/SELECT.test.js @@ -171,15 +171,15 @@ describe('SELECT', () => { await expect(cds.run(cqn)).rejected }) - test.skip('select xpr', async () => { + test('select xpr', async () => { // REVISIT: Make HANAService ANSI SQL compliant by wrapping compare expressions into case statements for columns const { string } = cds.entities('basic.projection') - const cqn = CQL`SELECT (${'yes'} = string) as xpr : cds.Boolean FROM ${string}` + const cqn = CQL`SELECT (${'yes'} = string) as xpr : cds.Boolean FROM ${string} order by string` const res = await cds.run(cqn) assert.strictEqual(res.length, 3, 'Ensure that all rows are coming back') - assert.equal(res[0].xpr, true) + assert.equal(res[0].xpr, null) assert.equal(res[1].xpr, false) - assert.equal(res[2].xpr, false) + assert.equal(res[2].xpr, true) }) test('select calculation', async () => { @@ -433,7 +433,7 @@ describe('SELECT', () => { // search tests don't check results as the search behavior is undefined test('search one column', async () => { const { string } = cds.entities('basic.literals') - const cqn = SELECT.from(string).where([{func: 'search', args: [{list: [{ref: ['string']}]}, {val: 'yes'}]}]) + const cqn = SELECT.from(string).where([{ func: 'search', args: [{ list: [{ ref: ['string'] }] }, { val: 'yes' }] }]) await cds.run(cqn) }) @@ -468,6 +468,136 @@ describe('SELECT', () => { const res = await cds.run(cqn) assert.strictEqual(res.length, 3, `Ensure that only matches comeback`) }) + + test('deep nested not', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: [CXL`not startswith(string,${'n'})`] }}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'yes') + }) + + test('deep nested boolean function w/o operator', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: [CXL`startswith(string,${'n'})`] }}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'no') + }) + + test('deep nested not + and', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: [CXL`not startswith(string,${'n'}) and not startswith(string,${'n'})`] }}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'yes') + }) + + test('multiple levels of not negations of expressions', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: ['not', { xpr: ['not', CXL`not startswith(string,${'n'})`] }] }}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'yes') + }) + + test('multiple not in a single deep nested expression', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: [CXL`not not not startswith(string,${'n'})`] }}` + await cds.tx(async tx => { + let res + try { + res = await tx.run(query) + } catch (err) { + if (tx.dbc.server.major < 4) return // not not is not supported by older HANA versions + throw err + } + assert.strictEqual(res[0].string, 'yes') + }) + }) + + test('multiple levels of not negations of expression with not + and', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: ['not', { xpr: ['not', CXL`not startswith(string,${'n'}) and not startswith(string,${'n'})`] }] }}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'yes') + }) + + test('multiple levels of not negations of expression with multiple not in a single expression', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: ['not', { xpr: ['not', CXL`not not not startswith(string,${'n'}) and not not not startswith(string,${'n'})`] }] }}` + await cds.tx(async tx => { + let res + try { + res = await tx.run(query) + } catch (err) { + if (tx.dbc.server.major < 4) return // not not is not supported by older HANA versions + throw err + } + assert.strictEqual(res[0].string, 'yes') + }) + }) + + test('deep nested not before xpr with CASE statement', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: [{ xpr: ['not', CXL`string = 'no' ? true : false`] }] }}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'yes') + }) + + test('deep nested multiple not before xpr with CASE statement', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: [{ xpr: ['not', 'not', 'not', CXL`string = 'no' ? true : false`] }] }}` + await cds.tx(async tx => { + let res + try { + res = await tx.run(query) + } catch (err) { + if (tx.dbc.server.major < 4) return // not not is not supported by older HANA versions + throw err + } + assert.strictEqual(res[0].string, 'yes') + }) + }) + + test('deep nested not before CASE statement', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: [{ xpr: ['not', ...(CXL`string = 'no' ? true : false`).xpr] }] }}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'yes') + }) + + test('deep nested multiple not before CASE statement', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: [{ xpr: ['not', 'not', 'not', ...(CXL`string = 'no' ? true : false`).xpr] }] }}` + await cds.tx(async tx => { + let res + try { + res = await tx.run(query) + } catch (err) { + if (tx.dbc.server.major < 4) return // not not is not supported by older HANA versions + throw err + } + assert.strictEqual(res[0].string, 'yes') + }) + }) + + test('not before CASE statement', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: ['not', ...(CXL`string = 'no' ? true : false`).xpr]}}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'yes') + }) + + test.skip('and beetwen CASE statements', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: [...(CXL`string = 'no' ? true : false`).xpr, 'and', ...(CXL`string = 'no' ? true : false`).xpr]}}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'no') + }) + + test('and beetwen CASE statements with not', async () => { + const { string } = cds.entities('basic.literals') + const query = CQL`SELECT * FROM ${string} WHERE ${{ xpr: ['not', ...(CXL`string = 'no' ? true : false`).xpr, 'and', 'not', ...(CXL`string = 'no' ? true : false`).xpr]}}` + const res = await cds.run(query) + assert.strictEqual(res[0].string, 'yes') + }) }) describe('groupby', () => { @@ -575,7 +705,7 @@ describe('SELECT', () => { const desc = SELECT.from(string).columns('string').orderBy('string desc') const mixedAsc = SELECT.from(string).columns('string').orderBy('string aSC') const asc = SELECT.from(string).columns('string').orderBy('string asc') - + expect(await cds.run(mixedDesc)).to.eql(await cds.run(desc)) expect(await cds.run(mixedAsc)).to.eql(await cds.run(asc)) }) @@ -1006,7 +1136,7 @@ describe('SELECT', () => { unified.scalar = [ // TODO: investigate search issue for nvarchar columns ...unified.ref.filter(ref => cds.builtin.types[ref.element?.type] === cds.builtin.types.LargeString).map(ref => { - return unified.string.map(val => ({ func: 'search', args: [{list:[ref]}, val] })) + return unified.string.map(val => ({ func: 'search', args: [{ list: [ref] }, val] })) }).flat(), // ...unified.string.map(val => ({ func: 'search', args: [{ list: unified.ref.filter(stringRefs) }, val] })), ...unified.ref.filter(stringRefs).filter(noBooleanRefs).map(X => { @@ -1236,7 +1366,7 @@ describe('SELECT', () => { for (const comp of unified.comparators) { yield { xpr: ['CASE', 'WHEN', comp, 'THEN', { val: true }, 'ELSE', { val: false }, 'END'], as: 'xpr' } if (!minimal || unified.comparators[0] === comp) { - yield { xpr: ['CASE', 'WHEN', { xpr: ['NOT', ...comp.xpr] }, 'THEN', { val: true }, 'ELSE', { val: false }, 'END'], as: 'xpr' } + yield { xpr: ['CASE', 'WHEN', { xpr: ['NOT', comp] }, 'THEN', { val: true }, 'ELSE', { val: false }, 'END'], as: 'xpr' } // for (const comp2 of unified.comparators) { yield { xpr: ['CASE', 'WHEN', comp, 'AND', comp, 'THEN', { val: true }, 'ELSE', { val: false }, 'END'], as: 'xpr' } yield { xpr: ['CASE', 'WHEN', comp, 'OR', comp, 'THEN', { val: true }, 'ELSE', { val: false }, 'END'], as: 'xpr' }