@sap/cds 9.0.4 → 9.2.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/deploy.js +29 -0
  3. package/bin/serve.js +1 -5
  4. package/lib/compile/etc/csv.js +11 -6
  5. package/lib/compile/for/lean_drafts.js +29 -7
  6. package/lib/compile/load.js +8 -5
  7. package/lib/compile/to/hdbtabledata.js +1 -1
  8. package/lib/dbs/cds-deploy.js +5 -34
  9. package/lib/env/cds-env.js +2 -1
  10. package/lib/env/cds-requires.js +4 -1
  11. package/lib/env/defaults.js +0 -11
  12. package/lib/env/schemas/cds-rc.js +218 -6
  13. package/lib/index.js +38 -38
  14. package/lib/log/cds-error.js +12 -11
  15. package/lib/log/format/json.js +1 -1
  16. package/lib/ql/SELECT.js +31 -0
  17. package/lib/ql/resolve.js +1 -1
  18. package/lib/req/context.js +1 -1
  19. package/lib/req/request.js +1 -1
  20. package/lib/req/validate.js +17 -19
  21. package/lib/srv/cds.Service.js +18 -28
  22. package/lib/srv/middlewares/auth/ias-auth.js +29 -2
  23. package/lib/srv/middlewares/auth/jwt-auth.js +11 -1
  24. package/lib/srv/middlewares/auth/xssec.js +1 -1
  25. package/lib/srv/srv-models.js +1 -1
  26. package/lib/srv/srv-tx.js +2 -2
  27. package/lib/utils/cds-utils.js +35 -2
  28. package/lib/utils/csv-reader.js +1 -1
  29. package/lib/utils/inflect.js +2 -2
  30. package/lib/utils/tar.js +60 -23
  31. package/lib/utils/version.js +18 -0
  32. package/libx/_runtime/cds.js +1 -1
  33. package/libx/_runtime/common/aspects/any.js +1 -23
  34. package/libx/_runtime/common/generic/crud.js +1 -3
  35. package/libx/_runtime/common/generic/input.js +113 -52
  36. package/libx/_runtime/common/generic/sorting.js +1 -1
  37. package/libx/_runtime/common/generic/temporal.js +0 -6
  38. package/libx/_runtime/common/utils/draft.js +1 -1
  39. package/libx/_runtime/common/utils/entityFromCqn.js +1 -1
  40. package/libx/_runtime/common/utils/propagateForeignKeys.js +1 -1
  41. package/libx/_runtime/common/utils/resolveView.js +2 -2
  42. package/libx/_runtime/common/utils/structured.js +2 -2
  43. package/libx/_runtime/common/utils/templateProcessor.js +0 -5
  44. package/libx/_runtime/common/utils/vcap.js +1 -1
  45. package/libx/_runtime/fiori/lean-draft.js +529 -143
  46. package/libx/_runtime/messaging/enterprise-messaging-utils/registerEndpoints.js +3 -2
  47. package/libx/_runtime/messaging/service.js +1 -1
  48. package/libx/_runtime/remote/utils/client.js +2 -1
  49. package/libx/common/assert/utils.js +2 -12
  50. package/libx/common/utils/streaming.js +4 -9
  51. package/libx/http/location.js +1 -0
  52. package/libx/odata/ODataAdapter.js +47 -43
  53. package/libx/odata/index.js +1 -1
  54. package/libx/odata/middleware/batch.js +6 -2
  55. package/libx/odata/middleware/create.js +1 -1
  56. package/libx/odata/middleware/error.js +27 -17
  57. package/libx/odata/middleware/operation.js +15 -21
  58. package/libx/odata/middleware/stream.js +1 -1
  59. package/libx/odata/parse/afterburner.js +22 -8
  60. package/libx/odata/parse/cqn2odata.js +16 -10
  61. package/libx/odata/parse/grammar.peggy +185 -134
  62. package/libx/odata/parse/parser.js +1 -1
  63. package/libx/odata/utils/index.js +1 -36
  64. package/libx/odata/utils/metadata.js +34 -1
  65. package/libx/odata/utils/odataBind.js +2 -1
  66. package/libx/odata/utils/result.js +22 -20
  67. package/libx/queue/index.js +7 -4
  68. package/libx/rest/RestAdapter.js +1 -2
  69. package/libx/rest/middleware/create.js +5 -2
  70. package/package.json +2 -2
  71. package/server.js +1 -1
  72. package/bin/deploy/to-hana.js +0 -1
  73. package/lib/utils/check-version.js +0 -9
  74. package/lib/utils/unit.js +0 -19
  75. package/libx/_runtime/cds-services/util/assert.js +0 -181
  76. package/libx/_runtime/types/api.js +0 -129
  77. package/libx/common/assert/validation.js +0 -109
@@ -29,9 +29,10 @@ class EndpointRegistry {
29
29
  if (cds.context.user._is_anonymous) return res.status(401).end()
30
30
  next()
31
31
  })
32
- } else if (process.env.NODE_ENV === 'production') {
33
- LOG.warn('Messaging endpoints not secured')
34
32
  } else {
33
+ if (process.env.NODE_ENV === 'production') {
34
+ LOG.warn('Messaging endpoints not secured')
35
+ }
35
36
  // auth middlewares set cds.context.user
36
37
  cds.app.use(basePath, cds.middlewares.context())
37
38
  }
@@ -48,7 +48,7 @@ module.exports = class MessagingService extends cds.Service {
48
48
  srv.on(event, async msg => {
49
49
  const { data, headers } = msg
50
50
  const messaging = await cds.connect.to('messaging') // needed for potential outbox
51
- return messaging.tx(msg).emit({ event: topic, data, headers })
51
+ return messaging.emit({ event: topic, data, headers })
52
52
  })
53
53
  }
54
54
  })
@@ -231,7 +231,7 @@ const run = async (requestConfig, options) => {
231
231
  e.message = msg ? 'Error during request to remote service: ' + msg : 'Request to remote service failed.'
232
232
  const sanitizedError = _getSanitizedError(e, requestConfig, { suppressRemoteResponseBody })
233
233
  const err = Object.assign(new Error(e.message), { statusCode: 502, reason: sanitizedError })
234
- LOG._warn && LOG.warn(err)
234
+ cds.repl || LOG.warn(err)
235
235
  throw err
236
236
  }
237
237
 
