// The criteria evaluator of fidelity: 'legacy' (CriteriaOptions.lenient): // this engine's own semantics up to 15.47, kept as they were — JavaScript // comparisons (`QTY > -1` holds for a null QTY, `QTY == null` misses a // missing field), functions that answer false rather than throw (so // `!STATUS.equals('NEW')` keeps a row with no STATUS), inclusive // dateTimeIsAfter / dateTimeIsBefore, dates read by Date.parse too, and the // AND / OR / NOT, `<>` and `=` dialect. GSF's semantics are criteria.ts's. // // Only addition: the GSF functions 15.47 didn't have and so failed open on — // containsWordsStartingWithIgnoreCase, isNullOrBlank, notNullOrBlank, // dateIsAfter, dateIsBefore, dateTimeIsInRange — in the same false-not-throw // style. import type { Row } from '../types.ts'; import { CriteriaSyntaxError, decodeStringLiteral, parseGenesisDateTime, tokenizeCriteria, UnsupportedCriteriaError, } from './criteriaTokens.ts'; import * as gsf from './gsfExpr.ts'; type Predicate = (record: Row) => boolean; function parseLiteral(token: string): unknown { if (/^['"]/.test(token)) return decodeStringLiteral(token); if (/^-?\d+(?:\.\d+)?$/.test(token)) return Number(token); if (token === 'true') return true; if (token === 'false') return false; if (token === 'null') return null; return undefined; // not a literal -> treat as a field reference } const COMPARATORS = new Set(['==', '=', '!=', '<>', '<', '<=', '>', '>=']); function toEpochDay(value: unknown): number | null { const epoch = parseGenesisDateTime(value); return epoch === null ? null : gsf.epochDay(epoch); } type ExprResolver = (token: string, record: Row) => unknown; type LegacyFunction = (record: Row, args: string[], resolve: ExprResolver) => boolean; function compareByDay(cmp: (a: number, b: number) => boolean): LegacyFunction { return (record, args, resolve) => { const a = toEpochDay(resolve(args[0], record)); const b = toEpochDay(resolve(args[1], record)); return a !== null && b !== null && cmp(a, b); }; } function compareByTime(cmp: (a: number, b: number) => boolean): LegacyFunction { return (record, args, resolve) => { const a = parseGenesisDateTime(resolve(args[0], record)); const b = parseGenesisDateTime(resolve(args[1], record)); return a !== null && b !== null && cmp(a, b); }; } const isBlankValue = (value: unknown) => value === null || value === undefined || (typeof value === 'string' && gsf.isBlank(value)); // Expr.(field, value), and the same functions as .(value) // with the receiver as the first argument. const LEGACY_FUNCTIONS: Record = { // Multi-value fields (e.g. DEAL_CURRENCIES) may hold an array — treat // "contains" as membership there, substring match on strings. contains: (record, args, resolve) => { const value = resolve(args[0], record); const needle = resolve(args[1], record); if (typeof needle !== 'string') return false; if (Array.isArray(value)) return value.some((v) => typeof v === 'string' && v === needle); return typeof value === 'string' && value.includes(needle); }, containsIgnoreCase: (record, args, resolve) => { const value = resolve(args[0], record); const needle = resolve(args[1], record); if (typeof needle !== 'string') return false; const lowered = needle.toLowerCase(); if (Array.isArray(value)) { return value.some((v) => typeof v === 'string' && v.toLowerCase() === lowered); } return typeof value === 'string' && value.toLowerCase().includes(lowered); }, equals: (record, args, resolve) => resolve(args[0], record) === resolve(args[1], record), equalsIgnoreCase: (record, args, resolve) => { const value = resolve(args[0], record); const other = resolve(args[1], record); if (typeof value !== 'string' || typeof other !== 'string') return value === other; return value.toLowerCase() === other.toLowerCase(); }, startsWith: (record, args, resolve) => { const value = resolve(args[0], record); const prefix = resolve(args[1], record); return typeof value === 'string' && typeof prefix === 'string' && value.startsWith(prefix); }, endsWith: (record, args, resolve) => { const value = resolve(args[0], record); const suffix = resolve(args[1], record); return typeof value === 'string' && typeof suffix === 'string' && value.endsWith(suffix); }, dateIsToday: (record, args, resolve) => { const day = toEpochDay(resolve(args[0], record)); return day !== null && day === toEpochDay(Date.now()); }, dateIsEqual: compareByDay((a, b) => a === b), dateIsGreaterEqual: compareByDay((a, b) => a >= b), dateIsLessEqual: compareByDay((a, b) => a <= b), dateTimeIsEqual: compareByTime((a, b) => a === b), dateTimeIsGreaterEqual: compareByTime((a, b) => a >= b), dateTimeIsLessEqual: compareByTime((a, b) => a <= b), dateTimeIsAfter: compareByTime((a, b) => a >= b), dateTimeIsBefore: compareByTime((a, b) => a <= b), // GSF's, which 15.47 didn't have. dateIsAfter: compareByDay((a, b) => a > b), dateIsBefore: compareByDay((a, b) => a < b), isNullOrBlank: (record, args, resolve) => isBlankValue(resolve(args[0], record)), notNullOrBlank: (record, args, resolve) => !isBlankValue(resolve(args[0], record)), containsWordsStartingWithIgnoreCase: (record, args, resolve) => { const value = resolve(args[0], record); const search = resolve(args[1], record); const delimiter = args.length > 2 ? resolve(args[2], record) : ' '; if (typeof value !== 'string' || typeof search !== 'string') return false; if (typeof delimiter !== 'string' || delimiter === '') return false; return gsf.containsWordsStartingWithIgnoreCase(value, search, delimiter[0]); }, dateTimeIsInRange: (record, args, resolve) => { const millis = parseGenesisDateTime(resolve(args[0], record)); const [range, context] = [resolve(args[1], record), resolve(args[2], record)]; if (millis === null || typeof range !== 'string' || typeof context !== 'string') return false; try { return gsf.dateTimeIsInRange(millis, range, context); } catch { return false; } }, }; class Parser { private tokens: string[]; private pos = 0; 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(): Predicate { let left = this.parseAnd(); while (this.peek() === '||' || this.peek()?.toUpperCase() === 'OR') { this.next(); const right = this.parseAnd(); const prev = left; left = (record) => prev(record) || right(record); } return left; } private parseAnd(): Predicate { let left = this.parseNot(); while (this.peek() === '&&' || this.peek()?.toUpperCase() === 'AND') { this.next(); const right = this.parseNot(); const prev = left; left = (record) => prev(record) && right(record); } return left; } private parseNot(): Predicate { const next = this.peek(); if (next === '!' || next?.toUpperCase() === 'NOT') { this.next(); const operand = this.parseNot(); return (record) => !operand(record); } return this.parseComparison(); } private parseComparison(): Predicate { if (this.peek() === '(') { this.next(); const inner = this.parseOr(); if (this.next() !== ')') throw new CriteriaSyntaxError('Missing closing parenthesis in criteria'); return inner; } const leftToken = this.next(); if (leftToken === undefined) { throw new CriteriaSyntaxError('Expected operand on the left side of comparison'); } if (leftToken.includes('.') && this.peek() === '(') return this.parseFunctionCall(leftToken); const op = this.peek(); if (!op || !COMPARATORS.has(op)) throw new CriteriaSyntaxError(`Expected comparator, got: ${op}`); this.next(); const rightToken = this.next(); if (rightToken === undefined) { throw new CriteriaSyntaxError('Expected operand on the right side of comparison'); } this.noteField(leftToken); this.noteField(rightToken); const resolveLeft = (record: Row) => this.resolveValue(leftToken, record); const resolveRight = (record: Row) => this.resolveValue(rightToken, record); return (record: Row) => { const a = resolveLeft(record); const b = resolveRight(record); 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); case '<=': return (a as any) <= (b as any); default: return false; } }; } private noteField(token: string): void { if (parseLiteral(token) === undefined) this.fields.add(token); } private resolveValue(token: string, record: Row): unknown { const literal = parseLiteral(token); if (literal !== undefined) return literal; return record[token]; } private parseFunctionCall(name: string): Predicate { this.next(); // consume '(' const args: string[] = []; if (this.peek() !== ')') { const arg = this.next(); if (arg === undefined) throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`); args.push(arg); while (this.peek() === ',') { this.next(); const nextArg = this.next(); if (nextArg === undefined) { throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`); } args.push(nextArg); } } if (this.next() !== ')') throw new CriteriaSyntaxError(`Missing closing parenthesis in ${name}(...)`); const dotIndex = name.lastIndexOf('.'); const receiver = name.slice(0, dotIndex); const fnName = name.slice(dotIndex + 1); const handler = Object.hasOwn(LEGACY_FUNCTIONS, fnName) ? LEGACY_FUNCTIONS[fnName] : undefined; if (!handler) throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`); const allArgs = receiver === 'Expr' ? args : [receiver, ...args]; for (const arg of allArgs) this.noteField(arg); const resolve: ExprResolver = (token, record) => this.resolveValue(token, record); return (record: Row) => handler(record, allArgs, resolve); } } // A criteria as fidelity: 'legacy' evaluates it: its predicate and the fields // it reads. Throws CriteriaSyntaxError for what this grammar can't parse // and UnsupportedCriteriaError for a function it doesn't have. export function parseLegacyCriteria(criteria: string): { predicate: Predicate; fields: string[] } { const tokens = tokenizeCriteria(criteria.trim()).map((token) => token.text); const parser = new Parser(tokens); const predicate = parser.parseOr(); if (parser.position !== tokens.length) { throw new CriteriaSyntaxError(`Unexpected trailing tokens in criteria: ${criteria}`); } return { predicate, fields: [...parser.fields] }; }