1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-18 13:48:58 +02: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';
const normalizedFavoritesFirst = favoritesFirst === 'true';
const { features, total } = await this.featureSearchService.search({
queryParams: normalizedQuery,
searchParams: normalizedQuery,
projectId,
type,
userId,

View File

@ -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;
};
}

View File

@ -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' }],
});
});

View File

@ -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;

View File

@ -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<IFeatureStrategy, string> {
createStrategyFeatureEnv(
@ -64,6 +72,7 @@ export interface IFeatureStrategiesStore
): Promise<IFeatureOverview[]>;
searchFeatures(
params: IFeatureSearchParams,
queryParams: IQueryParam[],
): Promise<{ features: IFeatureOverview[]; total: number }>;
getStrategyById(id: string): Promise<IFeatureStrategy>;
updateStrategy(

View File

@ -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',