From 3f1d8690fbc5f4fd87adf332c3b099229e6d95b5 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Fri, 3 Jul 2026 13:40:21 -0400 Subject: [PATCH 1/2] feat: long support; safe delegation to int * adds long support to client/provider * delegates to provider int resolver by default * widens, rejecting unsafe defaults * providers supporting Long should implement manually Signed-off-by: Todd Baert --- src/main/java/dev/openfeature/sdk/Client.java | 3 + .../dev/openfeature/sdk/FeatureProvider.java | 172 ++++++++++-- .../java/dev/openfeature/sdk/Features.java | 16 ++ .../dev/openfeature/sdk/FlagValueType.java | 1 + .../java/dev/openfeature/sdk/LongHook.java | 15 ++ .../openfeature/sdk/OpenFeatureClient.java | 33 +++ src/main/java/dev/openfeature/sdk/Value.java | 19 ++ .../providers/memory/InMemoryProvider.java | 36 ++- .../sdk/FlagEvaluationSpecTest.java | 29 ++ .../dev/openfeature/sdk/HookSupportTest.java | 2 + .../sdk/LongDefaultDelegationTest.java | 251 ++++++++++++++++++ .../dev/openfeature/sdk/LongHookTest.java | 38 +++ .../sdk/fixtures/HookFixtures.java | 5 + .../memory/InMemoryProviderTest.java | 53 ++++ .../testutils/testProvider/TestProvider.java | 5 + 15 files changed, 656 insertions(+), 22 deletions(-) create mode 100644 src/main/java/dev/openfeature/sdk/LongHook.java create mode 100644 src/test/java/dev/openfeature/sdk/LongDefaultDelegationTest.java create mode 100644 src/test/java/dev/openfeature/sdk/LongHookTest.java diff --git a/src/main/java/dev/openfeature/sdk/Client.java b/src/main/java/dev/openfeature/sdk/Client.java index 441d31e2b..165a29710 100644 --- a/src/main/java/dev/openfeature/sdk/Client.java +++ b/src/main/java/dev/openfeature/sdk/Client.java @@ -4,6 +4,9 @@ /** * Interface used to resolve flags of varying types. + * + *

API note: not intended for external implementation. Additive method changes + * (such as new flag-value-type accessors) are considered non-breaking. */ public interface Client extends Features, Tracking, EventBus { ClientMetadata getMetadata(); diff --git a/src/main/java/dev/openfeature/sdk/FeatureProvider.java b/src/main/java/dev/openfeature/sdk/FeatureProvider.java index 22819ef10..b6b7af9e0 100644 --- a/src/main/java/dev/openfeature/sdk/FeatureProvider.java +++ b/src/main/java/dev/openfeature/sdk/FeatureProvider.java @@ -9,49 +9,185 @@ * should extend {@link EventProvider} */ public interface FeatureProvider { + + /** Maximum integer losslessly representable as an IEEE-754 double: 2^53 - 1. */ + long MAX_SAFE_INTEGER = 9_007_199_254_740_991L; + + /** + * Returns provider-identifying metadata (typically the provider name). + * + * @return provider metadata + */ Metadata getMetadata(); + /** + * Returns provider-defined hooks that run alongside API/client/invocation hooks during + * flag evaluation. Provider hooks are managed by the provider, not the application author. + * + * @return list of provider hooks; empty by default + */ default List getProviderHooks() { return new ArrayList<>(); } + /** + * Resolves a boolean flag value. + * + * @param key flag key + * @param defaultValue value to return in the {@link ProviderEvaluation} if resolution fails + * @param ctx merged evaluation context (may be empty, never {@code null}) + * @return provider evaluation containing the resolved value or an error + */ ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx); + /** + * Resolves a string flag value. + * + * @param key flag key + * @param defaultValue value to return in the {@link ProviderEvaluation} if resolution fails + * @param ctx merged evaluation context (may be empty, never {@code null}) + * @return provider evaluation containing the resolved value or an error + */ ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx); + /** + * Resolves a 32-bit integer flag value. For flags whose values may exceed + * {@link Integer#MAX_VALUE}, use {@link #getLongEvaluation} instead. + * + * @param key flag key + * @param defaultValue value to return in the {@link ProviderEvaluation} if resolution fails + * @param ctx merged evaluation context (may be empty, never {@code null}) + * @return provider evaluation containing the resolved value or an error + */ ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx); + /** + * Resolves a double-precision floating-point flag value. + * + * @param key flag key + * @param defaultValue value to return in the {@link ProviderEvaluation} if resolution fails + * @param ctx merged evaluation context (may be empty, never {@code null}) + * @return provider evaluation containing the resolved value or an error + */ ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx); + /** + * Resolves a 64-bit integer (Long) flag value. + * + *

The default implementation delegates to {@link #getDoubleEvaluation} and returns a + * {@link ProviderEvaluation} with {@link ErrorCode#TYPE_MISMATCH} for values outside the + * safe-integer range ({@code [-(2^53 - 1), 2^53 - 1]}) or non-integral doubles (NaN, + * +/-Infinity, fractional). Providers that natively support 64-bit integer flags should + * override this method. + * + * @param key flag key + * @param defaultValue value to return in the {@link ProviderEvaluation} if resolution fails + * @param ctx merged evaluation context (may be empty, never {@code null}) + * @return provider evaluation containing the resolved value or an error + */ + default ProviderEvaluation getLongEvaluation(String key, Long defaultValue, EvaluationContext ctx) { + if (defaultValue != null && !isWithinSafeRange(defaultValue)) { + return longError( + defaultValue, + "Default value " + defaultValue + + " exceeds safe integer range [-(2^53 - 1), 2^53 - 1] for double-backed long evaluation"); + } + + Double doubleDefault = defaultValue == null ? null : (double) defaultValue; + ProviderEvaluation result = getDoubleEvaluation(key, doubleDefault, ctx); + + Double boxed = result.getValue(); + Long longValue; + if (boxed == null) { + longValue = defaultValue; + } else { + double value = boxed; + if (Double.isNaN(value) || Double.isInfinite(value)) { + return longError(defaultValue, "Cannot convert " + value + " to long", result); + } + if (value != Math.floor(value)) { + return longError(defaultValue, "Cannot convert fractional value " + value + " to long", result); + } + if (Math.abs(value) > MAX_SAFE_INTEGER) { + return longError( + defaultValue, + "Value " + value + " exceeds safe integer range [-(2^53 - 1), 2^53 - 1] for long", + result); + } + longValue = (long) value; + } + + return ProviderEvaluation.builder() + .value(longValue) + .reason(result.getReason()) + .variant(result.getVariant()) + .errorCode(result.getErrorCode()) + .errorMessage(result.getErrorMessage()) + .flagMetadata(result.getFlagMetadata()) + .build(); + } + + // avoid Math.abs; Math.abs(Long.MIN_VALUE) == Long.MIN_VALUE (two's-complement overflow) + private static boolean isWithinSafeRange(long value) { + return value >= -MAX_SAFE_INTEGER && value <= MAX_SAFE_INTEGER; + } + + private static ProviderEvaluation longError(Long defaultValue, String message) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage(message) + .build(); + } + + // preserve upstream metadata/variant; override with type error + private static ProviderEvaluation longError( + Long defaultValue, String message, ProviderEvaluation upstream) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage(message) + .variant(upstream.getVariant()) + .flagMetadata(upstream.getFlagMetadata()) + .build(); + } + + /** + * Resolves a structured (object) flag value. Values are wrapped in {@link Value} which can + * carry booleans, strings, numbers, structures, and lists. + * + * @param key flag key + * @param defaultValue value to return in the {@link ProviderEvaluation} if resolution fails + * @param ctx merged evaluation context (may be empty, never {@code null}) + * @return provider evaluation containing the resolved value or an error + */ ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx); /** - * This method is called before a provider is used to evaluate flags. Providers - * can overwrite this method, - * if they have special initialization needed prior being called for flag - * evaluation. + * Called once before a provider is used to evaluate flags. Providers can override this method + * if they have special initialization needed prior to being called for flag evaluation. + * + *

