Skip to content

Commit 2ec2246

Browse files
committed
Support singleton array equality with IS and IS_NOT
1 parent 7c5943f commit 2ec2246

4 files changed

Lines changed: 133 additions & 26 deletions

File tree

.changeset/array-contains-membership.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@
55

66
Fix `CONTAINS` and `NOT_CONTAINS` for array-valued context. `CONTAINS` now checks for an exact, case-sensitive array element; `NOT_CONTAINS` checks its absence. Both use the first comparison value, while `ANY_OF` and `NOT_ANY_OF` continue to support multiple comparison values. Empty arrays do not contain any value.
77

8-
Scalar strings retain case-insensitive substring matching. Supported array membership checks no longer produce `UNSUPPORTED_ARRAY_OPERATOR` warnings in Node SDK flag targeting or config evaluation.
8+
Support `IS` for arrays containing exactly one element equal to the comparison value; `IS_NOT` matches all other present arrays, including empty arrays and arrays with duplicate matching elements. Missing fields still fail closed. Scalar equality is unchanged.
9+
10+
Scalar strings retain case-insensitive substring matching. Supported array equality and membership checks no longer produce `UNSUPPORTED_ARRAY_OPERATOR` warnings in Node SDK flag targeting or config evaluation.

packages/flag-evaluation/src/index.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ export type FilterTree<T extends FilterClass> =
5959
* set membership, and boolean evaluations.
6060
*
6161
* Possible values:
62-
* - "IS": Specifies exact match.
63-
* - "IS_NOT": Specifies a negation of exact match.
62+
* - "IS": Exact scalar match, or an array containing exactly one matching element.
63+
* - "IS_NOT": Negates scalar equality or singleton-array equality.
6464
* - "ANY_OF": Checks if a value is present in a set of specified values.
6565
* - "NOT_ANY_OF": Checks if a value is not present in a set of specified values.
6666
* - "CONTAINS": Case-insensitive substring match for strings; exact, case-sensitive element membership for arrays.
@@ -374,6 +374,8 @@ export function hashInt(hashInput: string): number {
374374
}
375375

