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..5f4b4dd56 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) {
@@ -151,20 +157,27 @@ 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 = (T) flag.getVariants().get(flag.getDefaultVariant());
+ if (raw == null) {
+ raw = flag.getVariants().get(flag.getDefaultVariant());
reason = Reason.DEFAULT;
}
- } else if (!expectedType.isInstance(flag.getVariants().get(flag.getDefaultVariant()))) {
- throw new TypeMismatchError("flag " + key + "is not of expected type");
+ if (raw != null && !isAssignableTo(raw, expectedType)) {
+ throw new TypeMismatchError("flag " + key + " is not of expected type");
+ }
+ value = coerceVariant(raw, expectedType);
} 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 +186,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..7a4747df7 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,89 @@ 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()));
+ }
+
+ @SneakyThrows
+ @Test
+ void contextEvaluator_widensIntegerResultToLong() {
+ Flag