Skip to content

Commit d834910

Browse files
committed
feat: rebased
2 parents 4bf73bb + 3b729fc commit d834910

File tree

6 files changed

+216
-63
lines changed

6 files changed

+216
-63
lines changed

lib/flagsmith.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ def realtime_client
9898
@realtime_client ||= Flagsmith::RealtimeClient.new(@config)
9999
end
100100

101-
102101
def analytics_processor
103102
return nil unless @config.enable_analytics?
104103

lib/flagsmith/engine/core.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def evaluate_features(evaluation_context, segment_overrides)
103103

104104
# Set reason
105105
flag_result[:reason] = evaluated[:reason] ||
106-
(has_override ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}" : TARGETING_REASON_DEFAULT)
106+
(has_override ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}" : TARGETING_REASON_DEFAULT)
107107
flags[final_feature[:name].to_sym] = flag_result
108108
end
109109

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../utils/hash_func'
4+
require_relative '../features/constants'
5+
require_relative '../segments/evaluator'
6+
7+
module Flagsmith
8+
module Engine
9+
module Evaluation
10+
# Core evaluation logic for feature flags
11+
module Core
12+
extend self
13+
include Flagsmith::Engine::Utils::HashFunc
14+
include Flagsmith::Engine::Features::TargetingReasons
15+
include Flagsmith::Engine::Segments::Evaluator
16+
# Get evaluation result from evaluation context
17+
#
18+
# @param evaluation_context [Hash] The evaluation context
19+
# @return [Hash] Evaluation result with flags and segments
20+
# returns EvaluationResultWithMetadata
21+
def get_evaluation_result(evaluation_context)
22+
segments, segment_overrides = evaluate_segments(evaluation_context)
23+
flags = evaluate_features(evaluation_context, segment_overrides)
24+
{
25+
flags: flags,
26+
segments: segments
27+
}
28+
end
29+
30+
# Returns { segments: EvaluationResultSegments; segmentOverrides: Record<string, SegmentOverride>; }
31+
def evaluate_segments(evaluation_context)
32+
return [], {} if evaluation_context[:segments].nil?
33+
34+
identity_segments = get_segments_from_context(evaluation_context)
35+
36+
segments = identity_segments.map do |segment|
37+
{ name: segment[:name], metadata: segment[:metadata] }.compact
38+
end
39+
40+
segment_overrides = process_segment_overrides(identity_segments)
41+
42+
[segments, segment_overrides]
43+
end
44+
45+
# Returns Record<string: override.name, SegmentOverride>
46+
def process_segment_overrides(identity_segments) # rubocop:disable Metrics/MethodLength
47+
segment_overrides = {}
48+
49+
identity_segments.each do |segment|
50+
Array(segment[:overrides]).each do |override|
51+
next unless should_apply_override(override, segment_overrides)
52+
53+
segment_overrides[override[:name]] = {
54+
feature: override,
55+
segment_name: segment[:name]
56+
}
57+
end
58+
end
59+
60+
segment_overrides
61+
end
62+
63+
# returns EvaluationResultFlags<Metadata>
64+
def evaluate_features(evaluation_context, segment_overrides)
65+
identity_key = get_identity_key(evaluation_context)
66+
67+
(evaluation_context[:features] || {}).each_with_object({}) do |(_, feature), flags|
68+
segment_override = segment_overrides[feature[:name]]
69+
final_feature = segment_override ? segment_override[:feature] : feature
70+
71+
flag_result = build_flag_result(final_feature, identity_key, segment_override)
72+
flags[final_feature[:name].to_sym] = flag_result
73+
end
74+
end
75+
76+
# Returns {value: any; reason?: string}
77+
def evaluate_feature_value(feature, identity_key = nil)
78+
return get_multivariate_feature_value(feature, identity_key) if feature[:variants]&.any? && identity_key
79+
80+
{ value: feature[:value], reason: nil }
81+
end
82+
83+
# Returns {value: any; reason?: string}
84+
def get_multivariate_feature_value(feature, identity_key)
85+
percentage_value = hashed_percentage_for_object_ids([feature[:key], identity_key])
86+
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || WEAKEST_PRIORITY }
87+
88+
variant = find_matching_variant(sorted_variants, percentage_value)
89+
variant || { value: feature[:value], reason: nil }
90+
end
91+
92+
def find_matching_variant(sorted_variants, percentage_value)
93+
start_percentage = 0
94+
sorted_variants.each do |variant|
95+
limit = start_percentage + variant[:weight]
96+
return { value: variant[:value], reason: "#{TARGETING_REASON_SPLIT}; weight=#{variant[:weight]}" } if start_percentage <= percentage_value && percentage_value < limit
97+
98+
start_percentage = limit
99+
end
100+
nil
101+
end
102+
103+
# returns boolean
104+
def should_apply_override(override, existing_overrides)
105+
current_override = existing_overrides[override[:name]]
106+
!current_override || stronger_priority?(override[:priority], current_override[:feature][:priority])
107+
end
108+
109+
private
110+
111+
def build_flag_result(feature, identity_key, segment_override)
112+
evaluated = evaluate_feature_value(feature, identity_key)
113+
114+
flag_result = {
115+
name: feature[:name],
116+
enabled: feature[:enabled],
117+
value: evaluated[:value],
118+
reason: evaluated[:reason] || (segment_override ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}" : TARGETING_REASON_DEFAULT)
119+
}
120+
121+
flag_result[:metadata] = feature[:metadata] if feature[:metadata]
122+
flag_result
123+
end
124+
125+
# Extract identity key from evaluation context
126+
#
127+
# @param evaluation_context [Hash] The evaluation context
128+
# @return [String, nil] The identity key or nil if no identity
129+
def get_identity_key(evaluation_context)
130+
return nil unless evaluation_context[:identity]
131+
132+
evaluation_context[:identity][:key] ||
133+
"#{evaluation_context[:environment][:key]}_#{evaluation_context[:identity][:identifier]}"
134+
end
135+
136+
# returns boolean
137+
def stronger_priority?(priority_a, priority_b)
138+
(priority_a || WEAKEST_PRIORITY) < (priority_b || WEAKEST_PRIORITY)
139+
end
140+
end
141+
end
142+
end
143+
end

