@sap/cds 5.8.1 → 5.8.2

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,19 @@
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
+ ## Version 5.8.2 - 2022-02-22
8
+
9
+ ### Fixed
10
+
11
+ - Crash if error does not have a stack in kibana logging
12
+ - Allow short names for bound operations in odata-server
13
+ - Performance issue during deep operations
14
+ - Resolving views with parameters
15
+ - Expanding association-to-many within draft union scenario
16
+ - Erroneous invalidation of deep `INSERT|UPDATE|DELETE` operations if root entity has managed to-one association to non-writable view
17
+ - Handling of falsy results when sending requests to remote services
18
+ - Resolving foreign key propagations for views with union
19
+
7
20
  ## Version 5.8.1 - 2022-02-11
8
21
 
9
22
  ### Fixed
@@ -36,9 +49,6 @@
36
49
  - Restrict access to all services via `cds.env.requires.auth.restrict_all_services = true`
37
50
  + That is, all unrestricted services (i.e., w/o own `@requires`) are treated as having `@requires: 'authenticated-user'`
38
51
  - Threshold for automatically sending GET requests as `$batch` (beta, cf. @sap/cds@5.6.0) can be configured per remote service via `cds.env.requires.<srv>.max_get_url_length` (if not configured on service, the global config applies)
39
- - Alpha out-of-the-box support for DwC
40
- + Authentication based on headers set by Jupiter router via `cds.env.requires.auth.kind = 'dwc-auth'`
41
- + All DwC headers are forwarded to remote service via `cds.env.requires.<srv>.forward_dwc_headers = true`
42
52
  - Limited support for binary data in OData
43
53
  + In payloads, the binary data must be a base64 encoded string
44
54
  + In URLs, the binary data must have the following format: `binary'<url-safe base64 encoded>'`, e.g., `$filter=ID eq binary'Q0FQIE5vZGUuanM='`
@@ -1,4 +1,4 @@
1
- const cds = require ('../../')
1
+ const cds = require('../../')
2
2
  const util = require('util')
3
3
 
4
4
  const _l2l = { 1: 'error', 2: 'warn', 3: 'info', 4: 'debug', 5: 'trace' }
@@ -8,7 +8,7 @@ const _l2l = { 1: 'error', 2: 'warn', 3: 'info', 4: 'debug', 5: 'trace' }
8
8
  */
