From b543c7ba6158cfea73fb86a24a6b70fb4ab16c58 Mon Sep 17 00:00:00 2001 From: Max Komarychev Date: Wed, 1 Apr 2026 23:37:18 +0200 Subject: [PATCH 1/3] fix: Preserve native types for multivariate feature option values MultivariateFeatureOptionModel.value was typed as String, causing Jackson to coerce boolean and integer JSON values to their string representations (e.g. true -> "true", 42 -> "42") during deserialization of the environment document. This affected only the local evaluation path where multivariate variant values flow through EngineMappers into the flag engine. The control value (feature_state_value) was unaffected because FeatureStateModel already uses Object for its value field. Changing the field type to Object lets Jackson preserve the original JSON type through the entire evaluation pipeline. --- .../MultivariateFeatureOptionModel.java | 2 +- .../unit/mappers/EngineMappersTest.java | 93 ++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/flagsmith/models/features/MultivariateFeatureOptionModel.java b/src/main/java/com/flagsmith/models/features/MultivariateFeatureOptionModel.java index 115618a3..b0c47498 100644 --- a/src/main/java/com/flagsmith/models/features/MultivariateFeatureOptionModel.java +++ b/src/main/java/com/flagsmith/models/features/MultivariateFeatureOptionModel.java @@ -5,5 +5,5 @@ @Data public class MultivariateFeatureOptionModel extends BaseModel { - private String value; + private Object value; } \ No newline at end of file diff --git a/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java b/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java index 27e59b7f..c160fbb1 100644 --- a/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java +++ b/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java @@ -1,20 +1,28 @@ package com.flagsmith.flagengine.unit.mappers; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.flagsmith.MapperFactory; +import com.flagsmith.flagengine.EvaluationContext; +import com.flagsmith.flagengine.FeatureContext; +import com.flagsmith.flagengine.FeatureValue; +import com.flagsmith.flagengine.Traits; import com.flagsmith.mappers.EngineMappers; import com.flagsmith.models.TraitConfig; +import com.flagsmith.models.environments.EnvironmentModel; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import java.util.List; import java.util.Map; import java.util.stream.Stream; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import com.flagsmith.FlagsmithTestHelper; -import com.flagsmith.flagengine.EvaluationContext; -import com.flagsmith.flagengine.Traits; public class EngineMappersTest { private static Stream expectedTraitMaps() { @@ -49,4 +57,85 @@ public void testMapContextAndIdentityDataToContext_returnsExpectedContext( expectedTraits.getAdditionalProperties(), mappedContext.getIdentity().getTraits().getAdditionalProperties()); } + + @Test + public void testMapEnvironmentToContext_preservesMultivariateValueTypes() + throws JsonProcessingException { + String environmentJson = "{\n" + + " \"api_key\": \"test-key\",\n" + + " \"name\": \"Test\",\n" + + " \"project\": {\n" + + " \"name\": \"Test project\",\n" + + " \"organisation\": {\n" + + " \"feature_analytics\": false,\n" + + " \"name\": \"Test Org\",\n" + + " \"id\": 1,\n" + + " \"persist_trait_data\": true,\n" + + " \"stop_serving_flags\": false\n" + + " },\n" + + " \"id\": 1,\n" + + " \"hide_disabled_flags\": false,\n" + + " \"segments\": []\n" + + " },\n" + + " \"segment_overrides\": [],\n" + + " \"id\": 1,\n" + + " \"feature_states\": [\n" + + " {\n" + + " \"feature_state_value\": true,\n" + + " \"django_id\": 1,\n" + + " \"featurestate_uuid\": \"40eb539d-3713-4720-bbd4-829dbef10d51\",\n" + + " \"feature\": { \"name\": \"mv_feature\", \"type\": \"MULTIVARIATE\", \"id\": 1 },\n" + + " \"enabled\": true,\n" + + " \"multivariate_feature_state_values\": [\n" + + " {\n" + + " \"id\": 1,\n" + + " \"multivariate_feature_option\": { \"value\": false },\n" + + " \"percentage_allocation\": 50.0,\n" + + " \"mv_fs_value_uuid\": \"808cba14-03ca-4835-a7f7-58387f01f87d\"\n" + + " },\n" + + " {\n" + + " \"id\": 2,\n" + + " \"multivariate_feature_option\": { \"value\": 42 },\n" + + " \"percentage_allocation\": 30.0,\n" + + " \"mv_fs_value_uuid\": \"918dbb25-14db-4946-b8a8-69488f02f98e\"\n" + + " },\n" + + " {\n" + + " \"id\": 3,\n" + + " \"multivariate_feature_option\": { \"value\": \"a string\" },\n" + + " \"percentage_allocation\": 20.0,\n" + + " \"mv_fs_value_uuid\": \"a29eca36-25dc-5057-c9b9-7a599f13g09f\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"identity_overrides\": []\n" + + "}"; + + EnvironmentModel env = MapperFactory.getMapper() + .readValue(environmentJson, EnvironmentModel.class); + + EvaluationContext context = EngineMappers.mapEnvironmentToContext(env); + + FeatureContext feature = (FeatureContext) context.getFeatures() + .getAdditionalProperties().get("mv_feature"); + List variants = feature.getVariants(); + + assertEquals(3, variants.size()); + + assertInstanceOf(Boolean.class, feature.getValue(), + "Control value should be Boolean, not " + feature.getValue().getClass().getName()); + assertEquals(true, feature.getValue()); + + assertInstanceOf(Boolean.class, variants.get(0).getValue(), + "Boolean variant value should be Boolean, not " + variants.get(0).getValue().getClass().getName()); + assertEquals(false, variants.get(0).getValue()); + + assertInstanceOf(Integer.class, variants.get(1).getValue(), + "Integer variant value should be Integer, not " + variants.get(1).getValue().getClass().getName()); + assertEquals(42, variants.get(1).getValue()); + + assertInstanceOf(String.class, variants.get(2).getValue(), + "String variant value should be String, not " + variants.get(2).getValue().getClass().getName()); + assertEquals("a string", variants.get(2).getValue()); + } } From 342ac231b8c111dd5c2c29913849f3c5850701c1 Mon Sep 17 00:00:00 2001 From: Max Komarychev Date: Wed, 1 Apr 2026 23:44:15 +0200 Subject: [PATCH 2/3] Json as a file --- .../unit/mappers/EngineMappersTest.java | 58 ++----------------- .../mappers/multivariate_environment.json | 49 ++++++++++++++++ 2 files changed, 54 insertions(+), 53 deletions(-) create mode 100644 src/test/resources/com/flagsmith/flagengine/unit/mappers/multivariate_environment.json diff --git a/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java b/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java index c160fbb1..2d04969b 100644 --- a/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java +++ b/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java @@ -1,6 +1,5 @@ package com.flagsmith.flagengine.unit.mappers; -import com.fasterxml.jackson.core.JsonProcessingException; import com.flagsmith.MapperFactory; import com.flagsmith.flagengine.EvaluationContext; import com.flagsmith.flagengine.FeatureContext; @@ -13,6 +12,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import java.io.IOException; +import java.io.InputStream; import java.util.List; import java.util.Map; import java.util.stream.Stream; @@ -60,59 +61,10 @@ public void testMapContextAndIdentityDataToContext_returnsExpectedContext( @Test public void testMapEnvironmentToContext_preservesMultivariateValueTypes() - throws JsonProcessingException { - String environmentJson = "{\n" - + " \"api_key\": \"test-key\",\n" - + " \"name\": \"Test\",\n" - + " \"project\": {\n" - + " \"name\": \"Test project\",\n" - + " \"organisation\": {\n" - + " \"feature_analytics\": false,\n" - + " \"name\": \"Test Org\",\n" - + " \"id\": 1,\n" - + " \"persist_trait_data\": true,\n" - + " \"stop_serving_flags\": false\n" - + " },\n" - + " \"id\": 1,\n" - + " \"hide_disabled_flags\": false,\n" - + " \"segments\": []\n" - + " },\n" - + " \"segment_overrides\": [],\n" - + " \"id\": 1,\n" - + " \"feature_states\": [\n" - + " {\n" - + " \"feature_state_value\": true,\n" - + " \"django_id\": 1,\n" - + " \"featurestate_uuid\": \"40eb539d-3713-4720-bbd4-829dbef10d51\",\n" - + " \"feature\": { \"name\": \"mv_feature\", \"type\": \"MULTIVARIATE\", \"id\": 1 },\n" - + " \"enabled\": true,\n" - + " \"multivariate_feature_state_values\": [\n" - + " {\n" - + " \"id\": 1,\n" - + " \"multivariate_feature_option\": { \"value\": false },\n" - + " \"percentage_allocation\": 50.0,\n" - + " \"mv_fs_value_uuid\": \"808cba14-03ca-4835-a7f7-58387f01f87d\"\n" - + " },\n" - + " {\n" - + " \"id\": 2,\n" - + " \"multivariate_feature_option\": { \"value\": 42 },\n" - + " \"percentage_allocation\": 30.0,\n" - + " \"mv_fs_value_uuid\": \"918dbb25-14db-4946-b8a8-69488f02f98e\"\n" - + " },\n" - + " {\n" - + " \"id\": 3,\n" - + " \"multivariate_feature_option\": { \"value\": \"a string\" },\n" - + " \"percentage_allocation\": 20.0,\n" - + " \"mv_fs_value_uuid\": \"a29eca36-25dc-5057-c9b9-7a599f13g09f\"\n" - + " }\n" - + " ]\n" - + " }\n" - + " ],\n" - + " \"identity_overrides\": []\n" - + "}"; - + throws IOException { + InputStream json = getClass().getResourceAsStream("multivariate_environment.json"); EnvironmentModel env = MapperFactory.getMapper() - .readValue(environmentJson, EnvironmentModel.class); + .readValue(json, EnvironmentModel.class); EvaluationContext context = EngineMappers.mapEnvironmentToContext(env); diff --git a/src/test/resources/com/flagsmith/flagengine/unit/mappers/multivariate_environment.json b/src/test/resources/com/flagsmith/flagengine/unit/mappers/multivariate_environment.json new file mode 100644 index 00000000..d167dfc6 --- /dev/null +++ b/src/test/resources/com/flagsmith/flagengine/unit/mappers/multivariate_environment.json @@ -0,0 +1,49 @@ +{ + "api_key": "test-key", + "name": "Test", + "project": { + "name": "Test project", + "organisation": { + "feature_analytics": false, + "name": "Test Org", + "id": 1, + "persist_trait_data": true, + "stop_serving_flags": false + }, + "id": 1, + "hide_disabled_flags": false, + "segments": [] + }, + "segment_overrides": [], + "id": 1, + "feature_states": [ + { + "feature_state_value": true, + "django_id": 1, + "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51", + "feature": { "name": "mv_feature", "type": "MULTIVARIATE", "id": 1 }, + "enabled": true, + "multivariate_feature_state_values": [ + { + "id": 1, + "multivariate_feature_option": { "value": false }, + "percentage_allocation": 50.0, + "mv_fs_value_uuid": "808cba14-03ca-4835-a7f7-58387f01f87d" + }, + { + "id": 2, + "multivariate_feature_option": { "value": 42 }, + "percentage_allocation": 30.0, + "mv_fs_value_uuid": "918dbb25-14db-4946-b8a8-69488f02f98e" + }, + { + "id": 3, + "multivariate_feature_option": { "value": "a string" }, + "percentage_allocation": 20.0, + "mv_fs_value_uuid": "a29eca36-25dc-5057-c9b9-7a599f13g09f" + } + ] + } + ], + "identity_overrides": [] +} From 6c8344b0e2f20ad0909c80932229471a86e91b7f Mon Sep 17 00:00:00 2001 From: Max Komarychev Date: Wed, 1 Apr 2026 23:44:37 +0200 Subject: [PATCH 3/3] Revert "Json as a file" This reverts commit 342ac231b8c111dd5c2c29913849f3c5850701c1. --- .../unit/mappers/EngineMappersTest.java | 58 +++++++++++++++++-- .../mappers/multivariate_environment.json | 49 ---------------- 2 files changed, 53 insertions(+), 54 deletions(-) delete mode 100644 src/test/resources/com/flagsmith/flagengine/unit/mappers/multivariate_environment.json diff --git a/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java b/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java index 2d04969b..c160fbb1 100644 --- a/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java +++ b/src/test/java/com/flagsmith/flagengine/unit/mappers/EngineMappersTest.java @@ -1,5 +1,6 @@ package com.flagsmith.flagengine.unit.mappers; +import com.fasterxml.jackson.core.JsonProcessingException; import com.flagsmith.MapperFactory; import com.flagsmith.flagengine.EvaluationContext; import com.flagsmith.flagengine.FeatureContext; @@ -12,8 +13,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import java.io.IOException; -import java.io.InputStream; import java.util.List; import java.util.Map; import java.util.stream.Stream; @@ -61,10 +60,59 @@ public void testMapContextAndIdentityDataToContext_returnsExpectedContext( @Test public void testMapEnvironmentToContext_preservesMultivariateValueTypes() - throws IOException { - InputStream json = getClass().getResourceAsStream("multivariate_environment.json"); + throws JsonProcessingException { + String environmentJson = "{\n" + + " \"api_key\": \"test-key\",\n" + + " \"name\": \"Test\",\n" + + " \"project\": {\n" + + " \"name\": \"Test project\",\n" + + " \"organisation\": {\n" + + " \"feature_analytics\": false,\n" + + " \"name\": \"Test Org\",\n" + + " \"id\": 1,\n" + + " \"persist_trait_data\": true,\n" + + " \"stop_serving_flags\": false\n" + + " },\n" + + " \"id\": 1,\n" + + " \"hide_disabled_flags\": false,\n" + + " \"segments\": []\n" + + " },\n" + + " \"segment_overrides\": [],\n" + + " \"id\": 1,\n" + + " \"feature_states\": [\n" + + " {\n" + + " \"feature_state_value\": true,\n" + + " \"django_id\": 1,\n" + + " \"featurestate_uuid\": \"40eb539d-3713-4720-bbd4-829dbef10d51\",\n" + + " \"feature\": { \"name\": \"mv_feature\", \"type\": \"MULTIVARIATE\", \"id\": 1 },\n" + + " \"enabled\": true,\n" + + " \"multivariate_feature_state_values\": [\n" + + " {\n" + + " \"id\": 1,\n" + + " \"multivariate_feature_option\": { \"value\": false },\n" + + " \"percentage_allocation\": 50.0,\n" + + " \"mv_fs_value_uuid\": \"808cba14-03ca-4835-a7f7-58387f01f87d\"\n" + + " },\n" + + " {\n" + + " \"id\": 2,\n" + + " \"multivariate_feature_option\": { \"value\": 42 },\n" + + " \"percentage_allocation\": 30.0,\n" + + " \"mv_fs_value_uuid\": \"918dbb25-14db-4946-b8a8-69488f02f98e\"\n" + + " },\n" + + " {\n" + + " \"id\": 3,\n" + + " \"multivariate_feature_option\": { \"value\": \"a string\" },\n" + + " \"percentage_allocation\": 20.0,\n" + + " \"mv_fs_value_uuid\": \"a29eca36-25dc-5057-c9b9-7a599f13g09f\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"identity_overrides\": []\n" + + "}"; + EnvironmentModel env = MapperFactory.getMapper() - .readValue(json, EnvironmentModel.class); + .readValue(environmentJson, EnvironmentModel.class); EvaluationContext context = EngineMappers.mapEnvironmentToContext(env); diff --git a/src/test/resources/com/flagsmith/flagengine/unit/mappers/multivariate_environment.json b/src/test/resources/com/flagsmith/flagengine/unit/mappers/multivariate_environment.json deleted file mode 100644 index d167dfc6..00000000 --- a/src/test/resources/com/flagsmith/flagengine/unit/mappers/multivariate_environment.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "api_key": "test-key", - "name": "Test", - "project": { - "name": "Test project", - "organisation": { - "feature_analytics": false, - "name": "Test Org", - "id": 1, - "persist_trait_data": true, - "stop_serving_flags": false - }, - "id": 1, - "hide_disabled_flags": false, - "segments": [] - }, - "segment_overrides": [], - "id": 1, - "feature_states": [ - { - "feature_state_value": true, - "django_id": 1, - "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51", - "feature": { "name": "mv_feature", "type": "MULTIVARIATE", "id": 1 }, - "enabled": true, - "multivariate_feature_state_values": [ - { - "id": 1, - "multivariate_feature_option": { "value": false }, - "percentage_allocation": 50.0, - "mv_fs_value_uuid": "808cba14-03ca-4835-a7f7-58387f01f87d" - }, - { - "id": 2, - "multivariate_feature_option": { "value": 42 }, - "percentage_allocation": 30.0, - "mv_fs_value_uuid": "918dbb25-14db-4946-b8a8-69488f02f98e" - }, - { - "id": 3, - "multivariate_feature_option": { "value": "a string" }, - "percentage_allocation": 20.0, - "mv_fs_value_uuid": "a29eca36-25dc-5057-c9b9-7a599f13g09f" - } - ] - } - ], - "identity_overrides": [] -}