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
13 changes: 9 additions & 4 deletions db-service/lib/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,26 @@ const _getSearchableColumns = entity => {
if (key.startsWith(cdsSearchTerm)) cdsSearchKeys.push(key)
}

let atLeastOneColumnIsSearchable = false
let skipDefaultSearchableElements = 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()
const annotationKey = `${cdsSearchTerm}.${columnName}`
const annotationValue = entity[annotationKey]
if (annotationValue) atLeastOneColumnIsSearchable = true

const column = entity.elements[columnName]
// always ignore virtual elements from search
if(column?.virtual) continue
if (column?.isAssociation || columnName.includes('.')) {
deepSearchCandidates.push({ ref: columnName.split('.') })
const ref = columnName.split('.')
if(ref.length > 1) skipDefaultSearchableElements = true
deepSearchCandidates.push({ ref })
continue
}

if(annotationValue) skipDefaultSearchableElements = true
cdsSearchColumnMap.set(columnName, annotationValue)
}

Expand All @@ -99,7 +104,7 @@ const _getSearchableColumns = entity => {
// 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
if (atLeastOneColumnIsSearchable) return false
if (skipDefaultSearchableElements) return false

// the element is considered searchable if it is explicitly annotated as such or
// if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
Expand Down
25 changes: 23 additions & 2 deletions db-service/test/bookshop/db/search.cds
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,42 @@ entity NoSearchCandidateProjected as select from PathInSearchNotProjected {
ID
};

// search through all searchable fields in the author
// search all own searchable fields + those in `Authors`
@cds.search: {author}
entity BooksSearchAuthor : Books {}

entity Authors {
key ID : Integer;
lastName : String;
firstName : String;
books : Association to Books
books : Composition of many Books
on books.author = $self;
}

// search all searchable fields in `Books` + `Genres:name` via `AuthorSearchBooks:books`
@cds.search: {books, books.genre.name}
entity AuthorSearchBooks : Authors {
}

// search only `books.title`
@cds.search: {books.title}
entity AuthorSearchOnlyBooksTitle : Authors {}

// search only `description` (default searchable elements of `Books` are skipped)
@cds.search: {description}
entity BooksSearchOnlyDescription : Books {
description : String;
}

entity BooksIgnoreVirtualElement : Books {
virtual virtualElement : String;
}

@cds.search: { virtualElement: true }
entity BooksIgnoreExplicitVirtualElement : Books {
virtual virtualElement : String;
}

// search over multiple associations
@cds.search: {authorWithAddress}
entity BooksSearchAuthorAndAddress : Books {
Expand All @@ -60,6 +80,7 @@ entity AuthorsSearchAddresses : Authors {
address : Association to Addresses;
}

// exclude specific elements from search
@cds.search: {street: false}
entity Addresses {
key ID : Integer;
Expand Down
144 changes: 110 additions & 34 deletions db-service/test/cqn4sql/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,41 +62,30 @@ describe('Replace attribute search by search predicate', () => {

let res = cqn4sql(query, model)
// todo, not necessary to add the search predicate as xpr
const expected = { SELECT: {
columns: [ { ref: [ 'Genres', 'ID' ] } ],
from: { as: 'Genres', ref: [ 'bookshop.Genres' ] },
const expected = {
SELECT: {
columns: [{ ref: ['Genres', 'ID'] }],
from: { as: 'Genres', ref: ['bookshop.Genres'] },
where: [
{
xpr: [
{
args: [
{
list: [
{ ref: [ 'Genres', 'name' ] },
{ ref: [ 'Genres', 'descr' ] },
{ ref: [ 'Genres', 'code' ] }
]
list: [{ ref: ['Genres', 'name'] }, { ref: ['Genres', 'descr'] }, { ref: ['Genres', 'code'] }],
},
{ xpr: [ { val: 'x' }, 'or', { val: 'y' } ] }
{ xpr: [{ val: 'x' }, 'or', { val: 'y' }] },
],
func: 'search'
}
]
func: 'search',
},
],
},
'and',
{
xpr: [
{ ref: [ 'Genres', 'ID' ] },
'<',
{ val: 4 },
'or',
{ ref: [ 'Genres', 'ID' ] },
'>',
{ val: 5 }
]
}
]
}
xpr: [{ ref: ['Genres', 'ID'] }, '<', { val: 4 }, 'or', { ref: ['Genres', 'ID'] }, '>', { val: 5 }],
},
],
},
}
expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected)
})
Expand Down Expand Up @@ -168,9 +157,7 @@ describe('Replace attribute search by search predicate', () => {
where $A.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'))`
expect(JSON.parse(JSON.stringify(res))).to.deep.equal(
expected,
)
expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected)
})
it('Search with aggregated column and groupby must be put into having', () => {
// if we search on aggregated results, the search must be put into the having clause
Expand Down Expand Up @@ -311,7 +298,7 @@ describe('search w/ path expressions', () => {
expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected)
})

it('search all searchable fields in target', () => {
it('search all searchable fields in target + all own', () => {
let query = cds.ql`SELECT from search.BooksSearchAuthor as Books { ID, title }`
query.SELECT.search = [{ val: 'x' }]

Expand All @@ -325,7 +312,7 @@ describe('search w/ path expressions', () => {
SELECT from search.BooksSearchAuthor as $B left join search.Authors as author on author.ID = $B.author_ID
{
$B.ID
} where search((author.lastName, author.firstName), 'x')
} where search(($B.title, author.lastName, author.firstName), 'x')
)`
expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected)
})
Expand All @@ -346,9 +333,9 @@ describe('search w/ path expressions', () => {
left join search.Addresses as address on address.ID = authorWithAddress.address_ID
{
$B.ID
} where search((authorWithAddress.note, address.city), 'x')
} where search(($B.title, authorWithAddress.note, address.city), 'x')
)`

expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected)
})

Expand Down Expand Up @@ -424,7 +411,7 @@ describe('calculated elements', () => {
{
Address.ID
} where search(Address.city, 'x')`

expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected)
})
})
Expand All @@ -451,14 +438,14 @@ describe('caching searchable fields', () => {
left join search.Authors as author on author.ID = $B.author_ID
{
$B.ID
} where search((author.lastName, author.firstName), 'x')
} where search(($B.title, 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'] }])
.that.eqls([{ ref: ['title'] }, { ref: ['author', 'lastName'] }, { ref: ['author', 'firstName'] }])

let secondRun = cqn4sql(query, model)
expect(JSON.parse(JSON.stringify(secondRun))).to.deep.equal(expected)
Expand Down Expand Up @@ -494,3 +481,92 @@ describe('caching searchable fields', () => {
expect(JSON.parse(JSON.stringify(secondRun))).to.deep.equal(expected)
})
})

describe('include / exclude logic', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review the newly added tests

let model
beforeAll(async () => {
model = cds.model = cds.compile.for.nodejs(await cds.load(`${__dirname}/../bookshop/db/search`).then(cds.linked))
})

it('associations in search annotation are additive', () => {
const query = cds.ql`SELECT from search.BooksSearchAuthorAndAddress as Books { ID, title }`
query.SELECT.search = [{ val: 'x' }]
const transformed = cqn4sql(query, model)
const expected = cds.ql`
SELECT from search.BooksSearchAuthorAndAddress as Books {
Books.ID,
Books.title
} where Books.ID in (
SELECT from search.BooksSearchAuthorAndAddress as $B
left join search.AuthorsSearchAddresses as authorWithAddress on authorWithAddress.ID = $B.authorWithAddress_ID
left join search.Addresses as address on address.ID = authorWithAddress.address_ID
{
$B.ID
} where search(($B.title, authorWithAddress.note, address.city), 'x')
)`
expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected)
})

it('including own element invalidates default searchable elements', () => {
const query = cds.ql`SELECT from search.BooksSearchOnlyDescription as Books { ID, description, title }`
query.SELECT.search = [{ val: 'x' }]
const transformed = cqn4sql(query, model)
const expected = cds.ql`
SELECT from search.BooksSearchOnlyDescription as Books {
Books.ID,
Books.description,
Books.title
} where search(Books.description, 'x')`
expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected)
})

it('including element via path expression invalidates default searchable elements', () => {
const query = cds.ql`SELECT from search.AuthorSearchOnlyBooksTitle as A { ID }`
query.SELECT.search = [{ val: 'x' }]
const transformed = cqn4sql(query, model)
const expected = cds.ql`
SELECT from search.AuthorSearchOnlyBooksTitle as A {
A.ID
} where A.ID in (
SELECT from search.AuthorSearchOnlyBooksTitle as $A
left join search.Books as books on books.author_ID = $A.ID
{
$A.ID
} where search(books.title, 'x')
)`
expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected)
})

it('excluding an element from default searchable elements', () => {
const query = cds.ql`SELECT from search.Addresses as Addresses { ID }`
query.SELECT.search = [{ val: 'x' }]
const transformed = cqn4sql(query, model)
const expected = cds.ql`
SELECT from search.Addresses as Addresses {
Addresses.ID
} where search(Addresses.city, 'x')`
expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected)
})

it('virtual elements are ignored', () => {
const query = cds.ql`SELECT from search.BooksIgnoreVirtualElement as Books { ID }`
query.SELECT.search = [{ val: 'x' }]
const transformed = cqn4sql(query, model)
const expected = cds.ql`
SELECT from search.BooksIgnoreVirtualElement as Books {
Books.ID
} where search(Books.title, 'x')`
expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected)
})

it('virtual elements are ignored even if explicitly listed', () => {
const query = cds.ql`SELECT from search.BooksIgnoreExplicitVirtualElement as Books { ID }`
query.SELECT.search = [{ val: 'x' }]
const transformed = cqn4sql(query, model)
const expected = cds.ql`
SELECT from search.BooksIgnoreExplicitVirtualElement as Books {
Books.ID
} where search(Books.title, 'x')`
expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected)
})
})
15 changes: 15 additions & 0 deletions sqlite/test/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,5 +150,20 @@ describe('searching', () => {
const res = await cds.run(query)
expect(res.length).to.be.eq(2)
})

test('search also own columns if association is part of `@cds.search`', async () => {
const { Books } = cds.entities
// ad-hoc search expression
Books['@cds.search.author'] = true

// matches the title
let res = await SELECT.from(Books).columns('author.name', 'title').search('Wuthering')
expect(res.length).to.be.eq(1)
expect(res[0].title).to.be.eq('Wuthering Heights')

res = await SELECT.from(Books).columns('author.name', 'title').search('Emily')
expect(res.length).to.be.eq(1)
expect(res[0].title).to.be.eq('Wuthering Heights')
})
})
})