@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,325 @@
|
|
|
1
|
+
import { collectIdentifiers, collectReads, compileExpression, ExpressionSyntaxError, parseExpressionSource, } from './index.js';
|
|
2
|
+
describe('compileExpression', () => {
|
|
3
|
+
describe('literals and operators', () => {
|
|
4
|
+
it('evaluates arithmetic and precedence', () => {
|
|
5
|
+
expect(compileExpression('1 + 2 * 3')()).toBe(7);
|
|
6
|
+
expect(compileExpression('(1 + 2) * 3')()).toBe(9);
|
|
7
|
+
expect(compileExpression('10 % 3')()).toBe(1);
|
|
8
|
+
expect(compileExpression('-4 + 1')()).toBe(-3);
|
|
9
|
+
});
|
|
10
|
+
it('parses exponent notation', () => {
|
|
11
|
+
expect(compileExpression('1e3')()).toBe(1000);
|
|
12
|
+
expect(compileExpression('2.5E-3')()).toBe(0.0025);
|
|
13
|
+
expect(compileExpression('1e+2 + 1')()).toBe(101);
|
|
14
|
+
expect(() => compileExpression('1 e')).toThrow(ExpressionSyntaxError);
|
|
15
|
+
});
|
|
16
|
+
it('rejects malformed numbers', () => {
|
|
17
|
+
expect(() => compileExpression('1.2.3')).toThrow(/Invalid number/);
|
|
18
|
+
});
|
|
19
|
+
it('evaluates comparisons and logic', () => {
|
|
20
|
+
expect(compileExpression('1 < 2 && 3 >= 3')()).toBe(true);
|
|
21
|
+
expect(compileExpression('1 == 1 || false')()).toBe(true);
|
|
22
|
+
expect(compileExpression('!true')()).toBe(false);
|
|
23
|
+
expect(compileExpression("'a' != 'b'")()).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it('has one equality: === and !== are compile errors', () => {
|
|
26
|
+
expect(() => compileExpression('1 === 1')).toThrow(/'===' is not allowed; use '=='/);
|
|
27
|
+
expect(() => compileExpression('1 !== 1')).toThrow(/'!==' is not allowed/);
|
|
28
|
+
expect(compileExpression("'2' == 2")()).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it('gives + the type of its left operand: text joins, a number adds', () => {
|
|
31
|
+
const run = (source, scope = {}) => compileExpression(source)(scope);
|
|
32
|
+
expect(run("'2' + 3")).toBe('23');
|
|
33
|
+
expect(run("3 + '2'")).toBe(5);
|
|
34
|
+
expect(run("0 + '2' + 3")).toBe(5);
|
|
35
|
+
expect(run("'' + Rows + 'x' + Columns", { Rows: 512, Columns: 256 })).toBe('512x256');
|
|
36
|
+
expect(run("'b=' + B")).toBe('b=');
|
|
37
|
+
expect(run('B + 5')).toBeUndefined();
|
|
38
|
+
expect(run("'' + B + 5")).toBe('5');
|
|
39
|
+
expect(run('5 + B')).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
it('warns for a number plus text, and rejects text in - * / %', () => {
|
|
42
|
+
const warn = jest
|
|
43
|
+
.spyOn(console, 'warn')
|
|
44
|
+
.mockImplementation(() => undefined);
|
|
45
|
+
expect(compileExpression("Rows + 'x'")({ Rows: 512 })).toBeUndefined();
|
|
46
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
47
|
+
warn.mockRestore();
|
|
48
|
+
expect(() => compileExpression("Rows - 'x'")).toThrow(/'-'.*'x'/);
|
|
49
|
+
expect(() => compileExpression('Rows * `x`')).toThrow(/a template literal is not a number/);
|
|
50
|
+
});
|
|
51
|
+
it('gives the literal tests the DICOM kinds of a value', () => {
|
|
52
|
+
const tests = [
|
|
53
|
+
'X == undefined',
|
|
54
|
+
'X == null',
|
|
55
|
+
'X == defined',
|
|
56
|
+
'X != undefined',
|
|
57
|
+
].map((source) => compileExpression(source));
|
|
58
|
+
const row = (X) => tests.map((test) => test({ X }));
|
|
59
|
+
expect(row(undefined)).toEqual([true, true, false, false]);
|
|
60
|
+
expect(row(null)).toEqual([false, true, false, true]);
|
|
61
|
+
expect(row('')).toEqual([false, true, false, true]);
|
|
62
|
+
expect(row([])).toEqual([false, true, false, true]);
|
|
63
|
+
expect(row([''])).toEqual([false, true, false, true]);
|
|
64
|
+
expect(row([null])).toEqual([false, true, false, true]);
|
|
65
|
+
expect(row('CT')).toEqual([false, false, true, true]);
|
|
66
|
+
expect(compileExpression('X != defined')({ X: '' })).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it('compares two values with no value by kind: null is not undefined', () => {
|
|
69
|
+
const equal = compileExpression('Y == Z');
|
|
70
|
+
expect(equal({ Y: null })).toBe(false);
|
|
71
|
+
expect(equal({ Z: null })).toBe(false);
|
|
72
|
+
expect(equal({ Y: null, Z: '' })).toBe(false);
|
|
73
|
+
expect(equal({ Y: null, Z: null })).toBe(true);
|
|
74
|
+
expect(equal({})).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it('accepts defined only as an operand of == and !=', () => {
|
|
77
|
+
expect(() => compileExpression('defined')).toThrow(/'defined' is a literal/);
|
|
78
|
+
expect(() => compileExpression('defined(X)')).toThrow(ExpressionSyntaxError);
|
|
79
|
+
});
|
|
80
|
+
it('gives false for an ordering comparison with no value, and reads a numeric string as a number', () => {
|
|
81
|
+
const greater = compileExpression('X > 1');
|
|
82
|
+
expect([undefined, null, '', [], 'a'].map((X) => greater({ X }))).toEqual([false, false, false, false, false]);
|
|
83
|
+
expect(greater({ X: '2' })).toBe(true);
|
|
84
|
+
expect(compileExpression('X <= 0')({ X: null })).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
it('compares text without case with lower()', () => {
|
|
87
|
+
const fn = compileExpression("lower(BodyPartExamined) == 'breast'");
|
|
88
|
+
expect(fn({ BodyPartExamined: 'BREAST' })).toBe(true);
|
|
89
|
+
expect(fn({})).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
it('evaluates ternaries', () => {
|
|
92
|
+
expect(compileExpression('1 < 2 ? "yes" : "no"')()).toBe('yes');
|
|
93
|
+
});
|
|
94
|
+
it('evaluates membership with in', () => {
|
|
95
|
+
const fn = compileExpression("Modality in ['CR', 'DX', 'MG']");
|
|
96
|
+
expect(fn({ Modality: 'MG' })).toBe(true);
|
|
97
|
+
expect(fn({ Modality: 'CT' })).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
it('reads == as the whole value: a scalar equals an array of one element, and two arrays are equal in order', () => {
|
|
100
|
+
const equal = compileExpression('X == Y');
|
|
101
|
+
const CASES = [
|
|
102
|
+
['MR', ['MR'], true],
|
|
103
|
+
[['MR', 'CT'], ['MR', 'CT'], true],
|
|
104
|
+
[['CT', 'MR'], ['MR', 'CT'], false],
|
|
105
|
+
[['ORIGINAL', 'LOCALIZER'], 'LOCALIZER', false],
|
|
106
|
+
['MR', ['MR', 'CT'], false],
|
|
107
|
+
];
|
|
108
|
+
for (const [X, Y, expected] of CASES) {
|
|
109
|
+
expect([X, Y, equal({ X, Y })]).toEqual([X, Y, expected]);
|
|
110
|
+
}
|
|
111
|
+
expect(compileExpression("X == ['MR']")({ X: 'MR' })).toBe(true);
|
|
112
|
+
expect(compileExpression("X in ['MR', 'CT']")({ X: 'MR' })).toBe(true);
|
|
113
|
+
expect(compileExpression("'LOCALIZER' in X")({ X: ['ORIGINAL', 'LOCALIZER'] })).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
it('refuses == and != with an array literal of two or more values, and names in', () => {
|
|
116
|
+
for (const source of [
|
|
117
|
+
"Modality == ['MR', 'CT']",
|
|
118
|
+
"['MR', 'CT'] == Modality",
|
|
119
|
+
"Modality != ['MR', 'CT']",
|
|
120
|
+
]) {
|
|
121
|
+
expect(() => compileExpression(source)).toThrow(/use .*in \[\.\.\.\]/);
|
|
122
|
+
}
|
|
123
|
+
expect(() => compileExpression("Modality == ['MR']")).not.toThrow();
|
|
124
|
+
});
|
|
125
|
+
it('refuses an empty array literal next to ==', () => {
|
|
126
|
+
expect(() => compileExpression('X != []')).toThrow(/accepts no value/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('scope resolution', () => {
|
|
130
|
+
it('resolves bare identifiers against the first argument', () => {
|
|
131
|
+
const fn = compileExpression("Modality == 'CT' && Rows > 0");
|
|
132
|
+
expect(fn({ Modality: 'CT', Rows: 512 })).toBe(true);
|
|
133
|
+
expect(fn({ Modality: 'MR', Rows: 512 })).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
it('resolves named parameters', () => {
|
|
136
|
+
const fn = compileExpression('context.series.mixedBValue');
|
|
137
|
+
expect(fn({}, { series: { mixedBValue: true } })).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
it('supports custom parameter names', () => {
|
|
140
|
+
const fn = compileExpression('items.length', { params: ['items'] });
|
|
141
|
+
expect(fn([1, 2, 3])).toBe(3);
|
|
142
|
+
const fn2 = compileExpression('instances.length');
|
|
143
|
+
expect(fn2({ instances: [1, 2, 3] })).toBe(3);
|
|
144
|
+
});
|
|
145
|
+
it('reads the implicit scope from a named parameter', () => {
|
|
146
|
+
const fn = compileExpression('SliceLocation', {
|
|
147
|
+
params: ['a', 'b'],
|
|
148
|
+
implicitScope: 'b',
|
|
149
|
+
});
|
|
150
|
+
expect(fn({ SliceLocation: 1 }, { SliceLocation: 2 })).toBe(2);
|
|
151
|
+
expect(() => compileExpression('x', { params: ['a'], implicitScope: 'c' })).toThrow(/implicitScope 'c' is not one of the parameters \(a\)/);
|
|
152
|
+
});
|
|
153
|
+
it('refuses a bare identifier that is not a parameter with no implicit scope', () => {
|
|
154
|
+
const options = { params: ['a', 'b', 'context'], implicitScope: false };
|
|
155
|
+
const fn = compileExpression('a.SliceLocation - b.SliceLocation', options);
|
|
156
|
+
expect(fn({ SliceLocation: 5 }, { SliceLocation: 2 })).toBe(3);
|
|
157
|
+
expect(() => compileExpression('SliceLocation - b.SliceLocation', options)).toThrow(ExpressionSyntaxError);
|
|
158
|
+
expect(() => compileExpression('SliceLocation - b.SliceLocation', options)).toThrow(/'SliceLocation' is not a parameter.*\(a, b, context\)/);
|
|
159
|
+
expect(() => compileExpression('a[Key]', options)).toThrow(/'Key'/);
|
|
160
|
+
expect(() => compileExpression('`${Name}`', options)).toThrow(/'Name'/);
|
|
161
|
+
});
|
|
162
|
+
it('lets an aggregate argument read the element with no implicit scope', () => {
|
|
163
|
+
const fn = compileExpression('series.instances.count(Rows > 1)', {
|
|
164
|
+
params: ['items'],
|
|
165
|
+
implicitScope: false,
|
|
166
|
+
receivers: { series: 'items' },
|
|
167
|
+
});
|
|
168
|
+
expect(fn([{ Rows: 2 }, { Rows: 0 }])).toBe(1);
|
|
169
|
+
expect(() => compileExpression('Rows + series.instances.count()', {
|
|
170
|
+
params: ['items'],
|
|
171
|
+
implicitScope: false,
|
|
172
|
+
receivers: { series: 'items' },
|
|
173
|
+
})).toThrow(/'Rows' is not a parameter/);
|
|
174
|
+
});
|
|
175
|
+
it('returns undefined for unknown identifiers (sparse tags)', () => {
|
|
176
|
+
const fn = compileExpression('DiffusionBValue != undefined');
|
|
177
|
+
expect(fn({})).toBe(false);
|
|
178
|
+
expect(fn({ DiffusionBValue: 800 })).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe('template literals', () => {
|
|
182
|
+
it('builds strings with interpolations', () => {
|
|
183
|
+
const fn = compileExpression('`${SeriesDescription} #${InstanceNumber}`');
|
|
184
|
+
expect(fn({ SeriesDescription: 'AX T1', InstanceNumber: 3 })).toBe('AX T1 #3');
|
|
185
|
+
});
|
|
186
|
+
it('renders null/undefined interpolations as empty strings', () => {
|
|
187
|
+
const fn = compileExpression('`SCOUT ${SeriesDescription}`');
|
|
188
|
+
expect(fn({})).toBe('SCOUT ');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
describe('helpers and aggregates', () => {
|
|
192
|
+
it('gives includes, contains, startsWith and endsWith the meaning of the operators', () => {
|
|
193
|
+
const run = (source, ImageType) => compileExpression(source)({ ImageType });
|
|
194
|
+
expect(run("includes(ImageType, 'M')", 'MR')).toBe(false);
|
|
195
|
+
expect(run("includes(ImageType, 'LOCALIZER')", ['ORIGINAL', 'LOCALIZER'])).toBe(true);
|
|
196
|
+
expect(run("contains(ImageType, 'LOCAL')", ['ORIGINAL', 'LOCALIZER'])).toBe(true);
|
|
197
|
+
expect(run("startsWith(ImageType, 'ORIG')", ['ORIGINAL', 'PRIMARY'])).toBe(true);
|
|
198
|
+
expect(run("endsWith(ImageType, 'ARY')", ['ORIGINAL', 'PRIMARY'])).toBe(true);
|
|
199
|
+
expect(run('ImageType == defined', [''])).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
it('reads no value as undefined in arithmetic, and a numeric string as a number', () => {
|
|
202
|
+
const run = (source, A) => compileExpression(source)({ A });
|
|
203
|
+
expect(run('A - 1', '3')).toBe(2);
|
|
204
|
+
expect(run('Number(A) + 1', '2')).toBe(3);
|
|
205
|
+
expect(run('A * 512', null)).toBeUndefined();
|
|
206
|
+
expect(run('A - 1', '')).toBeUndefined();
|
|
207
|
+
expect(run('-A', undefined)).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
it('reads no value as false in !, && and ||, and gives a boolean', () => {
|
|
210
|
+
const run = (source, A) => compileExpression(source)({ A });
|
|
211
|
+
expect(run('!A', [])).toBe(true);
|
|
212
|
+
expect(run('A && A > 1000', undefined)).toBe(false);
|
|
213
|
+
expect(run("A || 'x'", 'y')).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
it('returns undefined for min/max with no number, and skips a missing value', () => {
|
|
216
|
+
expect(compileExpression('min()')()).toBeUndefined();
|
|
217
|
+
expect(compileExpression('max(undefined, null)')()).toBeUndefined();
|
|
218
|
+
expect(compileExpression('min(3, 1, 2)')()).toBe(1);
|
|
219
|
+
expect(compileExpression('max(3, null, 2)')()).toBe(3);
|
|
220
|
+
expect(compileExpression('round(x)')({})).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
it('runs min, max, sum and count over the list of the receiver', () => {
|
|
223
|
+
const over = { params: ['list'], receivers: { series: 'list' } };
|
|
224
|
+
const list = [
|
|
225
|
+
{ DiffusionBValue: 800, NumberOfFrames: '2' },
|
|
226
|
+
{ DiffusionBValue: null },
|
|
227
|
+
{ DiffusionBValue: 0, NumberOfFrames: 4 },
|
|
228
|
+
];
|
|
229
|
+
const run = (source) => compileExpression(`series.instances.${source}`, over)(list);
|
|
230
|
+
expect(run('count()')).toBe(3);
|
|
231
|
+
expect(run('count(DiffusionBValue)')).toBe(2);
|
|
232
|
+
expect(run('count(DiffusionBValue > 100)')).toBe(1);
|
|
233
|
+
expect(run('sum(NumberOfFrames)')).toBe(6);
|
|
234
|
+
expect(run('min(DiffusionBValue)')).toBe(0);
|
|
235
|
+
expect(run('max(NumberOfFrames)')).toBe(4);
|
|
236
|
+
expect(compileExpression('series.instances.sum(x)', over)([])).toBe(0);
|
|
237
|
+
expect(compileExpression('series.instances.max(x)', over)([])).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
it('refuses an aggregate inside the argument of an aggregate', () => {
|
|
240
|
+
const over = { params: ['list'], receivers: { series: 'list' } };
|
|
241
|
+
expect(() => compileExpression('series.instances.max(Rows - series.instances.min(Rows))', over)).toThrow(/own context entry/);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
describe('safety', () => {
|
|
245
|
+
it('rejects prototype-chain access at parse time', () => {
|
|
246
|
+
expect(() => compileExpression('a.__proto__')).toThrow(ExpressionSyntaxError);
|
|
247
|
+
expect(() => compileExpression('a.constructor')).toThrow(ExpressionSyntaxError);
|
|
248
|
+
expect(() => compileExpression('a.prototype.x')).toThrow(ExpressionSyntaxError);
|
|
249
|
+
expect(() => compileExpression('__proto__')).toThrow(ExpressionSyntaxError);
|
|
250
|
+
});
|
|
251
|
+
it('blocks computed prototype access at runtime', () => {
|
|
252
|
+
const fn = compileExpression('a[key]');
|
|
253
|
+
expect(fn({ a: {}, key: 'constructor' })).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
it('rejects non-whitelisted calls at compile time', () => {
|
|
256
|
+
expect(() => compileExpression('alert(1)')).toThrow(ExpressionSyntaxError);
|
|
257
|
+
expect(() => compileExpression('a.toString()')).toThrow(ExpressionSyntaxError);
|
|
258
|
+
});
|
|
259
|
+
it('rejects malformed expressions with the source in the message', () => {
|
|
260
|
+
expect(() => compileExpression('1 +')).toThrow(/1 \+/);
|
|
261
|
+
expect(() => compileExpression('`unterminated')).toThrow(ExpressionSyntaxError);
|
|
262
|
+
});
|
|
263
|
+
it('resolves null/undefined property access in templates to an empty string without throwing', () => {
|
|
264
|
+
const warn = jest
|
|
265
|
+
.spyOn(console, 'warn')
|
|
266
|
+
.mockImplementation(() => undefined);
|
|
267
|
+
try {
|
|
268
|
+
const fn = compileExpression('`${a.b.c}`');
|
|
269
|
+
expect(fn({})).toBe('');
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
warn.mockRestore();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
describe('collectIdentifiers', () => {
|
|
277
|
+
const identifiersOf = (source) => collectIdentifiers(parseExpressionSource(source)).sort();
|
|
278
|
+
it('collects the identifiers an expression resolves against its scope', () => {
|
|
279
|
+
expect(identifiersOf("Modality == 'CT' && Rows > 512")).toEqual([
|
|
280
|
+
'Modality',
|
|
281
|
+
'Rows',
|
|
282
|
+
]);
|
|
283
|
+
});
|
|
284
|
+
it('collects only the root of a member or index chain', () => {
|
|
285
|
+
expect(identifiersOf('instance.ViewCodeSequence[0].CodeValue')).toEqual([
|
|
286
|
+
'instance',
|
|
287
|
+
]);
|
|
288
|
+
expect(identifiersOf('instance.Sequence[Offset]')).toEqual([
|
|
289
|
+
'Offset',
|
|
290
|
+
'instance',
|
|
291
|
+
]);
|
|
292
|
+
});
|
|
293
|
+
it('does not collect helper or aggregate callees', () => {
|
|
294
|
+
expect(identifiersOf('round(Rows / 64)')).toEqual(['Rows']);
|
|
295
|
+
expect(identifiersOf('max(InstanceNumber)')).toEqual(['InstanceNumber']);
|
|
296
|
+
});
|
|
297
|
+
it('reaches into templates, ternaries and arrays', () => {
|
|
298
|
+
expect(identifiersOf('`${SeriesDescription} ${Modality}`')).toEqual([
|
|
299
|
+
'Modality',
|
|
300
|
+
'SeriesDescription',
|
|
301
|
+
]);
|
|
302
|
+
expect(identifiersOf('Rows > 2000 ? Big : Small')).toEqual([
|
|
303
|
+
'Big',
|
|
304
|
+
'Rows',
|
|
305
|
+
'Small',
|
|
306
|
+
]);
|
|
307
|
+
expect(identifiersOf("Modality in [Primary, 'CT']")).toEqual([
|
|
308
|
+
'Modality',
|
|
309
|
+
'Primary',
|
|
310
|
+
]);
|
|
311
|
+
});
|
|
312
|
+
it('reports the field of a path and the reads inside an aggregate', () => {
|
|
313
|
+
expect(collectReads(parseExpressionSource('round(sweepStart - timeClusters.first) + series.instances.max(Rows)'), { aggregates: true })).toEqual([
|
|
314
|
+
{ name: 'sweepStart', inAggregate: false },
|
|
315
|
+
{ name: 'timeClusters', field: 'first', inAggregate: false },
|
|
316
|
+
{ name: 'Rows', inAggregate: true },
|
|
317
|
+
]);
|
|
318
|
+
});
|
|
319
|
+
it('lets a host spot an identifier it does not supply', () => {
|
|
320
|
+
const supplied = new Set(['Modality', 'Rows', 'instance', 'context']);
|
|
321
|
+
const unknown = identifiersOf("Modallity == 'CT' && Rows > 0").filter((name) => !supplied.has(name));
|
|
322
|
+
expect(unknown).toEqual(['Modallity']);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { collectIdentifiers, collectReads, compileExpression, ExpressionSyntaxError, type CompiledExpression, type CompileExpressionOptions, type ExpressionRead, } from './compiler.js';
|
|
2
|
+
export { parseExpressionSource, FORBIDDEN_PROPERTIES } from './parser.js';
|
|
3
|
+
export type { ExpressionNode } from './parser.js';
|
|
4
|
+
export { tokenize } from './tokenizer.js';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type ExpressionNode = {
|
|
2
|
+
type: 'literal';
|
|
3
|
+
value: unknown;
|
|
4
|
+
} | {
|
|
5
|
+
type: 'identifier';
|
|
6
|
+
name: string;
|
|
7
|
+
} | {
|
|
8
|
+
type: 'member';
|
|
9
|
+
object: ExpressionNode;
|
|
10
|
+
property: string;
|
|
11
|
+
} | {
|
|
12
|
+
type: 'index';
|
|
13
|
+
object: ExpressionNode;
|
|
14
|
+
index: ExpressionNode;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'call';
|
|
17
|
+
callee: string;
|
|
18
|
+
args: ExpressionNode[];
|
|
19
|
+
receiver?: string;
|
|
20
|
+
} | {
|
|
21
|
+
type: 'unary';
|
|
22
|
+
operator: '!' | '-' | '+';
|
|
23
|
+
argument: ExpressionNode;
|
|
24
|
+
} | {
|
|
25
|
+
type: 'binary';
|
|
26
|
+
operator: string;
|
|
27
|
+
left: ExpressionNode;
|
|
28
|
+
right: ExpressionNode;
|
|
29
|
+
} | {
|
|
30
|
+
type: 'logical';
|
|
31
|
+
operator: '&&' | '||';
|
|
32
|
+
left: ExpressionNode;
|
|
33
|
+
right: ExpressionNode;
|
|
34
|
+
} | {
|
|
35
|
+
type: 'conditional';
|
|
36
|
+
test: ExpressionNode;
|
|
37
|
+
consequent: ExpressionNode;
|
|
38
|
+
alternate: ExpressionNode;
|
|
39
|
+
} | {
|
|
40
|
+
type: 'array';
|
|
41
|
+
elements: ExpressionNode[];
|
|
42
|
+
} | {
|
|
43
|
+
type: 'template';
|
|
44
|
+
parts: Array<{
|
|
45
|
+
kind: 'text';
|
|
46
|
+
text: string;
|
|
47
|
+
} | {
|
|
48
|
+
kind: 'expr';
|
|
49
|
+
node: ExpressionNode;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
52
|
+
export declare const FORBIDDEN_PROPERTIES: Set<string>;
|
|
53
|
+
export declare const DEFINED_LITERAL: unique symbol;
|
|
54
|
+
export declare const AGGREGATE_NAMES: Set<string>;
|
|
55
|
+
export declare const AGGREGATE_RECEIVERS: Set<string>;
|
|
56
|
+
export declare function parseExpressionSource(source: string, expression?: string): ExpressionNode;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { ExpressionSyntaxError, tokenize } from './tokenizer.js';
|
|
2
|
+
export const FORBIDDEN_PROPERTIES = new Set([
|
|
3
|
+
'__proto__',
|
|
4
|
+
'prototype',
|
|
5
|
+
'constructor',
|
|
6
|
+
]);
|
|
7
|
+
export const DEFINED_LITERAL = Symbol('defined');
|
|
8
|
+
const KEYWORD_LITERALS = new Map([
|
|
9
|
+
['true', true],
|
|
10
|
+
['false', false],
|
|
11
|
+
['null', null],
|
|
12
|
+
['undefined', undefined],
|
|
13
|
+
['defined', DEFINED_LITERAL],
|
|
14
|
+
]);
|
|
15
|
+
const BINARY_PRECEDENCE = {
|
|
16
|
+
'||': 1,
|
|
17
|
+
'&&': 2,
|
|
18
|
+
'==': 3,
|
|
19
|
+
'!=': 3,
|
|
20
|
+
'===': 3,
|
|
21
|
+
'!==': 3,
|
|
22
|
+
'<': 4,
|
|
23
|
+
'<=': 4,
|
|
24
|
+
'>': 4,
|
|
25
|
+
'>=': 4,
|
|
26
|
+
in: 4,
|
|
27
|
+
'+': 5,
|
|
28
|
+
'-': 5,
|
|
29
|
+
'*': 6,
|
|
30
|
+
'/': 6,
|
|
31
|
+
'%': 6,
|
|
32
|
+
};
|
|
33
|
+
export const AGGREGATE_NAMES = new Set(['min', 'max', 'sum', 'count']);
|
|
34
|
+
export const AGGREGATE_RECEIVERS = new Set([
|
|
35
|
+
'series',
|
|
36
|
+
'displaySet',
|
|
37
|
+
'displaySets',
|
|
38
|
+
]);
|
|
39
|
+
function aggregateReceiver(node) {
|
|
40
|
+
if (node.type === 'member' &&
|
|
41
|
+
AGGREGATE_NAMES.has(node.property) &&
|
|
42
|
+
node.object.type === 'member' &&
|
|
43
|
+
node.object.property === 'instances' &&
|
|
44
|
+
node.object.object.type === 'identifier' &&
|
|
45
|
+
AGGREGATE_RECEIVERS.has(node.object.object.name)) {
|
|
46
|
+
return node.object.object.name;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
class Parser {
|
|
51
|
+
constructor(source, expression) {
|
|
52
|
+
this.position = 0;
|
|
53
|
+
this.expression = expression;
|
|
54
|
+
this.tokens = tokenize(source, expression);
|
|
55
|
+
}
|
|
56
|
+
peek() {
|
|
57
|
+
return this.tokens[this.position];
|
|
58
|
+
}
|
|
59
|
+
next() {
|
|
60
|
+
return this.tokens[this.position++];
|
|
61
|
+
}
|
|
62
|
+
isPunct(value) {
|
|
63
|
+
const token = this.peek();
|
|
64
|
+
return token.kind === 'punct' && token.value === value;
|
|
65
|
+
}
|
|
66
|
+
isIdent(name) {
|
|
67
|
+
const token = this.peek();
|
|
68
|
+
return token.kind === 'ident' && token.name === name;
|
|
69
|
+
}
|
|
70
|
+
expectPunct(value) {
|
|
71
|
+
if (!this.isPunct(value)) {
|
|
72
|
+
throw new ExpressionSyntaxError(`Expected '${value}'`, this.expression);
|
|
73
|
+
}
|
|
74
|
+
this.next();
|
|
75
|
+
}
|
|
76
|
+
fail(message) {
|
|
77
|
+
throw new ExpressionSyntaxError(message, this.expression);
|
|
78
|
+
}
|
|
79
|
+
parse() {
|
|
80
|
+
const node = this.parseConditional();
|
|
81
|
+
if (this.peek().kind !== 'end') {
|
|
82
|
+
this.fail('Unexpected trailing input');
|
|
83
|
+
}
|
|
84
|
+
return node;
|
|
85
|
+
}
|
|
86
|
+
parseSubExpression() {
|
|
87
|
+
return this.parseConditional();
|
|
88
|
+
}
|
|
89
|
+
parseConditional() {
|
|
90
|
+
const test = this.parseBinary(0);
|
|
91
|
+
if (!this.isPunct('?')) {
|
|
92
|
+
return test;
|
|
93
|
+
}
|
|
94
|
+
this.next();
|
|
95
|
+
const consequent = this.parseConditional();
|
|
96
|
+
this.expectPunct(':');
|
|
97
|
+
const alternate = this.parseConditional();
|
|
98
|
+
return { type: 'conditional', test, consequent, alternate };
|
|
99
|
+
}
|
|
100
|
+
parseBinary(minPrecedence) {
|
|
101
|
+
let left = this.parseUnary();
|
|
102
|
+
for (;;) {
|
|
103
|
+
const token = this.peek();
|
|
104
|
+
let operator;
|
|
105
|
+
if (token.kind === 'punct' && BINARY_PRECEDENCE[token.value]) {
|
|
106
|
+
operator = token.value;
|
|
107
|
+
}
|
|
108
|
+
else if (token.kind === 'ident' && token.name === 'in') {
|
|
109
|
+
operator = 'in';
|
|
110
|
+
}
|
|
111
|
+
if (!operator) {
|
|
112
|
+
return left;
|
|
113
|
+
}
|
|
114
|
+
const precedence = BINARY_PRECEDENCE[operator];
|
|
115
|
+
if (precedence <= minPrecedence) {
|
|
116
|
+
return left;
|
|
117
|
+
}
|
|
118
|
+
this.next();
|
|
119
|
+
const right = this.parseBinary(precedence);
|
|
120
|
+
left =
|
|
121
|
+
operator === '&&' || operator === '||'
|
|
122
|
+
? { type: 'logical', operator, left, right }
|
|
123
|
+
: { type: 'binary', operator, left, right };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
parseUnary() {
|
|
127
|
+
const token = this.peek();
|
|
128
|
+
if (token.kind === 'punct' &&
|
|
129
|
+
(token.value === '!' || token.value === '-' || token.value === '+')) {
|
|
130
|
+
this.next();
|
|
131
|
+
return {
|
|
132
|
+
type: 'unary',
|
|
133
|
+
operator: token.value,
|
|
134
|
+
argument: this.parseUnary(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return this.parsePostfix();
|
|
138
|
+
}
|
|
139
|
+
parsePostfix() {
|
|
140
|
+
let node = this.parsePrimary();
|
|
141
|
+
for (;;) {
|
|
142
|
+
if (this.isPunct('.')) {
|
|
143
|
+
this.next();
|
|
144
|
+
const property = this.next();
|
|
145
|
+
if (property.kind !== 'ident') {
|
|
146
|
+
this.fail("Expected a property name after '.'");
|
|
147
|
+
}
|
|
148
|
+
if (FORBIDDEN_PROPERTIES.has(property.name)) {
|
|
149
|
+
this.fail(`Access to '${property.name}' is not allowed`);
|
|
150
|
+
}
|
|
151
|
+
node = { type: 'member', object: node, property: property.name };
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (this.isPunct('[')) {
|
|
155
|
+
this.next();
|
|
156
|
+
const index = this.parseSubExpression();
|
|
157
|
+
this.expectPunct(']');
|
|
158
|
+
node = { type: 'index', object: node, index };
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (this.isPunct('(')) {
|
|
162
|
+
const receiver = aggregateReceiver(node);
|
|
163
|
+
if (node.type !== 'identifier' && !receiver) {
|
|
164
|
+
this.fail('Only whitelisted helper functions may be called');
|
|
165
|
+
}
|
|
166
|
+
const callee = node.type === 'identifier'
|
|
167
|
+
? node.name
|
|
168
|
+
: node.property;
|
|
169
|
+
this.next();
|
|
170
|
+
const args = [];
|
|
171
|
+
if (!this.isPunct(')')) {
|
|
172
|
+
for (;;) {
|
|
173
|
+
args.push(this.parseSubExpression());
|
|
174
|
+
if (this.isPunct(',')) {
|
|
175
|
+
this.next();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.expectPunct(')');
|
|
182
|
+
node = receiver
|
|
183
|
+
? { type: 'call', callee, args, receiver }
|
|
184
|
+
: { type: 'call', callee, args };
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
return node;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
parsePrimary() {
|
|
191
|
+
const token = this.next();
|
|
192
|
+
if (token.kind === 'number') {
|
|
193
|
+
return { type: 'literal', value: token.value };
|
|
194
|
+
}
|
|
195
|
+
if (token.kind === 'string') {
|
|
196
|
+
return { type: 'literal', value: token.value };
|
|
197
|
+
}
|
|
198
|
+
if (token.kind === 'template') {
|
|
199
|
+
return {
|
|
200
|
+
type: 'template',
|
|
201
|
+
parts: token.parts.map((part) => part.kind === 'text'
|
|
202
|
+
? part
|
|
203
|
+
: {
|
|
204
|
+
kind: 'expr',
|
|
205
|
+
node: parseExpressionSource(part.source, this.expression),
|
|
206
|
+
}),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (token.kind === 'ident') {
|
|
210
|
+
if (KEYWORD_LITERALS.has(token.name)) {
|
|
211
|
+
return { type: 'literal', value: KEYWORD_LITERALS.get(token.name) };
|
|
212
|
+
}
|
|
213
|
+
if (token.name === 'in') {
|
|
214
|
+
this.fail("Unexpected 'in'");
|
|
215
|
+
}
|
|
216
|
+
if (FORBIDDEN_PROPERTIES.has(token.name)) {
|
|
217
|
+
this.fail(`Access to '${token.name}' is not allowed`);
|
|
218
|
+
}
|
|
219
|
+
return { type: 'identifier', name: token.name };
|
|
220
|
+
}
|
|
221
|
+
if (token.kind === 'punct') {
|
|
222
|
+
if (token.value === '(') {
|
|
223
|
+
const node = this.parseSubExpression();
|
|
224
|
+
this.expectPunct(')');
|
|
225
|
+
return node;
|
|
226
|
+
}
|
|
227
|
+
if (token.value === '[') {
|
|
228
|
+
const elements = [];
|
|
229
|
+
if (!this.isPunct(']')) {
|
|
230
|
+
for (;;) {
|
|
231
|
+
elements.push(this.parseSubExpression());
|
|
232
|
+
if (this.isPunct(',')) {
|
|
233
|
+
this.next();
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
this.expectPunct(']');
|
|
240
|
+
return { type: 'array', elements };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
this.fail('Unexpected token');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
export function parseExpressionSource(source, expression = source) {
|
|
247
|
+
return new Parser(source, expression).parse();
|
|
248
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type Token = {
|
|
2
|
+
kind: 'number';
|
|
3
|
+
value: number;
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'string';
|
|
6
|
+
value: string;
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'template';
|
|
9
|
+
parts: TemplatePart[];
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'ident';
|
|
12
|
+
name: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'punct';
|
|
15
|
+
value: string;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'end';
|
|
18
|
+
};
|
|
19
|
+
export type TemplatePart = {
|
|
20
|
+
kind: 'text';
|
|
21
|
+
text: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'expr';
|
|
24
|
+
source: string;
|
|
25
|
+
};
|
|
26
|
+
export declare class ExpressionSyntaxError extends Error {
|
|
27
|
+
constructor(message: string, expression: string);
|
|
28
|
+
}
|
|
29
|
+
export declare function tokenize(source: string, expression?: string): Token[];
|