Skip to content

feat: Add Semantic Version support #267

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

Merged
merged 5 commits into from
Aug 27, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 81 additions & 37 deletions lib/optimizely/custom_attribute_condition_evaluator.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

#
# Copyright 2019, Optimizely and contributors
# Copyright 2019-2020, Optimizely and contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -15,8 +15,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
require_relative 'exceptions'
require_relative 'helpers/constants'
require_relative 'helpers/validator'
require_relative 'semantic_version'

module Optimizely
class CustomAttributeConditionEvaluator
Expand All @@ -28,13 +30,23 @@ class CustomAttributeConditionEvaluator
GREATER_THAN_MATCH_TYPE = 'gt'
LESS_THAN_MATCH_TYPE = 'lt'
SUBSTRING_MATCH_TYPE = 'substring'
SEMVER_EQ = 'semver_eq'
SEMVER_GE = 'semver_ge'
SEMVER_GT = 'semver_gt'
SEMVER_LE = 'semver_le'
SEMVER_LT = 'semver_lt'

EVALUATORS_BY_MATCH_TYPE = {
EXACT_MATCH_TYPE => :exact_evaluator,
EXISTS_MATCH_TYPE => :exists_evaluator,
GREATER_THAN_MATCH_TYPE => :greater_than_evaluator,
LESS_THAN_MATCH_TYPE => :less_than_evaluator,
SUBSTRING_MATCH_TYPE => :substring_evaluator
SUBSTRING_MATCH_TYPE => :substring_evaluator,
SEMVER_EQ => :semver_equal_evaluator,
SEMVER_GE => :semver_greater_than_or_equal_evaluator,
SEMVER_GT => :semver_greater_than_evaluator,
SEMVER_LE => :semver_less_than_or_equal_evaluator,
SEMVER_LT => :semver_less_than_evaluator
}.freeze

attr_reader :user_attributes
Expand Down Expand Up @@ -95,7 +107,35 @@ def evaluate(leaf_condition)
return nil
end

send(EVALUATORS_BY_MATCH_TYPE[condition_match], leaf_condition)
begin
send(EVALUATORS_BY_MATCH_TYPE[condition_match], leaf_condition)
rescue InvalidAttributeType
condition_name = leaf_condition['name']
user_value = @user_attributes[condition_name]

@logger.log(
Logger::WARN,
format(
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNEXPECTED_TYPE'],
leaf_condition,
user_value.class,
condition_name
)
)
return nil
rescue InvalidSemanticVersion
condition_name = leaf_condition['name']

@logger.log(
Logger::WARN,
format(
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['INVALID_SEMANTIC_VERSION'],
leaf_condition,
condition_name
)
)
return nil
end
end

def exact_evaluator(condition)
Expand All @@ -122,16 +162,7 @@ def exact_evaluator(condition)

if !value_type_valid_for_exact_conditions?(user_provided_value) ||
!Helpers::Validator.same_types?(condition_value, user_provided_value)
@logger.log(
Logger::WARN,
format(
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNEXPECTED_TYPE'],
condition,
user_provided_value.class,
condition['name']
)
)
return nil
raise InvalidAttributeType
end

if user_provided_value.is_a?(Numeric) && !Helpers::Validator.finite_number?(user_provided_value)
Expand Down Expand Up @@ -204,22 +235,46 @@ def substring_evaluator(condition)
return nil
end

unless user_provided_value.is_a?(String)
@logger.log(
Logger::WARN,
format(
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNEXPECTED_TYPE'],
condition,
user_provided_value.class,
condition['name']
)
)
return nil
end
raise InvalidAttributeType unless user_provided_value.is_a?(String)

user_provided_value.include? condition_value
end

def semver_equal_evaluator(condition)
target_version = condition['value']
user_version = @user_attributes[condition['name']]

SemanticVersion.compare_user_version_with_target_version(target_version, user_version).zero?
end

def semver_greater_than_evaluator(condition)
target_version = condition['value']
user_version = @user_attributes[condition['name']]

SemanticVersion.compare_user_version_with_target_version(target_version, user_version).positive?
end

def semver_greater_than_or_equal_evaluator(condition)
target_version = condition['value']
user_version = @user_attributes[condition['name']]

SemanticVersion.compare_user_version_with_target_version(target_version, user_version) >= 0
end

def semver_less_than_evaluator(condition)
target_version = condition['value']
user_version = @user_attributes[condition['name']]

SemanticVersion.compare_user_version_with_target_version(target_version, user_version).negative?
end

def semver_less_than_or_equal_evaluator(condition)
target_version = condition['value']
user_version = @user_attributes[condition['name']]

