Skip to content

Commit 5c18367

Browse files
authored
feat (audiences): Typed audience evaluation (#174)
- When accessing audiences in project config, prefer typedAudiences over audiences - Support new match types: exact, exists, greater than, less than, substring - Evaluate a leaf condition to null if unknown condition type, unknown condition match type, necessary-but-missing condition value, missing user attribute value, invalid user attribute value, etc. - Allow null values to bubble up if necessary - Perform full audience evaluation even if the customer didn't pass any attribute values
1 parent 17ac1cf commit 5c18367

File tree

9 files changed

+1048
-81
lines changed

9 files changed

+1048
-81
lines changed

packages/optimizely-sdk/lib/core/audience_evaluator/index.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright 2016, Optimizely
2+
* Copyright 2016, 2018 Optimizely
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,8 +20,9 @@ module.exports = {
2020
* Determine if the given user attributes satisfy the given audience conditions
2121
* @param {Object[]} audiences Audiences to match the user attributes against
2222
* @param {Object[]} audiences.conditions Audience conditions to match the user attributes against
23-
* @param {Object} userAttributes Hash representing user attributes which will be used in determining if
24-
* the audience conditions are met
23+
* @param {Object} [userAttributes] Hash representing user attributes which will be used in
24+
* determining if the audience conditions are met. If not
25+
* provided, defaults to an empty object.
2526
* @return {Boolean} True if the user attributes match the given audience conditions
2627
*/
2728
evaluate: function(audiences, userAttributes) {
@@ -30,9 +31,8 @@ module.exports = {
3031
return true;
3132
}
3233

33-
// if no user attributes specified, return false
3434
if (!userAttributes) {
35-
return false;
35+
userAttributes = {};
3636
}
3737

3838
for (var i = 0; i < audiences.length; i++) {

packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright 2016, Optimizely
2+
* Copyright 2016, 2018 Optimizely
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,10 +18,18 @@ var chai = require('chai');
1818
var assert = chai.assert;
1919

2020
var chromeUserAudience = {
21-
conditions: ['and', {'name': 'browser_type', 'value': 'chrome'}],
21+
conditions: ['and', {
22+
name: 'browser_type',
23+
value: 'chrome',
24+
type: 'custom_attribute',
25+
}],
2226
};
2327
var iphoneUserAudience = {
24-
conditions: ['and', {'name': 'device_model', 'value': 'iphone'}],
28+
conditions: ['and', {
29+
name: 'device_model',
30+
value: 'iphone',
31+
type: 'custom_attribute',
32+
}],
2533
};
2634

2735
describe('lib/core/audience_evaluator', function() {
@@ -72,6 +80,18 @@ describe('lib/core/audience_evaluator', function() {
7280
assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], safariUsers));
7381
assert.isFalse(audienceEvaluator.evaluate([chromeUserAudience, iphoneUserAudience], nexusSafariUsers));
7482
});
83+
84+
it('should return true if no attributes are passed and the audience conditions evaluate to true in the absence of attributes', function() {
85+
var conditionsPassingWithNoAttrs = ['not', {
86+
match: 'exists',
87+
name: 'input_value',
88+
type: 'custom_attribute',
89+
}];
90+
var audience = {
91+
conditions: conditionsPassingWithNoAttrs,
92+
};
93+
assert.isTrue(audienceEvaluator.evaluate([audience]));
94+
});
7595
});
7696
});
7797
});

packages/optimizely-sdk/lib/core/condition_evaluator/index.js

