optimizely-sdk 3.3.2 → 3.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2016-2019, Optimizely and contributors
4
+ # Copyright 2016-2020, Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -81,14 +81,6 @@ module Optimizely
81
81
  end
82
82
  end
83
83
 
84
- class InvalidDatafileError < Error
85
- # Raised when a public method fails due to an invalid datafile
86
-
87
- def initialize(aborted_method)
88
- super("Provided datafile is in an invalid format. Aborting #{aborted_method}.")
89
- end
90
- end
91
-
92
84
  class InvalidDatafileVersionError < Error
93
85
  # Raised when a datafile with an unsupported version is provided
94
86
 
@@ -128,4 +120,20 @@ module Optimizely
128
120
  super("Optimizely instance is not valid. Failing '#{aborted_method}'.")
129
121
  end
130
122
  end
123
+
124
+ class InvalidAttributeType < Error
125
+ # Raised when an attribute is not provided in expected type.
126
+
127
+ def initialize(msg = 'Provided attribute value is not in the expected data type.')
128
+ super
129
+ end
130
+ end
131
+
132
+ class InvalidSemanticVersion < Error
133
+ # Raised when an invalid value is provided as semantic version.
134
+
135
+ def initialize(msg = 'Provided semantic version is invalid.')
136
+ super
137
+ end
138
+ end
131
139
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2016-2019, Optimizely and contributors
4
+ # Copyright 2016-2020, Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -304,7 +304,8 @@ module Optimizely
304
304
  'BOOLEAN' => 'boolean',
305
305
  'DOUBLE' => 'double',
306
306
  'INTEGER' => 'integer',
307
- 'STRING' => 'string'
307
+ 'STRING' => 'string',
308
+ 'JSON' => 'json'
308
309
  }.freeze
309
310
 
