@cap-js/db-service 2.0.0 → 2.1.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,28 @@
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
+ ## [2.1.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.0.1...db-service-v2.1.0) (2025-06-04)
8
+
9
+
10
+ ### Added
11
+
12
+ * opt-in replacement for `generic-pool` ([#815](https://github.com/cap-js/cds-dbs/issues/815)) ([32c08f4](https://github.com/cap-js/cds-dbs/commit/32c08f474eab67c71dad3b5e6a378f8bc52520b0))
13
+
14
+ ## [2.0.1](https://github.com/cap-js/cds-dbs/compare/db-service-v2.0.0...db-service-v2.0.1) (2025-05-27)
15
+
16
+
17
+ ### Fixed
18
+
19
+ * **`search`:** do not search on non-projected elements ([#1198](https://github.com/cap-js/cds-dbs/issues/1198)) ([73d9e67](https://github.com/cap-js/cds-dbs/commit/73d9e67b1bc7d7727c04b4577cb73f4daaed852b))
20
+ * add shortcut for empty UPDATE.data ([#1203](https://github.com/cap-js/cds-dbs/issues/1203)) ([cf991ff](https://github.com/cap-js/cds-dbs/commit/cf991ff8179efee6a4621d2a2bd8bf6265e58893))
21
+ * hierarchies in quoted mode ([3465cba](https://github.com/cap-js/cds-dbs/commit/3465cbab579d4560d12d3b230c55b746d4d3f5a5))
22
+ * only sort by locale if locale is set ([#1193](https://github.com/cap-js/cds-dbs/issues/1193)) ([3465cba](https://github.com/cap-js/cds-dbs/commit/3465cbab579d4560d12d3b230c55b746d4d3f5a5))
23
+
24
+
25
+ ### Changed
26
+
27
+ * remove stream_compat ([#1139](https://github.com/cap-js/cds-dbs/issues/1139)) ([#1144](https://github.com/cap-js/cds-dbs/issues/1144)) ([1b8b2d9](https://github.com/cap-js/cds-dbs/commit/1b8b2d9539cd97be2cef088c98d88ef9ec7dd1bf))
28
+
7
29
  ## [2.0.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.20.0...db-service-v2.0.0) (2025-05-07)
8
30
 
9
31
 
package/lib/SQLService.js CHANGED
@@ -11,6 +11,20 @@ const BINARY_TYPES = {
11
11
  'cds.hana.BINARY': 1
12
12
  }
13
13
 
14
+ /**
15
+ * Checks if parameter is an object that at least contains one property.
16
+ *
17
+ * @param {*} obj
18
+ * @returns Boolean
19
+ */
20
+ const _hasProps = (obj) => {
21
+ if (!obj) return false
22
+ for (const p in obj) {
23
+ return true
24
+ }
25
+ return false
26
+ }
27
+
14
28
  /** @typedef {import('@sap/cds/apis/services').Request} Request */
15
29
 
16
30
  /**
@@ -57,24 +71,18 @@ class SQLService extends DatabaseService {
57
71
  return super.init()
58
72
  }
59
73
 
60
- _changeToStreams(columns, rows, one, compat) {
74
+ _changeToStreams(columns, rows, one) {
61
75
  if (!rows || !columns) return
62
76
  if (!Array.isArray(rows)) rows = [rows]
63
- if (!rows.length || !Object.keys(rows[0]).length) return
64
-
65
- // REVISIT: remove after removing stream_compat feature flag
66
- if (compat) {
67
- rows[0][Object.keys(rows[0])[0]] = this._stream(Object.values(rows[0])[0])
68
- return
69
- }
77
+ if (!rows.length || !Object.keys(rows[0]).length) return
70
78
 
71
79
  let changes = false
72
80
  for (let col of columns) {
73
81
  const name = col.as || col.ref?.[col.ref.length - 1] || (typeof col === 'string' && col)
74
82
  if (col.element?.isAssociation) {
75
- if (one) this._changeToStreams(col.SELECT.columns, rows[0][name], false, compat)
83
+ if (one) this._changeToStreams(col.SELECT.columns, rows[0][name], false)
76
84
  else
77
- changes = rows.some(row => !this._changeToStreams(col.SELECT.columns, row[name], false, compat))
85
+ changes = rows.some(row => !this._changeToStreams(col.SELECT.columns, row[name], false))
78
86
  } else if (col.element?.type === 'cds.LargeBinary') {
79
87
  changes = true
80
88
  if (one) rows[0][name] = this._stream(rows[0][name])
@@ -141,23 +149,7 @@ class SQLService extends DatabaseService {
141
149
  if (expand) rows = rows.map(r => (typeof r._json_ === 'string' ? JSON.parse(r._json_) : r._json_ || r))
142
150
 
143
151
  if (!iterator) {
144
- // REVISIT: remove after removing stream_compat feature flag
145
- if (cds.env.features.stream_compat) {
146
- if (query._streaming) {
147
- if (!rows.length) return
148
- this._changeToStreams(cqn.SELECT.columns, rows, true, true)
149
- const result = rows[0]
150
-
151
- // stream is always on position 0. Further properties like etag are inserted later.
152
- let [key, val] = Object.entries(result)[0]
153
- result.value = val
154
- delete result[key]
155
-
156
- return result
157
- }
158
- } else {
159
- this._changeToStreams(cqn.SELECT.columns, rows, query.SELECT.one, false)
160
- }
152
+ this._changeToStreams(cqn.SELECT.columns, rows, query.SELECT.one)
161
153
  } else if (objectMode) {
162
154
  const converter = (row) => this._changeToStreams(cqn.SELECT.columns, row, true)
163
155
  const changeToStreams = new Transform({
@@ -216,8 +208,8 @@ class SQLService extends DatabaseService {
216
208
  async onUPDATE(req) {
217
209
  // noop if not a touch for @cds.on.update
218
210
  if (
219
- !req.query.UPDATE.data &&
220
- !req.query.UPDATE.with &&
211
+ !_hasProps(req.query.UPDATE.data) &&
212
+ !_hasProps(req.query.UPDATE.with) &&
221
213
  !Object.values(req.target?.elements || {}).some(e => e['@cds.on.update'])
222
214
  )
223
215
  return 0
@@ -404,8 +396,6 @@ class SQLService extends DatabaseService {
404
396
  let kind = q.kind || Object.keys(q)[0]
405
397
  if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
406
398
  q = resolveView(q, this.model, this) // REVISIT: before resolveView was called on flat cqn obtained from cqn4sql -> is it correct to call on original q instead?
407
- let target = q[kind]._transitions?.[0].target
408
- if (target) q._target = target // REVISIT: Why isn't that done in resolveView?
409
399
  }
410
400
  let cqn2sql = new this.class.CQN2SQL(this)
411
401
  return cqn2sql.render(q, values)
@@ -1,4 +1,10 @@
1
- const { createPool } = require('generic-pool')
1
+ const cds = require('@sap/cds')
2
+ const LOG = cds.log('db')
3
+
4
+ const createPool = (factory, config) => {
5
+ if (cds.requires.db?.pool?.builtin) return new Pool(factory, config)
6
+ return require('generic-pool').createPool(factory, config)
7
+ }
2
8
 
3
9
  function ConnectionPool (factory, tenant) {
4
10
  let bound_factory = { __proto__: factory, create: factory.create.bind(null, tenant) }
@@ -29,4 +35,256 @@ function TrackedConnectionPool (factory, tenant) {
29
35
  }
30
36
 
31
37
  const DEBUG = /\bpool\b/.test(process.env.DEBUG)
32
- module.exports = DEBUG ? TrackedConnectionPool : ConnectionPool
38
+ module.exports = DEBUG && !cds.requires.db?.pool?.builtin ? TrackedConnectionPool : ConnectionPool
39
+
40
+ // Drop-in replacement for https://github.com/coopernurse/node-pool
41
+ // TODO: fifo: true? relevant for our use case?
42
+
43
+ const { EventEmitter } = require('events')
44
+
45
+ const ResourceState = Object.freeze({
46
+ ALLOCATED: 'allocated',
47
+ IDLE: 'idle',
48
+ INVALID: 'invalid',
49
+ VALIDATION: 'validation'
50
+ })
51
+
52
+ const RequestState = Object.freeze({
53
+ PENDING: 'pending',
54
+ RESOLVED: 'resolved',
55
+ REJECTED: 'rejected'
56
+ })
57
+
58
+ class Request {
59
+ constructor (ttl) {
60
+ this.state = 'pending'
61
+ this.promise = new Promise((resolve, reject) => {
62
+ this._resolve = value => {
63
+ clearTimeout(this._timeout)
64
+ this.state = RequestState.RESOLVED
65
+ resolve(value)
66
+ }
67
+ this._reject = reason => {
68
+ clearTimeout(this._timeout)
69
+ this.state = RequestState.REJECTED
70
+ reject(reason)
71
+ }
72
+ if (typeof ttl === 'number' && ttl >= 0) {
73
+ const err = new Error(`Pool resource could not be acquired within ${ttl / 1000}s`)
74
+ this._timeout = setTimeout(() => this._reject(err), ttl)
75
+ }
76
+ })
77
+ }
78
+ resolve (v) { if (this.state === 'pending') this._resolve(v) }
79
+ reject (e) { if (this.state === 'pending') this._reject(e) }
80
+ }
81
+
82
+ class PooledResource {
83
+ constructor(resource) {
84
+ this.obj = resource
85
+ this.creationTime = Date.now()
86
+ this.idle()
87
+ }
88
+
89
+ update(newState) {
90
+ this.state = newState
91
+ }
92
+ idle() {
93
+ this.state = ResourceState.IDLE
94
+ this.lastIdleTime = Date.now()
95
+ }
96
+ }
97
+
98
+ class Pool extends EventEmitter {
99
+
100
+ constructor (factory, options = {}) {
101
+ super()
102
+ this.factory = factory
103
+
104
+ this.options = Object.assign({
105
+ testOnBorrow: false,
106
+ evictionRunIntervalMillis: 100000,
107
+ numTestsPerEvictionRun: 3,
108
+ softIdleTimeoutMillis: -1,
109
+ idleTimeoutMillis: 30000,
110
+ acquireTimeoutMillis: null,
111
+ destroyTimeoutMillis: null,
112
+ fifo: false,
113
+ min: 0,
114
+ max: 10
115
+ }, options)
116
+
117
+ /** @type {boolean} */
118
+ this._draining = false
119
+
120
+ /** @type {Set<PooledResource>} */
121
+ this._available = new Set()
122
+
123
+ /** @type {Map<any, { pooledResource: PooledResource }>} */
124
+ this._loans = new Map()
125
+
126
+ /** @type {Set<PooledResource>} */
127
+ this._all = new Set()
128
+
129
+ /** @type {Set<Promise<void>>} */
130
+ this._creates = new Set()
131
+
132
+ /** @type {Request[]} */
133
+ this._queue = []
134
+
135
+ this.#scheduleEviction()
136
+ for (let i = 0; i < this.options.min - this.size; i++) this.#createResource()
137
+ }
138
+
139
+ async acquire() {
140
+ if (this._draining) throw new Error('Pool is draining and cannot accept new requests')
141
+ const request = new Request(this.options.acquireTimeoutMillis)
142
+ this._queue.push(request)
143
+ this.#dispense()
144
+ return request.promise
145
+ }
146
+
147
+ async release(resource) {
148
+ const loan = this._loans.get(resource)
149
+ if (!loan) throw new Error('Resource not currently part of this pool')
150
+ this._loans.delete(resource)
151
+ const pooledResource = loan.pooledResource
152
+ pooledResource.idle()
153
+ this._available.add(pooledResource)
154
+ this.#dispense()
155
+ }
156
+
157
+ async destroy(resource) {
158
+ const loan = this._loans.get(resource)
159
+ if (!loan) throw new Error('Resource not currently part of this pool')
160
+ this._loans.delete(resource)
161
+ const pooledResource = loan.pooledResource
162
+ await this.#destroy(pooledResource)
163
+ this.#dispense()
164
+ }
165
+
166
+ async drain() {
167
+ this._draining = true
168
+ for (const request of this._queue.splice(0)) {
169
+ if (request.state === RequestState.PENDING) request.reject(new Error('Pool is draining and cannot fulfil request'))
170
+ }
171
+ clearTimeout(this._scheduledEviction)
172
+ }
173
+
174
+ async clear() {
175
+ await Promise.allSettled(Array.from(this._creates))
176
+ await Promise.allSettled(Array.from(this._available).map(resource => this.#destroy(resource)))
177
+ }
178
+
179
+ async #createResource() {
180
+ const createPromise = (async () => {
181
+ try {
182
+ const resource = new PooledResource(await this.factory.create())
183
+ this._all.add(resource)
184
+ this._available.add(resource)
185
+ } catch (err) {
186
+ this._queue.shift()?.reject(err)
187
+ }
188
+ })()
189
+ this._creates.add(createPromise)
190
+ createPromise.finally(() => {
191
+ this._creates.delete(createPromise)
192
+ this.#dispense()
193
+ })
194
+ return createPromise
195
+ }
196
+
197
+ async #dispense() {
198
+ const waiting = this._queue.length
199
+ if (waiting === 0) return
200
+ const capacity = this._available.size + this._creates.size
201
+ const shortfall = waiting - capacity
202
+ if (shortfall > 0 && this.size < this.options.max) {
203
+ const needed = Math.min(shortfall, this.options.max - this.size)
204
+ for (let i = 0; i < needed; i++) this.#createResource()
205
+ }
206
+ const dispense = async resource => {
207
+ const request = this._queue.shift()
208
+ if (!request) {
209
+ resource.idle()
210
+ this._available.add(resource)
211
+ return false
212
+ }
213
+ if (request.state !== RequestState.PENDING) {
214
+ this.#dispense()
215
+ return false
216
+ }
217
+ this._loans.set(resource.obj, { pooledResource: resource })
218
+ resource.update(ResourceState.ALLOCATED)
219
+ request.resolve(resource.obj)
220
+ return true
221
+ }
222
+
223
+ const _dispenses = []
224
+ for (let i = 0; i < Math.min(this._available.size, waiting); i++) {
225
+ const resource = this._available.values().next().value
226
+ this._available.delete(resource)
227
+ if (this.options.testOnBorrow) {
228
+ const validationPromise = (async () => {
229
+ resource.update(ResourceState.VALIDATION)
230
+ try {
231
+ const isValid = await this.factory.validate(resource.obj)
232
+ if (isValid) return dispense(resource)
233
+ } catch {/* marked as invalid below */}
234
+ resource.update(ResourceState.INVALID)
235
+ await this.#destroy(resource)
236
+ this.#dispense()
237
+ return false
238
+ })()
239
+ _dispenses.push(validationPromise)
240
+ } else {
241
+ _dispenses.push(dispense(resource))
242
+ }
243
+ }
244
+ await Promise.all(_dispenses)
245
+ }
246
+
247
+ async #destroy(resource) {
248
+ resource.update(ResourceState.INVALID)
249
+ this._all.delete(resource)
250
+ this._available.delete(resource)
251
+ this._loans.delete(resource.obj)
252
+ try {
253
+ await this.factory.destroy(resource.obj)
254
+ } catch (e) {
255
+ LOG.error(e)
256
+ /* FIXME: We have to ignore errors here due to a TypeError in hdb */
257
+ /* This was also a problem with the old (generic-pool) implementation */
258
+ /* Root cause in hdb needs to be fixed */
259
+ } finally {
260
+ if (!this._draining && this.size < this.options.min) {
261
+ await this.#createResource()
262
+ }
263
+ }
264
+ }
265
+
266
+ #scheduleEviction() {
267
+ const { evictionRunIntervalMillis, numTestsPerEvictionRun, softIdleTimeoutMillis, min, idleTimeoutMillis } = this.options
268
+ if (evictionRunIntervalMillis <= 0) return
269
+ this._scheduledEviction = setTimeout(async () => {
270
+ try {
271
+ const resourcesToEvict = Array.from(this._available)
272
+ .slice(0, numTestsPerEvictionRun)
273
+ .filter(resource => {
274
+ const idleTime = Date.now() - resource.lastIdleTime
275
+ const softEvict = softIdleTimeoutMillis > 0 && softIdleTimeoutMillis < idleTime && min < this._available.size
276
+ return softEvict || idleTimeoutMillis < idleTime
277
+ })
278
+ await Promise.all(resourcesToEvict.map(resource => this.#destroy(resource)))
279
+ } finally {
280
+ this.#scheduleEviction()
281
+ }
282
+ }, evictionRunIntervalMillis).unref()
283
+ }
284
+
285
+ get size() { return this._all.size + this._creates.size }
286
+ get available() { return this._available.size }
287
+ get borrowed() { return this._loans.size }
288
+ get pending() { return this._queue.length }
289
+ get tenant() { return this.options.tenant }
290
+ }
package/lib/cqn2sql.js CHANGED
@@ -338,8 +338,8 @@ class CQN2SQLRenderer {
338
338
  const parentKeys = []
339
339
  const association = target.elements[recurse.ref[0]]
340
340
  association._foreignKeys.forEach(fk => {
341
- nodeKeys.push(this.quote(fk.childElement.name))
342
- parentKeys.push(this.quote(fk.parentElement.name))
341
+ nodeKeys.push(fk.childElement.name)
342
+ parentKeys.push(fk.parentElement.name)
343
343
  })
344
344
 
345
345
  columnsIn.push(
@@ -364,11 +364,13 @@ class CQN2SQLRenderer {
364
364
  // In the case of join operations make sure to compute the hierarchy from the source table only
365
365
  const stableFrom = getStableFrom(from)
366
366
  const alias = stableFrom.as
367
- const source = () => ({
367
+ const source = () => {
368
+ return ({
368
369
  func: 'HIERARCHY',
369
370
  args: [{ xpr: ['SOURCE', { SELECT: { columns: columnsIn, from: stableFrom } }, ...(orderBy ? ['SIBLING', 'ORDER', 'BY', `${this.orderBy(orderBy)}`] : [])] }],
370
371
  as: alias
371
372
  })
373
+ }
372
374
 
373
375
  const expandedByNr = { list: [] } // DistanceTo(...,null)
374
376
  const expandedByOne = { list: [] } // DistanceTo(...,1)
@@ -724,7 +726,7 @@ class CQN2SQLRenderer {
724
726
  */
725
727
  orderBy(orderBy, localized) {
726
728
  return orderBy.map(c => {
727
- const o = localized
729
+ const o = (localized && this.context.locale)
728
730
  ? this.expr(c) +
729
731
  (c.element?.[this.class._localized] ? ' COLLATE NOCASE' : '') +
730
732
  (c.sort?.toLowerCase() === 'desc' || c.sort === -1 ? ' DESC' : ' ASC')
package/lib/cqn4sql.js CHANGED
@@ -802,11 +802,7 @@ function cqn4sql(originalQuery, model) {
802
802
  })
803
803
  } else {
804
804
  outerAlias = transformedQuery.SELECT.from.as
805
- const getInnermostTarget = q => (q._target ? getInnermostTarget(q._target) : q)
806
- subqueryFromRef = [
807
- ...(transformedQuery.SELECT.from.ref || /* subq in from */ [getInnermostTarget(transformedQuery).name]),
808
- ...ref,
809
- ]
805
+ subqueryFromRef = [transformedQuery._target.name, ...ref]
810
806
  }
811
807
 
812
808
  // this is the alias of the column which holds the correlated subquery
package/lib/search.js CHANGED
@@ -79,7 +79,7 @@ const _getSearchableColumns = entity => {
79
79
  const column = entity.elements[columnName]
80
80
  if (column?.isAssociation || columnName.includes('.')) {
81
81
  deepSearchCandidates.push({ ref: columnName.split('.') })
82
- continue;
82
+ continue
83
83
  }
84
84
  cdsSearchColumnMap.set(columnName, annotationValue)
85
85
  }
@@ -93,8 +93,8 @@ const _getSearchableColumns = entity => {
93
93
  // `@cds.search { element1: true }` or `@cds.search { element1 }`
94
94
  if (annotatedColumnValue) return true
95
95
 
96
- // calculated elements are only searchable if requested through `@cds.search`
97
- if(column.value) return false
96
+ // calculated elements are only searchable if requested through `@cds.search`
97
+ if (column.value) return false
98
98
 
99
99
  // if at least one element is explicitly annotated as searchable, e.g.:
100
100
  // `@cds.search { element1: true }` or `@cds.search { element1 }`
@@ -112,16 +112,23 @@ const _getSearchableColumns = entity => {
112
112
 
113
113
  if (deepSearchCandidates.length) {
114
114
  deepSearchCandidates.forEach(c => {
115
- const element = c.ref.reduce((resolveIn, curr, i) => {
116
- const next = resolveIn.elements?.[curr] || resolveIn._target.elements[curr]
117
- if (next?.isAssociation && !c.ref[i + 1]) {
118
- const searchInTarget = _getSearchableColumns(next._target)
119
- searchInTarget.forEach(elementRefInTarget => {
120
- searchableColumns.push({ ref: c.ref.concat(...elementRefInTarget.ref) })
121
- })
115
+ let element = entity
116
+ for (let i = 0; i < c.ref.length; ++i) {
117
+ const curr = c.ref[i]
118
+ const next = element.elements?.[curr] ?? element._target?.elements?.[curr]
119
+
120
+ if (!next) { // e.g. if a search element is not part of a projection
121
+ element = undefined
122
+ break
122
123
  }
123
- return next
124
- }, entity)
124
+
125
+ if (next.isAssociation && i === c.ref.length - 1) {
126
+ _getSearchableColumns(next._target).forEach(r => searchableColumns.push({ ref: c.ref.concat(...r.ref) }))
127
+ }
128
+
129
+ element = next
130
+ }
131
+
125
132
  if (element?.type === DEFAULT_SEARCHABLE_TYPE) {
126
133
  searchableColumns.push({ ref: c.ref })
127
134
  }
@@ -129,9 +136,8 @@ const _getSearchableColumns = entity => {
129
136
  }
130
137
 
131
138
  return searchableColumns.map(column => {
132
- if(column.ref)
133
- return column
134
- return { ref: [ column.name ] }
139
+ if (column.ref) return column
140
+ return { ref: [column.name] }
135
141
  })
136
142
  }
137
143
 
@@ -183,15 +189,16 @@ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }) =
183
189
  }
184
190
  })
185
191
  } else {
186
- if(entity.kind === 'entity') {
192
+ if (entity.kind === 'entity') {
187
193
  // first check cache
188
- toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
194
+ toBeSearched =
195
+ entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
189
196
  } else {
190
197
  // if we search on a subquery, we don't have a cache
191
198
  toBeSearched = _getSearchableColumns(entity)
192
199
  }
193
200
  toBeSearched = toBeSearched.map(c => {
194
- const column = {ref: [...c.ref]}
201
+ const column = { ref: [...c.ref] }
195
202
  return column
196
203
  })
197
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.0.0",
3
+ "version": "2.1.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": {