@pawel-up/jexl
Version:
Javascript Expression Language: Powerful context-based expression parser and evaluator
550 lines • 24 kB
JavaScript
import Lexer from './Lexer.js';
import Parser from './parser/Parser.js';
export var ValidationSeverity;
(function (ValidationSeverity) {
ValidationSeverity["ERROR"] = "error";
ValidationSeverity["WARNING"] = "warning";
ValidationSeverity["INFO"] = "info";
})(ValidationSeverity || (ValidationSeverity = {}));
export class Validator {
_grammar;
_lexer;
constructor(grammar) {
this._grammar = grammar;
this._lexer = new Lexer(grammar);
}
validate(expression, context, options = {}) {
const issues = [];
const opts = this._getDefaultOptions(options);
if (!expression || typeof expression !== 'string') {
issues.push({
severity: ValidationSeverity.ERROR,
message: 'Expression must be a non-empty string',
code: 'INVALID_INPUT',
});
return this._createResult(issues);
}
const trimmedExpression = expression.trim();
if (trimmedExpression.length === 0) {
issues.push({
severity: ValidationSeverity.ERROR,
message: 'Expression cannot be empty or whitespace only',
code: 'INVALID_INPUT',
});
return this._createResult(issues);
}
if (opts.maxLength && expression.length > opts.maxLength) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Expression length (${expression.length}) exceeds recommended maximum (${opts.maxLength})`,
code: 'EXPRESSION_TOO_LONG',
});
}
try {
this._validateLexical(trimmedExpression, issues);
if (this._hasErrors(issues)) {
return this._createResult(issues);
}
const ast = this._validateSyntax(trimmedExpression, issues);
if (this._hasErrors(issues) || !ast) {
return this._createResult(issues);
}
this._validateSemantics(ast, trimmedExpression, issues, opts);
if (!opts.allowUndefinedContext && context !== undefined) {
this._validateContext(ast, context, trimmedExpression, issues, opts);
}
if (opts.includeWarnings) {
this._performWarningAnalysis(ast, trimmedExpression, issues);
}
if (opts.includeInfo) {
this._performInfoAnalysis(ast, trimmedExpression, issues);
}
return this._createResult(issues, ast);
}
catch (error) {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
code: 'VALIDATION_ERROR',
});
return this._createResult(issues);
}
}
isValid(expression) {
try {
const result = this.validate(expression, {}, {
allowUndefinedContext: true,
includeWarnings: false,
includeInfo: false,
});
return result.valid;
}
catch {
return false;
}
}
getFirstError(expression) {
const result = this.validate(expression, {}, {
allowUndefinedContext: true,
includeWarnings: false,
includeInfo: false,
});
return result.errors[0] || null;
}
_validateLexical(expression, issues) {
try {
const tokens = this._lexer.tokenize(expression);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const nextToken = tokens[i + 1];
if (token.type === 'binaryOp' && nextToken?.type === 'binaryOp') {
const position = this._findTokenPosition(expression, token.raw);
issues.push({
severity: ValidationSeverity.ERROR,
message: `Consecutive operators: '${token.value}' followed by '${nextToken.value}'`,
startPosition: position,
endPosition: position + token.raw.length,
token: token.raw,
code: 'CONSECUTIVE_OPERATORS',
...this._getLineColumn(expression, position),
});
}
}
}
catch (error) {
const message = error instanceof Error ? error.message : 'Lexical analysis failed';
const match = message.match(/Invalid expression token: (.+)/);
if (match) {
const invalidToken = match[1];
const position = expression.indexOf(invalidToken);
issues.push({
severity: ValidationSeverity.ERROR,
message: `Invalid token: '${invalidToken}'`,
startPosition: position >= 0 ? position : undefined,
endPosition: position >= 0 ? position + invalidToken.length : undefined,
token: invalidToken,
code: 'INVALID_TOKEN',
...this._getLineColumn(expression, position >= 0 ? position : 0),
});
}
else {
issues.push({
severity: ValidationSeverity.ERROR,
message,
code: 'LEXICAL_ERROR',
});
}
}
}
_validateSyntax(expression, issues) {
try {
const parser = new Parser(this._grammar);
const tokens = this._lexer.tokenize(expression);
parser.addTokens(tokens);
const ast = parser.complete();
return ast;
}
catch (error) {
const originalMessage = error instanceof Error ? error.message : 'Parse error';
const message = `Syntax error: ${originalMessage}`;
let position;
let token;
const unexpectedMatch = message.match(/Unexpected token (.+)/);
if (unexpectedMatch) {
token = unexpectedMatch[1];
position = expression.indexOf(token);
}
issues.push({
severity: ValidationSeverity.ERROR,
message,
startPosition: position,
endPosition: position !== undefined && token ? position + token.length : undefined,
token,
code: 'SYNTAX_ERROR',
...this._getLineColumn(expression, position || 0),
});
return null;
}
}
_validateSemantics(ast, expression, issues, options) {
this._validateASTNode(ast, expression, issues, options, 0);
}
_validateASTNode(node, expression, issues, options, depth) {
if (!node)
return;
if (options.maxDepth && depth > options.maxDepth) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Expression depth (${depth}) exceeds recommended maximum (${options.maxDepth})`,
code: 'EXPRESSION_TOO_DEEP',
});
return;
}
switch (node.type) {
case 'FunctionCall':
this._validateFunction(node, expression, issues, options);
break;
case 'BinaryExpression':
this._validateBinaryExpression(node, expression, issues);
break;
case 'UnaryExpression':
this._validateUnaryExpression(node, expression, issues);
break;
}
switch (node.type) {
case 'BinaryExpression':
const binaryNode = node;
this._validateASTNode(binaryNode.left, expression, issues, options, depth + 1);
this._validateASTNode(binaryNode.right, expression, issues, options, depth + 1);
break;
case 'UnaryExpression':
const unaryNode = node;
this._validateASTNode(unaryNode.right, expression, issues, options, depth + 1);
break;
case 'ConditionalExpression':
const conditionalNode = node;
this._validateASTNode(conditionalNode.test, expression, issues, options, depth + 1);
this._validateASTNode(conditionalNode.consequent, expression, issues, options, depth + 1);
this._validateASTNode(conditionalNode.alternate, expression, issues, options, depth + 1);
break;
case 'FilterExpression':
const filterNode = node;
this._validateASTNode(filterNode.subject, expression, issues, options, depth + 1);
this._validateASTNode(filterNode.expr, expression, issues, options, depth + 1);
break;
case 'Identifier':
const identifierNode = node;
if (identifierNode.from) {
this._validateASTNode(identifierNode.from, expression, issues, options, depth + 1);
}
break;
case 'FunctionCall':
const functionNode = node;
if (functionNode.args) {
functionNode.args.forEach((arg) => {
this._validateASTNode(arg, expression, issues, options, depth + 1);
});
}
break;
case 'ArrayLiteral':
const arrayNode = node;
arrayNode.value.forEach((item) => {
this._validateASTNode(item, expression, issues, options, depth + 1);
});
break;
case 'ObjectLiteral':
const objectNode = node;
Object.values(objectNode.value).forEach((value) => {
this._validateASTNode(value, expression, issues, options, depth + 1);
});
break;
}
}
_validateFunction(node, _expression, issues, options) {
const funcName = node.name;
if (!funcName)
return;
const isBuiltIn = this._grammar.functions[funcName] !== undefined;
const isCustom = options.customFunctions.includes(funcName);
if (!isBuiltIn && !isCustom && node.pool === 'functions') {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Unknown function: '${funcName}'`,
token: funcName,
code: 'UNKNOWN_FUNCTION',
});
}
if (!isBuiltIn && !isCustom && node.pool === 'transforms') {
const isBuiltInTransform = this._grammar.transforms[funcName] !== undefined;
const isCustomTransform = options.customTransforms.includes(funcName);
if (!isBuiltInTransform && !isCustomTransform) {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Unknown transform: '${funcName}'`,
token: funcName,
code: 'UNKNOWN_TRANSFORM',
});
}
}
}
_validateBinaryExpression(node, _expression, issues) {
const operator = node.operator;
if (!operator)
return;
const isBuiltIn = this._grammar.elements[operator] !== undefined;
if (!isBuiltIn) {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Unknown binary operator: '${operator}'`,
token: operator,
code: 'UNKNOWN_OPERATOR',
});
}
}
_validateUnaryExpression(node, _expression, issues) {
const operator = node.operator;
if (!operator)
return;
const isBuiltIn = this._grammar.elements[operator] !== undefined;
if (!isBuiltIn) {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Unknown unary operator: '${operator}'`,
token: operator,
code: 'UNKNOWN_OPERATOR',
});
}
}
_validateContext(ast, context, expression, issues, options) {
this._validateContextPath(ast, context, context, expression, issues, options);
}
_validateContextPath(node, localContext, globalContext, expression, issues, options) {
if (!node)
return undefined;
switch (node.type) {
case 'Identifier':
const idNode = node;
if (idNode.from) {
const parentContext = this._validateContextPath(idNode.from, localContext, globalContext, expression, issues, options);
if (parentContext === undefined) {
return undefined;
}
if (parentContext === null) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Cannot access property '${idNode.value}' of null`,
token: idNode.value,
code: 'PROPERTY_ACCESS_ON_NULL',
});
return undefined;
}
if (typeof parentContext !== 'object') {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Cannot access property '${idNode.value}' on non-object value of type ${typeof parentContext}`,
token: idNode.value,
code: 'PROPERTY_ACCESS_ON_NON_OBJECT',
});
return undefined;
}
if (!(idNode.value in parentContext)) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Property '${idNode.value}' not found on object`,
token: idNode.value,
code: 'UNDEFINED_PROPERTY',
});
return undefined;
}
return parentContext[idNode.value];
}
if (idNode.relative) {
if (!idNode.value) {
return localContext;
}
const parentContext = localContext;
if (parentContext === null) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Cannot access property '${idNode.value}' of null`,
token: idNode.value,
code: 'PROPERTY_ACCESS_ON_NULL',
});
return undefined;
}
if (typeof parentContext !== 'object') {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Cannot access property '${idNode.value}' on non-object value of type ${typeof parentContext}`,
token: idNode.value,
code: 'PROPERTY_ACCESS_ON_NON_OBJECT',
});
return undefined;
}
if (!(idNode.value in parentContext)) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Property '${idNode.value}' not found on relative context object`,
token: idNode.value,
code: 'UNDEFINED_PROPERTY',
});
return undefined;
}
return parentContext[idNode.value];
}
if (typeof globalContext === 'object' && globalContext !== null && idNode.value in globalContext) {
return globalContext[idNode.value];
}
issues.push({
severity: ValidationSeverity.WARNING,
message: `Identifier '${idNode.value}' not found in context`,
token: idNode.value,
code: 'UNDEFINED_IDENTIFIER',
});
return undefined;
case 'Literal':
return node.value;
case 'ArrayLiteral':
const arrayNode = node;
return arrayNode.value.map((item) => this._validateContextPath(item, localContext, globalContext, expression, issues, options));
case 'ObjectLiteral':
const objectNode = node;
const newObj = {};
for (const key in objectNode.value) {
newObj[key] = this._validateContextPath(objectNode.value[key], localContext, globalContext, expression, issues, options);
}
return newObj;
case 'BinaryExpression':
const binaryNode = node;
this._validateContextPath(binaryNode.left, localContext, globalContext, expression, issues, options);
this._validateContextPath(binaryNode.right, localContext, globalContext, expression, issues, options);
return undefined;
case 'UnaryExpression':
const unaryNode = node;
this._validateContextPath(unaryNode.right, localContext, globalContext, expression, issues, options);
return undefined;
case 'ConditionalExpression':
const conditionalNode = node;
this._validateContextPath(conditionalNode.test, localContext, globalContext, expression, issues, options);
this._validateContextPath(conditionalNode.consequent, localContext, globalContext, expression, issues, options);
this._validateContextPath(conditionalNode.alternate, localContext, globalContext, expression, issues, options);
return undefined;
case 'FilterExpression':
const filterNode = node;
const subjectContext = this._validateContextPath(filterNode.subject, localContext, globalContext, expression, issues, options);
if (subjectContext === undefined)
return undefined;
if (filterNode.relative) {
if (!Array.isArray(subjectContext)) {
if (subjectContext !== undefined) {
issues.push({
severity: ValidationSeverity.WARNING,
message: 'Relative filter expression used on non-array value',
code: 'FILTER_ON_NON_ARRAY',
});
}
return undefined;
}
const relativeContext = subjectContext.length > 0 ? subjectContext[0] : {};
this._validateContextPath(filterNode.expr, relativeContext, globalContext, expression, issues, options);
}
else {
this._validateContextPath(filterNode.expr, localContext, globalContext, expression, issues, options);
}
return undefined;
case 'FunctionCall':
const functionNode = node;
if (functionNode.args) {
functionNode.args.forEach((arg) => this._validateContextPath(arg, localContext, globalContext, expression, issues, options));
}
return undefined;
}
return undefined;
}
_performWarningAnalysis(ast, _expression, issues) {
const nodeCount = this._countNodes(ast);
if (nodeCount > 50) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Complex expression with ${nodeCount} nodes may impact performance`,
code: 'COMPLEX_EXPRESSION',
});
}
}
_performInfoAnalysis(ast, _expression, issues) {
const nodeCount = this._countNodes(ast);
issues.push({
severity: ValidationSeverity.INFO,
message: `Expression contains ${nodeCount} nodes`,
code: 'EXPRESSION_STATS',
});
}
_getDefaultOptions(options) {
return {
allowUndefinedContext: true,
includeInfo: false,
includeWarnings: true,
maxDepth: 20,
maxLength: 1000,
customFunctions: [],
customTransforms: [],
...options,
};
}
_createResult(issues, ast) {
const errors = issues.filter((i) => i.severity === ValidationSeverity.ERROR);
const warnings = issues.filter((i) => i.severity === ValidationSeverity.WARNING);
const info = issues.filter((i) => i.severity === ValidationSeverity.INFO);
return {
valid: errors.length === 0,
issues: issues.sort((a, b) => (a.startPosition || 0) - (b.startPosition || 0)),
errors,
warnings,
info,
ast,
};
}
_hasErrors(issues) {
return issues.some((issue) => issue.severity === ValidationSeverity.ERROR);
}
_findTokenPosition(expression, tokenRaw) {
return expression.indexOf(tokenRaw.trim());
}
_getLineColumn(expression, position) {
const lines = expression.substring(0, position).split('\n');
return {
line: lines.length,
column: lines[lines.length - 1].length + 1,
};
}
_countNodes(node) {
if (!node)
return 0;
let count = 1;
switch (node.type) {
case 'BinaryExpression':
const binaryNode = node;
count += this._countNodes(binaryNode.left);
count += this._countNodes(binaryNode.right);
break;
case 'UnaryExpression':
const unaryNode = node;
count += this._countNodes(unaryNode.right);
break;
case 'ConditionalExpression':
const conditionalNode = node;
count += this._countNodes(conditionalNode.test);
count += this._countNodes(conditionalNode.consequent);
count += this._countNodes(conditionalNode.alternate);
break;
case 'FilterExpression':
const filterNode = node;
count += this._countNodes(filterNode.subject);
count += this._countNodes(filterNode.expr);
break;
case 'Identifier':
const identifierNode = node;
if (identifierNode.from) {
count += this._countNodes(identifierNode.from);
}
break;
case 'FunctionCall':
const functionNode = node;
if (functionNode.args) {
count += functionNode.args.reduce((sum, arg) => sum + this._countNodes(arg), 0);
}
break;
case 'ArrayLiteral':
const arrayNode = node;
count += arrayNode.value.reduce((sum, item) => sum + this._countNodes(item), 0);
break;
case 'ObjectLiteral':
const objectNode = node;
count += Object.values(objectNode.value).reduce((sum, value) => sum + this._countNodes(value), 0);
break;
}
return count;
}
}
export default Validator;
//# sourceMappingURL=Validator.js.map