9
9
  module.exports = (module, level, ...args) => {
10
10
  // config
11
- const { user: log_user , kibana_custom_fields } = cds.env.log
11
+ const { user: log_user, kibana_custom_fields } = cds.env.log
12
12
 
13
13
  // build the object to log
14
14
  const toLog = {
@@ -36,7 +36,7 @@ module.exports = (module, level, ...args) => {
36
36
  if (args.length && typeof args[0] === 'object' && args[0].message) {
37
37
  const err = args.shift()
38
38
  toLog.msg = err.message
39
- if (err instanceof Error) toLog.stacktrace = err.stack.split(/\s*\r?\n\s*/)
39
+ if (typeof err.stack === 'string') toLog.stacktrace = err.stack.split(/\s*\r?\n\s*/)
40
40
  Object.assign(toLog, err, { level: toLog.level })
41
41
  }
42
42
 
@@ -45,9 +45,7 @@ function _getTarget(service, segments) {
45
45
  : last.getEdmType().csdlStructuredType.name
46
46
 
47
47
  // autoexposed entities now used . in csn and _ in edm
48
- const target =
49
- findCsnTargetFor(name, service.model, namespace) ||
50
- (name.endsWith('Parameters') && service.model.definitions[namespace + '.' + name.replace(/Parameters$/, '')])
48
+ const target = findCsnTargetFor(name, service.model, namespace)
51
49
 
52
50
  if (target && target.kind === 'entity') {
53
51
  return target
@@ -396,7 +396,18 @@ class ResourcePathParser {
396
396
  throw new UriSyntaxError(UriSyntaxError.Message.PREVIOUS_TYPE_HAS_NO_MEDIA, currentType.getName())
397
397
  }
398
398
 
399
- const uriResources = this._parsePropertyPath(uriPathSegments, currentResource, tokenizer)
399
+ let uriResources
400
+ try {
401
+ uriResources = this._parsePropertyPath(uriPathSegments, currentResource, tokenizer)
402
+ } catch (e) {
403
+ try {
404
+ uriResources = this._parseBoundOperation(uriPathSegments, currentResource, tokenizer)
405
+ } catch (e1) {
406
+ // throw first error
407
+ throw e
408
+ }
409
+ }
410
+
400
411
  return result.concat(uriResources)
401
412
  }
402
413
 
@@ -409,7 +420,17 @@ class ResourcePathParser {
409
420
  * @private
410
421
  */
411
422
  _parseBoundOperation (uriPathSegments, currentResource, tokenizer) {
412
- const fqn = FullQualifiedName.createFromNameSpaceAndName(tokenizer.getText())
423
+ // allow short names for bound operations
424
+ let name = tokenizer.getText()
425
+ if (typeof name === 'string' && !name.match(/\./)) {
426
+ const namespace = currentResource._entitySet &&
427
+ currentResource._entitySet._target &&
428
+ currentResource._entitySet._target.type &&
429
+ currentResource._entitySet._target.type.namespace
430
+ if (namespace) name = namespace + '.' + name
431
+ }
432
+
433
+ const fqn = FullQualifiedName.createFromNameSpaceAndName(name)
413
434
  const bindingParamTypeFqn = currentResource.getEdmType().getFullQualifiedName()
414
435
 
415
436
  // parse bound action
@@ -38,7 +38,7 @@ const _foreignKeysToLinks = (element, inverse) =>
38
38
  const _resolvedElement = (element, service) => {
39
39
  if (!element.target) return element
40
40
  // skip forbidden view check if association to view with foreign key in target
41
- const skipForbiddenViewCheck = element._isAssociationStrict && element.on && !element['@odata.contained']
41
+ const skipForbiddenViewCheck = element._isAssociationStrict && !element['@odata.contained']
42
42
  const { target, mapping } = getTransition(element._target, service, skipForbiddenViewCheck)
43
43
  const newElement = { target: target.name, _target: target }
44
44
  Object.setPrototypeOf(newElement, element)
@@ -79,8 +79,20 @@ const getDataSubject = (entity, model, role) => {
79
79
  return entity.set(hash, dataSubject)
80
80
  }
81
81
 
82
- const _resolve = (name, model, namespace) =>
83
- model.entities(namespace)[name] || model.definitions[`${namespace}.${name}`]
82
+ const _findInModel = (name, model, namespace) => {
83
+ return model.entities(namespace)[name] || model.definitions[`${namespace}.${name}`]
84
+ }
85
+
86
+ const _resolve = (name, model, namespace) => {
87
+ const resolved = _findInModel(name, model, namespace)
88
+ // the edm name has an additional suffix 'Parameters' in case of views with parameters
89
+ if (!resolved && name.endsWith('Parameters')) {
90
+ const viewWithParam = _findInModel(name.replace(/Parameters$/, ''), model, namespace)
91
+ if (!viewWithParam || !viewWithParam.params) return
92
+ return viewWithParam
93
+ }
94
+ return resolved
95
+ }
84
96
 
85
97
  const _findRootEntity = (model, edmName, namespace) => {
86
98
  const parts = edmName.split('_')
@@ -178,6 +178,12 @@ const _resolveTargetForeignKey = targetKey => {
178
178
  return { targetName, propagation }
179
179
  }
180
180
 
181
+ const _resolveColumnsFromQuery = query => {
182
+ if (query && query.SET) return _resolveColumnsFromQuery(query.SET.args[0])
183
+ if (query && query.SELECT && query.SELECT.columns) return query.SELECT.columns
184
+ return []
185
+ }
186
+
181
187
  const _resolvedKeys = (foreignKeys, targetKeys, fillChild) => {
182
188
  const foreignKeyPropagations = []
183
189
 
@@ -191,12 +197,9 @@ const _resolvedKeys = (foreignKeys, targetKeys, fillChild) => {
191
197
  * Once you have the full path, you can find it in the target entity.
192
198
  * NOTE: There can be projections upon projections and renamings in every projection. -> not yet covered!!!
193
199
  */
194
- const tkCol =
195
- targetKeys[i].parent.query &&
196
- targetKeys[i].parent.query.SELECT.columns &&
197
- targetKeys[i].parent.query.SELECT.columns.find(
198
- c => c.ref && `${fk['@odata.foreignKey4']}_${c.ref.join('_')}` === fk.name
199
- )
200
+ const tkCol = _resolveColumnsFromQuery(targetKeys[i].parent.query).find(
201
+ c => c.ref && `${fk['@odata.foreignKey4']}_${c.ref.join('_')}` === fk.name
202
+ )
200
203
  tk = tkCol && targetKeys.find(tk => tk.name === (tkCol.as ? tkCol.as : tkCol.ref.join('_')))
201
204
  // with composition of aspects, the lookup fails -> we need this final fallback
202
205
  if (!tk) tk = targetKeys[i]
@@ -1151,28 +1151,37 @@ class JoinCQNFromExpanded {
1151
1151
  }
1152
1152
  }
1153
1153
  }
1154
- const ks = Object.keys(expandedEntity.keys).filter(
1155
- c => !expandedEntity.keys[c].isAssociation && !DRAFT_COLUMNS.includes(c)
1156
- )
1157
- const user = (cds.context && cds.context.user && cds.context.user.id) || 'anonymous'
1158
- const unionFrom = getCQNUnionFrom(cols, ref.replace(/_drafts$/, ''), ref, ks, user)
1159
- for (const each of cqn.columns) {
1160
- if (!each.as) continue
1161
- // replace val with ref
1162
- if (each.as === 'IsActiveEntity' || each.as === 'HasActiveEntity') {
1163
- delete each.val
1164
- each.ref = [tableAlias, each.as]
1165
- each.as = tableAlias + '_' + each.as
1166
- }
1167
- // ensure the cast
1168
- if (each.as.match(/IsActiveEntity$/) || each.as.match(/HasActiveEntity$/) || each.as.match(/HasDraftEntity$/)) {
1169
- each.cast = { type: 'cds.Boolean' }
1154
+
1155
+ if (!cqn[IS_ACTIVE]) {
1156
+ const ks = Object.keys(expandedEntity.keys).filter(
1157
+ c => !expandedEntity.keys[c].isAssociation && !DRAFT_COLUMNS.includes(c)
1158
+ )
1159
+ const user = (cds.context && cds.context.user && cds.context.user.id) || 'anonymous'
1160
+ const unionFrom = getCQNUnionFrom(cols, ref.replace(/_drafts$/, ''), ref, ks, user)
1161
+ for (const each of cqn.columns) {
1162
+ if (!each.as) continue
1163
+ // replace val with ref
1164
+ if (each.as === 'IsActiveEntity' || each.as === 'HasActiveEntity') {
1165
+ delete each.val
1166
+ each.ref = [tableAlias, each.as]
1167
+ each.as = tableAlias + '_' + each.as
1168
+ }
1169
+ // ensure the cast
1170
+ if (
1171
+ each.as.match(/IsActiveEntity$/) ||
1172
+ each.as.match(/HasActiveEntity$/) ||
1173
+ each.as.match(/HasDraftEntity$/)
1174
+ ) {
1175
+ each.cast = { type: 'cds.Boolean' }
1176
+ }
1170
1177
  }
1178
+ const cs = cqn.columns
1179
+ .filter(c => !c.expand && c.ref && c.ref[0] === tableAlias)
1180
+ .map(c => ({ ref: [c.ref[1]] }))
1181
+ const unionArgs = cqn.from.args
1182
+ unionArgs[0].SELECT = { columns: cs, from: unionFrom, distinct: true }
1183
+ delete unionArgs[0].ref
1171
1184
  }
1172
- const cs = cqn.columns.filter(c => !c.expand && c.ref && c.ref[0] === tableAlias).map(c => ({ ref: [c.ref[1]] }))
1173
- const unionArgs = cqn.from.args
1174
- unionArgs[0].SELECT = { columns: cs, from: unionFrom, distinct: true }
1175
- delete unionArgs[0].ref
1176
1185
  }
1177
1186
 
1178
1187
  return cqn
@@ -1,14 +1,18 @@
1
- function _flattenDeep(arr) {
2
- return arr.reduce((acc, val) => acc.concat(Array.isArray(val) ? _flattenDeep(val) : val), [])
1
+ const _flattenDeep = (arr, res) => {
2
+ if (!Array.isArray(arr)) {
3
+ res.push(arr)
4
+ return res
5
+ }
6
+ for (const a of arr) {
7
+ _flattenDeep(a, res)
8
+ }
9
+ return res
3
10
  }
4
11
 
5
12
  /*
6
13
  * flatten with a dfs approach. this is important!!!
7
14
  */
8
- function getFlatArray(arg) {
9
- if (!Array.isArray(arg)) return [arg]
10
- return _flattenDeep(arg)
11
- }
15
+ const getFlatArray = arg => _flattenDeep(arg, [])
12
16
 
13
17
  async function _processChunk(processFn, model, dbc, cqns, user, locale, ts, indexes, results) {
14
18
  const promises = []
@@ -7,11 +7,12 @@ try {
7
7
  }
8
8
 
9
9
  const isDynatraceEnabled = () => {
10
- return dynatrace.sdk !== undefined
10
+ return dynatrace.sdk !== undefined && !process.env.CDS_SKIP_DYNATRACE
11
11
  }
12
12
 
13
13
  const _dynatraceResultCallback = function (tracer, cb) {
14
- return function (err, results, fields) {
14
+ return function (err, ...args) {
15
+ const results = args.shift()
15
16
  if (err) {
16
17
  tracer.error(err)
17
18
  } else {
@@ -19,7 +20,7 @@ const _dynatraceResultCallback = function (tracer, cb) {
19
20
  rowsReturned: (results && results.length) || results
20
21
  })
21
22
  }
22
- tracer.end(cb, err, results, fields)
23
+ tracer.end(cb, err, results, ...args)
23
24
  }
24
25
  }
25
26
 
@@ -73,9 +74,14 @@ const dynatraceClient = (client, credentials, tenant) => {
73
74
  // hana-client does not like decorating.
74
75
  // because of that, we need to override the fn and pass the original fn for execution
75
76
  const originalExecFn = client.exec
76
- const originalPrepareFn = client.prepare
77
77
  client.exec = _execUsingDynatrace(client, originalExecFn, dbInfo)
78
- client.prepare = _preparedStmtUsingDynatrace(client, originalPrepareFn, dbInfo)
78
+ const originalPrepareFn = client.prepare
79
+ if (client.name === '@sap/hana-client') {
80
+ // client.prepare = ... doesn't work for hana-client
81
+ Object.defineProperty(client, 'prepare', { value: _preparedStmtUsingDynatrace(client, originalPrepareFn, dbInfo) })
82
+ } else {
83
+ client.prepare = _preparedStmtUsingDynatrace(client, originalPrepareFn, dbInfo)
84
+ }
79
85
 
80
86
  return client
81
87
  }
@@ -66,15 +66,73 @@ function _getBinaries(stmt) {
66
66
 
67
67
  const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}={2})$/
68
68
 
69
+ function _isProcedureCall(sql) {
70
+ return sql.trim().match(/^call \s*"{0,1}\w*/i)
71
+ }
72
+
73
+ function _getProcedureName(sql) {
74
+ const match = sql.trim().match(/^call \s*"{0,1}(\w*)/i)
75
+ return match && match[1]
76
+ }
77
+
78
+ function _hdbGetResultForProcedure(rows, args, outParameters) {
79
+ // on hdb, rows already contains results for scalar params
80
+ const result = rows || {}
81
+ // merge table output params into scalar params
82
+ if (args && args.length && outParameters) {
83
+ const params = outParameters.filter(md => !(md.PARAMETER_NAME in rows))
84
+ for (let i = 0; i < args.length; i++) {
85
+ result[params[i].PARAMETER_NAME] = args[i]
86
+ }
87
+ }
88
+ return result
89
+ }
90
+
91
+ function _hcGetResultForProcedure(stmt, resultSet, outParameters) {
92
+ const result = {}
93
+ // build result from scalar params
94
+ const paramInfo = stmt.getParameterInfo()
95
+ if (paramInfo.some(p => p.direction > 1)) {
96
+ for (let i = 0; i < paramInfo.length; i++) {
97
+ if (paramInfo[i].direction > 1) {
98
+ result[paramInfo[i].name] = stmt.getParameterValue(i)
99
+ }
100
+ }
101
+ }
102
+ // merge table output params into scalar params
103
+ if (outParameters && outParameters.length) {
104
+ const params = outParameters.filter(md => !(md.PARAMETER_NAME in result))
105
+ let i = 0
106
+ while (resultSet.next()) {
107
+ result[params[i].PARAMETER_NAME] = [resultSet.getValues()]
108
+ resultSet.nextResult()
109
+ i++
110
+ }
111
+ }
112
+ return result
113
+ }
114
+
115
+ function _getProcedureMetadata(procedureName, dbc) {
116
+ return new Promise((resolve, reject) => {
117
+ dbc.exec(
118
+ `SELECT PARAMETER_NAME FROM SYS.PROCEDURE_PARAMETERS WHERE SCHEMA_NAME = CURRENT_SCHEMA AND PROCEDURE_NAME = '${procedureName}' AND PARAMETER_TYPE IN ('OUT', 'INOUT') ORDER BY POSITION`,
119
+ (err, res) => {
120
+ if (err) reject(err)
121
+ else resolve(res)
122
+ }
123
+ )
124
+ })
125
+ }
126
+
69
127
  function _executeAsPreparedStatement(dbc, sql, values, reject, resolve) {
70
- dbc.prepare(sql, function (err, stmt) {
128
+ dbc.prepare(sql, async function (err, stmt) {
71
129
  if (err) {
72
130
  err.query = sql
73
131
  if (values) err.values = SANITIZE_VALUES ? ['***'] : values
74
132
  return reject(err)
75
133
  }
76
134
 
77
- // convert binary strings to buffers ()
135
+ // convert binary strings to buffers
78
136
  if (cds.env.hana.base64_to_buffer !== false && _hasValues(values)) {
79
137
  const binaries = _getBinaries(stmt)
80
138
  if (binaries.length) {
@@ -89,6 +147,46 @@ function _executeAsPreparedStatement(dbc, sql, values, reject, resolve) {
89
147
  }
90
148
  }
91
149
 
150
+ if (cds.env.features.new_call_prodecure) {
151
+ // procedure call metadata
152
+ let outParameters
153
+ const isProcedureCall = _isProcedureCall(sql)
154
+ if (isProcedureCall) {
155
+ try {
156
+ const procedureName = _getProcedureName(sql)
157
+ outParameters = await _getProcedureMetadata(procedureName, dbc)
158
+ } catch (e) {
159
+ LOG._warn && LOG.warn('Unable to fetch procedure metadata due to error:', e)
160
+ }
161
+ }
162
+
163
+ // on @sap/hana-client, we need to use execQuery in case of calling procedures
164
+ stmt[isProcedureCall && dbc.name !== 'hdb' ? 'execQuery' : 'exec'](values, function (err, rows, ...args) {
165
+ if (err) {
166
+ stmt.drop(() => {})
167
+ err.query = sql
168
+ if (values) err.values = SANITIZE_VALUES ? ['***'] : values
169
+ return reject(err)
170
+ }
171
+
172
+ let result
173
+ if (isProcedureCall) {
174
+ result =
175
+ dbc.name === 'hdb'
176
+ ? _hdbGetResultForProcedure(rows, args, outParameters)
177
+ : _hcGetResultForProcedure(stmt, rows, outParameters)
178
+ } else {
179
+ result = rows
180
+ }
181
+
182
+ stmt.drop(() => {})
183
+
184
+ resolve(result)
185
+ })
186
+
187
+ return
188
+ }
189
+
92
190
  stmt.exec(values, function (err, rows, procedureReturn) {
93
191
  if (err) {
94
192
  stmt.drop(() => {})
@@ -123,15 +221,15 @@ function _executeSimpleSQL(dbc, sql, values) {
123
221
  values = Object.values(values)
124
222
  }
125
223
  // ensure that stored procedure with parameters is always executed as prepared
126
- if (_hasValues(values) || sql.match(/^call.*?\?.*$/i)) {
224
+ if (_hasValues(values) || _isProcedureCall(sql)) {
127
225
  _executeAsPreparedStatement(dbc, sql, values, reject, resolve)
128
226
  } else {
129
- dbc.exec(sql, function (err, result, procedureReturn) {
227
+ dbc.exec(sql, function (err, result) {
130
228
  if (err) {
131
229
  err.query = sql
132
230
  return reject(err)
133
231
  }
134
- resolve(procedureReturn || result)
232
+ resolve(result)
135
233
  })
136
234
  }
137
235
  })
@@ -142,8 +142,8 @@ function _defineProperty(obj, property, value) {
142
142
  }
143
143
 
144
144
  function _normalizeMetadata(prefix, data, results) {
145
- const target = results || data
146
- if (typeof target !== 'object') return target
145
+ const target = results !== undefined ? results : data
146
+ if (typeof target !== 'object' || target === null) return target
147
147
  const metadataKeys = Object.keys(data).filter(k => prefix.test(k))
148
148
  for (const k of metadataKeys) {
149
149
  const $ = k.replace(prefix, '$')
@@ -169,7 +169,7 @@ const _purgeODataV2 = (data, target, reqHeaders) => {
169
169
  data = data.d
170
170
  const ieee754Compatible = reqHeaders.accept && reqHeaders.accept.includes('IEEE754Compatible=true')
171
171
  const exponentialDecimals = ieee754Compatible && reqHeaders.accept.includes('ExponentialDecimals=true')
172
- const purgedResponse = data.results || data
172
+ const purgedResponse = 'results' in data ? data.results : data
173
173
  const convertedResponse = convertV2ResponseData(purgedResponse, target, ieee754Compatible, exponentialDecimals)
174
174
  return _normalizeMetadata(/^__/, data, convertedResponse)
175
175
  }
@@ -177,7 +177,7 @@ const _purgeODataV2 = (data, target, reqHeaders) => {
177
177
  const _purgeODataV4 = data => {
178
178
  if (typeof data !== 'object') return data
179
179
 
180
- const purgedResponse = data.value || data
180
+ const purgedResponse = 'value' in data ? data.value : data
181
181
  return _normalizeMetadata(/^@odata\./, data, purgedResponse)
182
182
  }
183
183
 
@@ -39,7 +39,8 @@ const _getConvertRecordFn = (target, convertValueFn) => record => {
39
39
  if (!element) continue
40
40
 
41
41
  const recordValue = record[key]
42
- const value = (recordValue && recordValue.results) || recordValue
42
+ const value =
43
+ (recordValue && typeof recordValue === 'object' && 'results' in recordValue && recordValue.results) || recordValue
43
44
 
44
45
  if (value && (element.isAssociation || Array.isArray(value))) {
45
46
  record[key] = _convertData(value, element._target, convertValueFn)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "5.8.1",
3
+ "version": "5.8.2",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [