From 49c63b21cecaaecef2de1e15415d22098ea306c7 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 21 May 2025 20:58:09 +0200 Subject: [PATCH 1/3] feat(flag-evaluation): optimize ANY_OF and NOT_ANY_OFF operators --- packages/flag-evaluation/src/index.ts | 53 +++++++++- packages/flag-evaluation/test/index.test.ts | 110 ++++++++++++++++++++ 2 files changed, 161 insertions(+), 2 deletions(-) diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 293d43f6..e9adb195 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -116,6 +116,7 @@ export interface ContextFilter { field: string; operator: ContextFilterOperator; values?: string[]; + valueSet?: Set; } /** @@ -286,6 +287,7 @@ export function evaluate( fieldValue: string, operator: ContextFilterOperator, values: string[], + valueSet?: Set, ): boolean { const value = values[0]; @@ -331,9 +333,11 @@ export function evaluate( case "IS_NOT": return fieldValue !== value; case "ANY_OF": - return values.includes(fieldValue); + return valueSet ? valueSet.has(fieldValue) : values.includes(fieldValue); case "NOT_ANY_OF": - return !values.includes(fieldValue); + return valueSet + ? !valueSet.has(fieldValue) + : !values.includes(fieldValue); case "IS_TRUE": return fieldValue == "true"; case "IS_FALSE": @@ -362,6 +366,7 @@ function evaluateRecursively( context[filter.field], filter.operator, filter.values || [], + filter.valueSet, ); case "rolloutPercentage": { if (!(filter.partialRolloutAttribute in context)) { @@ -463,3 +468,47 @@ export function evaluateFeatureRules({ missingContextFields, }; } + +export function newEvaluator(rules: Rule[]) { + function translateRule(rule: RuleFilter): RuleFilter { + if (rule.type === "group") { + return { + ...rule, + filters: rule.filters.map(translateRule), + }; + } + + if ( + rule.type === "context" && + (rule.operator === "ANY_OF" || rule.operator === "NOT_ANY_OF") + ) { + return { + ...rule, + valueSet: new Set(rule.values), + }; + } + + return rule; + } + + const translatedRules = rules.map((rule) => { + const { filter } = rule; + const translatedFilter = translateRule(filter); + + return { + ...rule, + filter: translatedFilter, + }; + }); + + return function evaluateOptimized( + context: Record, + featureKey: string, + ) { + return evaluateFeatureRules({ + context, + featureKey, + rules: translatedRules, + }); + }; +} diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 7739f009..cb87eca5 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -6,6 +6,7 @@ import { EvaluationParams, flattenJSON, hashInt, + newEvaluator, unflattenJSON, } from "../src"; @@ -239,6 +240,115 @@ describe("evaluate feature targeting integration ", () => { ruleEvaluationResults: [false], }); }); + + it("evaluates optimized rule evaluations correctly", async () => { + const res = newEvaluator([ + { + value: true, + filter: { + type: "group", + operator: "and", + filters: [ + { + type: "context", + field: "company.id", + operator: "IS", + values: ["company1"], + }, + { + type: "rolloutPercentage", + key: "flag", + partialRolloutAttribute: "company.id", + partialRolloutThreshold: 99999, + }, + { + type: "group", + operator: "or", + filters: [ + { + type: "context", + field: "company.id", + operator: "ANY_OF", + values: ["company2"], + }, + { + type: "negation", + filter: { + type: "context", + field: "company.id", + operator: "IS", + values: ["company3"], + }, + }, + ], + }, + { + type: "negation", + filter: { + type: "constant", + value: false, + }, + }, + ], + }, + }, + ])( + { + "company.id": "company1", + }, + "feature", + ); + + expect(res).toEqual({ + value: true, + context: { + "company.id": "company1", + }, + featureKey: "feature", + missingContextFields: [], + reason: "rule #0 matched", + ruleEvaluationResults: [true], + }); + }); + + it.each([ + { + context: { "company.id": "company1" }, + expected: true, + }, + { + context: { "company.id": "company2" }, + expected: true, + }, + { + context: { "company.id": "company3" }, + expected: false, + }, + ])( + "%#: evaluates optimized rule evaluations correctly", + async ({ context, expected }) => { + const evaluator = newEvaluator([ + { + value: true, + filter: { + type: "group", + operator: "and", + filters: [ + { + type: "context", + field: "company.id", + operator: "ANY_OF", + values: ["company1", "company2"], + }, + ], + }, + }, + ]); + + const res = evaluator(context, "feature"); + expect(res.value ?? false).toEqual(expected); + }, + ); }); describe("operator evaluation", () => { From 3810ff8493c7b40106448c48b95d54650af4eebd Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 21 May 2025 21:02:17 +0200 Subject: [PATCH 2/3] avoid mutating input --- packages/flag-evaluation/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index e9adb195..03370066 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -488,7 +488,7 @@ export function newEvaluator(rules: Rule[]) { }; } - return rule; + return { ...rule }; } const translatedRules = rules.map((rule) => { From cede1743f3629933316838d7a3924551c4309240 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 22 May 2025 09:32:07 +0200 Subject: [PATCH 3/3] PR review --- packages/flag-evaluation/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 03370066..49c7e9af 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -484,7 +484,7 @@ export function newEvaluator(rules: Rule[]) { ) { return { ...rule, - valueSet: new Set(rule.values), + valueSet: new Set(rule.values ?? []), }; }