Skip to content

Commit

Permalink
feat: operators for segments (#5485)
Browse files Browse the repository at this point in the history
1. Added way to filter segments
2. Refactored some code, so tags and segments use same SQL methods.
  • Loading branch information
sjaanus committed Nov 29, 2023
1 parent 75aecfc commit 5fd1c16
Show file tree
Hide file tree
Showing 6 changed files with 232 additions and 74 deletions.
6 changes: 4 additions & 2 deletions src/lib/features/feature-search/feature-search-controller.ts
Expand Up @@ -73,9 +73,10 @@ export default class FeatureSearchController extends Controller {
if (this.config.flagResolver.isEnabled('featureSearchAPI')) {
const {
query,
projectId,
project,
type,
tag,
segment,
status,
offset,
limit = '50',
Expand Down Expand Up @@ -104,10 +105,11 @@ export default class FeatureSearchController extends Controller {
const normalizedFavoritesFirst = favoritesFirst === 'true';
const { features, total } = await this.featureSearchService.search({
searchParams: normalizedQuery,
projectId,
project,
type,
userId,
tag,
segment,
status: normalizedStatus,
offset: normalizedOffset,
limit: normalizedLimit,
Expand Down
13 changes: 3 additions & 10 deletions src/lib/features/feature-search/feature-search-service.ts
Expand Up @@ -60,17 +60,10 @@ export class FeatureSearchService {
convertToQueryParams = (params: IFeatureSearchParams): IQueryParam[] => {
const queryParams: IQueryParam[] = [];

if (params.projectId) {
const parsed = this.parseOperatorValue('project', params.projectId);
if (parsed) queryParams.push(parsed);
}

['tag'].forEach((field) => {
['tag', 'segment', 'project'].forEach((field) => {
if (params[field]) {
params[field].forEach((value) => {
const parsed = this.parseOperatorValue(field, value);
if (parsed) queryParams.push(parsed);
});
const parsed = this.parseOperatorValue(field, params[field]);
if (parsed) queryParams.push(parsed);
}
});

Expand Down
187 changes: 147 additions & 40 deletions src/lib/features/feature-search/feature.search.e2e.test.ts
Expand Up @@ -34,6 +34,20 @@ beforeAll(async () => {
email: 'user@getunleash.io',
})
.expect(200);

await stores.environmentStore.create({
name: 'development',
type: 'development',
});

await app.linkProjectToEnvironment('default', 'development');

await stores.environmentStore.create({
name: 'production',
type: 'production',
});

await app.linkProjectToEnvironment('default', 'production');
});

afterAll(async () => {
Expand All @@ -43,45 +57,46 @@ afterAll(async () => {

beforeEach(async () => {
await db.stores.featureToggleStore.deleteAll();
await db.stores.segmentStore.deleteAll();
});

const searchFeatures = async (
{ query = '', projectId = 'IS:default' }: FeatureSearchQueryParameters,
{ query = '', project = 'IS:default' }: FeatureSearchQueryParameters,
expectedCode = 200,
) => {
return app.request
.get(`/api/admin/search/features?query=${query}&projectId=${projectId}`)
.get(`/api/admin/search/features?query=${query}&project=${project}`)
.expect(expectedCode);
};

const sortFeatures = async (
{
sortBy = '',
sortOrder = '',
projectId = 'default',
project = 'default',
favoritesFirst = 'false',
}: FeatureSearchQueryParameters,
expectedCode = 200,
) => {
return app.request
.get(
`/api/admin/search/features?sortBy=${sortBy}&sortOrder=${sortOrder}&projectId=IS:${projectId}&favoritesFirst=${favoritesFirst}`,
`/api/admin/search/features?sortBy=${sortBy}&sortOrder=${sortOrder}&project=IS:${project}&favoritesFirst=${favoritesFirst}`,
)
.expect(expectedCode);
};

const searchFeaturesWithOffset = async (
{
query = '',
projectId = 'default',
project = 'default',
offset = '0',
limit = '10',
}: FeatureSearchQueryParameters,
expectedCode = 200,
) => {
return app.request
.get(
`/api/admin/search/features?query=${query}&projectId=IS:${projectId}&offset=${offset}&limit=${limit}`,
`/api/admin/search/features?query=${query}&project=IS:${project}&offset=${offset}&limit=${limit}`,
)
.expect(expectedCode);
};
Expand All @@ -93,10 +108,15 @@ const filterFeaturesByType = async (types: string[], expectedCode = 200) => {
.expect(expectedCode);
};

const filterFeaturesByTag = async (tags: string[], expectedCode = 200) => {
const tagParams = tags.map((tag) => `tag[]=${tag}`).join('&');
const filterFeaturesByTag = async (tag: string, expectedCode = 200) => {
return app.request
.get(`/api/admin/search/features?${tagParams}`)
.get(`/api/admin/search/features?tag=${tag}`)
.expect(expectedCode);
};

const filterFeaturesBySegment = async (segment: string, expectedCode = 200) => {
return app.request
.get(`/api/admin/search/features?segment=${segment}`)
.expect(expectedCode);
};

Expand Down Expand Up @@ -202,31 +222,31 @@ test('should filter features by tag', async () => {
value: 'my_tag',
});

const { body } = await filterFeaturesByTag(['INCLUDE:simple:my_tag']);
const { body } = await filterFeaturesByTag('INCLUDE:simple:my_tag');

expect(body).toMatchObject({
features: [{ name: 'my_feature_a' }, { name: 'my_feature_d' }],
});

const { body: notIncludeBody } = await filterFeaturesByTag([
const { body: notIncludeBody } = await filterFeaturesByTag(
'DO_NOT_INCLUDE:simple:my_tag',
]);
);

expect(notIncludeBody).toMatchObject({
features: [{ name: 'my_feature_b' }, { name: 'my_feature_c' }],
});

const { body: includeAllOf } = await filterFeaturesByTag([
const { body: includeAllOf } = await filterFeaturesByTag(
'INCLUDE_ALL_OF:simple:my_tag, simple:tag_c',
]);
);

expect(includeAllOf).toMatchObject({
features: [{ name: 'my_feature_d' }],
});

const { body: includeAnyOf } = await filterFeaturesByTag([
const { body: includeAnyOf } = await filterFeaturesByTag(
'INCLUDE_ANY_OF:simple:my_tag, simple:tag_c',
]);
);

expect(includeAnyOf).toMatchObject({
features: [
Expand All @@ -236,17 +256,17 @@ test('should filter features by tag', async () => {
],
});

const { body: excludeIfAnyOf } = await filterFeaturesByTag([
const { body: excludeIfAnyOf } = await filterFeaturesByTag(
'EXCLUDE_IF_ANY_OF:simple:my_tag, simple:tag_c',
]);
);

expect(excludeIfAnyOf).toMatchObject({
features: [{ name: 'my_feature_b' }],
});

const { body: excludeAll } = await filterFeaturesByTag([
const { body: excludeAll } = await filterFeaturesByTag(
'EXCLUDE_ALL:simple:my_tag, simple:tag_c',
]);
);

expect(excludeAll).toMatchObject({
features: [
Expand Down Expand Up @@ -316,7 +336,7 @@ test('should not search features from another project', async () => {

const { body } = await searchFeatures({
query: '',
projectId: 'IS:another_project',
project: 'IS:another_project',
});

expect(body).toMatchObject({ features: [] });
Expand Down Expand Up @@ -468,13 +488,6 @@ test('should not return duplicate entries when sorting by last seen', async () =
await app.createFeature('my_feature_a');
await app.createFeature('my_feature_b');
await app.createFeature('my_feature_c');

await stores.environmentStore.create({
name: 'production',
type: 'development',
});

await app.linkProjectToEnvironment('default', 'production');
await app.enableFeature('my_feature_a', 'production');
await app.enableFeature('my_feature_b', 'production');

Expand Down Expand Up @@ -586,28 +599,28 @@ test('should search features by project with operators', async () => {
});

const { body } = await searchFeatures({
projectId: 'IS:default',
project: 'IS:default',
});
expect(body).toMatchObject({
features: [{ name: 'my_feature_a' }],
});

const { body: isNotBody } = await searchFeatures({
projectId: 'IS_NOT:default',
project: 'IS_NOT:default',
});
expect(isNotBody).toMatchObject({
features: [{ name: 'my_feature_b' }, { name: 'my_feature_c' }],
});

const { body: isAnyOfBody } = await searchFeatures({
projectId: 'IS_ANY_OF:default,project_c',
project: 'IS_ANY_OF:default,project_c',
});
expect(isAnyOfBody).toMatchObject({
features: [{ name: 'my_feature_a' }, { name: 'my_feature_c' }],
});

const { body: isNotAnyBody } = await searchFeatures({
projectId: 'IS_NOT_ANY_OF:default,project_c',
project: 'IS_NOT_ANY_OF:default,project_c',
});
expect(isNotAnyBody).toMatchObject({
features: [{ name: 'my_feature_b' }],
Expand All @@ -620,14 +633,6 @@ test('should return segments in payload with no duplicates/nulls', async () => {
name: 'my_segment_a',
constraints: [],
});

await stores.environmentStore.create({
name: 'development',
type: 'development',
});

await app.linkProjectToEnvironment('default', 'development');
await app.enableFeature('my_feature_a', 'development');
await app.addStrategyToFeatureEnv(
{
name: 'default',
Expand All @@ -636,6 +641,7 @@ test('should return segments in payload with no duplicates/nulls', async () => {
DEFAULT_ENV,
'my_feature_a',
);
await app.enableFeature('my_feature_a', 'development');

const { body } = await searchFeatures({});

Expand All @@ -648,3 +654,104 @@ test('should return segments in payload with no duplicates/nulls', async () => {
],
});
});

test('should filter features by segment', async () => {
await app.createFeature('my_feature_a');
const { body: mySegmentA } = await app.createSegment({
name: 'my_segment_a',
constraints: [],
});
await app.addStrategyToFeatureEnv(
{
name: 'default',
segments: [mySegmentA.id],
},
DEFAULT_ENV,
'my_feature_a',
);
await app.createFeature('my_feature_b');
await app.createFeature('my_feature_c');
const { body: mySegmentC } = await app.createSegment({
name: 'my_segment_c',
constraints: [],
});
await app.addStrategyToFeatureEnv(
{
name: 'default',
segments: [mySegmentC.id],
},
DEFAULT_ENV,
'my_feature_c',
);
await app.createFeature('my_feature_d');
await app.addStrategyToFeatureEnv(
{
name: 'default',
segments: [mySegmentC.id],
},
DEFAULT_ENV,
'my_feature_d',
);
await app.addStrategyToFeatureEnv(
{
name: 'default',
segments: [mySegmentA.id],
},
DEFAULT_ENV,
'my_feature_d',
);

const { body } = await filterFeaturesBySegment('INCLUDE:my_segment_a');

expect(body).toMatchObject({
features: [{ name: 'my_feature_a' }, { name: 'my_feature_d' }],
});

const { body: notIncludeBody } = await filterFeaturesBySegment(
'DO_NOT_INCLUDE:my_segment_a',
);

expect(notIncludeBody).toMatchObject({
features: [{ name: 'my_feature_b' }, { name: 'my_feature_c' }],
});

const { body: includeAllOf } = await filterFeaturesBySegment(
'INCLUDE_ALL_OF:my_segment_a, my_segment_c',
);

expect(includeAllOf).toMatchObject({
features: [{ name: 'my_feature_d' }],
});

const { body: includeAnyOf } = await filterFeaturesBySegment(
'INCLUDE_ANY_OF:my_segment_a, my_segment_c',
);

expect(includeAnyOf).toMatchObject({
features: [
{ name: 'my_feature_a' },
{ name: 'my_feature_c' },
{ name: 'my_feature_d' },
],
});

const { body: excludeIfAnyOf } = await filterFeaturesBySegment(
'EXCLUDE_IF_ANY_OF:my_segment_a, my_segment_c',
);

expect(excludeIfAnyOf).toMatchObject({
features: [{ name: 'my_feature_b' }],
});

const { body: excludeAll } = await filterFeaturesBySegment(
'EXCLUDE_ALL:my_segment_a, my_segment_c',
);

expect(excludeAll).toMatchObject({
features: [
{ name: 'my_feature_a' },
{ name: 'my_feature_b' },
{ name: 'my_feature_c' },
],
});
});

0 comments on commit 5fd1c16

Please sign in to comment.