diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index 43af6be0a96c..9fa153c93b06 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -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, diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 729789d04f51..8900579b01e8 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -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); } }); diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index 755942e065de..39be78c0a044 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -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' }, + ], + }); +}); diff --git a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts index 8221f27fe435..abc6dbdcfc71 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -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; diff --git a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts index eb0ceed7d667..20fc81730ad3 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts @@ -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; diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 0ac16905be90..9115c45f7976 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -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: {