From abf57d1c705b30c360bf02542db4ed27c47b6357 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 27 Nov 2023 15:48:41 +0200 Subject: [PATCH] feat: tag operators for search api (#5425) Added new operators INCLUDE, DO NOT INCLUDE, INCLUDE ALL OF, INCLUDE ANY OF, EXCLUDE IF ANY OF, and EXCLUDE ALL and now support filtering by tags. --- .../feature-search-controller.ts | 5 +- .../feature-search/feature-search-service.ts | 17 ++-- .../feature-search/feature.search.e2e.test.ts | 68 +++++++++++++++- .../feature-toggle-strategies-store.ts | 80 +++++++++++++++---- .../feature-toggle-strategies-store-type.ts | 16 +++- .../spec/feature-search-query-parameters.ts | 4 +- 6 files changed, 160 insertions(+), 30 deletions(-) diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index 22039e7e2f..43af6be0a9 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -88,9 +88,6 @@ export default class FeatureSearchController extends Controller { ?.split(',') .map((query) => query.trim()) .filter((query) => query); - const normalizedTag = tag - ?.map((tag) => tag.split(':')) - .filter((tag) => tag.length === 2); const normalizedStatus = status ?.map((tag) => tag.split(':')) .filter( @@ -110,7 +107,7 @@ export default class FeatureSearchController extends Controller { projectId, type, userId, - tag: normalizedTag, + tag, 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 77aacda83d..729789d04f 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -42,17 +42,15 @@ export class FeatureSearchService { } 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 pattern = + /^(IS|IS_NOT|IS_ANY_OF|IS_NOT_ANY_OF|INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL):(.+)$/; const match = value.match(pattern); if (match) { return { field, operator: match[1] as IQueryOperator, - value: multiValueOperators.includes(match[1]) - ? match[2].split(',') - : match[2], + values: match[2].split(','), }; } @@ -67,6 +65,15 @@ export class FeatureSearchService { if (parsed) queryParams.push(parsed); } + ['tag'].forEach((field) => { + if (params[field]) { + params[field].forEach((value) => { + const parsed = this.parseOperatorValue(field, value); + 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 02fac18405..5e55bf0808 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -181,16 +181,78 @@ test('should filter features by type', async () => { test('should filter 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', }); + await app.createFeature('my_feature_b'); + await app.createFeature('my_feature_c'); + await app.addTag('my_feature_c', { + type: 'simple', + value: 'tag_c', + }); + await app.createFeature('my_feature_d'); + await app.addTag('my_feature_d', { + type: 'simple', + value: 'tag_c', + }); + await app.addTag('my_feature_d', { + type: 'simple', + value: 'my_tag', + }); - const { body } = await filterFeaturesByTag(['simple:my_tag']); + const { body } = await filterFeaturesByTag(['INCLUDE:simple:my_tag']); expect(body).toMatchObject({ - features: [{ name: 'my_feature_a' }], + features: [{ name: 'my_feature_a' }, { name: 'my_feature_d' }], + }); + + 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([ + 'INCLUDE_ALL_OF:simple:my_tag, simple:tag_c', + ]); + + expect(includeAllOf).toMatchObject({ + features: [{ name: 'my_feature_d' }], + }); + + const { body: includeAnyOf } = await filterFeaturesByTag([ + 'INCLUDE_ANY_OF:simple:my_tag, simple:tag_c', + ]); + + expect(includeAnyOf).toMatchObject({ + features: [ + { name: 'my_feature_a' }, + { name: 'my_feature_c' }, + { name: 'my_feature_d' }, + ], + }); + + 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([ + 'EXCLUDE_ALL:simple:my_tag, simple:tag_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 98905e9d76..9694860396 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -577,13 +577,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { ); }); } - if (tag && tag.length > 0) { - const tagQuery = this.db - .from('feature_tag') - .select('feature_name') - .whereIn(['tag_type', 'tag_value'], tag); - query.whereIn('features.name', tagQuery); - } + if (type) { query.whereIn('features.type', type); } @@ -1044,24 +1038,82 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { const applyQueryParams = ( query: Knex.QueryBuilder, queryParams: IQueryParam[], +): void => { + const tagConditions = queryParams.filter((param) => param.field === 'tag'); + const genericConditions = queryParams.filter( + (param) => param.field !== 'tag', + ); + applyTagQueryParams(query, tagConditions); + applyGenericQueryParams(query, genericConditions); +}; + +const applyGenericQueryParams = ( + query: Knex.QueryBuilder, + queryParams: IQueryParam[], ): void => { queryParams.forEach((param) => { switch (param.operator) { case 'IS': - query.where(param.field, '=', param.value); + case 'IS_ANY_OF': + query.whereIn(param.field, param.values); 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[]); + query.whereNotIn(param.field, param.values); break; } }); }; +const applyTagQueryParams = ( + query: Knex.QueryBuilder, + queryParams: IQueryParam[], +): void => { + queryParams.forEach((param) => { + const tags = param.values.map((val) => + val.split(':').map((s) => s.trim()), + ); + + const baseTagSubQuery = createTagBaseQuery(tags); + + switch (param.operator) { + case 'INCLUDE': + case 'INCLUDE_ANY_OF': + query.whereIn(['tag_type', 'tag_value'], tags); + break; + + case 'DO_NOT_INCLUDE': + case 'EXCLUDE_IF_ANY_OF': + query.whereNotIn('features.name', baseTagSubQuery); + break; + + case 'INCLUDE_ALL_OF': + query.whereIn('features.name', (dbSubQuery) => { + baseTagSubQuery(dbSubQuery) + .groupBy('feature_name') + .havingRaw('COUNT(*) = ?', [tags.length]); + }); + break; + + case 'EXCLUDE_ALL': + query.whereNotIn('features.name', (dbSubQuery) => { + baseTagSubQuery(dbSubQuery) + .groupBy('feature_name') + .havingRaw('COUNT(*) = ?', [tags.length]); + }); + break; + } + }); +}; + +const createTagBaseQuery = (tags: string[][]) => { + return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => { + return dbSubQuery + .from('feature_tag') + .select('feature_name') + .whereIn(['tag_type', 'tag_value'], tags); + }; +}; + 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 93e9665b94..eb0ceed7d6 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 @@ -26,7 +26,7 @@ export interface IFeatureSearchParams { searchParams?: string[]; projectId?: string; type?: string[]; - tag?: string[][]; + tag?: string[]; status?: string[][]; offset: number; favoritesFirst?: boolean; @@ -35,12 +35,22 @@ export interface IFeatureSearchParams { sortOrder: 'asc' | 'desc'; } -export type IQueryOperator = 'IS' | 'IS_NOT' | 'IS_ANY_OF' | 'IS_NOT_ANY_OF'; +export type IQueryOperator = + | 'IS' + | 'IS_NOT' + | 'IS_ANY_OF' + | 'IS_NOT_ANY_OF' + | 'INCLUDE' + | 'DO_NOT_INCLUDE' + | 'INCLUDE_ALL_OF' + | 'INCLUDE_ANY_OF' + | 'EXCLUDE_IF_ANY_OF' + | 'EXCLUDE_ALL'; export interface IQueryParam { field: string; operator: IQueryOperator; - value: string | string[]; + values: string[]; } export interface IFeatureStrategiesStore diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 538fb86f24..0ac16905be 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -39,7 +39,9 @@ export const featureSearchQueryParameters = [ type: 'array', items: { type: 'string', - example: 'simple:my_tag', + 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: