Skip to content

Commit 281a8a2

Browse files
JastrzebowskiKarol
andauthored
feat: Implement missing macros (Fix) (#61)
Co-authored-by: Karol <[email protected]>
1 parent ed8ce02 commit 281a8a2

File tree

6 files changed

+688
-10
lines changed

6 files changed

+688
-10
lines changed

demo/demo.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,55 @@ import { evaluate, parse } from 'cel-js'
4343

4444
// Macro expressions
4545
// size()
46-
const macroExpr = 'size([1, 2])'
47-
console.log(`${macroExpr} => ${evaluate(macroExpr)}`) // => 2
46+
const sizeMacroExpr = 'size([1, 2])'
47+
console.log(`${sizeMacroExpr} => ${evaluate(sizeMacroExpr)}`) // => 2
4848

4949
// has()
50-
const hasExpr = 'has(user.role)'
51-
console.log(`${hasExpr} => ${evaluate(hasExpr, context)}`) // => true
50+
const hasMacroExpr = 'has(user.role)'
51+
console.log(`${hasMacroExpr} => ${evaluate(hasMacroExpr, context)}`) // => true
52+
53+
// Collection macro context
54+
const collectionContext = {
55+
numbers: [1, 2, 3, 4, 5],
56+
scores: { alice: 85, bob: 92, charlie: 78 },
57+
people: [
58+
{ name: 'Alice', age: 25 },
59+
{ name: 'Bob', age: 30 },
60+
{ name: 'Charlie', age: 35 },
61+
],
62+
}
63+
64+
// Collection macros - filter()
65+
const filterMacroExpr = 'numbers.filter(n, n > 3)'
66+
console.log(`${filterMacroExpr} => ${evaluate(filterMacroExpr, collectionContext)}`) // => [4, 5]
67+
68+
// Collection macros - map()
69+
const mapMacroExpr = 'numbers.map(n, n * 2)'
70+
console.log(`${mapMacroExpr} => ${evaluate(mapMacroExpr, collectionContext)}`) // => [2, 4, 6, 8, 10]
71+
72+
// Collection macros - all()
73+
const allMacroExpr = 'numbers.all(n, n > 0)'
74+
console.log(`${allMacroExpr} => ${evaluate(allMacroExpr, collectionContext)}`) // => true
75+
76+
// Collection macros - exists()
77+
const existsMacroExpr = 'numbers.exists(n, n > 5)'
78+
console.log(`${existsMacroExpr} => ${evaluate(existsMacroExpr, collectionContext)}`) // => false
79+
80+
// Collection macros - exists_one()
81+
const existsOneMacroExpr = 'numbers.exists_one(n, n == 5)'
82+
console.log(`${existsOneMacroExpr} => ${evaluate(existsOneMacroExpr, collectionContext)}`) // => true
83+
84+
// Collection macros on maps
85+
const mapFilterExpr = 'scores.filter(name, scores[name] > 80)'
86+
console.log(`${mapFilterExpr} => ${evaluate(mapFilterExpr, collectionContext)}`) // => ['alice', 'bob']
87+
88+
// Collection macros - map with predicate (3 arguments)
89+
const mapWithPredicateExpr = 'numbers.map(n, n > 3, n * 10)'
90+
console.log(`${mapWithPredicateExpr} => ${evaluate(mapWithPredicateExpr, collectionContext)}`) // => [40, 50]
91+
92+
// Collection macros - extract properties
93+
const extractPropsExpr = 'people.map(person, person.name)'
94+
console.log(`${extractPropsExpr} => ${evaluate(extractPropsExpr, collectionContext)}`) // => ['Alice', 'Bob', 'Charlie']
5295

5396
// Custom function expressions
5497
const functionExpr = 'max(2, 1, 3, 7)'

readme.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ Try out `cel-js` in your browser with the [live demo](https://stackblitz.com/git
3939
- [x] Dot Notation (`foo.bar`)
4040
- [x] Index Notation (`foo["bar"]`)
4141
- [x] [Macros](https://github.com/google/cel-spec/blob/master/doc/langdef.md#macros): (`has`, `size`, etc.)
42-
- [ ] All (`e.all(x, p)`)
43-
- [ ] Exists (`e.exists(x, p)`)
44-
- [ ] Exists one (`e.exists_one(x, p)`)
45-
- [ ] Filter (`e.filter(x, p)`)
42+
- [x] All (`e.all(x, p)`)
43+
- [x] Exists (`e.exists(x, p)`)
44+
- [x] Exists one (`e.exists_one(x, p)`)
45+
- [x] Filter (`e.filter(x, p)`)
4646
- [x] Has (`has(foo.bar)`)
47-
- [ ] Map (`e.map(x, t)` and `e.map(x, p, t)`)
47+
- [x] Map (`e.map(x, t)` and `e.map(x, p, t)`)
4848
- [x] Size (`size(foo)`)
4949
- [x] Unary Operators (`!true`, `-123`)
5050
- [x] Custom Functions (`myFunction()`)

src/cst-definitions.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ export interface IdentifierDotExpressionCstNode extends CstNode {
161161
export type IdentifierDotExpressionCstChildren = {
162162
Dot: IToken[]
163163
Identifier: IToken[]
164+
OpenParenthesis?: IToken[]
165+
arg?: ExprCstNode[]
166+
Comma?: IToken[]
167+
args?: ExprCstNode[]
168+
CloseParenthesis?: IToken[]
164169
}
165170

166171
export interface IndexExpressionCstNode extends CstNode {

src/parser.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,18 @@ export class CelParser extends CstParser {
171171
private identifierDotExpression = this.RULE('identifierDotExpression', () => {
172172
this.CONSUME(Dot)
173173
this.CONSUME(Identifier)
174+
// Optional method call with arguments (for collection macros)
175+
this.OPTION(() => {
176+
this.CONSUME(OpenParenthesis)
177+
this.OPTION2(() => {
178+
this.SUBRULE(this.expr, { LABEL: 'arg' })
179+
this.MANY(() => {
180+
this.CONSUME(Comma)
181+
this.SUBRULE2(this.expr, { LABEL: 'args' })
182+
})
183+
})
184+
this.CONSUME(CloseParenthesis)
185+
})
174186
})
175187

176188
private indexExpression = this.RULE('indexExpression', () => {

src/spec/collection-macros.spec.ts

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { expect, describe, it } from 'vitest'
2+
import { CelEvaluationError, evaluate } from '..'
3+
4+
describe('collection macros', () => {
5+
const context = {
6+
groups: [
7+
{ custom: true, name: 'group 1' },
8+
{ custom: false, name: 'group 2' },
9+
{ custom: true, name: 'group 3' },
10+
],
11+
numbers: [1, 2, 3, 4, 5],
12+
people: [
13+
{ name: 'Alice', age: 25 },
14+
{ name: 'Bob', age: 30 },
15+
{ name: 'Charlie', age: 35 },
16+
],
17+
scores: { alice: 85, bob: 92, charlie: 78 },
18+
nested: {
19+
nested: {
20+
numbers: [1, 2, 3, 4, 5],
21+
},
22+
},
23+
map: {
24+
a: 1,
25+
b: 2,
26+
c: 3,
27+
},
28+
}
29+
30+
describe('filter', () => {
31+
it('should filter list with boolean condition', () => {
32+
const expr = 'groups.filter(group, group.custom == true)'
33+
const result = evaluate(expr, context)
34+
35+
expect(result).toStrictEqual([
36+
{ custom: true, name: 'group 1' },
37+
{ custom: true, name: 'group 3' },
38+
])
39+
})
40+
41+
it('should filter list', () => {
42+
const expr = 'numbers.filter(n, n > 3)'
43+
const result = evaluate(expr, context)
44+
45+
expect(result).toStrictEqual([4, 5])
46+
})
47+
48+
it('should filter maps', () => {
49+
const expr = 'scores.filter(name, scores[name] > 80)'
50+
const result = evaluate(expr, context)
51+
52+
expect(result).toStrictEqual(['alice', 'bob'])
53+
})
54+
})
55+
56+
describe('all', () => {
57+
it('should return true if all items match condition', () => {
58+
const expr = 'numbers.all(n, n > 0)'
59+
const result = evaluate(expr, context)
60+
61+
expect(result).toBe(true)
62+
})
63+
64+
it('should return false if not all items match', () => {
65+
const expr = 'numbers.all(n, n > 3)'
66+
const result = evaluate(expr, context)
67+
68+
expect(result).toBe(false)
69+
})
70+
71+
it('should operate on lists', () => {
72+
const expr = 'groups.all(group, group.custom == true)'
73+
const result = evaluate(expr, context)
74+
75+
expect(result).toBe(false) // Not all groups have custom=true
76+
})
77+
78+
it('should operate on maps', () => {
79+
const expr = 'scores.all(name, scores[name] > 70)'
80+
const result = evaluate(expr, context)
81+
82+
expect(result).toBe(true) // All scores are > 70
83+
})
84+
})
85+
86+
describe('exists', () => {
87+
it('should return true if any item matches condition', () => {
88+
const expr = 'numbers.exists(n, n > 4)'
89+
const result = evaluate(expr, context)
90+
91+
expect(result).toBe(true)
92+
})
93+
94+
it('should return false if no items match', () => {
95+
const expr = 'numbers.exists(n, n > 10)'
96+
const result = evaluate(expr, context)
97+
98+
expect(result).toBe(false)
99+
})
100+
101+
it('should operate on lists', () => {
102+
const expr = 'groups.exists(group, group.custom == true)'
103+
const result = evaluate(expr, context)
104+
105+
expect(result).toBe(true) // At least one group has custom=true
106+
})
107+
108+
it('should operate on maps', () => {
109+
const expr = 'scores.exists(name, scores[name] > 80)'
110+
const result = evaluate(expr, context)
111+
112+
expect(result).toBe(true) // At least one score is > 80
113+
})
114+
})
115+
116+
describe('exists_one', () => {
117+
it('should return true if exactly one item matches', () => {
118+
const expr = 'numbers.exists_one(n, n == 5)'
119+
const result = evaluate(expr, context)
120+
121+
expect(result).toBe(true)
122+
})
123+
124+
it('should return false if multiple items match', () => {
125+
const expr = 'numbers.exists_one(n, n > 3)'
126+
const result = evaluate(expr, context)
127+
128+
expect(result).toBe(false)
129+
})
130+
131+
it('should return false if no items match', () => {
132+
const expr = 'numbers.exists_one(n, n > 10)'
133+
const result = evaluate(expr, context)
134+
135+
expect(result).toBe(false)
136+
})
137+
138+
it('should operate on lists', () => {
139+
const expr = 'groups.exists_one(group, group.custom == false)'
140+
const result = evaluate(expr, context)
141+
142+
expect(result).toBe(true) // Exactly one group has custom=false
143+
})
144+
145+
it('should operate on maps', () => {
146+
const expr = 'scores.exists_one(name, scores[name] > 90)'
147+
const result = evaluate(expr, context)
148+
149+
expect(result).toBe(true) // Exactly one score is > 90
150+
})
151+
})
152+
153+
describe('map', () => {
154+
it('should transform list items (simple map)', () => {
155+
const expr = 'numbers.map(n, n * 2)'
156+
const result = evaluate(expr, context)
157+
158+
expect(result).toStrictEqual([2, 4, 6, 8, 10])
159+
})
160+
161+
it('should filter and transform (map with predicate)', () => {
162+
const expr = 'numbers.map(n, n > 3, n * 10)'
163+
const result = evaluate(expr, context)
164+
165+
expect(result).toStrictEqual([40, 50])
166+
})
167+
168+
it('should extract property names', () => {
169+
const expr = 'people.map(person, person.name)'
170+
const result = evaluate(expr, context)
171+
172+
expect(result).toStrictEqual(['Alice', 'Bob', 'Charlie'])
173+
})
174+
175+
it('should operate on lists', () => {
176+
const expr = 'groups.map(group, group.name)'
177+
const result = evaluate(expr, context)
178+
179+
expect(result).toStrictEqual(['group 1', 'group 2', 'group 3'])
180+
})
181+
182+
it('should operate on maps', () => {
183+
const expr = 'scores.map(name, scores[name] * 2)'
184+
const result = evaluate(expr, context)
185+
186+
expect(result).toStrictEqual([170, 184, 156])
187+
})
188+
})
189+
190+
describe('error handling', () => {
191+
it('should throw error if collection is not a list or map', () => {
192+
const contextWithString = { ...context, str: 'hello' }
193+
194+
expect(() => evaluate('str.map(n, n * 2)', contextWithString)).toThrow(
195+
CelEvaluationError,
196+
)
197+
})
198+
199+
describe('variable name validation', () => {
200+
it('should reject complex expressions as variable names', () => {
201+
expect(() => evaluate('numbers.filter(x + y, true)', context)).toThrow(
202+
'Variable name must be a simple identifier',
203+
)
204+
})
205+
206+
it('should reject ternary expressions as variable names', () => {
207+
expect(() =>
208+
evaluate('numbers.filter(x ? y : z, true)', context),
209+
).toThrow('Variable name must be a simple identifier')
210+
})
211+
212+
it('should reject comparison expressions as variable names', () => {
213+
expect(() => evaluate('numbers.filter(x > 5, true)', context)).toThrow(
214+
'Variable name must be a simple identifier',
215+
)
216+
})
217+
218+
it('should reject dot notation as variable names', () => {
219+
expect(() =>
220+
evaluate('numbers.filter(obj.prop, true)', context),
221+
).toThrow('Variable name must be a simple identifier')
222+
})
223+
224+
it('should reject index expressions as variable names', () => {
225+
expect(() => evaluate('numbers.filter(arr[0], true)', context)).toThrow(
226+
'Variable name must be a simple identifier',
227+
)
228+
})
229+
230+
it('should reject function calls as variable names', () => {
231+
expect(() => evaluate('numbers.filter(func(), true)', context)).toThrow(
232+
'Variable name must be a simple identifier',
233+
)
234+
})
235+
236+
it('should reject literals as variable names', () => {
237+
expect(() => evaluate('numbers.filter(123, true)', context)).toThrow(
238+
'Variable name must be a simple identifier',
239+
)
240+
expect(() =>
241+
evaluate('numbers.filter("string", true)', context),
242+
).toThrow('Variable name must be a simple identifier')
243+
expect(() => evaluate('numbers.filter(true, true)', context)).toThrow(
244+
'Variable name must be a simple identifier',
245+
)
246+
})
247+
248+
it('should accept simple identifiers as variable names', () => {
249+
// This should work fine
250+
expect(() =>
251+
evaluate('numbers.filter(item, item > 3)', context),
252+
).not.toThrow()
253+
expect(() =>
254+
evaluate('groups.map(group, group.name)', context),
255+
).not.toThrow()
256+
})
257+
})
258+
})
259+
260+
describe('nested macros', () => {
261+
it('should handle nested macros', () => {
262+
const contextWithMap = { ...context, data: [{ a: 10, b: 5, c: 20 }] }
263+
const expr = 'data.map(m, m.filter(key, m[key] > 10))'
264+
const result = evaluate(expr, contextWithMap)
265+
266+
expect(result).toStrictEqual([['c']])
267+
})
268+
269+
it('should handle deep nesting with multiple collections', () => {
270+
const deepContext = {
271+
sets: [
272+
{ type: 'odd', numbers: [1, 3, 5] },
273+
{ type: 'even', numbers: [2, 4, 6] },
274+
],
275+
}
276+
const expr =
277+
'sets.map(set, set.numbers.filter(id, id > 3).map(id, id * 10))'
278+
const result = evaluate(expr, deepContext)
279+
280+
expect(result).toStrictEqual([[50], [40, 60]])
281+
})
282+
283+
it('should handle nested objects', () => {
284+
const expr = 'nested.nested.numbers.map(n, n * 2)'
285+
const result = evaluate(expr, context)
286+
287+
expect(result).toStrictEqual([2, 4, 6, 8, 10])
288+
})
289+
})
290+
})

0 commit comments

Comments
 (0)