Lines changed: 192 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,263 @@
1-
/**
2-
* Copyright 2016, Optimizely
3-
*
4-
* Licensed under the Apache License, Version 2.0 (the "License");
5-
* you may not use this file except in compliance with the License.
6-
* You may obtain a copy of the License at
7-
*
8-
* http://www.apache.org/licenses/LICENSE-2.0
9-
*
10-
* Unless required by applicable law or agreed to in writing, software
11-
* distributed under the License is distributed on an "AS IS" BASIS,
12-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13-
* See the License for the specific language governing permissions and
14-
* limitations under the License.
15-
*/
1+
/****************************************************************************
2+
* Copyright 2016, 2018, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
var fns = require('../../utils/fns');
18+
1619
var AND_CONDITION = 'and';
1720
var OR_CONDITION = 'or';
1821
var NOT_CONDITION = 'not';
1922

2023
var DEFAULT_OPERATOR_TYPES = [AND_CONDITION, OR_CONDITION, NOT_CONDITION];
2124

25+
var CUSTOM_ATTRIBUTE_CONDITION_TYPE = 'custom_attribute';
26+
27+
var EXACT_MATCH_TYPE = 'exact';
28+
var EXISTS_MATCH_TYPE = 'exists';
29+
var GREATER_THAN_MATCH_TYPE = 'gt';
30+
var LESS_THAN_MATCH_TYPE = 'lt';
31+
var SUBSTRING_MATCH_TYPE = 'substring';
32+
33+
var MATCH_TYPES = [
34+
EXACT_MATCH_TYPE,
35+
EXISTS_MATCH_TYPE,
36+
GREATER_THAN_MATCH_TYPE,
37+
LESS_THAN_MATCH_TYPE,
38+
SUBSTRING_MATCH_TYPE,
39+
];
40+
41+
var EVALUATORS_BY_MATCH_TYPE = {};
42+
EVALUATORS_BY_MATCH_TYPE[EXACT_MATCH_TYPE] = exactEvaluator;
43+
EVALUATORS_BY_MATCH_TYPE[EXISTS_MATCH_TYPE] = existsEvaluator;
44+
EVALUATORS_BY_MATCH_TYPE[GREATER_THAN_MATCH_TYPE] = greaterThanEvaluator;
45+
EVALUATORS_BY_MATCH_TYPE[LESS_THAN_MATCH_TYPE] = lessThanEvaluator;
46+
EVALUATORS_BY_MATCH_TYPE[SUBSTRING_MATCH_TYPE] = substringEvaluator;
47+
2248
/**
2349
* Top level method to evaluate audience conditions
24-
* @param {Object[]} conditions Nested array of and/or conditions.
25-
* Example: ['and', operand_1, ['or', operand_2, operand_3]]
26-
* @param {Object} userAttributes Hash representing user attributes which will be used in determining if
27-
* the audience conditions are met.
28-
* @return {Boolean} true if the given user attributes match the given conditions
50+
* @param {Object[]|Object} conditions Nested array of and/or conditions, or a single condition object
51+
* Example: ['and', { type: 'custom_attribute', ... }, ['or', { type: 'custom_attribute', ... }, { type: 'custom_attribute', ... }]]
52+
* @param {Object} userAttributes Hash representing user attributes which will be used in determining if
53+
* the audience conditions are met.
54+
* @return {?Boolean} true/false if the given user attributes match/don't match the given conditions, null if
55+
* the given user attributes and conditions can't be evaluated
2956
*/
3057
function evaluate(conditions, userAttributes) {
3158
if (Array.isArray(conditions)) {
3259
var firstOperator = conditions[0];
60+
var restOfConditions = conditions.slice(1);
3361

34-
// return false for invalid operators
3562
if (DEFAULT_OPERATOR_TYPES.indexOf(firstOperator) === -1) {
36-
return false;
63+
// Operator to apply is not explicit - assume 'or'
64+
firstOperator = OR_CONDITION;
65+
restOfConditions = conditions;
3766
}
3867

39-
var restOfConditions = conditions.slice(1);
4068
switch (firstOperator) {
4169
case AND_CONDITION:
4270
return andEvaluator(restOfConditions, userAttributes);
4371
case NOT_CONDITION:
4472
return notEvaluator(restOfConditions, userAttributes);
45-
case OR_CONDITION:
73+
default: // firstOperator is OR_CONDITION
4674
return orEvaluator(restOfConditions, userAttributes);
4775
}
4876
}
4977

50-
var deserializedConditions = [conditions.name, conditions.value];
51-
return evaluator(deserializedConditions, userAttributes);
78+
var leafCondition = conditions;
79+
80+
if (leafCondition.type !== CUSTOM_ATTRIBUTE_CONDITION_TYPE) {
81+
return null;
82+
}
83+
84+
var conditionMatch = leafCondition.match;
85+
if (typeof conditionMatch !== 'undefined' && MATCH_TYPES.indexOf(conditionMatch) === -1) {
86+
return null;
87+
}
88+
89+
var evaluatorForMatch = EVALUATORS_BY_MATCH_TYPE[conditionMatch] || exactEvaluator;
90+
return evaluatorForMatch(leafCondition, userAttributes);
5291
}
5392

