Skip to content

feat (audiences): Typed audience evaluation #174

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

Merged
merged 2 commits into from
Oct 9, 2018
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
10 changes: 5 additions & 5 deletions packages/optimizely-sdk/lib/core/audience_evaluator/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright 2016, Optimizely
* Copyright 2016, 2018 Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -20,8 +20,9 @@ module.exports = {
* Determine if the given user attributes satisfy the given audience conditions
* @param {Object[]} audiences Audiences to match the user attributes against
* @param {Object[]} audiences.conditions Audience conditions to match the user attributes against
* @param {Object} userAttributes Hash representing user attributes which will be used in determining if
* the audience conditions are met
* @param {Object} [userAttributes] Hash representing user attributes which will be used in
* determining if the audience conditions are met. If not
* provided, defaults to an empty object.
* @return {Boolean} True if the user attributes match the given audience conditions
*/
evaluate: function(audiences, userAttributes) {
Expand All @@ -30,9 +31,8 @@ module.exports = {
return true;
}

// if no user attributes specified, return false
if (!userAttributes) {
return false;
userAttributes = {};
}

for (var i = 0; i < audiences.length; i++) {
Expand Down
26 changes: 23 additions & 3 deletions packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright 2016, Optimizely
* Copyright 2016, 2018 Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -18,10 +18,18 @@ var chai = require('chai');
var assert = chai.assert;

var chromeUserAudience = {
conditions: ['and', {'name': 'browser_type', 'value': 'chrome'}],
conditions: ['and', {
name: 'browser_type',
value: 'chrome',
type: 'custom_attribute',
}],
};
var iphoneUserAudience = {
conditions: ['and', {'name': 'device_model', 'value': 'iphone'}],
conditions: ['and', {
name: 'device_model',
value: 'iphone',
type: 'custom_attribute',
}],
};

describe('lib/core/audience_evaluator', function() {
Expand Down Expand Up @@ -72,6 +80,18 @@ describe('lib/core/audience_evaluator', function() {
assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], safariUsers));
assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], nexusSafariUsers));
});

it('should return true if no attributes are passed and the audience conditions evaluate to true in the absence of attributes', function() {
var conditionsPassingWithNoAttrs = ['not', {
match: 'exists',
name: 'input_value',
type: 'custom_attribute',
}];
var audience = {
conditions: conditionsPassingWithNoAttrs,
};
assert.isTrue(audienceEvaluator.evaluate([audience]));
});
});
});
});
242 changes: 192 additions & 50 deletions packages/optimizely-sdk/lib/core/condition_evaluator/index.js
Original file line number Diff line number Diff line change
@@ -1,121 +1,263 @@
/**
* Copyright 2016, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/****************************************************************************
* Copyright 2016, 2018, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
* You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
***************************************************************************/

var fns = require('../../utils/fns');

var AND_CONDITION = 'and';
var OR_CONDITION = 'or';
var NOT_CONDITION = 'not';

var DEFAULT_OPERATOR_TYPES = [AND_CONDITION, OR_CONDITION, NOT_CONDITION];

var CUSTOM_ATTRIBUTE_CONDITION_TYPE = 'custom_attribute';

var EXACT_MATCH_TYPE = 'exact';
var EXISTS_MATCH_TYPE = 'exists';
var GREATER_THAN_MATCH_TYPE = 'gt';
var LESS_THAN_MATCH_TYPE = 'lt';
var SUBSTRING_MATCH_TYPE = 'substring';

var MATCH_TYPES = [
EXACT_MATCH_TYPE,
EXISTS_MATCH_TYPE,
GREATER_THAN_MATCH_TYPE,
LESS_THAN_MATCH_TYPE,
SUBSTRING_MATCH_TYPE,
];

var EVALUATORS_BY_MATCH_TYPE = {};
EVALUATORS_BY_MATCH_TYPE[EXACT_MATCH_TYPE] = exactEvaluator;
EVALUATORS_BY_MATCH_TYPE[EXISTS_MATCH_TYPE] = existsEvaluator;
EVALUATORS_BY_MATCH_TYPE[GREATER_THAN_MATCH_TYPE] = greaterThanEvaluator;
EVALUATORS_BY_MATCH_TYPE[LESS_THAN_MATCH_TYPE] = lessThanEvaluator;
EVALUATORS_BY_MATCH_TYPE[SUBSTRING_MATCH_TYPE] = substringEvaluator;

