mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: add algorithm to detect what strategy changes would be overwritten by applying a CR (#5963)
This change adds an algorithm with tests for detecting what changes would be overwritten by applying a CR. Test cases: - It compares strategies regardless of order of keys in the objects. This ensures that two strategies with the same content but different order of keys are compared correctly. - It treats `undefined` or missing segments in old config as equal to `[]` in change - It treats `undefined` or missing strategy variants in old config and change as equal to `[]` - It lists changes in a sorted list with the correct values - It ignores object order on nested objects. Similar to the first point, this does order-insensitive comparison for nested objects (such as params and constraints).
This commit is contained in:
parent
b00909db3f
commit
c69137a1ee
@ -2,7 +2,10 @@
|
|||||||
"name": "unleash-frontend-local",
|
"name": "unleash-frontend-local",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": ["index.js", "build"],
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"build"
|
||||||
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@ -50,6 +53,7 @@
|
|||||||
"@types/deep-diff": "1.0.5",
|
"@types/deep-diff": "1.0.5",
|
||||||
"@types/jest": "29.5.11",
|
"@types/jest": "29.5.11",
|
||||||
"@types/lodash.clonedeep": "4.5.9",
|
"@types/lodash.clonedeep": "4.5.9",
|
||||||
|
"@types/lodash.isequal": "^4.5.8",
|
||||||
"@types/lodash.mapvalues": "^4.6.9",
|
"@types/lodash.mapvalues": "^4.6.9",
|
||||||
"@types/lodash.omit": "4.5.9",
|
"@types/lodash.omit": "4.5.9",
|
||||||
"@types/node": "18.19.6",
|
"@types/node": "18.19.6",
|
||||||
@ -83,6 +87,7 @@
|
|||||||
"immer": "9.0.21",
|
"immer": "9.0.21",
|
||||||
"jsdom": "23.1.0",
|
"jsdom": "23.1.0",
|
||||||
"lodash.clonedeep": "4.5.0",
|
"lodash.clonedeep": "4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
"lodash.mapvalues": "^4.6.0",
|
"lodash.mapvalues": "^4.6.0",
|
||||||
"lodash.omit": "4.5.0",
|
"lodash.omit": "4.5.0",
|
||||||
"mermaid": "^9.3.0",
|
"mermaid": "^9.3.0",
|
||||||
@ -138,7 +143,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [">0.2%", "not dead", "not op_mini all"],
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
"development": [
|
"development": [
|
||||||
"last 1 chrome version",
|
"last 1 chrome version",
|
||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
|
@ -0,0 +1,290 @@
|
|||||||
|
import { IChangeRequestUpdateStrategy } from 'component/changeRequest/changeRequest.types';
|
||||||
|
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
|
import { getChangesThatWouldBeOverwritten } from './strategy-change-diff-calculation';
|
||||||
|
|
||||||
|
describe('Strategy change conflict detection', () => {
|
||||||
|
const existingStrategy: IFeatureStrategy = {
|
||||||
|
name: 'flexibleRollout',
|
||||||
|
constraints: [],
|
||||||
|
variants: [],
|
||||||
|
parameters: {
|
||||||
|
groupId: 'aaa',
|
||||||
|
rollout: '71',
|
||||||
|
stickiness: 'default',
|
||||||
|
},
|
||||||
|
sortOrder: 0,
|
||||||
|
id: '31572930-2db7-461f-813b-3eedc200cb33',
|
||||||
|
title: '',
|
||||||
|
disabled: false,
|
||||||
|
segments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot: IFeatureStrategy = {
|
||||||
|
id: '31572930-2db7-461f-813b-3eedc200cb33',
|
||||||
|
name: 'flexibleRollout',
|
||||||
|
title: '',
|
||||||
|
disabled: false,
|
||||||
|
segments: [],
|
||||||
|
variants: [],
|
||||||
|
sortOrder: 0,
|
||||||
|
parameters: {
|
||||||
|
groupId: 'aaa',
|
||||||
|
rollout: '71',
|
||||||
|
stickiness: 'default',
|
||||||
|
},
|
||||||
|
constraints: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const change: IChangeRequestUpdateStrategy = {
|
||||||
|
id: 39,
|
||||||
|
action: 'updateStrategy' as const,
|
||||||
|
payload: {
|
||||||
|
id: '31572930-2db7-461f-813b-3eedc200cb33',
|
||||||
|
name: 'flexibleRollout',
|
||||||
|
title: '',
|
||||||
|
disabled: false,
|
||||||
|
segments: [],
|
||||||
|
snapshot,
|
||||||
|
variants: [],
|
||||||
|
parameters: {
|
||||||
|
groupId: 'aaa',
|
||||||
|
rollout: '38',
|
||||||
|
stickiness: 'default',
|
||||||
|
},
|
||||||
|
constraints: [],
|
||||||
|
},
|
||||||
|
createdAt: new Date('2024-01-18T07:58:36.314Z'),
|
||||||
|
createdBy: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
imageUrl:
|
||||||
|
'https://gravatar.com/avatar/8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918?s=42&d=retro&r=g',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('It compares strategies regardless of order of keys in the objects', () => {
|
||||||
|
const result = getChangesThatWouldBeOverwritten(
|
||||||
|
existingStrategy,
|
||||||
|
change,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('It treats `undefined` or missing segments in old config as equal to `[]` in change', () => {
|
||||||
|
const resultUndefined = getChangesThatWouldBeOverwritten(
|
||||||
|
{
|
||||||
|
...existingStrategy,
|
||||||
|
segments: undefined,
|
||||||
|
},
|
||||||
|
change,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultUndefined).toBeNull();
|
||||||
|
|
||||||
|
const { segments, ...withoutSegments } = existingStrategy;
|
||||||
|
const resultMissing = getChangesThatWouldBeOverwritten(
|
||||||
|
withoutSegments,
|
||||||
|
change,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultMissing).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('It treats `undefined` or missing strategy variants in old config and change as equal to `[]`', () => {
|
||||||
|
const undefinedVariantsExistingStrategy = {
|
||||||
|
...existingStrategy,
|
||||||
|
variants: undefined,
|
||||||
|
};
|
||||||
|
const { variants: _variants, ...missingVariantsExistingStrategy } =
|
||||||
|
existingStrategy;
|
||||||
|
|
||||||
|
const { variants: _snapshotVariants, ...snapshot } =
|
||||||
|
change.payload.snapshot!;
|
||||||
|
|
||||||
|
const undefinedVariantsInSnapshot = {
|
||||||
|
...change,
|
||||||
|
payload: {
|
||||||
|
...change.payload,
|
||||||
|
snapshot: {
|
||||||
|
...snapshot,
|
||||||
|
variants: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const missingVariantsInSnapshot = {
|
||||||
|
...change,
|
||||||
|
payload: {
|
||||||
|
...change.payload,
|
||||||
|
snapshot: snapshot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// for all combinations, check that there are no changes
|
||||||
|
const cases = [
|
||||||
|
undefinedVariantsExistingStrategy,
|
||||||
|
missingVariantsExistingStrategy,
|
||||||
|
].flatMap((existing) =>
|
||||||
|
[undefinedVariantsInSnapshot, missingVariantsInSnapshot].map(
|
||||||
|
(changeValue) =>
|
||||||
|
getChangesThatWouldBeOverwritten(existing, changeValue),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cases.every((result) => result === null)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('It lists changes in a sorted list with the correct values', () => {
|
||||||
|
const withChanges: IFeatureStrategy = {
|
||||||
|
name: 'flexibleRollout',
|
||||||
|
title: 'custom title',
|
||||||
|
constraints: [
|
||||||
|
{
|
||||||
|
values: ['blah'],
|
||||||
|
inverted: false,
|
||||||
|
operator: 'IN' as const,
|
||||||
|
contextName: 'appName',
|
||||||
|
caseInsensitive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
name: 'variant1',
|
||||||
|
weight: 1000,
|
||||||
|
payload: {
|
||||||
|
type: 'string',
|
||||||
|
value: 'beaty',
|
||||||
|
},
|
||||||
|
stickiness: 'userId',
|
||||||
|
weightType: 'variable' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
groupId: 'aab',
|
||||||
|
rollout: '39',
|
||||||
|
stickiness: 'userId',
|
||||||
|
},
|
||||||
|
sortOrder: 1,
|
||||||
|
id: '31572930-2db7-461f-813b-3eedc200cb33',
|
||||||
|
disabled: true,
|
||||||
|
segments: [3],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getChangesThatWouldBeOverwritten(withChanges, change);
|
||||||
|
|
||||||
|
const { id, name, ...changedProperties } = withChanges;
|
||||||
|
|
||||||
|
const expectedOutput = Object.entries(changedProperties).map(
|
||||||
|
([property, oldValue]) => ({
|
||||||
|
property,
|
||||||
|
oldValue,
|
||||||
|
newValue:
|
||||||
|
change.payload.snapshot![
|
||||||
|
property as keyof IFeatureStrategy
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectedOutput.sort((a, b) => a.property.localeCompare(b.property));
|
||||||
|
expect(result).toStrictEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it ignores object order on nested objects', () => {
|
||||||
|
const existingStrategyMod: IFeatureStrategy = {
|
||||||
|
...existingStrategy,
|
||||||
|
constraints: [
|
||||||
|
{
|
||||||
|
values: ['blah'],
|
||||||
|
inverted: false,
|
||||||
|
operator: 'IN' as const,
|
||||||
|
contextName: 'appName',
|
||||||
|
caseInsensitive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const constraintChange: IChangeRequestUpdateStrategy = {
|
||||||
|
...change,
|
||||||
|
payload: {
|
||||||
|
...change.payload,
|
||||||
|
// @ts-expect-error Some of the properties that may be
|
||||||
|
// undefined, we know exist in the value
|
||||||
|
snapshot: {
|
||||||
|
...change.payload.snapshot,
|
||||||
|
constraints: [
|
||||||
|
{
|
||||||
|
caseInsensitive: false,
|
||||||
|
contextName: 'appName',
|
||||||
|
inverted: false,
|
||||||
|
operator: 'IN' as const,
|
||||||
|
values: ['blah'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getChangesThatWouldBeOverwritten(
|
||||||
|
existingStrategyMod,
|
||||||
|
constraintChange,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Any properties in the existing strategy that do not exist in the snapshot are also detected', () => {
|
||||||
|
const { variants: _snapshotVariants, ...snapshot } =
|
||||||
|
change.payload.snapshot!;
|
||||||
|
|
||||||
|
const existingStrategyWithVariants = {
|
||||||
|
...existingStrategy,
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
name: 'variant1',
|
||||||
|
weight: 1000,
|
||||||
|
payload: {
|
||||||
|
type: 'string',
|
||||||
|
value: 'beaty',
|
||||||
|
},
|
||||||
|
stickiness: 'userId',
|
||||||
|
weightType: 'variable' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getChangesThatWouldBeOverwritten(
|
||||||
|
existingStrategyWithVariants,
|
||||||
|
{
|
||||||
|
...change,
|
||||||
|
payload: {
|
||||||
|
...change.payload,
|
||||||
|
snapshot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual([
|
||||||
|
{
|
||||||
|
property: 'variants',
|
||||||
|
oldValue: existingStrategyWithVariants.variants,
|
||||||
|
newValue: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns null if the existing strategy is undefined', () => {
|
||||||
|
const result = getChangesThatWouldBeOverwritten(undefined, change);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
test('it returns null if the snapshot is missing', () => {
|
||||||
|
const { snapshot, ...payload } = change.payload;
|
||||||
|
const result = getChangesThatWouldBeOverwritten(existingStrategy, {
|
||||||
|
...change,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,79 @@
|
|||||||
|
import { IChangeRequestUpdateStrategy } from 'component/changeRequest/changeRequest.types';
|
||||||
|
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
|
import isEqual from 'lodash.isequal';
|
||||||
|
|
||||||
|
const hasJsonDiff = (object: unknown, objectToCompare: unknown) =>
|
||||||
|
JSON.stringify(object) !== JSON.stringify(objectToCompare);
|
||||||
|
|
||||||
|
type DataToOverwrite<Prop extends keyof IFeatureStrategy> = {
|
||||||
|
property: Prop;
|
||||||
|
oldValue: IFeatureStrategy[Prop];
|
||||||
|
newValue: IFeatureStrategy[Prop];
|
||||||
|
};
|
||||||
|
type ChangesThatWouldBeOverwritten = DataToOverwrite<keyof IFeatureStrategy>[];
|
||||||
|
|
||||||
|
export const getChangesThatWouldBeOverwritten = (
|
||||||
|
currentStrategyConfig: IFeatureStrategy | undefined,
|
||||||
|
change: IChangeRequestUpdateStrategy,
|
||||||
|
): ChangesThatWouldBeOverwritten | null => {
|
||||||
|
const { snapshot } = change.payload;
|
||||||
|
if (snapshot && currentStrategyConfig) {
|
||||||
|
const hasChanged = (a: unknown, b: unknown) => {
|
||||||
|
if (typeof a === 'object') {
|
||||||
|
return !isEqual(a, b);
|
||||||
|
}
|
||||||
|
return hasJsonDiff(a, b);
|
||||||
|
};
|
||||||
|
|
||||||
|
// compare each property in the snapshot. The property order
|
||||||
|
// might differ, so using JSON.stringify to compare them
|
||||||
|
// doesn't work.
|
||||||
|
const changes: ChangesThatWouldBeOverwritten = Object.entries(
|
||||||
|
currentStrategyConfig,
|
||||||
|
)
|
||||||
|
.map(([key, currentValue]: [string, unknown]) => {
|
||||||
|
const snapshotValue = snapshot[key as keyof IFeatureStrategy];
|
||||||
|
|
||||||
|
// compare, assuming that order never changes
|
||||||
|
if (key === 'segments') {
|
||||||
|
// segments can be undefined on the original
|
||||||
|
// object, but that doesn't mean it has changed
|
||||||
|
if (hasJsonDiff(snapshotValue, currentValue ?? [])) {
|
||||||
|
return {
|
||||||
|
property: key as keyof IFeatureStrategy,
|
||||||
|
oldValue: currentValue,
|
||||||
|
newValue: snapshotValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (key === 'variants') {
|
||||||
|
// strategy variants might not be defined, so use
|
||||||
|
// fallback values
|
||||||
|
if (hasJsonDiff(snapshotValue ?? [], currentValue ?? [])) {
|
||||||
|
return {
|
||||||
|
property: key as keyof IFeatureStrategy,
|
||||||
|
oldValue: currentValue,
|
||||||
|
newValue: snapshotValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (hasChanged(snapshotValue, currentValue)) {
|
||||||
|
return {
|
||||||
|
property: key as keyof IFeatureStrategy,
|
||||||
|
oldValue: currentValue,
|
||||||
|
newValue: snapshotValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(change): change is DataToOverwrite<keyof IFeatureStrategy> =>
|
||||||
|
Boolean(change),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (changes.length) {
|
||||||
|
// we have changes that would be overwritten
|
||||||
|
changes.sort((a, b) => a.property.localeCompare(b.property));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
@ -228,6 +228,7 @@ export type ChangeRequestAddStrategy = Pick<
|
|||||||
|
|
||||||
export type ChangeRequestEditStrategy = ChangeRequestAddStrategy & {
|
export type ChangeRequestEditStrategy = ChangeRequestAddStrategy & {
|
||||||
id: string;
|
id: string;
|
||||||
|
snapshot?: IFeatureStrategy;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChangeRequestDeleteStrategy = {
|
type ChangeRequestDeleteStrategy = {
|
||||||
|
@ -2127,6 +2127,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/lodash" "*"
|
"@types/lodash" "*"
|
||||||
|
|
||||||
|
"@types/lodash.isequal@^4.5.8":
|
||||||
|
version "4.5.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz#b30bb6ff6a5f6c19b3daf389d649ac7f7a250499"
|
||||||
|
integrity sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==
|
||||||
|
dependencies:
|
||||||
|
"@types/lodash" "*"
|
||||||
|
|
||||||
"@types/lodash.mapvalues@^4.6.9":
|
"@types/lodash.mapvalues@^4.6.9":
|
||||||
version "4.6.9"
|
version "4.6.9"
|
||||||
resolved "https://registry.yarnpkg.com/@types/lodash.mapvalues/-/lodash.mapvalues-4.6.9.tgz#1edb4b1d299db332166b474221b06058b34030a7"
|
resolved "https://registry.yarnpkg.com/@types/lodash.mapvalues/-/lodash.mapvalues-4.6.9.tgz#1edb4b1d299db332166b474221b06058b34030a7"
|
||||||
@ -5289,6 +5296,11 @@ lodash.isempty@^4.4.0:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e"
|
resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e"
|
||||||
integrity sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==
|
integrity sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==
|
||||||
|
|
||||||
|
lodash.isequal@^4.5.0:
|
||||||
|
version "4.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
|
||||||
|
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
|
||||||
|
|
||||||
lodash.mapvalues@^4.6.0:
|
lodash.mapvalues@^4.6.0:
|
||||||
version "4.6.0"
|
version "4.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
|
resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
|
||||||
|
Loading…
Reference in New Issue
Block a user