@sap/cds 8.8.3 → 8.9.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 (68) hide show
  1. package/CHANGELOG.md +40 -4
  2. package/_i18n/i18n_en_US_saptrc.properties +3 -0
  3. package/bin/colors.js +2 -0
  4. package/bin/test.js +103 -75
  5. package/eslint.config.mjs +16 -4
  6. package/lib/compile/for/lean_drafts.js +4 -0
  7. package/lib/compile/parse.js +26 -6
  8. package/lib/env/cds-env.js +3 -1
  9. package/lib/env/cds-requires.js +0 -3
  10. package/lib/env/schemas/cds-rc.js +11 -0
  11. package/lib/log/format/aspects/cls.js +2 -1
  12. package/lib/log/format/json.js +1 -1
  13. package/lib/plugins.js +2 -3
  14. package/lib/ql/SELECT.js +2 -1
  15. package/lib/ql/cds-ql.js +2 -0
  16. package/lib/ql/cds.ql-predicates.js +6 -4
  17. package/lib/ql/resolve.js +46 -0
  18. package/lib/req/validate.js +1 -0
  19. package/lib/srv/bindings.js +64 -43
  20. package/lib/srv/cds-connect.js +1 -1
  21. package/lib/srv/cds-serve.js +2 -2
  22. package/lib/srv/middlewares/auth/ias-auth.js +2 -0
  23. package/lib/srv/protocols/http.js +2 -2
  24. package/lib/srv/protocols/index.js +1 -1
  25. package/lib/srv/protocols/odata-v4.js +0 -1
  26. package/lib/srv/srv-tx.js +1 -1
  27. package/lib/test/cds-test.js +3 -4
  28. package/lib/utils/cds-utils.js +19 -19
  29. package/lib/utils/colors.js +46 -45
  30. package/lib/utils/csv-reader.js +5 -5
  31. package/libx/_runtime/cds-services/adapter/odata-v4/handlers/read.js +1 -2
  32. package/libx/_runtime/cds-services/adapter/odata-v4/odata-to-cqn/index.js +1 -1
  33. package/libx/_runtime/common/Service.js +4 -2
  34. package/libx/_runtime/common/composition/data.js +1 -2
  35. package/libx/_runtime/common/composition/tree.js +6 -4
  36. package/libx/_runtime/common/generic/sorting.js +6 -2
  37. package/libx/_runtime/common/utils/cqn2cqn4sql.js +6 -7
  38. package/libx/_runtime/common/utils/differ.js +1 -1
  39. package/libx/_runtime/common/utils/draft.js +1 -1
  40. package/libx/_runtime/common/utils/foreignKeyPropagations.js +6 -2
  41. package/libx/_runtime/common/utils/keys.js +13 -84
  42. package/libx/_runtime/common/utils/propagateForeignKeys.js +4 -3
  43. package/libx/_runtime/common/utils/resolveView.js +96 -102
  44. package/libx/_runtime/common/utils/rewriteAsterisks.js +1 -1
  45. package/libx/_runtime/common/utils/stream.js +2 -3
  46. package/libx/_runtime/db/utils/columns.js +1 -1
  47. package/libx/_runtime/fiori/lean-draft.js +11 -7
  48. package/libx/_runtime/messaging/kafka.js +3 -4
  49. package/libx/_runtime/remote/Service.js +13 -5
  50. package/libx/_runtime/remote/utils/client.js +1 -0
  51. package/libx/_runtime/ucl/Service.js +135 -126
  52. package/libx/common/utils/path.js +34 -22
  53. package/libx/odata/middleware/create.js +2 -0
  54. package/libx/odata/middleware/operation.js +8 -2
  55. package/libx/odata/middleware/parse.js +1 -1
  56. package/libx/odata/middleware/stream.js +1 -2
  57. package/libx/odata/middleware/update.js +2 -0
  58. package/libx/odata/parse/afterburner.js +17 -9
  59. package/libx/odata/parse/cqn2odata.js +3 -1
  60. package/libx/odata/parse/grammar.peggy +21 -19
  61. package/libx/odata/parse/parser.js +1 -1
  62. package/libx/odata/utils/metadata.js +8 -2
  63. package/libx/odata/utils/odataBind.js +36 -0
  64. package/libx/outbox/index.js +1 -0
  65. package/libx/rest/middleware/operation.js +9 -8
  66. package/libx/rest/middleware/parse.js +1 -0
  67. package/package.json +3 -3
  68. package/lib/i18n/resources.js +0 -150
@@ -1,18 +1,18 @@
1
1
  const cds = require('../cds')
2
2
  const LOG = cds.log('ucl')
3
- const fs = require('fs').promises
4
3
 
4
+ const fs = require('fs').promises
5
5
  const https = require('https')
6
6
 
7
7
  class UCLService extends cds.Service {
8
8
  async init() {
9
9
  await super.init()
10
10
 
11
- for (const _required of ['namespace', 'systemType', 'systemDescription']) {
12
- if (!this.options[_required])
13
- throw new Error(
14
- `The UCL service requires mandatory parameter \`${_required}\`, please provide it as described in the documentation.`
15
- )
11
+ this._applicationTemplate = _getApplicationTemplate(this.options)
12
+ if (!this._applicationTemplate.applicationNamespace) {
13
+ throw new Error(
14
+ 'The UCL service requires a valid `applicationTemplate`, please provide it as described in the documentation.'
15
+ )
16
16
  }
17
17
 
18
18
  if (!cds.requires.multitenancy && cds.env.profile !== 'mtx-sidecar')
@@ -50,99 +50,6 @@ class UCLService extends cds.Service {
50
50
  })
51
51
  }
52
52
 
53
- async readTemplate() {
54
- const xsappname = this.options.credentials.xsappname
55
- const query = `
56
- query ($key: String!, $value: String!) {
57
- applicationTemplates(filter: { key: $key, query: $value }) {
58
- data {
59
- id
60
- name
61
- description
62
- placeholders {
63
- name
64
- description
65
- }
66
- applicationInput
67
- labels
68
- webhooks {
69
- type
70
- }
71
- }
72
- }
73
- }
74
- `
75
- const variables = { key: 'xsappname', value: `"${xsappname}"` }
76
- const res = await this._request(query, variables)
77
- if (res) return res.applicationTemplates.data[0]
78
- }
79
-
80
- async createTemplate() {
81
- const xsappname = this.options.credentials.xsappname
82
- const query = `mutation {
83
- result: createApplicationTemplate (
84
- in: {
85
- name: "${this.options.systemType}"
86
- description: "${this.options.systemDescription}"
87
- applicationInput: {
88
- name: "${this.options.systemType}"
89
- description: "${this.options.systemDescription}"
90
- providerName: "${this.options.provider}"
91
- localTenantID: "{{tenant-id}}"
92
- labels: {
93
- displayName: "{{subdomain}}"
94
- }
95
- }
96
- placeholders: [
97
- { name: "subdomain", description: "The subdomain of the consumer tenant" }
98
- { name: "tenant-id", description: "The tenant id as it's known in the product's domain", jsonPath: "$.subscribedTenantId" }
99
- ]
100
- labels: {
101
- managed_app_provisioning: true
102
- xsappname: "${xsappname}"
103
- }
104
- applicationNamespace: "${this.options.namespace}"
105
- accessLevel: GLOBAL
106
- }
107
- ) {
108
- id
109
- name
110
- labels
111
- applicationInput
112
- applicationNamespace
113
- }
114
- }`
115
- try {
116
- return this._handleResponse(await this._request(query))
117
- } catch (e) {
118
- this._handleResponse(e)
119
- }
120
- }
121
-
122
- _handleResponse(result) {
123
- if (result.response && result.response.errors) {
124
- let errorMessage = result.response.errors[0].message
125
- throw new Error(errorMessage)
126
- } else {
127
- return result.result
128
- }
129
- }
130
-
131
- async deleteTemplate() {
132
- const template = await this.readTemplate()
133
- if (!template) return
134
- const query = `mutation {
135
- result: deleteApplicationTemplate(
136
- id: "${template.id}"
137
- ){
138
- id
139
- name
140
- description
141
- }
142
- }`
143
- return this._handleResponse(await this._request(query))
144
- }
145
-
146
53
  // Replace with fetch
147
54
  async _request(query, variables) {
148
55
  const opts = {
@@ -150,9 +57,7 @@ class UCLService extends cds.Service {
150
57
  path: this.options.path,
151
58
  agent: this.agent,
152
59
  method: 'POST',
153
- headers: {
154
- 'Content-Type': 'application/json'
155
- }
60
+ headers: { 'Content-Type': 'application/json' }
156
61
  }
157
62
  return new Promise((resolve, reject) => {
158
63
  const req = https.request(opts, res => {
@@ -186,27 +91,87 @@ class UCLService extends cds.Service {
186
91
  })
187
92
  }
188
93
 
94
+ _handleResponse(result) {
95
+ if (result.response && result.response.errors) {
96
+ let errorMessage = result.response.errors[0].message
97
+ throw new Error(errorMessage)
98
+ } else {
99
+ return result.result
100
+ }
101
+ }
102
+
103
+ async readTemplate() {
104
+ const xsappname = this.options.credentials.xsappname
105
+ const variables = { key: 'xsappname', value: `"${xsappname}"` }
106
+ const res = await this._request(READ_QUERY, variables)
107
+ if (res) return res.applicationTemplates.data[0]
108
+ }
109
+
110
+ async createTemplate() {
111
+ try {
112
+ return this._handleResponse(await this._request(CREATE_MUTATION, { input: this._applicationTemplate }))
113
+ } catch (e) {
114
+ this._handleResponse(e)
115
+ }
116
+ }
117
+
189
118
  async updateTemplate(template) {
190
- const query = `mutation {
191
- result: updateApplicationTemplate(
192
- id: "${template.id}"
193
- in: {
194
- name: "${this.options.systemType}"
195
- description: "${this.options.systemDescription}"
196
- applicationInput: {
197
- name: "${this.options.systemType}"
198
- description: "${this.options.systemDescription}"
199
- providerName: "${this.options.provider}"
200
- localTenantID: "{{tenant-id}}"
201
- labels: { displayName: "{{subdomain}}" }
119
+ try {
120
+ const input = { ...this._applicationTemplate }
121
+ delete input.labels
122
+ const response = this._handleResponse(await this._request(UPDATE_MUTATION, { id: template.id, input }))
123
+ LOG.info('Application template updated successfully.')
124
+ return response
125
+ } catch (e) {
126
+ this._handleResponse(e)
127
+ }
128
+ }
129
+
130
+ async deleteTemplate() {
131
+ const template = await this.readTemplate()
132
+ if (!template) return
133
+ return this._handleResponse(await this._request(DELETE_MUTATION, { id: template.id }))
134
+ }
135
+ }
136
+
137
+ const READ_QUERY = `
138
+ query ($key: String!, $value: String!) {
139
+ applicationTemplates(filter: { key: $key, query: $value }) {
140
+ data {
141
+ id
142
+ name
143
+ description
144
+ placeholders {
145
+ name
146
+ description
147
+ }
148
+ applicationInput
149
+ labels
150
+ webhooks {
151
+ type
202
152
  }
203
- applicationNamespace: "${this.options.namespace}"
204
- placeholders: [
205
- { name: "subdomain", description: "The subdomain of the consumer tenant" }
206
- { name: "tenant-id", description: "The tenant id as it's known in the product's domain", jsonPath: "$.subscribedTenantId" }
207
- ]
208
- accessLevel: GLOBAL
209
153
  }
154
+ }
155
+ }`
156
+
157
+ const CREATE_MUTATION = `
158
+ mutation {
159
+ result: createApplicationTemplate (
160
+ in: $input
161
+ ) {
162
+ id
163
+ name
164
+ labels
165
+ applicationInput
166
+ applicationNamespace
167
+ }
168
+ }`
169
+
170
+ const UPDATE_MUTATION = `
171
+ mutation ($id: ID!, $input: ApplicationTemplateUpdateInput!) {
172
+ result: updateApplicationTemplate(
173
+ id: $id
174
+ in: $input
210
175
  ) {
211
176
  id
212
177
  name
@@ -215,14 +180,58 @@ class UCLService extends cds.Service {
215
180
  applicationInput
216
181
  }
217
182
  }`
218
- try {
219
- const response = this._handleResponse(await this._request(query))
220
- LOG.info('Application template updated successfully.')
221
- return response
222
- } catch (e) {
223
- this._handleResponse(e)
183
+
184
+ const DELETE_MUTATION = `
185
+ mutation ($id: ID!) {
186
+ result: deleteApplicationTemplate(
187
+ id: $id
188
+ ) {
189
+ id
190
+ name
191
+ description
224
192
  }
193
+ }`
194
+
195
+ const _deepAssign = (a, b) => {
196
+ for (const key in b) {
197
+ if (typeof b[key] === 'object' && !Array.isArray(b[key])) a[key] = _deepAssign(a[key] || {}, b[key])
198
+ else a[key] = b[key]
225
199
  }
200
+ return a
201
+ }
202
+
203
+ const _getApplicationTemplate = options => {
204
+ let applicationTemplate = {
205
+ applicationInput: {
206
+ providerName: 'SAP',
207
+ localTenantID: '{{tenant-id}}',
208
+ labels: {
209
+ displayName: '{{subdomain}}'
210
+ }
211
+ },
212
+ labels: {
213
+ managed_app_provisioning: true,
214
+ xsappname: '${xsappname}'
215
+ },
216
+ placeholders: [
217
+ { name: 'subdomain', description: 'The subdomain of the consumer tenant' },
218
+ {
219
+ name: 'tenant-id',
220
+ description: "The tenant id as it's known in the product's domain",
221
+ jsonPath: '$.subscribedTenantId'
222
+ }
223
+ ],
224
+ accessLevel: 'GLOBAL'
225
+ }
226
+ applicationTemplate = _deepAssign(applicationTemplate, options.applicationTemplate)
227
+
228
+ const pkg = require(cds.root + '/package')
229
+ if (!applicationTemplate.name) applicationTemplate.name = pkg.name
230
+ if (!applicationTemplate.applicationInput.name) applicationTemplate.applicationInput.name = pkg.name
231
+ if (applicationTemplate.labels.xsappname === '${xsappname}')
232
+ applicationTemplate.labels.xsappname = options.credentials.xsappname
233
+
234
+ return applicationTemplate
226
235
  }
227
236
 
228
237
  module.exports = UCLService
@@ -1,13 +1,22 @@
1
+ const cds = require('../../../')
2
+
1
3
  const { where2obj } = require('../../_runtime/common/utils/cqn')
2
- const { getKeysForNavigationFromRefPath } = require('../../_runtime/common/utils/keys')
4
+ const propagateForeignKeys = require('../../_runtime/common/utils/propagateForeignKeys')
5
+
6
+ let _consistent_params
3
7
 
4
8
  // REVISIT: do we already have something like this _without using okra api_?
5
9
  // REVISIT: should we still support process.env.CDS_FEATURES_PARAMS? probably nobody uses it...
6
10
  exports.getKeysAndParamsFromPath = (from, { model }) => {
7
11
  if (!from.ref || !from.ref.length) return {}
8
12
 
9
- const keys = {}
13
+ // REVISIT: make opt-out feature in cds^9
14
+ _consistent_params ??= cds.env.features.consistent_params
15
+
10
16
  const params = []
17
+ const data = {}
18
+ let currData = data
19
+ const navigations = []
11
20
 
12
21
  let cur = model.definitions
13
22
  let lastElement
@@ -19,28 +28,31 @@ exports.getKeysAndParamsFromPath = (from, { model }) => {
19
28
  const target = cur[id]._target ?? lastElement
20
29
  cur = target.elements
21
30
 
22
- if (i === from.ref.length - 1) {
23
- // last element
24
- if (ref.where) {
25
- const seg_keys = where2obj(ref.where)
26
- Object.assign(keys, seg_keys)
27
- params[i] = seg_keys.ID && Object.keys(seg_keys).length === 1 ? seg_keys.ID : seg_keys
28
- } else if (ref.args) {
29
- params[i] = Object.fromEntries(Object.entries(ref.args).map(([k, v]) => [k, 'val' in v ? v.val : v]))
30
- }
31
- if (lastElement.isAssociation && from.ref.length > 1) {
32
- // add keys for navigation from path
33
- const rootRef = from.ref[0]
34
- const rootTarget = model.definitions[rootRef.id || rootRef]
35
- const seg_keys = getKeysForNavigationFromRefPath(from.ref, rootTarget)
36
- // only take if a known property
37
- for (const k in seg_keys) if (k in cur) keys[k] = seg_keys[k]
38
- }
39
- } else if (ref.where) {
31
+ if (lastElement.isAssociation) {
32
+ currData[lastElement.name] = {}
33
+ currData = currData[lastElement.name]
34
+ navigations.push(lastElement)
35
+ }
36
+
37
+ if (ref.where) {
40
38
  const seg_keys = where2obj(ref.where)
41
- params[i] = seg_keys.ID && Object.keys(seg_keys).length === 1 ? seg_keys.ID : seg_keys
39
+ if (_consistent_params) params[i] = seg_keys
40
+ else params[i] = seg_keys.ID && Object.keys(seg_keys).length === 1 ? seg_keys.ID : seg_keys
41
+ Object.assign(currData, seg_keys)
42
42
  }
43
+
44
+ if (i === from.ref.length - 1 && !ref.where && ref.args) {
45
+ const seg_keys = Object.fromEntries(Object.entries(ref.args).map(([k, v]) => [k, 'val' in v ? v.val : v]))
46
+ if (_consistent_params) params[i] = seg_keys
47
+ else params[i] = seg_keys.ID && Object.keys(seg_keys).length === 1 ? seg_keys.ID : seg_keys
48
+ }
49
+ }
50
+
51
+ let current = data
52
+ for (let nav of navigations) {
53
+ propagateForeignKeys(nav.name, current, nav._foreignKeys, true, { enumerable: true, generateKeys: false })
54
+ current = current[nav.name]
43
55
  }
44
56
 
45
- return { keys, params }
57
+ return { keys: current || {}, params }
46
58
  }
@@ -7,6 +7,7 @@ const postProcess = require('../utils/postProcess')
7
7
  const readAfterWrite4 = require('../utils/readAfterWrite')
8
8
  const getODataResult = require('../utils/result')
9
9
  const normalizeTimeData = require('../utils/normalizeTimeData')
10
+ const odataBind = require('../utils/odataBind')
10
11
 
11
12
  const { getKeysAndParamsFromPath } = require('../../common/utils/path')
12
13
 
@@ -37,6 +38,7 @@ module.exports = (adapter, isUpsert) => {
37
38
  const msg = 'Only single entity representations are allowed'
38
39
  throw Object.assign(new Error(msg), { statusCode: 400 })
39
40
  }
41
+ odataBind(data, target)
40
42
  normalizeTimeData(data, model, target)
41
43
  const { keys, params } = getKeysAndParamsFromPath(from, { model })
42
44
  // add keys from url into payload (overwriting if already present)
@@ -113,7 +113,10 @@ module.exports = adapter => {
113
113
  handleSapMessages(cdsReq, req, res)
114
114
 
115
115
  const stream = getReadable(result)
116
- if (!stream) return res.sendStatus(204)
116
+ if (!stream) {
117
+ if (res.statusCode > 200) return res.end()
118
+ return res.sendStatus(204)
119
+ }
117
120
 
118
121
  const { mimetype, filename, disposition } = collectStreamMetadata(result, operation, query)
119
122
  validateMimetypeIsAcceptedOrThrow(req.headers, mimetype)
@@ -151,7 +154,10 @@ module.exports = adapter => {
151
154
  handleSapMessages(cdsReq, req, res)
152
155
 
153
156
  if (operation.returns?.items && result == null) result = []
154
- if (!operation.returns || result == null) return res.sendStatus(204)
157
+ if (!operation.returns || result == null) {
158
+ if (res.statusCode > 200) return res.end()
159
+ return res.sendStatus(204)
160
+ }
155
161
 
156
162
  if (operation.returns._type?.match?.(/^cds\./)) {
157
163
  const context = `${'../'.repeat(query?.SELECT?.from?.ref?.length)}$metadata#${cds2edm[operation.returns._type]}`
@@ -9,7 +9,7 @@ module.exports = adapter => {
9
9
 
10
10
  if (req._query) return next() //> already parsed (e.g., upsert)
11
11
 
12
- req._query = cds.odata.parse(req.url, { service, baseUrl: req.baseUrl, strict: true })
12
+ req._query = cds.odata.parse(req.url, { service, baseUrl: req.baseUrl, strict: true, protocol: 'odata' })
13
13
 
14
14
  next()
15
15
  }
@@ -9,7 +9,6 @@ const {
9
9
  validateMimetypeIsAcceptedOrThrow,
10
10
  getReadable
11
11
  } = require('../../common/utils/streaming')
12
- const { getTransition } = require('../../_runtime/common/utils/resolveView')
13
12
 
14
13
  const _resolveContentProperty = (target, annotName, resolvedProp) => {
15
14
  if (target.elements[resolvedProp]) {
@@ -19,7 +18,7 @@ const _resolveContentProperty = (target, annotName, resolvedProp) => {
19
18
  LOG.warn(
20
19
  `"${annotName}" in entity "${target.name}" points to property "${resolvedProp}" which was renamed or is not part of the projection. You must update the annotation value.`
21
20
  )
22
- const mapping = getTransition(target, cds.db).mapping
21
+ const mapping = cds.ql.resolve.transitions({ target }, cds.db).mapping
23
22
  const key = [...mapping.entries()].find(({ 1: val }) => val.ref[0] === resolvedProp)
24
23
  return key?.length && key[0]
25
24
  }
@@ -7,6 +7,7 @@ const postProcess = require('../utils/postProcess')
7
7
  const readAfterWrite4 = require('../utils/readAfterWrite')
8
8
  const getODataResult = require('../utils/result')
9
9
  const normalizeTimeData = require('../utils/normalizeTimeData')
10
+ const odataBind = require('../utils/odataBind')
10
11
 
11
12
  const { getKeysAndParamsFromPath } = require('../../common/utils/path')
12
13
 
@@ -70,6 +71,7 @@ module.exports = adapter => {
70
71
 
71
72
  // payload & params
72
73
  const data = _propertyAccess ? { [_propertyAccess]: req.body.value } : req.body
74
+ odataBind(data, target)
73
75
  normalizeTimeData(data, model, target)
74
76
  const { keys, params } = getKeysAndParamsFromPath(from, { model })
75
77
  // add keys from url into payload (overwriting if already present)
@@ -241,7 +241,7 @@ function _handleCollectionBoundActions(current, ref, i, namespace, one) {
241
241
  action = shortName && current.actions[shortName]
242
242
  }
243
243
 
244
- let incompleteKeys = ref[i].where ? false : i === ref.length - 1 || one ? false : true
244
+ let incompleteKeys = !(!!ref[i].where || i === ref.length - 1 || one)
245
245
  if (!action) return incompleteKeys
246
246
 
247
247
  const onCollection = !!(
@@ -301,7 +301,9 @@ function _processSegments(from, model, namespace, cqn, protocol) {
301
301
 
302
302
  if (incompleteKeys) {
303
303
  // > key
304
- keys = keys || keysOf(current, protocol !== 'rest') // if odata, skip backlinks as key as they are used from structure
304
+ // in case of odata, values for keys that are backlinks are expected to be omitted
305
+ keys = keys || keysOf(current, !!protocol?.match(/odata/i))
306
+ if (!keys.length) cds.error(`Invalid resource path "${path}"`, { code: '404', statusCode: 404 })
305
307
  let key = keys[keyCount++]
306
308
  one = true
307
309
  const element = current.elements[key]
@@ -388,6 +390,11 @@ function _processSegments(from, model, namespace, cqn, protocol) {
388
390
  if (!Object.keys(params).length) params = where2obj(ref[i].where)
389
391
  _processWhere(ref[i].where, current)
390
392
  _checkAllKeysProvided(params, current)
393
+
394
+ if (keyCount === 0 && !Object.keys(params).length && whereRef.length === 1) {
395
+ const msg = `Entity "${current.name}" can not be accessed by key.`
396
+ throw Object.assign(new Error(msg), { statusCode: 400 })
397
+ }
391
398
  }
392
399
  } else if ({ action: 1, function: 1 }[current.kind]) {
393
400
  // > action or function
@@ -578,8 +585,6 @@ function _processColumns(cqn, target, protocol) {
578
585
 
579
586
  let columns = cqn.SELECT.columns
580
587
 
581
- // REVISIT Keys should be added only in case of odata, not e.g. rest.
582
- // Currently odata is detected via odata_new_parser flag -> find a better indicator.
583
588
  if (columns && !cqn.SELECT.groupBy) {
584
589
  let entity
585
590
  if (target.kind === 'entity') entity = target
@@ -588,7 +593,9 @@ function _processColumns(cqn, target, protocol) {
588
593
 
589
594
  _removeUnneededColumnsIfHasAsterisk(columns)
590
595
  rewriteExpandAsterisk(columns, entity)
591
- if (protocol !== 'rest') _addKeys(columns, entity)
596
+
597
+ // in case of odata, add all missing key fields (i.e., not in $select)
598
+ if (protocol?.match(/odata/i)) _addKeys(columns, entity)
592
599
  }
593
600
 
594
601
  if (!Array.isArray(columns)) return
@@ -755,6 +762,8 @@ function _validateQuery(SELECT, target, isOne, model) {
755
762
  }
756
763
 
757
764
  module.exports = (cqn, model, namespace, protocol) => {
765
+ if (!model) return cqn
766
+
758
767
  const from = resolveFromSelect(cqn)
759
768
  const { ref } = from
760
769
 
@@ -816,11 +825,10 @@ module.exports = (cqn, model, namespace, protocol) => {
816
825
  break
817
826
  }
818
827
  }
819
- const setRecurseRef = SELECT => {
820
- if (SELECT.from.SELECT) setRecurseRef(SELECT.from.SELECT)
821
- if (SELECT.recurse) SELECT.recurse.ref[0] = uplinkName
828
+ if (uplinkName) {
829
+ let r = cqn.SELECT.recurse
830
+ if (r) r.ref[0] = uplinkName
822
831
  }
823
- if (uplinkName) setRecurseRef(cqn.SELECT)
824
832
  }
825
833
 
826
834
  // REVISIT: better
@@ -393,6 +393,8 @@ function $orderBy(orderBy) {
393
393
  const res = []
394
394
 
395
395
  for (const cur of orderBy) {
396
+ if (cur.implicit) continue
397
+
396
398
  if (hasValidProps(cur, 'ref', 'sort')) {
397
399
  res.push(_format(cur) + '%20' + cur.sort)
398
400
  continue
@@ -412,7 +414,7 @@ function $orderBy(orderBy) {
412
414
  }
413
415
  }
414
416
 
415
- return '$orderby=' + res.join(',')
417
+ if (res.length) return '$orderby=' + res.join(',')
416
418
  }
417
419
 
418
420
  function parseSearch(search) {
@@ -210,13 +210,18 @@
210
210
 
211
211
  }
212
212
  if (apply.topLevels.levels) {
213
- cqn.recurse.where = [{ ref: ['DistanceFromRoot'] }, '<=', { val: apply.topLevels.levels }]
213
+ cqn.recurse.where = [{ ref: ['DistanceFromRoot'] }, '<=', { val: apply.topLevels.levels - 1 }]
214
214
  }
215
215
  if (apply.topLevels.expandLevels) {
216
216
  if (!cqn.recurse.where) cqn.recurse.where = []
217
217
  for (const expandLevel of apply.topLevels.expandLevels) {
218
218
  if (cqn.recurse.where.length !== 0) cqn.recurse.where.push('or')
219
- cqn.recurse.where.push({ ref: [apply.topLevels.nodeProperty] }, '=', { val: expandLevel.nodeID }, 'and', { ref: ['Distance'] }, 'between', { val: 0 }, 'and', { val: expandLevel.levels } )
219
+ cqn.recurse.where.push({ ref: [apply.topLevels.nodeProperty] }, '=', { val: expandLevel.nodeID })
220
+ if (Number.isInteger(expandLevel.levels)) {
221
+ cqn.recurse.where.push('and', { ref: ['Distance'] })
222
+ if (expandLevel.levels === 1) cqn.recurse.where.push('=', { val: 1 })
223
+ else cqn.recurse.where.push('between', { val: 1 }, 'and', { val: expandLevel.levels } )
224
+ }
220
225
  }
221
226
  }
222
227
  }
@@ -244,23 +249,16 @@
244
249
  }
245
250
 
246
251
  if (apply.ancestors && apply.topLevels) {
247
- const inner = {
248
- SELECT: {
249
- from: cqn.from,
250
- }
251
- }
252
- _ancestors(inner.SELECT)
253
- cqn.from = inner
254
- _toplevels(cqn)
252
+ _ancestors(cqn)
253
+ const extra = {...cqn}
254
+ _toplevels(extra)
255
+ cqn.recurse = extra.recurse
255
256
  } else if (apply.ancestors && apply.descendants) {
256
- const inner = {
257
- SELECT: {
258
- from: cqn.from
259
- }
260
- }
261
- _ancestors(inner.SELECT)
262
- cqn.from = inner
263
- _descendants(cqn)
257
+ _ancestors(cqn)
258
+ const extra = {...cqn}
259
+ _descendants(extra)
260
+ cqn.where = extra.where
261
+ cqn.recurse = extra.recurse
264
262
  } else if (apply.topLevels) {
265
263
  _toplevels(cqn)
266
264
  } else if (apply.ancestors) {
@@ -1019,7 +1017,11 @@
1019
1017
 
1020
1018
  expandItemProp
1021
1019
  = "\"NodeID\"" o ":" o s:doubleQuotedString{return { nodeID: s } } /
1022
- "\"Levels\"" o ":" o i:integer{return { levels: i } }
1020
+ "\"Levels\"" o ":" o l:expandItemLevel{return { levels: l } }
1021
+
1022
+ expandItemLevel
1023
+ = i:integer{ return i } /
1024
+ null{ return null }
1023
1025
 
1024
1026
  ansDescTrafo
1025
1027
  = OPEN o "$root/" p:simplePath o COMMA o h:identifier o COMMA o e:identifier o COMMA? t:ansDescGetNodes? o COMMA? d:integer? o COMMA? k:ansDescKeepStart? CLOSE {