Skip to content

Commit 583fea6

Browse files
langium DI: fixed erroneous source module modification in 'Module.merge' (#1939)
* added API documentation corresponding tests
1 parent a317db0 commit 583fea6

File tree

2 files changed

+124
-8
lines changed

2 files changed

+124
-8
lines changed

packages/langium/src/dependency-injection.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ export type Module<I, T = I> = {
2020
}
2121

2222
export namespace Module {
23+
/**
24+
* Merges two dependency injection modules into a new (third) one that is returned.
25+
* At that `m1` and `m2` stay unchanged. Therefore, `m1` is deep-copied first,
26+
* and m2 is merged onto the copy afterwards.
27+
*
28+
* Note that the leaf values of `m1` and `m2`, i.e. the service constructor functions,
29+
* cannot be copied generically, since they are functions. They are shared by the source and merged modules.
30+
*
31+
* @returns the merged module being a deep copy of `m1` with `m2` merged onto it.
32+
*/
2333
export const merge = <M1, M2, R extends M1 & M2>(m1: Module<R, M1>, m2: Module<R, M2>) => (_merge(_merge({}, m1), m2) as Module<R, M1 & M2>);
2434
}
2535

@@ -142,13 +152,28 @@ function _resolve<I, T>(obj: any, prop: string | symbol | number, module: Module
142152
*/
143153
function _merge(target: Module<any>, source?: Module<any>): Module<unknown> {
144154
if (source) {
145-
for (const [key, value2] of Object.entries(source)) {
146-
if (value2 !== undefined) {
147-
const value1 = target[key];
148-
if (value1 !== null && value2 !== null && typeof value1 === 'object' && typeof value2 === 'object') {
149-
target[key] = _merge(value1, value2);
155+
for (const [key, sourceValue] of Object.entries(source)) {
156+
if (sourceValue !== undefined && sourceValue !== null) {
157+
if (typeof sourceValue === 'object') {
158+
const targetValue = target[key];
159+
160+
if (typeof targetValue === 'object' && targetValue !== null) {
161+
// in case both values are real (non-null) objects merge them recursively
162+
target[key] = _merge(targetValue, sourceValue);
163+
} else {
164+
// in case 'target[key]' is not a non-null object
165+
// we overwrite any existing value with a deep copy of 'sourceValue'
166+
// by recursively calling this function with a new 'target' object to be populated
167+
// that is assigned to 'target[key]' afterwards
168+
target[key] = _merge({}, sourceValue);
169+
}
150170
} else {
151-
target[key] = value2;
171+
// in case 'sourceValue' is defined and assigned (non-null) but not an object
172+
// we assume it to be a service constructor function according to the Module<I> type definition
173+
target[key] = sourceValue;
174+
// note the following for such service constructor functions:
175+
// 'target[key]' will now reference the same function object being referenced by 'source[key]'.
176+
// This is accepted here, since function objects cannot be safely copied in general.
152177
}
153178
}
154179
}

packages/langium/test/dependency-injection.test.ts

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@
77
/* eslint-disable @typescript-eslint/no-explicit-any */
88
/* eslint-disable @typescript-eslint/no-unused-vars */
99

10-
import type { Module } from 'langium';
10+
import { Module, inject } from 'langium';
1111
import { describe, expect, test } from 'vitest';
12-
import { inject } from 'langium';
1312

1413
describe('A dependency type', () => {
1514

@@ -357,3 +356,95 @@ describe('The inject result', () => {
357356
});
358357

359358
});
359+
360+
describe('The Module.merge function', () => {
361+
362+
const moduleA: Module<{ a: unknown, b: unknown }> = Object.freeze({ a: () => 1, b: () => ({ b: 2 }) });
363+
const moduleB: Module<{ c: unknown, d: unknown }> = Object.freeze({ c: () => () => 3, d: () => [ 4, 5] });
364+
365+
// Recall the contract: 'moduleA' and 'moduleB' shall stay unchanged during the merge, which is enforced by them being frozen.
366+
367+
test('Merge two flat modules', () => {
368+
const merged = Module.merge(moduleA, moduleB);
369+
370+
expect(merged).toEqual({
371+
...moduleA,
372+
...moduleB,
373+
});
374+
375+
// 'merged' is supposed to be a new object, different from moduleA and moduleB
376+
expect(merged).not.toBe(moduleA);
377+
expect(merged).not.toBe(moduleB);
378+
});
379+
380+
test('Merge two nested modules with different parent keys', () => {
381+
type NestedModule = {
382+
subModuleA: typeof moduleA,
383+
subModuleB: typeof moduleB
384+
}
385+
386+
const m1: Partial<NestedModule> = { subModuleA: moduleA };
387+
const m2: Partial<NestedModule> = { subModuleB: moduleB };
388+
389+
const merged = Module.merge(m1, m2);
390+
391+
expect(merged).toEqual({
392+
subModuleA: moduleA,
393+
subModuleB: moduleB
394+
});
395+
396+
// 'merged.subModuleA' and 'merged.subModuleB' are supposed to be new objects, different from moduleA and moduleB
397+
expect(merged.subModuleA).not.toBe(moduleA);
398+
expect(merged.subModuleB).not.toBe(moduleB);
399+
});
400+
401+
test('Merge two nested modules with equal parent keys and different child keys', () => {
402+
type NestedModule = {
403+
subModule: Partial<typeof moduleA & typeof moduleB>,
404+
}
405+
406+
const m1: Partial<NestedModule> = { subModule: moduleA };
407+
const m2: Partial<NestedModule> = { subModule: moduleB };
408+
409+
const merged = Module.merge(m1, m2);
410+
411+
expect(merged).toEqual({
412+
subModule: {
413+
...moduleA,
414+
...moduleB
415+
}
416+
});
417+
418+
// 'merged.subModule' is supposed to be a new object, different from moduleA and moduleB
419+
expect(merged.subModule).not.toBe(moduleA);
420+
expect(merged.subModule).not.toBe(moduleB);
421+
422+
// besides, 'moduleA' and 'moduleB' shall stay unchanged during the merge,
423+
// which is enforced by them being frozen;
424+
});
425+
426+
test('Merge two nested modules with equal parent keys and equal child keys', () => {
427+
type NestedModule = {
428+
subModule: Partial<typeof moduleA>,
429+
}
430+
431+
const m1: Partial<NestedModule> = { subModule: moduleA };
432+
const m2: Partial<NestedModule> = { subModule: Object.freeze({ a: moduleB.c, b: moduleB.d}) };
433+
434+
const merged = Module.merge(m1, m2);
435+
436+
expect(merged).toEqual({
437+
subModule: {
438+
a: moduleB.c,
439+
b: moduleB.d
440+
}
441+
});
442+
443+
// 'merged.subModule' is supposed to be a new object, different from moduleA and m2.subModule
444+
expect(merged.subModule).not.toBe(moduleA);
445+
expect(merged.subModule).not.toBe(m2.subModule);
446+
447+
// besides, 'moduleA' and 'm2.subModule' shall stay unchanged during the merge,
448+
// which is enforced by them being frozen;
449+
});
450+
});

0 commit comments

Comments
 (0)