From f88d7c0c1c6a5279bd26b579caade8678f49a533 Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 15 Nov 2024 16:14:44 +0100 Subject: [PATCH 01/19] wip --- hana/lib/cql-functions.js | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/hana/lib/cql-functions.js b/hana/lib/cql-functions.js index b78516553..78f9e9854 100644 --- a/hana/lib/cql-functions.js +++ b/hana/lib/cql-functions.js @@ -24,10 +24,43 @@ const StandardFunctions = { contains: (...args) => args.length > 2 ? `CONTAINS(${args})` : `(CASE WHEN coalesce(locate(${args}),0)>0 THEN TRUE ELSE FALSE END)`, concat: (...args) => `(${args.map(a => (a.xpr ? `(${a})` : a)).join(' || ')})`, search: function (ref, arg) { + const csnElements = ref.element ? [ref] : [...ref.list] + + const fuzzyIndex = cds.env.hana?.fuzzy || 0.7 + const customized = csnElements.some(e => e.element?.['@Search.ranking'] || e.element?.['@Search.fuzzinessThreshold']) + + if (customized) { + const x = csnElements.map(e => { + // REVISIT: How to do quoting? + let col = `${e.ref.join('.')} FUZZY` + + if (e.element?.['@Search.ranking']) { + if(e.element['@Search.ranking']['='] === 'HIGH') { + col += ' WEIGHT 0.8' + } else if(e.element['@Search.ranking']['='] === 'LOW') { + col += ' WEIGHT 0.3' + } else { + col += ' WEIGHT 0.5' + } + } else { + col += ' WEIGHT 0.5' + } + + col+= ` MINIMAL TOKEN SCORE ${e.element?.['@Search.fuzzinessThreshold'] || fuzzyIndex}` + col+= " SIMILARITY CALCULATION MODE 'search'" + return col + }) + + ref = `(${x.join(',')})` + } + + + // REVISIT: remove once the protocol adapter only creates vals if (Array.isArray(arg.xpr)) arg = { val: arg.xpr.filter(a => a.val).map(a => a.val).join(' ') } - // REVISIT: make this more configurable - return (`(CASE WHEN SCORE(${arg} IN ${ref} FUZZY MINIMAL TOKEN SCORE 0.7 SIMILARITY CALCULATION MODE 'search') > 0 THEN TRUE ELSE FALSE END)`) + + const globalOption = `FUZZY MINIMAL TOKEN SCORE ${fuzzyIndex} SIMILARITY CALCULATION MODE 'search'` + return (`(CASE WHEN SCORE(${arg} IN ${ref} ${!customized ? globalOption: ''}) > 0 THEN TRUE ELSE FALSE END)`) }, // Date and Time Functions From c5bca3298409ef15817d35d21287405865fd2b4c Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Mon, 18 Nov 2024 12:30:47 +0100 Subject: [PATCH 02/19] . --- hana/lib/cql-functions.js | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/hana/lib/cql-functions.js b/hana/lib/cql-functions.js index 78f9e9854..92e3448e3 100644 --- a/hana/lib/cql-functions.js +++ b/hana/lib/cql-functions.js @@ -25,33 +25,30 @@ const StandardFunctions = { concat: (...args) => `(${args.map(a => (a.xpr ? `(${a})` : a)).join(' || ')})`, search: function (ref, arg) { const csnElements = ref.element ? [ref] : [...ref.list] + let fuzzyString + // default config const fuzzyIndex = cds.env.hana?.fuzzy || 0.7 - const customized = csnElements.some(e => e.element?.['@Search.ranking'] || e.element?.['@Search.fuzzinessThreshold']) - if (customized) { - const x = csnElements.map(e => { + // if column specific value is provided, the configuration has to be defined on column level + if (csnElements.some(e => e.element?.['@Search.ranking'] || e.element?.['@Search.fuzzinessThreshold'])) { + const cols = csnElements.map(e => { // REVISIT: How to do quoting? let col = `${e.ref.join('.')} FUZZY` - if (e.element?.['@Search.ranking']) { - if(e.element['@Search.ranking']['='] === 'HIGH') { - col += ' WEIGHT 0.8' - } else if(e.element['@Search.ranking']['='] === 'LOW') { - col += ' WEIGHT 0.3' - } else { - col += ' WEIGHT 0.5' - } - } else { - col += ' WEIGHT 0.5' - } + const rank = e.element?.['@Search.ranking']?.['='] + if(rank === 'HIGH') col += ' WEIGHT 0.8' + else if(rank === 'LOW') col += ' WEIGHT 0.3' + else col += ' WEIGHT 0.5' // MEDIUM col+= ` MINIMAL TOKEN SCORE ${e.element?.['@Search.fuzzinessThreshold'] || fuzzyIndex}` col+= " SIMILARITY CALCULATION MODE 'search'" return col - }) + }).join(',') - ref = `(${x.join(',')})` + fuzzyString = `(${cols})` + } else { + fuzzyString = `${ref} FUZZY MINIMAL TOKEN SCORE ${fuzzyIndex} SIMILARITY CALCULATION MODE 'search'` } @@ -59,8 +56,7 @@ const StandardFunctions = { // REVISIT: remove once the protocol adapter only creates vals if (Array.isArray(arg.xpr)) arg = { val: arg.xpr.filter(a => a.val).map(a => a.val).join(' ') } - const globalOption = `FUZZY MINIMAL TOKEN SCORE ${fuzzyIndex} SIMILARITY CALCULATION MODE 'search'` - return (`(CASE WHEN SCORE(${arg} IN ${ref} ${!customized ? globalOption: ''}) > 0 THEN TRUE ELSE FALSE END)`) + return (`(CASE WHEN SCORE(${arg} IN ${fuzzyString}) > 0 THEN TRUE ELSE FALSE END)`) }, // Date and Time Functions From d92e58fb56a5a2a9155a29617be8215706f0346c Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Tue, 19 Nov 2024 11:01:45 +0100 Subject: [PATCH 03/19] improve --- hana/lib/cql-functions.js | 44 ++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/hana/lib/cql-functions.js b/hana/lib/cql-functions.js index 92e3448e3..88cf85e36 100644 --- a/hana/lib/cql-functions.js +++ b/hana/lib/cql-functions.js @@ -24,31 +24,37 @@ const StandardFunctions = { contains: (...args) => args.length > 2 ? `CONTAINS(${args})` : `(CASE WHEN coalesce(locate(${args}),0)>0 THEN TRUE ELSE FALSE END)`, concat: (...args) => `(${args.map(a => (a.xpr ? `(${a})` : a)).join(' || ')})`, search: function (ref, arg) { - const csnElements = ref.element ? [ref] : [...ref.list] - let fuzzyString - - // default config + // fuzziness config const fuzzyIndex = cds.env.hana?.fuzzy || 0.7 - + + const csnElements = ref.element ? [ref] : ref.list // if column specific value is provided, the configuration has to be defined on column level if (csnElements.some(e => e.element?.['@Search.ranking'] || e.element?.['@Search.fuzzinessThreshold'])) { - const cols = csnElements.map(e => { - // REVISIT: How to do quoting? - let col = `${e.ref.join('.')} FUZZY` + csnElements.forEach((e,i) => { + let fuzzy = `FUZZY` + // weighted search const rank = e.element?.['@Search.ranking']?.['='] - if(rank === 'HIGH') col += ' WEIGHT 0.8' - else if(rank === 'LOW') col += ' WEIGHT 0.3' - else col += ' WEIGHT 0.5' // MEDIUM + if(rank === 'HIGH') fuzzy += ' WEIGHT 0.8' + else if(rank === 'LOW') fuzzy += ' WEIGHT 0.3' + else fuzzy += ' WEIGHT 0.5' // MEDIUM - col+= ` MINIMAL TOKEN SCORE ${e.element?.['@Search.fuzzinessThreshold'] || fuzzyIndex}` - col+= " SIMILARITY CALCULATION MODE 'search'" - return col - }).join(',') - - fuzzyString = `(${cols})` + // fuzziness + fuzzy+= ` MINIMAL TOKEN SCORE ${e.element?.['@Search.fuzzinessThreshold'] || fuzzyIndex}` + fuzzy+= " SIMILARITY CALCULATION MODE 'search'" + + // rewrite ref to xpr to mix in search config + // ensure in place modification to reuse .toString method that ensures quoting + if (ref.list) { // list of columns + e.xpr = [{ ref: e.ref }, fuzzy] + delete e.ref + } else { + e.__proto__.xpr = [{ ref: e.ref }, fuzzy] + delete e.__proto__.ref + } + }) } else { - fuzzyString = `${ref} FUZZY MINIMAL TOKEN SCORE ${fuzzyIndex} SIMILARITY CALCULATION MODE 'search'` + ref = `${ref} FUZZY MINIMAL TOKEN SCORE ${fuzzyIndex} SIMILARITY CALCULATION MODE 'search'` } @@ -56,7 +62,7 @@ const StandardFunctions = { // REVISIT: remove once the protocol adapter only creates vals if (Array.isArray(arg.xpr)) arg = { val: arg.xpr.filter(a => a.val).map(a => a.val).join(' ') } - return (`(CASE WHEN SCORE(${arg} IN ${fuzzyString}) > 0 THEN TRUE ELSE FALSE END)`) + return (`(CASE WHEN SCORE(${arg} IN ${ref}) > 0 THEN TRUE ELSE FALSE END)`) }, // Date and Time Functions From 046910950651ac14eff1d02fdac927838322f676 Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Tue, 19 Nov 2024 16:48:21 +0100 Subject: [PATCH 04/19] search2like fallback --- db-service/lib/cqn4sql.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 8c1532fb3..c6c29cba2 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -2194,6 +2194,27 @@ function cqn4sql(originalQuery, model) { const entity = query.SELECT.from.SELECT ? query.SELECT.from : query.target const searchIn = computeColumnsToBeSearched(inferred, entity) if (searchIn.length > 0) { + if (cds.env.hana.fuzzy === false) { + // REVISIT: remove once the protocol adapter only creates vals + if (Array.isArray(search.xpr)) search = [{ val: search.xpr.filter(a => a.val).map(a => a.val).join(' ') }] + const searchTerms = search[0].val + .match(/("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/g) + .filter(el => el.length).map(el => `%${el.replace(/^\"|\"$/g, '').toLowerCase()}%`) + + const columns = searchIn + const xpr = [] + for (const s of searchTerms) { + const nestedXpr = [] + for (const c of columns) { + if (nestedXpr.length) nestedXpr.push('or') + nestedXpr.push({ func: 'lower', args: [c]}, 'like', {val: s}) + } + if (xpr.length) xpr.push('and') + xpr.push({xpr: nestedXpr}) + } + + return { xpr } + } const xpr = search const searchFunc = { func: 'search', From 90dd0fa9e90af1f6558967b25aa9a184ad8fb34c Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Thu, 21 Nov 2024 17:21:54 +0100 Subject: [PATCH 05/19] enhance fuzzy tests --- hana/test/fuzzy.cds | 6 ++- hana/test/fuzzy.test.js | 79 +++++++++++++++++++++++++++++++------ test/bookshop/db/schema.cds | 3 +- 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/hana/test/fuzzy.cds b/hana/test/fuzzy.cds index 977c4458a..ce8418f34 100644 --- a/hana/test/fuzzy.cds +++ b/hana/test/fuzzy.cds @@ -1 +1,5 @@ -using {sap.capire.bookshop.Books as Books} from '../../test/bookshop/db/schema.cds'; +using {sap.capire.bookshop.BooksAnnotated as BooksAnnotated} from '../../test/bookshop/db/schema.cds'; + +annotate BooksAnnotated with @cds.search: {title, descr, currency.code}; +annotate BooksAnnotated:title with @(Search.ranking: HIGH, Search.fuzzinessThreshold: 0.9); +annotate BooksAnnotated:descr with @(Search.ranking: LOW, Search.fuzzinessThreshold: 0.6); \ No newline at end of file diff --git a/hana/test/fuzzy.test.js b/hana/test/fuzzy.test.js index 857717c8f..bed2e2183 100644 --- a/hana/test/fuzzy.test.js +++ b/hana/test/fuzzy.test.js @@ -3,17 +3,74 @@ const cds = require('../../test/cds') describe('Fuzzy search', () => { const { expect } = cds.test(__dirname, 'fuzzy.cds') - test('select', async () => { + beforeEach (() => { + delete cds.env.hana.fuzzy + }) + + test('default', async () => { + const { Books } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(Books).search('"autobio"').columns('1') + const {sql} = cqn.toSQL() + expect(sql).to.include('FUZZY MINIMAL TOKEN SCORE 0.7') + const res = await cqn + expect(res.length).to.be(2) // Eleonora and Jane Eyre + }) + + test('multiple search terms', async () => { + const { Books } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(Books).search('"autobio" "jane"').columns('1') + const {sql, values} = cqn.toSQL() + expect(sql).to.include('FUZZY MINIMAL TOKEN SCORE 0.7') + expect(values[0]).to.eq('"autobio" "jane"') // taken as is + const res = await cqn + expect(res.length).to.be(2) // Eleonora and Jane Eyre + }) + + test('global config', async () => { + cds.env.hana.fuzzy = 1 + const { Books } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(Books).search('"autobio"').columns('1') + const {sql} = cqn.toSQL() + expect(sql).to.include('FUZZY MINIMAL TOKEN SCORE 1') + const res = await cqn + expect(res.length).to.be(2) // Eleonora and Jane Eyre + }) + + test('annotations', async () => { + const { BooksAnnotated } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(BooksAnnotated).search('"heights"').columns('1') + const {sql} = cqn.toSQL() + expect(sql).to.include('title FUZZY WEIGHT 0.8 MINIMAL TOKEN SCORE 0.9') + expect(sql).to.include('code FUZZY WEIGHT 0.5 MINIMAL TOKEN SCORE 0.7') + expect(sql).to.include('descr FUZZY WEIGHT 0.3 MINIMAL TOKEN SCORE 0.6') + + const res = await SELECT.from(BooksAnnotated).search('"heights"') + expect(res[0].title).to.eq('Wuthering Heights') + }) + + + test('fallback - 1 search term', async () => { + cds.env.hana.fuzzy = false + const { Books } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(Books).search('"autobio"').columns('1') + const {sql} = cqn.toSQL() + // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code + expect(sql.match(/(like)/g).length).to.be(5) + const res = await cqn + expect(res.length).to.be(2) // Eleonora and Jane Eyre + }) + + test('fallback - 2 search terms', async () => { + cds.env.hana.fuzzy = false const { Books } = cds.entities('sap.capire.bookshop') - const res = await SELECT.from(Books).where({ - func: 'contains', - args: [ - { list: [{ ref: ['title'] }, { ref: ['descr'] }] }, - { val: 'poem' }, - { func: 'FUZZY', args: [{ val: 0.8 }, { val: 'similarCalculationMode=searchCompare' }] } - ] - }) - - expect(res).to.have.property('length').to.be.eq(1) + const cqn = SELECT.from(Books).search('"autobio"', '"Jane"').columns('1') + const {sql, values} = cqn.toSQL() + // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code + expect(sql.match(/(like)/g).length).to.be(10) + expect(values).to.include('%autobio%') + expect(values).to.include('%jane%') + const res = await cqn + expect(res.length).to.be(1) // Jane Eyre }) + // TODO ODATA SEARCH OLD NEW }) \ No newline at end of file diff --git a/test/bookshop/db/schema.cds b/test/bookshop/db/schema.cds index f00593075..72931bd71 100644 --- a/test/bookshop/db/schema.cds +++ b/test/bookshop/db/schema.cds @@ -67,4 +67,5 @@ entity C : managed { B : Integer; toB : Composition of many B on toB.ID = $self.B; -} +}; +entity BooksAnnotated as projection on Books; \ No newline at end of file From 4eb2be108d13652a67288354be4ecac4ebf2a128 Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Thu, 21 Nov 2024 17:22:23 +0100 Subject: [PATCH 06/19] fix fallback --- db-service/lib/cqn4sql.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index c6c29cba2..fd1c425a5 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -2196,7 +2196,8 @@ function cqn4sql(originalQuery, model) { if (searchIn.length > 0) { if (cds.env.hana.fuzzy === false) { // REVISIT: remove once the protocol adapter only creates vals - if (Array.isArray(search.xpr)) search = [{ val: search.xpr.filter(a => a.val).map(a => a.val).join(' ') }] + search = search.xpr ? search.xpr : search + if (Array.isArray(search)) search = [{ val: search.filter(a => a.val).map(a => a.val).join(' ') }] const searchTerms = search[0].val .match(/("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/g) .filter(el => el.length).map(el => `%${el.replace(/^\"|\"$/g, '').toLowerCase()}%`) From 1c7b550960fa146c249c21ede7436979b941d2de Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Thu, 21 Nov 2024 17:26:43 +0100 Subject: [PATCH 07/19] cleanup --- hana/test/fuzzy.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/hana/test/fuzzy.test.js b/hana/test/fuzzy.test.js index bed2e2183..8852bee37 100644 --- a/hana/test/fuzzy.test.js +++ b/hana/test/fuzzy.test.js @@ -72,5 +72,4 @@ describe('Fuzzy search', () => { const res = await cqn expect(res.length).to.be(1) // Jane Eyre }) - // TODO ODATA SEARCH OLD NEW }) \ No newline at end of file From 917e424c78b58ea1890dd63f76170eb3d17c277f Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 10:17:05 +0100 Subject: [PATCH 08/19] reorder tests --- hana/test/fuzzy.test.js | 126 ++++++++++++++++++++-------------------- 1 file changed, 64 insertions(+), 62 deletions(-) diff --git a/hana/test/fuzzy.test.js b/hana/test/fuzzy.test.js index 8852bee37..bf874ef80 100644 --- a/hana/test/fuzzy.test.js +++ b/hana/test/fuzzy.test.js @@ -1,75 +1,77 @@ const cds = require('../../test/cds') -describe('Fuzzy search', () => { +describe('search', () => { const { expect } = cds.test(__dirname, 'fuzzy.cds') beforeEach (() => { delete cds.env.hana.fuzzy }) - test('default', async () => { - const { Books } = cds.entities('sap.capire.bookshop') - const cqn = SELECT.from(Books).search('"autobio"').columns('1') - const {sql} = cqn.toSQL() - expect(sql).to.include('FUZZY MINIMAL TOKEN SCORE 0.7') - const res = await cqn - expect(res.length).to.be(2) // Eleonora and Jane Eyre - }) - - test('multiple search terms', async () => { - const { Books } = cds.entities('sap.capire.bookshop') - const cqn = SELECT.from(Books).search('"autobio" "jane"').columns('1') - const {sql, values} = cqn.toSQL() - expect(sql).to.include('FUZZY MINIMAL TOKEN SCORE 0.7') - expect(values[0]).to.eq('"autobio" "jane"') // taken as is - const res = await cqn - expect(res.length).to.be(2) // Eleonora and Jane Eyre - }) - - test('global config', async () => { - cds.env.hana.fuzzy = 1 - const { Books } = cds.entities('sap.capire.bookshop') - const cqn = SELECT.from(Books).search('"autobio"').columns('1') - const {sql} = cqn.toSQL() - expect(sql).to.include('FUZZY MINIMAL TOKEN SCORE 1') - const res = await cqn - expect(res.length).to.be(2) // Eleonora and Jane Eyre - }) - - test('annotations', async () => { - const { BooksAnnotated } = cds.entities('sap.capire.bookshop') - const cqn = SELECT.from(BooksAnnotated).search('"heights"').columns('1') - const {sql} = cqn.toSQL() - expect(sql).to.include('title FUZZY WEIGHT 0.8 MINIMAL TOKEN SCORE 0.9') - expect(sql).to.include('code FUZZY WEIGHT 0.5 MINIMAL TOKEN SCORE 0.7') - expect(sql).to.include('descr FUZZY WEIGHT 0.3 MINIMAL TOKEN SCORE 0.6') - - const res = await SELECT.from(BooksAnnotated).search('"heights"') - expect(res[0].title).to.eq('Wuthering Heights') - }) - + describe('fuzzy', () => { + test('default', async () => { + const { Books } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(Books).search('"autobio"').columns('1') + const {sql} = cqn.toSQL() + expect(sql).to.include('FUZZY MINIMAL TOKEN SCORE 0.7') + const res = await cqn + expect(res.length).to.be(2) // Eleonora and Jane Eyre + }) + + test('multiple search terms', async () => { + const { Books } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(Books).search('"autobio" "jane"').columns('1') + const {sql, values} = cqn.toSQL() + expect(sql).to.include('FUZZY MINIMAL TOKEN SCORE 0.7') + expect(values[0]).to.eq('"autobio" "jane"') // taken as is + const res = await cqn + expect(res.length).to.be(2) // Eleonora and Jane Eyre + }) + + test('global config', async () => { + cds.env.hana.fuzzy = 1 + const { Books } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(Books).search('"autobio"').columns('1') + const {sql} = cqn.toSQL() + expect(sql).to.include('FUZZY MINIMAL TOKEN SCORE 1') + const res = await cqn + expect(res.length).to.be(2) // Eleonora and Jane Eyre + }) - test('fallback - 1 search term', async () => { - cds.env.hana.fuzzy = false - const { Books } = cds.entities('sap.capire.bookshop') - const cqn = SELECT.from(Books).search('"autobio"').columns('1') - const {sql} = cqn.toSQL() - // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code - expect(sql.match(/(like)/g).length).to.be(5) - const res = await cqn - expect(res.length).to.be(2) // Eleonora and Jane Eyre + test('annotations', async () => { + const { BooksAnnotated } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(BooksAnnotated).search('"heights"').columns('1') + const {sql} = cqn.toSQL() + expect(sql).to.include('title FUZZY WEIGHT 0.8 MINIMAL TOKEN SCORE 0.9') + expect(sql).to.include('code FUZZY WEIGHT 0.5 MINIMAL TOKEN SCORE 0.7') + expect(sql).to.include('descr FUZZY WEIGHT 0.3 MINIMAL TOKEN SCORE 0.6') + + const res = await SELECT.from(BooksAnnotated).search('"heights"') + expect(res[0].title).to.eq('Wuthering Heights') + }) }) - test('fallback - 2 search terms', async () => { - cds.env.hana.fuzzy = false - const { Books } = cds.entities('sap.capire.bookshop') - const cqn = SELECT.from(Books).search('"autobio"', '"Jane"').columns('1') - const {sql, values} = cqn.toSQL() - // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code - expect(sql.match(/(like)/g).length).to.be(10) - expect(values).to.include('%autobio%') - expect(values).to.include('%jane%') - const res = await cqn - expect(res.length).to.be(1) // Jane Eyre + describe('like', () => { + beforeEach (() => cds.env.hana.fuzzy = false) + test('fallback - 1 search term', async () => { + const { Books } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(Books).search('"autobio"').columns('1') + const {sql} = cqn.toSQL() + // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code + expect(sql.match(/(like)/g).length).to.be(5) + const res = await cqn + expect(res.length).to.be(2) // Eleonora and Jane Eyre + }) + + test('fallback - 2 search terms', async () => { + const { Books } = cds.entities('sap.capire.bookshop') + const cqn = SELECT.from(Books).search('"autobio"', '"Jane"').columns('1') + const {sql, values} = cqn.toSQL() + // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code + expect(sql.match(/(like)/g).length).to.be(10) + expect(values).to.include('%autobio%') + expect(values).to.include('%jane%') + const res = await cqn + expect(res.length).to.be(1) // Jane Eyre + }) }) }) \ No newline at end of file From 45a2759d5483e18ca83976cc69eb6d76d9b06b0e Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 10:34:39 +0100 Subject: [PATCH 09/19] move hana fallback --- db-service/lib/cqn4sql.js | 22 ---------------------- hana/lib/cql-functions.js | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 2cd695d2b..739597abf 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -2199,28 +2199,6 @@ function cqn4sql(originalQuery, model) { const entity = query.SELECT.from.SELECT ? query.SELECT.from : query.target const searchIn = computeColumnsToBeSearched(inferred, entity) if (searchIn.length > 0) { - if (cds.env.hana.fuzzy === false) { - // REVISIT: remove once the protocol adapter only creates vals - search = search.xpr ? search.xpr : search - if (Array.isArray(search)) search = [{ val: search.filter(a => a.val).map(a => a.val).join(' ') }] - const searchTerms = search[0].val - .match(/("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/g) - .filter(el => el.length).map(el => `%${el.replace(/^\"|\"$/g, '').toLowerCase()}%`) - - const columns = searchIn - const xpr = [] - for (const s of searchTerms) { - const nestedXpr = [] - for (const c of columns) { - if (nestedXpr.length) nestedXpr.push('or') - nestedXpr.push({ func: 'lower', args: [c]}, 'like', {val: s}) - } - if (xpr.length) xpr.push('and') - xpr.push({xpr: nestedXpr}) - } - - return { xpr } - } const xpr = search const searchFunc = { func: 'search', diff --git a/hana/lib/cql-functions.js b/hana/lib/cql-functions.js index 88cf85e36..3f094c4ff 100644 --- a/hana/lib/cql-functions.js +++ b/hana/lib/cql-functions.js @@ -24,6 +24,31 @@ const StandardFunctions = { contains: (...args) => args.length > 2 ? `CONTAINS(${args})` : `(CASE WHEN coalesce(locate(${args}),0)>0 THEN TRUE ELSE FALSE END)`, concat: (...args) => `(${args.map(a => (a.xpr ? `(${a})` : a)).join(' || ')})`, search: function (ref, arg) { + if (cds.env.hana.fuzzy === false) { + // REVISIT: remove once the protocol adapter only creates vals + arg = arg.xpr ? arg.xpr : arg + if (Array.isArray(arg)) arg = [{ val: arg.filter(a => a.val).map(a => a.val).join(' ') }] + else arg = [arg] + const searchTerms = arg[0].val + .match(/("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/g) + .filter(el => el.length).map(el => `%${el.replace(/^\"|\"$/g, '').toLowerCase()}%`) + + const columns = ref.element ? [ref] : ref.list + const xpr = [] + for (const s of searchTerms) { + const nestedXpr = [] + for (const c of columns) { + if (nestedXpr.length) nestedXpr.push('or') + nestedXpr.push({ func: 'lower', args: [c]}, 'like', {val: s}) + } + if (xpr.length) xpr.push('and') + xpr.push({xpr: nestedXpr}) + } + + const { toString } = ref + return `(CASE WHEN (${toString({ xpr })}) THEN TRUE ELSE FALSE END)` + } + // fuzziness config const fuzzyIndex = cds.env.hana?.fuzzy || 0.7 From 09c308990a17d2f2da9a4a73b1648d9d499b266a Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 10:48:54 +0100 Subject: [PATCH 10/19] skip failing test on HXE --- hana/test/fuzzy.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hana/test/fuzzy.test.js b/hana/test/fuzzy.test.js index bf874ef80..c7377fcad 100644 --- a/hana/test/fuzzy.test.js +++ b/hana/test/fuzzy.test.js @@ -17,7 +17,8 @@ describe('search', () => { expect(res.length).to.be(2) // Eleonora and Jane Eyre }) - test('multiple search terms', async () => { + //HCE returns different result than HXE + test.skip('multiple search terms', async () => { const { Books } = cds.entities('sap.capire.bookshop') const cqn = SELECT.from(Books).search('"autobio" "jane"').columns('1') const {sql, values} = cqn.toSQL() From a65ab70ba844d8d42b56b45e762590f16cd124c8 Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 11:16:39 +0100 Subject: [PATCH 11/19] rm index --- hana/lib/cql-functions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hana/lib/cql-functions.js b/hana/lib/cql-functions.js index 3f094c4ff..1e0b802d1 100644 --- a/hana/lib/cql-functions.js +++ b/hana/lib/cql-functions.js @@ -55,7 +55,7 @@ const StandardFunctions = { const csnElements = ref.element ? [ref] : ref.list // if column specific value is provided, the configuration has to be defined on column level if (csnElements.some(e => e.element?.['@Search.ranking'] || e.element?.['@Search.fuzzinessThreshold'])) { - csnElements.forEach((e,i) => { + csnElements.forEach(e => { let fuzzy = `FUZZY` // weighted search From d5366fc85cb1e2da6678178a8b62da6fe880780e Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 11:17:11 +0100 Subject: [PATCH 12/19] rm blanks --- hana/lib/cql-functions.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/hana/lib/cql-functions.js b/hana/lib/cql-functions.js index 1e0b802d1..5551cbb43 100644 --- a/hana/lib/cql-functions.js +++ b/hana/lib/cql-functions.js @@ -82,8 +82,6 @@ const StandardFunctions = { ref = `${ref} FUZZY MINIMAL TOKEN SCORE ${fuzzyIndex} SIMILARITY CALCULATION MODE 'search'` } - - // REVISIT: remove once the protocol adapter only creates vals if (Array.isArray(arg.xpr)) arg = { val: arg.xpr.filter(a => a.val).map(a => a.val).join(' ') } From afc5c73f10a04f4976cfdfc02dcec8a3fc543379 Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 11:20:16 +0100 Subject: [PATCH 13/19] always provide list of elements for search --- db-service/lib/cqn4sql.js | 2 +- hana/lib/cql-functions.js | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 739597abf..57b68f7a6 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -2203,7 +2203,7 @@ function cqn4sql(originalQuery, model) { const searchFunc = { func: 'search', args: [ - searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] }, + { list: searchIn }, xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr }, ], } diff --git a/hana/lib/cql-functions.js b/hana/lib/cql-functions.js index 5551cbb43..9682ec960 100644 --- a/hana/lib/cql-functions.js +++ b/hana/lib/cql-functions.js @@ -33,7 +33,7 @@ const StandardFunctions = { .match(/("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/g) .filter(el => el.length).map(el => `%${el.replace(/^\"|\"$/g, '').toLowerCase()}%`) - const columns = ref.element ? [ref] : ref.list + const columns = ref.list const xpr = [] for (const s of searchTerms) { const nestedXpr = [] @@ -52,7 +52,7 @@ const StandardFunctions = { // fuzziness config const fuzzyIndex = cds.env.hana?.fuzzy || 0.7 - const csnElements = ref.element ? [ref] : ref.list + const csnElements = ref.list // if column specific value is provided, the configuration has to be defined on column level if (csnElements.some(e => e.element?.['@Search.ranking'] || e.element?.['@Search.fuzzinessThreshold'])) { csnElements.forEach(e => { @@ -70,13 +70,8 @@ const StandardFunctions = { // rewrite ref to xpr to mix in search config // ensure in place modification to reuse .toString method that ensures quoting - if (ref.list) { // list of columns - e.xpr = [{ ref: e.ref }, fuzzy] - delete e.ref - } else { - e.__proto__.xpr = [{ ref: e.ref }, fuzzy] - delete e.__proto__.ref - } + e.xpr = [{ ref: e.ref }, fuzzy] + delete e.ref }) } else { ref = `${ref} FUZZY MINIMAL TOKEN SCORE ${fuzzyIndex} SIMILARITY CALCULATION MODE 'search'` From 7e8a6962afc6c8eba16cae5cc025c9e108208b73 Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 11:34:37 +0100 Subject: [PATCH 14/19] fix review comments --- hana/lib/cql-functions.js | 19 ++++++++++++++----- test/cds.js | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/hana/lib/cql-functions.js b/hana/lib/cql-functions.js index 9682ec960..0f7aa005e 100644 --- a/hana/lib/cql-functions.js +++ b/hana/lib/cql-functions.js @@ -60,13 +60,22 @@ const StandardFunctions = { // weighted search const rank = e.element?.['@Search.ranking']?.['='] - if(rank === 'HIGH') fuzzy += ' WEIGHT 0.8' - else if(rank === 'LOW') fuzzy += ' WEIGHT 0.3' - else fuzzy += ' WEIGHT 0.5' // MEDIUM + switch(rank) { + case 'HIGH': + fuzzy += ' WEIGHT 0.8' + break + case 'LOW': + fuzzy += ' WEIGHT 0.3' + break + case 'MEDIUM': + case undefined: + fuzzy += ' WEIGHT 0.5' + break + default: throw new Error(`Invalid configuration ${rank} for @Search.ranking. HIGH, MEDIUM, LOW are supported values.`) + } // fuzziness - fuzzy+= ` MINIMAL TOKEN SCORE ${e.element?.['@Search.fuzzinessThreshold'] || fuzzyIndex}` - fuzzy+= " SIMILARITY CALCULATION MODE 'search'" + fuzzy+= ` MINIMAL TOKEN SCORE ${e.element?.['@Search.fuzzinessThreshold'] || fuzzyIndex} SIMILARITY CALCULATION MODE 'search'` // rewrite ref to xpr to mix in search config // ensure in place modification to reuse .toString method that ensures quoting diff --git a/test/cds.js b/test/cds.js index f003d482c..c0807d7c8 100644 --- a/test/cds.js +++ b/test/cds.js @@ -72,7 +72,7 @@ cds.test = Object.setPrototypeOf(function () { hash.update(isolateName) ret.data.isolation = isolate = { // Create one database for each overall test execution - database: process.env.TRAVIS_JOB_ID || process.env.GITHUB_RUN_ID || require('os').userInfo().username || 'test_db', + database: process.env.TRAVIS_JOB_ID || process.env.GITHUB_RUN_ID || require('os').userInfo().username.repeat(6) || 'test_db', // Create one tenant for each test suite tenant: 'T' + hash.digest('hex'), } From b850105230d3987761647bf8c2d2cf505f1f3594 Mon Sep 17 00:00:00 2001 From: Johannes Vogel <31311694+johannes-vogel@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:34:59 +0100 Subject: [PATCH 15/19] align test structure Co-authored-by: Bob den Os <108393871+BobdenOs@users.noreply.github.com> --- hana/test/fuzzy.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hana/test/fuzzy.test.js b/hana/test/fuzzy.test.js index c7377fcad..bdac49e79 100644 --- a/hana/test/fuzzy.test.js +++ b/hana/test/fuzzy.test.js @@ -46,8 +46,8 @@ describe('search', () => { expect(sql).to.include('code FUZZY WEIGHT 0.5 MINIMAL TOKEN SCORE 0.7') expect(sql).to.include('descr FUZZY WEIGHT 0.3 MINIMAL TOKEN SCORE 0.6') - const res = await SELECT.from(BooksAnnotated).search('"heights"') - expect(res[0].title).to.eq('Wuthering Heights') + const res = await SELECT.one.from(BooksAnnotated).search('"heights"') + expect(res.length).to.be(1) // wuthering heights }) }) From 412ddc0874096a53cb080d1bfbfc8181a2b4094b Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 11:35:45 +0100 Subject: [PATCH 16/19] fix --- hana/test/fuzzy.cds | 2 +- hana/test/fuzzy.test.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/hana/test/fuzzy.cds b/hana/test/fuzzy.cds index ce8418f34..56e22edda 100644 --- a/hana/test/fuzzy.cds +++ b/hana/test/fuzzy.cds @@ -2,4 +2,4 @@ using {sap.capire.bookshop.BooksAnnotated as BooksAnnotated} from '../../test/bo annotate BooksAnnotated with @cds.search: {title, descr, currency.code}; annotate BooksAnnotated:title with @(Search.ranking: HIGH, Search.fuzzinessThreshold: 0.9); -annotate BooksAnnotated:descr with @(Search.ranking: LOW, Search.fuzzinessThreshold: 0.6); \ No newline at end of file +annotate BooksAnnotated:descr with @(Search.ranking: LOW, Search.fuzzinessThreshold: 0.9); \ No newline at end of file diff --git a/hana/test/fuzzy.test.js b/hana/test/fuzzy.test.js index bdac49e79..a2ae4f103 100644 --- a/hana/test/fuzzy.test.js +++ b/hana/test/fuzzy.test.js @@ -40,14 +40,14 @@ describe('search', () => { test('annotations', async () => { const { BooksAnnotated } = cds.entities('sap.capire.bookshop') - const cqn = SELECT.from(BooksAnnotated).search('"heights"').columns('1') + const cqn = SELECT.from(BooksAnnotated).search('"first-person"').columns('1') const {sql} = cqn.toSQL() expect(sql).to.include('title FUZZY WEIGHT 0.8 MINIMAL TOKEN SCORE 0.9') expect(sql).to.include('code FUZZY WEIGHT 0.5 MINIMAL TOKEN SCORE 0.7') - expect(sql).to.include('descr FUZZY WEIGHT 0.3 MINIMAL TOKEN SCORE 0.6') + expect(sql).to.include('descr FUZZY WEIGHT 0.3 MINIMAL TOKEN SCORE 0.9') - const res = await SELECT.one.from(BooksAnnotated).search('"heights"') - expect(res.length).to.be(1) // wuthering heights + const res = await cqn + expect(res.length).to.be(1) // jane eyre }) }) From b34cc5ece9fe37a06b11a95e1e71d9ede81b04f4 Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 12:16:33 +0100 Subject: [PATCH 17/19] the internal "search" cqn function only accepts list of columns now --- db-service/lib/cql-functions.js | 2 +- test/compliance/SELECT.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/db-service/lib/cql-functions.js b/db-service/lib/cql-functions.js index 0165278e5..6a7206119 100644 --- a/db-service/lib/cql-functions.js +++ b/db-service/lib/cql-functions.js @@ -33,7 +33,7 @@ const StandardFunctions = { val = sub[2] || sub[3] || '' } arg.val = arg.__proto__.val = val - const refs = ref.list || [ref] + const refs = ref.list const { toString } = ref return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)), this.tolower(arg))).join(' or ') + ')' }, diff --git a/test/compliance/SELECT.test.js b/test/compliance/SELECT.test.js index 49625ded7..5083391fd 100644 --- a/test/compliance/SELECT.test.js +++ b/test/compliance/SELECT.test.js @@ -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 = CQL`SELECT * FROM ${string} WHERE search((string),${'yes'})` + const cqn = SELECT.from(string).where([{func: 'search', args: [{list: [{ref: ['string']}]}, {val: 'yes'}]}]) await cds.run(cqn) }) @@ -994,7 +994,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: [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 => { From a537472fa78b7d6a79b54f957e8c1df50f4bb5f4 Mon Sep 17 00:00:00 2001 From: Johannes Vogel <31311694+johannes-vogel@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:17:53 +0100 Subject: [PATCH 18/19] revert --- test/cds.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cds.js b/test/cds.js index c0807d7c8..f003d482c 100644 --- a/test/cds.js +++ b/test/cds.js @@ -72,7 +72,7 @@ cds.test = Object.setPrototypeOf(function () { hash.update(isolateName) ret.data.isolation = isolate = { // Create one database for each overall test execution - database: process.env.TRAVIS_JOB_ID || process.env.GITHUB_RUN_ID || require('os').userInfo().username.repeat(6) || 'test_db', + database: process.env.TRAVIS_JOB_ID || process.env.GITHUB_RUN_ID || require('os').userInfo().username || 'test_db', // Create one tenant for each test suite tenant: 'T' + hash.digest('hex'), } From d6d783eb34e4a329007b7df683dd1ae3420c137a Mon Sep 17 00:00:00 2001 From: Johannes Vogel Date: Fri, 22 Nov 2024 13:07:14 +0100 Subject: [PATCH 19/19] fix cqn4sql tests --- db-service/test/cqn4sql/search.test.js | 105 ++++++++++++++----------- 1 file changed, 58 insertions(+), 47 deletions(-) diff --git a/db-service/test/cqn4sql/search.test.js b/db-service/test/cqn4sql/search.test.js index be44de4d0..7904150d7 100644 --- a/db-service/test/cqn4sql/search.test.js +++ b/db-service/test/cqn4sql/search.test.js @@ -16,9 +16,8 @@ describe('Replace attribute search by search predicate', () => { let res = cqn4sql(query, model) // single val is stored as val directly, not as expr with val - const expected = CQL`SELECT from bookshop.WithStructuredKey as wsk { - wsk.second - } where search(wsk.second, 'x')` + const expected = CQL`SELECT from bookshop.WithStructuredKey as wsk { wsk.second }` + expected.SELECT.where = [ {func: 'search', args: [{ list: [{ ref: ['wsk', 'second']}] }, {val: 'x'}]}] expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -28,9 +27,8 @@ describe('Replace attribute search by search predicate', () => { query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] let res = cqn4sql(query, model) - const expected = CQL`SELECT from bookshop.WithStructuredKey as wsk { - wsk.second - } where search(wsk.second, ('x' OR 'y'))` + const expected = CQL`SELECT from bookshop.WithStructuredKey as wsk { wsk.second }` + expected.SELECT.where = [ {func: 'search', args: [{ list: [{ ref: ['wsk', 'second']}] }, {xpr: [{val: 'x'}, 'or', {val: 'y'}]}]}] expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -109,16 +107,16 @@ describe('Replace attribute search by search predicate', () => { query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] let res = cqn4sql(query, model) - expect(JSON.parse(JSON.stringify(res))).to.deep.equal( - CQL` - SELECT from bookshop.Books as Books - left join bookshop.Authors as author on author.ID = Books.author_ID - left join bookshop.Books as books2 on books2.author_ID = author.ID - { - Books.ID, - books2.title as authorsBook - } where search(books2.title, ('x' OR 'y')) group by Books.title `, - ) + const expected = CQL` + SELECT from bookshop.Books as Books + left join bookshop.Authors as author on author.ID = Books.author_ID + left join bookshop.Books as books2 on books2.author_ID = author.ID + { + Books.ID, + books2.title as authorsBook + } where search(books2.title, ('x' OR 'y')) group by Books.title ` + expected.SELECT.where = [ {func: 'search', args: [{ list: [{ ref: ['books2', 'title']}] }, {xpr: [{val: 'x'}, 'or', {val: 'y'}]}]}] + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) it('Search on navigation', () => { let query = CQL`SELECT from bookshop.Authors:books { ID }` @@ -147,11 +145,12 @@ describe('Replace attribute search by search predicate', () => { .columns({ args: [{ ref: ['title'] }], as: 'firstInAlphabet', func: 'MIN' }) .groupBy('title') .search('Cat') - - expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(CQL` - SELECT from bookshop.Books as Books { - MIN(Books.title) as firstInAlphabet - } group by Books.title having search(MIN(Books.title), 'Cat')`) + const expected = CQL` + SELECT from bookshop.Books as Books { + MIN(Books.title) as firstInAlphabet + } group by Books.title having search(MIN(Books.title), 'Cat')` + expected.SELECT.having = [ {func: 'search', args: [{ list: [{func: 'MIN', args: [{ ref: ['Books', 'title']}]}] }, {val: 'Cat'}]}] + expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(expected) }) it('Ignore non string aggregates from being searched', () => { @@ -163,12 +162,13 @@ describe('Replace attribute search by search predicate', () => { ` query.SELECT.search = [{ val: 'x' }] - - expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(CQL` - SELECT from bookshop.Books as Books { - Books.title, - AVG(Books.stock) as searchRelevant, - } where search(Books.title, 'x') group by Books.title`) + const expected = CQL` + SELECT from bookshop.Books as Books { + Books.title, + AVG(Books.stock) as searchRelevant, + } where search(Books.title, 'x') group by Books.title` + expected.SELECT.where = [ {func: 'search', args: [{ list: [{ ref: ['Books', 'title']}] }, {val: 'x'}]}] + expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(expected) }) it('aggregations which are not of type string are not searched', () => { const query = CQL` @@ -197,12 +197,16 @@ describe('Replace attribute search by search predicate', () => { ` query.SELECT.search = [{ val: 'x' }] - - expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(CQL` - SELECT from bookshop.Books as Books { - Books.ID, - substring(Books.stock) as searchRelevantViaCast: cds.String, - } group by Books.title having search(substring(Books.stock), 'x')`) + const expected = CQL` + SELECT from bookshop.Books as Books { + Books.ID, + substring(Books.stock) as searchRelevantViaCast: cds.String, + } group by Books.title having search(substring(Books.stock), 'x')` + expected.SELECT.having = [ {func: 'search', args: [{ list: [{ + args: [ { ref: [ 'Books', 'stock' ] } ], + func: 'substring' + }] }, {val: 'x'}]}] + expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(expected) }) it('xpr is search relevant via cast', () => { // this aggregation is not relevant for search per default @@ -216,13 +220,21 @@ describe('Replace attribute search by search predicate', () => { ` query.SELECT.search = [{ val: 'x' }] - - expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(CQL` - SELECT from bookshop.Books as Books { - Books.ID, - ('very' + 'useful' + 'string') as searchRelevantViaCast: cds.String, - ('1' + '2' + '3') as notSearchRelevant: cds.Integer, - } group by Books.title having search(('very' + 'useful' + 'string'), 'x')`) + const expected = CQL` + SELECT from bookshop.Books as Books { + Books.ID, + ('very' + 'useful' + 'string') as searchRelevantViaCast: cds.String, + ('1' + '2' + '3') as notSearchRelevant: cds.Integer, + } group by Books.title` + expected.SELECT.having = [ {func: 'search', args: [{ list: [{ + xpr: [ + { val: 'very' }, + '+', + { val: 'useful' }, + '+', + { val: 'string' } + ] }] }, {val: 'x'}]}] + expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(expected) }) }) @@ -242,7 +254,8 @@ describe('search w/ path expressions', () => { { BooksSearchAuthorName.ID, BooksSearchAuthorName.title - } where search(author.lastName, 'x')` + }` + expected.SELECT.where = [ {func: 'search', args: [{ list: [{ref: ['author', 'lastName']}]}, {val: 'x'}]}] expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -286,7 +299,8 @@ describe('search w/ path expressions', () => { { BookShelf.ID, BookShelf.genre - } where search((BookShelf.genre), 'Harry Plotter')` + }` + expected.SELECT.where = [ {func: 'search', args: [{ list: [{ref: ['BookShelf', 'genre']}]}, {val: 'Harry Plotter'}]}] expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) }) @@ -316,11 +330,8 @@ describe('calculated elements', () => { query.SELECT.search = [{ val: 'x' }] let res = cqn4sql(query, model) - const expected = CQL` - SELECT from search.CalculatedAddressesWithoutAnno as Address - { - Address.ID - } where search((Address.city), 'x')` + const expected = CQL`SELECT from search.CalculatedAddressesWithoutAnno as Address { Address.ID }` + expected.SELECT.where = [ {func: 'search', args: [{ list: [{ref: ['Address', 'city']}]}, {val: 'x'}]}] expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) })