Skip to content
Merged
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
17 changes: 15 additions & 2 deletions src/main/java/dev/openfeature/sdk/AbstractStructure.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import lombok.EqualsAndHashCode;
import java.util.Objects;

@SuppressWarnings({"PMD.BeanMembersShouldSerialize", "checkstyle:MissingJavadocType"})
@EqualsAndHashCode
abstract class AbstractStructure implements Structure {

protected final Map<String, Value> attributes;
Expand Down Expand Up @@ -48,4 +47,18 @@ public Map<String, Object> asObjectMap() {
(accumulated, entry) -> accumulated.put(entry.getKey(), convertValue(entry.getValue())),
HashMap::putAll);
}

@Override
public boolean equals(Object object) {
if (!(object instanceof AbstractStructure)) {
return false;
}
AbstractStructure that = (AbstractStructure) object;
return Objects.equals(attributes, that.attributes);
}

@Override
public int hashCode() {
return Objects.hashCode(attributes);
}
}
25 changes: 25 additions & 0 deletions src/main/java/dev/openfeature/sdk/EvaluationContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ public interface EvaluationContext extends Structure {
*/
EvaluationContext merge(EvaluationContext overridingContext);

/**
* If the other object is an EvaluationContext, this method compares the results of asUnmodifiableMap() for
* equality. Otherwise, it returns false.
* <br>
* <br>
* Implementations of EvaluationContext are encouraged to delegate their equals() method to this method, or provide
* a more optimized check with the same semantics.
*
* @param other the object to compare to
* @return true if the other object is an EvaluationContext and has the same map representation, false otherwise
*/
default boolean isEqualTo(Object other) {
if (other == null) {
return false;
}
if (other == this) {
return true;
}
if (!(other instanceof EvaluationContext)) {
return false;
}
var otherContext = (EvaluationContext) other;
return asUnmodifiableMap().equals(otherContext.asUnmodifiableMap());
}

/**
* Recursively merges the overriding map into the base Value map.
* The base map is mutated, the overriding map is not.
Expand Down
39 changes: 37 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,40 @@ 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) {
return isEqualTo(o);
}

/**
* 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 = structure.hashCode();
cachedHashCode = result;
}
}
}
return result;
}

@SuppressWarnings("all")
private static class DelegateExclusions {
@ExcludeFromGeneratedCoverageReport
Expand Down
56 changes: 49 additions & 7 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<>();
}
Comment on lines 185 to 196
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

To improve performance and avoid potential HashMap resizing, it's better to compute the full set of keys before initializing the map. By calling ensureKeySet() upfront, you can guarantee that the HashMap is created with the correct initial capacity to hold all entries, which is more efficient than potentially creating a default-sized map that needs to be rehashed.

        ensureKeySet();

        if (this.keySet.isEmpty()) {
            cachedMap = Collections.emptyMap();
            return cachedMap;
        }

        // use helper to size the map based on expected entries
        HashMap<String, Value> map = HashMapUtils.forEntries(this.keySet.size());

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,7 +230,7 @@ public Map<String, Value> asUnmodifiableMap() {
return Collections.emptyMap();
}

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

@Override
Expand All @@ -225,7 +241,8 @@ public Map<String, Object> asObjectMap() {

HashMap<String, Object> 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 @@ -248,9 +265,33 @@ public Map<String, Object> asObjectMap() {
map.putAll(hookContext.asObjectMap());
}
}

return map;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof EvaluationContext)) {
return false;
}

EvaluationContext that = (EvaluationContext) o;

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

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

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

void putHookContext(EvaluationContext context) {
if (context == null || context.isEmpty()) {
return;
Expand All @@ -265,5 +306,6 @@ void putHookContext(EvaluationContext context) {
}
this.hookContexts.add(context);
this.keySet = null;
this.cachedMap = null;
}
}
20 changes: 18 additions & 2 deletions src/main/java/dev/openfeature/sdk/MutableContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import java.util.List;
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 @@
* be modified after instantiation.
*/
@ToString
@EqualsAndHashCode
@SuppressWarnings("PMD.BeanMembersShouldSerialize")
public class MutableContext implements EvaluationContext {

Expand Down Expand Up @@ -125,6 +123,24 @@ public EvaluationContext merge(EvaluationContext overridingContext) {
return new MutableContext(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) {
return isEqualTo(o);
}

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

/**
* Hidden class to tell Lombok not to copy these methods over via delegation.
*/
Expand Down
Loading
Loading