diff --git a/lib/splitclient-rb.rb b/lib/splitclient-rb.rb index 642db7d5..2bc230dd 100644 --- a/lib/splitclient-rb.rb +++ b/lib/splitclient-rb.rb @@ -90,6 +90,7 @@ require 'splitclient-rb/engine/matchers/equal_to_boolean_matcher' require 'splitclient-rb/engine/matchers/equal_to_matcher' require 'splitclient-rb/engine/matchers/matches_string_matcher' +require 'splitclient-rb/engine/matchers/semver' require 'splitclient-rb/engine/evaluator/splitter' require 'splitclient-rb/engine/impressions/noop_unique_keys_tracker' require 'splitclient-rb/engine/impressions/unique_keys_tracker' diff --git a/lib/splitclient-rb/engine/matchers/semver.rb b/lib/splitclient-rb/engine/matchers/semver.rb new file mode 100644 index 00000000..92d9a6a8 --- /dev/null +++ b/lib/splitclient-rb/engine/matchers/semver.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +module SplitIoClient + class Semver + METADATA_DELIMITER = '+' + PRE_RELEASE_DELIMITER = '-' + VALUE_DELIMITER = '.' + + attr_reader :major, :minor, :patch, :pre_release, :is_stable, :old_version + + def initialize(version) + @major = 0 + @minor = 0 + @patch = 0 + @pre_release = [] + @is_stable = false + @old_version = version + parse + end + + # + # Class builder + # + # @param version [String] raw version as read from splitChanges response. + # + # @return [type] Semver instance + def self.build(version, logger) + new(version) + rescue RuntimeError => e + logger.warn("Failed to parse Semver data: #{e}") + nil + end + + # + # Check if there is any metadata characters in self._old_version. + # + # @return [type] String semver without the metadata + # + def remove_metadata_if_exists + index = @old_version.index(METADATA_DELIMITER) + return @old_version if index.nil? + + @old_version[0, index] + end + + # Compare the current Semver object to a given Semver object, return: + # 0: if self == passed + # 1: if self > passed + # -1: if self < passed + # + # @param to_compare [trype] splitio.models.grammar.matchers.semver.Semver object + # + # @returns [Integer] based on comparison + def compare(to_compare) + return 0 if @old_version == to_compare.old_version + + # Compare major, minor, and patch versions numerically + return compare_attributes(to_compare) if compare_attributes(to_compare) != 0 + + # Compare pre-release versions lexically + compare_pre_release(to_compare) + end + + private + + def integer?(value) + value.to_i.to_s == value + end + + # + # Parse the string in self._old_version to update the other internal variables + # + def parse + without_metadata = remove_metadata_if_exists + + index = without_metadata.index(PRE_RELEASE_DELIMITER) + if index.nil? + @is_stable = true + else + pre_release_data = without_metadata[index + 1..-1] + without_metadata = without_metadata[0, index] + @pre_release = pre_release_data.split(VALUE_DELIMITER) + end + assign_major_minor_and_patch(without_metadata) + end + + # + # Set the major, minor and patch internal variables based on string passed. + # + # @param version [String] raw version containing major.minor.patch numbers. + def assign_major_minor_and_patch(version) + parts = version.split(VALUE_DELIMITER) + if parts.length != 3 || + !(integer?(parts[0]) && + integer?(parts[1]) && + integer?(parts[2])) + raise "Unable to convert to Semver, incorrect format: #{version}" + end + + @major = parts[0].to_i + @minor = parts[1].to_i + @patch = parts[2].to_i + end + + # + # Compare 2 variables and return int as follows: + # 0: if var1 == var2 + # 1: if var1 > var2 + # -1: if var1 < var2 + # + # @param var1 [type] String/Integer object that accept ==, < or > operators + # @param var2 [type] String/Integer object that accept ==, < or > operators + # + # @returns [Integer] based on comparison + def compare_vars(var1, var2) + return 0 if var1 == var2 + + return 1 if var1 > var2 + + -1 + end + + # Compare the current Semver object's major, minor, patch and is_stable attributes to a given Semver object, return: + # 0: if self == passed + # 1: if self > passed + # -1: if self < passed + # + # @param to_compare [trype] splitio.models.grammar.matchers.semver.Semver object + # + # @returns [Integer] based on comparison + def compare_attributes(to_compare) + result = compare_vars(@major, to_compare.major) + return result if result != 0 + + result = compare_vars(@minor, to_compare.minor) + return result if result != 0 + + result = compare_vars(@patch, to_compare.patch) + return result if result != 0 + + return -1 if !@is_stable && to_compare.is_stable + + return 1 if @is_stable && !to_compare.is_stable + + 0 + end + + # Compare the current Semver object's pre_release attribute to a given Semver object, return: + # 0: if self == passed + # 1: if self > passed + # -1: if self < passed + # + # @param to_compare [trype] splitio.models.grammar.matchers.semver.Semver object + # + # @returns [Integer] based on comparison + def compare_pre_release(to_compare) + min_length = get_pre_min_length(to_compare) + 0.upto(min_length - 1) do |i| + next if @pre_release[i] == to_compare.pre_release[i] + + if integer?(@pre_release[i]) && integer?(to_compare.pre_release[i]) + return compare_vars(@pre_release[i].to_i, to_compare.pre_release[i].to_i) + end + + return compare_vars(@pre_release[i], to_compare.pre_release[i]) + end + # Compare lengths of pre-release versions + compare_vars(@pre_release.length, to_compare.pre_release.length) + end + + # Get minimum of current Semver object's pre_release attributes length to a given Semver object + # + # @param to_compare [trype] splitio.models.grammar.matchers.semver.Semver object + # + # @returns [Integer] + def get_pre_min_length(to_compare) + [@pre_release.length, to_compare.pre_release.length].min + end + end +end diff --git a/spec/engine/matchers/semver_spec.rb b/spec/engine/matchers/semver_spec.rb new file mode 100644 index 00000000..7b90b2ec --- /dev/null +++ b/spec/engine/matchers/semver_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' +require 'csv' + +describe SplitIoClient::Semver do + let(:valid_versions) do + CSV.parse(File.read(File.expand_path(File.join(File.dirname(__FILE__), + '../../test_data/splits/semver/valid-semantic-versions.csv')))) + end + let(:invalid_versions) do + CSV.parse(File.read(File.expand_path(File.join(File.dirname(__FILE__), + '../../test_data/splits/semver/invalid-semantic-versions.csv')))) + end + let(:equal_to_versions) do + CSV.parse(File.read(File.expand_path(File.join(File.dirname(__FILE__), + '../../test_data/splits/semver/equal-to-semver.csv')))) + end + + let(:logger) { Logger.new('/dev/null') } + + context 'check versions' do + it 'accept valid versions' do + for i in (0..valid_versions.length-1) + expect(described_class.build(valid_versions[i][0], logger)).should_not be_nil + end + end + it 'reject invalid versions' do + for version in invalid_versions + expect(described_class.build(version[0], logger)).to eq(nil) + end + end + end + + context 'compare versions' do + it 'equal and not equal' do + for i in (1..valid_versions.length-1) + expect(described_class.build(valid_versions[i][0], logger).compare(described_class.build(valid_versions[i][1], logger))).to eq(1) + expect(described_class.build(valid_versions[i][1], logger).compare(described_class.build(valid_versions[i][0], logger))).to eq(-1) + expect(described_class.build(valid_versions[i][0], logger).compare(described_class.build(valid_versions[i][0], logger))).to eq(0) + expect(described_class.build(valid_versions[i][1], logger).compare(described_class.build(valid_versions[i][1], logger))).to eq(0) + end + for i in (1..equal_to_versions.length-1) + if valid_versions[i][2] + expect(described_class.build(valid_versions[i][0], logger).compare(described_class.build(valid_versions[i][1], logger))).to eq(0) + else + expect(described_class.build(valid_versions[i][0], logger).compare(described_class.build(valid_versions[i][1], logger))).not_to eq(0) + end + end + + end + end + + def verify_version(semver, major, minor, patch, pre_release="", is_stable=True) + if semver.major == major && semver.minor == minor && semver.patch == patch && + semver.pre_release == pre_release && semver.is_stable == is_stable + return true + end + return false + end +end diff --git a/spec/test_data/splits/semver/between-semver.csv b/spec/test_data/splits/semver/between-semver.csv new file mode 100644 index 00000000..4225e710 --- /dev/null +++ b/spec/test_data/splits/semver/between-semver.csv @@ -0,0 +1,18 @@ +# version1, version2, version3, expected +1.1.1,2.2.2,3.3.3,true +1.1.1-rc.1,1.1.1-rc.2,1.1.1-rc.3,true +1.0.0-alpha,1.0.0-alpha.1,1.0.0-alpha.beta,true +1.0.0-alpha.1,1.0.0-alpha.beta,1.0.0-beta,true +1.0.0-alpha.beta,1.0.0-beta,1.0.0-beta.2,true +1.0.0-beta,1.0.0-beta.2,1.0.0-beta.11,true +1.0.0-beta.2,1.0.0-beta.11,1.0.0-rc.1,true +1.0.0-beta.11,1.0.0-rc.1,1.0.0,true +1.1.2,1.1.3,1.1.4,true +1.2.1,1.3.1,1.4.1,true +2.0.0,3.0.0,4.0.0,true +2.2.2,2.2.3-rc1,2.2.3,true +2.2.2,2.3.2-rc100,2.3.3,true +1.0.0-rc.1+build.1,1.2.3-beta,1.2.3-rc.1+build.123,true +3.3.3,3.3.3-alpha,3.3.4,false +2.2.2-rc.1,2.2.2+metadata,2.2.2-rc.10,false +1.1.1-rc.1,1.1.1-rc.3,1.1.1-rc.2,false \ No newline at end of file diff --git a/spec/test_data/splits/semver/equal-to-semver.csv b/spec/test_data/splits/semver/equal-to-semver.csv new file mode 100644 index 00000000..4ac0b7c6 --- /dev/null +++ b/spec/test_data/splits/semver/equal-to-semver.csv @@ -0,0 +1,7 @@ +# version1, version2, equals +1.1.1,1.1.1,true +1.1.1,1.1.1+metadata,false +1.1.1,1.1.1-rc.1,false +88.88.88,88.88.88,true +1.2.3----RC-SNAPSHOT.12.9.1--.12,1.2.3----RC-SNAPSHOT.12.9.1--.12,true +10.2.3-DEV-SNAPSHOT,10.2.3-SNAPSHOT-123,false \ No newline at end of file diff --git a/spec/test_data/splits/semver/invalid-semantic-versions.csv b/spec/test_data/splits/semver/invalid-semantic-versions.csv new file mode 100644 index 00000000..b9eb3e50 --- /dev/null +++ b/spec/test_data/splits/semver/invalid-semantic-versions.csv @@ -0,0 +1,26 @@ +# invalid +1 +1.2 +1.alpha.2 ++invalid +-invalid +-invalid+invalid +-invalid.01 +alpha +alpha.beta +alpha.beta.1 +alpha.1 +alpha+beta +alpha_beta +alpha. +alpha.. +beta +-alpha. +1.2 +1.2.3.DEV +1.2-SNAPSHOT +1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788 +1.2-RC-SNAPSHOT +-1.0.3-gamma+b7718 ++justmeta +#99999999999999999999999.999999999999999999.99999999999999999----RC-SNAPSHOT.12.09.1--------------------------------..12 \ No newline at end of file diff --git a/spec/test_data/splits/semver/valid-semantic-versions.csv b/spec/test_data/splits/semver/valid-semantic-versions.csv new file mode 100644 index 00000000..da85709c --- /dev/null +++ b/spec/test_data/splits/semver/valid-semantic-versions.csv @@ -0,0 +1,25 @@ +# higher, lower +1.1.2,1.1.1 +1.0.0,1.0.0-rc.1 +1.1.0-rc.1,1.0.0-beta.11 +1.0.0-beta.11,1.0.0-beta.2 +1.0.0-beta.2,1.0.0-beta +1.0.0-beta,1.0.0-alpha.beta +1.0.0-alpha.beta,1.0.0-alpha.1 +1.0.0-alpha.1,1.0.0-alpha +2.2.2-rc.2+metadata-lalala,2.2.2-rc.1.2 +1.2.3,0.0.4 +1.1.2+meta,1.1.2-prerelease+meta +1.0.0-beta,1.0.0-alpha +1.0.0-alpha0.valid,1.0.0-alpha.0valid +1.0.0-rc.1+build.1,1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay +10.2.3-DEV-SNAPSHOT,1.2.3-SNAPSHOT-123 +1.1.1-rc2,1.0.0-0A.is.legal +1.2.3----RC-SNAPSHOT.12.9.1--.12+788,1.2.3----R-S.12.9.1--.12+meta +1.2.3----RC-SNAPSHOT.12.9.1--.12.88,1.2.3----RC-SNAPSHOT.12.9.1--.12 +9223372036854775807.9223372036854775807.9223372036854775807,9223372036854775807.9223372036854775807.9223372036854775806 +1.1.1-alpha.beta.rc.build.java.pr.support.10,1.1.1-alpha.beta.rc.build.java.pr.support +1.1.2,1.1.1 +1.2.1,1.1.1 +2.1.1,1.1.1 +1.1.1-rc.1,1.1.1-rc.0 \ No newline at end of file