mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	
							parent
							
								
									ee44fae6ea
								
							
						
					
					
						commit
						065e588e64
					
				@ -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(
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
@ -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' }],
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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')
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user