mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
Chore(1-3753)!: block deletion of context fields in use (#10005)
Blocks deletion of context fields that are in use and updates the "active usage" count to exclude use in archived flags. - Before allowing you to delete a context field, checks if it is in use by any strategies. If so, returns a 409 error. - Updates what we count as "in use" to exclude flags that have been archived. BREAKING CHANGE: Context fields can no longer be deleted if they are in use by active (non-archived) flags.
This commit is contained in:
parent
8fae8fb8b3
commit
9955267d39
6
src/lib/error/conflict-error.ts
Normal file
6
src/lib/error/conflict-error.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { UnleashError } from './unleash-error.js';
|
||||||
|
|
||||||
|
class ConflictError extends UnleashError {
|
||||||
|
statusCode = 409;
|
||||||
|
}
|
||||||
|
export default ConflictError;
|
@ -3,6 +3,7 @@ import type { FromSchema } from 'json-schema-to-ts';
|
|||||||
|
|
||||||
export const UnleashApiErrorTypes = [
|
export const UnleashApiErrorTypes = [
|
||||||
'ContentTypeError',
|
'ContentTypeError',
|
||||||
|
'ConflictErrror',
|
||||||
'DisabledError',
|
'DisabledError',
|
||||||
'FeatureHasTagError',
|
'FeatureHasTagError',
|
||||||
'IncompatibleProjectError',
|
'IncompatibleProjectError',
|
||||||
|
@ -20,6 +20,7 @@ const COLUMNS = [
|
|||||||
const T = {
|
const T = {
|
||||||
contextFields: 'context_fields',
|
contextFields: 'context_fields',
|
||||||
featureStrategies: 'feature_strategies',
|
featureStrategies: 'feature_strategies',
|
||||||
|
features: 'features',
|
||||||
};
|
};
|
||||||
|
|
||||||
type ContextFieldDB = {
|
type ContextFieldDB = {
|
||||||
@ -88,17 +89,21 @@ class ContextFieldStore implements IContextFieldStore {
|
|||||||
|
|
||||||
async getAll(): Promise<IContextField[]> {
|
async getAll(): Promise<IContextField[]> {
|
||||||
const rows = await this.db
|
const rows = await this.db
|
||||||
.select(
|
.select([
|
||||||
this.prefixColumns(),
|
...this.prefixColumns(),
|
||||||
'used_in_projects',
|
this.db.raw(
|
||||||
'used_in_features',
|
`COUNT(DISTINCT CASE
|
||||||
)
|
WHEN ${T.features}.archived_at IS NULL
|
||||||
.countDistinct(
|
THEN ${T.featureStrategies}.project_name
|
||||||
`${T.featureStrategies}.project_name AS used_in_projects`,
|
END) AS used_in_projects`,
|
||||||
)
|
),
|
||||||
.countDistinct(
|
this.db.raw(
|
||||||
`${T.featureStrategies}.feature_name AS used_in_features`,
|
`COUNT(DISTINCT CASE
|
||||||
)
|
WHEN ${T.features}.archived_at IS NULL
|
||||||
|
THEN ${T.featureStrategies}.feature_name
|
||||||
|
END) AS used_in_features`,
|
||||||
|
),
|
||||||
|
])
|
||||||
.from(T.contextFields)
|
.from(T.contextFields)
|
||||||
.joinRaw(
|
.joinRaw(
|
||||||
`LEFT JOIN ${T.featureStrategies} ON EXISTS (
|
`LEFT JOIN ${T.featureStrategies} ON EXISTS (
|
||||||
@ -107,12 +112,18 @@ class ContextFieldStore implements IContextFieldStore {
|
|||||||
WHERE elem ->> 'contextName' = ${T.contextFields}.name
|
WHERE elem ->> 'contextName' = ${T.contextFields}.name
|
||||||
)`,
|
)`,
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
T.features,
|
||||||
|
`${T.features}.name`,
|
||||||
|
`${T.featureStrategies}.feature_name`,
|
||||||
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
this.prefixColumns(
|
this.prefixColumns(
|
||||||
COLUMNS.filter((column) => column !== 'legal_values'),
|
COLUMNS.filter((column) => column !== 'legal_values'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy('name', 'asc');
|
.orderBy('name', 'asc');
|
||||||
|
|
||||||
return rows.map(mapRow);
|
return rows.map(mapRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,7 +155,6 @@ class ContextFieldStore implements IContextFieldStore {
|
|||||||
return present;
|
return present;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: write tests for the changes you made here?
|
|
||||||
async create(contextField: IContextFieldDto): Promise<IContextField> {
|
async create(contextField: IContextFieldDto): Promise<IContextField> {
|
||||||
const [row] = await this.db(T.contextFields)
|
const [row] = await this.db(T.contextFields)
|
||||||
.insert(this.fieldToRow(contextField))
|
.insert(this.fieldToRow(contextField))
|
||||||
|
@ -29,6 +29,7 @@ import {
|
|||||||
CONTEXT_FIELD_UPDATED,
|
CONTEXT_FIELD_UPDATED,
|
||||||
CONTEXT_FIELD_DELETED,
|
CONTEXT_FIELD_DELETED,
|
||||||
} from '../../events/index.js';
|
} from '../../events/index.js';
|
||||||
|
import ConflictError from '../../error/conflict-error.js';
|
||||||
|
|
||||||
class ContextService {
|
class ContextService {
|
||||||
private eventService: EventService;
|
private eventService: EventService;
|
||||||
@ -239,6 +240,15 @@ class ContextService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const contextField = await this.contextFieldStore.get(name);
|
const contextField = await this.contextFieldStore.get(name);
|
||||||
|
|
||||||
|
const strategies =
|
||||||
|
await this.featureStrategiesStore.getStrategiesByContextField(name);
|
||||||
|
|
||||||
|
if (strategies.length > 0) {
|
||||||
|
throw new ConflictError(
|
||||||
|
`This context field is in use by existing flags. To delete it, first remove its usage from all flags.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// delete
|
// delete
|
||||||
await this.contextFieldStore.delete(name);
|
await this.contextFieldStore.delete(name);
|
||||||
await this.eventService.storeEvent({
|
await this.eventService.storeEvent({
|
||||||
|
@ -925,6 +925,12 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
const rows = await this.db
|
const rows = await this.db
|
||||||
.select(this.prefixColumns())
|
.select(this.prefixColumns())
|
||||||
.from<IFeatureStrategiesTable>(T.featureStrategies)
|
.from<IFeatureStrategiesTable>(T.featureStrategies)
|
||||||
|
.join(
|
||||||
|
T.features,
|
||||||
|
`${T.features}.name`,
|
||||||
|
`${T.featureStrategies}.feature_name`,
|
||||||
|
)
|
||||||
|
.where(`${T.features}.archived_at`, 'IS', null)
|
||||||
.where(
|
.where(
|
||||||
this.db.raw(
|
this.db.raw(
|
||||||
"EXISTS (SELECT 1 FROM jsonb_array_elements(constraints) AS elem WHERE elem ->> 'contextName' = ?)",
|
"EXISTS (SELECT 1 FROM jsonb_array_elements(constraints) AS elem WHERE elem ->> 'contextName' = ?)",
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import dbInit, { type ITestDb } from '../../helpers/database-init.js';
|
import dbInit, { type ITestDb } from '../../helpers/database-init.js';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
type IUnleashTest,
|
type IUnleashTest,
|
||||||
setupAppWithCustomConfig,
|
setupAppWithCustomConfig,
|
||||||
@ -179,6 +180,47 @@ test('should delete context field', async () => {
|
|||||||
return app.request.delete('/api/admin/context/userId').expect(200);
|
return app.request.delete('/api/admin/context/userId').expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should not delete a context field that is in use by active flags', async () => {
|
||||||
|
const context = 'appName';
|
||||||
|
const feature = uuidv4();
|
||||||
|
await app.request
|
||||||
|
.post('/api/admin/projects/default/features')
|
||||||
|
.send({
|
||||||
|
name: feature,
|
||||||
|
enabled: false,
|
||||||
|
strategies: [{ name: 'default' }],
|
||||||
|
})
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(201);
|
||||||
|
await app.request
|
||||||
|
.post(
|
||||||
|
`/api/admin/projects/default/features/${feature}/environments/default/strategies`,
|
||||||
|
)
|
||||||
|
.send({
|
||||||
|
name: 'default',
|
||||||
|
constraints: [
|
||||||
|
{
|
||||||
|
contextName: context,
|
||||||
|
operator: 'IN',
|
||||||
|
values: ['test'],
|
||||||
|
caseInsensitive: false,
|
||||||
|
inverted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
app.request.delete(`/api/admin/context/${context}`).expect(409);
|
||||||
|
|
||||||
|
await app.archiveFeature(feature).expect(202);
|
||||||
|
|
||||||
|
const { body: postArchiveBody } = await app.request.get(
|
||||||
|
`/api/admin/context/${context}/strategies`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(postArchiveBody.strategies).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('refuses to create a context not url-friendly name', async () => {
|
test('refuses to create a context not url-friendly name', async () => {
|
||||||
expect.assertions(0);
|
expect.assertions(0);
|
||||||
return app.request
|
return app.request
|
||||||
@ -241,7 +283,7 @@ test('should update context field with stickiness', async () => {
|
|||||||
expect(contextField.stickiness).toBe(true);
|
expect(contextField.stickiness).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show context field usage', async () => {
|
test('should show context field usage for active flags', async () => {
|
||||||
const context = 'appName';
|
const context = 'appName';
|
||||||
const feature = 'contextFeature';
|
const feature = 'contextFeature';
|
||||||
await app.request
|
await app.request
|
||||||
@ -287,4 +329,33 @@ test('should show context field usage', async () => {
|
|||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
strategies: [{ environment: 'default', featureName: 'contextFeature' }],
|
strategies: [{ environment: 'default', featureName: 'contextFeature' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { body: getAllBody } = await app.request
|
||||||
|
.get(`/api/admin/context`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAllBody.find((field) => field.name === context)?.usedInFeatures,
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
await app.archiveFeature('contextFeature').expect(202);
|
||||||
|
|
||||||
|
const { body: postArchiveBody } = await app.request.get(
|
||||||
|
`/api/admin/context/${context}/strategies`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(postArchiveBody.strategies).toHaveLength(0);
|
||||||
|
|
||||||
|
const { body: getContextBody } = await app.request.get(
|
||||||
|
`/api/admin/context/${context}/strategies`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { body: postArchiveGetAllBody } = await app.request
|
||||||
|
.get(`/api/admin/context`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
postArchiveGetAllBody.find((field) => field.name === context)
|
||||||
|
?.usedInFeatures,
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user