Skip to content
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

Merged
merged 39 commits into from
Oct 2, 2018
Merged

Conversation

thomaszurkan-optimizely
Copy link
Contributor

@thomaszurkan-optimizely thomaszurkan-optimizely commented Sep 10, 2018

  • 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

@coveralls
Copy link

coveralls commented Sep 10, 2018

Pull Request Test Coverage Report for Build 658

  • 155 of 162 (95.68%) changed or added relevant lines in 21 files are covered.
  • 1 unchanged line in 1 file lost coverage.
  • Overall coverage increased (+0.4%) to 89.381%

Changes Missing Coverage Covered Lines Changed/Added Lines %
core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java 25 26 96.15%
core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java 7 10 70.0%
core-api/src/main/java/com/optimizely/ab/config/audience/match/AttributeMatch.java 3 6 50.0%
Files with Coverage Reduction New Missed Lines %
core-api/src/main/java/com/optimizely/ab/event/LogEvent.java 1 83.33%
Totals Coverage Status
Change from base Build 655: 0.4%
Covered Lines: 2500
Relevant Lines: 2797

💛 - 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.
Copy link
Contributor

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> {
Copy link
Contributor

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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please document this method.

Copy link
Contributor

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";
Copy link
Contributor

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;
Copy link
Contributor

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.
Copy link
Contributor

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) {
Copy link
Contributor

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;
Copy link
Contributor

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.

@thomaszurkan-optimizely thomaszurkan-optimizely changed the title WIP: New audience match types New audience match types Sep 13, 2018
Copy link
Contributor

@mikeproeng37 mikeproeng37 left a 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#
Copy link
Contributor

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#
Copy link
Contributor

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
Copy link
Contributor

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
Copy link
Contributor

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

Copy link
Contributor Author

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> {
Copy link
Contributor

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

Copy link
Contributor Author

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;
Copy link
Contributor

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.
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice :)

Copy link
Contributor

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){
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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"));
Copy link
Contributor

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"));
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: space after null

@thomaszurkan-optimizely
Copy link
Contributor Author

build

Copy link
Contributor

@mikeproeng37 mikeproeng37 left a 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
Copy link
Contributor

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)) {
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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);
Copy link
Contributor

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

Copy link
Contributor Author

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);
Copy link
Contributor

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":
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

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. :(

Copy link
Contributor

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);
Copy link
Contributor

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){
Copy link
Contributor

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 {

Copy link
Contributor

@mikeproeng37 mikeproeng37 left a 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

Copy link
Contributor

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();
Copy link
Contributor

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?

Copy link
Contributor

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);
}
Copy link
Contributor

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"],
Copy link
Contributor

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.
Copy link
Contributor

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 {
Copy link
Contributor

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 {
Copy link
Contributor

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

Copy link
Contributor

@aliabbasrizvi aliabbasrizvi left a 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.

Copy link
Contributor

@nchilada nchilada left a 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();
Copy link
Contributor

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);
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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!

Copy link
Contributor

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

Copy link
Contributor Author

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.

Copy link
Contributor

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"));
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why?

Copy link
Contributor

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);
Copy link
Contributor

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>>() {});
Copy link
Contributor

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\"}]]]"
}
],
Copy link
Contributor

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. 🤷

Copy link
Contributor Author

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.

Copy link
Contributor

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)) {
Copy link
Contributor

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)) {
Copy link
Contributor

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);
Copy link
Contributor

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);
Copy link
Contributor

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":
Copy link
Contributor

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) {
Copy link
Contributor

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) {
Copy link
Contributor

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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

We only track Integers and Doubles, not all Numbers; 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) {
Copy link
Contributor

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> {
Copy link
Contributor

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);
Copy link
Contributor

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);
Copy link
Contributor

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) {
Copy link
Contributor

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"

Copy link
Contributor Author

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;
Copy link
Contributor

@nchilada nchilada Sep 22, 2018

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 NullPointerExceptions 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 🤷)

Copy link
Contributor Author

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. :(

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, interesting, thanks.

Copy link
Contributor

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));
Copy link
Contributor

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.

Copy link
Contributor Author

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)?

Copy link
Contributor

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.

Copy link
Contributor

@nchilada nchilada left a 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;
Copy link
Contributor

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();
Copy link
Contributor

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();
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

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

Copy link
Contributor

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();
Copy link
Contributor

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();
Copy link
Contributor

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);
Copy link
Contributor

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);
Copy link
Contributor

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

@thomaszurkan-optimizely thomaszurkan-optimizely merged commit 684e47a into master Oct 2, 2018
@nchilada nchilada deleted the newAudienceMatchTypes branch October 2, 2018 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants