Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cqn4sql/infer): support for deep search #252

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
97148a8
cqn4sql/search: get rid of dependency of internal API
patricebender Sep 22, 2023
1a4e792
simplify
patricebender Sep 22, 2023
3ad91d8
Merge branch 'main' into search
patricebender Sep 25, 2023
89a90d1
WIP
patricebender Sep 26, 2023
bb5acd3
proof of concept
patricebender Sep 26, 2023
4109674
ref based search
patricebender Sep 27, 2023
e14a45d
more tests with path expressions and calc elements
patricebender Sep 27, 2023
3817bc2
Merge branch 'search' into search-w-paths
patricebender Sep 27, 2023
0f9cbd0
revert changes in hxe
patricebender Sep 27, 2023
e37e57a
trailing ws
patricebender Sep 27, 2023
f70de58
calculated elements for search must be requested
patricebender Sep 27, 2023
6a4d075
Merge branch 'main' into search
patricebender Sep 27, 2023
157056a
restructure tests, add test for caching
patricebender Sep 27, 2023
78b9901
Merge branch 'search' into search-w-paths
patricebender Sep 28, 2023
9688152
cleanup search function
patricebender Sep 29, 2023
f5f5954
cleanup search function
patricebender Sep 29, 2023
cb30b99
Revert "cleanup search function"
patricebender Oct 5, 2023
36a4ab6
Merge branch 'main' into search
patricebender Oct 6, 2023
7f28bc8
Merge branch 'search' into search-w-paths
patricebender Oct 6, 2023
f89358e
Merge remote-tracking branch 'origin/main' into search-w-paths
patricebender Oct 18, 2023
d994e52
Merge branch 'main' into search-w-paths
patricebender Oct 23, 2023
2400870
Merge branch 'main' into search-w-paths
patricebender Nov 2, 2023
29d01aa
Merge branch 'main' into search-w-paths
patricebender Dec 5, 2023
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
70 changes: 16 additions & 54 deletions db-service/lib/cqn4sql.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
'use strict'

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

const infer = require('./infer')

/**
Expand Down Expand Up @@ -57,10 +55,10 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
if (!inferred.STREAM?.from && inferred.STREAM?.into) {
transformedQuery = transformStreamQuery()
} else {
const { entity, where } = queryProp
const { entity } = queryProp
const from = queryProp.from

const transformedProp = { __proto__: queryProp } // IMPORTANT: don't lose anything you might not know of
const { where } = queryProp

// Transform the existing where, prepend table aliases, and so on...
if (where) {
Expand All @@ -69,10 +67,17 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {

// Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
// The already transformed `where` clause is then glued together with the resulting subqueries.
const { transformedWhere, transformedFrom } = getTransformedFrom(from || entity, transformedProp.where)
let { transformedWhere, transformedFrom } = getTransformedFrom(from || entity, transformedProp.where)
const queryNeedsJoins = inferred.joinTree && !inferred.joinTree.isInitial

if (inferred.SELECT) {
if (inferred.SELECT.search && inferred.SELECT.search.searchTerm) {
const searchTerm = getTransformedTokenStream([inferred.SELECT.search.searchTerm])
if(transformedWhere.length)
transformedWhere = [asXpr(transformedWhere), 'and', ...searchTerm]
else
transformedWhere = searchTerm
}
transformedQuery = transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery)
} else {
if (from) {
Expand Down Expand Up @@ -182,14 +187,6 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.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 = transformSearchToWhere(inferred.SELECT.search, transformedFrom)
if (where) {
transformedQuery.SELECT.where = where
}
}
return transformedQuery
}

Expand Down Expand Up @@ -230,41 +227,6 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
return transformedQuery
}

/**
* Transforms a search expression to a WHERE clause for a SELECT operation.
*
* @param {object} search - The search expression which shall be applied to the searchable columns on the query source.
* @param {object} from - The FROM clause of the CQN statement.
*
* @returns {(Object|Array|undefined)} - If the target of the query contains searchable elements, the function returns an array that represents the WHERE clause.
* If the SELECT query already contains a WHERE clause, this array includes the existing clause and appends an AND condition with the new 'contains' clause.
* If the SELECT query does not contain a WHERE clause, the returned array solely consists of the 'contains' clause.
* If the target entity of the query does not contain searchable elements, the function returns null.
*
*/
function transformSearchToWhere(search, from) {
const entity = from.$refLinks[0].definition._target || from.$refLinks[0].definition
const searchIn = computeColumnsToBeSearched(inferred, 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 (transformedQuery.SELECT.where) {
return [asXpr(transformedQuery.SELECT.where), 'and', contains]
} else {
return [contains]
}
} else {
return null
}
}

