mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-12 01:17:04 +02:00
feat: operators for segments (#5485)
1. Added way to filter segments 2. Refactored some code, so tags and segments use same SQL methods.
This commit is contained in:
parent
75aecfca07
commit
5fd1c16def
@ -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',
|
||||
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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 () => {
|
||||
@ -43,14 +57,15 @@ 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);
|
||||
};
|
||||
|
||||
@ -58,14 +73,14 @@ 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);
|
||||
};
|
||||
@ -73,7 +88,7 @@ const sortFeatures = async (
|
||||
const searchFeaturesWithOffset = async (
|
||||
{
|
||||
query = '',
|
||||
projectId = 'default',
|
||||
project = 'default',
|
||||
offset = '0',
|
||||
limit = '10',
|
||||
}: FeatureSearchQueryParameters,
|
||||
@ -81,7 +96,7 @@ const searchFeaturesWithOffset = async (
|
||||
) => {
|
||||
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);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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: [
|
||||
@ -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: [
|
||||
@ -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: [] });
|
||||
@ -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');
|
||||
|
||||
@ -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' }],
|
||||
@ -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',
|
||||
@ -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({});
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
@ -747,7 +747,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
)
|
||||
.joinRaw('CROSS JOIN total_features')
|
||||
.whereBetween('final_rank', [offset + 1, offset + limit]);
|
||||
|
||||
console.log(finalQuery.toQuery());
|
||||
const rows = await finalQuery;
|
||||
|
||||
if (rows.length > 0) {
|
||||
@ -1112,11 +1112,26 @@ const applyQueryParams = (
|
||||
queryParams: IQueryParam[],
|
||||
): void => {
|
||||
const tagConditions = queryParams.filter((param) => param.field === 'tag');
|
||||
const segmentConditions = queryParams.filter(
|
||||
(param) => param.field === 'segment',
|
||||
);
|
||||
const genericConditions = queryParams.filter(
|
||||
(param) => param.field !== 'tag',
|
||||
);
|
||||
applyTagQueryParams(query, tagConditions);
|
||||
applyGenericQueryParams(query, genericConditions);
|
||||
|
||||
applyMultiQueryParams(
|
||||
query,
|
||||
tagConditions,
|
||||
['tag_type', 'tag_value'],
|
||||
createTagBaseQuery,
|
||||
);
|
||||
applyMultiQueryParams(
|
||||
query,
|
||||
segmentConditions,
|
||||
'segments.name',
|
||||
createSegmentBaseQuery,
|
||||
);
|
||||
};
|
||||
|
||||
const applyGenericQueryParams = (
|
||||
@ -1137,41 +1152,54 @@ const applyGenericQueryParams = (
|
||||
});
|
||||
};
|
||||
|
||||
const applyTagQueryParams = (
|
||||
const applyMultiQueryParams = (
|
||||
query: Knex.QueryBuilder,
|
||||
queryParams: IQueryParam[],
|
||||
fields: string | string[],
|
||||
createBaseQuery: (
|
||||
values: string[] | string[][],
|
||||
) => (dbSubQuery: Knex.QueryBuilder) => Knex.QueryBuilder,
|
||||
): void => {
|
||||
queryParams.forEach((param) => {
|
||||
const tags = param.values.map((val) =>
|
||||
val.split(':').map((s) => s.trim()),
|
||||
const values = param.values.map((val) =>
|
||||
(Array.isArray(fields) ? val.split(':') : [val]).map((s) =>
|
||||
s.trim(),
|
||||
),
|
||||
);
|
||||
|
||||
const baseTagSubQuery = createTagBaseQuery(tags);
|
||||
const baseSubQuery = createBaseQuery(values);
|
||||
|
||||
switch (param.operator) {
|
||||
case 'INCLUDE':
|
||||
case 'INCLUDE_ANY_OF':
|
||||
query.whereIn(['tag_type', 'tag_value'], tags);
|
||||
if (Array.isArray(fields)) {
|
||||
query.whereIn(fields, values);
|
||||
} else {
|
||||
query.whereIn(
|
||||
fields,
|
||||
values.map((v) => v[0]),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DO_NOT_INCLUDE':
|
||||
case 'EXCLUDE_IF_ANY_OF':
|
||||
query.whereNotIn('features.name', baseTagSubQuery);
|
||||
query.whereNotIn('features.name', baseSubQuery);
|
||||
break;
|
||||
|
||||
case 'INCLUDE_ALL_OF':
|
||||
query.whereIn('features.name', (dbSubQuery) => {
|
||||
baseTagSubQuery(dbSubQuery)
|
||||
baseSubQuery(dbSubQuery)
|
||||
.groupBy('feature_name')
|
||||
.havingRaw('COUNT(*) = ?', [tags.length]);
|
||||
.havingRaw('COUNT(*) = ?', [values.length]);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'EXCLUDE_ALL':
|
||||
query.whereNotIn('features.name', (dbSubQuery) => {
|
||||
baseTagSubQuery(dbSubQuery)
|
||||
baseSubQuery(dbSubQuery)
|
||||
.groupBy('feature_name')
|
||||
.havingRaw('COUNT(*) = ?', [tags.length]);
|
||||
.havingRaw('COUNT(*) = ?', [values.length]);
|
||||
});
|
||||
break;
|
||||
}
|
||||
@ -1187,5 +1215,24 @@ const createTagBaseQuery = (tags: string[][]) => {
|
||||
};
|
||||
};
|
||||
|
||||
const createSegmentBaseQuery = (segments: string[]) => {
|
||||
return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => {
|
||||
return dbSubQuery
|
||||
.from('feature_strategies')
|
||||
.leftJoin(
|
||||
'feature_strategy_segment',
|
||||
'feature_strategy_segment.feature_strategy_id',
|
||||
'feature_strategies.id',
|
||||
)
|
||||
.leftJoin(
|
||||
'segments',
|
||||
'feature_strategy_segment.segment_id',
|
||||
'segments.id',
|
||||
)
|
||||
.select('feature_name')
|
||||
.whereIn('name', segments);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = FeatureStrategiesStore;
|
||||
export default FeatureStrategiesStore;
|
||||
|
@ -24,9 +24,10 @@ export interface FeatureConfigurationClient {
|
||||
export interface IFeatureSearchParams {
|
||||
userId: number;
|
||||
searchParams?: string[];
|
||||
projectId?: string;
|
||||
project?: string;
|
||||
segment?: string;
|
||||
type?: string[];
|
||||
tag?: string[];
|
||||
tag?: string;
|
||||
status?: string[][];
|
||||
offset: number;
|
||||
favoritesFirst?: boolean;
|
||||
|
@ -11,7 +11,7 @@ export const featureSearchQueryParameters = [
|
||||
in: 'query',
|
||||
},
|
||||
{
|
||||
name: 'projectId',
|
||||
name: 'project',
|
||||
schema: {
|
||||
type: 'string',
|
||||
example: 'IS:default',
|
||||
@ -36,18 +36,26 @@ export const featureSearchQueryParameters = [
|
||||
{
|
||||
name: 'tag',
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
pattern:
|
||||
'^(INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL):(.*?)(,([a-zA-Z0-9_]+))*$',
|
||||
example: 'INCLUDE:simple:my_tag',
|
||||
},
|
||||
type: 'string',
|
||||
pattern:
|
||||
'^(INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL):(.*?)(,([a-zA-Z0-9_]+))*$',
|
||||
example: 'INCLUDE:simple:my_tag',
|
||||
},
|
||||
description:
|
||||
'The list of feature tags to filter by. Feature tag has to specify a type and a value joined with a colon.',
|
||||
in: 'query',
|
||||
},
|
||||
{
|
||||
name: 'segment',
|
||||
schema: {
|
||||
type: 'string',
|
||||
pattern:
|
||||
'^(INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL):(.*?)(,([a-zA-Z0-9_]+))*$',
|
||||
example: 'INCLUDE:pro-users',
|
||||
},
|
||||
description: 'The list of segments with operators to filter by.',
|
||||
in: 'query',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
schema: {
|
||||
|
Loading…
Reference in New Issue
Block a user