@cap-js/db-service 1.6.4 → 1.8.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,45 @@
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.8.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.7.0...db-service-v1.8.0) (2024-04-12)
8
+
9
+
10
+ ### Added
11
+
12
+ * Odata built-in query functions ([#558](https://github.com/cap-js/cds-dbs/issues/558)) ([6e63367](https://github.com/cap-js/cds-dbs/commit/6e6336757129c4a9dac56f93fd768bb41d071c46))
13
+ * support HANA stored procedures ([#542](https://github.com/cap-js/cds-dbs/issues/542)) ([52a00a0](https://github.com/cap-js/cds-dbs/commit/52a00a0d642ba3c58dcad97b3ea1456f1bf3b04a))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **`expand`:** Only accept on structures, assocs or table aliases ([#551](https://github.com/cap-js/cds-dbs/issues/551)) ([3248512](https://github.com/cap-js/cds-dbs/commit/32485129147cd1b376f1d2faf2ea7c7232ba3794))
19
+ * **`order by`:** for localized sorting, prepend table alias ([#546](https://github.com/cap-js/cds-dbs/issues/546)) ([a273a92](https://github.com/cap-js/cds-dbs/commit/a273a9278b2551ed3381795effe28cf8de41b1bd))
20
+ * etag with stream_compat ([#562](https://github.com/cap-js/cds-dbs/issues/562)) ([b0a3a41](https://github.com/cap-js/cds-dbs/commit/b0a3a418fbcff7eb7e7b8fa4ff031e1c0c0faac4))
21
+ * exclude `cds.LargeBinary` from wildcard expansion ([#577](https://github.com/cap-js/cds-dbs/issues/577)) ([6661d63](https://github.com/cap-js/cds-dbs/commit/6661d635b2895a13d47e42495acf6fbd7247c535))
22
+ * Reduce insert queries for deep update ([#568](https://github.com/cap-js/cds-dbs/issues/568)) ([55e5114](https://github.com/cap-js/cds-dbs/commit/55e511471743c0445d41e8297f5530abe167a270))
23
+ * Reduced count query complexity when possible ([#553](https://github.com/cap-js/cds-dbs/issues/553)) ([3331f02](https://github.com/cap-js/cds-dbs/commit/3331f0224f02bd2e6cc9c6d2cd5f1c37a36ec8dd))
24
+
25
+ ## [1.7.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.6.4...db-service-v1.7.0) (2024-03-22)
26
+
27
+
28
+ ### Added
29
+
30
+ * also support lowercase matchespattern function ([#528](https://github.com/cap-js/cds-dbs/issues/528)) ([6ea574e](https://github.com/cap-js/cds-dbs/commit/6ea574ee67ef5e42e4f8ccbe4fe91b46097de129))
31
+ * forUpdate and forShareLock ([#148](https://github.com/cap-js/cds-dbs/issues/148)) ([99a1170](https://github.com/cap-js/cds-dbs/commit/99a1170e61de4fd0c505834c25a9c03fc34da85b))
32
+ * **hana:** drop prepared statements after end of transaction ([#537](https://github.com/cap-js/cds-dbs/issues/537)) ([b1f864e](https://github.com/cap-js/cds-dbs/commit/b1f864e0a3a0e5efacd803d3709379cab76d61cc))
33
+ * **hana:** Add views with parameters support ([#488](https://github.com/cap-js/cds-dbs/issues/488)) ([3790ec0](https://github.com/cap-js/cds-dbs/commit/3790ec0178aab2cdb429272bb3e813b13441785c))
34
+ * **orderby:** allow to disable collations with [@cds](https://github.com/cds).collate: false ([#492](https://github.com/cap-js/cds-dbs/issues/492)) ([820f971](https://github.com/cap-js/cds-dbs/commit/820f971e1ad21fa8f8ca289c1e29b373365df484))
35
+
36
+
37
+ ### Fixed
38
+
39
+ * **cqn2sql:** Smart quoting of columns inside UPSERT rows ([#519](https://github.com/cap-js/cds-dbs/issues/519)) ([78fe10b](https://github.com/cap-js/cds-dbs/commit/78fe10b1df3691614dc77b1d4f82df10a1d641d3))
40
+ * Getting rid of quirks mode ([#514](https://github.com/cap-js/cds-dbs/issues/514)) ([c9aa6e8](https://github.com/cap-js/cds-dbs/commit/c9aa6e835761ace38447f37cad6a5f39cb0b910c))
41
+ * issue with reused select cqns ([#505](https://github.com/cap-js/cds-dbs/issues/505)) ([916d175](https://github.com/cap-js/cds-dbs/commit/916d1756422f0caf02c323052f2addafed39182a))
42
+ * joins without columns are rejected ([#535](https://github.com/cap-js/cds-dbs/issues/535)) ([eb9beda](https://github.com/cap-js/cds-dbs/commit/eb9beda728de60081d7afbfcd49305eeb241f3fb))
43
+ * **search:** dont search non string aggregations ([#527](https://github.com/cap-js/cds-dbs/issues/527)) ([c87900c](https://github.com/cap-js/cds-dbs/commit/c87900cb157041a6ff76c45192c1d33180840d0f))
44
+ * **search:** search on aggregated results in HAVING clause ([#524](https://github.com/cap-js/cds-dbs/issues/524)) ([61d348e](https://github.com/cap-js/cds-dbs/commit/61d348ebc2528b7f1c6da8c78a7455a438e1b7cf))
45
+
7
46
  ## [1.6.4](https://github.com/cap-js/cds-dbs/compare/db-service-v1.6.3...db-service-v1.6.4) (2024-02-28)
8
47
 
9
48
 
package/lib/SQLService.js CHANGED
@@ -123,15 +123,26 @@ class SQLService extends DatabaseService {
123
123
  }
124
124
 
125
125
  const { sql, values, cqn } = this.cqn2sql(query, data)
126
+ const expand = query.SELECT.expand
127
+ delete query.SELECT.expand
128
+
126
129
  let ps = await this.prepare(sql)
127
130
  let rows = await ps.all(values)
128
131
  if (rows.length)
129
- if (cqn.SELECT.expand) rows = rows.map(r => (typeof r._json_ === 'string' ? JSON.parse(r._json_) : r._json_ || r))
132
+ if (expand) rows = rows.map(r => (typeof r._json_ === 'string' ? JSON.parse(r._json_) : r._json_ || r))
130
133
 
131
134
  if (cds.env.features.stream_compat) {
132
135
  if (query._streaming) {
133
136
  this._changeToStreams(cqn.SELECT.columns, rows, true, true)
134
- return rows.length ? { value: Object.values(rows[0])[0] } : undefined
137
+ if (!rows.length) return
138
+
139
+ const result = rows[0]
140
+ // stream is always on position 0. Further properties like etag are inserted later.
141
+ let [key, val] = Object.entries(result)[0]
142
+ result.value = val
143
+ delete result[key]
144
+
145
+ return result
135
146
  }
136
147
  } else {
137
148
  this._changeToStreams(cqn.SELECT.columns, rows, query.SELECT.one, false)
@@ -262,7 +273,7 @@ class SQLService extends DatabaseService {
262
273
  * @param {string} sql
263
274
  */
264
275
  hasResults(sql) {
265
- return /^(SELECT|WITH|CALL|PRAGMA table_info)/i.test(sql)
276
+ return /^\s*(SELECT|WITH|CALL|PRAGMA table_info)/i.test(sql)
266
277
  }
267
278
 
268
279
  /**
@@ -278,17 +289,23 @@ class SQLService extends DatabaseService {
278
289
  const [max, offset = 0] = one ? [1] : _ ? [_.rows?.val, _.offset?.val] : []
279
290
  if (max === undefined || (n < max && (n || !offset))) return n + offset
280
291
  }
281
- // REVISIT: made uppercase count because of HANA reserved word quoting
282
- const cq = SELECT.one([{ func: 'count', as: 'COUNT' }]).from(
292
+
293
+ // Keep original query columns when potentially used insde conditions
294
+ const { having, groupBy } = query.SELECT
295
+ const columns = (having?.length || groupBy?.length)
296
+ ? query.SELECT.columns.filter(c => !c.expand)
297
+ : [{ val: 1 }]
298
+ const cq = SELECT.one([{ func: 'count' }]).from(
283
299
  cds.ql.clone(query, {
300
+ columns,
284
301
  localized: false,
285
302
  expand: false,
286
303
  limit: undefined,
287
304
  orderBy: undefined,
288
305
  }),
289
306
  )
290
- const { count, COUNT } = await this.onSELECT({ query: cq })
291
- return count ?? COUNT
307
+ const { count } = await this.onSELECT({ query: cq })
308
+ return count
292
309
  }
293
310
 
294
311
  /**
@@ -428,6 +445,8 @@ SQLService.prototype.PreparedStatement = PreparedStatement
428
445
 
429
446
  const _target_name4 = q => {
430
447
  const target =
448
+ q._target_ref ||
449
+ q.from_into_ntt ||
431
450
  q.SELECT?.from ||
432
451
  q.INSERT?.into ||
433
452
  q.UPSERT?.into ||
@@ -441,7 +460,7 @@ const _target_name4 = q => {
441
460
  return first.id || first
442
461
  }
443
462
 
444
- const _unquirked = q => {
463
+ const _unquirked = !cds.env.ql.quirks_mode ? q => q : q => {
445
464
  if (!q) return q
446
465
  else if (typeof q.SELECT?.from === 'string') q.SELECT.from = { ref: [q.SELECT.from] }
447
466
  else if (typeof q.INSERT?.into === 'string') q.INSERT.into = { ref: [q.INSERT.into] }
@@ -132,9 +132,9 @@ class DatabaseService extends cds.Service {
132
132
  const tenants = tenant ? [tenant] : Object.keys(this.pools)
133
133
  await Promise.all (tenants.map (async t => {
134
134
  const pool = this.pools[t]; if (!pool) return
135
+ delete this.pools[t]
135
136
  await pool.drain()
136
137
  await pool.clear()
137
- delete this.pools[t]
138
138
  }))
139
139
  }
140
140
 
@@ -99,6 +99,13 @@ const StandardFunctions = {
99
99
  * @returns {string}
100
100
  */
101
101
  matchesPattern: (x, y) => `(${x} regexp ${y})`,
102
+ /**
103
+ * Generates SQL statement that matches the given string against a regular expression
104
+ * @param {string} x
105
+ * @param {string} y
106
+ * @returns {string}
107
+ */
108
+ matchespattern: (x, y) => `(${x} regexp ${y})`,
102
109
  /**
103
110
  * Generates SQL statement that produces the lower case value of a given string
104
111
  * @param {string} x
@@ -145,6 +152,13 @@ const StandardFunctions = {
145
152
  current_time: p => (p ? `current_time(${p})` : 'current_time'),
146
153
  current_timestamp: p => (p ? `current_timestamp(${p})` : 'current_timestamp'),
147
154
 
155
+ /**
156
+ * Generates SQL statement that produces current point in time (date and time with time zone)
157
+ * @returns {string}
158
+ */
159
+ now: function() {
160
+ return this.session_context({val: '$now'})
161
+ },
148
162
  /**
149
163
  * Generates SQL statement that produces the year of a given timestamp
150
164
  * @param {string} x
@@ -182,29 +196,27 @@ const StandardFunctions = {
182
196
  * /
183
197
  second: x => `cast( strftime('%S',${x}) as Integer )`,
184
198
 
199
+ // REVISIT: make precision configurable
185
200
  /**
186
201
  * Generates SQL statement that produces the fractional seconds of a given timestamp
187
202
  * @param {string} x
188
203
  * @returns {string}
189
204
  */
190
- fractionalseconds: x => `cast( strftime('%f0000',${x}) as Integer )`,
205
+ fractionalseconds: x => `cast( substr( strftime('%f', ${x}), length(strftime('%f', ${x})) - 3) as REAL)`,
191
206
 
192
207
  /**
193
208
  * maximum date time value
194
209
  * @returns {string}
195
210
  */
196
- maxdatetime: () => '9999-12-31 23:59:59.999',
211
+ maxdatetime: () => "'9999-12-31T23:59:59.999Z'",
197
212
  /**
198
213
  * minimum date time value
199
214
  * @returns {string}
200
215
  */
201
- mindatetime: () => '0001-01-01 00:00:00.000',
216
+ mindatetime: () => "'0001-01-01T00:00:00.000Z'",
202
217
 
203
218
  // odata spec defines the date time offset type as a normal ISO time stamp
204
219
  // Where the timezone can either be 'Z' (for UTC) or [+|-]xx:xx for the time offset
205
- // sqlite understands this so by splitting the timezone from the actual date
206
- // prefixing it with 1970 it allows sqlite to give back the number of seconds
207
- // which can be divided by 60 back to minutes
208
220
  /**
209
221
  * Generates SQL statement that produces the offset in minutes of a given date time offset string
210
222
  * @param {string} x
@@ -212,7 +224,9 @@ const StandardFunctions = {
212
224
  */
213
225
  totaloffsetminutes: x => `case
214
226
  when substr(${x}, length(${x})) = 'z' then 0
215
- else strftime('%s', '1970-01-01T00:00:00' || substr(${x}, length(${x}) - 5)) / 60
227
+ else sign( cast( substr(${x}, length(${x}) - 5) as Integer )) *
228
+ ( cast( strftime('%H', substr(${x}, length(${x}) - 4 )) as Integer ) * 60 +
229
+ cast( strftime('%M', substr(${x},length(${x}) - 4 )) as Integer ))
216
230
  end`,
217
231
 
218
232
  // odata spec defines the value format for totalseconds as a duration like: P12DT23H59M59.999999999999S
package/lib/cqn2sql.js CHANGED
@@ -40,7 +40,9 @@ class CQN2SQLRenderer {
40
40
  for (let each in mixins) {
41
41
  const def = types[each]
42
42
  if (!def) continue
43
- Object.defineProperty(def, fqn, { value: mixins[each] })
43
+ const value = mixins[each]
44
+ if (value?.get) Object.defineProperty(def, fqn, { get: value.get })
45
+ else Object.defineProperty(def, fqn, { value })
44
46
  }
45
47
  return fqn
46
48
  }
@@ -193,7 +195,7 @@ class CQN2SQLRenderer {
193
195
  DROP(q) {
194
196
  const { target } = q
195
197
  const isView = target.query || target.projection
196
- return (this.sql = `DROP ${isView ? 'VIEW' : 'TABLE'} IF EXISTS ${this.name(target.name)}`)
198
+ return (this.sql = `DROP ${isView ? 'VIEW' : 'TABLE'} IF EXISTS ${this.quote(this.name(target.name))}`)
197
199
  }
198
200
 
199
201
  // SELECT Statements ------------------------------------------------
@@ -203,7 +205,13 @@ class CQN2SQLRenderer {
203
205
  * @param {import('./infer/cqn').SELECT} q
204
206
  */
205
207
  SELECT(q) {
206
- let { from, expand, where, groupBy, having, orderBy, limit, one, distinct, localized } = q.SELECT
208
+ let { from, expand, where, groupBy, having, orderBy, limit, one, distinct, localized, forUpdate, forShareLock } =
209
+ q.SELECT
210
+
211
+ if (from?.join && !q.SELECT.columns) {
212
+ throw new Error('CQN query using joins must specify the selected columns.')
213
+ }
214
+
207
215
  // REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
208
216
  if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
209
217
  let columns = this.SELECT_columns(q)
@@ -217,6 +225,8 @@ class CQN2SQLRenderer {
217
225
  if (!_empty(orderBy)) sql += ` ORDER BY ${this.orderBy(orderBy, localized)}`
218
226
  if (one) limit = Object.assign({}, limit, { rows: { val: 1 } })
219
227
  if (limit) sql += ` LIMIT ${this.limit(limit)}`
228
+ if (forUpdate) sql += ` ${this.forUpdate(forUpdate)}`
229
+ else if (forShareLock) sql += ` ${this.forShareLock(forShareLock)}`
220
230
  // Expand cannot work without an inferred query
221
231
  if (expand) {
222
232
  if ('elements' in q) sql = this.SELECT_expand(q, sql)
@@ -303,12 +313,28 @@ class CQN2SQLRenderer {
303
313
  from(from) {
304
314
  const { ref, as } = from
305
315
  const _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
306
- if (ref) return _aliased(this.quote(this.name(ref[0])))
316
+ if (ref) {
317
+ const z = ref[0]
318
+ if (z.args) {
319
+ return _aliased(`${this.quote(this.name(z))}${this.from_args(z.args)}`)
320
+ }
321
+ return _aliased(this.quote(this.name(z)))
322
+ }
307
323
  if (from.SELECT) return _aliased(`(${this.SELECT(from)})`)
308
324
  if (from.join)
309
325
  return `${this.from(from.args[0])} ${from.join} JOIN ${this.from(from.args[1])} ON ${this.where(from.on)}`
310
326
  }
311
327
 
328
+ /**
329
+ * Renders a FROM clause into generic SQL
330
+ * @param {import('./infer/cqn').ref['ref'][0]['args']} args
331
+ * @returns {string} SQL
332
+ */
333
+ from_args(args) {
334
+ args
335
+ cds.error`Parameterized views are not supported by ${this.constructor.name}`
336
+ }
337
+
312
338
  /**
313
339
  * Renders a WHERE clause into generic SQL
314
340
  * @param {import('./infer/cqn').predicate} xpr
@@ -364,6 +390,32 @@ class CQN2SQLRenderer {
364
390
  return !offset ? rows.val : `${rows.val} OFFSET ${offset.val}`
365
391
  }
366
392
 
393
+ /**
394
+ * Renders an forUpdate clause into generic SQL
395
+ * @param {import('./infer/cqn').SELECT["SELECT"]["forUpdate"]} update
396
+ * @returns {string} SQL
397
+ */
398
+ forUpdate(update) {
399
+ const { wait, of } = update
400
+ let sql = 'FOR UPDATE'
401
+ if (!_empty(of)) sql += ` OF ${of.map(x => this.expr(x)).join(', ')}`
402
+ if (typeof wait === 'number') sql += ` WAIT ${wait}`
403
+ return sql
404
+ }
405
+
406
+ /**
407
+ * Renders an forShareLock clause into generic SQL
408
+ * @param {import('./infer/cqn').SELECT["SELECT"]["forShareLock"]} update
409
+ * @returns {string} SQL
410
+ */
411
+ forShareLock(lock) {
412
+ const { wait, of } = lock
413
+ let sql = 'FOR SHARE LOCK'
414
+ if (!_empty(of)) sql += ` OF ${of.map(x => this.expr(x)).join(', ')}`
415
+ if (typeof wait === 'number') sql += ` WAIT ${wait}`
416
+ return sql
417
+ }
418
+
367
419
  // INSERT Statements ------------------------------------------------
368
420
 
369
421
  /**
@@ -402,12 +454,12 @@ class CQN2SQLRenderer {
402
454
  : ObjectKeys(INSERT.entries[0])
403
455
 
404
456
  /** @type {string[]} */
405
- this.columns = columns.filter(elements ? c => !elements[c]?.['@cds.extension'] : () => true).map(c => this.quote(c))
457
+ this.columns = columns.filter(elements ? c => !elements[c]?.['@cds.extension'] : () => true)
406
458
 
407
459
  if (!elements) {
408
460
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
409
461
  const param = this.param.bind(this, { ref: ['?'] })
410
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns}) VALUES (${columns.map(param)})`)
462
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))}) VALUES (${columns.map(param)})`)
411
463
  }
412
464
 
413
465
  const extractions = this.managed(
@@ -446,7 +498,7 @@ class CQN2SQLRenderer {
446
498
  this.entries = [[...this.values, stream]]
447
499
  }
448
500
 
449
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns
501
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
450
502
  }) SELECT ${extraction} FROM json_each(?)`)
451
503
  }
452
504
 
@@ -573,12 +625,12 @@ class CQN2SQLRenderer {
573
625
  return converter?.(extract, element) || extract
574
626
  })
575
627
 
576
- this.columns = columns.map(c => this.quote(c))
628
+ this.columns = columns
577
629
 
578
630
  if (!elements) {
579
631
  this.entries = INSERT.rows
580
632
  const param = this.param.bind(this, { ref: ['?'] })
581
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns}) VALUES (${columns.map(param)})`)
633
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))}) VALUES (${columns.map(param)})`)
582
634
  }
583
635
 
584
636
  if (INSERT.rows[0] instanceof Readable) {
@@ -590,7 +642,7 @@ class CQN2SQLRenderer {
590
642
  this.entries = [[...this.values, stream]]
591
643
  }
592
644
 
593
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns
645
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
594
646
  }) SELECT ${extraction} FROM json_each(?)`)
595
647
  }
596
648
 
@@ -617,7 +669,7 @@ class CQN2SQLRenderer {
617
669
  const columns = (this.columns = (INSERT.columns || ObjectKeys(elements)).filter(
618
670
  c => c in elements && !elements[c].virtual && !elements[c].isAssociation,
619
671
  ))
620
- this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns}) ${this.SELECT(
672
+ this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(c))}) ${this.SELECT(
621
673
  this.cqn4sql(INSERT.as),
622
674
  )}`
623
675
  this.entries = [this.values]
@@ -641,7 +693,7 @@ class CQN2SQLRenderer {
641
693
  /** @type {import('./converters').Converters} */
642
694
  static OutputConverters = {} // subclasses to override
643
695
 
644
- static localized = { String: true, UUID: false }
696
+ static localized = { String: { get() { return this['@cds.collate'] !== false } }, UUID: false }
645
697
 
646
698
  // UPSERT Statements ------------------------------------------------
647
699
 
package/lib/cqn4sql.js CHANGED
@@ -111,7 +111,7 @@ function cqn4sql(originalQuery, model) {
111
111
 
112
112
  // calculate the primary keys of the target entity, there is always exactly
113
113
  // one query source for UPDATE / DELETE
114
- const queryTarget = Object.values(originalQuery.sources)[0]
114
+ const queryTarget = Object.values(originalQuery.sources)[0].definition
115
115
  const keys = Object.values(queryTarget.elements).filter(e => e.key === true)
116
116
  const primaryKey = { list: [] }
117
117
  keys.forEach(k => {
@@ -181,10 +181,9 @@ function cqn4sql(originalQuery, model) {
181
181
 
182
182
  if (inferred.SELECT.search) {
183
183
  // Search target can be a navigation, in that case use _target to get the correct entity
184
- const where = transformSearchToWhere(inferred.SELECT.search, transformedFrom)
185
- if (where) {
186
- transformedQuery.SELECT.where = where
187
- }
184
+ const { where, having } = transformSearch(inferred.SELECT.search, transformedFrom) || {}
185
+ if (where) transformedQuery.SELECT.where = where
186
+ else if (having) transformedQuery.SELECT.having = having
188
187
  }
189
188
  return transformedQuery
190
189
  }
@@ -204,20 +203,26 @@ function cqn4sql(originalQuery, model) {
204
203
  }
205
204
 
206
205
  /**
207
- * Transforms a search expression to a WHERE clause for a SELECT operation.
206
+ * Transforms a search expression into a WHERE or HAVING clause for a SELECT operation, depending on the context of the query.
207
+ * The function decides whether to use a WHERE or HAVING clause based on the presence of aggregated columns in the search criteria.
208
208
  *
209
- * @param {object} search - The search expression which shall be applied to the searchable columns on the query source.
209
+ * @param {object} search - The search expression to be applied to the searchable columns within the query source.
210
210
  * @param {object} from - The FROM clause of the CQN statement.
211
211
  *
212
- * @returns {(Object|Array|undefined)} - If the target of the query contains searchable elements, the function returns an array that represents the WHERE clause.
213
- * 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.
214
- * If the SELECT query does not contain a WHERE clause, the returned array solely consists of the 'contains' clause.
215
- * If the target entity of the query does not contain searchable elements, the function returns null.
212
+ * @returns {(Object|Array|null)} - The function returns an object representing the WHERE or HAVING clause of the query:
213
+ * - If the target of the query contains searchable elements, an array representing the WHERE or HAVING clause is returned.
214
+ * This includes appending to an existing clause with an AND condition or creating a new clause solely with the 'contains' clause.
215
+ * - If the SELECT query does not initially contain a WHERE or HAVING clause, the returned object solely consists of the 'contains' clause.
216
+ * - If the target entity of the query does not contain searchable elements, the function returns null.
216
217
  *
218
+ * Note: The WHERE clause is used for filtering individual rows before any aggregation occurs.
219
+ * The HAVING clause is utilized for conditions on aggregated data, applied after grouping operations.
217
220
  */
218
- function transformSearchToWhere(search, from) {
219
- const entity = from.$refLinks[0].definition._target || from.$refLinks[0].definition
220
- const searchIn = computeColumnsToBeSearched(inferred, entity, from.as)
221
+ function transformSearch(search, from) {
222
+ const entity = getDefinition(from.$refLinks[0].definition.target) || from.$refLinks[0].definition
223
+ // pass transformedQuery because we may need to search in the columns directly
224
+ // in case of aggregation
225
+ const searchIn = computeColumnsToBeSearched(transformedQuery, entity, from.as)
221
226
  if (searchIn.length > 0) {
222
227
  const xpr = search
223
228
  const contains = {
@@ -228,10 +233,16 @@ function cqn4sql(originalQuery, model) {
228
233
  ],
229
234
  }
230
235
 
231
- if (transformedQuery.SELECT.where) {
232
- return [asXpr(transformedQuery.SELECT.where), 'and', contains]
236
+ // if the query is grouped and the queries columns contain an aggregate function,
237
+ // we must put the search term into the `having` clause, as the search expression
238
+ // is defined on the aggregated result, not on the individual rows
239
+ let prop = 'where'
240
+
241
+ if (inferred.SELECT.groupBy && searchIn.some(c => c.func || c.xpr)) prop = 'having'
242
+ if (transformedQuery.SELECT[prop]) {
243
+ return { [prop]: [asXpr(transformedQuery.SELECT.where), 'and', contains] }
233
244
  } else {
234
- return [contains]
245
+ return { [prop]: [contains] }
235
246
  }
236
247
  } else {
237
248
  return null
@@ -255,9 +266,12 @@ function cqn4sql(originalQuery, model) {
255
266
  */
256
267
  const alreadySeen = new Map()
257
268
  inferred.joinTree._roots.forEach(r => {
258
- const args = r.queryArtifact.SELECT
259
- ? [{ SELECT: transformSubquery(r.queryArtifact).SELECT, as: r.alias }]
260
- : [{ ref: [localized(r.queryArtifact)], as: r.alias }]
269
+ const args = []
270
+ if (r.queryArtifact.SELECT) args.push({ SELECT: transformSubquery(r.queryArtifact).SELECT, as: r.alias })
271
+ else {
272
+ const id = localized(r.queryArtifact)
273
+ args.push({ ref: [r.args ? { id, args: r.args } : id], as: r.alias })
274
+ }
261
275
  from = { join: 'left', args, on: [] }
262
276
  r.children.forEach(c => {
263
277
  from = joinForBranch(from, c)
@@ -282,10 +296,13 @@ function cqn4sql(originalQuery, model) {
282
296
  ),
283
297
  )
284
298
 
299
+ const id = localized(getDefinition(nextAssoc.$refLink.definition.target))
300
+ const { args } = nextAssoc
285
301
  const arg = {
286
- ref: [localized(model.definitions[nextAssoc.$refLink.definition.target])],
302
+ ref: [args ? { id, args } : id],
287
303
  as: nextAssoc.$refLink.alias,
288
304
  }
305
+
289
306
  lhs.args.push(arg)
290
307
  alreadySeen.set(nextAssoc.$refLink.alias, true)
291
308
  if (nextAssoc.where) {
@@ -438,7 +455,7 @@ function cqn4sql(originalQuery, model) {
438
455
  const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
439
456
  if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
440
457
 
441
- if (col.$refLinks.some(link => link.definition._target?.['@cds.persistence.skip'] === true)) return
458
+ if (col.$refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true)) return
442
459
 
443
460
  const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
444
461
  flatColumns.forEach(flatColumn => {
@@ -472,13 +489,17 @@ function cqn4sql(originalQuery, model) {
472
489
 
473
490
  function getTransformedColumn(col) {
474
491
  if (col.xpr) {
475
- return { xpr: getTransformedTokenStream(col.xpr) }
492
+ const xpr = { xpr: getTransformedTokenStream(col.xpr) }
493
+ if (col.cast) xpr.cast = col.cast
494
+ return xpr
476
495
  } else if (col.func) {
477
- return {
496
+ const func = {
478
497
  func: col.func,
479
498
  args: col.args && getTransformedTokenStream(col.args),
480
- as: col.func,
499
+ as: col.func, // may be overwritten by the explicit alias
481
500
  }
501
+ if (col.cast) func.cast = col.cast
502
+ return func
482
503
  } else {
483
504
  return copy(col)
484
505
  }
@@ -680,7 +701,7 @@ function cqn4sql(originalQuery, model) {
680
701
  // select from books { { * } as bar }
681
702
  // only possible if there is exactly one query source
682
703
  if (!baseRef.length) {
683
- const [tableAlias, definition] = Object.entries(inferred.sources)[0]
704
+ const [tableAlias, { definition }] = Object.entries(inferred.sources)[0]
684
705
  baseRef.push(tableAlias)
685
706
  baseRefLinks.push({ definition, source: definition })
686
707
  }
@@ -833,7 +854,7 @@ function cqn4sql(originalQuery, model) {
833
854
  function getTransformedOrderByGroupBy(columns, inOrderBy = false) {
834
855
  const res = []
835
856
  for (let i = 0; i < columns.length; i++) {
836
- const col = columns[i]
857
+ let col = columns[i]
837
858
  if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
838
859
  const calcElement = resolveCalculatedElement(col, true)
839
860
  res.push(calcElement)
@@ -849,14 +870,44 @@ function cqn4sql(originalQuery, model) {
849
870
  } else if (pseudos.elements[col.ref?.[0]]) {
850
871
  res.push({ ...col })
851
872
  } else if (col.ref) {
852
- if (col.$refLinks.some(link => link.definition._target?.['@cds.persistence.skip'] === true)) continue
873
+ if (col.$refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true))
874
+ continue
853
875
  if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
854
876
  const dollarSelfReplacement = calculateDollarSelfColumn(col)
855
877
  res.push(...getTransformedOrderByGroupBy([dollarSelfReplacement], inOrderBy))
856
878
  continue
857
879
  }
858
- const { target } = col.$refLinks[0]
859
- const tableAlias = target.SELECT ? null : getQuerySourceName(col) // do not prepend TA if orderBy column addresses element of query
880
+ const { target, definition } = col.$refLinks[0]
881
+ let tableAlias = null
882
+ if (target.SELECT?.columns && inOrderBy) {
883
+ // usually TA is omitted if order by ref is a column
884
+ // if a localized sorting is requested, we add `COLLATE`s
885
+ // later on, which transforms the simple name to an expression
886
+ // --> in an expression, only source elements can be addressed, hence we must add TA
887
+ if (target.SELECT.localized && definition.type === 'cds.String') {
888
+ const referredCol = target.SELECT.columns.find(c => {
889
+ return c.as === col.ref[0] || c.ref?.at(-1) === col.ref[0]
890
+ })
891
+ if (referredCol) {
892
+ // keep sort and nulls properties
893
+ referredCol.sort = col.sort
894
+ referredCol.nulls = col.nulls
895
+ col = referredCol
896
+ if (definition.kind === 'element') {
897
+ tableAlias = getQuerySourceName(col)
898
+ } else {
899
+ // we must replace the reference with the underlying expression
900
+ const { val, func, args, xpr } = col
901
+ if (val) res.push({ val })
902
+ if (func) res.push({ func, args })
903
+ if (xpr) res.push({ xpr })
904
+ continue
905
+ }
906
+ }
907
+ }
908
+ } else {
909
+ tableAlias = getQuerySourceName(col) // do not prepend TA if orderBy column addresses element of query
910
+ }
860
911
  const leaf = col.$refLinks[col.$refLinks.length - 1].definition
861
912
  if (leaf.virtual === true) continue // already in getFlatColumnForElement
862
913
  let baseName
@@ -950,7 +1001,7 @@ function cqn4sql(originalQuery, model) {
950
1001
  const { index, tableAlias } = inferred.$combinedElements[k][0]
951
1002
  const element = tableAlias.elements[k]
952
1003
  // ignore FK for odata csn / ignore blobs from wildcard expansion
953
- if (isManagedAssocInFlatMode(element) || (element['@Core.MediaType'] && !element['@Core.IsURL'])) return
1004
+ if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') return
954
1005
  // for wildcard on subquery in from, just reference the elements
955
1006
  if (tableAlias.SELECT && !element.elements && !element.target) {
956
1007
  wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
@@ -991,7 +1042,7 @@ function cqn4sql(originalQuery, model) {
991
1042
  */
992
1043
  function getElementForRef(ref, def) {
993
1044
  return ref.reduce((prev, res) => {
994
- return (prev?.elements || prev?.foreignKeys)?.[res] || prev?._target?.elements[res] // PLEASE REVIEW: should we add the .foreignKey check here for the non-ucsn case?
1045
+ return (prev?.elements || prev?.foreignKeys)?.[res] || getDefinition(prev?.target)?.elements[res] // PLEASE REVIEW: should we add the .foreignKey check here for the non-ucsn case?
995
1046
  }, def)
996
1047
  }
997
1048
 
@@ -1087,7 +1138,7 @@ function cqn4sql(originalQuery, model) {
1087
1138
  if (element.keys) {
1088
1139
  const flatColumns = []
1089
1140
  element.keys.forEach(fk => {
1090
- const fkElement = getElementForRef(fk.ref, element._target)
1141
+ const fkElement = getElementForRef(fk.ref, getDefinition(element.target))
1091
1142
  let fkBaseName
1092
1143
  if (!leafAssoc || leafAssoc.onlyForeignKeyAccess)
1093
1144
  fkBaseName = `${baseName}_${fk.as || fk.ref[fk.ref.length - 1]}`
@@ -1141,7 +1192,7 @@ function cqn4sql(originalQuery, model) {
1141
1192
  if (tableAlias) flatColumn.ref.unshift(tableAlias)
1142
1193
 
1143
1194
  // in a flat model, we must assign the foreign key rather than the key in the target
1144
- const flatForeignKey = model.definitions[element.parent.name]?.elements[fkBaseName]
1195
+ const flatForeignKey = getDefinition(element.parent.name)?.elements[fkBaseName]
1145
1196
 
1146
1197
  setElementOnColumns(flatColumn, flatForeignKey || fkElement)
1147
1198
  Object.defineProperty(flatColumn, '_csnPath', { value: csnPath, writable: true })
@@ -1260,7 +1311,7 @@ function cqn4sql(originalQuery, model) {
1260
1311
  }”`,
1261
1312
  )
1262
1313
  }
1263
- whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true))
1314
+ whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true, step.args))
1264
1315
  }
1265
1316
 
1266
1317
  const whereExists = { SELECT: whereExistsSubqueries(whereExistsSubSelects) }
@@ -1292,7 +1343,7 @@ function cqn4sql(originalQuery, model) {
1292
1343
  }
1293
1344
  } else if (tokenStream.length === 1 && token.val && $baseLink) {
1294
1345
  // infix filter - OData variant w/o mentioning key --> flatten out and compare each leaf to token.val
1295
- const def = $baseLink.definition._target || $baseLink.definition
1346
+ const def = getDefinition($baseLink.definition.target) || $baseLink.definition
1296
1347
  const keys = def.keys // use key aspect on entity
1297
1348
  const keyValComparisons = []
1298
1349
  const flatKeys = []
@@ -1552,7 +1603,7 @@ function cqn4sql(originalQuery, model) {
1552
1603
  const nextStep = refReverse[i + 1] // only because we want the filter condition
1553
1604
 
1554
1605
  if (stepLink.definition.target && nextStepLink) {
1555
- const { where } = nextStep
1606
+ const { where, args } = nextStep
1556
1607
  if (isStructured(nextStepLink.definition)) {
1557
1608
  // find next association / entity in the ref because this is actually our real nextStep
1558
1609
  const nextStepIndex =
@@ -1573,7 +1624,7 @@ function cqn4sql(originalQuery, model) {
1573
1624
  as = getNextAvailableTableAlias(as)
1574
1625
  }
1575
1626
  nextStepLink.alias = as
1576
- whereExistsSubSelects.push(getWhereExistsSubquery(stepLink, nextStepLink, where))
1627
+ whereExistsSubSelects.push(getWhereExistsSubquery(stepLink, nextStepLink, where, false, args))
1577
1628
  }
1578
1629
  }
1579
1630
 
@@ -1607,7 +1658,12 @@ function cqn4sql(originalQuery, model) {
1607
1658
 
1608
1659
  // adjust ref & $refLinks after associations have turned into where exists subqueries
1609
1660
  transformedFrom.$refLinks.splice(0, transformedFrom.$refLinks.length - 1)
1610
- transformedFrom.ref = [localized(transformedFrom.$refLinks[0].target)]
1661
+
1662
+ let args = from.ref.at(-1).args
1663
+ const subquerySource = transformedFrom.$refLinks[0].target
1664
+ if (subquerySource.params && !args) args = {}
1665
+ const id = localized(subquerySource)
1666
+ transformedFrom.ref = [args ? { id, args } : id]
1611
1667
 
1612
1668
  return { transformedWhere, transformedFrom }
1613
1669
  }
@@ -1661,7 +1717,7 @@ function cqn4sql(originalQuery, model) {
1661
1717
  */
1662
1718
  function backlinkFor(assoc) {
1663
1719
  if (!assoc.on) return null
1664
- const target = model.definitions[assoc.target]
1720
+ const target = getDefinition(assoc.target)
1665
1721
  // technically we could have multiple backlinks
1666
1722
  const backlinks = []
1667
1723
  for (let i = 0; i < assoc.on.length; i += 3) {
@@ -1687,7 +1743,7 @@ function cqn4sql(originalQuery, model) {
1687
1743
  */
1688
1744
  function onCondFor(assocRefLink, targetSideRefLink, inWhereOrJoin) {
1689
1745
  const { on, keys } = assocRefLink.definition
1690
- const target = model.definitions[assocRefLink.definition.target]
1746
+ const target = getDefinition(assocRefLink.definition.target)
1691
1747
  let res
1692
1748
  // technically we could have multiple backlinks
1693
1749
  if (keys) {
@@ -1738,10 +1794,11 @@ function cqn4sql(originalQuery, model) {
1738
1794
  if (res === '$self')
1739
1795
  // next is resolvable in entity
1740
1796
  return prev
1741
- const definition = prev?.elements?.[res] || prev?._target?.elements[res] || pseudos.elements[res]
1797
+ const definition =
1798
+ prev?.elements?.[res] || getDefinition(prev?.target)?.elements[res] || pseudos.elements[res]
1742
1799
  const target = getParentEntity(definition)
1743
1800
  thing.$refLinks[i] = { definition, target, alias: definition.name }
1744
- return prev?.elements?.[res] || prev?._target?.elements[res] || pseudos.elements[res]
1801
+ return prev?.elements?.[res] || getDefinition(prev?.target)?.elements[res] || pseudos.elements[res]
1745
1802
  }, assocHost)
1746
1803
  }
1747
1804
 
@@ -1843,7 +1900,7 @@ function cqn4sql(originalQuery, model) {
1843
1900
  result[i].ref.splice(0, 1, assocRefLink.alias)
1844
1901
  } else if (
1845
1902
  definition.name in
1846
- (targetSideRefLink.definition.elements || targetSideRefLink.definition._target.elements)
1903
+ (targetSideRefLink.definition.elements || getDefinition(targetSideRefLink.definition.target).elements)
1847
1904
  ) {
1848
1905
  // first step is association which refers to its foreign key by dot notation
1849
1906
  result[i].ref = [targetSideRefLink.alias, lhs.ref.join('_')]
@@ -1865,7 +1922,7 @@ function cqn4sql(originalQuery, model) {
1865
1922
  // pseudo element
1866
1923
  return element
1867
1924
  if (element.kind === 'entity') return element
1868
- else return model.definitions[localized(getParentEntity(element.parent))]
1925
+ else return getDefinition(localized(getParentEntity(element.parent)))
1869
1926
  }
1870
1927
  }
1871
1928
 
@@ -1880,11 +1937,11 @@ function cqn4sql(originalQuery, model) {
1880
1937
  function getParentKeyForeignKeyPairs(assoc, targetSideRefLink, flipSourceAndTarget = false) {
1881
1938
  const res = []
1882
1939
  const backlink = backlinkFor(assoc)?.[0]
1883
- const { keys, _target } = backlink || assoc
1940
+ const { keys, target } = backlink || assoc
1884
1941
  if (keys) {
1885
1942
  keys.forEach(fk => {
1886
1943
  const { ref, as } = fk
1887
- const elem = getElementForRef(ref, _target) // find the element (the target element of the foreign key) in the target of the (backlink) association
1944
+ const elem = getElementForRef(ref, getDefinition(target)) // find the element (the target element of the foreign key) in the target of the (backlink) association
1888
1945
  const flatParentKeys = getFlatColumnsFor(elem, { baseName: ref.slice(0, ref.length - 1).join('_') }) // it might be a structured element, so expand it into the full parent key tuple
1889
1946
  const flatAssociationName = getFullName(backlink || assoc) // get the name of the (backlink) association
1890
1947
  const flatForeignKeys = getFlatColumnsFor(elem, { baseName: flatAssociationName, columnAlias: as }) // the name of the (backlink) association is the base of the foreign key tuple, also respect aliased fk.
@@ -1933,7 +1990,7 @@ function cqn4sql(originalQuery, model) {
1933
1990
  * -> if it is, target and source side are flipped in the where exists subquery
1934
1991
  * @returns {CQN.SELECT}
1935
1992
  */
1936
- function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false) {
1993
+ function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false, customArgs = null) {
1937
1994
  const { definition } = current
1938
1995
  const { definition: nextDefinition } = next
1939
1996
  const on = []
@@ -1957,9 +2014,12 @@ function cqn4sql(originalQuery, model) {
1957
2014
  on.push(...['and', ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter)])
1958
2015
  }
1959
2016
 
2017
+ const subquerySource = assocTarget(nextDefinition) || nextDefinition
2018
+ const id = localized(subquerySource)
2019
+ if (subquerySource.params && !customArgs) customArgs = {}
1960
2020
  const SELECT = {
1961
2021
  from: {
1962
- ref: [localized(assocTarget(nextDefinition) || nextDefinition)],
2022
+ ref: [customArgs ? { id, args: customArgs } : id],
1963
2023
  as: next.alias,
1964
2024
  },
1965
2025
  columns: [
@@ -1982,7 +2042,7 @@ function cqn4sql(originalQuery, model) {
1982
2042
  */
1983
2043
  function localized(definition) {
1984
2044
  if (!isLocalized(definition)) return definition.name
1985
- const view = model.definitions[`localized.${definition.name}`]
2045
+ const view = getDefinition(`localized.${definition.name}`)
1986
2046
  return view?.name || definition.name
1987
2047
  }
1988
2048
 
@@ -1995,7 +2055,18 @@ function cqn4sql(originalQuery, model) {
1995
2055
  * @returns true if the given definition shall be localized
1996
2056
  */
1997
2057
  function isLocalized(definition) {
1998
- return inferred.SELECT?.localized && definition['@cds.localized'] !== false
2058
+ return (
2059
+ inferred.SELECT?.localized &&
2060
+ definition['@cds.localized'] !== false &&
2061
+ !inferred.SELECT.forUpdate &&
2062
+ !inferred.SELECT.forShareLock
2063
+ )
2064
+ }
2065
+
2066
+ /** returns the CSN definition for the given name from the model */
2067
+ function getDefinition(name) {
2068
+ if (!name) return null
2069
+ return model.definitions[name]
1999
2070
  }
2000
2071
 
2001
2072
  /**
@@ -2005,7 +2076,7 @@ function cqn4sql(originalQuery, model) {
2005
2076
  * @returns the csn definition of the association target or null if it is not an association
2006
2077
  */
2007
2078
  function assocTarget(assoc) {
2008
- return model.definitions[assoc.target] || null
2079
+ return getDefinition(assoc.target) || null
2009
2080
  }
2010
2081
 
2011
2082
  /**
@@ -242,10 +242,23 @@ const _getDeepQueries = (diff, target, root = false) => {
242
242
  queries.push(...subQueries)
243
243
  }
244
244
 
245
- queries.forEach(q => {
245
+ const insertQueries = new Map()
246
+
247
+ return queries.map(q => {
248
+ // Merge all INSERT statements for each target
249
+ if (q.INSERT) {
250
+ const target = q.target
251
+ if (insertQueries.has(target)) {
252
+ insertQueries.get(target).INSERT.entries.push(...q.INSERT.entries)
253
+ return
254
+ } else {
255
+ insertQueries.set(target, q)
256
+ }
257
+ }
246
258
  Object.defineProperty(q, handledDeep, { value: true })
259
+ return q
247
260
  })
248
- return queries
261
+ .filter(a => a)
249
262
  }
250
263
 
251
264
  module.exports = {
@@ -47,13 +47,16 @@ function infer(originalQuery, model) {
47
47
  Object.defineProperties(inferred, {
48
48
  // REVISIT: public, or for local reuse, or in cqn4sql only?
49
49
  sources: { value: sources, writable: true },
50
- target: { value: aliases.length === 1 ? sources[aliases[0]] : originalQuery, writable: true }, // REVISIT: legacy?
50
+ target: {
51
+ value: aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery,
52
+ writable: true,
53
+ }, // REVISIT: legacy?
51
54
  })
52
55
  // also enrich original query -> writable because it may be inferred again
53
56
  Object.defineProperties(originalQuery, {
54
57
  sources: { value: sources, writable: true },
55
58
  target: {
56
- value: aliases.length === 1 ? sources[aliases[0]] : originalQuery,
59
+ value: aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery,
57
60
  writable: true,
58
61
  },
59
62
  })
@@ -94,12 +97,13 @@ function infer(originalQuery, model) {
94
97
  function inferTarget(from, querySources) {
95
98
  const { ref } = from
96
99
  if (ref) {
97
- const first = ref[0].id || ref[0]
98
- let target = getDefinition(first, model)
100
+ const { id, args } = ref[0]
101
+ const first = id || ref[0]
102
+ let target = getDefinition(first) || cds.error`"${first}" not found in the definitions of your model`
99
103
  if (!target) throw new Error(`"${first}" not found in the definitions of your model`)
100
104
  if (ref.length > 1) {
101
105
  target = from.ref.slice(1).reduce((d, r) => {
102
- const next = d.elements[r.id || r]?._target || d.elements[r.id || r]
106
+ const next = getDefinition(d.elements[r.id || r]?.target) || d.elements[r.id || r]
103
107
  if (!next) throw new Error(`No association “${r.id || r}” in ${d.kind} “${d.name}”`)
104
108
  return next
105
109
  }, target)
@@ -113,16 +117,18 @@ function infer(originalQuery, model) {
113
117
  from.as ||
114
118
  (ref.length === 1 ? first.match(/[^.]+$/)[0] : ref[ref.length - 1].id || ref[ref.length - 1])
115
119
  if (alias in querySources) throw new Error(`Duplicate alias "${alias}"`)
116
- querySources[alias] = target
120
+ querySources[alias] = { definition: target, args }
117
121
  const last = from.$refLinks.at(-1)
118
122
  last.alias = alias
119
123
  } else if (from.args) {
120
124
  from.args.forEach(a => inferTarget(a, querySources))
121
125
  } else if (from.SELECT) {
122
126
  infer(from, model) // we need the .elements in the sources
123
- querySources[from.as || ''] = from
127
+ querySources[from.as || ''] = { definition: from }
124
128
  } else if (typeof from === 'string') {
125
- querySources[/([^.]*)$/.exec(from)[0]] = getDefinition(from, model)
129
+ // TODO: Create unique alias, what about duplicates?
130
+ const definition = getDefinition(from) || cds.error`"${from}" not found in the definitions of your model`
131
+ querySources[/([^.]*)$/.exec(from)[0]] = { definition }
126
132
  } else if (from.SET) {
127
133
  infer(from, model)
128
134
  }
@@ -168,7 +174,7 @@ function infer(originalQuery, model) {
168
174
  // we need to search for first step in ´model.definitions[infixAlias]`
169
175
  if ($baseLink) {
170
176
  const { definition } = $baseLink
171
- const elements = definition._target?.elements || definition.elements
177
+ const elements = getDefinition(definition.target)?.elements || definition.elements
172
178
  const e = elements?.[id] || cds.error`"${id}" not found in the elements of "${definition.name}"`
173
179
  if (e.target) {
174
180
  // only fk access in infix filter
@@ -188,22 +194,22 @@ function infer(originalQuery, model) {
188
194
  Object.defineProperty(arg, 'flatName', { value: ref.join('_'), writable: true })
189
195
  } else {
190
196
  // must be in model.definitions
191
- const definition = getDefinition(id, model)
197
+ const definition = getDefinition(id) || cds.error`"${id}" not found in the definitions of your model`
192
198
  arg.$refLinks[0] = { definition, target: definition }
193
199
  }
194
200
  } else {
195
201
  const recent = arg.$refLinks[i - 1]
196
- const { elements } = recent.definition._target || recent.definition
202
+ const { elements } = getDefinition(recent.definition.target) || recent.definition
197
203
  const e = elements[id]
198
204
  if (!e) throw new Error(`"${id}" not found in the elements of "${arg.$refLinks[i - 1].definition.name}"`)
199
- arg.$refLinks.push({ definition: e, target: e._target || e })
205
+ arg.$refLinks.push({ definition: e, target: getDefinition(e.target) || e })
200
206
  }
201
207
  arg.$refLinks[i].alias = !ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
202
208
 
203
209
  // link refs in where
204
210
  if (step.where) {
205
211
  // REVISIT: why do we need to walk through these so early?
206
- if (arg.$refLinks[i].definition.kind === 'entity' || arg.$refLinks[i].definition._target) {
212
+ if (arg.$refLinks[i].definition.kind === 'entity' || getDefinition(arg.$refLinks[i].definition.target)) {
207
213
  let existsPredicate = false
208
214
  const walkTokenStream = token => {
209
215
  if (token === 'exists') {
@@ -243,7 +249,7 @@ function infer(originalQuery, model) {
243
249
  function inferCombinedElements() {
244
250
  const combinedElements = {}
245
251
  for (const index in sources) {
246
- const tableAlias = sources[index]
252
+ const tableAlias = getDefinitionFromSources(sources, index)
247
253
  for (const key in tableAlias.elements) {
248
254
  if (key in combinedElements) combinedElements[key].push({ index, tableAlias })
249
255
  else combinedElements[key] = [{ index, tableAlias }]
@@ -497,24 +503,30 @@ function infer(originalQuery, model) {
497
503
  nameSegments.push(id)
498
504
  } else if ($baseLink) {
499
505
  const { definition, target } = $baseLink
500
- const elements = definition._target?.elements || definition.elements
506
+ const elements = getDefinition(definition.target)?.elements || definition.elements
501
507
  if (elements && id in elements) {
502
508
  const element = elements[id]
503
509
  rejectNonFkAccess(element)
504
- const resolvableIn = definition.target ? definition._target : target
510
+ const resolvableIn = getDefinition(definition.target) || target
505
511
  column.$refLinks.push({ definition: elements[id], target: resolvableIn })
506
512
  } else {
507
513
  stepNotFoundInPredecessor(id, definition.name)
508
514
  }
509
515
  nameSegments.push(id)
510
516
  } else if (firstStepIsTableAlias) {
511
- column.$refLinks.push({ definition: sources[id], target: sources[id] })
517
+ column.$refLinks.push({
518
+ definition: getDefinitionFromSources(sources, id),
519
+ target: getDefinitionFromSources(sources, id),
520
+ })
512
521
  } else if (firstStepIsSelf) {
513
522
  column.$refLinks.push({ definition: { elements: queryElements }, target: { elements: queryElements } })
514
523
  } else if (column.ref.length > 1 && inferred.outerQueries?.find(outer => id in outer.sources)) {
515
524
  // outer query accessed via alias
516
525
  const outerAlias = inferred.outerQueries.find(outer => id in outer.sources)
517
- column.$refLinks.push({ definition: outerAlias.sources[id], target: outerAlias.sources[id] })
526
+ column.$refLinks.push({
527
+ definition: getDefinitionFromSources(outerAlias.sources, id),
528
+ target: getDefinitionFromSources(outerAlias.sources, id),
529
+ })
518
530
  } else if (id in $combinedElements) {
519
531
  if ($combinedElements[id].length > 1) stepIsAmbiguous(id) // exit
520
532
  const definition = $combinedElements[id][0].tableAlias.elements[id]
@@ -524,15 +536,15 @@ function infer(originalQuery, model) {
524
536
  } else if (expandOnTableAlias) {
525
537
  // expand on table alias
526
538
  column.$refLinks.push({
527
- definition: sources[id],
528
- target: sources[id],
539
+ definition: getDefinitionFromSources(sources, id),
540
+ target: getDefinitionFromSources(sources, id),
529
541
  })
530
542
  } else {
531
543
  stepNotFoundInCombinedElements(id) // REVISIT: fails with {__proto__:elements)
532
544
  }
533
545
  } else {
534
546
  const { definition } = column.$refLinks[i - 1]
535
- const elements = definition._target?.elements || definition.elements //> go for assoc._target first, instead of assoc as struct
547
+ const elements = getDefinition(definition.target)?.elements || definition.elements //> go for assoc._target first, instead of assoc as struct
536
548
  const element = elements?.[id]
537
549
 
538
550
  if (firstStepIsSelf && element?.isAssociation) {
@@ -543,7 +555,7 @@ function infer(originalQuery, model) {
543
555
  )
544
556
  }
545
557
 
546
- const target = definition._target || column.$refLinks[i - 1].target
558
+ const target = getDefinition(definition.target) || column.$refLinks[i - 1].target
547
559
  if (element) {
548
560
  if ($baseLink) rejectNonFkAccess(element)
549
561
  const $refLink = { definition: elements[id], target }
@@ -602,7 +614,8 @@ function infer(originalQuery, model) {
602
614
  }
603
615
 
604
616
  column.$refLinks[i].alias = !column.ref[i + 1] && column.as ? column.as : id.split('.').pop()
605
- if (column.$refLinks[i].definition._target?.['@cds.persistence.skip'] === true) isPersisted = false
617
+ if (getDefinition(column.$refLinks[i].definition.target)?.['@cds.persistence.skip'] === true)
618
+ isPersisted = false
606
619
  if (!column.ref[i + 1]) {
607
620
  const flatName = nameSegments.join('_')
608
621
  Object.defineProperty(column, 'flatName', { value: flatName, writable: true })
@@ -673,9 +686,7 @@ function infer(originalQuery, model) {
673
686
  // ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
674
687
  if (column.expand) {
675
688
  const { $refLinks } = column
676
- const skip = $refLinks.some(
677
- link => model.definitions[link.definition.target]?.['@cds.persistence.skip'] === true,
678
- )
689
+ const skip = $refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true)
679
690
  if (skip) {
680
691
  $refLinks[$refLinks.length - 1].skipExpand = true
681
692
  return
@@ -716,13 +727,17 @@ function infer(originalQuery, model) {
716
727
  function resolveInline(col, namePrefix = col.as || col.flatName) {
717
728
  const { inline, $refLinks } = col
718
729
  const $leafLink = $refLinks[$refLinks.length - 1]
730
+ if(!$leafLink.definition.target && !$leafLink.definition.elements) {
731
+ throw new Error(`Unexpected “inline” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`)
732
+ }
719
733
  let elements = {}
720
734
  inline.forEach(inlineCol => {
721
735
  inferQueryElement(inlineCol, false, $leafLink, { inExpr: true, inNestedProjection: true, baseColumn: col })
722
736
  if (inlineCol === '*') {
723
737
  const wildCardElements = {}
724
738
  // either the `.elements´ of the struct or the `.elements` of the assoc target
725
- const leafLinkElements = $leafLink.definition._target?.elements || $leafLink.definition.elements
739
+ const leafLinkElements =
740
+ getDefinition($leafLink.definition.target)?.elements || $leafLink.definition.elements
726
741
  Object.entries(leafLinkElements).forEach(([k, v]) => {
727
742
  const name = namePrefix ? `${namePrefix}_${k}` : k
728
743
  // if overwritten/excluded omit from wildcard elements
@@ -768,10 +783,14 @@ function infer(originalQuery, model) {
768
783
  function resolveExpand(col) {
769
784
  const { expand, $refLinks } = col
770
785
  const $leafLink = $refLinks?.[$refLinks.length - 1] || inferred.SELECT.from.$refLinks.at(-1) // fallback to anonymous expand
771
- if ($leafLink.definition._target) {
786
+ if(!$leafLink.definition.target && !$leafLink.definition.elements) {
787
+ throw new Error(`Unexpected “expand” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`)
788
+ }
789
+ const target = getDefinition($leafLink.definition.target)
790
+ if (target) {
772
791
  const expandSubquery = {
773
792
  SELECT: {
774
- from: $leafLink.definition._target.name,
793
+ from: target.name,
775
794
  columns: expand.filter(c => !c.inline),
776
795
  },
777
796
  }
@@ -782,19 +801,20 @@ function infer(originalQuery, model) {
782
801
  ? new cds.struct({ elements: inferredExpandSubquery.elements })
783
802
  : new cds.array({ items: new cds.struct({ elements: inferredExpandSubquery.elements }) })
784
803
  return Object.defineProperty(res, '$assocExpand', { value: true })
785
- } // struct
786
- let elements = {}
787
- expand.forEach(e => {
788
- if (e === '*') {
789
- elements = { ...elements, ...$leafLink.definition.elements }
790
- } else {
791
- inferQueryElement(e, false, $leafLink, { inExpr: true, inNestedProjection: true })
792
- if (e.expand) elements[e.as || e.flatName] = resolveExpand(e)
793
- if (e.inline) elements = { ...elements, ...resolveInline(e) }
794
- else elements[e.as || e.flatName] = e.$refLinks ? e.$refLinks[e.$refLinks.length - 1].definition : e
795
- }
796
- })
797
- return new cds.struct({ elements })
804
+ } else if ($leafLink.definition.elements) {
805
+ let elements = {}
806
+ expand.forEach(e => {
807
+ if (e === '*') {
808
+ elements = { ...elements, ...$leafLink.definition.elements }
809
+ } else {
810
+ inferQueryElement(e, false, $leafLink, { inExpr: true, inNestedProjection: true })
811
+ if (e.expand) elements[e.as || e.flatName] = resolveExpand(e)
812
+ if (e.inline) elements = { ...elements, ...resolveInline(e) }
813
+ else elements[e.as || e.flatName] = e.$refLinks ? e.$refLinks[e.$refLinks.length - 1].definition : e
814
+ }
815
+ })
816
+ return new cds.struct({ elements })
817
+ }
798
818
  }
799
819
 
800
820
  function stepNotFoundInPredecessor(step, def) {
@@ -812,6 +832,7 @@ function infer(originalQuery, model) {
812
832
  function stepNotFoundInCombinedElements(step) {
813
833
  throw new Error(
814
834
  `"${step}" not found in the elements of ${Object.values(sources)
835
+ .map(s => s.definition)
815
836
  .map(def => `"${def.name || /* subquery */ def.as}"`)
816
837
  .join(', ')}`,
817
838
  )
@@ -972,12 +993,12 @@ function infer(originalQuery, model) {
972
993
  const exclude = _.excluding ? x => _.excluding.includes(x) : () => false
973
994
 
974
995
  if (Object.keys(queryElements).length === 0 && aliases.length === 1) {
975
- const { elements } = sources[aliases[0]]
996
+ const { elements } = getDefinitionFromSources(sources, aliases[0])
976
997
  // only one query source and no overwritten columns
977
998
  Object.keys(elements)
978
999
  .filter(k => !exclude(k))
979
1000
  .forEach(k => {
980
- const element = sources[aliases[0]].elements[k]
1001
+ const element = elements[k]
981
1002
  if (element.type !== 'cds.LargeBinary') queryElements[k] = element
982
1003
  if (element.value) {
983
1004
  linkCalculatedElement(element)
@@ -1105,9 +1126,14 @@ function infer(originalQuery, model) {
1105
1126
  }
1106
1127
  }
1107
1128
 
1108
- /** gets the CSN element for the given name from the model */
1109
- function getDefinition(name, model) {
1110
- return model.definitions[name] || cds.error`"${name}" not found in the definitions of your model`
1129
+ /** returns the CSN definition for the given name from the model */
1130
+ function getDefinition(name) {
1131
+ if (!name) return null
1132
+ return model.definitions[name]
1133
+ }
1134
+
1135
+ function getDefinitionFromSources(sources, id) {
1136
+ return sources[id].definition
1111
1137
  }
1112
1138
 
1113
1139
  /**
@@ -1129,6 +1155,7 @@ function infer(originalQuery, model) {
1129
1155
  }, '')
1130
1156
  }
1131
1157
  }
1158
+
1132
1159
  /**
1133
1160
  * Returns true if e is a foreign key of assoc.
1134
1161
  * this function is also compatible with unfolded csn (UCSN),
@@ -43,13 +43,17 @@ class Node {
43
43
  * @param {parent} parent
44
44
  * @param {where} where
45
45
  */
46
- constructor($refLink, parent, where = null) {
46
+ constructor($refLink, parent, where = null, args = null) {
47
47
  /** @type {$refLink} - A reference link to this node. */
48
48
  this.$refLink = $refLink
49
49
  /** @type {parent} - The parent Node of this node. */
50
50
  this.parent = parent
51
51
  /** @type {where} - An optional condition to be applied to this node. */
52
52
  this.where = where
53
+ /** @type {args} - optional parameter object to be applied to this node. */
54
+ const targetHasParams = $refLink.definition._target?.params || $refLink.definition._target?.['@cds.persistence.udf']
55
+ if (!args && targetHasParams) args = {} // if no args are provided, provide empty argument list
56
+ this.args = args
53
57
  /** @type {children} - A Map of children nodes belonging to this node. */
54
58
  this.children = new Map()
55
59
  }
@@ -63,9 +67,13 @@ class Root {
63
67
  * @param {[alias, queryArtifact]} querySource
64
68
  */
65
69
  constructor(querySource) {
66
- const [alias, queryArtifact] = querySource
70
+ let [alias, { definition, args }] = querySource
67
71
  /** @type {queryArtifact} - The artifact used to make the query. */
68
- this.queryArtifact = queryArtifact
72
+ this.queryArtifact = definition
73
+ /** @type {args} - optional parameter object to be applied to this node. */
74
+ const definitionHasParams = definition.params || definition['@cds.persistence.udf']
75
+ if (!args && definitionHasParams) args = {} // if no args are provided, provide empty argument list
76
+ this.args = args
69
77
  /** @type {alias} - The alias of the artifact. */
70
78
  this.alias = alias
71
79
  /** @type {parent} - The parent Node of this root, null for the root Node. */
@@ -170,8 +178,8 @@ class JoinTree {
170
178
 
171
179
  while (i < col.ref.length) {
172
180
  const step = col.ref[i]
173
- const { where } = step
174
- const id = where ? step.id + JSON.stringify(where) : step
181
+ const { where, args } = step
182
+ const id = joinId(step, args, where)
175
183
  const next = node.children.get(id)
176
184
  const $refLink = col.$refLinks[i]
177
185
  if (next) {
@@ -187,7 +195,7 @@ class JoinTree {
187
195
  node.$refLink.onlyForeignKeyAccess = false
188
196
  return true
189
197
  }
190
- const child = new Node($refLink, node, where)
198
+ const child = new Node($refLink, node, where, args)
191
199
  if (child.$refLink.definition.isAssociation) {
192
200
  if (child.where || col.inline) {
193
201
  // filter is always join relevant
@@ -212,6 +220,14 @@ class JoinTree {
212
220
  i += 1
213
221
  }
214
222
  return true
223
+
224
+ function joinId(step, args, where) {
225
+ let appendix
226
+ if (where && args) appendix = JSON.stringify(where) + JSON.stringify(args)
227
+ else if (where) appendix = JSON.stringify(where)
228
+ else if (args) appendix = JSON.stringify(args)
229
+ return appendix ? step.id + appendix : step
230
+ }
215
231
  }
216
232
 
217
233
  /**
package/lib/search.js CHANGED
@@ -10,10 +10,16 @@ const DRAFT_COLUMNS_UNION = {
10
10
  }
11
11
  const DEFAULT_SEARCHABLE_TYPE = 'cds.String'
12
12
 
13
+ // only those which return strings are relevant for search
14
+ const aggregateFunctions = {
15
+ MAX: true,
16
+ MIN: true,
17
+ }
18
+
13
19
  /**
14
20
  * This method gets all columns for an entity.
15
21
  * 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.
22
+ * Moreover, it provides the annotations starting with '@' for each column.
17
23
  *
18
24
  * @param {object} entity - the csn entity
19
25
  * @param {object} [options]
@@ -120,37 +126,49 @@ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, a
120
126
  // aggregations case
121
127
  // in the new parser groupBy is moved to sub select.
122
128
  if (cqn._aggregated || /* new parser */ cqn.SELECT.groupBy || cqn.SELECT?.from?.SELECT?.groupBy) {
123
- cqn.SELECT.columns &&
124
- cqn.SELECT.columns.forEach(column => {
125
- if (column.func) {
126
- // exclude $count by SELECT of number of Items in a Collection
127
- if (
128
- cqn.SELECT.columns.length === 1 &&
129
- column.func === 'count' &&
130
- (column.as === '_counted_' || column.as === '$count')
131
- ) {
132
- return
133
- }
134
-
135
- toBeSearched.push(column)
129
+ cqn.SELECT.columns?.forEach(column => {
130
+ if (column.func || column.xpr) {
131
+ // exclude $count by SELECT of number of Items in a Collection
132
+ if (
133
+ cqn.SELECT.columns.length === 1 &&
134
+ column.func === 'count' &&
135
+ (column.as === '_counted_' || column.as === '$count')
136
+ ) {
136
137
  return
137
138
  }
138
139
 
139
- const columnRef = column.ref
140
- if (columnRef) {
141
- if (entity.elements[columnRef[columnRef.length - 1]]?._type !== DEFAULT_SEARCHABLE_TYPE) return
142
- column = { ref: [...column.ref] }
143
- if (alias) column.ref.unshift(alias)
144
- toBeSearched.push(column)
140
+ // only strings can be searched
141
+ if (column.element.type !== DEFAULT_SEARCHABLE_TYPE) {
142
+ if (column.xpr) return
143
+ if (column.func && !(column.func in aggregateFunctions)) return
145
144
  }
146
- })
145
+
146
+ const searchTerm = {}
147
+ if (column.func) {
148
+ searchTerm.func = column.func
149
+ searchTerm.args = column.args
150
+ } else if (column.xpr) {
151
+ searchTerm.xpr = column.xpr
152
+ }
153
+ toBeSearched.push(searchTerm)
154
+ return
155
+ }
156
+
157
+ // no need to set ref[0] to alias, because columns were already properly transformed
158
+ if (column.ref) {
159
+ if (column.element.type !== DEFAULT_SEARCHABLE_TYPE) return
160
+ column = { ref: [...column.ref] }
161
+ toBeSearched.push(column)
162
+ return
163
+ }
164
+ })
147
165
  } else {
148
166
  toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
149
167
  if (cqn.SELECT.groupBy) toBeSearched = toBeSearched.filter(tbs => cqn.SELECT.groupBy.some(gb => gb.ref[0] === tbs))
150
168
  toBeSearched = toBeSearched.map(c => {
151
- const col = { ref: [c] }
152
- if (alias) col.ref.unshift(alias)
153
- return col
169
+ const column = { ref: [c] }
170
+ if (alias) column.ref.unshift(alias)
171
+ return column
154
172
  })
155
173
  }
156
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.6.4",
3
+ "version": "1.8.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": {
@@ -31,9 +31,5 @@
31
31
  "peerDependencies": {
32
32
  "@sap/cds": ">=7.6"
33
33
  },
34
- "license": "SEE LICENSE",
35
- "devDependencies": {
36
- "@typescript-eslint/eslint-plugin": "^6.2.0",
37
- "typescript": "^5.1.6"
38
- }
34
+ "license": "SEE LICENSE"
39
35
  }