/**
* Top level method to evaluate audience conditions
* @param {Object[]} conditions Nested array of and/or conditions.
* Example: ['and', operand_1, ['or', operand_2, operand_3]]
* @param {Object} userAttributes Hash representing user attributes which will be used in determining if
* the audience conditions are met.
* @return {Boolean} true if the given user attributes match the given conditions
* @param {Object[]|Object} conditions Nested array of and/or conditions, or a single condition object
* Example: ['and', { type: 'custom_attribute', ... }, ['or', { type: 'custom_attribute', ... }, { type: 'custom_attribute', ... }]]
* @param {Object} userAttributes Hash representing user attributes which will be used in determining if
* the audience conditions are met.
* @return {?Boolean} true/false if the given user attributes match/don't match the given conditions, null if
* the given user attributes and conditions can't be evaluated
*/
function evaluate(conditions, userAttributes) {
if (Array.isArray(conditions)) {
var firstOperator = conditions[0];
var restOfConditions = conditions.slice(1);

// return false for invalid operators
if (DEFAULT_OPERATOR_TYPES.indexOf(firstOperator) === -1) {
return false;
// Operator to apply is not explicit - assume 'or'
firstOperator = OR_CONDITION;
restOfConditions = conditions;
}

var restOfConditions = conditions.slice(1);
switch (firstOperator) {
case AND_CONDITION:
return andEvaluator(restOfConditions, userAttributes);
case NOT_CONDITION:
return notEvaluator(restOfConditions, userAttributes);
case OR_CONDITION:
default: // firstOperator is OR_CONDITION
return orEvaluator(restOfConditions, userAttributes);
}
}

var deserializedConditions = [conditions.name, conditions.value];
return evaluator(deserializedConditions, userAttributes);
var leafCondition = conditions;

if (leafCondition.type !== CUSTOM_ATTRIBUTE_CONDITION_TYPE) {
return null;
}

var conditionMatch = leafCondition.match;
if (typeof conditionMatch !== 'undefined' && MATCH_TYPES.indexOf(conditionMatch) === -1) {
return null;
}

var evaluatorForMatch = EVALUATORS_BY_MATCH_TYPE[conditionMatch] || exactEvaluator;
return evaluatorForMatch(leafCondition, userAttributes);
}

