Skip to content

Commit da08e8e

Browse files
committed
feat: implemented-get-identity-segments
1 parent 0f08fb1 commit da08e8e

File tree

6 files changed

+270
-14
lines changed

6 files changed

+270
-14
lines changed

.gitmodules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[submodule "spec/engine-test-data"]
22
path = spec/engine-test-data
33
url = [email protected]:Flagsmith/engine-test-data.git
4-
branch = main
4+
branch = v3.1.0

lib/flagsmith/engine/core.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
require_relative 'segments/models'
1313
require_relative 'utils/hash_func'
1414
require_relative 'evaluation/mappers'
15+
require_relative 'evaluation/core'
1516

1617
module Flagsmith
1718
module Engine

lib/flagsmith/engine/evaluation/core.rb

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
# frozen_string_literal: true
22

3+
require_relative '../utils/hash_func'
4+
require_relative '../features/constants'
5+
require_relative '../segments/evaluator'
6+
37
module Flagsmith
48
module Engine
59
module Evaluation
610
# Core evaluation logic module
711
module Core
12+
extend self
13+
include Flagsmith::Engine::Utils::HashFunc
14+
include Flagsmith::Engine::Features::TargetingReasons
15+
include Flagsmith::Engine::Segments::Evaluator
816
# Get evaluation result from evaluation context
917
#
1018
# @param evaluation_context [Hash] The evaluation context
@@ -25,7 +33,7 @@ def evaluate_segments(evaluation_context)
2533
return [], {}
2634
end
2735

28-
identity_segments = [] # To be getIdentitySegments when implemented
36+
identity_segments = get_identity_segments_from_context(evaluation_context)
2937

3038
segments = identity_segments.map do |segment|
3139
result = {
@@ -68,18 +76,68 @@ def process_segment_overrides(identity_segments)
6876
end
6977

7078
# returns EvaluationResultFlags<Metadata>
71-
def evaluate_features(evaluation_context, _segment_overrides)
72-
raise NotImplementedError
79+
def evaluate_features(evaluation_context, segment_overrides)
80+
flags = {}
81+
82+
(evaluation_context[:features] || {}).each_value do |feature|
83+
segment_override = segment_overrides[feature[:name]]
84+
final_feature = segment_override ? segment_override[:feature] : feature
85+
has_override = !segment_override.nil?
86+
87+
# Evaluate feature value
88+
evaluated = if has_override
89+
{ value: final_feature[:value], reason: nil }
90+
else
91+
evaluate_feature_value(final_feature, get_identity_key(evaluation_context))
92+
end
93+
94+
# Build flag result
95+
flag_result = {
96+
name: final_feature[:name],
97+
enabled: final_feature[:enabled],
98+
value: evaluated[:value]
99+
}
100+
101+
# Add metadata if present
102+
flag_result[:metadata] = final_feature[:metadata] if final_feature[:metadata]
103+
104+
# Set reason
105+
flag_result[:reason] = evaluated[:reason] ||
106+
get_targeting_match_reason({ type: 'SEGMENT', override: segment_override })
107+
108+
flags[final_feature[:name]] = flag_result
109+
end
110+
111+
flags
73112
end
74113

75114
# Returns {value: any; reason?: string}
76-
def evaluate_feature_value(_feature, _identity_key)
77-
raise NotImplementedError
115+
def evaluate_feature_value(feature, identity_key = nil)
116+
if feature[:variants]&.any? && identity_key
117+
return get_multivariate_feature_value(feature, identity_key)
118+
end
119+
120+
{ value: feature[:value], reason: nil }
78121
end
79122

80123
# Returns {value: any; reason?: string}
81-
def get_multivariate_feature_value(_feature, _identity_key)
82-
raise NotImplementedError
124+
def get_multivariate_feature_value(feature, identity_key)
125+
percentage_value = hashed_percentage_for_object_ids([feature[:key], identity_key])
126+
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || Float::INFINITY }
127+
128+
start_percentage = 0
129+
sorted_variants.each do |variant|
130+
limit = start_percentage + variant[:weight]
131+
if start_percentage <= percentage_value && percentage_value < limit
132+
return {
133+
value: variant[:value],
134+
reason: get_targeting_match_reason({ type: 'SPLIT', weight: variant[:weight] })
135+
}
136+
end
137+
start_percentage = limit
138+
end
139+
140+
{ value: feature[:value], reason: nil }
83141
end
84142

85143
# returns boolean
@@ -90,21 +148,29 @@ def should_apply_override(override, existing_overrides)
90148

91149
private
92150

151+
# Extract identity key from evaluation context
152+
#
153+
# @param evaluation_context [Hash] The evaluation context
154+
# @return [String, nil] The identity key or nil if no identity
155+
def get_identity_key(evaluation_context)
156+
evaluation_context.dig(:identity, :key)
157+
end
158+
93159
# returns boolean
94160
def higher_priority?(priority_a, priority_b)
95161
(priority_a || Float::INFINITY) < (priority_b || Float::INFINITY)
96162
end
97163

