From 065e588e649083c9e90b6a89d78a875670f56fc7 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 26 Oct 2023 12:50:02 +0200 Subject: [PATCH] Search by tag (#5156) add tag search for feature search API --- .../feature-search-controller.ts | 6 +- .../feature-search/feature-search-service.ts | 4 +- .../feature-search/feature.search.e2e.test.ts | 45 ++++++- .../fakes/fake-feature-strategies-store.ts | 8 ++ .../feature-toggle-strategies-store.ts | 121 ++++++++++++++++++ .../feature-toggle-strategies-store-type.ts | 5 + src/test/e2e/helpers/test-helper.ts | 18 +++ 7 files changed, 202 insertions(+), 5 deletions(-) diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index fdfa5427f3..01d678c90e 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -71,7 +71,11 @@ export default class FeatureSearchController extends Controller { res: Response, ): Promise { if (this.config.flagResolver.isEnabled('featureSearchAPI')) { - const features = await this.featureSearchService.search(req.query); + const { query = '', projectId = '' } = req.query; + const features = await this.featureSearchService.search({ + query, + projectId, + }); res.json({ features }); } else { throw new InvalidOperationError( diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 70aa393012..749efdb1fb 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -20,9 +20,9 @@ export class FeatureSearchService { } async search(params: FeatureSearchQueryParameters) { - const features = await this.featureStrategiesStore.getFeatureOverview({ + const features = await this.featureStrategiesStore.searchFeatures({ projectId: params.projectId, - namePrefix: params.query, + queryString: params.query, }); return features; 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 271ee943ec..3d7b3d30c1 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -43,18 +43,48 @@ const searchFeatures = async ( .expect(expectedCode); }; -test('should return matching features', async () => { +const searchFeaturesWithoutQueryParams = async (expectedCode = 200) => { + return app.request.get(`/api/admin/search/features`).expect(expectedCode); +}; + +test('should return matching features by name', async () => { await app.createFeature('my_feature_a'); await app.createFeature('my_feature_b'); await app.createFeature('my_feat_c'); - const { body } = await searchFeatures({ query: 'my_feature' }); + const { body } = await searchFeatures({ query: 'feature' }); expect(body).toMatchObject({ features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }], }); }); +test('should return matching features by tag', async () => { + await app.createFeature('my_feature_a'); + await app.createFeature('my_feature_b'); + await app.addTag('my_feature_a', { type: 'simple', value: 'my_tag' }); + + const { body: fullMatch } = await searchFeatures({ + query: 'simple:my_tag', + }); + const { body: tagTypeMatch } = await searchFeatures({ query: 'simple' }); + const { body: tagValueMatch } = await searchFeatures({ query: 'my_tag' }); + const { body: partialTagMatch } = await searchFeatures({ query: 'e:m' }); + + expect(fullMatch).toMatchObject({ + features: [{ name: 'my_feature_a' }], + }); + expect(tagTypeMatch).toMatchObject({ + features: [{ name: 'my_feature_a' }], + }); + expect(tagValueMatch).toMatchObject({ + features: [{ name: 'my_feature_a' }], + }); + expect(partialTagMatch).toMatchObject({ + features: [{ name: 'my_feature_a' }], + }); +}); + test('should return empty features', async () => { const { body } = await searchFeatures({ query: '' }); expect(body).toMatchObject({ features: [] }); @@ -71,3 +101,14 @@ test('should not return features from another project', async () => { expect(body).toMatchObject({ features: [] }); }); + +test('should return features without query', async () => { + await app.createFeature('my_feature_a'); + await app.createFeature('my_feature_b'); + + const { body } = await searchFeaturesWithoutQueryParams(); + + expect(body).toMatchObject({ + features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }], + }); +}); diff --git a/src/lib/features/feature-toggle/fakes/fake-feature-strategies-store.ts b/src/lib/features/feature-toggle/fakes/fake-feature-strategies-store.ts index 688a84f1c2..5de74711a7 100644 --- a/src/lib/features/feature-toggle/fakes/fake-feature-strategies-store.ts +++ b/src/lib/features/feature-toggle/fakes/fake-feature-strategies-store.ts @@ -322,6 +322,14 @@ export default class FakeFeatureStrategiesStore return Promise.resolve([]); } + searchFeatures(params: { + projectId: string; + userId?: number | undefined; + queryString: string; + }): Promise { + return Promise.resolve([]); + } + getAllByFeatures( features: string[], environment?: string, 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 ab5e3fcc2a..ca88b8c759 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -514,6 +514,126 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { }; } + // WIP copy of getFeatureOverview to get the search PoC working + async searchFeatures({ + projectId, + userId, + queryString, + }: { projectId: string; userId?: number; queryString: string }): Promise< + IFeatureOverview[] + > { + let query = this.db('features'); + if (projectId) { + query = query.where({ project: projectId }); + } + + if (queryString?.trim()) { + // todo: we can run cheaper query when no colon is detected + const tagQuery = this.db + .from('feature_tag') + .select('feature_name') + .whereRaw("(?? || ':' || ??) LIKE ?", [ + 'tag_type', + 'tag_value', + `%${queryString}%`, + ]); + + query = query + .whereILike('features.name', `%${queryString}%`) + .orWhereIn('features.name', tagQuery); + } + query = query + .modify(FeatureToggleStore.filterByArchived, false) + .leftJoin( + 'feature_environments', + 'feature_environments.feature_name', + 'features.name', + ) + .leftJoin( + 'environments', + 'feature_environments.environment', + 'environments.name', + ) + .leftJoin('feature_tag as ft', 'ft.feature_name', 'features.name'); + + if (this.flagResolver.isEnabled('useLastSeenRefactor')) { + query.leftJoin('last_seen_at_metrics', function () { + this.on( + 'last_seen_at_metrics.environment', + '=', + 'environments.name', + ).andOn( + 'last_seen_at_metrics.feature_name', + '=', + 'features.name', + ); + }); + } + + let selectColumns = [ + 'features.name as feature_name', + 'features.description as description', + 'features.type as type', + 'features.created_at as created_at', + 'features.last_seen_at as last_seen_at', + 'features.stale as stale', + 'features.impression_data as impression_data', + 'feature_environments.enabled as enabled', + 'feature_environments.environment as environment', + 'feature_environments.variants as variants', + 'environments.type as environment_type', + 'environments.sort_order as environment_sort_order', + 'ft.tag_value as tag_value', + 'ft.tag_type as tag_type', + ] as (string | Raw | Knex.QueryBuilder)[]; + + if (this.flagResolver.isEnabled('useLastSeenRefactor')) { + selectColumns.push( + 'last_seen_at_metrics.last_seen_at as env_last_seen_at', + ); + } else { + selectColumns.push( + 'feature_environments.last_seen_at as env_last_seen_at', + ); + } + + if (userId) { + query = query.leftJoin(`favorite_features`, function () { + this.on('favorite_features.feature', 'features.name').andOnVal( + 'favorite_features.user_id', + '=', + userId, + ); + }); + selectColumns = [ + ...selectColumns, + this.db.raw( + 'favorite_features.feature is not null as favorite', + ), + ]; + } + + if (this.flagResolver.isEnabled('featureSwitchRefactor')) { + selectColumns = [ + ...selectColumns, + this.db.raw( + 'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies', + ), + this.db.raw( + 'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies', + ), + ]; + } + + query = query.select(selectColumns); + const rows = await query; + if (rows.length > 0) { + const overview = this.getFeatureOverviewData(getUniqueRows(rows)); + return sortEnvironments(overview); + } + return []; + } + async getFeatureOverview({ projectId, archived, @@ -522,6 +642,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { namePrefix, }: IFeatureProjectUserParams): Promise { let query = this.db('features').where({ project: projectId }); + if (tag) { const tagQuery = this.db .from('feature_tag') 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 00059dc895..3df185f9cf 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 @@ -46,6 +46,11 @@ export interface IFeatureStrategiesStore getFeatureOverview( params: IFeatureProjectUserParams, ): Promise; + searchFeatures(params: { + projectId: string; + userId?: number; + queryString: string; + }): Promise; getStrategyById(id: string): Promise; updateStrategy( id: string, diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts index 46f9e55bf9..e7b0f7aeaf 100644 --- a/src/test/e2e/helpers/test-helper.ts +++ b/src/test/e2e/helpers/test-helper.ts @@ -78,6 +78,12 @@ export interface IUnleashHttpAPI { ): supertest.Test; addDependency(child: string, parent: string): supertest.Test; + + addTag( + feature: string, + tag: { type: string; value: string }, + expectedResponseCode?: number, + ): supertest.Test; } function httpApis( @@ -201,6 +207,18 @@ function httpApis( .set('Content-Type', 'application/json') .expect(expectedResponseCode); }, + + addTag( + feature: string, + tag: { type: string; value: string }, + expectedResponseCode: number = 201, + ): supertest.Test { + return request + .post(`/api/admin/features/${feature}/tags`) + .send({ type: tag.type, value: tag.value }) + .set('Content-Type', 'application/json') + .expect(expectedResponseCode); + }, }; }