@sap/cds 9.4.4 → 9.5.1
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 +81 -1
- package/_i18n/messages_en_US_saptrc.properties +1 -1
- package/common.cds +5 -2
- package/lib/compile/cds-compile.js +1 -0
- package/lib/compile/for/assert.js +64 -0
- package/lib/compile/for/flows.js +194 -58
- package/lib/compile/for/lean_drafts.js +75 -7
- package/lib/compile/parse.js +1 -1
- package/lib/compile/to/csn.js +6 -2
- package/lib/compile/to/edm.js +1 -1
- package/lib/compile/to/yaml.js +8 -1
- package/lib/dbs/cds-deploy.js +2 -2
- package/lib/env/cds-env.js +14 -4
- package/lib/env/defaults.js +6 -1
- package/lib/i18n/localize.js +1 -1
- package/lib/index.js +7 -7
- package/lib/req/event.js +4 -0
- package/lib/req/validate.js +4 -1
- package/lib/srv/cds.Service.js +2 -1
- package/lib/srv/middlewares/auth/ias-auth.js +5 -7
- package/lib/srv/middlewares/auth/index.js +1 -1
- package/lib/srv/protocols/index.js +7 -6
- package/lib/srv/srv-handlers.js +7 -0
- package/libx/_runtime/common/Service.js +5 -1
- package/libx/_runtime/common/constants/events.js +1 -0
- package/libx/_runtime/common/generic/assert.js +220 -0
- package/libx/_runtime/common/generic/flows.js +168 -108
- package/libx/_runtime/common/generic/input.js +6 -4
- package/libx/_runtime/common/utils/cqn.js +0 -24
- package/libx/_runtime/common/utils/normalizeTimestamp.js +2 -2
- package/libx/_runtime/common/utils/resolveView.js +8 -2
- package/libx/_runtime/common/utils/templateProcessor.js +10 -1
- package/libx/_runtime/common/utils/templateProcessorPathSerializer.js +21 -9
- package/libx/_runtime/fiori/lean-draft.js +511 -379
- package/libx/_runtime/messaging/enterprise-messaging-utils/registerEndpoints.js +39 -35
- package/libx/_runtime/messaging/enterprise-messaging.js +2 -2
- package/libx/_runtime/remote/Service.js +4 -5
- package/libx/_runtime/ucl/Service.js +111 -15
- package/libx/common/utils/streaming.js +1 -1
- package/libx/odata/middleware/batch.js +8 -6
- package/libx/odata/middleware/create.js +2 -2
- package/libx/odata/middleware/delete.js +2 -2
- package/libx/odata/middleware/metadata.js +18 -11
- package/libx/odata/middleware/read.js +2 -2
- package/libx/odata/middleware/service-document.js +1 -1
- package/libx/odata/middleware/update.js +1 -1
- package/libx/odata/parse/afterburner.js +46 -36
- package/libx/odata/parse/cqn2odata.js +2 -6
- package/libx/odata/parse/grammar.peggy +91 -13
- package/libx/odata/parse/parser.js +1 -1
- package/libx/odata/utils/index.js +2 -2
- package/libx/odata/utils/readAfterWrite.js +2 -0
- package/libx/queue/TaskRunner.js +26 -1
- package/libx/queue/index.js +11 -1
- package/package.json +1 -1
- package/srv/ucl-service.cds +2 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
const cds = require('@sap/cds')
|
|
2
|
+
const getTemplate = require('../utils/template')
|
|
3
|
+
const templatePathSerializer = require('../utils/templateProcessorPathSerializer')
|
|
4
|
+
|
|
5
|
+
const $has_asserts = Symbol.for('has_asserts')
|
|
6
|
+
|
|
7
|
+
const { compileUpdatedDraftMessages } = require('../../fiori/lean-draft')
|
|
8
|
+
|
|
9
|
+
const _serialize = obj =>
|
|
10
|
+
JSON.stringify(
|
|
11
|
+
Object.keys(obj)
|
|
12
|
+
.sort()
|
|
13
|
+
.reduce((a, k) => ((a[k] = obj[k]), a), {})
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const _bufferReviver = (key, value) => {
|
|
17
|
+
if (value && typeof value === 'object' && value.type === 'Buffer' && Array.isArray(value.data)) {
|
|
18
|
+
return Buffer.from(value.data)
|
|
19
|
+
}
|
|
20
|
+
return value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = cds.service.impl(async function () {
|
|
24
|
+
this.after(['INSERT', 'UPSERT', 'UPDATE'], async (res, req) => {
|
|
25
|
+
if (!($has_asserts in req.target)) {
|
|
26
|
+
let has_asserts = false
|
|
27
|
+
for (const each in req.target.elements) {
|
|
28
|
+
const element = req.target.elements[each]
|
|
29
|
+
if (element['@assert']) {
|
|
30
|
+
has_asserts = true
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
req.target[$has_asserts] = has_asserts
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!cds.context?.tx || !req.target[$has_asserts]) return
|
|
38
|
+
|
|
39
|
+
const IS_DRAFT_ENTITY = req.target.isDraft
|
|
40
|
+
|
|
41
|
+
if (req.event === 'CREATE' && IS_DRAFT_ENTITY) return
|
|
42
|
+
|
|
43
|
+
let touched
|
|
44
|
+
if (cds.env.features.assert_touched_only !== false && IS_DRAFT_ENTITY && req.event === 'UPDATE')
|
|
45
|
+
touched = Object.keys(res).filter(k => !(k in req.target.keys))
|
|
46
|
+
|
|
47
|
+
const template = getTemplate('assert', this, req.target, {
|
|
48
|
+
pick: element => element['@assert'],
|
|
49
|
+
ignore: element => element.isAssociation && !element.isComposition
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (!cds.context.tx.changes) {
|
|
53
|
+
cds.context.tx.changes = {}
|
|
54
|
+
|
|
55
|
+
req.before('commit', async function () {
|
|
56
|
+
const { changes } = cds.context.tx
|
|
57
|
+
|
|
58
|
+
const errors = []
|
|
59
|
+
|
|
60
|
+
for (const [entityName, serializedChanges] of Object.entries(changes)) {
|
|
61
|
+
if (!serializedChanges.size) continue
|
|
62
|
+
|
|
63
|
+
const deserializedChanges = Array.from(serializedChanges).map(([k, v]) => [JSON.parse(k, _bufferReviver), v])
|
|
64
|
+
|
|
65
|
+
const entity = cds.model.definitions[entityName]
|
|
66
|
+
const IS_DRAFT_ENTITY = entity.isDraft
|
|
67
|
+
|
|
68
|
+
// Cache assert query on entity
|
|
69
|
+
if (!Object.hasOwn(entity, 'assert')) {
|
|
70
|
+
const asserts = []
|
|
71
|
+
|
|
72
|
+
for (const element of Object.values(entity.elements)) {
|
|
73
|
+
if (element._foreignKey4) continue
|
|
74
|
+
if (element.isAssociation && !element.isComposition) continue
|
|
75
|
+
|
|
76
|
+
const assert = element['@assert']
|
|
77
|
+
if (!assert) continue
|
|
78
|
+
|
|
79
|
+
// replace $self with $main
|
|
80
|
+
const xpr = JSON.parse(JSON.stringify(assert.xpr).replace(/\$self/g, '$main'))
|
|
81
|
+
|
|
82
|
+
asserts.push({ xpr, as: '@assert:' + element.name })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
entity.assert = cds.ql.SELECT([...Object.keys(entity.keys), ...asserts]).from(entity)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const query = cds.ql.clone(entity.assert)
|
|
89
|
+
|
|
90
|
+
// Select only rows with changes
|
|
91
|
+
const keyNames = Object.keys(entity.keys).filter(
|
|
92
|
+
k => !entity.keys[k].virtual && !entity.keys[k].isAssociation
|
|
93
|
+
)
|
|
94
|
+
const keyMap = Object.fromEntries(keyNames.map(k => [k, true]))
|
|
95
|
+
|
|
96
|
+
query.where([
|
|
97
|
+
{ list: keyNames.map(k => ({ ref: [k] })) },
|
|
98
|
+
'in',
|
|
99
|
+
{ list: deserializedChanges.map(([keyKV]) => ({ list: keyNames.map(k => ({ val: keyKV[k] })) })) }
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
const results = await query
|
|
103
|
+
|
|
104
|
+
for (const row of results) {
|
|
105
|
+
const keyColumns = Object.fromEntries(Object.entries(row).filter(([k]) => k in keyMap))
|
|
106
|
+
const { touched, req, pathSegmentsInfo } = serializedChanges.get(_serialize(keyColumns))
|
|
107
|
+
const failedColumns = Object.entries(row)
|
|
108
|
+
.filter(([k, v]) => v !== null && !(k in keyMap))
|
|
109
|
+
.map(([k, v]) => [k.replace(/^@assert:/, ''), v])
|
|
110
|
+
|
|
111
|
+
if (failedColumns.length === 0) continue
|
|
112
|
+
|
|
113
|
+
const failedAsserts = failedColumns.map(([element, message]) => {
|
|
114
|
+
const error = {
|
|
115
|
+
code: 'ASSERT',
|
|
116
|
+
target: element,
|
|
117
|
+
numericSeverity: 4,
|
|
118
|
+
'@Common.numericSeverity': 4
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// if error function was used in @assert expression -> use its output
|
|
122
|
+
try {
|
|
123
|
+
// Depending on DB, function result may be JavaScript Object or JSON String
|
|
124
|
+
const parsed = typeof message === 'string' ? JSON.parse(message) : message
|
|
125
|
+
Object.assign(error, parsed)
|
|
126
|
+
if (Array.isArray(error.targets)) {
|
|
127
|
+
const target = error.targets.at(0)
|
|
128
|
+
const additionalTargets = error.targets.slice(1)
|
|
129
|
+
if (target) error.target = target
|
|
130
|
+
if (additionalTargets.length) error.additionalTargets = additionalTargets
|
|
131
|
+
}
|
|
132
|
+
delete error.targets
|
|
133
|
+
} catch {
|
|
134
|
+
error.message = message
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return error
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
if (IS_DRAFT_ENTITY) {
|
|
141
|
+
const draft = await SELECT.one
|
|
142
|
+
.from({ ref: [req.subject.ref[0]] })
|
|
143
|
+
.columns('DraftAdministrativeData_DraftUUID', 'DraftAdministrativeData.DraftMessages')
|
|
144
|
+
const persistedMessages = draft.DraftAdministrativeData_DraftMessages || []
|
|
145
|
+
|
|
146
|
+
// keep all messages that have targets that were touched in this change
|
|
147
|
+
const newMessages = touched
|
|
148
|
+
? failedAsserts.filter(a => {
|
|
149
|
+
const targets = [a.target].concat(a.additionalTargets || [])
|
|
150
|
+
return touched.some(t => targets.includes(t))
|
|
151
|
+
})
|
|
152
|
+
: failedAsserts
|
|
153
|
+
|
|
154
|
+
const nextDraftMessages = compileUpdatedDraftMessages(
|
|
155
|
+
newMessages,
|
|
156
|
+
persistedMessages,
|
|
157
|
+
req.data,
|
|
158
|
+
req.subject.ref
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
await UPDATE('DRAFT.DraftAdministrativeData')
|
|
162
|
+
.set({ DraftMessages: nextDraftMessages })
|
|
163
|
+
.where({ DraftUUID: draft.DraftAdministrativeData_DraftUUID })
|
|
164
|
+
} else {
|
|
165
|
+
const isDraftAction = req._.event?.startsWith('draft')
|
|
166
|
+
const prefix = templatePathSerializer('', pathSegmentsInfo)
|
|
167
|
+
failedAsserts.forEach(err => {
|
|
168
|
+
err.target = (isDraftAction ? 'in/' : '') + prefix + err.target
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
errors.push(...failedAsserts)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (errors.length) {
|
|
177
|
+
if (errors.length === 1) throw errors[0]
|
|
178
|
+
const err = new cds.error('MULTIPLE_ERRORS', { details: errors })
|
|
179
|
+
delete err.stack
|
|
180
|
+
throw err
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const templateProcessOptions = {
|
|
186
|
+
pathSegmentsInfo: [],
|
|
187
|
+
includeKeyValues: true
|
|
188
|
+
}
|
|
189
|
+
if (req._.event?.startsWith('draft')) {
|
|
190
|
+
const IsActiveEntity = req.data.IsActiveEntity || false
|
|
191
|
+
templateProcessOptions.draftKeys = { IsActiveEntity }
|
|
192
|
+
}
|
|
193
|
+
// Collect entity keys and their values of changed rows
|
|
194
|
+
template.process(
|
|
195
|
+
req.data,
|
|
196
|
+
elementInfo => {
|
|
197
|
+
const { row, target, pathSegmentsInfo } = elementInfo
|
|
198
|
+
const targetName = target.name
|
|
199
|
+
|
|
200
|
+
cds.context.tx.changes[targetName] ??= new Map()
|
|
201
|
+
|
|
202
|
+
const keys = {}
|
|
203
|
+
for (const key in target.keys) {
|
|
204
|
+
if (key === 'IsActiveEntity') continue
|
|
205
|
+
if (!(key in row)) continue
|
|
206
|
+
keys[key] = row[key]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!Object.keys(keys).length) return
|
|
210
|
+
|
|
211
|
+
const serialized = _serialize(keys)
|
|
212
|
+
const changes = cds.context.tx.changes[targetName]
|
|
213
|
+
if (changes.has(serialized)) return
|
|
214
|
+
|
|
215
|
+
changes.set(serialized, { touched, req, pathSegmentsInfo: [...pathSegmentsInfo] })
|
|
216
|
+
},
|
|
217
|
+
templateProcessOptions
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
@@ -5,12 +5,10 @@ const { getFrom } = require('../../../../lib/compile/for/flows')
|
|
|
5
5
|
const FLOW_STATUS = '@flow.status'
|
|
6
6
|
const FROM = '@from'
|
|
7
7
|
const TO = '@to'
|
|
8
|
-
// backwards compat
|
|
9
|
-
const FLOW_FROM = '@flow.from'
|
|
10
|
-
const FLOW_TO = '@flow.to'
|
|
11
|
-
|
|
12
8
|
const FLOW_PREVIOUS = '$flow.previous'
|
|
13
9
|
|
|
10
|
+
const $transitions_ = Symbol.for('transitions_')
|
|
11
|
+
|
|
14
12
|
function buildAllowedCondition(action, statusElementName, statusEnum) {
|
|
15
13
|
const fromList = getFrom(action)
|
|
16
14
|
const conditions = fromList.map(from => {
|
|
@@ -20,154 +18,216 @@ function buildAllowedCondition(action, statusElementName, statusEnum) {
|
|
|
20
18
|
return `(${conditions.join(' OR ')})`
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
async function isCurrentStatusInFrom(
|
|
21
|
+
async function isCurrentStatusInFrom(subject, action, statusElementName, statusEnum) {
|
|
24
22
|
const cond = buildAllowedCondition(action, statusElementName, statusEnum)
|
|
25
23
|
const parsedXpr = cds.parse.expr(cond)
|
|
26
|
-
const dbEntity = await SELECT.one.from(
|
|
24
|
+
const dbEntity = await SELECT.one.from(subject).where(parsedXpr)
|
|
27
25
|
return dbEntity !== undefined
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
async function checkStatus(
|
|
31
|
-
const allowed = await isCurrentStatusInFrom(
|
|
28
|
+
async function checkStatus(subject, action, statusElementName, statusEnum) {
|
|
29
|
+
const allowed = await isCurrentStatusInFrom(subject, action, statusElementName, statusEnum)
|
|
32
30
|
if (!allowed) {
|
|
33
31
|
const from = getFrom(action)
|
|
34
32
|
const fromValues = JSON.stringify(from.flatMap(el => Object.values(el)))
|
|
35
33
|
cds.error({
|
|
36
|
-
|
|
34
|
+
status: 409,
|
|
37
35
|
message: from.length > 1 ? 'INVALID_FLOW_TRANSITION_MULTI' : 'INVALID_FLOW_TRANSITION_SINGLE',
|
|
38
36
|
args: [action.name, statusElementName, fromValues]
|
|
39
37
|
})
|
|
40
38
|
}
|
|
41
39
|
}
|
|
42
40
|
|
|
43
|
-
|
|
41
|
+
// REVISIT: what about renamed keys?
|
|
42
|
+
const buildUpKeys = async (entity, data, subject) => {
|
|
43
|
+
const parentKeys = Object.keys(entity.keys).filter(k => k !== 'IsActiveEntity')
|
|
44
|
+
// REVISIT: when do we not hava all keys?
|
|
45
|
+
const keyValues =
|
|
46
|
+
data && parentKeys.every(key => key in data) ? data : await SELECT.one.from(subject).columns(parentKeys)
|
|
44
47
|
const upKeys = {}
|
|
45
|
-
for (
|
|
46
|
-
upKeys[`up__${
|
|
48
|
+
for (let i = 0; i < parentKeys.length; i++) {
|
|
49
|
+
upKeys[`up__${parentKeys[i]}`] = keyValues[parentKeys[i]]
|
|
47
50
|
}
|
|
48
51
|
return upKeys
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
const resolveTo = (action, statusEnum) => {
|
|
55
|
+
let to = action[TO]
|
|
56
|
+
to = to['#'] ? (statusEnum[to['#']].val ?? statusEnum[to['#']]['$path'].at(-1)) : to
|
|
57
|
+
return to
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const handleTransition = async (entity, data, subject, to) => {
|
|
61
|
+
const isPrevious = to['='] === FLOW_PREVIOUS
|
|
62
|
+
if (isPrevious) {
|
|
63
|
+
const upKeys = await buildUpKeys(entity, data, subject)
|
|
64
|
+
const previous = await SELECT.one
|
|
65
|
+
.from(entity[$transitions_].target)
|
|
66
|
+
.where({ ...upKeys })
|
|
67
|
+
.orderBy('timestamp desc')
|
|
68
|
+
.limit(1, 1)
|
|
69
|
+
if (!previous)
|
|
70
|
+
cds.error({ status: 409, message: 'No change has been made yet, cannot transition to previous status.' })
|
|
71
|
+
to = previous.status
|
|
72
|
+
}
|
|
73
|
+
return to
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const getStatusInfo = statusElement => {
|
|
77
|
+
let statusEnum, statusElementName
|
|
78
|
+
if (statusElement.enum) {
|
|
79
|
+
statusEnum = statusElement.enum
|
|
80
|
+
statusElementName = statusElement.name
|
|
81
|
+
} else if (statusElement?._target?.elements['code']) {
|
|
82
|
+
statusEnum = statusElement._target.elements['code'].enum
|
|
83
|
+
statusElementName = statusElement.name + '_code'
|
|
56
84
|
} else {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
cds.error({
|
|
86
|
+
status: 409,
|
|
87
|
+
message: `Status element in ${statusElement.parent.name} must be an enum or target an entity with an enum named "code"`
|
|
60
88
|
})
|
|
61
89
|
}
|
|
90
|
+
return { statusEnum, statusElementName }
|
|
62
91
|
}
|
|
63
92
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
93
|
+
const from_factory = (entity, action, { statusElementName, statusEnum }) => {
|
|
94
|
+
async function handle_flow_from(req) {
|
|
95
|
+
const subject = cds.clone(req.subject)
|
|
96
|
+
if (entity.name.endsWith('.drafts')) subject.ref[0].id = entity.name
|
|
97
|
+
|
|
98
|
+
await checkStatus(subject, action, statusElementName, statusEnum)
|
|
99
|
+
}
|
|
100
|
+
handle_flow_from._initial = true
|
|
101
|
+
return handle_flow_from
|
|
68
102
|
}
|
|
69
103
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if
|
|
79
|
-
|
|
80
|
-
|
|
104
|
+
const to___factory = (entity, action, { statusElementName, statusEnum }) => {
|
|
105
|
+
return async function handle_flow_to(req, next) {
|
|
106
|
+
const res = await next()
|
|
107
|
+
|
|
108
|
+
let subject = cds.clone(req.subject)
|
|
109
|
+
if (entity.name.endsWith('.drafts')) subject.ref[0].id = entity.name
|
|
110
|
+
|
|
111
|
+
// REVISIT: this only happens on CREATE, where req.subject is a collection
|
|
112
|
+
// -> could be avoided if setting the status would be done via req.data
|
|
113
|
+
if (!subject.ref[0].id) {
|
|
114
|
+
const keys = Object.keys(entity.keys).reduce((acc, cur) => {
|
|
115
|
+
acc[cur] = res[cur]
|
|
116
|
+
return acc
|
|
117
|
+
}, {})
|
|
118
|
+
subject = SELECT.from(entity.name, keys).SELECT.from
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let to = resolveTo(action, statusEnum)
|
|
122
|
+
|
|
123
|
+
if (Object.prototype.hasOwnProperty.call(entity, $transitions_)) {
|
|
124
|
+
to = await handleTransition(entity, req.data, subject, to)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await UPDATE(subject).with({ [statusElementName]: to })
|
|
128
|
+
|
|
129
|
+
// REVISIT: for stack, we now need to delete the last to transitions
|
|
130
|
+
if (cds.env.features.flows_history_stack && resolveTo(action, statusEnum)['='] === FLOW_PREVIOUS) {
|
|
131
|
+
const upKeys = await buildUpKeys(entity, req.data, req.subject)
|
|
132
|
+
const timestamps = SELECT('timestamp')
|
|
133
|
+
.from(entity[$transitions_].target)
|
|
134
|
+
.where({ ...upKeys })
|
|
135
|
+
.orderBy('timestamp desc')
|
|
136
|
+
.limit(2)
|
|
137
|
+
await DELETE.from(entity[$transitions_].target)
|
|
138
|
+
.where({ ...upKeys })
|
|
139
|
+
.where(`timestamp in`, timestamps)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return res
|
|
81
143
|
}
|
|
82
|
-
await service.run(UPDATE(req.subject).with({ [statusElementName]: toKey }))
|
|
83
|
-
await updateFlowHistory(req, toKey, upKeys, changes, isPrevious)
|
|
84
144
|
}
|
|
85
145
|
|
|
86
146
|
/**
|
|
87
147
|
* handler registration
|
|
88
148
|
*/
|
|
89
149
|
module.exports = cds.service.impl(function () {
|
|
90
|
-
const
|
|
91
|
-
const
|
|
150
|
+
const b4 = []
|
|
151
|
+
const on = []
|
|
152
|
+
const after = []
|
|
92
153
|
|
|
93
154
|
for (const entity of this.entities) {
|
|
94
155
|
if (!entity.actions || !entity.elements) continue
|
|
95
156
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
157
|
+
const statusElement = Object.values(entity.elements).find(el => el[FLOW_STATUS])
|
|
158
|
+
if (!statusElement) continue
|
|
159
|
+
|
|
160
|
+
const statusInfo = getStatusInfo(statusElement)
|
|
161
|
+
|
|
162
|
+
// determine and cache target for transitions recording, if any
|
|
163
|
+
let base = entity
|
|
164
|
+
while (base.__proto__.kind === 'entity') base = base.__proto__
|
|
165
|
+
if (base.compositions?.transitions_) {
|
|
166
|
+
entity[$transitions_] = base.compositions.transitions_
|
|
167
|
+
// track changes on db level
|
|
168
|
+
cds.connect.to('db').then(db => {
|
|
169
|
+
db.after(['CREATE', 'UPDATE', 'UPSERT'], entity, async (res, req) => {
|
|
170
|
+
if ((res.affectedRows ?? res) !== 1) return
|
|
171
|
+
if (!(statusInfo.statusElementName in req.data)) return
|
|
172
|
+
const status = req.data[statusInfo.statusElementName]
|
|
173
|
+
const upKeys = await buildUpKeys(entity, req.data, req.subject)
|
|
174
|
+
const last = await SELECT.one.from(entity[$transitions_].target).orderBy('timestamp desc').where(upKeys)
|
|
175
|
+
if (last?.status !== status) await UPSERT.into(entity[$transitions_].target).entries({ ...upKeys, status })
|
|
176
|
+
})
|
|
109
177
|
})
|
|
110
178
|
}
|
|
111
179
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
180
|
+
// register handlers
|
|
181
|
+
for (const action of entity.actions) {
|
|
182
|
+
const to__ = action[TO]
|
|
183
|
+
const from = action[FROM]
|
|
184
|
+
|
|
185
|
+
// REVISIT: for CRUD and Draft, we could set status in before handlers (on db level) to save roundtrips
|
|
186
|
+
switch (action.name) {
|
|
187
|
+
// CRUD
|
|
188
|
+
case 'CREATE':
|
|
189
|
+
if (to__) on.push([action.name, entity, to___factory(entity, action, statusInfo)])
|
|
190
|
+
break
|
|
191
|
+
case 'READ':
|
|
192
|
+
// nothing to do
|
|
193
|
+
break
|
|
194
|
+
case 'UPDATE':
|
|
195
|
+
if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)])
|
|
196
|
+
if (to__) on.push([action.name, entity, to___factory(entity, action, statusInfo)])
|
|
197
|
+
break
|
|
198
|
+
case 'DELETE':
|
|
199
|
+
if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)])
|
|
200
|
+
break
|
|
201
|
+
// Draft
|
|
202
|
+
case 'NEW':
|
|
203
|
+
if (to__) on.push(['CREATE', entity.drafts, to___factory(entity.drafts, action, statusInfo)])
|
|
204
|
+
break
|
|
205
|
+
case 'PATCH':
|
|
206
|
+
if (from) b4.push(['UPDATE', entity.drafts, from_factory(entity.drafts, action, statusInfo)])
|
|
207
|
+
if (to__) on.push(['UPDATE', entity.drafts, to___factory(entity.drafts, action, statusInfo)])
|
|
208
|
+
break
|
|
209
|
+
case 'SAVE':
|
|
210
|
+
if (from) b4.push([action.name, entity.drafts, from_factory(entity.drafts, action, statusInfo)])
|
|
211
|
+
if (to__) on.push([action.name, entity.drafts, to___factory(entity, action, statusInfo)])
|
|
212
|
+
break
|
|
213
|
+
case 'EDIT':
|
|
214
|
+
if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)])
|
|
215
|
+
if (to__) on.push([action.name, entity, to___factory(entity.drafts, action, statusInfo)])
|
|
216
|
+
break
|
|
217
|
+
case 'DISCARD':
|
|
218
|
+
if (from) b4.push([action.name, entity.drafts, from_factory(entity.drafts, action, statusInfo)])
|
|
219
|
+
break
|
|
220
|
+
// custom actions
|
|
221
|
+
default:
|
|
222
|
+
if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)])
|
|
223
|
+
if (to__) on.push([action.name, entity, to___factory(entity, action, statusInfo)])
|
|
224
|
+
}
|
|
124
225
|
}
|
|
125
|
-
|
|
126
|
-
entry.push({ events: fromActions, entity, statusElementName, statusEnum })
|
|
127
|
-
exit.push({ events: toActions, entity, statusElementName, statusEnum })
|
|
128
226
|
}
|
|
129
227
|
|
|
130
228
|
this.prepend(function () {
|
|
131
|
-
for (const each of
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
each.entity,
|
|
135
|
-
Object.assign(
|
|
136
|
-
async function handle_entry_state(req) {
|
|
137
|
-
const action = req.target.actions[req.event]
|
|
138
|
-
await checkStatus(req, action, each.statusElementName, each.statusEnum)
|
|
139
|
-
},
|
|
140
|
-
{ _initial: true }
|
|
141
|
-
)
|
|
142
|
-
)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
for (const each of exit) {
|
|
146
|
-
async function handle_after_create(res, req) {
|
|
147
|
-
const parentKeys = Object.keys(req.target.keys)
|
|
148
|
-
const entry = {}
|
|
149
|
-
for (let i = 0; i < parentKeys.length; i++) {
|
|
150
|
-
entry[`up__${parentKeys[i]}`] = req.data[parentKeys[i]]
|
|
151
|
-
}
|
|
152
|
-
await INSERT.into(req.target.compositions['transitions_'].target).entries({
|
|
153
|
-
...entry,
|
|
154
|
-
status: res[each.statusElementName]
|
|
155
|
-
})
|
|
156
|
-
}
|
|
157
|
-
if ('transitions_' in (each.entity.compositions ?? {})) this.after('CREATE', each.entity, handle_after_create)
|
|
158
|
-
|
|
159
|
-
async function handle_exit_state(req, next) {
|
|
160
|
-
const res = await next()
|
|
161
|
-
const action = req.target.actions[req.event]
|
|
162
|
-
let toKey = buildToKey(action, each.statusEnum)
|
|
163
|
-
if ('transitions_' in (req.target.compositions ?? {})) {
|
|
164
|
-
await handleStatusTransitionWithHistory(req, each.statusElementName, toKey, this)
|
|
165
|
-
} else {
|
|
166
|
-
await this.run(UPDATE(req.subject).with({ [each.statusElementName]: toKey }))
|
|
167
|
-
}
|
|
168
|
-
return res
|
|
169
|
-
}
|
|
170
|
-
this.on(each.events, each.entity, handle_exit_state)
|
|
171
|
-
}
|
|
229
|
+
for (const each of b4) this.before(...each)
|
|
230
|
+
for (const each of on) this.on(...each)
|
|
231
|
+
for (const each of after) this.after(...each)
|
|
172
232
|
})
|
|
173
233
|
})
|
|
@@ -327,6 +327,10 @@ const _pick = element => {
|
|
|
327
327
|
if (categories.length) return { categories }
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
// List of validation error codes that should cause an early rejection of the request
|
|
331
|
+
// > If one of these codes is found, the request will be rejected right after validation
|
|
332
|
+
const EARLY_REJECT_CODES = { ASSERT_DATA_TYPE: 1, ASSERT_MANDATORY: 1, ASSERT_NOT_NULL: 1 }
|
|
333
|
+
|
|
330
334
|
async function validate_input(req) {
|
|
331
335
|
if (!req.target || req.target._unresolved) return // Validation requires resolved targets
|
|
332
336
|
if (req.event === 'CREATE' && req.target.isDraft) return // already handled in `NEW`, no need in `EDIT`
|
|
@@ -357,8 +361,7 @@ async function validate_input(req) {
|
|
|
357
361
|
const errs = cds.validate(req.data, req.target, assertOptions)
|
|
358
362
|
if (errs) {
|
|
359
363
|
errs.forEach(err => req.error(err))
|
|
360
|
-
|
|
361
|
-
if (errs.some(e => e.message === 'ASSERT_DATA_TYPE')) req.reject()
|
|
364
|
+
if (errs.some(e => e.message in EARLY_REJECT_CODES)) req.reject()
|
|
362
365
|
else return
|
|
363
366
|
}
|
|
364
367
|
|
|
@@ -406,8 +409,7 @@ function validate_action(req) {
|
|
|
406
409
|
let errs = cds.validate(data, operation, assertOptions)
|
|
407
410
|
if (errs) {
|
|
408
411
|
errs.forEach(err => req.error(err))
|
|
409
|
-
|
|
410
|
-
if (errs.some(e => e.message === 'ASSERT_DATA_TYPE')) req.reject()
|
|
412
|
+
if (errs.some(e => e.message in EARLY_REJECT_CODES)) req.reject()
|
|
411
413
|
else return
|
|
412
414
|
}
|
|
413
415
|
|
|
@@ -2,28 +2,6 @@ const cds = require('../../cds')
|
|
|
2
2
|
const { SELECT } = cds.ql
|
|
3
3
|
const { setEntityContained } = require('./csn')
|
|
4
4
|
|
|
5
|
-
const getEntityNameFromDeleteCQN = cqn => {
|
|
6
|
-
let from
|
|
7
|
-
if (cqn && cqn.DELETE && cqn.DELETE.from) {
|
|
8
|
-
if (typeof cqn.DELETE.from === 'string') {
|
|
9
|
-
from = cqn.DELETE.from
|
|
10
|
-
} else if (cqn.DELETE.from.name) {
|
|
11
|
-
from = cqn.DELETE.from.name
|
|
12
|
-
} else if (cqn.DELETE.from.ref && cqn.DELETE.from.ref.length === 1) {
|
|
13
|
-
from = cqn.DELETE.from.ref[0]
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
return from
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const getEntityNameFromUpdateCQN = cqn => {
|
|
20
|
-
return (
|
|
21
|
-
(cqn.UPDATE.entity.ref && cqn.UPDATE.entity.ref[0] && (cqn.UPDATE.entity.ref[0].id || cqn.UPDATE.entity.ref[0])) ||
|
|
22
|
-
cqn.UPDATE.entity.name ||
|
|
23
|
-
cqn.UPDATE.entity
|
|
24
|
-
)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
5
|
// scope: simple wheres à la "[{ ref: ['foo'] }, '=', { val: 'bar' }, 'and', ... ]"
|
|
28
6
|
function where2obj(where, target = null, data = {}) {
|
|
29
7
|
for (let i = 0; i < where.length; ) {
|
|
@@ -89,8 +67,6 @@ const resolveFromSelect = query => {
|
|
|
89
67
|
}
|
|
90
68
|
|
|
91
69
|
module.exports = {
|
|
92
|
-
getEntityNameFromDeleteCQN,
|
|
93
|
-
getEntityNameFromUpdateCQN,
|
|
94
70
|
where2obj,
|
|
95
71
|
targetFromPath,
|
|
96
72
|
resolveFromSelect
|
|
@@ -11,7 +11,7 @@ module.exports = value => {
|
|
|
11
11
|
if (typeof value === 'number') value = new Date(value).toISOString()
|
|
12
12
|
if (typeof value !== 'string') {
|
|
13
13
|
const msg = `Value "${value}" is not a valid Timestamp`
|
|
14
|
-
|
|
14
|
+
cds.error({ status: 400, message: msg })
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const decimalPointIndex = _lengthIfNotFoundIndex(value.lastIndexOf('.'), value.length)
|
|
@@ -22,7 +22,7 @@ module.exports = value => {
|
|
|
22
22
|
let dt = new Date(value.slice(0, dateEndIndex) + tz)
|
|
23
23
|
if (isNaN(dt)) {
|
|
24
24
|
const msg = `Value "${value}" is not a valid Timestamp`
|
|
25
|
-
|
|
25
|
+
cds.error({ status: 400, message: msg })
|
|
26
26
|
}
|
|
27
27
|
const dateNoMillisNoTZ = dt.toISOString().slice(0, 19)
|
|
28
28
|
const normalizedFractionalDigits = value
|
|
@@ -473,8 +473,14 @@ const _newDelete = (query, transitions, options) => {
|
|
|
473
473
|
: targetName
|
|
474
474
|
|
|
475
475
|
if (newDelete.where) {
|
|
476
|
-
|
|
477
|
-
|
|
476
|
+
newDelete.where = _newWhere(
|
|
477
|
+
newDelete.where,
|
|
478
|
+
targetTransition,
|
|
479
|
+
query.DELETE.from.ref[0],
|
|
480
|
+
query.DELETE.from.as,
|
|
481
|
+
undefined,
|
|
482
|
+
options
|
|
483
|
+
)
|
|
478
484
|
}
|
|
479
485
|
|
|
480
486
|
return newDelete
|