From a67d3218b50ed34848378dce8f876a3e749b94b7 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Oct 2025 10:50:38 +0200 Subject: [PATCH 01/25] fix: main-mappers-and-tests --- .gitmodules | 2 +- dev_test.rb | 31 ++ lib/flagsmith/engine/core.rb | 1 + .../engine/evaluationContext/core.rb | 23 ++ .../engine/evaluationContext/mappers.rb | 232 ++++++++++++++ spec/engine-test-data | 2 +- spec/engine/e2e/engine_spec.rb | 74 +++-- .../unit/evaluation_context_mappers_spec.rb | 293 ++++++++++++++++++ 8 files changed, 622 insertions(+), 36 deletions(-) create mode 100644 dev_test.rb create mode 100644 lib/flagsmith/engine/evaluationContext/core.rb create mode 100644 lib/flagsmith/engine/evaluationContext/mappers.rb create mode 100644 spec/engine/unit/evaluation_context_mappers_spec.rb diff --git a/.gitmodules b/.gitmodules index 4453baa..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 = v1.0.0 + branch = main diff --git a/dev_test.rb b/dev_test.rb new file mode 100644 index 0000000..4358309 --- /dev/null +++ b/dev_test.rb @@ -0,0 +1,31 @@ +#!/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/core.rb b/lib/flagsmith/engine/core.rb index 2d67852..c87eebb 100644 --- a/lib/flagsmith/engine/core.rb +++ b/lib/flagsmith/engine/core.rb @@ -11,6 +11,7 @@ require_relative 'segments/evaluator' require_relative 'segments/models' require_relative 'utils/hash_func' +require_relative 'evaluationContext/mappers' module Flagsmith module Engine diff --git a/lib/flagsmith/engine/evaluationContext/core.rb b/lib/flagsmith/engine/evaluationContext/core.rb new file mode 100644 index 0000000..7d69097 --- /dev/null +++ b/lib/flagsmith/engine/evaluationContext/core.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module EvaluationContext + # Core evaluation logic for the evaluation context + # This will contain the main evaluation engine logic similar to Node SDK + 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) + # TODO: Implement core evaluation logic + { + flags: {}, + segments: [] + } + end + end + end + end +end diff --git a/lib/flagsmith/engine/evaluationContext/mappers.rb b/lib/flagsmith/engine/evaluationContext/mappers.rb new file mode 100644 index 0000000..8e603c8 --- /dev/null +++ b/lib/flagsmith/engine/evaluationContext/mappers.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module EvaluationContext + module Mappers + # Using integer constant instead of -Float::INFINITY because the JSON serializer rejects infinity values + HIGHEST_PRIORITY = 0 + WEAKEST_PRIORITY = 99_999_999 + # + # @param environment [Flagsmith::Engine::Environment] The environment model + # @param identity [Flagsmith::Engine::Identity, nil] Optional identity model + # @param override_traits [Array, nil] Optional override traits + # @return [Hash] Evaluation context with environment, features, segments, and optionally identity + 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 + # @return [Hash] Context with :environment, :features, and :segments keys + def self.map_environment_model_to_evaluation_context(environment) + environment_context = { + key: environment.api_key, + name: environment.project.name + } + + # Map feature states to features hash + features = {} + environment.feature_states.each do |fs| + # Map multivariate values if present + variants = nil + if fs.multivariate_feature_state_values&.any? + variants = fs.multivariate_feature_state_values.map do |mv| + { + value: mv.multivariate_feature_option.value, + weight: mv.percentage_allocation, + priority: mv.id || uuid_to_big_int(mv.mv_fs_value_uuid) + } + end + end + + feature_hash = { + key: fs.django_id&.to_s || fs.uuid, + feature_key: fs.feature.id.to_s, + name: fs.feature.name, + enabled: fs.enabled, + value: fs.get_value + } + + feature_hash[:variants] = variants if variants + feature_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority + feature_hash[:metadata] = { flagsmithId: fs.feature.id } + + features[fs.feature.name] = feature_hash + end + + # Map segments from project + segments = {} + environment.project.segments.each do |segment| + overrides = segment.feature_states.map do |fs| + override_hash = { + key: fs.django_id&.to_s || fs.uuid, + feature_key: fs.feature.id.to_s, + name: fs.feature.name, + enabled: fs.enabled, + value: fs.get_value + } + override_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority + override_hash + end + + segments[segment.id.to_s] = { + key: segment.id.to_s, + name: segment.name, + rules: segment.rules.map { |rule| map_segment_rule_model_to_rule(rule) }, + overrides: overrides, + metadata: { + source: 'API', + flagsmith_id: segment.id + } + } + end + + # Map identity overrides to segments + if environment.identity_overrides&.any? + identity_override_segments = map_identity_overrides_to_segments(environment.identity_overrides) + segments.merge!(identity_override_segments) + end + + { + environment: environment_context, + features: features, + segments: segments + } + end + + 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 + # @param override_traits [Array, nil] Optional override traits + # @return [Hash] Identity context with :identifier, :key, and :traits + def self.map_identity_model_to_identity_context(identity, override_traits = nil) + # Use override traits if provided, otherwise use identity's traits + traits = override_traits || identity.identity_traits + + # Map traits to a hash with trait key => trait value + traits_hash = {} + traits.each do |trait| + traits_hash[trait.trait_key] = trait.trait_value + end + + { + identifier: identity.identifier, + key: identity.django_id&.to_s || identity.composite_key, + traits: traits_hash + } + end + + # Maps segment rule model to rule hash + # + # @param rule [Flagsmith::Engine::Segments::Rule] The segment rule model + # @return [Hash] Mapped rule with :type, :conditions, and :rules + def self.map_segment_rule_model_to_rule(rule) + result = { + type: rule.type + } + + # 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 + 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) + require 'digest' + + segments = {} + features_to_identifiers = {} + + identity_overrides.each do |identity| + next if identity.identity_features.nil? || !identity.identity_features.any? + + # Sort features by name for consistent hashing + sorted_features = identity.identity_features.to_a.sort_by { |fs| fs.feature.name } + + # Create override keys for hashing + overrides_key = sorted_features.map do |fs| + { + feature_key: fs.feature.id.to_s, + name: fs.feature.name, + enabled: fs.enabled, + value: fs.get_value, + priority: WEAKEST_PRIORITY, + metadata: { + flagsmithId: fs.feature.id + } + } + end + + # Create hash of the overrides to group identities with same overrides + overrides_hash = Digest::SHA1.hexdigest(overrides_key.to_json) + + features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key } + features_to_identifiers[overrides_hash][:identifiers] << identity.identifier + end + + # Create segments for each unique set of overrides + features_to_identifiers.each do |overrides_hash, data| + segment_key = "identity_override_#{overrides_hash}" + + segments[segment_key] = { + key: segment_key, + name: 'identity_override', + rules: [ + { + type: 'ALL', + conditions: [ + { + property: '$.identity.identifier', + operator: 'IN', + value: data[:identifiers].join(',') + } + ], + rules: [] + } + ], + metadata: { + source: 'identity_override' + }, + overrides: data[:overrides] + } + end + + segments + end + end + end + end +end + \ No newline at end of file diff --git a/spec/engine-test-data b/spec/engine-test-data index 71a9631..41c2021 160000 --- a/spec/engine-test-data +++ b/spec/engine-test-data @@ -1 +1 @@ -Subproject commit 71a963198d66d681d12f2bf92c42a3036ffe92a7 +Subproject commit 41c202145e375c712600e318c439456de5b221d7 diff --git a/spec/engine/e2e/engine_spec.rb b/spec/engine/e2e/engine_spec.rb index cc06a38..fb0d32d 100644 --- a/spec/engine/e2e/engine_spec.rb +++ b/spec/engine/e2e/engine_spec.rb @@ -2,43 +2,49 @@ require 'spec_helper' -def load_test_cases(filepath) - data = JSON.parse(File.open(filepath).read, symbolize_names: true) - environment = Flagsmith::Engine::Environment.build(data[:environment]) - - data[:identities_and_responses].map do |test_case| - identity = Flagsmith::Engine::Identity.build(test_case[:identity]) - { - environment: environment, - identity: identity, - response: test_case[:response] - } - end +def get_test_files + test_data_dir = File.join(APP_ROOT, 'spec/engine-test-data/test_cases') + Dir.glob(File.join(test_data_dir, '*.{json,jsonc}')).sort +end + +def parse_jsonc(content) + # Simple JSONC parser: remove single-line comments + # JSON.parse will handle the rest + cleaned = content.lines.reject { |line| line.strip.start_with?('//') }.join + JSON.parse(cleaned, symbolize_names: true) +end + +def load_test_file(filepath) + content = File.read(filepath) + parse_jsonc(content) end RSpec.describe Flagsmith::Engine do - load_test_cases( - File.join(APP_ROOT, 'spec/engine-test-data/data/environment_n9fbf9h3v4fFgH3U3ngWhb.json') - ).each do |test_case| - engine = Flagsmith::Engine::Engine.new - json_flags = test_case.dig(:response, :flags).sort_by { |json| json.dig(:feature, :name) } - feature_states = engine.get_identity_feature_states(test_case[:environment], test_case[:identity]).sort_by { |fs| fs.feature.name } - - it { expect(feature_states.length).to eq(json_flags.length) } - - json_flags.each.with_index do |json_flag, index| - describe "feature state with ID #{json_flag.dig(:feature, :id)}" do - subject { feature_states[index] } - - context '#enabled?' do - it { expect(subject.enabled?).to eq(json_flag[:enabled]) } - end - - context '#get_value' do - it { - expect(subject.get_value(test_case[:identity].django_id)).to eq(json_flag[:feature_state_value]) - } - end + test_files = get_test_files + + raise "No test files found" if test_files.empty? + + test_files.each do |filepath| + test_name = File.basename(filepath, File.extname(filepath)) + + describe test_name do + it 'should produce the expected evaluation result' do + test_case = load_test_file(filepath) + + test_evaluation_context = test_case[:context] + test_expected_result = test_case[:result] + + # TODO: Build environment/identity models and map to evaluation context + evaluation_context = test_evaluation_context + + # TODO: Implement evaluation logic + evaluation_result = {} + + # For now, verify the context structure is valid + expect(evaluation_context).to eq(test_evaluation_context) + + # TODO: Uncomment when evaluation is implemented + # expect(evaluation_result).to eq(test_expected_result) end end end diff --git a/spec/engine/unit/evaluation_context_mappers_spec.rb b/spec/engine/unit/evaluation_context_mappers_spec.rb new file mode 100644 index 0000000..5d05239 --- /dev/null +++ b/spec/engine/unit/evaluation_context_mappers_spec.rb @@ -0,0 +1,293 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Flagsmith::Engine::EvaluationContext::Mappers do + describe '.get_evaluation_context' do + let(:environment_json) do + JSON.parse( + File.read('spec/sdk/fixtures/environment.json'), + symbolize_names: true + ) + end + + let(:environment) { Flagsmith::Engine::Environment.build(environment_json) } + + it 'produces evaluation context from environment document' do + # When + context = described_class.get_evaluation_context(environment) + + # Then - verify structure + expect(context).to be_a(Hash) + expect(context[:environment][:key]).to eq('B62qaMZNwfiqT76p38ggrQ') + expect(context[:environment][:name]).to eq('Test project') + expect(context[:identity]).to be_nil + + # Verify segments + expect(context[:segments]).to be_a(Hash) + expect(context[:segments]).to have_key('1') + + segment = context[:segments]['1'] + expect(segment[:key]).to eq('1') + expect(segment[:name]).to eq('regular_segment') + expect(segment[:rules].length).to eq(1) + expect(segment[:overrides]).to be_empty.or be_an(Array) + expect(segment[:metadata][:source]).to eq('API') + expect(segment[:metadata][:flagsmith_id]).to eq(1) + + # Verify segment rules + expect(segment[:rules][0][:type]).to eq('ALL') + expect(segment[:rules][0][:conditions]).to eq([]) + expect(segment[:rules][0][:rules].length).to eq(1) + + nested_rule = segment[:rules][0][:rules][0] + expect(nested_rule[:type]).to eq('ANY') + expect(nested_rule[:conditions].length).to eq(1) + expect(nested_rule[:rules]).to eq([]) + + condition = nested_rule[:conditions][0] + expect(condition[:property]).to eq('age') + expect(condition[:operator]).to eq('LESS_THAN') + expect(condition[:value]).to eq(40) + + # Verify identity override segment + identity_override_segment = context[:segments].values.find { |s| s[:name] == 'identity_override' } + expect(identity_override_segment).not_to be_nil + expect(identity_override_segment[:name]).to eq('identity_override') + expect(identity_override_segment[:rules].length).to eq(1) + expect(identity_override_segment[:overrides].length).to eq(1) + + override_rule = identity_override_segment[:rules][0] + expect(override_rule[:type]).to eq('ALL') + expect(override_rule[:conditions].length).to eq(1) + expect(override_rule[:rules]).to eq([]) + + override_condition = override_rule[:conditions][0] + expect(override_condition[:property]).to eq('$.identity.identifier') + expect(override_condition[:operator]).to eq('IN') + expect(override_condition[:value]).to include('overridden-id') + + override = identity_override_segment[:overrides][0] + expect(override[:feature_key]).to eq('1') + expect(override[:name]).to eq('some_feature') + expect(override[:enabled]).to be false + expect(override[:value]).to eq('some-overridden-value') + expect(override[:priority]).to eq(Flagsmith::Engine::EvaluationContext::Mappers::WEAKEST_PRIORITY) + expect(override[:metadata][:flagsmithId]).to eq(1) + + # Verify features + expect(context[:features]).to be_a(Hash) + expect(context[:features]).to have_key('some_feature') + + some_feature = context[:features]['some_feature'] + expect(some_feature[:feature_key]).to eq('1') + expect(some_feature[:name]).to eq('some_feature') + expect(some_feature[:enabled]).to be true + expect(some_feature[:value]).to eq('some-value') + expect(some_feature[:priority]).to be_nil + expect(some_feature[:metadata][:flagsmithId]).to eq(1) + + # Verify multivariate feature + expect(context[:features]).to have_key('test_mv') + test_mv = context[:features]['test_mv'] + expect(test_mv[:feature_key]).to eq('83755') + expect(test_mv[:name]).to eq('test_mv') + expect(test_mv[:enabled]).to be false + expect(test_mv[:value]).to eq('1111') + expect(test_mv[:priority]).to be_nil + expect(test_mv[:variants].length).to eq(1) + + variant = test_mv[:variants][0] + expect(variant[:value]).to eq('8888') + expect(variant[:weight]).to eq(100.0) + expect(variant[:priority]).to eq(38451) + end + + it 'maps segment overrides correctly' do + context = described_class.get_evaluation_context(environment) + + segment = context[:segments]['1'] + expect(segment[:overrides]).to be_an(Array) + expect(segment[:overrides].length).to eq(1) + + override = segment[:overrides][0] + expect(override[:feature_key]).to eq('1') + expect(override[:name]).to eq('some_feature') + expect(override[:enabled]).to be false + expect(override[:value]).to eq('segment_override') + end + + it 'maps multivariate features with multiple variants correctly' do + # Given + mv_option1 = Flagsmith::Engine::Features::MultivariateOption.new(id: 100, value: 'variant_a') + mv_option2 = Flagsmith::Engine::Features::MultivariateOption.new(id: 200, value: 'variant_b') + mv_option3 = Flagsmith::Engine::Features::MultivariateOption.new(id: 150, value: 'variant_c') + + mv_value1 = Flagsmith::Engine::Features::MultivariateStateValue.new( + id: 100, + multivariate_feature_option: mv_option1, + percentage_allocation: 30 + ) + + mv_value2 = Flagsmith::Engine::Features::MultivariateStateValue.new( + id: 200, + multivariate_feature_option: mv_option2, + percentage_allocation: 50 + ) + + mv_value3 = Flagsmith::Engine::Features::MultivariateStateValue.new( + id: 150, + multivariate_feature_option: mv_option3, + percentage_allocation: 20 + ) + + feature = Flagsmith::Engine::Feature.new(id: 999, name: 'multi_variant_feature', type: 'MULTIVARIATE') + feature_state = Flagsmith::Engine::FeatureState.new( + feature: feature, + enabled: true, + django_id: 999, + feature_state_value: 'control', + multivariate_feature_state_values: [mv_value1, mv_value2, mv_value3] + ) + + env_with_mv = Flagsmith::Engine::Environment.new( + id: 1, + api_key: 'test_key', + project: environment.project, + feature_states: [feature_state] + ) + + # When + context = described_class.get_evaluation_context(env_with_mv) + + # Then + feature_context = context[:features]['multi_variant_feature'] + expect(feature_context[:variants].length).to eq(3) + + expect(feature_context[:variants][0][:value]).to eq('variant_a') + expect(feature_context[:variants][0][:weight]).to eq(30) + expect(feature_context[:variants][0][:priority]).to eq(100) + + expect(feature_context[:variants][1][:value]).to eq('variant_b') + expect(feature_context[:variants][1][:weight]).to eq(50) + expect(feature_context[:variants][1][:priority]).to eq(200) + + expect(feature_context[:variants][2][:value]).to eq('variant_c') + expect(feature_context[:variants][2][:weight]).to eq(20) + expect(feature_context[:variants][2][:priority]).to eq(150) + end + + it 'handles multivariate features without IDs using UUID' do + # Given + mv_option1 = Flagsmith::Engine::Features::MultivariateOption.new(value: 'option_x') + mv_option2 = Flagsmith::Engine::Features::MultivariateOption.new(value: 'option_y') + + mv_value1 = Flagsmith::Engine::Features::MultivariateStateValue.new( + id: nil, + multivariate_feature_option: mv_option1, + percentage_allocation: 60, + mv_fs_value_uuid: 'aaaaaaaa-bbbb-cccc-dddd-000000000001' + ) + + mv_value2 = Flagsmith::Engine::Features::MultivariateStateValue.new( + id: nil, + multivariate_feature_option: mv_option2, + percentage_allocation: 40, + mv_fs_value_uuid: 'aaaaaaaa-bbbb-cccc-dddd-000000000002' + ) + + feature = Flagsmith::Engine::Feature.new(id: 888, name: 'uuid_variant_feature', type: 'MULTIVARIATE') + feature_state = Flagsmith::Engine::FeatureState.new( + feature: feature, + enabled: true, + django_id: 888, + feature_state_value: 'default', + multivariate_feature_state_values: [mv_value1, mv_value2] + ) + + env_with_uuid = Flagsmith::Engine::Environment.new( + id: 1, + api_key: 'test_key', + project: environment.project, + feature_states: [feature_state] + ) + + # When + context = described_class.get_evaluation_context(env_with_uuid) + + # Then + feature_context = context[:features]['uuid_variant_feature'] + expect(feature_context[:variants].length).to eq(2) + + expect(feature_context[:variants][0][:priority]).to be_a(Integer) + expect(feature_context[:variants][1][:priority]).to be_a(Integer) + expect(feature_context[:variants][0][:priority]).not_to eq(feature_context[:variants][1][:priority]) + end + + it 'handles environment with no features' do + # Given + empty_env = Flagsmith::Engine::Environment.new( + id: 1, + api_key: 'test_key', + project: environment.project, + feature_states: [] + ) + + # When + context = described_class.get_evaluation_context(empty_env) + + # Then + expect(context[:features]).to eq({}) + expect(context[:environment][:key]).to eq('test_key') + end + + it 'produces evaluation context with identity' do + # Given + identity = Flagsmith::Engine::Identity.new( + identifier: 'test_user', + environment_api_key: 'B62qaMZNwfiqT76p38ggrQ', + identity_traits: [ + Flagsmith::Engine::Identities::Trait.new(trait_key: 'email', trait_value: 'test@example.com'), + Flagsmith::Engine::Identities::Trait.new(trait_key: 'age', trait_value: 25) + ] + ) + + # When + context = described_class.get_evaluation_context(environment, identity) + + # Then + expect(context[:identity]).not_to be_nil + expect(context[:identity][:identifier]).to eq('test_user') + expect(context[:identity][:key]).to eq('B62qaMZNwfiqT76p38ggrQ_test_user') + expect(context[:identity][:traits]).to eq({ + 'email' => 'test@example.com', + 'age' => 25 + }) + end + + it 'produces evaluation context with override traits' do + # Given + identity = Flagsmith::Engine::Identity.new( + identifier: 'test_user', + environment_api_key: 'B62qaMZNwfiqT76p38ggrQ', + identity_traits: [ + Flagsmith::Engine::Identities::Trait.new(trait_key: 'email', trait_value: 'original@example.com') + ] + ) + + override_traits = [ + Flagsmith::Engine::Identities::Trait.new(trait_key: 'email', trait_value: 'override@example.com'), + Flagsmith::Engine::Identities::Trait.new(trait_key: 'premium', trait_value: true) + ] + + # When + context = described_class.get_evaluation_context(environment, identity, override_traits) + + # Then + expect(context[:identity][:traits]).to eq({ + 'email' => 'override@example.com', + 'premium' => true + }) + end + end +end From dc19da6ec40cbad94dcc741bf9df54122974f00c Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Oct 2025 10:51:56 +0200 Subject: [PATCH 02/25] fix: added-metadata --- lib/flagsmith/engine/evaluationContext/mappers.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/flagsmith/engine/evaluationContext/mappers.rb b/lib/flagsmith/engine/evaluationContext/mappers.rb index 8e603c8..5d57f59 100644 --- a/lib/flagsmith/engine/evaluationContext/mappers.rb +++ b/lib/flagsmith/engine/evaluationContext/mappers.rb @@ -74,6 +74,7 @@ def self.map_environment_model_to_evaluation_context(environment) value: fs.get_value } override_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority + override_hash[:metadata] = { flagsmithId: fs.feature.id } override_hash end From d536c81bdf34187ab776f866fc1dfc89a9d3cee5 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Oct 2025 11:10:25 +0200 Subject: [PATCH 03/25] fix: renaming --- .../engine/{evaluationContext => evaluation}/core.rb | 4 +--- .../engine/{evaluationContext => evaluation}/mappers.rb | 0 2 files changed, 1 insertion(+), 3 deletions(-) rename lib/flagsmith/engine/{evaluationContext => evaluation}/core.rb (75%) rename lib/flagsmith/engine/{evaluationContext => evaluation}/mappers.rb (100%) diff --git a/lib/flagsmith/engine/evaluationContext/core.rb b/lib/flagsmith/engine/evaluation/core.rb similarity index 75% rename from lib/flagsmith/engine/evaluationContext/core.rb rename to lib/flagsmith/engine/evaluation/core.rb index 7d69097..4d8fd0a 100644 --- a/lib/flagsmith/engine/evaluationContext/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -2,9 +2,7 @@ module Flagsmith module Engine - module EvaluationContext - # Core evaluation logic for the evaluation context - # This will contain the main evaluation engine logic similar to Node SDK + module Evaluation module Core # Get evaluation result from evaluation context # diff --git a/lib/flagsmith/engine/evaluationContext/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb similarity index 100% rename from lib/flagsmith/engine/evaluationContext/mappers.rb rename to lib/flagsmith/engine/evaluation/mappers.rb From 79b31764746cef232aee3f6d52a74b328e171a97 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Oct 2025 11:11:00 +0200 Subject: [PATCH 04/25] fix: comments --- lib/flagsmith/engine/evaluation/mappers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index 5d57f59..74b0297 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -7,7 +7,7 @@ module Mappers # Using integer constant instead of -Float::INFINITY because the JSON serializer rejects infinity values HIGHEST_PRIORITY = 0 WEAKEST_PRIORITY = 99_999_999 - # + # @param environment [Flagsmith::Engine::Environment] The environment model # @param identity [Flagsmith::Engine::Identity, nil] Optional identity model # @param override_traits [Array, nil] Optional override traits From 45b926e0f0adc6a3ce18d527020d073cf15adb15 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Oct 2025 11:27:14 +0200 Subject: [PATCH 05/25] fix: remoevd-redundant-test --- .../engine/unit/evaluation_context_mappers_spec.rb | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/spec/engine/unit/evaluation_context_mappers_spec.rb b/spec/engine/unit/evaluation_context_mappers_spec.rb index 5d05239..3890afb 100644 --- a/spec/engine/unit/evaluation_context_mappers_spec.rb +++ b/spec/engine/unit/evaluation_context_mappers_spec.rb @@ -103,20 +103,6 @@ expect(variant[:priority]).to eq(38451) end - it 'maps segment overrides correctly' do - context = described_class.get_evaluation_context(environment) - - segment = context[:segments]['1'] - expect(segment[:overrides]).to be_an(Array) - expect(segment[:overrides].length).to eq(1) - - override = segment[:overrides][0] - expect(override[:feature_key]).to eq('1') - expect(override[:name]).to eq('some_feature') - expect(override[:enabled]).to be false - expect(override[:value]).to eq('segment_override') - end - it 'maps multivariate features with multiple variants correctly' do # Given mv_option1 = Flagsmith::Engine::Features::MultivariateOption.new(id: 100, value: 'variant_a') From 9ea952d605b277bcfc272198a51831f305c1982c Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Oct 2025 11:28:35 +0200 Subject: [PATCH 06/25] feat: reviewed-engine-test-todos --- spec/engine/e2e/engine_spec.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/spec/engine/e2e/engine_spec.rb b/spec/engine/e2e/engine_spec.rb index fb0d32d..77c9f92 100644 --- a/spec/engine/e2e/engine_spec.rb +++ b/spec/engine/e2e/engine_spec.rb @@ -34,14 +34,9 @@ def load_test_file(filepath) test_evaluation_context = test_case[:context] test_expected_result = test_case[:result] - # TODO: Build environment/identity models and map to evaluation context - evaluation_context = test_evaluation_context - # TODO: Implement evaluation logic evaluation_result = {} - # For now, verify the context structure is valid - expect(evaluation_context).to eq(test_evaluation_context) # TODO: Uncomment when evaluation is implemented # expect(evaluation_result).to eq(test_expected_result) From b9d14a813d3bff8b1e0deb1448227115b74e0021 Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Fri, 24 Oct 2025 09:51:46 +0200 Subject: [PATCH 07/25] Update lib/flagsmith/engine/evaluation/mappers.rb Co-authored-by: Kim Gustyr --- lib/flagsmith/engine/evaluation/mappers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index 74b0297..dba25be 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -183,7 +183,7 @@ def self.map_identity_overrides_to_segments(identity_overrides) name: fs.feature.name, enabled: fs.enabled, value: fs.get_value, - priority: WEAKEST_PRIORITY, + priority: STRONGEST_PRIORITY, metadata: { flagsmithId: fs.feature.id } From cb3558b958d1e729647937425356e439d32b9f46 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Oct 2025 15:48:31 +0200 Subject: [PATCH 08/25] feat: use-overrides-key-hash --- lib/flagsmith/engine/core.rb | 2 +- lib/flagsmith/engine/evaluation/mappers.rb | 8 +++----- spec/engine/unit/evaluation_context_mappers_spec.rb | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/flagsmith/engine/core.rb b/lib/flagsmith/engine/core.rb index c87eebb..530a3f4 100644 --- a/lib/flagsmith/engine/core.rb +++ b/lib/flagsmith/engine/core.rb @@ -11,7 +11,7 @@ require_relative 'segments/evaluator' require_relative 'segments/models' require_relative 'utils/hash_func' -require_relative 'evaluationContext/mappers' +require_relative 'evaluation/mappers' module Flagsmith module Engine diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index dba25be..c23df42 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -4,9 +4,8 @@ module Flagsmith module Engine module EvaluationContext module Mappers - # Using integer constant instead of -Float::INFINITY because the JSON serializer rejects infinity values - HIGHEST_PRIORITY = 0 - WEAKEST_PRIORITY = 99_999_999 + STRONGEST_PRIORITY = Float::INFINITY + WEAKEST_PRIORITY = -Float::INFINITY # @param environment [Flagsmith::Engine::Environment] The environment model # @param identity [Flagsmith::Engine::Identity, nil] Optional identity model @@ -165,7 +164,6 @@ def self.map_segment_rule_model_to_rule(rule) # @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) - require 'digest' segments = {} features_to_identifiers = {} @@ -191,7 +189,7 @@ def self.map_identity_overrides_to_segments(identity_overrides) end # Create hash of the overrides to group identities with same overrides - overrides_hash = Digest::SHA1.hexdigest(overrides_key.to_json) + overrides_hash = overrides_key.hash features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key } features_to_identifiers[overrides_hash][:identifiers] << identity.identifier diff --git a/spec/engine/unit/evaluation_context_mappers_spec.rb b/spec/engine/unit/evaluation_context_mappers_spec.rb index 3890afb..aac8a71 100644 --- a/spec/engine/unit/evaluation_context_mappers_spec.rb +++ b/spec/engine/unit/evaluation_context_mappers_spec.rb @@ -72,7 +72,7 @@ expect(override[:name]).to eq('some_feature') expect(override[:enabled]).to be false expect(override[:value]).to eq('some-overridden-value') - expect(override[:priority]).to eq(Flagsmith::Engine::EvaluationContext::Mappers::WEAKEST_PRIORITY) + expect(override[:priority]).to eq(Flagsmith::Engine::EvaluationContext::Mappers::STRONGEST_PRIORITY) expect(override[:metadata][:flagsmithId]).to eq(1) # Verify features From 58bfe8b8bd3afefa52208cbf3962bf91bac9c090 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Oct 2025 15:49:01 +0200 Subject: [PATCH 09/25] feat: removed-identifiers-join --- lib/flagsmith/engine/evaluation/mappers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index c23df42..48db0d1 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -209,7 +209,7 @@ def self.map_identity_overrides_to_segments(identity_overrides) { property: '$.identity.identifier', operator: 'IN', - value: data[:identifiers].join(',') + value: data[:identifiers] } ], rules: [] From 3d7fa00ea1b4a733ec0c1fd43b263727e9ddf952 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Oct 2025 16:20:25 +0200 Subject: [PATCH 10/25] feat: flagsmith-id-in-snake-case --- lib/flagsmith/engine/evaluation/mappers.rb | 6 +++--- spec/engine/unit/evaluation_context_mappers_spec.rb | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index 48db0d1..396fffe 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -56,7 +56,7 @@ def self.map_environment_model_to_evaluation_context(environment) feature_hash[:variants] = variants if variants feature_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority - feature_hash[:metadata] = { flagsmithId: fs.feature.id } + feature_hash[:metadata] = { flagsmith_id: fs.feature.id } features[fs.feature.name] = feature_hash end @@ -73,7 +73,7 @@ def self.map_environment_model_to_evaluation_context(environment) value: fs.get_value } override_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority - override_hash[:metadata] = { flagsmithId: fs.feature.id } + override_hash[:metadata] = { flagsmith_id: fs.feature.id } override_hash end @@ -183,7 +183,7 @@ def self.map_identity_overrides_to_segments(identity_overrides) value: fs.get_value, priority: STRONGEST_PRIORITY, metadata: { - flagsmithId: fs.feature.id + flagsmith_id: fs.feature.id } } end diff --git a/spec/engine/unit/evaluation_context_mappers_spec.rb b/spec/engine/unit/evaluation_context_mappers_spec.rb index aac8a71..6f693af 100644 --- a/spec/engine/unit/evaluation_context_mappers_spec.rb +++ b/spec/engine/unit/evaluation_context_mappers_spec.rb @@ -73,7 +73,7 @@ expect(override[:enabled]).to be false expect(override[:value]).to eq('some-overridden-value') expect(override[:priority]).to eq(Flagsmith::Engine::EvaluationContext::Mappers::STRONGEST_PRIORITY) - expect(override[:metadata][:flagsmithId]).to eq(1) + expect(override[:metadata][:flagsmith_id]).to eq(1) # Verify features expect(context[:features]).to be_a(Hash) @@ -85,7 +85,7 @@ expect(some_feature[:enabled]).to be true expect(some_feature[:value]).to eq('some-value') expect(some_feature[:priority]).to be_nil - expect(some_feature[:metadata][:flagsmithId]).to eq(1) + expect(some_feature[:metadata][:flagsmith_id]).to eq(1) # Verify multivariate feature expect(context[:features]).to have_key('test_mv') From 075a8a4062385edac6cd021330a413933ec1dcac Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 28 Oct 2025 17:16:44 +0100 Subject: [PATCH 11/25] feat: run-lint --- dev_test.rb | 31 -------------- lib/flagsmith/engine/evaluation/core.rb | 2 +- lib/flagsmith/engine/evaluation/mappers.rb | 48 +++++++++++----------- 3 files changed, 24 insertions(+), 57 deletions(-) delete mode 100644 dev_test.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..2b510cc 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -8,7 +8,7 @@ module Core # # @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/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index 396fffe..138537b 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,33 +138,32 @@ 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 = {} @@ -228,4 +227,3 @@ def self.map_identity_overrides_to_segments(identity_overrides) end end end - \ No newline at end of file From 73b841c677b299a7f798cdbf401339cf07f37541 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 28 Oct 2025 17:21:08 +0100 Subject: [PATCH 12/25] feat: renamed-module-evaluation --- lib/flagsmith/engine/evaluation/mappers.rb | 2 +- spec/engine/unit/evaluation_context_mappers_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index 138537b..3b0f37a 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -2,7 +2,7 @@ module Flagsmith module Engine - module EvaluationContext + module Evaluation module Mappers STRONGEST_PRIORITY = Float::INFINITY WEAKEST_PRIORITY = -Float::INFINITY diff --git a/spec/engine/unit/evaluation_context_mappers_spec.rb b/spec/engine/unit/evaluation_context_mappers_spec.rb index 6f693af..e65bb8a 100644 --- a/spec/engine/unit/evaluation_context_mappers_spec.rb +++ b/spec/engine/unit/evaluation_context_mappers_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Flagsmith::Engine::EvaluationContext::Mappers do +RSpec.describe Flagsmith::Engine::Evaluation::Mappers do describe '.get_evaluation_context' do let(:environment_json) do JSON.parse( @@ -72,7 +72,7 @@ expect(override[:name]).to eq('some_feature') expect(override[:enabled]).to be false expect(override[:value]).to eq('some-overridden-value') - expect(override[:priority]).to eq(Flagsmith::Engine::EvaluationContext::Mappers::STRONGEST_PRIORITY) + expect(override[:priority]).to eq(Flagsmith::Engine::Evaluation::Mappers::STRONGEST_PRIORITY) expect(override[:metadata][:flagsmith_id]).to eq(1) # Verify features From 51894ad59ad0b2bdd21415cc7f448b8e749fe98a Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 28 Oct 2025 17:32:35 +0100 Subject: [PATCH 13/25] feat: fixed-priority-0-being-skipped --- lib/flagsmith/engine/evaluation/mappers.rb | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index 3b0f37a..ed207f6 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -55,7 +55,8 @@ def self.map_environment_model_to_evaluation_context(environment) } feature_hash[:variants] = variants if variants - feature_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority + priority = fs.feature_segment&.priority + feature_hash[:priority] = priority unless priority.nil? feature_hash[:metadata] = { flagsmith_id: fs.feature.id } features[fs.feature.name] = feature_hash @@ -138,17 +139,9 @@ def self.map_segment_rule_model_to_rule(rule) } # Map conditions if present - result[:conditions] = if rule.conditions&.any? - rule.conditions.map do |condition| - { - property: condition.property, - operator: condition.operator, - value: condition.value - } - end - else - [] - end + result[:conditions] = (rule.conditions || []).map do |condition| + { property: condition.property, operator: condition.operator, value: condition.value } + end result[:rules] = if rule.rules&.any? rule.rules.map { |nested_rule| map_segment_rule_model_to_rule(nested_rule) } @@ -188,7 +181,7 @@ def self.map_identity_overrides_to_segments(identity_overrides) end # Create hash of the overrides to group identities with same overrides - overrides_hash = overrides_key.hash + overrides_hash = Digest::SHA1.hexdigest(overrides_key.inspect) features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key } features_to_identifiers[overrides_hash][:identifiers] << identity.identifier From e1eabd21567f6f2bbae84ae15c24fb4924a13ead Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 30 Oct 2025 11:48:28 +0100 Subject: [PATCH 14/25] feat: reverted-to-hash --- lib/flagsmith/engine/evaluation/mappers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index ed207f6..81ba85e 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -181,7 +181,7 @@ def self.map_identity_overrides_to_segments(identity_overrides) end # Create hash of the overrides to group identities with same overrides - overrides_hash = Digest::SHA1.hexdigest(overrides_key.inspect) + overrides_hash = overrides_key.hash features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key } features_to_identifiers[overrides_hash][:identifiers] << identity.identifier From 0386836f4934dea4cc78e6b7d31fae9b3a91401a Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 10 Nov 2025 14:43:19 +0100 Subject: [PATCH 15/25] feat: linter --- lib/flagsmith/engine/evaluation/core.rb | 1 + lib/flagsmith/engine/evaluation/mappers.rb | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index 2b510cc..0ddc342 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -3,6 +3,7 @@ module Flagsmith module Engine module Evaluation + # Core evaluation logic for feature flags module Core # Get evaluation result from evaluation context # diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index 81ba85e..664b0d9 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -3,6 +3,7 @@ module Flagsmith module Engine module Evaluation + # Mappers for converting between models and evaluation contexts module Mappers STRONGEST_PRIORITY = Float::INFINITY WEAKEST_PRIORITY = -Float::INFINITY @@ -161,7 +162,7 @@ def self.map_identity_overrides_to_segments(identity_overrides) 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 } From d86ba90a2a82f1b289e750c483e54eb4ab0f064d Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 10 Nov 2025 16:09:39 +0100 Subject: [PATCH 16/25] feat: split-functions-and-module-for-linting --- lib/flagsmith/engine/evaluation/mappers.rb | 185 ++---------------- .../engine/evaluation/mappers/environment.rb | 62 ++++++ .../engine/evaluation/mappers/identity.rb | 100 ++++++++++ .../engine/evaluation/mappers/segments.rb | 74 +++++++ 4 files changed, 251 insertions(+), 170 deletions(-) create mode 100644 lib/flagsmith/engine/evaluation/mappers/environment.rb create mode 100644 lib/flagsmith/engine/evaluation/mappers/identity.rb create mode 100644 lib/flagsmith/engine/evaluation/mappers/segments.rb diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb index 664b0d9..39a2aab 100644 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ b/lib/flagsmith/engine/evaluation/mappers.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +require_relative 'mappers/environment' +require_relative 'mappers/identity' +require_relative 'mappers/segments' + module Flagsmith module Engine module Evaluation @@ -13,12 +17,8 @@ module Mappers # @param override_traits [Array, nil] Optional override traits # @return [Hash] Evaluation context with environment, features, segments, and optionally identity 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 = map_environment_model_to_evaluation_context(environment) + context[:identity] = map_identity_model_to_identity_context(identity, override_traits) if identity context end @@ -27,85 +27,15 @@ def self.get_evaluation_context(environment, identity = nil, override_traits = n # @param environment [Flagsmith::Engine::Environment] The environment model # @return [Hash] Context with :environment, :features, and :segments keys def self.map_environment_model_to_evaluation_context(environment) - environment_context = { - key: environment.api_key, - name: environment.project.name + context = { + environment: Environment.build_environment_context(environment), + features: Environment.build_features_context(environment.feature_states), + segments: Segments.build_segments_context(environment.project.segments) } - # Map feature states to features hash - features = {} - environment.feature_states.each do |fs| - # Map multivariate values if present - variants = nil - if fs.multivariate_feature_state_values&.any? - variants = fs.multivariate_feature_state_values.map do |mv| - { - value: mv.multivariate_feature_option.value, - weight: mv.percentage_allocation, - priority: mv.id || uuid_to_big_int(mv.mv_fs_value_uuid) - } - end - end - - feature_hash = { - key: fs.django_id&.to_s || fs.uuid, - feature_key: fs.feature.id.to_s, - name: fs.feature.name, - enabled: fs.enabled, - value: fs.get_value - } - - feature_hash[:variants] = variants if variants - priority = fs.feature_segment&.priority - feature_hash[:priority] = priority unless priority.nil? - feature_hash[:metadata] = { flagsmith_id: fs.feature.id } - - features[fs.feature.name] = feature_hash - end - - # Map segments from project - segments = {} - environment.project.segments.each do |segment| - overrides = segment.feature_states.map do |fs| - override_hash = { - key: fs.django_id&.to_s || fs.uuid, - feature_key: fs.feature.id.to_s, - name: fs.feature.name, - enabled: fs.enabled, - value: fs.get_value - } - override_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority - override_hash[:metadata] = { flagsmith_id: fs.feature.id } - override_hash - end + context[:segments].merge!(Identity.map_overrides_to_segments(environment.identity_overrides)) if environment.identity_overrides&.any? - segments[segment.id.to_s] = { - key: segment.id.to_s, - name: segment.name, - rules: segment.rules.map { |rule| map_segment_rule_model_to_rule(rule) }, - overrides: overrides, - metadata: { - source: 'API', - flagsmith_id: segment.id - } - } - end - - # Map identity overrides to segments - if environment.identity_overrides&.any? - identity_override_segments = map_identity_overrides_to_segments(environment.identity_overrides) - segments.merge!(identity_override_segments) - end - - { - environment: environment_context, - features: features, - segments: segments - } - end - - def self.uuid_to_big_int(uuid) - uuid.gsub('-', '').to_i(16) + context end # Maps identity model to identity context @@ -114,20 +44,7 @@ def self.uuid_to_big_int(uuid) # @param override_traits [Array, nil] Optional override traits # @return [Hash] Identity context with :identifier, :key, and :traits def self.map_identity_model_to_identity_context(identity, override_traits = nil) - # Use override traits if provided, otherwise use identity's traits - traits = override_traits || identity.identity_traits - - # Map traits to a hash with trait key => trait value - traits_hash = {} - traits.each do |trait| - traits_hash[trait.trait_key] = trait.trait_value - end - - { - identifier: identity.identifier, - key: identity.django_id&.to_s || identity.composite_key, - traits: traits_hash - } + Identity.build_environment_context(identity, override_traits) end # Maps segment rule model to rule hash @@ -135,22 +52,7 @@ def self.map_identity_model_to_identity_context(identity, override_traits = nil) # @param rule [Flagsmith::Engine::Segments::Rule] The segment rule model # @return [Hash] Mapped rule with :type, :conditions, and :rules def self.map_segment_rule_model_to_rule(rule) - result = { - type: rule.type - } - - # Map conditions if present - result[:conditions] = (rule.conditions || []).map do |condition| - { property: condition.property, operator: condition.operator, value: condition.value } - end - - result[:rules] = if rule.rules&.any? - rule.rules.map { |nested_rule| map_segment_rule_model_to_rule(nested_rule) } - else - [] - end - - result + Segments.map_rule(rule) end # Maps identity overrides to segments @@ -158,64 +60,7 @@ def self.map_segment_rule_model_to_rule(rule) # @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.none? - - # Sort features by name for consistent hashing - sorted_features = identity.identity_features.to_a.sort_by { |fs| fs.feature.name } - - # Create override keys for hashing - overrides_key = sorted_features.map do |fs| - { - feature_key: fs.feature.id.to_s, - name: fs.feature.name, - enabled: fs.enabled, - value: fs.get_value, - priority: STRONGEST_PRIORITY, - metadata: { - flagsmith_id: fs.feature.id - } - } - end - - # Create hash of the overrides to group identities with same overrides - overrides_hash = overrides_key.hash - - features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key } - features_to_identifiers[overrides_hash][:identifiers] << identity.identifier - end - - # Create segments for each unique set of overrides - features_to_identifiers.each do |overrides_hash, data| - segment_key = "identity_override_#{overrides_hash}" - - segments[segment_key] = { - key: segment_key, - name: 'identity_override', - rules: [ - { - type: 'ALL', - conditions: [ - { - property: '$.identity.identifier', - operator: 'IN', - value: data[:identifiers] - } - ], - rules: [] - } - ], - metadata: { - source: 'identity_override' - }, - overrides: data[:overrides] - } - end - - segments + Identity.map_overrides_to_segments(identity_overrides) end end end diff --git a/lib/flagsmith/engine/evaluation/mappers/environment.rb b/lib/flagsmith/engine/evaluation/mappers/environment.rb new file mode 100644 index 0000000..07c88ac --- /dev/null +++ b/lib/flagsmith/engine/evaluation/mappers/environment.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module Evaluation + module Mappers + # Handles environment and feature mapping + module Environment + def self.build_environment_context(environment) + { + key: environment.api_key, + name: environment.project.name + } + end + + def self.build_features_context(feature_states) + features = {} + feature_states.each do |feature_state| + features[feature_state.feature.name] = build_feature_hash(feature_state) + end + features + end + + def self.build_feature_hash(feature_state) # rubocop:disable Metrics/MethodLength + feature_hash = { + key: feature_state.django_id&.to_s || feature_state.uuid, + feature_key: feature_state.feature.id.to_s, + name: feature_state.feature.name, + enabled: feature_state.enabled, + value: feature_state.get_value, + metadata: { flagsmith_id: feature_state.feature.id } + } + add_variants_to_feature(feature_hash, feature_state) + add_priority_to_feature(feature_hash, feature_state) + feature_hash + end + + def self.add_variants_to_feature(feature_hash, feature_state) + return unless feature_state.multivariate_feature_state_values&.any? + + feature_hash[:variants] = feature_state.multivariate_feature_state_values.map do |mv| + { + value: mv.multivariate_feature_option.value, + weight: mv.percentage_allocation, + priority: mv.id || uuid_to_big_int(mv.mv_fs_value_uuid) + } + end + end + + def self.uuid_to_big_int(uuid) + uuid.gsub('-', '').to_i(16) + end + + def self.add_priority_to_feature(feature_hash, feature_state) + priority = feature_state.feature_segment&.priority + feature_hash[:priority] = priority unless priority.nil? + end + end + end + end + end +end diff --git a/lib/flagsmith/engine/evaluation/mappers/identity.rb b/lib/flagsmith/engine/evaluation/mappers/identity.rb new file mode 100644 index 0000000..5aa47c7 --- /dev/null +++ b/lib/flagsmith/engine/evaluation/mappers/identity.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module Evaluation + module Mappers + # Handles identity and override mapping + module Identity + def self.build_environment_context(identity, override_traits = nil) + traits = override_traits || identity.identity_traits + + { + identifier: identity.identifier, + key: identity.django_id&.to_s || identity.composite_key, + traits: build_traits_hash(traits) + } + end + + def self.build_traits_hash(traits) + traits_hash = {} + traits.each do |trait| + traits_hash[trait.trait_key] = trait.trait_value + end + traits_hash + end + + def self.map_overrides_to_segments(identity_overrides) + features_to_identifiers = group_by_overrides(identity_overrides) + build_segments(features_to_identifiers) + end + + def self.group_by_overrides(identity_overrides) + features_to_identifiers = {} + + identity_overrides.each do |identity| + next if identity.identity_features.nil? || identity.identity_features.none? + + overrides_key = build_overrides_key(identity.identity_features) + overrides_hash = overrides_key.hash + + features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key } + features_to_identifiers[overrides_hash][:identifiers] << identity.identifier + end + + features_to_identifiers + end + + def self.build_overrides_key(identity_features) # rubocop:disable Metrics/MethodLength + sorted_features = identity_features.to_a.sort_by { |fs| fs.feature.name } + sorted_features.map do |feature_state| + { + feature_key: feature_state.feature.id.to_s, + name: feature_state.feature.name, + enabled: feature_state.enabled, + value: feature_state.get_value, + priority: Mappers::STRONGEST_PRIORITY, + metadata: { flagsmith_id: feature_state.feature.id } + } + end + end + + def self.build_segments(features_to_identifiers) + segments = {} + + features_to_identifiers.each do |overrides_hash, data| + segment_key = "identity_override_#{overrides_hash}" + segments[segment_key] = build_segment(segment_key, data) + end + + segments + end + + def self.build_segment(segment_key, data) + { + key: segment_key, + name: 'identity_override', + rules: [build_rule(data[:identifiers])], + metadata: { source: 'identity_override' }, + overrides: data[:overrides] + } + end + + def self.build_rule(identifiers) # rubocop:disable Metrics/MethodLength + { + type: 'ALL', + conditions: [ + { + property: '$.identity.identifier', + operator: 'IN', + value: identifiers + } + ], + rules: [] + } + end + end + end + end + end +end diff --git a/lib/flagsmith/engine/evaluation/mappers/segments.rb b/lib/flagsmith/engine/evaluation/mappers/segments.rb new file mode 100644 index 0000000..30d9378 --- /dev/null +++ b/lib/flagsmith/engine/evaluation/mappers/segments.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module Evaluation + module Mappers + # Handles segment and rule mapping + module Segments + def self.build_segments_context(project_segments) + segments = {} + project_segments.each do |segment| + segments[segment.id.to_s] = build_segment_hash(segment) + end + segments + end + + def self.build_segment_hash(segment) + { + key: segment.id.to_s, + name: segment.name, + rules: segment.rules.map { |rule| map_rule(rule) }, + overrides: build_overrides(segment.feature_states), + metadata: { + source: 'API', + flagsmith_id: segment.id + } + } + end + + def self.build_overrides(feature_states) # rubocop:disable Metrics/MethodLength + feature_states.map do |feature_state| + override_hash = { + key: feature_state.django_id&.to_s || feature_state.uuid, + feature_key: feature_state.feature.id.to_s, + name: feature_state.feature.name, + enabled: feature_state.enabled, + value: feature_state.get_value, + metadata: { flagsmith_id: feature_state.feature.id } + } + add_priority_to_override(override_hash, feature_state) + override_hash + end + end + + def self.add_priority_to_override(override_hash, feature_state) + return unless feature_state.feature_segment&.priority + + override_hash[:priority] = feature_state.feature_segment.priority + end + + def self.map_rule(rule) + { + type: rule.type, + conditions: map_conditions(rule.conditions), + rules: map_nested_rules(rule.rules) + } + end + + def self.map_conditions(conditions) + (conditions || []).map do |condition| + { property: condition.property, operator: condition.operator, value: condition.value } + end + end + + def self.map_nested_rules(rules) + return [] unless rules&.any? + + rules.map { |nested_rule| map_rule(nested_rule) } + end + end + end + end + end +end From f8ea862870ea691fe0f54fe3c76f5dcbfa4088de Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 10 Nov 2025 16:13:06 +0100 Subject: [PATCH 17/25] feat: get-rid-of-extra-map-nested-rule-function --- lib/flagsmith/engine/evaluation/mappers/segments.rb | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/flagsmith/engine/evaluation/mappers/segments.rb b/lib/flagsmith/engine/evaluation/mappers/segments.rb index 30d9378..b281c14 100644 --- a/lib/flagsmith/engine/evaluation/mappers/segments.rb +++ b/lib/flagsmith/engine/evaluation/mappers/segments.rb @@ -52,7 +52,7 @@ def self.map_rule(rule) { type: rule.type, conditions: map_conditions(rule.conditions), - rules: map_nested_rules(rule.rules) + rules: (rule.rules || []).map { |nested_rule| map_rule(nested_rule) } } end @@ -61,12 +61,6 @@ def self.map_conditions(conditions) { property: condition.property, operator: condition.operator, value: condition.value } end end - - def self.map_nested_rules(rules) - return [] unless rules&.any? - - rules.map { |nested_rule| map_rule(nested_rule) } - end end end end From b2c3d30daee66a4df4b76dbeaaed7977a5e58ecf Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 10 Nov 2025 16:55:54 +0100 Subject: [PATCH 18/25] feat: renaming-identity-methods --- lib/flagsmith/engine/evaluation/mappers/identity.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/flagsmith/engine/evaluation/mappers/identity.rb b/lib/flagsmith/engine/evaluation/mappers/identity.rb index 5aa47c7..c60a0e9 100644 --- a/lib/flagsmith/engine/evaluation/mappers/identity.rb +++ b/lib/flagsmith/engine/evaluation/mappers/identity.rb @@ -26,7 +26,7 @@ def self.build_traits_hash(traits) def self.map_overrides_to_segments(identity_overrides) features_to_identifiers = group_by_overrides(identity_overrides) - build_segments(features_to_identifiers) + build_identity_override_segments(features_to_identifiers) end def self.group_by_overrides(identity_overrides) @@ -59,18 +59,18 @@ def self.build_overrides_key(identity_features) # rubocop:disable Metrics/Method end end - def self.build_segments(features_to_identifiers) + def self.build_identity_override_segments(features_to_identifiers) segments = {} features_to_identifiers.each do |overrides_hash, data| segment_key = "identity_override_#{overrides_hash}" - segments[segment_key] = build_segment(segment_key, data) + segments[segment_key] = build_identity_override_segment(segment_key, data) end segments end - def self.build_segment(segment_key, data) + def self.build_identity_override_segment(segment_key, data) { key: segment_key, name: 'identity_override', @@ -80,7 +80,7 @@ def self.build_segment(segment_key, data) } end - def self.build_rule(identifiers) # rubocop:disable Metrics/MethodLength + def self.build_identity_override_rule(identifiers) # rubocop:disable Metrics/MethodLength { type: 'ALL', conditions: [ From 7de46c2236e7f8fcc30e601009fc82d47c6eba20 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 10 Nov 2025 17:14:24 +0100 Subject: [PATCH 19/25] feat: fixed-forgotten-func-renaming --- lib/flagsmith/engine/evaluation/mappers/identity.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flagsmith/engine/evaluation/mappers/identity.rb b/lib/flagsmith/engine/evaluation/mappers/identity.rb index c60a0e9..8c8c266 100644 --- a/lib/flagsmith/engine/evaluation/mappers/identity.rb +++ b/lib/flagsmith/engine/evaluation/mappers/identity.rb @@ -74,7 +74,7 @@ def self.build_identity_override_segment(segment_key, data) { key: segment_key, name: 'identity_override', - rules: [build_rule(data[:identifiers])], + rules: [build_identity_override_rule(data[:identifiers])], metadata: { source: 'identity_override' }, overrides: data[:overrides] } From 10b256c146cebfe0512c820cb29fce6decb1380a Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Mon, 10 Nov 2025 20:34:21 +0100 Subject: [PATCH 20/25] Update lib/flagsmith/engine/evaluation/mappers/environment.rb Co-authored-by: Kim Gustyr --- lib/flagsmith/engine/evaluation/mappers/environment.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flagsmith/engine/evaluation/mappers/environment.rb b/lib/flagsmith/engine/evaluation/mappers/environment.rb index 07c88ac..92b669b 100644 --- a/lib/flagsmith/engine/evaluation/mappers/environment.rb +++ b/lib/flagsmith/engine/evaluation/mappers/environment.rb @@ -9,7 +9,7 @@ module Environment def self.build_environment_context(environment) { key: environment.api_key, - name: environment.project.name + name: environment.name } end From 6c8dc0b186da08af6bc4ba340e04dc03837ba962 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 10 Nov 2025 20:43:38 +0100 Subject: [PATCH 21/25] feat: added-name-env-model-and-fixture --- lib/flagsmith/engine/environments/models.rb | 7 ++++--- spec/engine/unit/evaluation_context_mappers_spec.rb | 2 +- spec/sdk/fixtures/environment.json | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/flagsmith/engine/environments/models.rb b/lib/flagsmith/engine/environments/models.rb index 7a102f1..41f7da4 100644 --- a/lib/flagsmith/engine/environments/models.rb +++ b/lib/flagsmith/engine/environments/models.rb @@ -4,13 +4,14 @@ module Flagsmith module Engine # EnvironmentModel class Environment - attr_reader :id, :api_key + attr_reader :id, :api_key, :name attr_accessor :project, :feature_states, :amplitude_config, :segment_config, :mixpanel_config, :heap_config, :identity_overrides - def initialize(id:, api_key:, project:, feature_states: [], identity_overrides: []) + def initialize(id:, api_key:, name: nil, project:, feature_states: [], identity_overrides: []) @id = id @api_key = api_key + @name = name @project = project @feature_states = feature_states @identity_overrides = identity_overrides @@ -28,7 +29,7 @@ def build(json) Flagsmith::Engine::Identity.build(io) end - new(**json.slice(:id, :api_key).merge( + new(**json.slice(:id, :api_key, :name).merge( project: project, feature_states: feature_states, identity_overrides: identity_overrides diff --git a/spec/engine/unit/evaluation_context_mappers_spec.rb b/spec/engine/unit/evaluation_context_mappers_spec.rb index e65bb8a..dbaa4fe 100644 --- a/spec/engine/unit/evaluation_context_mappers_spec.rb +++ b/spec/engine/unit/evaluation_context_mappers_spec.rb @@ -20,7 +20,7 @@ # Then - verify structure expect(context).to be_a(Hash) expect(context[:environment][:key]).to eq('B62qaMZNwfiqT76p38ggrQ') - expect(context[:environment][:name]).to eq('Test project') + expect(context[:environment][:name]).to eq('Test environment') expect(context[:identity]).to be_nil # Verify segments diff --git a/spec/sdk/fixtures/environment.json b/spec/sdk/fixtures/environment.json index 0dea88a..cdf580a 100644 --- a/spec/sdk/fixtures/environment.json +++ b/spec/sdk/fixtures/environment.json @@ -1,5 +1,6 @@ { "api_key": "B62qaMZNwfiqT76p38ggrQ", + "name": "Test environment", "project": { "name": "Test project", "organisation": { From 1a908fee90fc5f90e86fb1cf8f404081024bfa46 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 10 Nov 2025 20:47:57 +0100 Subject: [PATCH 22/25] feat: moved-mappers-to-engine-namespace --- lib/flagsmith/engine/core.rb | 3 +- lib/flagsmith/engine/environments/models.rb | 4 +- lib/flagsmith/engine/evaluation/mappers.rb | 68 ------------ .../engine/evaluation/mappers/environment.rb | 62 ----------- .../engine/evaluation/mappers/identity.rb | 100 ------------------ .../engine/evaluation/mappers/segments.rb | 68 ------------ lib/flagsmith/engine/mappers.rb | 66 ++++++++++++ lib/flagsmith/engine/mappers/environment.rb | 60 +++++++++++ lib/flagsmith/engine/mappers/identity.rb | 98 +++++++++++++++++ lib/flagsmith/engine/mappers/segments.rb | 66 ++++++++++++ .../unit/evaluation_context_mappers_spec.rb | 4 +- 11 files changed, 297 insertions(+), 302 deletions(-) delete mode 100644 lib/flagsmith/engine/evaluation/mappers.rb delete mode 100644 lib/flagsmith/engine/evaluation/mappers/environment.rb delete mode 100644 lib/flagsmith/engine/evaluation/mappers/identity.rb delete mode 100644 lib/flagsmith/engine/evaluation/mappers/segments.rb create mode 100644 lib/flagsmith/engine/mappers.rb create mode 100644 lib/flagsmith/engine/mappers/environment.rb create mode 100644 lib/flagsmith/engine/mappers/identity.rb create mode 100644 lib/flagsmith/engine/mappers/segments.rb diff --git a/lib/flagsmith/engine/core.rb b/lib/flagsmith/engine/core.rb index 530a3f4..3e64f1c 100644 --- a/lib/flagsmith/engine/core.rb +++ b/lib/flagsmith/engine/core.rb @@ -11,7 +11,8 @@ require_relative 'segments/evaluator' require_relative 'segments/models' require_relative 'utils/hash_func' -require_relative 'evaluation/mappers' +require_relative 'mappers' +require_relative 'evaluation/core' module Flagsmith module Engine diff --git a/lib/flagsmith/engine/environments/models.rb b/lib/flagsmith/engine/environments/models.rb index 41f7da4..174d1fe 100644 --- a/lib/flagsmith/engine/environments/models.rb +++ b/lib/flagsmith/engine/environments/models.rb @@ -8,7 +8,8 @@ class Environment attr_accessor :project, :feature_states, :amplitude_config, :segment_config, :mixpanel_config, :heap_config, :identity_overrides - def initialize(id:, api_key:, name: nil, project:, feature_states: [], identity_overrides: []) + # rubocop:disable Metrics/ParameterLists + def initialize(id:, api_key:, project:, name: nil, feature_states: [], identity_overrides: []) @id = id @api_key = api_key @name = name @@ -16,6 +17,7 @@ def initialize(id:, api_key:, name: nil, project:, feature_states: [], identity_ @feature_states = feature_states @identity_overrides = identity_overrides end + # rubocop:enable Metrics/ParameterLists class << self # rubocop:disable Metrics/MethodLength diff --git a/lib/flagsmith/engine/evaluation/mappers.rb b/lib/flagsmith/engine/evaluation/mappers.rb deleted file mode 100644 index 39a2aab..0000000 --- a/lib/flagsmith/engine/evaluation/mappers.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require_relative 'mappers/environment' -require_relative 'mappers/identity' -require_relative 'mappers/segments' - -module Flagsmith - module Engine - module Evaluation - # Mappers for converting between models and evaluation contexts - module Mappers - STRONGEST_PRIORITY = Float::INFINITY - WEAKEST_PRIORITY = -Float::INFINITY - - # @param environment [Flagsmith::Engine::Environment] The environment model - # @param identity [Flagsmith::Engine::Identity, nil] Optional identity model - # @param override_traits [Array, nil] Optional override traits - # @return [Hash] Evaluation context with environment, features, segments, and optionally identity - def self.get_evaluation_context(environment, identity = nil, override_traits = nil) - context = map_environment_model_to_evaluation_context(environment) - context[:identity] = map_identity_model_to_identity_context(identity, override_traits) if identity - context - end - - # Maps environment model to evaluation context - # - # @param environment [Flagsmith::Engine::Environment] The environment model - # @return [Hash] Context with :environment, :features, and :segments keys - def self.map_environment_model_to_evaluation_context(environment) - context = { - environment: Environment.build_environment_context(environment), - features: Environment.build_features_context(environment.feature_states), - segments: Segments.build_segments_context(environment.project.segments) - } - - context[:segments].merge!(Identity.map_overrides_to_segments(environment.identity_overrides)) if environment.identity_overrides&.any? - - context - end - - # Maps identity model to identity context - # - # @param identity [Flagsmith::Engine::Identity] The identity model - # @param override_traits [Array, nil] Optional override traits - # @return [Hash] Identity context with :identifier, :key, and :traits - def self.map_identity_model_to_identity_context(identity, override_traits = nil) - Identity.build_environment_context(identity, override_traits) - end - - # Maps segment rule model to rule hash - # - # @param rule [Flagsmith::Engine::Segments::Rule] The segment rule model - # @return [Hash] Mapped rule with :type, :conditions, and :rules - def self.map_segment_rule_model_to_rule(rule) - Segments.map_rule(rule) - 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) - Identity.map_overrides_to_segments(identity_overrides) - end - end - end - end -end diff --git a/lib/flagsmith/engine/evaluation/mappers/environment.rb b/lib/flagsmith/engine/evaluation/mappers/environment.rb deleted file mode 100644 index 92b669b..0000000 --- a/lib/flagsmith/engine/evaluation/mappers/environment.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -module Flagsmith - module Engine - module Evaluation - module Mappers - # Handles environment and feature mapping - module Environment - def self.build_environment_context(environment) - { - key: environment.api_key, - name: environment.name - } - end - - def self.build_features_context(feature_states) - features = {} - feature_states.each do |feature_state| - features[feature_state.feature.name] = build_feature_hash(feature_state) - end - features - end - - def self.build_feature_hash(feature_state) # rubocop:disable Metrics/MethodLength - feature_hash = { - key: feature_state.django_id&.to_s || feature_state.uuid, - feature_key: feature_state.feature.id.to_s, - name: feature_state.feature.name, - enabled: feature_state.enabled, - value: feature_state.get_value, - metadata: { flagsmith_id: feature_state.feature.id } - } - add_variants_to_feature(feature_hash, feature_state) - add_priority_to_feature(feature_hash, feature_state) - feature_hash - end - - def self.add_variants_to_feature(feature_hash, feature_state) - return unless feature_state.multivariate_feature_state_values&.any? - - feature_hash[:variants] = feature_state.multivariate_feature_state_values.map do |mv| - { - value: mv.multivariate_feature_option.value, - weight: mv.percentage_allocation, - priority: mv.id || uuid_to_big_int(mv.mv_fs_value_uuid) - } - end - end - - def self.uuid_to_big_int(uuid) - uuid.gsub('-', '').to_i(16) - end - - def self.add_priority_to_feature(feature_hash, feature_state) - priority = feature_state.feature_segment&.priority - feature_hash[:priority] = priority unless priority.nil? - end - end - end - end - end -end diff --git a/lib/flagsmith/engine/evaluation/mappers/identity.rb b/lib/flagsmith/engine/evaluation/mappers/identity.rb deleted file mode 100644 index 8c8c266..0000000 --- a/lib/flagsmith/engine/evaluation/mappers/identity.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -module Flagsmith - module Engine - module Evaluation - module Mappers - # Handles identity and override mapping - module Identity - def self.build_environment_context(identity, override_traits = nil) - traits = override_traits || identity.identity_traits - - { - identifier: identity.identifier, - key: identity.django_id&.to_s || identity.composite_key, - traits: build_traits_hash(traits) - } - end - - def self.build_traits_hash(traits) - traits_hash = {} - traits.each do |trait| - traits_hash[trait.trait_key] = trait.trait_value - end - traits_hash - end - - def self.map_overrides_to_segments(identity_overrides) - features_to_identifiers = group_by_overrides(identity_overrides) - build_identity_override_segments(features_to_identifiers) - end - - def self.group_by_overrides(identity_overrides) - features_to_identifiers = {} - - identity_overrides.each do |identity| - next if identity.identity_features.nil? || identity.identity_features.none? - - overrides_key = build_overrides_key(identity.identity_features) - overrides_hash = overrides_key.hash - - features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key } - features_to_identifiers[overrides_hash][:identifiers] << identity.identifier - end - - features_to_identifiers - end - - def self.build_overrides_key(identity_features) # rubocop:disable Metrics/MethodLength - sorted_features = identity_features.to_a.sort_by { |fs| fs.feature.name } - sorted_features.map do |feature_state| - { - feature_key: feature_state.feature.id.to_s, - name: feature_state.feature.name, - enabled: feature_state.enabled, - value: feature_state.get_value, - priority: Mappers::STRONGEST_PRIORITY, - metadata: { flagsmith_id: feature_state.feature.id } - } - end - end - - def self.build_identity_override_segments(features_to_identifiers) - segments = {} - - features_to_identifiers.each do |overrides_hash, data| - segment_key = "identity_override_#{overrides_hash}" - segments[segment_key] = build_identity_override_segment(segment_key, data) - end - - segments - end - - def self.build_identity_override_segment(segment_key, data) - { - key: segment_key, - name: 'identity_override', - rules: [build_identity_override_rule(data[:identifiers])], - metadata: { source: 'identity_override' }, - overrides: data[:overrides] - } - end - - def self.build_identity_override_rule(identifiers) # rubocop:disable Metrics/MethodLength - { - type: 'ALL', - conditions: [ - { - property: '$.identity.identifier', - operator: 'IN', - value: identifiers - } - ], - rules: [] - } - end - end - end - end - end -end diff --git a/lib/flagsmith/engine/evaluation/mappers/segments.rb b/lib/flagsmith/engine/evaluation/mappers/segments.rb deleted file mode 100644 index b281c14..0000000 --- a/lib/flagsmith/engine/evaluation/mappers/segments.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module Flagsmith - module Engine - module Evaluation - module Mappers - # Handles segment and rule mapping - module Segments - def self.build_segments_context(project_segments) - segments = {} - project_segments.each do |segment| - segments[segment.id.to_s] = build_segment_hash(segment) - end - segments - end - - def self.build_segment_hash(segment) - { - key: segment.id.to_s, - name: segment.name, - rules: segment.rules.map { |rule| map_rule(rule) }, - overrides: build_overrides(segment.feature_states), - metadata: { - source: 'API', - flagsmith_id: segment.id - } - } - end - - def self.build_overrides(feature_states) # rubocop:disable Metrics/MethodLength - feature_states.map do |feature_state| - override_hash = { - key: feature_state.django_id&.to_s || feature_state.uuid, - feature_key: feature_state.feature.id.to_s, - name: feature_state.feature.name, - enabled: feature_state.enabled, - value: feature_state.get_value, - metadata: { flagsmith_id: feature_state.feature.id } - } - add_priority_to_override(override_hash, feature_state) - override_hash - end - end - - def self.add_priority_to_override(override_hash, feature_state) - return unless feature_state.feature_segment&.priority - - override_hash[:priority] = feature_state.feature_segment.priority - end - - def self.map_rule(rule) - { - type: rule.type, - conditions: map_conditions(rule.conditions), - rules: (rule.rules || []).map { |nested_rule| map_rule(nested_rule) } - } - end - - def self.map_conditions(conditions) - (conditions || []).map do |condition| - { property: condition.property, operator: condition.operator, value: condition.value } - end - end - end - end - end - end -end diff --git a/lib/flagsmith/engine/mappers.rb b/lib/flagsmith/engine/mappers.rb new file mode 100644 index 0000000..d9baa8a --- /dev/null +++ b/lib/flagsmith/engine/mappers.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative 'mappers/environment' +require_relative 'mappers/identity' +require_relative 'mappers/segments' + +module Flagsmith + module Engine + # Mappers for converting between models and evaluation contexts + module Mappers + STRONGEST_PRIORITY = Float::INFINITY + WEAKEST_PRIORITY = -Float::INFINITY + + # @param environment [Flagsmith::Engine::Environment] The environment model + # @param identity [Flagsmith::Engine::Identity, nil] Optional identity model + # @param override_traits [Array, nil] Optional override traits + # @return [Hash] Evaluation context with environment, features, segments, and optionally identity + def self.get_evaluation_context(environment, identity = nil, override_traits = nil) + context = map_environment_model_to_evaluation_context(environment) + context[:identity] = map_identity_model_to_identity_context(identity, override_traits) if identity + context + end + + # Maps environment model to evaluation context + # + # @param environment [Flagsmith::Engine::Environment] The environment model + # @return [Hash] Context with :environment, :features, and :segments keys + def self.map_environment_model_to_evaluation_context(environment) + context = { + environment: Environment.build_environment_context(environment), + features: Environment.build_features_context(environment.feature_states), + segments: Segments.build_segments_context(environment.project.segments) + } + + context[:segments].merge!(Identity.map_overrides_to_segments(environment.identity_overrides)) if environment.identity_overrides&.any? + + context + end + + # Maps identity model to identity context + # + # @param identity [Flagsmith::Engine::Identity] The identity model + # @param override_traits [Array, nil] Optional override traits + # @return [Hash] Identity context with :identifier, :key, and :traits + def self.map_identity_model_to_identity_context(identity, override_traits = nil) + Identity.build_environment_context(identity, override_traits) + end + + # Maps segment rule model to rule hash + # + # @param rule [Flagsmith::Engine::Segments::Rule] The segment rule model + # @return [Hash] Mapped rule with :type, :conditions, and :rules + def self.map_segment_rule_model_to_rule(rule) + Segments.map_rule(rule) + 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) + Identity.map_overrides_to_segments(identity_overrides) + end + end + end +end diff --git a/lib/flagsmith/engine/mappers/environment.rb b/lib/flagsmith/engine/mappers/environment.rb new file mode 100644 index 0000000..399d2d3 --- /dev/null +++ b/lib/flagsmith/engine/mappers/environment.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module Mappers + # Handles environment and feature mapping + module Environment + def self.build_environment_context(environment) + { + key: environment.api_key, + name: environment.name + } + end + + def self.build_features_context(feature_states) + features = {} + feature_states.each do |feature_state| + features[feature_state.feature.name] = build_feature_hash(feature_state) + end + features + end + + def self.build_feature_hash(feature_state) # rubocop:disable Metrics/MethodLength + feature_hash = { + key: feature_state.django_id&.to_s || feature_state.uuid, + feature_key: feature_state.feature.id.to_s, + name: feature_state.feature.name, + enabled: feature_state.enabled, + value: feature_state.get_value, + metadata: { flagsmith_id: feature_state.feature.id } + } + add_variants_to_feature(feature_hash, feature_state) + add_priority_to_feature(feature_hash, feature_state) + feature_hash + end + + def self.add_variants_to_feature(feature_hash, feature_state) + return unless feature_state.multivariate_feature_state_values&.any? + + feature_hash[:variants] = feature_state.multivariate_feature_state_values.map do |mv| + { + value: mv.multivariate_feature_option.value, + weight: mv.percentage_allocation, + priority: mv.id || uuid_to_big_int(mv.mv_fs_value_uuid) + } + end + end + + def self.uuid_to_big_int(uuid) + uuid.gsub('-', '').to_i(16) + end + + def self.add_priority_to_feature(feature_hash, feature_state) + priority = feature_state.feature_segment&.priority + feature_hash[:priority] = priority unless priority.nil? + end + end + end + end +end diff --git a/lib/flagsmith/engine/mappers/identity.rb b/lib/flagsmith/engine/mappers/identity.rb new file mode 100644 index 0000000..a0c1c22 --- /dev/null +++ b/lib/flagsmith/engine/mappers/identity.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module Mappers + # Handles identity and override mapping + module Identity + def self.build_environment_context(identity, override_traits = nil) + traits = override_traits || identity.identity_traits + + { + identifier: identity.identifier, + key: identity.django_id&.to_s || identity.composite_key, + traits: build_traits_hash(traits) + } + end + + def self.build_traits_hash(traits) + traits_hash = {} + traits.each do |trait| + traits_hash[trait.trait_key] = trait.trait_value + end + traits_hash + end + + def self.map_overrides_to_segments(identity_overrides) + features_to_identifiers = group_by_overrides(identity_overrides) + build_identity_override_segments(features_to_identifiers) + end + + def self.group_by_overrides(identity_overrides) + features_to_identifiers = {} + + identity_overrides.each do |identity| + next if identity.identity_features.nil? || identity.identity_features.none? + + overrides_key = build_overrides_key(identity.identity_features) + overrides_hash = overrides_key.hash + + features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key } + features_to_identifiers[overrides_hash][:identifiers] << identity.identifier + end + + features_to_identifiers + end + + def self.build_overrides_key(identity_features) # rubocop:disable Metrics/MethodLength + sorted_features = identity_features.to_a.sort_by { |fs| fs.feature.name } + sorted_features.map do |feature_state| + { + feature_key: feature_state.feature.id.to_s, + name: feature_state.feature.name, + enabled: feature_state.enabled, + value: feature_state.get_value, + priority: Mappers::STRONGEST_PRIORITY, + metadata: { flagsmith_id: feature_state.feature.id } + } + end + end + + def self.build_identity_override_segments(features_to_identifiers) + segments = {} + + features_to_identifiers.each do |overrides_hash, data| + segment_key = "identity_override_#{overrides_hash}" + segments[segment_key] = build_identity_override_segment(segment_key, data) + end + + segments + end + + def self.build_identity_override_segment(segment_key, data) + { + key: segment_key, + name: 'identity_override', + rules: [build_identity_override_rule(data[:identifiers])], + metadata: { source: 'identity_override' }, + overrides: data[:overrides] + } + end + + def self.build_identity_override_rule(identifiers) # rubocop:disable Metrics/MethodLength + { + type: 'ALL', + conditions: [ + { + property: '$.identity.identifier', + operator: 'IN', + value: identifiers + } + ], + rules: [] + } + end + end + end + end +end diff --git a/lib/flagsmith/engine/mappers/segments.rb b/lib/flagsmith/engine/mappers/segments.rb new file mode 100644 index 0000000..09858f1 --- /dev/null +++ b/lib/flagsmith/engine/mappers/segments.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Flagsmith + module Engine + module Mappers + # Handles segment and rule mapping + module Segments + def self.build_segments_context(project_segments) + segments = {} + project_segments.each do |segment| + segments[segment.id.to_s] = build_segment_hash(segment) + end + segments + end + + def self.build_segment_hash(segment) + { + key: segment.id.to_s, + name: segment.name, + rules: segment.rules.map { |rule| map_rule(rule) }, + overrides: build_overrides(segment.feature_states), + metadata: { + source: 'API', + flagsmith_id: segment.id + } + } + end + + def self.build_overrides(feature_states) # rubocop:disable Metrics/MethodLength + feature_states.map do |feature_state| + override_hash = { + key: feature_state.django_id&.to_s || feature_state.uuid, + feature_key: feature_state.feature.id.to_s, + name: feature_state.feature.name, + enabled: feature_state.enabled, + value: feature_state.get_value, + metadata: { flagsmith_id: feature_state.feature.id } + } + add_priority_to_override(override_hash, feature_state) + override_hash + end + end + + def self.add_priority_to_override(override_hash, feature_state) + return unless feature_state.feature_segment&.priority + + override_hash[:priority] = feature_state.feature_segment.priority + end + + def self.map_rule(rule) + { + type: rule.type, + conditions: map_conditions(rule.conditions), + rules: (rule.rules || []).map { |nested_rule| map_rule(nested_rule) } + } + end + + def self.map_conditions(conditions) + (conditions || []).map do |condition| + { property: condition.property, operator: condition.operator, value: condition.value } + end + end + end + end + end +end diff --git a/spec/engine/unit/evaluation_context_mappers_spec.rb b/spec/engine/unit/evaluation_context_mappers_spec.rb index dbaa4fe..2cb0301 100644 --- a/spec/engine/unit/evaluation_context_mappers_spec.rb +++ b/spec/engine/unit/evaluation_context_mappers_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Flagsmith::Engine::Evaluation::Mappers do +RSpec.describe Flagsmith::Engine::Mappers do describe '.get_evaluation_context' do let(:environment_json) do JSON.parse( @@ -72,7 +72,7 @@ expect(override[:name]).to eq('some_feature') expect(override[:enabled]).to be false expect(override[:value]).to eq('some-overridden-value') - expect(override[:priority]).to eq(Flagsmith::Engine::Evaluation::Mappers::STRONGEST_PRIORITY) + expect(override[:priority]).to eq(Flagsmith::Engine::Mappers::STRONGEST_PRIORITY) expect(override[:metadata][:flagsmith_id]).to eq(1) # Verify features From 0c9650a03ef7e05ca3cacb9d81d05fd2417fc0db Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 11 Nov 2025 12:09:26 +0100 Subject: [PATCH 23/25] feat: removed-feature-key --- lib/flagsmith/engine/mappers/environment.rb | 1 - lib/flagsmith/engine/mappers/identity.rb | 1 - lib/flagsmith/engine/mappers/segments.rb | 1 - spec/engine-test-data | 2 +- spec/engine/unit/evaluation_context_mappers_spec.rb | 3 --- 5 files changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/flagsmith/engine/mappers/environment.rb b/lib/flagsmith/engine/mappers/environment.rb index 399d2d3..e822e16 100644 --- a/lib/flagsmith/engine/mappers/environment.rb +++ b/lib/flagsmith/engine/mappers/environment.rb @@ -23,7 +23,6 @@ def self.build_features_context(feature_states) def self.build_feature_hash(feature_state) # rubocop:disable Metrics/MethodLength feature_hash = { key: feature_state.django_id&.to_s || feature_state.uuid, - feature_key: feature_state.feature.id.to_s, name: feature_state.feature.name, enabled: feature_state.enabled, value: feature_state.get_value, diff --git a/lib/flagsmith/engine/mappers/identity.rb b/lib/flagsmith/engine/mappers/identity.rb index a0c1c22..0678985 100644 --- a/lib/flagsmith/engine/mappers/identity.rb +++ b/lib/flagsmith/engine/mappers/identity.rb @@ -48,7 +48,6 @@ def self.build_overrides_key(identity_features) # rubocop:disable Metrics/Method sorted_features = identity_features.to_a.sort_by { |fs| fs.feature.name } sorted_features.map do |feature_state| { - feature_key: feature_state.feature.id.to_s, name: feature_state.feature.name, enabled: feature_state.enabled, value: feature_state.get_value, diff --git a/lib/flagsmith/engine/mappers/segments.rb b/lib/flagsmith/engine/mappers/segments.rb index 09858f1..7020be1 100644 --- a/lib/flagsmith/engine/mappers/segments.rb +++ b/lib/flagsmith/engine/mappers/segments.rb @@ -30,7 +30,6 @@ def self.build_overrides(feature_states) # rubocop:disable Metrics/MethodLength feature_states.map do |feature_state| override_hash = { key: feature_state.django_id&.to_s || feature_state.uuid, - feature_key: feature_state.feature.id.to_s, name: feature_state.feature.name, enabled: feature_state.enabled, value: feature_state.get_value, diff --git a/spec/engine-test-data b/spec/engine-test-data index 41c2021..839e8d5 160000 --- a/spec/engine-test-data +++ b/spec/engine-test-data @@ -1 +1 @@ -Subproject commit 41c202145e375c712600e318c439456de5b221d7 +Subproject commit 839e8d5e5f2e9af6392062cf5e575d43c03770d4 diff --git a/spec/engine/unit/evaluation_context_mappers_spec.rb b/spec/engine/unit/evaluation_context_mappers_spec.rb index 2cb0301..0af9686 100644 --- a/spec/engine/unit/evaluation_context_mappers_spec.rb +++ b/spec/engine/unit/evaluation_context_mappers_spec.rb @@ -68,7 +68,6 @@ expect(override_condition[:value]).to include('overridden-id') override = identity_override_segment[:overrides][0] - expect(override[:feature_key]).to eq('1') expect(override[:name]).to eq('some_feature') expect(override[:enabled]).to be false expect(override[:value]).to eq('some-overridden-value') @@ -80,7 +79,6 @@ expect(context[:features]).to have_key('some_feature') some_feature = context[:features]['some_feature'] - expect(some_feature[:feature_key]).to eq('1') expect(some_feature[:name]).to eq('some_feature') expect(some_feature[:enabled]).to be true expect(some_feature[:value]).to eq('some-value') @@ -90,7 +88,6 @@ # Verify multivariate feature expect(context[:features]).to have_key('test_mv') test_mv = context[:features]['test_mv'] - expect(test_mv[:feature_key]).to eq('83755') expect(test_mv[:name]).to eq('test_mv') expect(test_mv[:enabled]).to be false expect(test_mv[:value]).to eq('1111') From 4b106a725f74bee5a7db3bb44c226961df440ed6 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 11 Nov 2025 12:11:42 +0100 Subject: [PATCH 24/25] feat: linter --- lib/flagsmith/engine/mappers/environment.rb | 2 +- lib/flagsmith/engine/mappers/identity.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/flagsmith/engine/mappers/environment.rb b/lib/flagsmith/engine/mappers/environment.rb index e822e16..75539f6 100644 --- a/lib/flagsmith/engine/mappers/environment.rb +++ b/lib/flagsmith/engine/mappers/environment.rb @@ -20,7 +20,7 @@ def self.build_features_context(feature_states) features end - def self.build_feature_hash(feature_state) # rubocop:disable Metrics/MethodLength + def self.build_feature_hash(feature_state) feature_hash = { key: feature_state.django_id&.to_s || feature_state.uuid, name: feature_state.feature.name, diff --git a/lib/flagsmith/engine/mappers/identity.rb b/lib/flagsmith/engine/mappers/identity.rb index 0678985..84c278c 100644 --- a/lib/flagsmith/engine/mappers/identity.rb +++ b/lib/flagsmith/engine/mappers/identity.rb @@ -44,7 +44,7 @@ def self.group_by_overrides(identity_overrides) features_to_identifiers end - def self.build_overrides_key(identity_features) # rubocop:disable Metrics/MethodLength + def self.build_overrides_key(identity_features) sorted_features = identity_features.to_a.sort_by { |fs| fs.feature.name } sorted_features.map do |feature_state| { From f1026a28e09e7f2ef88c60e8a7e9ceb59c3d45e4 Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Wed, 12 Nov 2025 10:19:02 +0100 Subject: [PATCH 25/25] feat: get evaluation get result (#88) * feat: added-engine-function-signatures * feat: moved-engine-to-core * feat: implemented-process-segment-overrides * feat: implemented-evalute-segments-partially * feat: implemented-should-apply-override * feat: implemented-get-identity-segments * feat: implemented-new-in-and-fixed-remaining-tests * feat: run-lint * feat: misc * feat: json-path-lib-implementation * remove dup * feat: made-legacy-functions-public * feat: updated-tests-to-match-engine-in-operator-accepting-numbers * feat: engine-agnostic-to-empty-identity-in-segment-evaluation * feat: renamed-to-is-higher-priority * feat: renamed-get-identity-segments-func * feat: reverted-to-is-primitive * feat: use-weakest-priority-constant * feat: upgraded-engine-test-data-and-fixed-mv-evaluation-bug * feat: removed-targeting-reason-func * feat: linter-rubocop-autocorrect * feat: linter * feat: linter * feat: moved-mappers-to-engine-namespace * feat: enrich-context-with-identity-key * feat: run-ci-on-all-branches * feat: removed-comments * feat!: sdk consumes context engine (#89) * fix: sdk-uses-new-engine-methods * feat: introduced-jsonpath-library * feat: fixed-conflict * Update lib/flagsmith/engine/segments/models.rb Co-authored-by: Kim Gustyr * Update lib/flagsmith/engine/segments/models.rb * feat: removed-normalize * feat: linter * feat: replaced-flagsmith-id-with-id * feat: removed-comments --------- Co-authored-by: Kim Gustyr --------- Co-authored-by: Gagan Trivedi Co-authored-by: Kim Gustyr --- .github/workflows/pull_request.yaml | 3 - Gemfile.lock | 4 + flagsmith.gemspec | 1 + lib/flagsmith.rb | 46 ++-- lib/flagsmith/engine/core.rb | 155 +++++++++----- lib/flagsmith/engine/evaluation/core.rb | 128 ++++++++++- lib/flagsmith/engine/features/constants.rb | 14 ++ lib/flagsmith/engine/features/models.rb | 2 +- lib/flagsmith/engine/mappers/environment.rb | 2 +- lib/flagsmith/engine/mappers/identity.rb | 2 +- lib/flagsmith/engine/mappers/segments.rb | 4 +- lib/flagsmith/engine/segments/evaluator.rb | 198 ++++++++++++++++-- lib/flagsmith/engine/segments/models.rb | 66 ++++-- lib/flagsmith/sdk/models/flags.rb | 34 ++- spec/engine/e2e/engine_spec.rb | 10 +- spec/engine/unit/core_spec.rb | 114 ---------- .../unit/evaluation_context_mappers_spec.rb | 6 +- spec/engine/unit/segments/evaluator_spec.rb | 29 --- spec/engine/unit/segments/models_spec.rb | 4 +- 19 files changed, 543 insertions(+), 279 deletions(-) create mode 100644 lib/flagsmith/engine/features/constants.rb delete mode 100644 spec/engine/unit/core_spec.rb delete mode 100644 spec/engine/unit/segments/evaluator_spec.rb diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index ed95b9d..9880607 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -14,9 +14,6 @@ on: - synchronize - reopened - ready_for_review - branches: - - main - - release/** push: branches: 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.rb b/lib/flagsmith.rb index 6e0464e..51ae62a 100644 --- a/lib/flagsmith.rb +++ b/lib/flagsmith.rb @@ -69,7 +69,6 @@ def initialize(config) api_client analytics_processor environment_data_polling_manager - engine load_offline_handler end @@ -99,10 +98,6 @@ def realtime_client @realtime_client ||= Flagsmith::RealtimeClient.new(@config) end - def engine - @engine ||= Flagsmith::Engine::Engine.new - end - def analytics_processor return nil unless @config.enable_analytics? @@ -211,21 +206,33 @@ def get_value_for_identity(feature_name, user_id = nil, default: nil) end def get_identity_segments(identifier, traits = {}) - unless environment - raise Flagsmith::ClientError, - 'Local evaluation or offline handler is required to obtain identity segments.' - end + raise Flagsmith::ClientError, 'Local evaluation or offline handler is required to obtain identity segments.' unless environment 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::Mappers.get_evaluation_context(environment, identity_model) + raise Flagsmith::ClientError, 'Local evaluation required to obtain identity segments' unless context + + evaluation_result = Flagsmith::Engine.get_evaluation_result(context) + evaluation_result[:segments].filter_map do |segment_result| + id = segment_result.dig(:metadata, :id) + Flagsmith::Segments::Segment.new(id: id, name: segment_result[:name]) if id + end end private - def environment_flags_from_document - Flagsmith::Flags::Collection.from_feature_state_models( - engine.get_environment_feature_states(environment), + def environment_flags_from_document # rubocop:disable Metrics/MethodLength + context = Flagsmith::Engine::Mappers.get_evaluation_context(environment) + + unless context + raise Flagsmith::ClientError, + 'Unable to get flags. No environment present.' + end + + evaluation_result = Flagsmith::Engine.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 @@ -234,12 +241,13 @@ def environment_flags_from_document def get_identity_flags_from_document(identifier, traits = {}) identity_model = get_identity_model(identifier, traits) + context = Flagsmith::Engine::Mappers.get_evaluation_context(environment, identity_model) + raise Flagsmith::ClientError, 'Unable to get flags. No environment present.' unless context - Flagsmith::Flags::Collection.from_feature_state_models( - engine.get_identity_feature_states(environment, identity_model), - identity_id: identity_model.composite_key, - analytics_processor: analytics_processor, - default_flag_handler: default_flag_handler, + evaluation_result = Flagsmith::Engine.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 ) end diff --git a/lib/flagsmith/engine/core.rb b/lib/flagsmith/engine/core.rb index 3e64f1c..dded5a5 100644 --- a/lib/flagsmith/engine/core.rb +++ b/lib/flagsmith/engine/core.rb @@ -5,6 +5,7 @@ require_relative 'environments/models' require_relative 'features/models' +require_relative 'features/constants' require_relative 'identities/models' require_relative 'organisations/models' require_relative 'projects/models' @@ -12,79 +13,135 @@ require_relative 'segments/models' require_relative 'utils/hash_func' require_relative 'mappers' -require_relative 'evaluation/core' module Flagsmith + # Core evaluation logic for feature flags module Engine - # Flags engine methods - 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 + 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 + # @return [Hash] Evaluation result with flags and segments + def get_evaluation_result(evaluation_context) + evaluation_context = get_enriched_context(evaluation_context) + segments, segment_overrides = evaluate_segments(evaluation_context) + flags = evaluate_features(evaluation_context, segment_overrides) + { + flags: flags, + segments: segments + } + end - feature_state = feature_states.find { |f| f.feature.name == feature_name } + # Returns { segments: EvaluationResultSegments; segmentOverrides: Record; } + def evaluate_segments(evaluation_context) + return [], {} if evaluation_context[:segments].nil? - raise Flagsmith::FeatureStateNotFound, 'Feature State Not Found' if feature_state.nil? + identity_segments = get_segments_from_context(evaluation_context) - feature_state + segments = identity_segments.map do |segment| + { name: segment[:name], metadata: segment[:metadata] }.compact end - def get_identity_feature_states(environment, identity, override_traits = nil) - feature_states = get_identity_feature_states_dict(environment, identity, override_traits).values + segment_overrides = process_segment_overrides(identity_segments) + + [segments, segment_overrides] + end - return feature_states.select(&:enabled?) if environment.project.hide_disabled_flags + # Returns Record + def process_segment_overrides(identity_segments) # rubocop:disable Metrics/MethodLength + segment_overrides = {} - feature_states + identity_segments.each do |segment| + Array(segment[:overrides]).each do |override| + next unless should_apply_override(override, segment_overrides) + + segment_overrides[override[:name]] = { + feature: override, + segment_name: segment[:name] + } + end end - def get_environment_feature_state(environment, feature_name) - features_state = environment.feature_states.find { |f| f.feature.name == feature_name } + segment_overrides + end - raise Flagsmith::FeatureStateNotFound, 'Feature State Not Found' if features_state.nil? + def evaluate_features(evaluation_context, segment_overrides) + identity_key = get_identity_key(evaluation_context) - features_state + (evaluation_context[:features] || {}).each_with_object({}) do |(_, feature), flags| + segment_override = segment_overrides[feature[:name]] + final_feature = segment_override ? segment_override[:feature] : feature + + flag_result = build_flag_result(final_feature, identity_key, segment_override) + flags[final_feature[:name].to_sym] = flag_result end + end - def get_environment_feature_states(environment) - return environment.feature_states.select(&:enabled?) if environment.project.hide_disabled_flags + # Returns {value: any; reason?: string} + def evaluate_feature_value(feature, identity_key = nil) + return get_multivariate_feature_value(feature, identity_key) if feature[:variants]&.any? && identity_key - environment.feature_states - end + { value: feature[:value], reason: nil } + end - private + # 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] || WEAKEST_PRIORITY } - 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) + variant = find_matching_variant(sorted_variants, percentage_value) + variant || { value: feature[:value], reason: nil } + end - override_by_matching_segments(environment, identity, override_traits) do |fs| - override.call(fs) unless higher_segment_priority?(feature_states, fs) - end + def find_matching_variant(sorted_variants, percentage_value) + start_percentage = 0 + sorted_variants.each do |variant| + limit = start_percentage + variant[:weight] + return { value: variant[:value], reason: "#{TARGETING_REASON_SPLIT}; weight=#{variant[:weight]}" } if start_percentage <= percentage_value && percentage_value < limit - # Override with any feature states defined directly the identity - identity.identity_features.each(&override) - feature_states + start_percentage = limit end + nil + 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 + # returns boolean + def should_apply_override(override, existing_overrides) + current_override = existing_overrides[override[:name]] + !current_override || stronger_priority?(override[:priority], current_override[:feature][:priority]) + 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 + private + + def build_flag_result(feature, identity_key, segment_override) + evaluated = evaluate_feature_value(feature, identity_key) + + flag_result = { + name: feature[:name], + enabled: feature[:enabled], + value: evaluated[:value], + reason: evaluated[:reason] || (segment_override ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}" : TARGETING_REASON_DEFAULT) + } + + flag_result[:metadata] = feature[:metadata] if feature[:metadata] + flag_result + end + + # 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) + return nil unless evaluation_context[:identity] + + evaluation_context[:identity][:key] + end + + def stronger_priority?(priority_a, priority_b) + (priority_a || WEAKEST_PRIORITY) < (priority_b || WEAKEST_PRIORITY) end end end diff --git a/lib/flagsmith/engine/evaluation/core.rb b/lib/flagsmith/engine/evaluation/core.rb index 0ddc342..5a7fe58 100644 --- a/lib/flagsmith/engine/evaluation/core.rb +++ b/lib/flagsmith/engine/evaluation/core.rb @@ -1,20 +1,140 @@ # 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 for feature flags 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 # @return [Hash] Evaluation result with flags and segments - def self.get_evaluation_result(_evaluation_context) - # TODO: Implement core evaluation logic + def get_evaluation_result(evaluation_context) + evaluation_context = get_enriched_context(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) + return [], {} if evaluation_context[:segments].nil? + + identity_segments = get_segments_from_context(evaluation_context) + + segments = identity_segments.map do |segment| + { name: segment[:name], metadata: segment[:metadata] }.compact + end + + segment_overrides = process_segment_overrides(identity_segments) + + [segments, segment_overrides] + end + + # Returns Record + def process_segment_overrides(identity_segments) # rubocop:disable Metrics/MethodLength + segment_overrides = {} + + identity_segments.each do |segment| + Array(segment[:overrides]).each do |override| + next unless should_apply_override(override, segment_overrides) + + segment_overrides[override[:name]] = { + feature: override, + segment_name: segment[:name] + } + end + end + + segment_overrides + end + + def evaluate_features(evaluation_context, segment_overrides) + identity_key = get_identity_key(evaluation_context) + + (evaluation_context[:features] || {}).each_with_object({}) do |(_, feature), flags| + segment_override = segment_overrides[feature[:name]] + final_feature = segment_override ? segment_override[:feature] : feature + + flag_result = build_flag_result(final_feature, identity_key, segment_override) + flags[final_feature[:name].to_sym] = flag_result + end + end + + # Returns {value: any; reason?: string} + def evaluate_feature_value(feature, identity_key = nil) + return get_multivariate_feature_value(feature, identity_key) if feature[:variants]&.any? && identity_key + + { value: feature[:value], reason: nil } + end + + # 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] || WEAKEST_PRIORITY } + + variant = find_matching_variant(sorted_variants, percentage_value) + variant || { value: feature[:value], reason: nil } + end + + def find_matching_variant(sorted_variants, percentage_value) + start_percentage = 0 + sorted_variants.each do |variant| + limit = start_percentage + variant[:weight] + return { value: variant[:value], reason: "#{TARGETING_REASON_SPLIT}; weight=#{variant[:weight]}" } if start_percentage <= percentage_value && percentage_value < limit + + start_percentage = limit + end + nil + end + + # returns boolean + def should_apply_override(override, existing_overrides) + current_override = existing_overrides[override[:name]] + !current_override || stronger_priority?(override[:priority], current_override[:feature][:priority]) + end + + private + + def build_flag_result(feature, identity_key, segment_override) + evaluated = evaluate_feature_value(feature, identity_key) + + flag_result = { + name: feature[:name], + enabled: feature[:enabled], + value: evaluated[:value], + reason: evaluated[:reason] || (segment_override ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}" : TARGETING_REASON_DEFAULT) } + + flag_result[:metadata] = feature[:metadata] if feature[:metadata] + flag_result + end + + # 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) + return nil unless evaluation_context[:identity] + + evaluation_context[:identity][:key] || + "#{evaluation_context[:environment][:key]}_#{evaluation_context[:identity][:identifier]}" + end + + # returns boolean + def stronger_priority?(priority_a, priority_b) + (priority_a || WEAKEST_PRIORITY) < (priority_b || WEAKEST_PRIORITY) 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/features/models.rb b/lib/flagsmith/engine/features/models.rb index d6d0ff2..de7ae0f 100644 --- a/lib/flagsmith/engine/features/models.rb +++ b/lib/flagsmith/engine/features/models.rb @@ -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 diff --git a/lib/flagsmith/engine/mappers/environment.rb b/lib/flagsmith/engine/mappers/environment.rb index 75539f6..4931ace 100644 --- a/lib/flagsmith/engine/mappers/environment.rb +++ b/lib/flagsmith/engine/mappers/environment.rb @@ -26,7 +26,7 @@ def self.build_feature_hash(feature_state) name: feature_state.feature.name, enabled: feature_state.enabled, value: feature_state.get_value, - metadata: { flagsmith_id: feature_state.feature.id } + metadata: { id: feature_state.feature.id } } add_variants_to_feature(feature_hash, feature_state) add_priority_to_feature(feature_hash, feature_state) diff --git a/lib/flagsmith/engine/mappers/identity.rb b/lib/flagsmith/engine/mappers/identity.rb index 84c278c..45dc374 100644 --- a/lib/flagsmith/engine/mappers/identity.rb +++ b/lib/flagsmith/engine/mappers/identity.rb @@ -52,7 +52,7 @@ def self.build_overrides_key(identity_features) enabled: feature_state.enabled, value: feature_state.get_value, priority: Mappers::STRONGEST_PRIORITY, - metadata: { flagsmith_id: feature_state.feature.id } + metadata: { id: feature_state.feature.id } } end end diff --git a/lib/flagsmith/engine/mappers/segments.rb b/lib/flagsmith/engine/mappers/segments.rb index 7020be1..f842e6f 100644 --- a/lib/flagsmith/engine/mappers/segments.rb +++ b/lib/flagsmith/engine/mappers/segments.rb @@ -21,7 +21,7 @@ def self.build_segment_hash(segment) overrides: build_overrides(segment.feature_states), metadata: { source: 'API', - flagsmith_id: segment.id + id: segment.id } } end @@ -33,7 +33,7 @@ def self.build_overrides(feature_states) # rubocop:disable Metrics/MethodLength name: feature_state.feature.name, enabled: feature_state.enabled, value: feature_state.get_value, - metadata: { flagsmith_id: feature_state.feature.id } + metadata: { id: feature_state.feature.id } } add_priority_to_override(override_hash, feature_state) override_hash diff --git a/lib/flagsmith/engine/segments/evaluator.rb b/lib/flagsmith/engine/segments/evaluator.rb index cc4a2f0..c5d55cb 100644 --- a/lib/flagsmith/engine/segments/evaluator.rb +++ b/lib/flagsmith/engine/segments/evaluator.rb @@ -1,22 +1,65 @@ # frozen_string_literal: true +require 'json' +require 'jsonpath' require_relative 'constants' +require_relative 'models' require_relative '../utils/hash_func' module Flagsmith module Engine module Segments # Evaluator methods - module Evaluator + module Evaluator # rubocop:disable Metrics/ModuleLength include Flagsmith::Engine::Segments::Constants include Flagsmith::Engine::Utils::HashFunc - def get_identity_segments(environment, identity, override_traits = nil) - environment.project.segments.select do |s| - evaluate_identity_in_segment(identity, s, override_traits) + module_function + + def get_enriched_context(context) + identity_context = context[:identity] + return context unless identity_context + return context if identity_context[:key] + + enriched_context = context.dup + enriched_context[:identity] = identity_context.merge( + key: "#{context[:environment][:key]}_#{identity_context[:identifier]}" + ) + enriched_context + end + + # Context-based segment evaluation + # 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_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? + + matches = segment[:rules].all? { |rule| traits_match_segment_rule_from_context(rule, segment[:key], context) } + matches end end + 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 + # Evaluates whether a given identity is in the provided segment. # # :param identity: identity model object to evaluate @@ -54,28 +97,149 @@ def traits_match_segment_rule(identity_traits, rule, 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 + # Context-based helper functions + + # 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 unless rule[:conditions]&.any? + + condition_results = rule[:conditions].map do |condition| + traits_match_segment_condition_from_context(condition, segment_key, context) end - trait = identity_traits.find { |t| t.key.to_s == condition.property } + evaluate_rule_conditions(rule[:type], condition_results) + end - return handle_trait_existence_conditions(trait, condition.operator) if [IS_SET, - IS_NOT_SET].include?(condition.operator) + # 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? - return condition.match_trait_value?(trait.trait_value) if trait + rule[:rules].all? do |sub_rule| + traits_match_segment_rule_from_context(sub_rule, segment_key, context) + end + end - false + # 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) + return handle_percentage_split(condition, segment_key, context) if condition[:operator] == PERCENTAGE_SPLIT + return false if condition[:property].nil? + + trait_value = get_trait_value(condition[:property], context) + evaluate_trait_condition(condition, trait_value) + end + + def handle_percentage_split(condition, segment_key, context) + 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]) + hashed_percentage <= condition[:value].to_f end - private + def evaluate_trait_condition(condition, trait_value) + return !trait_value.nil? if condition[:operator] == IS_SET + return trait_value.nil? if condition[:operator] == IS_NOT_SET + return false if trait_value.nil? - def handle_trait_existence_conditions(matching_trait, operator) - return operator == IS_NOT_SET if matching_trait.nil? + condition_obj = Flagsmith::Engine::Segments::Condition.new( + operator: condition[:operator], + value: condition[:value], + property: condition[:property] + ) + condition_obj.match_trait_value?(trait_value) + 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) + if property.start_with?('$.') + context_value = get_context_value(property, context) + return context_value if !context_value.nil? && primitive?(context_value) + end + + traits = context.dig(:identity, :traits) || {} + traits[property] || traits[property.to_sym] + end + + # 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?('$.') + + results = JsonPath.new(json_path, use_symbols: true).on(context) + results.first + 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] + end + + # Check if value is primitive (not an object or array) + # + # @param value [Object] The value to check + # @return [Boolean] True if value is primitive (not an object or array) + def primitive?(value) + return true if value.nil? - operator == IS_SET + !(value.is_a?(Hash) || value.is_a?(Array)) end end end diff --git a/lib/flagsmith/engine/segments/models.rb b/lib/flagsmith/engine/segments/models.rb index f339112..70a6d03 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,30 +55,48 @@ 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$/) + trait_value = parse_semver_trait_value(trait_value) + return false if trait_value.nil? 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 + match_with_type_conversion(trait_value) + end + + def parse_semver_trait_value(trait_value) + return trait_value unless @value.is_a?(String) && @value.match?(/:semver$/) + + Semantic::Version.new(trait_value.to_s.gsub(/:semver$/, '')) + rescue ArgumentError, Semantic::Version::ValidationFailed + nil + end + + def match_with_type_conversion(trait_value) type_as_trait_value = format_to_type_of(trait_value) formatted_value = type_as_trait_value ? type_as_trait_value.call(@value) : @value - MATCHING_FUNCTIONS[operator]&.call(trait_value, formatted_value) end - # rubocop:disable Metrics/AbcSize + TYPE_CONVERTERS = { + 'String' => ->(v) { v.to_s }, + 'Semantic::Version' => ->(v) { Semantic::Version.new(v.to_s.gsub(/:semver$/, '')) }, + 'TrueClass' => ->(v) { ['True', 'true', 'TRUE', true, 1, '1'].include?(v) }, + 'FalseClass' => ->(v) { !['False', 'false', 'FALSE', false].include?(v) }, + 'Integer' => lambda { |v| + i = v.to_i + i.to_s == v.to_s ? i : v + }, + 'Float' => lambda { |v| + f = v.to_f + f.to_s == v.to_s ? f : v + } + }.freeze + def format_to_type_of(input) - { - 'String' => ->(v) { v.to_s }, - 'Semantic::Version' => ->(v) { Semantic::Version.new(v.to_s.gsub(/:semver$/, '')) }, - '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 } - }[input.class.to_s] + TYPE_CONVERTERS[input.class.to_s] end - # rubocop:enable Metrics/AbcSize def match_modulo_value(trait_value) divisor, remainder = @value.split('|') @@ -88,9 +106,25 @@ 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 invalid_in_value?(trait_value) + return @value.include?(trait_value.to_s) if @value.is_a?(Array) - false + parse_and_match_string_value(trait_value) + end + + def invalid_in_value?(trait_value) + trait_value.nil? || [TrueClass, FalseClass].any? { |klass| trait_value.is_a?(klass) } + end + + def parse_and_match_string_value(trait_value) # rubocop:disable Metrics/AbcSize + return @value.to_s.split(',').include?(trait_value.to_s) unless @value.is_a?(String) + + parsed = JSON.parse(@value) + return parsed.include?(trait_value.to_s) if parsed.is_a?(Array) + + @value.to_s.split(',').include?(trait_value.to_s) + rescue JSON::ParserError + @value.to_s.split(',').include?(trait_value.to_s) end class << self diff --git a/lib/flagsmith/sdk/models/flags.rb b/lib/flagsmith/sdk/models/flags.rb index d851c98..59cea07 100644 --- a/lib/flagsmith/sdk/models/flags.rb +++ b/lib/flagsmith/sdk/models/flags.rb @@ -32,12 +32,13 @@ def initialize(enabled:, value:) # 'live' Flag class as returned by API or local evaluation class Flag < BaseFlag - attr_reader :feature_name, :feature_id + attr_reader :feature_name, :feature_id, :reason - def initialize(feature_name:, enabled:, value:, feature_id:) + def initialize(feature_name:, enabled:, value:, feature_id:, reason: nil) super(enabled: enabled, value: value, default: false) @feature_name = feature_name @feature_id = feature_id + @reason = reason end def <=>(other) @@ -54,7 +55,8 @@ def to_h feature_name: feature_name, value: value, enabled: enabled, - default: default + default: default, + reason: reason } end @@ -172,15 +174,27 @@ def from_api(json_data, **args) ) end - def from_feature_state_models(feature_states, identity_id: nil, **args) - to_flag_object = lambda { |feature_state, acc| - acc[normalize_key(feature_state.feature.name)] = - Flagsmith::Flags::Flag.from_feature_state_model(feature_state, identity_id) + def from_evaluation_result(evaluation_result, **args) + to_flag_object = lambda { |flag_result, acc| + id = flag_result.dig(:metadata, :id) + if id.nil? + raise Flagsmith::ClientError, + "FlagResult metadata.id is missing for feature \"#{flag_result[:name]}\". This indicates a bug in the SDK, please report it." + end + + acc[flag_result[:name]] = Collection.map_evaluated_flag_to_flag_result(flag_result, id) } - new( - feature_states.each_with_object({}, &to_flag_object), - **args + new(evaluation_result[:flags].each_value.each_with_object({}, &to_flag_object), **args) + end + + def map_evaluated_flag_to_flag_result(flag_result, id) + Flagsmith::Flags::Flag.new( + feature_name: flag_result[:name], + enabled: flag_result[:enabled], + value: flag_result[:value], + feature_id: id, + reason: flag_result[:reason] ) end diff --git a/spec/engine/e2e/engine_spec.rb b/spec/engine/e2e/engine_spec.rb index 77c9f92..8fe8703 100644 --- a/spec/engine/e2e/engine_spec.rb +++ b/spec/engine/e2e/engine_spec.rb @@ -8,8 +8,6 @@ def get_test_files end def parse_jsonc(content) - # Simple JSONC parser: remove single-line comments - # JSON.parse will handle the rest cleaned = content.lines.reject { |line| line.strip.start_with?('//') }.join JSON.parse(cleaned, symbolize_names: true) end @@ -34,12 +32,10 @@ def load_test_file(filepath) test_evaluation_context = test_case[:context] test_expected_result = test_case[:result] - # TODO: Implement evaluation logic - evaluation_result = {} + evaluation_result = Flagsmith::Engine.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]) + expect(evaluation_result[:segments]).to eq(test_expected_result[:segments]) end end end diff --git a/spec/engine/unit/core_spec.rb b/spec/engine/unit/core_spec.rb deleted file mode 100644 index ad94a63..0000000 --- a/spec/engine/unit/core_spec.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Flagsmith::Engine, type: :model do - subject { Flagsmith::Engine::Engine.new } - - it 'test_identity_get_feature_state_without_any_override' do - feature_state = subject.get_identity_feature_state( - environment, identity, feature1.name - ) - - expect(feature_state.feature).to eq(feature1) - end - - it 'test_identity_get_feature_state_without_any_override_no_fs' do - expect { - subject.get_identity_feature_state(environment, identity, 'nonExistentName') - }.to raise_error(Flagsmith::FeatureStateNotFound) - end - - it 'test_identity_get_all_feature_states_no_segments' do - env = environment - ident = identity - overridden_feature = Flagsmith::Engine::Feature.new( - id: 3, name: 'overridden_feature', type: 'STANDARD' - ) - - env.feature_states << Flagsmith::Engine::FeatureState.new( - feature: overridden_feature, enabled: false, django_id: 3 - ) - - ident.identity_features << Flagsmith::Engine::FeatureState.new( - feature: overridden_feature, enabled: true, id: 4 - ) - - feature_states = subject.get_identity_feature_states(env, ident) - - expect(feature_states.length).to eq(3) - - feature_states.each do |feature_state| - environment_feature_state = get_environment_feature_state_for_feature( - env, feature_state.feature - ) - expected = - if environment_feature_state&.feature == overridden_feature then true - else environment_feature_state&.enabled - end - expect(feature_state.enabled?).to eq(expected) - end - end - - it 'test_identity_get_all_feature_states_with_traits' do - trait_models = [Flagsmith::Engine::Identities::Trait.new( - trait_key: Engine::Builders::SEGMENT_CONDITION_PROPERTY, - trait_value: Engine::Builders::SEGMENT_CONDITION_STRING_VALUE - )] - - feature_states = subject.get_identity_feature_states( - environment_with_segment_override, identity_in_segment, trait_models - ) - - expect(feature_states.first.get_value).to eq(Engine::Builders::SEGMENT_OVERRIDE_FEATURE_STATE_VALUE) - end - - it 'test_identity_get_all_feature_states_with_traits_hideDisabledFlags' do - trait_models = [Flagsmith::Engine::Identities::Trait.new( - trait_key: Engine::Builders::SEGMENT_CONDITION_PROPERTY, - trait_value: Engine::Builders::SEGMENT_CONDITION_STRING_VALUE - )] - - env = environment_with_segment_override - env.project.hide_disabled_flags = true - - feature_states = subject.get_identity_feature_states( - env, identity_in_segment, trait_models - ) - expect(feature_states.length).to eq(0) - end - - it 'test_environment_get_all_feature_states' do - env = environment - feature_states = subject.get_environment_feature_states(env) - - expect(feature_states).to eq(env.feature_states) - end - - it 'test_environment_get_feature_states_hides_disabled_flags_if_enabled' do - env = environment - - env.project.hide_disabled_flags = true - - feature_states = subject.get_environment_feature_states(env) - - expect(feature_states).to_not eq(env.feature_states) - feature_states.each do |fs| - expect(fs.enabled).to be_truthy - end - end - - it 'test_environment_get_feature_state' do - env = environment - feature = feature1 - feature_state = subject.get_environment_feature_state(env, feature.name) - - expect(feature_state.feature).to eq(feature) - end - - it 'test_environment_get_feature_state_raises_feature_state_not_found' do - expect { - subject.get_environment_feature_state(environment, 'not_a_feature_name') - }.to raise_error(Flagsmith::FeatureStateNotFound) - end -end diff --git a/spec/engine/unit/evaluation_context_mappers_spec.rb b/spec/engine/unit/evaluation_context_mappers_spec.rb index 0af9686..ce422b7 100644 --- a/spec/engine/unit/evaluation_context_mappers_spec.rb +++ b/spec/engine/unit/evaluation_context_mappers_spec.rb @@ -33,7 +33,7 @@ expect(segment[:rules].length).to eq(1) expect(segment[:overrides]).to be_empty.or be_an(Array) expect(segment[:metadata][:source]).to eq('API') - expect(segment[:metadata][:flagsmith_id]).to eq(1) + expect(segment[:metadata][:id]).to eq(1) # Verify segment rules expect(segment[:rules][0][:type]).to eq('ALL') @@ -72,7 +72,7 @@ expect(override[:enabled]).to be false expect(override[:value]).to eq('some-overridden-value') expect(override[:priority]).to eq(Flagsmith::Engine::Mappers::STRONGEST_PRIORITY) - expect(override[:metadata][:flagsmith_id]).to eq(1) + expect(override[:metadata][:id]).to eq(1) # Verify features expect(context[:features]).to be_a(Hash) @@ -83,7 +83,7 @@ expect(some_feature[:enabled]).to be true expect(some_feature[:value]).to eq('some-value') expect(some_feature[:priority]).to be_nil - expect(some_feature[:metadata][:flagsmith_id]).to eq(1) + expect(some_feature[:metadata][:id]).to eq(1) # Verify multivariate feature expect(context[:features]).to have_key('test_mv') diff --git a/spec/engine/unit/segments/evaluator_spec.rb b/spec/engine/unit/segments/evaluator_spec.rb deleted file mode 100644 index d89106b..0000000 --- a/spec/engine/unit/segments/evaluator_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -# list of test cases containing: operator, property, value, traits (list of dicts), expected_result -TEST_CASES = [ - ['IS_SET', 'foo', nil, {}, false], - ['IS_SET', 'foo', nil, {'foo': 'bar'}, true], - ['IS_NOT_SET', 'foo', nil, {}, true], - ['IS_NOT_SET', 'foo', nil, {'foo': 'bar'}, false], -] - -RSpec.describe Flagsmith::Engine::Segments::Evaluator do - subject { Class.new { extend Flagsmith::Engine::Segments::Evaluator } } - - TEST_CASES.each do |(operator, property, value, traits, expected_result)| - it "traits: #{traits} #traits_match_segment_condition(#{operator}, #{property}, #{value || 'No value'}) should be #{expected_result}" do - condition = Flagsmith::Engine::Segments::Condition.new( - operator: operator, property: property, value: value - ) - trait_models = traits.map { - |k,v| Flagsmith::Engine::Identities::Trait.new(trait_key: k, trait_value: v) - } - - expect(subject.traits_match_segment_condition( - trait_models, condition, 1, 1)).to eq(expected_result) - end - end -end diff --git a/spec/engine/unit/segments/models_spec.rb b/spec/engine/unit/segments/models_spec.rb index 517657a..1c50637 100644 --- a/spec/engine/unit/segments/models_spec.rb +++ b/spec/engine/unit/segments/models_spec.rb @@ -94,9 +94,7 @@ ['IN', 1, '1,2,3,4', true], ['IN', 1, '', false], ['IN', 1, '1', true], - # Flagsmith's engine does not evaluate `IN` condition for floats/doubles and booleans - # due to ambiguous serialization across supported platforms. - ['IN', 1.5, '1.5', false], + ['IN', 1.5, '1.5', true], ['IN', false, 'false', false], ].freeze