From 75905d65781ff23ee7db5f846a59680724d8d6fa Mon Sep 17 00:00:00 2001 From: Vincent Derks Date: Thu, 26 Feb 2026 09:27:34 +0100 Subject: [PATCH 1/5] Added new comparators: (not_)contains and case-insensitive versions --- .../vercel-flags-core/src/evaluate.test.ts | 402 ++++++++++++++++++ packages/vercel-flags-core/src/evaluate.ts | 87 ++++ packages/vercel-flags-core/src/types.ts | 95 +++++ 3 files changed, 584 insertions(+) diff --git a/packages/vercel-flags-core/src/evaluate.test.ts b/packages/vercel-flags-core/src/evaluate.test.ts index d30677ff..2c1685ee 100644 --- a/packages/vercel-flags-core/src/evaluate.test.ts +++ b/packages/vercel-flags-core/src/evaluate.test.ts @@ -1044,6 +1044,58 @@ describe('evaluate', () => { result: false, }, + // CONTAINS + { + name: `${Comparator.CONTAINS} match`, + condition: [['user', 'id'], Comparator.CONTAINS, 'wilk'], + entities: { user: { id: 'joewilkinson' } }, + result: true, + }, + { + name: `${Comparator.CONTAINS} miss`, + condition: [['user', 'id'], Comparator.CONTAINS, 'smith'], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + name: `${Comparator.CONTAINS} unset`, + condition: [['user', 'id'], Comparator.CONTAINS, 'wilk'], + entities: { user: {} }, + result: false, + }, + { + name: `${Comparator.CONTAINS} invalid`, + condition: [['user', 'id'], Comparator.CONTAINS, 'wilk'], + entities: { user: { id: null } }, + result: false, + }, + + // NOT_CONTAINS + { + name: `${Comparator.NOT_CONTAINS} match`, + condition: [['user', 'id'], Comparator.NOT_CONTAINS, 'smith'], + entities: { user: { id: 'joewilkinson' } }, + result: true, + }, + { + name: `${Comparator.NOT_CONTAINS} miss`, + condition: [['user', 'id'], Comparator.NOT_CONTAINS, 'wilk'], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + name: `${Comparator.NOT_CONTAINS} unset`, + condition: [['user', 'id'], Comparator.NOT_CONTAINS, 'smith'], + entities: { user: {} }, + result: false, + }, + { + name: `${Comparator.NOT_CONTAINS} invalid`, + condition: [['user', 'id'], Comparator.NOT_CONTAINS, 'smith'], + entities: { user: { id: null } }, + result: false, + }, + // EXISTS { name: `${Comparator.EXISTS} match`, @@ -1315,6 +1367,356 @@ describe('evaluate', () => { entities: { user: {} }, result: false, }, + + // ---- Case-insensitive variants ---- + + // EQ_CI + { + name: `${Comparator.EQ_CI} match (different case)`, + condition: [['user', 'id'], Comparator.EQ_CI, 'UID1'], + entities: { user: { id: 'uid1' } }, + result: true, + }, + { + name: `${Comparator.EQ_CI} match (same case)`, + condition: [['user', 'id'], Comparator.EQ_CI, 'uid1'], + entities: { user: { id: 'uid1' } }, + result: true, + }, + { + name: `${Comparator.EQ_CI} miss`, + condition: [['user', 'id'], Comparator.EQ_CI, 'uid2'], + entities: { user: { id: 'uid1' } }, + result: false, + }, + { + name: `${Comparator.EQ_CI} unset`, + condition: [['user', 'id'], Comparator.EQ_CI, 'uid1'], + entities: {}, + result: false, + }, + + // NOT_EQ_CI + { + name: `${Comparator.NOT_EQ_CI} match`, + condition: [['user', 'id'], Comparator.NOT_EQ_CI, 'uid2'], + entities: { user: { id: 'uid1' } }, + result: true, + }, + { + name: `${Comparator.NOT_EQ_CI} miss (different case)`, + condition: [['user', 'id'], Comparator.NOT_EQ_CI, 'UID1'], + entities: { user: { id: 'uid1' } }, + result: false, + }, + { + name: `${Comparator.NOT_EQ_CI} unset`, + condition: [['user', 'id'], Comparator.NOT_EQ_CI, 'uid1'], + entities: {}, + result: true, + }, + + // ONE_OF_CI + { + name: `${Comparator.ONE_OF_CI} match (different case)`, + condition: [['user', 'id'], Comparator.ONE_OF_CI, ['UID1', 'UID2']], + entities: { user: { id: 'uid1' } }, + result: true, + }, + { + name: `${Comparator.ONE_OF_CI} miss`, + condition: [['user', 'id'], Comparator.ONE_OF_CI, ['uid2']], + entities: { user: { id: 'uid1' } }, + result: false, + }, + { + name: `${Comparator.ONE_OF_CI} unset`, + condition: [['user', 'id'], Comparator.ONE_OF_CI, ['uid1']], + entities: {}, + result: false, + }, + + // NOT_ONE_OF_CI + { + name: `${Comparator.NOT_ONE_OF_CI} match`, + condition: [['user', 'id'], Comparator.NOT_ONE_OF_CI, ['uid2']], + entities: { user: { id: 'uid1' } }, + result: true, + }, + { + name: `${Comparator.NOT_ONE_OF_CI} miss (different case)`, + condition: [['user', 'id'], Comparator.NOT_ONE_OF_CI, ['UID1']], + entities: { user: { id: 'uid1' } }, + result: false, + }, + { + name: `${Comparator.NOT_ONE_OF_CI} unset`, + condition: [['user', 'id'], Comparator.NOT_ONE_OF_CI, ['uid2']], + entities: {}, + result: false, + }, + + // CONTAINS_ALL_OF_CI + { + name: `${Comparator.CONTAINS_ALL_OF_CI} match (different case)`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ALL_OF_CI, + ['TEAM1', 'TEAM2'], + ], + entities: { user: { teamIds: ['team2', 'team1'] } }, + result: true, + }, + { + name: `${Comparator.CONTAINS_ALL_OF_CI} partial match`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ALL_OF_CI, + ['TEAM1', 'TEAM2'], + ], + entities: { user: { teamIds: ['team2'] } }, + result: false, + }, + { + name: `${Comparator.CONTAINS_ALL_OF_CI} miss`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ALL_OF_CI, + ['TEAM1'], + ], + entities: { user: { teamIds: ['team2'] } }, + result: false, + }, + { + name: `${Comparator.CONTAINS_ALL_OF_CI} unset`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ALL_OF_CI, + ['TEAM1'], + ], + entities: {}, + result: false, + }, + + // CONTAINS_ANY_OF_CI + { + name: `${Comparator.CONTAINS_ANY_OF_CI} match (different case)`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ANY_OF_CI, + ['TEAM1', 'TEAM2'], + ], + entities: { user: { teamIds: ['team2'] } }, + result: true, + }, + { + name: `${Comparator.CONTAINS_ANY_OF_CI} miss`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ANY_OF_CI, + ['TEAM1', 'TEAM2'], + ], + entities: { user: { teamIds: ['team3'] } }, + result: false, + }, + { + name: `${Comparator.CONTAINS_ANY_OF_CI} unset`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ANY_OF_CI, + ['TEAM1'], + ], + entities: { user: {} }, + result: false, + }, + + // CONTAINS_NONE_OF_CI + { + name: `${Comparator.CONTAINS_NONE_OF_CI} match (different case)`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_NONE_OF_CI, + ['TEAM1'], + ], + entities: { user: { teamIds: ['team2'] } }, + result: true, + }, + { + name: `${Comparator.CONTAINS_NONE_OF_CI} miss (different case)`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_NONE_OF_CI, + ['TEAM1'], + ], + entities: { user: { teamIds: ['team1'] } }, + result: false, + }, + { + name: `${Comparator.CONTAINS_NONE_OF_CI} unset entity`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_NONE_OF_CI, + ['TEAM1'], + ], + entities: {}, + result: true, + }, + + // STARTS_WITH_CI + { + name: `${Comparator.STARTS_WITH_CI} match (different case)`, + condition: [['user', 'id'], Comparator.STARTS_WITH_CI, 'JOE'], + entities: { user: { id: 'joewilkinson' } }, + result: true, + }, + { + name: `${Comparator.STARTS_WITH_CI} miss`, + condition: [['user', 'id'], Comparator.STARTS_WITH_CI, 'jim'], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + name: `${Comparator.STARTS_WITH_CI} unset`, + condition: [['user', 'id'], Comparator.STARTS_WITH_CI, 'JOE'], + entities: { user: {} }, + result: false, + }, + { + name: `${Comparator.STARTS_WITH_CI} invalid`, + condition: [['user', 'id'], Comparator.STARTS_WITH_CI, 'JOE'], + entities: { user: { id: null } }, + result: false, + }, + + // NOT_STARTS_WITH_CI + { + name: `${Comparator.NOT_STARTS_WITH_CI} match`, + condition: [['user', 'id'], Comparator.NOT_STARTS_WITH_CI, 'jim'], + entities: { user: { id: 'joewilkinson' } }, + result: true, + }, + { + name: `${Comparator.NOT_STARTS_WITH_CI} miss (different case)`, + condition: [['user', 'id'], Comparator.NOT_STARTS_WITH_CI, 'JOE'], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + name: `${Comparator.NOT_STARTS_WITH_CI} unset`, + condition: [['user', 'id'], Comparator.NOT_STARTS_WITH_CI, 'JOE'], + entities: { user: {} }, + result: false, + }, + { + name: `${Comparator.NOT_STARTS_WITH_CI} invalid`, + condition: [['user', 'id'], Comparator.NOT_STARTS_WITH_CI, 'JOE'], + entities: { user: { id: null } }, + result: false, + }, + + // ENDS_WITH_CI + { + name: `${Comparator.ENDS_WITH_CI} match (different case)`, + condition: [['user', 'id'], Comparator.ENDS_WITH_CI, 'SON'], + entities: { user: { id: 'joewilkinson' } }, + result: true, + }, + { + name: `${Comparator.ENDS_WITH_CI} miss`, + condition: [['user', 'id'], Comparator.ENDS_WITH_CI, 'jim'], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + name: `${Comparator.ENDS_WITH_CI} unset`, + condition: [['user', 'id'], Comparator.ENDS_WITH_CI, 'SON'], + entities: { user: {} }, + result: false, + }, + { + name: `${Comparator.ENDS_WITH_CI} invalid`, + condition: [['user', 'id'], Comparator.ENDS_WITH_CI, 'SON'], + entities: { user: { id: null } }, + result: false, + }, + + // NOT_ENDS_WITH_CI + { + name: `${Comparator.NOT_ENDS_WITH_CI} match`, + condition: [['user', 'id'], Comparator.NOT_ENDS_WITH_CI, 'jim'], + entities: { user: { id: 'joewilkinson' } }, + result: true, + }, + { + name: `${Comparator.NOT_ENDS_WITH_CI} miss (different case)`, + condition: [['user', 'id'], Comparator.NOT_ENDS_WITH_CI, 'SON'], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + name: `${Comparator.NOT_ENDS_WITH_CI} unset`, + condition: [['user', 'id'], Comparator.NOT_ENDS_WITH_CI, 'jim'], + entities: { user: {} }, + result: false, + }, + { + name: `${Comparator.NOT_ENDS_WITH_CI} invalid`, + condition: [['user', 'id'], Comparator.NOT_ENDS_WITH_CI, 'jim'], + entities: { user: { id: null } }, + result: false, + }, + + // CONTAINS_CI + { + name: `${Comparator.CONTAINS_CI} match (different case)`, + condition: [['user', 'id'], Comparator.CONTAINS_CI, 'WILK'], + entities: { user: { id: 'joewilkinson' } }, + result: true, + }, + { + name: `${Comparator.CONTAINS_CI} miss`, + condition: [['user', 'id'], Comparator.CONTAINS_CI, 'smith'], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + name: `${Comparator.CONTAINS_CI} unset`, + condition: [['user', 'id'], Comparator.CONTAINS_CI, 'WILK'], + entities: { user: {} }, + result: false, + }, + { + name: `${Comparator.CONTAINS_CI} invalid`, + condition: [['user', 'id'], Comparator.CONTAINS_CI, 'WILK'], + entities: { user: { id: null } }, + result: false, + }, + + // NOT_CONTAINS_CI + { + name: `${Comparator.NOT_CONTAINS_CI} match`, + condition: [['user', 'id'], Comparator.NOT_CONTAINS_CI, 'smith'], + entities: { user: { id: 'joewilkinson' } }, + result: true, + }, + { + name: `${Comparator.NOT_CONTAINS_CI} miss (different case)`, + condition: [['user', 'id'], Comparator.NOT_CONTAINS_CI, 'WILK'], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + name: `${Comparator.NOT_CONTAINS_CI} unset`, + condition: [['user', 'id'], Comparator.NOT_CONTAINS_CI, 'smith'], + entities: { user: {} }, + result: false, + }, + { + name: `${Comparator.NOT_CONTAINS_CI} invalid`, + condition: [['user', 'id'], Comparator.NOT_CONTAINS_CI, 'smith'], + entities: { user: { id: null } }, + result: false, + }, ])('should evaluate comparator $name', ({ condition, entities, result }) => { expect( evaluate({ diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 91799f70..e0e0f7cd 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -55,6 +55,10 @@ function isArray(input: unknown): input is unknown[] { return Array.isArray(input); } +function lower(input: unknown): unknown { + return isString(input) ? input.toLowerCase() : input; +} + function matchTargetList( targets: Packed.TargetList, params: EvaluationParams, @@ -198,6 +202,10 @@ function matchConditions( return isString(lhs) && isString(rhs) && lhs.endsWith(rhs); case Comparator.NOT_ENDS_WITH: return isString(lhs) && isString(rhs) && !lhs.endsWith(rhs); + case Comparator.CONTAINS: + return isString(lhs) && isString(rhs) && lhs.includes(rhs); + case Comparator.NOT_CONTAINS: + return isString(lhs) && isString(rhs) && !lhs.includes(rhs); case Comparator.EXISTS: return lhs !== undefined && lhs !== null; case Comparator.NOT_EXISTS: @@ -252,6 +260,85 @@ function matchConditions( const b = new Date(rhs); return a.getTime() > b.getTime(); } + + // ---- Case-insensitive variants ---- + + case Comparator.EQ_CI: + return lower(lhs) === lower(rhs); + case Comparator.NOT_EQ_CI: + return lower(lhs) !== lower(rhs); + case Comparator.ONE_OF_CI: + return isArray(rhs) && rhs.map(lower).includes(lower(lhs)); + case Comparator.NOT_ONE_OF_CI: + return ( + isArray(rhs) && + typeof lhs !== 'undefined' && + !rhs.map(lower).includes(lower(lhs)) + ); + case Comparator.CONTAINS_ALL_OF_CI: { + if (!Array.isArray(rhs) || !Array.isArray(lhs)) return false; + const lhsSet = new Set( + lhs.filter(isString).map((s) => s.toLowerCase()), + ); + return rhs + .filter(isString) + .every((item) => lhsSet.has(item.toLowerCase())); + } + case Comparator.CONTAINS_ANY_OF_CI: { + if (!Array.isArray(rhs) || !Array.isArray(lhs)) return false; + const rhsSet = new Set( + rhs.filter(isString).map((s) => s.toLowerCase()), + ); + return lhs + .filter(isString) + .some((item) => rhsSet.has(item.toLowerCase())); + } + case Comparator.CONTAINS_NONE_OF_CI: { + if (!Array.isArray(rhs)) return false; + if (!Array.isArray(lhs)) return true; + const rhsSet = new Set( + rhs.filter(isString).map((s) => s.toLowerCase()), + ); + return lhs + .filter(isString) + .every((item) => !rhsSet.has(item.toLowerCase())); + } + case Comparator.STARTS_WITH_CI: + return ( + isString(lhs) && + isString(rhs) && + lhs.toLowerCase().startsWith(rhs.toLowerCase()) + ); + case Comparator.NOT_STARTS_WITH_CI: + return ( + isString(lhs) && + isString(rhs) && + !lhs.toLowerCase().startsWith(rhs.toLowerCase()) + ); + case Comparator.ENDS_WITH_CI: + return ( + isString(lhs) && + isString(rhs) && + lhs.toLowerCase().endsWith(rhs.toLowerCase()) + ); + case Comparator.NOT_ENDS_WITH_CI: + return ( + isString(lhs) && + isString(rhs) && + !lhs.toLowerCase().endsWith(rhs.toLowerCase()) + ); + case Comparator.CONTAINS_CI: + return ( + isString(lhs) && + isString(rhs) && + lhs.toLowerCase().includes(rhs.toLowerCase()) + ); + case Comparator.NOT_CONTAINS_CI: + return ( + isString(lhs) && + isString(rhs) && + !lhs.toLowerCase().includes(rhs.toLowerCase()) + ); default: { const _x: never = cmpKey; // exhaustive check return false; diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index c3fc9220..a0158715 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -356,6 +356,20 @@ export enum Comparator { * other comparisons have to be handled with a regex */ NOT_ENDS_WITH = '!endsWith', + /** + * lhs must be string + * rhs must be string + * + * checks if lhs contains rhs as a substring + */ + CONTAINS = 'contains', + /** + * lhs must be string + * rhs must be string + * + * checks if lhs does not contain rhs as a substring + */ + NOT_CONTAINS = '!contains', /** * lhs must be string * rhs must be never @@ -411,6 +425,87 @@ export enum Comparator { * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format */ AFTER = 'after', + + // ---- Case-insensitive variants ---- + + /** + * lhs must be string | number + * rhs must be string | number + * case-insensitive equality check + */ + EQ_CI = 'eq_ci', + /** + * lhs must be string | number + * rhs must be string | number + * case-insensitive inequality check + */ + NOT_EQ_CI = '!eq_ci', + /** + * lhs must be string + * rhs must be string[] + * case-insensitive one of check + */ + ONE_OF_CI = 'oneOf_ci', + /** + * lhs must be string + * rhs must be string[] + * case-insensitive not one of check + */ + NOT_ONE_OF_CI = '!oneOf_ci', + /** + * lhs must be string[] + * rhs must be string[] + * case-insensitive contains all of check + */ + CONTAINS_ALL_OF_CI = 'containsAllOf_ci', + /** + * lhs must be string[] + * rhs must be string[] + * case-insensitive contains any of check + */ + CONTAINS_ANY_OF_CI = 'containsAnyOf_ci', + /** + * lhs must be string[] + * rhs must be string[] + * case-insensitive contains none of check + */ + CONTAINS_NONE_OF_CI = 'containsNoneOf_ci', + /** + * lhs must be string + * rhs must be string + * case-insensitive prefix check + */ + STARTS_WITH_CI = 'startsWith_ci', + /** + * lhs must be string + * rhs must be string + * case-insensitive negated prefix check + */ + NOT_STARTS_WITH_CI = '!startsWith_ci', + /** + * lhs must be string + * rhs must be string + * case-insensitive suffix check + */ + ENDS_WITH_CI = 'endsWith_ci', + /** + * lhs must be string + * rhs must be string + * case-insensitive negated suffix check + */ + NOT_ENDS_WITH_CI = '!endsWith_ci', + /** + * lhs must be string + * rhs must be string + * case-insensitive substring check + */ + CONTAINS_CI = 'contains_ci', + /** + * lhs must be string + * rhs must be string + * case-insensitive negated substring check + */ + NOT_CONTAINS_CI = '!contains_ci', } // ----------------------------------------------------------------------------- From 93a5316d89506ac6215f9f925caccdb760f9baff Mon Sep 17 00:00:00 2001 From: Vincent Derks Date: Thu, 26 Feb 2026 09:48:46 +0100 Subject: [PATCH 2/5] Switched to "ci" option instead of creating new condition for every ci-case --- .../vercel-flags-core/src/evaluate.test.ts | 306 +++++++++++------- packages/vercel-flags-core/src/evaluate.ts | 191 +++++------ packages/vercel-flags-core/src/types.ts | 87 +---- 3 files changed, 283 insertions(+), 301 deletions(-) diff --git a/packages/vercel-flags-core/src/evaluate.test.ts b/packages/vercel-flags-core/src/evaluate.test.ts index 2c1685ee..4be1d2dd 100644 --- a/packages/vercel-flags-core/src/evaluate.test.ts +++ b/packages/vercel-flags-core/src/evaluate.test.ts @@ -1368,352 +1368,442 @@ describe('evaluate', () => { result: false, }, - // ---- Case-insensitive variants ---- + // ---- Case-insensitive (ci option) ---- - // EQ_CI + // EQ { ci: true } { - name: `${Comparator.EQ_CI} match (different case)`, - condition: [['user', 'id'], Comparator.EQ_CI, 'UID1'], + name: `${Comparator.EQ} ci match (different case)`, + condition: [['user', 'id'], Comparator.EQ, 'UID1', { ci: true }], entities: { user: { id: 'uid1' } }, result: true, }, { - name: `${Comparator.EQ_CI} match (same case)`, - condition: [['user', 'id'], Comparator.EQ_CI, 'uid1'], + name: `${Comparator.EQ} ci match (same case)`, + condition: [['user', 'id'], Comparator.EQ, 'uid1', { ci: true }], entities: { user: { id: 'uid1' } }, result: true, }, { - name: `${Comparator.EQ_CI} miss`, - condition: [['user', 'id'], Comparator.EQ_CI, 'uid2'], + name: `${Comparator.EQ} ci miss`, + condition: [['user', 'id'], Comparator.EQ, 'uid2', { ci: true }], entities: { user: { id: 'uid1' } }, result: false, }, { - name: `${Comparator.EQ_CI} unset`, - condition: [['user', 'id'], Comparator.EQ_CI, 'uid1'], + name: `${Comparator.EQ} ci unset`, + condition: [['user', 'id'], Comparator.EQ, 'uid1', { ci: true }], entities: {}, result: false, }, - // NOT_EQ_CI + // NOT_EQ { ci: true } { - name: `${Comparator.NOT_EQ_CI} match`, - condition: [['user', 'id'], Comparator.NOT_EQ_CI, 'uid2'], + name: `${Comparator.NOT_EQ} ci match`, + condition: [['user', 'id'], Comparator.NOT_EQ, 'uid2', { ci: true }], entities: { user: { id: 'uid1' } }, result: true, }, { - name: `${Comparator.NOT_EQ_CI} miss (different case)`, - condition: [['user', 'id'], Comparator.NOT_EQ_CI, 'UID1'], + name: `${Comparator.NOT_EQ} ci miss (different case)`, + condition: [['user', 'id'], Comparator.NOT_EQ, 'UID1', { ci: true }], entities: { user: { id: 'uid1' } }, result: false, }, { - name: `${Comparator.NOT_EQ_CI} unset`, - condition: [['user', 'id'], Comparator.NOT_EQ_CI, 'uid1'], + name: `${Comparator.NOT_EQ} ci unset`, + condition: [['user', 'id'], Comparator.NOT_EQ, 'uid1', { ci: true }], entities: {}, result: true, }, - // ONE_OF_CI + // ONE_OF { ci: true } { - name: `${Comparator.ONE_OF_CI} match (different case)`, - condition: [['user', 'id'], Comparator.ONE_OF_CI, ['UID1', 'UID2']], + name: `${Comparator.ONE_OF} ci match (different case)`, + condition: [ + ['user', 'id'], + Comparator.ONE_OF, + ['UID1', 'UID2'], + { ci: true }, + ], entities: { user: { id: 'uid1' } }, result: true, }, { - name: `${Comparator.ONE_OF_CI} miss`, - condition: [['user', 'id'], Comparator.ONE_OF_CI, ['uid2']], + name: `${Comparator.ONE_OF} ci miss`, + condition: [['user', 'id'], Comparator.ONE_OF, ['uid2'], { ci: true }], entities: { user: { id: 'uid1' } }, result: false, }, { - name: `${Comparator.ONE_OF_CI} unset`, - condition: [['user', 'id'], Comparator.ONE_OF_CI, ['uid1']], + name: `${Comparator.ONE_OF} ci unset`, + condition: [['user', 'id'], Comparator.ONE_OF, ['uid1'], { ci: true }], entities: {}, result: false, }, - // NOT_ONE_OF_CI + // NOT_ONE_OF { ci: true } { - name: `${Comparator.NOT_ONE_OF_CI} match`, - condition: [['user', 'id'], Comparator.NOT_ONE_OF_CI, ['uid2']], + name: `${Comparator.NOT_ONE_OF} ci match`, + condition: [ + ['user', 'id'], + Comparator.NOT_ONE_OF, + ['uid2'], + { ci: true }, + ], entities: { user: { id: 'uid1' } }, result: true, }, { - name: `${Comparator.NOT_ONE_OF_CI} miss (different case)`, - condition: [['user', 'id'], Comparator.NOT_ONE_OF_CI, ['UID1']], + name: `${Comparator.NOT_ONE_OF} ci miss (different case)`, + condition: [ + ['user', 'id'], + Comparator.NOT_ONE_OF, + ['UID1'], + { ci: true }, + ], entities: { user: { id: 'uid1' } }, result: false, }, { - name: `${Comparator.NOT_ONE_OF_CI} unset`, - condition: [['user', 'id'], Comparator.NOT_ONE_OF_CI, ['uid2']], + name: `${Comparator.NOT_ONE_OF} ci unset`, + condition: [ + ['user', 'id'], + Comparator.NOT_ONE_OF, + ['uid2'], + { ci: true }, + ], entities: {}, result: false, }, - // CONTAINS_ALL_OF_CI + // CONTAINS_ALL_OF { ci: true } { - name: `${Comparator.CONTAINS_ALL_OF_CI} match (different case)`, + name: `${Comparator.CONTAINS_ALL_OF} ci match (different case)`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_ALL_OF_CI, + Comparator.CONTAINS_ALL_OF, ['TEAM1', 'TEAM2'], + { ci: true }, ], entities: { user: { teamIds: ['team2', 'team1'] } }, result: true, }, { - name: `${Comparator.CONTAINS_ALL_OF_CI} partial match`, + name: `${Comparator.CONTAINS_ALL_OF} ci partial match`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_ALL_OF_CI, + Comparator.CONTAINS_ALL_OF, ['TEAM1', 'TEAM2'], + { ci: true }, ], entities: { user: { teamIds: ['team2'] } }, result: false, }, { - name: `${Comparator.CONTAINS_ALL_OF_CI} miss`, + name: `${Comparator.CONTAINS_ALL_OF} ci miss`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_ALL_OF_CI, + Comparator.CONTAINS_ALL_OF, ['TEAM1'], + { ci: true }, ], entities: { user: { teamIds: ['team2'] } }, result: false, }, { - name: `${Comparator.CONTAINS_ALL_OF_CI} unset`, + name: `${Comparator.CONTAINS_ALL_OF} ci unset`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_ALL_OF_CI, + Comparator.CONTAINS_ALL_OF, ['TEAM1'], + { ci: true }, ], entities: {}, result: false, }, - // CONTAINS_ANY_OF_CI + // CONTAINS_ANY_OF { ci: true } { - name: `${Comparator.CONTAINS_ANY_OF_CI} match (different case)`, + name: `${Comparator.CONTAINS_ANY_OF} ci match (different case)`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_ANY_OF_CI, + Comparator.CONTAINS_ANY_OF, ['TEAM1', 'TEAM2'], + { ci: true }, ], entities: { user: { teamIds: ['team2'] } }, result: true, }, { - name: `${Comparator.CONTAINS_ANY_OF_CI} miss`, + name: `${Comparator.CONTAINS_ANY_OF} ci miss`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_ANY_OF_CI, + Comparator.CONTAINS_ANY_OF, ['TEAM1', 'TEAM2'], + { ci: true }, ], entities: { user: { teamIds: ['team3'] } }, result: false, }, { - name: `${Comparator.CONTAINS_ANY_OF_CI} unset`, + name: `${Comparator.CONTAINS_ANY_OF} ci unset`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_ANY_OF_CI, + Comparator.CONTAINS_ANY_OF, ['TEAM1'], + { ci: true }, ], entities: { user: {} }, result: false, }, - // CONTAINS_NONE_OF_CI + // CONTAINS_NONE_OF { ci: true } { - name: `${Comparator.CONTAINS_NONE_OF_CI} match (different case)`, + name: `${Comparator.CONTAINS_NONE_OF} ci match (different case)`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_NONE_OF_CI, + Comparator.CONTAINS_NONE_OF, ['TEAM1'], + { ci: true }, ], entities: { user: { teamIds: ['team2'] } }, result: true, }, { - name: `${Comparator.CONTAINS_NONE_OF_CI} miss (different case)`, + name: `${Comparator.CONTAINS_NONE_OF} ci miss (different case)`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_NONE_OF_CI, + Comparator.CONTAINS_NONE_OF, ['TEAM1'], + { ci: true }, ], entities: { user: { teamIds: ['team1'] } }, result: false, }, { - name: `${Comparator.CONTAINS_NONE_OF_CI} unset entity`, + name: `${Comparator.CONTAINS_NONE_OF} ci unset entity`, condition: [ ['user', 'teamIds'], - Comparator.CONTAINS_NONE_OF_CI, + Comparator.CONTAINS_NONE_OF, ['TEAM1'], + { ci: true }, ], entities: {}, result: true, }, - // STARTS_WITH_CI + // STARTS_WITH { ci: true } { - name: `${Comparator.STARTS_WITH_CI} match (different case)`, - condition: [['user', 'id'], Comparator.STARTS_WITH_CI, 'JOE'], + name: `${Comparator.STARTS_WITH} ci match (different case)`, + condition: [['user', 'id'], Comparator.STARTS_WITH, 'JOE', { ci: true }], entities: { user: { id: 'joewilkinson' } }, result: true, }, { - name: `${Comparator.STARTS_WITH_CI} miss`, - condition: [['user', 'id'], Comparator.STARTS_WITH_CI, 'jim'], + name: `${Comparator.STARTS_WITH} ci miss`, + condition: [['user', 'id'], Comparator.STARTS_WITH, 'jim', { ci: true }], entities: { user: { id: 'joewilkinson' } }, result: false, }, { - name: `${Comparator.STARTS_WITH_CI} unset`, - condition: [['user', 'id'], Comparator.STARTS_WITH_CI, 'JOE'], + name: `${Comparator.STARTS_WITH} ci unset`, + condition: [['user', 'id'], Comparator.STARTS_WITH, 'JOE', { ci: true }], entities: { user: {} }, result: false, }, { - name: `${Comparator.STARTS_WITH_CI} invalid`, - condition: [['user', 'id'], Comparator.STARTS_WITH_CI, 'JOE'], + name: `${Comparator.STARTS_WITH} ci invalid`, + condition: [['user', 'id'], Comparator.STARTS_WITH, 'JOE', { ci: true }], entities: { user: { id: null } }, result: false, }, - // NOT_STARTS_WITH_CI + // NOT_STARTS_WITH { ci: true } { - name: `${Comparator.NOT_STARTS_WITH_CI} match`, - condition: [['user', 'id'], Comparator.NOT_STARTS_WITH_CI, 'jim'], + name: `${Comparator.NOT_STARTS_WITH} ci match`, + condition: [ + ['user', 'id'], + Comparator.NOT_STARTS_WITH, + 'jim', + { ci: true }, + ], entities: { user: { id: 'joewilkinson' } }, result: true, }, { - name: `${Comparator.NOT_STARTS_WITH_CI} miss (different case)`, - condition: [['user', 'id'], Comparator.NOT_STARTS_WITH_CI, 'JOE'], + name: `${Comparator.NOT_STARTS_WITH} ci miss (different case)`, + condition: [ + ['user', 'id'], + Comparator.NOT_STARTS_WITH, + 'JOE', + { ci: true }, + ], entities: { user: { id: 'joewilkinson' } }, result: false, }, { - name: `${Comparator.NOT_STARTS_WITH_CI} unset`, - condition: [['user', 'id'], Comparator.NOT_STARTS_WITH_CI, 'JOE'], + name: `${Comparator.NOT_STARTS_WITH} ci unset`, + condition: [ + ['user', 'id'], + Comparator.NOT_STARTS_WITH, + 'JOE', + { ci: true }, + ], entities: { user: {} }, result: false, }, { - name: `${Comparator.NOT_STARTS_WITH_CI} invalid`, - condition: [['user', 'id'], Comparator.NOT_STARTS_WITH_CI, 'JOE'], + name: `${Comparator.NOT_STARTS_WITH} ci invalid`, + condition: [ + ['user', 'id'], + Comparator.NOT_STARTS_WITH, + 'JOE', + { ci: true }, + ], entities: { user: { id: null } }, result: false, }, - // ENDS_WITH_CI + // ENDS_WITH { ci: true } { - name: `${Comparator.ENDS_WITH_CI} match (different case)`, - condition: [['user', 'id'], Comparator.ENDS_WITH_CI, 'SON'], + name: `${Comparator.ENDS_WITH} ci match (different case)`, + condition: [['user', 'id'], Comparator.ENDS_WITH, 'SON', { ci: true }], entities: { user: { id: 'joewilkinson' } }, result: true, }, { - name: `${Comparator.ENDS_WITH_CI} miss`, - condition: [['user', 'id'], Comparator.ENDS_WITH_CI, 'jim'], + name: `${Comparator.ENDS_WITH} ci miss`, + condition: [['user', 'id'], Comparator.ENDS_WITH, 'jim', { ci: true }], entities: { user: { id: 'joewilkinson' } }, result: false, }, { - name: `${Comparator.ENDS_WITH_CI} unset`, - condition: [['user', 'id'], Comparator.ENDS_WITH_CI, 'SON'], + name: `${Comparator.ENDS_WITH} ci unset`, + condition: [['user', 'id'], Comparator.ENDS_WITH, 'SON', { ci: true }], entities: { user: {} }, result: false, }, { - name: `${Comparator.ENDS_WITH_CI} invalid`, - condition: [['user', 'id'], Comparator.ENDS_WITH_CI, 'SON'], + name: `${Comparator.ENDS_WITH} ci invalid`, + condition: [['user', 'id'], Comparator.ENDS_WITH, 'SON', { ci: true }], entities: { user: { id: null } }, result: false, }, - // NOT_ENDS_WITH_CI + // NOT_ENDS_WITH { ci: true } { - name: `${Comparator.NOT_ENDS_WITH_CI} match`, - condition: [['user', 'id'], Comparator.NOT_ENDS_WITH_CI, 'jim'], + name: `${Comparator.NOT_ENDS_WITH} ci match`, + condition: [ + ['user', 'id'], + Comparator.NOT_ENDS_WITH, + 'jim', + { ci: true }, + ], entities: { user: { id: 'joewilkinson' } }, result: true, }, { - name: `${Comparator.NOT_ENDS_WITH_CI} miss (different case)`, - condition: [['user', 'id'], Comparator.NOT_ENDS_WITH_CI, 'SON'], + name: `${Comparator.NOT_ENDS_WITH} ci miss (different case)`, + condition: [ + ['user', 'id'], + Comparator.NOT_ENDS_WITH, + 'SON', + { ci: true }, + ], entities: { user: { id: 'joewilkinson' } }, result: false, }, { - name: `${Comparator.NOT_ENDS_WITH_CI} unset`, - condition: [['user', 'id'], Comparator.NOT_ENDS_WITH_CI, 'jim'], + name: `${Comparator.NOT_ENDS_WITH} ci unset`, + condition: [ + ['user', 'id'], + Comparator.NOT_ENDS_WITH, + 'jim', + { ci: true }, + ], entities: { user: {} }, result: false, }, { - name: `${Comparator.NOT_ENDS_WITH_CI} invalid`, - condition: [['user', 'id'], Comparator.NOT_ENDS_WITH_CI, 'jim'], + name: `${Comparator.NOT_ENDS_WITH} ci invalid`, + condition: [ + ['user', 'id'], + Comparator.NOT_ENDS_WITH, + 'jim', + { ci: true }, + ], entities: { user: { id: null } }, result: false, }, - // CONTAINS_CI + // CONTAINS { ci: true } { - name: `${Comparator.CONTAINS_CI} match (different case)`, - condition: [['user', 'id'], Comparator.CONTAINS_CI, 'WILK'], + name: `${Comparator.CONTAINS} ci match (different case)`, + condition: [['user', 'id'], Comparator.CONTAINS, 'WILK', { ci: true }], entities: { user: { id: 'joewilkinson' } }, result: true, }, { - name: `${Comparator.CONTAINS_CI} miss`, - condition: [['user', 'id'], Comparator.CONTAINS_CI, 'smith'], + name: `${Comparator.CONTAINS} ci miss`, + condition: [['user', 'id'], Comparator.CONTAINS, 'smith', { ci: true }], entities: { user: { id: 'joewilkinson' } }, result: false, }, { - name: `${Comparator.CONTAINS_CI} unset`, - condition: [['user', 'id'], Comparator.CONTAINS_CI, 'WILK'], + name: `${Comparator.CONTAINS} ci unset`, + condition: [['user', 'id'], Comparator.CONTAINS, 'WILK', { ci: true }], entities: { user: {} }, result: false, }, { - name: `${Comparator.CONTAINS_CI} invalid`, - condition: [['user', 'id'], Comparator.CONTAINS_CI, 'WILK'], + name: `${Comparator.CONTAINS} ci invalid`, + condition: [['user', 'id'], Comparator.CONTAINS, 'WILK', { ci: true }], entities: { user: { id: null } }, result: false, }, - // NOT_CONTAINS_CI + // NOT_CONTAINS { ci: true } { - name: `${Comparator.NOT_CONTAINS_CI} match`, - condition: [['user', 'id'], Comparator.NOT_CONTAINS_CI, 'smith'], + name: `${Comparator.NOT_CONTAINS} ci match`, + condition: [ + ['user', 'id'], + Comparator.NOT_CONTAINS, + 'smith', + { ci: true }, + ], entities: { user: { id: 'joewilkinson' } }, result: true, }, { - name: `${Comparator.NOT_CONTAINS_CI} miss (different case)`, - condition: [['user', 'id'], Comparator.NOT_CONTAINS_CI, 'WILK'], + name: `${Comparator.NOT_CONTAINS} ci miss (different case)`, + condition: [ + ['user', 'id'], + Comparator.NOT_CONTAINS, + 'WILK', + { ci: true }, + ], entities: { user: { id: 'joewilkinson' } }, result: false, }, { - name: `${Comparator.NOT_CONTAINS_CI} unset`, - condition: [['user', 'id'], Comparator.NOT_CONTAINS_CI, 'smith'], + name: `${Comparator.NOT_CONTAINS} ci unset`, + condition: [ + ['user', 'id'], + Comparator.NOT_CONTAINS, + 'smith', + { ci: true }, + ], entities: { user: {} }, result: false, }, { - name: `${Comparator.NOT_CONTAINS_CI} invalid`, - condition: [['user', 'id'], Comparator.NOT_CONTAINS_CI, 'smith'], + name: `${Comparator.NOT_CONTAINS} ci invalid`, + condition: [ + ['user', 'id'], + Comparator.NOT_CONTAINS, + 'smith', + { ci: true }, + ], entities: { user: { id: null } }, result: false, }, diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index e0e0f7cd..795b7c63 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -56,7 +56,7 @@ function isArray(input: unknown): input is unknown[] { } function lower(input: unknown): unknown { - return isString(input) ? input.toLowerCase() : input; + return typeof input === 'string' ? input.toLowerCase() : input; } function matchTargetList( @@ -131,7 +131,8 @@ function matchConditions( params: EvaluationParams, ): boolean { return conditions.every((condition) => { - const [lhsAccessor, cmpKey, rhs] = condition; + const [lhsAccessor, cmpKey, rhs, options] = condition; + const ci = options && 'ci' in options && options.ci === true; if (lhsAccessor === Packed.AccessorType.SEGMENT) { return rhs && matchSegmentCondition(cmpKey, rhs, params); @@ -141,71 +142,116 @@ function matchConditions( try { switch (cmpKey) { case Comparator.EQ: - return lhs === rhs; + return ci ? lower(lhs) === lower(rhs) : lhs === rhs; case Comparator.NOT_EQ: - return lhs !== rhs; + return ci ? lower(lhs) !== lower(rhs) : lhs !== rhs; case Comparator.ONE_OF: - return isArray(rhs) && rhs.includes(lhs); + if (!isArray(rhs)) return false; + return ci ? rhs.map(lower).includes(lower(lhs)) : rhs.includes(lhs); case Comparator.NOT_ONE_OF: - // lhs would be undefined when the value was not provided, in which - // case we should not match the rule - return ( - isArray(rhs) && typeof lhs !== 'undefined' && !rhs.includes(lhs) - ); + if (!isArray(rhs) || typeof lhs === 'undefined') return false; + return ci ? !rhs.map(lower).includes(lower(lhs)) : !rhs.includes(lhs); case Comparator.CONTAINS_ALL_OF: { if (!Array.isArray(rhs) || !Array.isArray(lhs)) return false; - const lhsSet = new Set(lhs.filter(isString)); + const lhsSet = new Set( + ci + ? lhs.filter(isString).map((s) => s.toLowerCase()) + : lhs.filter(isString), + ); - // try to use a set if the lhs is a list of strings - O(1) - // otherwise we need to iterate over the values - O(n) if (lhsSet.size === lhs.length) { - return rhs.filter(isString).every((item) => lhsSet.has(item)); + return rhs + .filter(isString) + .every((item) => lhsSet.has(ci ? item.toLowerCase() : item)); } - // this shouldn't happen since we only allow string[] on the lhs - return rhs.every((item) => lhs.includes(item)); + return ci + ? rhs.every((item) => + lhs.some( + (l) => + isString(l) && + isString(item) && + l.toLowerCase() === item.toLowerCase(), + ), + ) + : rhs.every((item) => lhs.includes(item)); } case Comparator.CONTAINS_ANY_OF: { if (!Array.isArray(rhs) || !Array.isArray(lhs)) return false; + if (ci) { + const rhsSet = new Set( + rhs.filter(isString).map((s) => s.toLowerCase()), + ); + return lhs + .filter(isString) + .some((item) => rhsSet.has(item.toLowerCase())); + } + const rhsSet = new Set(rhs.filter(isString)); return lhs.some( rhsSet.size === rhs.length - ? // try to use a set if the rhs is a list of strings - O(1) - (item) => rhsSet.has(item) - : // otherwise we need to iterate over the values - O(n) - (item) => rhs.includes(item), + ? (item) => rhsSet.has(item) + : (item) => rhs.includes(item), ); } case Comparator.CONTAINS_NONE_OF: { - // if the rhs is not an array something went wrong and we should not match if (!Array.isArray(rhs)) return false; - - // if it's not an array it doesn't contain any of the values if (!Array.isArray(lhs)) return true; + if (ci) { + const rhsSet = new Set( + rhs.filter(isString).map((s) => s.toLowerCase()), + ); + return lhs + .filter(isString) + .every((item) => !rhsSet.has(item.toLowerCase())); + } + const rhsSet = new Set(rhs.filter(isString)); return lhs.every( rhsSet.size === rhs.length - ? // try to use a set if the rhs is a list of strings - O(1) - (item) => !rhsSet.has(item) - : // otherwise we need to iterate over the values - O(n) - (item) => !rhs.includes(item), + ? (item) => !rhsSet.has(item) + : (item) => !rhs.includes(item), ); } case Comparator.STARTS_WITH: - return isString(lhs) && isString(rhs) && lhs.startsWith(rhs); + return ci + ? isString(lhs) && + isString(rhs) && + lhs.toLowerCase().startsWith(rhs.toLowerCase()) + : isString(lhs) && isString(rhs) && lhs.startsWith(rhs); case Comparator.NOT_STARTS_WITH: - return isString(lhs) && isString(rhs) && !lhs.startsWith(rhs); + return ci + ? isString(lhs) && + isString(rhs) && + !lhs.toLowerCase().startsWith(rhs.toLowerCase()) + : isString(lhs) && isString(rhs) && !lhs.startsWith(rhs); case Comparator.ENDS_WITH: - return isString(lhs) && isString(rhs) && lhs.endsWith(rhs); + return ci + ? isString(lhs) && + isString(rhs) && + lhs.toLowerCase().endsWith(rhs.toLowerCase()) + : isString(lhs) && isString(rhs) && lhs.endsWith(rhs); case Comparator.NOT_ENDS_WITH: - return isString(lhs) && isString(rhs) && !lhs.endsWith(rhs); + return ci + ? isString(lhs) && + isString(rhs) && + !lhs.toLowerCase().endsWith(rhs.toLowerCase()) + : isString(lhs) && isString(rhs) && !lhs.endsWith(rhs); case Comparator.CONTAINS: - return isString(lhs) && isString(rhs) && lhs.includes(rhs); + return ci + ? isString(lhs) && + isString(rhs) && + lhs.toLowerCase().includes(rhs.toLowerCase()) + : isString(lhs) && isString(rhs) && lhs.includes(rhs); case Comparator.NOT_CONTAINS: - return isString(lhs) && isString(rhs) && !lhs.includes(rhs); + return ci + ? isString(lhs) && + isString(rhs) && + !lhs.toLowerCase().includes(rhs.toLowerCase()) + : isString(lhs) && isString(rhs) && !lhs.includes(rhs); case Comparator.EXISTS: return lhs !== undefined && lhs !== null; case Comparator.NOT_EXISTS: @@ -260,85 +306,6 @@ function matchConditions( const b = new Date(rhs); return a.getTime() > b.getTime(); } - - // ---- Case-insensitive variants ---- - - case Comparator.EQ_CI: - return lower(lhs) === lower(rhs); - case Comparator.NOT_EQ_CI: - return lower(lhs) !== lower(rhs); - case Comparator.ONE_OF_CI: - return isArray(rhs) && rhs.map(lower).includes(lower(lhs)); - case Comparator.NOT_ONE_OF_CI: - return ( - isArray(rhs) && - typeof lhs !== 'undefined' && - !rhs.map(lower).includes(lower(lhs)) - ); - case Comparator.CONTAINS_ALL_OF_CI: { - if (!Array.isArray(rhs) || !Array.isArray(lhs)) return false; - const lhsSet = new Set( - lhs.filter(isString).map((s) => s.toLowerCase()), - ); - return rhs - .filter(isString) - .every((item) => lhsSet.has(item.toLowerCase())); - } - case Comparator.CONTAINS_ANY_OF_CI: { - if (!Array.isArray(rhs) || !Array.isArray(lhs)) return false; - const rhsSet = new Set( - rhs.filter(isString).map((s) => s.toLowerCase()), - ); - return lhs - .filter(isString) - .some((item) => rhsSet.has(item.toLowerCase())); - } - case Comparator.CONTAINS_NONE_OF_CI: { - if (!Array.isArray(rhs)) return false; - if (!Array.isArray(lhs)) return true; - const rhsSet = new Set( - rhs.filter(isString).map((s) => s.toLowerCase()), - ); - return lhs - .filter(isString) - .every((item) => !rhsSet.has(item.toLowerCase())); - } - case Comparator.STARTS_WITH_CI: - return ( - isString(lhs) && - isString(rhs) && - lhs.toLowerCase().startsWith(rhs.toLowerCase()) - ); - case Comparator.NOT_STARTS_WITH_CI: - return ( - isString(lhs) && - isString(rhs) && - !lhs.toLowerCase().startsWith(rhs.toLowerCase()) - ); - case Comparator.ENDS_WITH_CI: - return ( - isString(lhs) && - isString(rhs) && - lhs.toLowerCase().endsWith(rhs.toLowerCase()) - ); - case Comparator.NOT_ENDS_WITH_CI: - return ( - isString(lhs) && - isString(rhs) && - !lhs.toLowerCase().endsWith(rhs.toLowerCase()) - ); - case Comparator.CONTAINS_CI: - return ( - isString(lhs) && - isString(rhs) && - lhs.toLowerCase().includes(rhs.toLowerCase()) - ); - case Comparator.NOT_CONTAINS_CI: - return ( - isString(lhs) && - isString(rhs) && - !lhs.toLowerCase().includes(rhs.toLowerCase()) - ); default: { const _x: never = cmpKey; // exhaustive check return false; diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index a0158715..dec2600c 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -425,87 +425,6 @@ export enum Comparator { * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format */ AFTER = 'after', - - // ---- Case-insensitive variants ---- - - /** - * lhs must be string | number - * rhs must be string | number - * case-insensitive equality check - */ - EQ_CI = 'eq_ci', - /** - * lhs must be string | number - * rhs must be string | number - * case-insensitive inequality check - */ - NOT_EQ_CI = '!eq_ci', - /** - * lhs must be string - * rhs must be string[] - * case-insensitive one of check - */ - ONE_OF_CI = 'oneOf_ci', - /** - * lhs must be string - * rhs must be string[] - * case-insensitive not one of check - */ - NOT_ONE_OF_CI = '!oneOf_ci', - /** - * lhs must be string[] - * rhs must be string[] - * case-insensitive contains all of check - */ - CONTAINS_ALL_OF_CI = 'containsAllOf_ci', - /** - * lhs must be string[] - * rhs must be string[] - * case-insensitive contains any of check - */ - CONTAINS_ANY_OF_CI = 'containsAnyOf_ci', - /** - * lhs must be string[] - * rhs must be string[] - * case-insensitive contains none of check - */ - CONTAINS_NONE_OF_CI = 'containsNoneOf_ci', - /** - * lhs must be string - * rhs must be string - * case-insensitive prefix check - */ - STARTS_WITH_CI = 'startsWith_ci', - /** - * lhs must be string - * rhs must be string - * case-insensitive negated prefix check - */ - NOT_STARTS_WITH_CI = '!startsWith_ci', - /** - * lhs must be string - * rhs must be string - * case-insensitive suffix check - */ - ENDS_WITH_CI = 'endsWith_ci', - /** - * lhs must be string - * rhs must be string - * case-insensitive negated suffix check - */ - NOT_ENDS_WITH_CI = '!endsWith_ci', - /** - * lhs must be string - * rhs must be string - * case-insensitive substring check - */ - CONTAINS_CI = 'contains_ci', - /** - * lhs must be string - * rhs must be string - * case-insensitive negated substring check - */ - NOT_CONTAINS_CI = '!contains_ci', } // ----------------------------------------------------------------------------- @@ -774,8 +693,14 @@ export namespace Packed { | (string | number)[] | { type: 'regex'; pattern: string; flags: string }; + export type ConditionOptions = { + /** When true, string comparisons are case-insensitive. */ + ci?: boolean; + }; + export type Condition = | [LHS, Comparator, RHS] + | [LHS, Comparator, RHS, ConditionOptions] | [LHS, Comparator.EXISTS] | [LHS, Comparator.NOT_EXISTS]; From 1ebce2cfa96e02bf8766bf1f816380f8c4bbb982 Mon Sep 17 00:00:00 2001 From: Vincent Derks Date: Thu, 26 Feb 2026 11:28:57 +0100 Subject: [PATCH 3/5] Cleanup --- .../vercel-flags-core/src/evaluate.test.ts | 8 +- packages/vercel-flags-core/src/evaluate.ts | 162 ++++++------------ 2 files changed, 63 insertions(+), 107 deletions(-) diff --git a/packages/vercel-flags-core/src/evaluate.test.ts b/packages/vercel-flags-core/src/evaluate.test.ts index 4be1d2dd..b29d3fc4 100644 --- a/packages/vercel-flags-core/src/evaluate.test.ts +++ b/packages/vercel-flags-core/src/evaluate.test.ts @@ -1368,7 +1368,7 @@ describe('evaluate', () => { result: false, }, - // ---- Case-insensitive (ci option) ---- + // ---- Case-insensitive (4th element: { ci: true }) ---- // EQ { ci: true } { @@ -1395,6 +1395,12 @@ describe('evaluate', () => { entities: {}, result: false, }, + { + name: `${Comparator.EQ} ci with number (no effect)`, + condition: [['user', 'age'], Comparator.EQ, 42, { ci: true }], + entities: { user: { age: 42 } }, + result: true, + }, // NOT_EQ { ci: true } { diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 795b7c63..08577680 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -8,10 +8,10 @@ import { ResolutionReason, } from './types'; -type PathArray = (string | number)[]; - const MAX_REGEX_INPUT_LENGTH = 10_000; +type PathArray = (string | number)[]; + function exhaustivenessCheck(_: never): never { throw new Error('Exhaustiveness check failed'); } @@ -56,7 +56,9 @@ function isArray(input: unknown): input is unknown[] { } function lower(input: unknown): unknown { - return typeof input === 'string' ? input.toLowerCase() : input; + if (typeof input === 'string') return input.toLowerCase(); + if (Array.isArray(input)) return input.map(lower); + return input; } function matchTargetList( @@ -131,64 +133,45 @@ function matchConditions( params: EvaluationParams, ): boolean { return conditions.every((condition) => { - const [lhsAccessor, cmpKey, rhs, options] = condition; - const ci = options && 'ci' in options && options.ci === true; + const [lhsAccessor, cmpKey, rawRhs, options] = condition; + const ci = options !== undefined && options.ci === true; + // ci is not applicable to segment conditions (segments are internal IDs) if (lhsAccessor === Packed.AccessorType.SEGMENT) { - return rhs && matchSegmentCondition(cmpKey, rhs, params); + return rawRhs && matchSegmentCondition(cmpKey, rawRhs, params); } - const lhs = access(lhsAccessor, params); + const rawLhs = access(lhsAccessor, params); + const lhs = ci ? lower(rawLhs) : rawLhs; + const rhs = ci ? lower(rawRhs) : rawRhs; + try { switch (cmpKey) { case Comparator.EQ: - return ci ? lower(lhs) === lower(rhs) : lhs === rhs; + return lhs === rhs; case Comparator.NOT_EQ: - return ci ? lower(lhs) !== lower(rhs) : lhs !== rhs; + return lhs !== rhs; case Comparator.ONE_OF: - if (!isArray(rhs)) return false; - return ci ? rhs.map(lower).includes(lower(lhs)) : rhs.includes(lhs); + return isArray(rhs) && rhs.includes(lhs); case Comparator.NOT_ONE_OF: + // lhs is undefined when the entity value was not provided, in which + // case we should not match the rule if (!isArray(rhs) || typeof lhs === 'undefined') return false; - return ci ? !rhs.map(lower).includes(lower(lhs)) : !rhs.includes(lhs); + return !rhs.includes(lhs); case Comparator.CONTAINS_ALL_OF: { if (!Array.isArray(rhs) || !Array.isArray(lhs)) return false; - const lhsSet = new Set( - ci - ? lhs.filter(isString).map((s) => s.toLowerCase()) - : lhs.filter(isString), - ); + const lhsSet = new Set(lhs.filter(isString)); if (lhsSet.size === lhs.length) { - return rhs - .filter(isString) - .every((item) => lhsSet.has(ci ? item.toLowerCase() : item)); + return rhs.filter(isString).every((item) => lhsSet.has(item)); } - return ci - ? rhs.every((item) => - lhs.some( - (l) => - isString(l) && - isString(item) && - l.toLowerCase() === item.toLowerCase(), - ), - ) - : rhs.every((item) => lhs.includes(item)); + return rhs.every((item) => lhs.includes(item)); } case Comparator.CONTAINS_ANY_OF: { if (!Array.isArray(rhs) || !Array.isArray(lhs)) return false; - if (ci) { - const rhsSet = new Set( - rhs.filter(isString).map((s) => s.toLowerCase()), - ); - return lhs - .filter(isString) - .some((item) => rhsSet.has(item.toLowerCase())); - } - const rhsSet = new Set(rhs.filter(isString)); return lhs.some( rhsSet.size === rhs.length @@ -200,15 +183,6 @@ function matchConditions( if (!Array.isArray(rhs)) return false; if (!Array.isArray(lhs)) return true; - if (ci) { - const rhsSet = new Set( - rhs.filter(isString).map((s) => s.toLowerCase()), - ); - return lhs - .filter(isString) - .every((item) => !rhsSet.has(item.toLowerCase())); - } - const rhsSet = new Set(rhs.filter(isString)); return lhs.every( rhsSet.size === rhs.length @@ -217,93 +191,69 @@ function matchConditions( ); } case Comparator.STARTS_WITH: - return ci - ? isString(lhs) && - isString(rhs) && - lhs.toLowerCase().startsWith(rhs.toLowerCase()) - : isString(lhs) && isString(rhs) && lhs.startsWith(rhs); + return isString(lhs) && isString(rhs) && lhs.startsWith(rhs); case Comparator.NOT_STARTS_WITH: - return ci - ? isString(lhs) && - isString(rhs) && - !lhs.toLowerCase().startsWith(rhs.toLowerCase()) - : isString(lhs) && isString(rhs) && !lhs.startsWith(rhs); + return isString(lhs) && isString(rhs) && !lhs.startsWith(rhs); case Comparator.ENDS_WITH: - return ci - ? isString(lhs) && - isString(rhs) && - lhs.toLowerCase().endsWith(rhs.toLowerCase()) - : isString(lhs) && isString(rhs) && lhs.endsWith(rhs); + return isString(lhs) && isString(rhs) && lhs.endsWith(rhs); case Comparator.NOT_ENDS_WITH: - return ci - ? isString(lhs) && - isString(rhs) && - !lhs.toLowerCase().endsWith(rhs.toLowerCase()) - : isString(lhs) && isString(rhs) && !lhs.endsWith(rhs); + return isString(lhs) && isString(rhs) && !lhs.endsWith(rhs); case Comparator.CONTAINS: - return ci - ? isString(lhs) && - isString(rhs) && - lhs.toLowerCase().includes(rhs.toLowerCase()) - : isString(lhs) && isString(rhs) && lhs.includes(rhs); + return isString(lhs) && isString(rhs) && lhs.includes(rhs); case Comparator.NOT_CONTAINS: - return ci - ? isString(lhs) && - isString(rhs) && - !lhs.toLowerCase().includes(rhs.toLowerCase()) - : isString(lhs) && isString(rhs) && !lhs.includes(rhs); + return isString(lhs) && isString(rhs) && !lhs.includes(rhs); case Comparator.EXISTS: - return lhs !== undefined && lhs !== null; + return rawLhs !== undefined && rawLhs !== null; case Comparator.NOT_EXISTS: - return lhs === undefined || lhs === null; + return rawLhs === undefined || rawLhs === null; case Comparator.GT: // NaN will return false for any comparisons - if (lhs === null || lhs === undefined) return false; - return (isNumber(rhs) || isString(rhs)) && lhs > rhs; + if (rawLhs === null || rawLhs === undefined) return false; + return (isNumber(rawRhs) || isString(rawRhs)) && rawLhs > rawRhs; case Comparator.GTE: - if (lhs === null || lhs === undefined) return false; - return (isNumber(rhs) || isString(rhs)) && lhs >= rhs; + if (rawLhs === null || rawLhs === undefined) return false; + return (isNumber(rawRhs) || isString(rawRhs)) && rawLhs >= rawRhs; case Comparator.LT: - if (lhs === null || lhs === undefined) return false; - return (isNumber(rhs) || isString(rhs)) && lhs < rhs; + if (rawLhs === null || rawLhs === undefined) return false; + return (isNumber(rawRhs) || isString(rawRhs)) && rawLhs < rawRhs; case Comparator.LTE: - if (lhs === null || lhs === undefined) return false; - return (isNumber(rhs) || isString(rhs)) && lhs <= rhs; + if (rawLhs === null || rawLhs === undefined) return false; + return (isNumber(rawRhs) || isString(rawRhs)) && rawLhs <= rawRhs; case Comparator.REGEX: if ( - isString(lhs) && - lhs.length <= MAX_REGEX_INPUT_LENGTH && - typeof rhs === 'object' && - !Array.isArray(rhs) && - rhs?.type === 'regex' + isString(rawLhs) && + rawLhs.length <= MAX_REGEX_INPUT_LENGTH && + typeof rawRhs === 'object' && + !Array.isArray(rawRhs) && + rawRhs?.type === 'regex' ) { - return new RegExp(rhs.pattern, rhs.flags).test(lhs); + return new RegExp(rawRhs.pattern, rawRhs.flags).test(rawLhs); } return false; case Comparator.NOT_REGEX: if ( - isString(lhs) && - lhs.length <= MAX_REGEX_INPUT_LENGTH && - typeof rhs === 'object' && - !Array.isArray(rhs) && - rhs?.type === 'regex' + isString(rawLhs) && + rawLhs.length <= MAX_REGEX_INPUT_LENGTH && + typeof rawRhs === 'object' && + !Array.isArray(rawRhs) && + rawRhs?.type === 'regex' ) { - return !new RegExp(rhs.pattern, rhs.flags).test(lhs); + return !new RegExp(rawRhs.pattern, rawRhs.flags).test(rawLhs); } return false; case Comparator.BEFORE: { - if (!isString(lhs) || !isString(rhs)) return false; - const a = new Date(lhs); - const b = new Date(rhs); + if (!isString(rawLhs) || !isString(rawRhs)) return false; + const a = new Date(rawLhs); + const b = new Date(rawRhs); // if any date fails to parse getTime will return NaN, which will cause // comparisons to fail. return a.getTime() < b.getTime(); } case Comparator.AFTER: { - if (!isString(lhs) || !isString(rhs)) return false; - const a = new Date(lhs); - const b = new Date(rhs); + if (!isString(rawLhs) || !isString(rawRhs)) return false; + const a = new Date(rawLhs); + const b = new Date(rawRhs); return a.getTime() > b.getTime(); } default: { From 55a35cf74d56a47222505930d2241badf33e92e5 Mon Sep 17 00:00:00 2001 From: Vincent Derks Date: Thu, 26 Feb 2026 11:51:34 +0100 Subject: [PATCH 4/5] More cleanup --- .../vercel-flags-core/src/evaluate.test.ts | 88 +++++++++++++++++++ packages/vercel-flags-core/src/evaluate.ts | 29 ++++-- packages/vercel-flags-core/src/types.ts | 2 + 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/packages/vercel-flags-core/src/evaluate.test.ts b/packages/vercel-flags-core/src/evaluate.test.ts index b29d3fc4..99e0035f 100644 --- a/packages/vercel-flags-core/src/evaluate.test.ts +++ b/packages/vercel-flags-core/src/evaluate.test.ts @@ -1813,6 +1813,94 @@ describe('evaluate', () => { entities: { user: { id: null } }, result: false, }, + + // ---- ci flag is ignored for non-string comparators ---- + + { + name: `${Comparator.EXISTS} ci ignored (still matches)`, + condition: [['user', 'id'], Comparator.EXISTS, undefined, { ci: true }], + entities: { user: { id: 'uid1' } }, + result: true, + }, + { + name: `${Comparator.NOT_EXISTS} ci ignored (still matches)`, + condition: [ + ['user', 'id'], + Comparator.NOT_EXISTS, + undefined, + { ci: true }, + ], + entities: { user: {} }, + result: true, + }, + { + name: `${Comparator.GT} ci ignored (numeric comparison unchanged)`, + condition: [['user', 'age'], Comparator.GT, 20, { ci: true }], + entities: { user: { age: 30 } }, + result: true, + }, + { + name: `${Comparator.LT} ci ignored (numeric comparison unchanged)`, + condition: [['user', 'age'], Comparator.LT, 40, { ci: true }], + entities: { user: { age: 30 } }, + result: true, + }, + { + name: `${Comparator.GTE} ci ignored (numeric comparison unchanged)`, + condition: [['user', 'age'], Comparator.GTE, 30, { ci: true }], + entities: { user: { age: 30 } }, + result: true, + }, + { + name: `${Comparator.LTE} ci ignored (numeric comparison unchanged)`, + condition: [['user', 'age'], Comparator.LTE, 30, { ci: true }], + entities: { user: { age: 30 } }, + result: true, + }, + { + name: `${Comparator.REGEX} ci ignored (uses raw values)`, + condition: [ + ['user', 'id'], + Comparator.REGEX, + { type: 'regex', pattern: '^joe', flags: '' }, + { ci: true }, + ], + entities: { user: { id: 'joewilkinson' } }, + result: true, + }, + { + name: `${Comparator.NOT_REGEX} ci ignored (uses raw values)`, + condition: [ + ['user', 'id'], + Comparator.NOT_REGEX, + { type: 'regex', pattern: '^joe', flags: '' }, + { ci: true }, + ], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + name: `${Comparator.BEFORE} ci ignored (date comparison unchanged)`, + condition: [ + ['user', 'createdAt'], + Comparator.BEFORE, + '2025-01-02', + { ci: true }, + ], + entities: { user: { createdAt: '2025-01-01' } }, + result: true, + }, + { + name: `${Comparator.AFTER} ci ignored (date comparison unchanged)`, + condition: [ + ['user', 'createdAt'], + Comparator.AFTER, + '2024-12-31', + { ci: true }, + ], + entities: { user: { createdAt: '2025-01-01' } }, + result: true, + }, ])('should evaluate comparator $name', ({ condition, entities, result }) => { expect( evaluate({ diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 08577680..977fd715 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -8,10 +8,10 @@ import { ResolutionReason, } from './types'; -const MAX_REGEX_INPUT_LENGTH = 10_000; - type PathArray = (string | number)[]; +const MAX_REGEX_INPUT_LENGTH = 10_000; + function exhaustivenessCheck(_: never): never { throw new Error('Exhaustiveness check failed'); } @@ -154,19 +154,23 @@ function matchConditions( case Comparator.ONE_OF: return isArray(rhs) && rhs.includes(lhs); case Comparator.NOT_ONE_OF: - // lhs is undefined when the entity value was not provided, in which + // lhs would be undefined when the value was not provided, in which // case we should not match the rule - if (!isArray(rhs) || typeof lhs === 'undefined') return false; - return !rhs.includes(lhs); + return ( + isArray(rhs) && typeof lhs !== 'undefined' && !rhs.includes(lhs) + ); case Comparator.CONTAINS_ALL_OF: { if (!Array.isArray(rhs) || !Array.isArray(lhs)) return false; const lhsSet = new Set(lhs.filter(isString)); + // try to use a set if the lhs is a list of strings - O(1) + // otherwise we need to iterate over the values - O(n) if (lhsSet.size === lhs.length) { return rhs.filter(isString).every((item) => lhsSet.has(item)); } + // this shouldn't happen since we only allow string[] on the lhs return rhs.every((item) => lhs.includes(item)); } case Comparator.CONTAINS_ANY_OF: { @@ -175,19 +179,26 @@ function matchConditions( const rhsSet = new Set(rhs.filter(isString)); return lhs.some( rhsSet.size === rhs.length - ? (item) => rhsSet.has(item) - : (item) => rhs.includes(item), + ? // try to use a set if the rhs is a list of strings - O(1) + (item) => rhsSet.has(item) + : // otherwise we need to iterate over the values - O(n) + (item) => rhs.includes(item), ); } case Comparator.CONTAINS_NONE_OF: { + // if the rhs is not an array something went wrong and we should not match if (!Array.isArray(rhs)) return false; + + // if it's not an array it doesn't contain any of the values if (!Array.isArray(lhs)) return true; const rhsSet = new Set(rhs.filter(isString)); return lhs.every( rhsSet.size === rhs.length - ? (item) => !rhsSet.has(item) - : (item) => !rhs.includes(item), + ? // try to use a set if the rhs is a list of strings - O(1) + (item) => !rhsSet.has(item) + : // otherwise we need to iterate over the values - O(n) + (item) => !rhs.includes(item), ); } case Comparator.STARTS_WITH: diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index dec2600c..81594912 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -512,6 +512,8 @@ export namespace Original { lhs: LHS; cmp: Comparator; rhs: RHS; + /** When true, string comparisons are case-insensitive. */ + ci?: boolean; }; export type Rule = { From 627af2b2b7543415ec62215f892bd8ece980c5e3 Mon Sep 17 00:00:00 2001 From: Vincent Derks Date: Thu, 26 Feb 2026 15:41:19 +0100 Subject: [PATCH 5/5] Some more cleanup & finetuning --- packages/vercel-flags-core/src/evaluate.ts | 88 +++++++++++++--------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 977fd715..09de4037 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -55,12 +55,28 @@ function isArray(input: unknown): input is unknown[] { return Array.isArray(input); } -function lower(input: unknown): unknown { - if (typeof input === 'string') return input.toLowerCase(); - if (Array.isArray(input)) return input.map(lower); +function lower(input: T): T { + if (typeof input === 'string') return input.toLowerCase() as T; + if (Array.isArray(input)) return input.map(lower) as T; return input; } +const CI_COMPARATORS: ReadonlySet = new Set([ + Comparator.EQ, + Comparator.NOT_EQ, + Comparator.ONE_OF, + Comparator.NOT_ONE_OF, + Comparator.CONTAINS_ALL_OF, + Comparator.CONTAINS_ANY_OF, + Comparator.CONTAINS_NONE_OF, + Comparator.STARTS_WITH, + Comparator.NOT_STARTS_WITH, + Comparator.ENDS_WITH, + Comparator.NOT_ENDS_WITH, + Comparator.CONTAINS, + Comparator.NOT_CONTAINS, +]); + function matchTargetList( targets: Packed.TargetList, params: EvaluationParams, @@ -134,15 +150,19 @@ function matchConditions( ): boolean { return conditions.every((condition) => { const [lhsAccessor, cmpKey, rawRhs, options] = condition; - const ci = options !== undefined && options.ci === true; + const ci = + options !== undefined && + options.ci === true && + CI_COMPARATORS.has(cmpKey); // ci is not applicable to segment conditions (segments are internal IDs) if (lhsAccessor === Packed.AccessorType.SEGMENT) { return rawRhs && matchSegmentCondition(cmpKey, rawRhs, params); } - const rawLhs = access(lhsAccessor, params); - const lhs = ci ? lower(rawLhs) : rawLhs; + const lhs = ci + ? lower(access(lhsAccessor, params)) + : access(lhsAccessor, params); const rhs = ci ? lower(rawRhs) : rawRhs; try { @@ -214,57 +234,57 @@ function matchConditions( case Comparator.NOT_CONTAINS: return isString(lhs) && isString(rhs) && !lhs.includes(rhs); case Comparator.EXISTS: - return rawLhs !== undefined && rawLhs !== null; + return lhs !== undefined && lhs !== null; case Comparator.NOT_EXISTS: - return rawLhs === undefined || rawLhs === null; + return lhs === undefined || lhs === null; case Comparator.GT: // NaN will return false for any comparisons - if (rawLhs === null || rawLhs === undefined) return false; - return (isNumber(rawRhs) || isString(rawRhs)) && rawLhs > rawRhs; + if (lhs === null || lhs === undefined) return false; + return (isNumber(rhs) || isString(rhs)) && lhs > rhs; case Comparator.GTE: - if (rawLhs === null || rawLhs === undefined) return false; - return (isNumber(rawRhs) || isString(rawRhs)) && rawLhs >= rawRhs; + if (lhs === null || lhs === undefined) return false; + return (isNumber(rhs) || isString(rhs)) && lhs >= rhs; case Comparator.LT: - if (rawLhs === null || rawLhs === undefined) return false; - return (isNumber(rawRhs) || isString(rawRhs)) && rawLhs < rawRhs; + if (lhs === null || lhs === undefined) return false; + return (isNumber(rhs) || isString(rhs)) && lhs < rhs; case Comparator.LTE: - if (rawLhs === null || rawLhs === undefined) return false; - return (isNumber(rawRhs) || isString(rawRhs)) && rawLhs <= rawRhs; + if (lhs === null || lhs === undefined) return false; + return (isNumber(rhs) || isString(rhs)) && lhs <= rhs; case Comparator.REGEX: if ( - isString(rawLhs) && - rawLhs.length <= MAX_REGEX_INPUT_LENGTH && - typeof rawRhs === 'object' && - !Array.isArray(rawRhs) && - rawRhs?.type === 'regex' + isString(lhs) && + lhs.length <= MAX_REGEX_INPUT_LENGTH && + typeof rhs === 'object' && + !Array.isArray(rhs) && + rhs?.type === 'regex' ) { - return new RegExp(rawRhs.pattern, rawRhs.flags).test(rawLhs); + return new RegExp(rhs.pattern, rhs.flags).test(lhs); } return false; case Comparator.NOT_REGEX: if ( - isString(rawLhs) && - rawLhs.length <= MAX_REGEX_INPUT_LENGTH && - typeof rawRhs === 'object' && - !Array.isArray(rawRhs) && - rawRhs?.type === 'regex' + isString(lhs) && + lhs.length <= MAX_REGEX_INPUT_LENGTH && + typeof rhs === 'object' && + !Array.isArray(rhs) && + rhs?.type === 'regex' ) { - return !new RegExp(rawRhs.pattern, rawRhs.flags).test(rawLhs); + return !new RegExp(rhs.pattern, rhs.flags).test(lhs); } return false; case Comparator.BEFORE: { - if (!isString(rawLhs) || !isString(rawRhs)) return false; - const a = new Date(rawLhs); - const b = new Date(rawRhs); + if (!isString(lhs) || !isString(rhs)) return false; + const a = new Date(lhs); + const b = new Date(rhs); // if any date fails to parse getTime will return NaN, which will cause // comparisons to fail. return a.getTime() < b.getTime(); } case Comparator.AFTER: { - if (!isString(rawLhs) || !isString(rawRhs)) return false; - const a = new Date(rawLhs); - const b = new Date(rawRhs); + if (!isString(lhs) || !isString(rhs)) return false; + const a = new Date(lhs); + const b = new Date(rhs); return a.getTime() > b.getTime(); } default: {