@sap/cds 8.2.0 → 8.2.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,24 @@
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 8.2.2 - 2024-09-13
8
+
9
+ ### Fixed
10
+
11
+ - Erroneous caching in `cds.validate`
12
+ - Properly check `$filter` element types across navigations
13
+
14
+ ## Version 8.2.1 - 2024-09-04
15
+
16
+ ### Fixed
17
+
18
+ - Date validation of legacy OData protocol adapter
19
+ - Content-Length headers in multipart batch request body
20
+ - Streaming requests with virtual properties
21
+ - Bring back support for `x-correlationid`
22
+ - Validation of inlined elements
23
+ - multipart `$batch` parsing with _--_ as part of payload
24
+
7
25
  ## Version 8.2.0 - 2024-08-30
8
26
 
9
27
  ### Added
@@ -56,13 +56,15 @@ class Validation {
56
56
  class ValidationErrors extends Array {
57
57
  add (error) {
58
58
  const err = Object.create (ValidationErrors.proto)
59
- err.message = err.stack = error
59
+ err.message = error
60
60
  this.push (err)
61
61
  return err
62
62
  }
63
63
  static proto = Object.create (Error.prototype, {
64
64
  message: { writable:true, configurable:true },
65
- stack: { writable:true, configurable:true, value: '<none>' },
65
+ stack: { configurable:true, get() { return this.message },
66
+ set(v) { Object.defineProperty (this, 'stack', { value:v, writable:true, configurable:true }) },
67
+ },
66
68
  code: { value: '400', writable:true }, // REVISIT: should be 'ASSERT_'... (i.e. msg) but we need to adjust all tests, and have a code catalogue
67
69
  statusCode: { value: 400 }, // REVISIT: should go into mappings in adapter's error handlers -> requires a code catalogue // REVISIT: .statusCode vs .status?
68
70
  numericSeverity: { value: 4, enumerable: true }, // REVISIT: that is OData-specific
@@ -90,38 +92,52 @@ const $any = class any {
90
92
  * this method for subsequent usages, with statically determined checks.
91
93
  */
92
94
  check_asserts (val, path, /** @type {Validation} */ ctx) {
93
- // if (this['@cds.validate'] === false) return this.set ('check_asserts', ()=>{})
94
- const asserts = []
95
- const type_check = conf.strict && this.strict_check || this.type_check
96
- if (type_check) {
97
- asserts.push ((v,p,ctx) => v == null || type_check(v) || ctx.error ('ASSERT_DATA_TYPE', p, this.name, v, this ))
98
- }
99
- if (this._is_mandatory()) {
100
- asserts.push ((v,p,ctx) => v != null && v.trim?.() !== '' || ctx.error ('ASSERT_NOT_NULL', p, this.name, v)) // ASSERT_NOT_NULL is misleading -> should be ASSERT_REQUIRED
101
- }
102
- if (this['@assert.format']) {
103
- const format = new RegExp(this['@assert.format'],'u')
104
- asserts.push ((v,p,ctx) => v == null || format.test(v) || ctx.error ('ASSERT_FORMAT', p, this.name, v, format))
105
- }
106
- if (this['@assert.range'] && !this.enum) {
107
- const [ min, max ] = this['@assert.range']
108
- asserts.push ((v,p,ctx) => v == null || min <= v && v <= max || ctx.error ('ASSERT_RANGE', p, this.name, v, min, max))
109
- }
110
- if (this['@assert.enum'] || this['@assert.range'] && this.enum) {
111
- const vals = Object.entries(this.enum).map(([k,v]) => 'val' in v ? v.val : k)
112
- const enums = vals.reduce((a,v) => (a[v]=true, a),{})
113
- asserts.push ((v,p,ctx) => v == null || v in enums || vals.some(x => x == v) || ctx.error ('ASSERT_ENUM', p, this.name, typeof v === 'string' ? `"${v}"` : v, vals.join(', ')))
114
- }
115
- if (!asserts.length) return this.check_asserts = ()=>{} // nothing to do
116
- this.set ('check_asserts', (v,p,ctx) => asserts.forEach (a => a(v,p,ctx)))
117
- this.check_asserts (val, path, ctx) // call first time
95
+ // IMPORTANT: We need to use this.own() here as elements derived from reuse
96
+ // definitions or from elements of base entities might have different asserts
97
+ // than inherited ones.
98
+ const check_asserts = this.own('_check_asserts', () => {
99
+ const asserts = []
100
+ const type_check = conf.strict && this.strict_check || this.type_check
101
+ if (type_check) {
102
+ asserts.push ((v,p,ctx) => v == null || type_check(v) || ctx.error ('ASSERT_DATA_TYPE', p, this.name, v, this ))
103
+ }
104
+ if (this._is_mandatory()) {
105
+ asserts.push ((v,p,ctx) => v != null && v.trim?.() !== '' || ctx.error ('ASSERT_NOT_NULL', p, this.name, v)) // ASSERT_NOT_NULL is misleading -> should be ASSERT_REQUIRED
106
+ }
107
+ if (this['@assert.format']) {
108
+ const format = new RegExp(this['@assert.format'],'u')
109
+ asserts.push ((v,p,ctx) => v == null || format.test(v) || ctx.error ('ASSERT_FORMAT', p, this.name, v, format))
110
+ }
111
+ if (this['@assert.range'] && !this.enum) {
112
+ const [ min, max ] = this['@assert.range']
113
+ asserts.push ((v,p,ctx) => v == null || min <= v && v <= max || ctx.error ('ASSERT_RANGE', p, this.name, v, min, max))
114
+ }
115
+ if (this['@assert.enum'] || this['@assert.range'] && this.enum) {
116
+ const vals = Object.entries(this.enum).map(([k,v]) => 'val' in v ? v.val : k)
117
+ const enums = vals.reduce((a,v) => (a[v]=true, a),{})
118
+ asserts.push ((v,p,ctx) => v == null || v in enums || vals.some(x => x == v) || ctx.error ('ASSERT_ENUM', p, this.name, typeof v === 'string' ? `"${v}"` : v, vals.join(', ')))
119
+ }
120
+ if (!asserts.length) return ()=>{} // nothing to do
121
+ return (v,p,ctx) => asserts.forEach (a => a(v,p,ctx))
122
+ })
123
+ return check_asserts (val, path, ctx)
118
124
  }
119
125
 
120
126
  _is_mandatory (d=this) {
121
127
  return d.own('_mandatory', ()=> {
122
- if (d['@readonly']) return false // readonly -> not mandatory
123
- if (d['@mandatory']) return true
124
- if (d['@Common.FieldControl']?.['#'] === 'Mandatory') return true
128
+ if (d._is_readonly()) return false // readonly annotations have precedence over mandatory ones
129
+ if (d['@mandatory'] || d['@Common.FieldControl']?.['#'] === 'Mandatory') {
130
+ const q = d.parent?.query?.SELECT
131
+ if (!q) return true // it's a regular entity's element marked as mandatory
132
+ if (!q.from?.ref) return false // join or union -> elements can't be mandatory
133
+ const c = q.columns?.find (c => alias4(c) === d.name)
134
+ if (!c) return true // * or foo.* -> can't tell whether d is joined
135
+ if (!c.ref) return false // calculated fields aren't mandatory
136
+ if (c.ref.length === 1) return true // SELECT from Foo { foo }
137
+ if (c.ref.length === 2 && c.ref[0] === alias4(q.from)) return true // SELECT from Foo as f { f.foo }
138
+ else return false // joined field which can't be mandatory, e.g. SELECT from Books { author.name as author }
139
+ function alias4 (x) { return x.as || x.ref?.at(-1) }
140
+ }
125
141
  else return false
126
142
  })
127
143
  }
@@ -143,17 +159,21 @@ const $any = class any {
143
159
  * This is the case if the row date does not contain all primary key elements of the target entity.
144
160
  */
145
161
  _is_insert (row) {
146
- const entity = this._target || this
147
- const keys = Object.keys (entity.keys||{})
148
- if (!keys.length) return this.set('_is_insert', ()=> true), true
149
- else this.set('_is_insert', data => typeof data === 'object' && !keys.every(k => k in data))
150
- return this._is_insert (row)
162
+ // IMPORTANT: We need to use this.own() here as derived entities might have
163
+ // different keys and thus different insert checks.
164
+ const _is_insert = this.own('__is_insert', () => {
165
+ const entity = this._target || this
166
+ const keys = Object.keys (entity.keys||{})
167
+ if (!keys.length) return ()=> true
168
+ else return data => typeof data === 'object' && !keys.every(k => k in data)
169
+ })
170
+ return _is_insert(row)
151
171
  }
152
172
 
153
173
  _required (elements) {
154
- const _required = Object.values(elements).filter(this._is_mandatory)
155
- this.set('_required', ()=> _required)
156
- return _required
174
+ // IMPORTANT: We need to use this.own() here as derived entities might have
175
+ // different elements or elements with different annotations than base entitites.
176
+ return this.own('__required', ()=> Object.values(elements).filter(this._is_mandatory))
157
177
  }
158
178
 
159
179
  /** Forward declaration for universal CSN */
@@ -1,5 +1,6 @@
1
1
  const cds = require ('../../index')
2
2
  const corr_id = 'x-correlation-id'
3
+ const crippled_corr_id = 'x-correlationid'
3
4
  const req_id = 'x-request-id'
4
5
  const vr_id = 'x-vcap-request-id'
5
6
  const { uuid } = cds.utils
@@ -8,7 +9,7 @@ const { EventContext } = cds
8
9
  module.exports = () => {
9
10
  /** @type { import('express').Handler } */
10
11
  return function cds_context (req, res, next) {
11
- const id = req.headers[corr_id] ??= req.headers[req_id] || req.headers[vr_id] || uuid()
12
+ const id = req.headers[corr_id] ??= req.headers[req_id] || req.headers[vr_id] || req.headers[crippled_corr_id] || uuid()
12
13
  const ctx = EventContext.for ({ id, http: { req, res } })
13
14
  res.set ('X-Correlation-ID', id) // Note: we use capitalized style here as that's common standard in HTTP world
14
15
  cds._context.run (ctx, next)
@@ -4,7 +4,7 @@ const { big } = require('@sap/cds-foss')
4
4
  const { isInvalidBase64string } = require('../../../../../../common/utils/binary')
5
5
  const IllegalArgumentError = require('../errors/IllegalArgumentError')
6
6
 
7
- const YEAR_RE = '(?:-?(?:(?:(?:0\\d{3})|(?:[1-9]\\d{3,}))))'
7
+ const YEAR_RE = '(?:-?(?:(?:(?:0\\d{3})|(?:[1-9]\\d{3}))))'
8
8
  const MONTH_RE = '(?:(?:0[1-9])|(?:1[012]))'
9
9
  const DAY_RE = '(?:(?:0[1-9])|(?:[12]\\d)|(?:3[01]))'
10
10
  const HOURS_RE = '(?:(?:[01]\\d)|(?:2[0-3]))'
@@ -2,8 +2,10 @@ const cds = require('../../cds')
2
2
  const { ensureNoDraftsSuffix, ensureUnlocalized } = require('./draft')
3
3
  const { isDuplicate } = require('./rewriteAsterisks')
4
4
 
5
- const _addColumn = (name, type, columns, url) => {
6
- const mediaType = typeof type === 'object' ? { ref: [type['='].replaceAll(/\./g, '_')] } : { val: type }
5
+ const _addColumn = (name, type, columns, url, target) => {
6
+ let mediaType = typeof type === 'object' && type['=']
7
+ if (mediaType && target.elements[mediaType]?.virtual) return
8
+ mediaType = mediaType ? { ref: [mediaType.replaceAll(/\./g, '_')] } : { val: type }
7
9
  const col = {
8
10
  xpr: [
9
11
  'case',
@@ -34,8 +36,8 @@ const _addColumn = (name, type, columns, url) => {
34
36
  const _addColumns = (target, columns) => {
35
37
  for (const k in target.elements) {
36
38
  const el = target.elements[k]
37
- if (el['@Core.MediaType']) {
38
- _addColumn(el.name, el['@Core.MediaType'], columns, el['@Core.IsURL'] && el.type === 'cds.String')
39
+ if (el['@Core.MediaType'] && !el.virtual) {
40
+ _addColumn(el.name, el['@Core.MediaType'], columns, el['@Core.IsURL'] && el.type === 'cds.String', target)
39
41
  }
40
42
  }
41
43
  }
@@ -58,7 +60,7 @@ const handleStreamProperties = (target, columns, model) => {
58
60
  _addColumns(target, columns)
59
61
  } else if (col.ref && (type === 'cds.LargeBinary' || (mediaType && !ignoreMediaType))) {
60
62
  if (mediaType) {
61
- _addColumn(name, mediaType, columns, element['@Core.IsURL'])
63
+ _addColumn(name, mediaType, columns, element['@Core.IsURL'], target)
62
64
  columns.splice(index, 1)
63
65
  } else if (!cds.env.features.stream_compat) {
64
66
  columns.splice(index, 1)
@@ -754,7 +754,7 @@ module.exports = (cqn, model, namespace, protocol) => {
754
754
  }
755
755
 
756
756
  if (cqn.SELECT.where) {
757
- _processWhere(cqn.SELECT.where, root)
757
+ _processWhere(cqn.SELECT.where, target)
758
758
  }
759
759
 
760
760
  // one?
@@ -69,7 +69,9 @@ const parseStream = async function* (body, boundary) {
69
69
  const process = chunk => {
70
70
  let changed = chunk
71
71
  .toString()
72
- .replace(/--(.*)$/gm, (_, g) => `HEAD /${g} HTTP/1.1${g.slice(-2) === '--' ? CRLF : ''}`)
72
+ .replace(/^--(.*)$/gm, (_, g) => `HEAD /${g} HTTP/1.1${g.slice(-2) === '--' ? CRLF : ''}`)
73
+ // correct content-length for non-HEAD requests is inserted below
74
+ .replace(/content-length: \d+\r\n/gim, '')
73
75
  .replace(/ \$/g, ' /$')
74
76
 
75
77
  // HACKS!!!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "8.2.0",
3
+ "version": "8.2.2",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [