From 16cb5571d1a6533fecc87b84c3ec1a57ad25eba7 Mon Sep 17 00:00:00 2001 From: Patrice Bender Date: Thu, 27 Aug 2026 12:14:33 +0200 Subject: [PATCH 1/6] fix: correlate $search ranking ORDER BY to the outer row The deep-search ranking ORDER BY reused the search expression without binding it to the outer query row, so the sub-select produced a `key IN (key)` tautology instead of a correlation (the TODO in cqn4sql). Defer the ranking ORDER BY to after infer(), where the outer alias is known, and correlate the score sub-select to the outer row post-transform (mirroring expand's _correlate). A deep search fans one outer row out to many joined child rows, so wrap the score in MAX() to keep the scalar sub-select single-valued and rank by the best-matching child. Also emit the numeric flag as the proper CQN literal `{ val: true }`, and make the HANA fuzzy `search` renderer idempotent: it rewrites annotated columns in place (ref -> xpr), which crashed when the same search() args are rendered twice (WHERE predicate + injected ranking ORDER BY). Verified end-to-end against a real HANA: deep to-many $search ranks by best match, de-duplicated; fuzzy suite green. --- db-service/lib/cqn4sql.js | 93 +++++++++-- db-service/test/cqn4sql/search.test.js | 223 ++++++++++++++++++++----- hana/lib/cql-functions.js | 8 +- hana/test/fuzzy.test.js | 13 +- hana/test/search-ranking.cds | 25 +++ hana/test/search-ranking.test.js | 55 ++++++ 6 files changed, 357 insertions(+), 60 deletions(-) create mode 100644 hana/test/search-ranking.cds create mode 100644 hana/test/search-ranking.test.js diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 1e032622d..220a6dd36 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -82,17 +82,9 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) { const { where, having } = transformSearch(searchTerm) if (where) inferred.SELECT.where = where else if (having) inferred.SELECT.having = having - if (searchTerm.func) (inferred.SELECT.orderBy ??= []).unshift({ func: searchTerm.func, args: [...searchTerm.args, true], sort: 'desc' }) - else if (searchTerm.xpr) { - const searchSelect = searchTerm.xpr[2] - const searchFunc = searchSelect.SELECT.where[0] - ; (inferred.SELECT.orderBy ??= []).unshift({ - __proto__: SELECT.from(searchSelect.SELECT.from) - .columns({ func: searchFunc.func, args: [...searchFunc.args, true] }) - .where([searchTerm.xpr[0], 'in', { list: searchSelect.SELECT.columns }]), // TODO: <-- ensure that the sub select in the order by is bound to the original query result row - sort: 'desc' - }) - } + // Defer the ranking ORDER BY to after infer(), where the outer table alias is known and the + // deep-search sub-select can be correlated to the outer row (see buildSearchRankOrderBy). + defineProperty(inferred, '$searchRank', searchTerm) } } // query modifiers can also be defined in from ref leaf infix filter @@ -341,9 +333,17 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) { // Since all the expressions in the SELECT part of the query have been computed, // one can reference aliases of the queries columns in the orderBy clause. - if (orderBy) { - const transformedOrderBy = getTransformedOrderByGroupBy(orderBy, true) + let effectiveOrderBy = orderBy + // Rank by $search relevance. Prepended here (post-infer) so the deep-search scalar sub-select + // can be correlated to the OUTER row, whose alias is only known now. + const searchRank = inferred.$searchRank && buildSearchRankOrderBy(inferred.$searchRank) + if (searchRank) effectiveOrderBy = [searchRank, ...(orderBy || [])] + if (effectiveOrderBy) { + const transformedOrderBy = getTransformedOrderByGroupBy(effectiveOrderBy, true) if (transformedOrderBy.length) { + // correlate the (now transformed) deep-search ranking sub-select to the outer row + if (searchRank?.$searchRank && transformedOrderBy[0].SELECT) + correlateSearchRank(transformedOrderBy[0], transformedFrom.as) transformedQuery.SELECT.orderBy = transformedOrderBy } } @@ -2608,6 +2608,73 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) { return { xpr: [matchColumns.length === 1 ? matchColumns[0] : { list: matchColumns }, 'in', subquery] } } + /** + * Builds the ORDER BY entry ranking rows by $search relevance, sorted desc. + * + * Flat search: the score is computed on the outer row itself, so the entry is just the + * search() func with the numeric flag (`true`) appended. + * + * Deep search: the score lives in a semi-join sub-select, so we emit a CORRELATED scalar + * sub-select that selects the numeric score and binds its inner key(s) to the outer row: + * (SELECT search(, , true) FROM WHERE innerKey = .key) DESC + * + * The correlation cannot be expressed by pre-qualifying the outer key here: the sub-select's + * source is the same entity as the outer query, so infer() would re-resolve an outer-qualified + * ref back to the sub-select's own source. Instead we build both sides of each key comparison + * unqualified (they resolve to the sub-select's own alias) and mark the entry as `$searchRank`, + * so it can be correlated to the outer alias AFTER transformation (see correlateSearchRank) — + * the same "rewrite the inner alias to the outer alias" trick used by expand's _correlate. + * + * @param {object} searchTerm the search term as returned by getSearch (func or xpr shape) + * @returns {object|null} an orderBy entry, or null if there is nothing to rank by + */ + function buildSearchRankOrderBy(searchTerm) { + if (searchTerm.func) return { func: searchTerm.func, args: [...searchTerm.args, { val: true }], sort: 'desc' } + if (!searchTerm.xpr) return null + + const searchSelect = searchTerm.xpr[2] + const searchFunc = searchSelect.SELECT.where[0] + const innerKeys = searchSelect.SELECT.columns // unqualified pk refs, e.g. [{ ref: ['ID'] }] + + const where = [] + for (let i = 0; i < innerKeys.length; i++) { + if (i) where.push('and') + // both sides unqualified -> resolve to the sub-select's own alias; the right-hand side is + // rewired to the outer alias post-transform in correlateSearchRank + where.push({ ref: [...innerKeys[i].ref] }, '=', { ref: [...innerKeys[i].ref] }) + } + + const entry = { + __proto__: SELECT.from(searchSelect.SELECT.from) + // one correlated outer row fans out to many joined child rows -> MAX collapses them to a + // single value so the scalar sub-select is well-defined: rank by the best-matching score + .columns({ func: 'max', args: [{ func: searchFunc.func, args: [...searchFunc.args, { val: true }] }] }) + .where(where), + sort: 'desc', + } + defineProperty(entry, '$searchRank', true) + return entry + } + + /** + * Correlates the deep-search ranking sub-select to the outer row, after it has been transformed. + * + * The transformed sub-select's WHERE is a chain of `innerKey = innerKey` comparisons, both sides + * resolved to the sub-select's own leading source alias. This rewrites the right-hand side of each + * comparison to `.`, turning the tautology into a correlation to the outer row. + * + * @param {object} entry the transformed orderBy entry produced from a `$searchRank` sub-select + * @param {string} outerAlias the final table alias of the outer query source + */ + function correlateSearchRank(entry, outerAlias) { + const where = entry.SELECT.where + // comparisons are laid out as: ref '=' ref ['and' ref '=' ref ...] -> every 3rd token (rhs) + for (let i = 2; i < where.length; i += 4) { + const rhs = where[i] + rhs.ref = [outerAlias, ...rhs.ref.slice(1)] + } + } + /** * Calculates the name of the source which can be used to address the given node. * diff --git a/db-service/test/cqn4sql/search.test.js b/db-service/test/cqn4sql/search.test.js index c9d72a5b3..160a8d737 100644 --- a/db-service/test/cqn4sql/search.test.js +++ b/db-service/test/cqn4sql/search.test.js @@ -3,6 +3,18 @@ const cqn4sql = require('../../lib/cqn4sql') const cds = require('@sap/cds') const { expect } = cds.test +// PR #1564 injects an `ORDER BY DESC` into every search query so results are +// ranked by relevance. Each test asserts the full transformed query — including that order-by — +// as a single cds.ql template, so a regression in the injected ranking is caught. +// +// Flat (non-navigation) search: the score is computed on the row itself, so the order-by is just +// the search() func with the numeric flag `true` appended, sorted desc. +// +// Deep (path-expression) search: the where clause is a semi-join `outerKey in (SELECT key FROM +// WHERE search(...))`; the order-by is a scalar sub-select over the SAME joins that +// selects the numeric score (`search(..., true) as search`) and is correlated to the current +// outer row via `innerKey = outerKey` (AND-chained for structured keys). + describe('Replace attribute search by search predicate', () => { let model beforeAll(async () => { @@ -18,7 +30,8 @@ describe('Replace attribute search by search predicate', () => { // single val is stored as val directly, not as expr with val const expected = cds.ql` SELECT from bookshop.WithStructuredKey as wsk { wsk.second } - where search(wsk.second, 'x')` + where search(wsk.second, 'x') + order by search(wsk.second, 'x', true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -30,7 +43,8 @@ describe('Replace attribute search by search predicate', () => { let res = cqn4sql(query, model) const expected = cds.ql` SELECT from bookshop.WithStructuredKey as wsk { wsk.second } - where search(wsk.second, ('x' OR 'y'))` + where search(wsk.second, ('x' OR 'y')) + order by search(wsk.second, ('x' or 'y'), true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -39,9 +53,11 @@ 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(cds.ql`SELECT from bookshop.Genres as Genres { + const expected = cds.ql`SELECT from bookshop.Genres as Genres { Genres.ID - } where search((Genres.name, Genres.descr, Genres.code), ('x' OR 'y'))`) + } where search((Genres.name, Genres.descr, Genres.code), ('x' OR 'y')) + order by search((Genres.name, Genres.descr, Genres.code), ('x' or 'y'), true) desc` + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) it('with existing WHERE clause', () => { @@ -52,7 +68,8 @@ describe('Replace attribute search by search predicate', () => { const expected = cds.ql`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'))` + and search((Genres.name, Genres.descr, Genres.code), ('x' OR 'y')) + order by search((Genres.name, Genres.descr, Genres.code), ('x' or 'y'), true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -81,6 +98,17 @@ describe('Replace attribute search by search predicate', () => { func: 'search', }, ], + orderBy: [ + { + func: 'search', + args: [ + { list: [{ ref: ['Genres', 'name'] }, { ref: ['Genres', 'descr'] }, { ref: ['Genres', 'code'] }] }, + { xpr: [{ val: 'x' }, 'or', { val: 'y' }] }, + { val: true }, + ], + sort: 'desc', + }, + ], }, } expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) @@ -93,7 +121,8 @@ describe('Replace attribute search by search predicate', () => { let res = cqn4sql(query, model) const expected = cds.ql`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')))` + } where (search((Person.name, Person.placeOfBirth, Person.placeOfDeath, Person.address_street, Person.address_city), ('x' OR 'y'))) + order by search((Person.name, Person.placeOfBirth, Person.placeOfDeath, Person.address_street, Person.address_city), ('x' or 'y'), true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -102,6 +131,7 @@ describe('Replace attribute search by search predicate', () => { query.SELECT.search = [{ val: 'x' }, 'or', { val: 'y' }] let res = cqn4sql(query, model) + // no searchable string elements → no search predicate and no ranking order-by expect(JSON.parse(JSON.stringify(res))).to.deep.equal(cds.ql`SELECT from bookshop.Foo as Foo { Foo.ID }`) @@ -111,16 +141,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( - cds.ql` + const expected = cds.ql` 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((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'))`, - ) + } 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')) + order by 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'), true) desc` + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) it('Search columns if result is grouped', () => { // in this case, we actually search the "title" which comes from the join @@ -135,7 +165,8 @@ describe('Replace attribute search by search predicate', () => { { Books.ID, books2.title as authorsBook - } where search(books2.title, ('x' OR 'y')) group by Books.title ` + } where search(books2.title, ('x' OR 'y')) group by Books.title + order by search(books2.title, ('x' or 'y'), true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) it('Search on navigation', () => { @@ -152,7 +183,8 @@ describe('Replace attribute search by search predicate', () => { SELECT 1 from bookshop.Authors as $A 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'))` + 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')) + order by 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'), true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) it('Search with aggregated column and groupby must be put into having', () => { @@ -166,7 +198,8 @@ describe('Replace attribute search by search predicate', () => { const expected = cds.ql` SELECT from bookshop.Books as Books { MIN(Books.title) as firstInAlphabet - } group by Books.title having search(MIN(Books.title), 'Cat')` + } group by Books.title having search(MIN(Books.title), 'Cat') + order by search(MIN(Books.title), 'Cat', true) desc` expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(expected) }) @@ -180,7 +213,8 @@ describe('Replace attribute search by search predicate', () => { const expected = cds.ql` SELECT from bookshop.Books as Books { min(Books.title) as firstInAlphabet - } group by Books.title having search(min(Books.title), 'Cat')` + } group by Books.title having search(min(Books.title), 'Cat') + order by search(min(Books.title), 'Cat', true) desc` expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(expected) }) @@ -197,7 +231,8 @@ describe('Replace attribute search by search predicate', () => { SELECT from bookshop.Books as Books { Books.title, AVG(Books.stock) as searchRelevant, - } where search(Books.title, 'x') group by Books.title` + } where search(Books.title, 'x') group by Books.title + order by search(Books.title, 'x', true) desc` expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(expected) }) it('aggregations which are not of type string are not searched', () => { @@ -210,6 +245,7 @@ describe('Replace attribute search by search predicate', () => { query.SELECT.search = [{ val: 'x' }] + // no searchable string column → no search predicate and no ranking order-by expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(cds.ql` SELECT from bookshop.Books as Books { Books.ID, @@ -231,7 +267,8 @@ describe('Replace attribute search by search predicate', () => { 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')` + } group by Books.title having search(substring(Books.stock), 'x') + order by search(substring(Books.stock), 'x', true) desc` expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(expected) }) @@ -254,6 +291,7 @@ describe('Replace attribute search by search predicate', () => { ('1' + '2' + '3') as notSearchRelevant: cds.Integer, } group by Books.title having search(('very' + 'useful' + 'string'), 'x') + order by search(('very' + 'useful' + 'string'), 'x', true) desc ` expect(JSON.parse(JSON.stringify(cqn4sql(query, model)))).to.deep.equal(expected) }) @@ -276,11 +314,53 @@ describe('search w/ path expressions', () => { BooksSearchAuthorName.ID, BooksSearchAuthorName.title } where BooksSearchAuthorName.ID in ( - SELECT from search.BooksSearchAuthorName as $B left join search.Authors as author on author.ID = $B.author_ID + SELECT from search.BooksSearchAuthorName as $B left join search.Authors as author on author.ID = $B.author_ID { $B.ID } where search(author.lastName, 'x') - )` + ) + order by ( + SELECT from search.BooksSearchAuthorName as $B + left join search.Authors as author on author.ID = $B.author_ID + { max(search(author.lastName, 'x', true)) as max } + where $B.ID = BooksSearchAuthorName.ID + ) desc` + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) + }) + + // Documents the gap behind the TODO in cqn4sql (PR #1564), per BobdenOs' review note. + // For a to-many search path the match score genuinely lives inside a semi-join subquery + // (one author matches via many books), not on the outer row. The WHERE clause is correlated + // to the outer row through the `ID in (SELECT ...)` pattern. To ALSO rank the outer result + // the ORDER BY must carry a subquery that is likewise correlated to the current outer row. + // The PR currently reuses the search expression WITHOUT that correlation — this test fails + // until cqn4sql binds the order-by sub-select to the outer row. + it('deep search along to-many path ranks the outer row by its own correlated score', () => { + // @cds.search: {books, books.genre.name} + let query = cds.ql`SELECT from search.AuthorSearchBooks as A { ID }` + query.SELECT.search = [{ val: 'x' }] + + let res = cqn4sql(query, model) + + const expected = cds.ql` + SELECT from search.AuthorSearchBooks as A { + A.ID + } where A.ID in ( + SELECT from search.AuthorSearchBooks as $A + left join search.Books as books on books.author_ID = $A.ID + left join search.Genres as genre on genre.ID = books.genre_ID + { + $A.ID + } where search((books.title, genre.name), 'x') + ) + order by ( + SELECT from search.AuthorSearchBooks as $A + left join search.Books as books on books.author_ID = $A.ID + left join search.Genres as genre on genre.ID = books.genre_ID + { max(search((books.title, genre.name), 'x', true)) as max } + where $A.ID = A.ID + ) desc` + expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -304,7 +384,14 @@ describe('search w/ path expressions', () => { $M.toMulti_ID2, $M.toMulti_ID3 } where search(toMulti.text, 'x') - )` + ) + order by ( + SELECT from search.MultipleLeafAssocAsKey as $M + left join search.MultipleKeys as toMulti + on toMulti.ID1 = $M.toMulti_ID1 and toMulti.ID2 = $M.toMulti_ID2 and toMulti.ID3 = $M.toMulti_ID3 + { max(search(toMulti.text, 'x', true)) as max } + where $M.toMulti_ID1 = M.toMulti_ID1 and $M.toMulti_ID2 = M.toMulti_ID2 and $M.toMulti_ID3 = M.toMulti_ID3 + ) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -316,7 +403,8 @@ describe('search w/ path expressions', () => { const expected = cds.ql` SELECT from search.PathInSearchNotProjected as PathInSearchNotProjected { PathInSearchNotProjected.title - } where search(PathInSearchNotProjected.title, 'x')` + } where search(PathInSearchNotProjected.title, 'x') + order by search(PathInSearchNotProjected.title, 'x', true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -347,7 +435,13 @@ describe('search w/ path expressions', () => { { $B.ID } where search(($B.title, author.lastName, author.firstName), 'x') - )` + ) + order by ( + SELECT from search.BooksSearchAuthor as $B + left join search.Authors as author on author.ID = $B.author_ID + { max(search(($B.title, author.lastName, author.firstName), 'x', true)) as max } + where $B.ID = Books.ID + ) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -368,7 +462,14 @@ describe('search w/ path expressions', () => { { $B.ID } where search(($B.title, authorWithAddress.note, address.city), 'x') - )` + ) + order by ( + 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 + { max(search(($B.title, authorWithAddress.note, address.city), 'x', true)) as max } + where $B.ID = Books.ID + ) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -388,7 +489,14 @@ describe('search w/ path expressions', () => { { $A.ID } where search((books.title, genre.name), 'x') - )` + ) + order by ( + SELECT from search.AuthorSearchBooks as $A + left join search.Books as books on books.author_ID = $A.ID + left join search.Genres as genre on genre.ID = books.genre_ID + { max(search((books.title, genre.name), 'x', true)) as max } + where $A.ID = A.ID + ) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -402,7 +510,8 @@ describe('search w/ path expressions', () => { { BookShelf.ID, BookShelf.genre - } where search(BookShelf.genre, 'Harry Plotter')` + } where search(BookShelf.genre, 'Harry Plotter') + order by search(BookShelf.genre, 'Harry Plotter', true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) }) @@ -428,10 +537,20 @@ describe('calculated elements', () => { { $A.ID } where search( - ( $A.note, (address.street || ' ' || address.zip || '' || address.city) ), + ( $A.note, (address.street || ' ' || address.zip || '' || address.city) ), 'x' ) - )` + ) + order by ( + SELECT from search.AuthorsSearchCalculatedAddress as $A + left join search.CalculatedAddresses as address on address.ID = $A.address_ID + { max(search( + ( $A.note, (address.street || ' ' || address.zip || '' || address.city) ), + 'x', + true + )) as max } + where $A.ID = Authors.ID + ) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -444,7 +563,8 @@ describe('calculated elements', () => { SELECT from search.CalculatedAddressesWithoutAnno as Address { Address.ID - } where search(Address.city, 'x')` + } where search(Address.city, 'x') + order by search(Address.city, 'x', true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -466,14 +586,20 @@ describe('caching searchable fields', () => { { Books.ID, Books.title - } + } where Books.ID in ( SELECT from search.BooksSearchAuthor as $B left join search.Authors as author on author.ID = $B.author_ID { $B.ID } where search(($B.title, author.lastName, author.firstName), 'x') - )` + ) + order by ( + SELECT from search.BooksSearchAuthor as $B + left join search.Authors as author on author.ID = $B.author_ID + { max(search(($B.title, author.lastName, author.firstName), 'x', true)) as max } + where $B.ID = Books.ID + ) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) // test caching @@ -536,8 +662,15 @@ describe('include / exclude logic', () => { left join search.Addresses as address on address.ID = authorWithAddress.address_ID { $B.ID - } where search(($B.title, authorWithAddress.note, address.city), 'x') - )` + } where search(($B.title, authorWithAddress.note, address.city), 'x') + ) + order by ( + 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 + { max(search(($B.title, authorWithAddress.note, address.city), 'x', true)) as max } + where $B.ID = Books.ID + ) desc` expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected) }) @@ -550,7 +683,8 @@ describe('include / exclude logic', () => { Books.ID, Books.description, Books.title - } where search(Books.description, 'x')` + } where search(Books.description, 'x') + order by search(Books.description, 'x', true) desc` expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected) }) @@ -566,8 +700,14 @@ describe('include / exclude logic', () => { left join search.Books as books on books.author_ID = $A.ID { $A.ID - } where search(books.title, 'x') - )` + } where search(books.title, 'x') + ) + order by ( + SELECT from search.AuthorSearchOnlyBooksTitle as $A + left join search.Books as books on books.author_ID = $A.ID + { max(search(books.title, 'x', true)) as max } + where $A.ID = A.ID + ) desc` expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected) }) @@ -578,7 +718,8 @@ describe('include / exclude logic', () => { const expected = cds.ql` SELECT from search.Addresses as Addresses { Addresses.ID - } where search(Addresses.city, 'x')` + } where search(Addresses.city, 'x') + order by search(Addresses.city, 'x', true) desc` expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected) }) @@ -589,7 +730,8 @@ describe('include / exclude logic', () => { const expected = cds.ql` SELECT from search.BooksIgnoreVirtualElement as Books { Books.ID - } where search(Books.title, 'x')` + } where search(Books.title, 'x') + order by search(Books.title, 'x', true) desc` expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected) }) @@ -600,7 +742,8 @@ describe('include / exclude logic', () => { const expected = cds.ql` SELECT from search.BooksIgnoreExplicitVirtualElement as Books { Books.ID - } where search(Books.title, 'x')` + } where search(Books.title, 'x') + order by search(Books.title, 'x', true) desc` expect(JSON.parse(JSON.stringify(transformed))).to.deep.equal(expected) }) @@ -613,7 +756,8 @@ describe('include / exclude logic', () => { SELECT from search.CalculatedAddressesExclude as Address { Address.ID - } where search(Address.city, 'x')` + } where search(Address.city, 'x') + order by search(Address.city, 'x', true) desc` expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) @@ -638,7 +782,8 @@ describe('include / exclude logic', () => { SELECT from search.BooksDontSearchAuthor as Books { Books.ID - } where search(Books.title, 'x')` + } where search(Books.title, 'x') + order by search(Books.title, 'x', true) desc` expect(JSON.parse(JSON.stringify(noAuthor))).to.deep.equal(expected) }) }) diff --git a/hana/lib/cql-functions.js b/hana/lib/cql-functions.js index 3e3022e0b..9889c4bb3 100644 --- a/hana/lib/cql-functions.js +++ b/hana/lib/cql-functions.js @@ -184,8 +184,12 @@ const StandardFunctions = { } fuzzy += ` MINIMAL 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 - e.xpr = [{ ref: e.ref }, fuzzy] + // ensure in place modification to reuse .toString method that ensures quoting. + // idempotent: the same search() args may be rendered twice (e.g. WHERE predicate and the + // injected ranking ORDER BY), so recover the original ref from a prior rewrite instead of + // reading a now-deleted e.ref. + const originalRef = e.ref || e.xpr?.[0]?.ref + e.xpr = [{ ref: originalRef }, fuzzy] delete e.ref }) } else { diff --git a/hana/test/fuzzy.test.js b/hana/test/fuzzy.test.js index 7df636c5a..142ab0c4e 100644 --- a/hana/test/fuzzy.test.js +++ b/hana/test/fuzzy.test.js @@ -64,8 +64,9 @@ describe('search', () => { const { Books } = cds.entities('sap.capire.bookshop') const cqn = SELECT.from(Books).search('"autobio"').columns('1') const { sql } = cds.db.cqn2sql(cqn) - // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code - expect(sql.match(/(like)/g).length).to.eq(5) + // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code — once in + // the WHERE and once more in the injected ranking ORDER BY (search relevance) + expect(sql.match(/(like)/g).length).to.eq(10) const res = await cqn expect(res.length).to.eq(2) // Eleonora and Jane Eyre }) @@ -74,8 +75,8 @@ describe('search', () => { const { Books } = cds.entities('sap.capire.bookshop') const cqn = SELECT.from(Books).search('"autobio"', '"Jane"').columns('1') const { sql, values } = cds.db.cqn2sql(cqn) - // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code - expect(sql.match(/(like)/g).length).to.eq(10) + // 5 searched columns × 2 terms, in WHERE and again in the ranking ORDER BY + expect(sql.match(/(like)/g).length).to.eq(20) expect(values).to.include('%autobio%') expect(values).to.include('%jane%') const res = await cqn @@ -86,8 +87,8 @@ describe('search', () => { const { Books } = cds.entities('sap.capire.bookshop') const cqn = SELECT.from(Books).search('"1847"', '1846', '"\\"Ellis Bell\\""').columns('1') const { sql, values } = cds.db.cqn2sql(cqn) - // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code - expect(sql.match(/(like)/g).length).to.eq(15) + // 5 searched columns × 3 terms, in WHERE and again in the ranking ORDER BY + expect(sql.match(/(like)/g).length).to.eq(30) expect(values).to.include('%1847%') expect(values).to.include('%1846%') expect(values).to.include('%"ellis bell"%') diff --git a/hana/test/search-ranking.cds b/hana/test/search-ranking.cds new file mode 100644 index 000000000..c603d0b88 --- /dev/null +++ b/hana/test/search-ranking.cds @@ -0,0 +1,25 @@ +namespace search.ranking; + +entity Genres { + key ID : Integer; + name : String; +} + +entity Books { + key ID : Integer; + title : String; + author : Association to Authors; + genre : Association to Genres; +} + +entity Authors { + key ID : Integer; + name : String; + books : Composition of many Books + on books.author = $self; +} + +// to-many searchable path: an author matches via any of its books' title or genre name. +// The ranking ORDER BY must correlate a MAX(SCORE(...)) sub-select to each author row. +@cds.search: {books.title, books.genre.name} +entity SearchAuthors : Authors {} diff --git a/hana/test/search-ranking.test.js b/hana/test/search-ranking.test.js new file mode 100644 index 000000000..653e9722c --- /dev/null +++ b/hana/test/search-ranking.test.js @@ -0,0 +1,55 @@ +const cds = require('../../test/cds') + +// End-to-end verification of $search relevance ranking on a real HANA (needs SCORE()). +// A to-many search path (SearchAuthors -> books.title / books.genre.name) makes one author +// fan out to many joined child rows; the ranking ORDER BY is a correlated MAX(SCORE(...)) +// sub-select. This asserts the actual row order and de-duplication end to end — something +// SQLite/Postgres cannot validate because they have no relevance score. +describe('search ranking (e2e, HANA only)', () => { + const { expect } = cds.test(__dirname, 'search-ranking.cds') + + beforeAll(async () => { + const { SearchAuthors, Books, Genres } = cds.entities('search.ranking') + await cds.run([ + INSERT.into(Genres).entries([ + { ID: 1, name: 'Fantasy' }, + { ID: 2, name: 'Catalogue' }, + { ID: 3, name: 'History' }, + ]), + INSERT.into(SearchAuthors).entries([ + { ID: 10, name: 'Strong' }, + { ID: 20, name: 'Weak' }, + { ID: 30, name: 'None' }, + ]), + INSERT.into(Books).entries([ + // author 10: an exact title hit for 'Cat' plus an unrelated book -> should rank highest + { ID: 100, title: 'Cat', author_ID: 10, genre_ID: 1 }, + { ID: 101, title: 'Unrelated', author_ID: 10, genre_ID: 1 }, + // author 20: only a partial/weaker hit via the genre name 'Catalogue' + { ID: 200, title: 'Unrelated', author_ID: 20, genre_ID: 2 }, + // author 30: no match at all + { ID: 300, title: 'Unrelated', author_ID: 30, genre_ID: 3 }, + ]), + ]) + }) + + test('deep to-many search ranks by best-matching child score, without duplicates', async () => { + const { SearchAuthors } = cds.entities('search.ranking') + const q = SELECT.from(SearchAuthors).columns('ID').search('Cat') + + // sanity: the injected order-by is the correlated MAX(SCORE(...)) sub-select + const { sql } = cds.db.cqn2sql(q) + expect(sql).to.match(/ORDER BY \(SELECT max\(SCORE\(/i) + + const res = await q + + // only the two matching authors come back, each exactly once (semi-join de-dups the fan-out) + const ids = res.map(r => r.ID) + expect(ids).to.have.members([10, 20]) + expect(ids.length).to.eq(2) + + // ranked by relevance desc: the exact title match (author 10) outranks the genre-only match (author 20) + expect(ids[0]).to.eq(10) + expect(ids[1]).to.eq(20) + }) +}) From d6f62495d747d98b7bc89950cbd4496739f7fd5a Mon Sep 17 00:00:00 2001 From: Patrice Bender Date: Thu, 27 Aug 2026 14:42:51 +0200 Subject: [PATCH 2/6] test: make search-ranking e2e model compile on cds-compiler 7.0.1 The inherited `$self` composition (SearchAuthors : Authors) made the backlink resolve to the base entity, which cds-compiler 7.0.1 (CI) rejects during the relational SQL transform. Make the model self-contained: SearchAuthors owns its books composition and Books.author points to SearchAuthors directly, so the $self backlink is unambiguous. Verified compile+to.sql on 7.0.1 and the e2e ranking still passes on a real HANA. --- hana/test/search-ranking.cds | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/hana/test/search-ranking.cds b/hana/test/search-ranking.cds index c603d0b88..79c2cd139 100644 --- a/hana/test/search-ranking.cds +++ b/hana/test/search-ranking.cds @@ -8,18 +8,16 @@ entity Genres { entity Books { key ID : Integer; title : String; - author : Association to Authors; + author : Association to SearchAuthors; genre : Association to Genres; } -entity Authors { +// to-many searchable path: an author matches via any of its books' title or genre name. +// The ranking ORDER BY must correlate a MAX(SCORE(...)) sub-select to each author row. +@cds.search: {books.title, books.genre.name} +entity SearchAuthors { key ID : Integer; name : String; books : Composition of many Books on books.author = $self; } - -// to-many searchable path: an author matches via any of its books' title or genre name. -// The ranking ORDER BY must correlate a MAX(SCORE(...)) sub-select to each author row. -@cds.search: {books.title, books.genre.name} -entity SearchAuthors : Authors {} From 9bb00c1e1922cdf0a8e109ebee6d76f1eca0e351 Mon Sep 17 00:00:00 2001 From: Patrice Bender Date: Thu, 27 Aug 2026 16:02:03 +0200 Subject: [PATCH 3/6] cosmetics --- db-service/lib/cqn4sql.js | 22 +++++++++++++--------- db-service/test/cqn4sql/search.test.js | 10 +++------- hana/test/search-ranking.cds | 1 - hana/test/search-ranking.test.js | 4 +--- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 220a6dd36..4aa4677da 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -2646,8 +2646,8 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) { const entry = { __proto__: SELECT.from(searchSelect.SELECT.from) - // one correlated outer row fans out to many joined child rows -> MAX collapses them to a - // single value so the scalar sub-select is well-defined: rank by the best-matching score + // one correlated outer may lead to many joined child rows -> MAX collapses them to a + // single value so the scalar sub-select is valid in the context of order by: rank by the best-matching score .columns({ func: 'max', args: [{ func: searchFunc.func, args: [...searchFunc.args, { val: true }] }] }) .where(where), sort: 'desc', @@ -2659,19 +2659,23 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) { /** * Correlates the deep-search ranking sub-select to the outer row, after it has been transformed. * - * The transformed sub-select's WHERE is a chain of `innerKey = innerKey` comparisons, both sides - * resolved to the sub-select's own leading source alias. This rewrites the right-hand side of each - * comparison to `.`, turning the tautology into a correlation to the outer row. + * `buildSearchRankOrderBy` seeds the sub-select's WHERE as a chain of `innerKey = innerKey` + * comparisons (`ref '=' ref ['and' ref '=' ref ...]`), both sides resolved to the sub-select's + * own leading source alias. For each such `=` comparison this rewrites the right-hand ref to + * `.`, turning the tautology into a correlation to the outer row. Driven off the + * `=` operator (not a fixed stride) so it stays correct regardless of key count. * * @param {object} entry the transformed orderBy entry produced from a `$searchRank` sub-select * @param {string} outerAlias the final table alias of the outer query source */ function correlateSearchRank(entry, outerAlias) { const where = entry.SELECT.where - // comparisons are laid out as: ref '=' ref ['and' ref '=' ref ...] -> every 3rd token (rhs) - for (let i = 2; i < where.length; i += 4) { - const rhs = where[i] - rhs.ref = [outerAlias, ...rhs.ref.slice(1)] + for (let i = 1; i < where.length; i++) { + // seeded comparisons are exactly ` = `; rewrite the rhs ref to the outer row + if (where[i] === '=' && where[i - 1]?.ref && where[i + 1]?.ref) { + const rhs = where[i + 1] + rhs.ref = [outerAlias, ...rhs.ref.slice(1)] + } } } diff --git a/db-service/test/cqn4sql/search.test.js b/db-service/test/cqn4sql/search.test.js index 160a8d737..c07ea837b 100644 --- a/db-service/test/cqn4sql/search.test.js +++ b/db-service/test/cqn4sql/search.test.js @@ -3,9 +3,8 @@ const cqn4sql = require('../../lib/cqn4sql') const cds = require('@sap/cds') const { expect } = cds.test -// PR #1564 injects an `ORDER BY DESC` into every search query so results are -// ranked by relevance. Each test asserts the full transformed query — including that order-by — -// as a single cds.ql template, so a regression in the injected ranking is caught. +// An `ORDER BY DESC` is injected into every search query so results are +// ranked by relevance. // // Flat (non-navigation) search: the score is computed on the row itself, so the order-by is just // the search() func with the numeric flag `true` appended, sorted desc. @@ -328,13 +327,10 @@ describe('search w/ path expressions', () => { expect(JSON.parse(JSON.stringify(res))).to.deep.equal(expected) }) - // Documents the gap behind the TODO in cqn4sql (PR #1564), per BobdenOs' review note. - // For a to-many search path the match score genuinely lives inside a semi-join subquery + // For a to-many search path the match score lives inside a semi-join subquery // (one author matches via many books), not on the outer row. The WHERE clause is correlated // to the outer row through the `ID in (SELECT ...)` pattern. To ALSO rank the outer result // the ORDER BY must carry a subquery that is likewise correlated to the current outer row. - // The PR currently reuses the search expression WITHOUT that correlation — this test fails - // until cqn4sql binds the order-by sub-select to the outer row. it('deep search along to-many path ranks the outer row by its own correlated score', () => { // @cds.search: {books, books.genre.name} let query = cds.ql`SELECT from search.AuthorSearchBooks as A { ID }` diff --git a/hana/test/search-ranking.cds b/hana/test/search-ranking.cds index 79c2cd139..5efc312e0 100644 --- a/hana/test/search-ranking.cds +++ b/hana/test/search-ranking.cds @@ -13,7 +13,6 @@ entity Books { } // to-many searchable path: an author matches via any of its books' title or genre name. -// The ranking ORDER BY must correlate a MAX(SCORE(...)) sub-select to each author row. @cds.search: {books.title, books.genre.name} entity SearchAuthors { key ID : Integer; diff --git a/hana/test/search-ranking.test.js b/hana/test/search-ranking.test.js index 653e9722c..89c73b97a 100644 --- a/hana/test/search-ranking.test.js +++ b/hana/test/search-ranking.test.js @@ -1,10 +1,8 @@ const cds = require('../../test/cds') -// End-to-end verification of $search relevance ranking on a real HANA (needs SCORE()). // A to-many search path (SearchAuthors -> books.title / books.genre.name) makes one author // fan out to many joined child rows; the ranking ORDER BY is a correlated MAX(SCORE(...)) -// sub-select. This asserts the actual row order and de-duplication end to end — something -// SQLite/Postgres cannot validate because they have no relevance score. +// sub-select. describe('search ranking (e2e, HANA only)', () => { const { expect } = cds.test(__dirname, 'search-ranking.cds') From 6083a81bf068c6ea650fb6dc67780c49b7f7a1b8 Mon Sep 17 00:00:00 2001 From: Patrice Bender Date: Thu, 27 Aug 2026 16:04:34 +0200 Subject: [PATCH 4/6] more cosmetics --- db-service/lib/cqn4sql.js | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 4aa4677da..8d87a64ea 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -2611,19 +2611,15 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) { /** * Builds the ORDER BY entry ranking rows by $search relevance, sorted desc. * - * Flat search: the score is computed on the outer row itself, so the entry is just the - * search() func with the numeric flag (`true`) appended. + * Flat search: the score is on the outer row, so the entry is the search() func with the + * numeric flag appended. * - * Deep search: the score lives in a semi-join sub-select, so we emit a CORRELATED scalar - * sub-select that selects the numeric score and binds its inner key(s) to the outer row: + * Deep search: the score lives in a semi-join sub-select, so we emit a correlated scalar + * sub-select selecting the score, keyed back to the outer row: * (SELECT search(, , true) FROM WHERE innerKey = .key) DESC - * - * The correlation cannot be expressed by pre-qualifying the outer key here: the sub-select's - * source is the same entity as the outer query, so infer() would re-resolve an outer-qualified - * ref back to the sub-select's own source. Instead we build both sides of each key comparison - * unqualified (they resolve to the sub-select's own alias) and mark the entry as `$searchRank`, - * so it can be correlated to the outer alias AFTER transformation (see correlateSearchRank) — - * the same "rewrite the inner alias to the outer alias" trick used by expand's _correlate. + * Both sides of the key comparison are seeded unqualified so infer() binds them to the + * sub-select's own source; the rhs is redirected to the outer alias afterwards, see + * correlateSearchRank. * * @param {object} searchTerm the search term as returned by getSearch (func or xpr shape) * @returns {object|null} an orderBy entry, or null if there is nothing to rank by @@ -2639,8 +2635,7 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) { const where = [] for (let i = 0; i < innerKeys.length; i++) { if (i) where.push('and') - // both sides unqualified -> resolve to the sub-select's own alias; the right-hand side is - // rewired to the outer alias post-transform in correlateSearchRank + // seeded unqualified on both sides; correlateSearchRank redirects the rhs to the outer row where.push({ ref: [...innerKeys[i].ref] }, '=', { ref: [...innerKeys[i].ref] }) } From 260514f415a525cfd8278c8bad3d0fc47c96e0a1 Mon Sep 17 00:00:00 2001 From: Patrice Bender Date: Mon, 31 Aug 2026 11:59:04 +0200 Subject: [PATCH 5/6] fix: only inject $search ranking ORDER BY when HANA fuzzy scoring is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relevance ranking is a HANA fuzzy-search feature: with cds.env.hana.fuzzy=false (and on other DBs) search() yields no score, so the injected ORDER BY sorted by a constant boolean — useless and a wasted duplicate of the search expression. Skip the ranking injection unless fuzzy scoring is active. Reverts the fuzzy fallback like-count assertions accordingly (no ranking ORDER BY, so no doubling). --- db-service/lib/cqn4sql.js | 8 +++++--- hana/test/fuzzy.test.js | 13 ++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 8d87a64ea..70cfb8c11 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -334,9 +334,11 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) { // Since all the expressions in the SELECT part of the query have been computed, // one can reference aliases of the queries columns in the orderBy clause. let effectiveOrderBy = orderBy - // Rank by $search relevance. Prepended here (post-infer) so the deep-search scalar sub-select - // can be correlated to the OUTER row, whose alias is only known now. - const searchRank = inferred.$searchRank && buildSearchRankOrderBy(inferred.$searchRank) + // Rank by $search relevance. Only HANA fuzzy search yields a score; without it the ranking + // would sort by a constant boolean, so skip it. Prepended here (post-infer) so the deep-search + // scalar sub-select can be correlated to the OUTER row, whose alias is only known now. + const searchRank = + inferred.$searchRank && cds.env.hana?.fuzzy !== false && buildSearchRankOrderBy(inferred.$searchRank) if (searchRank) effectiveOrderBy = [searchRank, ...(orderBy || [])] if (effectiveOrderBy) { const transformedOrderBy = getTransformedOrderByGroupBy(effectiveOrderBy, true) diff --git a/hana/test/fuzzy.test.js b/hana/test/fuzzy.test.js index 142ab0c4e..7df636c5a 100644 --- a/hana/test/fuzzy.test.js +++ b/hana/test/fuzzy.test.js @@ -64,9 +64,8 @@ describe('search', () => { const { Books } = cds.entities('sap.capire.bookshop') const cqn = SELECT.from(Books).search('"autobio"').columns('1') const { sql } = cds.db.cqn2sql(cqn) - // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code — once in - // the WHERE and once more in the injected ranking ORDER BY (search relevance) - expect(sql.match(/(like)/g).length).to.eq(10) + // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code + expect(sql.match(/(like)/g).length).to.eq(5) const res = await cqn expect(res.length).to.eq(2) // Eleonora and Jane Eyre }) @@ -75,8 +74,8 @@ describe('search', () => { const { Books } = cds.entities('sap.capire.bookshop') const cqn = SELECT.from(Books).search('"autobio"', '"Jane"').columns('1') const { sql, values } = cds.db.cqn2sql(cqn) - // 5 searched columns × 2 terms, in WHERE and again in the ranking ORDER BY - expect(sql.match(/(like)/g).length).to.eq(20) + // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code + expect(sql.match(/(like)/g).length).to.eq(10) expect(values).to.include('%autobio%') expect(values).to.include('%jane%') const res = await cqn @@ -87,8 +86,8 @@ describe('search', () => { const { Books } = cds.entities('sap.capire.bookshop') const cqn = SELECT.from(Books).search('"1847"', '1846', '"\\"Ellis Bell\\""').columns('1') const { sql, values } = cds.db.cqn2sql(cqn) - // 5 searched columns × 3 terms, in WHERE and again in the ranking ORDER BY - expect(sql.match(/(like)/g).length).to.eq(30) + // 5 columns to be searched createdBy, modifiedBy, title, descr, currency_code + expect(sql.match(/(like)/g).length).to.eq(15) expect(values).to.include('%1847%') expect(values).to.include('%1846%') expect(values).to.include('%"ellis bell"%') From 98a8a6b4f57b003c0bcb4b02a3e1570f570a87ed Mon Sep 17 00:00:00 2001 From: Patrice Bender Date: Mon, 31 Aug 2026 13:34:20 +0200 Subject: [PATCH 6/6] fix: gate $search ranking on active HANA db, not just fuzzy flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cds.env.hana.fuzzy is undefined (not false) on every dialect and cds.env.hana is an always-present config block, so the previous guard injected the ranking ORDER BY on sqlite/postgres too — where search() is a boolean and the sort is meaningless. Gate on the active db instead: cds.db?.kind === 'hana' && fuzzy !== false. Reference tests stub cds.db = { kind: 'hana' } and add negative tests asserting no ranking on non-HANA and on HANA with fuzzy=false. --- db-service/lib/cqn4sql.js | 11 ++--- db-service/test/cqn4sql/search.test.js | 56 +++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 70cfb8c11..eb9ca57f3 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -334,11 +334,12 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) { // Since all the expressions in the SELECT part of the query have been computed, // one can reference aliases of the queries columns in the orderBy clause. let effectiveOrderBy = orderBy - // Rank by $search relevance. Only HANA fuzzy search yields a score; without it the ranking - // would sort by a constant boolean, so skip it. Prepended here (post-infer) so the deep-search - // scalar sub-select can be correlated to the OUTER row, whose alias is only known now. - const searchRank = - inferred.$searchRank && cds.env.hana?.fuzzy !== false && buildSearchRankOrderBy(inferred.$searchRank) + // Rank by $search relevance. Only HANA fuzzy search yields a score; on other DBs (or with + // hana.fuzzy === false) search() is a boolean, so ranking would sort by a constant — skip it. + // Prepended here (post-infer) so the deep-search scalar sub-select can be correlated to the + // OUTER row, whose alias is only known now. + const ranksSearch = cds.db?.kind === 'hana' && cds.env.hana?.fuzzy !== false + const searchRank = ranksSearch && inferred.$searchRank && buildSearchRankOrderBy(inferred.$searchRank) if (searchRank) effectiveOrderBy = [searchRank, ...(orderBy || [])] if (effectiveOrderBy) { const transformedOrderBy = getTransformedOrderByGroupBy(effectiveOrderBy, true) diff --git a/db-service/test/cqn4sql/search.test.js b/db-service/test/cqn4sql/search.test.js index c07ea837b..84ed1bbf2 100644 --- a/db-service/test/cqn4sql/search.test.js +++ b/db-service/test/cqn4sql/search.test.js @@ -3,8 +3,10 @@ const cqn4sql = require('../../lib/cqn4sql') const cds = require('@sap/cds') const { expect } = cds.test -// An `ORDER BY DESC` is injected into every search query so results are -// ranked by relevance. +// An `ORDER BY DESC` is injected into search queries so results are ranked by +// relevance. This is a HANA fuzzy-search feature (other DBs have no relevance score), so it is +// only emitted when the active db is HANA with fuzzy enabled. The tests below therefore run with +// a HANA db kind + fuzzy on; the dedicated test at the end asserts it is skipped otherwise. // // Flat (non-navigation) search: the score is computed on the row itself, so the order-by is just // the search() func with the numeric flag `true` appended, sorted desc. @@ -14,6 +16,20 @@ const { expect } = cds.test // selects the numeric score (`search(..., true) as search`) and is correlated to the current // outer row via `innerKey = outerKey` (AND-chained for structured keys). +let _db, _fuzzy +beforeAll(() => { + // ranking is gated on `cds.db.kind === 'hana' && cds.env.hana.fuzzy !== false` + _db = cds.db + _fuzzy = cds.env.hana?.fuzzy + cds.db = { kind: 'hana' } + ;(cds.env.hana ??= {}).fuzzy = true +}) +afterAll(() => { + cds.db = _db + if (_fuzzy === undefined) delete cds.env.hana.fuzzy + else cds.env.hana.fuzzy = _fuzzy +}) + describe('Replace attribute search by search predicate', () => { let model beforeAll(async () => { @@ -783,3 +799,39 @@ describe('include / exclude logic', () => { expect(JSON.parse(JSON.stringify(noAuthor))).to.deep.equal(expected) }) }) + +describe('no search ranking without HANA fuzzy scoring', () => { + // ranking is gated on `cds.db.kind === 'hana' && cds.env.hana.fuzzy !== false`; when the active + // db has no relevance score the injected order-by would sort by a constant, so it is skipped. + let model, _db, _fuzzy + beforeAll(async () => { + model = cds.model = cds.compile.for.nodejs(await cds.load(`${__dirname}/../bookshop/db/schema`).then(cds.linked)) + _db = cds.db + _fuzzy = cds.env.hana?.fuzzy + }) + afterAll(() => { + cds.db = _db + if (_fuzzy === undefined) delete cds.env.hana.fuzzy + else cds.env.hana.fuzzy = _fuzzy + }) + + it('non-HANA db does not get a ranking order-by', () => { + cds.db = { kind: 'sqlite' } + ;(cds.env.hana ??= {}).fuzzy = true // irrelevant for non-HANA + const query = cds.ql`SELECT from bookshop.Genres as Genres { ID }` + query.SELECT.search = [{ val: 'x' }] + + const res = cqn4sql(query, model) + expect(res.SELECT.orderBy).to.be.undefined + }) + + it('HANA with fuzzy=false does not get a ranking order-by', () => { + cds.db = { kind: 'hana' } + cds.env.hana.fuzzy = false + const query = cds.ql`SELECT from bookshop.Genres as Genres { ID }` + query.SELECT.search = [{ val: 'x' }] + + const res = cqn4sql(query, model) + expect(res.SELECT.orderBy).to.be.undefined + }) +})