// Evaluator for Genesis CRITERIA_MATCH expressions, e.g.: // TRADE_STATUS == 'CONFIRMED' && QUANTITY > 100 // !(SIDE == 'SELL') || PRICE >= 100.5 // Expr.dateTimeIsGreaterEqual(TRADE_DATE, '20240101-00:00:00') && COUNTERPARTY_ID == 'CPTY001' // // Supports: == != > >= < <=, && || !, parentheses, string/number/boolean/null // literals, field names, and calls in both forms GSF takes: // - `Expr.(args)` — every function of GSF's Expr whitelist // (CriteriaValidatorVisitor, v8.15.29), with Expr.java's overloads; // - `.(args)` — the field methods GSF allows: String's // startsWith / equals / equalsIgnoreCase / contains and the // ExtraOperators.kt extensions containsIgnoreCase, // containsWordsStartingWithIgnoreCase and dateIsToday. // A call may be an operand (`Expr.longLocalDate(D) == Expr.stringLocalDate('20240101')`) // or an argument of another. What the functions compute, and when they throw // (making the row not match), is GSF's: see gsfExpr.ts. // // It also reads what GSF refuses — = <>, AND OR NOT, and the functions this // engine used to offer (Expr.contains, Expr.equals, Expr.equalsIgnoreCase, // Expr.startsWith, Expr.endsWith, Expr.dateTimeIsEqual, FIELD.endsWith, and // any Expr function called as a field method). Strict criteria refuse those // before they get here (criteriaDialect.ts); only fidelity: 'legacy' // evaluates them. // // Types: GSF compiles a criteria against the entity's Java types — a STRING // or ENUM field is a String, DATE and DATETIME a Joda DateTime, LONG a Long — // which picks each function's overload and lets ExtraOperators compare a // DateTime with a date string (`TRADE_DATE > '20240101'`). With // CriteriaOptions.types the evaluator does the same; a field without a known // type is read by its value (a number as a Long, a string as a String). // criteriaCompileRefusal reports what that compilation refuses. // // Deliberately NOT eval()/new Function() — this parses a small typed grammar // so a malformed or hostile criteria string can only fail to parse, never // execute arbitrary JS. import type { Row } from '../types.ts'; import { CriteriaSyntaxError, decodeStringLiteral, tokenizeCriteria, UnsupportedCriteriaError, } from './criteriaTokens.ts'; import * as gsf from './gsfExpr.ts'; import { CriteriaEvaluationError, evaluationError } from './gsfExpr.ts'; import { parseLegacyCriteria } from './legacyCriteria.ts'; import { epochMillis } from './values.ts'; export type Predicate = (record: Row) => boolean; export { CriteriaSyntaxError, type CriteriaToken, parseGenesisDateTime, tokenizeCriteria, UnsupportedCriteriaError, } from './criteriaTokens.ts'; /** * How {@link compileCriteria} and {@link filterByCriteria} read a criteria. * Without options they evaluate it as GSF 8.15 does, each field read by its * value. * @public */ export interface CriteriaOptions { /** * Each field's Genesis type (`STRING`, `DATETIME`, ...), where it is * declared: GSF compiles a criteria against these, which picks each * function's overload (a `DATETIME` is a Joda DateTime, a `STRING` a * String). */ types?: ReadonlyMap; /** * `fidelity: 'legacy'`: evaluate the criteria as this engine did up to * 15.47 — its comparisons, its function semantics (inclusive * `dateTimeIsAfter`, `false` instead of an exception, dates read by * `Date.parse` too), its `AND`/`OR`/`NOT` dialect — plus the GSF functions * it didn't have. `types` is ignored. */ lenient?: boolean; } // --- values ------------------------------------------------------------------ // The Java types a criteria's values have in GSF's compiled filter. 'null' is // the null literal; 'int' and 'char' are primitive parameters (null can't be // passed); 'Object' and 'CharSequence' only appear as parameters. type JavaType = | 'String' | 'Integer' | 'Short' | 'Long' | 'BigInteger' | 'Double' | 'BigDecimal' | 'Boolean' | 'DateTime' | 'LocalDate' | 'null' | 'Object' | 'CharSequence' | 'Character' | 'char' | 'int'; // A generated entity's getter types (ENUM getters are read with toString()). const ENTITY_TYPES: Record = { STRING: 'String', ENUM: 'String', INT: 'Integer', SHORT: 'Short', LONG: 'Long', NANO_TIMESTAMP: 'Long', DOUBLE: 'Double', BIGDECIMAL: 'BigDecimal', BOOLEAN: 'Boolean', DATE: 'DateTime', DATETIME: 'DateTime', }; // A Joda DateTime: an instant, in epoch millis. class DateTimeValue { readonly millis: number; constructor(millis: number) { this.millis = millis; } } // A Joda LocalDate: a day, in days since 1970-01-01. class LocalDateValue { readonly day: number; constructor(day: number) { this.day = day; } } // One evaluated operand: its value, its Java type when known, and whether it // is a field read without one (mock data, read leniently). interface Value { value: unknown; type?: JavaType; untyped?: boolean; // Written in the criteria: Groovy types a one-character string literal // as a char where it compares one with a DateTime. literal?: boolean; } const INT_MIN = -2_147_483_648; const INT_MAX = 2_147_483_647; const NULL_POINTER = 'java.lang.NullPointerException'; function parseLiteral(token: string): { value: unknown; type: JavaType } | undefined { if (/^['"]/.test(token)) { return { value: decodeStringLiteral(token), type: 'String' }; } if (/^-?\d+$/.test(token)) { const value = Number(token); const type = value >= INT_MIN && value <= INT_MAX ? 'Integer' : 'Long'; return { value, type }; } if (/^-?\d+\.\d+$/.test(token)) return { value: Number(token), type: 'BigDecimal' }; if (token === 'true' || token === 'false') return { value: token === 'true', type: 'Boolean' }; if (token === 'null') return { value: null, type: 'null' }; return undefined; // not a literal -> a field reference } const isNullish = (value: unknown) => value === null || value === undefined; // Groovy truth: false for null, false, '', 0; true for anything else. function groovyTruth(value: unknown): boolean { if (isNullish(value)) return false; if (typeof value === 'boolean') return value; if (typeof value === 'string') return value.length > 0; if (typeof value === 'number') return value !== 0; return true; } // --- the syntax tree --------------------------------------------------------- type Node = | { kind: 'literal'; value: unknown; type: JavaType } | { kind: 'field'; name: string } | { kind: 'call'; receiver: string; method: string; args: Node[] } | { kind: 'ctor'; type: JavaType; args: Node[] } | { kind: 'compare'; op: string; left: Node; right: Node } | { kind: 'and' | 'or'; left: Node; right: Node } | { kind: 'not'; operand: Node }; const COMPARATORS = new Set(['==', '=', '!=', '<>', '<', '<=', '>', '>=']); // CriteriaValidatorVisitor's allowedConstructors (GSF v8.15.29). const CONSTRUCTORS: Record = { BigDecimal: 'BigDecimal', 'java.math.BigDecimal': 'BigDecimal', Integer: 'Integer', 'java.lang.Integer': 'Integer', Long: 'Long', 'java.lang.Long': 'Long', Double: 'Double', 'java.lang.Double': 'Double', String: 'String', 'java.lang.String': 'String', }; const NAME = /^[A-Za-z_][A-Za-z0-9_.]*$/; class Parser { private tokens: string[]; private pos = 0; // Every field the expression reads: field operands, call arguments and // call receivers. readonly fields = new Set(); constructor(tokens: string[]) { this.tokens = tokens; } get position(): number { return this.pos; } private peek(): string | undefined { return this.tokens[this.pos]; } private next(): string | undefined { const token = this.tokens[this.pos]; this.pos += 1; return token; } parseOr(): Node { let left = this.parseAnd(); while (this.peek() === '||' || this.peek()?.toUpperCase() === 'OR') { this.next(); left = { kind: 'or', left, right: this.parseAnd() }; } return left; } private parseAnd(): Node { let left = this.parseNot(); while (this.peek() === '&&' || this.peek()?.toUpperCase() === 'AND') { this.next(); left = { kind: 'and', left, right: this.parseNot() }; } return left; } private parseNot(): Node { const next = this.peek(); if (next === '!' || next?.toUpperCase() === 'NOT') { this.next(); return { kind: 'not', operand: this.parseNot() }; } return this.parsePrimary(); } private parsePrimary(): Node { if (this.peek() === '(') { this.next(); const inner = this.parseOr(); if (this.next() !== ')') throw new CriteriaSyntaxError('Missing closing parenthesis in criteria'); return inner; } const left = this.parseOperand(); const op = this.peek(); if (op !== undefined && COMPARATORS.has(op)) { this.next(); return { kind: 'compare', op, left, right: this.parseOperand() }; } // An operand alone is the condition, by Groovy truth (`ENABLED`, // `Expr.isNullOrBlank(NAME)`). return left; } private parseOperand(): Node { const token = this.next(); if (token === undefined) throw new CriteriaSyntaxError('Expected an operand'); if (token === 'new' && this.tokens[this.pos + 1] === '(') return this.parseConstructor(); if (token.includes('.') && this.peek() === '(') return this.parseCall(token); const literal = parseLiteral(token); if (literal) return { kind: 'literal', ...literal }; if (!NAME.test(token)) throw new CriteriaSyntaxError(`Unexpected token: ${token}`); this.fields.add(token); return { kind: 'field', name: token }; } // The arguments after a call's or a constructor's '('. private parseArguments(name: string): Node[] { this.next(); // '(' const args: Node[] = []; if (this.peek() !== ')') { args.push(this.parseOperand()); while (this.peek() === ',') { this.next(); args.push(this.parseOperand()); } } if (this.next() !== ')') throw new CriteriaSyntaxError(`Missing closing parenthesis in ${name}(...)`); return args; } // `new BigDecimal('1.5')`: the constructors GSF's validator allows. private parseConstructor(): Node { const name = this.next()!; const type = Object.hasOwn(CONSTRUCTORS, name) ? CONSTRUCTORS[name] : undefined; if (!type) throw new UnsupportedCriteriaError(`Unsupported constructor: new ${name}`); return { kind: 'ctor', type, args: this.parseArguments(`new ${name}`) }; } private parseCall(name: string): Node { const args = this.parseArguments(name); const dot = name.lastIndexOf('.'); const [receiver, method] = [name.slice(0, dot), name.slice(dot + 1)]; if (receiver === 'Expr') { if (!Object.hasOwn(EXPR_FUNCTIONS, method)) { throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`); } } else { if (!Object.hasOwn(FIELD_METHODS, method) && !Object.hasOwn(EXPR_FUNCTIONS, method)) { throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`); } this.fields.add(receiver); } return { kind: 'call', receiver, method, args }; } } function parse(criteria: string): { root: Node; fields: string[] } { const tokens = tokenizeCriteria(criteria.trim()).map((token) => token.text); const parser = new Parser(tokens); const root = parser.parseOr(); if (parser.position !== tokens.length) { throw new CriteriaSyntaxError(`Unexpected trailing tokens in criteria: ${criteria}`); } return { root, fields: [...parser.fields] }; } // --- argument readers -------------------------------------------------------- // A date in one of GSF's formats; for `untyped` text (a field without a // declared type: mock data) ISO-8601 and epoch-millis text as well. function instantOfText(text: string, untyped: boolean): number { if (!untyped) return gsf.stringDateTime(text); try { return gsf.stringDateTime(text); } catch (error) { const millis = epochMillis(text); if (millis === undefined) throw error; return millis; } } const dayOfText = (text: string, untyped: boolean) => gsf.epochDay(instantOfText(text, untyped)); const isWholeNumber = (value: unknown) => typeof value === 'number' || (typeof value === 'string' && /^-?\d+$/.test(value)); const LONG_TYPES = new Set(['Long', 'Integer', 'Short']); // The first argument of a date function, by the overload GSF picks: an // instant for a DateTime or a Long (epoch millis), text for a String. function dateOperand(arg: Value | undefined): { millis: number } | { text: string } { if (arg === undefined) return evaluationError('Missing argument'); const { value, type } = arg; if (isNullish(value)) return evaluationError(NULL_POINTER); if (value instanceof DateTimeValue) return { millis: value.millis }; if (LONG_TYPES.has(type) || (type === undefined && typeof value === 'number')) { return isWholeNumber(value) ? { millis: Number(value) } : evaluationError(`Not a Long: ${String(value)}`); } if ((type === 'String' || type === undefined) && typeof value === 'string') return { text: value }; return evaluationError(`No date overload for ${String(value)}`); } // A String argument: null stays null. function stringArg(arg: Value | undefined): string | null { if (arg === undefined) return evaluationError('Missing argument'); if (isNullish(arg.value)) return null; return typeof arg.value === 'string' ? arg.value : evaluationError(`Not a String: ${String(arg.value)}`); } // A String argument that is dereferenced: null throws. function requiredString(arg: Value | undefined): string { return stringArg(arg) ?? evaluationError(NULL_POINTER); } // The delimiter of containsWordsStartingWithIgnoreCase: its first character. function delimiterArg(arg: Value | undefined): string | undefined { if (arg === undefined) return undefined; const text = requiredString(arg); return text.length > 0 ? text[0] : evaluationError('String index out of range: 0'); } function instantOf(arg: Value): number { const operand = dateOperand(arg); return 'millis' in operand ? operand.millis : instantOfText(operand.text, arg.untyped === true); } // --- Expr --------------------------------------------------------------------- type ExprFunction = (args: Value[]) => unknown; // dateIs*(date, comparableDate): by day. A DateTime or Long date is compared // with a yyyyMMdd date only; a String date and its comparable with any of // GSF's formats. function compareDays(compare: (a: number, b: number) => boolean): ExprFunction { return ([date, comparable]) => { const operand = dateOperand(date); const other = requiredString(comparable); if ('millis' in operand) { return compare(gsf.epochDay(operand.millis), gsf.singlePatternLocalDate(other)); } const day = dayOfText(operand.text, date.untyped === true); return compare(day, gsf.stringLocalDate(other)); }; } // dateTimeIs*(date, comparableDate): by instant. function compareInstants(compare: (a: number, b: number) => boolean): ExprFunction { return ([date, comparable]) => compare(instantOf(date), gsf.stringDateTime(requiredString(comparable))); } function isNullOrBlank([text]: Value[]): boolean { const value = stringArg(text); return value === null || gsf.isBlank(value); } // A mock row may hold a list in a field (DEAL_CURRENCIES: ['USD', 'GBP']): // contains / containsIgnoreCase then test membership. GSF has no such field. function listMember(list: unknown[], needle: Value | undefined, ignoreCase: boolean): boolean { const search = needle === undefined ? null : stringArg(needle); if (search === null) return false; return list.some( (item) => typeof item === 'string' && (ignoreCase ? item.toLowerCase() === search.toLowerCase() : item === search), ); } function containsIgnoreCase([text, search]: Value[]): boolean { if (Array.isArray(text?.value)) return listMember(text.value, search, true); return gsf.containsIgnoreCase(stringArg(text), stringArg(search)); } function containsWordsStartingWithIgnoreCase([text, search, delimiter]: Value[]): boolean { const separator = delimiterArg(delimiter); return gsf.containsWordsStartingWithIgnoreCase(stringArg(text), stringArg(search), separator); } function dateIsToday([date]: Value[]): boolean { const operand = dateOperand(date); const day = 'millis' in operand ? gsf.epochDay(operand.millis) : dayOfText(operand.text, date.untyped === true); return day === gsf.today(); } function longArg(arg: Value | undefined): number { if (arg === undefined) return evaluationError('Missing argument'); if (isNullish(arg.value)) return evaluationError(NULL_POINTER); return isWholeNumber(arg.value) ? Number(arg.value) : evaluationError(`Not a Long: ${String(arg.value)}`); } // What this engine evaluated before GSF's semantics, kept for fidelity: // 'legacy' (strict criteria refuse them: GSF's validator doesn't allow them). const legacyFunctions: Record = { contains: ([text, search]) => { if (Array.isArray(text?.value)) return listMember(text.value, search, false); return ( typeof text?.value === 'string' && typeof search?.value === 'string' && text.value.includes(search.value) ); }, equals: ([a, b]) => a?.value === b?.value, equalsIgnoreCase: ([a, b]) => { if (typeof a?.value !== 'string' || typeof b?.value !== 'string') return a?.value === b?.value; return a.value.toLowerCase() === b.value.toLowerCase(); }, startsWith: ([text, prefix]) => typeof text?.value === 'string' && typeof prefix?.value === 'string' && text.value.startsWith(prefix.value), endsWith: ([text, suffix]) => typeof text?.value === 'string' && typeof suffix?.value === 'string' && text.value.endsWith(suffix.value), dateTimeIsEqual: compareInstants((a, b) => a === b), }; // Expr.java (GSF v8.15.29), every function its validator allows plus // stringDateTime (GSF has it, its validator refuses it). const EXPR_FUNCTIONS: Record = { ...legacyFunctions, isNullOrBlank, notNullOrBlank: (args) => !isNullOrBlank(args), containsIgnoreCase, containsWordsStartingWithIgnoreCase, dateIsToday, dateIsEqual: compareDays((a, b) => a === b), dateIsBefore: compareDays((a, b) => a < b), dateIsAfter: compareDays((a, b) => a > b), dateIsGreaterEqual: compareDays((a, b) => a >= b), dateIsLessEqual: compareDays((a, b) => a <= b), dateTimeIsAfter: compareInstants((a, b) => a > b), dateTimeIsGreaterEqual: compareInstants((a, b) => a >= b), dateTimeIsBefore: compareInstants((a, b) => a < b), dateTimeIsLessEqual: compareInstants((a, b) => a <= b), dateTimeIsInRange: ([date, range, context]) => { const [rangeName, contextName] = [requiredString(range), requiredString(context)]; return gsf.dateTimeIsInRange(instantOf(date), rangeName, contextName); }, longLocalDate: ([date]) => new LocalDateValue(gsf.epochDay(longArg(date))), longDateTime: ([date]) => new DateTimeValue(longArg(date)), stringLocalDate: ([date]) => new LocalDateValue(dayOfText(requiredString(date), date.untyped === true)), stringDateTime: ([date]) => new DateTimeValue(instantOfText(requiredString(date), date.untyped === true)), }; // --- field methods ------------------------------------------------------------ // Object.equals: the same Java class and value. A field without a known type // equals a value of the same kind. function javaEquals(receiver: Value, other: Value): boolean { const [a, b] = [receiver.value, other.value]; if (isNullish(b)) return false; if (a instanceof DateTimeValue) return b instanceof DateTimeValue && a.millis === b.millis; if (a instanceof LocalDateValue) return b instanceof LocalDateValue && a.day === b.day; if (typeof a === 'number' && typeof b === 'number') { // Long.equals(5) is false: 5 is an Integer. if (receiver.type && other.type && receiver.type !== other.type) return false; return a === b; } return a === b; } type FieldMethod = (receiver: Value, args: Value[]) => boolean; // The receiver of a String method: null throws (the call dereferences it). function stringReceiver(receiver: Value): string { if (isNullish(receiver.value)) return evaluationError(NULL_POINTER); return typeof receiver.value === 'string' ? receiver.value : evaluationError(`Not a String: ${String(receiver.value)}`); } // String's own methods, then the ExtraOperators.kt extensions (whose String? // receiver may be null: containsIgnoreCase and // containsWordsStartingWithIgnoreCase answer false for it). const FIELD_METHODS: Record = { startsWith: (receiver, [prefix, offset]) => { const text = stringReceiver(receiver); const start = requiredString(prefix); const from = offset === undefined ? 0 : longArg(offset); if (from < 0 || from > text.length - start.length) return false; return text.startsWith(start, from); }, equals: (receiver, [other]) => { if (isNullish(receiver.value)) return evaluationError(NULL_POINTER); return other !== undefined && javaEquals(receiver, other); }, equalsIgnoreCase: (receiver, [other]) => { const text = stringReceiver(receiver); const value = stringArg(other); return value !== null && value.length === text.length && gsf.containsIgnoreCase(text, value); }, contains: (receiver, [search]) => { if (Array.isArray(receiver.value)) return listMember(receiver.value, search, false); return stringReceiver(receiver).includes(requiredString(search)); }, containsIgnoreCase: (receiver, [search]) => containsIgnoreCase([receiver, search]), containsWordsStartingWithIgnoreCase: (receiver, [search, delimiter]) => containsWordsStartingWithIgnoreCase([receiver, search, ...(delimiter ? [delimiter] : [])]), dateIsToday: (receiver) => dateIsToday([receiver]), }; // --- comparisons ---------------------------------------------------------------- // Groovy orders null before everything. function compareNullable(a: unknown, b: unknown): number | undefined { if (!isNullish(a) && !isNullish(b)) return undefined; if (isNullish(a) && isNullish(b)) return 0; return isNullish(a) ? -1 : 1; } function fromOrder(op: string, order: number): boolean { switch (op) { case '==': case '=': return order === 0; case '!=': case '<>': return order !== 0; case '<': return order < 0; case '<=': return order <= 0; case '>': return order > 0; default: return order >= 0; } } const EQUALITY = new Set(['==', '=', '!=', '<>']); // What a DateTime compares with (ExtraOperators.kt compareTo): another // DateTime, epoch millis (a Long or an int), a date string. undefined for // anything else — a Double, a BigDecimal, a Boolean. function instantOperand(operand: Value): number | undefined { const { value, type } = operand; if (value instanceof DateTimeValue) return value.millis; if (typeof value === 'number') { return type === 'Double' || type === 'BigDecimal' ? undefined : value; } if (typeof value === 'string') return gsf.stringDateTime(value); return undefined; } // A comparison with a DateTime or a LocalDate on one side. `==` takes a // DateTime equal to a date string naming its instant, and never to a // one-character string literal (Groovy types that as a char; a field's // one-character value is parsed like any other), a Double, a BigDecimal or a // Boolean; an unparseable date string throws. A relational // operator throws for a pair it can't compare. function compareDates(op: string, a: Value, b: Value): boolean { const equality = EQUALITY.has(op); const unrelated = () => equality ? op === '!=' || op === '<>' : evaluationError('Cannot compare these values'); if (a.value instanceof LocalDateValue || b.value instanceof LocalDateValue) { if (a.value instanceof LocalDateValue && b.value instanceof LocalDateValue) { return fromOrder(op, Math.sign(a.value.day - b.value.day)); } return unrelated(); } const char = (operand: Value) => operand.literal === true && typeof operand.value === 'string' && operand.value.length === 1; if (equality && (char(a) || char(b))) return unrelated(); const [x, y] = [instantOperand(a), instantOperand(b)]; if (x === undefined || y === undefined) return unrelated(); return fromOrder(op, Math.sign(x - y)); } const isDated = (operand: Value) => operand.value instanceof DateTimeValue || operand.value instanceof LocalDateValue; function compareValues(op: string, left: Value, right: Value): boolean { const [a, b] = [left.value, right.value]; const order = compareNullable(a, b); if (order !== undefined) return fromOrder(op, order); if (isDated(left) || isDated(right)) return compareDates(op, left, right); switch (op) { case '==': case '=': return a === b; case '!=': case '<>': return a !== b; case '>': return (a as any) > (b as any); case '>=': return (a as any) >= (b as any); case '<': return (a as any) < (b as any); default: return (a as any) <= (b as any); } } // --- evaluation ------------------------------------------------------------------- type Evaluator = (record: Row) => Value; // A field as the entity's getter returns it: a DateTime for DATE and // DATETIME, a String for STRING and ENUM. function readField(name: string, type: JavaType | undefined, record: Row): Value { const raw = record[name] ?? null; if (type === undefined) return { value: raw, untyped: true }; if (raw === null) return { value: null, type }; if (type === 'DateTime') { const millis = epochMillis(raw); return millis === undefined ? evaluationError(`Not a date: ${String(raw)}`) : { value: new DateTimeValue(millis), type }; } if (type === 'String' && (typeof raw === 'number' || typeof raw === 'boolean')) { return { value: String(raw), type }; } return { value: raw, type }; } function compileNode(node: Node, options: CriteriaOptions): Evaluator { switch (node.kind) { case 'literal': { const value: Value = { value: node.value, type: node.type, literal: true }; return () => value; } case 'field': { const typeName = options.types?.get(node.name); const type = typeName ? ENTITY_TYPES[typeName] : undefined; return (record) => readField(node.name, type, record); } case 'compare': { const [left, right] = [compileNode(node.left, options), compileNode(node.right, options)]; return (record) => ({ value: compareValues(node.op, left(record), right(record)), type: 'Boolean', }); } case 'and': case 'or': { const [left, right] = [compileNode(node.left, options), compileNode(node.right, options)]; const and = node.kind === 'and'; return (record) => { const first = groovyTruth(left(record).value); if (and ? !first : first) return { value: first, type: 'Boolean' }; return { value: groovyTruth(right(record).value), type: 'Boolean' }; }; } case 'not': { const operand = compileNode(node.operand, options); return (record) => ({ value: !groovyTruth(operand(record).value), type: 'Boolean' }); } case 'call': return compileCall(node, options); case 'ctor': { const args = node.args.map((arg) => compileNode(arg, options)); return (record) => ({ value: construct( node.type, args.map((arg) => arg(record)), ), type: node.type, }); } } } const JAVA_DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; const JAVA_INTEGER = /^[+-]?\d+$/; const JAVA_DOUBLE = /^[+-]?(?:(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?[dDfF]?|Infinity|NaN)$/; // `new X(arg)`: the value Java's constructor makes, or the exception it // throws (a NumberFormatException for text that isn't the number). function construct(type: JavaType, args: Value[]): unknown { if (type === 'String' && args.length === 0) return ''; if (args.length !== 1) return evaluationError('No such constructor'); const { value } = args[0]; if (isNullish(value)) return evaluationError(NULL_POINTER); if (type === 'String') return typeof value === 'string' ? value : String(value); if (typeof value === 'number') return value; if (typeof value !== 'string') return evaluationError('No such constructor'); const text = type === 'Double' ? value.trim() : value; // only Double.parseDouble trims const pattern = type === 'BigDecimal' ? JAVA_DECIMAL : type === 'Double' ? JAVA_DOUBLE : JAVA_INTEGER; if (!pattern.test(text)) return evaluationError(`NumberFormatException: ${value}`); const number = Number(text.replace(/[dDfF]$/, '')); if (type === 'Integer' && (number < INT_MIN || number > INT_MAX)) { return evaluationError(`NumberFormatException: ${value}`); } return number; } function compileCall(node: Extract, options: CriteriaOptions): Evaluator { const args = node.args.map((arg) => compileNode(arg, options)); const readArgs = (record: Row) => args.map((arg) => arg(record)); if (node.receiver === 'Expr') { const fn = EXPR_FUNCTIONS[node.method]; return (record) => ({ value: fn(readArgs(record)) }); } const receiver = compileNode({ kind: 'field', name: node.receiver }, options); const method = Object.hasOwn(FIELD_METHODS, node.method) ? FIELD_METHODS[node.method] : undefined; if (method) { return (record) => ({ value: method(receiver(record), readArgs(record)), type: 'Boolean', }); } // fidelity: 'legacy' — an Expr function called on its first argument. const fn = EXPR_FUNCTIONS[node.method]; return (record) => ({ value: fn([receiver(record), ...readArgs(record)]) }); } export interface ParsedCriteria { predicate: Predicate; // Every field the expression reads. fields: string[]; } // Parses a CRITERIA_MATCH without failing open: throws CriteriaSyntaxError for // a malformed one and UnsupportedCriteriaError for a call to a function the // grammar doesn't have. A blank criteria matches everything. A row the // criteria throws on (gsfExpr.ts) doesn't match. export function parseCriteria( criteria?: string | null, options: CriteriaOptions = {}, ): ParsedCriteria { if (!criteria || typeof criteria !== 'string' || criteria.trim() === '') { return { predicate: () => true, fields: [] }; } if (options.lenient) return parseLegacyCriteria(criteria); const { root, fields } = parse(criteria); const evaluate = compileNode(root, options); const predicate: Predicate = (record) => { try { return groovyTruth(evaluate(record).value); } catch (error) { if (error instanceof CriteriaEvaluationError) return false; throw error; } }; return { predicate, fields }; } /** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */ export function compileCriteria( criteria?: string | null, options: CriteriaOptions = {}, ): Predicate { // Real Genesis clients can emit CRITERIA_MATCH syntax this grammar doesn't // cover. Failing open — matching everything and logging — keeps // DATA_LOGON/broadcast working (unfiltered) instead of crashing the handler // and leaving the client's grid stuck on "Loading...". Strict criteria // refuse what GSF refuses before they get here. try { return parseCriteria(criteria, options).predicate; } catch (error) { console.warn( `[mock-server] Unsupported CRITERIA_MATCH syntax, ignoring filter: ${criteria}`, error, ); return () => true; } } // The fields a CRITERIA_MATCH reads, or undefined when it doesn't parse // (compileCriteria then fails open). The real dataserver refuses a criteria // naming a field its query doesn't expose (INVALID_CRITERIA). export function criteriaFields(criteria?: string | null): string[] | undefined { try { return parseCriteria(criteria).fields; } catch { return undefined; } } export function filterByCriteria( rows: Row[], criteria?: string | null, options: CriteriaOptions = {}, ): Row[] { const predicate = compileCriteria(criteria, options); return rows.filter((row) => { try { return predicate(row); } catch { return false; } }); } // --- GSF's compilation ------------------------------------------------------------ type Signature = JavaType[]; const S: JavaType = 'String'; const DATE_OVERLOADS: Signature[] = [ [S, S], ['Long', S], ['DateTime', S], ]; // Expr.java's overloads, and what each function returns. const EXPR_SIGNATURES: Record = { isNullOrBlank: { overloads: [[S]], returns: 'Boolean' }, notNullOrBlank: { overloads: [[S]], returns: 'Boolean' }, containsIgnoreCase: { overloads: [[S, S]], returns: 'Boolean' }, containsWordsStartingWithIgnoreCase: { overloads: [ [S, S], [S, S, S], [S, S, 'Character'], ], returns: 'Boolean', }, dateIsToday: { overloads: [['Long'], [S], ['DateTime']], returns: 'Boolean' }, ...Object.fromEntries( [ 'dateIsEqual', 'dateIsBefore', 'dateIsAfter', 'dateIsGreaterEqual', 'dateIsLessEqual', 'dateTimeIsAfter', 'dateTimeIsGreaterEqual', 'dateTimeIsBefore', 'dateTimeIsLessEqual', ].map((name) => [name, { overloads: DATE_OVERLOADS, returns: 'Boolean' as JavaType }]), ), dateTimeIsInRange: { overloads: [ ['DateTime', S, S], ['Long', S, S], [S, S, S], ], returns: 'Boolean', }, longLocalDate: { overloads: [['Long']], returns: 'LocalDate' }, stringLocalDate: { overloads: [[S]], returns: 'LocalDate' }, longDateTime: { overloads: [['Long']], returns: 'DateTime' }, stringDateTime: { overloads: [[S]], returns: 'DateTime' }, }; const EQUALS: Signature[] = [['Object']]; const TODAY: Signature[] = [[]]; // The field methods each receiver type has (String's, Object.equals and the // ExtraOperators.kt extensions, whose delimiter is a primitive char). const FIELD_SIGNATURES: Partial>> = { String: { startsWith: [[S], [S, 'int']], equals: EQUALS, equalsIgnoreCase: [[S]], contains: [['CharSequence']], containsIgnoreCase: [[S]], containsWordsStartingWithIgnoreCase: [[S], [S, S], [S, 'char']], dateIsToday: TODAY, }, Long: { equals: EQUALS, dateIsToday: TODAY }, DateTime: { equals: EQUALS, dateIsToday: TODAY }, Integer: { equals: EQUALS }, Short: { equals: EQUALS }, Double: { equals: EQUALS }, BigDecimal: { equals: EQUALS }, Boolean: { equals: EQUALS }, }; const PRIMITIVES = new Set(['int', 'char']); // Whether Groovy's static type checker passes an argument of type `arg` for a // parameter of type `param` (null to any object, Integer and Short widened // to Long). function assignable(arg: JavaType, param: JavaType): boolean { if (arg === param || param === 'Object') return true; if (arg === 'null') return !PRIMITIVES.has(param); if (param === 'CharSequence') return arg === 'String'; if (param === 'Long') return arg === 'Integer' || arg === 'Short'; if (param === 'int') return arg === 'Integer' || arg === 'Short'; return false; } class CompileFailure extends Error {} const NUMBERS = new Set([ 'Integer', 'Short', 'Long', 'BigInteger', 'Double', 'BigDecimal', ]); // Whether `left < right` compiles: a compareTo for the pair exists — numbers // with numbers, a String with a String, a Boolean with a Boolean, and a // DateTime with a DateTime, a date string or epoch millis (ExtraOperators.kt). // Never with null on the left (Object#compareTo). A LocalDate's comparisons // fail when they run, if at all. function comparable(left: JavaType, right: JavaType): boolean { if (left === 'null') return false; if (right === 'null' || left === 'LocalDate' || right === 'LocalDate') return true; if (left === 'DateTime') return ['DateTime', 'String', 'Long', 'Integer', 'Short'].includes(right); if (right === 'DateTime') return left === 'String' || left === 'Long'; if (NUMBERS.has(left)) return NUMBERS.has(right); return left === right; } // Whether exactly one overload takes these arguments. An argument of unknown // type passes any parameter (it can't be checked). function checkOverloads(overloads: Signature[], args: Array): void { const matching = overloads.filter( (params) => params.length === args.length && params.every((param, index) => args[index] === undefined || assignable(args[index]!, param)), ); if (matching.length === 0) throw new CompileFailure(); // Overloads that all take the arguments (a null) are ambiguous — when the // arguments they differ on have known types. const differing = args.flatMap((_arg, index) => matching.some((params) => params[index] !== matching[0][index]) ? [index] : [], ); if (matching.length > 1 && differing.every((index) => args[index] !== undefined)) { throw new CompileFailure(); } } // The type GSF's compiler gives a node, checking every call in it; undefined // when it can't be known (a field without a declared type). function staticType( node: Node, types: ReadonlyMap | undefined, ): JavaType | undefined { switch (node.kind) { case 'literal': return node.type; case 'field': { const typeName = types?.get(node.name); return typeName ? ENTITY_TYPES[typeName] : undefined; } case 'compare': { const [left, right] = [staticType(node.left, types), staticType(node.right, types)]; const relational = !EQUALITY.has(node.op); if (relational && left && right && !comparable(left, right)) throw new CompileFailure(); return 'Boolean'; } case 'and': case 'or': staticType(node.left, types); staticType(node.right, types); return 'Boolean'; case 'not': staticType(node.operand, types); return 'Boolean'; case 'ctor': return node.type; case 'call': { const args = node.args.map((arg) => staticType(arg, types)); if (node.receiver === 'Expr') { const signature = EXPR_SIGNATURES[node.method]; if (!signature) return undefined; // not GSF's: the validator refuses it checkOverloads(signature.overloads, args); return signature.returns; } const receiver = staticType({ kind: 'field', name: node.receiver }, types); if (receiver === undefined || !Object.hasOwn(FIELD_METHODS, node.method)) return undefined; const overloads = FIELD_SIGNATURES[receiver]?.[node.method]; if (!overloads) throw new CompileFailure(); checkOverloads(overloads, args); return 'Boolean'; } } } // Guava's UPPER_UNDERSCORE to UPPER_CAMEL / LOWER_CAMEL, as GSF names getters. function camelCase(name: string, upperFirst: boolean): string { const words = name.split('_').map((word, index) => { const lowered = word.toLowerCase(); if (index === 0 && !upperFirst) return lowered; return lowered.charAt(0).toUpperCase() + lowered.slice(1); }); const joined = words.join(''); return /^\d/.test(joined) ? `_${joined}` : joined; } // The getter call CriteriaFilterFactory puts in place of a field. function getterOf(name: string, type: string | undefined): string { if (type === 'ENUM') return `row.get${camelCase(name, true)}()?.toString()`; if (type === 'BOOLEAN' && name.startsWith('IS')) return `row.${camelCase(name, false)}()`; return `row.get${camelCase(name, true)}()`; } const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // The INVALID_CRITERIA TEXT GSF sends when its @CompileStatic compilation // of a criteria fails (CriteriaFilterBuilder.getCriteriaFilter, v8.15.29): // the criteria as it was compiled — each field the validator saw it read // replaced by the entity's getter (CriteriaFilterFactory: longest name // first, not where a quote or a word character touches it). export function compileFailureText( criteria: string, fields: Iterable, types?: ReadonlyMap, ): string { let replaced = criteria; for (const field of [...new Set(fields)].sort((a, b) => b.length - a.length)) { const pattern = new RegExp(`(? getterOf(field, types?.get(field))); } return `Error compiling criteria expression: Failed to compile criteria expression ${replaced}`; } // What GSF's compilation refuses in a criteria its validator passes: a call // no overload takes — the wrong number of arguments, or (where the fields' // types are known) an argument of the wrong type, a field method the // field's type doesn't have, an ambiguous null — and a relational operator // between types that don't compare (`QTY > '5'`). The INVALID_CRITERIA // TEXT, or undefined when it compiles or doesn't parse here. export function criteriaCompileRefusal( criteria: string, types?: ReadonlyMap, ): string | undefined { let parsed: { root: Node; fields: string[] }; try { parsed = parse(criteria); } catch { return undefined; } try { staticType(parsed.root, types); return undefined; } catch (error) { if (!(error instanceof CompileFailure)) throw error; } return compileFailureText(criteria, parsed.fields, types); }