diff --git a/optimizely/config/datafileProjectConfig/config.go b/optimizely/config/datafileProjectConfig/config.go index 5ecf75779..6d3e6cf0b 100644 --- a/optimizely/config/datafileProjectConfig/config.go +++ b/optimizely/config/datafileProjectConfig/config.go @@ -116,6 +116,11 @@ func (c DatafileProjectConfig) GetAudienceByID(audienceID string) (entities.Audi return entities.Audience{}, errors.New(errMessage) } +// GetAudienceMap returns the audience map +func (c DatafileProjectConfig) GetAudienceMap() map[string]entities.Audience { + return c.audienceMap +} + // GetExperimentByKey returns the experiment with the given key func (c DatafileProjectConfig) GetExperimentByKey(experimentKey string) (entities.Experiment, error) { if experimentID, ok := c.experimentKeyToIDMap[experimentKey]; ok { diff --git a/optimizely/config/datafileProjectConfig/entities/entities.go b/optimizely/config/datafileProjectConfig/entities/entities.go index 4f50a8789..b7b3fd87d 100644 --- a/optimizely/config/datafileProjectConfig/entities/entities.go +++ b/optimizely/config/datafileProjectConfig/entities/entities.go @@ -25,15 +25,15 @@ type Audience struct { // Experiment represents an Experiment object from the Optimizely datafile type Experiment struct { - // @TODO(mng): include audienceConditions - ID string `json:"id"` - Key string `json:"key"` - LayerID string `json:"layerId"` - Status string `json:"status"` - Variations []Variation `json:"variations"` - TrafficAllocation []trafficAllocation `json:"trafficAllocation"` - AudienceIds []string `json:"audienceIds"` - ForcedVariations map[string]string `json:"forcedVariations"` + ID string `json:"id"` + Key string `json:"key"` + LayerID string `json:"layerId"` + Status string `json:"status"` + Variations []Variation `json:"variations"` + TrafficAllocation []trafficAllocation `json:"trafficAllocation"` + AudienceIds []string `json:"audienceIds"` + ForcedVariations map[string]string `json:"forcedVariations"` + AudienceConditions interface{} `json:"audienceConditions"` } // FeatureFlag represents a FeatureFlag object from the Optimizely datafile @@ -60,8 +60,8 @@ type Variation struct { } type Event struct { - ID string `json:"id"` - Key string `json:"key"` + ID string `json:"id"` + Key string `json:"key"` ExperimentIds []string `json:"experimentIds"` } diff --git a/optimizely/config/datafileProjectConfig/mappers/audience.go b/optimizely/config/datafileProjectConfig/mappers/audience.go index 62fb305c2..483e1520a 100644 --- a/optimizely/config/datafileProjectConfig/mappers/audience.go +++ b/optimizely/config/datafileProjectConfig/mappers/audience.go @@ -17,9 +17,6 @@ package mappers import ( - "encoding/json" - "reflect" - datafileEntities "github.com/optimizely/go-sdk/optimizely/config/datafileProjectConfig/entities" "github.com/optimizely/go-sdk/optimizely/entities" ) @@ -41,62 +38,3 @@ func MapAudiences(audiences []datafileEntities.Audience) map[string]entities.Aud } return audienceMap } - -// Takes the conditions array from the audience in the datafile and turns it into a condition tree -func buildConditionTree(conditions interface{}) (*entities.ConditionTreeNode, error) { - - value := reflect.ValueOf(conditions) - visited := make(map[interface{}]bool) - var retErr error - - conditionTree := &entities.ConditionTreeNode{} - var populateConditions func(v reflect.Value, root *entities.ConditionTreeNode) - populateConditions = func(v reflect.Value, root *entities.ConditionTreeNode) { - - for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { - if v.Kind() == reflect.Ptr { - // Check for recursive data - if visited[v.Interface()] { - return - } - visited[v.Interface()] = true - } - v = v.Elem() - } - - switch v.Kind() { - - case reflect.Slice, reflect.Array: - for i := 0; i < v.Len(); i++ { - n := &entities.ConditionTreeNode{} - typedV := v.Index(i).Interface() - switch typedV.(type) { - case string: - n.Operator = typedV.(string) - root.Operator = n.Operator - continue - - case map[string]interface{}: - jsonBody, err := json.Marshal(typedV) - if err != nil { - retErr = err - return - } - condition := entities.Condition{} - if err := json.Unmarshal(jsonBody, &condition); err != nil { - retErr = err - return - } - n.Condition = condition - } - - root.Nodes = append(root.Nodes, n) - - populateConditions(v.Index(i), n) - } - } - } - - populateConditions(value, conditionTree) - return conditionTree, retErr -} diff --git a/optimizely/config/datafileProjectConfig/mappers/audience_test.go b/optimizely/config/datafileProjectConfig/mappers/audience_test.go index fb05dbff2..e872c264e 100644 --- a/optimizely/config/datafileProjectConfig/mappers/audience_test.go +++ b/optimizely/config/datafileProjectConfig/mappers/audience_test.go @@ -15,46 +15,3 @@ ***************************************************************************/ package mappers - -import ( - "encoding/json" - "testing" - - "github.com/optimizely/go-sdk/optimizely/entities" - "github.com/stretchr/testify/assert" -) - -func TestBuildConditionTreeSimpleAudienceCondition(t *testing.T) { - conditionString := "[ \"and\", [ \"or\", [ \"or\", { \"type\": \"custom_attribute\", \"name\": \"s_foo\", \"match\": \"exact\", \"value\": \"foo\" } ] ] ]" - var conditions interface{} - json.Unmarshal([]byte(conditionString), &conditions) - conditionTree, err := buildConditionTree(conditions) - if err != nil { - assert.Fail(t, err.Error()) - } - - expectedConditionTree := &entities.ConditionTreeNode{ - Operator: "and", - Nodes: []*entities.ConditionTreeNode{ - &entities.ConditionTreeNode{ - Operator: "or", - Nodes: []*entities.ConditionTreeNode{ - &entities.ConditionTreeNode{ - Operator: "or", - Nodes: []*entities.ConditionTreeNode{ - &entities.ConditionTreeNode{ - Condition: entities.Condition{ - Name: "s_foo", - Match: "exact", - Type: "custom_attribute", - Value: "foo", - }, - }, - }, - }, - }, - }, - }, - } - assert.Equal(t, expectedConditionTree, conditionTree) -} diff --git a/optimizely/config/datafileProjectConfig/mappers/condition_trees.go b/optimizely/config/datafileProjectConfig/mappers/condition_trees.go new file mode 100644 index 000000000..28d6ca91e --- /dev/null +++ b/optimizely/config/datafileProjectConfig/mappers/condition_trees.go @@ -0,0 +1,155 @@ +/**************************************************************************** + * Copyright 2019, 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. * + ***************************************************************************/ + +package mappers + +import ( + "encoding/json" + "errors" + "github.com/optimizely/go-sdk/optimizely/entities" + "reflect" +) + +var ErrEmptyTree = errors.New("Empty Tree") + +// Takes the conditions array from the audience in the datafile and turns it into a condition tree +func buildConditionTree(conditions interface{}) (conditionTree *entities.TreeNode, retErr error) { + + value := reflect.ValueOf(conditions) + visited := make(map[interface{}]bool) + + conditionTree = &entities.TreeNode{} + var populateConditions func(v reflect.Value, root *entities.TreeNode) + populateConditions = func(v reflect.Value, root *entities.TreeNode) { + + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + if v.Kind() == reflect.Ptr { + // Check for recursive data + if visited[v.Interface()] { + return + } + visited[v.Interface()] = true + } + v = v.Elem() + } + + switch v.Kind() { + + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + n := &entities.TreeNode{} + typedV := v.Index(i).Interface() + switch typedV.(type) { + case string: + n.Operator = typedV.(string) + root.Operator = n.Operator + continue + + case map[string]interface{}: + jsonBody, err := json.Marshal(typedV) + if err != nil { + retErr = err + return + } + condition := entities.Condition{} + if err := json.Unmarshal(jsonBody, &condition); err != nil { + retErr = err + return + } + n.Item = condition + } + + root.Nodes = append(root.Nodes, n) + + populateConditions(v.Index(i), n) + } + } + } + + populateConditions(value, conditionTree) + if conditionTree.Nodes == nil && conditionTree.Operator == "" { + retErr = ErrEmptyTree + conditionTree = nil + } + return conditionTree, retErr +} + +// Takes the conditions array from the audience in the datafile and turns it into a condition tree +func buildAudienceConditionTree(conditions interface{}) (conditionTree *entities.TreeNode, err error) { + + var operators = []string{"or", "and", "not"} // any other operators? + value := reflect.ValueOf(conditions) + visited := make(map[interface{}]bool) + + conditionTree = &entities.TreeNode{} + var populateConditions func(v reflect.Value, root *entities.TreeNode) + populateConditions = func(v reflect.Value, root *entities.TreeNode) { + + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + if v.Kind() == reflect.Ptr { + // Check for recursive data + if visited[v.Interface()] { + return + } + visited[v.Interface()] = true + } + v = v.Elem() + } + + switch v.Kind() { + + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + n := &entities.TreeNode{} + typedV := v.Index(i).Interface() + switch typedV.(type) { + case string: + value := typedV.(string) + if stringInSlice(value, operators) { + n.Operator = typedV.(string) + root.Operator = n.Operator + continue + } else { + n.Item = value + + } + } + + root.Nodes = append(root.Nodes, n) + + populateConditions(v.Index(i), n) + } + } + } + + populateConditions(value, conditionTree) + + if conditionTree.Nodes == nil && conditionTree.Operator == "" { + err = ErrEmptyTree + conditionTree = nil + } + + return conditionTree, err +} + +func stringInSlice(str string, list []string) bool { + for _, v := range list { + if v == str { + return true + } + } + return false +} diff --git a/optimizely/config/datafileProjectConfig/mappers/condition_trees_test.go b/optimizely/config/datafileProjectConfig/mappers/condition_trees_test.go new file mode 100644 index 000000000..75403d2f2 --- /dev/null +++ b/optimizely/config/datafileProjectConfig/mappers/condition_trees_test.go @@ -0,0 +1,96 @@ +/**************************************************************************** + * Copyright 2019, 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. * + ***************************************************************************/ + +package mappers + +import ( + "encoding/json" + "testing" + + "github.com/optimizely/go-sdk/optimizely/entities" + "github.com/stretchr/testify/assert" +) + +func TestBuildAudienceConditionTreeSimpleAudienceCondition(t *testing.T) { + conditionString := "[ \"and\", [ \"or\", [ \"or\", \"12\", \"123\", \"1234\"] ] ]" + var conditions interface{} + json.Unmarshal([]byte(conditionString), &conditions) + conditionTree, err := buildAudienceConditionTree(conditions) + if err != nil { + assert.Fail(t, err.Error()) + } + + expectedConditionTree := &entities.TreeNode{ + Operator: "and", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Item: "12", + }, + { + Item: "123", + }, + { + Item: "1234", + }, + }, + }, + }, + }, + }, + } + assert.Equal(t, expectedConditionTree, conditionTree) +} + +func TestBuildConditionTreeSimpleAudienceCondition(t *testing.T) { + conditionString := "[ \"and\", [ \"or\", [ \"or\", { \"type\": \"custom_attribute\", \"name\": \"s_foo\", \"match\": \"exact\", \"value\": \"foo\" } ] ] ]" + var conditions interface{} + json.Unmarshal([]byte(conditionString), &conditions) + conditionTree, err := buildConditionTree(conditions) + if err != nil { + assert.Fail(t, err.Error()) + } + + expectedConditionTree := &entities.TreeNode{ + Operator: "and", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Item: entities.Condition{ + Name: "s_foo", + Match: "exact", + Type: "custom_attribute", + Value: "foo", + }, + }, + }, + }, + }, + }, + }, + } + assert.Equal(t, expectedConditionTree, conditionTree) +} diff --git a/optimizely/config/datafileProjectConfig/mappers/experiment.go b/optimizely/config/datafileProjectConfig/mappers/experiment.go index 09ba71b79..a7926317e 100644 --- a/optimizely/config/datafileProjectConfig/mappers/experiment.go +++ b/optimizely/config/datafileProjectConfig/mappers/experiment.go @@ -27,12 +27,18 @@ func MapExperiments(rawExperiments []datafileEntities.Experiment) (map[string]en experimentMap := make(map[string]entities.Experiment) experimentKeyMap := make(map[string]string) for _, rawExperiment := range rawExperiments { + audienceConditionTree, err := buildAudienceConditionTree(rawExperiment.AudienceConditions) + if err != nil { + // @TODO: handle error + } + experiment := entities.Experiment{ - AudienceIds: rawExperiment.AudienceIds, - ID: rawExperiment.ID, - Key: rawExperiment.Key, - TrafficAllocation: make([]entities.Range, len(rawExperiment.TrafficAllocation)), - Variations: make(map[string]entities.Variation), + AudienceIds: rawExperiment.AudienceIds, + ID: rawExperiment.ID, + Key: rawExperiment.Key, + TrafficAllocation: make([]entities.Range, len(rawExperiment.TrafficAllocation)), + Variations: make(map[string]entities.Variation), + AudienceConditionTree: audienceConditionTree, } for _, variation := range rawExperiment.Variations { diff --git a/optimizely/config/datafileProjectConfig/mappers/experiment_test.go b/optimizely/config/datafileProjectConfig/mappers/experiment_test.go index 68d1ab01c..c74f8bd43 100644 --- a/optimizely/config/datafileProjectConfig/mappers/experiment_test.go +++ b/optimizely/config/datafileProjectConfig/mappers/experiment_test.go @@ -51,40 +51,54 @@ func TestMapExperiments(t *testing.T) { "entityId": "21112", "endOfRange": 10000 } + ], + "audienceConditions": [ + "or", + "31111" ] }` + var rawExperiment datafileEntities.Experiment json.Unmarshal([]byte(testExperimentString), &rawExperiment) rawExperiments := []datafileEntities.Experiment{rawExperiment} experiments, experimentKeyMap := MapExperiments(rawExperiments) expectedExperiments := map[string]entities.Experiment{ - "11111": entities.Experiment{ + "11111": { AudienceIds: []string{"31111"}, ID: "11111", Key: "test_experiment_11111", Variations: map[string]entities.Variation{ - "21111": entities.Variation{ + "21111": { ID: "21111", Key: "variation_1", FeatureEnabled: true, }, - "21112": entities.Variation{ + "21112": { ID: "21112", Key: "variation_2", FeatureEnabled: false, }, }, TrafficAllocation: []entities.Range{ - entities.Range{ + { EntityID: "21111", EndOfRange: 7000, }, - entities.Range{ + { EntityID: "21112", EndOfRange: 10000, }, }, + AudienceConditionTree: &entities.TreeNode{ + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Operator: "", + Item: "31111", + }, + }, + }, }, } expectedExperimentKeyMap := map[string]string{ diff --git a/optimizely/decision/evaluator/audience.go b/optimizely/decision/evaluator/audience.go index c0b9266e8..19a10ed3c 100644 --- a/optimizely/decision/evaluator/audience.go +++ b/optimizely/decision/evaluator/audience.go @@ -22,23 +22,23 @@ import ( // AudienceEvaluator evaluates an audience against the given user's attributes type AudienceEvaluator interface { - Evaluate(audience entities.Audience, user entities.UserContext) bool + Evaluate(audience entities.Audience, condTreeParams *entities.TreeParameters) bool } // TypedAudienceEvaluator evaluates typed audiences type TypedAudienceEvaluator struct { - conditionTreeEvaluator ConditionTreeEvaluator + conditionTreeEvaluator TreeEvaluator } // NewTypedAudienceEvaluator creates a new instance of the TypedAudienceEvaluator func NewTypedAudienceEvaluator() *TypedAudienceEvaluator { - conditionTreeEvaluator := NewConditionTreeEvaluator() + conditionTreeEvaluator := NewTreeEvaluator() return &TypedAudienceEvaluator{ conditionTreeEvaluator: *conditionTreeEvaluator, } } // Evaluate evaluates the typed audience against the given user's attributes -func (a TypedAudienceEvaluator) Evaluate(audience entities.Audience, user entities.UserContext) bool { - return a.conditionTreeEvaluator.Evaluate(audience.ConditionTree, user) +func (a TypedAudienceEvaluator) Evaluate(audience entities.Audience, condTreeParams *entities.TreeParameters) bool { + return a.conditionTreeEvaluator.Evaluate(audience.ConditionTree, condTreeParams) } diff --git a/optimizely/decision/evaluator/condition.go b/optimizely/decision/evaluator/condition.go index 7a4af7397..f45be3507 100644 --- a/optimizely/decision/evaluator/condition.go +++ b/optimizely/decision/evaluator/condition.go @@ -18,29 +18,29 @@ package evaluator import ( "fmt" - "github.com/optimizely/go-sdk/optimizely/decision/evaluator/matchers" "github.com/optimizely/go-sdk/optimizely/entities" ) const ( - exactMatchType = "exact" + exactMatchType = "exact" existsMatchType = "exists" - ltMatchType = "lt" - gtMatchType = "gt" + ltMatchType = "lt" + gtMatchType = "gt" ) -// ConditionEvaluator evaluates a condition against the given user's attributes -type ConditionEvaluator interface { - Evaluate(entities.Condition, entities.UserContext) (bool, error) +// ItemEvaluator evaluates a condition against the given user's attributes +type ItemEvaluator interface { + Evaluate(interface{}, *entities.TreeParameters) (bool, error) } // CustomAttributeConditionEvaluator evaluates conditions with custom attributes type CustomAttributeConditionEvaluator struct{} // Evaluate returns true if the given user's attributes match the condition -func (c CustomAttributeConditionEvaluator) Evaluate(condition entities.Condition, user entities.UserContext) (bool, error) { +func (c CustomAttributeConditionEvaluator) Evaluate(condition entities.Condition, condTreeParams *entities.TreeParameters) (bool, error) { // We should only be evaluating custom attributes + if condition.Type != customAttributeType { return false, fmt.Errorf(`Unable to evaluator condition of type "%s"`, condition.Type) } @@ -64,8 +64,28 @@ func (c CustomAttributeConditionEvaluator) Evaluate(condition entities.Condition matcher = matchers.GtMatcher{ Condition: condition, } + default: + return false, fmt.Errorf(`Invalid Condition matcher "%s"`, condition.Match) } + user := *condTreeParams.User result, err := matcher.Match(user) return result, err } + +// AudienceConditionEvaluator evaluates conditions with audience condition +type AudienceConditionEvaluator struct{} + +// Evaluate returns true if the given user's attributes match the condition +func (c AudienceConditionEvaluator) Evaluate(audienceID string, condTreeParams *entities.TreeParameters) (bool, error) { + + if audience, ok := condTreeParams.AudienceMap[audienceID]; ok { + condTree := audience.ConditionTree + conditionTreeEvaluator := NewTreeEvaluator() + retValue := conditionTreeEvaluator.Evaluate(condTree, condTreeParams) + return retValue, nil + + } + + return false, fmt.Errorf(`Unable to evaluate nested tree for audience ID "%s"`, audienceID) +} diff --git a/optimizely/decision/evaluator/condition_test.go b/optimizely/decision/evaluator/condition_test.go index b1839c4be..be97c10e4 100644 --- a/optimizely/decision/evaluator/condition_test.go +++ b/optimizely/decision/evaluator/condition_test.go @@ -40,7 +40,9 @@ func TestCustomAttributeConditionEvaluator(t *testing.T) { }, }, } - result, _ := conditionEvaluator.Evaluate(condition, user) + + condTreeParams := entities.NewTreeParameters(&user, map[string]entities.Audience{}) + result, _ := conditionEvaluator.Evaluate(condition, condTreeParams) assert.Equal(t, result, true) // Test condition fails @@ -51,6 +53,6 @@ func TestCustomAttributeConditionEvaluator(t *testing.T) { }, }, } - result, _ = conditionEvaluator.Evaluate(condition, user) + result, _ = conditionEvaluator.Evaluate(condition, condTreeParams) assert.Equal(t, result, false) } diff --git a/optimizely/decision/evaluator/condition_tree.go b/optimizely/decision/evaluator/condition_tree.go index 1043aba5d..9c76cddc6 100644 --- a/optimizely/decision/evaluator/condition_tree.go +++ b/optimizely/decision/evaluator/condition_tree.go @@ -17,6 +17,7 @@ package evaluator import ( + "fmt" "github.com/optimizely/go-sdk/optimizely/entities" ) @@ -31,52 +32,54 @@ const ( orOperator = "or" ) -// ConditionTreeEvaluator evaluates a condition tree -type ConditionTreeEvaluator struct { - conditionEvaluatorMap map[string]ConditionEvaluator +//TreeEvaluator evaluates a condition tree +type TreeEvaluator struct { } -// NewConditionTreeEvaluator creates a condition tree evaluator with the out-of-the-box condition evaluators -func NewConditionTreeEvaluator() *ConditionTreeEvaluator { - // For now, only one evaluator per attribute type - conditionEvaluatorMap := make(map[string]ConditionEvaluator) - conditionEvaluatorMap[customAttributeType] = CustomAttributeConditionEvaluator{} - return &ConditionTreeEvaluator{ - conditionEvaluatorMap: conditionEvaluatorMap, - } +// NewTreeEvaluator creates a condition tree evaluator with the out-of-the-box condition evaluators +func NewTreeEvaluator() *TreeEvaluator { + return &TreeEvaluator{} } +// entities.UserContext // Evaluate returns true if the userAttributes satisfy the given condition tree -func (c ConditionTreeEvaluator) Evaluate(node *entities.ConditionTreeNode, user entities.UserContext) bool { +func (c TreeEvaluator) Evaluate(node *entities.TreeNode, condTreeParams *entities.TreeParameters) bool { // This wrapper method converts the conditionEvalResult to a boolean - result, _ := c.evaluate(node, user) + result, _ := c.evaluate(node, condTreeParams) return result == true } // Helper method to recursively evaluate a condition tree // Returns the result of the evaluation and whether the evaluation of the condition is valid or not (to handle null bubbling) -func (c ConditionTreeEvaluator) evaluate(node *entities.ConditionTreeNode, user entities.UserContext) (evalResult bool, isValid bool) { +func (c TreeEvaluator) evaluate(node *entities.TreeNode, condTreeParams *entities.TreeParameters) (evalResult bool, isValid bool) { operator := node.Operator if operator != "" { switch operator { case andOperator: - return c.evaluateAnd(node.Nodes, user) + return c.evaluateAnd(node.Nodes, condTreeParams) case notOperator: - return c.evaluateNot(node.Nodes, user) + return c.evaluateNot(node.Nodes, condTreeParams) case orOperator: fallthrough default: - return c.evaluateOr(node.Nodes, user) + return c.evaluateOr(node.Nodes, condTreeParams) } } - conditionEvaluator, ok := c.conditionEvaluatorMap[node.Condition.Type] - if !ok { - // TODO(mng): log error - // Result is invalid + var result bool + var err error + switch v := node.Item.(type) { + case entities.Condition: + evaluator := CustomAttributeConditionEvaluator{} + result, err = evaluator.Evaluate(node.Item.(entities.Condition), condTreeParams) + case string: + evaluator := AudienceConditionEvaluator{} + result, err = evaluator.Evaluate(node.Item.(string), condTreeParams) + default: + fmt.Printf("I don't know about type %T!\n", v) return false, false } - result, err := conditionEvaluator.Evaluate(node.Condition, user) + if err != nil { // Result is invalid return false, false @@ -84,10 +87,10 @@ func (c ConditionTreeEvaluator) evaluate(node *entities.ConditionTreeNode, user return result, true } -func (c ConditionTreeEvaluator) evaluateAnd(nodes []*entities.ConditionTreeNode, user entities.UserContext) (evalResult bool, isValid bool) { +func (c TreeEvaluator) evaluateAnd(nodes []*entities.TreeNode, condTreeParams *entities.TreeParameters) (evalResult bool, isValid bool) { sawInvalid := false for _, node := range nodes { - result, isValid := c.evaluate(node, user) + result, isValid := c.evaluate(node, condTreeParams) if !isValid { return false, isValid } else if result == false { @@ -103,9 +106,9 @@ func (c ConditionTreeEvaluator) evaluateAnd(nodes []*entities.ConditionTreeNode, return true, true } -func (c ConditionTreeEvaluator) evaluateNot(nodes []*entities.ConditionTreeNode, user entities.UserContext) (evalResult bool, isValid bool) { +func (c TreeEvaluator) evaluateNot(nodes []*entities.TreeNode, condTreeParams *entities.TreeParameters) (evalResult bool, isValid bool) { if len(nodes) > 0 { - result, isValid := c.evaluate(nodes[0], user) + result, isValid := c.evaluate(nodes[0], condTreeParams) if !isValid { return false, false } @@ -114,10 +117,10 @@ func (c ConditionTreeEvaluator) evaluateNot(nodes []*entities.ConditionTreeNode, return false, false } -func (c ConditionTreeEvaluator) evaluateOr(nodes []*entities.ConditionTreeNode, user entities.UserContext) (evalResult bool, isValid bool) { +func (c TreeEvaluator) evaluateOr(nodes []*entities.TreeNode, condTreeParams *entities.TreeParameters) (evalResult bool, isValid bool) { sawInvalid := false for _, node := range nodes { - result, isValid := c.evaluate(node, user) + result, isValid := c.evaluate(node, condTreeParams) if !isValid { sawInvalid = true } else if result == true { diff --git a/optimizely/decision/evaluator/condition_tree_test.go b/optimizely/decision/evaluator/condition_tree_test.go index e425d9f96..28faace5b 100644 --- a/optimizely/decision/evaluator/condition_tree_test.go +++ b/optimizely/decision/evaluator/condition_tree_test.go @@ -30,12 +30,12 @@ var int42Condition = e.Condition{ } func TestConditionTreeEvaluateSimpleCondition(t *testing.T) { - conditionTreeEvaluator := NewConditionTreeEvaluator() - conditionTree := &e.ConditionTreeNode{ + conditionTreeEvaluator := NewTreeEvaluator() + conditionTree := &e.TreeNode{ Operator: "or", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ - Condition: stringFooCondition, + Nodes: []*e.TreeNode{ + &e.TreeNode{ + Item: stringFooCondition, }, }, } @@ -48,7 +48,8 @@ func TestConditionTreeEvaluateSimpleCondition(t *testing.T) { }, }, } - result := conditionTreeEvaluator.Evaluate(conditionTree, user) + condTreeParams := e.NewTreeParameters(&user, map[string]e.Audience{}) + result := conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test no match @@ -59,20 +60,20 @@ func TestConditionTreeEvaluateSimpleCondition(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.False(t, result) } func TestConditionTreeEvaluateMultipleOrConditions(t *testing.T) { - conditionTreeEvaluator := NewConditionTreeEvaluator() - conditionTree := &e.ConditionTreeNode{ + conditionTreeEvaluator := NewTreeEvaluator() + conditionTree := &e.TreeNode{ Operator: "or", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ - Condition: stringFooCondition, + Nodes: []*e.TreeNode{ + &e.TreeNode{ + Item: stringFooCondition, }, - &e.ConditionTreeNode{ - Condition: boolTrueCondition, + &e.TreeNode{ + Item: boolTrueCondition, }, }, } @@ -85,7 +86,9 @@ func TestConditionTreeEvaluateMultipleOrConditions(t *testing.T) { }, }, } - result := conditionTreeEvaluator.Evaluate(conditionTree, user) + + condTreeParams := e.NewTreeParameters(&user, map[string]e.Audience{}) + result := conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test match bool @@ -96,7 +99,7 @@ func TestConditionTreeEvaluateMultipleOrConditions(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test match both @@ -108,7 +111,7 @@ func TestConditionTreeEvaluateMultipleOrConditions(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test no match @@ -120,20 +123,20 @@ func TestConditionTreeEvaluateMultipleOrConditions(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.False(t, result) } func TestConditionTreeEvaluateMultipleAndConditions(t *testing.T) { - conditionTreeEvaluator := NewConditionTreeEvaluator() - conditionTree := &e.ConditionTreeNode{ + conditionTreeEvaluator := NewTreeEvaluator() + conditionTree := &e.TreeNode{ Operator: "and", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ - Condition: stringFooCondition, + Nodes: []*e.TreeNode{ + &e.TreeNode{ + Item: stringFooCondition, }, - &e.ConditionTreeNode{ - Condition: boolTrueCondition, + &e.TreeNode{ + Item: boolTrueCondition, }, }, } @@ -146,7 +149,9 @@ func TestConditionTreeEvaluateMultipleAndConditions(t *testing.T) { }, }, } - result := conditionTreeEvaluator.Evaluate(conditionTree, user) + + condTreeParams := e.NewTreeParameters(&user, map[string]e.Audience{}) + result := conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.False(t, result) // Test only bool match with NULL bubbling @@ -157,7 +162,7 @@ func TestConditionTreeEvaluateMultipleAndConditions(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.False(t, result) // Test match both @@ -169,7 +174,7 @@ func TestConditionTreeEvaluateMultipleAndConditions(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test no match @@ -181,29 +186,29 @@ func TestConditionTreeEvaluateMultipleAndConditions(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.False(t, result) } func TestConditionTreeEvaluateNotCondition(t *testing.T) { - conditionTreeEvaluator := NewConditionTreeEvaluator() + conditionTreeEvaluator := NewTreeEvaluator() // [or, [not, stringFooCondition], [not, boolTrueCondition]] - conditionTree := &e.ConditionTreeNode{ + conditionTree := &e.TreeNode{ Operator: "or", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ + Nodes: []*e.TreeNode{ + &e.TreeNode{ Operator: "not", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ - Condition: stringFooCondition, + Nodes: []*e.TreeNode{ + &e.TreeNode{ + Item: stringFooCondition, }, }, }, - &e.ConditionTreeNode{ + &e.TreeNode{ Operator: "not", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ - Condition: boolTrueCondition, + Nodes: []*e.TreeNode{ + &e.TreeNode{ + Item: boolTrueCondition, }, }, }, @@ -218,7 +223,9 @@ func TestConditionTreeEvaluateNotCondition(t *testing.T) { }, }, } - result := conditionTreeEvaluator.Evaluate(conditionTree, user) + + condTreeParams := e.NewTreeParameters(&user, map[string]e.Audience{}) + result := conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test match bool @@ -229,7 +236,7 @@ func TestConditionTreeEvaluateNotCondition(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test match both @@ -241,7 +248,7 @@ func TestConditionTreeEvaluateNotCondition(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test no match @@ -253,40 +260,40 @@ func TestConditionTreeEvaluateNotCondition(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.False(t, result) } func TestConditionTreeEvaluateMultipleMixedConditions(t *testing.T) { - conditionTreeEvaluator := NewConditionTreeEvaluator() + conditionTreeEvaluator := NewTreeEvaluator() // [or, [and, stringFooCondition, boolTrueCondition], [or, [not, stringFooCondition], int42Condition]] - conditionTree := &e.ConditionTreeNode{ + conditionTree := &e.TreeNode{ Operator: "or", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ + Nodes: []*e.TreeNode{ + &e.TreeNode{ Operator: "and", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ - Condition: stringFooCondition, + Nodes: []*e.TreeNode{ + &e.TreeNode{ + Item: stringFooCondition, }, - &e.ConditionTreeNode{ - Condition: boolTrueCondition, + &e.TreeNode{ + Item: boolTrueCondition, }, }, }, - &e.ConditionTreeNode{ + &e.TreeNode{ Operator: "or", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ + Nodes: []*e.TreeNode{ + &e.TreeNode{ Operator: "not", - Nodes: []*e.ConditionTreeNode{ - &e.ConditionTreeNode{ - Condition: stringFooCondition, + Nodes: []*e.TreeNode{ + &e.TreeNode{ + Item: stringFooCondition, }, }, }, - &e.ConditionTreeNode{ - Condition: int42Condition, + &e.TreeNode{ + Item: int42Condition, }, }, }, @@ -303,7 +310,9 @@ func TestConditionTreeEvaluateMultipleMixedConditions(t *testing.T) { }, }, } - result := conditionTreeEvaluator.Evaluate(conditionTree, user) + + condTreeParams := e.NewTreeParameters(&user, map[string]e.Audience{}) + result := conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test only match the NOT condition @@ -316,7 +325,7 @@ func TestConditionTreeEvaluateMultipleMixedConditions(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test only match the int condition @@ -329,7 +338,7 @@ func TestConditionTreeEvaluateMultipleMixedConditions(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.True(t, result) // Test no match @@ -342,6 +351,6 @@ func TestConditionTreeEvaluateMultipleMixedConditions(t *testing.T) { }, }, } - result = conditionTreeEvaluator.Evaluate(conditionTree, user) + result = conditionTreeEvaluator.Evaluate(conditionTree, condTreeParams) assert.False(t, result) } diff --git a/optimizely/decision/experiment_targeting_service.go b/optimizely/decision/experiment_targeting_service.go index 1b89deef6..422239625 100644 --- a/optimizely/decision/experiment_targeting_service.go +++ b/optimizely/decision/experiment_targeting_service.go @@ -37,14 +37,27 @@ func NewExperimentTargetingService() *ExperimentTargetingService { func (s ExperimentTargetingService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext) (ExperimentDecision, error) { experimentDecision := ExperimentDecision{} experiment, _ := decisionContext.ProjectConfig.GetExperimentByKey(decisionContext.ExperimentKey) + + if experiment.AudienceConditionTree != nil { + + condTreeParams := entities.NewTreeParameters(&userContext, decisionContext.ProjectConfig.GetAudienceMap()) + conditionTreeEvaluator := evaluator.NewTreeEvaluator() + evalResult := conditionTreeEvaluator.Evaluate(experiment.AudienceConditionTree, condTreeParams) + if !evalResult { + // user not targeted for experiment, return an empty variation + experimentDecision.DecisionMade = true + } + return experimentDecision, nil + } + if len(experiment.AudienceIds) > 0 { // @TODO: figure out what to do with the error experimentAudience, _ := decisionContext.ProjectConfig.GetAudienceByID(experiment.AudienceIds[0]) - evalResult := s.audienceEvaluator.Evaluate(experimentAudience, userContext) + condTreeParams := entities.NewTreeParameters(&userContext, map[string]entities.Audience{}) + evalResult := s.audienceEvaluator.Evaluate(experimentAudience, condTreeParams) if evalResult == false { // user not targeted for experiment, return an empty variation experimentDecision.DecisionMade = true - experimentDecision.Variation = entities.Variation{} return experimentDecision, nil } } diff --git a/optimizely/decision/experiment_targeting_service_test.go b/optimizely/decision/experiment_targeting_service_test.go index 0c39aa1ff..4f498583b 100644 --- a/optimizely/decision/experiment_targeting_service_test.go +++ b/optimizely/decision/experiment_targeting_service_test.go @@ -19,6 +19,7 @@ package decision import ( "testing" + "github.com/optimizely/go-sdk/optimizely/decision/evaluator" "github.com/optimizely/go-sdk/optimizely/entities" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -28,18 +29,20 @@ type MockAudienceEvaluator struct { mock.Mock } -func (m *MockAudienceEvaluator) Evaluate(audience entities.Audience, userContext entities.UserContext) bool { +func (m *MockAudienceEvaluator) Evaluate(audience entities.Audience, condTreeParams *entities.TreeParameters) bool { + userContext := *condTreeParams.User args := m.Called(audience, userContext) return args.Bool(0) } -func TestExperimentTargetingGetDecision(t *testing.T) { +// test with mocking +func TestExperimentTargetingGetDecisionNoAudienceCondTree(t *testing.T) { testAudience := entities.Audience{ - ConditionTree: &entities.ConditionTreeNode{ + ConditionTree: &entities.TreeNode{ Operator: "or", - Nodes: []*entities.ConditionTreeNode{ - &entities.ConditionTreeNode{ - Condition: entities.Condition{ + Nodes: []*entities.TreeNode{ + &entities.TreeNode{ + Item: entities.Condition{ Name: "s_foo", Value: "foo", }, @@ -110,3 +113,81 @@ func TestExperimentTargetingGetDecision(t *testing.T) { mockAudienceEvaluator.AssertExpectations(t) mockProjectConfig.AssertExpectations(t) } + +// Real tests with no mocking +func TestExperimentTargetingGetDecisionWithAudienceCondTree(t *testing.T) { + testAudience := entities.Audience{ + ConditionTree: &entities.TreeNode{ + Operator: "or", + Nodes: []*entities.TreeNode{ + { + Item: entities.Condition{ + Name: "s_foo", + Type: "custom_attribute", + Match: "exact", + Value: "foo", + }, + }, + }, + }, + } + + testExperimentKey := "test_experiment" + testExperiment := entities.Experiment{ + ID: "111111", + AudienceIds: []string{"33333"}, + } + + mockProjectConfig := new(MockProjectConfig) + mockProjectConfig.On("GetAudienceByID", "33333").Return(testAudience, nil) + mockProjectConfig.On("GetExperimentByKey", testExperimentKey).Return(testExperiment, nil) + testDecisionContext := ExperimentDecisionContext{ + ExperimentKey: testExperimentKey, + ProjectConfig: mockProjectConfig, + } + // test does not pass audience evaluation + testUserContext := entities.UserContext{ + ID: "test_user_1", + Attributes: entities.UserAttributes{ + Attributes: map[string]interface{}{ + "s_foo": "not_foo", + }, + }, + } + expectedExperimentDecision := ExperimentDecision{ + Decision: Decision{ + DecisionMade: true, + }, + Variation: entities.Variation{}, + } + + audienceEvaluator := evaluator.NewTypedAudienceEvaluator() + experimentTargetingService := ExperimentTargetingService{ + audienceEvaluator: audienceEvaluator, + } + + decision, _ := experimentTargetingService.GetDecision(testDecisionContext, testUserContext) + assert.Equal(t, expectedExperimentDecision, decision) //decision made but did not pass + + /****** Perfect Match ***************/ + + testUserContext = entities.UserContext{ + ID: "test_user_1", + Attributes: entities.UserAttributes{ + Attributes: map[string]interface{}{ + "s_foo": "foo", + }, + }, + } + + expectedExperimentDecision = ExperimentDecision{ + Decision: Decision{ + DecisionMade: false, + }, + Variation: entities.Variation{}, + } + + decision, _ = experimentTargetingService.GetDecision(testDecisionContext, testUserContext) + assert.Equal(t, expectedExperimentDecision, decision) // decision not made? but it passed + +} diff --git a/optimizely/entities/audience.go b/optimizely/entities/audience.go index d613dd04b..5300b0e3f 100644 --- a/optimizely/entities/audience.go +++ b/optimizely/entities/audience.go @@ -20,7 +20,7 @@ package entities type Audience struct { ID string Name string - ConditionTree *ConditionTreeNode + ConditionTree *TreeNode } // Condition has condition info @@ -30,11 +30,3 @@ type Condition struct { Type string `json:"type"` Value interface{} `json:"value"` } - -//ConditionTreeNode in a condition tree -type ConditionTreeNode struct { - Condition Condition - Operator string - - Nodes []*ConditionTreeNode -} diff --git a/optimizely/entities/condition_tree.go b/optimizely/entities/condition_tree.go new file mode 100644 index 000000000..c63562cae --- /dev/null +++ b/optimizely/entities/condition_tree.go @@ -0,0 +1,34 @@ +/**************************************************************************** + * Copyright 2019, 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. * + ***************************************************************************/ + +package entities + +//TreeNode in a condition tree +type TreeNode struct { + Item interface{} // can be a condition or a string + Operator string + + Nodes []*TreeNode +} + +type TreeParameters struct { + User *UserContext + AudienceMap map[string]Audience +} + +func NewTreeParameters(user *UserContext, audience map[string]Audience) *TreeParameters { + return &TreeParameters{User: user, AudienceMap: audience} +} diff --git a/optimizely/entities/experiment.go b/optimizely/entities/experiment.go index 6b68f767c..e3937ece2 100644 --- a/optimizely/entities/experiment.go +++ b/optimizely/entities/experiment.go @@ -26,13 +26,14 @@ type Variation struct { // Experiment represents an experiment type Experiment struct { - AudienceIds []string - ID string - LayerID string - Key string - Variations map[string]Variation - TrafficAllocation []Range - GroupID string + AudienceIds []string + ID string + LayerID string + Key string + Variations map[string]Variation + TrafficAllocation []Range + GroupID string + AudienceConditionTree *TreeNode } // Range represents bucketing range that the specify entityID falls into diff --git a/optimizely/interface.go b/optimizely/interface.go index 626919344..e3a2a23b9 100644 --- a/optimizely/interface.go +++ b/optimizely/interface.go @@ -26,6 +26,7 @@ type ProjectConfig interface { GetAnonymizeIP() bool GetAttributeID(key string) string // returns "" if there is no id GetAudienceByID(string) (entities.Audience, error) + GetAudienceMap() map[string]entities.Audience GetBotFiltering() bool GetEventByKey(string) (entities.Event, error) GetExperimentByKey(string) (entities.Experiment, error) diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100644 index 000000000..6446bb148 --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Generate test coverage statistics for Go packages. +# +# Works around the fact that `go test -coverprofile` currently does not work +# with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909 +# +# Usage: script/coverage [--html|--coveralls] +# +# --html Additionally create HTML report and open it in browser +# --coveralls Push coverage statistics to coveralls.io +# + +scriptdir=`dirname $0` +cd $scriptdir && cd .. + +set -e + +workdir=.cover +profile="$workdir/cover.out" +mode=count + +generate_cover_data() { + rm -rf "$workdir" + mkdir "$workdir" + + for pkg in "$@"; do + f="$workdir/$(echo $pkg | tr / -).cover" + go test -covermode="$mode" -coverprofile="$f" "$pkg" + done + + echo "mode: $mode" >"$profile" + grep -h -v "^mode:" "$workdir"/*.cover >>"$profile" +} + +show_cover_report() { + go tool cover -${1}="$profile" +} + +push_to_coveralls() { + echo "Pushing coverage statistics to coveralls.io" + goveralls -coverprofile="$profile" +} + +generate_cover_data $(go list ./... | grep -v vendor | grep -v mock) +go tool cover -html="./${profile}" -o coverage.html +show_cover_report func +case "$1" in +"") + ;; +--html) + show_cover_report html ;; +--coveralls) + push_to_coveralls ;; +*) + echo >&2 "error: invalid option: $1"; exit 1 ;; +esac