From 2aeb65a5db7252e396c7bbcdcb78eb7319497e6e Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Oct 2025 16:51:37 +0200 Subject: [PATCH 01/11] feat: added-engine-function-signatures --- dev_test.rb | 31 ------------- lib/flagsmith/engine/evaluation/core.rb | 3 +- lib/flagsmith/engine/evaluation/engine.rb | 54 ++++++++++++++++++++++ lib/flagsmith/engine/evaluation/mappers.rb | 50 ++++++++++---------- 4 files changed, 80 insertions(+), 58 deletions(-) delete mode 100644 dev_test.rb create mode 100644 lib/flagsmith/engine/evaluation/engine.rb diff --git a/dev_test.rb b/dev_test.rb deleted file mode 100644 index 4358309..0000000 --- a/dev_test.rb +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require 'bundler/setup' -require_relative 'lib/flagsmith' - -flagsmith = Flagsmith::Client.new( - environment_key: '' -) - -begin - flags = flagsmith.get_environment_flags - - beta_users_flag = flags['beta_users'] - - if beta_users_flag - puts "Flag found!" - else - puts "error getting flag environment" - end - - puts "-" * 50 - puts "All flags" - flags.all_flags.each do |flag| - puts " - #{flag.feature_name}: enabled=#{flag.enabled?}, value=#{flag.value.inspect}" - end - -rescue StandardError => e - puts "Error: #{e.message}" - puts e.backtrace.join("\n") -end diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index 4d8fd0a..bbb9154 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -3,12 +3,13 @@ module Flagsmith module Engine module Evaluation + # Core evaluation logic module module Core # Get evaluation result from evaluation context # # @param evaluation_context [Hash] The evaluation context # @return [Hash] Evaluation result with flags and segments - def self.get_evaluation_result(evaluation_context) + def self.get_evaluation_result(_evaluation_context) # TODO: Implement core evaluation logic { flags: {}, diff --git a/lib/flagsmith/engine/evaluation/engine.rb b/lib/flagsmith/engine/evaluation/engine.rb new file mode 100644 index 0000000..62a1f9a --- /dev/null +++ b/lib/flagsmith/engine/evaluation/engine.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module Evaluation + class Engine + # returns EvaluationResultWithMetadata + def get_evaluation_result(_evaluation_context) + { + flags: {}, + segments: [] + } + end + + # Returns { segments: EvaluationResultSegments; segmentOverrides: Record; } + def evaluate_segments(evaluation_context); end + + # Returns Record + def process_segment_overrides(identity_segments); end + + # returns EvaluationResultFlags + def evalute_features(evaluation_context, segment_overrides); end + + # Returns {value: any; reason?: string} + def evaluate_feature_value(feature, identity_key); end + + # Returns {value: any; reason?: string} + def get_multivariate_feature_value(feature, identity_key); end + + # returns boolean + def should_apply_override(override, existing_overrides); end + + private + + # returns boolean + def higher_priority?(priority_a, priority_b) + priority_a || priority_b > Float::INFINITY || Float::INFINITY + end + + def get_targeting_match_reason(match_object) + type = match_object.type + + if type == 'SEGMENT' + return match_object.override ? "TARGETING_MATCH; segment=#{match_object.override.segment_name}" : 'DEFAULT' + end + + return "SPLIT; weight=#{match_object.weight}" if type == 'SPLIT' + + 'DEFAULT' + end + end + end + end +end diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index 396fffe..3a87516 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -14,13 +14,13 @@ module Mappers def self.get_evaluation_context(environment, identity = nil, override_traits = nil) environment_context = map_environment_model_to_evaluation_context(environment) identity_context = identity ? map_identity_model_to_identity_context(identity, override_traits) : nil - + context = environment_context.dup context[:identity] = identity_context if identity_context - + context end - + # Maps environment model to evaluation context # # @param environment [Flagsmith::Engine::Environment] The environment model @@ -105,7 +105,7 @@ def self.map_environment_model_to_evaluation_context(environment) def self.uuid_to_big_int(uuid) uuid.gsub('-', '').to_i(16) end - + # Maps identity model to identity context # # @param identity [Flagsmith::Engine::Identity] The identity model @@ -127,7 +127,7 @@ def self.map_identity_model_to_identity_context(identity, override_traits = nil) traits: traits_hash } end - + # Maps segment rule model to rule hash # # @param rule [Flagsmith::Engine::Segments::Rule] The segment rule model @@ -138,38 +138,37 @@ def self.map_segment_rule_model_to_rule(rule) } # Map conditions if present - if rule.conditions&.any? - result[:conditions] = rule.conditions.map do |condition| - { - property: condition.property, - operator: condition.operator, - value: condition.value - } - end - else - result[:conditions] = [] - end - - if rule.rules&.any? - result[:rules] = rule.rules.map { |nested_rule| map_segment_rule_model_to_rule(nested_rule) } - else - result[:rules] = [] - end + result[:conditions] = if rule.conditions&.any? + rule.conditions.map do |condition| + { + property: condition.property, + operator: condition.operator, + value: condition.value + } + end + else + [] + end + + result[:rules] = if rule.rules&.any? + rule.rules.map { |nested_rule| map_segment_rule_model_to_rule(nested_rule) } + else + [] + end result end - + # Maps identity overrides to segments # # @param identity_overrides [Array] Array of identity override models # @return [Hash] Segments hash for identity overrides def self.map_identity_overrides_to_segments(identity_overrides) - segments = {} features_to_identifiers = {} identity_overrides.each do |identity| - next if identity.identity_features.nil? || !identity.identity_features.any? + next if identity.identity_features.nil? || identity.identity_features.none? # Sort features by name for consistent hashing sorted_features = identity.identity_features.to_a.sort_by { |fs| fs.feature.name } @@ -228,4 +227,3 @@ def self.map_identity_overrides_to_segments(identity_overrides) end end end - \ No newline at end of file From ba057cbb85432823fc49ec83da2887a86fc830cc Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Oct 2025 17:30:50 +0200 Subject: [PATCH 02/11] feat: moved-engine-to-core --- lib/flagsmith/engine/evaluation/core.rb | 65 +++++++++++++++++++++-- lib/flagsmith/engine/evaluation/engine.rb | 54 ------------------- 2 files changed, 61 insertions(+), 58 deletions(-) delete mode 100644 lib/flagsmith/engine/evaluation/engine.rb diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index bbb9154..c59881e 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -9,13 +9,70 @@ module Core # # @param evaluation_context [Hash] The evaluation context # @return [Hash] Evaluation result with flags and segments - def self.get_evaluation_result(_evaluation_context) - # TODO: Implement core evaluation logic + # returns EvaluationResultWithMetadata + def get_evaluation_result(evaluation_context) + segments, segment_overrides = evaluate_segments(evaluation_context) + flags = evaluate_features(evaluation_context, segment_overrides) { - flags: {}, - segments: [] + flags: flags, + segments: segments, } end + + # Returns { segments: EvaluationResultSegments; segmentOverrides: Record; } + def evaluate_segments(evaluation_context) + if evaluation_context.identities.nil? || evaluation_context.segments.nil? + return [], {} + end + segments = [] + segment_overrides = process_segment_overrides(evaluation_context.identities) + return segments, segment_overrides + end + + # Returns Record + def process_segment_overrides(_identity_segments) + segment_overrides = {} + return segment_overrides + end + + # returns EvaluationResultFlags + def evaluate_features(evaluation_context, _segment_overrides) + raise NotImplementedError + end + + # Returns {value: any; reason?: string} + def evaluate_feature_value(_feature, _identity_key) + raise NotImplementedError + end + + # Returns {value: any; reason?: string} + def get_multivariate_feature_value(_feature, _identity_key) + raise NotImplementedError + end + + # returns boolean + def should_apply_override(_override, _existing_overrides) + raise NotImplementedError + end + + private + + # returns boolean + def higher_priority?(priority_a, priority_b) + (priority_a || Float::INFINITY) < (priority_b || Float::INFINITY) + end + + def get_targeting_match_reason(match_object) + type = match_object.type + + if type == 'SEGMENT' + return match_object.override ? "TARGETING_MATCH; segment=#{match_object.override.segment_name}" : 'DEFAULT' + end + + return "SPLIT; weight=#{match_object.weight}" if type == 'SPLIT' + + 'DEFAULT' + end end end end diff --git a/lib/flagsmith/engine/evaluation/engine.rb b/lib/flagsmith/engine/evaluation/engine.rb deleted file mode 100644 index 62a1f9a..0000000 --- a/lib/flagsmith/engine/evaluation/engine.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -module Flagsmith - module Engine - module Evaluation - class Engine - # returns EvaluationResultWithMetadata - def get_evaluation_result(_evaluation_context) - { - flags: {}, - segments: [] - } - end - - # Returns { segments: EvaluationResultSegments; segmentOverrides: Record; } - def evaluate_segments(evaluation_context); end - - # Returns Record - def process_segment_overrides(identity_segments); end - - # returns EvaluationResultFlags - def evalute_features(evaluation_context, segment_overrides); end - - # Returns {value: any; reason?: string} - def evaluate_feature_value(feature, identity_key); end - - # Returns {value: any; reason?: string} - def get_multivariate_feature_value(feature, identity_key); end - - # returns boolean - def should_apply_override(override, existing_overrides); end - - private - - # returns boolean - def higher_priority?(priority_a, priority_b) - priority_a || priority_b > Float::INFINITY || Float::INFINITY - end - - def get_targeting_match_reason(match_object) - type = match_object.type - - if type == 'SEGMENT' - return match_object.override ? "TARGETING_MATCH; segment=#{match_object.override.segment_name}" : 'DEFAULT' - end - - return "SPLIT; weight=#{match_object.weight}" if type == 'SPLIT' - - 'DEFAULT' - end - end - end - end -end From bb96eb173bab343744825dfc817684ed9acb5fa9 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Oct 2025 17:45:58 +0200 Subject: [PATCH 03/11] feat: implemented-process-segment-overrides --- lib/flagsmith/engine/evaluation/core.rb | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index c59881e..af6d6e8 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -30,9 +30,25 @@ def evaluate_segments(evaluation_context) end # Returns Record - def process_segment_overrides(_identity_segments) + def process_segment_overrides(identity_segments) segment_overrides = {} - return segment_overrides + + identity_segments.each do |segment| + next unless segment[:overrides] + + overrides_list = segment[:overrides].is_a?(Array) ? segment[:overrides] : [] + + overrides_list.each do |override| + if should_apply_override(override, segment_overrides) + segment_overrides[override[:feature_key]] = { + feature: override, + segment_name: segment[:name] + } + end + end + end + + segment_overrides end # returns EvaluationResultFlags From a2fd5a894b5abd10c5ba19ed595f9cb481c6f22c Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Oct 2025 17:49:13 +0200 Subject: [PATCH 04/11] feat: implemented-evalute-segments-partially --- lib/flagsmith/engine/evaluation/core.rb | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index af6d6e8..94889f1 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -21,11 +21,27 @@ def get_evaluation_result(evaluation_context) # Returns { segments: EvaluationResultSegments; segmentOverrides: Record; } def evaluate_segments(evaluation_context) - if evaluation_context.identities.nil? || evaluation_context.segments.nil? + if evaluation_context[:identity].nil? || evaluation_context[:segments].nil? return [], {} end - segments = [] - segment_overrides = process_segment_overrides(evaluation_context.identities) + + identity_segments = [] # To be getIdentitySegments when implemented + + segments = identity_segments.map do |segment| + result = { + key: segment[:key], + name: segment[:name] + } + + if segment[:metadata] + result[:metadata] = segment[:metadata].dup + end + + result + end + + segment_overrides = process_segment_overrides(identity_segments) + return segments, segment_overrides end From 0f08fb1c88733028a695e7e32f5f4ddb92373946 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Oct 2025 17:50:13 +0200 Subject: [PATCH 05/11] feat: implemented-should-apply-override --- lib/flagsmith/engine/evaluation/core.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index 94889f1..ff30a3a 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -56,7 +56,7 @@ def process_segment_overrides(identity_segments) overrides_list.each do |override| if should_apply_override(override, segment_overrides) - segment_overrides[override[:feature_key]] = { + segment_overrides[override[:name]] = { feature: override, segment_name: segment[:name] } @@ -83,8 +83,9 @@ def get_multivariate_feature_value(_feature, _identity_key) end # returns boolean - def should_apply_override(_override, _existing_overrides) - raise NotImplementedError + def should_apply_override(override, existing_overrides) + current_override = existing_overrides[override[:name]] + !current_override || higher_priority?(override[:priority], current_override[:feature][:priority]) end private From da08e8ec5658e8d11eeb7b88997c3c008709565d Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 27 Oct 2025 11:35:43 +0100 Subject: [PATCH 06/11] feat: implemented-get-identity-segments --- .gitmodules | 2 +- lib/flagsmith/engine/core.rb | 1 + lib/flagsmith/engine/evaluation/core.rb | 88 +++++++++-- lib/flagsmith/engine/features/constants.rb | 14 ++ lib/flagsmith/engine/segments/evaluator.rb | 175 +++++++++++++++++++++ spec/engine/e2e/engine_spec.rb | 4 +- 6 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 lib/flagsmith/engine/features/constants.rb diff --git a/.gitmodules b/.gitmodules index da6957d..1fca970 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "spec/engine-test-data"] path = spec/engine-test-data url = git@github.com:Flagsmith/engine-test-data.git - branch = main + branch = v3.1.0 diff --git a/lib/flagsmith/engine/core.rb b/lib/flagsmith/engine/core.rb index 530a3f4..cb872b0 100644 --- a/lib/flagsmith/engine/core.rb +++ b/lib/flagsmith/engine/core.rb @@ -12,6 +12,7 @@ require_relative 'segments/models' require_relative 'utils/hash_func' require_relative 'evaluation/mappers' +require_relative 'evaluation/core' module Flagsmith module Engine diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index ff30a3a..1f8a898 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -1,10 +1,18 @@ # frozen_string_literal: true +require_relative '../utils/hash_func' +require_relative '../features/constants' +require_relative '../segments/evaluator' + module Flagsmith module Engine module Evaluation # Core evaluation logic module module Core + extend self + include Flagsmith::Engine::Utils::HashFunc + include Flagsmith::Engine::Features::TargetingReasons + include Flagsmith::Engine::Segments::Evaluator # Get evaluation result from evaluation context # # @param evaluation_context [Hash] The evaluation context @@ -25,7 +33,7 @@ def evaluate_segments(evaluation_context) return [], {} end - identity_segments = [] # To be getIdentitySegments when implemented + identity_segments = get_identity_segments_from_context(evaluation_context) segments = identity_segments.map do |segment| result = { @@ -68,18 +76,68 @@ def process_segment_overrides(identity_segments) end # returns EvaluationResultFlags - def evaluate_features(evaluation_context, _segment_overrides) - raise NotImplementedError + def evaluate_features(evaluation_context, segment_overrides) + flags = {} + + (evaluation_context[:features] || {}).each_value do |feature| + segment_override = segment_overrides[feature[:name]] + final_feature = segment_override ? segment_override[:feature] : feature + has_override = !segment_override.nil? + + # Evaluate feature value + evaluated = if has_override + { value: final_feature[:value], reason: nil } + else + evaluate_feature_value(final_feature, get_identity_key(evaluation_context)) + end + + # Build flag result + flag_result = { + name: final_feature[:name], + enabled: final_feature[:enabled], + value: evaluated[:value] + } + + # Add metadata if present + flag_result[:metadata] = final_feature[:metadata] if final_feature[:metadata] + + # Set reason + flag_result[:reason] = evaluated[:reason] || + get_targeting_match_reason({ type: 'SEGMENT', override: segment_override }) + + flags[final_feature[:name]] = flag_result + end + + flags end # Returns {value: any; reason?: string} - def evaluate_feature_value(_feature, _identity_key) - raise NotImplementedError + def evaluate_feature_value(feature, identity_key = nil) + if feature[:variants]&.any? && identity_key + return get_multivariate_feature_value(feature, identity_key) + end + + { value: feature[:value], reason: nil } end # Returns {value: any; reason?: string} - def get_multivariate_feature_value(_feature, _identity_key) - raise NotImplementedError + 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 } + + start_percentage = 0 + sorted_variants.each do |variant| + limit = start_percentage + variant[:weight] + if start_percentage <= percentage_value && percentage_value < limit + return { + value: variant[:value], + reason: get_targeting_match_reason({ type: 'SPLIT', weight: variant[:weight] }) + } + end + start_percentage = limit + end + + { value: feature[:value], reason: nil } end # returns boolean @@ -90,21 +148,29 @@ def should_apply_override(override, existing_overrides) private + # Extract identity key from evaluation context + # + # @param evaluation_context [Hash] The evaluation context + # @return [String, nil] The identity key or nil if no identity + def get_identity_key(evaluation_context) + evaluation_context.dig(:identity, :key) + end + # returns boolean def higher_priority?(priority_a, priority_b) (priority_a || Float::INFINITY) < (priority_b || Float::INFINITY) end def get_targeting_match_reason(match_object) - type = match_object.type + type = match_object[:type] if type == 'SEGMENT' - return match_object.override ? "TARGETING_MATCH; segment=#{match_object.override.segment_name}" : 'DEFAULT' + return match_object[:override] ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{match_object[:override][:segment_name]}" : TARGETING_REASON_DEFAULT end - return "SPLIT; weight=#{match_object.weight}" if type == 'SPLIT' + return "#{TARGETING_REASON_SPLIT}; weight=#{match_object[:weight]}" if type == 'SPLIT' - 'DEFAULT' + TARGETING_REASON_DEFAULT end end end diff --git a/lib/flagsmith/engine/features/constants.rb b/lib/flagsmith/engine/features/constants.rb new file mode 100644 index 0000000..b3baa08 --- /dev/null +++ b/lib/flagsmith/engine/features/constants.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module Features + # Targeting reason constants for evaluation results + module TargetingReasons + TARGETING_REASON_DEFAULT = 'DEFAULT' + TARGETING_REASON_TARGETING_MATCH = 'TARGETING_MATCH' + TARGETING_REASON_SPLIT = 'SPLIT' + end + end + end +end diff --git a/lib/flagsmith/engine/segments/evaluator.rb b/lib/flagsmith/engine/segments/evaluator.rb index cc4a2f0..019da54 100644 --- a/lib/flagsmith/engine/segments/evaluator.rb +++ b/lib/flagsmith/engine/segments/evaluator.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative 'constants' +require_relative 'models' require_relative '../utils/hash_func' module Flagsmith @@ -8,9 +9,26 @@ module Engine module Segments # Evaluator methods module Evaluator + extend self include Flagsmith::Engine::Segments::Constants include Flagsmith::Engine::Utils::HashFunc + # Context-based segment evaluation (new approach) + # Returns all segments that the identity belongs to based on segment rules evaluation + # + # @param context [Hash] Evaluation context containing identity and segment definitions + # @return [Array] Array of segments that the identity matches + def get_identity_segments_from_context(context) + return [] unless context[:identity] && context[:segments] + + context[:segments].values.select do |segment| + next false if segment[:rules].nil? || segment[:rules].empty? + + segment[:rules].all? { |rule| traits_match_segment_rule_from_context(rule, segment[:key], 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) @@ -70,6 +88,163 @@ def traits_match_segment_condition(identity_traits, condition, segment_id, ident false end + # Context-based helper functions (new approach) + + # Evaluates whether a segment rule matches using context + # + # @param rule [Hash] The rule to evaluate + # @param segment_key [String] The segment key (used for percentage split) + # @param context [Hash] The evaluation context + # @return [Boolean] True if the rule matches + def traits_match_segment_rule_from_context(rule, segment_key, context) + matches_conditions = evaluate_conditions_from_context(rule, segment_key, context) + matches_sub_rules = evaluate_sub_rules_from_context(rule, segment_key, context) + + matches_conditions && matches_sub_rules + end + + # Evaluates rule conditions based on rule type (ALL/ANY/NONE) + # + # @param rule [Hash] The rule containing conditions and type + # @param segment_key [String] The segment key + # @param context [Hash] The evaluation context + # @return [Boolean] True if conditions match according to rule type + def evaluate_conditions_from_context(rule, segment_key, context) + return true if rule[:conditions].nil? || rule[:conditions].empty? + + condition_results = rule[:conditions].map do |condition| + traits_match_segment_condition_from_context(condition, segment_key, context) + end + + evaluate_rule_conditions(rule[:type], condition_results) + end + + # Evaluates nested sub-rules + # + # @param rule [Hash] The rule containing nested rules + # @param segment_key [String] The segment key + # @param context [Hash] The evaluation context + # @return [Boolean] True if all sub-rules match + def evaluate_sub_rules_from_context(rule, segment_key, context) + return true if rule[:rules].nil? || rule[:rules].empty? + + rule[:rules].all? do |sub_rule| + traits_match_segment_rule_from_context(sub_rule, segment_key, context) + end + end + + # Evaluates a single segment condition using context + # + # @param condition [Hash] The condition to evaluate + # @param segment_key [String] The segment key (used for percentage split hashing) + # @param context [Hash] The evaluation context + # @return [Boolean] True if the condition matches + def traits_match_segment_condition_from_context(condition, segment_key, context) + if condition[:operator] == PERCENTAGE_SPLIT + context_value_key = get_context_value(condition[:property], context) || get_identity_key_from_context(context) + hashed_percentage = hashed_percentage_for_object_ids([segment_key, context_value_key]) + return hashed_percentage <= condition[:value].to_f + 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 + + if !trait_value.nil? + # Reuse existing Condition class logic + condition_obj = Flagsmith::Engine::Segments::Condition.new( + operator: condition[:operator], + value: condition[:value], + property: condition[:property] + ) + return condition_obj.match_trait_value?(trait_value) + end + + false + end + + # Evaluate rule conditions based on type (ALL/ANY/NONE) + # + # @param rule_type [String] The rule type + # @param condition_results [Array] Array of condition evaluation results + # @return [Boolean] True if conditions match according to rule type + def evaluate_rule_conditions(rule_type, condition_results) + case rule_type + when 'ALL' + condition_results.empty? || condition_results.all? + when 'ANY' + !condition_results.empty? && condition_results.any? + when 'NONE' + condition_results.empty? || condition_results.none? + else + false + end + end + + # Get trait value from context, supporting JSONPath expressions + # + # @param property [String] The property name or JSONPath + # @param context [Hash] The evaluation context + # @return [Object, nil] The trait value or nil + def get_trait_value(property, context) + # Check if it's a JSONPath expression + if property.start_with?('$.') + context_value = get_context_value(property, context) + return context_value unless non_primitive?(context_value) + end + + # Otherwise look in traits + traits = context.dig(:identity, :traits) || {} + traits[property] + end + + # Get value from context using JSONPath-like 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 + rescue StandardError + nil + end + + # Get identity key from context + # + # @param context [Hash] The evaluation context + # @return [String, nil] The identity key or generated composite key + def get_identity_key_from_context(context) + return nil unless context[:identity] + + context[:identity][:key] || + "#{context[:environment][:key]}_#{context[:identity][:identifier]}" + end + + # Check if value is non-primitive (object or array) + # + # @param value [Object] The value to check + # @return [Boolean] True if value is an object or array + def non_primitive?(value) + return false if value.nil? + + value.is_a?(Hash) || value.is_a?(Array) + end + private def handle_trait_existence_conditions(matching_trait, operator) diff --git a/spec/engine/e2e/engine_spec.rb b/spec/engine/e2e/engine_spec.rb index 77c9f92..0f227a1 100644 --- a/spec/engine/e2e/engine_spec.rb +++ b/spec/engine/e2e/engine_spec.rb @@ -35,11 +35,11 @@ def load_test_file(filepath) test_expected_result = test_case[:result] # TODO: Implement evaluation logic - evaluation_result = {} + evaluation_result = Flagsmith::Engine::Evaluation::Core.get_evaluation_result(test_evaluation_context) # TODO: Uncomment when evaluation is implemented - # expect(evaluation_result).to eq(test_expected_result) + expect(evaluation_result[:flags]).to eq(test_expected_result[:flags]) end end end From af51bf794ae8ab8b40d6ecd442a1bf40389b5111 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 27 Oct 2025 15:33:17 +0100 Subject: [PATCH 07/11] feat: implemented-new-in-and-fixed-remaining-tests --- .gitmodules | 2 +- lib/flagsmith/engine/evaluation/core.rb | 10 +++-- lib/flagsmith/engine/segments/evaluator.rb | 15 +++++--- lib/flagsmith/engine/segments/models.rb | 43 ++++++++++++++++++---- spec/engine-test-data | 2 +- spec/engine/e2e/engine_spec.rb | 1 + 6 files changed, 53 insertions(+), 20 deletions(-) diff --git a/.gitmodules b/.gitmodules index 1fca970..da6957d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "spec/engine-test-data"] path = spec/engine-test-data url = git@github.com:Flagsmith/engine-test-data.git - branch = v3.1.0 + branch = main diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index 1f8a898..90154f2 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -37,7 +37,6 @@ def evaluate_segments(evaluation_context) segments = identity_segments.map do |segment| result = { - key: segment[:key], name: segment[:name] } @@ -103,9 +102,9 @@ def evaluate_features(evaluation_context, segment_overrides) # Set reason flag_result[:reason] = evaluated[:reason] || - get_targeting_match_reason({ type: 'SEGMENT', override: segment_override }) + get_targeting_match_reason({ type: 'SEGMENT', override: segment_override }) - flags[final_feature[:name]] = flag_result + flags[final_feature[:name].to_sym] = flag_result end flags @@ -153,7 +152,10 @@ def should_apply_override(override, existing_overrides) # @param evaluation_context [Hash] The evaluation context # @return [String, nil] The identity key or nil if no identity def get_identity_key(evaluation_context) - evaluation_context.dig(:identity, :key) + return nil unless evaluation_context[:identity] + + evaluation_context[:identity][:key] || + "#{evaluation_context[:environment][:key]}_#{evaluation_context[:identity][:identifier]}" end # returns boolean diff --git a/lib/flagsmith/engine/segments/evaluator.rb b/lib/flagsmith/engine/segments/evaluator.rb index 019da54..d1031f6 100644 --- a/lib/flagsmith/engine/segments/evaluator.rb +++ b/lib/flagsmith/engine/segments/evaluator.rb @@ -21,11 +21,14 @@ module Evaluator def get_identity_segments_from_context(context) return [] unless context[:identity] && context[:segments] - context[:segments].values.select do |segment| + matching_segments = context[:segments].values.select do |segment| next false if segment[:rules].nil? || segment[:rules].empty? - segment[:rules].all? { |rule| traits_match_segment_rule_from_context(rule, segment[:key], context) } + matches = segment[:rules].all? { |rule| traits_match_segment_rule_from_context(rule, segment[:key], context) } + matches end + + matching_segments end # Model-based segment evaluation (existing approach) @@ -190,15 +193,15 @@ def evaluate_rule_conditions(rule_type, condition_results) # @param context [Hash] The evaluation context # @return [Object, nil] The trait value or nil def get_trait_value(property, context) - # Check if it's a JSONPath expression if property.start_with?('$.') context_value = get_context_value(property, context) - return context_value unless non_primitive?(context_value) + if !context_value.nil? && !non_primitive?(context_value) + return context_value + end end - # Otherwise look in traits traits = context.dig(:identity, :traits) || {} - traits[property] + traits[property] || traits[property.to_sym] end # Get value from context using JSONPath-like syntax diff --git a/lib/flagsmith/engine/segments/models.rb b/lib/flagsmith/engine/segments/models.rb index f339112..a95d547 100644 --- a/lib/flagsmith/engine/segments/models.rb +++ b/lib/flagsmith/engine/segments/models.rb @@ -45,7 +45,7 @@ class Condition CONTAINS => ->(other_value, self_value) { (other_value || false) && other_value.include?(self_value) }, NOT_CONTAINS => ->(other_value, self_value) { (other_value || false) && !other_value.include?(self_value) }, - REGEX => ->(other_value, self_value) { (other_value || false) && other_value.match?(self_value) } + REGEX => ->(other_value, self_value) { (other_value || false) && other_value.to_s.match?(self_value) } }.freeze def initialize(operator:, value:, property: nil) @@ -55,11 +55,17 @@ def initialize(operator:, value:, property: nil) end def match_trait_value?(trait_value) - # handle some exceptions - trait_value = Semantic::Version.new(trait_value.gsub(/:semver$/, '')) if @value.is_a?(String) && @value.match?(/:semver$/) + if @value.is_a?(String) && @value.match?(/:semver$/) + begin + trait_value = Semantic::Version.new(trait_value.to_s.gsub(/:semver$/, '')) + rescue StandardError + return false + end + end return match_in_value(trait_value) if @operator == IN return match_modulo_value(trait_value) if @operator == MODULO + return MATCHING_FUNCTIONS[REGEX]&.call(trait_value, @value) if @operator == REGEX type_as_trait_value = format_to_type_of(trait_value) formatted_value = type_as_trait_value ? type_as_trait_value.call(@value) : @value @@ -72,10 +78,17 @@ def format_to_type_of(input) { 'String' => ->(v) { v.to_s }, 'Semantic::Version' => ->(v) { Semantic::Version.new(v.to_s.gsub(/:semver$/, '')) }, + # Double check this is the desired behavior between SDKs 'TrueClass' => ->(v) { ['True', 'true', 'TRUE', true, 1, '1'].include?(v) }, - 'FalseClass' => ->(v) { !['False', 'false', 'FALSE', false, 0, '0'].include?(v) }, - 'Integer' => ->(v) { v.to_i }, - 'Float' => ->(v) { v.to_f } + 'FalseClass' => ->(v) { !['False', 'false', 'FALSE', false].include?(v) }, + 'Integer' => ->(v) { + i = v.to_i; + i.to_s == v.to_s ? i : v + }, + 'Float' => ->(v) { + f = v.to_f; + f.to_s == v.to_s ? f : v + } }[input.class.to_s] end # rubocop:enable Metrics/AbcSize @@ -88,9 +101,23 @@ def match_modulo_value(trait_value) end def match_in_value(trait_value) - return @value.split(',').include?(trait_value.to_s) if trait_value.is_a?(String) || trait_value.is_a?(Integer) + return false if trait_value.nil? || trait_value.is_a?(TrueClass) || trait_value.is_a?(FalseClass) - false + if @value.is_a?(Array) + return @value.include?(trait_value.to_s) + end + + if @value.is_a?(String) + begin + parsed = JSON.parse(@value) + if parsed.is_a?(Array) + return parsed.include?(trait_value.to_s) + end + rescue JSON::ParserError + end + end + + @value.to_s.split(',').include?(trait_value.to_s) end class << self diff --git a/spec/engine-test-data b/spec/engine-test-data index 41c2021..6ab57ec 160000 --- a/spec/engine-test-data +++ b/spec/engine-test-data @@ -1 +1 @@ -Subproject commit 41c202145e375c712600e318c439456de5b221d7 +Subproject commit 6ab57ec67bc84659e8b5aa41534b04fe45cc4cbe diff --git a/spec/engine/e2e/engine_spec.rb b/spec/engine/e2e/engine_spec.rb index 0f227a1..0a40dbe 100644 --- a/spec/engine/e2e/engine_spec.rb +++ b/spec/engine/e2e/engine_spec.rb @@ -40,6 +40,7 @@ def load_test_file(filepath) # TODO: Uncomment when evaluation is implemented expect(evaluation_result[:flags]).to eq(test_expected_result[:flags]) + expect(evaluation_result[:segments]).to eq(test_expected_result[:segments]) end end end From 0fb7587b92c809e4146e788601b3ff5893a755b1 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 28 Oct 2025 17:15:38 +0100 Subject: [PATCH 08/11] feat: run-lint --- lib/flagsmith/engine/evaluation/core.rb | 30 +++++++++------------- lib/flagsmith/engine/segments/evaluator.rb | 12 +++------ lib/flagsmith/engine/segments/models.rb | 16 +++++------- 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index 90154f2..10fc8d3 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -23,15 +23,13 @@ def get_evaluation_result(evaluation_context) flags = evaluate_features(evaluation_context, segment_overrides) { flags: flags, - segments: segments, + segments: segments } end # Returns { segments: EvaluationResultSegments; segmentOverrides: Record; } def evaluate_segments(evaluation_context) - if evaluation_context[:identity].nil? || evaluation_context[:segments].nil? - return [], {} - end + return [], {} if evaluation_context[:identity].nil? || evaluation_context[:segments].nil? identity_segments = get_identity_segments_from_context(evaluation_context) @@ -40,16 +38,14 @@ def evaluate_segments(evaluation_context) name: segment[:name] } - if segment[:metadata] - result[:metadata] = segment[:metadata].dup - end + result[:metadata] = segment[:metadata].dup if segment[:metadata] result end segment_overrides = process_segment_overrides(identity_segments) - return segments, segment_overrides + [segments, segment_overrides] end # Returns Record @@ -62,12 +58,12 @@ def process_segment_overrides(identity_segments) overrides_list = segment[:overrides].is_a?(Array) ? segment[:overrides] : [] overrides_list.each do |override| - if should_apply_override(override, segment_overrides) - segment_overrides[override[:name]] = { - feature: override, - segment_name: segment[:name] - } - end + next unless should_apply_override(override, segment_overrides) + + segment_overrides[override[:name]] = { + feature: override, + segment_name: segment[:name] + } end end @@ -102,7 +98,7 @@ def evaluate_features(evaluation_context, segment_overrides) # Set reason flag_result[:reason] = evaluated[:reason] || - get_targeting_match_reason({ type: 'SEGMENT', override: segment_override }) + get_targeting_match_reason({ type: 'SEGMENT', override: segment_override }) flags[final_feature[:name].to_sym] = flag_result end @@ -112,9 +108,7 @@ def evaluate_features(evaluation_context, segment_overrides) # Returns {value: any; reason?: string} def evaluate_feature_value(feature, identity_key = nil) - if feature[:variants]&.any? && identity_key - return get_multivariate_feature_value(feature, identity_key) - end + return get_multivariate_feature_value(feature, identity_key) if feature[:variants]&.any? && identity_key { value: feature[:value], reason: nil } end diff --git a/lib/flagsmith/engine/segments/evaluator.rb b/lib/flagsmith/engine/segments/evaluator.rb index d1031f6..547804c 100644 --- a/lib/flagsmith/engine/segments/evaluator.rb +++ b/lib/flagsmith/engine/segments/evaluator.rb @@ -21,14 +21,12 @@ module Evaluator def get_identity_segments_from_context(context) return [] unless context[:identity] && context[:segments] - matching_segments = context[:segments].values.select do |segment| + context[:segments].values.select do |segment| next false if segment[:rules].nil? || segment[:rules].empty? matches = segment[:rules].all? { |rule| traits_match_segment_rule_from_context(rule, segment[:key], context) } matches end - - matching_segments end # Model-based segment evaluation (existing approach) @@ -153,10 +151,10 @@ def traits_match_segment_condition_from_context(condition, segment_key, context) 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_SET return trait_value.nil? if condition[:operator] == IS_NOT_SET - if !trait_value.nil? + unless trait_value.nil? # Reuse existing Condition class logic condition_obj = Flagsmith::Engine::Segments::Condition.new( operator: condition[:operator], @@ -195,9 +193,7 @@ def evaluate_rule_conditions(rule_type, condition_results) def get_trait_value(property, context) if property.start_with?('$.') context_value = get_context_value(property, context) - if !context_value.nil? && !non_primitive?(context_value) - return context_value - end + return context_value if !context_value.nil? && !non_primitive?(context_value) end traits = context.dig(:identity, :traits) || {} diff --git a/lib/flagsmith/engine/segments/models.rb b/lib/flagsmith/engine/segments/models.rb index a95d547..94cf4a6 100644 --- a/lib/flagsmith/engine/segments/models.rb +++ b/lib/flagsmith/engine/segments/models.rb @@ -81,12 +81,12 @@ def format_to_type_of(input) # Double check this is the desired behavior between SDKs 'TrueClass' => ->(v) { ['True', 'true', 'TRUE', true, 1, '1'].include?(v) }, 'FalseClass' => ->(v) { !['False', 'false', 'FALSE', false].include?(v) }, - 'Integer' => ->(v) { - i = v.to_i; + 'Integer' => lambda { |v| + i = v.to_i i.to_s == v.to_s ? i : v }, - 'Float' => ->(v) { - f = v.to_f; + 'Float' => lambda { |v| + f = v.to_f f.to_s == v.to_s ? f : v } }[input.class.to_s] @@ -103,16 +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) - if @value.is_a?(Array) - return @value.include?(trait_value.to_s) - end + return @value.include?(trait_value.to_s) if @value.is_a?(Array) if @value.is_a?(String) begin parsed = JSON.parse(@value) - if parsed.is_a?(Array) - return parsed.include?(trait_value.to_s) - end + return parsed.include?(trait_value.to_s) if parsed.is_a?(Array) rescue JSON::ParserError end end From 6a6a129d14a0f15bbf3252a2c6b539681dfa7e85 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 28 Oct 2025 17:44:35 +0100 Subject: [PATCH 09/11] feat: misc --- lib/flagsmith/engine/segments/evaluator.rb | 4 ++-- lib/flagsmith/engine/segments/models.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/flagsmith/engine/segments/evaluator.rb b/lib/flagsmith/engine/segments/evaluator.rb index 547804c..8b8a4b1 100644 --- a/lib/flagsmith/engine/segments/evaluator.rb +++ b/lib/flagsmith/engine/segments/evaluator.rb @@ -9,10 +9,10 @@ module Engine module Segments # Evaluator methods module Evaluator - extend self include Flagsmith::Engine::Segments::Constants include Flagsmith::Engine::Utils::HashFunc + module_function # Context-based segment evaluation (new approach) # Returns all segments that the identity belongs to based on segment rules evaluation # @@ -111,7 +111,7 @@ def traits_match_segment_rule_from_context(rule, segment_key, context) # @param context [Hash] The evaluation context # @return [Boolean] True if conditions match according to rule type def evaluate_conditions_from_context(rule, segment_key, context) - return true if rule[:conditions].nil? || rule[:conditions].empty? + return true unless rule[:conditions]&.any? condition_results = rule[:conditions].map do |condition| traits_match_segment_condition_from_context(condition, segment_key, context) diff --git a/lib/flagsmith/engine/segments/models.rb b/lib/flagsmith/engine/segments/models.rb index 94cf4a6..756c905 100644 --- a/lib/flagsmith/engine/segments/models.rb +++ b/lib/flagsmith/engine/segments/models.rb @@ -58,7 +58,7 @@ def match_trait_value?(trait_value) if @value.is_a?(String) && @value.match?(/:semver$/) begin trait_value = Semantic::Version.new(trait_value.to_s.gsub(/:semver$/, '')) - rescue StandardError + rescue ArgumentError, Semantic::Version::ValidationFailed => _e return false end end From f0a53b77166adfbf533d43640a6ca650cbf6dcbc Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 30 Oct 2025 20:41:40 +0100 Subject: [PATCH 10/11] feat: json-path-lib-implementation --- Gemfile.lock | 4 ++++ flagsmith.gemspec | 1 + lib/flagsmith/engine/segments/evaluator.rb | 18 +++++------------- spec/engine-test-data | 2 +- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index bed803f..249a91e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ PATH flagsmith (4.3.0) faraday (>= 2.0.1) faraday-retry + jsonpath (~> 1.1) semantic GEM @@ -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) diff --git a/flagsmith.gemspec b/flagsmith.gemspec index a4b950d..a1e9459 100644 --- a/flagsmith.gemspec +++ b/flagsmith.gemspec @@ -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 diff --git a/lib/flagsmith/engine/segments/evaluator.rb b/lib/flagsmith/engine/segments/evaluator.rb index 8b8a4b1..d4342f8 100644 --- a/lib/flagsmith/engine/segments/evaluator.rb +++ b/lib/flagsmith/engine/segments/evaluator.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'json' +require 'jsonpath' require_relative 'constants' require_relative 'models' require_relative '../utils/hash_func' @@ -200,25 +202,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 diff --git a/spec/engine-test-data b/spec/engine-test-data index 6ab57ec..218757f 160000 --- a/spec/engine-test-data +++ b/spec/engine-test-data @@ -1 +1 @@ -Subproject commit 6ab57ec67bc84659e8b5aa41534b04fe45cc4cbe +Subproject commit 218757fd23f932760be681a09c686c9d6ef55fad From ef1274a63848569c9533eb0e1cc558755d40b00e Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 5 Nov 2025 15:52:08 +0530 Subject: [PATCH 11/11] remove dup --- lib/flagsmith/engine/evaluation/core.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index 10fc8d3..e0d4eb1 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -38,7 +38,7 @@ def evaluate_segments(evaluation_context) name: segment[:name] } - result[:metadata] = segment[:metadata].dup if segment[:metadata] + result[:metadata] = segment[:metadata] if segment[:metadata] result end