@oondemand/oon-core-back 0.3.40 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.3.40",
3
+ "version": "0.3.41",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -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 validação
14
- * declarada via `defineValidation` antes de create/update.
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, a validação recebe o registro consolidado, enquanto
18
- * o service persiste somente os campos enviados pelo cliente.
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 runValidation = async (data, context) => {
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 runUpdateValidation = async (id, changes, method) => {
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) return;
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 normalizedChanges = changes && typeof changes === "object" ? { ...changes } : {};
33
- const consolidated = { ...current, ...normalizedChanges };
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: normalizedChanges,
41
- consolidated,
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 data = req.body ?? {};
65
- await runValidation(data, {
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 changes = req.body ?? {};
78
- await runUpdateValidation(req.params.id, changes, "put");
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 changes = req.body ?? {};
85
- await runUpdateValidation(req.params.id, changes, "patch");
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 result = await service.importMany(req.body?.rows || req.body);
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 validateField(fieldName, field, fieldPath, issues) {
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
+ };