Skip to content

Commit bc93160

Browse files
patricebenderBobdenOsjohannes-vogel
authored
feat: rank $search results by relevance (#1725)
Ranks `$search` results by fuzzy relevance on HANA, and correctly correlates the ranking `ORDER BY` to the outer row. Standalone against `main` (supersedes the stacked PR #1717 / includes #1564's search-order groundwork). ### What - **Rank by relevance**: inject `ORDER BY <score> DESC` for `$search`. Deep (path) search ranks each outer row by its best-matching child via a correlated `(SELECT MAX(SCORE(...)) ... WHERE innerKey = outerKey)` sub-select — resolving the earlier `key IN (key)` tautology (correlation applied post-`infer()`, mirroring expand's `_correlate`). - **Gated to scoring backends**: only when `cds.db.kind === 'hana'` and fuzzy is on — sqlite/postgres (no score) get no ranking. - **Opt-out**: `cds.env.hana.fuzzy.ranked_search = false`; `fuzzy` also accepts `{ score, ranked_search }`. - **Order-by precedence**: user ordering → search rank → runtime implicit key ordering (`implicit: true`). ### Tests - cqn4sql: rank shape, precedence, opt-out, non-HANA gating. - HANA e2e (live-verified): deep to-many ranking + dedup; user order-by precedence; opt-out contrast; OData `$search` via the bookshop service showing rank beats implicit key ordering; `fuzzy` object config. --------- Co-authored-by: Bob den Os <bob.den.os@sap.com> Co-authored-by: D045778 <johannes.vogel@sap.com>
1 parent 633db11 commit bc93160

7 files changed

Lines changed: 564 additions & 47 deletions

File tree

db-service/lib/cqn4sql.js

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) {
8282
const { where, having } = transformSearch(searchTerm)
8383
if (where) inferred.SELECT.where = where
8484
else if (having) inferred.SELECT.having = having
85+
// Defer the ranking ORDER BY to after infer(), where the outer table alias is known and the
86+
// deep-search sub-select can be correlated to the outer row (see buildSearchRankOrderBy).
87+
defineProperty(inferred, '$searchRank', searchTerm)
8588
}
8689
}
8790
// query modifiers can also be defined in from ref leaf infix filter
@@ -330,9 +333,29 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) {
330333

331334
// Since all the expressions in the SELECT part of the query have been computed,
332335
// one can reference aliases of the queries columns in the orderBy clause.
333-
if (orderBy) {
334-
const transformedOrderBy = getTransformedOrderByGroupBy(orderBy, true)
336+
let effectiveOrderBy = orderBy
337+
// Rank by $search relevance — only when the score exists (HANA fuzzy, not opted out); else
338+
// it would sort by a constant boolean.
339+
const ranksSearch =
340+
cds.db?.kind === 'hana' && cds.env.hana?.fuzzy !== false && cds.env.hana?.fuzzy?.ranked_search !== false
341+
// count queries do not need ranked search
342+
const isCountQuery = columns?.length === 1 && columns[0].func === 'count'
343+
const searchRank = ranksSearch && !isCountQuery && inferred.$searchRank && buildSearchRankOrderBy(inferred.$searchRank)
344+
if (searchRank) {
345+
// precedence: user ordering, then rank, then the runtime's implicit key ordering
346+
const implicitAt = (orderBy || []).findIndex(o => o.implicit)
347+
const at = implicitAt === -1 ? (orderBy?.length ?? 0) : implicitAt
348+
effectiveOrderBy = [...(orderBy || [])]
349+
effectiveOrderBy.splice(at, 0, searchRank)
350+
}
351+
if (effectiveOrderBy) {
352+
const transformedOrderBy = getTransformedOrderByGroupBy(effectiveOrderBy, true)
335353
if (transformedOrderBy.length) {
354+
// the rank is the only order-by entry that is a correlated sub-select
355+
if (searchRank?.$searchRank) {
356+
const rank = transformedOrderBy.find(o => o.SELECT)
357+
if (rank) correlateSearchRank(rank, transformedFrom.as)
358+
}
336359
transformedQuery.SELECT.orderBy = transformedOrderBy
337360
}
338361
}
@@ -2597,6 +2620,65 @@ function _cqn4sql(originalQuery, model, useTechnicalAlias = true) {
25972620
return { xpr: [matchColumns.length === 1 ? matchColumns[0] : { list: matchColumns }, 'in', subquery] }
25982621
}
25992622

2623+
/**
2624+
* Builds the ORDER BY entry ranking rows by $search relevance, sorted desc.
2625+
*
2626+
* Flat search: the score is on the outer row.
2627+
* Deep search: the score lives in a semi-join, so emit a correlated scalar sub-select
2628+
* (SELECT search(…, true) FROM <same source> WHERE innerKey = <outerAlias>.key) DESC.
2629+
* Key comparisons are seeded unqualified (infer() binds them to the sub-select's own source);
2630+
* correlateSearchRank redirects the rhs to the outer alias afterwards.
2631+
*
2632+
* @param {object} searchTerm the search term as returned by getSearch (func or xpr shape)
2633+
* @returns {object|null} an orderBy entry, or null if there is nothing to rank by
2634+
*/
2635+
function buildSearchRankOrderBy(searchTerm) {
2636+
if (searchTerm.func) return { func: searchTerm.func, args: [...searchTerm.args, { val: true }], sort: 'desc' }
2637+
if (!searchTerm.xpr) return null
2638+
2639+
const searchSelect = searchTerm.xpr[2]
2640+
const searchFunc = searchSelect.SELECT.where[0]
2641+
const innerKeys = searchSelect.SELECT.columns // unqualified pk refs, e.g. [{ ref: ['ID'] }]
2642+
2643+
const where = []
2644+
for (let i = 0; i < innerKeys.length; i++) {
2645+
if (i) where.push('and')
2646+
// seeded unqualified on both sides; correlateSearchRank redirects the rhs to the outer row
2647+
where.push({ ref: [...innerKeys[i].ref] }, '=', { ref: [...innerKeys[i].ref] })
2648+
}
2649+
2650+
const entry = {
2651+
__proto__: SELECT.from(searchSelect.SELECT.from)
2652+
// the correlated outer row fans out to many child rows -> MAX makes it a single score
2653+
.columns({ func: 'max', args: [{ func: searchFunc.func, args: [...searchFunc.args, { val: true }] }] })
2654+
.where(where),
2655+
sort: 'desc',
2656+
}
2657+
defineProperty(entry, '$searchRank', true)
2658+
return entry
2659+
}
2660+
2661+
/**
2662+
* Correlates the (transformed) deep-search ranking sub-select to the outer row.
2663+
*
2664+
* buildSearchRankOrderBy seeds its WHERE as `ref = ref` comparisons on the sub-select's own
2665+
* alias; this rewrites each rhs to `<outerAlias>.<key>`. Driven off the `=` operator (not a
2666+
* fixed stride) so it holds for any key count.
2667+
*
2668+
* @param {object} entry the transformed orderBy entry produced from a `$searchRank` sub-select
2669+
* @param {string} outerAlias the final table alias of the outer query source
2670+
*/
2671+
function correlateSearchRank(entry, outerAlias) {
2672+
const where = entry.SELECT.where
2673+
for (let i = 1; i < where.length; i++) {
2674+
// seeded comparisons are exactly `<ref> = <ref>`; rewrite the rhs ref to the outer row
2675+
if (where[i] === '=' && where[i - 1]?.ref && where[i + 1]?.ref) {
2676+
const rhs = where[i + 1]
2677+
rhs.ref = [outerAlias, ...rhs.ref.slice(1)]
2678+
}
2679+
}
2680+
}
2681+
26002682
/**
26012683
* Calculates the name of the source which can be used to address the given node.
26022684
*

0 commit comments

Comments
 (0)