@safeaccess/inline 0.1.1 → 0.1.3

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.
Files changed (179) hide show
  1. package/.gitattributes +1 -1
  2. package/CHANGELOG.md +23 -5
  3. package/LICENSE +1 -1
  4. package/README.md +79 -21
  5. package/dist/accessors/abstract-accessor.d.ts +24 -10
  6. package/dist/accessors/abstract-accessor.js +21 -8
  7. package/dist/accessors/abstract-integration-accessor.d.ts +22 -0
  8. package/dist/accessors/abstract-integration-accessor.js +23 -0
  9. package/dist/accessors/formats/any-accessor.d.ts +10 -8
  10. package/dist/accessors/formats/any-accessor.js +9 -8
  11. package/dist/accessors/formats/array-accessor.d.ts +2 -0
  12. package/dist/accessors/formats/array-accessor.js +2 -0
  13. package/dist/accessors/formats/env-accessor.d.ts +2 -0
  14. package/dist/accessors/formats/env-accessor.js +2 -0
  15. package/dist/accessors/formats/ini-accessor.d.ts +2 -0
  16. package/dist/accessors/formats/ini-accessor.js +2 -0
  17. package/dist/accessors/formats/json-accessor.d.ts +2 -0
  18. package/dist/accessors/formats/json-accessor.js +2 -0
  19. package/dist/accessors/formats/ndjson-accessor.d.ts +2 -0
  20. package/dist/accessors/formats/ndjson-accessor.js +2 -0
  21. package/dist/accessors/formats/object-accessor.d.ts +2 -0
  22. package/dist/accessors/formats/object-accessor.js +2 -0
  23. package/dist/accessors/formats/xml-accessor.d.ts +2 -0
  24. package/dist/accessors/formats/xml-accessor.js +2 -0
  25. package/dist/accessors/formats/yaml-accessor.d.ts +3 -1
  26. package/dist/accessors/formats/yaml-accessor.js +4 -2
  27. package/dist/cache/simple-path-cache.d.ts +51 -0
  28. package/dist/cache/simple-path-cache.js +72 -0
  29. package/dist/contracts/accessors-interface.d.ts +2 -0
  30. package/dist/contracts/factory-accessors-interface.d.ts +2 -0
  31. package/dist/contracts/filter-evaluator-interface.d.ts +28 -0
  32. package/dist/contracts/filter-evaluator-interface.js +1 -0
  33. package/dist/contracts/parse-integration-interface.d.ts +2 -0
  34. package/dist/contracts/parser-interface.d.ts +92 -0
  35. package/dist/contracts/parser-interface.js +1 -0
  36. package/dist/contracts/path-cache-interface.d.ts +7 -6
  37. package/dist/contracts/readable-accessors-interface.d.ts +11 -6
  38. package/dist/contracts/security-guard-interface.d.ts +2 -0
  39. package/dist/contracts/security-parser-interface.d.ts +2 -0
  40. package/dist/contracts/validatable-parser-interface.d.ts +59 -0
  41. package/dist/contracts/validatable-parser-interface.js +1 -0
  42. package/dist/contracts/writable-accessors-interface.d.ts +5 -0
  43. package/dist/core/accessor-factory.d.ts +124 -0
  44. package/dist/core/accessor-factory.js +157 -0
  45. package/dist/core/dot-notation-parser.d.ts +34 -5
  46. package/dist/core/dot-notation-parser.js +51 -10
  47. package/dist/core/inline-builder-accessor.d.ts +82 -0
  48. package/dist/core/inline-builder-accessor.js +107 -0
  49. package/dist/exceptions/accessor-exception.d.ts +9 -0
  50. package/dist/exceptions/accessor-exception.js +9 -0
  51. package/dist/exceptions/invalid-format-exception.d.ts +5 -0
  52. package/dist/exceptions/invalid-format-exception.js +5 -0
  53. package/dist/exceptions/parser-exception.d.ts +4 -0
  54. package/dist/exceptions/parser-exception.js +4 -0
  55. package/dist/exceptions/path-not-found-exception.d.ts +4 -0
  56. package/dist/exceptions/path-not-found-exception.js +4 -0
  57. package/dist/exceptions/readonly-violation-exception.d.ts +4 -0
  58. package/dist/exceptions/readonly-violation-exception.js +4 -0
  59. package/dist/exceptions/security-exception.d.ts +6 -0
  60. package/dist/exceptions/security-exception.js +6 -0
  61. package/dist/exceptions/unsupported-type-exception.d.ts +4 -0
  62. package/dist/exceptions/unsupported-type-exception.js +4 -0
  63. package/dist/exceptions/yaml-parse-exception.d.ts +4 -0
  64. package/dist/exceptions/yaml-parse-exception.js +4 -0
  65. package/dist/index.js +2 -1
  66. package/dist/inline.d.ts +26 -56
  67. package/dist/inline.js +43 -111
  68. package/dist/parser/xml-parser.js +23 -10
  69. package/dist/parser/yaml-parser.d.ts +54 -7
  70. package/dist/parser/yaml-parser.js +268 -51
  71. package/dist/path-query/segment-filter-parser.d.ts +142 -0
  72. package/dist/path-query/segment-filter-parser.js +384 -0
  73. package/dist/path-query/segment-parser.d.ts +98 -0
  74. package/dist/path-query/segment-parser.js +283 -0
  75. package/dist/path-query/segment-path-resolver.d.ts +149 -0
  76. package/dist/path-query/segment-path-resolver.js +351 -0
  77. package/dist/path-query/segment-type.d.ts +85 -0
  78. package/dist/path-query/segment-type.js +35 -0
  79. package/dist/security/forbidden-keys.d.ts +2 -2
  80. package/dist/security/forbidden-keys.js +5 -5
  81. package/dist/security/security-guard.d.ts +4 -1
  82. package/dist/security/security-guard.js +7 -2
  83. package/dist/security/security-parser.d.ts +10 -1
  84. package/dist/security/security-parser.js +10 -1
  85. package/dist/type-format.d.ts +2 -0
  86. package/dist/type-format.js +2 -0
  87. package/package.json +11 -3
  88. package/src/accessors/abstract-accessor.ts +25 -19
  89. package/src/accessors/abstract-integration-accessor.ts +27 -0
  90. package/src/accessors/formats/any-accessor.ts +11 -11
  91. package/src/accessors/formats/array-accessor.ts +2 -0
  92. package/src/accessors/formats/env-accessor.ts +2 -0
  93. package/src/accessors/formats/ini-accessor.ts +2 -0
  94. package/src/accessors/formats/json-accessor.ts +2 -0
  95. package/src/accessors/formats/ndjson-accessor.ts +2 -0
  96. package/src/accessors/formats/object-accessor.ts +2 -0
  97. package/src/accessors/formats/xml-accessor.ts +2 -0
  98. package/src/accessors/formats/yaml-accessor.ts +4 -2
  99. package/src/cache/simple-path-cache.ts +77 -0
  100. package/src/contracts/accessors-interface.ts +2 -0
  101. package/src/contracts/factory-accessors-interface.ts +2 -0
  102. package/src/contracts/filter-evaluator-interface.ts +30 -0
  103. package/src/contracts/parse-integration-interface.ts +2 -0
  104. package/src/contracts/parser-interface.ts +114 -0
  105. package/src/contracts/path-cache-interface.ts +8 -6
  106. package/src/contracts/readable-accessors-interface.ts +11 -6
  107. package/src/contracts/security-guard-interface.ts +2 -0
  108. package/src/contracts/security-parser-interface.ts +2 -0
  109. package/src/contracts/validatable-parser-interface.ts +64 -0
  110. package/src/contracts/writable-accessors-interface.ts +5 -0
  111. package/src/core/accessor-factory.ts +173 -0
  112. package/src/core/dot-notation-parser.ts +74 -11
  113. package/src/core/inline-builder-accessor.ts +163 -0
  114. package/src/exceptions/accessor-exception.ts +9 -0
  115. package/src/exceptions/invalid-format-exception.ts +5 -0
  116. package/src/exceptions/parser-exception.ts +4 -0
  117. package/src/exceptions/path-not-found-exception.ts +4 -0
  118. package/src/exceptions/readonly-violation-exception.ts +4 -0
  119. package/src/exceptions/security-exception.ts +6 -0
  120. package/src/exceptions/unsupported-type-exception.ts +4 -0
  121. package/src/exceptions/yaml-parse-exception.ts +4 -0
  122. package/src/index.ts +3 -1
  123. package/src/inline.ts +46 -120
  124. package/src/parser/xml-parser.ts +31 -10
  125. package/src/parser/yaml-parser.ts +310 -45
  126. package/src/path-query/segment-filter-parser.ts +444 -0
  127. package/src/path-query/segment-parser.ts +321 -0
  128. package/src/path-query/segment-path-resolver.ts +521 -0
  129. package/src/path-query/segment-type.ts +82 -0
  130. package/src/security/forbidden-keys.ts +5 -5
  131. package/src/security/security-guard.ts +10 -2
  132. package/src/security/security-parser.ts +18 -3
  133. package/src/type-format.ts +2 -0
  134. package/stryker.config.json +8 -10
  135. package/tests/accessors/abstract-accessor.test.ts +217 -0
  136. package/tests/accessors/abstract-integration-accessor.test.ts +37 -0
  137. package/tests/accessors/formats/any-accessor.test.ts +57 -0
  138. package/tests/accessors/formats/array-accessor.test.ts +42 -0
  139. package/tests/accessors/formats/env-accessor.test.ts +103 -0
  140. package/tests/accessors/formats/ini-accessor.test.ts +186 -0
  141. package/tests/accessors/{json-accessor.test.ts → formats/json-accessor.test.ts} +6 -6
  142. package/tests/accessors/formats/ndjson-accessor.test.ts +49 -0
  143. package/tests/accessors/formats/object-accessor.test.ts +172 -0
  144. package/tests/accessors/formats/xml-accessor.test.ts +162 -0
  145. package/tests/accessors/formats/yaml-accessor.test.ts +36 -0
  146. package/tests/cache/simple-path-cache.test.ts +168 -0
  147. package/tests/core/accessor-factory.test.ts +157 -0
  148. package/tests/core/dot-notation-parser-edge-cases.test.ts +415 -0
  149. package/tests/core/dot-notation-parser.test.ts +0 -288
  150. package/tests/core/inline-builder-accessor.test.ts +114 -0
  151. package/tests/exceptions/accessor-exception.test.ts +28 -0
  152. package/tests/exceptions/invalid-format-exception.test.ts +31 -0
  153. package/tests/exceptions/path-not-found-exception.test.ts +33 -0
  154. package/tests/exceptions/readonly-violation-exception.test.ts +35 -0
  155. package/tests/exceptions/security-exception.test.ts +33 -0
  156. package/tests/exceptions/unsupported-type-exception.test.ts +33 -0
  157. package/tests/exceptions/yaml-parse-exception.test.ts +38 -0
  158. package/tests/mocks/fake-path-cache.ts +4 -3
  159. package/tests/parity-from.test.ts +118 -0
  160. package/tests/parity.test.ts +227 -10
  161. package/tests/parser/xml-parser-mutations.test.ts +579 -0
  162. package/tests/parser/xml-parser-scanner.test.ts +379 -0
  163. package/tests/parser/xml-parser.test.ts +17 -330
  164. package/tests/parser/yaml-parser-mutations.test.ts +750 -0
  165. package/tests/parser/yaml-parser.test.ts +844 -18
  166. package/tests/path-query/segment-filter-parser-mutations.test.ts +735 -0
  167. package/tests/path-query/segment-filter-parser.test.ts +1091 -0
  168. package/tests/path-query/segment-parser-mutations.test.ts +539 -0
  169. package/tests/path-query/segment-parser.test.ts +606 -0
  170. package/tests/path-query/segment-path-resolver-mutations.test.ts +626 -0
  171. package/tests/path-query/segment-path-resolver.test.ts +1009 -0
  172. package/tests/security/security-guard-advanced.test.ts +413 -0
  173. package/tests/security/security-guard-forbidden-keys.test.ts +87 -0
  174. package/tests/security/security-guard.test.ts +8 -479
  175. package/tests/security/security-parser.test.ts +18 -14
  176. package/vitest.config.ts +3 -3
  177. package/benchmarks/get.bench.ts +0 -26
  178. package/benchmarks/parse.bench.ts +0 -41
  179. package/tests/accessors/accessors.test.ts +0 -1017
