diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 3e5d3c3..5d8c0b7 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -422,6 +422,103 @@ def self.relative_date_parse_for_feature_flag_matching(value) parsed_dt end + # Parse a semver string into a comparable [major, minor, patch] integer array. + # Handles v-prefix, whitespace, pre-release suffixes. Defaults missing components to 0. + def self.parse_semver(value) + raise InconclusiveMatchError, 'Invalid semver format' if value.nil? + + text = value.to_s.strip.sub(/^[vV]/, '') + + raise InconclusiveMatchError, 'Invalid semver format' if text.empty? + + # Strip pre-release and build metadata suffixes + text = text.split('-')[0].split('+')[0] + parts = text.split('.') + + raise InconclusiveMatchError, 'Invalid semver format' if parts.empty? || parts[0].to_s.empty? + + # Check for leading dot or non-numeric parts + parts.each do |part| + raise InconclusiveMatchError, 'Invalid semver format' if part.empty? || part !~ /^\d+$/ + end + + major = parts[0].to_i + minor = parts.length > 1 ? parts[1].to_i : 0 + patch = parts.length > 2 ? parts[2].to_i : 0 + + [major, minor, patch] + end + + # Returns bounds for tilde (~) range: + # ~X → >=X.0.0 <(X+1).0.0 + # ~X.Y → >=X.Y.0 =X.Y.Z 0 → >=X.Y.Z <(X+1).0.0 + # ^0.Y.Z where Y > 0 → >=0.Y.Z <0.(Y+1).0 + # ^0.0.Z → >=0.0.Z <0.0.(Z+1) + def self.semver_caret_bounds(value) + major, minor, patch = parse_semver(value) + lower = [major, minor, patch] + + upper = if major.positive? + [major + 1, 0, 0] + elsif minor.positive? + [0, minor + 1, 0] + else + [0, 0, patch + 1] + end + + [lower, upper] + end + + # Returns bounds for wildcard (*) range: + # X.* or X → >=X.0.0 <(X+1).0.0 + # X.Y.* → >=X.Y.0 parsed_date end + when 'semver_eq', 'semver_neq', 'semver_gt', 'semver_gte', 'semver_lt', 'semver_lte' + override_parsed = parse_semver(override_value) + flag_parsed = parse_semver(value) + + case operator + when 'semver_eq' + override_parsed == flag_parsed + when 'semver_neq' + override_parsed != flag_parsed + when 'semver_gt' + (override_parsed <=> flag_parsed) == 1 + when 'semver_gte' + (override_parsed <=> flag_parsed) >= 0 + when 'semver_lt' + (override_parsed <=> flag_parsed) == -1 + when 'semver_lte' + (override_parsed <=> flag_parsed) <= 0 + end + when 'semver_tilde' + override_parsed = parse_semver(override_value) + lower, upper = semver_tilde_bounds(value) + (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1 + when 'semver_caret' + override_parsed = parse_semver(override_value) + lower, upper = semver_caret_bounds(value) + (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1 + when 'semver_wildcard' + override_parsed = parse_semver(override_value) + lower, upper = semver_wildcard_bounds(value) + (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1 else raise InconclusiveMatchError, "Unknown operator: #{operator}" end diff --git a/spec/posthog/feature_flag_spec.rb b/spec/posthog/feature_flag_spec.rb index 60f83bd..1f72000 100644 --- a/spec/posthog/feature_flag_spec.rb +++ b/spec/posthog/feature_flag_spec.rb @@ -1566,6 +1566,256 @@ module PostHog expect(e.message).to eq('Unknown operator: is_unknown') end end + + # Semver operator tests + it 'with semver_eq operator' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.4' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.2' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '2.0.0' })).to be false + end + + it 'with semver_neq operator' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_neq' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.4' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.2' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '2.0.0' })).to be true + end + + it 'with semver_gt operator' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_gt' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.4' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.3.0' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '2.0.0' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.2' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '0.9.9' })).to be false + end + + it 'with semver_gte operator' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_gte' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.4' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.3.0' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '2.0.0' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.2' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '0.9.9' })).to be false + end + + it 'with semver_lt operator' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_lt' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.2' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.1.0' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '0.9.9' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.4' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '2.0.0' })).to be false + end + + it 'with semver_lte operator' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_lte' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.2' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.1.0' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '0.9.9' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.4' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '2.0.0' })).to be false + end + + it 'with semver_tilde operator' do + # ~1.2.3 means >=1.2.3 <1.3.0 + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_tilde' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.4' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.99' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.2' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.3.0' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.3.1' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '2.0.0' })).to be false + + # Test boundary values + property2 = { 'key' => 'version', 'value' => '1.0.0', 'operator' => 'semver_tilde' } + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.0.0' })).to be true + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.0.99' })).to be true + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.1.0' })).to be false + + # Major-only tilde: ~1 means >=1.0.0 <2.0.0 + property3 = { 'key' => 'version', 'value' => '1', 'operator' => 'semver_tilde' } + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '1.0.0' })).to be true + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '1.5.0' })).to be true + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '1.99.99' })).to be true + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '0.9.9' })).to be false + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '2.0.0' })).to be false + end + + it 'with semver_caret operator' do + # ^1.2.3 means >=1.2.3 <2.0.0 (major > 0) + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_caret' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.4' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.3.0' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.99.99' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.2' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '2.0.0' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '0.9.9' })).to be false + + # ^0.2.3 means >=0.2.3 <0.3.0 (major == 0, minor > 0) + property2 = { 'key' => 'version', 'value' => '0.2.3', 'operator' => 'semver_caret' } + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '0.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '0.2.4' })).to be true + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '0.2.99' })).to be true + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '0.2.2' })).to be false + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '0.3.0' })).to be false + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.0.0' })).to be false + + # ^0.0.3 means >=0.0.3 <0.0.4 (major == 0, minor == 0) + property3 = { 'key' => 'version', 'value' => '0.0.3', 'operator' => 'semver_caret' } + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '0.0.3' })).to be true + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '0.0.2' })).to be false + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '0.0.4' })).to be false + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '0.1.0' })).to be false + end + + it 'with semver_wildcard operator' do + # 1.2.* means >=1.2.0 <1.3.0 + property = { 'key' => 'version', 'value' => '1.2.*', 'operator' => 'semver_wildcard' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.0' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.99' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.1.9' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.3.0' })).to be false + expect(FeatureFlagsPoller.match_property(property, { 'version' => '2.0.0' })).to be false + + # 1.* means >=1.0.0 <2.0.0 + property2 = { 'key' => 'version', 'value' => '1.*', 'operator' => 'semver_wildcard' } + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.0.0' })).to be true + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.99.99' })).to be true + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '0.9.9' })).to be false + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '2.0.0' })).to be false + end + + it 'with semver operators and v-prefix' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => 'v1.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => 'V1.2.3' })).to be true + + property2 = { 'key' => 'version', 'value' => 'v1.2.3', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.2.3' })).to be true + expect(FeatureFlagsPoller.match_property(property2, { 'version' => 'v1.2.3' })).to be true + end + + it 'with semver operators and pre-release suffixes stripped' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3-alpha' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3-beta.1' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3+build' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3-alpha+build' })).to be true + + property2 = { 'key' => 'version', 'value' => '1.2.3-alpha', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.2.3' })).to be true + end + + it 'with semver operators and whitespace handling' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => ' 1.2.3 ' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => ' 1.2.3' })).to be true + + property2 = { 'key' => 'version', 'value' => ' 1.2.3 ', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.2.3' })).to be true + end + + it 'with semver operators and leading zeros' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '01.02.03' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '001.002.003' })).to be true + end + + it 'with semver operators and partial versions' do + # "1.2" equals "1.2.0" + property = { 'key' => 'version', 'value' => '1.2.0', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2' })).to be true + + property2 = { 'key' => 'version', 'value' => '1.2', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.2.0' })).to be true + + # "1" equals "1.0.0" + property3 = { 'key' => 'version', 'value' => '1.0.0', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property3, { 'version' => '1' })).to be true + + property4 = { 'key' => 'version', 'value' => '1', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property4, { 'version' => '1.0.0' })).to be true + end + + it 'with semver operators and 4-part versions' do + # "1.2.3.4" equals "1.2.3" (extra parts ignored) + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3.4' })).to be true + expect(FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3.999' })).to be true + + property2 = { 'key' => 'version', 'value' => '1.2.3.4', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.2.3' })).to be true + end + + it 'with semver operators and invalid values' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } + + # Non-numeric strings raise InconclusiveMatchError + expect do + FeatureFlagsPoller.match_property(property, { 'version' => 'not-a-version' }) + end.to raise_error(InconclusiveMatchError) + + expect do + FeatureFlagsPoller.match_property(property, { 'version' => '' }) + end.to raise_error(InconclusiveMatchError) + + expect do + FeatureFlagsPoller.match_property(property, { 'version' => '.1.2.3' }) + end.to raise_error(InconclusiveMatchError) + + expect do + FeatureFlagsPoller.match_property(property, { 'version' => 'abc.def.ghi' }) + end.to raise_error(InconclusiveMatchError) + + # Invalid flag condition value + invalid_property = { 'key' => 'version', 'value' => 'invalid', 'operator' => 'semver_eq' } + expect do + FeatureFlagsPoller.match_property(invalid_property, { 'version' => '1.2.3' }) + end.to raise_error(InconclusiveMatchError) + end + + it 'with semver operators and missing property key' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } + expect do + FeatureFlagsPoller.match_property(property, { 'other_key' => '1.2.3' }) + end.to raise_error(InconclusiveMatchError) + + expect do + FeatureFlagsPoller.match_property(property, {}) + end.to raise_error(InconclusiveMatchError) + end + + it 'with semver operators and null property value' do + property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } + expect do + FeatureFlagsPoller.match_property(property, { 'version' => nil }) + end.to raise_error(InconclusiveMatchError) + end + + it 'with semver_wildcard invalid patterns' do + # Invalid wildcard pattern - completely empty after cleaning + property = { 'key' => 'version', 'value' => '*', 'operator' => 'semver_wildcard' } + expect do + FeatureFlagsPoller.match_property(property, { 'version' => '1.2.3' }) + end.to raise_error(InconclusiveMatchError) + + property2 = { 'key' => 'version', 'value' => '*.*', 'operator' => 'semver_wildcard' } + expect do + FeatureFlagsPoller.match_property(property2, { 'version' => '1.2.3' }) + end.to raise_error(InconclusiveMatchError) + end end describe 'relative date parsing' do