lib/flagsmith/engine/segments/evaluator.rb

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ module Flagsmith
1010
module Engine
1111
module Segments
1212
# Evaluator methods
13-
module Evaluator
13+
module Evaluator # rubocop:disable Metrics/ModuleLength
1414
include Flagsmith::Engine::Segments::Constants
1515
include Flagsmith::Engine::Utils::HashFunc
16-
16+
1717
module_function
18+
1819
# Context-based segment evaluation (new approach)
1920
# Returns all segments that the identity belongs to based on segment rules evaluation
2021
#
@@ -136,28 +137,30 @@ def evaluate_sub_rules_from_context(rule, segment_key, context)
136137
# @param context [Hash] The evaluation context
137138
# @return [Boolean] True if the condition matches
138139
def traits_match_segment_condition_from_context(condition, segment_key, context)
139-
if condition[:operator] == PERCENTAGE_SPLIT
140-
context_value_key = get_context_value(condition[:property], context) || get_identity_key_from_context(context)
141-
hashed_percentage = hashed_percentage_for_object_ids([segment_key, context_value_key])
142-
return hashed_percentage <= condition[:value].to_f
143-
end
144-
140+
return handle_percentage_split(condition, segment_key, context) if condition[:operator] == PERCENTAGE_SPLIT
145141
return false if condition[:property].nil?
142+
146143
trait_value = get_trait_value(condition[:property], context)
147-
return !trait_value.nil? if condition[:operator] == IS_SET
148-
return trait_value.nil? if condition[:operator] == IS_NOT_SET
144+
evaluate_trait_condition(condition, trait_value)
145+
end
149146

