@cap-js/db-service 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@
4
4
  - The format is based on [Keep a Changelog](http://keepachangelog.com/).
5
5
  - This project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ ## [1.4.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.3.2...db-service-v1.4.0) (2023-11-20)
8
+
9
+
10
+ ### Added
11
+
12
+ * **`UPDATE`/`DELETE`:** Enable path expressions for improved data manipulation ([#325](https://github.com/cap-js/cds-dbs/issues/325)) ([94f0776](https://github.com/cap-js/cds-dbs/commit/94f077661cffad8f137dc692a2cb9b0ae5e4d75b))
13
+ * **temporal data:** add time slice key to conflict clause ([#249](https://github.com/cap-js/cds-dbs/issues/249)) ([67b8edf](https://github.com/cap-js/cds-dbs/commit/67b8edf9b7f6b0fbab0010d7c93ed03a01e103ed))
14
+ * use place holders for update and delete ([#323](https://github.com/cap-js/cds-dbs/issues/323)) ([81472b9](https://github.com/cap-js/cds-dbs/commit/81472b971183f701e401247611310be56745a87a))
15
+
16
+
17
+ ### Fixed
18
+
19
+ * align time function behavior ([#322](https://github.com/cap-js/cds-dbs/issues/322)) ([c3ab40a](https://github.com/cap-js/cds-dbs/commit/c3ab40a007c105465349dd2f612178367b8e713a))
20
+ * **calculated elements:** path expressions in `func.args` within `xpr` ([#321](https://github.com/cap-js/cds-dbs/issues/321)) ([cee25e3](https://github.com/cap-js/cds-dbs/commit/cee25e33cf289592a87779cfa34dddc53e467676))
21
+ * Disconnect db service on shutdown ([#327](https://github.com/cap-js/cds-dbs/issues/327)) ([8471bda](https://github.com/cap-js/cds-dbs/commit/8471bda44fc030205abec45b1581b2cf6ed7c800))
22
+ * non-fk access in filter conditions are properly rejected ([#336](https://github.com/cap-js/cds-dbs/issues/336)) ([4c948fe](https://github.com/cap-js/cds-dbs/commit/4c948fecead1de562e1583886516413e131a39aa))
23
+ * **search:** check calculated columns at any depth ([#310](https://github.com/cap-js/cds-dbs/issues/310)) ([8fd6153](https://github.com/cap-js/cds-dbs/commit/8fd6153dfcd472a6d95c33faa58c4b3f96f485df))
24
+
7
25
  ## [1.3.2](https://github.com/cap-js/cds-dbs/compare/db-service-v1.3.1...db-service-v1.3.2) (2023-10-13)
8
26
 
9
27
 
@@ -6,10 +6,16 @@ const cds = require('@sap/cds/lib')
6
6
  /** @typedef {unknown} DatabaseDriver */
7
7
 
8
8
  class DatabaseService extends cds.Service {
9
+
10
+ init() {
11
+ cds.on('shutdown', () => this.disconnect())
12
+ return super.init()
13
+ }
14
+
9
15
  /**
10
16
  * Dictionary of connection pools per tenant
11
17
  */
12
- pools = { _factory: this.factory }
18
+ pools = Object.setPrototypeOf({}, { _factory: this.factory })
13
19
 
14
20
  /**
15
21
  * Return a pool factory + options property as expected by
@@ -44,7 +50,9 @@ class DatabaseService extends cds.Service {
44
50
  async begin() {
45
51
  // We expect tx.begin() being called for an txed db service
46
52
  const ctx = this.context
47
- if (!ctx) return this.tx().begin() // REVISIT: Is this correct? When does this happen?
53
+
54
+ // If .begin is called explicitly it starts a new transaction and executes begin
55
+ if (!ctx) return this.tx().begin()
48
56
 
49
57
  // REVISIT: tenant should be undefined if !this.isMultitenant
50
58
  let isMultitenant = 'multiTenant' in this.options ? this.options.multiTenant : cds.env.requires.multitenancy
@@ -111,11 +119,18 @@ class DatabaseService extends cds.Service {
111
119
  * @param {string} tenant
112
120
  */
113
121
  async disconnect(tenant) {
114
- const pool = this.pools[tenant]
115
- if (!pool) return
116
- await pool.drain()
117
- await pool.clear()
118
- delete this.pools[tenant]
122
+ const _disconnect = async tenant => {
123
+ const pool = this.pools[tenant]
124
+ if (!pool) {
125
+ return
126
+ }
127
+ await pool.drain()
128
+ await pool.clear()
129
+ delete this.pools[tenant]
130
+ }
131
+ if (tenant == null)
132
+ return Promise.all(Object.keys(this.pools).map(_disconnect))
133
+ return _disconnect(tenant)
119
134
  }
120
135
 
121
136
  /**
@@ -149,37 +149,37 @@ const StandardFunctions = {
149
149
  * Generates SQL statement that produces the year of a given timestamp
150
150
  * @param {string} x
151
151
  * @returns {string}
152
- */
152
+ * /
153
153
  year: x => `cast( strftime('%Y',${x}) as Integer )`,
154
154
  /**
155
155
  * Generates SQL statement that produces the month of a given timestamp
156
156
  * @param {string} x
157
157
  * @returns {string}
158
- */
158
+ * /
159
159
  month: x => `cast( strftime('%m',${x}) as Integer )`,
160
160
  /**
161
161
  * Generates SQL statement that produces the day of a given timestamp
162
162
  * @param {string} x
163
163
  * @returns {string}
164
- */
164
+ * /
165
165
  day: x => `cast( strftime('%d',${x}) as Integer )`,
166
166
  /**
167
167
  * Generates SQL statement that produces the hours of a given timestamp
168
168
  * @param {string} x
169
169
  * @returns {string}
170
- */
170
+ * /
171
171
  hour: x => `cast( strftime('%H',${x}) as Integer )`,
172
172
  /**
173
173
  * Generates SQL statement that produces the minutes of a given timestamp
174
174
  * @param {string} x
175
175
  * @returns {string}
176
- */
176
+ * /
177
177
  minute: x => `cast( strftime('%M',${x}) as Integer )`,
178
178
  /**
179
179
  * Generates SQL statement that produces the seconds of a given timestamp
180
180
  * @param {string} x
181
181
  * @returns {string}
182
- */
182
+ * /
183
183
  second: x => `cast( strftime('%S',${x}) as Integer )`,
184
184
 
185
185
  /**
package/lib/cqn2sql.js CHANGED
@@ -70,7 +70,7 @@ class CQN2SQLRenderer {
70
70
  /** @type {unknown[]} */
71
71
  this.values = [] // prepare values, filled in by subroutines
72
72
  this[cmd]((this.cqn = q)) // actual sql rendering happens here
73
- if (vars?.length && !this.values.length) this.values = vars
73
+ if (vars?.length && !this.values?.length) this.values = vars
74
74
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
75
75
  DEBUG?.(
76
76
  this.sql,
@@ -527,6 +527,9 @@ class CQN2SQLRenderer {
527
527
  .filter(c => !keys.includes(c))
528
528
  .map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
529
529
 
530
+ // temporal data
531
+ keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name))
532
+
530
533
  keys = keys.map(k => this.quote(k))
531
534
  const conflict = updateColumns.length
532
535
  ? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns
@@ -872,7 +875,6 @@ class CQN2SQLRenderer {
872
875
 
873
876
  let val = _managed[element[annotation]?.['=']]
874
877
  if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val }] })})`
875
-
876
878
  else if (!isUpdate && element.default) {
877
879
  const d = element.default
878
880
  if (d.val !== undefined || d.ref?.[0] === '$now') {
package/lib/cqn4sql.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const cds = require('@sap/cds/lib')
4
- const { computeColumnsToBeSearched } = require('@sap/cds/libx/_runtime/cds-services/services/utils/columns.js')
4
+ const { computeColumnsToBeSearched } = require('./search')
5
5
 
6
6
  const infer = require('./infer')
7
7
 
@@ -96,7 +96,46 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
96
96
  }
97
97
 
98
98
  if (queryNeedsJoins) {
99
- transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
99
+ if (inferred.UPDATE || inferred.DELETE) {
100
+ const prop = inferred.UPDATE ? 'UPDATE' : 'DELETE'
101
+ const subquery = {
102
+ SELECT: {
103
+ from: { ...transformedFrom },
104
+ columns: [], // primary keys of the query target will be added later
105
+ where: [...transformedProp.where],
106
+ },
107
+ }
108
+ // The alias of the original query is now the alias for the subquery
109
+ // so that potential references in the where clause to the alias match.
110
+ // Hence, replace the alias of the original query with the next
111
+ // available alias, so that each alias is unique.
112
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(transformedFrom.as)
113
+ transformedFrom.as = uniqueSubqueryAlias
114
+
115
+ // calculate the primary keys of the target entity, there is always exactly
116
+ // one query source for UPDATE / DELETE
117
+ const queryTarget = Object.values(originalQuery.sources)[0]
118
+ const keys = Object.values(queryTarget.elements).filter(e => e.key === true)
119
+ const primaryKey = { list: [] }
120
+ keys.forEach(k => {
121
+ // cqn4sql will add the table alias to the column later, no need to add it here
122
+ subquery.SELECT.columns.push({ ref: [k.name] })
123
+
124
+ // add the alias of the main query to the list of primary key references
125
+ primaryKey.list.push({ ref: [transformedFrom.as, k.name] })
126
+ })
127
+
128
+ const transformedSubquery = cqn4sql(subquery)
129
+
130
+ // replace where condition of original query with the transformed subquery
131
+ // correlate UPDATE / DELETE query with subquery by primary key matches
132
+ transformedQuery[prop].where = [primaryKey, 'in', transformedSubquery]
133
+
134
+ if (prop === 'UPDATE') transformedQuery.UPDATE.entity = transformedFrom
135
+ else transformedQuery.DELETE.from = transformedFrom
136
+ } else {
137
+ transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
138
+ }
100
139
  }
101
140
  }
102
141
  }
@@ -499,20 +499,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
499
499
  const elements = definition.elements || definition._target?.elements
500
500
  if (elements && id in elements) {
501
501
  const element = elements[id]
502
- if (!inNestedProjection && !inCalcElement && element.target) {
503
- // only fk access in infix filter
504
- const nextStep = column.ref[1]?.id || column.ref[1]
505
- // no unmanaged assoc in infix filter path
506
- if (!inExists && element.on)
507
- throw new Error(
508
- `"${element.name}" in path "${column.ref
509
- .map(idOnly)
510
- .join('.')}" must not be an unmanaged association`,
511
- )
512
- // no non-fk traversal in infix filter
513
- if (nextStep && element.foreignKeys && !(nextStep in element.foreignKeys))
514
- throw new Error(`Only foreign keys of "${element.name}" can be accessed in infix filter`)
515
- }
502
+ rejectNonFkAccess(element)
516
503
  const resolvableIn = definition.target ? definition._target : target
517
504
  column.$refLinks.push({ definition: elements[id], target: resolvableIn })
518
505
  } else {
@@ -557,6 +544,8 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
557
544
 
558
545
  const target = definition._target || column.$refLinks[i - 1].target
559
546
  if (element) {
547
+ if($baseLink)
548
+ rejectNonFkAccess(element)
560
549
  const $refLink = { definition: elements[id], target }
561
550
  column.$refLinks.push($refLink)
562
551
  } else if (firstStepIsSelf) {
@@ -658,6 +647,29 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
658
647
  }
659
648
  }
660
649
  }
650
+
651
+ /**
652
+ * Check if the next step in the ref is foreign key of `element`
653
+ * if not, an error is thrown.
654
+ *
655
+ * @param {CSN.Element} element if this is an association, the next step must be a foreign key of the element.
656
+ */
657
+ function rejectNonFkAccess(element) {
658
+ if (!inNestedProjection && !inCalcElement && element.target) {
659
+ // only fk access in infix filter
660
+ const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
661
+ // no unmanaged assoc in infix filter path
662
+ if (!inExists && element.on)
663
+ throw new Error(
664
+ `"${element.name}" in path "${column.ref
665
+ .map(idOnly)
666
+ .join('.')}" must not be an unmanaged association`
667
+ )
668
+ // no non-fk traversal in infix filter
669
+ if (nextStep && element.foreignKeys && !(nextStep in element.foreignKeys))
670
+ throw new Error(`Only foreign keys of "${element.name}" can be accessed in infix filter`)
671
+ }
672
+ }
661
673
  })
662
674
 
663
675
  // ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
@@ -680,10 +692,6 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
680
692
  ? { ref: [...baseColumn.ref, ...column.ref], $refLinks: [...baseColumn.$refLinks, ...column.$refLinks] }
681
693
  : column
682
694
  if (isColumnJoinRelevant(colWithBase)) {
683
- if (originalQuery.UPDATE)
684
- throw new Error(
685
- 'Path expressions for UPDATE statements are not supported. Use “where exists” with infix filters instead.',
686
- )
687
695
  Object.defineProperty(column, 'isJoinRelevant', { value: true })
688
696
  joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
689
697
  }
@@ -873,20 +881,26 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
873
881
  if (leafOfCalculatedElementRef.value) mergePathsIntoJoinTree(leafOfCalculatedElementRef.value, basePath)
874
882
 
875
883
  mergePathIfNecessary(basePath, arg)
876
- } else if (arg.xpr) {
877
- arg.xpr.forEach(step => {
884
+ } else if (arg.xpr || arg.args) {
885
+ const prop = arg.xpr ? 'xpr' : 'args'
886
+ arg[prop].forEach(step => {
887
+ const subPath = { $refLinks: [...basePath.$refLinks], ref: [...basePath.ref] }
878
888
  if (step.ref) {
879
- const subPath = { $refLinks: [...basePath.$refLinks], ref: [...basePath.ref] }
880
889
  step.$refLinks.forEach((link, i) => {
881
890
  const { definition } = link
882
891
  if (definition.value) {
883
- mergePathsIntoJoinTree(definition.value)
892
+ mergePathsIntoJoinTree(definition.value, subPath)
884
893
  } else {
885
894
  subPath.$refLinks.push(link)
886
895
  subPath.ref.push(step.ref[i])
887
896
  }
888
897
  })
889
898
  mergePathIfNecessary(subPath, step)
899
+ } else if (step.args || step.xpr) {
900
+ const nestedProp = step.xpr ? 'xpr' : 'args'
901
+ step[nestedProp].forEach(a => {
902
+ mergePathsIntoJoinTree(a, subPath)
903
+ })
890
904
  }
891
905
  })
892
906
  }
package/lib/search.js ADDED
@@ -0,0 +1,164 @@
1
+ 'use strict'
2
+
3
+ const DRAFT_COLUMNS_UNION = {
4
+ IsActiveEntity: 1,
5
+ HasActiveEntity: 1,
6
+ HasDraftEntity: 1,
7
+ DraftAdministrativeData_DraftUUID: 1,
8
+ SiblingEntity: 1,
9
+ DraftAdministrativeData: 1,
10
+ }
11
+ const DEFAULT_SEARCHABLE_TYPE = 'cds.String'
12
+
13
+ /**
14
+ * This method gets all columns for an entity.
15
+ * It includes the generated foreign keys from managed associations, structured elements and complex and custom types.
16
+ * As well, it provides the annotations starting with '@' for each column.
17
+ *
18
+ * @param {object} entity - the csn entity
19
+ * @param {object} [options]
20
+ * @param [options.onlyNames=false] - decides if the column name or the csn representation of the column should be returned
21
+ * @param [options.filterDraft=false] - indicates whether the draft columns should be filtered if the entity is draft enabled
22
+ * @param [options.removeIgnore=false]
23
+ * @param [options.filterVirtual=false]
24
+ * @param [options.keysOnly=false]
25
+ * @returns {Array<object>} - array of columns
26
+ */
27
+ const getColumns = (
28
+ entity,
29
+ { onlyNames = false, removeIgnore = false, filterDraft = true, filterVirtual = false, keysOnly = false },
30
+ ) => {
31
+ const skipDraft = filterDraft && entity._isDraftEnabled
32
+ const columns = []
33
+ const elements = entity.elements
34
+
35
+ for (const each in elements) {
36
+ const element = elements[each]
37
+ if (element.isAssociation) continue
38
+ if (filterVirtual && element.virtual) continue
39
+ if (removeIgnore && element['@cds.api.ignore']) continue
40
+ if (skipDraft && each in DRAFT_COLUMNS_UNION) continue
41
+ if (keysOnly && !element.key) continue
42
+ columns.push(onlyNames ? each : element)
43
+ }
44
+
45
+ return columns
46
+ }
47
+
48
+ const _isColumnCalculated = (query, columnName) => {
49
+ if (!query) return false
50
+ if (query.SELECT?.columns?.find(col => col.xpr && col.as === columnName)) return true
51
+ return _isColumnCalculated(query._target?.query, columnName)
52
+ }
53
+
54
+ const _getSearchableColumns = entity => {
55
+ const columnsOptions = { removeIgnore: true, filterVirtual: true }
56
+ const columns = getColumns(entity, columnsOptions)
57
+ const cdsSearchTerm = '@cds.search'
58
+ const cdsSearchKeys = []
59
+ const cdsSearchColumnMap = new Map()
60
+
61
+ for (const key in entity) {
62
+ if (key.startsWith(cdsSearchTerm)) cdsSearchKeys.push(key)
63
+ }
64
+
65
+ let atLeastOneColumnIsSearchable = false
66
+
67
+ // build a map of columns annotated with the @cds.search annotation
68
+ for (const key of cdsSearchKeys) {
69
+ const columnName = key.split(cdsSearchTerm + '.').pop()
70
+
71
+ // REVISIT: for now, exclude search using path expression, as deep search is not currently
72
+ // supported
73
+ if (columnName.includes('.')) {
74
+ continue
75
+ }
76
+
77
+ const annotationKey = `${cdsSearchTerm}.${columnName}`
78
+ const annotationValue = entity[annotationKey]
79
+ if (annotationValue) atLeastOneColumnIsSearchable = true
80
+ cdsSearchColumnMap.set(columnName, annotationValue)
81
+ }
82
+
83
+ const searchableColumns = columns.filter(column => {
84
+ const annotatedColumnValue = cdsSearchColumnMap.get(column.name)
85
+
86
+ // the element is searchable if it is annotated with the @cds.search, e.g.:
87
+ // `@cds.search { element1: true }` or `@cds.search { element1 }`
88
+ if (annotatedColumnValue) return true
89
+
90
+ // if at least one element is explicitly annotated as searchable, e.g.:
91
+ // `@cds.search { element1: true }` or `@cds.search { element1 }`
92
+ // and it is not the current column name, then it must be excluded from the search
93
+ if (atLeastOneColumnIsSearchable) return false
94
+
95
+ // the element is considered searchable if it is explicitly annotated as such or
96
+ // if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
97
+ return (
98
+ annotatedColumnValue === undefined &&
99
+ column._type === DEFAULT_SEARCHABLE_TYPE &&
100
+ !_isColumnCalculated(entity?.query, column.name)
101
+ )
102
+ })
103
+
104
+ // if the @cds.search annotation is provided -->
105
+ // Early return to ignore the interpretation of the @Search.defaultSearchElement
106
+ // annotation when an entity is annotated with the @cds.search annotation.
107
+ // The @cds.search annotation overrules the @Search.defaultSearchElement annotation.
108
+ if (cdsSearchKeys.length > 0) {
109
+ return searchableColumns.map(column => column.name)
110
+ }
111
+
112
+ return searchableColumns.map(column => column.name)
113
+ }
114
+
115
+ /**
116
+ * @returns {Array<object>} - array of columns
117
+ */
118
+ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, alias) => {
119
+ let toBeSearched = []
120
+
121
+ // aggregations case
122
+ // in the new parser groupBy is moved to sub select.
123
+ if (cqn._aggregated || /* new parser */ cqn.SELECT.groupBy || cqn.SELECT?.from?.SELECT?.groupBy) {
124
+ cqn.SELECT.columns &&
125
+ cqn.SELECT.columns.forEach(column => {
126
+ if (column.func) {
127
+ // exclude $count by SELECT of number of Items in a Collection
128
+ if (
129
+ cqn.SELECT.columns.length === 1 &&
130
+ column.func === 'count' &&
131
+ (column.as === '_counted_' || column.as === '$count')
132
+ ) {
133
+ return
134
+ }
135
+
136
+ toBeSearched.push(column)
137
+ return
138
+ }
139
+
140
+ const columnRef = column.ref
141
+ if (columnRef) {
142
+ if (entity.elements[columnRef[columnRef.length - 1]]?._type !== DEFAULT_SEARCHABLE_TYPE) return
143
+ column = { ref: [...column.ref] }
144
+ if (alias) column.ref.unshift(alias)
145
+ toBeSearched.push(column)
146
+ }
147
+ })
148
+ } else {
149
+ toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
150
+ if (cqn.SELECT.groupBy) toBeSearched = toBeSearched.filter(tbs => cqn.SELECT.groupBy.some(gb => gb.ref[0] === tbs))
151
+ toBeSearched = toBeSearched.map(c => {
152
+ const col = { ref: [c] }
153
+ if (alias) col.ref.unshift(alias)
154
+ return col
155
+ })
156
+ }
157
+
158
+ return toBeSearched
159
+ }
160
+
161
+ module.exports = {
162
+ getColumns,
163
+ computeColumnsToBeSearched,
164
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "CDS base database service",
5
5
  "homepage": "https://github.com/cap-js/cds-dbs/tree/main/db-service#cds-base-database-service",
6
6
  "repository": {