@sap/cds 9.2.1 → 9.3.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 (70) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/_i18n/i18n_es.properties +3 -3
  3. package/_i18n/i18n_es_MX.properties +3 -3
  4. package/_i18n/i18n_fr.properties +2 -2
  5. package/_i18n/messages.properties +6 -0
  6. package/app/index.js +0 -1
  7. package/bin/deploy.js +1 -1
  8. package/bin/serve.js +7 -20
  9. package/lib/compile/cdsc.js +3 -0
  10. package/lib/compile/for/flows.js +102 -0
  11. package/lib/compile/for/nodejs.js +28 -0
  12. package/lib/compile/to/edm.js +11 -4
  13. package/lib/core/classes.js +1 -1
  14. package/lib/core/linked-csn.js +8 -0
  15. package/lib/dbs/cds-deploy.js +12 -12
  16. package/lib/env/cds-env.js +1 -1
  17. package/lib/env/cds-requires.js +21 -20
  18. package/lib/env/defaults.js +2 -1
  19. package/lib/index.js +5 -6
  20. package/lib/log/cds-log.js +6 -5
  21. package/lib/log/format/aspects/cf.js +2 -2
  22. package/lib/plugins.js +1 -1
  23. package/lib/ql/cds-ql.js +0 -3
  24. package/lib/req/request.js +3 -3
  25. package/lib/req/response.js +12 -7
  26. package/lib/srv/bindings.js +17 -17
  27. package/lib/srv/cds-connect.js +6 -9
  28. package/lib/srv/cds-serve.js +74 -137
  29. package/lib/srv/cds.Service.js +49 -0
  30. package/lib/srv/factory.js +4 -4
  31. package/lib/srv/middlewares/auth/ias-auth.js +29 -9
  32. package/lib/srv/middlewares/auth/index.js +3 -2
  33. package/lib/srv/middlewares/auth/jwt-auth.js +19 -6
  34. package/lib/srv/protocols/hcql.js +16 -1
  35. package/lib/srv/srv-dispatch.js +1 -1
  36. package/lib/utils/cds-utils.js +4 -8
  37. package/lib/utils/csv-reader.js +27 -7
  38. package/libx/_runtime/cds.js +0 -6
  39. package/libx/_runtime/common/Service.js +5 -0
  40. package/libx/_runtime/common/generic/crud.js +1 -1
  41. package/libx/_runtime/common/generic/flows.js +106 -0
  42. package/libx/_runtime/common/generic/paging.js +3 -3
  43. package/libx/_runtime/common/utils/differ.js +5 -15
  44. package/libx/_runtime/common/utils/resolveView.js +2 -2
  45. package/libx/_runtime/fiori/lean-draft.js +76 -40
  46. package/libx/_runtime/messaging/enterprise-messaging.js +1 -1
  47. package/libx/_runtime/remote/Service.js +68 -62
  48. package/libx/_runtime/remote/utils/client.js +29 -216
  49. package/libx/_runtime/remote/utils/query.js +197 -0
  50. package/libx/_runtime/ucl/Service.js +180 -112
  51. package/libx/_runtime/ucl/queries.js +61 -0
  52. package/libx/odata/ODataAdapter.js +1 -4
  53. package/libx/odata/index.js +2 -10
  54. package/libx/odata/middleware/error.js +8 -1
  55. package/libx/odata/middleware/stream.js +1 -1
  56. package/libx/odata/middleware/update.js +12 -2
  57. package/libx/odata/parse/afterburner.js +113 -20
  58. package/libx/odata/parse/cqn2odata.js +1 -3
  59. package/libx/rest/middleware/parse.js +9 -2
  60. package/package.json +2 -2
  61. package/server.js +2 -0
  62. package/srv/app-service.js +1 -0
  63. package/srv/db-service.js +1 -0
  64. package/srv/msg-service.js +1 -0
  65. package/srv/remote-service.js +1 -0
  66. package/srv/ucl-service.cds +32 -0
  67. package/srv/ucl-service.js +1 -0
  68. package/lib/ql/resolve.js +0 -45
  69. package/libx/common/assert/type-strict.js +0 -109
  70. package/libx/common/assert/utils.js +0 -60
