Skip to content

Commit

Permalink
feat: bulk update tags (#3274)
Browse files Browse the repository at this point in the history
  • Loading branch information
sjaanus committed Mar 9, 2023
1 parent c42e3f2 commit a52dd10
Show file tree
Hide file tree
Showing 16 changed files with 176 additions and 157 deletions.
1 change: 1 addition & 0 deletions frontend/src/interfaces/uiConfig.ts
Expand Up @@ -48,6 +48,7 @@ export interface IFlags {
proPlanAutoCharge?: boolean;
notifications?: boolean;
loginHistory?: boolean;
bulkOperations?: boolean;
projectScopedSegments?: boolean;
}

Expand Down
2 changes: 2 additions & 0 deletions src/lib/__snapshots__/create-config.test.ts.snap
Expand Up @@ -69,6 +69,7 @@ exports[`should create default config 1`] = `
"flags": {
"ENABLE_DARK_MODE_SUPPORT": false,
"anonymiseEventLog": false,
"bulkOperations": false,
"caseInsensitiveInOperators": false,
"crOnVariants": false,
"embedProxy": true,
Expand All @@ -92,6 +93,7 @@ exports[`should create default config 1`] = `
"experiments": {
"ENABLE_DARK_MODE_SUPPORT": false,
"anonymiseEventLog": false,
"bulkOperations": false,
"caseInsensitiveInOperators": false,
"crOnVariants": false,
"embedProxy": true,
Expand Down
63 changes: 20 additions & 43 deletions src/lib/db/feature-tag-store.ts
@@ -1,10 +1,8 @@
import { EventEmitter } from 'stream';
import { Logger, LogProvider } from '../logger';
import { ITag } from '../types/model';
import { ITag } from '../types';
import EventEmitter from 'events';
import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-error';
import FeatureHasTagError from '../error/feature-has-tag-error';
import {
IFeatureAndTag,
IFeatureTag,
Expand Down Expand Up @@ -123,34 +121,22 @@ class FeatureTagStore implements IFeatureTagStore {
const stopTimer = this.timer('tagFeature');
await this.db<FeatureTagTable>(TABLE)
.insert(this.featureAndTagToRow(featureName, tag))
.catch((err) => {
if (err.code === UNIQUE_CONSTRAINT_VIOLATION) {
throw new FeatureHasTagError(
`${featureName} already has the tag: [${tag.type}:${tag.value}]`,
);
} else {
throw err;
}
});
.onConflict(COLUMNS)
.merge();
stopTimer();
return tag;
}

async tagFeatures(featureNames: string[], tag: ITag): Promise<ITag> {
const stopTimer = this.timer('tagFeatures');
await this.db<FeatureTagTable>(TABLE)
.insert(this.featuresAndTagToRow(featureNames, tag))
.catch((err) => {
if (err.code === UNIQUE_CONSTRAINT_VIOLATION) {
throw new FeatureHasTagError(
`Some of the features already have the tag: [${tag.type}:${tag.value}]`,
);
} else {
throw err;
}
});
async untagFeatures(featureTags: IFeatureTag[]): Promise<void> {
const stopTimer = this.timer('untagFeatures');
try {
await this.db(TABLE)
.whereIn(COLUMNS, featureTags.map(this.featureTagArray))
.delete();
} catch (err) {
this.logger.error(err);
}
stopTimer();
return tag;
}

/**
Expand All @@ -176,11 +162,9 @@ class FeatureTagStore implements IFeatureTagStore {
stopTimer();
}

async importFeatureTags(
featureTags: IFeatureTag[],
): Promise<IFeatureAndTag[]> {
async tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]> {
const rows = await this.db(TABLE)
.insert(featureTags.map(this.importToRow))
.insert(featureTags.map(this.featureTagToRow))
.returning(COLUMNS)
.onConflict(COLUMNS)
.ignore();
Expand Down Expand Up @@ -222,7 +206,7 @@ class FeatureTagStore implements IFeatureTagStore {
};
}

importToRow({
featureTagToRow({
featureName,
tagType,
tagValue,
Expand All @@ -234,6 +218,10 @@ class FeatureTagStore implements IFeatureTagStore {
};
}

featureTagArray({ featureName, tagType, tagValue }: IFeatureTag): string[] {
return [featureName, tagType, tagValue];
}

featureAndTagToRow(
featureName: string,
{ type, value }: ITag,
Expand All @@ -244,17 +232,6 @@ class FeatureTagStore implements IFeatureTagStore {
tag_value: value,
};
}

featuresAndTagToRow(
featureNames: string[],
{ type, value }: ITag,
): FeatureTagTable[] {
return featureNames.map((featureName) => ({
feature_name: featureName,
tag_type: type,
tag_value: value,
}));
}
}

module.exports = FeatureTagStore;
Expand Down
16 changes: 13 additions & 3 deletions src/lib/openapi/spec/tags-bulk-add-schema.test.ts
Expand Up @@ -4,9 +4,19 @@ import { TagsBulkAddSchema } from './tags-bulk-add-schema';
test('tagsBulkAddSchema', () => {
const data: TagsBulkAddSchema = {
features: ['my-feature'],
tag: {
type: 'simple',
value: 'besttag',
tags: {
addedTags: [
{
type: 'simple',
value: 'besttag',
},
],
removedTags: [
{
type: 'simple2',
value: 'besttag2',
},
],
},
};

Expand Down
8 changes: 5 additions & 3 deletions src/lib/openapi/spec/tags-bulk-add-schema.ts
@@ -1,11 +1,12 @@
import { FromSchema } from 'json-schema-to-ts';
import { updateTagsSchema } from './update-tags-schema';
import { tagSchema } from './tag-schema';

export const tagsBulkAddSchema = {
$id: '#/components/schemas/tagsBulkAddSchema',
type: 'object',
additionalProperties: false,
required: ['features', 'tag'],
required: ['features', 'tags'],
properties: {
features: {
type: 'array',
Expand All @@ -14,12 +15,13 @@ export const tagsBulkAddSchema = {
minLength: 1,
},
},
tag: {
$ref: '#/components/schemas/tagSchema',
tags: {
$ref: '#/components/schemas/updateTagsSchema',
},
},
components: {
schemas: {
updateTagsSchema,
tagSchema,
},
},
Expand Down
23 changes: 23 additions & 0 deletions src/lib/openapi/spec/update-tags-schema.test.ts
@@ -0,0 +1,23 @@
import { validateSchema } from '../validate';
import { UpdateTagsSchema } from './update-tags-schema';

test('updateTagsSchema', () => {
const data: UpdateTagsSchema = {
addedTags: [
{
type: 'simple',
value: 'besttag',
},
],
removedTags: [
{
type: 'simple2',
value: 'besttag2',
},
],
};

expect(
validateSchema('#/components/schemas/updateTagsSchema', data),
).toBeUndefined();
});
27 changes: 17 additions & 10 deletions src/lib/routes/admin-api/tag.ts
Expand Up @@ -24,6 +24,8 @@ import {
import { emptyResponse } from '../../openapi/util/standard-responses';
import FeatureTagService from 'lib/services/feature-tag-service';
import { TagsBulkAddSchema } from '../../openapi/spec/tags-bulk-add-schema';
import NotFoundError from '../../error/notfound-error';
import { IFlagResolver } from '../../types';

const version = 1;

Expand All @@ -36,6 +38,8 @@ class TagController extends Controller {

private openApiService: OpenApiService;

private flagResolver: IFlagResolver;

constructor(
config: IUnleashConfig,
{
Expand All @@ -52,6 +56,7 @@ class TagController extends Controller {
this.openApiService = openApiService;
this.featureTagService = featureTagService;
this.logger = config.getLogger('/admin-api/tag.js');
this.flagResolver = config.flagResolver;

this.route({
method: 'get',
Expand Down Expand Up @@ -85,18 +90,16 @@ class TagController extends Controller {
],
});
this.route({
method: 'post',
method: 'put',
path: '/features',
handler: this.addTagToFeatures,
handler: this.updateFeaturesTags,
permission: UPDATE_FEATURE,
middleware: [
openApiService.validPath({
tags: ['Tags'],
operationId: 'addTagToFeatures',
requestBody: createRequestSchema('tagsBulkAddSchema'),
responses: {
201: resourceCreatedResponseSchema('tagSchema'),
},
responses: { 200: emptyResponse },
}),
],
});
Expand Down Expand Up @@ -207,18 +210,22 @@ class TagController extends Controller {
res.status(200).end();
}

async addTagToFeatures(
async updateFeaturesTags(
req: IAuthRequest<void, void, TagsBulkAddSchema>,
res: Response<TagSchema>,
): Promise<void> {
const { features, tag } = req.body;
if (!this.flagResolver.isEnabled('bulkOperations')) {
throw new NotFoundError('Bulk operations are not enabled');
}
const { features, tags } = req.body;
const userName = extractUsername(req);
const addedTag = await this.featureTagService.addTags(
await this.featureTagService.updateTags(
features,
tag,
tags.addedTags,
tags.removedTags,
userName,
);
res.status(201).json(addedTag);
res.status(200).end();
}
}
export default TagController;
70 changes: 52 additions & 18 deletions src/lib/services/feature-tag-service.ts
Expand Up @@ -4,7 +4,10 @@ import { FEATURE_TAGGED, FEATURE_UNTAGGED, TAG_CREATED } from '../types/events';
import { IUnleashConfig } from '../types/option';
import { IFeatureToggleStore, IUnleashStores } from '../types/stores';
import { tagSchema } from './tag-schema';
import { IFeatureTagStore } from '../types/stores/feature-tag-store';
import {
IFeatureTag,
IFeatureTagStore,
} from '../types/stores/feature-tag-store';
import { IEventStore } from '../types/stores/event-store';
import { ITagStore } from '../types/stores/tag-store';
import { ITag } from '../types/model';
Expand Down Expand Up @@ -64,30 +67,61 @@ class FeatureTagService {
return validatedTag;
}

async addTags(
async updateTags(
featureNames: string[],
tag: ITag,
addedTags: ITag[],
removedTags: ITag[],
userName: string,
): Promise<ITag> {
): Promise<void> {
const featureToggles = await this.featureToggleStore.getAllByNames(
featureNames,
);
const validatedTag = await tagSchema.validateAsync(tag);
await this.createTagIfNeeded(validatedTag, userName);
await this.featureTagStore.tagFeatures(featureNames, validatedTag);

await Promise.all(
featureToggles.map((featureToggle) =>
this.eventStore.store({
type: FEATURE_TAGGED,
createdBy: userName,
featureName: featureToggle.name,
project: featureToggle.project,
data: validatedTag,
}),
),
addedTags.map((tag) => this.createTagIfNeeded(tag, userName)),
);
return validatedTag;
const createdFeatureTags: IFeatureTag[] = featureNames.flatMap(
(featureName) =>
addedTags.map((addedTag) => ({
featureName,
tagType: addedTag.type,
tagValue: addedTag.value,
})),
);

await this.featureTagStore.tagFeatures(createdFeatureTags);

const removedFeatureTags: IFeatureTag[] = featureNames.flatMap(
(featureName) =>
removedTags.map((addedTag) => ({
featureName,
tagType: addedTag.type,
tagValue: addedTag.value,
})),
);

await this.featureTagStore.untagFeatures(removedFeatureTags);

const creationEvents = featureToggles.flatMap((featureToggle) =>
addedTags.map((addedTag) => ({
type: FEATURE_TAGGED,
createdBy: userName,
featureName: featureToggle.name,
project: featureToggle.project,
data: addedTag,
})),
);

const removalEvents = featureToggles.flatMap((featureToggle) =>
removedTags.map((removedTag) => ({
type: FEATURE_UNTAGGED,
createdBy: userName,
featureName: featureToggle.name,
project: featureToggle.project,
data: removedTag,
})),
);

await this.eventStore.batchStore([...creationEvents, ...removalEvents]);
}

async createTagIfNeeded(tag: ITag, userName: string): Promise<void> {
Expand Down
7 changes: 3 additions & 4 deletions src/lib/services/state-service.ts
Expand Up @@ -600,10 +600,9 @@ export default class StateService {
: true,
);
if (featureTagsToInsert.length > 0) {
const importedFeatureTags =
await this.featureTagStore.importFeatureTags(
featureTagsToInsert,
);
const importedFeatureTags = await this.featureTagStore.tagFeatures(
featureTagsToInsert,
);
const importedFeatureTagEvents = importedFeatureTags.map((tag) => ({
type: FEATURE_TAG_IMPORT,
createdBy: userName,
Expand Down

0 comments on commit a52dd10

Please sign in to comment.