1
0
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:
Jaanus Sellin 2023-11-27 15:48:41 +02:00 committed by GitHub
parent 82b2bb85dc
commit abf57d1c70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 160 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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