Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ PATH
flagsmith (4.3.0)
faraday (>= 2.0.1)
faraday-retry
jsonpath (~> 1.1)
semantic

GEM
Expand All @@ -21,8 +22,11 @@ GEM
faraday (~> 2.0)
gem-release (2.2.2)
json (2.7.1)
jsonpath (1.1.5)
multi_json
language_server-protocol (3.17.0.3)
method_source (1.0.0)
multi_json (1.17.0)
net-http (0.4.1)
uri
parallel (1.24.0)
Expand Down
1 change: 1 addition & 0 deletions flagsmith.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Gem::Specification.new do |spec|

spec.add_dependency 'faraday', '>= 2.0.1'
spec.add_dependency 'faraday-retry'
spec.add_dependency 'jsonpath', '~> 1.1'
spec.add_dependency 'semantic'
spec.metadata['rubygems_mfa_required'] = 'true'
end
49 changes: 42 additions & 7 deletions lib/flagsmith.rb
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,40 @@ def get_identity_segments(identifier, traits = {})
end

identity_model = get_identity_model(identifier, traits)
segment_models = engine.get_identity_segments(environment, identity_model)
segment_models.map { |sm| Flagsmith::Segments::Segment.new(id: sm.id, name: sm.name) }.compact

context = Flagsmith::Engine::Evaluation::Mappers.get_evaluation_context(
environment, identity_model
)

unless context
raise Flagsmith::ClientError,
'Local evaluation required to obtain identity segments'
end

evaluation_result = Flagsmith::Engine::Evaluation::Core.get_evaluation_result(context)

evaluation_result[:segments].map do |segment_result|
flagsmith_id = segment_result.dig(:metadata, :flagsmith_id)
next unless flagsmith_id

Flagsmith::Segments::Segment.new(id: flagsmith_id, name: segment_result[:name])
end.compact
end

private

