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:
parent
2e1790985c
commit
b0c05111c6
@ -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,
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
@ -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' }],
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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(
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user