Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions src/main/java/dev/openfeature/sdk/ImmutableContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.experimental.Delegate;

Expand All @@ -17,7 +16,6 @@
* not be modified after instantiation.
*/
@ToString
@EqualsAndHashCode
@SuppressWarnings("PMD.BeanMembersShouldSerialize")
public final class ImmutableContext implements EvaluationContext {

Expand All @@ -26,6 +24,9 @@ public final class ImmutableContext implements EvaluationContext {
@Delegate(excludes = DelegateExclusions.class)
private final ImmutableStructure structure;

// Lazily computed hash code, safe because this class is immutable.
private volatile Integer cachedHashCode;

/**
* Create an immutable context with an empty targeting_key and attributes
* provided.
Expand Down Expand Up @@ -96,6 +97,47 @@ public EvaluationContext merge(EvaluationContext overridingContext) {
return new ImmutableContext(attributes);
}

/**
* Equality for EvaluationContext implementations is defined in terms of their resolved
* attribute maps. Two contexts are considered equal if their {@link #asMap()} representations
* contain the same key/value pairs, regardless of how the context was constructed or layered.
*
* @param o the object to compare with this context
* @return true if the other object is an EvaluationContext whose resolved attributes match
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof EvaluationContext)) {
return false;
}
EvaluationContext that = (EvaluationContext) o;
return this.asMap().equals(that.asMap());
}

/**
* Computes a hash code consistent with {@link #equals(Object)}. Since this context is immutable,
* the hash code is lazily computed once from its resolved attribute map and then cached.
*
* @return the cached hash code derived from this context's attribute map
*/
@Override
public int hashCode() {
Integer result = cachedHashCode;
if (result == null) {
synchronized (this) {
result = cachedHashCode;
if (result == null) {
result = asMap().hashCode();
cachedHashCode = result;
}
}
}
return result;
}

@SuppressWarnings("all")
private static class DelegateExclusions {
@ExcludeFromGeneratedCoverageReport
Expand Down
80 changes: 52 additions & 28 deletions src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public class LayeredEvaluationContext implements EvaluationContext {
private ArrayList<EvaluationContext> hookContexts;
private String targetingKey;
private Set<String> keySet = null;
// Lazily computed resolved attribute map for this layered context.
// This must be invalidated whenever the underlying layers change.
private Map<String, Value> cachedMap;

/**
* Constructor for LayeredEvaluationContext.
Expand Down Expand Up @@ -174,15 +177,20 @@ public Value getValue(String key) {
return getFromContext(apiContext, key);
}

@Override
public Map<String, Value> asMap() {
private Map<String, Value> getResolvedMap() {
if (cachedMap != null) {
return cachedMap;
}

if (keySet != null && keySet.isEmpty()) {
return new HashMap<>(0);
cachedMap = Collections.emptyMap();
return cachedMap;
}

HashMap<String, Value> map;
if (keySet != null) {
map = new HashMap<>(keySet.size());
// use helper to size the map based on expected entries
map = HashMapUtils.forEntries(keySet.size());
} else {
map = new HashMap<>();
}
Expand All @@ -205,7 +213,15 @@ public Map<String, Value> asMap() {
map.putAll(hookContext.asMap());
}
}
return map;

cachedMap = Collections.unmodifiableMap(map);
return cachedMap;
}

@Override
public Map<String, Value> asMap() {
// Return a defensive copy so callers can't mutate our cached map.
return new HashMap<>(getResolvedMap());
}

@Override
Expand All @@ -214,41 +230,48 @@ public Map<String, Value> asUnmodifiableMap() {
return Collections.emptyMap();
}

return Collections.unmodifiableMap(asMap());
return getResolvedMap();
}

@Override
public Map<String, Object> asObjectMap() {
if (keySet != null && keySet.isEmpty()) {
// Build the object map directly from the resolved attribute map,
// so this stays consistent with equals/hashCode and asMap().
Map<String, Value> resolved = getResolvedMap();
if (resolved.isEmpty()) {
return new HashMap<>(0);
}

HashMap<String, Object> map;
if (keySet != null) {
map = new HashMap<>(keySet.size());
} else {
map = new HashMap<>();
HashMap<String, Object> map = HashMapUtils.forEntries(resolved.size());
for (Map.Entry<String, Value> entry : resolved.entrySet()) {
Value value = entry.getValue();
// Value is responsible for exposing the underlying Java representation.
map.put(entry.getKey(), value == null ? null : value.asObject());
}
return map;
}

if (apiContext != null) {
map.putAll(apiContext.asObjectMap());
}
if (transactionContext != null) {
map.putAll(transactionContext.asObjectMap());
}
if (clientContext != null) {
map.putAll(clientContext.asObjectMap());
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (invocationContext != null) {
map.putAll(invocationContext.asObjectMap());
if (!(o instanceof EvaluationContext)) {
return false;
}
if (hookContexts != null) {
for (int i = 0; i < hookContexts.size(); i++) {
EvaluationContext hookContext = hookContexts.get(i);
map.putAll(hookContext.asObjectMap());
}

EvaluationContext that = (EvaluationContext) o;

if (that instanceof LayeredEvaluationContext) {
return this.getResolvedMap().equals(((LayeredEvaluationContext) that).getResolvedMap());
}
return map;

return this.getResolvedMap().equals(that.asMap());
}

@Override
public int hashCode() {
return getResolvedMap().hashCode();
}

void putHookContext(EvaluationContext context) {
Expand All @@ -265,5 +288,6 @@ void putHookContext(EvaluationContext context) {
}
this.hookContexts.add(context);
this.keySet = null;
this.cachedMap = null;
}
}
44 changes: 31 additions & 13 deletions src/test/java/dev/openfeature/sdk/ImmutableContextTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -50,7 +53,7 @@ void shouldChangeTargetingKeyFromOverridingContext() {
assertEquals("overriding_key", merge.getTargetingKey());
}

@DisplayName("targeting key should not changed from the overriding context if missing")
@DisplayName("targeting key should not be changed from the overriding context if missing")
@Test
void shouldRetainTargetingKeyWhenOverridingContextTargetingKeyValueIsEmpty() {
HashMap<String, Value> attributes = new HashMap<>();
Expand All @@ -66,7 +69,7 @@ void shouldRetainTargetingKeyWhenOverridingContextTargetingKeyValueIsEmpty() {
@Test
void missingTargetingKeyShould() {
EvaluationContext ctx = new ImmutableContext();
assertEquals(null, ctx.getTargetingKey());
assertNull(ctx.getTargetingKey());
}

@DisplayName("Merge should retain all the attributes from the existing context when overriding context is null")
Expand Down Expand Up @@ -145,10 +148,26 @@ void mergeShouldObtainKeysFromOverridingContextWhenExistingContextIsEmpty() {
EvaluationContext ctx = new ImmutableContext();
EvaluationContext overriding = new ImmutableContext(attributes);
EvaluationContext merge = ctx.merge(overriding);
assertEquals(new java.util.HashSet<>(java.util.Arrays.asList("key1", "key2")), merge.keySet());
assertEquals(new HashSet<>(Arrays.asList("key1", "key2")), merge.keySet());
}

@DisplayName("Two different MutableContext objects with the different contents are not considered equal")
@DisplayName("Two ImmutableContext objects with identical attributes are considered equal")
@Test
void testImmutableContextEquality() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a very similar test in line 190

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a new test instead

Map<String, Value> map1 = new HashMap<>();
map1.put("key", new Value("value"));

Map<String, Value> map2 = new HashMap<>();
map2.put("key", new Value("value"));

ImmutableContext a = new ImmutableContext(null, map1);
ImmutableContext b = new ImmutableContext(null, map2);

assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
}

@DisplayName("Two different ImmutableContext objects with different contents are not considered equal")
@Test
void unequalImmutableContextsAreNotEqual() {
final Map<String, Value> attributes = new HashMap<>();
Expand All @@ -161,17 +180,16 @@ void unequalImmutableContextsAreNotEqual() {
assertNotEquals(ctx, ctx2);
}

@DisplayName("Two different MutableContext objects with the same content are considered equal")
@DisplayName("ImmutableContext hashCode is stable across multiple invocations")
@Test
void equalImmutableContextsAreEqual() {
final Map<String, Value> attributes = new HashMap<>();
attributes.put("key1", new Value("val1"));
final ImmutableContext ctx = new ImmutableContext(attributes);
void immutableContextHashCodeIsStable() {
Map<String, Value> map = new HashMap<>();
map.put("key", new Value("value"));

final Map<String, Value> attributes2 = new HashMap<>();
attributes2.put("key1", new Value("val1"));
final ImmutableContext ctx2 = new ImmutableContext(attributes2);
ImmutableContext ctx = new ImmutableContext(null, map);

assertEquals(ctx, ctx2);
int first = ctx.hashCode();
int second = ctx.hashCode();
assertEquals(first, second);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -397,5 +397,37 @@ void mergesCorrectlyWhenOtherHasNoTargetingKey() {
merged.asMap());
assertEquals(invocationContext.getTargetingKey(), merged.getTargetingKey());
}

@Test
void testLayeredContextEquality() {
Map<String, Value> baseMap = Map.of("k", new Value("v"));
Map<String, Value> layerMap = Map.of("x", new Value("y"));

EvaluationContext base = new MutableContext(null, baseMap);
EvaluationContext layer = new MutableContext(null, layerMap);

LayeredEvaluationContext l1 = new LayeredEvaluationContext(base, layer, null, null);
LayeredEvaluationContext l2 = new LayeredEvaluationContext(base, layer, null, null);

assertEquals(l1, l2);
assertEquals(l1.hashCode(), l2.hashCode());
}

@Test
void testMixedContextEquality() {
Map<String, Value> map = Map.of("foo", new Value("bar"));

EvaluationContext base = new MutableContext(null, map);
LayeredEvaluationContext layered = new LayeredEvaluationContext(null, null, null, base);

// Equality from the layered context's perspective (map-based equality)
assertEquals(layered, base);

// Resolved maps should be identical
assertEquals(base.asMap(), layered.asMap());

// Layered's hashCode must be consistent with its resolved attribute map
assertEquals(base.asMap().hashCode(), layered.hashCode());
}
}
}