-
Notifications
You must be signed in to change notification settings - Fork 35
feat(flags): add semver targeting to local evaluation #446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e027a5c
65cc2a9
818c106
41ae99f
a065238
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<SemverVersion> { | ||
| 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 | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think you could do this in a cleaner way with regex ps: have not tested this, gpt generated, but i remember doing this before so it should work or just small changes needed |
||
| * 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. all methods that throw should be annotated with @throws(type...) so the caller knows what to do |
||
| 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<SemverVersion, SemverVersion> { | ||
| 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<SemverVersion, SemverVersion> { | ||
| 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<SemverVersion, SemverVersion> { | ||
| 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<Int>() | ||
|
|
||
| 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 | ||
| */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you also need to release core because theres changes for PostHogLocalEvaluationModels.kt