diff --git a/package.json b/package.json index 49df423..7530e31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@segment/tsub", - "version": "2.0.0", + "version": "2.0.1", "description": "Tsub for JS", "main": "dist/index.js", "browser": "dist/index.js", @@ -8,6 +8,7 @@ "scripts": { "test": "jest", "build": "rm -rf dist && tsc", + "prepare": "npm run build", "lint": "tslint src/**/*.ts", "lint_test": "tslint src/**/*.test.ts" }, diff --git a/src/__tests__/matchers.test.ts b/src/__tests__/matchers.test.ts index be6f928..e1838ef 100644 --- a/src/__tests__/matchers.test.ts +++ b/src/__tests__/matchers.test.ts @@ -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', () => { diff --git a/src/matchers.ts b/src/matchers.ts index 618ce61..c09c8ef 100644 --- a/src/matchers.ts +++ b/src/matchers.ts @@ -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}`) @@ -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 { @@ -247,6 +361,10 @@ function isIR(value): boolean { return true } + if (value[0] === 'semver' && value.length === 4) { + return true + } + return false }