Merge pull request #5126 from abpframework/feat/5015

Add deepMerge (object-utils) and merge strategy on environment-utils
pull/5134/head
Bunyamin Coskuner 5 years ago committed by GitHub
commit 38d5db2580
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -42,9 +42,14 @@ export namespace Config {
}
export type LocalizationParam = string | LocalizationWithDefault;
export type customMergeFn = (
localEnv: Partial<Config.Environment>,
remoteEnv: any,
) => Config.Environment;
export interface RemoteEnv {
url: string;
mergeStrategy: 'deepmerge' | 'overwrite' | customMergeFn;
method?: string;
headers?: ABP.Dictionary<string>;
}

@ -1,4 +1,12 @@
import { isUndefinedOrEmptyString, noop } from '../utils';
import {
isUndefinedOrEmptyString,
noop,
isNullOrUndefined,
exists,
isObject,
isArray,
isObjectAndNotArray,
} from '../utils';
describe('CommonUtils', () => {
describe('#noop', () => {
@ -23,4 +31,88 @@ describe('CommonUtils', () => {
expect(isUndefinedOrEmptyString(value)).toBe(expected);
});
});
describe('#isNullOrUndefined & #exists', () => {
test.each`
value | expected
${null} | ${true}
${undefined} | ${true}
${true} | ${false}
${false} | ${false}
${''} | ${false}
${'test'} | ${false}
${0} | ${false}
${10} | ${false}
${[]} | ${false}
${[1, 2]} | ${false}
${{}} | ${false}
${{ a: 1 }} | ${false}
`(
'should return $expected and !$expected (for #exists) when given parameter is $value',
({ value, expected }) => {
expect(isNullOrUndefined(value)).toBe(expected);
expect(exists(value)).toBe(!expected);
},
);
});
describe('#isObject', () => {
test.each`
value | expected
${null} | ${false}
${undefined} | ${false}
${true} | ${false}
${false} | ${false}
${''} | ${false}
${'test'} | ${false}
${0} | ${false}
${10} | ${false}
${[]} | ${true}
${[1, 2]} | ${true}
${{}} | ${true}
${{ a: 1 }} | ${true}
`('should return $expected when given parameter is $value', ({ value, expected }) => {
expect(isObject(value)).toBe(expected);
});
});
describe('#isArray', () => {
test.each`
value | expected
${null} | ${false}
${undefined} | ${false}
${true} | ${false}
${false} | ${false}
${''} | ${false}
${'test'} | ${false}
${0} | ${false}
${10} | ${false}
${[]} | ${true}
${[1, 2]} | ${true}
${{}} | ${false}
${{ a: 1 }} | ${false}
`('should return $expected when given parameter is $value', ({ value, expected }) => {
expect(isArray(value)).toBe(expected);
});
});
describe('#isObjectAndNotArray', () => {
test.each`
value | expected
${null} | ${false}
${undefined} | ${false}
${true} | ${false}
${false} | ${false}
${''} | ${false}
${'test'} | ${false}
${0} | ${false}
${10} | ${false}
${[]} | ${false}
${[1, 2]} | ${false}
${{}} | ${true}
${{ a: 1 }} | ${true}
`('should return $expected when given parameter is $value', ({ value, expected }) => {
expect(isObjectAndNotArray(value)).toBe(expected);
});
});
});

