Skip to content

Commit

Permalink
feat(search): enable deep search with path expressions (#590)
Browse files Browse the repository at this point in the history
support search via path expressions:

- deep search via associations
- include calculated elements if explicitly requested

---

with `cds.infer` being a part of all our query objects, each query comes
with `target` and `elements` even without the `infer` function of the
`db-service`being called. Hence, it was possible to calculate the search
term as a very first step of `cqn4sql`. After the `contains(…)` function
has been added to the queries `where` or `having` clause the usual join
tree algorithm is executed, making it possible to use path expressions
within the search term.

---

rework of #252

---------

Co-authored-by: Johannes Vogel <31311694+johannes-vogel@users.noreply.github.com>
  • Loading branch information
patricebender and johannes-vogel authored Jul 8, 2024
1 parent 0b9108c commit e9e9461
Show file tree
Hide file tree
Showing 9 changed files with 428 additions and 90 deletions.
2 changes: 2 additions & 0 deletions db-service/lib/SQLService.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ class SQLService extends DatabaseService {
* @type {Handler}
*/
async onSELECT({ query, data }) {
// REVISIT: for custom joins, infer is called twice, which is bad
// --> make cds.infer properly work with custom joins and remove this
if (!query.target) {
try { this.infer(query) } catch { /**/ }
}
Expand Down
134 changes: 80 additions & 54 deletions db-service/lib/cqn4sql.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
'use strict'

const cds = require('@sap/cds')
const { computeColumnsToBeSearched } = require('./search')

const infer = require('./infer')
const { computeColumnsToBeSearched } = require('./search')

/**
* For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
Expand Down Expand Up @@ -44,8 +44,25 @@ const { pseudos } = require('./infer/pseudos')
* @returns {object} transformedQuery the transformed query
*/
function cqn4sql(originalQuery, model) {
const inferred = infer(originalQuery, model)
if (originalQuery.SELECT?.from.args && !originalQuery.joinTree) return inferred
let inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
const hasCustomJoins = originalQuery.SELECT?.from.args && (!originalQuery.joinTree || originalQuery.joinTree.isInitial)

if (!hasCustomJoins && inferred.SELECT?.search) {
// we need an instance of query because the elements of the query are needed for the calculation of the search columns
if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, Object.getPrototypeOf(SELECT()))
const searchTerm = getSearchTerm(inferred.SELECT.search, inferred)
if (searchTerm) {
// Search target can be a navigation, in that case use _target to get the correct entity
const { where, having } = transformSearch(searchTerm)
if (where) inferred.SELECT.where = where
else if (having) inferred.SELECT.having = having
}
}
inferred = infer(inferred, model)
// if the query has custom joins we don't want to transform it
// TODO: move all the way to the top of this function once cds.infer supports joins as well
// we need to infer the query even if no transformation will happen because cds.infer can't calculate the target
if (hasCustomJoins) return originalQuery

let transformedQuery = cds.ql.clone(inferred)
const kind = inferred.kind || Object.keys(inferred)[0]
Expand Down Expand Up @@ -111,7 +128,7 @@ function cqn4sql(originalQuery, model) {

// calculate the primary keys of the target entity, there is always exactly
// one query source for UPDATE / DELETE
const queryTarget = Object.values(originalQuery.sources)[0].definition
const queryTarget = Object.values(inferred.sources)[0].definition
const keys = Object.values(queryTarget.elements).filter(e => e.key === true)
const primaryKey = { list: [] }
keys.forEach(k => {
Expand Down Expand Up @@ -155,7 +172,7 @@ function cqn4sql(originalQuery, model) {
if (columns) {
transformedQuery.SELECT.columns = getTransformedColumns(columns)
} else {
transformedQuery.SELECT.columns = getColumnsForWildcard(originalQuery.SELECT?.excluding)
transformedQuery.SELECT.columns = getColumnsForWildcard(inferred.SELECT?.excluding)
}

// Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
Expand All @@ -178,13 +195,6 @@ function cqn4sql(originalQuery, 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, having } = transformSearch(inferred.SELECT.search, transformedFrom) || {}
if (where) transformedQuery.SELECT.where = where
else if (having) transformedQuery.SELECT.having = having
}
return transformedQuery
}

Expand All @@ -206,46 +216,29 @@ function cqn4sql(originalQuery, model) {
* Transforms a search expression into a WHERE or HAVING clause for a SELECT operation, depending on the context of the query.
* The function decides whether to use a WHERE or HAVING clause based on the presence of aggregated columns in the search criteria.
*
* @param {object} search - The search expression to be applied to the searchable columns within the query source.
* @param {object} searchTerm - The search expression to be applied to the searchable columns within the query source.
* @param {object} from - The FROM clause of the CQN statement.
*
* @returns {(Object|Array|null)} - The function returns an object representing the WHERE or HAVING clause of the query:
* - If the target of the query contains searchable elements, an array representing the WHERE or HAVING clause is returned.
* This includes appending to an existing clause with an AND condition or creating a new clause solely with the 'contains' clause.
* - If the SELECT query does not initially contain a WHERE or HAVING clause, the returned object solely consists of the 'contains' clause.
* - If the target entity of the query does not contain searchable elements, the function returns null.
* @returns {Object} - The function returns an object representing the WHERE or HAVING clause of the query.
*
* Note: The WHERE clause is used for filtering individual rows before any aggregation occurs.
* The HAVING clause is utilized for conditions on aggregated data, applied after grouping operations.
*/
function transformSearch(search, from) {
const entity = getDefinition(from.$refLinks[0].definition.target) || from.$refLinks[0].definition
// pass transformedQuery because we may need to search in the columns directly
// in case of aggregation
const searchIn = computeColumnsToBeSearched(transformedQuery, 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 the query is grouped and the queries columns contain an aggregate function,
// we must put the search term into the `having` clause, as the search expression
// is defined on the aggregated result, not on the individual rows
let prop = 'where'

if (inferred.SELECT.groupBy && searchIn.some(c => c.func || c.xpr)) prop = 'having'
if (transformedQuery.SELECT[prop]) {
return { [prop]: [asXpr(transformedQuery.SELECT.where), 'and', contains] }
} else {
return { [prop]: [contains] }
}
function transformSearch(searchTerm) {
let prop = 'where'

// if the query is grouped and the queries columns contain an aggregate function,
// we must put the search term into the `having` clause, as the search expression
// is defined on the aggregated result, not on the individual rows
const usesAggregation =
inferred.SELECT.groupBy &&
(searchTerm.args[0].func || searchTerm.args[0].xpr || searchTerm.args[0].list?.some(c => c.func || c.xpr))

if (usesAggregation) prop = 'having'
if (inferred.SELECT[prop]) {
return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
} else {
return null
return { [prop]: [searchTerm] }
}
}

Expand Down Expand Up @@ -484,7 +477,7 @@ function cqn4sql(originalQuery, model) {
if (replaceWith === -1) transformedColumns.push(transformedColumn)
else transformedColumns.splice(replaceWith, 1, transformedColumn)

setElementOnColumns(transformedColumn, originalQuery.elements[col.as])
setElementOnColumns(transformedColumn, inferred.elements[col.as])
}

function getTransformedColumn(col) {
Expand Down Expand Up @@ -812,7 +805,8 @@ function cqn4sql(originalQuery, model) {

// we need to respect the aliases of the outer query, so the columnAlias might not be suitable
// as table alias for the correlated subquery
const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, originalQuery.outerQueries)
const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, inferred.outerQueries)

// `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
const subqueryBase = Object.fromEntries(
Expand All @@ -830,7 +824,10 @@ function cqn4sql(originalQuery, model) {
}
const expanded = transformSubquery(subquery)
const correlated = _correlate({ ...expanded, as: columnAlias }, outerAlias)
Object.defineProperty(correlated, 'elements', { value: subquery.elements, writable: true })
Object.defineProperty(correlated, 'elements', {
value: expanded.elements,
writable: true,
})
return correlated

function _correlate(subq, outer) {
Expand Down Expand Up @@ -1051,7 +1048,7 @@ function cqn4sql(originalQuery, model) {
const last = q.SELECT.from.ref.at(-1)
const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
getLastStringSegment(last.id || last),
originalQuery.outerQueries,
inferred.outerQueries,
)
Object.defineProperty(q.SELECT.from, 'uniqueSubqueryAlias', { value: uniqueSubqueryAlias })
}
Expand Down Expand Up @@ -1659,7 +1656,7 @@ function cqn4sql(originalQuery, model) {
transformedFrom.as = from.as
} else {
// select from anonymous query, use artificial alias
transformedFrom.as = Object.keys(originalQuery.sources)[0]
transformedFrom.as = Object.keys(inferred.sources)[0]
}
return { transformedFrom, transformedWhere: existingWhere }
} else {
Expand Down Expand Up @@ -1702,7 +1699,7 @@ function cqn4sql(originalQuery, model) {
* --> This is an artificial query, which will later be correlated
* with the main query alias. see @function expandColumn()
*/
if (!(originalQuery.SELECT?.expand === true)) {
if (!(inferred.SELECT?.expand === true)) {
as = getNextAvailableTableAlias(as)
}
nextStepLink.alias = as
Expand Down Expand Up @@ -2162,6 +2159,35 @@ function cqn4sql(originalQuery, model) {
return getDefinition(assoc.target) || null
}

/**
* 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} query - 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` nor in the target entity itself.
*/
function getSearchTerm(search, query) {
const entity = query.SELECT.from.SELECT ? query.SELECT.from : query.target
const searchIn = computeColumnsToBeSearched(inferred, entity)
if (searchIn.length > 0) {
const xpr = search
const searchFunc = {
func: 'search',
args: [
searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] },
xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr },
],
}
return searchFunc
} else {
return null
}
}

/**
* Calculates the name of the source which can be used to address the given node.
*
Expand Down Expand Up @@ -2207,11 +2233,11 @@ function cqn4sql(originalQuery, model) {
* - but differs from the explicit alias, assigned by cqn4sql (i.e. <subquery>.from.uniqueSubqueryAlias)
*/
if (
originalQuery.SELECT?.from.uniqueSubqueryAlias &&
!originalQuery.SELECT?.from.as &&
inferred.SELECT?.from.uniqueSubqueryAlias &&
!inferred.SELECT?.from.as &&
firstStep === getLastStringSegment(transformedQuery.SELECT.from.ref[0])
) {
return originalQuery.SELECT?.from.uniqueSubqueryAlias
return inferred.SELECT?.from.uniqueSubqueryAlias
}
return node.ref[0]
}
Expand Down
2 changes: 1 addition & 1 deletion db-service/lib/infer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ for (const each in cdsTypes) cdsTypes[`cds.${each}`] = cdsTypes[each]
*/
function infer(originalQuery, model) {
if (!model) throw new Error('Please specify a model')
const inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
const inferred = originalQuery

// REVISIT: The more edge use cases we support, thes less optimized are we for the 90+% use cases
// e.g. there's a lot of overhead for infer( SELECT.from(Books) )
Expand Down
Loading

0 comments on commit e9e9461

Please sign in to comment.