310
311
  INPUT_VARIABLES = {
@@ -334,11 +335,11 @@ module Optimizely
334
335
 
335
336
  AUDIENCE_EVALUATION_LOGS = {
336
337
  'AUDIENCE_EVALUATION_RESULT' => "Audience '%s' evaluated to %s.",
337
- 'AUDIENCE_EVALUATION_RESULT_COMBINED' => "Audiences for experiment '%s' collectively evaluated to %s.",
338
338
  'EVALUATING_AUDIENCE' => "Starting to evaluate audience '%s' with conditions: %s.",
339
- 'EVALUATING_AUDIENCES_COMBINED' => "Evaluating audiences for experiment '%s': %s.",
340
339
  'INFINITE_ATTRIBUTE_VALUE' => 'Audience condition %s evaluated to UNKNOWN because the number value ' \
341
340
  "for user attribute '%s' is not in the range [-2^53, +2^53].",
341
+ 'INVALID_SEMANTIC_VERSION' => 'Audience condition %s evaluated as UNKNOWN because an invalid semantic version ' \
342
+ "was passed for user attribute '%s'.",
342
343
  'MISSING_ATTRIBUTE_VALUE' => 'Audience condition %s evaluated as UNKNOWN because no value ' \
343
344
  "was passed for user attribute '%s'.",
344
345
  'NULL_ATTRIBUTE_VALUE' => 'Audience condition %s evaluated to UNKNOWN because a nil value was passed ' \
@@ -353,15 +354,27 @@ module Optimizely
353
354
  'to upgrade to a newer release of the Optimizely SDK.'
354
355
  }.freeze
355
356
 
357
+ EXPERIMENT_AUDIENCE_EVALUATION_LOGS = {
358
+ 'AUDIENCE_EVALUATION_RESULT_COMBINED' => "Audiences for experiment '%s' collectively evaluated to %s.",
359
+ 'EVALUATING_AUDIENCES_COMBINED' => "Evaluating audiences for experiment '%s': %s."
360
+ }.merge(AUDIENCE_EVALUATION_LOGS).freeze
361
+
362
+ ROLLOUT_AUDIENCE_EVALUATION_LOGS = {
363
+ 'AUDIENCE_EVALUATION_RESULT_COMBINED' => "Audiences for rule '%s' collectively evaluated to %s.",
364
+ 'EVALUATING_AUDIENCES_COMBINED' => "Evaluating audiences for rule '%s': %s."
365
+ }.merge(AUDIENCE_EVALUATION_LOGS).freeze
366
+
356
367
  DECISION_NOTIFICATION_TYPES = {
357
368
  'AB_TEST' => 'ab-test',
358
369
  'FEATURE' => 'feature',
359
370
  'FEATURE_TEST' => 'feature-test',
360
- 'FEATURE_VARIABLE' => 'feature-variable'
371
+ 'FEATURE_VARIABLE' => 'feature-variable',
372
+ 'ALL_FEATURE_VARIABLES' => 'all-feature-variables'
361
373
  }.freeze
362
374
 
363
375
  CONFIG_MANAGER = {
364
376
  'DATAFILE_URL_TEMPLATE' => 'https://cdn.optimizely.com/datafiles/%s.json',
377
+ 'AUTHENTICATED_DATAFILE_URL_TEMPLATE' => 'https://config.optimizely.com/datafiles/auth/%s.json',
365
378
  # Default time in seconds to block the 'config' method call until 'config' instance has been initialized.
366
379
  'DEFAULT_BLOCKING_TIMEOUT' => 15,
367
380
  # Default config update interval of 5 minutes
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2020, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require 'net/http'
20
+
21
+ module Optimizely
22
+ module Helpers
23
+ module HttpUtils
24
+ module_function
25
+
26
+ def make_request(url, http_method, request_body = nil, headers = {}, read_timeout = nil, proxy_config = nil)
27
+ # makes http/https GET/POST request and returns response
28
+ #
29
+ uri = URI.parse(url)
30
+
31
+ if http_method == :get
32
+ request = Net::HTTP::Get.new(uri.request_uri)
33
+ elsif http_method == :post
34
+ request = Net::HTTP::Post.new(uri.request_uri)
35
+ request.body = request_body if request_body
36
+ else
37
+ return nil
38
+ end
39
+
40
+ # set headers
41
+ headers&.each do |key, val|
42
+ request[key] = val
43
+ end
44
+
45
+ # do not try to make request with proxy unless we have at least a host
46
+ http_class = if proxy_config&.host
47
+ Net::HTTP::Proxy(
48
+ proxy_config.host,
49
+ proxy_config.port,
50
+ proxy_config.username,
51
+ proxy_config.password
52
+ )
53
+ else
54
+ Net::HTTP
55
+ end
56
+
57
+ http = http_class.new(uri.host, uri.port)
58
+ http.read_timeout = read_timeout if read_timeout
59
+ http.use_ssl = uri.scheme == 'https'
60
+ http.request(request)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2017, Optimizely and contributors
4
+ # Copyright 2017, 2020, Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -48,6 +48,13 @@ module Optimizely
48
48
  logger.log(Logger::ERROR, "Unable to cast variable value '#{value}' to type "\
49
49
  "'#{variable_type}': #{e.message}.")
50
50
  end
51
+ when 'json'
52
+ begin
53
+ return_value = JSON.parse(value)
54
+ rescue => e
55
+ logger.log(Logger::ERROR, "Unable to cast variable value '#{value}' to type "\
56
+ "'#{variable_type}': #{e.message}.")
57
+ end
51
58
  else
52
59
  # default case is string
53
60
  return_value = value
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019-2020, Optimizely and contributors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ module Optimizely
19
+ class OptimizelyConfig
20
+ def initialize(project_config)
21
+ @project_config = project_config
22
+ end
23
+
24
+ def config
25
+ experiments_map_object = experiments_map
26
+ features_map = get_features_map(experiments_map_object)
27
+ {
28
+ 'datafile' => @project_config.datafile,
29
+ 'experimentsMap' => experiments_map_object,
30
+ 'featuresMap' => features_map,
31
+ 'revision' => @project_config.revision
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def experiments_map
38
+ feature_variables_map = @project_config.feature_flags.reduce({}) do |result_map, feature|
39
+ result_map.update(feature['id'] => feature['variables'])
40
+ end
41
+ @project_config.experiments.reduce({}) do |experiments_map, experiment|
42
+ experiments_map.update(
43
+ experiment['key'] => {
44
+ 'id' => experiment['id'],
45
+ 'key' => experiment['key'],
46
+ 'variationsMap' => experiment['variations'].reduce({}) do |variations_map, variation|
47
+ variation_object = {
48
+ 'id' => variation['id'],
49
+ 'key' => variation['key'],
50
+ 'variablesMap' => get_merged_variables_map(variation, experiment['id'], feature_variables_map)
51
+ }
52
+ variation_object['featureEnabled'] = variation['featureEnabled'] if @project_config.feature_experiment?(experiment['id'])
53
+ variations_map.update(variation['key'] => variation_object)
54
+ end
55
+ }
56
+ )
57
+ end
58
+ end
59
+
60
+ # Merges feature key and type from feature variables to variation variables.
61
+ def get_merged_variables_map(variation, experiment_id, feature_variables_map)
62
+ feature_ids = @project_config.experiment_feature_map[experiment_id]
63
+ return {} unless feature_ids
64
+
65
+ experiment_feature_variables = feature_variables_map[feature_ids[0]]
66
+ # temporary variation variables map to get values to merge.
67
+ temp_variables_id_map = {}
68
+ if variation['variables']
69
+ temp_variables_id_map = variation['variables'].reduce({}) do |variables_map, variable|
70
+ variables_map.update(
71
+ variable['id'] => {
72
+ 'id' => variable['id'],
73
+ 'value' => variable['value']
74
+ }
75
+ )
76
+ end
77
+ end
78
+ experiment_feature_variables.reduce({}) do |variables_map, feature_variable|
79
+ variation_variable = temp_variables_id_map[feature_variable['id']]
80
+ variable_value = variation['featureEnabled'] && variation_variable ? variation_variable['value'] : feature_variable['defaultValue']
81
+ variables_map.update(
82
+ feature_variable['key'] => {
83
+ 'id' => feature_variable['id'],
84
+ 'key' => feature_variable['key'],
85
+ 'type' => feature_variable['type'],
86
+ 'value' => variable_value
87
+ }
88
+ )
89
+ end
90
+ end
91
+
92
+ def get_features_map(all_experiments_map)
93
+ @project_config.feature_flags.reduce({}) do |features_map, feature|
94
+ features_map.update(
95
+ feature['key'] => {
96
+ 'id' => feature['id'],
97
+ 'key' => feature['key'],
98
+ 'experimentsMap' => feature['experimentIds'].reduce({}) do |experiments_map, experiment_id|
99
+ experiment_key = @project_config.experiment_id_map[experiment_id]['key']
100
+ experiments_map.update(experiment_key => all_experiments_map[experiment_key])
101
+ end,
102
+ 'variablesMap' => feature['variables'].reduce({}) do |variables, variable|
103
+ variables.update(
104
+ variable['key'] => {
105
+ 'id' => variable['id'],
106
+ 'key' => variable['key'],
107
+ 'type' => variable['type'],
108
+ 'value' => variable['defaultValue']
109
+ }
110
+ )
111
+ end
112
+ }
113
+ )
114
+ end
115
+ end
116
+ end
117
+ end
@@ -17,16 +17,18 @@
17
17
  #
18
18
 
19
19
  require 'optimizely'
20
+ require 'optimizely/error_handler'
20
21
  require 'optimizely/event_dispatcher'
21
22
  require 'optimizely/event/batch_event_processor'
23
+ require 'optimizely/logger'
24
+ require 'optimizely/notification_center'
25
+
22
26
  module Optimizely
23
27
  class OptimizelyFactory
24
- attr_reader :max_event_batch_size, :max_event_flush_interval
25
-
26
28
  # Convenience method for setting the maximum number of events contained within a batch.
27
29
  # @param batch_size Integer - Sets size of EventQueue.
28
30
  # @param logger - Optional LoggerInterface Provides a log method to log messages.
29
- def self.max_event_batch_size(batch_size, logger)
31
+ def self.max_event_batch_size(batch_size, logger = NoOpLogger.new)
30
32
  unless batch_size.is_a? Integer
31
33
  logger.log(
32
34
  Logger::ERROR,
@@ -48,7 +50,7 @@ module Optimizely
48
50
  # Convenience method for setting the maximum time interval in milliseconds between event dispatches.
49
51
  # @param flush_interval Numeric - Time interval between event dispatches.
50
52
  # @param logger - Optional LoggerInterface Provides a log method to log messages.
51
- def self.max_event_flush_interval(flush_interval, logger)
53
+ def self.max_event_flush_interval(flush_interval, logger = NoOpLogger.new)
52
54
  unless flush_interval.is_a? Numeric
53
55
  logger.log(
54
56
  Logger::ERROR,
@@ -67,12 +69,42 @@ module Optimizely
67
69
  @max_event_flush_interval = flush_interval
68
70
  end
69
71
 
72
+ # Convenience method for setting frequency at which datafile has to be polled and ProjectConfig updated.
73
+ #
74
+ # @param polling_interval Numeric - Time in seconds after which to update datafile.
75
+ def self.polling_interval(polling_interval)
76
+ @polling_interval = polling_interval
77
+ end
78
+
79
+ # Convenience method for setting timeout to block the config call until config has been initialized.
80
+ #
81
+ # @param blocking_timeout Numeric - Time in seconds.
82
+ def self.blocking_timeout(blocking_timeout)
83
+ @blocking_timeout = blocking_timeout
84
+ end
85
+
70
86
  # Returns a new optimizely instance.
71
87
  #
72
88
  # @params sdk_key - Required String uniquely identifying the fallback datafile corresponding to project.
73
89
  # @param fallback datafile - Optional JSON string datafile.
74
90
  def self.default_instance(sdk_key, datafile = nil)
75
- Optimizely::Project.new(datafile, nil, nil, nil, nil, nil, sdk_key)
91
+ error_handler = NoOpErrorHandler.new
92
+ logger = NoOpLogger.new
93
+ notification_center = NotificationCenter.new(logger, error_handler)
94
+
95
+ config_manager = Optimizely::HTTPProjectConfigManager.new(
96
+ sdk_key: sdk_key,
97
+ polling_interval: @polling_interval,
98
+ blocking_timeout: @blocking_timeout,
99
+ datafile: datafile,
100
+ logger: logger,
101
+ error_handler: error_handler,
102
+ notification_center: notification_center
103
+ )
104
+
105
+ Optimizely::Project.new(
106
+ datafile, nil, logger, error_handler, nil, nil, sdk_key, config_manager, notification_center
107
+ )
76
108
  end
77
109
 
78
110
  # Returns a new optimizely instance.
@@ -108,10 +140,27 @@ module Optimizely
108
140
  config_manager = nil,
109
141
  notification_center = nil
110
142
  )
143
+
144
+ error_handler ||= NoOpErrorHandler.new
145
+ logger ||= NoOpLogger.new
146
+ notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(logger, error_handler)
147
+
111
148
  event_processor = BatchEventProcessor.new(
112
149
  event_dispatcher: event_dispatcher || EventDispatcher.new,
113
150
  batch_size: @max_event_batch_size,
114
151
  flush_interval: @max_event_flush_interval,
152
+ logger: logger,
153
+ notification_center: notification_center
154
+ )
155
+
156
+ config_manager ||= Optimizely::HTTPProjectConfigManager.new(
157
+ sdk_key: sdk_key,
158
+ polling_interval: @polling_interval,
159
+ blocking_timeout: @blocking_timeout,
160
+ datafile: datafile,
161
+ logger: logger,
162
+ error_handler: error_handler,
163
+ skip_json_validation: skip_json_validation,
115
164
  notification_center: notification_center
116
165
  )
117
166
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2016-2019, Optimizely and contributors
3
+ # Copyright 2016-2020, Optimizely and contributors
4
4
  #
5
5
  # Licensed under the Apache License, Version 2.0 (the "License");
6
6
  # you may not use this file except in compliance with the License.
@@ -20,6 +20,8 @@ module Optimizely
20
20
  # ProjectConfig is an interface capturing the experiment, variation and feature definitions.
21
21
  # The default implementation of ProjectConfig can be found in DatafileProjectConfig.
22
22
 
23
+ def datafile; end
24
+
23
25
  def account_id; end
24
26
 
25
27
  def attributes; end
@@ -44,6 +46,8 @@ module Optimizely
44
46
 
45
47
  def revision; end
46
48
 
49
+ def send_flag_decisions; end
50
+
47
51
  def rollouts; end
48
52
 
49
53
  def experiment_running?(experiment); end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2020, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require_relative 'exceptions'
20
+
21
+ module Optimizely
22
+ module SemanticVersion
23
+ # Semantic Version Operators
24
+ SEMVER_PRE_RELEASE = '-'
25
+ SEMVER_BUILD = '+'
26
+
27
+ module_function
28
+
29
+ def pre_release?(target)
30
+ # Method to check if the given version is a prerelease
31
+ #
32
+ # target - String representing semantic version
33
+ #
34
+ # Returns true if the given version is a prerelease
35
+ # false if it doesn't
36
+
37
+ raise unless target.is_a? String
38
+
39
+ prerelease_index = target.index(SEMVER_PRE_RELEASE)
40
+ build_index = target.index(SEMVER_BUILD)
41
+
42
+ return false if prerelease_index.nil?
43
+ return true if build_index.nil?
44
+
45
+ # when both operators are present prerelease should precede the build operator
46
+ prerelease_index < build_index
47
+ end
48
+
49
+ def build?(target)
50
+ # Method to check if the given version is a build
51
+ #
52
+ # target - String representing semantic version
53
+ #
54
+ # Returns true if the given version is a build
55
+ # false if it doesn't
56
+
57
+ raise unless target.is_a? String
58
+
59
+ prerelease_index = target.index(SEMVER_PRE_RELEASE)
60
+ build_index = target.index(SEMVER_BUILD)
61
+
62
+ return false if build_index.nil?
63
+ return true if prerelease_index.nil?
64
+
65
+ # when both operators are present build should precede the prerelease operator
66
+ build_index < prerelease_index
67
+ end
68
+
69
+ def split_semantic_version(target)
70
+ # Method to split the given version.
71
+ #
72
+ # target - String representing semantic version
73
+ #
74
+ # Returns List The array of version split into smaller parts i.e major, minor, patch etc,
75
+ # Exception if the given version is invalid.
76
+
77
+ target_prefix = target
78
+ target_suffix = ''
79
+ target_parts = []
80
+
81
+ raise InvalidSemanticVersion if target.include? ' '
82
+
83
+ if pre_release?(target)
84
+ target_parts = target.split(SEMVER_PRE_RELEASE, 2)
85
+ elsif build? target
86
+ target_parts = target.split(SEMVER_BUILD, 2)
87
+ end
88
+
89
+ unless target_parts.empty?
90
+ target_prefix = target_parts[0].to_s
91
+ target_suffix = target_parts[1..-1]
92
+ end
93
+
94
+ # expect a version string of the form x.y.z
95
+ dot_count = target_prefix.count('.')
96
+ raise InvalidSemanticVersion if dot_count > 2
97
+
98
+ target_version_parts = target_prefix.split('.')
99
+ raise InvalidSemanticVersion if target_version_parts.length != dot_count + 1
100
+
101
+ target_version_parts.each do |part|
102
+ raise InvalidSemanticVersion unless Helpers::Validator.string_numeric? part
103
+ end
104
+
105
+ target_version_parts.concat(target_suffix) if target_suffix.is_a?(Array)
106
+
107
+ target_version_parts
108
+ end
109
+
110
+ def compare_user_version_with_target_version(target_version, user_version)
111
+ # Compares target and user versions
112
+ #
113
+ # target_version - String representing target version
114
+ # user_version - String representing user version
115
+
116
+ # Returns boolean 0 if user version is equal to target version,
117
+ # 1 if user version is greater than target version,
118
+ # -1 if user version is less than target version.
119
+
120
+ raise InvalidAttributeType unless target_version.is_a? String
121
+ raise InvalidAttributeType unless user_version.is_a? String
122
+
123
+ is_target_version_prerelease = pre_release?(target_version)
124
+ is_user_version_prerelease = pre_release?(user_version)
125
+
126
+ target_version_parts = split_semantic_version(target_version)
127
+ user_version_parts = split_semantic_version(user_version)
128
+ user_version_parts_len = user_version_parts.length if user_version_parts
129
+
130
+ # Up to the precision of targetedVersion, expect version to match exactly.
131
+ target_version_parts.each_with_index do |_item, idx|
132
+ if user_version_parts_len <= idx
133
+ # even if they are equal at this point. if the target is a prerelease
134
+ # then user version must be greater than the pre release.
135
+ return 1 if is_target_version_prerelease
136
+
137
+ return -1
138
+
139
+ elsif !Helpers::Validator.string_numeric? user_version_parts[idx]
140
+ # compare strings
141
+ if user_version_parts[idx] < target_version_parts[idx]
142
+ return 1 if is_target_version_prerelease && !is_user_version_prerelease
143
+
144
+ return -1
145
+
146
+ elsif user_version_parts[idx] > target_version_parts[idx]
147
+ return -1 if is_user_version_prerelease && !is_target_version_prerelease
148
+
149
+ return 1
150
+ end
151
+
152
+ else
153
+ user_version_part = user_version_parts[idx].to_i
154
+ target_version_part = target_version_parts[idx].to_i
155
+
156
+ return 1 if user_version_part > target_version_part
157
+ return -1 if user_version_part < target_version_part
158
+ end
159
+ end
160
+
161
+ return -1 if is_user_version_prerelease && !is_target_version_prerelease
162
+
163
+ 0
164
+ end
165
+ end
166
+ end