@cornerstonejs/metadata 5.13.2 → 5.15.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/dist/esm/displayset/BaseDisplaySet.d.ts +3 -4
- package/dist/esm/displayset/BaseDisplaySet.js +1 -5
- package/dist/esm/displayset/IDisplaySet.d.ts +9 -3
- package/dist/esm/displayset/ImageStackDisplaySet.d.ts +1 -3
- package/dist/esm/displayset/ImageStackDisplaySet.js +0 -1
- package/dist/esm/displayset/__fixtures__/scenarioInstances.d.ts +12 -0
- package/dist/esm/displayset/__fixtures__/scenarioInstances.js +210 -0
- package/dist/esm/displayset/compileSplitRules.d.ts +3 -0
- package/dist/esm/displayset/compileSplitRules.js +197 -0
- package/dist/esm/displayset/compiledDisplaySetRules.d.ts +3 -0
- package/dist/esm/displayset/compiledDisplaySetRules.js +3 -0
- package/dist/esm/displayset/createDisplaySetFromGroup.js +54 -19
- package/dist/esm/displayset/displaySetContext.d.ts +150 -0
- package/dist/esm/displayset/displaySetContext.js +918 -0
- package/dist/esm/displayset/displaySetRules.d.ts +5 -0
- package/dist/esm/displayset/displaySetRules.js +142 -0
- package/dist/esm/displayset/displaySetRules.test.d.ts +1 -0
- package/dist/esm/displayset/displaySetRules.test.js +1306 -0
- package/dist/esm/displayset/displaySetRulesTypes.d.ts +34 -0
- package/dist/esm/displayset/displaySetRulesTypes.js +0 -0
- package/dist/esm/displayset/displayset.test.js +422 -130
- package/dist/esm/displayset/groupInstancesBySplitRules.d.ts +8 -2
- package/dist/esm/displayset/groupInstancesBySplitRules.js +174 -17
- package/dist/esm/displayset/index.d.ts +14 -4
- package/dist/esm/displayset/index.js +10 -3
- package/dist/esm/displayset/isEcgInstance.d.ts +2 -1
- package/dist/esm/displayset/isEcgInstance.js +4 -9
- package/dist/esm/displayset/isImageInstance.d.ts +2 -1
- package/dist/esm/displayset/isImageInstance.js +4 -44
- package/dist/esm/displayset/isReconstructableInstances.d.ts +2 -0
- package/dist/esm/displayset/isReconstructableInstances.js +91 -0
- package/dist/esm/displayset/isVideoInstance.d.ts +2 -1
- package/dist/esm/displayset/isVideoInstance.js +4 -30
- package/dist/esm/displayset/isWsiInstance.d.ts +2 -1
- package/dist/esm/displayset/isWsiInstance.js +4 -6
- package/dist/esm/displayset/metadataContext.d.ts +13 -0
- package/dist/esm/displayset/metadataContext.js +42 -0
- package/dist/esm/displayset/metadataContext.test.d.ts +1 -0
- package/dist/esm/displayset/metadataContext.test.js +62 -0
- package/dist/esm/displayset/scenarioFixtures.test.d.ts +1 -0
- package/dist/esm/displayset/scenarioFixtures.test.js +206 -0
- package/dist/esm/displayset/seriesContext.test.d.ts +1 -0
- package/dist/esm/displayset/seriesContext.test.js +238 -0
- package/dist/esm/displayset/seriesFunctions.d.ts +29 -0
- package/dist/esm/displayset/seriesFunctions.js +327 -0
- package/dist/esm/displayset/sopClassUidLists.d.ts +14 -0
- package/dist/esm/displayset/sopClassUidLists.js +100 -0
- package/dist/esm/displayset/sortInstances.d.ts +10 -0
- package/dist/esm/displayset/sortInstances.js +33 -0
- package/dist/esm/displayset/sortInstances.test.d.ts +1 -0
- package/dist/esm/displayset/sortInstances.test.js +56 -0
- package/dist/esm/displayset/splitImageIdsBySplitRules.d.ts +3 -3
- package/dist/esm/displayset/splitImageIdsBySplitRules.js +5 -5
- package/dist/esm/displayset/splitRuleSchema.d.ts +11 -0
- package/dist/esm/displayset/splitRuleSchema.js +168 -0
- package/dist/esm/displayset/splitRuleSet.d.ts +12 -0
- package/dist/esm/displayset/splitRuleSet.js +47 -0
- package/dist/esm/displayset/splitRuleSet.test.d.ts +1 -0
- package/dist/esm/displayset/splitRuleSet.test.js +119 -0
- package/dist/esm/displayset/types.d.ts +37 -4
- package/dist/esm/displayset/types.js +15 -0
- package/dist/esm/index.d.ts +4 -2
- package/dist/esm/index.js +2 -1
- package/dist/esm/safeFunctions/compile.d.ts +31 -0
- package/dist/esm/safeFunctions/compile.js +142 -0
- package/dist/esm/safeFunctions/compile.test.d.ts +1 -0
- package/dist/esm/safeFunctions/compile.test.js +82 -0
- package/dist/esm/safeFunctions/expression/compiler.d.ts +23 -0
- package/dist/esm/safeFunctions/expression/compiler.js +481 -0
- package/dist/esm/safeFunctions/expression/expression.test.d.ts +1 -0
- package/dist/esm/safeFunctions/expression/expression.test.js +325 -0
- package/dist/esm/safeFunctions/expression/index.d.ts +4 -0
- package/dist/esm/safeFunctions/expression/index.js +3 -0
- package/dist/esm/safeFunctions/expression/parser.d.ts +56 -0
- package/dist/esm/safeFunctions/expression/parser.js +248 -0
- package/dist/esm/safeFunctions/expression/tokenizer.d.ts +29 -0
- package/dist/esm/safeFunctions/expression/tokenizer.js +198 -0
- package/dist/esm/safeFunctions/index.d.ts +5 -0
- package/dist/esm/safeFunctions/index.js +4 -0
- package/dist/esm/safeFunctions/matchTests.d.ts +16 -0
- package/dist/esm/safeFunctions/matchTests.js +203 -0
- package/dist/esm/safeFunctions/schema.d.ts +72 -0
- package/dist/esm/safeFunctions/schema.js +361 -0
- package/dist/esm/safeFunctions/types.d.ts +47 -0
- package/dist/esm/safeFunctions/types.js +0 -0
- package/dist/esm/safeFunctions/values.d.ts +6 -0
- package/dist/esm/safeFunctions/values.js +71 -0
- package/dist/esm/utilities/Tags.d.ts +1 -0
- package/dist/esm/utilities/Tags.js +14 -2
- package/dist/esm/utilities/index.d.ts +1 -1
- package/dist/esm/utilities/index.js +1 -1
- package/dist/esm/utilities/toNumber.d.ts +1 -1
- package/dist/esm/utilities/toNumber.js +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +2 -2
- package/dist/esm/displayset/defaultDisplaySetSplitRules.d.ts +0 -2
- package/dist/esm/displayset/defaultDisplaySetSplitRules.js +0 -93
- package/dist/esm/displayset/viewportTypes.d.ts +0 -4
- package/dist/esm/displayset/viewportTypes.js +0 -13
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { compileCondition, compileValue } from './compile.js';
|
|
2
|
+
import { MATCH_HELPERS } from './matchTests.js';
|
|
3
|
+
import { RuleCompileError } from './schema.js';
|
|
4
|
+
describe('membership - the cached Set of a long list', () => {
|
|
5
|
+
const uids = [
|
|
6
|
+
...Array.from({ length: 48 }, (_, i) => `1.2.840.10008.5.1.4.1.1.${i}`),
|
|
7
|
+
'7',
|
|
8
|
+
'true',
|
|
9
|
+
];
|
|
10
|
+
const loopList = [...uids, -1];
|
|
11
|
+
const cases = [
|
|
12
|
+
['1.2.840.10008.5.1.4.1.1.4', true],
|
|
13
|
+
['9.9', false],
|
|
14
|
+
[7, true],
|
|
15
|
+
['07', false],
|
|
16
|
+
[true, true],
|
|
17
|
+
[false, false],
|
|
18
|
+
['', false],
|
|
19
|
+
[null, false],
|
|
20
|
+
[undefined, false],
|
|
21
|
+
[[], false],
|
|
22
|
+
[['x', '1.2.840.10008.5.1.4.1.1.5'], true],
|
|
23
|
+
[['x', 'y'], false],
|
|
24
|
+
];
|
|
25
|
+
it('gives the result of the loop in matches and in the in operator', () => {
|
|
26
|
+
const viaSet = compileCondition({ X: uids });
|
|
27
|
+
const viaLoop = compileCondition({ X: loopList });
|
|
28
|
+
for (const [X, expected] of cases) {
|
|
29
|
+
expect([X, viaSet({ X }), viaLoop({ X })]).toEqual([
|
|
30
|
+
X,
|
|
31
|
+
expected,
|
|
32
|
+
expected,
|
|
33
|
+
]);
|
|
34
|
+
expect([
|
|
35
|
+
X,
|
|
36
|
+
MATCH_HELPERS.includes(uids, X),
|
|
37
|
+
MATCH_HELPERS.includes(loopList, X),
|
|
38
|
+
]).toEqual([X, expected, expected]);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
const errorOf = (compile) => {
|
|
43
|
+
try {
|
|
44
|
+
compile();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
expect(error).toBeInstanceOf(RuleCompileError);
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
throw new Error('expected a compile error');
|
|
51
|
+
};
|
|
52
|
+
describe('compileCondition / compileValue - strict and stand-alone', () => {
|
|
53
|
+
it('names a stand-alone definition and its path in a message', () => {
|
|
54
|
+
const standAlone = errorOf(() => compileCondition({ Modality: { equal: 'CT' } }));
|
|
55
|
+
expect(standAlone.definition).toBe('safe function definition');
|
|
56
|
+
expect(standAlone.path).toBe('Modality');
|
|
57
|
+
expect(standAlone.message).toContain("'equal'");
|
|
58
|
+
const named = errorOf(() => compileCondition({ Rows: { greaterThan: 'x' } }, {
|
|
59
|
+
path: 'protocol.match',
|
|
60
|
+
definition: 'hanging protocol',
|
|
61
|
+
}));
|
|
62
|
+
expect(named.definition).toBe('hanging protocol');
|
|
63
|
+
expect(named.path).toBe('protocol.match.Rows');
|
|
64
|
+
expect(named.message).toMatch(/^Invalid hanging protocol: protocol\.match\.Rows: /);
|
|
65
|
+
});
|
|
66
|
+
it('passes an actual function through as is', () => {
|
|
67
|
+
const predicate = () => true;
|
|
68
|
+
const reader = () => 42;
|
|
69
|
+
expect(compileCondition(predicate)).toBe(predicate);
|
|
70
|
+
expect(compileValue(reader)).toBe(reader);
|
|
71
|
+
expect(compileCondition({ Rows: (value) => value === 2 })({ Rows: 2 })).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it('reads a name through the reader of the place', () => {
|
|
74
|
+
const readName = (name) => () => `${name}!`;
|
|
75
|
+
expect(compileValue('anything', { readName })({})).toBe('anything!');
|
|
76
|
+
expect(compileValue({ template: '<{a}>' }, { readName })({}, undefined)).toBe('<a!>');
|
|
77
|
+
});
|
|
78
|
+
it('reads own properties only by default', () => {
|
|
79
|
+
expect(compileValue('constructor')({})).toBeUndefined();
|
|
80
|
+
expect(compileCondition({ toString: null })({})).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExpressionNode } from './parser.js';
|
|
2
|
+
import { ExpressionSyntaxError } from './tokenizer.js';
|
|
3
|
+
export type CompiledExpression = ((...args: unknown[]) => unknown) & {
|
|
4
|
+
expressionSource: string;
|
|
5
|
+
};
|
|
6
|
+
export type CompileExpressionOptions = {
|
|
7
|
+
params?: readonly string[];
|
|
8
|
+
implicitScope?: boolean | string;
|
|
9
|
+
receivers?: Readonly<Record<string, string>>;
|
|
10
|
+
unavailableReceivers?: Readonly<Record<string, string>>;
|
|
11
|
+
};
|
|
12
|
+
export declare function collectIdentifiers(node: ExpressionNode): string[];
|
|
13
|
+
export type ExpressionRead = {
|
|
14
|
+
name: string;
|
|
15
|
+
field?: string;
|
|
16
|
+
inAggregate: boolean;
|
|
17
|
+
indexed?: boolean;
|
|
18
|
+
};
|
|
19
|
+
export declare function collectReads(node: ExpressionNode, options?: {
|
|
20
|
+
aggregates?: boolean;
|
|
21
|
+
}): ExpressionRead[];
|
|
22
|
+
export declare function compileExpression(source: string, options?: CompileExpressionOptions): CompiledExpression;
|
|
23
|
+
export { ExpressionSyntaxError };
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { DEFINED_LITERAL, FORBIDDEN_PROPERTIES, parseExpressionSource, } from './parser.js';
|
|
2
|
+
import { ExpressionSyntaxError } from './tokenizer.js';
|
|
3
|
+
import { asArrayFirst, toFiniteFirst } from '@cornerstonejs/utils';
|
|
4
|
+
import { MATCH_HELPERS } from '../matchTests.js';
|
|
5
|
+
import { RuleCompileError } from '../schema.js';
|
|
6
|
+
import { compareFinite, hasNoValue, isAbsent, isEmpty, isTrue, looseEquals, } from '../values.js';
|
|
7
|
+
function safeGet(object, key) {
|
|
8
|
+
if (object == null) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if (typeof key !== 'string' && typeof key !== 'number') {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
if (typeof key === 'string' && FORBIDDEN_PROPERTIES.has(key)) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return object[key];
|
|
18
|
+
}
|
|
19
|
+
function numeric(operation) {
|
|
20
|
+
return (value) => {
|
|
21
|
+
const number = toFiniteFirst(value);
|
|
22
|
+
if (number === undefined) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const result = operation(number);
|
|
26
|
+
return Number.isFinite(result) ? result : undefined;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function finiteValues(values) {
|
|
30
|
+
return values
|
|
31
|
+
.map((value) => toFiniteFirst(value))
|
|
32
|
+
.filter((value) => value !== undefined);
|
|
33
|
+
}
|
|
34
|
+
const HELPERS = {
|
|
35
|
+
includes: MATCH_HELPERS.includes,
|
|
36
|
+
contains: MATCH_HELPERS.contains,
|
|
37
|
+
startsWith: MATCH_HELPERS.startsWith,
|
|
38
|
+
endsWith: MATCH_HELPERS.endsWith,
|
|
39
|
+
lower: (value) => hasNoValue(value) ? undefined : String(asArrayFirst(value)).toLowerCase(),
|
|
40
|
+
abs: numeric(Math.abs),
|
|
41
|
+
min: (...values) => {
|
|
42
|
+
const numbers = finiteValues(values);
|
|
43
|
+
return numbers.length ? Math.min(...numbers) : undefined;
|
|
44
|
+
},
|
|
45
|
+
max: (...values) => {
|
|
46
|
+
const numbers = finiteValues(values);
|
|
47
|
+
return numbers.length ? Math.max(...numbers) : undefined;
|
|
48
|
+
},
|
|
49
|
+
round: numeric(Math.round),
|
|
50
|
+
floor: numeric(Math.floor),
|
|
51
|
+
ceil: numeric(Math.ceil),
|
|
52
|
+
Number: (value) => toFiniteFirst(value),
|
|
53
|
+
String: (value) => (hasNoValue(value) ? '' : String(value)),
|
|
54
|
+
};
|
|
55
|
+
const AGGREGATES = new Set(['min', 'max', 'sum', 'count']);
|
|
56
|
+
function isAggregateCall(node) {
|
|
57
|
+
return node.receiver !== undefined;
|
|
58
|
+
}
|
|
59
|
+
function isBareAggregate(node) {
|
|
60
|
+
if (node.receiver !== undefined || !AGGREGATES.has(node.callee)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return node.callee === 'min' || node.callee === 'max'
|
|
64
|
+
? node.args.length === 1
|
|
65
|
+
: true;
|
|
66
|
+
}
|
|
67
|
+
export function collectIdentifiers(node) {
|
|
68
|
+
return [...new Set(collectReads(node).map((read) => read.name))];
|
|
69
|
+
}
|
|
70
|
+
export function collectReads(node, options = {}) {
|
|
71
|
+
const reads = [];
|
|
72
|
+
const walk = (current, inAggregate) => {
|
|
73
|
+
switch (current.type) {
|
|
74
|
+
case 'literal':
|
|
75
|
+
return;
|
|
76
|
+
case 'identifier':
|
|
77
|
+
reads.push({ name: current.name, inAggregate });
|
|
78
|
+
return;
|
|
79
|
+
case 'member':
|
|
80
|
+
if (current.object.type === 'identifier') {
|
|
81
|
+
reads.push({
|
|
82
|
+
name: current.object.name,
|
|
83
|
+
field: current.property,
|
|
84
|
+
inAggregate,
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
walk(current.object, inAggregate);
|
|
89
|
+
return;
|
|
90
|
+
case 'index':
|
|
91
|
+
if (current.object.type === 'identifier') {
|
|
92
|
+
reads.push({
|
|
93
|
+
name: current.object.name,
|
|
94
|
+
indexed: true,
|
|
95
|
+
inAggregate,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
walk(current.object, inAggregate);
|
|
100
|
+
}
|
|
101
|
+
walk(current.index, inAggregate);
|
|
102
|
+
return;
|
|
103
|
+
case 'call': {
|
|
104
|
+
const aggregate = inAggregate || (!!options.aggregates && isAggregateCall(current));
|
|
105
|
+
current.args.forEach((argument) => walk(argument, aggregate));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case 'unary':
|
|
109
|
+
walk(current.argument, inAggregate);
|
|
110
|
+
return;
|
|
111
|
+
case 'binary':
|
|
112
|
+
case 'logical':
|
|
113
|
+
walk(current.left, inAggregate);
|
|
114
|
+
walk(current.right, inAggregate);
|
|
115
|
+
return;
|
|
116
|
+
case 'conditional':
|
|
117
|
+
walk(current.test, inAggregate);
|
|
118
|
+
walk(current.consequent, inAggregate);
|
|
119
|
+
walk(current.alternate, inAggregate);
|
|
120
|
+
return;
|
|
121
|
+
case 'array':
|
|
122
|
+
current.elements.forEach((element) => walk(element, inAggregate));
|
|
123
|
+
return;
|
|
124
|
+
case 'template':
|
|
125
|
+
current.parts.forEach((part) => {
|
|
126
|
+
if (part.kind === 'expr') {
|
|
127
|
+
walk(part.node, inAggregate);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
walk(node, false);
|
|
134
|
+
return reads;
|
|
135
|
+
}
|
|
136
|
+
function assertOnlyParams(node, paramNames, context) {
|
|
137
|
+
const allowed = new Set(paramNames);
|
|
138
|
+
for (const read of collectReads(node, {
|
|
139
|
+
aggregates: !!context.receivers,
|
|
140
|
+
})) {
|
|
141
|
+
if (!read.inAggregate && !allowed.has(read.name)) {
|
|
142
|
+
throw new ExpressionSyntaxError(`'${read.name}' is not a parameter, and this expression has no ` +
|
|
143
|
+
`implicit scope; it can read only (${paramNames.join(', ')})`, context.expression);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function resolveIdentifier(name, scope) {
|
|
148
|
+
if (Object.prototype.hasOwnProperty.call(scope.params, name)) {
|
|
149
|
+
return scope.params[name];
|
|
150
|
+
}
|
|
151
|
+
for (let i = scope.implicit.length - 1; i >= 0; i--) {
|
|
152
|
+
const implicitScope = scope.implicit[i];
|
|
153
|
+
if (implicitScope != null &&
|
|
154
|
+
typeof implicitScope === 'object' &&
|
|
155
|
+
name in implicitScope) {
|
|
156
|
+
return safeGet(implicitScope, name);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
const COMPARISONS = {
|
|
162
|
+
'<': compareFinite((a, b) => a < b),
|
|
163
|
+
'<=': compareFinite((a, b) => a <= b),
|
|
164
|
+
'>': compareFinite((a, b) => a > b),
|
|
165
|
+
'>=': compareFinite((a, b) => a >= b),
|
|
166
|
+
};
|
|
167
|
+
function arithmetic(operation) {
|
|
168
|
+
return (left, right) => {
|
|
169
|
+
const a = toFiniteFirst(left);
|
|
170
|
+
const b = toFiniteFirst(right);
|
|
171
|
+
if (a === undefined || b === undefined) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
const result = operation(a, b);
|
|
175
|
+
return Number.isFinite(result) ? result : undefined;
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function plus(left, right) {
|
|
179
|
+
const first = asArrayFirst(left);
|
|
180
|
+
if (typeof first === 'string') {
|
|
181
|
+
return first + (hasNoValue(right) ? '' : String(asArrayFirst(right)));
|
|
182
|
+
}
|
|
183
|
+
if (hasNoValue(left) || hasNoValue(right)) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
const a = toFiniteFirst(left);
|
|
187
|
+
const b = toFiniteFirst(right);
|
|
188
|
+
if (a === undefined || b === undefined) {
|
|
189
|
+
throw new TypeError(`'+' adds numbers when the left side is not text, and ${JSON.stringify(a === undefined ? left : right)} is not a number; put the text first, or use a template`);
|
|
190
|
+
}
|
|
191
|
+
const result = a + b;
|
|
192
|
+
return Number.isFinite(result) ? result : undefined;
|
|
193
|
+
}
|
|
194
|
+
function isTextOperand(node) {
|
|
195
|
+
if (node.type === 'template') {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
return (node.type === 'literal' &&
|
|
199
|
+
typeof node.value === 'string' &&
|
|
200
|
+
toFiniteFirst(node.value) === undefined);
|
|
201
|
+
}
|
|
202
|
+
const ARITHMETIC = {
|
|
203
|
+
'+': plus,
|
|
204
|
+
'-': arithmetic((a, b) => a - b),
|
|
205
|
+
'*': arithmetic((a, b) => a * b),
|
|
206
|
+
'/': arithmetic((a, b) => a / b),
|
|
207
|
+
'%': arithmetic((a, b) => a % b),
|
|
208
|
+
};
|
|
209
|
+
function missingValueTest(node) {
|
|
210
|
+
if (node.type === 'literal') {
|
|
211
|
+
if (node.value === DEFINED_LITERAL) {
|
|
212
|
+
return (value) => !hasNoValue(value);
|
|
213
|
+
}
|
|
214
|
+
if (isAbsent(node.value)) {
|
|
215
|
+
return isAbsent;
|
|
216
|
+
}
|
|
217
|
+
if (isEmpty(node.value)) {
|
|
218
|
+
return hasNoValue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
function rejectListArrayLiteral(node, context) {
|
|
224
|
+
for (const [side, other] of [
|
|
225
|
+
[node.left, node.right],
|
|
226
|
+
[node.right, node.left],
|
|
227
|
+
]) {
|
|
228
|
+
if (side.type === 'array' && side.elements.length > 1) {
|
|
229
|
+
const name = other.type === 'identifier' ? other.name : 'X';
|
|
230
|
+
throw new ExpressionSyntaxError(`'${node.operator}' with an array of ${side.elements.length} values compares the whole value; to test that a value is one of a list, use ${node.operator === '!=' ? `!(${name} in [...])` : `${name} in [...]`} or includes(...), e.g. Modality in ['MR', 'CT']`, context.expression);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function rejectEmptyArrayLiteral(node, context) {
|
|
235
|
+
for (const side of [node.left, node.right]) {
|
|
236
|
+
if (side.type === 'array' && !side.elements.length) {
|
|
237
|
+
throw new ExpressionSyntaxError(`'${node.operator} []' accepts no value; give one or more values, or use '${node.operator} null' to test for no value`, context.expression);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function compileEquality(node, context) {
|
|
242
|
+
const negate = node.operator === '!=';
|
|
243
|
+
rejectEmptyArrayLiteral(node, context);
|
|
244
|
+
rejectListArrayLiteral(node, context);
|
|
245
|
+
const rightTest = missingValueTest(node.right);
|
|
246
|
+
const leftTest = missingValueTest(node.left);
|
|
247
|
+
if (rightTest || leftTest) {
|
|
248
|
+
const test = rightTest ?? leftTest;
|
|
249
|
+
const operand = compileNode(rightTest ? node.left : node.right, context);
|
|
250
|
+
return negate
|
|
251
|
+
? (scope) => !test(operand(scope))
|
|
252
|
+
: (scope) => test(operand(scope));
|
|
253
|
+
}
|
|
254
|
+
const left = compileNode(node.left, context);
|
|
255
|
+
const right = compileNode(node.right, context);
|
|
256
|
+
return negate
|
|
257
|
+
? (scope) => !looseEquals(left(scope), right(scope))
|
|
258
|
+
: (scope) => looseEquals(left(scope), right(scope));
|
|
259
|
+
}
|
|
260
|
+
function compileAggregate(node, context) {
|
|
261
|
+
const { callee } = node;
|
|
262
|
+
const over = context.receivers[node.receiver];
|
|
263
|
+
const element = node.args[0]
|
|
264
|
+
? compileNode(node.args[0], { ...context, inAggregate: true })
|
|
265
|
+
: undefined;
|
|
266
|
+
return (scope) => {
|
|
267
|
+
const list = scope.params[over];
|
|
268
|
+
const items = Array.isArray(list) ? list : [];
|
|
269
|
+
if (!element) {
|
|
270
|
+
return items.length;
|
|
271
|
+
}
|
|
272
|
+
const values = items.map((item) => {
|
|
273
|
+
scope.implicit.push(item);
|
|
274
|
+
try {
|
|
275
|
+
return element(scope);
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
scope.implicit.pop();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
if (callee === 'count') {
|
|
282
|
+
return values.filter((value) => !hasNoValue(value) && value !== false)
|
|
283
|
+
.length;
|
|
284
|
+
}
|
|
285
|
+
const numbers = finiteValues(values);
|
|
286
|
+
if (callee === 'sum') {
|
|
287
|
+
return numbers.reduce((total, value) => total + value, 0);
|
|
288
|
+
}
|
|
289
|
+
if (!numbers.length) {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
return callee === 'min' ? Math.min(...numbers) : Math.max(...numbers);
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function compileNode(node, context) {
|
|
296
|
+
const { expression } = context;
|
|
297
|
+
switch (node.type) {
|
|
298
|
+
case 'literal': {
|
|
299
|
+
const { value } = node;
|
|
300
|
+
if (value === DEFINED_LITERAL) {
|
|
301
|
+
throw new ExpressionSyntaxError("'defined' is a literal for '== defined' and '!= defined' only", expression);
|
|
302
|
+
}
|
|
303
|
+
return () => value;
|
|
304
|
+
}
|
|
305
|
+
case 'identifier': {
|
|
306
|
+
const { name } = node;
|
|
307
|
+
return (scope) => resolveIdentifier(name, scope);
|
|
308
|
+
}
|
|
309
|
+
case 'member': {
|
|
310
|
+
const object = compileNode(node.object, context);
|
|
311
|
+
const { property } = node;
|
|
312
|
+
return (scope) => safeGet(object(scope), property);
|
|
313
|
+
}
|
|
314
|
+
case 'index': {
|
|
315
|
+
const object = compileNode(node.object, context);
|
|
316
|
+
const index = compileNode(node.index, context);
|
|
317
|
+
return (scope) => safeGet(object(scope), index(scope));
|
|
318
|
+
}
|
|
319
|
+
case 'array': {
|
|
320
|
+
const elements = node.elements.map((element) => compileNode(element, context));
|
|
321
|
+
if (node.elements.every((element) => element.type === 'literal')) {
|
|
322
|
+
const folded = node.elements.map((element) => element.value);
|
|
323
|
+
return () => folded;
|
|
324
|
+
}
|
|
325
|
+
return (scope) => elements.map((element) => element(scope));
|
|
326
|
+
}
|
|
327
|
+
case 'template': {
|
|
328
|
+
const parts = node.parts.map((part) => part.kind === 'text' ? () => part.text : compileNode(part.node, context));
|
|
329
|
+
return (scope) => parts
|
|
330
|
+
.map((part) => {
|
|
331
|
+
const value = part(scope);
|
|
332
|
+
return hasNoValue(value) ? '' : String(value);
|
|
333
|
+
})
|
|
334
|
+
.join('');
|
|
335
|
+
}
|
|
336
|
+
case 'unary': {
|
|
337
|
+
const argument = compileNode(node.argument, context);
|
|
338
|
+
switch (node.operator) {
|
|
339
|
+
case '!':
|
|
340
|
+
return (scope) => !isTrue(argument(scope));
|
|
341
|
+
case '-': {
|
|
342
|
+
const negate = numeric((value) => -value);
|
|
343
|
+
return (scope) => negate(argument(scope));
|
|
344
|
+
}
|
|
345
|
+
case '+':
|
|
346
|
+
return (scope) => toFiniteFirst(argument(scope));
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case 'logical': {
|
|
351
|
+
const left = compileNode(node.left, context);
|
|
352
|
+
const right = compileNode(node.right, context);
|
|
353
|
+
return node.operator === '&&'
|
|
354
|
+
? (scope) => isTrue(left(scope)) && isTrue(right(scope))
|
|
355
|
+
: (scope) => isTrue(left(scope)) || isTrue(right(scope));
|
|
356
|
+
}
|
|
357
|
+
case 'binary': {
|
|
358
|
+
if (node.operator === '==' || node.operator === '!=') {
|
|
359
|
+
return compileEquality(node, context);
|
|
360
|
+
}
|
|
361
|
+
if (node.operator === '===' || node.operator === '!==') {
|
|
362
|
+
throw new ExpressionSyntaxError(`'${node.operator}' is not allowed; use '${node.operator === '===' ? '==' : '!='}', the one equality of the rule language`, expression);
|
|
363
|
+
}
|
|
364
|
+
if (ARITHMETIC[node.operator] && node.operator !== '+') {
|
|
365
|
+
const text = [node.left, node.right].find(isTextOperand);
|
|
366
|
+
if (text) {
|
|
367
|
+
throw new ExpressionSyntaxError(`'${node.operator}' works on numbers only, and ${text.type === 'template' ? 'a template literal' : `'${String(text.value)}'`} is not a number`, expression);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const left = compileNode(node.left, context);
|
|
371
|
+
const right = compileNode(node.right, context);
|
|
372
|
+
const comparison = COMPARISONS[node.operator];
|
|
373
|
+
if (comparison) {
|
|
374
|
+
return (scope) => comparison(left(scope), right(scope));
|
|
375
|
+
}
|
|
376
|
+
const operation = ARITHMETIC[node.operator];
|
|
377
|
+
if (operation) {
|
|
378
|
+
return (scope) => operation(left(scope), right(scope));
|
|
379
|
+
}
|
|
380
|
+
if (node.operator === 'in') {
|
|
381
|
+
return (scope) => MATCH_HELPERS.includes(right(scope), left(scope));
|
|
382
|
+
}
|
|
383
|
+
throw new ExpressionSyntaxError(`Unknown operator '${node.operator}'`, expression);
|
|
384
|
+
}
|
|
385
|
+
case 'conditional': {
|
|
386
|
+
const test = compileNode(node.test, context);
|
|
387
|
+
const consequent = compileNode(node.consequent, context);
|
|
388
|
+
const alternate = compileNode(node.alternate, context);
|
|
389
|
+
return (scope) => isTrue(test(scope)) ? consequent(scope) : alternate(scope);
|
|
390
|
+
}
|
|
391
|
+
case 'call': {
|
|
392
|
+
const { callee } = node;
|
|
393
|
+
if (isAggregateCall(node)) {
|
|
394
|
+
const { receiver } = node;
|
|
395
|
+
if (!Object.prototype.hasOwnProperty.call(context.receivers ?? {}, receiver)) {
|
|
396
|
+
throw new ExpressionSyntaxError(context.unavailable?.[receiver] ??
|
|
397
|
+
`${receiver}.instances.${callee}() is not available here`, expression);
|
|
398
|
+
}
|
|
399
|
+
if (context.inAggregate) {
|
|
400
|
+
throw new ExpressionSyntaxError(`${receiver}.instances.${callee}() is not allowed inside the argument of another aggregate; put the inner aggregate in its own context entry, and read that name`, expression);
|
|
401
|
+
}
|
|
402
|
+
const arity = node.args.length;
|
|
403
|
+
if (callee === 'count' ? arity > 1 : arity !== 1) {
|
|
404
|
+
throw new ExpressionSyntaxError(`${receiver}.instances.${callee}() takes ${callee === 'count' ? 'zero or one argument' : 'one argument'}`, expression);
|
|
405
|
+
}
|
|
406
|
+
return compileAggregate(node, context);
|
|
407
|
+
}
|
|
408
|
+
if (isBareAggregate(node)) {
|
|
409
|
+
const places = Object.keys(context.receivers ?? {}).map((receiver) => `${receiver}.instances.${callee}(...)`);
|
|
410
|
+
throw new ExpressionSyntaxError(places.length
|
|
411
|
+
? `${callee}(...) needs a receiver; use ${places.join(' or ')}`
|
|
412
|
+
: `${callee}(...) is not available here: this place has no list of instances`, expression);
|
|
413
|
+
}
|
|
414
|
+
const helper = Object.prototype.hasOwnProperty.call(HELPERS, callee)
|
|
415
|
+
? HELPERS[callee]
|
|
416
|
+
: undefined;
|
|
417
|
+
if (!helper) {
|
|
418
|
+
throw new ExpressionSyntaxError(`'${callee}' is not a whitelisted helper function`, expression);
|
|
419
|
+
}
|
|
420
|
+
const args = node.args.map((argument) => compileNode(argument, context));
|
|
421
|
+
return (scope) => helper(...args.map((argument) => argument(scope)));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
throw new ExpressionSyntaxError('Unsupported expression node', expression);
|
|
425
|
+
}
|
|
426
|
+
export function compileExpression(source, options = {}) {
|
|
427
|
+
const paramNames = options.params ?? ['instance', 'context'];
|
|
428
|
+
const { implicitScope = true, receivers, unavailableReceivers } = options;
|
|
429
|
+
for (const [receiver, param] of Object.entries(receivers ?? {})) {
|
|
430
|
+
if (!paramNames.includes(param)) {
|
|
431
|
+
throw new ExpressionSyntaxError(`the list '${param}' of the receiver '${receiver}' is not one of the parameters (${paramNames.join(', ')})`, source);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
let implicitIndex = -1;
|
|
435
|
+
if (implicitScope === true) {
|
|
436
|
+
implicitIndex = 0;
|
|
437
|
+
}
|
|
438
|
+
else if (typeof implicitScope === 'string') {
|
|
439
|
+
implicitIndex = paramNames.indexOf(implicitScope);
|
|
440
|
+
if (implicitIndex === -1) {
|
|
441
|
+
throw new ExpressionSyntaxError(`implicitScope '${implicitScope}' is not one of the parameters (${paramNames.join(', ')})`, source);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const context = {
|
|
445
|
+
expression: source,
|
|
446
|
+
receivers,
|
|
447
|
+
unavailable: unavailableReceivers,
|
|
448
|
+
};
|
|
449
|
+
const ast = parseExpressionSource(source);
|
|
450
|
+
if (implicitIndex === -1) {
|
|
451
|
+
assertOnlyParams(ast, paramNames, context);
|
|
452
|
+
}
|
|
453
|
+
const evaluator = compileNode(ast, context);
|
|
454
|
+
let warned = false;
|
|
455
|
+
const compiled = ((...args) => {
|
|
456
|
+
const params = Object.create(null);
|
|
457
|
+
paramNames.forEach((name, index) => {
|
|
458
|
+
params[name] = args[index];
|
|
459
|
+
});
|
|
460
|
+
const scope = {
|
|
461
|
+
params,
|
|
462
|
+
implicit: implicitIndex === -1 ? [] : [args[implicitIndex]],
|
|
463
|
+
};
|
|
464
|
+
try {
|
|
465
|
+
return evaluator(scope);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
if (error instanceof RuleCompileError) {
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
if (!warned) {
|
|
472
|
+
warned = true;
|
|
473
|
+
console.warn(`Safe function expression failed at runtime: ${source}`, error);
|
|
474
|
+
}
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
compiled.expressionSource = source;
|
|
479
|
+
return compiled;
|
|
480
|
+
}
|
|
481
|
+
export { ExpressionSyntaxError };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|