98164
def get_targeting_match_reason(match_object)
99-
type = match_object.type
165+
type = match_object[:type]
100166

101167
if type == 'SEGMENT'
102-
return match_object.override ? "TARGETING_MATCH; segment=#{match_object.override.segment_name}" : 'DEFAULT'
168+
return match_object[:override] ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{match_object[:override][:segment_name]}" : TARGETING_REASON_DEFAULT
103169
end
104170

105-
return "SPLIT; weight=#{match_object.weight}" if type == 'SPLIT'
171+
return "#{TARGETING_REASON_SPLIT}; weight=#{match_object[:weight]}" if type == 'SPLIT'
106172

107-
'DEFAULT'
173+
TARGETING_REASON_DEFAULT
108174
end
109175
end
110176
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
module Flagsmith
4+
module Engine
5+
module Features
6+
# Targeting reason constants for evaluation results
7+
module TargetingReasons
8+
TARGETING_REASON_DEFAULT = 'DEFAULT'
9+
TARGETING_REASON_TARGETING_MATCH = 'TARGETING_MATCH'
10+
TARGETING_REASON_SPLIT = 'SPLIT'
11+
end
12+
end
13+
end
14+
end

lib/flagsmith/engine/segments/evaluator.rb

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,34 @@
11
# frozen_string_literal: true
22

33
require_relative 'constants'
4+
require_relative 'models'
45
require_relative '../utils/hash_func'
56

67
module Flagsmith
78
module Engine
89
module Segments
910
# Evaluator methods
1011
module Evaluator
12+
extend self
1113
include Flagsmith::Engine::Segments::Constants
1214
include Flagsmith::Engine::Utils::HashFunc
1315