/**
* Evaluates an array of conditions as if the evaluator had been applied
* to each entry and the results AND-ed together.
* @param {Object[]} conditions Array of conditions ex: [operand_1, operand_2]
* @param {Object} userAttributes Hash representing user attributes
* @return {Boolean} true if the user attributes match the given conditions
* @return {?Boolean} true/false if the user attributes match/don't match the given conditions,
* null if the user attributes and conditions can't be evaluated
*/
function andEvaluator(conditions, userAttributes) {
var condition;
var sawNullResult = false;
for (var i = 0; i < conditions.length; i++) {
condition = conditions[i];
if (!evaluate(condition, userAttributes)) {
var conditionResult = evaluate(conditions[i], userAttributes);
if (conditionResult === false) {
return false;
}
if (conditionResult === null) {
sawNullResult = true;
}
}

return true;
return sawNullResult ? null : true;
}

/**
* Evaluates an array of conditions as if the evaluator had been applied
* to a single entry and NOT was applied to the result.
* @param {Object[]} conditions Array of conditions ex: [operand_1, operand_2]
* @param {Object} userAttributes Hash representing user attributes
* @return {Boolean} true if the user attributes match the given conditions
* @return {?Boolean} true/false if the user attributes match/don't match the given conditions,
* null if the user attributes and conditions can't be evaluated
*/
function notEvaluator(conditions, userAttributes) {
if (conditions.length !== 1) {
return false;
if (conditions.length > 0) {
var result = evaluate(conditions[0], userAttributes);
return result === null ? null : !result;
}

return !evaluate(conditions[0], userAttributes);
return null;
}

/**
* Evaluates an array of conditions as if the evaluator had been applied
* to each entry and the results OR-ed together.
* @param {Object[]} conditions Array of conditions ex: [operand_1, operand_2]
* @param {Object} userAttributes Hash representing user attributes
* @return {Boolean} true if the user attributes match the given conditions
* @return {?Boolean} true/false if the user attributes match/don't match the given conditions,
* null if the user attributes and conditions can't be evaluated
*/
function orEvaluator(conditions, userAttributes) {
var sawNullResult = false;
for (var i = 0; i < conditions.length; i++) {
var condition = conditions[i];
if (evaluate(condition, userAttributes)) {
var conditionResult = evaluate(conditions[i], userAttributes);
if (conditionResult === true) {
return true;
}
if (conditionResult === null) {
sawNullResult = true;
}
}
return sawNullResult ? null : false;
}

return false;
/**
* Returns true if the value is valid for exact conditions. Valid values include
* strings, booleans, and numbers that aren't NaN, -Infinity, or Infinity.
* @param value
* @returns {Boolean}
*/
function isValueValidForExactConditions(value) {
return typeof value === 'string' || typeof value === 'boolean' ||
fns.isFinite(value);
}

/**
* Evaluates an array of conditions as if the evaluator had been applied
* to a single entry and NOT was applied to the result.
* @param {Object[]} conditions Array of a single condition ex: [operand_1]
* @param {Object} userAttributes Hash representing user attributes
* @return {Boolean} true if the user attributes match the given conditions
* Evaluate the given exact match condition for the given user attributes
* @param {Object} condition
* @param {Object} userAttributes
* @return {?Boolean} true if the user attribute value is equal (===) to the condition value,
* false if the user attribute value is not equal (!==) to the condition value,
* null if the condition value or user attribute value has an invalid type, or
* if there is a mismatch between the user attribute type and the condition value
* type
*/
function evaluator(conditions, userAttributes) {
if (userAttributes.hasOwnProperty(conditions[0])) {
return userAttributes[conditions[0]] === conditions[1];
function exactEvaluator(condition, userAttributes) {
var conditionValue = condition.value;
var conditionValueType = typeof conditionValue;
var userValue = userAttributes[condition.name];
var userValueType = typeof userValue;

if (!isValueValidForExactConditions(userValue) ||
!isValueValidForExactConditions(conditionValueType) ||
conditionValueType !== userValueType) {
return null;
}

return conditionValue === userValue;
}

/**
* Evaluate the given exists match condition for the given user attributes
* @param {Object} condition
* @param {Object} userAttributes
* @returns {Boolean} true if both:
* 1) the user attributes have a value for the given condition, and
* 2) the user attribute value is neither null nor undefined
* Returns false otherwise
*/
function existsEvaluator(condition, userAttributes) {
var userValue = userAttributes[condition.name];
return typeof userValue !== 'undefined' && userValue !== null;
}

/**
* Evaluate the given greater than match condition for the given user attributes
* @param {Object} condition
* @param {Object} userAttributes
* @returns {?Boolean} true if the user attribute value is greater than the condition value,
* false if the user attribute value is less than or equal to the condition value,
* null if the condition value isn't a number or the user attribute value
* isn't a number
*/
function greaterThanEvaluator(condition, userAttributes) {
var userValue = userAttributes[condition.name];
var conditionValue = condition.value;

if (!fns.isFinite(userValue) || !fns.isFinite(conditionValue)) {
return null;
}

return userValue > conditionValue;
}

/**
* Evaluate the given less than match condition for the given user attributes
* @param {Object} condition
* @param {Object} userAttributes
* @returns {?Boolean} true if the user attribute value is less than the condition value,
* false if the user attribute value is greater than or equal to the condition value,
* null if the condition value isn't a number or the user attribute value isn't a
* number
*/
function lessThanEvaluator(condition, userAttributes) {
var userValue = userAttributes[condition.name];
var conditionValue = condition.value;

if (!fns.isFinite(userValue) || !fns.isFinite(conditionValue)) {
return null;
}

return userValue < conditionValue;
}

/**
* Evaluate the given substring match condition for the given user attributes
* @param {Object} condition
* @param {Object} userAttributes
* @returns {?Boolean} true if the condition value is a substring of the user attribute value,
* false if the condition value is not a substring of the user attribute value,
* null if the condition value isn't a string or the user attribute value
* isn't a string
*/
function substringEvaluator(condition, userAttributes) {
var userValue = userAttributes[condition.name];
var conditionValue = condition.value;

if (typeof userValue !== 'string' || typeof conditionValue !== 'string') {
return null;
}

return false;
return userValue.indexOf(conditionValue) !== -1;
}

module.exports = {
Expand Down
Loading