// What GSF answers a CRITERIA_MATCH written in Groovy this engine's grammar // doesn't read — a list (`NAME in ['A']`), arithmetic (`QTY * PRICE > 10`), // a ternary, `new X(...)`, `===`, `=~` — which its CriteriaValidatorVisitor // (genesis-db v8.15.29) refuses. The criteria is parsed as Groovy reads it // (Groovy 3.0.25's precedence) and the validator's walk is repeated over // it: every error, in the order the validator meets them, with its text. // That includes a call's only argument written as operands side by side // (`NAME.equals("say "hi"")`), which Groovy reads as a command expression. // Anything this parser doesn't follow — the engine's AND / OR / NOT dialect, // an assignment, a closure's insides, a GString — gets no verdict here. import { juxtaposedIndex } from './criteriaJuxtaposition.ts'; import { groovyCompileErrorText, groovyTokens, type Token } from './groovySyntax.ts'; const VALIDATION_FAILED = 'GenericCriteriaValidationException : Criteria validation failed: '; // Classes Groovy resolves in a criteria without an import (java.lang, // java.util, java.math, groovy.lang, groovy.util): the ones a criteria might // name, with the name the validator prints for a constructor call. export const DEFAULT_IMPORT_CLASSES: Record = { Arrays: 'java.util.Arrays', BigDecimal: 'java.math.BigDecimal', BigInteger: 'java.math.BigInteger', Boolean: 'java.lang.Boolean', Byte: 'java.lang.Byte', Calendar: 'java.util.Calendar', Character: 'java.lang.Character', Class: 'java.lang.Class', Collections: 'java.util.Collections', Date: 'java.util.Date', Double: 'java.lang.Double', Eval: 'groovy.util.Eval', Float: 'java.lang.Float', GString: 'groovy.lang.GString', GroovySystem: 'groovy.lang.GroovySystem', Integer: 'java.lang.Integer', List: 'java.util.List', Locale: 'java.util.Locale', Long: 'java.lang.Long', Map: 'java.util.Map', Math: 'java.lang.Math', Number: 'java.lang.Number', Object: 'java.lang.Object', Objects: 'java.util.Objects', Optional: 'java.util.Optional', Random: 'java.util.Random', Runtime: 'java.lang.Runtime', Set: 'java.util.Set', Short: 'java.lang.Short', StrictMath: 'java.lang.StrictMath', String: 'java.lang.String', StringBuilder: 'java.lang.StringBuilder', System: 'java.lang.System', Thread: 'java.lang.Thread', TimeZone: 'java.util.TimeZone', UUID: 'java.util.UUID', }; const QUALIFIED_CLASS = /^(?:java|javax|groovy)\.(?:[a-z]\w*\.)*[A-Z]\w*$/; // Whether Groovy resolves this name to a class (a call on it is a class // expression). export const isClassName = (name: string) => Object.hasOwn(DEFAULT_IMPORT_CLASSES, name) || QUALIFIED_CLASS.test(name); // The validator's whitelists (CriteriaValidatorVisitor). export const EXPR_METHODS = new Set([ 'dateIsToday', 'dateIsEqual', 'dateIsAfter', 'dateIsBefore', 'dateIsGreaterEqual', 'dateIsLessEqual', 'dateTimeIsAfter', 'dateTimeIsGreaterEqual', 'dateTimeIsBefore', 'dateTimeIsLessEqual', 'containsIgnoreCase', 'containsWordsStartingWithIgnoreCase', 'isNullOrBlank', 'notNullOrBlank', 'longLocalDate', 'longDateTime', 'dateTimeIsInRange', 'stringLocalDate', ]); export const FIELD_METHODS = new Set([ 'startsWith', 'equals', 'equalsIgnoreCase', 'contains', 'containsIgnoreCase', 'containsWordsStartingWithIgnoreCase', 'dateIsToday', ]); const ALLOWED_CONSTRUCTORS = new Set([ 'java.math.BigDecimal', 'java.lang.Integer', 'java.lang.Long', 'java.lang.Double', 'java.lang.String', ]); // The binary operators the validator visits the operands of. const LOGICAL = new Set(['&&', '||']); const COMPARISONS = new Set(['==', '!=', '>', '>=', '<', '<=', 'in', '!in']); const NOT_SUPPORTED = (what: string) => `${what} are not supported in criteria`; // A name that can stand for a type or a variable: not a keyword operator, // a literal word or this engine's dialect. const isTypeName = (name: string) => !KEYWORD_OPERATORS.has(name) && !['true', 'false', 'null', 'new'].includes(name) && !DIALECT_WORDS.has(name.toUpperCase()); const PRIMITIVES = new Set(['boolean', 'byte', 'char', 'short', 'int', 'long', 'float', 'double']); // Whether Groovy resolves a type a cast or a declaration names: a // primitive, `def` / `var`, a class it imports by default, or a qualified // JDK or Groovy class. function resolvesType(type: string): boolean { return ( PRIMITIVES.has(type) || type === 'def' || type === 'var' || Object.hasOwn(DEFAULT_IMPORT_CLASSES, type) || QUALIFIED_CLASS.test(type) ); } type Node = | { kind: 'binary'; op: string; left: Node; right: Node } | { kind: 'visitInner'; operand: Node } // !x, -x, +x | { kind: 'refused'; message: string } // its children are never visited | { kind: 'constant' } | { kind: 'variable'; name: string } | { kind: 'property'; object: Node; name: string } | { kind: 'call'; object: Node | undefined; name: string; args: Node[] } | { kind: 'ctor'; name: string; offset: number } // `(Type) x`, refused whole; an unresolved Type fails compilation at its '('. | { kind: 'cast'; type: string; offset: number } // `Type x`, `def x = ...`, refused whole; an unresolved Type fails // compilation at the variable. | { kind: 'declaration'; type: string; offset: number } | { kind: 'statements'; items: Node[] }; // Thrown for anything this parser doesn't follow: no verdict. class GiveUp extends Error {} // Binding powers, Groovy 3's precedence (higher binds tighter). const BINARY_POWER: Record = { '||': 2, '&&': 3, '|': 4, '^': 5, '&': 6, '==': 7, '!=': 7, '<=>': 7, '===': 7, '!==': 7, '=~': 7, '==~': 7, '<': 8, '<=': 8, '>': 8, '>=': 8, in: 8, '!in': 8, instanceof: 8, '!instanceof': 8, as: 8, '<<': 9, '>>': 9, '>>>': 9, '..': 9, '..<': 9, '+': 10, '-': 10, '*': 11, '/': 11, '%': 11, '**': 13, }; const UNARY_POWER = 12; const RIGHT_ASSOCIATIVE = new Set(['**']); const KEYWORD_OPERATORS = new Set(['in', 'instanceof', 'as']); // This engine's dialect: criteriaDialect.ts answers it. const DIALECT_WORDS = new Set(['AND', 'OR', 'NOT']); class Parser { private at = 0; private readonly tokens: Token[]; // A binary operator read so far, and how deep inside a prefix operator's // operand the parser is: either makes a command-expression argument a // shape Groovy parses differently (criteriaJuxtaposition.ts answers it). private sawBinary = false; private prefixDepth = 0; constructor(tokens: Token[]) { this.tokens = tokens; } get done(): boolean { return this.at >= this.tokens.length; } private peek(ahead = 0): Token | undefined { return this.tokens[this.at + ahead]; } private next(): Token { const token = this.tokens[this.at]; if (!token) throw new GiveUp(); this.at += 1; return token; } private expect(text: string): void { if (this.next().text !== text) throw new GiveUp(); } // The binary operator at the cursor, and how many tokens it takes. private operator(): { op: string; length: number } | undefined { const token = this.peek(); if (!token) return undefined; if (token.kind === 'operator' && token.text === '!') { const word = this.peek(1); if (word?.kind === 'name' && (word.text === 'in' || word.text === 'instanceof')) { return { op: `!${word.text}`, length: 2 }; } return undefined; } if (token.kind === 'name' && KEYWORD_OPERATORS.has(token.text)) { return { op: token.text, length: 1 }; } if (token.kind === 'name' && DIALECT_WORDS.has(token.text.toUpperCase())) throw new GiveUp(); if (token.kind !== 'operator') return undefined; if (Object.hasOwn(BINARY_POWER, token.text)) return { op: token.text, length: 1 }; return undefined; } // Statements separated by ';', each a declaration or an expression. statements(): Node { const items: Node[] = []; for (;;) { items.push(this.declaration() ?? this.expression()); if (this.peek()?.kind !== 'semicolon') break; this.next(); if (this.done) break; } return items.length === 1 ? items[0] : { kind: 'statements', items }; } // `Type x`, `def x`, then `= value`, ';' or the end: a declaration, which // Groovy reads even with a field name for its type (`TRADER x`). private declaration(): Node | undefined { let ahead = 0; const first = this.peek(); if (first?.kind !== 'name' || !isTypeName(first.text)) return undefined; let type = first.text; while (this.peek(ahead + 1)?.text === '.' && this.peek(ahead + 2)?.kind === 'name') { type += `.${this.peek(ahead + 2)!.text}`; ahead += 2; } const variable = this.peek(ahead + 1); if (variable?.kind !== 'name' || !isTypeName(variable.text)) return undefined; const after = this.peek(ahead + 2); const assigned = after?.kind === 'operator' && after.text === '='; if (after && after.kind !== 'semicolon' && !assigned) return undefined; this.at += ahead + 2; if (assigned) { this.next(); this.expression(); } return { kind: 'declaration', type, offset: variable.offset }; } // `(Type)` before something a cast can apply to: the type and how many // tokens it takes, '(' to ')'. `(TRADER) == x` is a parenthesized TRADER. private castType(): { type: string; length: number } | undefined { let ahead = 1; const first = this.peek(ahead); if (first?.kind !== 'name' || !isTypeName(first.text)) return undefined; let type = first.text; while (this.peek(ahead + 1)?.text === '.' && this.peek(ahead + 2)?.kind === 'name') { type += `.${this.peek(ahead + 2)!.text}`; ahead += 2; } if (this.peek(ahead + 1)?.text !== ')') return undefined; const operand = this.peek(ahead + 2); const startsOperand = operand?.kind === 'literal' || (operand?.kind === 'name' && isTypeName(operand.text)) || (operand?.kind === 'open' && ['(', '[', '{'].includes(operand.text)) || (operand?.kind === 'operator' && (operand.text === '!' || operand.text === '~')); return startsOperand ? { type, length: ahead + 2 } : undefined; } expression(minPower = 0): Node { let left = this.unary(); for (;;) { const token = this.peek(); if (token?.kind === 'operator' && (token.text === '?' || token.text === '?:')) { if (minPower > 1) return left; this.next(); if (token.text === '?') { this.expression(1); this.expect(':'); } this.expression(1); left = { kind: 'refused', message: token.text === '?' ? NOT_SUPPORTED('Ternary expressions') : 'Elvis operator is not supported in criteria', }; continue; } if (token?.kind === 'operator' && (token.text === '=' || token.text.endsWith('='))) { // An assignment (`=`, `+=`, ...) is the dialect's or no criteria. if (!Object.hasOwn(BINARY_POWER, token.text)) throw new GiveUp(); } if (token?.kind === 'operator' && token.text === '<>') throw new GiveUp(); const operator = this.operator(); if (!operator) return left; const power = BINARY_POWER[operator.op]; if (power <= minPower) return left; this.at += operator.length; this.sawBinary = true; const right = this.expression(RIGHT_ASSOCIATIVE.has(operator.op) ? power - 1 : power); if (operator.op === '..' || operator.op === '..<') { left = { kind: 'refused', message: NOT_SUPPORTED('Range expressions') }; } else if (operator.op === 'as') { left = { kind: 'refused', message: NOT_SUPPORTED('Cast expressions') }; } else { left = { kind: 'binary', op: operator.op, left, right }; } } } private unary(): Node { const token = this.peek(); if (token?.kind === 'operator') { if (token.text === '!' || token.text === '-' || token.text === '+') { this.next(); return { kind: 'visitInner', operand: this.prefixOperand() }; } if (token.text === '~') { this.next(); this.prefixOperand(); return { kind: 'refused', message: NOT_SUPPORTED('Bitwise negation expressions') }; } if (token.text === '++' || token.text === '--') { this.next(); this.prefixOperand(); return { kind: 'refused', message: NOT_SUPPORTED('Prefix expressions') }; } } return this.postfix(this.primary()); } private prefixOperand(): Node { this.prefixDepth += 1; try { return this.expression(UNARY_POWER); } finally { this.prefixDepth -= 1; } } private postfix(start: Node): Node { let node = start; for (;;) { const token = this.peek(); if (token?.kind === 'operator' && (token.text === '.' || token.text === '?.')) { this.next(); const member = this.next(); if (member.kind !== 'name') throw new GiveUp(); if (this.peek()?.text === '(' && this.peek()?.kind === 'open') { node = { kind: 'call', object: node, name: member.text, args: this.arguments() }; } else if (this.peek()?.text === '{' && this.peek()?.kind === 'open') { // A closure as the only argument: `.any { ... }`. node = { kind: 'call', object: node, name: member.text, args: [this.primary()] }; } else { node = { kind: 'property', object: node, name: member.text }; } continue; } if (token?.kind === 'open' && token.text === '[') { this.next(); const index = this.expression(); this.expect(']'); node = { kind: 'binary', op: '[', left: node, right: index }; continue; } if (token?.kind === 'operator' && (token.text === '++' || token.text === '--')) { this.next(); node = { kind: 'refused', message: NOT_SUPPORTED('Postfix expressions') }; continue; } if (token?.kind === 'operator' && ['*.', '.&', '.@', '::', '->'].includes(token.text)) { throw new GiveUp(); } return node; } } // The arguments after a call's '('. private arguments(): Node[] { this.expect('('); const args: Node[] = []; if (this.peek()?.text === ')') { this.next(); return args; } const command = this.commandArgument(); if (command) return [command]; for (;;) { args.push(this.expression()); const after = this.next(); if (after.text === ')') return args; if (after.kind !== 'comma') throw new GiveUp(); } } // A call's only argument written as operands side by side, up to its ')': // the command expression Groovy reads, `a b c d e` as `this.a(b).c(d).e`. // Undefined when the argument doesn't start that way. private commandArgument(): Node | undefined { const tokens = this.tokens.slice(this.at, this.at + 2); if (tokens.length < 2 || juxtaposedIndex(tokens) !== 1) return undefined; // After a binary operator, inside a prefix operator's operand, or led by // a number (`1.call(b)`), Groovy parses it another way. if (this.sawBinary || this.prefixDepth > 0) throw new GiveUp(); if (tokens[0].kind === 'literal' && !/^['"]/.test(tokens[0].text)) throw new GiveUp(); const run: Token[] = [this.next(), this.next()]; while (this.peek() && juxtaposedIndex([run.at(-1)!, this.peek()!]) === 1) run.push(this.next()); this.expect(')'); const operand = (token: Token): Node => token.kind === 'name' && !['true', 'false', 'null'].includes(token.text) ? { kind: 'variable', name: token.text } : { kind: 'constant' }; let node: Node = { kind: 'call', object: undefined, name: run[0].text, args: [operand(run[1])], }; for (let index = 2; index < run.length; index += 2) { const argument = run[index + 1]; node = argument ? { kind: 'call', object: node, name: run[index].text, args: [operand(argument)] } : { kind: 'property', object: node, name: run[index].text }; } return node; } private primary(): Node { const token = this.next(); if (token.kind === 'literal') { // A GString is criteriaDialect.ts's. if (token.text.startsWith('"') && token.text.includes('$')) throw new GiveUp(); return { kind: 'constant' }; } if (token.kind === 'name') { if (['true', 'false', 'null'].includes(token.text)) return { kind: 'constant' }; if (DIALECT_WORDS.has(token.text.toUpperCase()) || KEYWORD_OPERATORS.has(token.text)) { throw new GiveUp(); } if (token.text === 'new') return this.constructorCall(token); // A name called directly is a call on an implicit `this`. if (this.peek()?.kind === 'open' && this.peek()?.text === '(') { return { kind: 'call', object: undefined, name: token.text, args: this.arguments() }; } return { kind: 'variable', name: token.text }; } if (token.kind === 'open' && token.text === '(') { this.at -= 1; const cast = this.castType(); this.at += 1; if (cast) { this.at += cast.length - 1; this.unary(); return { kind: 'cast', type: cast.type, offset: token.offset }; } const inner = this.expression(); this.expect(')'); return inner; } if (token.kind === 'open' && token.text === '[') return this.listOrMap(); if (token.kind === 'open' && token.text === '{') { let depth = 1; while (depth > 0) { const inner = this.next(); if (inner.kind === 'open') depth += 1; if (inner.kind === 'close') depth -= 1; } return { kind: 'refused', message: 'Closures are not supported in criteria' }; } throw new GiveUp(); } // `[a, b]` or `[k: v]` (`[:]`): refused whole. private listOrMap(): Node { let map = false; if (this.peek()?.text === ':') { this.next(); this.expect(']'); return { kind: 'refused', message: NOT_SUPPORTED('Map expressions') }; } if (this.peek()?.text === ']') { this.next(); return { kind: 'refused', message: NOT_SUPPORTED('List expressions') }; } for (;;) { this.expression(); if (this.peek()?.text === ':') { this.next(); this.expression(); map = true; } const after = this.next(); if (after.text === ']') break; if (after.kind !== 'comma') throw new GiveUp(); } return { kind: 'refused', message: NOT_SUPPORTED(map ? 'Map expressions' : 'List expressions'), }; } // `new X(...)`: the class, possibly qualified; its arguments are never // visited. private constructorCall(keyword: Token): Node { let name = this.next(); if (name.kind !== 'name') throw new GiveUp(); let qualified = name.text; while (this.peek()?.kind === 'operator' && this.peek()?.text === '.') { this.next(); name = this.next(); if (name.kind !== 'name') throw new GiveUp(); qualified += `.${name.text}`; } if (this.peek()?.text !== '(') throw new GiveUp(); // an array, a closure class this.arguments(); return { kind: 'ctor', name: qualified, offset: keyword.offset }; } } // The class a qualified name or a name Groovy resolves names, or undefined. function className(node: Node): string | undefined { if (node.kind === 'variable') return isClassName(node.name) ? node.name : undefined; if (node.kind !== 'property') return undefined; const parts: string[] = [node.name]; let object: Node = node.object; while (object.kind === 'property') { parts.unshift(object.name); object = object.object; } if (object.kind !== 'variable') return undefined; const qualified = [object.name, ...parts].join('.'); return QUALIFIED_CLASS.test(qualified) ? qualified : undefined; } interface Verdict { errors: string[]; // A constructor of a class Groovy can't resolve: its compilation fails first. unresolved?: { name: string; offset: number }; } // CriteriaValidatorVisitor's walk. function visit(node: Node, allowed: ReadonlySet | undefined, verdict: Verdict): void { const known = (name: string) => !allowed || allowed.has(name); switch (node.kind) { case 'binary': if (LOGICAL.has(node.op) || COMPARISONS.has(node.op)) { visit(node.left, allowed, verdict); visit(node.right, allowed, verdict); } else { verdict.errors.push(`Binary operator ${node.op} not allowed in criteria expression`); } return; case 'visitInner': visit(node.operand, allowed, verdict); return; case 'refused': verdict.errors.push(node.message); return; case 'constant': return; case 'variable': if (isClassName(node.name)) verdict.errors.push(NOT_SUPPORTED('Class expressions')); else if (!known(node.name)) { verdict.errors.push(`Variable ${node.name} is not allowed in criteria expression`); } return; case 'property': verdict.errors.push( className(node) ? NOT_SUPPORTED('Class expressions') : NOT_SUPPORTED('Property expressions'), ); return; case 'ctor': { const resolved = Object.hasOwn(DEFAULT_IMPORT_CLASSES, node.name) ? DEFAULT_IMPORT_CLASSES[node.name] : QUALIFIED_CLASS.test(node.name) ? node.name : undefined; if (!resolved) { verdict.unresolved ??= { name: node.name, offset: node.offset }; } else if (!ALLOWED_CONSTRUCTORS.has(resolved)) { verdict.errors.push( `Constructor calls for ${resolved} are not allowed in criteria expressions`, ); } return; } case 'cast': case 'declaration': if (!resolvesType(node.type)) verdict.unresolved ??= { name: node.type, offset: node.offset }; else { verdict.errors.push( NOT_SUPPORTED(node.kind === 'cast' ? 'Cast expressions' : 'Declaration expressions'), ); } return; case 'statements': for (const item of node.items) visit(item, allowed, verdict); return; case 'call': { const { object } = node; if (object?.kind === 'variable' && object.name === 'Expr') { if (!EXPR_METHODS.has(node.name)) { verdict.errors.push(`Method call Expr.${node.name} is not supported`); } } else if (object?.kind === 'variable' && !isClassName(object.name) && known(object.name)) { if (!FIELD_METHODS.has(node.name)) { verdict.errors.push(`Field method ${object.name}.${node.name} is not supported`); } } else if (object === undefined) { verdict.errors.push('Variable this is not allowed in criteria expression'); } else { visit(object, allowed, verdict); } for (const arg of node.args) visit(arg, allowed, verdict); } } } const SIMPLE_OPERATORS = new Set(['&&', '||', '==', '!=', '<', '<=', '>', '>=', '!', '.']); // Whether the criteria uses Groovy this engine's grammar doesn't read: an // operator other than its own, a bracket or brace, `new`, `in`, // `instanceof`, `as`, or operands side by side. function usesGroovy(tokens: Token[]): boolean { return ( tokens.some( (token) => (token.kind === 'operator' && !SIMPLE_OPERATORS.has(token.text)) || (token.kind === 'open' && token.text !== '(') || (token.kind === 'name' && ['new', 'in', 'instanceof', 'as'].includes(token.text)), ) || juxtaposedIndex(tokens) >= 0 ); } export type GroovyValidation = // Groovy this engine's grammar doesn't read, which GSF refuses: its text. | { kind: 'refused'; text: string } // Groovy GSF's validator passes. | { kind: 'passed' }; // GSF's verdict on a criteria in Groovy this engine's grammar doesn't read; // undefined when it reads it all (criteriaDialect.ts answers), or this // parser doesn't follow it. `allowed`: the query's fields, when known. export function groovyValidation( criteria: string, allowed?: ReadonlySet, ): GroovyValidation | undefined { const tokens = groovyTokens(criteria); if (!tokens || !usesGroovy(tokens)) return undefined; let root: Node; try { const parser = new Parser(tokens); root = parser.statements(); if (!parser.done) return undefined; } catch (error) { if (error instanceof GiveUp || error instanceof RangeError) return undefined; throw error; } const verdict: Verdict = { errors: [] }; visit(root, allowed, verdict); if (verdict.unresolved) { const { name, offset } = verdict.unresolved; const text = groovyCompileErrorText( criteria, { message: `unable to resolve class ${name}`, line: 1, column: offset + 1 }, { ownLine: true }, ); return { kind: 'refused', text }; } if (verdict.errors.length > 0) { return { kind: 'refused', text: `${VALIDATION_FAILED}${verdict.errors.join(',')}` }; } // Statements that pass the validator still have to compile as a filter, // which this parser doesn't follow. if (root.kind === 'statements') return undefined; return { kind: 'passed' }; }