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

feat: implement column created_by_user_id in feature_tag (#5695)

## About the changes

Adds the new nullable column created_by_user_id to the data used by
feature-tag-store and feature-tag-service. Also updates openapi schemas.
This commit is contained in:
David Leek 2023-12-21 10:00:45 +01:00 committed by GitHub
parent e0f83347ab
commit 4e56d1d8d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 172 additions and 60 deletions

View File

@ -24,4 +24,6 @@ export interface FeatureTagSchema {
* @deprecated
*/
value?: string;
/** The id of the user who created this tag */
createdByUserId?: number;
}

View File

@ -6,6 +6,7 @@ import { DB_TIME } from '../metric-events';
import {
IFeatureAndTag,
IFeatureTag,
IFeatureTagInsert,
IFeatureTagStore,
} from '../types/stores/feature-tag-store';
import { Db } from './db';
@ -18,6 +19,7 @@ interface FeatureTagTable {
feature_name: string;
tag_type: string;
tag_value: string;
created_by_user_id?: number;
}
class FeatureTagStore implements IFeatureTagStore {
@ -82,6 +84,7 @@ class FeatureTagStore implements IFeatureTagStore {
featureName: row.feature_name,
tagType: row.tag_type,
tagValue: row.tag_value,
createdByUserId: row.created_by_user_id,
};
}
@ -91,6 +94,7 @@ class FeatureTagStore implements IFeatureTagStore {
featureName: row.feature_name,
tagType: row.tag_type,
tagValue: row.tag_value,
createdByUserId: row.created_by_user_id,
}));
}
@ -138,13 +142,18 @@ class FeatureTagStore implements IFeatureTagStore {
featureName: row.feature_name,
tagType: row.tag_type,
tagValue: row.tag_value,
createdByUserId: row.created_by_user_id,
}));
}
async tagFeature(featureName: string, tag: ITag): Promise<ITag> {
async tagFeature(
featureName: string,
tag: ITag,
createdByUserId: number,
): Promise<ITag> {
const stopTimer = this.timer('tagFeature');
await this.db<FeatureTagTable>(TABLE)
.insert(this.featureAndTagToRow(featureName, tag))
.insert(this.featureAndTagToRow(featureName, tag, createdByUserId))
.onConflict(COLUMNS)
.merge();
stopTimer();
@ -177,6 +186,7 @@ class FeatureTagStore implements IFeatureTagStore {
featureName: row.feature_name,
tagType: row.tag_type,
tagValue: row.tag_value,
createdByUserId: row.created_by_user_id,
}));
}
@ -186,7 +196,9 @@ class FeatureTagStore implements IFeatureTagStore {
stopTimer();
}
async tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]> {
async tagFeatures(
featureTags: IFeatureTagInsert[],
): Promise<IFeatureAndTag[]> {
if (featureTags.length !== 0) {
const rows = await this.db(TABLE)
.insert(featureTags.map(this.featureTagToRow))
@ -204,7 +216,11 @@ class FeatureTagStore implements IFeatureTagStore {
const stopTimer = this.timer('untagFeature');
try {
await this.db(TABLE)
.where(this.featureAndTagToRow(featureName, tag))
.where({
feature_name: featureName,
tag_type: tag.type,
tag_value: tag.value,
})
.delete();
} catch (err) {
this.logger.error(err);
@ -233,11 +249,13 @@ class FeatureTagStore implements IFeatureTagStore {
featureName,
tagType,
tagValue,
}: IFeatureTag): FeatureTagTable {
createdByUserId,
}: IFeatureTagInsert): FeatureTagTable {
return {
feature_name: featureName,
tag_type: tagType,
tag_value: tagValue,
created_by_user_id: createdByUserId,
};
}
@ -248,11 +266,13 @@ class FeatureTagStore implements IFeatureTagStore {
featureAndTagToRow(
featureName: string,
{ type, value }: ITag,
createdByUserId: number,
): FeatureTagTable {
return {
feature_name: featureName,
tag_type: type,
tag_value: value,
created_by_user_id: createdByUserId,
};
}
}

View File

@ -34,6 +34,7 @@ let app: IUnleashTest;
let db: ITestDb;
const sortOrderFirst = 0;
const sortOrderSecond = 10;
const TESTUSERID = 3333;
const createSegment = async (segmentName: string) => {
const segment = await app.services.segmentService.create(
@ -2991,7 +2992,7 @@ test('Can filter based on tags', async () => {
await db.stores.featureToggleStore.create('default', {
name: 'not-tagged',
});
await db.stores.featureTagStore.tagFeature('to-be-tagged', tag);
await db.stores.featureTagStore.tagFeature('to-be-tagged', tag, TESTUSERID);
await app.request
.get('/api/admin/projects/default/features?tag=simple:hello-tags')
.expect((res) => {
@ -3028,10 +3029,12 @@ test('Can query for features with namePrefix and tags', async () => {
await db.stores.featureTagStore.tagFeature(
'to-be-tagged-nameprefix-and-tags',
tag,
TESTUSERID,
);
await db.stores.featureTagStore.tagFeature(
'tagged-but-not-hit-nameprefix-and-tags',
tag,
TESTUSERID,
);
await app.request
.get(
@ -3065,13 +3068,26 @@ test('Can query for two tags at the same time. Tags are ORed together', async ()
name: 'tagged-with-both-tags',
},
);
await db.stores.featureTagStore.tagFeature(taggedWithFirst.name, tag);
await db.stores.featureTagStore.tagFeature(
taggedWithFirst.name,
tag,
TESTUSERID,
);
await db.stores.featureTagStore.tagFeature(
taggedWithSecond.name,
secondTag,
TESTUSERID,
);
await db.stores.featureTagStore.tagFeature(
taggedWithBoth.name,
tag,
TESTUSERID,
);
await db.stores.featureTagStore.tagFeature(
taggedWithBoth.name,
secondTag,
TESTUSERID,
);
await db.stores.featureTagStore.tagFeature(taggedWithBoth.name, tag);
await db.stores.featureTagStore.tagFeature(taggedWithBoth.name, secondTag);
await app.request
.get(
`/api/admin/projects/default/features?tag=${tag.type}:${tag.value}&tag=${secondTag.type}:${secondTag.value}`,

View File

@ -35,6 +35,12 @@ export const featureTagSchema = {
description:
'The value of the tag. This property is deprecated and will be removed in a future version of Unleash. Superseded by the `tagValue` property.',
},
createdByUserId: {
type: 'number',
nullable: true,
example: 1,
description: 'The id of the user who created this tag',
},
},
components: {},
} as const;

View File

@ -159,4 +159,5 @@ export const featureTagSchema = joi.object().keys({
tagValue: joi.string(),
type: nameType.optional(),
value: joi.string().optional(),
createdByUserId: joi.number().optional(),
});

View File

@ -6,6 +6,7 @@ import { IFeatureToggleStore, IUnleashStores } from '../types/stores';
import { tagSchema } from './tag-schema';
import {
IFeatureTag,
IFeatureTagInsert,
IFeatureTagStore,
} from '../types/stores/feature-tag-store';
import { ITagStore } from '../types/stores/tag-store';
@ -61,7 +62,11 @@ class FeatureTagService {
const featureToggle = await this.featureToggleStore.get(featureName);
const validatedTag = await tagSchema.validateAsync(tag);
await this.createTagIfNeeded(validatedTag, userName, addedByUserId);
await this.featureTagStore.tagFeature(featureName, validatedTag);
await this.featureTagStore.tagFeature(
featureName,
validatedTag,
addedByUserId,
);
await this.eventService.storeEvent({
type: FEATURE_TAGGED,
@ -88,25 +93,26 @@ class FeatureTagService {
this.createTagIfNeeded(tag, userName, updatedByUserId),
),
);
const createdFeatureTags: IFeatureTag[] = featureNames.flatMap(
const createdFeatureTags: IFeatureTagInsert[] = featureNames.flatMap(
(featureName) =>
addedTags.map((addedTag) => ({
featureName,
tagType: addedTag.type,
tagValue: addedTag.value,
createdByUserId: updatedByUserId,
})),
);
await this.featureTagStore.tagFeatures(createdFeatureTags);
const removedFeatureTags: IFeatureTag[] = featureNames.flatMap(
(featureName) =>
const removedFeatureTags: Omit<IFeatureTag, 'createdByUserId'>[] =
featureNames.flatMap((featureName) =>
removedTags.map((addedTag) => ({
featureName,
tagType: addedTag.type,
tagValue: addedTag.value,
})),
);
);
await this.featureTagStore.untagFeatures(removedFeatureTags);

View File

@ -16,6 +16,7 @@ import variantsExportV3 from '../../test/examples/variantsexport_v3.json';
import EventService from './event-service';
import { SYSTEM_USER_ID } from '../types';
const oldExportExample = require('./state-service-export-v1.json');
const TESTUSERID = 3333;
function getSetup() {
const stores = createStores();
@ -398,10 +399,14 @@ test('Should not import an existing tag', async () => {
};
await stores.tagTypeStore.createTagType(data.tagTypes[0]);
await stores.tagStore.createTag(data.tags[0]);
await stores.featureTagStore.tagFeature(data.featureTags[0].featureName, {
type: data.featureTags[0].tagType,
value: data.featureTags[0].tagValue,
});
await stores.featureTagStore.tagFeature(
data.featureTags[0].featureName,
{
type: data.featureTags[0].tagType,
value: data.featureTags[0].tagValue,
},
TESTUSERID,
);
await stateService.import({
data,
userId: SYSTEM_USER_ID,
@ -466,10 +471,14 @@ test('should export tag, tagtypes but not feature tags if the feature is not exp
};
await stores.tagTypeStore.createTagType(data.tagTypes[0]);
await stores.tagStore.createTag(data.tags[0]);
await stores.featureTagStore.tagFeature(data.featureTags[0].featureName, {
type: data.featureTags[0].tagType,
value: data.featureTags[0].tagValue,
});
await stores.featureTagStore.tagFeature(
data.featureTags[0].featureName,
{
type: data.featureTags[0].tagType,
value: data.featureTags[0].tagValue,
},
TESTUSERID,
);
const exported = await stateService.export({
includeFeatureToggles: false,
@ -504,10 +513,14 @@ test('should export tag, tagtypes, featureTags and features', async () => {
};
await stores.tagTypeStore.createTagType(data.tagTypes[0]);
await stores.tagStore.createTag(data.tags[0]);
await stores.featureTagStore.tagFeature(data.featureTags[0].featureName, {
type: data.featureTags[0].tagType,
value: data.featureTags[0].tagValue,
});
await stores.featureTagStore.tagFeature(
data.featureTags[0].featureName,
{
type: data.featureTags[0].tagType,
value: data.featureTags[0].tagValue,
},
TESTUSERID,
);
const exported = await stateService.export({
includeFeatureToggles: true,
@ -667,10 +680,14 @@ test('exporting to new format works', async () => {
parameters: {},
constraints: [],
});
await stores.featureTagStore.tagFeature('Some-feature', {
type: 'simple',
value: 'Test',
});
await stores.featureTagStore.tagFeature(
'Some-feature',
{
type: 'simple',
value: 'Test',
},
TESTUSERID,
);
const exported = await stateService.export({});
expect(exported.featureStrategies).toHaveLength(1);
});
@ -725,10 +742,14 @@ test('featureStrategies can keep existing', async () => {
parameters: {},
constraints: [],
});
await stores.featureTagStore.tagFeature('Some-feature', {
type: 'simple',
value: 'Test',
});
await stores.featureTagStore.tagFeature(
'Some-feature',
{
type: 'simple',
value: 'Test',
},
TESTUSERID,
);
const exported = await stateService.export({});
await stateService.import({
@ -776,10 +797,14 @@ test('featureStrategies should not keep existing if dropBeforeImport', async ()
parameters: {},
constraints: [],
});
await stores.featureTagStore.tagFeature('Some-feature', {
type: 'simple',
value: 'Test',
});
await stores.featureTagStore.tagFeature(
'Some-feature',
{
type: 'simple',
value: 'Test',
},
TESTUSERID,
);
const exported = await stateService.export({});
exported.featureStrategies = [];

View File

@ -616,13 +616,18 @@ export default class StateService {
userName: string,
userId: number,
): Promise<void> {
const featureTagsToInsert = featureTags.filter((tag) =>
keepExisting
? !oldFeatureTags.some((old) =>
this.compareFeatureTags(old, tag),
)
: true,
);
const featureTagsToInsert = featureTags
.filter((tag) =>
keepExisting
? !oldFeatureTags.some((old) =>
this.compareFeatureTags(old, tag),
)
: true,
)
.map((tag) => ({
createdByUserId: userId,
...tag,
}));
if (featureTagsToInsert.length > 0) {
const importedFeatureTags =
await this.featureTagStore.tagFeatures(featureTagsToInsert);

View File

@ -5,6 +5,12 @@ export interface IFeatureTag {
featureName: string;
tagType: string;
tagValue: string;
createdByUserId?: number;
}
export interface IFeatureTagInsert
extends Omit<IFeatureTag, 'created_by_user_id'> {
createdByUserId: number;
}
export interface IFeatureAndTag {
@ -15,8 +21,14 @@ export interface IFeatureTagStore extends Store<IFeatureTag, IFeatureTag> {
getAllTagsForFeature(featureName: string): Promise<ITag[]>;
getAllFeaturesForTag(tagValue: string): Promise<string[]>;
getAllByFeatures(features: string[]): Promise<IFeatureTag[]>;
tagFeature(featureName: string, tag: ITag): Promise<ITag>;
tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
tagFeature(
featureName: string,
tag: ITag,
createdByUserId: number,
): Promise<ITag>;
tagFeatures(featureTags: IFeatureTagInsert[]): Promise<IFeatureAndTag[]>;
untagFeature(featureName: string, tag: ITag): Promise<void>;
untagFeatures(featureTags: IFeatureTag[]): Promise<void>;
untagFeatures(
featureTags: Omit<IFeatureTag, 'createdByUserId'>[],
): Promise<void>;
}

View File

@ -11,6 +11,7 @@ let featureToggleStore: IFeatureToggleStore;
const featureName = 'test-tag';
const tag = { type: 'simple', value: 'test' };
const TESTUSERID = 3333;
beforeAll(async () => {
db = await dbInit('feature_tag_store_serial', getLogger);
@ -31,12 +32,13 @@ afterEach(async () => {
});
test('should tag feature', async () => {
await featureTagStore.tagFeature(featureName, tag);
await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
const featureTags = await featureTagStore.getAllTagsForFeature(featureName);
const featureTag = await featureTagStore.get({
featureName,
tagType: tag.type,
tagValue: tag.value,
createdByUserId: TESTUSERID,
});
expect(featureTags).toHaveLength(1);
expect(featureTags[0]).toStrictEqual(tag);
@ -45,39 +47,41 @@ test('should tag feature', async () => {
});
test('feature tag exists', async () => {
await featureTagStore.tagFeature(featureName, tag);
await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
const exists = await featureTagStore.exists({
featureName,
tagType: tag.type,
tagValue: tag.value,
createdByUserId: TESTUSERID,
});
expect(exists).toBe(true);
});
test('should delete feature tag', async () => {
await featureTagStore.tagFeature(featureName, tag);
await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
await featureTagStore.delete({
featureName,
tagType: tag.type,
tagValue: tag.value,
createdByUserId: TESTUSERID,
});
const featureTags = await featureTagStore.getAllTagsForFeature(featureName);
expect(featureTags).toHaveLength(0);
});
test('should untag feature', async () => {
await featureTagStore.tagFeature(featureName, tag);
await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
await featureTagStore.untagFeature(featureName, tag);
const featureTags = await featureTagStore.getAllTagsForFeature(featureName);
expect(featureTags).toHaveLength(0);
});
test('get all feature tags', async () => {
await featureTagStore.tagFeature(featureName, tag);
await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
await featureToggleStore.create('default', {
name: 'some-other-toggle',
});
await featureTagStore.tagFeature('some-other-toggle', tag);
await featureTagStore.tagFeature('some-other-toggle', tag, TESTUSERID);
const all = await featureTagStore.getAll();
expect(all).toHaveLength(2);
});
@ -87,11 +91,17 @@ test('should import feature tags', async () => {
name: 'some-other-toggle-import',
});
await featureTagStore.tagFeatures([
{ featureName, tagType: tag.type, tagValue: tag.value },
{
featureName,
tagType: tag.type,
tagValue: tag.value,
createdByUserId: TESTUSERID,
},
{
featureName: 'some-other-toggle-import',
tagType: tag.type,
tagValue: tag.value,
createdByUserId: TESTUSERID,
},
]);

View File

@ -46,11 +46,16 @@ export default class FakeFeatureTagStore implements IFeatureTagStore {
return this.featureTags;
}
async tagFeature(featureName: string, tag: ITag): Promise<ITag> {
async tagFeature(
featureName: string,
tag: ITag,
createdByUserId: number,
): Promise<ITag> {
this.featureTags.push({
featureName,
tagType: tag.type,
tagValue: tag.value,
createdByUserId,
});
return Promise.resolve(tag);
}
@ -67,10 +72,14 @@ export default class FakeFeatureTagStore implements IFeatureTagStore {
async tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]> {
return Promise.all(
featureTags.map(async (fT) => {
const saved = await this.tagFeature(fT.featureName, {
value: fT.tagValue,
type: fT.tagType,
});
const saved = await this.tagFeature(
fT.featureName,
{
value: fT.tagValue,
type: fT.tagType,
},
fT.createdByUserId,
);
return {
featureName: fT.featureName,
tag: saved,