@oondemand/oon-core-back 0.3.40 → 0.3.42
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/package.json +1 -1
- package/src/core/factories/crudController.js +75 -33
- package/src/index.js +5 -0
- package/src/manifest/domainManifest.js +254 -4
- package/src/manifest/domainRules.js +318 -0
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const Helpers = require("../utils/helpers");
|
|
2
2
|
const registry = require("../registry");
|
|
3
|
+
const { applyDomainMutation } = require("../../manifest/domainRules");
|
|
3
4
|
|
|
4
5
|
function toPlainRecord(record) {
|
|
5
6
|
if (!record || typeof record !== "object") return {};
|
|
@@ -9,37 +10,83 @@ function toPlainRecord(record) {
|
|
|
9
10
|
return { ...record };
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
function hasDomainRules(entry) {
|
|
14
|
+
const rules = entry?.definition?.domainRules;
|
|
15
|
+
return Boolean(
|
|
16
|
+
rules
|
|
17
|
+
&& ((rules.readonlyFields || []).length
|
|
18
|
+
|| (rules.computedFields || []).length
|
|
19
|
+
|| (rules.validations || []).length),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
/**
|
|
13
|
-
* Controllers HTTP de CRUD ligados a um service. Aplica
|
|
14
|
-
*
|
|
24
|
+
* Controllers HTTP de CRUD ligados a um service. Aplica regras declarativas do
|
|
25
|
+
* manifesto e validações registradas via `defineValidation` antes de persistir.
|
|
15
26
|
*
|
|
16
27
|
* Atualizações podem chegar parcialmente pelo formulário em abas ou pelo
|
|
17
|
-
* datagrid. Nesses casos,
|
|
18
|
-
* o service persiste somente os campos enviados
|
|
28
|
+
* datagrid. Nesses casos, fórmulas e validações recebem o registro consolidado,
|
|
29
|
+
* enquanto o service persiste somente os campos enviados e calculados.
|
|
19
30
|
*/
|
|
20
31
|
function createCrudController(entry, service) {
|
|
21
|
-
const
|
|
32
|
+
const runCustomValidation = async (data, context) => {
|
|
22
33
|
const validate = registry.getValidation(entry.name);
|
|
23
34
|
if (validate) await validate(data, context);
|
|
24
35
|
};
|
|
25
36
|
|
|
26
|
-
const
|
|
37
|
+
const prepareCreate = async (data, method = "post") => {
|
|
38
|
+
const prepared = applyDomainMutation({
|
|
39
|
+
rules: entry.definition?.domainRules,
|
|
40
|
+
current: null,
|
|
41
|
+
changes: data,
|
|
42
|
+
op: "create",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await runCustomValidation(prepared.consolidated, {
|
|
46
|
+
op: "create",
|
|
47
|
+
method,
|
|
48
|
+
current: null,
|
|
49
|
+
requestedChanges: prepared.requestedChanges,
|
|
50
|
+
changes: prepared.changes,
|
|
51
|
+
consolidated: prepared.consolidated,
|
|
52
|
+
});
|
|
53
|
+
return prepared;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const prepareUpdate = async (id, changes, method) => {
|
|
27
57
|
const validate = registry.getValidation(entry.name);
|
|
28
|
-
if (!validate)
|
|
58
|
+
if (!validate && !hasDomainRules(entry)) {
|
|
59
|
+
const normalizedChanges = changes && typeof changes === "object" ? { ...changes } : {};
|
|
60
|
+
return {
|
|
61
|
+
current: null,
|
|
62
|
+
requestedChanges: normalizedChanges,
|
|
63
|
+
changes: normalizedChanges,
|
|
64
|
+
consolidated: normalizedChanges,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
29
67
|
|
|
30
68
|
const currentRecord = await service.getById(id);
|
|
31
69
|
const current = toPlainRecord(currentRecord);
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
await validate(consolidated, {
|
|
36
|
-
op: "update",
|
|
37
|
-
method,
|
|
38
|
-
id,
|
|
70
|
+
const prepared = applyDomainMutation({
|
|
71
|
+
rules: entry.definition?.domainRules,
|
|
39
72
|
current,
|
|
40
|
-
changes
|
|
41
|
-
|
|
73
|
+
changes,
|
|
74
|
+
op: "update",
|
|
42
75
|
});
|
|
76
|
+
|
|
77
|
+
if (validate) {
|
|
78
|
+
await validate(prepared.consolidated, {
|
|
79
|
+
op: "update",
|
|
80
|
+
method,
|
|
81
|
+
id,
|
|
82
|
+
current,
|
|
83
|
+
requestedChanges: prepared.requestedChanges,
|
|
84
|
+
changes: prepared.changes,
|
|
85
|
+
consolidated: prepared.consolidated,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { current, ...prepared };
|
|
43
90
|
};
|
|
44
91
|
|
|
45
92
|
return {
|
|
@@ -61,29 +108,20 @@ function createCrudController(entry, service) {
|
|
|
61
108
|
},
|
|
62
109
|
|
|
63
110
|
async create(req, res) {
|
|
64
|
-
const
|
|
65
|
-
await
|
|
66
|
-
op: "create",
|
|
67
|
-
method: "post",
|
|
68
|
-
current: null,
|
|
69
|
-
changes: data,
|
|
70
|
-
consolidated: data,
|
|
71
|
-
});
|
|
72
|
-
const registro = await service.create(data);
|
|
111
|
+
const prepared = await prepareCreate(req.body ?? {}, "post");
|
|
112
|
+
const registro = await service.create(prepared.changes);
|
|
73
113
|
Helpers.sendResponse({ res, statusCode: 201, [entry.singular]: registro });
|
|
74
114
|
},
|
|
75
115
|
|
|
76
116
|
async update(req, res) {
|
|
77
|
-
const
|
|
78
|
-
await
|
|
79
|
-
const registro = await service.update(req.params.id, changes);
|
|
117
|
+
const prepared = await prepareUpdate(req.params.id, req.body ?? {}, "put");
|
|
118
|
+
const registro = await service.update(req.params.id, prepared.changes);
|
|
80
119
|
Helpers.sendResponse({ res, statusCode: 200, [entry.singular]: registro });
|
|
81
120
|
},
|
|
82
121
|
|
|
83
122
|
async patch(req, res) {
|
|
84
|
-
const
|
|
85
|
-
await
|
|
86
|
-
const registro = await service.update(req.params.id, changes);
|
|
123
|
+
const prepared = await prepareUpdate(req.params.id, req.body ?? {}, "patch");
|
|
124
|
+
const registro = await service.update(req.params.id, prepared.changes);
|
|
87
125
|
Helpers.sendResponse({ res, statusCode: 200, [entry.singular]: registro });
|
|
88
126
|
},
|
|
89
127
|
|
|
@@ -93,7 +131,11 @@ function createCrudController(entry, service) {
|
|
|
93
131
|
},
|
|
94
132
|
|
|
95
133
|
async importMany(req, res) {
|
|
96
|
-
const
|
|
134
|
+
const rows = req.body?.rows || req.body;
|
|
135
|
+
const preparedRows = Array.isArray(rows)
|
|
136
|
+
? await Promise.all(rows.map(async (row) => (await prepareCreate(row, "import")).changes))
|
|
137
|
+
: rows;
|
|
138
|
+
const result = await service.importMany(preparedRows);
|
|
97
139
|
Helpers.sendResponse({ res, statusCode: 201, ...result });
|
|
98
140
|
},
|
|
99
141
|
|
|
@@ -104,4 +146,4 @@ function createCrudController(entry, service) {
|
|
|
104
146
|
};
|
|
105
147
|
}
|
|
106
148
|
|
|
107
|
-
module.exports = { createCrudController, toPlainRecord };
|
|
149
|
+
module.exports = { createCrudController, toPlainRecord, hasDomainRules };
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ const registry = require("./core/registry");
|
|
|
16
16
|
const GenericError = require("./core/errors/GenericError");
|
|
17
17
|
const { omie } = require("./modules/omie");
|
|
18
18
|
const domainManifest = require("./manifest/domainManifest");
|
|
19
|
+
const domainRules = require("./manifest/domainRules");
|
|
19
20
|
|
|
20
21
|
module.exports = {
|
|
21
22
|
// boot
|
|
@@ -38,11 +39,15 @@ module.exports = {
|
|
|
38
39
|
DOMAIN_MANIFEST_FILENAME: domainManifest.DOMAIN_MANIFEST_FILENAME,
|
|
39
40
|
DOMAIN_MANIFEST_SCHEMA_VERSION: domainManifest.DOMAIN_MANIFEST_SCHEMA_VERSION,
|
|
40
41
|
SUPPORTED_DOMAIN_FIELD_KINDS: domainManifest.SUPPORTED_FIELD_KINDS,
|
|
42
|
+
DOMAIN_EXPRESSION_OPERATORS: domainRules.EXPRESSION_OPERATORS,
|
|
41
43
|
DomainManifestError: domainManifest.DomainManifestError,
|
|
44
|
+
DomainRuleError: domainRules.DomainRuleError,
|
|
42
45
|
validateDomainManifest: domainManifest.validateDomainManifest,
|
|
43
46
|
domainManifestToDefinitions: domainManifest.domainManifestToDefinitions,
|
|
44
47
|
registerDomainManifest: domainManifest.registerDomainManifest,
|
|
45
48
|
loadDomainManifest: domainManifest.loadDomainManifest,
|
|
49
|
+
evaluateDomainExpression: domainRules.evaluateDomainExpression,
|
|
50
|
+
applyDomainMutation: domainRules.applyDomainMutation,
|
|
46
51
|
|
|
47
52
|
// helpers
|
|
48
53
|
fields,
|
|
@@ -4,6 +4,7 @@ const fs = require("node:fs");
|
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
const { defineModel } = require("../core/define");
|
|
6
6
|
const fields = require("../core/fields");
|
|
7
|
+
const { EXPRESSION_OPERATORS } = require("./domainRules");
|
|
7
8
|
|
|
8
9
|
const DOMAIN_MANIFEST_FILENAME = "central.domain.json";
|
|
9
10
|
const DOMAIN_MANIFEST_SCHEMA_VERSION = 1;
|
|
@@ -19,6 +20,30 @@ const SUPPORTED_FIELD_KINDS = Object.freeze([
|
|
|
19
20
|
"currencyConverted",
|
|
20
21
|
]);
|
|
21
22
|
const SUPPORTED_FIELD_KIND_SET = new Set(SUPPORTED_FIELD_KINDS);
|
|
23
|
+
const EXPRESSION_OPERATOR_SET = new Set(EXPRESSION_OPERATORS);
|
|
24
|
+
const NUMERIC_FIELD_KINDS = new Set(["number", "currency", "currencyConverted"]);
|
|
25
|
+
const EXPRESSION_ARITY = Object.freeze({
|
|
26
|
+
add: { min: 1 },
|
|
27
|
+
subtract: { min: 2 },
|
|
28
|
+
multiply: { min: 1 },
|
|
29
|
+
divide: { min: 2 },
|
|
30
|
+
min: { min: 1 },
|
|
31
|
+
max: { min: 1 },
|
|
32
|
+
abs: { exact: 1 },
|
|
33
|
+
negate: { exact: 1 },
|
|
34
|
+
coalesce: { min: 1 },
|
|
35
|
+
eq: { exact: 2 },
|
|
36
|
+
neq: { exact: 2 },
|
|
37
|
+
gt: { exact: 2 },
|
|
38
|
+
gte: { exact: 2 },
|
|
39
|
+
lt: { exact: 2 },
|
|
40
|
+
lte: { exact: 2 },
|
|
41
|
+
and: { min: 1 },
|
|
42
|
+
or: { min: 1 },
|
|
43
|
+
not: { exact: 1 },
|
|
44
|
+
present: { exact: 1 },
|
|
45
|
+
in: { exact: 2 },
|
|
46
|
+
});
|
|
22
47
|
|
|
23
48
|
class DomainManifestError extends Error {
|
|
24
49
|
constructor(message, issues = [], options = {}) {
|
|
@@ -75,7 +100,105 @@ function validateNumberOption(field, optionName, fieldPath, issues) {
|
|
|
75
100
|
}
|
|
76
101
|
}
|
|
77
102
|
|
|
78
|
-
function
|
|
103
|
+
function expressionKind(expression) {
|
|
104
|
+
const kinds = ["value", "field", "op"].filter((key) => hasOwn(expression, key));
|
|
105
|
+
return kinds.length === 1 ? kinds[0] : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateExpression(expression, expressionPath, issues, fieldNames) {
|
|
109
|
+
if (!isPlainObject(expression)) {
|
|
110
|
+
addIssue(issues, expressionPath, "deve ser um objeto de expressão.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const kind = expressionKind(expression);
|
|
115
|
+
if (!kind) {
|
|
116
|
+
addIssue(issues, expressionPath, "deve declarar exatamente um entre value, field ou op.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (kind === "value") {
|
|
121
|
+
if (!isJsonValue(expression.value)) {
|
|
122
|
+
addIssue(issues, `${expressionPath}.value`, "deve conter somente um valor JSON.");
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (kind === "field") {
|
|
128
|
+
if (typeof expression.field !== "string" || !expression.field.trim()) {
|
|
129
|
+
addIssue(issues, `${expressionPath}.field`, "deve ser texto não vazio.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const rootField = expression.field.split(".")[0];
|
|
133
|
+
if (!fieldNames.has(rootField)) {
|
|
134
|
+
addIssue(issues, `${expressionPath}.field`, `campo não declarado: ${rootField}.`);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!EXPRESSION_OPERATOR_SET.has(expression.op)) {
|
|
140
|
+
addIssue(
|
|
141
|
+
issues,
|
|
142
|
+
`${expressionPath}.op`,
|
|
143
|
+
`operador não suportado. Use: ${EXPRESSION_OPERATORS.join(", ")}.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(expression.args)) {
|
|
147
|
+
addIssue(issues, `${expressionPath}.args`, "deve ser uma lista.");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const arity = EXPRESSION_ARITY[expression.op];
|
|
152
|
+
if (arity?.exact !== undefined && expression.args.length !== arity.exact) {
|
|
153
|
+
addIssue(issues, `${expressionPath}.args`, `deve conter exatamente ${arity.exact} argumento(s).`);
|
|
154
|
+
}
|
|
155
|
+
if (arity?.min !== undefined && expression.args.length < arity.min) {
|
|
156
|
+
addIssue(issues, `${expressionPath}.args`, `deve conter ao menos ${arity.min} argumento(s).`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
expression.args.forEach((arg, index) => {
|
|
160
|
+
validateExpression(arg, `${expressionPath}.args[${index}]`, issues, fieldNames);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function collectExpressionFields(expression, result = new Set()) {
|
|
165
|
+
if (!isPlainObject(expression)) return result;
|
|
166
|
+
if (typeof expression.field === "string") result.add(expression.field.split(".")[0]);
|
|
167
|
+
if (Array.isArray(expression.args)) {
|
|
168
|
+
expression.args.forEach((arg) => collectExpressionFields(arg, result));
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validateComputedField(field, fieldPath, issues, fieldNames) {
|
|
174
|
+
if (!hasOwn(field, "computed")) return;
|
|
175
|
+
if (!isPlainObject(field.computed)) {
|
|
176
|
+
addIssue(issues, `${fieldPath}.computed`, "deve ser um objeto.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!NUMERIC_FIELD_KINDS.has(field.kind)) {
|
|
180
|
+
addIssue(issues, `${fieldPath}.computed`, "é permitido somente em campos number ou monetários.");
|
|
181
|
+
}
|
|
182
|
+
if (!hasOwn(field.computed, "expression")) {
|
|
183
|
+
addIssue(issues, `${fieldPath}.computed.expression`, "é obrigatória.");
|
|
184
|
+
} else {
|
|
185
|
+
validateExpression(field.computed.expression, `${fieldPath}.computed.expression`, issues, fieldNames);
|
|
186
|
+
}
|
|
187
|
+
if (hasOwn(field.computed, "precision")
|
|
188
|
+
&& (!Number.isInteger(field.computed.precision)
|
|
189
|
+
|| field.computed.precision < 0
|
|
190
|
+
|| field.computed.precision > 8)) {
|
|
191
|
+
addIssue(issues, `${fieldPath}.computed.precision`, "deve ser inteiro entre 0 e 8.");
|
|
192
|
+
}
|
|
193
|
+
if (hasOwn(field.computed, "nullAsZero") && typeof field.computed.nullAsZero !== "boolean") {
|
|
194
|
+
addIssue(issues, `${fieldPath}.computed.nullAsZero`, "deve ser booleano.");
|
|
195
|
+
}
|
|
196
|
+
if (field.readonly === false) {
|
|
197
|
+
addIssue(issues, `${fieldPath}.readonly`, "campos calculados são sempre somente leitura.");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validateField(fieldName, field, fieldPath, issues, fieldNames) {
|
|
79
202
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)) {
|
|
80
203
|
addIssue(issues, fieldPath, "o nome do campo deve ser um identificador JavaScript válido.");
|
|
81
204
|
}
|
|
@@ -134,6 +257,96 @@ function validateField(fieldName, field, fieldPath, issues) {
|
|
|
134
257
|
&& (typeof field.base !== "string" || !/^[A-Za-z]{3}$/.test(field.base))) {
|
|
135
258
|
addIssue(issues, `${fieldPath}.base`, "deve ser um código de moeda com três letras.");
|
|
136
259
|
}
|
|
260
|
+
|
|
261
|
+
validateComputedField(field, fieldPath, issues, fieldNames);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function validateModelValidations(model, modelPath, issues, fieldNames) {
|
|
265
|
+
if (!hasOwn(model, "validations")) return;
|
|
266
|
+
if (!Array.isArray(model.validations)) {
|
|
267
|
+
addIssue(issues, `${modelPath}.validations`, "deve ser uma lista.");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const names = new Set();
|
|
272
|
+
model.validations.forEach((validation, index) => {
|
|
273
|
+
const validationPath = `${modelPath}.validations[${index}]`;
|
|
274
|
+
if (!isPlainObject(validation)) {
|
|
275
|
+
addIssue(issues, validationPath, "deve ser um objeto.");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (typeof validation.name !== "string" || !validation.name.trim()) {
|
|
279
|
+
addIssue(issues, `${validationPath}.name`, "é obrigatório e deve ser texto não vazio.");
|
|
280
|
+
} else if (names.has(validation.name)) {
|
|
281
|
+
addIssue(issues, `${validationPath}.name`, `validação duplicada: ${validation.name}.`);
|
|
282
|
+
} else {
|
|
283
|
+
names.add(validation.name);
|
|
284
|
+
}
|
|
285
|
+
if (typeof validation.message !== "string" || !validation.message.trim()) {
|
|
286
|
+
addIssue(issues, `${validationPath}.message`, "é obrigatória e deve ser texto não vazio.");
|
|
287
|
+
}
|
|
288
|
+
if (hasOwn(validation, "code")
|
|
289
|
+
&& (typeof validation.code !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(validation.code))) {
|
|
290
|
+
addIssue(issues, `${validationPath}.code`, "deve usar letras maiúsculas, números e sublinhado.");
|
|
291
|
+
}
|
|
292
|
+
if (hasOwn(validation, "field")) {
|
|
293
|
+
if (typeof validation.field !== "string" || !fieldNames.has(validation.field.split(".")[0])) {
|
|
294
|
+
addIssue(issues, `${validationPath}.field`, "deve apontar para um campo declarado.");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!hasOwn(validation, "assert")) {
|
|
298
|
+
addIssue(issues, `${validationPath}.assert`, "é obrigatória.");
|
|
299
|
+
} else {
|
|
300
|
+
validateExpression(validation.assert, `${validationPath}.assert`, issues, fieldNames);
|
|
301
|
+
}
|
|
302
|
+
if (hasOwn(validation, "when")) {
|
|
303
|
+
validateExpression(validation.when, `${validationPath}.when`, issues, fieldNames);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function computedDependencies(model) {
|
|
309
|
+
const result = new Map();
|
|
310
|
+
const computedNames = new Set(
|
|
311
|
+
Object.entries(model.fields)
|
|
312
|
+
.filter(([, field]) => isPlainObject(field?.computed))
|
|
313
|
+
.map(([fieldName]) => fieldName),
|
|
314
|
+
);
|
|
315
|
+
for (const [fieldName, field] of Object.entries(model.fields)) {
|
|
316
|
+
if (!isPlainObject(field?.computed)) continue;
|
|
317
|
+
const dependencies = [...collectExpressionFields(field.computed.expression)]
|
|
318
|
+
.filter((dependency) => computedNames.has(dependency));
|
|
319
|
+
result.set(fieldName, dependencies);
|
|
320
|
+
}
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function validateComputedCycles(model, modelPath, issues) {
|
|
325
|
+
const dependencies = computedDependencies(model);
|
|
326
|
+
const visited = new Set();
|
|
327
|
+
const visiting = new Set();
|
|
328
|
+
|
|
329
|
+
function visit(fieldName, trail = []) {
|
|
330
|
+
if (visited.has(fieldName)) return;
|
|
331
|
+
if (visiting.has(fieldName)) {
|
|
332
|
+
const cycleStart = trail.indexOf(fieldName);
|
|
333
|
+
const cycle = [...trail.slice(cycleStart), fieldName];
|
|
334
|
+
addIssue(
|
|
335
|
+
issues,
|
|
336
|
+
`${modelPath}.fields.${fieldName}.computed.expression`,
|
|
337
|
+
`dependência circular entre campos calculados: ${cycle.join(" -> ")}.`,
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
visiting.add(fieldName);
|
|
342
|
+
for (const dependency of dependencies.get(fieldName) || []) {
|
|
343
|
+
visit(dependency, [...trail, fieldName]);
|
|
344
|
+
}
|
|
345
|
+
visiting.delete(fieldName);
|
|
346
|
+
visited.add(fieldName);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const fieldName of dependencies.keys()) visit(fieldName);
|
|
137
350
|
}
|
|
138
351
|
|
|
139
352
|
function validateModel(model, index, issues, modelNames) {
|
|
@@ -167,9 +380,12 @@ function validateModel(model, index, issues, modelNames) {
|
|
|
167
380
|
return;
|
|
168
381
|
}
|
|
169
382
|
|
|
383
|
+
const fieldNames = new Set(Object.keys(model.fields));
|
|
170
384
|
for (const [fieldName, field] of Object.entries(model.fields)) {
|
|
171
|
-
validateField(fieldName, field, `${modelPath}.fields.${fieldName}`, issues);
|
|
385
|
+
validateField(fieldName, field, `${modelPath}.fields.${fieldName}`, issues, fieldNames);
|
|
172
386
|
}
|
|
387
|
+
validateModelValidations(model, modelPath, issues, fieldNames);
|
|
388
|
+
validateComputedCycles(model, modelPath, issues);
|
|
173
389
|
}
|
|
174
390
|
|
|
175
391
|
function validateDomainManifest(input) {
|
|
@@ -258,7 +474,7 @@ function createFieldDescriptor(field) {
|
|
|
258
474
|
]);
|
|
259
475
|
}
|
|
260
476
|
|
|
261
|
-
if (field.readonly) descriptor.immutable = true;
|
|
477
|
+
if (field.readonly || field.computed) descriptor.immutable = true;
|
|
262
478
|
if (field.unique) descriptor.unique = true;
|
|
263
479
|
if (field.index) descriptor.index = true;
|
|
264
480
|
if (typeof field.min === "number") descriptor.min = field.min;
|
|
@@ -269,13 +485,46 @@ function createFieldDescriptor(field) {
|
|
|
269
485
|
descriptor.__meta = {
|
|
270
486
|
...descriptor.__meta,
|
|
271
487
|
description: field.description,
|
|
272
|
-
readonly: Boolean(field.readonly),
|
|
488
|
+
readonly: Boolean(field.readonly || field.computed),
|
|
489
|
+
computed: field.computed || undefined,
|
|
273
490
|
unique: Boolean(field.unique),
|
|
274
491
|
index: Boolean(field.index),
|
|
275
492
|
};
|
|
276
493
|
return descriptor;
|
|
277
494
|
}
|
|
278
495
|
|
|
496
|
+
function orderComputedFields(model) {
|
|
497
|
+
const dependencies = computedDependencies(model);
|
|
498
|
+
const ordered = [];
|
|
499
|
+
const visited = new Set();
|
|
500
|
+
|
|
501
|
+
function visit(fieldName) {
|
|
502
|
+
if (visited.has(fieldName)) return;
|
|
503
|
+
for (const dependency of dependencies.get(fieldName) || []) visit(dependency);
|
|
504
|
+
visited.add(fieldName);
|
|
505
|
+
const computed = model.fields[fieldName].computed;
|
|
506
|
+
ordered.push({
|
|
507
|
+
field: fieldName,
|
|
508
|
+
expression: computed.expression,
|
|
509
|
+
precision: computed.precision ?? 2,
|
|
510
|
+
nullAsZero: computed.nullAsZero !== false,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for (const fieldName of dependencies.keys()) visit(fieldName);
|
|
515
|
+
return ordered;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function createDomainRules(model) {
|
|
519
|
+
return {
|
|
520
|
+
readonlyFields: Object.entries(model.fields)
|
|
521
|
+
.filter(([, field]) => Boolean(field.readonly || field.computed))
|
|
522
|
+
.map(([fieldName]) => fieldName),
|
|
523
|
+
computedFields: orderComputedFields(model),
|
|
524
|
+
validations: model.validations || [],
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
279
528
|
function domainManifestToDefinitions(input) {
|
|
280
529
|
const manifest = validateDomainManifest(input);
|
|
281
530
|
return manifest.models.map((model) => ({
|
|
@@ -284,6 +533,7 @@ function domainManifestToDefinitions(input) {
|
|
|
284
533
|
basePath: model.basePath,
|
|
285
534
|
crud: model.crud,
|
|
286
535
|
options: model.options,
|
|
536
|
+
domainRules: createDomainRules(model),
|
|
287
537
|
schema: Object.fromEntries(
|
|
288
538
|
Object.entries(model.fields).map(([fieldName, field]) => [
|
|
289
539
|
fieldName,
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const GenericError = require("../core/errors/GenericError");
|
|
4
|
+
|
|
5
|
+
const EXPRESSION_OPERATORS = Object.freeze([
|
|
6
|
+
"add",
|
|
7
|
+
"subtract",
|
|
8
|
+
"multiply",
|
|
9
|
+
"divide",
|
|
10
|
+
"min",
|
|
11
|
+
"max",
|
|
12
|
+
"abs",
|
|
13
|
+
"negate",
|
|
14
|
+
"coalesce",
|
|
15
|
+
"eq",
|
|
16
|
+
"neq",
|
|
17
|
+
"gt",
|
|
18
|
+
"gte",
|
|
19
|
+
"lt",
|
|
20
|
+
"lte",
|
|
21
|
+
"and",
|
|
22
|
+
"or",
|
|
23
|
+
"not",
|
|
24
|
+
"present",
|
|
25
|
+
"in",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
class DomainRuleError extends GenericError {
|
|
29
|
+
constructor(message, options = {}) {
|
|
30
|
+
const field = options.field || null;
|
|
31
|
+
const rule = options.rule || null;
|
|
32
|
+
super(message, {
|
|
33
|
+
statusCode: 422,
|
|
34
|
+
details: {
|
|
35
|
+
field,
|
|
36
|
+
message,
|
|
37
|
+
rule,
|
|
38
|
+
...(options.details || {}),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
this.name = "DomainRuleError";
|
|
42
|
+
this.code = options.code || "OON_DOMAIN_RULE_VIOLATION";
|
|
43
|
+
this.field = field;
|
|
44
|
+
this.rule = rule;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasOwn(object, key) {
|
|
49
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getFieldValue(record, fieldPath) {
|
|
53
|
+
if (!record || !fieldPath) return undefined;
|
|
54
|
+
const parts = String(fieldPath).split(".");
|
|
55
|
+
let current = record;
|
|
56
|
+
for (const part of parts) {
|
|
57
|
+
if (["__proto__", "prototype", "constructor"].includes(part)) return undefined;
|
|
58
|
+
if (current === null || current === undefined || typeof current !== "object") return undefined;
|
|
59
|
+
current = current[part];
|
|
60
|
+
}
|
|
61
|
+
return current;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isEmpty(value) {
|
|
65
|
+
return value === null || value === undefined || (typeof value === "string" && !value.trim());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toNumber(value, context = {}) {
|
|
69
|
+
if (isEmpty(value)) {
|
|
70
|
+
return context.nullAsZero ? 0 : null;
|
|
71
|
+
}
|
|
72
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
73
|
+
if (!Number.isFinite(numeric)) {
|
|
74
|
+
throw new DomainRuleError(
|
|
75
|
+
`A fórmula do campo ${context.field || "calculado"} recebeu um valor não numérico.`,
|
|
76
|
+
{
|
|
77
|
+
code: "OON_DOMAIN_FORMULA_ERROR",
|
|
78
|
+
field: context.field,
|
|
79
|
+
rule: context.rule,
|
|
80
|
+
details: { value },
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return numeric;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function numericArguments(args, record, context) {
|
|
88
|
+
const values = args.map((arg) => evaluateDomainExpression(arg, record, context));
|
|
89
|
+
if (!context.nullAsZero && values.some((value) => isEmpty(value))) return null;
|
|
90
|
+
return values.map((value) => toNumber(value, context));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function compareValues(left, right, operator) {
|
|
94
|
+
if (["gt", "gte", "lt", "lte"].includes(operator)) {
|
|
95
|
+
const leftNumber = Number(left);
|
|
96
|
+
const rightNumber = Number(right);
|
|
97
|
+
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
|
|
98
|
+
left = leftNumber;
|
|
99
|
+
right = rightNumber;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
switch (operator) {
|
|
104
|
+
case "eq": return Object.is(left, right);
|
|
105
|
+
case "neq": return !Object.is(left, right);
|
|
106
|
+
case "gt": return left > right;
|
|
107
|
+
case "gte": return left >= right;
|
|
108
|
+
case "lt": return left < right;
|
|
109
|
+
case "lte": return left <= right;
|
|
110
|
+
default: return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function evaluateDomainExpression(expression, record = {}, context = {}) {
|
|
115
|
+
if (!expression || typeof expression !== "object" || Array.isArray(expression)) {
|
|
116
|
+
throw new DomainRuleError("Expressão declarativa inválida.", {
|
|
117
|
+
code: "OON_DOMAIN_EXPRESSION_INVALID",
|
|
118
|
+
field: context.field,
|
|
119
|
+
rule: context.rule,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (hasOwn(expression, "value")) return expression.value;
|
|
124
|
+
if (hasOwn(expression, "field")) return getFieldValue(record, expression.field);
|
|
125
|
+
|
|
126
|
+
const operator = expression.op;
|
|
127
|
+
const args = Array.isArray(expression.args) ? expression.args : [];
|
|
128
|
+
|
|
129
|
+
switch (operator) {
|
|
130
|
+
case "add": {
|
|
131
|
+
const values = numericArguments(args, record, context);
|
|
132
|
+
return values === null ? null : values.reduce((total, value) => total + value, 0);
|
|
133
|
+
}
|
|
134
|
+
case "subtract": {
|
|
135
|
+
const values = numericArguments(args, record, context);
|
|
136
|
+
return values === null ? null : values.slice(1).reduce((total, value) => total - value, values[0]);
|
|
137
|
+
}
|
|
138
|
+
case "multiply": {
|
|
139
|
+
const values = numericArguments(args, record, context);
|
|
140
|
+
return values === null ? null : values.reduce((total, value) => total * value, 1);
|
|
141
|
+
}
|
|
142
|
+
case "divide": {
|
|
143
|
+
const values = numericArguments(args, record, context);
|
|
144
|
+
if (values === null) return null;
|
|
145
|
+
return values.slice(1).reduce((total, divisor) => {
|
|
146
|
+
if (divisor === 0) {
|
|
147
|
+
throw new DomainRuleError(
|
|
148
|
+
`A fórmula do campo ${context.field || "calculado"} tentou dividir por zero.`,
|
|
149
|
+
{
|
|
150
|
+
code: "OON_DOMAIN_FORMULA_ERROR",
|
|
151
|
+
field: context.field,
|
|
152
|
+
rule: context.rule,
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return total / divisor;
|
|
157
|
+
}, values[0]);
|
|
158
|
+
}
|
|
159
|
+
case "min": {
|
|
160
|
+
const values = numericArguments(args, record, context);
|
|
161
|
+
return values === null ? null : Math.min(...values);
|
|
162
|
+
}
|
|
163
|
+
case "max": {
|
|
164
|
+
const values = numericArguments(args, record, context);
|
|
165
|
+
return values === null ? null : Math.max(...values);
|
|
166
|
+
}
|
|
167
|
+
case "abs": {
|
|
168
|
+
const value = numericArguments(args, record, context);
|
|
169
|
+
return value === null ? null : Math.abs(value[0]);
|
|
170
|
+
}
|
|
171
|
+
case "negate": {
|
|
172
|
+
const value = numericArguments(args, record, context);
|
|
173
|
+
return value === null ? null : -value[0];
|
|
174
|
+
}
|
|
175
|
+
case "coalesce": {
|
|
176
|
+
for (const arg of args) {
|
|
177
|
+
const value = evaluateDomainExpression(arg, record, context);
|
|
178
|
+
if (!isEmpty(value)) return value;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
case "eq":
|
|
183
|
+
case "neq":
|
|
184
|
+
case "gt":
|
|
185
|
+
case "gte":
|
|
186
|
+
case "lt":
|
|
187
|
+
case "lte": {
|
|
188
|
+
const left = evaluateDomainExpression(args[0], record, context);
|
|
189
|
+
const right = evaluateDomainExpression(args[1], record, context);
|
|
190
|
+
return compareValues(left, right, operator);
|
|
191
|
+
}
|
|
192
|
+
case "and":
|
|
193
|
+
return args.every((arg) => Boolean(evaluateDomainExpression(arg, record, context)));
|
|
194
|
+
case "or":
|
|
195
|
+
return args.some((arg) => Boolean(evaluateDomainExpression(arg, record, context)));
|
|
196
|
+
case "not":
|
|
197
|
+
return !Boolean(evaluateDomainExpression(args[0], record, context));
|
|
198
|
+
case "present":
|
|
199
|
+
return !isEmpty(evaluateDomainExpression(args[0], record, context));
|
|
200
|
+
case "in": {
|
|
201
|
+
const value = evaluateDomainExpression(args[0], record, context);
|
|
202
|
+
const list = evaluateDomainExpression(args[1], record, context);
|
|
203
|
+
return Array.isArray(list) && list.some((item) => Object.is(item, value));
|
|
204
|
+
}
|
|
205
|
+
default:
|
|
206
|
+
throw new DomainRuleError(`Operador declarativo não suportado: ${String(operator)}.`, {
|
|
207
|
+
code: "OON_DOMAIN_EXPRESSION_INVALID",
|
|
208
|
+
field: context.field,
|
|
209
|
+
rule: context.rule,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function roundValue(value, precision) {
|
|
215
|
+
if (value === null || value === undefined) return value;
|
|
216
|
+
const numeric = toNumber(value);
|
|
217
|
+
const factor = 10 ** Number(precision || 0);
|
|
218
|
+
return Math.round((numeric + Number.EPSILON) * factor) / factor;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function valuesEquivalent(left, right) {
|
|
222
|
+
if (Object.is(left, right)) return true;
|
|
223
|
+
if (isEmpty(left) && isEmpty(right)) return true;
|
|
224
|
+
const leftNumber = Number(left);
|
|
225
|
+
const rightNumber = Number(right);
|
|
226
|
+
return Number.isFinite(leftNumber)
|
|
227
|
+
&& Number.isFinite(rightNumber)
|
|
228
|
+
&& Object.is(leftNumber, rightNumber);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readonlyViolation(field, rule) {
|
|
232
|
+
throw new DomainRuleError(`O campo ${field} é somente leitura e não pode ser alterado.`, {
|
|
233
|
+
code: "OON_DOMAIN_READONLY_FIELD",
|
|
234
|
+
field,
|
|
235
|
+
rule,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function applyDomainMutation({ rules, current = null, changes = {}, op = "update" } = {}) {
|
|
240
|
+
const requestedChanges = changes && typeof changes === "object" && !Array.isArray(changes)
|
|
241
|
+
? { ...changes }
|
|
242
|
+
: {};
|
|
243
|
+
const sanitizedChanges = { ...requestedChanges };
|
|
244
|
+
const currentRecord = current && typeof current === "object" ? { ...current } : {};
|
|
245
|
+
const domainRules = rules || {};
|
|
246
|
+
const computedFields = Array.isArray(domainRules.computedFields)
|
|
247
|
+
? domainRules.computedFields
|
|
248
|
+
: [];
|
|
249
|
+
const computedNames = new Set(computedFields.map((item) => item.field));
|
|
250
|
+
|
|
251
|
+
for (const field of domainRules.readonlyFields || []) {
|
|
252
|
+
if (!hasOwn(requestedChanges, field) || computedNames.has(field)) continue;
|
|
253
|
+
if (op === "update" && valuesEquivalent(requestedChanges[field], currentRecord[field])) {
|
|
254
|
+
delete sanitizedChanges[field];
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
readonlyViolation(field, "readonly");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const computed of computedFields) {
|
|
261
|
+
delete sanitizedChanges[computed.field];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const consolidated = { ...currentRecord, ...sanitizedChanges };
|
|
265
|
+
|
|
266
|
+
for (const computed of computedFields) {
|
|
267
|
+
const value = evaluateDomainExpression(computed.expression, consolidated, {
|
|
268
|
+
field: computed.field,
|
|
269
|
+
rule: "computed",
|
|
270
|
+
nullAsZero: Boolean(computed.nullAsZero),
|
|
271
|
+
});
|
|
272
|
+
const normalized = value === null || value === undefined
|
|
273
|
+
? value
|
|
274
|
+
: roundValue(value, computed.precision);
|
|
275
|
+
|
|
276
|
+
if (hasOwn(requestedChanges, computed.field)
|
|
277
|
+
&& !valuesEquivalent(requestedChanges[computed.field], normalized)) {
|
|
278
|
+
readonlyViolation(computed.field, "computed");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
consolidated[computed.field] = normalized;
|
|
282
|
+
sanitizedChanges[computed.field] = normalized;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const validation of domainRules.validations || []) {
|
|
286
|
+
const context = {
|
|
287
|
+
field: validation.field,
|
|
288
|
+
rule: validation.name || validation.code || "validation",
|
|
289
|
+
};
|
|
290
|
+
if (validation.when
|
|
291
|
+
&& !Boolean(evaluateDomainExpression(validation.when, consolidated, context))) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (!Boolean(evaluateDomainExpression(validation.assert, consolidated, context))) {
|
|
295
|
+
throw new DomainRuleError(validation.message, {
|
|
296
|
+
code: validation.code || "OON_DOMAIN_VALIDATION_FAILED",
|
|
297
|
+
field: validation.field,
|
|
298
|
+
rule: validation.name,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
changes: sanitizedChanges,
|
|
305
|
+
consolidated,
|
|
306
|
+
requestedChanges,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
EXPRESSION_OPERATORS,
|
|
312
|
+
DomainRuleError,
|
|
313
|
+
evaluateDomainExpression,
|
|
314
|
+
applyDomainMutation,
|
|
315
|
+
getFieldValue,
|
|
316
|
+
roundValue,
|
|
317
|
+
valuesEquivalent,
|
|
318
|
+
};
|