5493
/**
5594
* Evaluates an array of conditions as if the evaluator had been applied
5695
* to each entry and the results AND-ed together.
5796
* @param {Object[]} conditions Array of conditions ex: [operand_1, operand_2]
5897
* @param {Object} userAttributes Hash representing user attributes
59-
* @return {Boolean} true if the user attributes match the given conditions
98+
* @return {?Boolean} true/false if the user attributes match/don't match the given conditions,
99+
* null if the user attributes and conditions can't be evaluated
60100
*/
61101
function andEvaluator(conditions, userAttributes) {
62-
var condition;
102+
var sawNullResult = false;
63103
for (var i = 0; i < conditions.length; i++) {
64-
condition = conditions[i];
65-
if (!evaluate(condition, userAttributes)) {
104+
var conditionResult = evaluate(conditions[i], userAttributes);
105+
if (conditionResult === false) {
66106
return false;
67107
}
108+
if (conditionResult === null) {
109+
sawNullResult = true;
110+
}
68111
}
69-
70-
return true;
112+
return sawNullResult ? null : true;
71113
}
72114

73115
/**
74116
* Evaluates an array of conditions as if the evaluator had been applied
75117
* to a single entry and NOT was applied to the result.
76118
* @param {Object[]} conditions Array of conditions ex: [operand_1, operand_2]
77119
* @param {Object} userAttributes Hash representing user attributes
78-
* @return {Boolean} true if the user attributes match the given conditions
120+
* @return {?Boolean} true/false if the user attributes match/don't match the given conditions,
121+
* null if the user attributes and conditions can't be evaluated
79122
*/
80123
function notEvaluator(conditions, userAttributes) {
81-
if (conditions.length !== 1) {
82-
return false;
124+
if (conditions.length > 0) {
125+
var result = evaluate(conditions[0], userAttributes);
126+
return result === null ? null : !result;
83127
}
84-
85-
return !evaluate(conditions[0], userAttributes);
128+
return null;
86129
}
87130

88131
/**
89132
* Evaluates an array of conditions as if the evaluator had been applied
90133
* to each entry and the results OR-ed together.
91134
* @param {Object[]} conditions Array of conditions ex: [operand_1, operand_2]
92135
* @param {Object} userAttributes Hash representing user attributes
93-
* @return {Boolean} true if the user attributes match the given conditions
136+
* @return {?Boolean} true/false if the user attributes match/don't match the given conditions,
137+
* null if the user attributes and conditions can't be evaluated
94138
*/
95139
function orEvaluator(conditions, userAttributes) {
140+
var sawNullResult = false;
96141
for (var i = 0; i < conditions.length; i++) {
97-
var condition = conditions[i];
98-
if (evaluate(condition, userAttributes)) {
142+
var conditionResult = evaluate(conditions[i], userAttributes);
143+
if (conditionResult === true) {
99144
return true;
100145
}
146+
if (conditionResult === null) {
147+
sawNullResult = true;
148+
}
101149
}
150+
return sawNullResult ? null : false;
151+
}
102152

103-
return false;
153+
/**
154+
* Returns true if the value is valid for exact conditions. Valid values include
155+
* strings, booleans, and numbers that aren't NaN, -Infinity, or Infinity.
156+
* @param value
157+
* @returns {Boolean}
158+
*/
159+
function isValueValidForExactConditions(value) {
160+
return typeof value === 'string' || typeof value === 'boolean' ||
161+
fns.isFinite(value);
104162
}
105163

106164
/**
107-
* Evaluates an array of conditions as if the evaluator had been applied
108-
* to a single entry and NOT was applied to the result.
109-
* @param {Object[]} conditions Array of a single condition ex: [operand_1]
110-
* @param {Object} userAttributes Hash representing user attributes
111-
* @return {Boolean} true if the user attributes match the given conditions
165+
* Evaluate the given exact match condition for the given user attributes
166+
* @param {Object} condition
167+
* @param {Object} userAttributes
168+
* @return {?Boolean} true if the user attribute value is equal (===) to the condition value,
169+
* false if the user attribute value is not equal (!==) to the condition value,
170+
* null if the condition value or user attribute value has an invalid type, or
171+
* if there is a mismatch between the user attribute type and the condition value
172+
* type
112173
*/
113-
function evaluator(conditions, userAttributes) {
114-
if (userAttributes.hasOwnProperty(conditions[0])) {
115-
return userAttributes[conditions[0]] === conditions[1];
174+
function exactEvaluator(condition, userAttributes) {
175+
var conditionValue = condition.value;
176+
var conditionValueType = typeof conditionValue;
177+
var userValue = userAttributes[condition.name];
178+
var userValueType = typeof userValue;
179+
180+
if (!isValueValidForExactConditions(userValue) ||
181+
!isValueValidForExactConditions(conditionValueType) ||
182+
conditionValueType !== userValueType) {
183+
return null;
184+
}
185+
186+
return conditionValue === userValue;
187+
}
188+
189+
/**
190+
* Evaluate the given exists match condition for the given user attributes
191+
* @param {Object} condition
192+
* @param {Object} userAttributes
193+
* @returns {Boolean} true if both:
194+
* 1) the user attributes have a value for the given condition, and
195+
* 2) the user attribute value is neither null nor undefined
196+
* Returns false otherwise
197+
*/
198+
function existsEvaluator(condition, userAttributes) {
199+
var userValue = userAttributes[condition.name];
200+
return typeof userValue !== 'undefined' && userValue !== null;
201+
}
202+
203+
/**
204+
* Evaluate the given greater than match condition for the given user attributes
205+
* @param {Object} condition
206+
* @param {Object} userAttributes
207+
* @returns {?Boolean} true if the user attribute value is greater than the condition value,
208+
* false if the user attribute value is less than or equal to the condition value,
209+
* null if the condition value isn't a number or the user attribute value
210+
* isn't a number
211+
*/
212+
function greaterThanEvaluator(condition, userAttributes) {
213+
var userValue = userAttributes[condition.name];
214+
var conditionValue = condition.value;
215+
216+
if (!fns.isFinite(userValue) || !fns.isFinite(conditionValue)) {
217+
return null;
218+
}
219+
220+
return userValue > conditionValue;
221+
}
222+
223+
/**
224+
* Evaluate the given less than match condition for the given user attributes
225+
* @param {Object} condition
226+
* @param {Object} userAttributes
227+
* @returns {?Boolean} true if the user attribute value is less than the condition value,
228+
* false if the user attribute value is greater than or equal to the condition value,
229+
* null if the condition value isn't a number or the user attribute value isn't a
230+
* number
231+
*/
232+
function lessThanEvaluator(condition, userAttributes) {
233+
var userValue = userAttributes[condition.name];
234+
var conditionValue = condition.value;
235+
236+
if (!fns.isFinite(userValue) || !fns.isFinite(conditionValue)) {
237+
return null;
238+
}
239+
240+
return userValue < conditionValue;
241+
}
242+
243+
/**
244+
* Evaluate the given substring match condition for the given user attributes
245+
* @param {Object} condition
246+
* @param {Object} userAttributes
247+
* @returns {?Boolean} true if the condition value is a substring of the user attribute value,
248+
* false if the condition value is not a substring of the user attribute value,
249+
* null if the condition value isn't a string or the user attribute value
250+
* isn't a string
251+
*/
252+
function substringEvaluator(condition, userAttributes) {
253+
var userValue = userAttributes[condition.name];
254+
var conditionValue = condition.value;
255+
256+
if (typeof userValue !== 'string' || typeof conditionValue !== 'string') {
257+
return null;
116258
}
117259

118-
return false;
260+
return userValue.indexOf(conditionValue) !== -1;
119261
}
120262

121263
module.exports = {

0 commit comments

Comments
 (0)