diff --git a/.changeset/semver-targeting.md b/.changeset/semver-targeting.md new file mode 100644 index 000000000..ea34c4850 --- /dev/null +++ b/.changeset/semver-targeting.md @@ -0,0 +1,13 @@ +--- +"posthog": patch +"posthog-server": patch +--- + +Add semver comparison operators to local feature flag evaluation + +This adds 9 semver operators for targeting users based on app version: +- `semver_eq`, `semver_neq` — exact match / not equal +- `semver_gt`, `semver_gte`, `semver_lt`, `semver_lte` — comparison operators +- `semver_tilde` — patch-level range (~1.2.3 means >=1.2.3 <1.3.0) +- `semver_caret` — compatible-with range (^1.2.3 means >=1.2.3 <2.0.0) +- `semver_wildcard` — wildcard range (1.2.* means >=1.2.0 <1.3.0) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt index b6e0686d0..12610282a 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -31,6 +31,8 @@ internal class FlagEvaluator( private val NONE_VALUES_ALLOWED_OPERATORS = setOf(PropertyOperator.IS_NOT) private val REGEX_COMBINING_MARKS = "\\p{M}+".toRegex() private val REGEX_RELATIVE_DATE = "^-?([0-9]+)([hdwmy])$".toRegex() + private val REGEX_SEMVER = + Regex("""^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.\d+)*(?:[-+].*)?$""") private val DATE_FORMATTER_WITH_SPACE_TZ = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XXX") @@ -189,6 +191,22 @@ internal class FlagEvaluator( propertyOperator, ) + PropertyOperator.SEMVER_EQ, + PropertyOperator.SEMVER_NEQ, + PropertyOperator.SEMVER_GT, + PropertyOperator.SEMVER_GTE, + PropertyOperator.SEMVER_LT, + PropertyOperator.SEMVER_LTE, + PropertyOperator.SEMVER_TILDE, + PropertyOperator.SEMVER_CARET, + PropertyOperator.SEMVER_WILDCARD, + -> + compareSemver( + overrideValue, + propertyValue, + propertyOperator, + ) + else -> throw InconclusiveMatchException("Unknown operator: $propertyOperator") } } @@ -435,6 +453,232 @@ internal class FlagEvaluator( throw DateTimeParseException("Unable to parse date: $propertyValue", propertyValue, 0) } + /** + * A parsed semver version as (major, minor, patch) tuple + */ + private class SemverVersion( + val major: Int, + val minor: Int, + val patch: Int, + ) : Comparable { + override fun compareTo(other: SemverVersion): Int { + val majorCmp = major.compareTo(other.major) + if (majorCmp != 0) return majorCmp + val minorCmp = minor.compareTo(other.minor) + if (minorCmp != 0) return minorCmp + return patch.compareTo(other.patch) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SemverVersion) return false + return major == other.major && minor == other.minor && patch == other.patch + } + + override fun hashCode(): Int { + var result = major + result = 31 * result + minor + result = 31 * result + patch + return result + } + } + + /** + * Parse a version string into a SemverVersion. + * + * Parsing rules: + * 1. Strip leading/trailing whitespace + * 2. Strip v/V prefix (e.g., "v1.2.3" → "1.2.3") + * 3. Strip pre-release and build metadata suffixes (handled by regex) + * 4. Parse first 3 numeric components + * 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0)) + * 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3)) + * 7. Throw an error for truly invalid input (empty string, non-numeric parts, leading dot) + */ + @Throws(InconclusiveMatchException::class) + private fun parseSemver(version: String): SemverVersion { + var cleaned = version.trim() + if (cleaned.startsWith("v", ignoreCase = true)) { + cleaned = cleaned.substring(1) + } + + val match = + REGEX_SEMVER.matchEntire(cleaned) + ?: throw InconclusiveMatchException("Invalid semver version: '$version'") + + val major = match.groupValues[1].toInt() + val minor = match.groupValues[2].takeIf { it.isNotEmpty() }?.toInt() ?: 0 + val patch = match.groupValues[3].takeIf { it.isNotEmpty() }?.toInt() ?: 0 + + return SemverVersion(major, minor, patch) + } + + /** + * Compare two semver versions using the specified operator + */ + @Throws(InconclusiveMatchException::class) + private fun compareSemver( + overrideValue: Any?, + propertyValue: Any?, + propertyOperator: PropertyOperator, + ): Boolean { + val overrideVersion = + try { + parseSemver(overrideValue.toString()) + } catch (e: InconclusiveMatchException) { + throw InconclusiveMatchException("The person property value is not a valid semver: ${e.message}") + } + + val propertyString = propertyValue.toString() + + return when (propertyOperator) { + PropertyOperator.SEMVER_EQ, + PropertyOperator.SEMVER_NEQ, + PropertyOperator.SEMVER_GT, + PropertyOperator.SEMVER_GTE, + PropertyOperator.SEMVER_LT, + PropertyOperator.SEMVER_LTE, + -> { + val conditionVersion = + try { + parseSemver(propertyString) + } catch (e: InconclusiveMatchException) { + throw InconclusiveMatchException("The flag condition value is not a valid semver: ${e.message}") + } + compareSemverVersions(overrideVersion, conditionVersion, propertyOperator) + } + + PropertyOperator.SEMVER_TILDE -> { + val (lower, upper) = computeTildeBounds(propertyString) + overrideVersion >= lower && overrideVersion < upper + } + + PropertyOperator.SEMVER_CARET -> { + val (lower, upper) = computeCaretBounds(propertyString) + overrideVersion >= lower && overrideVersion < upper + } + + PropertyOperator.SEMVER_WILDCARD -> { + val (lower, upper) = computeWildcardBounds(propertyString) + overrideVersion >= lower && overrideVersion < upper + } + + else -> throw InconclusiveMatchException("Unknown semver operator: $propertyOperator") + } + } + + /** + * Compare two parsed semver versions + */ + private fun compareSemverVersions( + override: SemverVersion, + condition: SemverVersion, + operator: PropertyOperator, + ): Boolean { + return when (operator) { + PropertyOperator.SEMVER_EQ -> override == condition + PropertyOperator.SEMVER_NEQ -> override != condition + PropertyOperator.SEMVER_GT -> override > condition + PropertyOperator.SEMVER_GTE -> override >= condition + PropertyOperator.SEMVER_LT -> override < condition + PropertyOperator.SEMVER_LTE -> override <= condition + else -> false + } + } + + /** + * Compute lower and upper bounds for tilde range (~X.Y.Z) + * ~X.Y.Z → lower=(X,Y,Z), upper=(X,Y+1,0) + */ + @Throws(InconclusiveMatchException::class) + private fun computeTildeBounds(propertyValue: String): Pair { + val version = + try { + parseSemver(propertyValue) + } catch (e: InconclusiveMatchException) { + throw InconclusiveMatchException("The flag condition value is not a valid semver: ${e.message}") + } + val lower = version + val upper = SemverVersion(version.major, version.minor + 1, 0) + return Pair(lower, upper) + } + + /** + * Compute lower and upper bounds for caret range (^X.Y.Z) + * ^X.Y.Z where: + * - X > 0 → lower=(X,Y,Z), upper=(X+1,0,0) + * - X == 0, Y > 0 → lower=(0,Y,Z), upper=(0,Y+1,0) + * - X == 0, Y == 0 → lower=(0,0,Z), upper=(0,0,Z+1) + */ + @Throws(InconclusiveMatchException::class) + private fun computeCaretBounds(propertyValue: String): Pair { + val version = + try { + parseSemver(propertyValue) + } catch (e: InconclusiveMatchException) { + throw InconclusiveMatchException("The flag condition value is not a valid semver: ${e.message}") + } + val lower = version + val upper = + when { + version.major > 0 -> SemverVersion(version.major + 1, 0, 0) + version.minor > 0 -> SemverVersion(0, version.minor + 1, 0) + else -> SemverVersion(0, 0, version.patch + 1) + } + return Pair(lower, upper) + } + + /** + * Compute lower and upper bounds for wildcard range + * - "X.*" or "X" with wildcard → lower=(X,0,0), upper=(X+1,0,0) + * - "X.Y.*" → lower=(X,Y,0), upper=(X,Y+1,0) + */ + @Throws(InconclusiveMatchException::class) + private fun computeWildcardBounds(propertyValue: String): Pair { + var cleaned = propertyValue.trim() + + // Strip v/V prefix + if (cleaned.startsWith("v", ignoreCase = true)) { + cleaned = cleaned.substring(1) + } + + // Remove trailing .* wildcards + cleaned = cleaned.trimEnd('*', '.') + + if (cleaned.isEmpty()) { + throw InconclusiveMatchException("Invalid wildcard version: '$propertyValue'") + } + + val parts = cleaned.split(".") + val components = mutableListOf() + + for (part in parts) { + if (part.isEmpty()) continue + val num = + part.toIntOrNull() + ?: throw InconclusiveMatchException("Invalid wildcard version: '$propertyValue'") + components.add(num) + } + + if (components.isEmpty()) { + throw InconclusiveMatchException("Invalid wildcard version: '$propertyValue'") + } + + return when (components.size) { + 1 -> { + // "X.*" or just "X" → lower=(X,0,0), upper=(X+1,0,0) + val major = components[0] + Pair(SemverVersion(major, 0, 0), SemverVersion(major + 1, 0, 0)) + } + else -> { + // "X.Y.*" → lower=(X,Y,0), upper=(X,Y+1,0) + val major = components[0] + val minor = components[1] + Pair(SemverVersion(major, minor, 0), SemverVersion(major, minor + 1, 0)) + } + } + } + /** * Match a cohort property against property values */ diff --git a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt index 0c5de3a01..b364eed3e 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -1948,6 +1948,583 @@ internal class FlagEvaluatorTest { assertEquals(true, resultAbsent) } + // Semver Operator Tests + + @Test + internal fun testSemverEq() { + // Exact match cases + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + + // v-prefix handling + assertTrue(evaluator.matchProperty(property, mapOf("version" to "v1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "V1.2.3"))) + + // Pre-release suffix stripping + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3-alpha"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3+build123"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3-beta.1+build"))) + + // Whitespace handling + assertTrue(evaluator.matchProperty(property, mapOf("version" to " 1.2.3 "))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to " v1.2.3 "))) + } + + @Test + internal fun testSemverNeq() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_NEQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + } + + @Test + internal fun testSemverGt() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_GT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.9"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverGte() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_GTE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.9"))) + } + + @Test + internal fun testSemverLt() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_LT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.1.9"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverLte() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_LTE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverTilde() { + // ~1.2.3 means >=1.2.3 and <1.3.0 + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_TILDE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Lower boundary (inclusive) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.1"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverTildeWithZeroMinor() { + // ~1.0.0 means >=1.0.0 and <1.1.0 + val property = + FlagProperty( + key = "version", + propertyValue = "1.0.0", + propertyOperator = PropertyOperator.SEMVER_TILDE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.0.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.0.5"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.0"))) + } + + @Test + internal fun testSemverCaretMajorGreaterThanZero() { + // ^1.2.3 means >=1.2.3 <2.0.0 (when major > 0) + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_CARET, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Lower boundary (inclusive) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.99.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.1"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.0"))) + } + + @Test + internal fun testSemverCaretMajorZeroMinorGreaterThanZero() { + // ^0.2.3 means >=0.2.3 <0.3.0 (when major=0, minor>0) + val property = + FlagProperty( + key = "version", + propertyValue = "0.2.3", + propertyOperator = PropertyOperator.SEMVER_CARET, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // In range + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.2.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.3.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.0.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.1.0"))) + } + + @Test + internal fun testSemverCaretMajorAndMinorZero() { + // ^0.0.3 means >=0.0.3 <0.0.4 (when major=0, minor=0) + val property = + FlagProperty( + key = "version", + propertyValue = "0.0.3", + propertyOperator = PropertyOperator.SEMVER_CARET, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Only exact match (within the patch increment) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.0.3"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.0.4"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.1.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.0.2"))) + } + + @Test + internal fun testSemverWildcardMajorOnly() { + // 1.* means >=1.0.0 <2.0.0 + val property = + FlagProperty( + key = "version", + propertyValue = "1.*", + propertyOperator = PropertyOperator.SEMVER_WILDCARD, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // In range + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.0.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.5.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.99.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverWildcardMajorAndMinor() { + // 1.2.* means >=1.2.0 <1.3.0 + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.*", + propertyOperator = PropertyOperator.SEMVER_WILDCARD, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // In range + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.5"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.9"))) + } + + @Test + internal fun testSemverPartialVersions() { + // "1.2" should be parsed as "1.2.0" + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.0", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.0"))) + + // "1" should be parsed as "1.0.0" + val propertySingle = + FlagProperty( + key = "version", + propertyValue = "1.0.0", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(propertySingle, mapOf("version" to "1"))) + assertTrue(evaluator.matchProperty(propertySingle, mapOf("version" to "1.0"))) + assertTrue(evaluator.matchProperty(propertySingle, mapOf("version" to "1.0.0"))) + } + + @Test + internal fun testSemverFourPartVersions() { + // "1.2.3.4" should be parsed as "1.2.3" (extra parts ignored) + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3.99"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3.0.0.0"))) + } + + @Test + internal fun testSemverLeadingZeros() { + // "01.02.03" should parse as (1, 2, 3) + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "01.02.03"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "001.002.003"))) + } + + @Test + internal fun testSemverInvalidEmptyString() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("version" to "")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + } + + @Test + internal fun testSemverInvalidLeadingDot() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("version" to ".1.2.3")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + } + + @Test + internal fun testSemverInvalidNonNumeric() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("version" to "abc")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + + try { + evaluator.matchProperty(property, mapOf("version" to "1.x.3")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + } + + @Test + internal fun testSemverInvalidConditionValue() { + val property = + FlagProperty( + key = "version", + propertyValue = "invalid", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("version" to "1.2.3")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + } + + @Test + internal fun testSemverMissingPropertyKey() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("other_key" to "1.2.3")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("without a given property value") ?: false) + } + } + + @Test + internal fun testSemverNullPropertyValue() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Null values return false before reaching operator logic + assertFalse(evaluator.matchProperty(property, mapOf("version" to null))) + } + + @Test + internal fun testSemverWithVPrefixInCondition() { + // Condition value with v prefix should work + val property = + FlagProperty( + key = "version", + propertyValue = "v1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "v1.2.3"))) + } + + @Test + internal fun testSemverWildcardWithVPrefix() { + val property = + FlagProperty( + key = "version", + propertyValue = "v1.*", + propertyOperator = PropertyOperator.SEMVER_WILDCARD, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.0.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.5.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + } + + @Test + internal fun testSemverInFeatureFlag() { + // Test semver operator in a full flag evaluation + val json = + """ + { + "id": 1, + "name": "Semver Flag", + "key": "semver-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "app_version", + "value": "2.0.0", + "operator": "semver_gte", + "type": "person", + "negation": false + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) + + // User with version >= 2.0.0 should match + val propsMatch = mapOf("app_version" to "2.1.0") + val resultMatch = evaluator.matchFeatureFlagProperties(flag, "user-123", propsMatch) + assertEquals(true, resultMatch) + + // User with version < 2.0.0 should not match + val propsNoMatch = mapOf("app_version" to "1.9.9") + val resultNoMatch = evaluator.matchFeatureFlagProperties(flag, "user-123", propsNoMatch) + assertEquals(false, resultNoMatch) + } + @Test internal fun testBooleanFlagDependencyMatchesFalse() { // Regression test for bug: Boolean flag should match false expectation diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 22e23b3bb..ababa6f23 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -904,6 +904,15 @@ public final class com/posthog/internal/PropertyOperator : java/lang/Enum { public static final field NOT_ICONTAINS Lcom/posthog/internal/PropertyOperator; public static final field NOT_REGEX Lcom/posthog/internal/PropertyOperator; public static final field REGEX Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_CARET Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_EQ Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_GT Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_GTE Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_LT Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_LTE Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_NEQ Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_TILDE Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_WILDCARD Lcom/posthog/internal/PropertyOperator; public static final field UNKNOWN Lcom/posthog/internal/PropertyOperator; public static fun valueOf (Ljava/lang/String;)Lcom/posthog/internal/PropertyOperator; public static fun values ()[Lcom/posthog/internal/PropertyOperator; diff --git a/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationModels.kt b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationModels.kt index d55b3f7a2..f86991e12 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationModels.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationModels.kt @@ -133,6 +133,15 @@ public enum class PropertyOperator { IS_DATE_BEFORE, IS_DATE_AFTER, FLAG_EVALUATES_TO, + SEMVER_EQ, + SEMVER_NEQ, + SEMVER_GT, + SEMVER_GTE, + SEMVER_LT, + SEMVER_LTE, + SEMVER_TILDE, + SEMVER_CARET, + SEMVER_WILDCARD, ; public companion object { @@ -154,6 +163,15 @@ public enum class PropertyOperator { "is_date_before" -> IS_DATE_BEFORE "is_date_after" -> IS_DATE_AFTER "flag_evaluates_to" -> FLAG_EVALUATES_TO + "semver_eq" -> SEMVER_EQ + "semver_neq" -> SEMVER_NEQ + "semver_gt" -> SEMVER_GT + "semver_gte" -> SEMVER_GTE + "semver_lt" -> SEMVER_LT + "semver_lte" -> SEMVER_LTE + "semver_tilde" -> SEMVER_TILDE + "semver_caret" -> SEMVER_CARET + "semver_wildcard" -> SEMVER_WILDCARD else -> UNKNOWN } }