1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

Search by tag (#5156)

add tag search for feature search API
This commit is contained in:
Mateusz Kwasniewski 2023-10-26 12:50:02 +02:00 committed by GitHub
parent ee44fae6ea
commit 065e588e64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 202 additions and 5 deletions

View File

@ -71,7 +71,11 @@ export default class FeatureSearchController extends Controller {
res: Response,
): Promise<void> {
if (this.config.flagResolver.isEnabled('featureSearchAPI')) {
const features = await this.featureSearchService.search(req.query);
const { query = '', projectId = '' } = req.query;
const features = await this.featureSearchService.search({
query,
projectId,
});
res.json({ features });
} else {
throw new InvalidOperationError(

View File

@ -20,9 +20,9 @@ export class FeatureSearchService {
}
async search(params: FeatureSearchQueryParameters) {
const features = await this.featureStrategiesStore.getFeatureOverview({
const features = await this.featureStrategiesStore.searchFeatures({
projectId: params.projectId,
namePrefix: params.query,
queryString: params.query,
});
return features;

View File

@ -43,18 +43,48 @@ const searchFeatures = async (
.expect(expectedCode);
};
test('should return matching features', async () => {
const searchFeaturesWithoutQueryParams = async (expectedCode = 200) => {
return app.request.get(`/api/admin/search/features`).expect(expectedCode);
};
test('should return matching features by name', async () => {
await app.createFeature('my_feature_a');
await app.createFeature('my_feature_b');
await app.createFeature('my_feat_c');
const { body } = await searchFeatures({ query: 'my_feature' });
const { body } = await searchFeatures({ query: 'feature' });
expect(body).toMatchObject({
features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }],
});
});
test('should return matching 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' });
const { body: fullMatch } = await searchFeatures({
query: 'simple:my_tag',
});
const { body: tagTypeMatch } = await searchFeatures({ query: 'simple' });
const { body: tagValueMatch } = await searchFeatures({ query: 'my_tag' });
const { body: partialTagMatch } = await searchFeatures({ query: 'e:m' });
expect(fullMatch).toMatchObject({
features: [{ name: 'my_feature_a' }],
});
expect(tagTypeMatch).toMatchObject({
features: [{ name: 'my_feature_a' }],
});
expect(tagValueMatch).toMatchObject({
features: [{ name: 'my_feature_a' }],
});
expect(partialTagMatch).toMatchObject({
features: [{ name: 'my_feature_a' }],
});
});
test('should return empty features', async () => {
const { body } = await searchFeatures({ query: '' });
expect(body).toMatchObject({ features: [] });
@ -71,3 +101,14 @@ test('should not return features from another project', async () => {
expect(body).toMatchObject({ features: [] });
});
test('should return features without query', async () => {
await app.createFeature('my_feature_a');
await app.createFeature('my_feature_b');
const { body } = await searchFeaturesWithoutQueryParams();
expect(body).toMatchObject({
features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }],
});
});

View File

@ -322,6 +322,14 @@ export default class FakeFeatureStrategiesStore
return Promise.resolve([]);
}
searchFeatures(params: {
projectId: string;
userId?: number | undefined;
queryString: string;
}): Promise<IFeatureOverview[]> {
return Promise.resolve([]);
}
getAllByFeatures(
features: string[],
environment?: string,

View File

@ -514,6 +514,126 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
};
}
// WIP copy of getFeatureOverview to get the search PoC working
async searchFeatures({
projectId,
userId,
queryString,
}: { projectId: string; userId?: number; queryString: string }): Promise<
IFeatureOverview[]
> {
let query = this.db('features');
if (projectId) {
query = query.where({ project: projectId });
}
if (queryString?.trim()) {
// todo: we can run cheaper query when no colon is detected
const tagQuery = this.db
.from('feature_tag')
.select('feature_name')
.whereRaw("(?? || ':' || ??) LIKE ?", [
'tag_type',
'tag_value',
`%${queryString}%`,
]);
query = query
.whereILike('features.name', `%${queryString}%`)
.orWhereIn('features.name', tagQuery);
}
query = query
.modify(FeatureToggleStore.filterByArchived, false)
.leftJoin(
'feature_environments',
'feature_environments.feature_name',
'features.name',
)
.leftJoin(
'environments',
'feature_environments.environment',
'environments.name',
)
.leftJoin('feature_tag as ft', 'ft.feature_name', 'features.name');
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
query.leftJoin('last_seen_at_metrics', function () {
this.on(
'last_seen_at_metrics.environment',
'=',
'environments.name',
).andOn(
'last_seen_at_metrics.feature_name',
'=',
'features.name',
);
});
}
let selectColumns = [
'features.name as feature_name',
'features.description as description',
'features.type as type',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'features.stale as stale',
'features.impression_data as impression_data',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'feature_environments.variants as variants',
'environments.type as environment_type',
'environments.sort_order as environment_sort_order',
'ft.tag_value as tag_value',
'ft.tag_type as tag_type',
] as (string | Raw<any> | Knex.QueryBuilder)[];
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
selectColumns.push(
'last_seen_at_metrics.last_seen_at as env_last_seen_at',
);
} else {
selectColumns.push(
'feature_environments.last_seen_at as env_last_seen_at',
);
}
if (userId) {
query = query.leftJoin(`favorite_features`, function () {
this.on('favorite_features.feature', 'features.name').andOnVal(
'favorite_features.user_id',
'=',
userId,
);
});
selectColumns = [
...selectColumns,
this.db.raw(
'favorite_features.feature is not null as favorite',
),
];
}
if (this.flagResolver.isEnabled('featureSwitchRefactor')) {
selectColumns = [
...selectColumns,
this.db.raw(
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies',
),
this.db.raw(
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies',
),
];
}
query = query.select(selectColumns);
const rows = await query;
if (rows.length > 0) {
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
return sortEnvironments(overview);
}
return [];
}
async getFeatureOverview({
projectId,
archived,
@ -522,6 +642,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
namePrefix,
}: IFeatureProjectUserParams): Promise<IFeatureOverview[]> {
let query = this.db('features').where({ project: projectId });
if (tag) {
const tagQuery = this.db
.from('feature_tag')

View File

@ -46,6 +46,11 @@ export interface IFeatureStrategiesStore
getFeatureOverview(
params: IFeatureProjectUserParams,
): Promise<IFeatureOverview[]>;
searchFeatures(params: {
projectId: string;
userId?: number;
queryString: string;
}): Promise<IFeatureOverview[]>;
getStrategyById(id: string): Promise<IFeatureStrategy>;
updateStrategy(
id: string,

View File

@ -78,6 +78,12 @@ export interface IUnleashHttpAPI {
): supertest.Test;
addDependency(child: string, parent: string): supertest.Test;
addTag(
feature: string,
tag: { type: string; value: string },
expectedResponseCode?: number,
): supertest.Test;
}
function httpApis(
@ -201,6 +207,18 @@ function httpApis(
.set('Content-Type', 'application/json')
.expect(expectedResponseCode);
},
addTag(
feature: string,
tag: { type: string; value: string },
expectedResponseCode: number = 201,
): supertest.Test {
return request
.post(`/api/admin/features/${feature}/tags`)
.send({ type: tag.type, value: tag.value })
.set('Content-Type', 'application/json')
.expect(expectedResponseCode);
},
};
}