16+
# Context-based segment evaluation (new approach)
17+
# Returns all segments that the identity belongs to based on segment rules evaluation
18+
#
19+
# @param context [Hash] Evaluation context containing identity and segment definitions
20+
# @return [Array<Hash>] Array of segments that the identity matches
21+
def get_identity_segments_from_context(context)
22+
return [] unless context[:identity] && context[:segments]
23+
24+
context[:segments].values.select do |segment|
25+
next false if segment[:rules].nil? || segment[:rules].empty?
26+
27+
segment[:rules].all? { |rule| traits_match_segment_rule_from_context(rule, segment[:key], context) }
28+
end
29+
end
30+
31+
# Model-based segment evaluation (existing approach)
1432
def get_identity_segments(environment, identity, override_traits = nil)
1533
environment.project.segments.select do |s|
1634
evaluate_identity_in_segment(identity, s, override_traits)
@@ -70,6 +88,163 @@ def traits_match_segment_condition(identity_traits, condition, segment_id, ident
7088
false
7189
end
7290

91+
# Context-based helper functions (new approach)
92+
93+
# Evaluates whether a segment rule matches using context
94+
#
95+
# @param rule [Hash] The rule to evaluate
96+
# @param segment_key [String] The segment key (used for percentage split)
97+
# @param context [Hash] The evaluation context
98+
# @return [Boolean] True if the rule matches
99+
def traits_match_segment_rule_from_context(rule, segment_key, context)
100+
matches_conditions = evaluate_conditions_from_context(rule, segment_key, context)
101+
matches_sub_rules = evaluate_sub_rules_from_context(rule, segment_key, context)
102+
103+
matches_conditions && matches_sub_rules
104+
end
105+
106+
# Evaluates rule conditions based on rule type (ALL/ANY/NONE)
107+
#
108+
# @param rule [Hash] The rule containing conditions and type
109+
# @param segment_key [String] The segment key
110+
# @param context [Hash] The evaluation context
111+
# @return [Boolean] True if conditions match according to rule type
112+
def evaluate_conditions_from_context(rule, segment_key, context)
113+
return true if rule[:conditions].nil? || rule[:conditions].empty?
114+
115+
condition_results = rule[:conditions].map do |condition|
116+
traits_match_segment_condition_from_context(condition, segment_key, context)
117+
end
118+
119+
evaluate_rule_conditions(rule[:type], condition_results)
120+
end
121+
122+
# Evaluates nested sub-rules
123+
#
124+
# @param rule [Hash] The rule containing nested rules
125+
# @param segment_key [String] The segment key
126+
# @param context [Hash] The evaluation context
127+
# @return [Boolean] True if all sub-rules match
128+
def evaluate_sub_rules_from_context(rule, segment_key, context)
129+
return true if rule[:rules].nil? || rule[:rules].empty?
130+
131+
rule[:rules].all? do |sub_rule|
132+
traits_match_segment_rule_from_context(sub_rule, segment_key, context)
133+
end
134+
end
135+
136+
# Evaluates a single segment condition using context
137+
#
138+
# @param condition [Hash] The condition to evaluate
139+
# @param segment_key [String] The segment key (used for percentage split hashing)
140+
# @param context [Hash] The evaluation context
141+
# @return [Boolean] True if the condition matches
142+
def traits_match_segment_condition_from_context(condition, segment_key, context)
143+
if condition[:operator] == PERCENTAGE_SPLIT
144+
context_value_key = get_context_value(condition[:property], context) || get_identity_key_from_context(context)
145+
hashed_percentage = hashed_percentage_for_object_ids([segment_key, context_value_key])
146+
return hashed_percentage <= condition[:value].to_f
147+
end
148+
149+
return false if condition[:property].nil?
150+
151+
trait_value = get_trait_value(condition[:property], context)
152+
153+
return trait_value != nil if condition[:operator] == IS_SET
154+
return trait_value.nil? if condition[:operator] == IS_NOT_SET
155+
156+
if !trait_value.nil?
157+
# Reuse existing Condition class logic
158+
condition_obj = Flagsmith::Engine::Segments::Condition.new(
159+
operator: condition[:operator],
160+
value: condition[:value],
161+
property: condition[:property]
162+
)
163+
return condition_obj.match_trait_value?(trait_value)
164+
end
165+
166+
false
167+
end
168+
169+
# Evaluate rule conditions based on type (ALL/ANY/NONE)
170+
#
171+
# @param rule_type [String] The rule type
172+
# @param condition_results [Array<Boolean>] Array of condition evaluation results
173+
# @return [Boolean] True if conditions match according to rule type
174+
def evaluate_rule_conditions(rule_type, condition_results)
175+
case rule_type
176+
when 'ALL'
177+
condition_results.empty? || condition_results.all?
178+
when 'ANY'
179+
!condition_results.empty? && condition_results.any?
180+
when 'NONE'
181+
condition_results.empty? || condition_results.none?
182+
else
183+
false
184+
end
185+
end
186+
187+
# Get trait value from context, supporting JSONPath expressions
188+
#
189+
# @param property [String] The property name or JSONPath
190+
# @param context [Hash] The evaluation context
191+
# @return [Object, nil] The trait value or nil
192+
def get_trait_value(property, context)
193+
# Check if it's a JSONPath expression
194+
if property.start_with?('$.')
195+
context_value = get_context_value(property, context)
196+
return context_value unless non_primitive?(context_value)
197+
end
198+
199+
# Otherwise look in traits
200+
traits = context.dig(:identity, :traits) || {}
201+
traits[property]
202+
end
203+
204+
# Get value from context using JSONPath-like syntax
205+
#
206+
# @param json_path [String] JSONPath expression (e.g., '$.identity.identifier')
207+
# @param context [Hash] The evaluation context
208+
# @return [Object, nil] The value at the path or nil
209+
def get_context_value(json_path, context)
210+
return nil unless context && json_path&.start_with?('$.')
211+
212+
# Simple JSONPath implementation - handle basic cases
213+
path_parts = json_path.sub('$.', '').split('.')
214+
current = context
215+
216+
path_parts.each do |part|
217+
return nil unless current.is_a?(Hash)
218+
219+
current = current[part.to_sym]
220+
end
221+
222+
current
223+
rescue StandardError
224+
nil
225+
end
226+
227+
# Get identity key from context
228+
#
229+
# @param context [Hash] The evaluation context
230+
# @return [String, nil] The identity key or generated composite key
231+
def get_identity_key_from_context(context)
232+
return nil unless context[:identity]
233+
234+
context[:identity][:key] ||
235+
"#{context[:environment][:key]}_#{context[:identity][:identifier]}"
236+
end
237+
238+
# Check if value is non-primitive (object or array)
239+
#
240+
# @param value [Object] The value to check
241+
# @return [Boolean] True if value is an object or array
242+
def non_primitive?(value)
243+
return false if value.nil?
244+
245+
value.is_a?(Hash) || value.is_a?(Array)
246+
end
247+
73248
private
74249

75250
def handle_trait_existence_conditions(matching_trait, operator)

spec/engine/e2e/engine_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ def load_test_file(filepath)
3535
test_expected_result = test_case[:result]
3636

3737
# TODO: Implement evaluation logic
38-
evaluation_result = {}
38+
evaluation_result = Flagsmith::Engine::Evaluation::Core.get_evaluation_result(test_evaluation_context)
3939

4040

4141
# TODO: Uncomment when evaluation is implemented
42-
# expect(evaluation_result).to eq(test_expected_result)
42+
expect(evaluation_result[:flags]).to eq(test_expected_result[:flags])
4343
end
4444
end
4545
end

0 commit comments

Comments
 (0)