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