Skip to content
Draft
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
95 changes: 82 additions & 13 deletions db-service/lib/cqn4sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -341,9 +333,20 @@ 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. 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)
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
}
}
Expand Down Expand Up @@ -2608,6 +2611,72 @@ 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 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 selecting the score, keyed back to the outer row:
* (SELECT search(<cols>, <val>, true) FROM <same source> WHERE innerKey = <outerAlias>.key) DESC
* 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
*/
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')
// seeded unqualified on both sides; correlateSearchRank redirects the rhs to the outer row
where.push({ ref: [...innerKeys[i].ref] }, '=', { ref: [...innerKeys[i].ref] })
}

const entry = {
__proto__: SELECT.from(searchSelect.SELECT.from)
// 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 }] }] })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How do you solve that in where clauses atm?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The WHERE doesn't need to collapse the to-many: the subquery key IN (SELECT key FROM …joins… WHERE search(…)) yields one row per matching child, so the same key repeats, but IN checks set membership so the duplicates don't matter (A.ID IN (10,10,10)A.ID IN (10)).

.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.
*
* `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
* `<outerAlias>.<key>`, 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
for (let i = 1; i < where.length; i++) {
// seeded comparisons are exactly `<ref> = <ref>`; 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)]
}
}
}

/**
* Calculates the name of the source which can be used to address the given node.
*
Expand Down
Loading