Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support has() macro #33

Merged
merged 1 commit into from
Dec 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,17 @@ import { evaluate, parse } from 'cel-js'
console.log(`${mapExpr} => ${JSON.stringify(evaluate(mapExpr))}`) // => { a: 1, b: 2 }

// Macro expressions
// size()
const macroExpr = 'size([1, 2])'
console.log(`${macroExpr} => ${evaluate(macroExpr)}`) // => 2

// has()
const hasExpr = 'has(user.role)'
console.log(`${hasExpr} => ${evaluate(hasExpr, context)}`) // => true

const hasExpr2 = 'has(user.name)'
console.log(`${hasExpr2} => ${evaluate(hasExpr2, context)}`) // => false

// Custom function expressions
const functionExpr = 'max(2, 1, 3, 7)'
console.log(
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cel-js",
"version": "0.2.1",
"version": "0.3.0",
"description": "Common Expression Language (CEL) evaluator for JavaScript",
"keywords": [
"Common Expression Language",
Expand Down
14 changes: 14 additions & 0 deletions src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,17 @@ export const size = (arr: unknown) => {

throw new CelEvaluationError(`invalid_argument: ${arr}`)
}

/**
* Macro definition for the CEL has() function that checks if a path exists in an object.
*
* @param path - The path to check for existence
* @returns boolean - True if the path exists (is not undefined), false otherwise
*
* @example
* has(obj.field) // returns true if field exists on obj
*/
export const has = (path: unknown): boolean => {
// If the path itself is undefined, it means the field/index doesn't exist
return !(path === undefined)
}
89 changes: 89 additions & 0 deletions src/spec/macros.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,95 @@ import { CelEvaluationError, CelTypeError, evaluate } from '..'
import { Operations } from '../helper'

describe('lists expressions', () => {

describe('has', () => {
it('should return true when nested property exists', () => {
const expr = 'has(object.property)'

const result = evaluate(expr, { object: { property: true } })

expect(result).toBe(true)
})

it('should return false when property does not exists', () => {
const expr = 'has(object.nonExisting)'

const result = evaluate(expr, { object: { property: true } })

expect(result).toBe(false)
})

it('should return false when property does not exists, combined with property usage', () => {
const expr = 'has(object.nonExisting) && object.nonExisting'

const result = evaluate(expr, { object: { property: true } })

expect(result).toBe(false)
})

it('should throw when no arguments are passed', () => {
const expr = 'has()'
const context = { object: { property: true } }

expect(() => evaluate(expr, context))
.toThrow('has() requires exactly one argument')
})

it('should throw when argument is not an object', () => {
const context = { object: { property: true } }
const errorMessages = 'has() requires a field selection';

expect(() => evaluate('has(object)', context))
.toThrow(errorMessages)

expect(() => evaluate('has(object[0])', context))
.toThrow(errorMessages)

expect(() => evaluate('has(object[property])', context))
.toThrow(errorMessages)
})

describe('should throw when argument is an atomic expresion of type', () => {
const errorMessages = 'has() does not support atomic expressions'
const context = { object: { property: true } }

it('string', () => {
expect(() => evaluate('has("")', context))
.toThrow(errorMessages)

expect(() => evaluate('has("string")', context))
.toThrow(errorMessages)
})

it('array', () => {
expect(() => evaluate('has([])', context))
.toThrow(errorMessages)

expect(() => evaluate('has([1, 2, 3])', context))
.toThrow(errorMessages)
})

it('boolean', () => {
expect(() => evaluate('has(true)', context))
.toThrow(errorMessages)

expect(() => evaluate('has(false)', context))
.toThrow(errorMessages)
})

it('number', () => {
expect(() => evaluate('has(42)', context))
.toThrow(errorMessages)

expect(() => evaluate('has(0)', context))
.toThrow(errorMessages)

expect(() => evaluate('has(0.3)', context))
.toThrow(errorMessages)
})
})
})

describe('size', () => {
describe('list', () => {
it('should return 0 for empty list', () => {
Expand Down
137 changes: 129 additions & 8 deletions src/visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,26 @@ import {
getPosition,
getResult,
getUnaryResult,
size,
has,
size
} from './helper.js'
import { CelEvaluationError } from './index.js'

/** Mode in which visitors are executed */
enum Mode {
/** The visitor is executed without any specified mode */
'normal',
/** The visitor is executed inside a has macro */
'has'
}

const parserInstance = new CelParser()

const BaseCelVisitor = parserInstance.getBaseCstVisitorConstructor()

const defaultFunctions = {
size: size,
has,
size
};

export class CelVisitor
Expand All @@ -56,12 +66,58 @@ export class CelVisitor

private context: Record<string, unknown>

/**
* Tracks the current mode of the visitor to handle special cases.
*/
private mode: Mode = Mode.normal

private functions: Record<string, CallableFunction>

public expr(ctx: ExprCstChildren) {
return this.visit(ctx.conditionalOr) as unknown
}

/**
* Handles the special 'has' macro which checks for the existence of a field.
*
* @param ctx - The macro expression context containing the argument to check
* @returns boolean indicating if the field exists
* @throws CelEvaluationError if argument is missing or invalid
*/
private handleHasMacro(ctx: MacrosExpressionCstChildren): boolean {
if (!ctx.arg) {
throw new CelEvaluationError('has() requires exactly one argument')
}

this.mode = Mode.has
try {
const result = this.visit(ctx.arg)
return this.functions.has(result)
} catch (error) {
// Only convert to false if it's not a validation error
if (error instanceof CelEvaluationError) {
throw error
}
return false
} finally {
this.mode = Mode.normal
}
}

/**
* Handles execution of generic macro functions by evaluating and passing their arguments.
*
* @param fn - The macro function to execute
* @param ctx - The macro expression context containing the arguments
* @returns The result of executing the macro function with the evaluated arguments
*/
private handleGenericMacro(fn: CallableFunction, ctx: MacrosExpressionCstChildren): unknown {
return fn(...[
...(ctx.arg ? [this.visit(ctx.arg)] : []),
...(ctx.args ? ctx.args.map((arg) => this.visit(arg)) : [])
])
}

conditionalOr(ctx: ConditionalOrCstChildren): boolean {
let left = this.visit(ctx.lhs)

Expand All @@ -77,9 +133,27 @@ export class CelVisitor
return left
}

/**
* Evaluates a logical AND expression by visiting left and right hand operands.
*
* @param ctx - The conditional AND context containing left and right operands
* @returns The boolean result of evaluating the AND expression
*
* This method implements short-circuit evaluation - if the left operand is false,
* it returns false immediately without evaluating the right operand. This is required
* for proper handling of the has() macro.
*
* For multiple right-hand operands, it evaluates them sequentially, combining results
* with logical AND operations.
*/
conditionalAnd(ctx: ConditionalAndCstChildren): boolean {
let left = this.visit(ctx.lhs)

// Short circuit if left is false. Required to quick fail for has() macro.
if (left === false) {
ChromeGG marked this conversation as resolved.
Show resolved Hide resolved
return false
}

if (ctx.rhs) {
ctx.rhs.forEach((rhsOperand) => {
const right = this.visit(rhsOperand)
Expand Down Expand Up @@ -237,21 +311,63 @@ export class CelVisitor
return [key, value]
}

/**
* Evaluates a macros expression by executing the corresponding macro function.
*
* @param ctx - The macro expression context containing the macro identifier and arguments
* @returns The result of executing the macro function
* @throws Error if the macro function is not recognized
*
* This method handles two types of macros:
* 1. The special 'has' macro which checks for field existence
* 2. Generic macros that take evaluated arguments
*/
macrosExpression(ctx: MacrosExpressionCstChildren): unknown {
const macrosIdentifier = ctx.Identifier[0]
const fn = this.functions[macrosIdentifier.image];
if (fn) {
return fn(...[...(ctx.arg ? [this.visit(ctx.arg)] : []), ...(ctx.args ? ctx.args.map((arg) => this.visit(arg)) : [])])
const [ macrosIdentifier ] = ctx.Identifier
const fn = this.functions[macrosIdentifier.image]

if (!fn) {
throw new Error(`Macros ${macrosIdentifier.image} not recognized`)
}

// Handle special case for `has` macro
if (macrosIdentifier.image === 'has') {
return this.handleHasMacro(ctx)
}
throw new Error(`Macros ${macrosIdentifier.image} not recognized`)

return this.handleGenericMacro(fn, ctx)
}

// these two visitor methods will return a string.
/**
* Evaluates an atomic expression node in the AST.
*
* @param ctx - The atomic expression context containing the expression type and value
* @returns The evaluated value of the atomic expression
* @throws CelEvaluationError if invalid atomic expression is used in has() macro
* @throws Error if reserved identifier is used or expression type not recognized
*
* Handles the following atomic expression types:
* - Null literals
* - Parenthesized expressions
* - String literals
* - Boolean literals
* - Float literals
* - Integer literals
* - Identifier expressions
* - List expressions
* - Map expressions
* - Macro expressions
*/
atomicExpression(ctx: AtomicExpressionCstChildren) {
if (ctx.Null) {
return null
}

// Check if we are in a has() macro, and if so, throw an error if we are not in a field selection
if (this.mode === Mode.has && !ctx.identifierExpression) {
throw new CelEvaluationError('has() does not support atomic expressions')
}

if (ctx.parenthesisExpression) {
return this.visit(ctx.parenthesisExpression)
}
Expand Down Expand Up @@ -296,6 +412,11 @@ export class CelVisitor
}

identifierExpression(ctx: IdentifierExpressionCstChildren): unknown {
// Validate that we have a dot expression when in a has() macro
if (this.mode === Mode.has && !ctx.identifierDotExpression?.length) {
throw new CelEvaluationError('has() requires a field selection')
}

const data = this.context
const result = this.getIdentifier(data, ctx.Identifier[0].image)

Expand Down
Loading