Skip to content
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
{
"name": "@segment/tsub",
"version": "2.0.0",
"version": "2.0.1",
"description": "Tsub for JS",
"main": "dist/index.js",
"browser": "dist/index.js",
"bin": "dist/index.js",
"scripts": {
"test": "jest",
"build": "rm -rf dist && tsc",
"prepare": "npm run build",
"lint": "tslint src/**/*.ts",
"lint_test": "tslint src/**/*.test.ts"
},
Expand Down
148 changes: 148 additions & 0 deletions src/__tests__/matchers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,154 @@ describe('functions', () => {
matches({}, matcher)
}).toThrow()
})

describe('semver()', () => {
test('basic >= comparison works', () => {
// FQL: semver("1.0.1", ">=", "1.0.0")
matcher.ir = `["semver", {"value": "1.0.1"}, {"value": ">="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("1.0.0", ">=", "1.0.0")
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": ">="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("0.9.9", ">=", "1.0.0")
matcher.ir = `["semver", {"value": "0.9.9"}, {"value": ">="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(false)
})

test('> comparison works', () => {
// FQL: semver("2.0.0", ">", "1.9.9")
matcher.ir = `["semver", {"value": "2.0.0"}, {"value": ">"}, {"value": "1.9.9"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("1.0.0", ">", "1.0.0")
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": ">"}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(false)
})

test('< comparison works', () => {
// FQL: semver("1.5.0", "<", "2.0.0")
matcher.ir = `["semver", {"value": "1.5.0"}, {"value": "<"}, {"value": "2.0.0"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("2.0.0", "<", "2.0.0")
matcher.ir = `["semver", {"value": "2.0.0"}, {"value": "<"}, {"value": "2.0.0"}]`
expect(matches({}, matcher)).toBe(false)
})

test('<= comparison works', () => {
// FQL: semver("1.0.0", "<=", "1.0.0")
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": "<="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("1.0.1", "<=", "1.0.0")
matcher.ir = `["semver", {"value": "1.0.1"}, {"value": "<="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(false)
})

test('= comparison works', () => {
// FQL: semver("1.2.3", "=", "1.2.3")
matcher.ir = `["semver", {"value": "1.2.3"}, {"value": "="}, {"value": "1.2.3"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("1.2.3", "=", "1.2.4")
matcher.ir = `["semver", {"value": "1.2.3"}, {"value": "="}, {"value": "1.2.4"}]`
expect(matches({}, matcher)).toBe(false)
})

test('!= comparison works', () => {
// FQL: semver("1.2.3", "!=", "1.2.4")
matcher.ir = `["semver", {"value": "1.2.3"}, {"value": "!="}, {"value": "1.2.4"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("1.2.3", "!=", "1.2.3")
matcher.ir = `["semver", {"value": "1.2.3"}, {"value": "!="}, {"value": "1.2.3"}]`
expect(matches({}, matcher)).toBe(false)
})

test('partial versions work', () => {
// FQL: semver("2", ">", "1.9.9")
matcher.ir = `["semver", {"value": "2"}, {"value": ">"}, {"value": "1.9.9"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("1.5", ">", "1.4.9")
matcher.ir = `["semver", {"value": "1.5"}, {"value": ">"}, {"value": "1.4.9"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("1.5", "=", "1.5.0")
matcher.ir = `["semver", {"value": "1.5"}, {"value": "="}, {"value": "1.5.0"}]`
expect(matches({}, matcher)).toBe(true)

// FQL: semver("2.0", "=", "2")
matcher.ir = `["semver", {"value": "2.0"}, {"value": "="}, {"value": "2"}]`
expect(matches({}, matcher)).toBe(true)
})

test('version precedence works correctly', () => {
// Major version takes precedence
// FQL: semver("2.0.0", ">", "1.99.99")
matcher.ir = `["semver", {"value": "2.0.0"}, {"value": ">"}, {"value": "1.99.99"}]`
expect(matches({}, matcher)).toBe(true)

// Minor version takes precedence
// FQL: semver("1.10.0", ">", "1.9.0")
matcher.ir = `["semver", {"value": "1.10.0"}, {"value": ">"}, {"value": "1.9.0"}]`
expect(matches({}, matcher)).toBe(true)

// Patch version comparison
// FQL: semver("1.0.10", ">", "1.0.9")
matcher.ir = `["semver", {"value": "1.0.10"}, {"value": ">"}, {"value": "1.0.9"}]`
expect(matches({}, matcher)).toBe(true)
})

test('null handling works', () => {
// FQL: semver(null, ">=", "1.0.0")
matcher.ir = `["semver", {"value": null}, {"value": ">="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(false)

// FQL: semver("1.0.0", ">=", null)
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": ">="}, {"value": null}]`
expect(matches({}, matcher)).toBe(false)
})

test('invalid inputs return false', () => {
// Invalid version format
// FQL: semver("invalid", ">=", "1.0.0")
matcher.ir = `["semver", {"value": "invalid"}, {"value": ">="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(false)

// Invalid version format
// FQL: semver("1.0.0", ">=", "invalid")
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": ">="}, {"value": "invalid"}]`
expect(matches({}, matcher)).toBe(false)

// Invalid operator
// FQL: semver("1.0.0", "~=", "1.0.0")
matcher.ir = `["semver", {"value": "1.0.0"}, {"value": "~="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(false)

// Too many parts
// FQL: semver("1.2.3.4", ">=", "1.0.0")
matcher.ir = `["semver", {"value": "1.2.3.4"}, {"value": ">="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(false)

// Non-numeric parts
// FQL: semver("1.a.0", ">=", "1.0.0")
matcher.ir = `["semver", {"value": "1.a.0"}, {"value": ">="}, {"value": "1.0.0"}]`
expect(matches({}, matcher)).toBe(false)
})

test('works with event properties', () => {
// FQL: semver(app_version, ">=", "1.0.0")
matcher.ir = `["semver", "app_version", {"value": ">="}, {"value": "1.0.0"}]`
simpleEvent.app_version = '1.5.0'
expect(matches(simpleEvent, matcher)).toBe(true)

simpleEvent.app_version = '0.9.0'
expect(matches(simpleEvent, matcher)).toBe(false)
})
})
})

describe('arrays', () => {
Expand Down
118 changes: 118 additions & 0 deletions src/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ function fqlEvaluate(ir, event) {
// 'length(val)' => Returns the length of an array or string, NaN if neither
case 'length':
return length(getValue(ir[1], event))
// 'semver(version, operator, version)' => Compares semantic versions
case 'semver':
return semver(getValue(ir[1], event), getValue(ir[2], event), getValue(ir[3], event))
// If nothing hit, we or the IR messed up somewhere.
default:
throw new Error(`FQL IR could not evaluate for token: ${item}`)
Expand Down Expand Up @@ -227,6 +230,117 @@ function length(item) {
return item.length
}

interface SemverVersion {
major: number
minor: number
patch: number
}

// semver performs semantic version comparison. It takes 3 arguments:
// 1. version string (e.g. "1.0.1")
// 2. operator (e.g. ">=", ">", "=", "!=", "<", "<=")
// 3. version string to compare against (e.g. "1.0.0")
// Returns true if the comparison is satisfied, false otherwise.
function semver(v1Str, operator, v2Str): boolean {
// Handle null values
if (v1Str === null || v2Str === null) {
return false
}

// Type check
if (typeof v1Str !== 'string' || typeof operator !== 'string' || typeof v2Str !== 'string') {
return false
}

let v1: SemverVersion
let v2: SemverVersion

try {
v1 = parseSemver(v1Str)
v2 = parseSemver(v2Str)
} catch (e) {
return false
}

const cmp = compareSemver(v1, v2)

switch (operator) {
case '=':
return cmp === 0
case '!=':
return cmp !== 0
case '>':
return cmp > 0
case '>=':
return cmp >= 0
case '<':
return cmp < 0
case '<=':
return cmp <= 0
default:
return false
}
}

// parseSemver parses a semantic version string like "1.2.3" or "1.0" or "2"
function parseSemver(version: string): SemverVersion {
const DECIMAL_RADIX = 10
const parts = version.split('.')
if (parts.length === 0 || parts.length > 3) {
throw new Error('invalid version format')
}

const v: SemverVersion = {
major: 0,
minor: 0,
patch: 0,
}

// Parse major version
if (parts.length >= 1) {
v.major = parseInt(parts[0].trim(), DECIMAL_RADIX)
if (isNaN(v.major)) {
throw new Error('invalid major version')
}
}

// Parse minor version (defaults to 0)
if (parts.length >= 2) {
v.minor = parseInt(parts[1].trim(), DECIMAL_RADIX)
if (isNaN(v.minor)) {
throw new Error('invalid minor version')
}
}

// Parse patch version (defaults to 0)
if (parts.length >= 3) {
v.patch = parseInt(parts[2].trim(), DECIMAL_RADIX)
if (isNaN(v.patch)) {
throw new Error('invalid patch version')
}
}

return v
}

// compareSemver compares two semantic versions
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
function compareSemver(v1: SemverVersion, v2: SemverVersion): number {
if (v1.major !== v2.major) {
return v1.major < v2.major ? -1 : 1
}

if (v1.minor !== v2.minor) {
return v1.minor < v2.minor ? -1 : 1
}

if (v1.patch !== v2.patch) {
return v1.patch < v2.patch ? -1 : 1
}

return 0
}

// This is a heuristic technically speaking, but should be close enough. The odds of someone trying to test
// a func with identical IR notation is pretty low.
function isIR(value): boolean {
Expand All @@ -247,6 +361,10 @@ function isIR(value): boolean {
return true
}

if (value[0] === 'semver' && value.length === 4) {
return true
}

return false
}

Expand Down
Loading