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

View File

@ -34,6 +34,7 @@ let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
const sortOrderFirst = 0; const sortOrderFirst = 0;
const sortOrderSecond = 10; const sortOrderSecond = 10;
const TESTUSERID = 3333;
const createSegment = async (segmentName: string) => { const createSegment = async (segmentName: string) => {
const segment = await app.services.segmentService.create( const segment = await app.services.segmentService.create(
@ -2991,7 +2992,7 @@ test('Can filter based on tags', async () => {
await db.stores.featureToggleStore.create('default', { await db.stores.featureToggleStore.create('default', {
name: 'not-tagged', 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 await app.request
.get('/api/admin/projects/default/features?tag=simple:hello-tags') .get('/api/admin/projects/default/features?tag=simple:hello-tags')
.expect((res) => { .expect((res) => {
@ -3028,10 +3029,12 @@ test('Can query for features with namePrefix and tags', async () => {
await db.stores.featureTagStore.tagFeature( await db.stores.featureTagStore.tagFeature(
'to-be-tagged-nameprefix-and-tags', 'to-be-tagged-nameprefix-and-tags',
tag, tag,
TESTUSERID,
); );
await db.stores.featureTagStore.tagFeature( await db.stores.featureTagStore.tagFeature(
'tagged-but-not-hit-nameprefix-and-tags', 'tagged-but-not-hit-nameprefix-and-tags',
tag, tag,
TESTUSERID,
); );
await app.request await app.request
.get( .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', 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( await db.stores.featureTagStore.tagFeature(
taggedWithSecond.name, taggedWithSecond.name,
secondTag, 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 await app.request
.get( .get(
`/api/admin/projects/default/features?tag=${tag.type}:${tag.value}&tag=${secondTag.type}:${secondTag.value}`, `/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: 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.', '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: {}, components: {},
} as const; } as const;

View File

@ -159,4 +159,5 @@ export const featureTagSchema = joi.object().keys({
tagValue: joi.string(), tagValue: joi.string(),
type: nameType.optional(), type: nameType.optional(),
value: joi.string().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 { tagSchema } from './tag-schema';
import { import {
IFeatureTag, IFeatureTag,
IFeatureTagInsert,
IFeatureTagStore, IFeatureTagStore,
} from '../types/stores/feature-tag-store'; } from '../types/stores/feature-tag-store';
import { ITagStore } from '../types/stores/tag-store'; import { ITagStore } from '../types/stores/tag-store';
@ -61,7 +62,11 @@ class FeatureTagService {
const featureToggle = await this.featureToggleStore.get(featureName); const featureToggle = await this.featureToggleStore.get(featureName);
const validatedTag = await tagSchema.validateAsync(tag); const validatedTag = await tagSchema.validateAsync(tag);
await this.createTagIfNeeded(validatedTag, userName, addedByUserId); await this.createTagIfNeeded(validatedTag, userName, addedByUserId);
await this.featureTagStore.tagFeature(featureName, validatedTag); await this.featureTagStore.tagFeature(
featureName,
validatedTag,
addedByUserId,
);
await this.eventService.storeEvent({ await this.eventService.storeEvent({
type: FEATURE_TAGGED, type: FEATURE_TAGGED,
@ -88,25 +93,26 @@ class FeatureTagService {
this.createTagIfNeeded(tag, userName, updatedByUserId), this.createTagIfNeeded(tag, userName, updatedByUserId),
), ),
); );
const createdFeatureTags: IFeatureTag[] = featureNames.flatMap( const createdFeatureTags: IFeatureTagInsert[] = featureNames.flatMap(
(featureName) => (featureName) =>
addedTags.map((addedTag) => ({ addedTags.map((addedTag) => ({
featureName, featureName,
tagType: addedTag.type, tagType: addedTag.type,
tagValue: addedTag.value, tagValue: addedTag.value,
createdByUserId: updatedByUserId,
})), })),
); );
await this.featureTagStore.tagFeatures(createdFeatureTags); await this.featureTagStore.tagFeatures(createdFeatureTags);
const removedFeatureTags: IFeatureTag[] = featureNames.flatMap( const removedFeatureTags: Omit<IFeatureTag, 'createdByUserId'>[] =
(featureName) => featureNames.flatMap((featureName) =>
removedTags.map((addedTag) => ({ removedTags.map((addedTag) => ({
featureName, featureName,
tagType: addedTag.type, tagType: addedTag.type,
tagValue: addedTag.value, tagValue: addedTag.value,
})), })),
); );
await this.featureTagStore.untagFeatures(removedFeatureTags); 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 EventService from './event-service';
import { SYSTEM_USER_ID } from '../types'; import { SYSTEM_USER_ID } from '../types';
const oldExportExample = require('./state-service-export-v1.json'); const oldExportExample = require('./state-service-export-v1.json');
const TESTUSERID = 3333;
function getSetup() { function getSetup() {
const stores = createStores(); const stores = createStores();
@ -398,10 +399,14 @@ test('Should not import an existing tag', async () => {
}; };
await stores.tagTypeStore.createTagType(data.tagTypes[0]); await stores.tagTypeStore.createTagType(data.tagTypes[0]);
await stores.tagStore.createTag(data.tags[0]); await stores.tagStore.createTag(data.tags[0]);
await stores.featureTagStore.tagFeature(data.featureTags[0].featureName, { await stores.featureTagStore.tagFeature(
type: data.featureTags[0].tagType, data.featureTags[0].featureName,
value: data.featureTags[0].tagValue, {
}); type: data.featureTags[0].tagType,
value: data.featureTags[0].tagValue,
},
TESTUSERID,
);
await stateService.import({ await stateService.import({
data, data,
userId: SYSTEM_USER_ID, 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.tagTypeStore.createTagType(data.tagTypes[0]);
await stores.tagStore.createTag(data.tags[0]); await stores.tagStore.createTag(data.tags[0]);
await stores.featureTagStore.tagFeature(data.featureTags[0].featureName, { await stores.featureTagStore.tagFeature(
type: data.featureTags[0].tagType, data.featureTags[0].featureName,
value: data.featureTags[0].tagValue, {
}); type: data.featureTags[0].tagType,
value: data.featureTags[0].tagValue,
},
TESTUSERID,
);
const exported = await stateService.export({ const exported = await stateService.export({
includeFeatureToggles: false, includeFeatureToggles: false,
@ -504,10 +513,14 @@ test('should export tag, tagtypes, featureTags and features', async () => {
}; };
await stores.tagTypeStore.createTagType(data.tagTypes[0]); await stores.tagTypeStore.createTagType(data.tagTypes[0]);
await stores.tagStore.createTag(data.tags[0]); await stores.tagStore.createTag(data.tags[0]);
await stores.featureTagStore.tagFeature(data.featureTags[0].featureName, { await stores.featureTagStore.tagFeature(
type: data.featureTags[0].tagType, data.featureTags[0].featureName,
value: data.featureTags[0].tagValue, {
}); type: data.featureTags[0].tagType,
value: data.featureTags[0].tagValue,
},
TESTUSERID,
);
const exported = await stateService.export({ const exported = await stateService.export({
includeFeatureToggles: true, includeFeatureToggles: true,
@ -667,10 +680,14 @@ test('exporting to new format works', async () => {
parameters: {}, parameters: {},
constraints: [], constraints: [],
}); });
await stores.featureTagStore.tagFeature('Some-feature', { await stores.featureTagStore.tagFeature(
type: 'simple', 'Some-feature',
value: 'Test', {
}); type: 'simple',
value: 'Test',
},
TESTUSERID,
);
const exported = await stateService.export({}); const exported = await stateService.export({});
expect(exported.featureStrategies).toHaveLength(1); expect(exported.featureStrategies).toHaveLength(1);
}); });
@ -725,10 +742,14 @@ test('featureStrategies can keep existing', async () => {
parameters: {}, parameters: {},
constraints: [], constraints: [],
}); });
await stores.featureTagStore.tagFeature('Some-feature', { await stores.featureTagStore.tagFeature(
type: 'simple', 'Some-feature',
value: 'Test', {
}); type: 'simple',
value: 'Test',
},
TESTUSERID,
);
const exported = await stateService.export({}); const exported = await stateService.export({});
await stateService.import({ await stateService.import({
@ -776,10 +797,14 @@ test('featureStrategies should not keep existing if dropBeforeImport', async ()
parameters: {}, parameters: {},
constraints: [], constraints: [],
}); });
await stores.featureTagStore.tagFeature('Some-feature', { await stores.featureTagStore.tagFeature(
type: 'simple', 'Some-feature',
value: 'Test', {
}); type: 'simple',
value: 'Test',
},
TESTUSERID,
);
const exported = await stateService.export({}); const exported = await stateService.export({});
exported.featureStrategies = []; exported.featureStrategies = [];

View File

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

View File

@ -5,6 +5,12 @@ export interface IFeatureTag {
featureName: string; featureName: string;
tagType: string; tagType: string;
tagValue: string; tagValue: string;
createdByUserId?: number;
}
export interface IFeatureTagInsert
extends Omit<IFeatureTag, 'created_by_user_id'> {
createdByUserId: number;
} }
export interface IFeatureAndTag { export interface IFeatureAndTag {
@ -15,8 +21,14 @@ export interface IFeatureTagStore extends Store<IFeatureTag, IFeatureTag> {
getAllTagsForFeature(featureName: string): Promise<ITag[]>; getAllTagsForFeature(featureName: string): Promise<ITag[]>;
getAllFeaturesForTag(tagValue: string): Promise<string[]>; getAllFeaturesForTag(tagValue: string): Promise<string[]>;
getAllByFeatures(features: string[]): Promise<IFeatureTag[]>; getAllByFeatures(features: string[]): Promise<IFeatureTag[]>;
tagFeature(featureName: string, tag: ITag): Promise<ITag>; tagFeature(
tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>; featureName: string,
tag: ITag,
createdByUserId: number,
): Promise<ITag>;
tagFeatures(featureTags: IFeatureTagInsert[]): Promise<IFeatureAndTag[]>;
untagFeature(featureName: string, tag: ITag): Promise<void>; 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 featureName = 'test-tag';
const tag = { type: 'simple', value: 'test' }; const tag = { type: 'simple', value: 'test' };
const TESTUSERID = 3333;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('feature_tag_store_serial', getLogger); db = await dbInit('feature_tag_store_serial', getLogger);
@ -31,12 +32,13 @@ afterEach(async () => {
}); });
test('should tag feature', async () => { test('should tag feature', async () => {
await featureTagStore.tagFeature(featureName, tag); await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
const featureTags = await featureTagStore.getAllTagsForFeature(featureName); const featureTags = await featureTagStore.getAllTagsForFeature(featureName);
const featureTag = await featureTagStore.get({ const featureTag = await featureTagStore.get({
featureName, featureName,
tagType: tag.type, tagType: tag.type,
tagValue: tag.value, tagValue: tag.value,
createdByUserId: TESTUSERID,
}); });
expect(featureTags).toHaveLength(1); expect(featureTags).toHaveLength(1);
expect(featureTags[0]).toStrictEqual(tag); expect(featureTags[0]).toStrictEqual(tag);
@ -45,39 +47,41 @@ test('should tag feature', async () => {
}); });
test('feature tag exists', async () => { test('feature tag exists', async () => {
await featureTagStore.tagFeature(featureName, tag); await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
const exists = await featureTagStore.exists({ const exists = await featureTagStore.exists({
featureName, featureName,
tagType: tag.type, tagType: tag.type,
tagValue: tag.value, tagValue: tag.value,
createdByUserId: TESTUSERID,
}); });
expect(exists).toBe(true); expect(exists).toBe(true);
}); });
test('should delete feature tag', async () => { test('should delete feature tag', async () => {
await featureTagStore.tagFeature(featureName, tag); await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
await featureTagStore.delete({ await featureTagStore.delete({
featureName, featureName,
tagType: tag.type, tagType: tag.type,
tagValue: tag.value, tagValue: tag.value,
createdByUserId: TESTUSERID,
}); });
const featureTags = await featureTagStore.getAllTagsForFeature(featureName); const featureTags = await featureTagStore.getAllTagsForFeature(featureName);
expect(featureTags).toHaveLength(0); expect(featureTags).toHaveLength(0);
}); });
test('should untag feature', async () => { test('should untag feature', async () => {
await featureTagStore.tagFeature(featureName, tag); await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
await featureTagStore.untagFeature(featureName, tag); await featureTagStore.untagFeature(featureName, tag);
const featureTags = await featureTagStore.getAllTagsForFeature(featureName); const featureTags = await featureTagStore.getAllTagsForFeature(featureName);
expect(featureTags).toHaveLength(0); expect(featureTags).toHaveLength(0);
}); });
test('get all feature tags', async () => { test('get all feature tags', async () => {
await featureTagStore.tagFeature(featureName, tag); await featureTagStore.tagFeature(featureName, tag, TESTUSERID);
await featureToggleStore.create('default', { await featureToggleStore.create('default', {
name: 'some-other-toggle', name: 'some-other-toggle',
}); });
await featureTagStore.tagFeature('some-other-toggle', tag); await featureTagStore.tagFeature('some-other-toggle', tag, TESTUSERID);
const all = await featureTagStore.getAll(); const all = await featureTagStore.getAll();
expect(all).toHaveLength(2); expect(all).toHaveLength(2);
}); });
@ -87,11 +91,17 @@ test('should import feature tags', async () => {
name: 'some-other-toggle-import', name: 'some-other-toggle-import',
}); });
await featureTagStore.tagFeatures([ await featureTagStore.tagFeatures([
{ featureName, tagType: tag.type, tagValue: tag.value }, {
featureName,
tagType: tag.type,
tagValue: tag.value,
createdByUserId: TESTUSERID,
},
{ {
featureName: 'some-other-toggle-import', featureName: 'some-other-toggle-import',
tagType: tag.type, tagType: tag.type,
tagValue: tag.value, tagValue: tag.value,
createdByUserId: TESTUSERID,
}, },
]); ]);

View File

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