376376
const ARRAY_OPERATORS = new Set<ContextFilterOperator>([
377+
"IS",
378+
"IS_NOT",
377379
"CONTAINS",
378380
"NOT_CONTAINS",
379381
"ANY_OF",
@@ -395,6 +397,20 @@ export function evaluate(
395397

396398
if (Array.isArray(normalizedFieldValue)) {
397399
switch (operator) {
400+
case "IS":
401+
return (
402+
typeof value === "string" &&
403+
normalizedFieldValue.length === 1 &&
404+
normalizedFieldValue[0] === value
405+
);
406+
case "IS_NOT":
407+
return (
408+
typeof value === "string" &&
409+
!(
410+
normalizedFieldValue.length === 1 &&
411+
normalizedFieldValue[0] === value
412+
)
413+
);
398414
case "CONTAINS":
399415
return (
400416
typeof value === "string" && normalizedFieldValue.includes(value)
@@ -416,7 +432,7 @@ export function evaluate(
416432
case "NOT_SET":
417433
return normalizedFieldValue.length === 0;
418434
default:
419-
// Scalar equality, numeric, date, and boolean operators are scalar-only.
435+
// Numeric, date, and boolean operators are scalar-only.
420436
// Do not accidentally stringify arrays for comparison.
421437
return false;
422438
}

packages/flag-evaluation/test/index.test.ts

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,70 @@ describe("evaluate flag targeting integration ", () => {
613613
},
614614
);
615615

616+
describe.each(["IS", "IS_NOT"] as const)(
617+
"%s singleton arrays",
618+
(operator) => {
619+
it.each([
620+
{ entries: [2], candidate: "2", equal: true },
621+
{ entries: [true], candidate: "true", equal: true },
622+
{ entries: [null], candidate: "", equal: true },
623+
{ entries: [{ level: 3 }], candidate: '{"level":3}', equal: true },
624+
{ entries: [[false]], candidate: "[false]", equal: true },
625+
{ entries: [2, 2], candidate: "2", equal: false },
626+
{ entries: [], candidate: "2", equal: false },
627+
])(
628+
"evaluates $entries against $candidate without errors",
629+
({ entries, candidate, equal }) => {
630+
const rules: Rule<boolean>[] = [
631+
{
632+
value: true,
633+
filter: {
634+
type: "context",
635+
field: "user.roles",
636+
operator,
637+
values: [candidate],
638+
},
639+
},
640+
];
641+
const context = { user: { roles: entries } };
642+
for (const result of [
643+
evaluateFlagRules({ flagKey: "singleton", rules, context }),
644+
newEvaluator(rules)(context, "singleton"),
645+
]) {
646+
expect(result.ruleEvaluationResults).toEqual([
647+
operator === "IS" ? equal : !equal,
648+
]);
649+
expect(result.errors).toBeUndefined();
650+
}
651+
},
652+
);
653+
654+
it("fails closed for missing fields, even under negation", () => {
655+
const result = evaluateFlagRules({
656+
flagKey: "missing",
657+
rules: [
658+
{
659+
value: true,
660+
filter: {
661+
type: "negation",
662+
filter: {
663+
type: "context",
664+
field: "user.roles",
665+
operator,
666+
values: ["admin"],
667+
},
668+
},
669+
},
670+
],
671+
context: { user: {} },
672+
});
673+
expect(result.ruleEvaluationResults).toEqual([false]);
674+
expect(result.missingContextFields).toEqual(["user.roles"]);
675+
expect(result.errors?.[0].code).toBe("MISSING_CONTEXT_FIELD");
676+
});
677+
},
678+
);
679+
616680
it("keeps JSON-looking strings scalar", () => {
617681
const evaluator = newEvaluator([
618682
{
@@ -670,7 +734,7 @@ describe("evaluate flag targeting integration ", () => {
670734
filter: {
671735
type: "context",
672736
field: "user.roles",
673-
operator: "IS",
737+
operator: "GT",
674738
values: ["admin"],
675739
},
676740
},
@@ -684,9 +748,9 @@ describe("evaluate flag targeting integration ", () => {
684748
{
685749
code: "UNSUPPORTED_ARRAY_OPERATOR",
686750
field: "user.roles",
687-
operator: "IS",
751+
operator: "GT",
688752
message:
689-
'Operator IS does not support array-valued context field "user.roles".',
753+
'Operator GT does not support array-valued context field "user.roles".',
690754
},
691755
]);
692756
});
@@ -702,7 +766,7 @@ describe("evaluate flag targeting integration ", () => {
702766
filter: {
703767
type: "context",
704768
field: "user.roles",
705-
operator: "IS",
769+
operator: "GT",
706770
values: ["admin"],
707771
},
708772
},
@@ -728,7 +792,7 @@ describe("evaluate flag targeting integration ", () => {
728792
{
729793
type: "context",
730794
field: "user.teams",
731-
operator: "IS",
795+
operator: "GT",
732796
values: ["platform"],
733797
},
734798
{ type: "constant", value: true },
@@ -1124,6 +1188,25 @@ describe("operator evaluation", () => {
11241188
);
11251189

11261190
it.each([
1191+
[["a"], "IS", ["a"], true],
1192+
[["a"], "IS_NOT", ["a"], false],
1193+
[["a", "b"], "IS", ["a"], false],
1194+
[["a", "b"], "IS_NOT", ["a"], true],
1195+
[["a", "a"], "IS", ["a"], false],
1196+
[["a", "a"], "IS_NOT", ["a"], true],
1197+
[[], "IS", ["a"], false],
1198+
[[], "IS_NOT", ["a"], true],
1199+
[["A"], "IS", ["a"], false],
1200+
[["A"], "IS_NOT", ["a"], true],
1201+
[["admin"], "IS", ["adm"], false],
1202+
[[""], "IS", [""], true],
1203+
[[""], "IS_NOT", [""], false],
1204+
[["a"], "IS", ["b", "a"], false],
1205+
[["a"], "IS_NOT", ["b", "a"], true],
1206+
[["a"], "IS", [], false],
1207+
[["a"], "IS_NOT", [], false],
1208+
[[], "IS", [], false],
1209+
[[], "IS_NOT", [], false],
11271210
[["a", "b"], "CONTAINS", ["a"], true],
11281211
[["a", "b"], "CONTAINS", ["c"], false],
11291212
[["a", "b"], "NOT_CONTAINS", ["c"], true],
@@ -1162,8 +1245,6 @@ describe("operator evaluation", () => {
11621245
);
11631246

11641247
it.each([
1165-
"IS",
1166-
"IS_NOT",
11671248
"GT",
11681249
"LT",
11691250
"AFTER",

packages/node-sdk/test/client.test.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,7 +1756,7 @@ describe("ReflagClient", () => {
17561756
expect(logger.warn).toHaveBeenCalledTimes(1);
17571757
});
17581758

1759-
it.each(["CONTAINS", "NOT_CONTAINS"] as const)(
1759+
it.each(["CONTAINS", "NOT_CONTAINS", "IS", "IS_NOT"] as const)(
17601760
"evaluates array %s for targeting and config without warnings",
17611761
async (operator) => {
17621762
const filter = {
@@ -1788,19 +1788,27 @@ describe("ReflagClient", () => {
17881788
});
17891789
await client.initialize();
17901790

1791-
for (const [roles, contains] of [
1792-
[["admin", "editor"], true],
1793-
[["superadmin"], false],
1794-
[["Admin"], false],
1795-
[[], false],
1796-
// Scalars retain their case-insensitive substring semantics.
1797-
["SUPERADMIN", true],
1791+
for (const [roles, contains, equals] of [
1792+
[["admin"], true, true],
1793+
[["admin", "editor"], true, false],
1794+
[["admin", "admin"], true, false],
1795+
[["superadmin"], false, false],
1796+
[["Admin"], false, false],
1797+
[[], false, false],
1798+
// Scalars retain substring matching and exact equality respectively.
1799+
["SUPERADMIN", true, false],
1800+
["admin", true, true],
17981801
] as const) {
17991802
const flag = client.getFlag(
18001803
{ user: { id: "user123", roles } },
18011804
"array-membership",
18021805
);
1803-
const expected = operator === "CONTAINS" ? contains : !contains;
1806+
const positiveMatch =
1807+
operator === "IS" || operator === "IS_NOT" ? equals : contains;
1808+
const expected =
1809+
operator === "CONTAINS" || operator === "IS"
1810+
? positiveMatch
1811+
: !positiveMatch;
18041812
expect(flag.isEnabled).toBe(expected);
18051813
expect(flag.config).toEqual(
18061814
expected
@@ -1826,7 +1834,7 @@ describe("ReflagClient", () => {
18261834
filter: {
18271835
type: "context",
18281836
field: "user.roles",
1829-
operator: "IS",
1837+
operator: "GT",
18301838
values: ["admin"],
18311839
},
18321840
},
@@ -1855,9 +1863,9 @@ describe("ReflagClient", () => {
18551863
{
18561864
code: "UNSUPPORTED_ARRAY_OPERATOR",
18571865
field: "user.roles",
1858-
operator: "IS",
1866+
operator: "GT",
18591867
message:
1860-
'Operator IS does not support array-valued context field "user.roles".',
1868+
'Operator GT does not support array-valued context field "user.roles".',
18611869
},
18621870
],
18631871
},
@@ -1891,7 +1899,7 @@ describe("ReflagClient", () => {
18911899
filter: {
18921900
type: "context",
18931901
field: "user.roles",
1894-
operator: "IS",
1902+
operator: "GT",
18951903
values: ["admin"],
18961904
},
18971905
},
@@ -1920,9 +1928,9 @@ describe("ReflagClient", () => {
19201928
{
19211929
code: "UNSUPPORTED_ARRAY_OPERATOR",
19221930
field: "user.roles",
1923-
operator: "IS",
1931+
operator: "GT",
19241932
message:
1925-
'Operator IS does not support array-valued context field "user.roles".',
1933+
'Operator GT does not support array-valued context field "user.roles".',
19261934
},
19271935
],
19281936
},

0 commit comments

Comments
 (0)