@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 +21 -0
- package/bin/serve.js +3 -0
- package/lib/dbs/cds-deploy.js +2 -0
- package/lib/linked/validate.js +62 -45
- package/libx/_runtime/common/utils/streamProp.js +1 -1
- package/libx/_runtime/fiori/lean-draft.js +4 -1
- package/libx/odata/middleware/read.js +5 -0
- package/libx/odata/parse/afterburner.js +1 -1
- package/libx/odata/utils/result.js +3 -1
- package/package.json +1 -1
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
|
|
package/lib/dbs/cds-deploy.js
CHANGED
|
@@ -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 {
|
package/lib/linked/validate.js
CHANGED
|
@@ -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
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
asserts
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
return
|
|
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
|
|
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'))
|
|
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 => {
|
|
@@ -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
|