@@ -306,6 +306,7 @@ const _cqnToReqOptions = (query, service, req) => {
306
306
  const reqOptions = {
307
307
  method: queryObject.method,
308
308
  url: queryObject.path
309
+ // REVISIT: remove when we can assume that number of remote services running Okra is negligible
309
310
  // ugly workaround for Okra not allowing spaces in ( x eq 1 )
310
311
  .replace(/\( /g, '(')
311
312
  .replace(/ \)/g, ')')
@@ -50,21 +50,11 @@ function isBase64String(string, strict = false) {
50
50
  return true
51
51
  }
52
52
 
53
- const resolveCDSType = ele => {
54
- // REVISIT: when is ele._type not set and sufficient?
55
- if (ele._type?.match(/^cds\./)) return ele._type
56
- if (ele.type) {
57
- if (ele.type.match(/^cds\./)) return ele.type
58
- return resolveCDSType(ele.__proto__)
59
- }
60
- if (ele.items) return resolveCDSType(ele.items)
61
- return ele
62
- }
53
+
63
54
 
64
55
 
65
56
  module.exports = {
66
57
  getNormalizedDecimal,
67
58
  getTarget,
68
- isBase64String,
69
- resolveCDSType
59
+ isBase64String
70
60
  }
@@ -64,14 +64,9 @@ exports.collectStreamMetadata = (result, operation, query) => {
64
64
  }
65
65
 
66
66
  exports.getReadable = function readable4 (result) {
67
- if (result == null) return
68
- if (typeof result !== 'object') {
69
- const stream = new Readable()
70
- stream.push(result)
71
- stream.push(null)
72
- return stream
73
- }
67
+ if (result == null) return null
74
68
  if (result instanceof Readable) return result
75
- if (result.value) return readable4 (result.value) // REVISIT: for OData legacy only?
76
- if (Array.isArray(result)) return readable4 (result[0]) // compat // REVISIT: remove ?
69
+ if (Array.isArray(result)) return readable4 (result[0]) // compat
70
+ if (typeof result === 'object' && 'value' in result) return readable4 (result.value)
71
+ cds.error(500, `Unexpected result type for streaming: Expected stream.Readable or null but got ${typeof result}`)
77
72
  }
@@ -1,4 +1,5 @@
1
1
  module.exports = (target, srv, result, keys_as_segments) => {
2
+ if (typeof result !== 'object' || result == null || Array.isArray(result)) return
2
3
  const targetName = target.name.replace(`${srv.definition.name}.`, '')
3
4
  if (!target.keys) return targetName
4
5
 
@@ -30,62 +30,66 @@ module.exports = class ODataAdapter extends HttpAdapter {
30
30
  }
31
31
 
32
32
  get router() {
33
+ function set_odata_version(_, res, next) {
34
+ res.set('OData-Version', '4.0')
35
+ next()
36
+ }
37
+
38
+ function validate_representation_headers(req, res, next) {
39
+ if (req.method === 'PUT' && isStream(req._query)) {
40
+ req.body = { value: req }
41
+ return next()
42
+ }
43
+ if (req.method === 'POST' && req.headers['content-type']?.match(/multipart\/mixed/)) {
44
+ return next()
45
+ }
46
+
47
+ const contentLength = req.headers['content-length']
48
+ const [type = '', suffix] = (req.headers['content-type'] || '').split(';')
49
+ // header ending with semicolon is not allowed
50
+ const isJson = type.match(/^application\/json$/) && suffix !== ''
51
+
52
+ // POST with empty body is allowed if no content-type header is set
53
+ if (req.method === 'POST' && (!contentLength || isJson)) return jsonBodyParser(req, res, next)
54
+
55
+ if (req.method in { POST: 1, PUT: 1, PATCH: 1 }) {
56
+ if (!isJson) {
57
+ res.status(415).json({ error: { message: 'Unsupported Media Type', statusCode: 415, code: '415' } })
58
+ return
59
+ }
60
+ if (contentLength === '0') {
61
+ res.status(400).json({ error: { message: 'Expected non-empty body', statusCode: 400, code: '400' } })
62
+ return
63
+ }
64
+ }
65
+
66
+ return jsonBodyParser(req, res, next)
67
+ }
68
+
69
+ function validate_operation_http_method(req, _, next) {
70
+ // operations must have been handled above (POST or GET)
71
+ const { operation } = req._query.SELECT?.from.ref?.slice(-1)[0] || {}
72
+ next(operation ? { code: 405 } : undefined)
73
+ }
74
+
33
75
  const jsonBodyParser = bodyParser4(this)
34
76
  return (
35
77
  super.router
36
- .use(function odata_version(req, res, next) {
37
- res.set('OData-Version', '4.0')
38
- next()
39
- })
40
- // REVISIT: add middleware for negative cases?
78
+ .use(set_odata_version)
41
79
  // service root
42
- .use(/^\/$/, require('./middleware/service-document')(this))
43
- .use('/\\$metadata', require('./middleware/metadata')(this))
80
+ .all('/', require('./middleware/service-document')(this))
81
+ .all('/\\$metadata', require('./middleware/metadata')(this))
44
82
  // parse
45
83
  .use(require('./middleware/parse')(this))
46
- .use(function odata_streams(req, res, next) {
47
- if (req.method === 'PUT' && isStream(req._query)) {
48
- req.body = { value: req }
49
- return next()
50
- }
51
- if (req.method === 'POST' && req.headers['content-type']?.match(/multipart\/mixed/)) {
52
- return next()
53
- }
54
- // POST with empty body is allowed by actions
55
- if (req.method in { PUT: 1, PATCH: 1 }) {
56
- if (req.headers['content-length'] === '0') {
57
- res.status(400).json({ error: { message: 'Expected non-empty body', statusCode: 400, code: '400' } })
58
- return
59
- }
60
- }
61
- if (req.method in { POST: 1, PUT: 1, PATCH: 1 }) {
62
- const contentType = req.headers['content-type'] ?? ''
63
- let contentLength = req.headers['content-length']
64
- contentLength = contentLength ? parseInt(contentLength) : 0
65
-
66
- const parts = contentType.split(';')
67
- // header ending with semicolon is not allowed
68
- if ((contentLength && !parts[0].match(/^application\/json$/)) || parts[1] === '') {
69
- res.status(415).json({ error: { message: 'Unsupported Media Type', statusCode: 415, code: '415' } })
70
- return
71
- }
72
- }
73
-
74
- return jsonBodyParser(req, res, next)
75
- })
84
+ .use(validate_representation_headers)
76
85
  // batch
77
86
  // .all is used deliberately instead of .use so that the matched path is not stripped from req properties
78
87
  .all('/\\$batch', require('./middleware/batch')(this))
79
88
  // handle
80
- // REVISIT: with old adapter, we return 405 for HEAD requests -> check OData spec
81
89
  .head('*', (_, res) => res.sendStatus(405))
82
90
  .post('*', operation4(this), create4(this))
83
91
  .get('*', operation4(this), stream4(this), read4(this))
84
- .use('*', (req, res, next) => {
85
- // operations must have been handled above (POST or GET)
86
- const { operation } = req._query.SELECT?.from.ref?.slice(-1)[0] || {}
87
- next(operation ? { code: 405 } : undefined)
88
- })
92
+ .use(validate_operation_http_method)
89
93
  .put('*', update4(this), create4(this, 'upsert'))
90
94
  .patch('*', update4(this), create4(this, 'upsert'))
91
95
  .delete('*', delete4(this))
@@ -1,6 +1,6 @@
1
1
  /** @typedef {import('../../lib/srv/cds.Service')} Service */
2
2
 
3
- const cds = require('../../')
3
+ const cds = require('../..')
4
4
  const { decodeURIComponent } = cds.utils
5
5
 
6
6
  const odata2cqn = require('./parse/parser').parse
@@ -62,7 +62,6 @@ const _validateBatch = body => {
62
62
  throw _deserializationError(`Method '${method}' is not allowed. Only DELETE, GET, PATCH, POST or PUT are.`)
63
63
 
64
64
  _validateProperty('url', url, 'string')
65
- // TODO: need similar validation in multipart/mixed batch
66
65
  if (url.startsWith('/$batch')) throw _deserializationError('Nested batch requests are not allowed.')
67
66
 
68
67
  // TODO: support for non JSON bodies?
@@ -260,7 +259,12 @@ const _tx_done = async (tx, responses, isJson) => {
260
259
  // here, the commit was rejected even though all requests were successful (e.g., by custom handler or db consistency check)
261
260
  rejected = 'rejected'
262
261
  // construct commit error (without modifying original error)
263
- const error = normalizeError(Object.create(e), { locale: cds.context.locale })
262
+ const error = normalizeError(Object.create(e), {
263
+ get locale() {
264
+ return cds.context.locale
265
+ },
266
+ get() {}
267
+ })
264
268
  // replace all responses with commit error
265
269
  for (const res of responses) {
266
270
  res.status = 'fail'
@@ -72,7 +72,7 @@ module.exports = (adapter, isUpsert) => {
72
72
 
73
73
  handleSapMessages(cdsReq, req, res)
74
74
 
75
- if (!target._isSingleton) {
75
+ if (!target._isSingleton && !res.hasHeader('location')) {
76
76
  res.set('location', location4(cdsReq.target, service, result || cdsReq.data))
77
77
  }
78
78
 
@@ -4,17 +4,8 @@ const { shutdown_on_uncaught_errors } = cds.env.server
4
4
  exports = module.exports = () =>
5
5
  function odata_error(err, req, res, next) {
6
6
  if (exports.pass_through(err)) return next(err)
7
+ else req._is_odata = true
7
8
  if (err.details) err = _fioritized(err)
8
- const content_id = req.headers['content-id']
9
- if (content_id) err['@Core.ContentID'] = content_id
10
- err['@Common.numericSeverity'] ??= err.numericSeverity || 4
11
-
12
- // propagate content-id and set @Common.numericSeverity
13
- err.details?.forEach(e => {
14
- if (content_id) e['@Core.ContentID'] = content_id
15
- e['@Common.numericSeverity'] ??= e.numericSeverity || 4 // Fiori expects this in error.details
16
- })
17
-
18
9
  exports.normalizeError(err, req)
19
10
  return next(err)
20
11
  }
@@ -25,14 +16,18 @@ exports.pass_through = err => {
25
16
  }
26
17
 
27
18
  exports.normalizeError = (err, req, cleanse = ODATA_PROPERTIES) => {
28
- const locale = cds.i18n.locale.from(req)
29
- err.status = _normalize(err, locale, cleanse) || 500
19
+ err.status = _normalize(err, req, cleanse) || 500
30
20
  return err
31
21
  }
32
22
 
33
- exports.getSapMessages = (messages, req) => {
23
+ exports.getLocalizedMessages = (messages, req) => {
34
24
  const locale = cds.i18n.locale.from(req)
35
- for (let m of messages) _normalize(m, locale, SAP_MSG_PROPERTIES)
25
+ for (let m of messages) _normalize(m, req, SAP_MSG_PROPERTIES, locale)
26
+ return messages
27
+ }
28
+
29
+ exports.getSapMessages = (messages, req) => {
30
+ messages = exports.getLocalizedMessages(messages, req)
36
31
  return JSON.stringify(messages).replace(
37
32
  /[\u007F-\uFFFF]/g,
38
33
  c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')
@@ -45,10 +40,13 @@ const SAP_MSG_PROPERTIES = { ...ODATA_PROPERTIES, longtextUrl: 2, transition: 2,
45
40
  const BAD_REQUESTS = { ENTITY_ALREADY_EXISTS: 1, FK_CONSTRAINT_VIOLATION: 2, UNIQUE_CONSTRAINT_VIOLATION: 3 }
46
41
 
47
42
  // prettier-ignore
48
- const _normalize = (err, locale = cds.env.i18n.default_language, keep) => {
43
+ const _normalize = (err, req, keep,
44
+ locale = cds.i18n.locale.from(req) || cds.env.i18n.default_language,
45
+ content_id = req.get('content-id')
46
+ ) => {
49
47
 
50
48
  // Determine status code if not already set
51
- const details = err.details?.map?.(each => _normalize (each, locale, keep))
49
+ const details = err.details?.map?.(each => _normalize (each, req, keep, locale, content_id))
52
50
  const status = err.status || err.statusCode || _status4(err) || _reduce(details)
53
51
 
54
52
  // Determine error code and message
@@ -64,7 +62,11 @@ const _normalize = (err, locale = cds.env.i18n.default_language, keep) => {
64
62
  Object.defineProperty(err, 'toJSON', { value: function() {
65
63
  const that = keep ? {} : {...this}
66
64
  if (keep) for (let k in this) if (k in keep || k[0] === '@') that[k] = this[k]
67
- if (locale) that.message = i18n.messages.at (key, locale, this.args)
65
+ if (req._is_odata && keep !== SAP_MSG_PROPERTIES) {
66
+ that['@Common.numericSeverity'] ??= err.numericSeverity || 4
67
+ if (content_id) that['@Core.ContentID'] = content_id
68
+ }
69
+ if (locale) that.message = _message4 (err, key, locale)
68
70
  if (!that.message) that.message = this.message
69
71
  return that
70
72
  }})
@@ -76,6 +78,14 @@ const _status4 = err => {
76
78
  if (err.message in BAD_REQUESTS) return 400 // REVISIT: should we use 409 or 500 instead?
77
79
  }
78
80
 
81
+ const _message4 = (err, key, locale) => {
82
+ if (err.i18n) {
83
+ key = /{i18n>(.*)}/.exec(err.i18n)?.[1]
84
+ if (!key) return err.i18n
85
+ }
86
+ return i18n.messages.at(key, locale, err.args)
87
+ }
88
+
79
89
  const _reduce = details => {
80
90
  const unique = [...new Set(details)]
81
91
  if (unique.length === 1) return unique[0] // if only one unique status exists, we use that
@@ -2,7 +2,7 @@ const cds = require('../../../')
2
2
 
3
3
  const { pipeline } = require('node:stream/promises')
4
4
 
5
- const { cds2edm, handleSapMessages } = require('../utils')
5
+ const { handleSapMessages } = require('../utils')
6
6
  const getODataMetadata = require('../utils/metadata')
7
7
  const postProcess = require('../utils/postProcess')
8
8
  const getODataResult = require('../utils/result')
@@ -143,12 +143,6 @@ module.exports = adapter => {
143
143
  return res.sendStatus(204)
144
144
  }
145
145
 
146
- if (operation.returns._type?.match?.(/^cds\./)) {
147
- const context = `${'../'.repeat(query?.SELECT?.from?.ref?.length)}$metadata#${cds2edm[operation.returns._type]}`
148
- result = { '@odata.context': context, value: result }
149
- return res.send(result)
150
- }
151
-
152
146
  if (res.statusCode === 201 && !res.hasHeader('location')) {
153
147
  const location = location4(operation.returns, service, result)
154
148
  if (location) res.set('location', location)
@@ -160,21 +154,21 @@ module.exports = adapter => {
160
154
  }
161
155
 
162
156
  // REVISIT: enterprise search result? -> simply return what was provided
163
- if (operation.returns.type !== 'sap.esh.SearchResult') {
164
- const isCollection = !!operation.returns.items
165
- const _target = operation.returns.items ?? operation.returns
166
- const options = { result, isCollection }
167
- if (!_target.name) {
168
- // case: return inline type def
169
- options.edmName = _opResultName({ service, operation, returnType: _target })
170
- }
171
- const SELECT = {
172
- from: query ? { ref: [...query.SELECT.from.ref, { operation: operation.name }] } : {},
173
- one: !isCollection
174
- }
175
- const metadata = getODataMetadata({ SELECT, _target }, options)
176
- result = getODataResult(result, metadata, { isCollection })
157
+ if (operation.returns.type === 'sap.esh.SearchResult') return res.send(result)
158
+
159
+ const isCollection = !!operation.returns.items
160
+ const _target = operation.returns.items ?? operation.returns
161
+ const options = { result, isCollection }
162
+ if (!_target.name) {
163
+ // case: return inline type def
164
+ options.edmName = _opResultName({ service, operation, returnType: _target })
165
+ }
166
+ const SELECT = {
167
+ from: query ? { ref: [...query.SELECT.from.ref, { operation: operation.name }] } : {},
168
+ one: !isCollection
177
169
  }
170
+ const metadata = getODataMetadata({ SELECT, _target }, options)
171
+ result = getODataResult(result, metadata, { isCollection })
178
172
 
179
173
  res.send(result)
180
174
  })
@@ -149,7 +149,7 @@ module.exports = adapter => {
149
149
  }
150
150
 
151
151
  const stream = getReadable(result)
152
- if (!stream) return res.sendStatus(204)
152
+ if (stream == null) return res.sendStatus(204)
153
153
 
154
154
  validateMimetypeIsAcceptedOrThrow(req.headers, mimetype)
155
155
  if (!res.get('content-type')) res.set('content-type', mimetype)
@@ -249,13 +249,17 @@ function _handleCollectionBoundActions(current, ref, i, namespace, one) {
249
249
  )
250
250
 
251
251
  if (onCollection && one) {
252
- const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${action.name}" must be called on a collection of ${current.name}`
252
+ const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${
253
+ action.name
254
+ }" must be called on a collection of ${current.name}`
253
255
  throw Object.assign(new Error(msg), { statusCode: 400 })
254
256
  }
255
257
 
256
258
  if (incompleteKeys) {
257
259
  if (!onCollection) {
258
- const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${action.name}" must be called on a single instance of ${current.name}`
260
+ const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${
261
+ action.name
262
+ }" must be called on a single instance of ${current.name}`
259
263
  throw Object.assign(new Error(msg), { statusCode: 400 })
260
264
  }
261
265
 
@@ -510,7 +514,9 @@ function _processSegments(from, model, namespace, cqn, protocol) {
510
514
 
511
515
  if (incompleteKeys) {
512
516
  // > last segment not fully qualified
513
- const msg = `Entity "${current.name}" has ${keysOf(current).length} keys. Only ${keyCount} ${keyCount === 1 ? 'was' : 'were'} provided.`
517
+ const msg = `Entity "${current.name}" has ${keysOf(current).length} keys. Only ${keyCount} ${
518
+ keyCount === 1 ? 'was' : 'were'
519
+ } provided.`
514
520
  throw Object.assign(new Error(msg), { statusCode: 400 })
515
521
  }
516
522
 
@@ -820,7 +826,13 @@ module.exports = (cqn, model, namespace, protocol) => {
820
826
 
821
827
  // hierarchy requests, quick check to avoid unnecessary traversing
822
828
  // REVISIT: Should be done via annotation on backlink, would make lookup easier
823
- if (target?.elements?.LimitedDescendantCount) {
829
+ const _getRecurse = SELECT => {
830
+ if (SELECT.recurse) return SELECT.recurse
831
+ if (SELECT.from && SELECT.from.SELECT) return _getRecurse(SELECT.from.SELECT)
832
+ }
833
+
834
+ const _recurse = _getRecurse(cqn.SELECT)
835
+ if (_recurse) {
824
836
  let uplinkName
825
837
  for (const key in target) {
826
838
  if (key.match(/@Aggregation\.RecursiveHierarchy\s*#.*\.ParentNavigationProperty/)) {
@@ -829,10 +841,12 @@ module.exports = (cqn, model, namespace, protocol) => {
829
841
  break
830
842
  }
831
843
  }
832
- if (uplinkName) {
833
- let r = cqn.SELECT.recurse
834
- if (r) r.ref[0] = uplinkName
835
- }
844
+ if (!uplinkName || !target.elements[uplinkName])
845
+ throw new cds.error(
846
+ 500,
847
+ 'Cannot resolve `ParentNavigationProperty` in `@Aggregation.RecursiveHierarchy` annotation'
848
+ )
849
+ _recurse.ref[0] = uplinkName
836
850
  }
837
851
 
838
852
  // REVISIT: better
@@ -1,4 +1,4 @@
1
- const cds = require('../../../')
1
+ const cds = require('../../..')
2
2
 
3
3
  const { formatVal } = require('../utils')
4
4
 
@@ -65,10 +65,7 @@ function hasValidProps(obj, ...names) {
65
65
  for (const propName of names) {
66
66
  const validate = validators[propName]
67
67
  const isValid = validate && validate(obj[propName])
68
-
69
- if (!isValid) {
70
- return false
71
- }
68
+ if (!isValid) return false
72
69
  }
73
70
 
74
71
  return true
@@ -183,23 +180,32 @@ function _xpr(expr, target, kind, isLambda, navPrefix = []) {
183
180
  if (inExpr) res.push(`(${inExpr})`)
184
181
  i += 1
185
182
  } else if (_isLambda(cur, expr[i + 1])) {
183
+ // Process 'exists' expression
184
+
186
185
  const { where } = expr[i + 1].ref.at(-1)
187
- const nav = expr[i + 1].ref.map(ref => ref?.id ?? ref).join('/')
186
+ const navSegments = expr[i + 1].ref.map(ref => ref?.id ?? ref)
187
+ const nav = navSegments.join('/')
188
188
 
189
189
  if (kind === 'odata-v2') {
190
190
  // odata-v2 does not support lambda expressions but successfactors allows filter like for to-one assocs
191
191
  cds.log('remote').info(`OData V2 does not support lambda expressions. Using path expression as best effort.`)
192
192
  isLambda = false
193
193
  res.push(_xpr(where, target, kind, isLambda, [...navPrefix, nav]))
194
- } else if (!where) {
195
- res.push(`${nav}/any()`)
196
- } else {
194
+ } else if (res.at(-1) === 'not' && where.length === 2 && where[0] === 'not' && where[1].xpr) {
195
+ // Convert double negation 'not exists where not' to 'all'
196
+ // > reverting the mirrored transformation performed in grammar.peggy/lambda
197
+
198
+ res.pop()
199
+ res.push(`${nav}/all(${LAMBDA_VARIABLE}:${_xpr(where[1].xpr, target, kind, true, navPrefix)})`)
200
+ } else if (where) {
197
201
  res.push(
198
202
  `${nav}/any(${LAMBDA_VARIABLE}:${_xpr(where, target?.elements[nav]._target, kind, true, navPrefix)})`
199
203
  )
204
+ } else {
205
+ res.push(`${nav}/any()`)
200
206
  }
201
207
 
202
- i++
208
+ i += 1
203
209
  } else {
204
210
  res.push(OPERATORS[cur] || cur.toLowerCase())
205
211
  }