Skip to content

Commit

Permalink
feat: Adding Has macro
Browse files Browse the repository at this point in the history
  • Loading branch information
Karol committed Dec 13, 2024
1 parent 9520c5d commit 7559679
Show file tree
Hide file tree
Showing 3 changed files with 219 additions and 8 deletions.
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
124 changes: 116 additions & 8 deletions src/visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
getPosition,
getResult,
getUnaryResult,
size,
has,
size
} from './helper.js'
import { CelEvaluationError } from './index.js'

Expand All @@ -35,7 +36,8 @@ const parserInstance = new CelParser()
const BaseCelVisitor = parserInstance.getBaseCstVisitorConstructor()

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

export class CelVisitor
Expand All @@ -62,6 +64,47 @@ export class CelVisitor
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.context.inHas = true
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.context.inHas = false
}
}

/**
* 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 +120,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) {
return false
}

if (ctx.rhs) {
ctx.rhs.forEach((rhsOperand) => {
const right = this.visit(rhsOperand)
Expand Down Expand Up @@ -237,21 +298,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.context.inHas && !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 +399,11 @@ export class CelVisitor
}

identifierExpression(ctx: IdentifierExpressionCstChildren): unknown {
// Validate that we have a dot expression when in a has() macro
if (this.context.inHas && !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

0 comments on commit 7559679

Please sign in to comment.