/**
* Rewrites the from clause based on the `query.joinTree`.
*
Expand Down Expand Up @@ -1734,10 +1696,10 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
if (rhs?.ref || lhs.ref) {
// if we have refs on each side of the comparison, we might need to perform tuple expansion
// or flatten the structures
const refLinkFaker = thing => {
const { ref } = thing
const adHocRefLinks = column => {
const { ref } = column
const assocHost = getParentEntity(assocRefLink.definition)
Object.defineProperty(thing, '$refLinks', {
Object.defineProperty(column, '$refLinks', {
value: [],
writable: true,
})
Expand All @@ -1747,16 +1709,16 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
return prev
const definition = prev?.elements?.[res] || prev?._target?.elements[res] || pseudos.elements[res]
const target = getParentEntity(definition)
thing.$refLinks[i] = { definition, target, alias: definition.name }
column.$refLinks[i] = { definition, target, alias: definition.name }
return prev?.elements?.[res] || prev?._target?.elements[res] || pseudos.elements[res]
}, assocHost)
}

// comparison in on condition needs to be expanded...
// re-use existing algorithm for that
// we need to fake some $refLinks for that to work though...
lhs?.ref && !lhs.$refLinks && refLinkFaker(lhs)
rhs?.ref && !rhs.$refLinks && refLinkFaker(rhs)
lhs?.ref && !lhs.$refLinks && adHocRefLinks(lhs)
rhs?.ref && !rhs.$refLinks && adHocRefLinks(rhs)
}

let backlink
Expand Down
42 changes: 41 additions & 1 deletion db-service/lib/infer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

const cds = require('@sap/cds/lib')

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

const JoinTree = require('./join-tree')
const { pseudos } = require('./pseudos')
const cdsTypes = cds.linked({
Expand Down Expand Up @@ -278,7 +280,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
*/
function inferQueryElements($combinedElements) {
let queryElements = {}
const { columns, where, groupBy, having, orderBy } = _
const { columns, where, groupBy, having, orderBy, search } = _
if (!columns) {
inferElementsFromWildCard(aliases)
} else {
Expand Down Expand Up @@ -347,6 +349,16 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
if (_.with)
// consider UPDATE.with
Object.values(_.with).forEach(val => inferQueryElement(val, false))
if (search) {
const searchTerm = getSearchTerm(inferred.SELECT.search, inferred.SELECT.from, inferred.SELECT.where)
if (searchTerm) {
searchTerm.args.forEach(arg => inferQueryElement(arg, false))
Object.defineProperty(search, 'searchTerm', {
writable: true,
value: searchTerm,
})
}
}

return queryElements

Expand Down Expand Up @@ -1130,6 +1142,34 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
return res !== '' ? res + dot + cur.definition.name : cur.definition.name
}, '')
}
/**
* 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} from - 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` or in the target entity itself.
*/
function getSearchTerm(search, from) {
const entity = from.$refLinks.at(-1).definition._target || from.$refLinks.at(-1).definition
const searchIn = computeColumnsToBeSearched(inferred, 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 },
],
}
return contains
} else {
return null
}
}
}
const idOnly = ref => ref.id || ref

Expand Down
88 changes: 35 additions & 53 deletions db-service/lib/search.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
'use strict'

const DRAFT_COLUMNS_UNION = {
IsActiveEntity: 1,
HasActiveEntity: 1,
HasDraftEntity: 1,
DraftAdministrativeData_DraftUUID: 1,
SiblingEntity: 1,
DraftAdministrativeData: 1,
}
const DEFAULT_SEARCHABLE_TYPE = 'cds.String'

