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