@@ -32,11 +32,16 @@ module.exports = function jwt_auth(config) {
32
32
 
33
33
  try {
34
34
  const securityContext = await createSecurityContext(auth_service, { req })
35
- const tokenInfo = securityContext.token
36
35
  const ctx = cds.context
37
- ctx.user = user_factory(tokenInfo)
38
- ctx.tenant = tokenInfo.getZoneId()
39
- req.authInfo = securityContext //> compat req.authInfo
36
+ ctx.user = user_factory(securityContext)
37
+ ctx.tenant = securityContext.token.getZoneId()
38
+ // REVISIT: remove compat in cds^10
39
+ Object.defineProperty(req, 'authInfo', {
40
+ get() {
41
+ cds.utils.deprecated({ kind: 'API', old: 'cds.context.http.req.authInfo', use: 'cds.context.user.authInfo' })
42
+ return securityContext
43
+ }
44
+ })
40
45
  } catch (e) {
41
46
  if (e instanceof ValidationError) {
42
47
  LOG.warn('Unauthenticated request: ', e)
@@ -53,7 +58,8 @@ module.exports = function jwt_auth(config) {
53
58
  function get_user_factory(credentials, xsappname, kind) {
54
59
  xsappname = xsappname + '.'
55
60
 
56
- return function user_factory(tokenInfo) {
61
+ return function user_factory(securityContext) {
62
+ const tokenInfo = securityContext.token
57
63
  const payload = tokenInfo.getPayload()
58
64
 
59
65
  let id = payload.user_name
@@ -81,7 +87,14 @@ function get_user_factory(credentials, xsappname, kind) {
81
87
  attr.email = payload.email
82
88
  }
83
89
 
84
- return new cds.User({ id, roles, attr, tokenInfo })
90
+ return new cds.User({
91
+ id, roles, attr, authInfo: securityContext,
92
+ // REVISIT: remove compat in cds^10
93
+ get tokenInfo() {
94
+ cds.utils.deprecated({ kind: 'API', old: 'cds.context.user.tokenInfo', use: 'cds.context.user.authInfo.token' })
95
+ return securityContext.token
96
+ }
97
+ })
85
98
  }
86
99
  }
87
100
 
@@ -1,5 +1,6 @@
1
1
  const cds = require('../../index'), {inspect} = cds.utils
2
2
  const express = require('express')
3
+
3
4
  const LOG = cds.log('hcql')
4
5
  const PROD = process.env.NODE_ENV === 'production'
5
6
 
@@ -11,7 +12,11 @@ class HCQLAdapter extends require('./http') {
11
12
  .get ('/\\$csn', this.schema.bind(this)) //> return the CSN as schema
12
13
  .use (express.json(this.body_parser_options)) //> for application/json -> cqn
13
14
  .use (express.text(this.body_parser_options)) //> for text/plain -> cql -> cqn
14
-
15
+ .use ((req,res,next) => {
16
+ const q = typeof req.body === 'string' ? req.body : inspect(req.body, { depth: 4 })
17
+ LOG.info (req.method, decodeURI (req.baseUrl + req.path), q)
18
+ next()
19
+ })
15
20
  // Route for custom actions and functions ...
16
21
  const action = this.action.bind(this)
17
22
  router.param('action', (r,_,next,a) => a in this.service.actions ? next() : next('route'))
@@ -40,6 +45,7 @@ class HCQLAdapter extends require('./http') {
40
45
  router.use (this.crud.bind(this))
41
46
  return router
42
47
  }
48
+ log (req) { } // eslint-disable-line no-unused-vars
43
49
 
44
50
 
45
51
  /**
@@ -73,7 +79,15 @@ class HCQLAdapter extends require('./http') {
73
79
  if (q.SELECT) {
74
80
  if (req.get('Accept-Language')) q.SELECT.localized = true
75
81
  if (req.get('X-Total-Count')) q.SELECT.count = true
82
+ // special handling for $search queries
83
+ const {where} = q.SELECT, $search = where?.[0]
84
+ if ($search?.func === '$search') {
85
+ q.SELECT.search = $search.args
86
+ where.splice(0, where[1] === '>' ? 4 : 2) // remove $search(...) > 0.1 and ...
87
+ if (where.length === 0) delete q.SELECT.where // remove empty where clause
88
+ }
76
89
  }
90
+
77
91
  // got a valid query
78
92
  if (LOG._debug) LOG.debug (inspect(q))
79
93
  return this.valid(q)
@@ -96,6 +110,7 @@ class HCQLAdapter extends require('./http') {
96
110
  if (!results) return res.end()
97
111
  if (results.$count) res.set ('X-Total-Count', results.$count)
98
112
  if (typeof results === 'object') return res.json (results)
113
+ if (res.req.method === 'DELETE') return res.sendStatus(204)
99
114
  else res.send (results)
100
115
  }
101
116
 
@@ -64,7 +64,7 @@ exports.handle = async function handle (req) {
64
64
  }()
65
65
  if (req.errors) throw req.reject()
66
66
  }
67
- else if (req.query) throw _unhandled (this,req)
67
+ else if (req.event in (req.target?.actions ?? srv.actions)) throw _unhandled(this,req)
68
68
 
69
69
  // .after handlers run in parallel
70
70
  handlers = this.handlers.after.filter (h => h.for(req))
@@ -144,14 +144,11 @@ exports.location = function() {
144
144
  }
145
145
 
146
146
 
147
- exports.exists = function(x) {
148
- if (x) {
149
- const y = resolve (cds.root,x)
150
- return fs.existsSync(y)
151
- }
147
+ exports.exists = function(x) { if (!x) return
148
+ const y = resolve (cds.root,x)
149
+ return fs.existsSync(y)
152
150
  }
153
151
 
154
- // REVISIT naming: doesn't return boolean
155
152
  exports.isdir = function isdir (...args) {
156
153
  if (args.length) try {
157
154
  const y = resolve (cds.root,...args)
@@ -161,7 +158,6 @@ exports.isdir = function isdir (...args) {
161
158
  } catch {/* ignore */}
162
159
  }
163
160
 
164
- // REVISIT naming: doesn't return boolean
165
161
  exports.isfile = function isfile (...args) {
166
162
  if (args.length) try {
167
163
  const y = resolve (cds.root,...args)
@@ -189,7 +185,7 @@ exports.read = async function read (file, _encoding) {
189
185
  } catch(e) {
190
186
  throw new Error (`Failed to parse JSON in ${f}: ${e.message}`)
191
187
  }
192
- else return src
188
+ else return process.platform === 'win32' ? src?.replace(/\r\n/g, '\n') : src
193
189
  }
194
190
 
195
191
  exports.write = function write (file, data, o) {
@@ -16,7 +16,7 @@ exports.readHeader = async function (inStream, o = { ignoreComments: true }) {
16
16
  let delimiter = ';'
17
17
  let cols = []
18
18
  let filtered = false
19
- await _filterLines(inStream, null, (line, readLine) => {
19
+ await _filterLines({ delimiter }, inStream, null, (line, readLine) => {
20
20
  if (!cols.length) {
21
21
  if (o.ignoreComments && _ignoreLine(line)) {
22
22
  filtered = true
@@ -33,17 +33,16 @@ exports.readHeader = async function (inStream, o = { ignoreComments: true }) {
33
33
  return { cols, delimiter, filtered }
34
34
  }
35
35
 
36
- exports.stripComments = async function (file, outStream) {
37
- // most files don't need filtering, so do a quick check first
38
- const { filtered } = await exports.readHeader(createReadStream(file))
39
- if (!filtered) return false
36
+ exports.stripComments = async function (file, outStream, trimWhitespaces = false) {
37
+
38
+ const { delimiter } = await exports.readHeader(createReadStream(file))
40
39
 
41
40
  // buffer whole content so that we can write the out file
42
41
  const inStream = Readable.from([await fsp.readFile(file)])
43
42
  // clears the output file
44
43
  outStream = outStream || createWriteStream(file)
45
44
  let prelude = true
46
- await _filterLines(inStream, outStream, line => {
45
+ await _filterLines({ delimiter, trimWhitespaces }, inStream, outStream, line => {
47
46
  if (prelude) {
48
47
  if (_ignoreLine(line)) return false
49
48
  prelude = false
@@ -58,13 +57,34 @@ function _ignoreLine(line) {
58
57
  return line[0] === '#' || !line.trim().length
59
58
  }
60
59
 
61
- function _filterLines(input, out, filter) {
60
+ function _filterLines({ delimiter, trimWhitespaces }, input, out, filter) {
62
61
  return new Promise((resolve, reject) => {
63
62
  const rl = require('readline').createInterface({ input, crlfDelay: Infinity })
64
63
  const resumeOnDrain = () => rl.resume()
65
64
  let filtered = false
66
65
  rl.on('line', line => {
67
66
  if (filter(line, rl)) {
67
+ // Process the line character by character to handle quoted fields properly
68
+ if (trimWhitespaces) {
69
+ const result = []
70
+ let field = ''
71
+ let quoted = false
72
+
73
+ for (const char of line) {
74
+ if (char === '"') {
75
+ quoted = !quoted
76
+ field += char
77
+ } else if (char === delimiter && !quoted) {
78
+ result.push(field.startsWith('"') ? field : field.trim())
79
+ field = ''
80
+ } else {
81
+ field += char
82
+ }
83
+ }
84
+ // Don't forget the last field
85
+ result.push(field.startsWith('"') ? field : field.trim())
86
+ line = result.join(delimiter)
87
+ }
68
88
  if (out && !out.write(line + '\n')) {
69
89
  rl.pause() // pause when writable signals so
70
90
  out.removeListener('drain', resumeOnDrain) // avoid too many listeners
@@ -23,9 +23,3 @@ cds.Service.prototype._requires_resolving = function (req) {
23
23
  if (req.target?.name?.startsWith(this.definition?.name + '.')) return false
24
24
  else return true
25
25
  }
26
-
27
- // FIXME: move resolve out of cds.ql !
28
- const resolve = require('../../lib/ql/resolve')
29
- cds.Service.prototype.resolve = function (query) {
30
- return resolve(query, this)
31
- }
@@ -11,6 +11,7 @@ class ApplicationService extends cds.Service {
11
11
  for (let each of clazz.generics) clazz[each].call(this)
12
12
  return super.init()
13
13
  }
14
+
14
15
  static get generics() {
15
16
  return (this._generics ??= new Set([
16
17
  ...(this.__proto__.generics || []),
@@ -54,6 +55,10 @@ class ApplicationService extends cds.Service {
54
55
  return require('./generic/crud')
55
56
  }
56
57
 
58
+ static get handle_flows() {
59
+ return require('./generic/flows')
60
+ }
61
+
57
62
  // Overload .handle in order to resolve projections up to a definition that is known by the remote service instance.
58
63
  // Result is post processed according to the inverse projection in order to reflect the correct result of the original query.
59
64
  async handle(req) {
@@ -8,7 +8,7 @@ const _targetEntityDoesNotExist = async req => {
8
8
 
9
9
  module.exports = cds.service.impl(function () {
10
10
  // prettier-ignore
11
- this.on(['CREATE', 'READ', 'UPDATE', 'DELETE', 'UPSERT'], '*', async function handle_crud_requests(req) {
11
+ this.on(['CREATE', 'READ', 'UPDATE', 'UPSERT', 'DELETE'], '*', async function handle_crud_requests(req) {
12
12
 
13
13
  if (!cds.db)
14
14
  return req.reject ('NO_DATABASE_CONNECTION') // REVISIT: error message
@@ -0,0 +1,106 @@
1
+ const cds = require('../../cds')
2
+
3
+ const { getFrom } = require('../../../../lib/compile/for/flows')
4
+
5
+ const FLOW_STATUS = '@flow.status'
6
+ const FROM = '@from'
7
+ const TO = '@to'
8
+ // backwards compat
9
+ const FLOW_FROM = '@flow.from'
10
+ const FLOW_TO = '@flow.to'
11
+
12
+ function buildAllowedCondition(action, statusElementName, statusEnum) {
13
+ const fromList = getFrom(action)
14
+ const conditions = fromList.map(from => `${statusElementName} = '${statusEnum[from].val ?? from}'`)
15
+ return `(${conditions.join(' OR ')})`
16
+ }
17
+
18
+ async function isCurrentStatusInFrom(req, action, statusElementName, statusEnum) {
19
+ const cond = buildAllowedCondition(action, statusElementName, statusEnum)
20
+ const parsedXpr = cds.parse.expr(cond)
21
+ const dbEntity = await SELECT.one.from(req.subject).where(parsedXpr)
22
+ return dbEntity !== undefined
23
+ }
24
+
25
+ async function checkStatus(req, action, statusElementName, statusEnum) {
26
+ const allowed = await isCurrentStatusInFrom(req, action, statusElementName, statusEnum)
27
+ if (!allowed) {
28
+ const from = getFrom(action)
29
+ req.reject({
30
+ code: 409,
31
+ message: from.length > 1 ? 'INVALID_FLOW_TRANSITION_MULTI' : 'INVALID_FLOW_TRANSITION_SINGLE',
32
+ args: [action.name, statusElementName, from.join(',')]
33
+ })
34
+ }
35
+ }
36
+
37
+ /**
38
+ * handler registration
39
+ */
40
+ module.exports = cds.service.impl(function () {
41
+ const entry = []
42
+ const exit = []
43
+
44
+ for (const entity of this.entities) {
45
+ if (!entity.actions || !entity.elements) continue
46
+
47
+ const fromActions = []
48
+ const toActions = []
49
+ for (const action of entity.actions) {
50
+ if (action[FROM] || action[FLOW_FROM]) fromActions.push(action)
51
+ if (action[TO] || action[FLOW_TO]) toActions.push(action)
52
+ }
53
+ if (fromActions.length === 0 && toActions.length === 0) continue
54
+
55
+ let statusElement = Object.values(entity.elements).find(el => el[FLOW_STATUS])
56
+ if (!statusElement) {
57
+ cds.error(
58
+ `Entity ${entity.name} does not have a status element, but its actions have registered @flow annotations.`
59
+ )
60
+ }
61
+
62
+ let statusEnum, statusElementName
63
+ if (statusElement.enum) {
64
+ statusEnum = statusElement.enum
65
+ statusElementName = statusElement.name
66
+ } else if (statusElement?._target?.elements['code']) {
67
+ statusEnum = statusElement._target.elements['code'].enum
68
+ statusElementName = statusElement.name + '_code'
69
+ } else {
70
+ cds.error(
71
+ `Status element in entity ${entity.name} is not an enum and does not have a valid target with code enum.`
72
+ )
73
+ }
74
+
75
+ entry.push({ events: fromActions, entity, statusElementName, statusEnum })
76
+ exit.push({ events: toActions, entity, statusElementName, statusEnum })
77
+ }
78
+
79
+ this.prepend(function () {
80
+ for (const each of entry) {
81
+ this.before(
82
+ each.events,
83
+ each.entity,
84
+ Object.assign(
85
+ async function handle_entry_state(req) {
86
+ const action = req.target.actions[req.event]
87
+ await checkStatus(req, action, each.statusElementName, each.statusEnum)
88
+ },
89
+ { _initial: true }
90
+ )
91
+ )
92
+ }
93
+
94
+ for (const each of exit) {
95
+ async function handle_exit_state(req, next) {
96
+ const res = await next()
97
+ const action = req.target.actions[req.event]
98
+ const to = action[TO] ?? action[FLOW_TO]
99
+ const toKey = to['#'] ?? to['='] ?? to
100
+ await UPDATE(req.subject).with({ [each.statusElementName]: each.statusEnum[toKey].val ?? toKey })
101
+ return res
102
+ }
103
+ this.on(each.events, each.entity, handle_exit_state)
104
+ }
105
+ })
106
+ })
@@ -1,6 +1,6 @@
1
1
  const cds = require('../../cds')
2
2
 
3
- module.exports = exports = cds.service.impl(function () {
3
+ module.exports = cds.service.impl(function () {
4
4
  this.before('READ', '*', handle_paging)
5
5
  })
6
6
 
@@ -46,5 +46,5 @@ const _addPaging = function ({ SELECT }, target) {
46
46
  handle_paging._initial = true
47
47
 
48
48
  // needed in lean draft
49
- exports.getPageSize = getPageSize
50
- exports.commonGenericPaging = handle_paging
49
+ module.exports.getPageSize = getPageSize
50
+ module.exports.commonGenericPaging = handle_paging
@@ -21,13 +21,9 @@ const columnRefs = (data, target) => {
21
21
  .filter(k => !k.isAssociation && !k.virtual)
22
22
  .forEach(k => columns.add(k.name))
23
23
 
24
- if (!Array.isArray(data)) data = [data]
25
- // loop and get all columns from current level
26
- for (const row of data) {
27
- for (const e in row) {
28
- if (target.elements[e] && !target.elements[e].isAssociation) {
29
- columns.add(e)
30
- }
24
+ for (const e in target.elements) {
25
+ if (!target.elements[e].isAssociation) {
26
+ columns.add(e)
31
27
  }
32
28
  }
33
29
 
@@ -44,18 +40,12 @@ const expandColumns = (target, data, columns = [], elementMap = new Map()) => {
44
40
 
45
41
  for (const compName in compositions) {
46
42
  let compositionData
47
- if (data === null || (Array.isArray(data) && !data.length)) {
43
+ if (data == null || (Array.isArray(data) && !data.length)) {
48
44
  compositionData = null
49
45
  } else {
50
46
  compositionData = data[compName]
51
47
  }
52
48
 
53
- // ignore not provided compositions as nothing happens with them (expect deep delete)
54
- if (compositionData === undefined) {
55
- // fill columns in case
56
- continue
57
- }
58
-
59
49
  const composition = compositions[compName]
60
50
 
61
51
  const fqn = composition.parent.name + ':' + composition.name
@@ -83,7 +73,7 @@ const expandColumns = (target, data, columns = [], elementMap = new Map()) => {
83
73
 
84
74
  if (composition.is2many) {
85
75
  // expandColumn.expand = getColumnsFromDataOrKeys(compositionData, composition._target)
86
- if (compositionData === null || compositionData.length === 0) {
76
+ if (compositionData == null || compositionData.length === 0) {
87
77
  // deep delete, get all subitems until recursion depth
88
78
  expandColumns(composition._target, null, expandColumn.expand, newElementMap)
89
79
  continue
@@ -528,7 +528,7 @@ const _mappedValue = (col, alias) => {
528
528
  return [key, { val: col.val }]
529
529
  }
530
530
 
531
- const getDBTable = target => cds.ql.resolve.table(target)
531
+ const getDBTable = target => cds.db.resolve.table(target)
532
532
 
533
533
  const _appendForeignKeys = (newColumns, target, columns, { as, ref = [] }) => {
534
534
  const el = target.elements[as] || target.query._target?.elements[ref.at(-1)]
@@ -575,7 +575,7 @@ const _checkForForbiddenViews = (queryTarget, event) => {
575
575
  const _getTransitionData = (target, columns, service, options) => {
576
576
  let { abort, skipForbiddenViewCheck, event } = options
577
577
  // REVISIT revert after cds-dbs pr
578
- if (!abort) abort = cds.ql.resolve.abortDB
578
+ if (!abort) abort = service.resolve.abortDB
579
579
  // REVISIT: Find less param polluting way to skip forbidden view check for reads
580
580
  if (!skipForbiddenViewCheck) _checkForForbiddenViews(target, event)
581
581
  const isAborted = abort(target)