@genesislcap/mock-server 15.59.0 → 15.61.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.
Files changed (57) hide show
  1. package/README.md +287 -40
  2. package/dist/db/criteria.d.ts +28 -9
  3. package/dist/db/criteria.js +856 -258
  4. package/dist/db/criteriaDialect.d.ts +6 -0
  5. package/dist/db/criteriaDialect.js +441 -0
  6. package/dist/db/criteriaTokens.d.ts +12 -0
  7. package/dist/db/criteriaTokens.js +53 -0
  8. package/dist/db/groovySyntax.d.ts +26 -0
  9. package/dist/db/groovySyntax.js +481 -0
  10. package/dist/db/groovyValidator.d.ts +11 -0
  11. package/dist/db/groovyValidator.js +523 -0
  12. package/dist/db/gsfExpr.d.ts +21 -0
  13. package/dist/db/gsfExpr.js +333 -0
  14. package/dist/db/identity.d.ts +2 -1
  15. package/dist/db/identity.js +24 -3
  16. package/dist/db/legacyCriteria.d.ts +7 -0
  17. package/dist/db/legacyCriteria.js +280 -0
  18. package/dist/db/ordering.d.ts +2 -0
  19. package/dist/db/ordering.js +24 -3
  20. package/dist/db/schema.d.ts +2 -0
  21. package/dist/db/schema.js +34 -3
  22. package/dist/db/store.d.ts +7 -0
  23. package/dist/db/store.js +55 -7
  24. package/dist/db/values.d.ts +19 -0
  25. package/dist/db/values.js +243 -0
  26. package/dist/handlers/commitEvent.js +3 -2
  27. package/dist/handlers/dataLogon.d.ts +2 -1
  28. package/dist/handlers/dataLogon.js +108 -51
  29. package/dist/handlers/eventValidation.d.ts +2 -1
  30. package/dist/handlers/eventValidation.js +33 -6
  31. package/dist/handlers/requestReply.js +43 -16
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/protocol/connection.d.ts +1 -1
  35. package/dist/server.js +15 -8
  36. package/dist/types.d.ts +1 -0
  37. package/package.json +1 -1
  38. package/src/db/criteria.ts +979 -290
  39. package/src/db/criteriaDialect.ts +487 -0
  40. package/src/db/criteriaTokens.ts +72 -0
  41. package/src/db/groovySyntax.ts +500 -0
  42. package/src/db/groovyValidator.ts +546 -0
  43. package/src/db/gsfExpr.ts +392 -0
  44. package/src/db/identity.ts +22 -4
  45. package/src/db/legacyCriteria.ts +301 -0
  46. package/src/db/ordering.ts +27 -3
  47. package/src/db/schema.ts +43 -8
  48. package/src/db/store.ts +65 -7
  49. package/src/db/values.ts +247 -0
  50. package/src/handlers/commitEvent.ts +3 -2
  51. package/src/handlers/dataLogon.ts +134 -56
  52. package/src/handlers/eventValidation.ts +40 -13
  53. package/src/handlers/requestReply.ts +47 -14
  54. package/src/index.ts +6 -1
  55. package/src/protocol/connection.ts +3 -4
  56. package/src/server.ts +16 -15
  57. package/src/types.ts +7 -0
@@ -1,172 +1,125 @@
1
1
  // Evaluator for Genesis CRITERIA_MATCH expressions, e.g.:
2
- // TRADE_STATUS == 'CONFIRMED' AND QUANTITY > 100
3
- // NOT (SIDE == 'SELL') OR PRICE >= 100.5
2
+ // TRADE_STATUS == 'CONFIRMED' && QUANTITY > 100
3
+ // !(SIDE == 'SELL') || PRICE >= 100.5
4
4
  // Expr.dateTimeIsGreaterEqual(TRADE_DATE, '20240101-00:00:00') && COUNTERPARTY_ID == 'CPTY001'
5
5
  //
6
- // Supports: == = != <> > >= < <=, AND OR NOT (also && || !), parentheses,
7
- // string/number/boolean literals, bare field identifiers, and two
8
- // function-call dialects real clients emit:
9
- // - `Expr.<fn>(field, value)` — foundation-criteria's serialisers.ts
10
- // (grid date/text filters);
11
- // - `<FIELD>.<fn>(value)` method-call form — e.g.
12
- // `DEAL_CURRENCIES.containsIgnoreCase('USD')`, as emitted by
13
- // project-level filter builders. Same EXPR_FUNCTIONS, with the receiver
14
- // field as the implicit first argument.
6
+ // Supports: == != > >= < <=, && || !, parentheses, string/number/boolean/null
7
+ // literals, field names, and calls in both forms GSF takes:
8
+ // - `Expr.<fn>(args)` — every function of GSF's Expr whitelist
9
+ // (CriteriaValidatorVisitor, v8.15.29), with Expr.java's overloads;
10
+ // - `<FIELD>.<method>(args)` — the field methods GSF allows: String's
11
+ // startsWith / equals / equalsIgnoreCase / contains and the
12
+ // ExtraOperators.kt extensions containsIgnoreCase,
13
+ // containsWordsStartingWithIgnoreCase and dateIsToday.
14
+ // A call may be an operand (`Expr.longLocalDate(D) == Expr.stringLocalDate('20240101')`)
15
+ // or an argument of another. What the functions compute, and when they throw
16
+ // (making the row not match), is GSF's: see gsfExpr.ts.
17
+ //
18
+ // It also reads what GSF refuses — = <>, AND OR NOT, and the functions this
19
+ // engine used to offer (Expr.contains, Expr.equals, Expr.equalsIgnoreCase,
20
+ // Expr.startsWith, Expr.endsWith, Expr.dateTimeIsEqual, FIELD.endsWith, and
21
+ // any Expr function called as a field method). Strict criteria refuse those
22
+ // before they get here (criteriaDialect.ts); only fidelity: 'legacy'
23
+ // evaluates them.
24
+ //
25
+ // Types: GSF compiles a criteria against the entity's Java types — a STRING
26
+ // or ENUM field is a String, DATE and DATETIME a Joda DateTime, LONG a Long —
27
+ // which picks each function's overload and lets ExtraOperators compare a
28
+ // DateTime with a date string (`TRADE_DATE > '20240101'`). With
29
+ // CriteriaOptions.types the evaluator does the same; a field without a known
30
+ // type is read by its value (a number as a Long, a string as a String).
31
+ // criteriaCompileRefusal reports what that compilation refuses.
32
+ //
15
33
  // Deliberately NOT eval()/new Function() — this parses a small typed grammar
16
34
  // so a malformed or hostile criteria string can only fail to parse, never
17
35
  // execute arbitrary JS.
