diff --git a/packages/flag-evaluation/package.json b/packages/flag-evaluation/package.json index 815f352b..0b52d043 100644 --- a/packages/flag-evaluation/package.json +++ b/packages/flag-evaluation/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/flag-evaluation", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "repository": { "type": "git", diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index e87ae2c1..51488f7e 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -203,25 +203,43 @@ export interface Rule { * @return {Record} A flattened JSON object with "stringified" keys and values. */ export function flattenJSON(data: object): Record { - if (Object.keys(data).length === 0) return {}; - const result: Record = {}; - function recurse(cur: any, prop: string) { - if (Object(cur) !== cur) { - result[prop] = cur; - } else if (Array.isArray(cur)) { - const l = cur.length; - for (let i = 0; i < l; i++) - recurse(cur[i], prop ? prop + "." + i : "" + i); - if (l == 0) result[prop] = []; + const result: Record = {}; + + if (Object.keys(data).length === 0) { + return result; + } + + function recurse(value: any, prop: string) { + if (value === undefined) { + return; + } + + if (value === null) { + result[prop] = ""; + } else if (typeof value !== "object") { + result[prop] = String(value); + } else if (Array.isArray(value)) { + if (value.length === 0) { + result[prop] = ""; + } + + for (let i = 0; i < value.length; i++) { + recurse(value[i], prop ? prop + "." + i : "" + i); + } } else { let isEmpty = true; - for (const p in cur) { + + for (const p in value) { isEmpty = false; - recurse(cur[p], prop ? prop + "." + p : p); + recurse(value[p], prop ? prop + "." + p : p); + } + + if (isEmpty) { + result[prop] = ""; } - if (isEmpty) result[prop] = {}; } } + recurse(data, ""); return result; } @@ -325,9 +343,9 @@ export function evaluate( : fieldValueDate < daysAgo.getTime(); } case "SET": - return fieldValue != ""; + return fieldValue !== ""; case "NOT_SET": - return fieldValue == ""; + return fieldValue === ""; case "IS": return fieldValue === value; case "IS_NOT": @@ -357,13 +375,17 @@ function evaluateRecursively( case "constant": return filter.value; case "context": - if (!(filter.field in context)) { + if ( + !(filter.field in context) && + filter.operator !== "SET" && + filter.operator !== "NOT_SET" + ) { missingContextFieldsSet.add(filter.field); return false; } return evaluate( - context[filter.field], + context[filter.field] ?? "", filter.operator, filter.values || [], filter.valueSet, diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 66ffa1e2..5434c0ee 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -311,6 +311,128 @@ describe("evaluate feature targeting integration ", () => { }); }); + describe("SET and NOT_SET operators", () => { + it("should handle `SET` operator with missing field value", () => { + const res = evaluateFeatureRules({ + featureKey: "test_feature", + rules: [ + { + value: true, + filter: { + type: "context", + field: "user.name", + operator: "SET", + values: [], + }, + }, + ], + context: {}, + }); + + expect(res).toEqual({ + featureKey: "test_feature", + value: undefined, + context: {}, + ruleEvaluationResults: [false], + reason: "no matched rules", + missingContextFields: [], + }); + }); + + it("should handle `NOT_SET` operator with missing field value", () => { + const res = evaluateFeatureRules({ + featureKey: "test_feature", + rules: [ + { + value: true, + filter: { + type: "context", + field: "user.name", + operator: "NOT_SET", + values: [], + }, + }, + ], + context: {}, + }); + + expect(res).toEqual({ + featureKey: "test_feature", + value: true, + context: {}, + ruleEvaluationResults: [true], + reason: "rule #0 matched", + missingContextFields: [], + }); + }); + + it("should handle `SET` operator with empty string field value", () => { + const res = evaluateFeatureRules({ + featureKey: "test_feature", + rules: [ + { + value: true, + filter: { + type: "context", + field: "user.name", + operator: "SET", + values: [], + }, + }, + ], + context: { + user: { + name: "", + }, + }, + }); + + expect(res).toEqual({ + featureKey: "test_feature", + value: undefined, + context: { + "user.name": "", + }, + ruleEvaluationResults: [false], + reason: "no matched rules", + missingContextFields: [], + }); + }); + + it("should handle `NOT_SET` operator with empty string field value", () => { + const res = evaluateFeatureRules({ + featureKey: "test_feature", + rules: [ + { + value: true, + filter: { + type: "context", + field: "user.name", + operator: "NOT_SET", + values: [], + }, + }, + ], + context: { + user: { + name: "", + }, + }, + }); + + expect(res).toEqual({ + featureKey: "test_feature", + value: true, + context: { + "user.name": "", + }, + ruleEvaluationResults: [true], + reason: "rule #0 matched", + missingContextFields: [], + }); + }); + }); + it.each([ { context: { "company.id": "company1" }, @@ -376,6 +498,8 @@ describe("operator evaluation", () => { ["value", "SET", "", true], ["", "SET", "", false], + ["value", "NOT_SET", "", false], + ["", "NOT_SET", "", true], // non numeric values should return false ["value", "GT", "value", false], @@ -526,8 +650,8 @@ describe("flattenJSON", () => { expect(output).toEqual({ "a.b": "string", - "a.c": 123, - "a.d": true, + "a.c": "123", + "a.d": "true", }); }); @@ -553,7 +677,7 @@ describe("flattenJSON", () => { const output = flattenJSON(input); expect(output).toEqual({ - a: [], + a: "", }); }); @@ -608,6 +732,148 @@ describe("flattenJSON", () => { "a.b": "", }); }); + + it("should handle null values", () => { + const input = { + a: null, + b: { + c: null, + }, + }; + + const output = flattenJSON(input); + + expect(output).toEqual({ + a: "", + "b.c": "", + }); + }); + + it("should skip undefined values", () => { + const input = { + a: "value", + b: undefined, + c: { + d: undefined, + e: "another value", + }, + }; + + const output = flattenJSON(input); + + expect(output).toEqual({ + a: "value", + "c.e": "another value", + }); + }); + + it("should handle empty nested objects", () => { + const input = { + a: {}, + b: { + c: {}, + d: "value", + }, + }; + + const output = flattenJSON(input); + + expect(output).toEqual({ + a: "", + "b.c": "", + "b.d": "value", + }); + }); + + it("should handle top-level primitive values", () => { + const input = { + a: "simple", + b: 42, + c: true, + d: false, + }; + + const output = flattenJSON(input); + + expect(output).toEqual({ + a: "simple", + b: "42", + c: "true", + d: "false", + }); + }); + + it("should handle arrays with null and undefined values", () => { + const input = { + a: ["value1", null, undefined, "value4"], + }; + + const output = flattenJSON(input); + + expect(output).toEqual({ + "a.0": "value1", + "a.1": "", + "a.3": "value4", + }); + }); + + it("should handle deeply nested empty structures", () => { + const input = { + a: { + b: { + c: {}, + d: [], + }, + }, + }; + + const output = flattenJSON(input); + + expect(output).toEqual({ + "a.b.c": "", + "a.b.d": "", + }); + }); + + it("should handle keys with special characters", () => { + const input = { + "key.with.dots": "value1", + "key-with-dashes": "value2", + "key with spaces": "value3", + }; + + const output = flattenJSON(input); + + expect(output).toEqual({ + "key.with.dots": "value1", + "key-with-dashes": "value2", + "key with spaces": "value3", + }); + }); + + it("should handle edge case numbers and booleans", () => { + const input = { + zero: 0, + negativeNumber: -42, + float: 3.14, + infinity: Infinity, + negativeInfinity: -Infinity, + nan: NaN, + falseValue: false, + }; + + const output = flattenJSON(input); + + expect(output).toEqual({ + zero: "0", + negativeNumber: "-42", + float: "3.14", + infinity: "Infinity", + negativeInfinity: "-Infinity", + nan: "NaN", + falseValue: "false", + }); + }); }); describe("unflattenJSON", () => { diff --git a/yarn.lock b/yarn.lock index 4d14bc76..99ea94ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -768,7 +768,16 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/flag-evaluation@npm:0.1.4, @bucketco/flag-evaluation@workspace:packages/flag-evaluation": +"@bucketco/flag-evaluation@npm:0.1.4": + version: 0.1.4 + resolution: "@bucketco/flag-evaluation@npm:0.1.4" + dependencies: + js-sha256: "npm:0.11.0" + checksum: 10c0/d1f0513a3167d1c4d2a3caf782072f949f508760d77be2c6d5957f5a92d76aaf70467c1d923591c6663a6dcbe01f4d476e46eb501834231ae3d4f28ecff6640c + languageName: node + linkType: hard + +"@bucketco/flag-evaluation@workspace:packages/flag-evaluation": version: 0.0.0-use.local resolution: "@bucketco/flag-evaluation@workspace:packages/flag-evaluation" dependencies: