mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-11 00:08:30 +01:00
feat: tag operators for search api (#5425)
Added new operators INCLUDE, DO NOT INCLUDE, INCLUDE ALL OF, INCLUDE ANY OF, EXCLUDE IF ANY OF, and EXCLUDE ALL and now support filtering by tags.
This commit is contained in:
parent
82b2bb85dc
commit
abf57d1c70
@ -88,9 +88,6 @@ export default class FeatureSearchController extends Controller {
|
||||
?.split(',')
|
||||
.map((query) => query.trim())
|
||||
.filter((query) => query);
|
||||
const normalizedTag = tag
|
||||
?.map((tag) => tag.split(':'))
|
||||
.filter((tag) => tag.length === 2);
|
||||
const normalizedStatus = status
|
||||
?.map((tag) => tag.split(':'))
|
||||
.filter(
|
||||
@ -110,7 +107,7 @@ export default class FeatureSearchController extends Controller {
|
||||
projectId,
|
||||
type,
|
||||
userId,
|
||||
tag: normalizedTag,
|
||||
tag,
|
||||
status: normalizedStatus,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
|
@ -42,17 +42,15 @@ export class FeatureSearchService {
|
||||
}
|
||||
|
||||
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 pattern =
|
||||
/^(IS|IS_NOT|IS_ANY_OF|IS_NOT_ANY_OF|INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL):(.+)$/;
|
||||
const match = value.match(pattern);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
field,
|
||||
operator: match[1] as IQueryOperator,
|
||||
value: multiValueOperators.includes(match[1])
|
||||
? match[2].split(',')
|
||||
: match[2],
|
||||
values: match[2].split(','),
|
||||
};
|
||||
}
|
||||
|
||||
@ -67,6 +65,15 @@ export class FeatureSearchService {
|
||||
if (parsed) queryParams.push(parsed);
|
||||
}
|
||||
|
||||
['tag'].forEach((field) => {
|
||||
if (params[field]) {
|
||||
params[field].forEach((value) => {
|
||||
const parsed = this.parseOperatorValue(field, value);
|
||||
if (parsed) queryParams.push(parsed);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return queryParams;
|
||||
};
|
||||
}
|
||||
|
@ -181,16 +181,78 @@ test('should filter features by type', async () => {
|
||||
|
||||
test('should filter features by tag', async () => {
|
||||
await app.createFeature('my_feature_a');
|
||||
await app.createFeature('my_feature_b');
|
||||
await app.addTag('my_feature_a', {
|
||||
type: 'simple',
|
||||
value: 'my_tag',
|
||||
});
|
||||
await app.createFeature('my_feature_b');
|
||||
await app.createFeature('my_feature_c');
|
||||
await app.addTag('my_feature_c', {
|
||||
type: 'simple',
|
||||
value: 'tag_c',
|
||||
});
|
||||
await app.createFeature('my_feature_d');
|
||||
await app.addTag('my_feature_d', {
|
||||
type: 'simple',
|
||||
value: 'tag_c',
|
||||
});
|
||||
await app.addTag('my_feature_d', {
|
||||
type: 'simple',
|
||||
value: 'my_tag',
|
||||
});
|
||||
|
||||
const { body } = await filterFeaturesByTag(['simple:my_tag']);
|
||||
const { body } = await filterFeaturesByTag(['INCLUDE:simple:my_tag']);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
features: [{ name: 'my_feature_a' }],
|
||||
features: [{ name: 'my_feature_a' }, { name: 'my_feature_d' }],
|
||||
});
|
||||
|
||||
const { body: notIncludeBody } = await filterFeaturesByTag([
|
||||
'DO_NOT_INCLUDE:simple:my_tag',
|
||||
]);
|
||||
|
||||
expect(notIncludeBody).toMatchObject({
|
||||
features: [{ name: 'my_feature_b' }, { name: 'my_feature_c' }],
|
||||
});
|
||||
|
||||
const { body: includeAllOf } = await filterFeaturesByTag([
|
||||
'INCLUDE_ALL_OF:simple:my_tag, simple:tag_c',
|
||||
]);
|
||||
|
||||
expect(includeAllOf).toMatchObject({
|
||||
features: [{ name: 'my_feature_d' }],
|
||||
});
|
||||
|
||||
const { body: includeAnyOf } = await filterFeaturesByTag([
|
||||
'INCLUDE_ANY_OF:simple:my_tag, simple:tag_c',
|
||||
]);
|
||||
|
||||
expect(includeAnyOf).toMatchObject({
|
||||
features: [
|
||||
{ name: 'my_feature_a' },
|
||||
{ name: 'my_feature_c' },
|
||||
{ name: 'my_feature_d' },
|
||||
],
|
||||
});
|
||||
|
||||
const { body: excludeIfAnyOf } = await filterFeaturesByTag([
|
||||
'EXCLUDE_IF_ANY_OF:simple:my_tag, simple:tag_c',
|
||||
]);
|
||||
|
||||
expect(excludeIfAnyOf).toMatchObject({
|
||||
features: [{ name: 'my_feature_b' }],
|
||||
});
|
||||
|
||||
const { body: excludeAll } = await filterFeaturesByTag([
|
||||
'EXCLUDE_ALL:simple:my_tag, simple:tag_c',
|
||||
]);
|
||||
|
||||
expect(excludeAll).toMatchObject({
|
||||
features: [
|
||||
{ name: 'my_feature_a' },
|
||||
{ name: 'my_feature_b' },
|
||||
{ name: 'my_feature_c' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -577,13 +577,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
);
|
||||
});
|
||||
}
|
||||
if (tag && tag.length > 0) {
|
||||
const tagQuery = this.db
|
||||
.from('feature_tag')
|
||||
.select('feature_name')
|
||||
.whereIn(['tag_type', 'tag_value'], tag);
|
||||
query.whereIn('features.name', tagQuery);
|
||||
}
|
||||
|
||||
if (type) {
|
||||
query.whereIn('features.type', type);
|
||||
}
|
||||
@ -1044,24 +1038,82 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
const applyQueryParams = (
|
||||
query: Knex.QueryBuilder,
|
||||
queryParams: IQueryParam[],
|
||||
): void => {
|
||||
const tagConditions = queryParams.filter((param) => param.field === 'tag');
|
||||
const genericConditions = queryParams.filter(
|
||||
(param) => param.field !== 'tag',
|
||||
);
|
||||
applyTagQueryParams(query, tagConditions);
|
||||
applyGenericQueryParams(query, genericConditions);
|
||||
};
|
||||
|
||||
const applyGenericQueryParams = (
|
||||
query: Knex.QueryBuilder,
|
||||
queryParams: IQueryParam[],
|
||||
): void => {
|
||||
queryParams.forEach((param) => {
|
||||
switch (param.operator) {
|
||||
case 'IS':
|
||||
query.where(param.field, '=', param.value);
|
||||
case 'IS_ANY_OF':
|
||||
query.whereIn(param.field, param.values);
|
||||
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[]);
|
||||
query.whereNotIn(param.field, param.values);
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyTagQueryParams = (
|
||||
query: Knex.QueryBuilder,
|
||||
queryParams: IQueryParam[],
|
||||
): void => {
|
||||
queryParams.forEach((param) => {
|
||||
const tags = param.values.map((val) =>
|
||||
val.split(':').map((s) => s.trim()),
|
||||
);
|
||||
|
||||
const baseTagSubQuery = createTagBaseQuery(tags);
|
||||
|
||||
switch (param.operator) {
|
||||
case 'INCLUDE':
|
||||
case 'INCLUDE_ANY_OF':
|
||||
query.whereIn(['tag_type', 'tag_value'], tags);
|
||||
break;
|
||||
|
||||
case 'DO_NOT_INCLUDE':
|
||||
case 'EXCLUDE_IF_ANY_OF':
|
||||
query.whereNotIn('features.name', baseTagSubQuery);
|
||||
break;
|
||||
|
||||
case 'INCLUDE_ALL_OF':
|
||||
query.whereIn('features.name', (dbSubQuery) => {
|
||||
baseTagSubQuery(dbSubQuery)
|
||||
.groupBy('feature_name')
|
||||
.havingRaw('COUNT(*) = ?', [tags.length]);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'EXCLUDE_ALL':
|
||||
query.whereNotIn('features.name', (dbSubQuery) => {
|
||||
baseTagSubQuery(dbSubQuery)
|
||||
.groupBy('feature_name')
|
||||
.havingRaw('COUNT(*) = ?', [tags.length]);
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createTagBaseQuery = (tags: string[][]) => {
|
||||
return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => {
|
||||
return dbSubQuery
|
||||
.from('feature_tag')
|
||||
.select('feature_name')
|
||||
.whereIn(['tag_type', 'tag_value'], tags);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = FeatureStrategiesStore;
|
||||
export default FeatureStrategiesStore;
|
||||
|
@ -26,7 +26,7 @@ export interface IFeatureSearchParams {
|
||||
searchParams?: string[];
|
||||
projectId?: string;
|
||||
type?: string[];
|
||||
tag?: string[][];
|
||||
tag?: string[];
|
||||
status?: string[][];
|
||||
offset: number;
|
||||
favoritesFirst?: boolean;
|
||||
@ -35,12 +35,22 @@ export interface IFeatureSearchParams {
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export type IQueryOperator = 'IS' | 'IS_NOT' | 'IS_ANY_OF' | 'IS_NOT_ANY_OF';
|
||||
export type IQueryOperator =
|
||||
| 'IS'
|
||||
| 'IS_NOT'
|
||||
| 'IS_ANY_OF'
|
||||
| 'IS_NOT_ANY_OF'
|
||||
| 'INCLUDE'
|
||||
| 'DO_NOT_INCLUDE'
|
||||
| 'INCLUDE_ALL_OF'
|
||||
| 'INCLUDE_ANY_OF'
|
||||
| 'EXCLUDE_IF_ANY_OF'
|
||||
| 'EXCLUDE_ALL';
|
||||
|
||||
export interface IQueryParam {
|
||||
field: string;
|
||||
operator: IQueryOperator;
|
||||
value: string | string[];
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export interface IFeatureStrategiesStore
|
||||
|
@ -39,7 +39,9 @@ export const featureSearchQueryParameters = [
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
example: 'simple:my_tag',
|
||||
pattern:
|
||||
'^(INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL):(.*?)(,([a-zA-Z0-9_]+))*$',
|
||||
example: 'INCLUDE:simple:my_tag',
|
||||
},
|
||||
},
|
||||
description:
|
||||
|
Loading…
Reference in New Issue
Block a user