18
- // A criteria this grammar can't parse: unbalanced parentheses, a missing
19
- // operand, text it can't tokenize. GSF fails to compile those too.
20
- export class CriteriaSyntaxError extends Error {
21
- name = 'CriteriaSyntaxError';
22
- }
23
- // A well-formed call to a function EXPR_FUNCTIONS doesn't have. It may well be
24
- // valid Groovy, so callers that refuse malformed criteria still fail open on
25
- // this one.
26
- export class UnsupportedCriteriaError extends Error {
27
- name = 'UnsupportedCriteriaError';
28
- }
29
- const TOKEN_RE = /\s*(&&|\|\||<=|>=|!=|<>|==|=|<|>|!|\(|\)|,|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*)/g;
30
- function tokenize(input) {
31
- const tokens = [];
32
- let match;
33
- let lastIndex = 0;
34
- TOKEN_RE.lastIndex = 0;
35
- while ((match = TOKEN_RE.exec(input))) {
36
- if (match.index !== lastIndex) {
37
- throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
38
- }
39
- tokens.push(match[1]);
40
- lastIndex = TOKEN_RE.lastIndex;
36
+ import { CriteriaSyntaxError, tokenizeCriteria, UnsupportedCriteriaError, } from "./criteriaTokens.js";
37
+ import * as gsf from "./gsfExpr.js";
38
+ import { CriteriaEvaluationError, evaluationError } from "./gsfExpr.js";
39
+ import { parseLegacyCriteria } from "./legacyCriteria.js";
40
+ import { epochMillis } from "./values.js";
41
+ export { CriteriaSyntaxError, parseGenesisDateTime, tokenizeCriteria, UnsupportedCriteriaError, } from "./criteriaTokens.js";
42
+ // A generated entity's getter types (ENUM getters are read with toString()).
43
+ const ENTITY_TYPES = {
44
+ STRING: 'String',
45
+ ENUM: 'String',
46
+ INT: 'Integer',
47
+ SHORT: 'Short',
48
+ LONG: 'Long',
49
+ NANO_TIMESTAMP: 'Long',
50
+ DOUBLE: 'Double',
51
+ BIGDECIMAL: 'BigDecimal',
52
+ BOOLEAN: 'Boolean',
53
+ DATE: 'DateTime',
54
+ DATETIME: 'DateTime',
55
+ };
56
+ // A Joda DateTime: an instant, in epoch millis.
57
+ class DateTimeValue {
58
+ millis;
59
+ constructor(millis) {
60
+ this.millis = millis;
41
61
  }
42
- if (lastIndex !== input.length) {
43
- throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
62
+ }
63
+ // A Joda LocalDate: a day, in days since 1970-01-01.
64
+ class LocalDateValue {
65
+ day;
66
+ constructor(day) {
67
+ this.day = day;
44
68
  }
45
- return tokens;
46
69
  }
70
+ const INT_MIN = -2_147_483_648;
71
+ const INT_MAX = 2_147_483_647;
72
+ const NULL_POINTER = 'java.lang.NullPointerException';
47
73
  function parseLiteral(token) {
48
- if (/^['"]/.test(token))
49
- return token.slice(1, -1).replace(/\\(.)/g, '$1');
50
- if (/^-?\d+(?:\.\d+)?$/.test(token))
51
- return Number(token);
52
- if (token === 'true')
53
- return true;
54
- if (token === 'false')
55
- return false;
74
+ if (/^['"]/.test(token)) {
75
+ return { value: token.slice(1, -1).replace(/\\(.)/g, '$1'), type: 'String' };
76
+ }
77
+ if (/^-?\d+$/.test(token)) {
78
+ const value = Number(token);
79
+ const type = value >= INT_MIN && value <= INT_MAX ? 'Integer' : 'Long';
80
+ return { value, type };
81
+ }
82
+ if (/^-?\d+\.\d+$/.test(token))
83
+ return { value: Number(token), type: 'BigDecimal' };
84
+ if (token === 'true' || token === 'false')
85
+ return { value: token === 'true', type: 'Boolean' };
56
86
  if (token === 'null')
57
- return null;
58
- return undefined; // not a literal -> treat as a field reference
87
+ return { value: null, type: 'null' };
88
+ return undefined; // not a literal -> a field reference
59
89
  }
60
- const COMPARATORS = new Set(['==', '=', '!=', '<>', '<', '<=', '>', '>=']);
61
- // Genesis date/datetime values serialize as 'YYYYMMDD-HH:MM[:SS[.mmm]]' —
62
- // foundation-criteria's serialisers.ts formatDate stops at seconds, but
63
- // getGroovyDateFormat() emits milliseconds too, and a parser that rejects
64
- // them makes every Expr.dateTimeIs* comparison silently false (date-filtered
65
- // grids render zero rows with no warning). Row values may already be epoch
66
- // millis (number) or an ISO string. Returns null if unparseable.
67
- export function parseGenesisDateTime(value) {
68
- if (typeof value === 'number')
90
+ const isNullish = (value) => value === null || value === undefined;
91
+ // Groovy truth: false for null, false, '', 0; true for anything else.
92
+ function groovyTruth(value) {
93
+ if (isNullish(value))
94
+ return false;
95
+ if (typeof value === 'boolean')
69
96
  return value;
70
- if (typeof value !== 'string')
71
- return null;
72
- const match = value.match(/^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/);
73
- if (match) {
74
- const MS_DIGITS = 3; // '.5' means 500ms, not 5ms — right-pad to full precision
75
- const [, y, mo, d, h, mi, s, ms] = match;
76
- return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s ?? '0'), Number((ms ?? '0').padEnd(MS_DIGITS, '0')));
77
- }
78
- const parsed = Date.parse(value);
79
- return Number.isNaN(parsed) ? null : parsed;
80
- }
81
- const HOURS_PER_DAY = 24;
82
- const MINUTES_PER_HOUR = 60;
83
- const SECONDS_PER_MINUTE = 60;
84
- const MS_PER_SECOND = 1000;
85
- const MS_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND;
86
- function toEpochDay(value) {
87
- const epoch = parseGenesisDateTime(value);
88
- return epoch === null ? null : Math.floor(epoch / MS_PER_DAY);
89
- }
90
- // Expr.<fn>(field, value) function-call criteria, as emitted by
91
- // foundation-criteria's serialisers.ts (contains/containsIgnoreCase/dateIs*)
92
- // and foundation-ui's date-range filter.ts (dateTimeIsAfter/dateTimeIsBefore,
93
- // which operator-map.ts maps onto the same >=/<= semantics as
94
- // dateTimeIsGreaterEqual/dateTimeIsLessEqual). `dateIs*` compares by calendar
95
- // day (UTC), `dateTimeIs*` compares full timestamps.
96
- const EXPR_FUNCTIONS = {
97
- // Multi-value fields (e.g. DEAL_CURRENCIES) may hold an array — treat
98
- // "contains" as membership there, substring match on strings.
99
- contains: (record, args, resolve) => {
100
- const value = resolve(args[0], record);
101
- const needle = resolve(args[1], record);
102
- if (typeof needle !== 'string')
103
- return false;
104
- if (Array.isArray(value))
105
- return value.some((v) => typeof v === 'string' && v === needle);
106
- return typeof value === 'string' && value.includes(needle);
107
- },
108
- containsIgnoreCase: (record, args, resolve) => {
109
- const value = resolve(args[0], record);
110
- const needle = resolve(args[1], record);
111
- if (typeof needle !== 'string')
112
- return false;
113
- const lowered = needle.toLowerCase();
114
- if (Array.isArray(value)) {
115
- return value.some((v) => typeof v === 'string' && v.toLowerCase() === lowered);
116
- }
117
- return typeof value === 'string' && value.toLowerCase().includes(lowered);
118
- },
119
- // Groovy/Java String methods the client emits in the FIELD.method(args)
120
- // form — e.g. pbc-notify-ui's inbox subscribes with
121
- // `((ALERT_STATUS.equals("NEW")))`. Without these the criteria fails to
122
- // compile, the filter is dropped, and the grid shows every row instead of
123
- // the filtered set.
124
- equals: (record, args, resolve) => resolve(args[0], record) === resolve(args[1], record),
125
- equalsIgnoreCase: (record, args, resolve) => {
126
- const value = resolve(args[0], record);
127
- const other = resolve(args[1], record);
128
- if (typeof value !== 'string' || typeof other !== 'string')
129
- return value === other;
130
- return value.toLowerCase() === other.toLowerCase();
131
- },
132
- startsWith: (record, args, resolve) => {
133
- const value = resolve(args[0], record);
134
- const prefix = resolve(args[1], record);
135
- return typeof value === 'string' && typeof prefix === 'string' && value.startsWith(prefix);
136
- },
137
- endsWith: (record, args, resolve) => {
138
- const value = resolve(args[0], record);
139
- const suffix = resolve(args[1], record);
140
- return typeof value === 'string' && typeof suffix === 'string' && value.endsWith(suffix);
141
- },
142
- dateIsToday: (record, args, resolve) => {
143
- const day = toEpochDay(resolve(args[0], record));
144
- return day !== null && day === toEpochDay(Date.now());
145
- },
146
- dateIsEqual: (record, args, resolve) => compareByDay(record, args, resolve, (a, b) => a === b),
147
- dateIsGreaterEqual: (record, args, resolve) => compareByDay(record, args, resolve, (a, b) => a >= b),
148
- dateIsLessEqual: (record, args, resolve) => compareByDay(record, args, resolve, (a, b) => a <= b),
149
- dateTimeIsEqual: (record, args, resolve) => compareByTime(record, args, resolve, (a, b) => a === b),
150
- dateTimeIsGreaterEqual: (record, args, resolve) => compareByTime(record, args, resolve, (a, b) => a >= b),
151
- dateTimeIsLessEqual: (record, args, resolve) => compareByTime(record, args, resolve, (a, b) => a <= b),
152
- dateTimeIsAfter: (record, args, resolve) => compareByTime(record, args, resolve, (a, b) => a >= b),
153
- dateTimeIsBefore: (record, args, resolve) => compareByTime(record, args, resolve, (a, b) => a <= b),
154
- };
155
- function compareByDay(record, args, resolve, cmp) {
156
- const a = toEpochDay(resolve(args[0], record));
157
- const b = toEpochDay(resolve(args[1], record));
158
- return a !== null && b !== null && cmp(a, b);
159
- }
160
- function compareByTime(record, args, resolve, cmp) {
161
- const a = parseGenesisDateTime(resolve(args[0], record));
162
- const b = parseGenesisDateTime(resolve(args[1], record));
163
- return a !== null && b !== null && cmp(a, b);
97
+ if (typeof value === 'string')
98
+ return value.length > 0;
99
+ if (typeof value === 'number')
100
+ return value !== 0;
101
+ return true;
164
102
  }
103
+ const COMPARATORS = new Set(['==', '=', '!=', '<>', '<', '<=', '>', '>=']);
104
+ // CriteriaValidatorVisitor's allowedConstructors (GSF v8.15.29).
105
+ const CONSTRUCTORS = {
106
+ BigDecimal: 'BigDecimal',
107
+ 'java.math.BigDecimal': 'BigDecimal',
108
+ Integer: 'Integer',
109
+ 'java.lang.Integer': 'Integer',
110
+ Long: 'Long',
111
+ 'java.lang.Long': 'Long',
112
+ Double: 'Double',
113
+ 'java.lang.Double': 'Double',
114
+ String: 'String',
115
+ 'java.lang.String': 'String',
116
+ };
117
+ const NAME = /^[A-Za-z_][A-Za-z0-9_.]*$/;
165
118
  class Parser {
166
119
  tokens;
167
120
  pos = 0;
168
- // Every field the expression reads: bare identifiers in comparisons, and
169
- // function-call arguments and receivers.
121
+ // Every field the expression reads: field operands, call arguments and
122
+ // call receivers.
170
123
  fields = new Set();
171
124
  constructor(tokens) {
172
125
  this.tokens = tokens;
@@ -182,16 +135,11 @@ class Parser {
182
135
  this.pos += 1;
183
136
  return token;
184
137
  }
185
- parseExpression() {
186
- return this.parseOr();
187
- }
188
138
  parseOr() {
189
139
  let left = this.parseAnd();
190
140
  while (this.peek() === '||' || this.peek()?.toUpperCase() === 'OR') {
191
141
  this.next();
192
- const right = this.parseAnd();
193
- const prev = left;
194
- left = (record) => prev(record) || right(record);
142
+ left = { kind: 'or', left, right: this.parseAnd() };
195
143
  }
196
144
  return left;
197
145
  }
@@ -199,9 +147,7 @@ class Parser {
199
147
  let left = this.parseNot();
200
148
  while (this.peek() === '&&' || this.peek()?.toUpperCase() === 'AND') {
201
149
  this.next();
202
- const right = this.parseNot();
203
- const prev = left;
204
- left = (record) => prev(record) && right(record);
150
+ left = { kind: 'and', left, right: this.parseNot() };
205
151
  }
206
152
  return left;
207
153
  }
@@ -209,133 +155,556 @@ class Parser {
209
155
  const next = this.peek();
210
156
  if (next === '!' || next?.toUpperCase() === 'NOT') {
211
157
  this.next();
212
- const operand = this.parseNot();
213
- return (record) => !operand(record);
158
+ return { kind: 'not', operand: this.parseNot() };
214
159
  }
215
- return this.parseComparison();
160
+ return this.parsePrimary();
216
161
  }
217
- parseComparison() {
162
+ parsePrimary() {
218
163
  if (this.peek() === '(') {
219
164
  this.next();
220
- const inner = this.parseExpression();
165
+ const inner = this.parseOr();
221
166
  if (this.next() !== ')')
222
167
  throw new CriteriaSyntaxError('Missing closing parenthesis in criteria');
223
168
  return inner;
224
169
  }
225
- const leftToken = this.next();
226
- if (leftToken === undefined) {
227
- throw new CriteriaSyntaxError('Expected operand on the left side of comparison');
228
- }
229
- // Both function-call dialects arrive as one dotted token followed by '(':
230
- // 'Expr.containsIgnoreCase' (foundation-criteria) or
231
- // 'DEAL_CURRENCIES.containsIgnoreCase' (field method-call form).
232
- if (leftToken.includes('.') && this.peek() === '(') {
233
- return this.parseFunctionCall(leftToken);
234
- }
170
+ const left = this.parseOperand();
235
171
  const op = this.peek();
236
- if (!op || !COMPARATORS.has(op)) {
237
- throw new CriteriaSyntaxError(`Expected comparator, got: ${op}`);
238
- }
239
- this.next();
240
- const rightToken = this.next();
241
- if (rightToken === undefined) {
242
- throw new CriteriaSyntaxError('Expected operand on the right side of comparison');
172
+ if (op !== undefined && COMPARATORS.has(op)) {
173
+ this.next();
174
+ return { kind: 'compare', op, left, right: this.parseOperand() };
243
175
  }
244
- this.noteField(leftToken);
245
- this.noteField(rightToken);
246
- const resolveLeft = (record) => this.resolveValue(leftToken, record);
247
- const resolveRight = (record) => this.resolveValue(rightToken, record);
248
- return (record) => {
249
- const a = resolveLeft(record);
250
- const b = resolveRight(record);
251
- switch (op) {
252
- case '==':
253
- case '=':
254
- return a === b;
255
- case '!=':
256
- case '<>':
257
- return a !== b;
258
- case '>':
259
- return a > b;
260
- case '>=':
261
- return a >= b;
262
- case '<':
263
- return a < b;
264
- case '<=':
265
- return a <= b;
266
- default:
267
- return false;
268
- }
269
- };
270
- }
271
- noteField(token) {
272
- if (parseLiteral(token) === undefined)
273
- this.fields.add(token);
176
+ // An operand alone is the condition, by Groovy truth (`ENABLED`,
177
+ // `Expr.isNullOrBlank(NAME)`).
178
+ return left;
274
179
  }
275
- resolveValue(token, record) {
180
+ parseOperand() {
181
+ const token = this.next();
182
+ if (token === undefined)
183
+ throw new CriteriaSyntaxError('Expected an operand');
184
+ if (token === 'new' && this.tokens[this.pos + 1] === '(')
185
+ return this.parseConstructor();
186
+ if (token.includes('.') && this.peek() === '(')
187
+ return this.parseCall(token);
276
188
  const literal = parseLiteral(token);
277
- if (literal !== undefined)
278
- return literal;
279
- return record[token];
189
+ if (literal)
190
+ return { kind: 'literal', ...literal };
191
+ if (!NAME.test(token))
192
+ throw new CriteriaSyntaxError(`Unexpected token: ${token}`);
193
+ this.fields.add(token);
194
+ return { kind: 'field', name: token };
280
195
  }
281
- parseFunctionCall(name) {
282
- this.next(); // consume '('
196
+ // The arguments after a call's or a constructor's '('.
197
+ parseArguments(name) {
198
+ this.next(); // '('
283
199
  const args = [];
284
200
  if (this.peek() !== ')') {
285
- const arg = this.next();
286
- if (arg === undefined)
287
- throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
288
- args.push(arg);
201
+ args.push(this.parseOperand());
289
202
  while (this.peek() === ',') {
290
203
  this.next();
291
- const nextArg = this.next();
292
- if (nextArg === undefined)
293
- throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
294
- args.push(nextArg);
204
+ args.push(this.parseOperand());
295
205
  }
296
206
  }
297
207
  if (this.next() !== ')')
298
208
  throw new CriteriaSyntaxError(`Missing closing parenthesis in ${name}(...)`);
299
- // 'Expr.<fn>(field, value)' passes args through as-is; the method-call
300
- // form '<FIELD>.<fn>(value)' makes the receiver field the implicit first
301
- // argument, so both dialects share EXPR_FUNCTIONS.
302
- const dotIndex = name.lastIndexOf('.');
303
- const receiver = name.slice(0, dotIndex);
304
- const fnName = name.slice(dotIndex + 1);
305
- const handler = EXPR_FUNCTIONS[fnName];
306
- if (!handler)
307
- throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`);
308
- const allArgs = receiver === 'Expr' ? args : [receiver, ...args];
309
- for (const arg of allArgs)
310
- this.noteField(arg);
311
- const resolve = (token, record) => this.resolveValue(token, record);
312
- return (record) => handler(record, allArgs, resolve);
209
+ return args;
210
+ }
211
+ // `new BigDecimal('1.5')`: the constructors GSF's validator allows.
212
+ parseConstructor() {
213
+ const name = this.next();
214
+ const type = Object.hasOwn(CONSTRUCTORS, name) ? CONSTRUCTORS[name] : undefined;
215
+ if (!type)
216
+ throw new UnsupportedCriteriaError(`Unsupported constructor: new ${name}`);
217
+ return { kind: 'ctor', type, args: this.parseArguments(`new ${name}`) };
218
+ }
219
+ parseCall(name) {
220
+ const args = this.parseArguments(name);
221
+ const dot = name.lastIndexOf('.');
222
+ const [receiver, method] = [name.slice(0, dot), name.slice(dot + 1)];
223
+ if (receiver === 'Expr') {
224
+ if (!Object.hasOwn(EXPR_FUNCTIONS, method)) {
225
+ throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`);
226
+ }
227
+ }
228
+ else {
229
+ if (!Object.hasOwn(FIELD_METHODS, method) && !Object.hasOwn(EXPR_FUNCTIONS, method)) {
230
+ throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`);
231
+ }
232
+ this.fields.add(receiver);
233
+ }
234
+ return { kind: 'call', receiver, method, args };
235
+ }
236
+ }
237
+ function parse(criteria) {
238
+ const tokens = tokenizeCriteria(criteria.trim()).map((token) => token.text);
239
+ const parser = new Parser(tokens);
240
+ const root = parser.parseOr();
241
+ if (parser.position !== tokens.length) {
242
+ throw new CriteriaSyntaxError(`Unexpected trailing tokens in criteria: ${criteria}`);
243
+ }
244
+ return { root, fields: [...parser.fields] };
245
+ }
246
+ // --- argument readers --------------------------------------------------------
247
+ // A date in one of GSF's formats; for `untyped` text (a field without a
248
+ // declared type: mock data) ISO-8601 and epoch-millis text as well.
249
+ function instantOfText(text, untyped) {
250
+ if (!untyped)
251
+ return gsf.stringDateTime(text);
252
+ try {
253
+ return gsf.stringDateTime(text);
254
+ }
255
+ catch (error) {
256
+ const millis = epochMillis(text);
257
+ if (millis === undefined)
258
+ throw error;
259
+ return millis;
260
+ }
261
+ }
262
+ const dayOfText = (text, untyped) => gsf.epochDay(instantOfText(text, untyped));
263
+ const isWholeNumber = (value) => typeof value === 'number' || (typeof value === 'string' && /^-?\d+$/.test(value));
264
+ const LONG_TYPES = new Set(['Long', 'Integer', 'Short']);
265
+ // The first argument of a date function, by the overload GSF picks: an
266
+ // instant for a DateTime or a Long (epoch millis), text for a String.
267
+ function dateOperand(arg) {
268
+ if (arg === undefined)
269
+ return evaluationError('Missing argument');
270
+ const { value, type } = arg;
271
+ if (isNullish(value))
272
+ return evaluationError(NULL_POINTER);
273
+ if (value instanceof DateTimeValue)
274
+ return { millis: value.millis };
275
+ if (LONG_TYPES.has(type) || (type === undefined && typeof value === 'number')) {
276
+ return isWholeNumber(value)
277
+ ? { millis: Number(value) }
278
+ : evaluationError(`Not a Long: ${String(value)}`);
279
+ }
280
+ if ((type === 'String' || type === undefined) && typeof value === 'string')
281
+ return { text: value };
282
+ return evaluationError(`No date overload for ${String(value)}`);
283
+ }
284
+ // A String argument: null stays null.
285
+ function stringArg(arg) {
286
+ if (arg === undefined)
287
+ return evaluationError('Missing argument');
288
+ if (isNullish(arg.value))
289
+ return null;
290
+ return typeof arg.value === 'string'
291
+ ? arg.value
292
+ : evaluationError(`Not a String: ${String(arg.value)}`);
293
+ }
294
+ // A String argument that is dereferenced: null throws.
295
+ function requiredString(arg) {
296
+ return stringArg(arg) ?? evaluationError(NULL_POINTER);
297
+ }
298
+ // The delimiter of containsWordsStartingWithIgnoreCase: its first character.
299
+ function delimiterArg(arg) {
300
+ if (arg === undefined)
301
+ return undefined;
302
+ const text = requiredString(arg);
303
+ return text.length > 0 ? text[0] : evaluationError('String index out of range: 0');
304
+ }
305
+ function instantOf(arg) {
306
+ const operand = dateOperand(arg);
307
+ return 'millis' in operand ? operand.millis : instantOfText(operand.text, arg.untyped === true);
308
+ }
309
+ // dateIs*(date, comparableDate): by day. A DateTime or Long date is compared
310
+ // with a yyyyMMdd date only; a String date and its comparable with any of
311
+ // GSF's formats.
312
+ function compareDays(compare) {
313
+ return ([date, comparable]) => {
314
+ const operand = dateOperand(date);
315
+ const other = requiredString(comparable);
316
+ if ('millis' in operand) {
317
+ return compare(gsf.epochDay(operand.millis), gsf.singlePatternLocalDate(other));
318
+ }
319
+ const day = dayOfText(operand.text, date.untyped === true);
320
+ return compare(day, gsf.stringLocalDate(other));
321
+ };
322
+ }
323
+ // dateTimeIs*(date, comparableDate): by instant.
324
+ function compareInstants(compare) {
325
+ return ([date, comparable]) => compare(instantOf(date), gsf.stringDateTime(requiredString(comparable)));
326
+ }
327
+ function isNullOrBlank([text]) {
328
+ const value = stringArg(text);
329
+ return value === null || gsf.isBlank(value);
330
+ }
331
+ // A mock row may hold a list in a field (DEAL_CURRENCIES: ['USD', 'GBP']):
332
+ // contains / containsIgnoreCase then test membership. GSF has no such field.
333
+ function listMember(list, needle, ignoreCase) {
334
+ const search = needle === undefined ? null : stringArg(needle);
335
+ if (search === null)
336
+ return false;
337
+ return list.some((item) => typeof item === 'string' &&
338
+ (ignoreCase ? item.toLowerCase() === search.toLowerCase() : item === search));
339
+ }
340
+ function containsIgnoreCase([text, search]) {
341
+ if (Array.isArray(text?.value))
342
+ return listMember(text.value, search, true);
343
+ return gsf.containsIgnoreCase(stringArg(text), stringArg(search));
344
+ }
345
+ function containsWordsStartingWithIgnoreCase([text, search, delimiter]) {
346
+ const separator = delimiterArg(delimiter);
347
+ return gsf.containsWordsStartingWithIgnoreCase(stringArg(text), stringArg(search), separator);
348
+ }
349
+ function dateIsToday([date]) {
350
+ const operand = dateOperand(date);
351
+ const day = 'millis' in operand
352
+ ? gsf.epochDay(operand.millis)
353
+ : dayOfText(operand.text, date.untyped === true);
354
+ return day === gsf.today();
355
+ }
356
+ function longArg(arg) {
357
+ if (arg === undefined)
358
+ return evaluationError('Missing argument');
359
+ if (isNullish(arg.value))
360
+ return evaluationError(NULL_POINTER);
361
+ return isWholeNumber(arg.value)
362
+ ? Number(arg.value)
363
+ : evaluationError(`Not a Long: ${String(arg.value)}`);
364
+ }
365
+ // What this engine evaluated before GSF's semantics, kept for fidelity:
366
+ // 'legacy' (strict criteria refuse them: GSF's validator doesn't allow them).
367
+ const legacyFunctions = {
368
+ contains: ([text, search]) => {
369
+ if (Array.isArray(text?.value))
370
+ return listMember(text.value, search, false);
371
+ return (typeof text?.value === 'string' &&
372
+ typeof search?.value === 'string' &&
373
+ text.value.includes(search.value));
374
+ },
375
+ equals: ([a, b]) => a?.value === b?.value,
376
+ equalsIgnoreCase: ([a, b]) => {
377
+ if (typeof a?.value !== 'string' || typeof b?.value !== 'string')
378
+ return a?.value === b?.value;
379
+ return a.value.toLowerCase() === b.value.toLowerCase();
380
+ },
381
+ startsWith: ([text, prefix]) => typeof text?.value === 'string' &&
382
+ typeof prefix?.value === 'string' &&
383
+ text.value.startsWith(prefix.value),
384
+ endsWith: ([text, suffix]) => typeof text?.value === 'string' &&
385
+ typeof suffix?.value === 'string' &&
386
+ text.value.endsWith(suffix.value),
387
+ dateTimeIsEqual: compareInstants((a, b) => a === b),
388
+ };
389
+ // Expr.java (GSF v8.15.29), every function its validator allows plus
390
+ // stringDateTime (GSF has it, its validator refuses it).
391
+ const EXPR_FUNCTIONS = {
392
+ ...legacyFunctions,
393
+ isNullOrBlank,
394
+ notNullOrBlank: (args) => !isNullOrBlank(args),
395
+ containsIgnoreCase,
396
+ containsWordsStartingWithIgnoreCase,
397
+ dateIsToday,
398
+ dateIsEqual: compareDays((a, b) => a === b),
399
+ dateIsBefore: compareDays((a, b) => a < b),
400
+ dateIsAfter: compareDays((a, b) => a > b),
401
+ dateIsGreaterEqual: compareDays((a, b) => a >= b),
402
+ dateIsLessEqual: compareDays((a, b) => a <= b),
403
+ dateTimeIsAfter: compareInstants((a, b) => a > b),
404
+ dateTimeIsGreaterEqual: compareInstants((a, b) => a >= b),
405
+ dateTimeIsBefore: compareInstants((a, b) => a < b),
406
+ dateTimeIsLessEqual: compareInstants((a, b) => a <= b),
407
+ dateTimeIsInRange: ([date, range, context]) => {
408
+ const [rangeName, contextName] = [requiredString(range), requiredString(context)];
409
+ return gsf.dateTimeIsInRange(instantOf(date), rangeName, contextName);
410
+ },
411
+ longLocalDate: ([date]) => new LocalDateValue(gsf.epochDay(longArg(date))),
412
+ longDateTime: ([date]) => new DateTimeValue(longArg(date)),
413
+ stringLocalDate: ([date]) => new LocalDateValue(dayOfText(requiredString(date), date.untyped === true)),
414
+ stringDateTime: ([date]) => new DateTimeValue(instantOfText(requiredString(date), date.untyped === true)),
415
+ };
416
+ // --- field methods ------------------------------------------------------------
417
+ // Object.equals: the same Java class and value. A field without a known type
418
+ // equals a value of the same kind.
419
+ function javaEquals(receiver, other) {
420
+ const [a, b] = [receiver.value, other.value];
421
+ if (isNullish(b))
422
+ return false;
423
+ if (a instanceof DateTimeValue)
424
+ return b instanceof DateTimeValue && a.millis === b.millis;
425
+ if (a instanceof LocalDateValue)
426
+ return b instanceof LocalDateValue && a.day === b.day;
427
+ if (typeof a === 'number' && typeof b === 'number') {
428
+ // Long.equals(5) is false: 5 is an Integer.
429
+ if (receiver.type && other.type && receiver.type !== other.type)
430
+ return false;
431
+ return a === b;
313
432
  }
433
+ return a === b;
434
+ }
435
+ // The receiver of a String method: null throws (the call dereferences it).
436
+ function stringReceiver(receiver) {
437
+ if (isNullish(receiver.value))
438
+ return evaluationError(NULL_POINTER);
439
+ return typeof receiver.value === 'string'
440
+ ? receiver.value
441
+ : evaluationError(`Not a String: ${String(receiver.value)}`);
442
+ }
443
+ // String's own methods, then the ExtraOperators.kt extensions (whose String?
444
+ // receiver may be null: containsIgnoreCase and
445
+ // containsWordsStartingWithIgnoreCase answer false for it).
446
+ const FIELD_METHODS = {
447
+ startsWith: (receiver, [prefix, offset]) => {
448
+ const text = stringReceiver(receiver);
449
+ const start = requiredString(prefix);
450
+ const from = offset === undefined ? 0 : longArg(offset);
451
+ if (from < 0 || from > text.length - start.length)
452
+ return false;
453
+ return text.startsWith(start, from);
454
+ },
455
+ equals: (receiver, [other]) => {
456
+ if (isNullish(receiver.value))
457
+ return evaluationError(NULL_POINTER);
458
+ return other !== undefined && javaEquals(receiver, other);
459
+ },
460
+ equalsIgnoreCase: (receiver, [other]) => {
461
+ const text = stringReceiver(receiver);
462
+ const value = stringArg(other);
463
+ return value !== null && value.length === text.length && gsf.containsIgnoreCase(text, value);
464
+ },
465
+ contains: (receiver, [search]) => {
466
+ if (Array.isArray(receiver.value))
467
+ return listMember(receiver.value, search, false);
468
+ return stringReceiver(receiver).includes(requiredString(search));
469
+ },
470
+ containsIgnoreCase: (receiver, [search]) => containsIgnoreCase([receiver, search]),
471
+ containsWordsStartingWithIgnoreCase: (receiver, [search, delimiter]) => containsWordsStartingWithIgnoreCase([receiver, search, ...(delimiter ? [delimiter] : [])]),
472
+ dateIsToday: (receiver) => dateIsToday([receiver]),
473
+ };
474
+ // --- comparisons ----------------------------------------------------------------
475
+ // Groovy orders null before everything.
476
+ function compareNullable(a, b) {
477
+ if (!isNullish(a) && !isNullish(b))
478
+ return undefined;
479
+ if (isNullish(a) && isNullish(b))
480
+ return 0;
481
+ return isNullish(a) ? -1 : 1;
482
+ }
483
+ function fromOrder(op, order) {
484
+ switch (op) {
485
+ case '==':
486
+ case '=':
487
+ return order === 0;
488
+ case '!=':
489
+ case '<>':
490
+ return order !== 0;
491
+ case '<':
492
+ return order < 0;
493
+ case '<=':
494
+ return order <= 0;
495
+ case '>':
496
+ return order > 0;
497
+ default:
498
+ return order >= 0;
499
+ }
500
+ }
501
+ const EQUALITY = new Set(['==', '=', '!=', '<>']);
502
+ // What a DateTime compares with (ExtraOperators.kt compareTo): another
503
+ // DateTime, epoch millis (a Long or an int), a date string. undefined for
504
+ // anything else — a Double, a BigDecimal, a Boolean.
505
+ function instantOperand(operand) {
506
+ const { value, type } = operand;
507
+ if (value instanceof DateTimeValue)
508
+ return value.millis;
509
+ if (typeof value === 'number') {
510
+ return type === 'Double' || type === 'BigDecimal' ? undefined : value;
511
+ }
512
+ if (typeof value === 'string')
513
+ return gsf.stringDateTime(value);
514
+ return undefined;
515
+ }
516
+ // A comparison with a DateTime or a LocalDate on one side. `==` takes a
517
+ // DateTime equal to a date string naming its instant, and never to a
518
+ // one-character string literal (Groovy types that as a char; a field's
519
+ // one-character value is parsed like any other), a Double, a BigDecimal or a
520
+ // Boolean; an unparseable date string throws. A relational
521
+ // operator throws for a pair it can't compare.
522
+ function compareDates(op, a, b) {
523
+ const equality = EQUALITY.has(op);
524
+ const unrelated = () => equality ? op === '!=' || op === '<>' : evaluationError('Cannot compare these values');
525
+ if (a.value instanceof LocalDateValue || b.value instanceof LocalDateValue) {
526
+ if (a.value instanceof LocalDateValue && b.value instanceof LocalDateValue) {
527
+ return fromOrder(op, Math.sign(a.value.day - b.value.day));
528
+ }
529
+ return unrelated();
530
+ }
531
+ const char = (operand) => operand.literal === true && typeof operand.value === 'string' && operand.value.length === 1;
532
+ if (equality && (char(a) || char(b)))
533
+ return unrelated();
534
+ const [x, y] = [instantOperand(a), instantOperand(b)];
535
+ if (x === undefined || y === undefined)
536
+ return unrelated();
537
+ return fromOrder(op, Math.sign(x - y));
538
+ }
539
+ const isDated = (operand) => operand.value instanceof DateTimeValue || operand.value instanceof LocalDateValue;
540
+ function compareValues(op, left, right) {
541
+ const [a, b] = [left.value, right.value];
542
+ const order = compareNullable(a, b);
543
+ if (order !== undefined)
544
+ return fromOrder(op, order);
545
+ if (isDated(left) || isDated(right))
546
+ return compareDates(op, left, right);
547
+ switch (op) {
548
+ case '==':
549
+ case '=':
550
+ return a === b;
551
+ case '!=':
552
+ case '<>':
553
+ return a !== b;
554
+ case '>':
555
+ return a > b;
556
+ case '>=':
557
+ return a >= b;
558
+ case '<':
559
+ return a < b;
560
+ default:
561
+ return a <= b;
562
+ }
563
+ }
564
+ // A field as the entity's getter returns it: a DateTime for DATE and
565
+ // DATETIME, a String for STRING and ENUM.
566
+ function readField(name, type, record) {
567
+ const raw = record[name] ?? null;
568
+ if (type === undefined)
569
+ return { value: raw, untyped: true };
570
+ if (raw === null)
571
+ return { value: null, type };
572
+ if (type === 'DateTime') {
573
+ const millis = epochMillis(raw);
574
+ return millis === undefined
575
+ ? evaluationError(`Not a date: ${String(raw)}`)
576
+ : { value: new DateTimeValue(millis), type };
577
+ }
578
+ if (type === 'String' && (typeof raw === 'number' || typeof raw === 'boolean')) {
579
+ return { value: String(raw), type };
580
+ }
581
+ return { value: raw, type };
582
+ }
583
+ function compileNode(node, options) {
584
+ switch (node.kind) {
585
+ case 'literal': {
586
+ const value = { value: node.value, type: node.type, literal: true };
587
+ return () => value;
588
+ }
589
+ case 'field': {
590
+ const typeName = options.types?.get(node.name);
591
+ const type = typeName ? ENTITY_TYPES[typeName] : undefined;
592
+ return (record) => readField(node.name, type, record);
593
+ }
594
+ case 'compare': {
595
+ const [left, right] = [compileNode(node.left, options), compileNode(node.right, options)];
596
+ return (record) => ({
597
+ value: compareValues(node.op, left(record), right(record)),
598
+ type: 'Boolean',
599
+ });
600
+ }
601
+ case 'and':
602
+ case 'or': {
603
+ const [left, right] = [compileNode(node.left, options), compileNode(node.right, options)];
604
+ const and = node.kind === 'and';
605
+ return (record) => {
606
+ const first = groovyTruth(left(record).value);
607
+ if (and ? !first : first)
608
+ return { value: first, type: 'Boolean' };
609
+ return { value: groovyTruth(right(record).value), type: 'Boolean' };
610
+ };
611
+ }
612
+ case 'not': {
613
+ const operand = compileNode(node.operand, options);
614
+ return (record) => ({ value: !groovyTruth(operand(record).value), type: 'Boolean' });
615
+ }
616
+ case 'call':
617
+ return compileCall(node, options);
618
+ case 'ctor': {
619
+ const args = node.args.map((arg) => compileNode(arg, options));
620
+ return (record) => ({
621
+ value: construct(node.type, args.map((arg) => arg(record))),
622
+ type: node.type,
623
+ });
624
+ }
625
+ }
626
+ }
627
+ const JAVA_DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
628
+ const JAVA_INTEGER = /^[+-]?\d+$/;
629
+ const JAVA_DOUBLE = /^[+-]?(?:(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?[dDfF]?|Infinity|NaN)$/;
630
+ // `new X(arg)`: the value Java's constructor makes, or the exception it
631
+ // throws (a NumberFormatException for text that isn't the number).
632
+ function construct(type, args) {
633
+ if (type === 'String' && args.length === 0)
634
+ return '';
635
+ if (args.length !== 1)
636
+ return evaluationError('No such constructor');
637
+ const { value } = args[0];
638
+ if (isNullish(value))
639
+ return evaluationError(NULL_POINTER);
640
+ if (type === 'String')
641
+ return typeof value === 'string' ? value : String(value);
642
+ if (typeof value === 'number')
643
+ return value;
644
+ if (typeof value !== 'string')
645
+ return evaluationError('No such constructor');
646
+ const text = type === 'Double' ? value.trim() : value; // only Double.parseDouble trims
647
+ const pattern = type === 'BigDecimal' ? JAVA_DECIMAL : type === 'Double' ? JAVA_DOUBLE : JAVA_INTEGER;
648
+ if (!pattern.test(text))
649
+ return evaluationError(`NumberFormatException: ${value}`);
650
+ const number = Number(text.replace(/[dDfF]$/, ''));
651
+ if (type === 'Integer' && (number < INT_MIN || number > INT_MAX)) {
652
+ return evaluationError(`NumberFormatException: ${value}`);
653
+ }
654
+ return number;
655
+ }
656
+ function compileCall(node, options) {
657
+ const args = node.args.map((arg) => compileNode(arg, options));
658
+ const readArgs = (record) => args.map((arg) => arg(record));
659
+ if (node.receiver === 'Expr') {
660
+ const fn = EXPR_FUNCTIONS[node.method];
661
+ return (record) => ({ value: fn(readArgs(record)) });
662
+ }
663
+ const receiver = compileNode({ kind: 'field', name: node.receiver }, options);
664
+ const method = Object.hasOwn(FIELD_METHODS, node.method) ? FIELD_METHODS[node.method] : undefined;
665
+ if (method) {
666
+ return (record) => ({
667
+ value: method(receiver(record), readArgs(record)),
668
+ type: 'Boolean',
669
+ });
670
+ }
671
+ // fidelity: 'legacy' — an Expr function called on its first argument.
672
+ const fn = EXPR_FUNCTIONS[node.method];
673
+ return (record) => ({ value: fn([receiver(record), ...readArgs(record)]) });
314
674
  }
315
675
  // Parses a CRITERIA_MATCH without failing open: throws CriteriaSyntaxError for
316
676
  // a malformed one and UnsupportedCriteriaError for a call to a function the
317
- // grammar doesn't have. A blank criteria matches everything.
318
- export function parseCriteria(criteria) {
677
+ // grammar doesn't have. A blank criteria matches everything. A row the
678
+ // criteria throws on (gsfExpr.ts) doesn't match.
679
+ export function parseCriteria(criteria, options = {}) {
319
680
  if (!criteria || typeof criteria !== 'string' || criteria.trim() === '') {
320
681
  return { predicate: () => true, fields: [] };
321
682
  }
322
- const tokens = tokenize(criteria.trim());
323
- const parser = new Parser(tokens);
324
- const predicate = parser.parseExpression();
325
- if (parser.position !== tokens.length) {
326
- throw new CriteriaSyntaxError(`Unexpected trailing tokens in criteria: ${criteria}`);
327
- }
328
- return { predicate, fields: [...parser.fields] };
683
+ if (options.lenient)
684
+ return parseLegacyCriteria(criteria);
685
+ const { root, fields } = parse(criteria);
686
+ const evaluate = compileNode(root, options);
687
+ const predicate = (record) => {
688
+ try {
689
+ return groovyTruth(evaluate(record).value);
690
+ }
691
+ catch (error) {
692
+ if (error instanceof CriteriaEvaluationError)
693
+ return false;
694
+ throw error;
695
+ }
696
+ };
697
+ return { predicate, fields };
329
698
  }
330
699
  /** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */
331
- export function compileCriteria(criteria) {
700
+ export function compileCriteria(criteria, options = {}) {
332
701
  // Real Genesis clients can emit CRITERIA_MATCH syntax this grammar doesn't
333
- // cover yet (e.g. function-call criteria like Expr.dateTimeIsGreaterEqual(...)
334
- // from date-range filters). Failing open — matching everything and logging —
335
- // keeps DATA_LOGON/broadcast working (unfiltered) instead of crashing the
336
- // handler and leaving the client's grid stuck on "Loading...".
702
+ // cover. Failing open — matching everything and logging — keeps
703
+ // DATA_LOGON/broadcast working (unfiltered) instead of crashing the handler
704
+ // and leaving the client's grid stuck on "Loading...". Strict criteria
705
+ // refuse what GSF refuses before they get here.
337
706
  try {
338
- return parseCriteria(criteria).predicate;
707
+ return parseCriteria(criteria, options).predicate;
339
708
  }
340
709
  catch (error) {
341
710
  console.warn(`[mock-server] Unsupported CRITERIA_MATCH syntax, ignoring filter: ${criteria}`, error);
@@ -353,8 +722,8 @@ export function criteriaFields(criteria) {
353
722
  return undefined;
354
723
  }
355
724
  }
356
- export function filterByCriteria(rows, criteria) {
357
- const predicate = compileCriteria(criteria);
725
+ export function filterByCriteria(rows, criteria, options = {}) {
726
+ const predicate = compileCriteria(criteria, options);
358
727
  return rows.filter((row) => {
359
728
  try {
360
729
  return predicate(row);
@@ -364,3 +733,232 @@ export function filterByCriteria(rows, criteria) {
364
733
  }
365
734
  });
366
735
  }
736
+ const S = 'String';
737
+ const DATE_OVERLOADS = [
738
+ [S, S],
739
+ ['Long', S],
740
+ ['DateTime', S],
741
+ ];
742
+ // Expr.java's overloads, and what each function returns.
743
+ const EXPR_SIGNATURES = {
744
+ isNullOrBlank: { overloads: [[S]], returns: 'Boolean' },
745
+ notNullOrBlank: { overloads: [[S]], returns: 'Boolean' },
746
+ containsIgnoreCase: { overloads: [[S, S]], returns: 'Boolean' },
747
+ containsWordsStartingWithIgnoreCase: {
748
+ overloads: [
749
+ [S, S],
750
+ [S, S, S],
751
+ [S, S, 'Character'],
752
+ ],
753
+ returns: 'Boolean',
754
+ },
755
+ dateIsToday: { overloads: [['Long'], [S], ['DateTime']], returns: 'Boolean' },
756
+ ...Object.fromEntries([
757
+ 'dateIsEqual',
758
+ 'dateIsBefore',
759
+ 'dateIsAfter',
760
+ 'dateIsGreaterEqual',
761
+ 'dateIsLessEqual',
762
+ 'dateTimeIsAfter',
763
+ 'dateTimeIsGreaterEqual',
764
+ 'dateTimeIsBefore',
765
+ 'dateTimeIsLessEqual',
766
+ ].map((name) => [name, { overloads: DATE_OVERLOADS, returns: 'Boolean' }])),
767
+ dateTimeIsInRange: {
768
+ overloads: [
769
+ ['DateTime', S, S],
770
+ ['Long', S, S],
771
+ [S, S, S],
772
+ ],
773
+ returns: 'Boolean',
774
+ },
775
+ longLocalDate: { overloads: [['Long']], returns: 'LocalDate' },
776
+ stringLocalDate: { overloads: [[S]], returns: 'LocalDate' },
777
+ longDateTime: { overloads: [['Long']], returns: 'DateTime' },
778
+ stringDateTime: { overloads: [[S]], returns: 'DateTime' },
779
+ };
780
+ const EQUALS = [['Object']];
781
+ const TODAY = [[]];
782
+ // The field methods each receiver type has (String's, Object.equals and the
783
+ // ExtraOperators.kt extensions, whose delimiter is a primitive char).
784
+ const FIELD_SIGNATURES = {
785
+ String: {
786
+ startsWith: [[S], [S, 'int']],
787
+ equals: EQUALS,
788
+ equalsIgnoreCase: [[S]],
789
+ contains: [['CharSequence']],
790
+ containsIgnoreCase: [[S]],
791
+ containsWordsStartingWithIgnoreCase: [[S], [S, S], [S, 'char']],
792
+ dateIsToday: TODAY,
793
+ },
794
+ Long: { equals: EQUALS, dateIsToday: TODAY },
795
+ DateTime: { equals: EQUALS, dateIsToday: TODAY },
796
+ Integer: { equals: EQUALS },
797
+ Short: { equals: EQUALS },
798
+ Double: { equals: EQUALS },
799
+ BigDecimal: { equals: EQUALS },
800
+ Boolean: { equals: EQUALS },
801
+ };
802
+ const PRIMITIVES = new Set(['int', 'char']);
803
+ // Whether Groovy's static type checker passes an argument of type `arg` for a
804
+ // parameter of type `param` (null to any object, Integer and Short widened
805
+ // to Long).
806
+ function assignable(arg, param) {
807
+ if (arg === param || param === 'Object')
808
+ return true;
809
+ if (arg === 'null')
810
+ return !PRIMITIVES.has(param);
811
+ if (param === 'CharSequence')
812
+ return arg === 'String';
813
+ if (param === 'Long')
814
+ return arg === 'Integer' || arg === 'Short';
815
+ if (param === 'int')
816
+ return arg === 'Integer' || arg === 'Short';
817
+ return false;
818
+ }
819
+ class CompileFailure extends Error {
820
+ }
821
+ const NUMBERS = new Set([
822
+ 'Integer',
823
+ 'Short',
824
+ 'Long',
825
+ 'BigInteger',
826
+ 'Double',
827
+ 'BigDecimal',
828
+ ]);
829
+ // Whether `left < right` compiles: a compareTo for the pair exists — numbers
830
+ // with numbers, a String with a String, a Boolean with a Boolean, and a
831
+ // DateTime with a DateTime, a date string or epoch millis (ExtraOperators.kt).
832
+ // Never with null on the left (Object#compareTo). A LocalDate's comparisons
833
+ // fail when they run, if at all.
834
+ function comparable(left, right) {
835
+ if (left === 'null')
836
+ return false;
837
+ if (right === 'null' || left === 'LocalDate' || right === 'LocalDate')
838
+ return true;
839
+ if (left === 'DateTime')
840
+ return ['DateTime', 'String', 'Long', 'Integer', 'Short'].includes(right);
841
+ if (right === 'DateTime')
842
+ return left === 'String' || left === 'Long';
843
+ if (NUMBERS.has(left))
844
+ return NUMBERS.has(right);
845
+ return left === right;
846
+ }
847
+ // Whether exactly one overload takes these arguments. An argument of unknown
848
+ // type passes any parameter (it can't be checked).
849
+ function checkOverloads(overloads, args) {
850
+ const matching = overloads.filter((params) => params.length === args.length &&
851
+ params.every((param, index) => args[index] === undefined || assignable(args[index], param)));
852
+ if (matching.length === 0)
853
+ throw new CompileFailure();
854
+ // Overloads that all take the arguments (a null) are ambiguous — when the
855
+ // arguments they differ on have known types.
856
+ const differing = args.flatMap((_arg, index) => matching.some((params) => params[index] !== matching[0][index]) ? [index] : []);
857
+ if (matching.length > 1 && differing.every((index) => args[index] !== undefined)) {
858
+ throw new CompileFailure();
859
+ }
860
+ }
861
+ // The type GSF's compiler gives a node, checking every call in it; undefined
862
+ // when it can't be known (a field without a declared type).
863
+ function staticType(node, types) {
864
+ switch (node.kind) {
865
+ case 'literal':
866
+ return node.type;
867
+ case 'field': {
868
+ const typeName = types?.get(node.name);
869
+ return typeName ? ENTITY_TYPES[typeName] : undefined;
870
+ }
871
+ case 'compare': {
872
+ const [left, right] = [staticType(node.left, types), staticType(node.right, types)];
873
+ const relational = !EQUALITY.has(node.op);
874
+ if (relational && left && right && !comparable(left, right))
875
+ throw new CompileFailure();
876
+ return 'Boolean';
877
+ }
878
+ case 'and':
879
+ case 'or':
880
+ staticType(node.left, types);
881
+ staticType(node.right, types);
882
+ return 'Boolean';
883
+ case 'not':
884
+ staticType(node.operand, types);
885
+ return 'Boolean';
886
+ case 'ctor':
887
+ return node.type;
888
+ case 'call': {
889
+ const args = node.args.map((arg) => staticType(arg, types));
890
+ if (node.receiver === 'Expr') {
891
+ const signature = EXPR_SIGNATURES[node.method];
892
+ if (!signature)
893
+ return undefined; // not GSF's: the validator refuses it
894
+ checkOverloads(signature.overloads, args);
895
+ return signature.returns;
896
+ }
897
+ const receiver = staticType({ kind: 'field', name: node.receiver }, types);
898
+ if (receiver === undefined || !Object.hasOwn(FIELD_METHODS, node.method))
899
+ return undefined;
900
+ const overloads = FIELD_SIGNATURES[receiver]?.[node.method];
901
+ if (!overloads)
902
+ throw new CompileFailure();
903
+ checkOverloads(overloads, args);
904
+ return 'Boolean';
905
+ }
906
+ }
907
+ }
908
+ // Guava's UPPER_UNDERSCORE to UPPER_CAMEL / LOWER_CAMEL, as GSF names getters.
909
+ function camelCase(name, upperFirst) {
910
+ const words = name.split('_').map((word, index) => {
911
+ const lowered = word.toLowerCase();
912
+ if (index === 0 && !upperFirst)
913
+ return lowered;
914
+ return lowered.charAt(0).toUpperCase() + lowered.slice(1);
915
+ });
916
+ const joined = words.join('');
917
+ return /^\d/.test(joined) ? `_${joined}` : joined;
918
+ }
919
+ // The getter call CriteriaFilterFactory puts in place of a field.
920
+ function getterOf(name, type) {
921
+ if (type === 'ENUM')
922
+ return `row.get${camelCase(name, true)}()?.toString()`;
923
+ if (type === 'BOOLEAN' && name.startsWith('IS'))
924
+ return `row.${camelCase(name, false)}()`;
925
+ return `row.get${camelCase(name, true)}()`;
926
+ }
927
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
928
+ // The INVALID_CRITERIA TEXT GSF sends when its @CompileStatic compilation
929
+ // of a criteria fails (CriteriaFilterBuilder.getCriteriaFilter, v8.15.29):
930
+ // the criteria as it was compiled — each field the validator saw it read
931
+ // replaced by the entity's getter (CriteriaFilterFactory: longest name
932
+ // first, not where a quote or a word character touches it).
933
+ export function compileFailureText(criteria, fields, types) {
934
+ let replaced = criteria;
935
+ for (const field of [...new Set(fields)].sort((a, b) => b.length - a.length)) {
936
+ const pattern = new RegExp(`(?<!["'\\w])${escapeRegExp(field)}(?!["'\\w])`, 'g');
937
+ replaced = replaced.replace(pattern, () => getterOf(field, types?.get(field)));
938
+ }
939
+ return `Error compiling criteria expression: Failed to compile criteria expression ${replaced}`;
940
+ }
941
+ // What GSF's compilation refuses in a criteria its validator passes: a call
942
+ // no overload takes — the wrong number of arguments, or (where the fields'
943
+ // types are known) an argument of the wrong type, a field method the
944
+ // field's type doesn't have, an ambiguous null — and a relational operator
945
+ // between types that don't compare (`QTY > '5'`). The INVALID_CRITERIA
946
+ // TEXT, or undefined when it compiles or doesn't parse here.
947
+ export function criteriaCompileRefusal(criteria, types) {
948
+ let parsed;
949
+ try {
950
+ parsed = parse(criteria);
951
+ }
952
+ catch {
953
+ return undefined;
954
+ }
955
+ try {
956
+ staticType(parsed.root, types);
957
+ return undefined;
958
+ }
959
+ catch (error) {
960
+ if (!(error instanceof CompileFailure))
961
+ throw error;
962
+ }
963
+ return compileFailureText(criteria, parsed.fields, types);
964
+ }