@sap/cds 8.2.1 → 8.2.3

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,27 @@
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.3 - 2024-09-20
8
+
9
+ ### Changed
10
+
11
+ - All annotations in input data are skipped and removed from the input by `cds.validate()` - as we did in legacy OData adapter
12
+
13
+ ### Fixed
14
+
15
+ - Unmanaged associations are excluded from `@mandatory` checks
16
+ - Properly reject direct requests to `DraftAdministrativeData`
17
+ - Virtual elements annotated with `@Core.MediaType`
18
+ - OData Requests targeting a specific instance and custom handler returns empty array
19
+ - `cds-serve` and `cds-deploy` now set `cds.cli` information
20
+
21
+ ## Version 8.2.2 - 2024-09-13
22
+
23
+ ### Fixed
24
+
25
+ - Erroneous caching in `cds.validate`
26
+ - Properly check `$filter` element types across navigations
27
+
7
28
  ## Version 8.2.1 - 2024-09-04
8
29
 
9
30
  ### Fixed
package/bin/serve.js CHANGED
@@ -158,6 +158,9 @@ async function serve (all=[], o={}) {
158
158
  if (o.watch) return _watch.call(this, o.project,o) // cds serve --watch <project>
159
159
  if (o.project) _chdir_to (o.project) // cds run --project <project>
160
160
 
161
+ // let plugins know about the CLI
162
+ cds.cli = { command: 'serve', argv: all, options: o }
163
+
161
164
  // Ensure loading plugins before calling cds.env!
162
165
  await cds.plugins
163
166
 
@@ -384,6 +384,7 @@ const _entity4 = (file, csn) => {
384
384
 
385
385
  /** CLI used as via cds-deploy as deployer for PostgreSQL */
386
386
  if (!module.parent) (async function CLI () {
387
+ cds.cli = { command: 'deploy', argv: process.argv.slice(2), options: {} }
387
388
  await cds.plugins // IMPORTANT: that has to go before any call to cds.env, like through cds.deploy or cds.requires below
388
389
  let db = cds.requires.db
389
390
  try {
@@ -400,6 +401,7 @@ if (!module.parent) (async function CLI () {
400
401
  if (o.username) (db.credentials ??= {}).username = o.username
401
402
  if (o.password) (db.credentials ??= {}).password = o.password
402
403
  }
404
+ cds.cli.options = o
403
405
  db = await cds.connect.to(db);
404
406
  db = await cds.deploy('*',o).to(db)
405
407
  } finally {
@@ -46,7 +46,8 @@ class Validation {
46
46
  return filter.length ? `(${filter})` : `[${index}]`
47
47
  }
48
48
 
49
- unknown(e,d) {
49
+ unknown(e,d,input) {
50
+ if (e.startsWith('@')) return delete input[e] //> skip all annotations, like @odata.Type
50
51
  d['@open'] || cds.error (`Property "${e}" does not exist in ${d.name}`, {status:400})
51
52
  }
52
53
  }
@@ -92,43 +93,54 @@ const $any = class any {
92
93
  * this method for subsequent usages, with statically determined checks.
93
94
  */
94
95
  check_asserts (val, path, /** @type {Validation} */ ctx) {
95
- // if (this['@cds.validate'] === false) return this.set ('check_asserts', ()=>{})
96
- const asserts = []
97
- const type_check = conf.strict && this.strict_check || this.type_check
98
- if (type_check) {
99
- asserts.push ((v,p,ctx) => v == null || type_check(v) || ctx.error ('ASSERT_DATA_TYPE', p, this.name, v, this ))
100
- }
101
- if (this._is_mandatory()) {
102
- 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
103
- }
104
- if (this['@assert.format']) {
105
- const format = new RegExp(this['@assert.format'],'u')
106
- asserts.push ((v,p,ctx) => v == null || format.test(v) || ctx.error ('ASSERT_FORMAT', p, this.name, v, format))
107
- }
108
- if (this['@assert.range'] && !this.enum) {
109
- const [ min, max ] = this['@assert.range']
110
- asserts.push ((v,p,ctx) => v == null || min <= v && v <= max || ctx.error ('ASSERT_RANGE', p, this.name, v, min, max))
111
- }
112
- if (this['@assert.enum'] || this['@assert.range'] && this.enum) {
113
- const vals = Object.entries(this.enum).map(([k,v]) => 'val' in v ? v.val : k)
114
- const enums = vals.reduce((a,v) => (a[v]=true, a),{})
115
- 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(', ')))
116
- }
117
- if (!asserts.length) return this.check_asserts = ()=>{} // nothing to do
118
- this.set ('check_asserts', (v,p,ctx) => asserts.forEach (a => a(v,p,ctx)))
119
- this.check_asserts (val, path, ctx) // call first time
96
+ // IMPORTANT: We need to use this.own() here as elements derived from reuse
97
+ // definitions or from elements of base entities might have different asserts
98
+ // than inherited ones.
99
+ const check_asserts = this.own('_check_asserts', () => {
100
+ const asserts = []
101
+ const type_check = conf.strict && this.strict_check || this.type_check
102
+ if (type_check) {
103
+ asserts.push ((v,p,ctx) => v == null || type_check(v) || ctx.error ('ASSERT_DATA_TYPE', p, this.name, v, this ))
104
+ }
105
+ if (this._is_mandatory()) {
106
+ 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
107
+ }
108
+ if (this['@assert.format']) {
109
+ const format = new RegExp(this['@assert.format'],'u')
110
+ asserts.push ((v,p,ctx) => v == null || format.test(v) || ctx.error ('ASSERT_FORMAT', p, this.name, v, format))
111
+ }
112
+ if (this['@assert.range'] && !this.enum) {
113
+ const [ min, max ] = this['@assert.range']
114
+ asserts.push ((v,p,ctx) => v == null || min <= v && v <= max || ctx.error ('ASSERT_RANGE', p, this.name, v, min, max))
115
+ }
116
+ if (this['@assert.enum'] || this['@assert.range'] && this.enum) {
117
+ const vals = Object.entries(this.enum).map(([k,v]) => 'val' in v ? v.val : k)
118
+ const enums = vals.reduce((a,v) => (a[v]=true, a),{})
119
+ 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(', ')))
120
+ }
121
+ if (!asserts.length) return ()=>{} // nothing to do
122
+ return (v,p,ctx) => asserts.forEach (a => a(v,p,ctx))
123
+ })
124
+ return check_asserts (val, path, ctx)
120
125
  }
121
126
 
122
127
  _is_mandatory (d=this) {
123
- return d.own('_mandatory', ()=> (
124
- !d['@readonly'] // readonly -> not mandatory
125
- && (d['@mandatory'] || d['@Common.FieldControl']?.['#'] === 'Mandatory')
126
- && !d._is_flattened()
127
- ))
128
- }
129
-
130
- _is_flattened (d=this) {
131
- return d.parent?.query?.SELECT.columns?.some (c => c.ref?.length > 1 && d.name === (c.as || c.ref.at(-1)))
128
+ return d.own('_mandatory', ()=> {
129
+ if (d._is_readonly()) return false // readonly annotations have precedence over mandatory ones
130
+ if (d['@mandatory'] || d['@Common.FieldControl']?.['#'] === 'Mandatory') {
131
+ const q = d.parent?.query?.SELECT
132
+ if (!q) return true // it's a regular entity's element marked as mandatory
133
+ if (!q.from?.ref) return false // join or union -> elements can't be mandatory
134
+ const c = q.columns?.find (c => alias4(c) === d.name)
135
+ if (!c) return true // * or foo.* -> can't tell whether d is joined
136
+ if (!c.ref) return false // calculated fields aren't mandatory
137
+ if (c.ref.length === 1) return true // SELECT from Foo { foo }
138
+ if (c.ref.length === 2 && c.ref[0] === alias4(q.from)) return true // SELECT from Foo as f { f.foo }
139
+ else return false // joined field which can't be mandatory, e.g. SELECT from Books { author.name as author }
140
+ function alias4 (x) { return x.as || x.ref?.at(-1) }
141
+ }
142
+ else return false
143
+ })
132
144
  }
133
145
 
134
146
  _is_readonly (d=this) {
@@ -148,17 +160,21 @@ const $any = class any {
148
160
  * This is the case if the row date does not contain all primary key elements of the target entity.
149
161
  */
150
162
  _is_insert (row) {
151
- const entity = this._target || this
152
- const keys = Object.keys (entity.keys||{})
153
- if (!keys.length) return this.set('_is_insert', ()=> true), true
154
- else this.set('_is_insert', data => typeof data === 'object' && !keys.every(k => k in data))
155
- return this._is_insert (row)
163
+ // IMPORTANT: We need to use this.own() here as derived entities might have
164
+ // different keys and thus different insert checks.
165
+ const _is_insert = this.own('__is_insert', () => {
166
+ const entity = this._target || this
167
+ const keys = Object.keys (entity.keys||{})
168
+ if (!keys.length) return ()=> true
169
+ else return data => typeof data === 'object' && !keys.every(k => k in data)
170
+ })
171
+ return _is_insert(row)
156
172
  }
157
173
 
158
174
  _required (elements) {
159
- const _required = Object.values(elements).filter(this._is_mandatory)
160
- this.set('_required', ()=> _required)
161
- return _required
175
+ // IMPORTANT: We need to use this.own() here as derived entities might have
176
+ // different elements or elements with different annotations than base entitites.
177
+ return this.own('__required', ()=> Object.values(elements).filter(this._is_mandatory))
162
178
  }
163
179
 
164
180
  /** Forward declaration for universal CSN */
@@ -175,12 +191,13 @@ class struct extends $any {
175
191
  if (each.name in skip) continue // skip uplinks in deep inserts -> see Composition.validate()
176
192
  if (each.$struct in data) continue // got struct for flattened element/fk, e.g. {author:{ID:1}}
177
193
  if (each.elements || each.foreignKeys) continue // skip struct-likes as we check flat payloads above, and deep payloads via struct.validate()
194
+ if (each.isAssociation) continue // unmanaged associations are always ignored (no value like)
178
195
  else ctx.error ('ASSERT_NOT_NULL', path_, each.name) // ASSERT_NOT_NULL should be ASSERT_REQUIRED
179
196
  }
180
197
  // check values of given data
181
198
  for (let each in data) { // will work for structured payloads as well as flattened ones with universal CSN
182
199
  let /** @type {$any} */ d = elements[each]
183
- if (!d || typeof d === 'function') ctx.unknown (each, this, data) // `each` might be a method of LinkedDefinitions, like filter, map, some, every, ...
200
+ if (!d) ctx.unknown (each, this, data)
184
201
  else if (ctx.cleanse && d._is_readonly()) delete data[each]
185
202
  else if (d['@cds.validate'] !== false) d.validate (data[each], path_, ctx)
186
203
  }
@@ -295,4 +312,4 @@ $.LargeBinary.prototype .type_check = v => Buffer.isBuffer(v) || typeof v === 's
295
312
  $.LargeString.prototype .type_check = v => Buffer.isBuffer(v) || typeof v === 'string' || v instanceof Readable
296
313
 
297
314
  // Mixin above class extensions to cds.linked.classes
298
- $.mixin ( Decimal, string, $any, action, array, struct, entity, Association, Composition )
315
+ $.mixin ( Decimal, string, $any, action, array, struct, entity, Association, Composition )
@@ -60,7 +60,7 @@ const handleStreamProperties = (target, columns, model) => {
60
60
  _addColumns(target, columns)
61
61
  } else if (col.ref && (type === 'cds.LargeBinary' || (mediaType && !ignoreMediaType))) {
62
62
  if (mediaType) {
63
- _addColumn(name, mediaType, columns, element['@Core.IsURL'], target)
63
+ if (!element.virtual) _addColumn(name, mediaType, columns, element['@Core.IsURL'], target)
64
64
  columns.splice(index, 1)
65
65
  } else if (!cds.env.features.stream_compat) {
66
66
  columns.splice(index, 1)
@@ -781,7 +781,10 @@ const Read = {
781
781
 
782
782
  // DraftAdministrativeData is only accessible via drafts
783
783
  if (_isCount(query)) return run(query)
784
- if (query._target.name.endsWith('.DraftAdministrativeData')) return run(query._drafts)
784
+ if (query._target.name.endsWith('.DraftAdministrativeData')) {
785
+ if (query.SELECT.from.ref?.length === 1) throw new Error('Invalid draft request') // only via drafts
786
+ return run(query._drafts)
787
+ }
785
788
  if (!query._target._isDraftEnabled) return run(query)
786
789
  if (
787
790
  !query.SELECT.groupBy &&
@@ -262,6 +262,11 @@ module.exports = adapter => {
262
262
 
263
263
  const metadata = getODataMetadata(query, { result, isCollection: !one })
264
264
  result = getODataResult(result, metadata, { isCollection: !one, property: _propertyAccess })
265
+
266
+ if (!result) {
267
+ throw Object.assign(new Error('404'), { statusCode: 404 })
268
+ }
269
+
265
270
  res.send(result)
266
271
  })
267
272
  .catch(err => {
@@ -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?
@@ -53,13 +53,15 @@ const _rewriteMetadataDeep = result => {
53
53
  * @returns {object} - the odata result
54
54
  */
55
55
  module.exports = function getODataResult(result, metadata, options = {}) {
56
- if (result == null) return ''
56
+ if (result == null) return
57
57
 
58
58
  const { isCollection, property } = options
59
59
 
60
60
  if (isCollection && !Array.isArray(result)) result = [result]
61
61
  else if (!isCollection && Array.isArray(result)) result = result[0]
62
62
 
63
+ if (result === undefined) return
64
+
63
65
  // make sure @odata.context is the first element (per OData spec)
64
66
  const odataResult = {
65
67
  [METADATA.$context]: metadata.context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "8.2.1",
3
+ "version": "8.2.3",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [