Skip to content

Commit

Permalink
feat: allow filtering projects with operators (#5400)
Browse files Browse the repository at this point in the history
This is first iteration. When we add more fields to be filterable with
operators, we can have more reusable components for this.
  • Loading branch information
sjaanus authored Nov 24, 2023
1 parent 2e17909 commit b0c0511
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 42 additions & 5 deletions src/lib/features/feature-search/feature-search-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
};
}
60 changes: 56 additions & 4 deletions src/lib/features/feature-search/feature.search.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ beforeEach(async () => {
});

const searchFeatures = async (
{ query = '', projectId = 'default' }: FeatureSearchQueryParameters,
{ query = '', projectId = 'IS:default' }: FeatureSearchQueryParameters,
expectedCode = 200,
) => {
return app.request
Expand All @@ -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);
};
Expand All @@ -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);
};
Expand Down Expand Up @@ -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: [] });
Expand Down Expand Up @@ -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' }],
});
});
67 changes: 46 additions & 21 deletions src/lib/features/feature-toggle/feature-toggle-strategies-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
}> {
Expand All @@ -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
Expand Down Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface FeatureConfigurationClient {

export interface IFeatureSearchParams {
userId: number;
queryParams?: string[];
searchParams?: string[];
projectId?: string;
type?: string[];
tag?: string[][];
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion src/lib/openapi/spec/feature-search-query-parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit b0c0511

Please sign in to comment.