Skip to content

Commit 8b0cf8b

Browse files
authored
feat: allow to delete dependencies when no orphans (#4952)
1 parent 52fa872 commit 8b0cf8b

File tree

6 files changed

+81
-9
lines changed

6 files changed

+81
-9
lines changed

src/lib/features/dependent-features/dependent-features-read-model-type.ts

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { IDependency } from '../../types';
22

33
export interface IDependentFeaturesReadModel {
44
getChildren(parents: string[]): Promise<string[]>;
5+
// given a list of parents and children verifies if some children would be orphaned after deletion
6+
// we're interested in the list of parents, not orphans
7+
getOrphanParents(parentsAndChildren: string[]): Promise<string[]>;
58
getParents(child: string): Promise<IDependency[]>;
69
getParentOptions(child: string): Promise<string[]>;
710
hasDependencies(feature: string): Promise<boolean>;

src/lib/features/dependent-features/dependent-features-read-model.ts

+15
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
99
this.db = db;
1010
}
1111

12+
async getOrphanParents(parentsAndChildren: string[]): Promise<string[]> {
13+
const rows = await this.db('dependent_features')
14+
.distinct('parent')
15+
.whereIn('parent', parentsAndChildren)
16+
.andWhere(function () {
17+
this.whereIn('parent', function () {
18+
this.select('parent')
19+
.from('dependent_features')
20+
.whereNotIn('child', parentsAndChildren);
21+
});
22+
});
23+
24+
return rows.map((row) => row.parent);
25+
}
26+
1227
async getChildren(parents: string[]): Promise<string[]> {
1328
const rows = await this.db('dependent_features').whereIn(
1429
'parent',

src/lib/features/dependent-features/fake-dependent-features-read-model.ts

+4
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,8 @@ export class FakeDependentFeaturesReadModel
1919
hasDependencies(): Promise<boolean> {
2020
return Promise.resolve(false);
2121
}
22+
23+
getOrphanParents(parentsAndChildren: string[]): Promise<string[]> {
24+
return Promise.resolve([]);
25+
}
2226
}

src/lib/features/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './feature-toggle/createFeatureToggleService';
44
export * from './project/createProjectService';
55
export * from './change-request-access-service/createChangeRequestAccessReadModel';
66
export * from './segment/createSegmentService';
7+
export * from './dependent-features/createDependentFeaturesService';

src/lib/services/feature-toggle-service.ts

+23-9
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,27 @@ class FeatureToggleService {
256256
}
257257
}
258258

259-
async validateNoChildren(featureNames: string[]): Promise<void> {
259+
async validateNoChildren(featureName: string): Promise<void> {
260260
if (this.flagResolver.isEnabled('dependentFeatures')) {
261-
if (featureNames.length === 0) return;
262-
const children = await this.dependentFeaturesReadModel.getChildren(
263-
featureNames,
264-
);
261+
const children = await this.dependentFeaturesReadModel.getChildren([
262+
featureName,
263+
]);
265264
if (children.length > 0) {
265+
throw new InvalidOperationError(
266+
'You can not archive/delete this feature since other features depend on it.',
267+
);
268+
}
269+
}
270+
}
271+
272+
async validateNoOrphanParents(featureNames: string[]): Promise<void> {
273+
if (this.flagResolver.isEnabled('dependentFeatures')) {
274+
if (featureNames.length === 0) return;
275+
const parents =
276+
await this.dependentFeaturesReadModel.getOrphanParents(
277+
featureNames,
278+
);
279+
if (parents.length > 0) {
266280
throw new InvalidOperationError(
267281
featureNames.length > 1
268282
? `You can not archive/delete those features since other features depend on them.`
@@ -1460,7 +1474,7 @@ class FeatureToggleService {
14601474
});
14611475
}
14621476

1463-
await this.validateNoChildren([featureName]);
1477+
await this.validateNoChildren(featureName);
14641478

14651479
await this.featureToggleStore.archive(featureName);
14661480

@@ -1479,7 +1493,7 @@ class FeatureToggleService {
14791493
projectId: string,
14801494
): Promise<void> {
14811495
await this.validateFeaturesContext(featureNames, projectId);
1482-
await this.validateNoChildren(featureNames);
1496+
await this.validateNoOrphanParents(featureNames);
14831497

14841498
const features = await this.featureToggleStore.getAllByNames(
14851499
featureNames,
@@ -1780,7 +1794,7 @@ class FeatureToggleService {
17801794

17811795
// TODO: add project id.
17821796
async deleteFeature(featureName: string, createdBy: string): Promise<void> {
1783-
await this.validateNoChildren([featureName]);
1797+
await this.validateNoChildren(featureName);
17841798
const toggle = await this.featureToggleStore.get(featureName);
17851799
const tags = await this.tagStore.getAllTagsForFeature(featureName);
17861800
await this.featureToggleStore.delete(featureName);
@@ -1802,7 +1816,7 @@ class FeatureToggleService {
18021816
createdBy: string,
18031817
): Promise<void> {
18041818
await this.validateFeaturesContext(featureNames, projectId);
1805-
await this.validateNoChildren(featureNames);
1819+
await this.validateNoOrphanParents(featureNames);
18061820

18071821
const features = await this.featureToggleStore.getAllByNames(
18081822
featureNames,

src/test/e2e/api/admin/project/features.e2e.test.ts

+35
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,41 @@ test('Should not allow to archive/delete feature with children', async () => {
295295
);
296296
});
297297

298+
test('Should allow to archive/delete feature with children if no orphans are left', async () => {
299+
const parent = uuidv4();
300+
const child = uuidv4();
301+
await app.createFeature(parent, 'default');
302+
await app.createFeature(child, 'default');
303+
await app.addDependency(child, parent);
304+
305+
const { body: deleteBody } = await app.request
306+
.post(`/api/admin/projects/default/delete`)
307+
.set('Content-Type', 'application/json')
308+
.send({ features: [parent, child] })
309+
.expect(200);
310+
});
311+
312+
test('Should not allow to archive/delete feature when orphans are left', async () => {
313+
const parent = uuidv4();
314+
const child = uuidv4();
315+
const orphan = uuidv4();
316+
await app.createFeature(parent, 'default');
317+
await app.createFeature(child, 'default');
318+
await app.createFeature(orphan, 'default');
319+
await app.addDependency(child, parent);
320+
await app.addDependency(orphan, parent);
321+
322+
const { body: deleteBody } = await app.request
323+
.post(`/api/admin/projects/default/delete`)
324+
.set('Content-Type', 'application/json')
325+
.send({ features: [parent, child] })
326+
.expect(403);
327+
328+
expect(deleteBody.message).toBe(
329+
'You can not archive/delete those features since other features depend on them.',
330+
);
331+
});
332+
298333
test('should clone feature with parent dependencies', async () => {
299334
const parent = uuidv4();
300335
const child = uuidv4();

0 commit comments

Comments
 (0)