diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index f4091e3e5..42d4abcca 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -1,8 +1,6 @@ 'use strict' const cds = require('@sap/cds/lib') -const { computeColumnsToBeSearched } = require('./search') - const infer = require('./infer') /** @@ -57,10 +55,10 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) { if (!inferred.STREAM?.from && inferred.STREAM?.into) { transformedQuery = transformStreamQuery() } else { - const { entity, where } = queryProp + const { entity } = queryProp const from = queryProp.from - const transformedProp = { __proto__: queryProp } // IMPORTANT: don't lose anything you might not know of + const { where } = queryProp // Transform the existing where, prepend table aliases, and so on... if (where) { @@ -69,10 +67,17 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) { // Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries. // The already transformed `where` clause is then glued together with the resulting subqueries. - const { transformedWhere, transformedFrom } = getTransformedFrom(from || entity, transformedProp.where) + let { transformedWhere, transformedFrom } = getTransformedFrom(from || entity, transformedProp.where) const queryNeedsJoins = inferred.joinTree && !inferred.joinTree.isInitial if (inferred.SELECT) { + if (inferred.SELECT.search && inferred.SELECT.search.searchTerm) { + const searchTerm = getTransformedTokenStream([inferred.SELECT.search.searchTerm]) + if(transformedWhere.length) + transformedWhere = [asXpr(transformedWhere), 'and', ...searchTerm] + else + transformedWhere = searchTerm + } transformedQuery = transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery) } else { if (from) { @@ -182,14 +187,6 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) { transformedQuery.SELECT.orderBy = transformedOrderBy } } - - if (inferred.SELECT.search) { - // Search target can be a navigation, in that case use _target to get the correct entity - const where = transformSearchToWhere(inferred.SELECT.search, transformedFrom) - if (where) { - transformedQuery.SELECT.where = where - } - } return transformedQuery } @@ -230,41 +227,6 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) { return transformedQuery } - /** - * Transforms a search expression to a WHERE clause for a SELECT operation. - * - * @param {object} search - The search expression which shall be applied to the searchable columns on the query source. - * @param {object} from - The FROM clause of the CQN statement. - * - * @returns {(Object|Array|undefined)} - If the target of the query contains searchable elements, the function returns an array that represents the WHERE clause. - * If the SELECT query already contains a WHERE clause, this array includes the existing clause and appends an AND condition with the new 'contains' clause. - * If the SELECT query does not contain a WHERE clause, the returned array solely consists of the 'contains' clause. - * If the target entity of the query does not contain searchable elements, the function returns null. - * - */ - function transformSearchToWhere(search, from) { - const entity = from.$refLinks[0].definition._target || from.$refLinks[0].definition - const searchIn = computeColumnsToBeSearched(inferred, entity, from.as) - if (searchIn.length > 0) { - const xpr = search - const contains = { - func: 'search', - args: [ - searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] }, - xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr }, - ], - } - - if (transformedQuery.SELECT.where) { - return [asXpr(transformedQuery.SELECT.where), 'and', contains] - } else { - return [contains] - } - } else { - return null - } - } - /** * Rewrites the from clause based on the `query.joinTree`. * @@ -1734,10 +1696,10 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) { if (rhs?.ref || lhs.ref) { // if we have refs on each side of the comparison, we might need to perform tuple expansion // or flatten the structures - const refLinkFaker = thing => { - const { ref } = thing + const adHocRefLinks = column => { + const { ref } = column const assocHost = getParentEntity(assocRefLink.definition) - Object.defineProperty(thing, '$refLinks', { + Object.defineProperty(column, '$refLinks', { value: [], writable: true, }) @@ -1747,7 +1709,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) { return prev const definition = prev?.elements?.[res] || prev?._target?.elements[res] || pseudos.elements[res] const target = getParentEntity(definition) - thing.$refLinks[i] = { definition, target, alias: definition.name } + column.$refLinks[i] = { definition, target, alias: definition.name } return prev?.elements?.[res] || prev?._target?.elements[res] || pseudos.elements[res] }, assocHost) } @@ -1755,8 +1717,8 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) { // comparison in on condition needs to be expanded... // re-use existing algorithm for that // we need to fake some $refLinks for that to work though... - lhs?.ref && !lhs.$refLinks && refLinkFaker(lhs) - rhs?.ref && !rhs.$refLinks && refLinkFaker(rhs) + lhs?.ref && !lhs.$refLinks && adHocRefLinks(lhs) + rhs?.ref && !rhs.$refLinks && adHocRefLinks(rhs) } let backlink diff --git a/db-service/lib/infer/index.js b/db-service/lib/infer/index.js index ce5ab3f59..f0a184f85 100644 --- a/db-service/lib/infer/index.js +++ b/db-service/lib/infer/index.js @@ -2,6 +2,8 @@ const cds = require('@sap/cds/lib') +const { computeColumnsToBeSearched } = require('../search') + const JoinTree = require('./join-tree') const { pseudos } = require('./pseudos') const cdsTypes = cds.linked({ @@ -278,7 +280,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) { */ function inferQueryElements($combinedElements) { let queryElements = {} - const { columns, where, groupBy, having, orderBy } = _ + const { columns, where, groupBy, having, orderBy, search } = _ if (!columns) { inferElementsFromWildCard(aliases) } else { @@ -347,6 +349,16 @@ function infer(originalQuery, model = cds.context?.model || cds.model) { if (_.with) // consider UPDATE.with Object.values(_.with).forEach(val => inferQueryElement(val, false)) + if (search) { + const searchTerm = getSearchTerm(inferred.SELECT.search, inferred.SELECT.from, inferred.SELECT.where) + if (searchTerm) { + searchTerm.args.forEach(arg => inferQueryElement(arg, false)) + Object.defineProperty(search, 'searchTerm', { + writable: true, + value: searchTerm, + }) + } + } return queryElements @@ -1130,6 +1142,34 @@ function infer(originalQuery, model = cds.context?.model || cds.model) { return res !== '' ? res + dot + cur.definition.name : cur.definition.name }, '') } + /** + * For a given search expression return a function "search" which holds the search expression + * as well as the searchable columns as arguments. + * + * @param {object} search - The search expression which shall be applied to the searchable columns on the query source. + * @param {object} from - The FROM clause of the CQN statement. + * + * @returns {(Object|null)} returns either: + * - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression. + * - or null, if no searchable columns are found in neither in `@cds.search` or in the target entity itself. + */ + function getSearchTerm(search, from) { + const entity = from.$refLinks.at(-1).definition._target || from.$refLinks.at(-1).definition + const searchIn = computeColumnsToBeSearched(inferred, entity, from.as) + if (searchIn.length > 0) { + const xpr = search + const contains = { + func: 'search', + args: [ + searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] }, + xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr }, + ], + } + return contains + } else { + return null + } + } } const idOnly = ref => ref.id || ref diff --git a/db-service/lib/search.js b/db-service/lib/search.js index 89fc101ba..6ed094091 100644 --- a/db-service/lib/search.js +++ b/db-service/lib/search.js @@ -1,13 +1,5 @@ 'use strict' -const DRAFT_COLUMNS_UNION = { - IsActiveEntity: 1, - HasActiveEntity: 1, - HasDraftEntity: 1, - DraftAdministrativeData_DraftUUID: 1, - SiblingEntity: 1, - DraftAdministrativeData: 1, -} const DEFAULT_SEARCHABLE_TYPE = 'cds.String' /** @@ -26,20 +18,16 @@ const DEFAULT_SEARCHABLE_TYPE = 'cds.String' */ const getColumns = ( entity, - { onlyNames = false, removeIgnore = false, filterDraft = true, filterVirtual = false, keysOnly = false }, + { removeIgnore = false, filterVirtual = false}, ) => { - const skipDraft = filterDraft && entity._isDraftEnabled const columns = [] const elements = entity.elements for (const each in elements) { const element = elements[each] - if (element.isAssociation) continue if (filterVirtual && element.virtual) continue if (removeIgnore && element['@cds.api.ignore']) continue - if (skipDraft && each in DRAFT_COLUMNS_UNION) continue - if (keysOnly && !element.key) continue - columns.push(onlyNames ? each : element) + columns.push(element) } return columns @@ -63,20 +51,21 @@ const _getSearchableColumns = entity => { } let atLeastOneColumnIsSearchable = false + const deepSearchCandidates = [] // build a map of columns annotated with the @cds.search annotation for (const key of cdsSearchKeys) { const columnName = key.split(cdsSearchTerm + '.').pop() - // REVISIT: for now, exclude search using path expression, as deep search is not currently - // supported - if (columnName.includes('.')) { - continue - } - const annotationKey = `${cdsSearchTerm}.${columnName}` const annotationValue = entity[annotationKey] if (annotationValue) atLeastOneColumnIsSearchable = true + const column = entity.elements[columnName] + + if (column?.isAssociation || columnName.includes('.')) { + deepSearchCandidates.push({ ref: columnName.split('.') }) + continue; + } cdsSearchColumnMap.set(columnName, annotationValue) } @@ -87,6 +76,9 @@ const _getSearchableColumns = entity => { // `@cds.search { element1: true }` or `@cds.search { element1 }` if (annotatedColumnValue) return true + // calculated elements are only searchable if requested through `@cds.search` + if(column.value) return false + // if at least one element is explicitly annotated as searchable, e.g.: // `@cds.search { element1: true }` or `@cds.search { element1 }` // and it is not the current column name, then it must be excluded from the search @@ -101,15 +93,29 @@ const _getSearchableColumns = entity => { ) }) - // if the @cds.search annotation is provided --> - // Early return to ignore the interpretation of the @Search.defaultSearchElement - // annotation when an entity is annotated with the @cds.search annotation. - // The @cds.search annotation overrules the @Search.defaultSearchElement annotation. - if (cdsSearchKeys.length > 0) { - return searchableColumns.map(column => column.name) + if (deepSearchCandidates.length) { + deepSearchCandidates.forEach(c => { + const element = c.ref.reduce((resolveIn, curr, i) => { + const next = resolveIn.elements?.[curr] || resolveIn._target.elements[curr] + if (next.isAssociation && !c.ref[i + 1]) { + const searchInTarget = _getSearchableColumns(next._target) + searchInTarget.forEach(elementRefInTarget => { + searchableColumns.push({ ref: c.ref.concat(...elementRefInTarget.ref) }) + }) + } + return next + }, entity) + if (element?.type === DEFAULT_SEARCHABLE_TYPE) { + searchableColumns.push({ ref: c.ref }) + } + }) } - return searchableColumns.map(column => column.name) + return searchableColumns.map(column => { + if(column.ref) + return column + return { ref: [ column.name ] } + }) } /** @@ -121,35 +127,11 @@ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, a // aggregations case // in the new parser groupBy is moved to sub select. if (cqn._aggregated || /* new parser */ cqn.SELECT.groupBy || cqn.SELECT?.from?.SELECT?.groupBy) { - cqn.SELECT.columns && - cqn.SELECT.columns.forEach(column => { - if (column.func) { - // exclude $count by SELECT of number of Items in a Collection - if ( - cqn.SELECT.columns.length === 1 && - column.func === 'count' && - (column.as === '_counted_' || column.as === '$count') - ) { - return - } - - toBeSearched.push(column) - return - } - - const columnRef = column.ref - if (columnRef) { - if (entity.elements[columnRef[columnRef.length - 1]]?._type !== DEFAULT_SEARCHABLE_TYPE) return - column = { ref: [...column.ref] } - if (alias) column.ref.unshift(alias) - toBeSearched.push(column) - } - }) + // REVISIT: No search for aggregation case for the moment } else { toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity)) - if (cqn.SELECT.groupBy) toBeSearched = toBeSearched.filter(tbs => cqn.SELECT.groupBy.some(gb => gb.ref[0] === tbs)) toBeSearched = toBeSearched.map(c => { - const col = { ref: [c] } + const col = {ref: [...c.ref]} if (alias) col.ref.unshift(alias) return col }) diff --git a/db-service/test/bookshop/db/schema.cds b/db-service/test/bookshop/db/schema.cds index 2afc210e6..78e8d7f80 100644 --- a/db-service/test/bookshop/db/schema.cds +++ b/db-service/test/bookshop/db/schema.cds @@ -392,9 +392,9 @@ entity PartialStructuredKey { toSelf: Association to PartialStructuredKey { struct.one as partial} } - entity Reproduce { - key ID : Integer; - title : String(5000); - author : Association to Authors; - accessGroup : Composition of AccessGroups; - } +entity Reproduce { + key ID : Integer; + title : String(5000); + author : Association to Authors; + accessGroup : Composition of AccessGroups; +} diff --git a/db-service/test/bookshop/db/search.cds b/db-service/test/bookshop/db/search.cds new file mode 100644 index 000000000..b2f2a69a0 --- /dev/null +++ b/db-service/test/bookshop/db/search.cds @@ -0,0 +1,75 @@ +namespace search; + +entity Books { + key ID : Integer; + title: String; + + author : Association to Authors; + coAuthor_ID_unmanaged: Integer; + coAuthorUnmanaged: Association to Authors on coAuthorUnmanaged.ID = coAuthor_ID_unmanaged; +} + +@cds.search: { + author.lastName +} +entity BooksSearchAuthorName: Books {} + +// search through all searchable fields in the author +@cds.search: { author } +entity BooksSearchAuthor: Books {} + +entity Authors { + key ID : Integer; + lastName: String; + firstName: String; + books: Association to Books on books.author = $self; +} + +// search over multiple associations +@cds.search: { authorWithAddress } +entity BooksSearchAuthorAndAddress: Books { + authorWithAddress: Association to AuthorsSearchAddresses; +} + +@cds.search: { + address, + note +} +entity AuthorsSearchAddresses : Authors { + note: String; + address: Association to Addresses; +} + +@cds.search: { + street: false +} +entity Addresses { + key ID: Integer; + street: String; + city: String; + zip: Integer; +} + +// search with calculated elements + +@cds.search: { + address, + note +} +entity AuthorsSearchCalculatedAddress : Authors { + note: String; + address: Association to CalculatedAddresses; +} + +@cds.search: { + city: false, + calculatedAddress: true +} +entity CalculatedAddresses : Addresses { + calculatedAddress: String = street || ' ' || zip || '' || city +} + +// calculated elements are not searchable by default +entity CalculatedAddressesWithoutAnno : Addresses { + calculatedAddress: String = street || ' ' || zip || '' || city +} diff --git a/db-service/test/cqn4sql/search.test.js b/db-service/test/cqn4sql/search.test.js index 9d4b0ec97..beead244c 100644 --- a/db-service/test/cqn4sql/search.test.js +++ b/db-service/test/cqn4sql/search.test.js @@ -4,94 +4,94 @@ const cds = require('@sap/cds/lib') const { expect } = cds.test describe('Replace attribute search by search predicate', () => { - let model - beforeAll(async () => { - model = cds.model = cds.compile.for.nodejs(await cds.load(`${__dirname}/../bookshop/db/schema`).then(cds.linked)) - }) - - it('one string element with one search element', () => { - // WithStructuredKey is the only entity with only one string element in the model ... - let query = CQL`SELECT from bookshop.WithStructuredKey as wsk { second }` - query.SELECT.search = [{ val: 'x' }] - - 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 { + describe('basic search', () => { + let model + beforeAll(async () => { + model = cds.model = cds.compile.for.nodejs(await cds.load(`${__dirname}/../bookshop/db/schema`).then(cds.linked)) + }) + it('one string element with one search element', () => { + // WithStructuredKey is the only entity with only one string element in the model ... + let query = CQL`SELECT from bookshop.WithStructuredKey as wsk { second }` + query.SELECT.search = [{ val: 'x' }] + + 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')` - expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) - }) + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) + }) - it('one string element', () => { - // WithStructuredKey is the only entity with only one string element in the model ... - let query = CQL`SELECT from bookshop.WithStructuredKey as wsk { second }` - query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] + it('one string element', () => { + // WithStructuredKey is the only entity with only one string element in the model ... + let query = CQL`SELECT from bookshop.WithStructuredKey as wsk { second }` + query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] - let res = cqn4sql(query, model) - const expected = CQL`SELECT from bookshop.WithStructuredKey as wsk { + let res = cqn4sql(query, model) + const expected = CQL`SELECT from bookshop.WithStructuredKey as wsk { wsk.second } where search(wsk.second, ('x' OR 'y'))` - expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) - }) + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) + }) - it('multiple string elements', () => { - let query = CQL`SELECT from bookshop.Genres { ID }` - query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] + it('multiple string elements', () => { + let query = CQL`SELECT from bookshop.Genres { ID }` + 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.Genres as Genres { + let res = cqn4sql(query, model) + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(CQL`SELECT from bookshop.Genres as Genres { Genres.ID } where search((Genres.name, Genres.descr, Genres.code), ('x' OR 'y'))`) - }) + }) - it('with existing WHERE clause', () => { - let query = CQL`SELECT from bookshop.Genres { ID } where ID < 4 or ID > 5` - query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] + it('with existing WHERE clause', () => { + let query = CQL`SELECT from bookshop.Genres { ID } where ID < 4 or ID > 5` + 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.Genres as Genres { + let res = cqn4sql(query, model) + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(CQL`SELECT from bookshop.Genres as Genres { Genres.ID } where (Genres.ID < 4 or Genres.ID > 5) and search((Genres.name, Genres.descr, Genres.code), ('x' OR 'y'))`) - }) + }) - it('with filter on data source', () => { - let query = CQL`SELECT from bookshop.Genres[ID < 4 or ID > 5] { ID }` - query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] + it('with filter on data source', () => { + let query = CQL`SELECT from bookshop.Genres[ID < 4 or ID > 5] { ID }` + 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.Genres as Genres { + let res = cqn4sql(query, model) + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(CQL`SELECT from bookshop.Genres as Genres { Genres.ID } where (Genres.ID < 4 or Genres.ID > 5) and search((Genres.name, Genres.descr, Genres.code), ('x' OR 'y'))`) - }) + }) - it('string fields inside struct', () => { - let query = CQL`SELECT from bookshop.Person { ID }` - query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] + it('string fields inside struct', () => { + let query = CQL`SELECT from bookshop.Person { ID }` + 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.Person as Person { + let res = cqn4sql(query, model) + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(CQL`SELECT from bookshop.Person as Person { Person.ID } where search((Person.name, Person.placeOfBirth, Person.placeOfDeath, Person.address_street, Person.address_city), ('x' OR 'y'))`) - }) + }) - it('ignores virtual string elements', () => { - let query = CQL`SELECT from bookshop.Foo { ID }` - query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] + it('ignores virtual string elements', () => { + let query = CQL`SELECT from bookshop.Foo { ID }` + 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.Foo as Foo { + let res = cqn4sql(query, model) + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(CQL`SELECT from bookshop.Foo as Foo { Foo.ID }`) - }) - it('Uses primary query source in case of joins', () => { - let query = CQL`SELECT from bookshop.Books { ID, author.books.title as authorsBook }` - query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] - - let res = cqn4sql(query, model) - expect(JSON.parse(JSON.stringify(res))).to.deep.equal( - CQL` + }) + it('Uses primary query source in case of joins', () => { + let query = CQL`SELECT from bookshop.Books { ID, author.books.title as authorsBook }` + 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 @@ -99,25 +99,145 @@ describe('Replace attribute search by search predicate', () => { Books.ID, books2.title as authorsBook } where search((Books.createdBy, Books.modifiedBy, Books.anotherText, Books.title, Books.descr, Books.currency_code, Books.dedication_text, Books.dedication_sub_foo, Books.dedication_dedication), ('x' OR 'y')) `, - ) - }) - it('Search on navigation', () => { - let query = CQL`SELECT from bookshop.Authors:books { ID }` - query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] - - let res = cqn4sql(query, model) - expect(JSON.parse(JSON.stringify(res))).to.deep.equal( - CQL` + ) + }) + it('Search on navigation', () => { + let query = CQL`SELECT from bookshop.Authors:books { ID }` + 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 { books.ID, } where ( - exists( + exists ( SELECT 1 from bookshop.Authors as Authors where Authors.ID = books.author_ID ) - ) and - search((books.createdBy, books.modifiedBy, books.anotherText, books.title, books.descr, books.currency_code, books.dedication_text, books.dedication_sub_foo, books.dedication_dedication), ('x' OR 'y')) `, - ) + ) + and + search((books.createdBy, books.modifiedBy, books.anotherText, books.title, books.descr, books.currency_code, books.dedication_text, books.dedication_sub_foo, books.dedication_dedication), ('x' OR 'y'))`, + ) + }) + }) + + describe('search w/ path expressions', () => { + let model + beforeAll(async () => { + model = cds.model = cds.compile.for.nodejs(await cds.load(`${__dirname}/../bookshop/db/search`).then(cds.linked)) + }) + + it('one string element with one search element', () => { + let query = CQL`SELECT from search.BooksSearchAuthorName { ID, title }` + query.SELECT.search = [{ val: 'x' }] + + let res = cqn4sql(query, model) + const expected = CQL` + SELECT from search.BooksSearchAuthorName as BooksSearchAuthorName left join search.Authors as author on author.ID = BooksSearchAuthorName.author_ID + { + BooksSearchAuthorName.ID, + BooksSearchAuthorName.title + } where search(author.lastName, 'x')` + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) + }) + + it('search all searchable fields in target', () => { + let query = CQL`SELECT from search.BooksSearchAuthor as Books { ID, title }` + query.SELECT.search = [{ val: 'x' }] + + let res = cqn4sql(query, model) + const expected = CQL` + SELECT from search.BooksSearchAuthor as Books left join search.Authors as author on author.ID = Books.author_ID + { + Books.ID, + Books.title + } where search((author.lastName, author.firstName), 'x')` + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) + }) + + it('search only some searchable fields via multiple association paths', () => { + let query = CQL`SELECT from search.BooksSearchAuthorAndAddress as Books { ID, title }` + query.SELECT.search = [{ val: 'x' }] + + let res = cqn4sql(query, model) + const expected = CQL` + SELECT from search.BooksSearchAuthorAndAddress as Books + left join search.AuthorsSearchAddresses as authorWithAddress on authorWithAddress.ID = Books.authorWithAddress_ID + left join search.Addresses as address on address.ID = authorWithAddress.address_ID + { + Books.ID, + Books.title + } where search((authorWithAddress.note, address.city), 'x')` + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) + }) + }) + + describe('calculated elements', () => { + let model + beforeAll(async () => { + model = cds.model = cds.compile.for.nodejs(await cds.load(`${__dirname}/../bookshop/db/search`).then(cds.linked)) + }) + + it('search calculated element via path expression', () => { + let query = CQL`SELECT from search.AuthorsSearchCalculatedAddress as Authors { lastName }` + query.SELECT.search = [{ val: 'x' }] + + let res = cqn4sql(query, model) + const expected = CQL` + SELECT from search.AuthorsSearchCalculatedAddress as Authors + left join search.CalculatedAddresses as address on address.ID = Authors.address_ID + { + Authors.lastName + } where search((Authors.note, (address.street || ' ' || address.zip || '' || address.city)), 'x')` + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) + }) + + it('search calculated element only if explicitly requested', () => { + let query = CQL`SELECT from search.CalculatedAddressesWithoutAnno as Address { Address.ID }` + 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')` + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) + }) + }) + + describe('search with aggregation', () => { + // TODO + }) + + describe('caching searchable fields', () => { + let model + beforeAll(async () => { + model = cds.model = cds.compile.for.nodejs(await cds.load(`${__dirname}/../bookshop/db/search`).then(cds.linked)) + }) + + it('search all searchable fields in target', () => { + let query = CQL`SELECT from search.BooksSearchAuthor as Books { ID, title }` + query.SELECT.search = [{ val: 'x' }] + + let res = cqn4sql(query, model) + const expected = CQL` + SELECT from search.BooksSearchAuthor as Books left join search.Authors as author on author.ID = Books.author_ID + { + Books.ID, + Books.title + } where search((author.lastName, author.firstName), 'x')` + + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) + // test caching + expect(model.definitions['search.BooksSearchAuthor']) + .to.have.property('__searchableColumns') + .that.eqls([{ ref: ['author', 'lastName'] }, { ref: ['author', 'firstName'] }]) + + let secondRun = cqn4sql(query, model) + expect(JSON.parse(JSON.stringify(secondRun))).to.deep.equal(expected) + }) }) })