-
Notifications
You must be signed in to change notification settings - Fork 31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
New audience match types #213
Conversation
thomaszurkan-optimizely
commented
Sep 10, 2018
•
edited
Loading
edited
- New match types along with leaf matchers
- Changed logic of AND,OR,NOT conditions to allow for nulls to bubble through appropriately.
- Change audience evaluation to always evaluate regardless if there was an attribute key passed in or not.
- Still allows null in your attribute list.
- Added match to UserAttribute in preparation of typedAttributes. Added to all json parsers as a default.
- Parse typedAudiences and include in v4 datafile validation. V3 and V2
…ustomDimensionMatcher to mimic current custom dimension behavior. This should be changed to use Exact.
…/java-sdk into newAudienceMatchTypes
…/java-sdk into newAudienceMatchTypes
Pull Request Test Coverage Report for Build 658
💛 - Coveralls |
Boolean evaluate(Map<String, ?> attributes) { | ||
boolean foundNull = false; | ||
// https://docs.google.com/document/d/158_83difXVXF0nb91rxzrfHZwnhsybH21ImRA_si7sg/edit# | ||
// According to the matix mentioned in the above document. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: typo in matrix
* custom dimension. This will be dropped for ExactMatch and the unit tests need to be fixed. | ||
* @param <T> | ||
*/ | ||
class CustomDimensionMatch<T> extends LeafMatch<T> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't support custom dimensions anymore, even in 1.x. I think we can safely omit this.
} | ||
|
||
abstract class LeafMatch<T> implements LeafMatcher { | ||
T convert(Object o) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please document this method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, I think we should be letting these throw so we can log the type mismatch as a debug level log?
private LeafMatcher matcher; | ||
|
||
public static MatchType getMatchType(String type, Object value) { | ||
if (type == null) type = "custom_dimension"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure this is accurate, we should default to exact
right?
} | ||
|
||
public @Nullable Boolean eval(Object otherValue) { | ||
return null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a bit confusing, shouldn't this match the value to null
?
@@ -36,10 +37,22 @@ public OrCondition(@Nonnull List<Condition> conditions) { | |||
return conditions; | |||
} | |||
|
|||
public boolean evaluate(Map<String, ?> attributes) { | |||
// https://docs.google.com/document/d/158_83difXVXF0nb91rxzrfHZwnhsybH21ImRA_si7sg/edit# | |||
// According to the matix mentioned in the above document. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: typo in matrix
public Object getValue() { | ||
return value; | ||
} | ||
|
||
public boolean evaluate(Map<String, ?> attributes) { | ||
public Boolean evaluate(Map<String, ?> attributes) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add unit tests for this method.
@@ -0,0 +1,187 @@ | |||
package com.optimizely.ab.config.audience; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add unit tests for the Match types as well as the different Conditions classes, especially around the evaluate
logic.
… write more unit tests that use them
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good for the most part. Sending it back for thoughts on bubbling up exceptions so we can log proper debug messages
public @Nullable | ||
Boolean evaluate(Map<String, ?> attributes) { | ||
boolean foundNull = false; | ||
// https://docs.google.com/document/d/158_83difXVXF0nb91rxzrfHZwnhsybH21ImRA_si7sg/edit# |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove this as this is public and the doc is not :)
@@ -36,10 +37,22 @@ public OrCondition(@Nonnull List<Condition> conditions) { | |||
return conditions; | |||
} | |||
|
|||
public boolean evaluate(Map<String, ?> attributes) { | |||
// https://docs.google.com/document/d/158_83difXVXF0nb91rxzrfHZwnhsybH21ImRA_si7sg/edit# |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove this
for (Condition condition : conditions) { | ||
if (condition.evaluate(attributes)) | ||
Boolean conditionEval = condition.evaluate(attributes); | ||
if (conditionEval == null) {// true with falses and nulls is still true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: space before the comment?
// check user attribute value is equal | ||
return value.equals(userAttributeValue); | ||
if (!"custom_attribute".equals(type)) { | ||
return null; // unknown type |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder, if in these cases, we should be throwing and have the caller catch and log the fact that the types are mismatched. I think it would be extremely valuable for debugging
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added logging instead.
* custom dimension. This will be dropped for ExactMatch and the unit tests need to be fixed. | ||
* @param <T> | ||
*/ | ||
class CustomDimensionMatch<T> extends LeafMatch<T> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I asked this before, but do we need this? CustomDimensions are from Classic
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If there is no match we use the old style matcher which is captured in CustomDimension (this is before we changed everything to custom attribute. Do you think it should be named DefaultMatchForLegacyAttributes?
@@ -71,6 +71,10 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse | |||
|
|||
List<EventType> events = parseEvents((JSONArray)rootObject.get("events")); | |||
List<Audience> audiences = parseAudiences((JSONArray)parser.parse(rootObject.get("audiences").toString())); | |||
List<Audience> typedAudiences =null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: space after =
@@ -66,14 +66,11 @@ public static boolean isUserInExperiment(@Nonnull ProjectConfig projectConfig, | |||
return true; | |||
} | |||
|
|||
// if there are audiences, but no user attributes, the user is not in the experiment. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
:yasss:
if (conditions.evaluate(attributes)) { | ||
Boolean conditionEval = conditions.evaluate(attributes); | ||
|
||
if (conditionEval != null && conditionEval){ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be shortened to if conditionEval == true
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Need to check for null. Auto boxing I believe would throw an exception here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK.
Also, nit: space before the opening {
@@ -106,7 +106,7 @@ public void verifyGetExperimentsForInvalidEvent() throws Exception { | |||
@Test | |||
public void verifyGetAudienceConditionsFromValidId() throws Exception { | |||
List<Condition> userAttributes = new ArrayList<Condition>(); | |||
userAttributes.add(new UserAttribute("browser_type", "custom_attribute", "firefox")); | |||
userAttributes.add(new UserAttribute("browser_type", "custom_attribute", null,"firefox")); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: space after null
@@ -245,7 +245,7 @@ private static ProjectConfig generateValidProjectConfigV3() { | |||
new EventType("100", "no_running_experiments", singletonList("118"))); | |||
|
|||
List<Condition> userAttributes = new ArrayList<Condition>(); | |||
userAttributes.add(new UserAttribute("browser_type", "custom_attribute", "firefox")); | |||
userAttributes.add(new UserAttribute("browser_type", "custom_attribute", null,"firefox")); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: space after null
build |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Almost there, I have a couple of nits and a question on the legacy support. Would like @nchilada to also chime in here.
FYI: I ran the compat suite tests for typed audiences and they all pass on your branch 😄
} | ||
} | ||
|
||
if (foundNull) {// if found null and false return null. all false return false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: space before the start of the comment
if (value != null) { // if there is a value in the condition | ||
// check user attribute value is equal | ||
return value.equals(userAttributeValue); | ||
if (!"custom_attribute".equals(type)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Why not invoke the equals
method from the variable itself?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this works when the variable is null.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good thinking @thomaszurkan-optimizely!
return convert(otherValue).doubleValue() > value.doubleValue(); | ||
} | ||
catch (Exception e) { | ||
MatchType.logger.error("Greater than match ", e); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Can be a bit more verbose: Greater than match failed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the exception will be included in the output making it pretty obvious it is an error.
return convert(otherValue).doubleValue() < value.doubleValue(); | ||
} | ||
catch (Exception e) { | ||
MatchType.logger.error("Less than match ", e); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as above
return new MatchType(type, new LTMatch((Number) value)); | ||
} | ||
break; | ||
case "legacy_custom_attribute": |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huh? Is this something that we are populating in the datafile? @nchilada I don't recall seeing this in the design doc. Isn't this the default case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, I changed the legacy so that if no match type is provide, I try and use the legacy custom attribute matcher.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's set at the top of the method when match is null (name="browser", type="custom_attribute" value="firefox") would end up using legacy. I thought changing the name would make that clearer. :(
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@thomaszurkan-optimizely I think it would be sufficient—and less confusing, IMO—to just use "exact" instead of defining a new "legacy_custom_attribute" match type and bifurcating the code.
return value.contains(convert(otherValue)); | ||
} | ||
catch (Exception e) { | ||
MatchType.logger.error("Substring match ", e); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Substring match failed
if (conditions.evaluate(attributes)) { | ||
Boolean conditionEval = conditions.evaluate(attributes); | ||
|
||
if (conditionEval != null && conditionEval){ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK.
Also, nit: space before the opening {
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lgtm
.travis.yml
Outdated
@@ -26,3 +26,6 @@ branches: | |||
- /^\d+\.\d+\.\d+(-SNAPSHOT|-alpha|-beta)?\d*$/ # trigger builds on tags which are semantically versioned to ship the SDK. | |||
after_success: | |||
- ./gradlew coveralls uploadArchives --console plain | |||
after_failure: | |||
- cat /home/travis/build/optimizely/java-sdk/core-api/build/reports/findbugs/main.html | |||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit. Remove extraneous line.
this.typedAudiences = Collections.unmodifiableList(typedAudiences); | ||
} | ||
else { | ||
this.typedAudiences = Collections.emptyList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't it make life easier if this.typedAudiences = Collections.unmodifiableList(audiences)
instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@aliabbasrizvi I think that might be unnecessary and confusing. We still make use of this.audences
since audiences that happen to be backwards-compatible are still going to be serialized in audiences
(rather than in typedAudiences
).
import java.util.Map; | ||
|
||
/** | ||
* Interface implemented by all conditions condition objects to aid in condition evaluation. | ||
*/ | ||
public interface Condition { | ||
|
||
boolean evaluate(Map<String, ?> attributes); | ||
@Nullable | ||
Boolean evaluate(Map<String, ?> attributes); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit. Insert new line here.
"endOfRange": 10000 | ||
} | ||
], | ||
"audienceIds": ["3468206643","3468206644","3468206645"], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit. Insert space after ,
|
||
/** | ||
* This is a temporary class. It mimics the current behaviour for | ||
* legasy custom attributes. This will be dropped for ExactMatch and the unit tests need to be fixed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
typo. legacy
*/ | ||
package com.optimizely.ab.config.audience.match; | ||
|
||
abstract class LeafMatch<T> implements LeafMatcher { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a big fan of this name LeafMatch
. May be just Match
?
*/ | ||
package com.optimizely.ab.config.audience.match; | ||
|
||
public interface LeafMatcher { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same feedback as above. Perhaps just call this Matcher
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting to request changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll get to MatchType
, AttributeMatch
, or their subclasses later today, but I finished the ProjectConfig
and Condition
side, and everything looks good overall! Nice work @thomaszurkan-optimizely, I only have minor feedback so far
this.typedAudiences = Collections.unmodifiableList(typedAudiences); | ||
} | ||
else { | ||
this.typedAudiences = Collections.emptyList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@aliabbasrizvi I think that might be unnecessary and confusing. We still make use of this.audences
since audiences that happen to be backwards-compatible are still going to be serialized in audiences
(rather than in typedAudiences
).
else { | ||
List<Audience> combinedList = new ArrayList<>(audiences); | ||
combinedList.addAll(typedAudiences); | ||
this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(combinedList); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a given audience ID is found in both audiences
and typedAudiences
, we want to use the audience in typedAudiences
. Are we sure this code achieves that? Do we have a unit test for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes. that is exactly the point of this combination. the map is audience ids. The first set of mappings is for audiences. If the Id exists, it is replaced when the typed audiences is added to the map.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay thanks. That's quite implicit but I guess it works. A code comment and unit tests would help!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A code comment and unit tests would help!
Bump
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is in a bunch of unit tests that I added with V4 datafile with typedAudiences. They share Ids for the string exact ones (i.e. legacy and typed). The audience id map is used to get the audience and uses the combined map in question.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh cool! Didn't see that just now, but that's perfect, thanks.
@@ -69,6 +69,11 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse | |||
|
|||
List<EventType> events = parseEvents(rootObject.getJSONArray("events")); | |||
List<Audience> audiences = parseAudiences(rootObject.getJSONArray("audiences")); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we gate this with if (rootObject.has("audiences"))
, the same way we're handling typedAudiences
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So that app backend isn't tied down with audiences
if we decide to move entirely to typedAudiences
at some point. Semantically, it seems reasonable to make audiences
optional once typedAudiences
is supported too.
@@ -70,6 +70,10 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa | |||
List<Audience> audiences = | |||
context.deserialize(jsonObject.get("audiences").getAsJsonArray(), audienceType); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we gate this with if (rootObject.has("audiences"))
, the same way we're handling typedAudiences
?
@@ -66,6 +66,14 @@ public ProjectConfig deserialize(JsonParser parser, DeserializationContext conte | |||
List<Audience> audiences = mapper.readValue(node.get("audiences").toString(), | |||
new TypeReference<List<Audience>>() {}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we gate this with if (rootObject.has("audiences"))
, the same way we're handling typedAudiences
?
"name": "audience_with_missing_value", | ||
"conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"value\": \"English\"}, {\"name\": \"nationality\", \"type\": \"custom_attribute\"}]]]" | ||
} | ||
], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since you'd asked me to check: this looks accurate. But I'd also test with an audience whose ID is found in both audiences
and typedAudiences
.
It might also be wise to include audience conditions with match
being null
instead of being undefined, though I have no intention of having the app backend serialize such conditions. 🤷
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All the string conditions that map to exact are also in the audience list and replace the audience lookup map with the typed audience since they share ids. Look at the audience list above typed audience definition.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, gotcha. I should've expanded the whole file and checked 😬
if (value != null) { // if there is a value in the condition | ||
// check user attribute value is equal | ||
return value.equals(userAttributeValue); | ||
if (!"custom_attribute".equals(type)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good thinking @thomaszurkan-optimizely!
if (value != null) { // if there is a value in the condition | ||
// check user attribute value is equal | ||
return value.equals(userAttributeValue); | ||
if (!"custom_attribute".equals(type)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Once we support other condition types we'll probably want to do this check in ProjectConfig or wherever. But this is fine for now. I'm not overly concerned about the fact that we're constructing CustomAttribute objects for things that might not semantically represent custom_attribute
conditions.
return false; | ||
// check user attribute value is equal | ||
try { | ||
return MatchType.getMatchType(match, value).getMatcher().eval(userAttributeValue); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on the name alone, I find it surprising that we're passing value
to .getMatchType(...)
and not to .getMatcher(...)
or .eval(...)
. That said, this is minor and I haven't looked at those functions yet.
else { // both are null | ||
return true; | ||
catch (NullPointerException np) { | ||
MatchType.logger.error(String.format("attribute or value null for match %s", match != null ? match : "legacy condition"),np); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can definitely wait until after the initial PR is merged, but we may want to have super-specific error messages. cc @ceimaj
return new MatchType(type, new LTMatch((Number) value)); | ||
} | ||
break; | ||
case "legacy_custom_attribute": |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@thomaszurkan-optimizely I think it would be sufficient—and less confusing, IMO—to just use "exact" instead of defining a new "legacy_custom_attribute" match type and bifurcating the code.
private String type; | ||
private Match matcher; | ||
|
||
public static MatchType getMatchType(String type, Object value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the context of this function, maybe rename type
-> matchType
so it can't be misread as "valueType
"?
private String type; | ||
private Match matcher; | ||
|
||
public static MatchType getMatchType(String type, Object value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we drop the MatchType
abstraction and just return a Match
object instead? In this PR I only see one call to MatchType.getMatchType(...)
, and the caller only seems to care about the Match
object.
(And if you take this suggestion, it might also make sense to move this static function from MatchType.getMatchType
to Match.getMatcher
.)
} | ||
break; | ||
case "gt": | ||
if (value instanceof Number) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We only track Integer
s and Double
s, not all Number
s; can we do the same for targeting? That'd make the SDK behavior more consistent and easier to document, IMO.
And this is a design decision (which I've yet to see anyone else weigh in one) so I'll confirm with others offline.
} | ||
break; | ||
case "lt": | ||
if (value instanceof Number) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as above. I'd rather proceed only if value instanceof Integer
or value instanceof Double
.
|
||
import javax.annotation.Nullable; | ||
|
||
class ExactMatch<T> extends AttributeMatch<T> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Match and all its implementations: maybe rename otherValue
-> userValue
/userAttributeValue
and rename value
-> conditionValue
?
package com.optimizely.ab.config.audience.match; | ||
|
||
public interface Match { | ||
Boolean eval(Object otherValue); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Match and all its implementations: maybe rename otherValue
-> userValue
/userAttributeValue
and rename value
-> conditionValue
?
package com.optimizely.ab.config.audience.match; | ||
|
||
public interface Match { | ||
Boolean eval(Object otherValue); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And I'm just curious, since I don't know anything about Java annotations: would @Nullable
be relevant here?
try { | ||
T rv = (T)o; | ||
return rv; | ||
} catch(java.lang.ClassCastException e) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we log this? In principle something like "Cannot evaluate targeting condition since the value for attribute is of an incompatible value"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding logging
T rv = (T)o; | ||
return rv; | ||
} catch(java.lang.ClassCastException e) { | ||
return null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Swallowing the error and returning null
feels pretty weird to me. This causes subclasses' eval(...)
methods to proceed blindly with a null
value (in some cases producing NullPointerException
s which they must know to catch) when the more straightforward, robust behavior might be for them to immediately return null
.
Can we try a slightly different pattern? Maybe something like
abstract class TypedMatch<T> implements Match {
Boolean eval(Object otherValue) {
try {
T otherValueT = (T) otherValue;
} catch(java.lang.ClassCastException e) {
return null;
}
return eval(typedOtherValue);
}
Boolean eval(T otherValue);
}
with our subclasses implementing eval(T otherValue)
instead of eval(Object otherValue)
.
(Or should we give up on having common, generic-powered type-checking code? But maybe that's just my dynamic typing background coming through 🤷)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a great idea and I really like where you are going with it. However, in Java (and other strongly typed languages) the eval here ends up with the same signature. Meaning a value is both of type object and type t. So, it becomes ambiguous. :(
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, interesting, thanks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is technically fine. Since we won't ever be constructing instances of AttributeMatch<String>
, AttributeMatch<Number>
, AttributeMatch<Boolean>
, etc. where this.value
(the condition value) is null
, we won't accidentally evaluate to true
when the value returned by convert
is compared to this.value
.
The customer may see NullPointerException
or some other exception that doesn't specifically talk about the type mismatch, but that's not my top concern.
BTW I was hoping for us to support conditions like {..., "match": "exact", "value": null}
. That doesn't work so well with the current scheme, but I assume we could hack it if necessary. It's certainly not worth optimizing for 🤷♂️
condition = new OrCondition(conditions); | ||
break; | ||
default: | ||
condition = new NotCondition(conditions.get(0)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After discussing with @mjc1283: we should also return null
instead of raising IndexOutOfBoundsException
if conditions.length == 0
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there ever going to be a audience without a condition (not to be confused with audience id list)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there ever going to be a audience without a condition (not to be confused with audience id list)?
As we started to discuss in Slack: if an audience object has null or missing conditions
, I think that's rather problematic and it's appropriate for the SDK to reject the datafile. On the other hand, if the conditions
are empty ([]
, {}
, etc.) then it's fine to apply our recursive evaluation algorithm to that value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, just some minor feedback since this is a reference SDK.
As a heads up, there are a couple smaller changes that we couldn't get to here but which I'd like to make separately
- Recognizing audience conditions that aren't double-JSON-serialized in the datafile
- Inferring
“or”
rather than“not”
if we see a composite condition whose first element isn't"and"
/"or"
/"not"
.
T rv = (T)o; | ||
return rv; | ||
} catch(java.lang.ClassCastException e) { | ||
return null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is technically fine. Since we won't ever be constructing instances of AttributeMatch<String>
, AttributeMatch<Number>
, AttributeMatch<Boolean>
, etc. where this.value
(the condition value) is null
, we won't accidentally evaluate to true
when the value returned by convert
is compared to this.value
.
The customer may see NullPointerException
or some other exception that doesn't specifically talk about the type mismatch, but that's not my top concern.
BTW I was hoping for us to support conditions like {..., "match": "exact", "value": null}
. That doesn't work so well with the current scheme, but I assume we could hack it if necessary. It's certainly not worth optimizing for 🤷♂️
List<Audience> audiences = mapper.readValue(node.get("audiences").toString(), | ||
new TypeReference<List<Audience>>() {}); | ||
|
||
List<Audience> audiences = Collections.emptyList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same question as above - can we initialize to a consistent value?
@@ -68,7 +69,17 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse | |||
attributes = parseAttributes(rootObject.getJSONArray("attributes")); | |||
|
|||
List<EventType> events = parseEvents(rootObject.getJSONArray("events")); | |||
List<Audience> audiences = parseAudiences(rootObject.getJSONArray("audiences")); | |||
List<Audience> audiences = Collections.emptyList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this initialized to Collections.emptyList()
while the typed version is initialized to null
? Can we make these consistent with each other?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
backward compatibility. A v2 or V3 would possibly have problems with a null audience.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whereas typedAudience is new and will be null in V2 and V3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I think it's just a question of whether the missing audience arrays get infilled with empty lists in the various JSON parsers vs. in the ProjectConfig constructors, but shouldn't affect correctness either way.
Thanks for making both audiences
and typedAudiences
optional in the JSON config! 😊
@@ -70,7 +71,17 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse | |||
attributes = parseAttributes((JSONArray)rootObject.get("attributes")); | |||
|
|||
List<EventType> events = parseEvents((JSONArray)rootObject.get("events")); | |||
List<Audience> audiences = parseAudiences((JSONArray)parser.parse(rootObject.get("audiences").toString())); | |||
List<Audience> audiences = Collections.emptyList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same question as above - can we initialize to a consistent value?
@@ -67,9 +68,15 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa | |||
|
|||
List<EventType> events = | |||
context.deserialize(jsonObject.get("events").getAsJsonArray(), eventsType); | |||
List<Audience> audiences = | |||
context.deserialize(jsonObject.get("audiences").getAsJsonArray(), audienceType); | |||
List<Audience> audiences = Collections.emptyList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same question as above - can we initialize to a consistent value?
@@ -170,6 +173,14 @@ public ProjectConfig(String accountId, | |||
|
|||
this.attributes = Collections.unmodifiableList(attributes); | |||
this.audiences = Collections.unmodifiableList(audiences); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned above - can we allow the audiences
and typedAudiences
args are equally nullable?
else { | ||
List<Audience> combinedList = new ArrayList<>(audiences); | ||
combinedList.addAll(typedAudiences); | ||
this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(combinedList); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A code comment and unit tests would help!
Bump