150-
unless trait_value.nil?
151-
# Reuse existing Condition class logic
152-
condition_obj = Flagsmith::Engine::Segments::Condition.new(
153-
operator: condition[:operator],
154-
value: condition[:value],
155-
property: condition[:property]
156-
)
157-
return condition_obj.match_trait_value?(trait_value)
158-
end
147+
def handle_percentage_split(condition, segment_key, context)
148+
context_value_key = get_context_value(condition[:property], context) || get_identity_key_from_context(context)
149+
hashed_percentage = hashed_percentage_for_object_ids([segment_key, context_value_key])
150+
hashed_percentage <= condition[:value].to_f
151+
end
159152

160-
false
153+
def evaluate_trait_condition(condition, trait_value)
154+
return !trait_value.nil? if condition[:operator] == IS_SET
155+
return trait_value.nil? if condition[:operator] == IS_NOT_SET
156+
return false if trait_value.nil?
157+
158+
condition_obj = Flagsmith::Engine::Segments::Condition.new(
159+
operator: condition[:operator],
160+
value: condition[:value],
161+
property: condition[:property]
162+
)
163+
condition_obj.match_trait_value?(trait_value)
161164
end
162165

163166
# Evaluate rule conditions based on type (ALL/ANY/NONE)
@@ -186,7 +189,7 @@ def evaluate_rule_conditions(rule_type, condition_results)
186189
def get_trait_value(property, context)
187190
if property.start_with?('$.')
188191
context_value = get_context_value(property, context)
189-
return context_value if !context_value.nil? && is_primitive?(context_value)
192+
return context_value if !context_value.nil? && primitive?(context_value)
190193
end
191194

192195
traits = context.dig(:identity, :traits) || {}
@@ -200,6 +203,7 @@ def get_trait_value(property, context)
200203
# @return [Object, nil] The value at the path or nil
201204
def get_context_value(json_path, context)
202205
return nil unless context && json_path&.start_with?('$.')
206+
203207
results = JsonPath.new(json_path, use_symbols: true).on(context)
204208
results.first
205209
rescue StandardError
@@ -221,7 +225,7 @@ def get_identity_key_from_context(context)
221225
#
222226
# @param value [Object] The value to check
223227
# @return [Boolean] True if value is primitive (not an object or array)
224-
def is_primitive?(value)
228+
def primitive?(value)
225229
return true if value.nil?
226230

227231
!(value.is_a?(Hash) || value.is_a?(Array))

lib/flagsmith/engine/segments/models.rb

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -55,43 +55,48 @@ def initialize(operator:, value:, property: nil)
5555
end
5656

5757
def match_trait_value?(trait_value)
58-
if @value.is_a?(String) && @value.match?(/:semver$/)
59-
begin
60-
trait_value = Semantic::Version.new(trait_value.to_s.gsub(/:semver$/, ''))
61-
rescue ArgumentError, Semantic::Version::ValidationFailed => _e
62-
return false
63-
end
64-
end
58+
trait_value = parse_semver_trait_value(trait_value)
59+
return false if trait_value.nil?
6560

6661
return match_in_value(trait_value) if @operator == IN
6762
return match_modulo_value(trait_value) if @operator == MODULO
6863
return MATCHING_FUNCTIONS[REGEX]&.call(trait_value, @value) if @operator == REGEX
6964

65+
match_with_type_conversion(trait_value)
66+
end
67+
68+
def parse_semver_trait_value(trait_value)
69+
return trait_value unless @value.is_a?(String) && @value.match?(/:semver$/)
70+
71+
Semantic::Version.new(trait_value.to_s.gsub(/:semver$/, ''))
72+
rescue ArgumentError, Semantic::Version::ValidationFailed
73+
nil
74+
end
75+
76+
def match_with_type_conversion(trait_value)
7077
type_as_trait_value = format_to_type_of(trait_value)
7178
formatted_value = type_as_trait_value ? type_as_trait_value.call(@value) : @value
72-
7379
MATCHING_FUNCTIONS[operator]&.call(trait_value, formatted_value)
7480
end
7581