@ -5,29 +5,8 @@ import { Store } from '@ngxs/store';
import { BehaviorSubject, of } from 'rxjs';
import { getRemoteEnv } from '../utils/environment-utils';
import { SetEnvironment } from '../actions/config.actions';
const environment = {
production: false,
hmr: false,
application: {
baseUrl: 'https://volosoft.com',
name: 'MyProjectName',
logoUrl: '',
},
oAuthConfig: {
issuer: 'https://api.volosoft.com',
clientId: 'MyProjectName_App',
dummyClientSecret: '1q2w3e*',
scope: 'MyProjectName',
oidc: false,
requireHttps: true,
},
apis: {
default: {
url: 'https://api.volosoft.com',
},
},
};
import { Config } from '../models/config';
import { deepMerge } from '../utils/object-utils';
@Component({
selector: 'abp-dummy',
@ -44,8 +23,60 @@ describe('EnvironmentUtils', () => {
beforeEach(() => (spectator = createComponent()));
describe('#getRemoteEnv', async () => {
test('should call the remoteEnv URL and dispatch the SetEnvironment action ', async () => {
describe('#getRemoteEnv', () => {
const environment: Config.Environment = {
production: false,
hmr: false,
application: {
baseUrl: 'https://volosoft.com',
name: 'MyProjectName',
logoUrl: '',
},
remoteEnv: { url: '/assets/appsettings.json', mergeStrategy: 'deepmerge' },
oAuthConfig: {
issuer: 'https://api.volosoft.com',
clientId: 'MyProjectName_App',
dummyClientSecret: '1q2w3e*',
scope: 'MyProjectName',
oidc: false,
requireHttps: true,
},
apis: {
default: {
url: 'https://api.volosoft.com',
},
},
};
const customEnv = {
application: {
baseUrl: 'https://custom-volosoft.com',
name: 'Custom-MyProjectName',
logoUrl: 'https://logourl/',
},
apis: {
default: {
url: 'https://test-api.volosoft.com',
},
},
};
const someEnv = { apiUrl: 'https://some-api-url' } as any;
const customFn = (_, __) => someEnv;
test.each`
case | strategy | expected
${'null'} | ${null} | ${customEnv}
${'undefined'} | ${undefined} | ${customEnv}
${'overwrite'} | ${'overwrite'} | ${customEnv}
${'deepmerge'} | ${'deepmerge'} | ${deepMerge(environment, customEnv)}
${'customFn'} | ${customFn} | ${someEnv}
`(
'should call the remoteEnv URL and dispatch the SetEnvironment action for case $case ',
({ strategy, expected }) => setupTestAndRun({ mergeStrategy: strategy }, expected),
);
function setupTestAndRun(strategy: Pick<Config.RemoteEnv, 'mergeStrategy'>, expectedValue) {
const injector = spectator.inject(Injector);
const injectorSpy = jest.spyOn(injector, 'get');
const store = spectator.inject(Store);
@ -56,16 +87,14 @@ describe('EnvironmentUtils', () => {
injectorSpy.mockReturnValueOnce(http);
injectorSpy.mockReturnValueOnce(store);
requestSpy.mockReturnValue(new BehaviorSubject(environment));
requestSpy.mockReturnValue(new BehaviorSubject(customEnv));
dispatchSpy.mockReturnValue(of(true));
const partialEnv = { remoteEnv: { url: '/assets/appsettings.json' } };
getRemoteEnv(injector, partialEnv);
environment.remoteEnv.mergeStrategy = strategy.mergeStrategy;
getRemoteEnv(injector, environment);
expect(requestSpy).toHaveBeenCalledWith('GET', '/assets/appsettings.json', { headers: {} });
expect(dispatchSpy).toHaveBeenCalledWith(
new SetEnvironment({ ...environment, ...partialEnv }),
);
});
expect(dispatchSpy).toHaveBeenCalledWith(new SetEnvironment(expectedValue));
}
});
});

@ -0,0 +1,141 @@
import { deepMerge } from '../utils/object-utils';
describe('DeepMerge', () => {
test.each`
target | source
${null} | ${null}
${null} | ${undefined}
${undefined} | ${null}
${undefined} | ${undefined}
`('should return empty object when both inputs are $target and $source', ({ target, source }) => {
expect(deepMerge(target, source)).toEqual({});
});
test.each`
value
${10}
${false}
${''}
${'test-string'}
${{ a: 1 }}
${[1, 2, 3]}
${{}}
`('should correctly return when any of the inputs is null or undefined', val => {
expect(deepMerge(undefined, val)).toEqual(val);
expect(deepMerge(null, val)).toEqual(val);
expect(deepMerge(val, undefined)).toEqual(val);
expect(deepMerge(val, null)).toEqual(val);
});
test.each`
target | source
${10} | ${false}
${false} | ${20}
${'some-string'} | ${{ a: 5 }}
${{ b: 10 }} | ${50}
${[1, 2, 3]} | ${40}
${{ k: 60 }} | ${[4, 5, 6]}
`(
'should correctly return source if one of them is primitive or an array',
({ target, source }) => {
expect(deepMerge(target, source)).toEqual(source);
},
);
it('should correctly return when both inputs are objects with different fields', () => {
const target = { a: 1 };
const source = { b: 2 };
const expected = { a: 1, b: 2 };
expect(deepMerge(target, source)).toEqual(expected);
expect(deepMerge(source, target)).toEqual(expected);
});
it('should correctly return when both inputs are object with same fields but different values', () => {
const target = { a: 1 };
const source = { a: 5 };
expect(deepMerge(target, source)).toEqual(source);
expect(deepMerge(source, target)).toEqual(target);
});
it('should correctly merge shallow objects with different fields as well as some shared ones', () => {
const target = { a: 1, b: 2, c: 3 };
const source = { a: 4, d: 5, e: 6 };
expect(deepMerge(target, source)).toEqual({ a: 4, b: 2, c: 3, d: 5, e: 6 });
});
it('should not merge arrays and return the latter', () => {
const firstArray = [1, 2, 3];
const secondArray = [3, 4, 5, 6];
expect(deepMerge(firstArray, secondArray)).toEqual(secondArray);
const target = { a: firstArray };
const source = { a: secondArray };
expect(deepMerge(target, source)).toEqual({ a: secondArray });
});
it('should correctly merge nested objects', () => {
const target = {
a: {
b: {
c: {
d: 1,
g: 10,
q: undefined,
t: false,
},
e: {
f: [1, 2, 3],
p: 'other-string',
},
},
},
x: {
q: 'some-string',
},
};
const source = {
a: {
b: {
c: {
h: 30,
q: 45,
t: null,
},
m: 20,
e: {
f: [20, 30, 40],
},
},
e: { k: [5, 6] },
},
z: {
y: true,
},
};
const expected = {
a: {
b: {
c: {
d: 1,
g: 10,
h: 30,
q: 45,
t: false,
},
e: {
f: [20, 30, 40],
p: 'other-string',
},
m: 20,
},
e: { k: [5, 6] },
},
x: {
q: 'some-string',
},
z: {
y: true,
},
};
expect(deepMerge(target, source)).toEqual(expected);
});
});

@ -7,3 +7,23 @@ export function noop() {
export function isUndefinedOrEmptyString(value: unknown): boolean {
return value === undefined || value === '';
}
export function isNullOrUndefined(obj) {
return obj === null || obj === undefined;
}
export function exists(obj) {
return !isNullOrUndefined(obj);
}
export function isObject(obj) {
return obj instanceof Object;
}
export function isArray(obj) {
return Array.isArray(obj);
}
export function isObjectAndNotArray(obj) {
return isObject(obj) && !isArray(obj);
}

@ -5,6 +5,7 @@ import { catchError, switchMap } from 'rxjs/operators';
import { SetEnvironment } from '../actions/config.actions';
import { Config } from '../models/config';
import { RestOccurError } from '../actions/rest.actions';
import { deepMerge } from './object-utils';
export function getRemoteEnv(injector: Injector, environment: Partial<Config.Environment>) {
const { remoteEnv } = environment;
@ -18,7 +19,24 @@ export function getRemoteEnv(injector: Injector, environment: Partial<Config.Env
.request<Config.Environment>(method, url, { headers })
.pipe(
catchError(err => store.dispatch(new RestOccurError(err))), // TODO: Condiser get handle function from a provider
switchMap(env => store.dispatch(new SetEnvironment({ ...environment, ...env }))),
switchMap(env => store.dispatch(mergeEnvironments(environment, env, remoteEnv))),
)
.toPromise();
}
function mergeEnvironments(
local: Partial<Config.Environment>,
remote: any,
config: Config.RemoteEnv,
) {
switch (config.mergeStrategy) {
case 'deepmerge':
return new SetEnvironment(deepMerge(local, remote));
case 'overwrite':
case null:
case undefined:
return new SetEnvironment(remote);
default:
return new SetEnvironment(config.mergeStrategy(local, remote));
}
}

@ -10,6 +10,7 @@ export * from './lazy-load-utils';
export * from './localization-utils';
export * from './multi-tenancy-utils';
export * from './number-utils';
export * from './object-utils';
export * from './route-utils';
export * from './rxjs-utils';
export * from './string-utils';

@ -0,0 +1,37 @@
import { isObjectAndNotArray, isNullOrUndefined, exists, isArray, isObject } from './common-utils';
export function deepMerge(target, source) {
if (isObjectAndNotArray(target) && isObjectAndNotArray(source)) {
return deepMergeRecursively(target, source);
} else if (isNullOrUndefined(target) && isNullOrUndefined(source)) {
return {};
} else {
return exists(source) ? source : target;
}
}
function deepMergeRecursively(target, source) {
const shouldNotRecurse =
isNullOrUndefined(target) ||
isNullOrUndefined(source) || // at least one not defined
isArray(target) ||
isArray(source) || // at least one array
!isObject(target) ||
!isObject(source); // at least one not an object
/**
* if we will not recurse any further,
* we will prioritize source if it is a defined value.
*/
if (shouldNotRecurse) {
return exists(source) ? source : target;
}
const keysOfTarget = Object.keys(target);
const keysOfSource = Object.keys(source);
const uniqueKeys = new Set(keysOfTarget.concat(keysOfSource));
return [...uniqueKeys].reduce((retVal, key) => {
retVal[key] = deepMergeRecursively(target[key], source[key]);
return retVal;
}, {});
}
Loading…
Cancel
Save