/**
Expand All @@ -26,20 +18,16 @@ const DEFAULT_SEARCHABLE_TYPE = 'cds.String'
*/
const getColumns = (
entity,
{ onlyNames = false, removeIgnore = false, filterDraft = true, filterVirtual = false, keysOnly = false },
{ removeIgnore = false, filterVirtual = false},
) => {
const skipDraft = filterDraft && entity._isDraftEnabled
const columns = []
const elements = entity.elements

for (const each in elements) {
const element = elements[each]
if (element.isAssociation) continue
if (filterVirtual && element.virtual) continue
if (removeIgnore && element['@cds.api.ignore']) continue
if (skipDraft && each in DRAFT_COLUMNS_UNION) continue
if (keysOnly && !element.key) continue
columns.push(onlyNames ? each : element)
columns.push(element)
}

return columns
Expand All @@ -63,20 +51,21 @@ const _getSearchableColumns = entity => {
}

let atLeastOneColumnIsSearchable = 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()

// REVISIT: for now, exclude search using path expression, as deep search is not currently
// supported
if (columnName.includes('.')) {
continue
}

const annotationKey = `${cdsSearchTerm}.${columnName}`
const annotationValue = entity[annotationKey]
if (annotationValue) atLeastOneColumnIsSearchable = true
const column = entity.elements[columnName]

if (column?.isAssociation || columnName.includes('.')) {
deepSearchCandidates.push({ ref: columnName.split('.') })
continue;
}
cdsSearchColumnMap.set(columnName, annotationValue)
}

Expand All @@ -87,6 +76,9 @@ const _getSearchableColumns = entity => {
// `@cds.search { element1: true }` or `@cds.search { element1 }`
if (annotatedColumnValue) return true

// calculated elements are only searchable if requested through `@cds.search`
if(column.value) return false

// 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
Expand All @@ -101,15 +93,29 @@ const _getSearchableColumns = entity => {
)
})

// if the @cds.search annotation is provided -->
// Early return to ignore the interpretation of the @Search.defaultSearchElement
// annotation when an entity is annotated with the @cds.search annotation.
// The @cds.search annotation overrules the @Search.defaultSearchElement annotation.
if (cdsSearchKeys.length > 0) {
return searchableColumns.map(column => column.name)
if (deepSearchCandidates.length) {
deepSearchCandidates.forEach(c => {
const element = c.ref.reduce((resolveIn, curr, i) => {
const next = resolveIn.elements?.[curr] || resolveIn._target.elements[curr]
if (next.isAssociation && !c.ref[i + 1]) {
const searchInTarget = _getSearchableColumns(next._target)
searchInTarget.forEach(elementRefInTarget => {
searchableColumns.push({ ref: c.ref.concat(...elementRefInTarget.ref) })
})
}
return next
}, entity)
if (element?.type === DEFAULT_SEARCHABLE_TYPE) {
searchableColumns.push({ ref: c.ref })
}
})
}

return searchableColumns.map(column => column.name)
return searchableColumns.map(column => {
if(column.ref)
return column
return { ref: [ column.name ] }
})
}

/**
Expand All @@ -121,35 +127,11 @@ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, a
// aggregations case
// in the new parser groupBy is moved to sub select.
if (cqn._aggregated || /* new parser */ cqn.SELECT.groupBy || cqn.SELECT?.from?.SELECT?.groupBy) {
cqn.SELECT.columns &&
cqn.SELECT.columns.forEach(column => {
if (column.func) {
// exclude $count by SELECT of number of Items in a Collection
if (
cqn.SELECT.columns.length === 1 &&
column.func === 'count' &&
(column.as === '_counted_' || column.as === '$count')
) {
return
}

toBeSearched.push(column)
return
}

const columnRef = column.ref
if (columnRef) {
if (entity.elements[columnRef[columnRef.length - 1]]?._type !== DEFAULT_SEARCHABLE_TYPE) return
column = { ref: [...column.ref] }
if (alias) column.ref.unshift(alias)
toBeSearched.push(column)
}
})
// REVISIT: No search for aggregation case for the moment
} else {
toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
if (cqn.SELECT.groupBy) toBeSearched = toBeSearched.filter(tbs => cqn.SELECT.groupBy.some(gb => gb.ref[0] === tbs))
toBeSearched = toBeSearched.map(c => {
const col = { ref: [c] }
const col = {ref: [...c.ref]}
if (alias) col.ref.unshift(alias)
return col
})
Expand Down
12 changes: 6 additions & 6 deletions db-service/test/bookshop/db/schema.cds
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,9 @@ entity PartialStructuredKey {
toSelf: Association to PartialStructuredKey { struct.one as partial}
}

entity Reproduce {
key ID : Integer;
title : String(5000);
author : Association to Authors;
accessGroup : Composition of AccessGroups;
}
entity Reproduce {
key ID : Integer;
title : String(5000);
author : Association to Authors;
accessGroup : Composition of AccessGroups;
}
Loading
Loading