From b0c05111c6d79fef739455ff7c8fe1f52dcd8437 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Fri, 24 Nov 2023 10:45:44 +0200 Subject: [PATCH] feat: allow filtering projects with operators (#5400) This is first iteration. When we add more fields to be filterable with operators, we can have more reusable components for this. --- .../feature-search-controller.ts | 2 +- .../feature-search/feature-search-service.ts | 47 +++++++++++-- .../feature-search/feature.search.e2e.test.ts | 60 +++++++++++++++-- .../feature-toggle-strategies-store.ts | 67 +++++++++++++------ .../feature-toggle-strategies-store-type.ts | 11 ++- .../spec/feature-search-query-parameters.ts | 4 +- 6 files changed, 158 insertions(+), 33 deletions(-) diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index 4ec8b741b9..22039e7e2f 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -106,7 +106,7 @@ export default class FeatureSearchController extends Controller { sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; const normalizedFavoritesFirst = favoritesFirst === 'true'; const { features, total } = await this.featureSearchService.search({ - queryParams: normalizedQuery, + searchParams: normalizedQuery, projectId, type, userId, diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 5e14f8c166..77aacda83d 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -5,7 +5,11 @@ import { IUnleashStores, serializeDates, } from '../../types'; -import { IFeatureSearchParams } from '../feature-toggle/types/feature-toggle-strategies-store-type'; +import { + IFeatureSearchParams, + IQueryOperator, + IQueryParam, +} from '../feature-toggle/types/feature-toggle-strategies-store-type'; export class FeatureSearchService { private featureStrategiesStore: IFeatureStrategiesStore; @@ -21,15 +25,48 @@ export class FeatureSearchService { } async search(params: IFeatureSearchParams) { + const queryParams = this.convertToQueryParams(params); const { features, total } = - await this.featureStrategiesStore.searchFeatures({ - ...params, - limit: params.limit, - }); + await this.featureStrategiesStore.searchFeatures( + { + ...params, + limit: params.limit, + }, + queryParams, + ); return { features, total, }; } + + parseOperatorValue = (field: string, value: string): IQueryParam | null => { + const multiValueOperators = ['IS_ANY_OF', 'IS_NOT_ANY_OF']; + const pattern = /^(IS|IS_NOT|IS_ANY_OF|IS_NOT_ANY_OF):(.+)$/; + const match = value.match(pattern); + + if (match) { + return { + field, + operator: match[1] as IQueryOperator, + value: multiValueOperators.includes(match[1]) + ? match[2].split(',') + : match[2], + }; + } + + return null; + }; + + convertToQueryParams = (params: IFeatureSearchParams): IQueryParam[] => { + const queryParams: IQueryParam[] = []; + + if (params.projectId) { + const parsed = this.parseOperatorValue('project', params.projectId); + if (parsed) queryParams.push(parsed); + } + + return queryParams; + }; } 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 25ee75a693..02fac18405 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -45,7 +45,7 @@ beforeEach(async () => { }); const searchFeatures = async ( - { query = '', projectId = 'default' }: FeatureSearchQueryParameters, + { query = '', projectId = 'IS:default' }: FeatureSearchQueryParameters, expectedCode = 200, ) => { return app.request @@ -64,7 +64,7 @@ const sortFeatures = async ( ) => { return app.request .get( - `/api/admin/search/features?sortBy=${sortBy}&sortOrder=${sortOrder}&projectId=${projectId}&favoritesFirst=${favoritesFirst}`, + `/api/admin/search/features?sortBy=${sortBy}&sortOrder=${sortOrder}&projectId=IS:${projectId}&favoritesFirst=${favoritesFirst}`, ) .expect(expectedCode); }; @@ -80,7 +80,7 @@ const searchFeaturesWithOffset = async ( ) => { return app.request .get( - `/api/admin/search/features?query=${query}&projectId=${projectId}&offset=${offset}&limit=${limit}`, + `/api/admin/search/features?query=${query}&projectId=IS:${projectId}&offset=${offset}&limit=${limit}`, ) .expect(expectedCode); }; @@ -253,7 +253,7 @@ test('should not search features from another project', async () => { const { body } = await searchFeatures({ query: '', - projectId: 'another_project', + projectId: 'IS:another_project', }); expect(body).toMatchObject({ features: [] }); @@ -484,3 +484,55 @@ test('should support multiple search values', async () => { ], }); }); + +test('should search features by project with operators', async () => { + await app.createFeature('my_feature_a'); + + await db.stores.projectStore.create({ + name: 'project_b', + description: '', + id: 'project_b', + }); + + await db.stores.featureToggleStore.create('project_b', { + name: 'my_feature_b', + }); + + await db.stores.projectStore.create({ + name: 'project_c', + description: '', + id: 'project_c', + }); + + await db.stores.featureToggleStore.create('project_c', { + name: 'my_feature_c', + }); + + const { body } = await searchFeatures({ + projectId: 'IS:default', + }); + expect(body).toMatchObject({ + features: [{ name: 'my_feature_a' }], + }); + + const { body: isNotBody } = await searchFeatures({ + projectId: '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', + }); + 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', + }); + expect(isNotAnyBody).toMatchObject({ + features: [{ name: 'my_feature_b' }], + }); +}); 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 a5164fc5d1..98905e9d76 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -25,7 +25,10 @@ import { ensureStringValue, mapValues } from '../../util'; import { IFeatureProjectUserParams } from './feature-toggle-controller'; import { Db } from '../../db/db'; import Raw = Knex.Raw; -import { IFeatureSearchParams } from './types/feature-toggle-strategies-store-type'; +import { + IFeatureSearchParams, + IQueryParam, +} from './types/feature-toggle-strategies-store-type'; const COLUMNS = [ 'id', @@ -526,20 +529,21 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { }; } - // WIP copy of getFeatureOverview to get the search PoC working - async searchFeatures({ - projectId, - userId, - queryParams, - type, - tag, - status, - offset, - limit, - sortOrder, - sortBy, - favoritesFirst, - }: IFeatureSearchParams): Promise<{ + async searchFeatures( + { + userId, + searchParams, + type, + tag, + status, + offset, + limit, + sortOrder, + sortBy, + favoritesFirst, + }: IFeatureSearchParams, + queryParams: IQueryParam[], + ): Promise<{ features: IFeatureOverview[]; total: number; }> { @@ -549,13 +553,12 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { const finalQuery = this.db .with('ranked_features', (query) => { query.from('features'); - if (projectId) { - query.where({ project: projectId }); - } - const hasQueryString = queryParams?.length; - if (hasQueryString) { - const sqlParameters = queryParams.map( + applyQueryParams(query, queryParams); + + const hasSearchParams = searchParams?.length; + if (hasSearchParams) { + const sqlParameters = searchParams.map( (item) => `%${item}%`, ); const sqlQueryParameters = sqlParameters @@ -1038,5 +1041,27 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { } } +const applyQueryParams = ( + query: Knex.QueryBuilder, + queryParams: IQueryParam[], +): void => { + queryParams.forEach((param) => { + switch (param.operator) { + case 'IS': + query.where(param.field, '=', param.value); + break; + case 'IS_NOT': + query.where(param.field, '!=', param.value); + break; + case 'IS_ANY_OF': + query.whereIn(param.field, param.value as string[]); + break; + case 'IS_NOT_ANY_OF': + query.whereNotIn(param.field, param.value as string[]); + break; + } + }); +}; + 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 29e4f8aaec..93e9665b94 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 @@ -23,7 +23,7 @@ export interface FeatureConfigurationClient { export interface IFeatureSearchParams { userId: number; - queryParams?: string[]; + searchParams?: string[]; projectId?: string; type?: string[]; tag?: string[][]; @@ -35,6 +35,14 @@ export interface IFeatureSearchParams { sortOrder: 'asc' | 'desc'; } +export type IQueryOperator = 'IS' | 'IS_NOT' | 'IS_ANY_OF' | 'IS_NOT_ANY_OF'; + +export interface IQueryParam { + field: string; + operator: IQueryOperator; + value: string | string[]; +} + export interface IFeatureStrategiesStore extends Store { createStrategyFeatureEnv( @@ -64,6 +72,7 @@ export interface IFeatureStrategiesStore ): Promise; searchFeatures( params: IFeatureSearchParams, + queryParams: IQueryParam[], ): Promise<{ features: IFeatureOverview[]; total: number }>; getStrategyById(id: string): Promise; updateStrategy( diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 90ab7b391a..538fb86f24 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -14,7 +14,9 @@ export const featureSearchQueryParameters = [ name: 'projectId', schema: { type: 'string', - example: 'default', + example: 'IS:default', + pattern: + '^(IS|IS_NOT|IS_ANY_OF|IS_NOT_ANY_OF):(.*?)(,([a-zA-Z0-9_]+))*$', }, description: 'Id of the project where search and filter is performed', in: 'query',