From 74bbc7799e8fea1fd8ce2006c389984931270f13 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Wed, 1 Nov 2023 09:19:42 +0100 Subject: [PATCH] feat: total count in search results (#5235) --- .../feature-search-controller.ts | 4 +-- .../feature-search/feature-search-service.ts | 10 +++--- .../feature-search/feature.search.e2e.test.ts | 3 ++ .../fakes/fake-feature-strategies-store.ts | 6 ++-- .../feature-toggle-strategies-store.ts | 32 ++++++++++++------- .../feature-toggle-strategies-store-type.ts | 4 ++- .../spec/feature-search-query-parameters.ts | 4 +-- .../openapi/spec/search-features-schema.ts | 6 ++++ 8 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index 2b0b405e3c..4de715c5d9 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -95,7 +95,7 @@ export default class FeatureSearchController extends Controller { ); const normalizedLimit = Number(limit) > 0 && Number(limit) <= 50 ? Number(limit) : 50; - const { features, nextCursor } = + const { features, nextCursor, total } = await this.featureSearchService.search({ query, projectId, @@ -108,7 +108,7 @@ export default class FeatureSearchController extends Controller { }); res.header('Link', nextLink(req, nextCursor)); - res.json({ features }); + res.json({ features, total }); } else { throw new InvalidOperationError( 'Feature Search API is not enabled', diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 8d50d71a75..9746d0bd93 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -22,10 +22,11 @@ export class FeatureSearchService { async search(params: IFeatureSearchParams) { // fetch one more item than needed to get a cursor of the next item - const features = await this.featureStrategiesStore.searchFeatures({ - ...params, - limit: params.limit + 1, - }); + const { features, total } = + await this.featureStrategiesStore.searchFeatures({ + ...params, + limit: params.limit + 1, + }); const nextCursor = features.length > params.limit @@ -39,6 +40,7 @@ export class FeatureSearchService { ? features.slice(0, -1) : features, nextCursor, + total, }; } } 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 bb733322da..1fee193370 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -102,6 +102,7 @@ test('should search matching features by name', async () => { expect(body).toMatchObject({ features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }], + total: 2, }); }); @@ -120,6 +121,7 @@ test('should paginate with cursor', async () => { expect(firstPage).toMatchObject({ features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }], + total: 4, }); const { body: secondPage, headers: secondHeaders } = await getPage( @@ -128,6 +130,7 @@ test('should paginate with cursor', async () => { expect(secondPage).toMatchObject({ features: [{ name: 'my_feature_c' }, { name: 'my_feature_d' }], + total: 4, }); expect(secondHeaders.link).toBe(''); 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 a7376b1860..e129b67f22 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 @@ -325,8 +325,10 @@ export default class FakeFeatureStrategiesStore return Promise.resolve([]); } - searchFeatures(params: IFeatureSearchParams): Promise { - return Promise.resolve([]); + searchFeatures( + params: IFeatureSearchParams, + ): Promise<{ features: IFeatureOverview[]; total: number }> { + return Promise.resolve({ features: [], total: 0 }); } getAllByFeatures( 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 4aa4c7bcdc..7a4d7ab811 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -532,14 +532,17 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { status, cursor, limit, - }: IFeatureSearchParams): Promise { - let query = this.db('features').limit(limit); + }: IFeatureSearchParams): Promise<{ + features: IFeatureOverview[]; + total: number; + }> { + 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 + // todo: we can run a cheaper query when no colon is detected const tagQuery = this.db .from('feature_tag') .select('feature_name') @@ -582,11 +585,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { }); } - if (cursor) { - query = query.where('features.created_at', '>=', cursor); - } - query = query.orderBy('features.created_at', 'asc'); - query = query .modify(FeatureToggleStore.filterByArchived, false) .leftJoin( @@ -601,6 +599,8 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { ) .leftJoin('feature_tag as ft', 'ft.feature_name', 'features.name'); + const countQuery = query.clone(); + if (this.flagResolver.isEnabled('useLastSeenRefactor')) { query.leftJoin('last_seen_at_metrics', function () { this.on( @@ -670,14 +670,24 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { ]; } - query = query.select(selectColumns); + if (cursor) { + query = query.where('features.created_at', '>=', cursor); + } + query = query.orderBy('features.created_at', 'asc'); + + const total = await countQuery + .countDistinct({ total: 'features.name' }) + .first(); + + query = query.select(selectColumns).limit(limit); const rows = await query; if (rows.length > 0) { const overview = this.getFeatureOverviewData(getUniqueRows(rows)); - return sortEnvironments(overview); + const features = sortEnvironments(overview); + return { features, total: Number(total?.total) || 0 }; } - return []; + return { features: [], total: 0 }; } async getFeatureOverview({ 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 7b358ac87b..f27753c69d 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 @@ -59,7 +59,9 @@ export interface IFeatureStrategiesStore getFeatureOverview( params: IFeatureProjectUserParams, ): Promise; - searchFeatures(params: IFeatureSearchParams): Promise; + searchFeatures( + params: IFeatureSearchParams, + ): Promise<{ features: IFeatureOverview[]; total: number }>; getStrategyById(id: string): Promise; updateStrategy( id: string, diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index ba146dff97..7c5bcaa41e 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -71,10 +71,10 @@ export const featureSearchQueryParameters = [ name: 'limit', schema: { type: 'string', - example: '10', + example: '50', }, description: - 'The number of results to return in a page. By default it is set to 50', + 'The number of feature environments to return in a page. By default it is set to 50.', in: 'query', }, ] as const; diff --git a/src/lib/openapi/spec/search-features-schema.ts b/src/lib/openapi/spec/search-features-schema.ts index b5369a0622..30c93605e9 100644 --- a/src/lib/openapi/spec/search-features-schema.ts +++ b/src/lib/openapi/spec/search-features-schema.ts @@ -24,6 +24,12 @@ export const searchFeaturesSchema = { description: 'The full list of features in this project (excluding archived features)', }, + total: { + type: 'number', + description: + 'Total count of the features matching search and filter criteria', + example: 10, + }, }, components: { schemas: {