@@ -0,0 +1,444 @@
1
+ import type { FilterEvaluatorInterface } from '../contracts/filter-evaluator-interface.js';
2
+ import type { SecurityGuardInterface } from '../contracts/security-guard-interface.js';
3
+ import type { FilterCondition, FilterExpression } from './segment-type.js';
4
+ import { InvalidFormatException } from '../exceptions/invalid-format-exception.js';
5
+
6
+ /**
7
+ * Parse and evaluate filter predicate expressions for path queries.
8
+ *
9
+ * Handles `[?expression]` syntax with comparison operators (==, !=, >, <, >=, <=),
10
+ * logical operators (&& and ||), and built-in functions (starts_with, contains, values).
11
+ * Supports arithmetic expressions and nested field access via dot-notation.
12
+ *
13
+ * @internal
14
+ *
15
+ * @see FilterEvaluatorInterface Contract this class implements.
16
+ * @see SegmentParser Uses this for filter segment parsing.
17
+ * @see SegmentPathResolver Uses this for filter evaluation during resolution.
18
+ * @see SecurityGuardInterface Guards field access against forbidden keys.
19
+ */
20
+ export class SegmentFilterParser implements FilterEvaluatorInterface {
21
+ /**
22
+ * Create a filter parser with security guard for field validation.
23
+ *
24
+ * @param guard - Key validator for field access.
25
+ */
26
+ constructor(private readonly guard: SecurityGuardInterface) {}
27
+
28
+ /**
29
+ * Parse a filter expression into structured conditions and logical operators.
30
+ *
31
+ * @param expression - Raw filter string (e.g. "age>18 && active==true").
32
+ * @returns Parsed structure with conditions and logical operators.
33
+ *
34
+ * @throws {InvalidFormatException} When the expression syntax is invalid.
35
+ */
36
+ parse(expression: string): FilterExpression {
37
+ const conditions: FilterCondition[] = [];
38
+ const parts = this.splitLogical(expression);
39
+
40
+ for (const token of parts.tokens) {
41
+ conditions.push(this.parseCondition(token.trim()));
42
+ }
43
+
44
+ return { conditions, logicals: parts.operators };
45
+ }
46
+
47
+ /**
48
+ * Evaluate a parsed filter expression against a data item.
49
+ *
50
+ * @param item - Data item to test.
51
+ * @param expr - Parsed expression.
52
+ * @returns True if the item satisfies all conditions.
53
+ */
54
+ evaluate(item: Record<string, unknown>, expr: FilterExpression): boolean {
55
+ if (expr.conditions.length === 0) {
56
+ return false;
57
+ }
58
+
59
+ let result = this.evaluateCondition(item, expr.conditions[0]);
60
+
61
+ for (let i = 0; i < expr.logicals.length; i++) {
62
+ const nextResult = this.evaluateCondition(item, expr.conditions[i + 1]);
63
+
64
+ if (expr.logicals[i] === '&&') {
65
+ result = result && nextResult;
66
+ } else {
67
+ result = result || nextResult;
68
+ }
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * Split expression by logical operators (&& and ||), respecting quotes.
76
+ *
77
+ * @param expression - Raw expression string.
78
+ * @returns Tokens and their joining operators.
79
+ */
80
+ private splitLogical(expression: string): { tokens: string[]; operators: string[] } {
81
+ const tokens: string[] = [];
82
+ const operators: string[] = [];
83
+ let current = '';
84
+ let inString = false;
85
+ let stringChar = '';
86
+
87
+ for (let i = 0; i < expression.length; i++) {
88
+ const ch = expression[i];
89
+
90
+ if (inString) {
91
+ current += ch;
92
+ if (ch === stringChar) {
93
+ inString = false;
94
+ }
95
+ continue;
96
+ }
97
+
98
+ if (ch === "'" || ch === '"') {
99
+ inString = true;
100
+ stringChar = ch;
101
+ current += ch;
102
+ continue;
103
+ }
104
+
105
+ if (ch === '&' && expression[i + 1] === '&') {
106
+ tokens.push(current);
107
+ operators.push('&&');
108
+ current = '';
109
+ i++;
110
+ continue;
111
+ }
112
+
113
+ if (ch === '|' && expression[i + 1] === '|') {
114
+ tokens.push(current);
115
+ operators.push('||');
116
+ current = '';
117
+ i++;
118
+ continue;
119
+ }
120
+
121
+ current += ch;
122
+ }
123
+
124
+ tokens.push(current);
125
+ return { tokens, operators };
126
+ }
127
+
128
+ /**
129
+ * Parse a single condition token into a structured object.
130
+ *
131
+ * @param token - Single condition (e.g. "age>18", "starts_with(@.name, 'J')").
132
+ * @returns Parsed condition.
133
+ *
134
+ * @throws {InvalidFormatException} When the condition syntax is invalid.
135
+ */
136
+ private parseCondition(token: string): FilterCondition {
137
+ const operators = ['>=', '<=', '!=', '==', '>', '<'];
138
+
139
+ const funcCompareMatch = token.match(/^(\w+)\(([^)]*)\)\s*(>=|<=|!=|==|>|<)\s*(.+)$/);
140
+ if (funcCompareMatch) {
141
+ const func = funcCompareMatch[1];
142
+ const argsRaw = funcCompareMatch[2];
143
+ const operator = funcCompareMatch[3];
144
+ const rawValue = funcCompareMatch[4].trim();
145
+ const funcArgs = argsRaw.split(',').map((a) => a.trim());
146
+
147
+ return {
148
+ field: funcArgs[0],
149
+ operator,
150
+ value: this.parseValue(rawValue),
151
+ func,
152
+ funcArgs,
153
+ };
154
+ }
155
+
156
+ const funcBoolMatch = token.match(/^(\w+)\(([^)]*)\)$/);
157
+ if (funcBoolMatch) {
158
+ const func = funcBoolMatch[1];
159
+ const argsRaw = funcBoolMatch[2];
160
+ const funcArgs = argsRaw.split(',').map((a) => a.trim());
161
+
162
+ return {
163
+ field: funcArgs[0],
164
+ operator: '==',
165
+ value: true,
166
+ func,
167
+ funcArgs,
168
+ };
169
+ }
170
+
171
+ for (const op of operators) {
172
+ const pos = token.indexOf(op);
173
+ if (pos !== -1) {
174
+ const field = token.substring(0, pos).trim();
175
+ const rawValue = token.substring(pos + op.length).trim();
176
+
177
+ return {
178
+ field,
179
+ operator: op,
180
+ value: this.parseValue(rawValue),
181
+ };
182
+ }
183
+ }
184
+
185
+ throw new InvalidFormatException(`Invalid filter condition: "${token}"`);
186
+ }
187
+
188
+ /**
189
+ * Parse a raw value string to its native type.
190
+ *
191
+ * @param raw - Raw value (e.g. "true", "'hello'", "42").
192
+ * @returns Native value.
193
+ */
194
+ private parseValue(raw: string): boolean | null | number | string {
195
+ if (raw === 'true') return true;
196
+ if (raw === 'false') return false;
197
+ if (raw === 'null') return null;
198
+ return this.parseValueDefault(raw);
199
+ }
200
+
201
+ /**
202
+ * Parse a non-keyword raw value to number or string.
203
+ *
204
+ * @param raw - Raw value string.
205
+ * @returns Typed value.
206
+ */
207
+ private parseValueDefault(raw: string): number | string {
208
+ if (
209
+ (raw.startsWith("'") && raw.endsWith("'")) ||
210
+ (raw.startsWith('"') && raw.endsWith('"'))
211
+ ) {
212
+ return raw.substring(1, raw.length - 1);
213
+ }
214
+
215
+ if (!isNaN(Number(raw)) && raw !== '') {
216
+ return raw.includes('.') ? parseFloat(raw) : parseInt(raw, 10);
217
+ }
218
+
219
+ return raw;
220
+ }
221
+
222
+ /**
223
+ * Evaluate a single parsed condition against a data item.
224
+ *
225
+ * @param item - Data item.
226
+ * @param condition - Parsed condition.
227
+ * @returns True if the condition is satisfied.
228
+ */
229
+ private evaluateCondition(item: Record<string, unknown>, condition: FilterCondition): boolean {
230
+ let fieldValue: unknown;
231
+
232
+ if (condition.func !== undefined) {
233
+ fieldValue = this.evaluateFunction(item, condition.func, condition.funcArgs ?? []);
234
+ } else if (/[@\w.]+\s*[+\-*/]\s*[@\w.]+/.test(condition.field)) {
235
+ fieldValue = this.resolveArithmetic(item, condition.field);
236
+ } else {
237
+ fieldValue = this.resolveField(item, condition.field);
238
+ }
239
+
240
+ const expected = condition.value;
241
+
242
+ switch (condition.operator) {
243
+ case '==':
244
+ return fieldValue === expected;
245
+ case '!=':
246
+ return fieldValue !== expected;
247
+ case '>':
248
+ return (fieldValue as number) > (expected as number);
249
+ case '<':
250
+ return (fieldValue as number) < (expected as number);
251
+ case '>=':
252
+ return (fieldValue as number) >= (expected as number);
253
+ case '<=':
254
+ return (fieldValue as number) <= (expected as number);
255
+ default:
256
+ return false;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Dispatch and evaluate a built-in filter function.
262
+ *
263
+ * @param item - Data item.
264
+ * @param func - Function name.
265
+ * @param funcArgs - Function arguments.
266
+ * @returns Function result.
267
+ *
268
+ * @throws {InvalidFormatException} When the function name is unknown.
269
+ */
270
+ private evaluateFunction(
271
+ item: Record<string, unknown>,
272
+ func: string,
273
+ funcArgs: ReadonlyArray<string>,
274
+ ): unknown {
275
+ switch (func) {
276
+ case 'starts_with':
277
+ return this.evalStartsWith(item, funcArgs);
278
+ case 'contains':
279
+ return this.evalContains(item, funcArgs);
280
+ case 'values':
281
+ return this.evalValues(item, funcArgs);
282
+ default:
283
+ throw new InvalidFormatException(`Unknown filter function: "${func}"`);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Evaluate the starts_with() filter function.
289
+ *
290
+ * @param item - Data item.
291
+ * @param funcArgs - [field, prefix].
292
+ * @returns True if the field value starts with the prefix.
293
+ */
294
+ private evalStartsWith(
295
+ item: Record<string, unknown>,
296
+ funcArgs: ReadonlyArray<string>,
297
+ ): boolean {
298
+ const val = this.resolveFilterArg(item, funcArgs[0] ?? '@');
299
+ if (typeof val !== 'string') {
300
+ return false;
301
+ }
302
+
303
+ const prefix = String(this.parseValue((funcArgs[1] ?? '').trim()));
304
+ return val.startsWith(prefix);
305
+ }
306
+
307
+ /**
308
+ * Evaluate the contains() filter function.
309
+ *
310
+ * @param item - Data item.
311
+ * @param funcArgs - [field, needle].
312
+ * @returns True if the field value contains the needle.
313
+ */
314
+ private evalContains(item: Record<string, unknown>, funcArgs: ReadonlyArray<string>): boolean {
315
+ const val = this.resolveFilterArg(item, funcArgs[0] ?? '@');
316
+ const needle = String(this.parseValue((funcArgs[1] ?? '').trim()));
317
+
318
+ if (typeof val === 'string') {
319
+ return val.includes(needle);
320
+ }
321
+
322
+ if (Array.isArray(val)) {
323
+ return val.includes(needle);
324
+ }
325
+
326
+ return false;
327
+ }
328
+
329
+ /**
330
+ * Evaluate the values() filter function (returns count).
331
+ *
332
+ * @param item - Data item.
333
+ * @param funcArgs - [field].
334
+ * @returns Number of elements in the field array, or 0.
335
+ */
336
+ private evalValues(item: Record<string, unknown>, funcArgs: ReadonlyArray<string>): number {
337
+ const val = this.resolveFilterArg(item, funcArgs[0] ?? '@');
338
+ if (Array.isArray(val)) {
339
+ return val.length;
340
+ }
341
+ return 0;
342
+ }
343
+
344
+ /**
345
+ * Resolve an arithmetic expression from a filter predicate.
346
+ *
347
+ * @param item - Data item for field resolution.
348
+ * @param expr - Arithmetic expression (e.g. "@.price * @.qty").
349
+ * @returns Computed result, or null on failure.
350
+ */
351
+ private resolveArithmetic(item: Record<string, unknown>, expr: string): number | null {
352
+ const m = expr.match(/^([@\w.]+)\s*([+\-*/])\s*([@\w.]+|\d+(?:\.\d+)?)$/);
353
+ if (!m) {
354
+ return null;
355
+ }
356
+
357
+ const toNumber = (token: string): number | null => {
358
+ if (!token.startsWith('@') && !isNaN(Number(token)) && token !== '') {
359
+ return token.includes('.') ? parseFloat(token) : parseInt(token, 10);
360
+ }
361
+
362
+ const val = this.resolveFilterArg(item, token);
363
+
364
+ if (typeof val === 'number') {
365
+ return val;
366
+ }
367
+
368
+ if (typeof val === 'string' && !isNaN(Number(val)) && val !== '') {
369
+ return val.includes('.') ? parseFloat(val) : parseInt(val, 10);
370
+ }
371
+
372
+ return null;
373
+ };
374
+
375
+ const left = toNumber(m[1]);
376
+ const right = toNumber(m[3]);
377
+
378
+ if (left === null || right === null) {
379
+ return null;
380
+ }
381
+
382
+ switch (m[2]) {
383
+ case '+':
384
+ return left + right;
385
+ case '-':
386
+ return left - right;
387
+ case '*':
388
+ return left * right;
389
+ default:
390
+ return right !== 0 ? left / right : null;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Resolve a filter argument to its value from the data item.
396
+ *
397
+ * @param item - Data item.
398
+ * @param arg - Argument ("@", "@.field", or "field").
399
+ * @returns Resolved value.
400
+ */
401
+ private resolveFilterArg(item: Record<string, unknown>, arg: string): unknown {
402
+ if (arg === '' || arg === '@') {
403
+ return item;
404
+ }
405
+
406
+ if (arg.startsWith('@.')) {
407
+ return this.resolveField(item, arg.substring(2));
408
+ }
409
+
410
+ return this.resolveField(item, arg);
411
+ }
412
+
413
+ /**
414
+ * Resolve a dot-separated field path from a data item.
415
+ *
416
+ * @param item - Data item.
417
+ * @param field - Dot-separated field path.
418
+ * @returns Resolved value, or null if not found.
419
+ *
420
+ * @throws {SecurityException} When a field key is forbidden.
421
+ */
422
+ private resolveField(item: Record<string, unknown>, field: string): unknown {
423
+ if (field.includes('.')) {
424
+ let current: unknown = item;
425
+ for (const key of field.split('.')) {
426
+ this.guard.assertSafeKey(key);
427
+
428
+ if (
429
+ typeof current === 'object' &&
430
+ current !== null &&
431
+ Object.prototype.hasOwnProperty.call(current, key)
432
+ ) {
433
+ current = (current as Record<string, unknown>)[key];
434
+ } else {
435
+ return null;
436
+ }
437
+ }
438
+ return current;
439
+ }
440
+
441
+ this.guard.assertSafeKey(field);
442
+ return (item as Record<string, unknown>)[field] ?? null;
443
+ }
444
+ }