// What GSF answers a CRITERIA_MATCH written in this engine's own dialect: the // forms its grammar (criteria.ts) accepts but GSF never does — the AND, OR // and NOT keywords (any case), `<>`, a single `=`, a dotted field name, a // double-quoted string with a `$` in it, and a call GSF's validator doesn't // allow: an Expr function or field method off its whitelists // (`Expr.contains`, `NAME.endsWith`, `Expr.foo`), a call on a class // (`Math.abs`) or on an implicit `this` (`foo(X)`). // // GSF refuses every one of them, at one of three stages // (CriteriaFilterBuilder.getCriteriaFilter, GSF v8.15.29): // 1. Groovy's lexer and parser (AstBuilder().buildFromString, Groovy 3.0.25): // `A == 1 AND B == 2`, `A <> 1`, `"US$"`; // 2. CriteriaValidatorVisitor over the parsed AST: Groovy reads `NOT x` as a // call on an implicit `this`, `A = 1` as an assignment, `A.B` as a // property, `"a$b"` as a GString, `Expr.foo(x)` as a method its // whitelist lacks — all refused, with every other error in source order // (unknown fields too); // 3. the @CompileStatic compilation of the filter (EvaluatorPool // addExpression), for what Groovy reads as a method call on a Boolean: // `Expr.f(x) AND y`, `(A) AND B`, `!A AND B` — its text the criteria // with each field replaced by its getter (criteria.ts compileFailureText). // The texts below are Groovy's and the validator's for the shapes clients // write (checked by running Groovy 3.0.25 and genesis-db 8.15.29's // CriteriaValidatorVisitor on them; test/criteriaDialect.test.ts pins them). // Any other shape is still refused, with an approximate text: ANTLR's error // recovery differs case by case. import { compileFailureText, tokenizeCriteria } from './criteria.ts'; import { juxtaposition } from './criteriaJuxtaposition.ts'; import { groovyCompileErrorText, groovySyntaxError } from './groovySyntax.ts'; import { EXPR_METHODS, FIELD_METHODS, groovyValidation, isClassName } from './groovyValidator.ts'; const VALIDATION_FAILED = 'GenericCriteriaValidationException : Criteria validation failed: '; const THIS_NOT_ALLOWED = 'Variable this is not allowed in criteria expression'; const ASSIGNMENT_NOT_ALLOWED = 'Binary operator = not allowed in criteria expression'; const PROPERTY_NOT_ALLOWED = 'Property expressions are not supported in criteria'; const GSTRING_NOT_ALLOWED = 'GString expressions are not supported in criteria'; const LHS_NOT_ASSIGNABLE = 'The LHS of an assignment should be a variable or a field accessing expression'; const CLASS_NOT_ALLOWED = 'Class expressions are not supported in criteria'; const COMPARISONS = new Set(['==', '!=', '<', '<=', '>', '>=']); const LITERAL_WORDS = new Set(['true', 'false', 'null']); type Role = | 'field' // a name read as a field | 'literal' // a string, number, true, false or null | 'call' // `Expr.fn` or `FIELD.fn` before its '(' | 'thisCall' // `fn` before its '(': a call on an implicit `this` | 'new' // the `new` of a constructor call | 'ctor' // its class, before its '(' | 'property' // a dotted name that isn't a call | 'comparison' // == != < <= > >= | 'logic' // && || | 'and' // the AND / OR keyword (dialect) | 'not' // the NOT keyword (dialect) | 'bang' // ! | 'diamond' // <> (dialect) | 'assign' // = (dialect) | 'open' // a grouping '(' | 'callOpen' // a call's '(' | 'close' | 'comma'; interface Token { text: string; offset: number; end: number; role: Role; // The index of the grouping '(' this token is inside (a call's parentheses // don't count), or -1 at the top. group: number; // A grouping '(': the index of its ')'. closeAt?: number; // A double-quoted string: whether a `$` starts an interpolation (a // GString), and the offset of the character a lone `$` can't be followed by. gstring?: boolean; badDollarAt?: number; } const endsOperand = (token: Token | undefined) => token !== undefined && (token.role === 'field' || token.role === 'literal' || token.role === 'property' || token.role === 'close'); const startsNegation = (token: Token | undefined) => token === undefined || token.role === 'open' || token.role === 'logic' || token.role === 'and' || token.role === 'not' || token.role === 'bang'; // In a double-quoted Groovy string, `$` starts an interpolation when a name or // `{` follows; anything else after it is a lexer error at that character. function inspectDollars(token: Token): void { if (!token.text.startsWith('"')) return; const body = token.text.slice(1, -1); for (let at = 0; at < body.length; at += 1) { if (body[at] === '\\') { at += 1; continue; } if (body[at] !== '$') continue; if (/[A-Za-z_{]/.test(body[at + 1] ?? '')) { token.gstring = true; continue; } // The character after the `$`: at worst the closing quote. token.badDollarAt = token.offset + 1 + at + 1; return; } } // The criteria's tokens with the role this grammar gives each one. function classify(criteria: string): Token[] { const tokens: Token[] = []; // Open parentheses: a grouping one by its index, a call's as -1. const opens: number[] = []; const currentGroup = () => opens.findLast((open) => open >= 0) ?? -1; for (const { text, offset } of tokenizeCriteria(criteria.trimEnd())) { const previous = tokens.at(-1); const token: Token = { text, offset, end: offset + text.length, role: 'field', group: currentGroup(), }; const upper = text.toUpperCase(); if (/^['"]/.test(text) || /^-?\d/.test(text) || LITERAL_WORDS.has(text)) { token.role = 'literal'; inspectDollars(token); } else if (text === '(') { const called = ['property', 'field', 'ctor'].includes(previous?.role ?? ''); token.role = called ? 'callOpen' : 'open'; } else if (text === ')') { token.role = 'close'; } else if (text === ',') { token.role = 'comma'; } else if (COMPARISONS.has(text)) { token.role = 'comparison'; } else if (text === '&&' || text === '||') { token.role = 'logic'; } else if (text === '!') { token.role = 'bang'; } else if (text === '<>') { token.role = 'diamond'; } else if (text === '=') { token.role = 'assign'; } else if ((upper === 'AND' || upper === 'OR') && endsOperand(previous)) { token.role = 'and'; } else if (upper === 'NOT' && startsNegation(previous)) { token.role = 'not'; } else if (text.includes('.')) { token.role = 'property'; // a call is told apart at its '(' } // `new X(...)`: a constructor call (groovyValidator.ts gives GSF's verdict // on it), not a variable `new` and a call on `this`. if (previous?.text === 'new' && previous.role === 'field' && /^[A-Za-z_]/.test(text)) { if (token.role === 'field' || token.role === 'property') { previous.role = 'new'; token.role = 'ctor'; } } if (token.role === 'callOpen' && previous!.role !== 'ctor') { previous!.role = previous!.role === 'field' ? 'thisCall' : 'call'; } if (token.role === 'open') opens.push(tokens.length); if (token.role === 'callOpen') opens.push(-1); if (token.role === 'close') { const open = opens.pop(); if (open !== undefined && open >= 0) tokens[open].closeAt = tokens.length; token.group = currentGroup(); } tokens.push(token); } return tokens; } const DIALECT_ROLES = new Set(['and', 'not', 'diamond', 'assign', 'property', 'thisCall']); const splitCall = (token: Token) => { const dot = token.text.lastIndexOf('.'); return [token.text.slice(0, dot), token.text.slice(dot + 1)]; }; // A call the validator refuses whatever else the criteria holds: an Expr // function or method off its whitelists — on a field, or on any other name // (`UNKNOWN.bar(x)`, `this.foo(x)`: the validator names the variable) — or a // call on a class or a property. function isRefusedCall(token: Token): boolean { if (token.role !== 'call') return false; const [receiver, method] = splitCall(token); if (receiver === 'Expr') return !EXPR_METHODS.has(method); if (isClassName(receiver) || receiver.includes('.')) return true; return !FIELD_METHODS.has(method); } const isDialect = (token: Token) => DIALECT_ROLES.has(token.role) || token.gstring === true || token.badDollarAt !== undefined || isRefusedCall(token); // operand, comparison, operand const COMPARISON_LENGTH = 3; const isOperand = (token: Token | undefined) => token?.role === 'field' || token?.role === 'literal' || token?.role === 'property'; // A complete expression Groovy can't continue with an AND or OR: comparisons // (`A == 1`) and parenthesised groups joined by && and ||. A lone group is // not one: Groovy reads `(A) AND (B)` as a call instead. function isCompleteExpression(tokens: Token[]): boolean { let at = 0; let items = 0; let comparisons = 0; while (at < tokens.length) { const token = tokens[at]; if (token.role === 'open' && token.closeAt !== undefined) { at = token.closeAt + 1; } else if ( isOperand(token) && tokens[at + 1]?.role === 'comparison' && isOperand(tokens[at + 2]) ) { at += COMPARISON_LENGTH; comparisons += 1; } else { return false; } items += 1; if (at === tokens.length) break; if (tokens[at].role !== 'logic') return false; at += 1; if (at === tokens.length) return false; } return items > 0 && (comparisons > 0 || items > 1); } // Every token from the start of the group `index` is in up to `index`. function groupPrefix(tokens: Token[], index: number): Token[] { return tokens.slice(tokens[index].group + 1, index); } // The index of the ')' closing the group a token is in, or the end. function groupEnd(tokens: Token[], index: number): number { const group = tokens[index].group; return group < 0 ? tokens.length : (tokens[group].closeAt ?? tokens.length); } const syntaxError = (criteria: string, message: string, offset: number) => groovyCompileErrorText(criteria, { message, line: 1, column: offset + 1 }); const span = (criteria: string, from: Token, to: Token) => `Unexpected input: '${criteria.slice(from.offset, to.end)}'`; // Stage 1, the parser's errors. `<>` is reported at its '>': a name before // it that starts the criteria or a group reads as the start of a declaration // or cast, so the text runs from there; anything else names the operator, // and after an AND or OR, only its '<'. Without `<>`, the first AND or OR // after a complete expression, or NOT after `X &&` before a name, is // reported spanning what came before it in its group. function parseError(criteria: string, tokens: Token[]): string | undefined { const diamondAt = tokens.findIndex((token) => token.role === 'diamond'); if (diamondAt >= 0) { const diamond = tokens[diamondAt]; if (tokens.slice(0, diamondAt).some((token) => token.role === 'and')) { return syntaxError(criteria, "Unexpected input: '<'", diamond.offset); } const [left, beforeLeft] = [tokens[diamondAt - 1], tokens[diamondAt - 2]]; let from = diamond.offset; const named = left?.role === 'field' || left?.role === 'property'; if (named && beforeLeft === undefined) from = left.offset; if (named && beforeLeft?.role === 'open') from = beforeLeft.offset; const gt = diamond.offset + 1; return syntaxError(criteria, `Unexpected input: '${criteria.slice(from, gt + 1)}'`, gt); } for (const [index, token] of tokens.entries()) { if (token.role === 'and') { const prefix = groupPrefix(tokens, index); if (isCompleteExpression(prefix)) { return syntaxError(criteria, span(criteria, prefix[0], prefix.at(-1)!), prefix[0].offset); } } if ( token.role === 'not' && token.group === -1 && tokens[index - 1]?.role === 'logic' && ['field', 'property', 'call'].includes(tokens[index + 1]?.role ?? '') ) { const prefix = groupPrefix(tokens, index - 1); if (isCompleteExpression(prefix)) { return syntaxError(criteria, span(criteria, prefix[0], token), prefix[0].offset); } } } return undefined; } // Stage 1 still, after parsing: a capitalised NOT before a lone name declares // a variable of class NOT; an assignment needs a name on its left (each `=` // of a group checked from the right, as assignment binds rightwards). function semanticError(criteria: string, tokens: Token[]): string | undefined { const [not, name] = tokens; if ( tokens.length === 2 && not.role === 'not' && name.role === 'field' && /^[A-Z]/.test(not.text) ) { return groovyCompileErrorText( criteria, { message: `unable to resolve class ${not.text}`, line: 1, column: name.offset + 1 }, { ownLine: true }, ); } const assigns = tokens.flatMap((token, index) => (token.role === 'assign' ? [index] : [])); for (const index of [...assigns].reverse()) { // The left side: back to the group's start, or to the `=` before it in // the same group. const group = tokens[index].group; let from = group + 1; for (let at = index - 1; at > group; at -= 1) { if (tokens[at].role === 'assign' && tokens[at].group === group) { from = at + 1; break; } } const left = tokens.slice(from, index); // A name, or "a field accessing expression" (A.B). if (left.length !== 1 || (left[0].role !== 'field' && left[0].role !== 'property')) { return syntaxError(criteria, LHS_NOT_ASSIGNABLE, (left[0] ?? tokens[index]).offset); } } return undefined; } // Stage 2, the validator's errors in the order it visits the AST — source // order for the shapes modelled here — or undefined for a shape it isn't // modelled for. `allowed` unset: every field is (a query whose fields aren't // known). Groovy reads `NOT x` as `this.NOT(x)`, `(A) AND (B)` as a call of // AND on `this` (its first such keyword only), `F AND x` as `F.AND(x)`. function validationErrors(tokens: Token[], allowed?: ReadonlySet): string[] | undefined { const errors: string[] = []; const known = (name: string) => !allowed || allowed.has(name); let calledOnThis = false; for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; const next = tokens[index + 1]; if ((token.role === 'field' || token.role === 'property') && next?.role === 'assign') { // Refused whole: nothing to its right in the group is visited. errors.push(ASSIGNMENT_NOT_ALLOWED); index = groupEnd(tokens, index) - 1; } else if (index === 0 && token.role === 'field' && next?.role === 'and') { errors.push( known(token.text) ? `Field method ${token.text}.${next.text} is not supported` : `Variable ${token.text} is not allowed in criteria expression`, ); index += 1; } else if (token.role === 'and') { const prefix = groupPrefix(tokens, index); const loneGroup = prefix[0]?.role === 'open' && prefix[0].closeAt === index - 1; if (!calledOnThis && loneGroup && next?.role === 'open') { errors.push(THIS_NOT_ALLOWED); calledOnThis = true; } else if ( !calledOnThis && loneGroup && (next?.role === 'call' || next?.role === 'property') ) { // `(A) AND X.y(...)`: Groovy's AST has a property expression here, // which the validator refuses before visiting the rest. errors.push(PROPERTY_NOT_ALLOWED); return errors; } } else if (token.role === 'not') { if (next?.role === 'not') return undefined; errors.push(THIS_NOT_ALLOWED); } else if (token.role === 'field' && !known(token.text)) { errors.push(`Variable ${token.text} is not allowed in criteria expression`); } else if (token.role === 'property') { errors.push(PROPERTY_NOT_ALLOWED); } else if (token.role === 'literal' && token.gstring) { errors.push(GSTRING_NOT_ALLOWED); } else if (token.role === 'thisCall') { errors.push(THIS_NOT_ALLOWED); } else if (token.role === 'call') { const [receiver, method] = splitCall(token); if (receiver === 'Expr') { if (!EXPR_METHODS.has(method)) errors.push(`Method call Expr.${method} is not supported`); } else if (isClassName(receiver)) { errors.push(CLASS_NOT_ALLOWED); } else if (receiver.includes('.')) { errors.push(PROPERTY_NOT_ALLOWED); } else if (!known(receiver)) { errors.push(`Variable ${receiver} is not allowed in criteria expression`); } else if (!FIELD_METHODS.has(method)) { errors.push(`Field method ${receiver}.${method} is not supported`); } } } return errors; } // The fields the validator records a criteria reading (usedFields): the // names it reads, and the receivers of field methods. function usedFields(tokens: Token[], allowed?: ReadonlySet): string[] { const known = (name: string) => !allowed || allowed.has(name); return tokens.flatMap((token) => { if (token.role === 'field') return known(token.text) ? [token.text] : []; if (token.role !== 'call') return []; const [receiver] = splitCall(token); const field = receiver !== 'Expr' && !isClassName(receiver) && !receiver.includes('.'); return field && known(receiver) ? [receiver] : []; }); } // The INVALID_CRITERIA TEXT GSF sends for a criteria in this engine's // dialect, and where its first dialect token is; undefined when it uses none. // `allowed`: the fields the query exposes, when known; `types`: their // Genesis types, when known (they name the getters a compilation error // quotes). The criteria needn't parse in this grammar, only tokenize: every // dialect token, wherever it stands, is one GSF refuses. export function criteriaDialect( criteria: string, allowed?: ReadonlySet, types?: ReadonlyMap, ): { text: string; at: number } | undefined { let tokens: Token[]; try { tokens = classify(criteria); } catch { return undefined; } const first = tokens.find(isDialect); if (!first) return undefined; const refused = (text: string) => ({ text, at: first.offset }); // The lexer, then the parser, then the validator. const dollar = tokens.find((token) => token.badDollarAt !== undefined); if (dollar) { const at = dollar.badDollarAt!; return refused(syntaxError(criteria, `token recognition error at: '${criteria[at]}'`, at)); } const compileError = parseError(criteria, tokens) ?? semanticError(criteria, tokens); if (compileError) return refused(compileError); const errors = validationErrors(tokens, allowed); if (errors?.length) return refused(`${VALIDATION_FAILED}${errors.join(',')}`); // The validator passes it, so the @CompileStatic compilation refuses it // (a method call on a Boolean: `Expr.f(x) AND y`, `(A) AND B`, `!A AND B`). if (errors) return refused(compileFailureText(criteria, usedFields(tokens, allowed), types)); // Not modelled: still refused, as GSF refuses it, with a text naming the // first dialect token. return refused(syntaxError(criteria, `Unexpected input: '${first.text}'`, first.offset)); } export function dialectRefusal( criteria: string, allowed?: ReadonlySet, types?: ReadonlyMap, ): string | undefined { return criteriaDialect(criteria, allowed, types)?.text; } // What strict criteria refuse: a Groovy syntax error (groovySyntax.ts), the // dialect, or operands side by side (criteriaJuxtaposition.ts), whichever // Groovy meets first — the INVALID_CRITERIA TEXT, or undefined. export function strictCriteriaRefusal( criteria: string, allowed?: ReadonlySet, types?: ReadonlyMap, ): string | undefined { const syntax = groovySyntaxError(criteria); // Groovy this grammar doesn't read (a list, arithmetic, a ternary, `new`, // a command expression): GSF's validator answers it whole. const groovy = syntax ? undefined : groovyValidation(criteria, allowed); if (groovy) return groovy.kind === 'refused' ? groovy.text : undefined; // The syntax check stops at operands side by side, so its error is before them. const [first] = [ criteriaDialect(criteria, allowed, types), syntax ? undefined : juxtaposition(criteria), ] .filter((refusal) => refusal !== undefined) .sort((a, b) => a.at - b.at); if (syntax && (!first || syntax.column - 1 < first.at)) { return groovyCompileErrorText(criteria, syntax); } return first?.text; }