76-
# rubocop:disable Metrics/AbcSize
82+
TYPE_CONVERTERS = {
83+
'String' => ->(v) { v.to_s },
84+
'Semantic::Version' => ->(v) { Semantic::Version.new(v.to_s.gsub(/:semver$/, '')) },
85+
'TrueClass' => ->(v) { ['True', 'true', 'TRUE', true, 1, '1'].include?(v) },
86+
'FalseClass' => ->(v) { !['False', 'false', 'FALSE', false].include?(v) },
87+
'Integer' => lambda { |v|
88+
i = v.to_i
89+
i.to_s == v.to_s ? i : v
90+
},
91+
'Float' => lambda { |v|
92+
f = v.to_f
93+
f.to_s == v.to_s ? f : v
94+
}
95+
}.freeze
96+
7797
def format_to_type_of(input)
78-
{
79-
'String' => ->(v) { v.to_s },
80-
'Semantic::Version' => ->(v) { Semantic::Version.new(v.to_s.gsub(/:semver$/, '')) },
81-
# Double check this is the desired behavior between SDKs
82-
'TrueClass' => ->(v) { ['True', 'true', 'TRUE', true, 1, '1'].include?(v) },
83-
'FalseClass' => ->(v) { !['False', 'false', 'FALSE', false].include?(v) },
84-
'Integer' => lambda { |v|
85-
i = v.to_i
86-
i.to_s == v.to_s ? i : v
87-
},
88-
'Float' => lambda { |v|
89-
f = v.to_f
90-
f.to_s == v.to_s ? f : v
91-
}
92-
}[input.class.to_s]
98+
TYPE_CONVERTERS[input.class.to_s]
9399
end
94-
# rubocop:enable Metrics/AbcSize
95100

96101
def match_modulo_value(trait_value)
97102
divisor, remainder = @value.split('|')
@@ -101,22 +106,24 @@ def match_modulo_value(trait_value)
101106
end
102107

103108
def match_in_value(trait_value)
104-
return false if trait_value.nil? || trait_value.is_a?(TrueClass) || trait_value.is_a?(FalseClass)
105-
106-
return false unless ![true, false].include? trait_value
109+
return false if invalid_in_value?(trait_value)
110+
return @value.include?(trait_value.to_s) if @value.is_a?(Array)
107111

108-
if @value.is_a?(Array)
109-
return @value.include?(trait_value.to_s)
110-
end
112+
parse_and_match_string_value(trait_value)
113+
end
111114

112-
if @value.is_a?(String)
113-
begin
114-
parsed = JSON.parse(@value)
115-
return parsed.include?(trait_value.to_s) if parsed.is_a?(Array)
116-
rescue JSON::ParserError
117-
end
118-
end
115+
def invalid_in_value?(trait_value)
116+
trait_value.nil? || [TrueClass, FalseClass].any? { |klass| trait_value.is_a?(klass) }
117+
end
118+
119+
def parse_and_match_string_value(trait_value) # rubocop:disable Metrics/AbcSize
120+
return @value.to_s.split(',').include?(trait_value.to_s) unless @value.is_a?(String)
119121

122+
parsed = JSON.parse(@value)
123+
return parsed.include?(trait_value.to_s) if parsed.is_a?(Array)
124+
125+
@value.to_s.split(',').include?(trait_value.to_s)
126+
rescue JSON::ParserError
120127
@value.to_s.split(',').include?(trait_value.to_s)
121128
end
122129

lib/flagsmith/sdk/models/flags.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def from_evaluation_result(evaluation_result, **args)
181181
if flagsmith_id.nil?
182182
raise Flagsmith::ClientError,
183183
"FlagResult metadata.flagsmith_id is missing for feature \"#{flag_result[:name]}\". " \
184-
"This indicates a bug in the SDK, please report it."
184+
'This indicates a bug in the SDK, please report it.'
185185
end
186186

187187
acc[normalize_key(flag_result[:name])] = Flagsmith::Flags::Flag.new(

0 commit comments

Comments
 (0)