@legalplace/conditions-runner 2.2.20 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/ComparatorResolver.d.ts +22 -0
- package/dist/ComparatorResolver.js +180 -0
- package/dist/ConditionsRunner.d.ts +1 -0
- package/dist/ConditionsRunner.js +23 -1
- package/dist/ConditionsRunner.test.js +281 -0
- package/dist/ScopedLeafEvaluator.d.ts +3 -0
- package/dist/ScopedLeafEvaluator.js +106 -0
- package/package.json +6 -6
- package/src/ComparatorResolver.ts +338 -0
- package/src/ConditionsRunner.test.ts +361 -1
- package/src/ConditionsRunner.ts +62 -1
- package/src/ScopedLeafEvaluator.ts +236 -0
|
@@ -1,7 +1,185 @@
|
|
|
1
|
-
import { Types } from "@legalplace/referencesparser";
|
|
1
|
+
import { Types, extractConditionDataMap, lowerScope } from "@legalplace/referencesparser";
|
|
2
|
+
import type { ConditionV3 } from "@legalplace/models-v3-types";
|
|
2
3
|
import ConditionsRunner from "./ConditionsRunner";
|
|
4
|
+
import {
|
|
5
|
+
CONDITION_COMPARATOR_EMPTY_SENTINEL,
|
|
6
|
+
resolveConditionComparators,
|
|
7
|
+
} from "./ComparatorResolver";
|
|
3
8
|
import dummyInputs from "./fixtures/inputs-type.json";
|
|
4
9
|
import dummyReferences from "./fixtures/references.json";
|
|
10
|
+
import type { ConditionDataMap } from "./types/conditions.d";
|
|
11
|
+
|
|
12
|
+
const emptyOptionRelations = {
|
|
13
|
+
options: [],
|
|
14
|
+
variables: [],
|
|
15
|
+
sections: {},
|
|
16
|
+
documents: [] as string[],
|
|
17
|
+
prefillers: [],
|
|
18
|
+
validators: { options: [], variables: [] },
|
|
19
|
+
parents: [] as number[],
|
|
20
|
+
children: { options: [] as number[], variables: [] as number[] },
|
|
21
|
+
dependants: [] as number[],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Builds a minimal references graph: multiple #2 with radio children 3/4.
|
|
26
|
+
*/
|
|
27
|
+
const buildRelatedDocumentReferences = (): Types.ReferencesType =>
|
|
28
|
+
({
|
|
29
|
+
options: {
|
|
30
|
+
2: {
|
|
31
|
+
meta: {
|
|
32
|
+
id: 2,
|
|
33
|
+
type: "static",
|
|
34
|
+
label: "Partner",
|
|
35
|
+
step: "*",
|
|
36
|
+
multiple: { enabled: true },
|
|
37
|
+
},
|
|
38
|
+
options: [3, 4],
|
|
39
|
+
variables: [],
|
|
40
|
+
},
|
|
41
|
+
3: {
|
|
42
|
+
meta: { id: 3, type: "radio", label: "Yes", step: "*" },
|
|
43
|
+
options: [],
|
|
44
|
+
variables: [],
|
|
45
|
+
},
|
|
46
|
+
4: {
|
|
47
|
+
meta: { id: 4, type: "radio", label: "No", step: "*" },
|
|
48
|
+
options: [],
|
|
49
|
+
variables: [],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
variables: {},
|
|
53
|
+
outputs: {},
|
|
54
|
+
boxes: {},
|
|
55
|
+
documents: {
|
|
56
|
+
annex: {
|
|
57
|
+
name: "Annex",
|
|
58
|
+
params: { multiple: { enabled: true, repeatOption: 2 } },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
sections: {},
|
|
62
|
+
relations: {
|
|
63
|
+
options: {
|
|
64
|
+
2: {
|
|
65
|
+
...emptyOptionRelations,
|
|
66
|
+
children: { options: [3, 4], variables: [] },
|
|
67
|
+
},
|
|
68
|
+
3: { ...emptyOptionRelations, parents: [2] },
|
|
69
|
+
4: { ...emptyOptionRelations, parents: [2] },
|
|
70
|
+
},
|
|
71
|
+
variables: {},
|
|
72
|
+
sections: {},
|
|
73
|
+
},
|
|
74
|
+
conditions: {
|
|
75
|
+
options: {},
|
|
76
|
+
variables: {},
|
|
77
|
+
documents: {},
|
|
78
|
+
sections: { main: {} },
|
|
79
|
+
prefillers: {},
|
|
80
|
+
validators: { options: {}, variables: {} },
|
|
81
|
+
},
|
|
82
|
+
defaults: {},
|
|
83
|
+
evaluations: {},
|
|
84
|
+
optionTags: {},
|
|
85
|
+
variableTags: {},
|
|
86
|
+
} as unknown as Types.ReferencesType);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Multiple #10 hosting sibling variables 3 and 4 (same root parent).
|
|
90
|
+
*/
|
|
91
|
+
const buildSiblingVariableReferences = (): Types.ReferencesType =>
|
|
92
|
+
({
|
|
93
|
+
options: {
|
|
94
|
+
10: {
|
|
95
|
+
meta: {
|
|
96
|
+
id: 10,
|
|
97
|
+
type: "static",
|
|
98
|
+
label: "Repeat",
|
|
99
|
+
step: "*",
|
|
100
|
+
multiple: { enabled: true },
|
|
101
|
+
},
|
|
102
|
+
options: [],
|
|
103
|
+
variables: [3, 4],
|
|
104
|
+
},
|
|
105
|
+
20: {
|
|
106
|
+
meta: { id: 20, type: "static", label: "Other", step: "*" },
|
|
107
|
+
options: [],
|
|
108
|
+
variables: [5],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
variables: {
|
|
112
|
+
3: { meta: { id: 3, type: "string", label: "Left" }, options: [] },
|
|
113
|
+
4: { meta: { id: 4, type: "string", label: "Right" }, options: [] },
|
|
114
|
+
5: { meta: { id: 5, type: "string", label: "Elsewhere" }, options: [] },
|
|
115
|
+
},
|
|
116
|
+
outputs: {},
|
|
117
|
+
boxes: {},
|
|
118
|
+
documents: {},
|
|
119
|
+
sections: {},
|
|
120
|
+
relations: {
|
|
121
|
+
options: {
|
|
122
|
+
10: {
|
|
123
|
+
...emptyOptionRelations,
|
|
124
|
+
children: { options: [], variables: [3, 4] },
|
|
125
|
+
},
|
|
126
|
+
20: {
|
|
127
|
+
...emptyOptionRelations,
|
|
128
|
+
children: { options: [], variables: [5] },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
variables: {
|
|
132
|
+
3: { ...emptyOptionRelations, parents: [10] },
|
|
133
|
+
4: { ...emptyOptionRelations, parents: [10] },
|
|
134
|
+
5: { ...emptyOptionRelations, parents: [20] },
|
|
135
|
+
},
|
|
136
|
+
sections: {},
|
|
137
|
+
},
|
|
138
|
+
conditions: {
|
|
139
|
+
options: {},
|
|
140
|
+
variables: {},
|
|
141
|
+
documents: {},
|
|
142
|
+
sections: { main: {} },
|
|
143
|
+
prefillers: {},
|
|
144
|
+
validators: { options: {}, variables: {} },
|
|
145
|
+
},
|
|
146
|
+
defaults: {},
|
|
147
|
+
evaluations: {},
|
|
148
|
+
optionTags: {},
|
|
149
|
+
variableTags: {},
|
|
150
|
+
} as unknown as Types.ReferencesType);
|
|
151
|
+
|
|
152
|
+
const relatedOvc = {
|
|
153
|
+
options: {
|
|
154
|
+
2: [true, true],
|
|
155
|
+
3: [true, false],
|
|
156
|
+
4: [false, true],
|
|
157
|
+
},
|
|
158
|
+
variables: {},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const siblingOvc = {
|
|
162
|
+
options: {
|
|
163
|
+
10: [true, true, true],
|
|
164
|
+
20: [true],
|
|
165
|
+
},
|
|
166
|
+
variables: {
|
|
167
|
+
3: ["same", "b", "c"],
|
|
168
|
+
4: ["same", "y", "c"],
|
|
169
|
+
5: ["solo-a", "solo-b"],
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Builds executeCondition input from an authored ConditionV3 (scope on the node).
|
|
175
|
+
* @param conditions Authored condition tree
|
|
176
|
+
*/
|
|
177
|
+
const mappedCondition = (
|
|
178
|
+
conditions: ConditionV3
|
|
179
|
+
): { dataMap: ConditionDataMap; conditions: ConditionV3 } => ({
|
|
180
|
+
dataMap: extractConditionDataMap(conditions),
|
|
181
|
+
conditions,
|
|
182
|
+
});
|
|
5
183
|
|
|
6
184
|
describe("ConditionsRunner class", () => {
|
|
7
185
|
it("should not raise error when instanciating class", () => {
|
|
@@ -12,4 +190,186 @@ describe("ConditionsRunner class", () => {
|
|
|
12
190
|
)
|
|
13
191
|
).toBeDefined();
|
|
14
192
|
});
|
|
193
|
+
|
|
194
|
+
it("slices related document conditions to the current occurrence", () => {
|
|
195
|
+
const runner = new ConditionsRunner(
|
|
196
|
+
buildRelatedDocumentReferences(),
|
|
197
|
+
relatedOvc
|
|
198
|
+
);
|
|
199
|
+
const condition = mappedCondition({
|
|
200
|
+
selected: [{ var: "o.3" }],
|
|
201
|
+
scope: "related",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(runner.executeCondition(condition, 2, 0, "documents")).toBe(true);
|
|
205
|
+
expect(runner.executeCondition(condition, 2, 1, "documents")).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("evaluates selected + first against the first row after id/index slicing", () => {
|
|
209
|
+
const runner = new ConditionsRunner(
|
|
210
|
+
buildRelatedDocumentReferences(),
|
|
211
|
+
relatedOvc
|
|
212
|
+
);
|
|
213
|
+
const condition = mappedCondition({
|
|
214
|
+
selected: [{ var: "o.3" }],
|
|
215
|
+
scope: "first",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(runner.executeCondition(condition, 2, 1, "documents")).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("related sibling [var:N] uses the current occurrence index", () => {
|
|
222
|
+
const runner = new ConditionsRunner(
|
|
223
|
+
buildSiblingVariableReferences(),
|
|
224
|
+
siblingOvc
|
|
225
|
+
);
|
|
226
|
+
const condition = mappedCondition({
|
|
227
|
+
"=": [{ var: "v.3" }, "[var:4]"],
|
|
228
|
+
scope: "related",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// index 0: "same" === "same"
|
|
232
|
+
expect(runner.executeCondition(condition, 3, 0, "variables")).toBe(true);
|
|
233
|
+
// index 1: "b" === "y"
|
|
234
|
+
expect(runner.executeCondition(condition, 3, 1, "variables")).toBe(false);
|
|
235
|
+
// index 2: "c" === "c" (not "same" from occurrence 0)
|
|
236
|
+
expect(runner.executeCondition(condition, 3, 2, "variables")).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("first-scoped sibling [var:N] uses occurrence 0", () => {
|
|
240
|
+
const runner = new ConditionsRunner(
|
|
241
|
+
buildSiblingVariableReferences(),
|
|
242
|
+
siblingOvc
|
|
243
|
+
);
|
|
244
|
+
const condition = mappedCondition({
|
|
245
|
+
"=": [{ var: "v.3" }, "[var:4]"],
|
|
246
|
+
scope: "first",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Outer index 2 still binds both sides to occurrence 0 → "same" === "same"
|
|
250
|
+
expect(runner.executeCondition(condition, 3, 2, "variables")).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("all-scope sibling [var:N] is pairwise true when every row matches", () => {
|
|
254
|
+
const runner = new ConditionsRunner(buildSiblingVariableReferences(), {
|
|
255
|
+
options: { 10: [true, true], 20: [true] },
|
|
256
|
+
variables: {
|
|
257
|
+
3: [1, 2],
|
|
258
|
+
4: [2, 3],
|
|
259
|
+
5: ["solo-a"],
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const condition = mappedCondition({
|
|
263
|
+
"<": [{ var: "v.3" }, "[var:4]"],
|
|
264
|
+
scope: "all",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(runner.executeCondition(condition, 3, 0, "options")).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("all-scope sibling [var:N] is pairwise false when a row fails", () => {
|
|
271
|
+
const runner = new ConditionsRunner(buildSiblingVariableReferences(), {
|
|
272
|
+
options: { 10: [true, true], 20: [true] },
|
|
273
|
+
variables: {
|
|
274
|
+
3: [1, 5],
|
|
275
|
+
4: [2, 3],
|
|
276
|
+
5: ["solo-a"],
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const condition = mappedCondition({
|
|
280
|
+
"<": [{ var: "v.3" }, "[var:4]"],
|
|
281
|
+
scope: "all",
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(runner.executeCondition(condition, 3, 0, "options")).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("any-scope sibling [var:N] succeeds on a later pairwise row", () => {
|
|
288
|
+
const runner = new ConditionsRunner(buildSiblingVariableReferences(), {
|
|
289
|
+
options: { 10: [true, true], 20: [true] },
|
|
290
|
+
variables: {
|
|
291
|
+
3: [5, 1],
|
|
292
|
+
4: [2, 10],
|
|
293
|
+
5: ["solo-a"],
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
const condition = mappedCondition({
|
|
297
|
+
"<": [{ var: "v.3" }, "[var:4]"],
|
|
298
|
+
scope: "any",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(runner.executeCondition(condition, 5, 0, "options")).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("joins non-sibling [var:N] values instead of slicing by row", () => {
|
|
305
|
+
const references = buildSiblingVariableReferences();
|
|
306
|
+
const resolved = resolveConditionComparators(
|
|
307
|
+
lowerScope({
|
|
308
|
+
"=": [{ var: "v.3" }, "[var:5]"],
|
|
309
|
+
scope: "related",
|
|
310
|
+
}),
|
|
311
|
+
{ optionId: 10, index: 2, conditionVariableId: 3 },
|
|
312
|
+
{ references, ovc: siblingOvc }
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(resolved).toEqual({
|
|
316
|
+
"=": [{ var: "v.3" }, "solo-a, solo-b"],
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("resolveConditionComparators", () => {
|
|
322
|
+
const references = buildSiblingVariableReferences();
|
|
323
|
+
const deps = { references, ovc: siblingOvc };
|
|
324
|
+
|
|
325
|
+
it("related leaf: sibling [var:N] uses the current occurrence index", () => {
|
|
326
|
+
const resolved = resolveConditionComparators(
|
|
327
|
+
lowerScope({
|
|
328
|
+
"=": [{ var: "v.3" }, "[var:4]"],
|
|
329
|
+
scope: "related",
|
|
330
|
+
}),
|
|
331
|
+
{ optionId: 10, index: 2, conditionVariableId: 3 },
|
|
332
|
+
deps
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
expect(resolved).toEqual({
|
|
336
|
+
"=": [{ var: "v.3" }, "c"],
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("first-scoped leaf: sibling [var:N] uses occurrence 0", () => {
|
|
341
|
+
const resolved = resolveConditionComparators(
|
|
342
|
+
lowerScope({
|
|
343
|
+
"=": [{ var: "v.3" }, "[var:4]"],
|
|
344
|
+
scope: "first",
|
|
345
|
+
}),
|
|
346
|
+
{ optionId: 10, index: 2, conditionVariableId: 3 },
|
|
347
|
+
deps
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
expect(resolved).toEqual({
|
|
351
|
+
"=-first": [{ var: "v.3" }, "same"],
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("uses empty sentinel for missing sibling values", () => {
|
|
356
|
+
const resolved = resolveConditionComparators(
|
|
357
|
+
lowerScope({
|
|
358
|
+
"=": [{ var: "v.3" }, "[var:4]"],
|
|
359
|
+
scope: "related",
|
|
360
|
+
}),
|
|
361
|
+
{ optionId: 10, index: 5, conditionVariableId: 3 },
|
|
362
|
+
{
|
|
363
|
+
references,
|
|
364
|
+
ovc: {
|
|
365
|
+
options: siblingOvc.options,
|
|
366
|
+
variables: { 3: ["a"], 4: ["b"], 5: [] },
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expect(resolved).toEqual({
|
|
372
|
+
"=": [{ var: "v.3" }, CONDITION_COMPARATOR_EMPTY_SENTINEL],
|
|
373
|
+
});
|
|
374
|
+
});
|
|
15
375
|
});
|
package/src/ConditionsRunner.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/* eslint-disable max-len */
|
|
2
2
|
import LpLogic from "@legalplace/lplogic";
|
|
3
3
|
import type { Types } from "@legalplace/referencesparser";
|
|
4
|
+
import { lowerScope } from "@legalplace/referencesparser";
|
|
4
5
|
import type { ConditionV3 } from "@legalplace/models-v3-types";
|
|
5
6
|
import type InputsType from "@legalplace/types/dist/inputs";
|
|
6
7
|
import DataPopulator from "./DataPopulator";
|
|
7
8
|
import type { ConditionDataMap } from "./types/conditions.d";
|
|
9
|
+
import { aggregateScopedLeaves } from "./ScopedLeafEvaluator";
|
|
10
|
+
import {
|
|
11
|
+
getConditionVariableOptionId,
|
|
12
|
+
resolveConditionComparators,
|
|
13
|
+
type ComparatorResolverContext,
|
|
14
|
+
} from "./ComparatorResolver";
|
|
8
15
|
|
|
9
16
|
class ConditionsRunner {
|
|
10
17
|
private references: Types.ReferencesType;
|
|
@@ -171,10 +178,64 @@ class ConditionsRunner {
|
|
|
171
178
|
).getData();
|
|
172
179
|
}
|
|
173
180
|
|
|
174
|
-
const
|
|
181
|
+
const resolverContext = this.buildComparatorResolverContext(
|
|
182
|
+
id,
|
|
183
|
+
index,
|
|
184
|
+
conditionType
|
|
185
|
+
);
|
|
186
|
+
const deps = { references: this.references, ovc: this.ovc };
|
|
187
|
+
|
|
188
|
+
// Pairwise sibling `[var:N]` (all/any/first) must run before string
|
|
189
|
+
// substitution collapses the tag to a single value.
|
|
190
|
+
const aggregatedConditions = aggregateScopedLeaves(
|
|
191
|
+
lowerScope(condition.conditions),
|
|
192
|
+
new DataPopulator(
|
|
193
|
+
this,
|
|
194
|
+
this.references,
|
|
195
|
+
this.ovc,
|
|
196
|
+
condition.dataMap
|
|
197
|
+
).getData(),
|
|
198
|
+
resolverContext,
|
|
199
|
+
deps
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const resolvedConditions = resolveConditionComparators(
|
|
203
|
+
aggregatedConditions,
|
|
204
|
+
resolverContext,
|
|
205
|
+
deps
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const result = LpLogic(resolvedConditions, currentData);
|
|
175
209
|
this.executeConditionMemo[memoizedKey] = result;
|
|
176
210
|
return result;
|
|
177
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Builds option/index context for comparator `[var:N]` substitution.
|
|
215
|
+
*/
|
|
216
|
+
private buildComparatorResolverContext(
|
|
217
|
+
id: number,
|
|
218
|
+
index: number,
|
|
219
|
+
conditionType: string
|
|
220
|
+
): ComparatorResolverContext {
|
|
221
|
+
if (conditionType === "options" || conditionType === "optionValidator") {
|
|
222
|
+
return { optionId: id, index, conditionOptionId: id };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (
|
|
226
|
+
conditionType === "variables" ||
|
|
227
|
+
conditionType === "prefillers" ||
|
|
228
|
+
conditionType === "variableValidator"
|
|
229
|
+
) {
|
|
230
|
+
return {
|
|
231
|
+
optionId: getConditionVariableOptionId(this.references, id),
|
|
232
|
+
index,
|
|
233
|
+
conditionVariableId: id,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { optionId: id, index };
|
|
238
|
+
}
|
|
178
239
|
}
|
|
179
240
|
|
|
180
241
|
export default ConditionsRunner;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import LpLogic from "@legalplace/lplogic";
|
|
2
|
+
import type { ConditionData } from "./types/conditions.d";
|
|
3
|
+
import {
|
|
4
|
+
areVariablesSiblings,
|
|
5
|
+
extractLhsVariableId,
|
|
6
|
+
extractVarTagIds,
|
|
7
|
+
getConditionVariableOptionId,
|
|
8
|
+
resolveComparatorString,
|
|
9
|
+
SCOPED_OPERATOR_REGEX,
|
|
10
|
+
type ComparatorResolverContext,
|
|
11
|
+
type ComparatorResolverDeps,
|
|
12
|
+
} from "./ComparatorResolver";
|
|
13
|
+
|
|
14
|
+
const UI_METADATA_KEYS = new Set(["scope", "groupMeta"]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether the comparator references a same-multiple sibling of the LHS variable.
|
|
18
|
+
*/
|
|
19
|
+
const hasSiblingComparatorTag = (
|
|
20
|
+
deps: ComparatorResolverDeps,
|
|
21
|
+
comparator: string,
|
|
22
|
+
lhsVariableId: number
|
|
23
|
+
): boolean =>
|
|
24
|
+
extractVarTagIds(comparator).some((variableId) =>
|
|
25
|
+
areVariablesSiblings(deps.references, variableId, lhsVariableId)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether this leaf must be evaluated pairwise (one RHS index per LHS row).
|
|
30
|
+
*/
|
|
31
|
+
const shouldEvaluatePairwiseSiblingLeaf = (
|
|
32
|
+
operatorKey: string,
|
|
33
|
+
args: unknown[],
|
|
34
|
+
context: ComparatorResolverContext,
|
|
35
|
+
deps: ComparatorResolverDeps
|
|
36
|
+
): boolean => {
|
|
37
|
+
const comparator = args[1];
|
|
38
|
+
if (typeof comparator !== "string") return false;
|
|
39
|
+
|
|
40
|
+
const lhsVariableId = extractLhsVariableId(args[0]);
|
|
41
|
+
if (lhsVariableId === undefined) return false;
|
|
42
|
+
if (!hasSiblingComparatorTag(deps, comparator, lhsVariableId)) return false;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
context.conditionVariableId === undefined ||
|
|
46
|
+
SCOPED_OPERATOR_REGEX.test(operatorKey)
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Builds leaf context bound to a specific LHS variable occurrence.
|
|
52
|
+
*/
|
|
53
|
+
const withLhsLeafContext = (
|
|
54
|
+
context: ComparatorResolverContext,
|
|
55
|
+
deps: ComparatorResolverDeps,
|
|
56
|
+
lhsVariableId: number,
|
|
57
|
+
index: number
|
|
58
|
+
): ComparatorResolverContext => ({
|
|
59
|
+
...context,
|
|
60
|
+
index,
|
|
61
|
+
evalVariableId: lhsVariableId,
|
|
62
|
+
optionId: getConditionVariableOptionId(deps.references, lhsVariableId),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Evaluates one occurrence of a comparison leaf.
|
|
67
|
+
*/
|
|
68
|
+
const evaluateRowComparison = (
|
|
69
|
+
baseOperator: string,
|
|
70
|
+
lhs: unknown,
|
|
71
|
+
comparator: string,
|
|
72
|
+
lhsVariableId: number,
|
|
73
|
+
lhsValue: string | number,
|
|
74
|
+
leafContext: ComparatorResolverContext,
|
|
75
|
+
deps: ComparatorResolverDeps
|
|
76
|
+
): boolean => {
|
|
77
|
+
const resolvedComparator = resolveComparatorString(
|
|
78
|
+
comparator,
|
|
79
|
+
leafContext,
|
|
80
|
+
deps
|
|
81
|
+
);
|
|
82
|
+
const leafData: ConditionData = {
|
|
83
|
+
o: {},
|
|
84
|
+
v: { [lhsVariableId]: [lhsValue] },
|
|
85
|
+
c: { o: {}, s: {}, v: {} },
|
|
86
|
+
};
|
|
87
|
+
return Boolean(
|
|
88
|
+
LpLogic({ [baseOperator]: [lhs, resolvedComparator] }, leafData)
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Evaluates a sibling `[var:N]` leaf pairwise across occurrences.
|
|
94
|
+
*/
|
|
95
|
+
const evaluatePairwiseSiblingLeaf = (
|
|
96
|
+
operatorKey: string,
|
|
97
|
+
args: unknown[],
|
|
98
|
+
context: ComparatorResolverContext,
|
|
99
|
+
deps: ComparatorResolverDeps
|
|
100
|
+
): boolean => {
|
|
101
|
+
const scopeMatch = operatorKey.match(SCOPED_OPERATOR_REGEX);
|
|
102
|
+
const scope = scopeMatch === null ? "all" : scopeMatch[1];
|
|
103
|
+
const baseOperator = operatorKey.replace(SCOPED_OPERATOR_REGEX, "");
|
|
104
|
+
const lhsVariableId = extractLhsVariableId(args[0]);
|
|
105
|
+
if (lhsVariableId === undefined) return false;
|
|
106
|
+
|
|
107
|
+
const lhsValues = deps.ovc.variables[lhsVariableId] ?? [];
|
|
108
|
+
if (lhsValues.length === 0) {
|
|
109
|
+
return scope !== "any" && scope !== "first";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const comparator = args[1] as string;
|
|
113
|
+
const lhs = args[0];
|
|
114
|
+
|
|
115
|
+
if (scope === "first") {
|
|
116
|
+
return evaluateRowComparison(
|
|
117
|
+
baseOperator,
|
|
118
|
+
lhs,
|
|
119
|
+
comparator,
|
|
120
|
+
lhsVariableId,
|
|
121
|
+
lhsValues[0] as string | number,
|
|
122
|
+
withLhsLeafContext(context, deps, lhsVariableId, 0),
|
|
123
|
+
deps
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (scope === "any") {
|
|
128
|
+
return lhsValues.some((lhsValue, index) =>
|
|
129
|
+
evaluateRowComparison(
|
|
130
|
+
baseOperator,
|
|
131
|
+
lhs,
|
|
132
|
+
comparator,
|
|
133
|
+
lhsVariableId,
|
|
134
|
+
lhsValue as string | number,
|
|
135
|
+
withLhsLeafContext(context, deps, lhsVariableId, index),
|
|
136
|
+
deps
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return lhsValues.every((lhsValue, index) =>
|
|
142
|
+
evaluateRowComparison(
|
|
143
|
+
baseOperator,
|
|
144
|
+
lhs,
|
|
145
|
+
comparator,
|
|
146
|
+
lhsVariableId,
|
|
147
|
+
lhsValue as string | number,
|
|
148
|
+
withLhsLeafContext(context, deps, lhsVariableId, index),
|
|
149
|
+
deps
|
|
150
|
+
)
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Evaluates a scoped `-first` / `-any` leaf (non-pairwise) against unsliced data.
|
|
156
|
+
*/
|
|
157
|
+
const evaluateScopedLeaf = (
|
|
158
|
+
operatorKey: string,
|
|
159
|
+
args: unknown[],
|
|
160
|
+
unslicedData: ConditionData,
|
|
161
|
+
context: ComparatorResolverContext,
|
|
162
|
+
deps: ComparatorResolverDeps
|
|
163
|
+
): boolean => {
|
|
164
|
+
const lhsVariableId = extractLhsVariableId(args[0]);
|
|
165
|
+
const comparator = args[1];
|
|
166
|
+
const leafContext =
|
|
167
|
+
lhsVariableId === undefined
|
|
168
|
+
? { ...context, index: 0 }
|
|
169
|
+
: withLhsLeafContext(context, deps, lhsVariableId, 0);
|
|
170
|
+
|
|
171
|
+
const resolvedArgs = [...args];
|
|
172
|
+
if (typeof comparator === "string") {
|
|
173
|
+
resolvedArgs[1] = resolveComparatorString(comparator, leafContext, deps);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return Boolean(LpLogic({ [operatorKey]: resolvedArgs }, unslicedData));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Replaces `-first` / `-any` leaves and pairwise sibling `[var:N]` leaves with booleans.
|
|
181
|
+
*
|
|
182
|
+
* DataPopulator slices same-multiple rows for `related`; scoped operators must
|
|
183
|
+
* still aggregate across every occurrence. Sibling comparators on `all` / `any`
|
|
184
|
+
* / `first` must compare matching indexes pairwise.
|
|
185
|
+
*/
|
|
186
|
+
export const aggregateScopedLeaves = (
|
|
187
|
+
tree: unknown,
|
|
188
|
+
unslicedData: ConditionData,
|
|
189
|
+
context: ComparatorResolverContext,
|
|
190
|
+
deps: ComparatorResolverDeps
|
|
191
|
+
): unknown => {
|
|
192
|
+
if (tree === null || tree === undefined) return tree;
|
|
193
|
+
|
|
194
|
+
if (Array.isArray(tree)) {
|
|
195
|
+
return tree.map((child) =>
|
|
196
|
+
aggregateScopedLeaves(child, unslicedData, context, deps)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (typeof tree !== "object") return tree;
|
|
201
|
+
|
|
202
|
+
const node = tree as Record<string, unknown>;
|
|
203
|
+
const structuralKeys = Object.keys(node).filter(
|
|
204
|
+
(key) => !UI_METADATA_KEYS.has(key)
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (structuralKeys.length === 0) return node;
|
|
208
|
+
|
|
209
|
+
const operatorKey = structuralKeys[0];
|
|
210
|
+
|
|
211
|
+
if (operatorKey === "and" || operatorKey === "or") {
|
|
212
|
+
const children = node[operatorKey];
|
|
213
|
+
if (!Array.isArray(children)) return node;
|
|
214
|
+
return {
|
|
215
|
+
...node,
|
|
216
|
+
[operatorKey]: children.map((child) =>
|
|
217
|
+
aggregateScopedLeaves(child, unslicedData, context, deps)
|
|
218
|
+
),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const args = node[operatorKey];
|
|
223
|
+
if (
|
|
224
|
+
Array.isArray(args) &&
|
|
225
|
+
shouldEvaluatePairwiseSiblingLeaf(operatorKey, args, context, deps)
|
|
226
|
+
) {
|
|
227
|
+
return evaluatePairwiseSiblingLeaf(operatorKey, args, context, deps);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (SCOPED_OPERATOR_REGEX.test(operatorKey)) {
|
|
231
|
+
if (!Array.isArray(args)) return node;
|
|
232
|
+
return evaluateScopedLeaf(operatorKey, args, unslicedData, context, deps);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return node;
|
|
236
|
+
};
|