mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-14 01:16:17 +02:00
parent
ee44fae6ea
commit
065e588e64
@ -71,7 +71,11 @@ export default class FeatureSearchController extends Controller {
|
|||||||
res: Response,
|
res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.config.flagResolver.isEnabled('featureSearchAPI')) {
|
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 });
|
res.json({ features });
|
||||||
} else {
|
} else {
|
||||||
throw new InvalidOperationError(
|
throw new InvalidOperationError(
|
||||||
|
@ -20,9 +20,9 @@ export class FeatureSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async search(params: FeatureSearchQueryParameters) {
|
async search(params: FeatureSearchQueryParameters) {
|
||||||
const features = await this.featureStrategiesStore.getFeatureOverview({
|
const features = await this.featureStrategiesStore.searchFeatures({
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
namePrefix: params.query,
|
queryString: params.query,
|
||||||
});
|
});
|
||||||
|
|
||||||
return features;
|
return features;
|
||||||
|
@ -43,18 +43,48 @@ const searchFeatures = async (
|
|||||||
.expect(expectedCode);
|
.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_a');
|
||||||
await app.createFeature('my_feature_b');
|
await app.createFeature('my_feature_b');
|
||||||
await app.createFeature('my_feat_c');
|
await app.createFeature('my_feat_c');
|
||||||
|
|
||||||
const { body } = await searchFeatures({ query: 'my_feature' });
|
const { body } = await searchFeatures({ query: 'feature' });
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }],
|
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 () => {
|
test('should return empty features', async () => {
|
||||||
const { body } = await searchFeatures({ query: '' });
|
const { body } = await searchFeatures({ query: '' });
|
||||||
expect(body).toMatchObject({ features: [] });
|
expect(body).toMatchObject({ features: [] });
|
||||||
@ -71,3 +101,14 @@ test('should not return features from another project', async () => {
|
|||||||
|
|
||||||
expect(body).toMatchObject({ features: [] });
|
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' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -322,6 +322,14 @@ export default class FakeFeatureStrategiesStore
|
|||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchFeatures(params: {
|
||||||
|
projectId: string;
|
||||||
|
userId?: number | undefined;
|
||||||
|
queryString: string;
|
||||||
|
}): Promise<IFeatureOverview[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
getAllByFeatures(
|
getAllByFeatures(
|
||||||
features: string[],
|
features: string[],
|
||||||
environment?: string,
|
environment?: string,
|
||||||
|
@ -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({
|
async getFeatureOverview({
|
||||||
projectId,
|
projectId,
|
||||||
archived,
|
archived,
|
||||||
@ -522,6 +642,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
namePrefix,
|
namePrefix,
|
||||||
}: IFeatureProjectUserParams): Promise<IFeatureOverview[]> {
|
}: IFeatureProjectUserParams): Promise<IFeatureOverview[]> {
|
||||||
let query = this.db('features').where({ project: projectId });
|
let query = this.db('features').where({ project: projectId });
|
||||||
|
|
||||||
if (tag) {
|
if (tag) {
|
||||||
const tagQuery = this.db
|
const tagQuery = this.db
|
||||||
.from('feature_tag')
|
.from('feature_tag')
|
||||||
|
@ -46,6 +46,11 @@ export interface IFeatureStrategiesStore
|
|||||||
getFeatureOverview(
|
getFeatureOverview(
|
||||||
params: IFeatureProjectUserParams,
|
params: IFeatureProjectUserParams,
|
||||||
): Promise<IFeatureOverview[]>;
|
): Promise<IFeatureOverview[]>;
|
||||||
|
searchFeatures(params: {
|
||||||
|
projectId: string;
|
||||||
|
userId?: number;
|
||||||
|
queryString: string;
|
||||||
|
}): Promise<IFeatureOverview[]>;
|
||||||
getStrategyById(id: string): Promise<IFeatureStrategy>;
|
getStrategyById(id: string): Promise<IFeatureStrategy>;
|
||||||
updateStrategy(
|
updateStrategy(
|
||||||
id: string,
|
id: string,
|
||||||
|
@ -78,6 +78,12 @@ export interface IUnleashHttpAPI {
|
|||||||
): supertest.Test;
|
): supertest.Test;
|
||||||
|
|
||||||
addDependency(child: string, parent: string): supertest.Test;
|
addDependency(child: string, parent: string): supertest.Test;
|
||||||
|
|
||||||
|
addTag(
|
||||||
|
feature: string,
|
||||||
|
tag: { type: string; value: string },
|
||||||
|
expectedResponseCode?: number,
|
||||||
|
): supertest.Test;
|
||||||
}
|
}
|
||||||
|
|
||||||
function httpApis(
|
function httpApis(
|
||||||
@ -201,6 +207,18 @@ function httpApis(
|
|||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
.expect(expectedResponseCode);
|
.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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user