It is ok if the method is expensive; it is executed in the background. All runtime + * exceptions will be caught and logged. * - *

- * It is ok if the method is expensive as it is executed in the background. All - * runtime exceptions will be - * caught and logged. - *

+ * @param evaluationContext the API-level evaluation context at the time of initialization + * @throws Exception any exception thrown here transitions the provider to + * {@link ProviderState#ERROR} (or {@link ProviderState#FATAL} for + * {@link dev.openfeature.sdk.exceptions.FatalError}) */ default void initialize(EvaluationContext evaluationContext) throws Exception { // Intentionally left blank } /** - * This method is called when a new provider is about to be used to evaluate - * flags, or the SDK is shut down. - * Providers can overwrite this method, if they have special shutdown actions - * needed. + * Called when a provider is about to be replaced or the SDK is shutting down. Providers can + * override this method if they have resources to release (background threads, connections, + * caches, etc.). * - *

- * It is ok if the method is expensive as it is executed in the background. All - * runtime exceptions will be - * caught and logged. - *

+ *

It is ok if the method is expensive; it is executed in the background. All runtime + * exceptions will be caught and logged. */ default void shutdown() { // Intentionally left blank diff --git a/src/main/java/dev/openfeature/sdk/Features.java b/src/main/java/dev/openfeature/sdk/Features.java index 1f0b73d43..3d627e523 100644 --- a/src/main/java/dev/openfeature/sdk/Features.java +++ b/src/main/java/dev/openfeature/sdk/Features.java @@ -2,6 +2,9 @@ /** * An API for the type-specific fetch methods offered to users. + * + *

API note: not intended for external implementation. Additive method changes + * (such as new flag-value-type accessors) are considered non-breaking. */ public interface Features { @@ -44,6 +47,19 @@ FlagEvaluationDetails getStringDetails( FlagEvaluationDetails getIntegerDetails( String key, Integer defaultValue, EvaluationContext ctx, FlagEvaluationOptions options); + Long getLongValue(String key, Long defaultValue); + + Long getLongValue(String key, Long defaultValue, EvaluationContext ctx); + + Long getLongValue(String key, Long defaultValue, EvaluationContext ctx, FlagEvaluationOptions options); + + FlagEvaluationDetails getLongDetails(String key, Long defaultValue); + + FlagEvaluationDetails getLongDetails(String key, Long defaultValue, EvaluationContext ctx); + + FlagEvaluationDetails getLongDetails( + String key, Long defaultValue, EvaluationContext ctx, FlagEvaluationOptions options); + Double getDoubleValue(String key, Double defaultValue); Double getDoubleValue(String key, Double defaultValue, EvaluationContext ctx); diff --git a/src/main/java/dev/openfeature/sdk/FlagValueType.java b/src/main/java/dev/openfeature/sdk/FlagValueType.java index a8938d454..03883e493 100644 --- a/src/main/java/dev/openfeature/sdk/FlagValueType.java +++ b/src/main/java/dev/openfeature/sdk/FlagValueType.java @@ -4,6 +4,7 @@ public enum FlagValueType { STRING, INTEGER, + LONG, DOUBLE, OBJECT, BOOLEAN; diff --git a/src/main/java/dev/openfeature/sdk/LongHook.java b/src/main/java/dev/openfeature/sdk/LongHook.java new file mode 100644 index 000000000..2f453473f --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/LongHook.java @@ -0,0 +1,15 @@ +package dev.openfeature.sdk; + +/** + * An extension point which can run around flag resolution. They are intended to be used as a way to add custom logic + * to the lifecycle of flag evaluation. + * + * @see Hook + */ +public interface LongHook extends Hook { + + @Override + default boolean supportsFlagValueType(FlagValueType flagValueType) { + return FlagValueType.LONG == flagValueType; + } +} diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java index 818583724..66caf76e5 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java @@ -302,6 +302,8 @@ private ProviderEvaluation createProviderEvaluation( return provider.getStringEvaluation(key, (String) defaultValue, invocationContext); case INTEGER: return provider.getIntegerEvaluation(key, (Integer) defaultValue, invocationContext); + case LONG: + return provider.getLongEvaluation(key, (Long) defaultValue, invocationContext); case DOUBLE: return provider.getDoubleEvaluation(key, (Double) defaultValue, invocationContext); case OBJECT: @@ -407,6 +409,37 @@ public FlagEvaluationDetails getIntegerDetails( return this.evaluateFlag(FlagValueType.INTEGER, key, defaultValue, ctx, options); } + @Override + public Long getLongValue(String key, Long defaultValue) { + return getLongDetails(key, defaultValue).getValue(); + } + + @Override + public Long getLongValue(String key, Long defaultValue, EvaluationContext ctx) { + return getLongDetails(key, defaultValue, ctx).getValue(); + } + + @Override + public Long getLongValue(String key, Long defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) { + return getLongDetails(key, defaultValue, ctx, options).getValue(); + } + + @Override + public FlagEvaluationDetails getLongDetails(String key, Long defaultValue) { + return getLongDetails(key, defaultValue, null); + } + + @Override + public FlagEvaluationDetails getLongDetails(String key, Long defaultValue, EvaluationContext ctx) { + return getLongDetails(key, defaultValue, ctx, FlagEvaluationOptions.EMPTY); + } + + @Override + public FlagEvaluationDetails getLongDetails( + String key, Long defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) { + return this.evaluateFlag(FlagValueType.LONG, key, defaultValue, ctx, options); + } + @Override public Double getDoubleValue(String key, Double defaultValue) { return getDoubleValue(key, defaultValue, null); diff --git a/src/main/java/dev/openfeature/sdk/Value.java b/src/main/java/dev/openfeature/sdk/Value.java index 05e538e50..c13094d98 100644 --- a/src/main/java/dev/openfeature/sdk/Value.java +++ b/src/main/java/dev/openfeature/sdk/Value.java @@ -70,6 +70,10 @@ public Value(Integer value) { this.innerObject = value; } + public Value(Long value) { + this.innerObject = value; + } + public Value(Double value) { this.innerObject = value; } @@ -213,6 +217,19 @@ public Integer asInteger() { return null; } + /** + * Retrieve the underlying numeric value as a Long, or null. + * If the value is a non-integral number, it will be truncated using Number#longValue(). + * + * @return Long + */ + public Long asLong() { + if (this.isNumber() && !this.isNull()) { + return ((Number) this.innerObject).longValue(); + } + return null; + } + /** * Retrieve the underlying numeric value as a Double, or null. * @@ -301,6 +318,8 @@ public static Value objectToValue(Object object) { return new Value((Boolean) object); } else if (object instanceof Integer) { return new Value((Integer) object); + } else if (object instanceof Long) { + return new Value((Long) object); } else if (object instanceof Double) { return new Value((Double) object); } else if (object instanceof Structure) { diff --git a/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java b/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java index 1773ae8a8..263d91401 100644 --- a/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java +++ b/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java @@ -112,6 +112,12 @@ public ProviderEvaluation getIntegerEvaluation( return getEvaluation(key, defaultValue, evaluationContext, Integer.class); } + @Override + public ProviderEvaluation getLongEvaluation( + String key, Long defaultValue, EvaluationContext evaluationContext) { + return getEvaluation(key, defaultValue, evaluationContext, Long.class); + } + @Override public ProviderEvaluation getDoubleEvaluation( String key, Double defaultValue, EvaluationContext evaluationContext) { @@ -158,13 +164,15 @@ private ProviderEvaluation getEvaluation( value = null; } if (value == null) { - value = (T) flag.getVariants().get(flag.getDefaultVariant()); + value = coerceVariant(flag.getVariants().get(flag.getDefaultVariant()), expectedType); reason = Reason.DEFAULT; } - } else if (!expectedType.isInstance(flag.getVariants().get(flag.getDefaultVariant()))) { - throw new TypeMismatchError("flag " + key + "is not of expected type"); } else { - value = (T) flag.getVariants().get(flag.getDefaultVariant()); + Object variant = flag.getVariants().get(flag.getDefaultVariant()); + if (!isAssignableTo(variant, expectedType)) { + throw new TypeMismatchError("flag " + key + "is not of expected type"); + } + value = coerceVariant(variant, expectedType); } return ProviderEvaluation.builder() .value(value) @@ -173,4 +181,24 @@ private ProviderEvaluation getEvaluation( .flagMetadata(flag.getFlagMetadata()) .build(); } + + // true if variant satisfies expectedType directly or via widening (Integer -> Long) + private static boolean isAssignableTo(Object variant, Class expectedType) { + if (expectedType.isInstance(variant)) { + return true; + } + return Long.class.equals(expectedType) && variant instanceof Integer; + } + + // coerce variant to expectedType, widening Integer -> Long when needed + @SuppressWarnings("unchecked") + private static T coerceVariant(Object variant, Class expectedType) { + if (variant == null) { + return null; + } + if (Long.class.equals(expectedType) && variant instanceof Integer) { + return (T) Long.valueOf(((Integer) variant).longValue()); + } + return (T) variant; + } } diff --git a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java index 82aa4e3cc..9b256f5ad 100644 --- a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java @@ -198,6 +198,7 @@ void value_flags() { new Flag(FlagValueType.BOOLEAN.name(), "boolean", true), new Flag(FlagValueType.STRING.name(), "string", "default"), new Flag(FlagValueType.INTEGER.name(), "int", 400), + new Flag(FlagValueType.LONG.name(), "long", 9_007_199_254_740_991L), new Flag(FlagValueType.DOUBLE.name(), "double", 40.0), new Flag(FlagValueType.OBJECT.name(), "obj", new Value())) .initsToReady()); @@ -234,6 +235,16 @@ void value_flags() { new ImmutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals(9_007_199_254_740_991L, c.getLongValue("long", 0L)); + assertEquals(9_007_199_254_740_991L, c.getLongValue("long", 0L, new ImmutableContext())); + assertEquals( + 9_007_199_254_740_991L, + c.getLongValue( + "long", + 0L, + new ImmutableContext(), + FlagEvaluationOptions.builder().build())); + assertEquals(40.0, c.getDoubleValue("double", .4)); assertEquals(40.0, c.getDoubleValue("double", .4, new ImmutableContext())); assertEquals( @@ -285,6 +296,7 @@ void detail_flags() { new Flag(FlagValueType.BOOLEAN.name(), "boolean", true), new Flag(FlagValueType.STRING.name(), "string", "default"), new Flag(FlagValueType.INTEGER.name(), "int", 400), + new Flag(FlagValueType.LONG.name(), "long", 9_007_199_254_740_991L), new Flag(FlagValueType.DOUBLE.name(), "double", 40.0), new Flag(FlagValueType.OBJECT.name(), "obj", new Value())) .initsToReady()); @@ -341,6 +353,23 @@ void detail_flags() { new ImmutableContext(), FlagEvaluationOptions.builder().build())); + FlagEvaluationDetails ld = FlagEvaluationDetails.builder() + .flagKey("long") + .value(9_007_199_254_740_991L) + .flagMetadata(ImmutableMetadata.EMPTY) + .reason(Reason.STATIC.name()) + .variant(TestProvider.DEFAULT_VARIANT) + .build(); + assertEquals(ld, c.getLongDetails("long", 0L)); + assertEquals(ld, c.getLongDetails("long", 0L, new ImmutableContext())); + assertEquals( + ld, + c.getLongDetails( + "long", + 0L, + new ImmutableContext(), + FlagEvaluationOptions.builder().build())); + FlagEvaluationDetails dd = FlagEvaluationDetails.builder() .flagKey("double") .value(40.0) diff --git a/src/test/java/dev/openfeature/sdk/HookSupportTest.java b/src/test/java/dev/openfeature/sdk/HookSupportTest.java index 8d60122d3..c2b7831c5 100644 --- a/src/test/java/dev/openfeature/sdk/HookSupportTest.java +++ b/src/test/java/dev/openfeature/sdk/HookSupportTest.java @@ -265,6 +265,8 @@ private Object createDefaultValue(FlagValueType flagValueType) { switch (flagValueType) { case INTEGER: return 1; + case LONG: + return 1L; case BOOLEAN: return true; case STRING: diff --git a/src/test/java/dev/openfeature/sdk/LongDefaultDelegationTest.java b/src/test/java/dev/openfeature/sdk/LongDefaultDelegationTest.java new file mode 100644 index 000000000..e8a8fd8d9 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/LongDefaultDelegationTest.java @@ -0,0 +1,251 @@ +package dev.openfeature.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests the default {@link FeatureProvider#getLongEvaluation(String, Long, EvaluationContext)} + * delegation behavior. + */ +class LongDefaultDelegationTest { + + /** + * A FeatureProvider that records calls to getDoubleEvaluation and returns a configurable + * Double, exercising only the default getLongEvaluation impl. + */ + private static final class StubDoubleProvider implements FeatureProvider { + private final Double valueToReturn; + final List capturedDefaults = new ArrayList<>(); + + StubDoubleProvider(Double valueToReturn) { + this.valueToReturn = valueToReturn; + } + + @Override + public Metadata getMetadata() { + return () -> "stub"; + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext c) { + throw new UnsupportedOperationException(); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext c) { + throw new UnsupportedOperationException(); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext c) { + throw new UnsupportedOperationException(); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext c) { + capturedDefaults.add(defaultValue); + return ProviderEvaluation.builder() + .value(valueToReturn) + .reason(Reason.STATIC.name()) + .variant("v") + .build(); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext c) { + throw new UnsupportedOperationException(); + } + } + + @Nested + @DisplayName("Successful conversions") + class Successful { + + @Test + void convertsIntegerValuedDoubleToLong() { + var provider = new StubDoubleProvider(42.0); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getValue()).isEqualTo(42L); + assertThat(result.getReason()).isEqualTo(Reason.STATIC.name()); + assertThat(result.getErrorCode()).isNull(); + } + + @Test + void convertsZero() { + var provider = new StubDoubleProvider(0.0); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getValue()).isEqualTo(0L); + assertThat(result.getErrorCode()).isNull(); + } + + @Test + void convertsNegativeZeroToZeroLong() { + var provider = new StubDoubleProvider(-0.0); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getValue()).isEqualTo(0L); + assertThat(result.getErrorCode()).isNull(); + } + + @Test + void convertsAtMaxSafeInteger() { + // 2^53 - 1 + long maxSafe = 9_007_199_254_740_991L; + var provider = new StubDoubleProvider((double) maxSafe); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getValue()).isEqualTo(maxSafe); + assertThat(result.getErrorCode()).isNull(); + } + + @Test + void convertsAtNegativeMaxSafeInteger() { + long minSafe = -9_007_199_254_740_991L; + var provider = new StubDoubleProvider((double) minSafe); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getValue()).isEqualTo(minSafe); + assertThat(result.getErrorCode()).isNull(); + } + + @Test + void passesThroughErrorMetadataFromProvider() { + var provider = new FeatureProvider() { + @Override + public Metadata getMetadata() { + return () -> "stub"; + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String k, Boolean d, EvaluationContext c) { + return null; + } + + @Override + public ProviderEvaluation getStringEvaluation(String k, String d, EvaluationContext c) { + return null; + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String k, Integer d, EvaluationContext c) { + return null; + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String k, Double d, EvaluationContext c) { + return ProviderEvaluation.builder() + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("nope") + .build(); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String k, Value d, EvaluationContext c) { + return null; + } + }; + var result = provider.getLongEvaluation("k", 99L, new ImmutableContext()); + // null double should fall back to the user's Long default + assertThat(result.getValue()).isEqualTo(99L); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.FLAG_NOT_FOUND); + assertThat(result.getErrorMessage()).isEqualTo("nope"); + } + + @Test + void passesLongDefaultAsDoubleToProvider() { + var provider = new StubDoubleProvider(0.0); + provider.getLongEvaluation("k", 12345L, new ImmutableContext()); + assertThat(provider.capturedDefaults).containsExactly(12345.0); + } + + @Test + void passesNullDefaultAsNullToProvider() { + var provider = new StubDoubleProvider(0.0); + provider.getLongEvaluation("k", null, new ImmutableContext()); + assertThat(provider.capturedDefaults).containsExactly((Double) null); + } + } + + @Nested + @DisplayName("Bound violations return TYPE_MISMATCH") + class Bounds { + + @Test + void returnsTypeMismatchAtTwoToTheFiftyThree() { + // 2^53 ; representable as double, but outside the JS-safe-integer range + var provider = new StubDoubleProvider(9_007_199_254_740_992.0); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.TYPE_MISMATCH); + assertThat(result.getReason()).isEqualTo(Reason.ERROR.toString()); + assertThat(result.getValue()).isEqualTo(0L); + } + + @Test + void returnsTypeMismatchAboveTwoToTheFiftyThree() { + var provider = new StubDoubleProvider(1e16); + var result = provider.getLongEvaluation("k", 7L, new ImmutableContext()); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.TYPE_MISMATCH); + assertThat(result.getValue()).isEqualTo(7L); + } + + @Test + void returnsTypeMismatchBelowNegativeTwoToTheFiftyThree() { + var provider = new StubDoubleProvider(-9_007_199_254_740_992.0); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.TYPE_MISMATCH); + } + + @Test + void returnsTypeMismatchWhenLongDefaultExceedsSafeRange() { + var provider = new StubDoubleProvider(0.0); + var result = provider.getLongEvaluation("k", Long.MAX_VALUE, new ImmutableContext()); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.TYPE_MISMATCH); + assertThat(result.getValue()).isEqualTo(Long.MAX_VALUE); + // provider was never called, so no default captured + assertThat(provider.capturedDefaults).isEmpty(); + } + + @Test + void returnsTypeMismatchWhenNegativeLongDefaultExceedsSafeRange() { + var provider = new StubDoubleProvider(0.0); + var result = provider.getLongEvaluation("k", Long.MIN_VALUE, new ImmutableContext()); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.TYPE_MISMATCH); + } + } + + @Nested + @DisplayName("Non-integer doubles return TYPE_MISMATCH") + class NonInteger { + + @Test + void returnsTypeMismatchOnFractional() { + var provider = new StubDoubleProvider(1.5); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.TYPE_MISMATCH); + assertThat(result.getValue()).isEqualTo(0L); + } + + @Test + void returnsTypeMismatchOnNaN() { + var provider = new StubDoubleProvider(Double.NaN); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.TYPE_MISMATCH); + } + + @Test + void returnsTypeMismatchOnPositiveInfinity() { + var provider = new StubDoubleProvider(Double.POSITIVE_INFINITY); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.TYPE_MISMATCH); + } + + @Test + void returnsTypeMismatchOnNegativeInfinity() { + var provider = new StubDoubleProvider(Double.NEGATIVE_INFINITY); + var result = provider.getLongEvaluation("k", 0L, new ImmutableContext()); + assertThat(result.getErrorCode()).isEqualTo(ErrorCode.TYPE_MISMATCH); + } + } +} diff --git a/src/test/java/dev/openfeature/sdk/LongHookTest.java b/src/test/java/dev/openfeature/sdk/LongHookTest.java new file mode 100644 index 000000000..aabcc7c9c --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/LongHookTest.java @@ -0,0 +1,38 @@ +package dev.openfeature.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.openfeature.sdk.fixtures.HookFixtures; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LongHookTest implements HookFixtures { + + private Hook hook; + + @BeforeEach + void setupTest() { + hook = mockLongHook(); + } + + @Test + void verifyFlagValueTypeIsSupportedByHook() { + boolean hookSupported = hook.supportsFlagValueType(FlagValueType.LONG); + + assertThat(hookSupported).isTrue(); + } + + @Test + void verifyFlagValueTypeIsNotSupportedByHook() { + boolean hookSupported = hook.supportsFlagValueType(FlagValueType.STRING); + + assertThat(hookSupported).isFalse(); + } + + @Test + void verifyIntegerNotSupportedByLongHook() { + boolean hookSupported = hook.supportsFlagValueType(FlagValueType.INTEGER); + + assertThat(hookSupported).isFalse(); + } +} diff --git a/src/test/java/dev/openfeature/sdk/fixtures/HookFixtures.java b/src/test/java/dev/openfeature/sdk/fixtures/HookFixtures.java index d2d51bac7..a240af991 100644 --- a/src/test/java/dev/openfeature/sdk/fixtures/HookFixtures.java +++ b/src/test/java/dev/openfeature/sdk/fixtures/HookFixtures.java @@ -6,6 +6,7 @@ import dev.openfeature.sdk.DoubleHook; import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.IntegerHook; +import dev.openfeature.sdk.LongHook; import dev.openfeature.sdk.ObjectHook; import dev.openfeature.sdk.StringHook; @@ -23,6 +24,10 @@ default Hook mockIntegerHook() { return spy(IntegerHook.class); } + default Hook mockLongHook() { + return spy(LongHook.class); + } + default Hook mockDoubleHook() { return spy(DoubleHook.class); } diff --git a/src/test/java/dev/openfeature/sdk/providers/memory/InMemoryProviderTest.java b/src/test/java/dev/openfeature/sdk/providers/memory/InMemoryProviderTest.java index 970495940..baa28624a 100644 --- a/src/test/java/dev/openfeature/sdk/providers/memory/InMemoryProviderTest.java +++ b/src/test/java/dev/openfeature/sdk/providers/memory/InMemoryProviderTest.java @@ -131,4 +131,57 @@ void emitChangedFlagsOnlyIfThereAreChangedFlags() { .accept(argThat(details -> details.getFlagsChanged().size() == buildFlags().size()))); } + + @Test + void getLongEvaluation_nativeLongVariant() { + InMemoryProvider local = new InMemoryProvider(Map.of( + "long-flag", + Flag.builder() + .variant("big", 9_007_199_254_740_991L) + .defaultVariant("big") + .build())); + api.setProviderAndWait(local); + assertEquals(9_007_199_254_740_991L, api.getClient().getLongValue("long-flag", 0L)); + } + + @Test + void getLongEvaluation_widensIntegerVariantToLong() { + InMemoryProvider local = new InMemoryProvider(Map.of( + "int-as-long", + Flag.builder() + .variant("v", 42) + .defaultVariant("v") + .build())); + api.setProviderAndWait(local); + assertEquals(42L, api.getClient().getLongValue("int-as-long", 0L)); + } + + @SneakyThrows + @Test + void getLongEvaluation_doesNotWidenDouble() { + InMemoryProvider local = new InMemoryProvider(Map.of( + "double-as-long", + Flag.builder() + .variant("v", 42.0) + .defaultVariant("v") + .build())); + local.initialize(new ImmutableContext()); + assertThrows( + TypeMismatchError.class, + () -> local.getLongEvaluation("double-as-long", 0L, new ImmutableContext())); + } + + @SneakyThrows + @Test + void getIntegerEvaluation_doesNotAcceptLongVariant() { + InMemoryProvider local = new InMemoryProvider(Map.of( + "long-flag", + Flag.builder() + .variant("v", 42L) + .defaultVariant("v") + .build())); + local.initialize(new ImmutableContext()); + assertThrows( + TypeMismatchError.class, () -> local.getIntegerEvaluation("long-flag", 0, new ImmutableContext())); + } } diff --git a/src/test/java/dev/openfeature/sdk/testutils/testProvider/TestProvider.java b/src/test/java/dev/openfeature/sdk/testutils/testProvider/TestProvider.java index 383ade483..4ea169aaf 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/testProvider/TestProvider.java +++ b/src/test/java/dev/openfeature/sdk/testutils/testProvider/TestProvider.java @@ -156,6 +156,11 @@ public ProviderEvaluation getIntegerEvaluation(String key, Integer defa return getEvaluation(key, defaultValue, FlagValueType.INTEGER, Integer.class, ctx); } + @Override + public ProviderEvaluation getLongEvaluation(String key, Long defaultValue, EvaluationContext ctx) { + return getEvaluation(key, defaultValue, FlagValueType.LONG, Long.class, ctx); + } + @Override public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { return getEvaluation(key, defaultValue, FlagValueType.DOUBLE, Double.class, ctx); From 62f073f8e1b7771eadf4073c526f60d86a728b99 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Fri, 3 Jul 2026 14:21:23 -0400 Subject: [PATCH 2/2] pr feedback Signed-off-by: Todd Baert --- .../providers/memory/InMemoryProvider.java | 15 +++-- .../memory/InMemoryProviderTest.java | 64 ++++++++++++++----- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java b/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java index 263d91401..5f4b4dd56 100644 --- a/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java +++ b/src/main/java/dev/openfeature/sdk/providers/memory/InMemoryProvider.java @@ -157,20 +157,25 @@ private ProviderEvaluation getEvaluation( T value; Reason reason = Reason.STATIC; if (flag.getContextEvaluator() != null) { + Object raw; try { - value = (T) flag.getContextEvaluator().evaluate(flag, evaluationContext); + raw = flag.getContextEvaluator().evaluate(flag, evaluationContext); reason = Reason.TARGETING_MATCH; } catch (Exception e) { - value = null; + raw = null; } - if (value == null) { - value = coerceVariant(flag.getVariants().get(flag.getDefaultVariant()), expectedType); + if (raw == null) { + raw = flag.getVariants().get(flag.getDefaultVariant()); reason = Reason.DEFAULT; } + if (raw != null && !isAssignableTo(raw, expectedType)) { + throw new TypeMismatchError("flag " + key + " is not of expected type"); + } + value = coerceVariant(raw, expectedType); } else { Object variant = flag.getVariants().get(flag.getDefaultVariant()); if (!isAssignableTo(variant, expectedType)) { - throw new TypeMismatchError("flag " + key + "is not of expected type"); + throw new TypeMismatchError("flag " + key + " is not of expected type"); } value = coerceVariant(variant, expectedType); } diff --git a/src/test/java/dev/openfeature/sdk/providers/memory/InMemoryProviderTest.java b/src/test/java/dev/openfeature/sdk/providers/memory/InMemoryProviderTest.java index baa28624a..7a4747df7 100644 --- a/src/test/java/dev/openfeature/sdk/providers/memory/InMemoryProviderTest.java +++ b/src/test/java/dev/openfeature/sdk/providers/memory/InMemoryProviderTest.java @@ -148,10 +148,7 @@ void getLongEvaluation_nativeLongVariant() { void getLongEvaluation_widensIntegerVariantToLong() { InMemoryProvider local = new InMemoryProvider(Map.of( "int-as-long", - Flag.builder() - .variant("v", 42) - .defaultVariant("v") - .build())); + Flag.builder().variant("v", 42).defaultVariant("v").build())); api.setProviderAndWait(local); assertEquals(42L, api.getClient().getLongValue("int-as-long", 0L)); } @@ -161,14 +158,10 @@ void getLongEvaluation_widensIntegerVariantToLong() { void getLongEvaluation_doesNotWidenDouble() { InMemoryProvider local = new InMemoryProvider(Map.of( "double-as-long", - Flag.builder() - .variant("v", 42.0) - .defaultVariant("v") - .build())); + Flag.builder().variant("v", 42.0).defaultVariant("v").build())); local.initialize(new ImmutableContext()); assertThrows( - TypeMismatchError.class, - () -> local.getLongEvaluation("double-as-long", 0L, new ImmutableContext())); + TypeMismatchError.class, () -> local.getLongEvaluation("double-as-long", 0L, new ImmutableContext())); } @SneakyThrows @@ -176,12 +169,51 @@ void getLongEvaluation_doesNotWidenDouble() { void getIntegerEvaluation_doesNotAcceptLongVariant() { InMemoryProvider local = new InMemoryProvider(Map.of( "long-flag", - Flag.builder() - .variant("v", 42L) - .defaultVariant("v") - .build())); + Flag.builder().variant("v", 42L).defaultVariant("v").build())); local.initialize(new ImmutableContext()); - assertThrows( - TypeMismatchError.class, () -> local.getIntegerEvaluation("long-flag", 0, new ImmutableContext())); + assertThrows(TypeMismatchError.class, () -> local.getIntegerEvaluation("long-flag", 0, new ImmutableContext())); + } + + @SneakyThrows + @Test + void contextEvaluator_widensIntegerResultToLong() { + Flag flag = Flag.builder() + .variant("v", 0L) + .defaultVariant("v") + .contextEvaluator((f, ctx) -> Integer.valueOf(42)) + .build(); + InMemoryProvider local = new InMemoryProvider(Map.of("targeted", flag)); + local.initialize(new ImmutableContext()); + assertEquals( + 42L, + local.getLongEvaluation("targeted", 0L, new ImmutableContext()).getValue()); + } + + @SneakyThrows + @Test + void contextEvaluator_rejectsMismatchedResultType() { + Flag flag = Flag.builder() + .variant("v", 0L) + .defaultVariant("v") + .contextEvaluator((f, ctx) -> Double.valueOf(3.14)) + .build(); + InMemoryProvider local = new InMemoryProvider(Map.of("targeted", flag)); + local.initialize(new ImmutableContext()); + assertThrows(TypeMismatchError.class, () -> local.getLongEvaluation("targeted", 0L, new ImmutableContext())); + } + + @SneakyThrows + @Test + void contextEvaluator_nullResultFallsBackToDefaultVariantWithTypeCheck() { + Flag flag = Flag.builder() + .variant("v", 7L) + .defaultVariant("v") + .contextEvaluator((f, ctx) -> null) + .build(); + InMemoryProvider local = new InMemoryProvider(Map.of("targeted", flag)); + local.initialize(new ImmutableContext()); + assertEquals( + 7L, + local.getLongEvaluation("targeted", 0L, new ImmutableContext()).getValue()); } }