@oondemand/oon-core-back 0.3.39 → 0.3.41
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/boot/loadCentral.js +15 -1
- package/src/core/factories/crudController.js +75 -33
- package/src/index.js +16 -0
- package/src/manifest/domainManifest.js +585 -0
- package/src/manifest/domainRules.js +318 -0
package/package.json
CHANGED
package/src/boot/loadCentral.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
3
|
const ctx = require("../core/context");
|
|
4
|
+
const {
|
|
5
|
+
DOMAIN_MANIFEST_FILENAME,
|
|
6
|
+
loadDomainManifest,
|
|
7
|
+
} = require("../manifest/domainManifest");
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Descobre e carrega a Central do consumidor:
|
|
7
11
|
* 1. `central.config.js` na raiz do projeto (mescla na config do Core);
|
|
8
|
-
* 2.
|
|
12
|
+
* 2. `central.domain.json` na raiz (registra models declarativas);
|
|
13
|
+
* 3. arquivos de domínio em `src/{models,validations,triggers,routes,
|
|
9
14
|
* pipelines,documents,integrations,mappings}` (apenas `require`,
|
|
10
15
|
* o efeito colateral é registrar via `define*`).
|
|
16
|
+
*
|
|
17
|
+
* O carregamento dos diretórios JavaScript permanece ativo durante a migração.
|
|
18
|
+
* Declarar a mesma model no manifesto e em `src/models` falha explicitamente no
|
|
19
|
+
* registry, impedindo duas fontes de verdade silenciosas.
|
|
11
20
|
*/
|
|
12
21
|
const DOMAIN_DIRS = [
|
|
13
22
|
"models",
|
|
@@ -28,6 +37,11 @@ function loadCentral(cwd = process.cwd()) {
|
|
|
28
37
|
ctx.setConfig(typeof config === "function" ? config() : config);
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
const domainManifestPath = path.join(cwd, DOMAIN_MANIFEST_FILENAME);
|
|
41
|
+
if (fs.existsSync(domainManifestPath)) {
|
|
42
|
+
loadDomainManifest(domainManifestPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
const srcDir = path.join(cwd, "src");
|
|
32
46
|
for (const dir of DOMAIN_DIRS) {
|
|
33
47
|
const full = path.join(srcDir, dir);
|
|
@@ -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
|
@@ -15,6 +15,8 @@ const fields = require("./core/fields");
|
|
|
15
15
|
const registry = require("./core/registry");
|
|
16
16
|
const GenericError = require("./core/errors/GenericError");
|
|
17
17
|
const { omie } = require("./modules/omie");
|
|
18
|
+
const domainManifest = require("./manifest/domainManifest");
|
|
19
|
+
const domainRules = require("./manifest/domainRules");
|
|
18
20
|
|
|
19
21
|
module.exports = {
|
|
20
22
|
// boot
|
|
@@ -33,6 +35,20 @@ module.exports = {
|
|
|
33
35
|
defineValidation: define.defineValidation,
|
|
34
36
|
defineTrigger: define.defineTrigger,
|
|
35
37
|
|
|
38
|
+
// manifesto declarativo de domínio
|
|
39
|
+
DOMAIN_MANIFEST_FILENAME: domainManifest.DOMAIN_MANIFEST_FILENAME,
|
|
40
|
+
DOMAIN_MANIFEST_SCHEMA_VERSION: domainManifest.DOMAIN_MANIFEST_SCHEMA_VERSION,
|
|
41
|
+
SUPPORTED_DOMAIN_FIELD_KINDS: domainManifest.SUPPORTED_FIELD_KINDS,
|
|
42
|
+
DOMAIN_EXPRESSION_OPERATORS: domainRules.EXPRESSION_OPERATORS,
|
|
43
|
+
DomainManifestError: domainManifest.DomainManifestError,
|
|
44
|
+
DomainRuleError: domainRules.DomainRuleError,
|
|
45
|
+
validateDomainManifest: domainManifest.validateDomainManifest,
|
|
46
|
+
domainManifestToDefinitions: domainManifest.domainManifestToDefinitions,
|
|
47
|
+
registerDomainManifest: domainManifest.registerDomainManifest,
|
|
48
|
+
loadDomainManifest: domainManifest.loadDomainManifest,
|
|
49
|
+
evaluateDomainExpression: domainRules.evaluateDomainExpression,
|
|
50
|
+
applyDomainMutation: domainRules.applyDomainMutation,
|
|
51
|
+
|
|
36
52
|
// helpers
|
|
37
53
|
fields,
|
|
38
54
|
omie,
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { defineModel } = require("../core/define");
|
|
6
|
+
const fields = require("../core/fields");
|
|
7
|
+
const { EXPRESSION_OPERATORS } = require("./domainRules");
|
|
8
|
+
|
|
9
|
+
const DOMAIN_MANIFEST_FILENAME = "central.domain.json";
|
|
10
|
+
const DOMAIN_MANIFEST_SCHEMA_VERSION = 1;
|
|
11
|
+
const SUPPORTED_FIELD_KINDS = Object.freeze([
|
|
12
|
+
"string",
|
|
13
|
+
"number",
|
|
14
|
+
"boolean",
|
|
15
|
+
"date",
|
|
16
|
+
"ref",
|
|
17
|
+
"enum",
|
|
18
|
+
"currency",
|
|
19
|
+
"currencyCode",
|
|
20
|
+
"currencyConverted",
|
|
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
|
+
});
|
|
47
|
+
|
|
48
|
+
class DomainManifestError extends Error {
|
|
49
|
+
constructor(message, issues = [], options = {}) {
|
|
50
|
+
super(message, options);
|
|
51
|
+
this.name = "DomainManifestError";
|
|
52
|
+
this.code = "OON_DOMAIN_MANIFEST_INVALID";
|
|
53
|
+
this.statusCode = 422;
|
|
54
|
+
this.issues = issues;
|
|
55
|
+
this.details = { issues };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isPlainObject(value) {
|
|
60
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasOwn(object, key) {
|
|
64
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function addIssue(issues, fieldPath, message) {
|
|
68
|
+
issues.push({ path: fieldPath, message });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isJsonValue(value, seen = new Set()) {
|
|
72
|
+
if (value === null) return true;
|
|
73
|
+
if (["string", "number", "boolean"].includes(typeof value)) {
|
|
74
|
+
return typeof value !== "number" || Number.isFinite(value);
|
|
75
|
+
}
|
|
76
|
+
if (typeof value !== "object" || seen.has(value)) return false;
|
|
77
|
+
|
|
78
|
+
seen.add(value);
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
const valid = value.every((item) => isJsonValue(item, seen));
|
|
81
|
+
seen.delete(value);
|
|
82
|
+
return valid;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const valid = Object.values(value).every((item) => isJsonValue(item, seen));
|
|
86
|
+
seen.delete(value);
|
|
87
|
+
return valid;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateBooleanOption(field, optionName, fieldPath, issues) {
|
|
91
|
+
if (hasOwn(field, optionName) && typeof field[optionName] !== "boolean") {
|
|
92
|
+
addIssue(issues, `${fieldPath}.${optionName}`, "deve ser booleano.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateNumberOption(field, optionName, fieldPath, issues) {
|
|
97
|
+
if (hasOwn(field, optionName)
|
|
98
|
+
&& (typeof field[optionName] !== "number" || !Number.isFinite(field[optionName]))) {
|
|
99
|
+
addIssue(issues, `${fieldPath}.${optionName}`, "deve ser um número finito.");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
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) {
|
|
202
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)) {
|
|
203
|
+
addIssue(issues, fieldPath, "o nome do campo deve ser um identificador JavaScript válido.");
|
|
204
|
+
}
|
|
205
|
+
if (!isPlainObject(field)) {
|
|
206
|
+
addIssue(issues, fieldPath, "deve ser um objeto.");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!SUPPORTED_FIELD_KIND_SET.has(field.kind)) {
|
|
210
|
+
addIssue(
|
|
211
|
+
issues,
|
|
212
|
+
`${fieldPath}.kind`,
|
|
213
|
+
`tipo não suportado. Use: ${SUPPORTED_FIELD_KINDS.join(", ")}.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (hasOwn(field, "label") && typeof field.label !== "string") {
|
|
217
|
+
addIssue(issues, `${fieldPath}.label`, "deve ser texto.");
|
|
218
|
+
}
|
|
219
|
+
if (hasOwn(field, "description") && typeof field.description !== "string") {
|
|
220
|
+
addIssue(issues, `${fieldPath}.description`, "deve ser texto.");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const optionName of ["required", "readonly", "searchable", "unique", "index"]) {
|
|
224
|
+
validateBooleanOption(field, optionName, fieldPath, issues);
|
|
225
|
+
}
|
|
226
|
+
for (const optionName of ["min", "max", "minLength", "maxLength"]) {
|
|
227
|
+
validateNumberOption(field, optionName, fieldPath, issues);
|
|
228
|
+
}
|
|
229
|
+
if (typeof field.min === "number" && typeof field.max === "number" && field.min > field.max) {
|
|
230
|
+
addIssue(issues, fieldPath, "min não pode ser maior que max.");
|
|
231
|
+
}
|
|
232
|
+
if (typeof field.minLength === "number"
|
|
233
|
+
&& typeof field.maxLength === "number"
|
|
234
|
+
&& field.minLength > field.maxLength) {
|
|
235
|
+
addIssue(issues, fieldPath, "minLength não pode ser maior que maxLength.");
|
|
236
|
+
}
|
|
237
|
+
if (hasOwn(field, "default") && !isJsonValue(field.default)) {
|
|
238
|
+
addIssue(issues, `${fieldPath}.default`, "deve conter somente um valor JSON.");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (field.kind === "ref" && (typeof field.ref !== "string" || !field.ref.trim())) {
|
|
242
|
+
addIssue(issues, `${fieldPath}.ref`, "é obrigatório para campos ref.");
|
|
243
|
+
}
|
|
244
|
+
if (field.kind === "enum") {
|
|
245
|
+
if (!Array.isArray(field.values) || field.values.length === 0) {
|
|
246
|
+
addIssue(issues, `${fieldPath}.values`, "deve ser uma lista não vazia para campos enum.");
|
|
247
|
+
} else {
|
|
248
|
+
const invalid = field.values.some((value) => typeof value !== "string" || !value.length);
|
|
249
|
+
if (invalid) addIssue(issues, `${fieldPath}.values`, "deve conter somente textos não vazios.");
|
|
250
|
+
if (new Set(field.values).size !== field.values.length) {
|
|
251
|
+
addIssue(issues, `${fieldPath}.values`, "não pode conter valores duplicados.");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (field.kind === "currencyConverted"
|
|
256
|
+
&& hasOwn(field, "base")
|
|
257
|
+
&& (typeof field.base !== "string" || !/^[A-Za-z]{3}$/.test(field.base))) {
|
|
258
|
+
addIssue(issues, `${fieldPath}.base`, "deve ser um código de moeda com três letras.");
|
|
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);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function validateModel(model, index, issues, modelNames) {
|
|
353
|
+
const modelPath = `models[${index}]`;
|
|
354
|
+
if (!isPlainObject(model)) {
|
|
355
|
+
addIssue(issues, modelPath, "deve ser um objeto.");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (typeof model.name !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(model.name)) {
|
|
359
|
+
addIssue(issues, `${modelPath}.name`, "deve começar com letra maiúscula e conter apenas letras e números.");
|
|
360
|
+
} else if (modelNames.has(model.name)) {
|
|
361
|
+
addIssue(issues, `${modelPath}.name`, `model duplicada: ${model.name}.`);
|
|
362
|
+
} else {
|
|
363
|
+
modelNames.add(model.name);
|
|
364
|
+
}
|
|
365
|
+
if (hasOwn(model, "singular") && (typeof model.singular !== "string" || !model.singular.trim())) {
|
|
366
|
+
addIssue(issues, `${modelPath}.singular`, "deve ser texto não vazio.");
|
|
367
|
+
}
|
|
368
|
+
if (hasOwn(model, "basePath")
|
|
369
|
+
&& (typeof model.basePath !== "string" || !/^\/[a-z0-9][a-z0-9/_-]*$/i.test(model.basePath))) {
|
|
370
|
+
addIssue(issues, `${modelPath}.basePath`, "deve ser um caminho iniciado por /.");
|
|
371
|
+
}
|
|
372
|
+
if (hasOwn(model, "crud") && !isPlainObject(model.crud)) {
|
|
373
|
+
addIssue(issues, `${modelPath}.crud`, "deve ser um objeto.");
|
|
374
|
+
}
|
|
375
|
+
if (hasOwn(model, "options") && !isPlainObject(model.options)) {
|
|
376
|
+
addIssue(issues, `${modelPath}.options`, "deve ser um objeto.");
|
|
377
|
+
}
|
|
378
|
+
if (!isPlainObject(model.fields) || Object.keys(model.fields).length === 0) {
|
|
379
|
+
addIssue(issues, `${modelPath}.fields`, "deve ser um objeto com pelo menos um campo.");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const fieldNames = new Set(Object.keys(model.fields));
|
|
384
|
+
for (const [fieldName, field] of Object.entries(model.fields)) {
|
|
385
|
+
validateField(fieldName, field, `${modelPath}.fields.${fieldName}`, issues, fieldNames);
|
|
386
|
+
}
|
|
387
|
+
validateModelValidations(model, modelPath, issues, fieldNames);
|
|
388
|
+
validateComputedCycles(model, modelPath, issues);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function validateDomainManifest(input) {
|
|
392
|
+
const issues = [];
|
|
393
|
+
if (!isPlainObject(input)) {
|
|
394
|
+
throw new DomainManifestError("Manifesto de domínio inválido.", [
|
|
395
|
+
{ path: "$", message: "o documento deve ser um objeto JSON." },
|
|
396
|
+
]);
|
|
397
|
+
}
|
|
398
|
+
if (input.schemaVersion !== DOMAIN_MANIFEST_SCHEMA_VERSION) {
|
|
399
|
+
addIssue(
|
|
400
|
+
issues,
|
|
401
|
+
"schemaVersion",
|
|
402
|
+
`versão não suportada. Use ${DOMAIN_MANIFEST_SCHEMA_VERSION}.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (typeof input.name !== "string" || !input.name.trim()) {
|
|
406
|
+
addIssue(issues, "name", "é obrigatório e deve ser texto não vazio.");
|
|
407
|
+
}
|
|
408
|
+
if (hasOwn(input, "slug")
|
|
409
|
+
&& (typeof input.slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.slug))) {
|
|
410
|
+
addIssue(issues, "slug", "deve usar letras minúsculas, números e hífens.");
|
|
411
|
+
}
|
|
412
|
+
if (!Array.isArray(input.models) || input.models.length === 0) {
|
|
413
|
+
addIssue(issues, "models", "deve ser uma lista com pelo menos uma model.");
|
|
414
|
+
} else {
|
|
415
|
+
const modelNames = new Set();
|
|
416
|
+
input.models.forEach((model, index) => validateModel(model, index, issues, modelNames));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (issues.length) {
|
|
420
|
+
throw new DomainManifestError(
|
|
421
|
+
`Manifesto de domínio inválido: ${issues.length} problema(s) encontrado(s).`,
|
|
422
|
+
issues,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return JSON.parse(JSON.stringify(input));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function baseFieldOptions(field) {
|
|
430
|
+
const options = {
|
|
431
|
+
required: field.required,
|
|
432
|
+
label: field.label,
|
|
433
|
+
searchable: field.searchable,
|
|
434
|
+
};
|
|
435
|
+
if (hasOwn(field, "default")) options.default = field.default;
|
|
436
|
+
return options;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function createFieldDescriptor(field) {
|
|
440
|
+
const options = baseFieldOptions(field);
|
|
441
|
+
let descriptor;
|
|
442
|
+
|
|
443
|
+
switch (field.kind) {
|
|
444
|
+
case "string":
|
|
445
|
+
descriptor = fields.string(options);
|
|
446
|
+
break;
|
|
447
|
+
case "number":
|
|
448
|
+
descriptor = fields.number(options);
|
|
449
|
+
break;
|
|
450
|
+
case "boolean":
|
|
451
|
+
descriptor = fields.boolean(options);
|
|
452
|
+
break;
|
|
453
|
+
case "date":
|
|
454
|
+
descriptor = fields.date(options);
|
|
455
|
+
break;
|
|
456
|
+
case "ref":
|
|
457
|
+
descriptor = fields.ref(field.ref, options);
|
|
458
|
+
break;
|
|
459
|
+
case "enum":
|
|
460
|
+
descriptor = fields.enum(field.values, options);
|
|
461
|
+
break;
|
|
462
|
+
case "currency":
|
|
463
|
+
descriptor = fields.currency(options);
|
|
464
|
+
break;
|
|
465
|
+
case "currencyCode":
|
|
466
|
+
descriptor = fields.currencyCode(options);
|
|
467
|
+
break;
|
|
468
|
+
case "currencyConverted":
|
|
469
|
+
descriptor = fields.currencyConverted({ ...options, base: field.base });
|
|
470
|
+
break;
|
|
471
|
+
default:
|
|
472
|
+
throw new DomainManifestError("Tipo de campo não suportado após validação.", [
|
|
473
|
+
{ path: "kind", message: String(field.kind) },
|
|
474
|
+
]);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (field.readonly || field.computed) descriptor.immutable = true;
|
|
478
|
+
if (field.unique) descriptor.unique = true;
|
|
479
|
+
if (field.index) descriptor.index = true;
|
|
480
|
+
if (typeof field.min === "number") descriptor.min = field.min;
|
|
481
|
+
if (typeof field.max === "number") descriptor.max = field.max;
|
|
482
|
+
if (typeof field.minLength === "number") descriptor.minlength = field.minLength;
|
|
483
|
+
if (typeof field.maxLength === "number") descriptor.maxlength = field.maxLength;
|
|
484
|
+
|
|
485
|
+
descriptor.__meta = {
|
|
486
|
+
...descriptor.__meta,
|
|
487
|
+
description: field.description,
|
|
488
|
+
readonly: Boolean(field.readonly || field.computed),
|
|
489
|
+
computed: field.computed || undefined,
|
|
490
|
+
unique: Boolean(field.unique),
|
|
491
|
+
index: Boolean(field.index),
|
|
492
|
+
};
|
|
493
|
+
return descriptor;
|
|
494
|
+
}
|
|
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
|
+
|
|
528
|
+
function domainManifestToDefinitions(input) {
|
|
529
|
+
const manifest = validateDomainManifest(input);
|
|
530
|
+
return manifest.models.map((model) => ({
|
|
531
|
+
name: model.name,
|
|
532
|
+
singular: model.singular,
|
|
533
|
+
basePath: model.basePath,
|
|
534
|
+
crud: model.crud,
|
|
535
|
+
options: model.options,
|
|
536
|
+
domainRules: createDomainRules(model),
|
|
537
|
+
schema: Object.fromEntries(
|
|
538
|
+
Object.entries(model.fields).map(([fieldName, field]) => [
|
|
539
|
+
fieldName,
|
|
540
|
+
createFieldDescriptor(field),
|
|
541
|
+
]),
|
|
542
|
+
),
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function registerDomainManifest(input) {
|
|
547
|
+
const definitions = domainManifestToDefinitions(input);
|
|
548
|
+
return definitions.map((definition) => defineModel(definition));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function loadDomainManifest(filePath, options = {}) {
|
|
552
|
+
const resolvedPath = path.resolve(filePath);
|
|
553
|
+
let parsed;
|
|
554
|
+
try {
|
|
555
|
+
parsed = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (error instanceof SyntaxError) {
|
|
558
|
+
throw new DomainManifestError(
|
|
559
|
+
`JSON inválido em ${resolvedPath}.`,
|
|
560
|
+
[{ path: "$", message: error.message }],
|
|
561
|
+
{ cause: error },
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const manifest = validateDomainManifest(parsed);
|
|
568
|
+
const definitions = domainManifestToDefinitions(manifest);
|
|
569
|
+
const entries = options.register === false
|
|
570
|
+
? []
|
|
571
|
+
: definitions.map((definition) => defineModel(definition));
|
|
572
|
+
|
|
573
|
+
return { filePath: resolvedPath, manifest, definitions, entries };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
module.exports = {
|
|
577
|
+
DOMAIN_MANIFEST_FILENAME,
|
|
578
|
+
DOMAIN_MANIFEST_SCHEMA_VERSION,
|
|
579
|
+
SUPPORTED_FIELD_KINDS,
|
|
580
|
+
DomainManifestError,
|
|
581
|
+
validateDomainManifest,
|
|
582
|
+
domainManifestToDefinitions,
|
|
583
|
+
registerDomainManifest,
|
|
584
|
+
loadDomainManifest,
|
|
585
|
+
};
|
|
@@ -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
|
+
};
|