1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-12-09 20:04:11 +01:00

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.
This commit is contained in:
Jaanus Sellin 2023-11-24 10:45:44 +02:00 committed by GitHub
parent 2e1790985c
commit b0c05111c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 158 additions and 33 deletions

View File

@ -106,7 +106,7 @@ export default class FeatureSearchController extends Controller {
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const normalizedFavoritesFirst = favoritesFirst === 'true'; const normalizedFavoritesFirst = favoritesFirst === 'true';
const { features, total } = await this.featureSearchService.search({ const { features, total } = await this.featureSearchService.search({
queryParams: normalizedQuery, searchParams: normalizedQuery,
projectId, projectId,
type, type,
userId, userId,

View File

@ -5,7 +5,11 @@ import {
IUnleashStores, IUnleashStores,
serializeDates, serializeDates,
} from '../../types'; } 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 { export class FeatureSearchService {
private featureStrategiesStore: IFeatureStrategiesStore; private featureStrategiesStore: IFeatureStrategiesStore;
@ -21,15 +25,48 @@ export class FeatureSearchService {
} }
async search(params: IFeatureSearchParams) { async search(params: IFeatureSearchParams) {
const queryParams = this.convertToQueryParams(params);
const { features, total } = const { features, total } =
await this.featureStrategiesStore.searchFeatures({ await this.featureStrategiesStore.searchFeatures(
...params, {
limit: params.limit, ...params,
}); limit: params.limit,
},
queryParams,
);
return { return {
features, features,
total, 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;
};
} }

View File

@ -45,7 +45,7 @@ beforeEach(async () => {
}); });
const searchFeatures = async ( const searchFeatures = async (
{ query = '', projectId = 'default' }: FeatureSearchQueryParameters, { query = '', projectId = 'IS:default' }: FeatureSearchQueryParameters,
expectedCode = 200, expectedCode = 200,
) => { ) => {
return app.request return app.request
@ -64,7 +64,7 @@ const sortFeatures = async (
) => { ) => {
return app.request return app.request
.get( .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); .expect(expectedCode);
}; };
@ -80,7 +80,7 @@ const searchFeaturesWithOffset = async (
) => { ) => {
return app.request return app.request
.get( .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); .expect(expectedCode);
}; };
@ -253,7 +253,7 @@ test('should not search features from another project', async () => {
const { body } = await searchFeatures({ const { body } = await searchFeatures({
query: '', query: '',
projectId: 'another_project', projectId: 'IS:another_project',
}); });
expect(body).toMatchObject({ features: [] }); 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' }],
});
});

View File

@ -25,7 +25,10 @@ import { ensureStringValue, mapValues } from '../../util';
import { IFeatureProjectUserParams } from './feature-toggle-controller'; import { IFeatureProjectUserParams } from './feature-toggle-controller';
import { Db } from '../../db/db'; import { Db } from '../../db/db';
import Raw = Knex.Raw; 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 = [ const COLUMNS = [
'id', 'id',
@ -526,20 +529,21 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
}; };
} }
// WIP copy of getFeatureOverview to get the search PoC working async searchFeatures(
async searchFeatures({ {
projectId, userId,
userId, searchParams,
queryParams, type,
type, tag,
tag, status,
status, offset,
offset, limit,
limit, sortOrder,
sortOrder, sortBy,
sortBy, favoritesFirst,
favoritesFirst, }: IFeatureSearchParams,
}: IFeatureSearchParams): Promise<{ queryParams: IQueryParam[],
): Promise<{
features: IFeatureOverview[]; features: IFeatureOverview[];
total: number; total: number;
}> { }> {
@ -549,13 +553,12 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
const finalQuery = this.db const finalQuery = this.db
.with('ranked_features', (query) => { .with('ranked_features', (query) => {
query.from('features'); query.from('features');
if (projectId) {
query.where({ project: projectId });
}
const hasQueryString = queryParams?.length;
if (hasQueryString) { applyQueryParams(query, queryParams);
const sqlParameters = queryParams.map(
const hasSearchParams = searchParams?.length;
if (hasSearchParams) {
const sqlParameters = searchParams.map(
(item) => `%${item}%`, (item) => `%${item}%`,
); );
const sqlQueryParameters = sqlParameters 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; module.exports = FeatureStrategiesStore;
export default FeatureStrategiesStore; export default FeatureStrategiesStore;

View File

@ -23,7 +23,7 @@ export interface FeatureConfigurationClient {
export interface IFeatureSearchParams { export interface IFeatureSearchParams {
userId: number; userId: number;
queryParams?: string[]; searchParams?: string[];
projectId?: string; projectId?: string;
type?: string[]; type?: string[];
tag?: string[][]; tag?: string[][];
@ -35,6 +35,14 @@ export interface IFeatureSearchParams {
sortOrder: 'asc' | 'desc'; 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 export interface IFeatureStrategiesStore
extends Store<IFeatureStrategy, string> { extends Store<IFeatureStrategy, string> {
createStrategyFeatureEnv( createStrategyFeatureEnv(
@ -64,6 +72,7 @@ export interface IFeatureStrategiesStore
): Promise<IFeatureOverview[]>; ): Promise<IFeatureOverview[]>;
searchFeatures( searchFeatures(
params: IFeatureSearchParams, params: IFeatureSearchParams,
queryParams: IQueryParam[],
): Promise<{ features: IFeatureOverview[]; total: number }>; ): Promise<{ features: IFeatureOverview[]; total: number }>;
getStrategyById(id: string): Promise<IFeatureStrategy>; getStrategyById(id: string): Promise<IFeatureStrategy>;
updateStrategy( updateStrategy(

View File

@ -14,7 +14,9 @@ export const featureSearchQueryParameters = [
name: 'projectId', name: 'projectId',
schema: { schema: {
type: 'string', 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', description: 'Id of the project where search and filter is performed',
in: 'query', in: 'query',