Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/evaluator/matchers/__tests__/dependency.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import { IMatcher, IMatcherDto } from '../../types';
import { IStorageSync } from '../../../storages/types';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
import { ISplit } from '../../../dtos/types';

const ALWAYS_ON_SPLIT = { 'trafficTypeName': 'user', 'name': 'always-on', 'trafficAllocation': 100, 'trafficAllocationSeed': 1012950810, 'seed': -725161385, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'off', 'changeNumber': 1494364996459, 'algo': 2, 'conditions': [{ 'conditionType': 'ROLLOUT', 'matcherGroup': { 'combiner': 'AND', 'matchers': [{ 'keySelector': { 'trafficType': 'user', 'attribute': null }, 'matcherType': 'ALL_KEYS', 'negate': false, 'userDefinedSegmentMatcherData': null, 'whitelistMatcherData': null, 'unaryNumericMatcherData': null, 'betweenMatcherData': null }] }, 'partitions': [{ 'treatment': 'on', 'size': 100 }, { 'treatment': 'off', 'size': 0 }], 'label': 'in segment all' }], 'sets':[] } as ISplit;
const ALWAYS_OFF_SPLIT = { 'trafficTypeName': 'user', 'name': 'always-off', 'trafficAllocation': 100, 'trafficAllocationSeed': -331690370, 'seed': 403891040, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'on', 'changeNumber': 1494365020316, 'algo': 2, 'conditions': [{ 'conditionType': 'ROLLOUT', 'matcherGroup': { 'combiner': 'AND', 'matchers': [{ 'keySelector': { 'trafficType': 'user', 'attribute': null }, 'matcherType': 'ALL_KEYS', 'negate': false, 'userDefinedSegmentMatcherData': null, 'whitelistMatcherData': null, 'unaryNumericMatcherData': null, 'betweenMatcherData': null }] }, 'partitions': [{ 'treatment': 'on', 'size': 0 }, { 'treatment': 'off', 'size': 100 }], 'label': 'in segment all' }], 'sets':[] } as ISplit;
import { ALWAYS_ON_SPLIT, ALWAYS_OFF_SPLIT } from '../../../storages/__tests__/testUtils';

const STORED_SPLITS: Record<string, ISplit> = {
'always-on': ALWAYS_ON_SPLIT,
Expand Down
243 changes: 243 additions & 0 deletions src/evaluator/matchers/__tests__/rbsegment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { matcherTypes } from '../matcherTypes';
import { matcherFactory } from '..';
import { evaluateFeature } from '../../index';
import { IMatcherDto } from '../../types';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
import { IRBSegment, ISplit } from '../../../dtos/types';
import { IStorageAsync, IStorageSync } from '../../../storages/types';
import { thenable } from '../../../utils/promise/thenable';
import { ALWAYS_ON_SPLIT } from '../../../storages/__tests__/testUtils';

const STORED_SPLITS: Record<string, ISplit> = {
'always-on': ALWAYS_ON_SPLIT
};

const STORED_SEGMENTS: Record<string, Set<string>> = {
'segment_test': new Set(['emi@split.io']),
'regular_segment': new Set(['nadia@split.io'])
};

const STORED_RBSEGMENTS: Record<string, IRBSegment> = {
'mauro_rule_based_segment': {
changeNumber: 5,
name: 'mauro_rule_based_segment',
status: 'ACTIVE',
excluded: {
keys: ['mauro@split.io', 'gaston@split.io'],
segments: ['segment_test']
},
conditions: [
{
matcherGroup: {
combiner: 'AND',
matchers: [
{
keySelector: {
trafficType: 'user',
attribute: 'location',
},
matcherType: 'WHITELIST',
negate: false,
whitelistMatcherData: {
whitelist: [
'mdp',
'tandil',
'bsas'
]
}
},
{
keySelector: {
trafficType: 'user',
attribute: null
},
matcherType: 'ENDS_WITH',
negate: false,
whitelistMatcherData: {
whitelist: [
'@split.io'
]
}
}
]
}
},
{
matcherGroup: {
combiner: 'AND',
matchers: [
{
keySelector: {
trafficType: 'user',
attribute: null
},
matcherType: 'IN_SEGMENT',
negate: false,
userDefinedSegmentMatcherData: {
segmentName: 'regular_segment'
}
}
]
}
}
]
},
'depend_on_always_on': {
name: 'depend_on_always_on',
changeNumber: 123,
status: 'ACTIVE',
excluded: {
keys: [],
segments: []
},
conditions: [{
matcherGroup: {
combiner: 'AND',
matchers: [{
matcherType: 'IN_SPLIT_TREATMENT',
keySelector: {
trafficType: 'user',
attribute: null
},
negate: false,
dependencyMatcherData: {
split: 'always-on',
treatments: [
'on',
]
}
}]
}
}]
},
'depend_on_mauro_rule_based_segment': {
name: 'depend_on_mauro_rule_based_segment',
changeNumber: 123,
status: 'ACTIVE',
excluded: {
keys: [],
segments: []
},
conditions: [{
matcherGroup: {
combiner: 'AND',
matchers: [{
matcherType: 'IN_RULE_BASED_SEGMENT',
keySelector: {
trafficType: 'user',
attribute: null
},
negate: false,
userDefinedSegmentMatcherData: {
segmentName: 'mauro_rule_based_segment'
}
}]
}
}]
},
};

const mockStorageSync = {
isSync: true,
splits: {
getSplit(name: string) {
return STORED_SPLITS[name];
}
},
segments: {
isInSegment(segmentName: string, matchingKey: string) {
return STORED_SEGMENTS[segmentName] ? STORED_SEGMENTS[segmentName].has(matchingKey) : false;
}
},
rbSegments: {
get(rbsegmentName: string) {
return STORED_RBSEGMENTS[rbsegmentName];
}
}
} as unknown as IStorageSync;

const mockStorageAsync = {
isSync: false,
splits: {
getSplit(name: string) {
return Promise.resolve(STORED_SPLITS[name]);
}
},
segments: {
isInSegment(segmentName: string, matchingKey: string) {
return Promise.resolve(STORED_SEGMENTS[segmentName] ? STORED_SEGMENTS[segmentName].has(matchingKey) : false);
}
},
rbSegments: {
get(rbsegmentName: string) {
return Promise.resolve(STORED_RBSEGMENTS[rbsegmentName]);
}
}
} as unknown as IStorageAsync;

describe.each([
{ mockStorage: mockStorageSync, isAsync: false },
{ mockStorage: mockStorageAsync, isAsync: true }
])('MATCHER IN_RULE_BASED_SEGMENT', ({ mockStorage, isAsync }) => {
test('should support excluded keys, excluded segments, and multiple conditions', async () => {
const matcher = matcherFactory(loggerMock, {
type: matcherTypes.IN_RULE_BASED_SEGMENT,
value: 'mauro_rule_based_segment'
} as IMatcherDto, mockStorage)!;

const dependentMatcher = matcherFactory(loggerMock, {
type: matcherTypes.IN_RULE_BASED_SEGMENT,
value: 'depend_on_mauro_rule_based_segment'
} as IMatcherDto, mockStorage)!;

[matcher, dependentMatcher].forEach(async matcher => {

// should return false if the provided key is excluded (even if some condition is met)
let match = matcher({ key: 'mauro@split.io', attributes: { location: 'mdp' } }, evaluateFeature);
expect(thenable(match)).toBe(isAsync);
expect(await match).toBe(false);

// should return false if the provided key is in some excluded segment (even if some condition is met)
match = matcher({ key: 'emi@split.io', attributes: { location: 'tandil' } }, evaluateFeature);
expect(thenable(match)).toBe(isAsync);
expect(await match).toBe(false);

// should return false if doesn't match any condition
match = matcher({ key: 'zeta@split.io' }, evaluateFeature);
expect(thenable(match)).toBe(isAsync);
expect(await match).toBe(false);
match = matcher({ key: { matchingKey: 'zeta@split.io', bucketingKey: '123' }, attributes: { location: 'italy' } }, evaluateFeature);
expect(thenable(match)).toBe(isAsync);
expect(await match).toBe(false);

// should return true if match the first condition: location attribute in whitelist and key ends with '@split.io'
match = matcher({ key: 'emma@split.io', attributes: { location: 'tandil' } }, evaluateFeature);
expect(thenable(match)).toBe(isAsync);
expect(await match).toBe(true);

// should return true if match the second condition: key in regular_segment
match = matcher({ key: { matchingKey: 'nadia@split.io', bucketingKey: '123' }, attributes: { location: 'mdp' } }, evaluateFeature);
expect(thenable(match)).toBe(isAsync);
expect(await match).toBe(true);
});
});

test('edge cases', async () => {
const matcherNotExist = matcherFactory(loggerMock, {
type: matcherTypes.IN_RULE_BASED_SEGMENT,
value: 'non_existent_segment'
} as IMatcherDto, mockStorageSync)!;

// should return false if the provided segment does not exist
expect(await matcherNotExist({ key: 'a-key' }, evaluateFeature)).toBe(false);

const matcherTrueAlwaysOn = matcherFactory(loggerMock, {
type: matcherTypes.IN_RULE_BASED_SEGMENT,
value: 'depend_on_always_on'
} as IMatcherDto, mockStorageSync)!;

// should support feature flag dependency matcher
expect(await matcherTrueAlwaysOn({ key: 'a-key' }, evaluateFeature)).toBe(true); // Parent split returns one of the expected treatments, so the matcher returns true
});

});
2 changes: 2 additions & 0 deletions src/evaluator/matchers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { inListSemverMatcherContext } from './semver_inlist';
import { IStorageAsync, IStorageSync } from '../../storages/types';
import { IMatcher, IMatcherDto } from '../types';
import { ILogger } from '../../logger/types';
import { ruleBasedSegmentMatcherContext } from './rbsegment';

const matchers = [
undefined, // UNDEFINED: 0
Expand All @@ -50,6 +51,7 @@ const matchers = [
betweenSemverMatcherContext, // BETWEEN_SEMVER: 21
inListSemverMatcherContext, // IN_LIST_SEMVER: 22
largeSegmentMatcherContext, // IN_LARGE_SEGMENT: 23
ruleBasedSegmentMatcherContext // IN_RULE_BASED_SEGMENT: 24
];

/**
Expand Down
1 change: 1 addition & 0 deletions src/evaluator/matchers/matcherTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const matcherTypes: Record<string, number> = {
BETWEEN_SEMVER: 21,
IN_LIST_SEMVER: 22,
IN_LARGE_SEGMENT: 23,
IN_RULE_BASED_SEGMENT: 24,
};

export const matcherDataTypes = {
Expand Down
61 changes: 61 additions & 0 deletions src/evaluator/matchers/rbsegment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { IRBSegment, MaybeThenable } from '../../dtos/types';
import { IStorageAsync, IStorageSync } from '../../storages/types';
import { ILogger } from '../../logger/types';
import { IDependencyMatcherValue, ISplitEvaluator } from '../types';
import { thenable } from '../../utils/promise/thenable';
import { getMatching, keyParser } from '../../utils/key';
import { parser } from '../parser';


export function ruleBasedSegmentMatcherContext(segmentName: string, storage: IStorageSync | IStorageAsync, log: ILogger) {

return function ruleBasedSegmentMatcher({ key, attributes }: IDependencyMatcherValue, splitEvaluator: ISplitEvaluator): MaybeThenable<boolean> {

function matchConditions(rbsegment: IRBSegment) {
const conditions = rbsegment.conditions;
const evaluator = parser(log, conditions, storage);

const evaluation = evaluator(
keyParser(key),
undefined,
undefined,
undefined,
attributes,
splitEvaluator
);

return thenable(evaluation) ?
evaluation.then(evaluation => evaluation ? true : false) :
evaluation ? true : false;
}

function isExcluded(rbSegment: IRBSegment) {
const matchingKey = getMatching(key);

if (rbSegment.excluded.keys.indexOf(matchingKey) !== -1) return true;

const isInSegment = rbSegment.excluded.segments.map(segmentName => {
return storage.segments.isInSegment(segmentName, matchingKey);
});

return isInSegment.length && thenable(isInSegment[0]) ?
Promise.all(isInSegment).then(results => results.some(result => result)) :
isInSegment.some(result => result);
}

function isInSegment(rbSegment: IRBSegment | null) {
if (!rbSegment) return false;
const excluded = isExcluded(rbSegment);

return thenable(excluded) ?
excluded.then(excluded => excluded ? false : matchConditions(rbSegment)) :
excluded ? false : matchConditions(rbSegment);
}

const rbSegment = storage.rbSegments.get(segmentName);

return thenable(rbSegment) ?
rbSegment.then(isInSegment) :
isInSegment(rbSegment);
};
}
3 changes: 3 additions & 0 deletions src/evaluator/matchersTransform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export function matchersTransform(matchers: ISplitMatcher[]): IMatcherDto[] {
type === matcherTypes.LESS_THAN_OR_EQUAL_TO_SEMVER
) {
value = stringMatcherData;
} else if (type === matcherTypes.IN_RULE_BASED_SEGMENT) {
value = segmentTransform(userDefinedSegmentMatcherData as IInSegmentMatcherData);
dataType = matcherDataTypes.NOT_SPECIFIED;
}

return {
Expand Down
1 change: 1 addition & 0 deletions src/evaluator/value/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function getProcessingFunction(matcherTypeID: number, dataType: string) {
case matcherTypes.BETWEEN:
return dataType === 'DATETIME' ? zeroSinceSS : undefined;
case matcherTypes.IN_SPLIT_TREATMENT:
case matcherTypes.IN_RULE_BASED_SEGMENT:
return dependencyProcessor;
default:
return undefined;
Expand Down
7 changes: 5 additions & 2 deletions src/storages/AbstractSplitsCacheSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export abstract class AbstractSplitsCacheSync implements ISplitsCacheSync {
* Given a parsed split, it returns a boolean flagging if its conditions use segments matchers (rules & whitelists).
* This util is intended to simplify the implementation of `splitsCache::usesSegments` method
*/
export function usesSegments(split: ISplit | IRBSegment) {
const conditions = split.conditions || [];
export function usesSegments(ruleEntity: ISplit | IRBSegment) {
const conditions = ruleEntity.conditions || [];
for (let i = 0; i < conditions.length; i++) {
const matchers = conditions[i].matcherGroup.matchers;

Expand All @@ -91,5 +91,8 @@ export function usesSegments(split: ISplit | IRBSegment) {
}
}

const excluded = (ruleEntity as IRBSegment).excluded;
if (excluded && excluded.segments && excluded.segments.length > 0) return true;

return false;
}
4 changes: 2 additions & 2 deletions src/storages/KeyBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export class KeyBuilder {
return `${this.prefix}.split.`;
}

buildRBSegmentKey(splitName: string) {
return `${this.prefix}.rbsegment.${splitName}`;
buildRBSegmentKey(rbsegmentName: string) {
return `${this.prefix}.rbsegment.${rbsegmentName}`;
}

buildRBSegmentsTillKey() {
Expand Down
Loading