1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

feat: bulk delete features (#3314)

This commit is contained in:
Jaanus Sellin 2023-03-15 15:08:08 +02:00 committed by GitHub
parent ac1be475e4
commit a5f1b89b4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 215 additions and 7 deletions

View File

@ -301,6 +301,13 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.del();
}
async batchDelete(names: string[]): Promise<void> {
await this.db(TABLE)
.whereIn('name', names)
.whereNotNull('archived_at')
.del();
}
async revive(name: string): Promise<FeatureToggle> {
const row = await this.db(TABLE)
.where({ name })

View File

@ -26,6 +26,7 @@ import {
import { IArchivedQuery, IProjectParam } from '../../../types/model';
import { ProjectApiTokenController } from './api-token';
import { SettingService } from '../../../services';
import ProjectArchiveController from './project-archive';
const STICKINESS_KEY = 'stickiness';
const DEFAULT_STICKINESS = 'default';
@ -114,6 +115,7 @@ export default class ProjectApi extends Controller {
this.use('/', new ProjectHealthReport(config, services).router);
this.use('/', new VariantsController(config, services).router);
this.use('/', new ProjectApiTokenController(config, services).router);
this.use('/', new ProjectArchiveController(config, services).router);
}
async getProjects(

View File

@ -0,0 +1,72 @@
import { Response } from 'express';
import { IUnleashConfig } from '../../../types/option';
import { IFlagResolver, IProjectParam, IUnleashServices } from '../../../types';
import { Logger } from '../../../logger';
import { extractUsername } from '../../../util/extract-user';
import { DELETE_FEATURE } from '../../../types/permissions';
import FeatureToggleService from '../../../services/feature-toggle-service';
import { IAuthRequest } from '../../unleash-types';
import { OpenApiService } from '../../../services/openapi-service';
import { emptyResponse } from '../../../openapi/util/standard-responses';
import { BatchFeaturesSchema, createRequestSchema } from '../../../openapi';
import NotFoundError from '../../../error/notfound-error';
import Controller from '../../controller';
const PATH = '/:projectId/archive';
const PATH_DELETE = `${PATH}/delete`;
export default class ProjectArchiveController extends Controller {
private readonly logger: Logger;
private featureService: FeatureToggleService;
private openApiService: OpenApiService;
private flagResolver: IFlagResolver;
constructor(
config: IUnleashConfig,
{
featureToggleServiceV2,
openApiService,
}: Pick<IUnleashServices, 'featureToggleServiceV2' | 'openApiService'>,
) {
super(config);
this.logger = config.getLogger('/admin-api/archive.js');
this.featureService = featureToggleServiceV2;
this.openApiService = openApiService;
this.flagResolver = config.flagResolver;
this.route({
method: 'post',
path: PATH_DELETE,
acceptAnyContentType: true,
handler: this.deleteFeatures,
permission: DELETE_FEATURE,
middleware: [
openApiService.validPath({
tags: ['Archive'],
operationId: 'deleteFeatures',
requestBody: createRequestSchema('batchFeaturesSchema'),
responses: { 200: emptyResponse },
}),
],
});
}
async deleteFeatures(
req: IAuthRequest<IProjectParam, any, BatchFeaturesSchema>,
res: Response<void>,
): Promise<void> {
if (!this.flagResolver.isEnabled('bulkOperations')) {
throw new NotFoundError('Bulk operations are not enabled');
}
const { projectId } = req.params;
const { features } = req.body;
const user = extractUsername(req);
await this.featureService.deleteFeatures(features, projectId, user);
res.status(200).end();
}
}
module.exports = ProjectArchiveController;

View File

@ -1089,6 +1089,7 @@ class FeatureToggleService {
featureNames,
);
await this.featureToggleStore.batchArchive(featureNames);
const tags = await this.tagStore.getAllByFeatures(featureNames);
await this.eventStore.batchStore(
features.map(
(feature) =>
@ -1096,6 +1097,12 @@ class FeatureToggleService {
featureName: feature.name,
createdBy,
project: feature.project,
tags: tags
.filter((tag) => tag.featureName === feature.name)
.map((tag) => ({
value: tag.tagValue,
type: tag.tagType,
})),
}),
),
);
@ -1115,10 +1122,11 @@ class FeatureToggleService {
const relevantFeatures = features.filter(
(feature) => feature.stale !== stale,
);
await this.featureToggleStore.batchStale(
relevantFeatures.map((feature) => feature.name),
stale,
const relevantFeatureNames = relevantFeatures.map(
(feature) => feature.name,
);
await this.featureToggleStore.batchStale(relevantFeatureNames, stale);
const tags = await this.tagStore.getAllByFeatures(relevantFeatureNames);
await this.eventStore.batchStore(
relevantFeatures.map(
(feature) =>
@ -1127,6 +1135,12 @@ class FeatureToggleService {
project: projectId,
featureName: feature.name,
createdBy,
tags: tags
.filter((tag) => tag.featureName === feature.name)
.map((tag) => ({
value: tag.tagValue,
type: tag.tagType,
})),
}),
),
);
@ -1320,6 +1334,43 @@ class FeatureToggleService {
);
}
async deleteFeatures(
featureNames: string[],
projectId: string,
createdBy: string,
): Promise<void> {
await this.validateFeaturesContext(featureNames, projectId);
const features = await this.featureToggleStore.getAllByNames(
featureNames,
);
const eligibleFeatures = features.filter(
(toggle) => toggle.archivedAt !== null,
);
const eligibleFeatureNames = eligibleFeatures.map(
(toggle) => toggle.name,
);
const tags = await this.tagStore.getAllByFeatures(eligibleFeatureNames);
await this.featureToggleStore.batchDelete(eligibleFeatureNames);
await this.eventStore.batchStore(
eligibleFeatures.map(
(feature) =>
new FeatureDeletedEvent({
featureName: feature.name,
createdBy,
project: feature.project,
preData: feature,
tags: tags
.filter((tag) => tag.featureName === feature.name)
.map((tag) => ({
value: tag.tagValue,
type: tag.tagType,
})),
}),
),
);
}
// TODO: add project id.
async reviveToggle(featureName: string, createdBy: string): Promise<void> {
const toggle = await this.featureToggleStore.revive(featureName);

View File

@ -163,7 +163,7 @@ export class FeatureStaleEvent extends BaseEvent {
project: string;
featureName: string;
createdBy: string | IUser;
tags?: ITag[];
tags: ITag[];
}) {
super(
p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF,
@ -330,7 +330,7 @@ export class FeatureArchivedEvent extends BaseEvent {
project: string;
featureName: string;
createdBy: string | IUser;
tags?: ITag[];
tags: ITag[];
}) {
super(FEATURE_ARCHIVED, p.createdBy, p.tags);
const { project, featureName } = p;

View File

@ -20,6 +20,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
featureNames: string[],
stale: boolean,
): Promise<FeatureToggle[]>;
batchDelete(featureNames: string[]): Promise<void>;
revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
getAllByNames(names: string[]): Promise<FeatureToggle[]>;

View File

@ -1,4 +1,4 @@
import { setupApp } from '../../helpers/test-helper';
import { setupAppWithCustomConfig } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
@ -7,7 +7,14 @@ let db;
beforeAll(async () => {
db = await dbInit('archive_serial', getLogger);
app = await setupApp(db.stores);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
bulkOperations: true,
},
},
});
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{
@ -183,3 +190,30 @@ test('Deleting an unarchived toggle should not take effect', async () => {
.set('Content-Type', 'application/json')
.expect(409); // because it still exists
});
test('can bulk delete features and recreate after', async () => {
const features = ['first.bulk.issue', 'second.bulk.issue'];
for (const feature of features) {
await app.request
.post('/api/admin/features')
.send({
name: feature,
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(201);
await app.request.delete(`/api/admin/features/${feature}`).expect(200);
}
await app.request
.post('/api/admin/projects/default/archive/delete')
.send({ features })
.expect(200);
for (const feature of features) {
await app.request
.post('/api/admin/features/validate')
.send({ name: feature })
.set('Content-Type', 'application/json')
.expect(200);
}
});

View File

@ -6044,6 +6044,40 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/archive/delete": {
"post": {
"operationId": "deleteFeatures",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/batchFeaturesSchema",
},
},
},
"description": "batchFeaturesSchema",
"required": true,
},
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Archive",
],
},
},
"/api/admin/projects/{projectId}/environments": {
"post": {
"operationId": "addEnvironmentToProject",

View File

@ -47,6 +47,13 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return features;
}
async batchDelete(featureNames: string[]): Promise<void> {
this.features = this.features.filter(
(feature) => !featureNames.includes(feature.name),
);
return Promise.resolve();
}
async count(query: Partial<IFeatureToggleQuery>): Promise<number> {
return this.features.filter(this.getFilterQuery(query)).length;
}