def environment_flags_from_document
Flagsmith::Flags::Collection.from_feature_state_models(
engine.get_environment_feature_states(environment),
context = Flagsmith::Engine::Evaluation::Mappers.get_evaluation_context(environment)

unless context
raise Flagsmith::ClientError,
'Unable to get flags. No environment present.'
end

evaluation_result = Flagsmith::Engine::Evaluation::Core.get_evaluation_result(context)

Flagsmith::Flags::Collection.from_evaluation_result(
evaluation_result,
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler,
offline_handler: offline_handler
Expand All @@ -235,9 +260,19 @@ def environment_flags_from_document
def get_identity_flags_from_document(identifier, traits = {})
identity_model = get_identity_model(identifier, traits)

Flagsmith::Flags::Collection.from_feature_state_models(
engine.get_identity_feature_states(environment, identity_model),
identity_id: identity_model.composite_key,
context = Flagsmith::Engine::Evaluation::Mappers.get_evaluation_context(
environment, identity_model
)

unless context
raise Flagsmith::ClientError,
'Unable to get flags. No environment present.'
end

evaluation_result = Flagsmith::Engine::Evaluation::Core.get_evaluation_result(context)

Flagsmith::Flags::Collection.from_evaluation_result(
evaluation_result,
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler,
offline_handler: offline_handler
Expand Down
69 changes: 3 additions & 66 deletions lib/flagsmith/engine/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,74 +17,11 @@
module Flagsmith
module Engine
# Flags engine methods
# NOTE: This class is kept for backwards compatibility but no longer contains
# the old model-based evaluation methods. Use the context-based evaluation
# via Flagsmith::Engine::Evaluation::Core.get_evaluation_result instead.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're major-versioning anyway, can we either remove the class altogether, add Flagsmith::Engine::get_evaluation_result interface? I don't see how an empty Engine class helps to maintain backwards compatibility here?

class Engine
include Flagsmith::Engine::Segments::Evaluator

def get_identity_feature_state(environment, identity, feature_name, override_traits = nil)
feature_states = get_identity_feature_states_dict(environment, identity, override_traits).values

feature_state = feature_states.find { |f| f.feature.name == feature_name }

raise Flagsmith::FeatureStateNotFound, 'Feature State Not Found' if feature_state.nil?

feature_state
end

def get_identity_feature_states(environment, identity, override_traits = nil)
feature_states = get_identity_feature_states_dict(environment, identity, override_traits).values

return feature_states.select(&:enabled?) if environment.project.hide_disabled_flags

feature_states
end

def get_environment_feature_state(environment, feature_name)
features_state = environment.feature_states.find { |f| f.feature.name == feature_name }

raise Flagsmith::FeatureStateNotFound, 'Feature State Not Found' if features_state.nil?

features_state
end

def get_environment_feature_states(environment)
return environment.feature_states.select(&:enabled?) if environment.project.hide_disabled_flags

environment.feature_states
end

private

def get_identity_feature_states_dict(environment, identity, override_traits = nil)
# Get feature states from the environment
feature_states = {}
override = ->(fs) { feature_states[fs.feature.id] = fs }
environment.feature_states.each(&override)

override_by_matching_segments(environment, identity, override_traits) do |fs|
override.call(fs) unless higher_segment_priority?(feature_states, fs)
end

# Override with any feature states defined directly the identity
identity.identity_features.each(&override)
feature_states
end

# Override with any feature states defined by matching segments
def override_by_matching_segments(environment, identity, override_traits)
identity_segments = get_identity_segments(environment, identity, override_traits)
identity_segments.each do |matching_segment|
matching_segment.feature_states.each do |feature_state|
yield feature_state if block_given?
end
end
end

def higher_segment_priority?(collection, feature_state)
collection.key?(feature_state.feature.id) &&
collection[feature_state.feature.id].higher_segment_priority?(
feature_state
)
end
end
end
end
6 changes: 3 additions & 3 deletions lib/flagsmith/engine/evaluation/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def get_evaluation_result(evaluation_context)
def evaluate_segments(evaluation_context)
return [], {} if evaluation_context[:identity].nil? || evaluation_context[:segments].nil?

identity_segments = get_identity_segments_from_context(evaluation_context)
identity_segments = get_identity_segments(evaluation_context)

segments = identity_segments.map do |segment|
result = {
Expand Down Expand Up @@ -116,7 +116,7 @@ def evaluate_feature_value(feature, identity_key = nil)
# Returns {value: any; reason?: string}
def get_multivariate_feature_value(feature, identity_key)
percentage_value = hashed_percentage_for_object_ids([feature[:key], identity_key])
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || Float::INFINITY }
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || WEAKEST_PRIORITY }

start_percentage = 0
sorted_variants.each do |variant|
Expand Down Expand Up @@ -154,7 +154,7 @@ def get_identity_key(evaluation_context)

# returns boolean
def higher_priority?(priority_a, priority_b)
(priority_a || Float::INFINITY) < (priority_b || Float::INFINITY)
(priority_a || WEAKEST_PRIORITY) < (priority_b || WEAKEST_PRIORITY)
end

def get_targeting_match_reason(match_object)
Expand Down
2 changes: 1 addition & 1 deletion lib/flagsmith/engine/features/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def multivariate_value(identity_id)
# but `self` does.
# 2. `other` have a feature segment with high priority
def higher_segment_priority?(other)
feature_segment.priority.to_i < (other&.feature_segment&.priority || Float::INFINITY)
feature_segment.priority.to_i < (other&.feature_segment&.priority || WEAKEST_PRIORITY)
rescue TypeError, NoMethodError
false
end
Expand Down
92 changes: 7 additions & 85 deletions lib/flagsmith/engine/segments/evaluator.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'json'
require 'jsonpath'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought JSONPath part was moved to #88? Should this PR get rebased?

require_relative 'constants'
require_relative 'models'
require_relative '../utils/hash_func'
Expand All @@ -18,7 +20,7 @@ module Evaluator
#
# @param context [Hash] Evaluation context containing identity and segment definitions
# @return [Array<Hash>] Array of segments that the identity matches
def get_identity_segments_from_context(context)
def get_identity_segments(context)
return [] unless context[:identity] && context[:segments]

context[:segments].values.select do |segment|
Expand All @@ -29,67 +31,7 @@ def get_identity_segments_from_context(context)
end
end

# Model-based segment evaluation (existing approach)
def get_identity_segments(environment, identity, override_traits = nil)
environment.project.segments.select do |s|
evaluate_identity_in_segment(identity, s, override_traits)
end
end

# Evaluates whether a given identity is in the provided segment.
#
# :param identity: identity model object to evaluate
# :param segment: segment model object to evaluate
# :param override_traits: pass in a list of traits to use instead of those on the
# identity model itself
# :return: True if the identity is in the segment, False otherwise
def evaluate_identity_in_segment(identity, segment, override_traits = nil)
segment.rules&.length&.positive? &&
segment.rules.all? do |rule|
traits_match_segment_rule(
override_traits || identity.identity_traits,
rule,
segment.id,
identity.django_id || identity.composite_key
)
end
end

# rubocop:disable Metrics/MethodLength
def traits_match_segment_rule(identity_traits, rule, segment_id, identity_id)
matching_block = lambda { |condition|
traits_match_segment_condition(identity_traits, condition, segment_id, identity_id)
}

matches_conditions =
if rule.conditions&.length&.positive?
rule.conditions.send(rule.matching_function, &matching_block)
else
true
end

matches_conditions &&
rule.rules.all? { |r| traits_match_segment_rule(identity_traits, r, segment_id, identity_id) }
end
# rubocop:enable Metrics/MethodLength

def traits_match_segment_condition(identity_traits, condition, segment_id, identity_id)
if condition.operator == PERCENTAGE_SPLIT
return hashed_percentage_for_object_ids([segment_id,
identity_id]) <= condition.value.to_f
end

trait = identity_traits.find { |t| t.key.to_s == condition.property }

return handle_trait_existence_conditions(trait, condition.operator) if [IS_SET,
IS_NOT_SET].include?(condition.operator)

return condition.match_trait_value?(trait.trait_value) if trait

false
end

# Context-based helper functions (new approach)
# Context-based helper functions

# Evaluates whether a segment rule matches using context
#
Expand Down Expand Up @@ -148,9 +90,7 @@ def traits_match_segment_condition_from_context(condition, segment_key, context)
end

return false if condition[:property].nil?

trait_value = get_trait_value(condition[:property], context)

return !trait_value.nil? if condition[:operator] == IS_SET
return trait_value.nil? if condition[:operator] == IS_NOT_SET

Expand Down Expand Up @@ -200,25 +140,15 @@ def get_trait_value(property, context)
traits[property] || traits[property.to_sym]
end

# Get value from context using JSONPath-like syntax
# Get value from context using JSONPath syntax
#
# @param json_path [String] JSONPath expression (e.g., '$.identity.identifier')
# @param context [Hash] The evaluation context
# @return [Object, nil] The value at the path or nil
def get_context_value(json_path, context)
return nil unless context && json_path&.start_with?('$.')

# Simple JSONPath implementation - handle basic cases
path_parts = json_path.sub('$.', '').split('.')
current = context

path_parts.each do |part|
return nil unless current.is_a?(Hash)

current = current[part.to_sym]
end

current
results = JsonPath.new(json_path, use_symbols: true).on(context)
results.first
rescue StandardError
nil
end
Expand All @@ -243,14 +173,6 @@ def non_primitive?(value)

value.is_a?(Hash) || value.is_a?(Array)
end

private

def handle_trait_existence_conditions(matching_trait, operator)
return operator == IS_NOT_SET if matching_trait.nil?

operator == IS_SET
end
end
end
end
Expand Down
7 changes: 6 additions & 1 deletion lib/flagsmith/engine/segments/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ def match_modulo_value(trait_value)
def match_in_value(trait_value)
return false if trait_value.nil? || trait_value.is_a?(TrueClass) || trait_value.is_a?(FalseClass)

return @value.include?(trait_value.to_s) if @value.is_a?(Array)
# Floats/doubles are not supported by the engine due to ambiguous serialization across supported platforms. (segments/models_spec.rb)
return false unless trait_value.is_a?(String) || trait_value.is_a?(Integer)

if @value.is_a?(Array)
return @value.include?(trait_value.to_s)
end
Comment on lines +106 to +111
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to Flagsmith/engine-test-data#38, we only skip booleans now:

Suggested change
# Floats/doubles are not supported by the engine due to ambiguous serialization across supported platforms. (segments/models_spec.rb)
return false unless trait_value.is_a?(String) || trait_value.is_a?(Integer)
if @value.is_a?(Array)
return @value.include?(trait_value.to_s)
end
(segments/models_spec.rb)
return false unless ![true, false].include? trait_value
if @value.is_a?(Array)
return @value.include?(trait_value.to_s)
end


if @value.is_a?(String)
begin
Expand Down
Loading