Skip to content

Commit 8df2b5c

Browse files
committed
Add semver function for semantic version comparison
- Implement semver(version, operator, version) function - Support operators: =, !=, >, >=, <, <= - Handle partial versions (e.g., '2', '1.5', '1.0.0') - Add comprehensive test coverage - Handle null values by returning false
1 parent 64c100c commit 8df2b5c

File tree

2 files changed

+265
-0
lines changed

2 files changed

+265
-0
lines changed

src/__tests__/matchers.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,154 @@ describe('functions', () => {
154154
matches({}, matcher)
155155
}).toThrow()
156156
})
157+
158+
describe('semver()', () => {
159+
test('basic >= comparison works', () => {
160+
// FQL: semver("1.0.1", ">=", "1.0.0")
161+
matcher.ir = `["semver", {"value": "1.0.1"}, {"value": ">="}, {"value": "1.0.0"}]`
162+
expect(matches({}, matcher)).toBe(true)
163+
164+
// FQL: semver("1.0.0", ">=", "1.0.0")
165+
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": ">="}, {"value": "1.0.0"}]`
166+
expect(matches({}, matcher)).toBe(true)
167+
168+
// FQL: semver("0.9.9", ">=", "1.0.0")
169+
matcher.ir = `["semver", {"value": "0.9.9"}, {"value": ">="}, {"value": "1.0.0"}]`
170+
expect(matches({}, matcher)).toBe(false)
171+
})
172+
173+
test('> comparison works', () => {
174+
// FQL: semver("2.0.0", ">", "1.9.9")
175+
matcher.ir = `["semver", {"value": "2.0.0"}, {"value": ">"}, {"value": "1.9.9"}]`
176+
expect(matches({}, matcher)).toBe(true)
177+
178+
// FQL: semver("1.0.0", ">", "1.0.0")
179+
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": ">"}, {"value": "1.0.0"}]`
180+
expect(matches({}, matcher)).toBe(false)
181+
})
182+
183+
test('< comparison works', () => {
184+
// FQL: semver("1.5.0", "<", "2.0.0")
185+
matcher.ir = `["semver", {"value": "1.5.0"}, {"value": "<"}, {"value": "2.0.0"}]`
186+
expect(matches({}, matcher)).toBe(true)
187+
188+
// FQL: semver("2.0.0", "<", "2.0.0")
189+
matcher.ir = `["semver", {"value": "2.0.0"}, {"value": "<"}, {"value": "2.0.0"}]`
190+
expect(matches({}, matcher)).toBe(false)
191+
})
192+
193+
test('<= comparison works', () => {
194+
// FQL: semver("1.0.0", "<=", "1.0.0")
195+
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": "<="}, {"value": "1.0.0"}]`
196+
expect(matches({}, matcher)).toBe(true)
197+
198+
// FQL: semver("1.0.1", "<=", "1.0.0")
199+
matcher.ir = `["semver", {"value": "1.0.1"}, {"value": "<="}, {"value": "1.0.0"}]`
200+
expect(matches({}, matcher)).toBe(false)
201+
})
202+
203+
test('= comparison works', () => {
204+
// FQL: semver("1.2.3", "=", "1.2.3")
205+
matcher.ir = `["semver", {"value": "1.2.3"}, {"value": "="}, {"value": "1.2.3"}]`
206+
expect(matches({}, matcher)).toBe(true)
207+
208+
// FQL: semver("1.2.3", "=", "1.2.4")
209+
matcher.ir = `["semver", {"value": "1.2.3"}, {"value": "="}, {"value": "1.2.4"}]`
210+
expect(matches({}, matcher)).toBe(false)
211+
})
212+
213+
test('!= comparison works', () => {
214+
// FQL: semver("1.2.3", "!=", "1.2.4")
215+
matcher.ir = `["semver", {"value": "1.2.3"}, {"value": "!="}, {"value": "1.2.4"}]`
216+
expect(matches({}, matcher)).toBe(true)
217+
218+
// FQL: semver("1.2.3", "!=", "1.2.3")
219+
matcher.ir = `["semver", {"value": "1.2.3"}, {"value": "!="}, {"value": "1.2.3"}]`
220+
expect(matches({}, matcher)).toBe(false)
221+
})
222+
223+
test('partial versions work', () => {
224+
// FQL: semver("2", ">", "1.9.9")
225+
matcher.ir = `["semver", {"value": "2"}, {"value": ">"}, {"value": "1.9.9"}]`
226+
expect(matches({}, matcher)).toBe(true)
227+
228+
// FQL: semver("1.5", ">", "1.4.9")
229+
matcher.ir = `["semver", {"value": "1.5"}, {"value": ">"}, {"value": "1.4.9"}]`
230+
expect(matches({}, matcher)).toBe(true)
231+
232+
// FQL: semver("1.5", "=", "1.5.0")
233+
matcher.ir = `["semver", {"value": "1.5"}, {"value": "="}, {"value": "1.5.0"}]`
234+
expect(matches({}, matcher)).toBe(true)
235+
236+
// FQL: semver("2.0", "=", "2")
237+
matcher.ir = `["semver", {"value": "2.0"}, {"value": "="}, {"value": "2"}]`
238+
expect(matches({}, matcher)).toBe(true)
239+
})
240+
241+
test('version precedence works correctly', () => {
242+
// Major version takes precedence
243+
// FQL: semver("2.0.0", ">", "1.99.99")
244+
matcher.ir = `["semver", {"value": "2.0.0"}, {"value": ">"}, {"value": "1.99.99"}]`
245+
expect(matches({}, matcher)).toBe(true)
246+
247+
// Minor version takes precedence
248+
// FQL: semver("1.10.0", ">", "1.9.0")
249+
matcher.ir = `["semver", {"value": "1.10.0"}, {"value": ">"}, {"value": "1.9.0"}]`
250+
expect(matches({}, matcher)).toBe(true)
251+
252+
// Patch version comparison
253+
// FQL: semver("1.0.10", ">", "1.0.9")
254+
matcher.ir = `["semver", {"value": "1.0.10"}, {"value": ">"}, {"value": "1.0.9"}]`
255+
expect(matches({}, matcher)).toBe(true)
256+
})
257+
258+
test('null handling works', () => {
259+
// FQL: semver(null, ">=", "1.0.0")
260+
matcher.ir = `["semver", {"value": null}, {"value": ">="}, {"value": "1.0.0"}]`
261+
expect(matches({}, matcher)).toBe(false)
262+
263+
// FQL: semver("1.0.0", ">=", null)
264+
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": ">="}, {"value": null}]`
265+
expect(matches({}, matcher)).toBe(false)
266+
})
267+
268+
test('invalid inputs return false', () => {
269+
// Invalid version format
270+
// FQL: semver("invalid", ">=", "1.0.0")
271+
matcher.ir = `["semver", {"value": "invalid"}, {"value": ">="}, {"value": "1.0.0"}]`
272+
expect(matches({}, matcher)).toBe(false)
273+
274+
// Invalid version format
275+
// FQL: semver("1.0.0", ">=", "invalid")
276+
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": ">="}, {"value": "invalid"}]`
277+
expect(matches({}, matcher)).toBe(false)
278+
279+
// Invalid operator
280+
// FQL: semver("1.0.0", "~=", "1.0.0")
281+
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": "~="}, {"value": "1.0.0"}]`
282+
expect(matches({}, matcher)).toBe(false)
283+
284+
// Too many parts
285+
// FQL: semver("1.2.3.4", ">=", "1.0.0")
286+
matcher.ir = `["semver", {"value": "1.2.3.4"}, {"value": ">="}, {"value": "1.0.0"}]`
287+
expect(matches({}, matcher)).toBe(false)
288+
289+
// Non-numeric parts
290+
// FQL: semver("1.a.0", ">=", "1.0.0")
291+
matcher.ir = `["semver", {"value": "1.a.0"}, {"value": ">="}, {"value": "1.0.0"}]`
292+
expect(matches({}, matcher)).toBe(false)
293+
})
294+
295+
test('works with event properties', () => {
296+
// FQL: semver(app_version, ">=", "1.0.0")
297+
matcher.ir = `["semver", "app_version", {"value": ">="}, {"value": "1.0.0"}]`
298+
simpleEvent.app_version = '1.5.0'
299+
expect(matches(simpleEvent, matcher)).toBe(true)
300+
301+
simpleEvent.app_version = '0.9.0'
302+
expect(matches(simpleEvent, matcher)).toBe(false)
303+
})
304+
})
157305
})
158306

