@genesislcap/mock-server 15.60.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.
- package/README.md +287 -40
- package/dist/db/criteria.d.ts +28 -9
- package/dist/db/criteria.js +856 -258
- package/dist/db/criteriaDialect.d.ts +6 -0
- package/dist/db/criteriaDialect.js +441 -0
- package/dist/db/criteriaTokens.d.ts +12 -0
- package/dist/db/criteriaTokens.js +53 -0
- package/dist/db/groovySyntax.d.ts +26 -0
- package/dist/db/groovySyntax.js +481 -0
- package/dist/db/groovyValidator.d.ts +11 -0
- package/dist/db/groovyValidator.js +523 -0
- package/dist/db/gsfExpr.d.ts +21 -0
- package/dist/db/gsfExpr.js +333 -0
- package/dist/db/identity.d.ts +2 -1
- package/dist/db/identity.js +24 -3
- package/dist/db/legacyCriteria.d.ts +7 -0
- package/dist/db/legacyCriteria.js +280 -0
- package/dist/db/ordering.d.ts +2 -0
- package/dist/db/ordering.js +24 -3
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +34 -3
- package/dist/db/store.d.ts +7 -0
- package/dist/db/store.js +55 -7
- package/dist/db/values.d.ts +19 -0
- package/dist/db/values.js +243 -0
- package/dist/handlers/commitEvent.js +3 -2
- package/dist/handlers/dataLogon.d.ts +2 -1
- package/dist/handlers/dataLogon.js +108 -51
- package/dist/handlers/eventValidation.d.ts +2 -1
- package/dist/handlers/eventValidation.js +33 -6
- package/dist/handlers/requestReply.js +43 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/protocol/connection.d.ts +1 -1
- package/dist/server.js +15 -8
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +979 -290
- package/src/db/criteriaDialect.ts +487 -0
- package/src/db/criteriaTokens.ts +72 -0
- package/src/db/groovySyntax.ts +500 -0
- package/src/db/groovyValidator.ts +546 -0
- package/src/db/gsfExpr.ts +392 -0
- package/src/db/identity.ts +22 -4
- package/src/db/legacyCriteria.ts +301 -0
- package/src/db/ordering.ts +27 -3
- package/src/db/schema.ts +43 -8
- package/src/db/store.ts +65 -7
- package/src/db/values.ts +247 -0
- package/src/handlers/commitEvent.ts +3 -2
- package/src/handlers/dataLogon.ts +134 -56
- package/src/handlers/eventValidation.ts +40 -13
- package/src/handlers/requestReply.ts +47 -14
- package/src/index.ts +6 -1
- package/src/protocol/connection.ts +3 -4
- package/src/server.ts +16 -15
- package/src/types.ts +7 -0
package/src/db/criteria.ts
CHANGED
|
@@ -1,208 +1,212 @@
|
|
|
1
1
|
// Evaluator for Genesis CRITERIA_MATCH expressions, e.g.:
|
|
2
|
-
// TRADE_STATUS == 'CONFIRMED'
|
|
3
|
-
//
|
|
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: ==
|
|
7
|
-
//
|
|
8
|
-
// function
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
export {
|
|
51
|
+
CriteriaSyntaxError,
|
|
52
|
+
type CriteriaToken,
|
|
53
|
+
parseGenesisDateTime,
|
|
54
|
+
tokenizeCriteria,
|
|
55
|
+
UnsupportedCriteriaError,
|
|
56
|
+
} from './criteriaTokens.ts';
|
|
28
57
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
/\s*(&&|\|\||<=|>=|!=|<>|==|=|<|>|!|\(|\)|,|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*)/g;
|
|
82
|
+
// --- values ------------------------------------------------------------------
|
|
38
83
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return
|
|
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:
|
|
205
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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():
|
|
239
|
+
private parseAnd(): Node {
|
|
242
240
|
let left = this.parseNot();
|
|
243
241
|
while (this.peek() === '&&' || this.peek()?.toUpperCase() === 'AND') {
|
|
244
242
|
this.next();
|
|
245
|
-
|
|
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():
|
|
248
|
+
private parseNot(): Node {
|
|
253
249
|
const next = this.peek();
|
|
254
250
|
if (next === '!' || next?.toUpperCase() === 'NOT') {
|
|
255
251
|
this.next();
|
|
256
|
-
|
|
257
|
-
return (record) => !operand(record);
|
|
252
|
+
return { kind: 'not', operand: this.parseNot() };
|
|
258
253
|
}
|
|
259
|
-
return this.
|
|
254
|
+
return this.parsePrimary();
|
|
260
255
|
}
|
|
261
256
|
|
|
262
|
-
|
|
257
|
+
private parsePrimary(): Node {
|
|
263
258
|
if (this.peek() === '(') {
|
|
264
259
|
this.next();
|
|
265
|
-
const inner = this.
|
|
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 (
|
|
285
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
|
329
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
const
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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(
|
|
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
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
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(
|
|
419
|
-
|
|
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
|
+
}
|