1
0
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:
Jaanus Sellin 2023-11-29 10:40:25 +02:00 committed by GitHub
parent 75aecfca07
commit 5fd1c16def
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 232 additions and 74 deletions

View File

@ -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,

View File

@ -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);
const parsed = this.parseOperatorValue(field, params[field]);
if (parsed) queryParams.push(parsed);
});
}
});

View File

@ -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' },
],
});
});

View File

@ -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;

View File

@ -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;

View File

@ -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',
},
},
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: {