diff --git a/packages/vercel-flags-core/src/evaluate.test.ts b/packages/vercel-flags-core/src/evaluate.test.ts index d30677ff..99e0035f 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,540 @@ describe('evaluate', () => { entities: { user: {} }, result: false, }, + + // ---- Case-insensitive (4th element: { ci: true }) ---- + + // EQ { ci: true } + { + 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, 'uid1', { ci: true }], + entities: { user: { id: 'uid1' } }, + result: true, + }, + { + 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, 'uid1', { ci: true }], + 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 } + { + 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, 'UID1', { ci: true }], + entities: { user: { id: 'uid1' } }, + result: false, + }, + { + name: `${Comparator.NOT_EQ} ci unset`, + condition: [['user', 'id'], Comparator.NOT_EQ, 'uid1', { ci: true }], + entities: {}, + result: true, + }, + + // ONE_OF { ci: true } + { + 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, ['uid2'], { ci: true }], + entities: { user: { id: 'uid1' } }, + result: false, + }, + { + name: `${Comparator.ONE_OF} ci unset`, + condition: [['user', 'id'], Comparator.ONE_OF, ['uid1'], { ci: true }], + entities: {}, + result: false, + }, + + // NOT_ONE_OF { ci: true } + { + 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, + ['UID1'], + { ci: true }, + ], + entities: { user: { id: 'uid1' } }, + result: false, + }, + { + name: `${Comparator.NOT_ONE_OF} ci unset`, + condition: [ + ['user', 'id'], + Comparator.NOT_ONE_OF, + ['uid2'], + { ci: true }, + ], + entities: {}, + result: false, + }, + + // CONTAINS_ALL_OF { ci: true } + { + name: `${Comparator.CONTAINS_ALL_OF} ci match (different case)`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ALL_OF, + ['TEAM1', 'TEAM2'], + { ci: true }, + ], + entities: { user: { teamIds: ['team2', 'team1'] } }, + result: true, + }, + { + name: `${Comparator.CONTAINS_ALL_OF} ci partial match`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ALL_OF, + ['TEAM1', 'TEAM2'], + { ci: true }, + ], + entities: { user: { teamIds: ['team2'] } }, + result: false, + }, + { + name: `${Comparator.CONTAINS_ALL_OF} ci miss`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ALL_OF, + ['TEAM1'], + { ci: true }, + ], + entities: { user: { teamIds: ['team2'] } }, + result: false, + }, + { + name: `${Comparator.CONTAINS_ALL_OF} ci unset`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ALL_OF, + ['TEAM1'], + { ci: true }, + ], + entities: {}, + result: false, + }, + + // CONTAINS_ANY_OF { ci: true } + { + name: `${Comparator.CONTAINS_ANY_OF} ci match (different case)`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ANY_OF, + ['TEAM1', 'TEAM2'], + { ci: true }, + ], + entities: { user: { teamIds: ['team2'] } }, + result: true, + }, + { + name: `${Comparator.CONTAINS_ANY_OF} ci miss`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ANY_OF, + ['TEAM1', 'TEAM2'], + { ci: true }, + ], + entities: { user: { teamIds: ['team3'] } }, + result: false, + }, + { + name: `${Comparator.CONTAINS_ANY_OF} ci unset`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_ANY_OF, + ['TEAM1'], + { ci: true }, + ], + entities: { user: {} }, + result: false, + }, + + // CONTAINS_NONE_OF { ci: true } + { + name: `${Comparator.CONTAINS_NONE_OF} ci match (different case)`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_NONE_OF, + ['TEAM1'], + { ci: true }, + ], + entities: { user: { teamIds: ['team2'] } }, + result: true, + }, + { + name: `${Comparator.CONTAINS_NONE_OF} ci miss (different case)`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_NONE_OF, + ['TEAM1'], + { ci: true }, + ], + entities: { user: { teamIds: ['team1'] } }, + result: false, + }, + { + name: `${Comparator.CONTAINS_NONE_OF} ci unset entity`, + condition: [ + ['user', 'teamIds'], + Comparator.CONTAINS_NONE_OF, + ['TEAM1'], + { ci: true }, + ], + entities: {}, + result: true, + }, + + // STARTS_WITH { ci: true } + { + 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, 'jim', { ci: true }], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + 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, 'JOE', { ci: true }], + entities: { user: { id: null } }, + result: false, + }, + + // NOT_STARTS_WITH { ci: true } + { + 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, + 'JOE', + { ci: true }, + ], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + 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, + 'JOE', + { ci: true }, + ], + entities: { user: { id: null } }, + result: false, + }, + + // ENDS_WITH { ci: true } + { + 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, 'jim', { ci: true }], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + 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, 'SON', { ci: true }], + entities: { user: { id: null } }, + result: false, + }, + + // NOT_ENDS_WITH { ci: true } + { + 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, + 'SON', + { ci: true }, + ], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + 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, + 'jim', + { ci: true }, + ], + entities: { user: { id: null } }, + result: false, + }, + + // CONTAINS { ci: true } + { + 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, 'smith', { ci: true }], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + 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, 'WILK', { ci: true }], + entities: { user: { id: null } }, + result: false, + }, + + // NOT_CONTAINS { ci: true } + { + 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, + 'WILK', + { ci: true }, + ], + entities: { user: { id: 'joewilkinson' } }, + result: false, + }, + { + 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, + 'smith', + { ci: true }, + ], + 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 91799f70..09de4037 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -55,6 +55,28 @@ function isArray(input: unknown): input is unknown[] { return Array.isArray(input); } +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, @@ -127,13 +149,22 @@ function matchConditions( params: EvaluationParams, ): boolean { return conditions.every((condition) => { - const [lhsAccessor, cmpKey, rhs] = condition; + const [lhsAccessor, cmpKey, rawRhs, options] = condition; + 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 rhs && matchSegmentCondition(cmpKey, rhs, params); + return rawRhs && matchSegmentCondition(cmpKey, rawRhs, params); } - const lhs = access(lhsAccessor, params); + const lhs = ci + ? lower(access(lhsAccessor, params)) + : access(lhsAccessor, params); + const rhs = ci ? lower(rawRhs) : rawRhs; + try { switch (cmpKey) { case Comparator.EQ: @@ -198,6 +229,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: diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index c3fc9220..81594912 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 @@ -498,6 +512,8 @@ export namespace Original { lhs: LHS; cmp: Comparator; rhs: RHS; + /** When true, string comparisons are case-insensitive. */ + ci?: boolean; }; export type Rule = { @@ -679,8 +695,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];