159307
describe('arrays', () => {

src/matchers.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ function fqlEvaluate(ir, event) {
108108
// 'length(val)' => Returns the length of an array or string, NaN if neither
109109
case 'length':
110110
return length(getValue(ir[1], event))
111+
// 'semver(version, operator, version)' => Compares semantic versions
112+
case 'semver':
113+
return semver(getValue(ir[1], event), getValue(ir[2], event), getValue(ir[3], event))
111114
// If nothing hit, we or the IR messed up somewhere.
112115
default:
113116
throw new Error(`FQL IR could not evaluate for token: ${item}`)
@@ -227,6 +230,116 @@ function length(item) {
227230
return item.length
228231
}
229232

233+
interface SemverVersion {
234+
major: number
235+
minor: number
236+
patch: number
237+
}
238+
239+
// semver performs semantic version comparison. It takes 3 arguments:
240+
// 1. version string (e.g. "1.0.1")
241+
// 2. operator (e.g. ">=", ">", "=", "!=", "<", "<=")
242+
// 3. version string to compare against (e.g. "1.0.0")
243+
// Returns true if the comparison is satisfied, false otherwise.
244+
function semver(v1Str, operator, v2Str): boolean {
245+
// Handle null values
246+
if (v1Str === null || v2Str === null) {
247+
return false
248+
}
249+
250+
// Type check
251+
if (typeof v1Str !== 'string' || typeof operator !== 'string' || typeof v2Str !== 'string') {
252+
return false
253+
}
254+
255+
let v1: SemverVersion
256+
let v2: SemverVersion
257+
258+
try {
259+
v1 = parseSemver(v1Str)
260+
v2 = parseSemver(v2Str)
261+
} catch (e) {
262+
return false
263+
}
264+
265+
const cmp = compareSemver(v1, v2)
266+
267+
switch (operator) {
268+
case '=':
269+
return cmp === 0
270+
case '!=':
271+
return cmp !== 0
272+
case '>':
273+
return cmp > 0
274+
case '>=':
275+
return cmp >= 0
276+
case '<':
277+
return cmp < 0
278+
case '<=':
279+
return cmp <= 0
280+
default:
281+
return false
282+
}
283+
}
284+
285+
// parseSemver parses a semantic version string like "1.2.3" or "1.0" or "2"
286+
function parseSemver(version: string): SemverVersion {
287+
const parts = version.split('.')
288+
if (parts.length === 0 || parts.length > 3) {
289+
throw new Error('invalid version format')
290+
}
291+
292+
const v: SemverVersion = {
293+
major: 0,
294+
minor: 0,
295+
patch: 0,
296+
}
297+
298+
// Parse major version
299+
if (parts.length >= 1) {
300+
v.major = parseInt(parts[0].trim(), 10)
301+
if (isNaN(v.major)) {
302+
throw new Error('invalid major version')
303+
}
304+
}
305+
306+
// Parse minor version (defaults to 0)
307+
if (parts.length >= 2) {
308+
v.minor = parseInt(parts[1].trim(), 10)
309+
if (isNaN(v.minor)) {
310+
throw new Error('invalid minor version')
311+
}
312+
}
313+
314+
// Parse patch version (defaults to 0)
315+
if (parts.length >= 3) {
316+
v.patch = parseInt(parts[2].trim(), 10)
317+
if (isNaN(v.patch)) {
318+
throw new Error('invalid patch version')
319+
}
320+
}
321+
322+
return v
323+
}
324+
325+
// compareSemver compares two semantic versions
326+
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
327+
function compareSemver(v1: SemverVersion, v2: SemverVersion): number {
328+
if (v1.major !== v2.major) {
329+
return v1.major < v2.major ? -1 : 1
330+
}
331+
332+
if (v1.minor !== v2.minor) {
333+
return v1.minor < v2.minor ? -1 : 1
334+
}
335+
336+
if (v1.patch !== v2.patch) {
337+
return v1.patch < v2.patch ? -1 : 1
338+
}
339+
340+
return 0
341+
}
342+
230343
// This is a heuristic technically speaking, but should be close enough. The odds of someone trying to test
231344
// a func with identical IR notation is pretty low.
232345
function isIR(value): boolean {
@@ -247,6 +360,10 @@ function isIR(value): boolean {
247360
return true
248361
}
249362

363+
if (value[0] === 'semver' && value.length === 4) {
364+
return true
365+
}
366+
250367
return false
251368
}
252369

0 commit comments

Comments
 (0)