@cap-js/db-service
Version:
CDS base database service
206 lines (180 loc) • 7.08 kB
JavaScript
'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'
// only those which return strings are relevant for search
const aggregateFunctions = {
MAX: true,
MIN: true,
}
/**
* This method gets all columns for an entity.
* It includes the generated foreign keys from managed associations, structured elements and complex and custom types.
* Moreover, it provides the annotations starting with '@' for each column.
*
* @param {object} entity - the csn entity
* @param {object} [options]
* @param [options.onlyNames=false] - decides if the column name or the csn representation of the column should be returned
* @param [options.filterDraft=false] - indicates whether the draft columns should be filtered if the entity is draft enabled
* @param [options.removeIgnore=false]
* @param [options.filterVirtual=false]
* @param [options.keysOnly=false]
* @returns {Array<object>} - array of columns
*/
const getColumns = (
entity,
{ onlyNames = false, removeIgnore = false, filterDraft = true, filterVirtual = false, keysOnly = false },
) => {
const columns = []
const elements = entity.elements
for (const each in elements) {
const element = elements[each]
if (filterVirtual && element.virtual) continue
if (removeIgnore && element['@cds.api.ignore']) continue
if (filterDraft && each in DRAFT_COLUMNS_UNION) continue
if (keysOnly && !element.key) continue
columns.push(onlyNames ? each : element)
}
return columns
}
const _isColumnCalculated = (query, columnName) => {
if (!query) return false
if (query.SELECT?.columns?.find(col => col.xpr && col.as === columnName)) return true
return _isColumnCalculated(query._target?.query, columnName)
}
const _getSearchableColumns = entity => {
const columnsOptions = { removeIgnore: true, filterVirtual: true }
const columns = entity.SELECT?.columns || getColumns(entity, columnsOptions)
const cdsSearchTerm = '@cds.search'
const cdsSearchKeys = []
const cdsSearchColumnMap = new Map()
for (const key in entity) {
if (key.startsWith(cdsSearchTerm)) cdsSearchKeys.push(key)
}
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()
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)
}
const searchableColumns = columns.filter(column => {
const annotatedColumnValue = cdsSearchColumnMap.get(column.name)
const elementName = column.as || column.ref?.at(-1) || column.name
const element = entity.elements[elementName]
// the element is searchable if it is annotated with the @cds.search, e.g.:
// `@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
if (atLeastOneColumnIsSearchable) return false
// the element is considered searchable if it is explicitly annotated as such or
// if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
return (
annotatedColumnValue === undefined &&
element?.type === DEFAULT_SEARCHABLE_TYPE &&
!_isColumnCalculated(entity?.query, 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 => {
if(column.ref)
return column
return { ref: [ column.name ] }
})
}
/**
* @returns {Array<object>} - array of columns
*/
const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }) => {
let toBeSearched = []
// 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?.forEach(column => {
const elementName = column.as || column.ref?.at(-1) || column.name
const element = cqn.elements[elementName]
if (column.func || column.xpr) {
// 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
}
// only strings can be searched
if (element?.type !== DEFAULT_SEARCHABLE_TYPE) {
if (column.xpr) return
if (column.func && !(column.func in aggregateFunctions)) return
}
const searchTerm = {}
if (column.func) {
searchTerm.func = column.func
searchTerm.args = column.args
} else if (column.xpr) {
searchTerm.xpr = column.xpr
}
toBeSearched.push(searchTerm)
return
}
// no need to set ref[0] to alias, because columns were already properly transformed
if (column.ref) {
if (element?.type !== DEFAULT_SEARCHABLE_TYPE) return
column = { ref: [...column.ref] }
toBeSearched.push(column)
return
}
})
} else {
if(entity.kind === 'entity') {
// first check cache
toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
} else {
// if we search on a subquery, we don't have a cache
toBeSearched = _getSearchableColumns(entity)
}
toBeSearched = toBeSearched.map(c => {
const column = {ref: [...c.ref]}
return column
})
}
return toBeSearched
}
module.exports = {
getColumns,
computeColumnsToBeSearched,
}