SemanticVersion.compare_user_version_with_target_version(target_version, user_version) <= 0
end

private

def valid_numeric_values?(user_value, condition_value, condition)
Expand All @@ -234,18 +289,7 @@ def valid_numeric_values?(user_value, condition_value, condition)
return false
end

unless user_value.is_a?(Numeric)
@logger.log(
Logger::WARN,
format(
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNEXPECTED_TYPE'],
condition,
user_value.class,
condition['name']
)
)
return false
end
raise InvalidAttributeType unless user_value.is_a?(Numeric)

unless Helpers::Validator.finite_number?(user_value)
@logger.log(
Expand Down
16 changes: 16 additions & 0 deletions lib/optimizely/exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,20 @@ def initialize(aborted_method)
super("Optimizely instance is not valid. Failing '#{aborted_method}'.")
end
end

class InvalidAttributeType < Error
# Raised when an attribute is not provided in expected type.

def initialize(msg = 'Provided attribute value is not in the expected data type.')
super
end
end

class InvalidSemanticVersion < Error
# Raised when an invalid value is provided as semantic version.

def initialize(msg = 'Provided semantic version is invalid.')
super
end
end
end
2 changes: 2 additions & 0 deletions lib/optimizely/helpers/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ module Constants
'EVALUATING_AUDIENCE' => "Starting to evaluate audience '%s' with conditions: %s.",
'INFINITE_ATTRIBUTE_VALUE' => 'Audience condition %s evaluated to UNKNOWN because the number value ' \
"for user attribute '%s' is not in the range [-2^53, +2^53].",
'INVALID_SEMANTIC_VERSION' => 'Audience condition %s evaluated as UNKNOWN because an invalid semantic version ' \
"was passed for user attribute '%s'.",
'MISSING_ATTRIBUTE_VALUE' => 'Audience condition %s evaluated as UNKNOWN because no value ' \
"was passed for user attribute '%s'.",
'NULL_ATTRIBUTE_VALUE' => 'Audience condition %s evaluated to UNKNOWN because a nil value was passed ' \
Expand Down
103 changes: 103 additions & 0 deletions lib/optimizely/semantic_version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# frozen_string_literal: true

#
# Copyright 2020, Optimizely and contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require_relative 'exceptions'

module Optimizely
module SemanticVersion
# Semantic Version Operators
SEMVER_PRE_RELEASE = '-'
SEMVER_BUILD = '+'

module_function

def pre_release?(target)
target.include? SEMVER_PRE_RELEASE
end

def split_semantic_version(target)
target_prefix = target
target_suffix = ''
target_parts = []

raise InvalidSemanticVersion if target.include? ' '

if pre_release?(target)
target_parts = target.split(SEMVER_PRE_RELEASE)
elsif target.include? SEMVER_BUILD
target_parts = target.split(SEMVER_BUILD)
end

unless target_parts.empty?
target_prefix = target_parts[0].to_s
target_suffix = target_parts[1..-1]
end

# expect a version string of the form x.y.z
dot_count = target_prefix.count('.')
raise InvalidSemanticVersion if dot_count > 2

target_version_parts = target_prefix.split('.')
raise InvalidSemanticVersion if target_version_parts.length != dot_count + 1

target_version_parts.each do |part|
raise InvalidSemanticVersion unless Helpers::Validator.string_numeric? part
end

target_version_parts.concat(target_suffix) if target_suffix.is_a?(Array)

target_version_parts
end

def compare_user_version_with_target_version(target_version, user_version)
raise InvalidAttributeType unless target_version.is_a? String
raise InvalidAttributeType unless user_version.is_a? String

target_version_parts = split_semantic_version(target_version)
user_version_parts = split_semantic_version(user_version)
user_version_parts_len = user_version_parts.length if user_version_parts

# Up to the precision of targetedVersion, expect version to match exactly.
target_version_parts.each_with_index do |_item, idx|
if user_version_parts_len <= idx
# even if they are equal at this point. if the target is a prerelease
# then it must be greater than the pre release.
return 1 if pre_release?(target_version)

return -1

elsif !Helpers::Validator.string_numeric? user_version_parts[idx]
# compare strings
return -1 if user_version_parts[idx] < target_version_parts[idx]
return 1 if user_version_parts[idx] > target_version_parts[idx]

else
user_version_part = user_version_parts[idx].to_i
target_version_part = target_version_parts[idx].to_i

return 1 if user_version_part > target_version_part
return -1 if user_version_part < target_version_part
end
end

return -1 if pre_release?(user_version) && !pre_release?(target_version)

0
end
end
end
Loading