Skip to content

Commit

Permalink
feat(di): allow registering multiple providers for the same token
Browse files Browse the repository at this point in the history
Closes: #1693
  • Loading branch information
Romakita committed Aug 29, 2022
1 parent f862616 commit 5c21b57
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 160 deletions.
55 changes: 55 additions & 0 deletions docs/docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,61 @@ This is possible with the combination of @@Opts@@ and @@UseOpts@@ decorators.
to `ProviderScope.INSTANCE`.
:::

## Inject many provider <Badge text="6.129.0+"/>

This feature lets to simplify the dependency management while working on multiple implementations of the same interface with type code.

If users use the same token when registering providers, the IoC container should exchange a token for a list of instances. Let's consider the following real example:

```typescript
interface Bar {
type: string;
}

const Bar: unique symbol = Symbol("Bar");

@Injectable({type: Bar})
class Foo implements Bar {
private readonly type = "foo";
}

@Injectable({type: Bar})
class Baz implements Bar {
private readonly type = "baz";
}
```

Now as a user, I would like to create a [registry](https://www.martinfowler.com/eaaCatalog/registry.html) and retrieve an appropriate instance by type:

```typescript
@Controller("/some")
export class SomeController {
constructor(@Inject(Bar) private readonly bars: Bar[]) {}

@Post()
async create(@Body("type") type: "baz" | "foo") {
const bar: Bar | undefined = this.bars.find((x) => x.type === type);
}
}
```

or in the following way as well:

```typescript
@Controller("/some")
export class SomeController {
constructor(private readonly injector: InjectorService) {}

@Post()
async create(@Body("type") type: "baz" | "foo") {
const bars: Bar[] = this.injector.getAll<Bar>(Bar);
const bar: Bar | undefined = bars.find((x) => x.type === type);

// your code
}
}
```

## Override an injection token <Badge text="6.93.0+"/>

By default, the `@Injectable()` are register a class provider with an injection token obtained from the metadata generated by Typescript.
Expand Down
8 changes: 4 additions & 4 deletions packages/di/coverage.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"statements": 99.37,
"branches": 93.54,
"functions": 99.22,
"lines": 99.48
"statements": 99,
"branches": 93.12,
"lines": 99.1,
"functions": 98.46
}
193 changes: 119 additions & 74 deletions packages/di/src/decorators/inject.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {catchError, descriptorOf, Metadata, Store} from "@tsed/core";
import {Required} from "@tsed/schema";
import {Inject, Injectable, InjectorService} from "../../src";
import {INJECTABLE_PROP} from "../constants/constants";
import {descriptorOf} from "@tsed/core";
import {Inject, Injectable, InjectorService, registerProvider} from "../../src";

describe("@Inject()", () => {
describe("used on unsupported decorator type", () => {
Expand All @@ -24,29 +22,8 @@ describe("@Inject()", () => {
});
});

describe("used on method", () => {
it("should store metadata", () => {
// GIVEN
class Test {
test() {}
}

// WHEN
Inject()(Test.prototype, "test", descriptorOf(Test, "test"));

// THEN
const store = Store.from(Test).get(INJECTABLE_PROP);
expect(store).toEqual({
test: {
bindingType: "method",
propertyKey: "test"
}
});
});
});

describe("used on property", () => {
it("should store metadata from inferred type", async () => {
describe("@property", () => {
it("should inject service", async () => {
// GIVEN
@Injectable()
class Test {
Expand All @@ -60,7 +37,7 @@ describe("@Inject()", () => {
expect(instance).toBeInstanceOf(Test);
expect(instance.test).toBeInstanceOf(InjectorService);
});
it("should store metadata from given type", async () => {
it("should inject service with the given type", async () => {
// GIVEN
@Injectable()
class Test {
Expand All @@ -74,67 +51,135 @@ describe("@Inject()", () => {
expect(instance).toBeInstanceOf(Test);
expect(instance.test).toBeInstanceOf(InjectorService);
});
it("should catch error when an object is given as token provider", async () => {
// GIVEN
@Injectable()
class Test {
@Required()
test: Object;
it("should inject many services", async () => {
const TOKEN_GROUPS = Symbol.for("groups:1");

interface InterfaceGroup {
type: string;
}

const error = catchError(() => {
Inject()(Test.prototype, "test");
});
@Injectable({
type: TOKEN_GROUPS
})
class MyService1 implements InterfaceGroup {
readonly type: string = "service1";

expect(error?.message).toMatchSnapshot();
});
});
constructor(@Inject(InjectorService) readonly injector: InjectorService) {}
}

describe("used on constructor/params", () => {
beforeAll(() => {
jest.spyOn(Metadata, "getParamTypes").mockReturnValue([]);
jest.spyOn(Metadata, "setParamTypes").mockReturnValue(undefined);
});
afterAll(() => {
jest.resetAllMocks();
});
@Injectable({
type: TOKEN_GROUPS
})
class MyService2 implements InterfaceGroup {
readonly type: string = "service2";

it("should call Metadata.getParamTypes()", () => {
// GIVEN
class Test {
test() {}
constructor(@Inject(InjectorService) readonly injector: InjectorService) {}
}

// WHEN
Inject(String)(Test.prototype, undefined, 0);
const TokenAsync = Symbol.for("MyService2");

// THEN
expect(Metadata.getParamTypes).toBeCalledWith(Test.prototype, undefined);
expect(Metadata.setParamTypes).toBeCalledWith(Test.prototype, undefined, [String]);
registerProvider({
provide: TokenAsync,
type: TOKEN_GROUPS,
deps: [],
async useAsyncFactory() {
return {
type: "async"
};
}
});

@Injectable()
class MyInjectable {
@Inject(TOKEN_GROUPS)
instances: InterfaceGroup[];
}

const injector = new InjectorService();

await injector.load();

const instance = await injector.invoke<MyInjectable>(MyInjectable);

expect(instance.instances).toBeInstanceOf(Array);
expect(instance.instances).toHaveLength(3);
expect(instance.instances[0].type).toEqual("service1");
expect(instance.instances[1].type).toEqual("service2");
expect(instance.instances[2].type).toEqual("async");
});
});

describe("used on method/params", () => {
beforeAll(() => {
jest.spyOn(Metadata, "getParamTypes").mockReturnValue([]);
jest.spyOn(Metadata, "setParamTypes").mockReturnValue(undefined);
});
afterAll(() => {
jest.resetAllMocks();
describe("@constructorParameters", () => {
describe("when token is given on constructor", () => {
it("should inject the expected provider", async () => {
@Injectable()
class MyInjectable {
constructor(@Inject(InjectorService) readonly injector: InjectorService) {}
}

const injector = new InjectorService();
const instance = await injector.invoke<MyInjectable>(MyInjectable);

expect(instance.injector).toBeInstanceOf(InjectorService);
});
});

it("should call Metadata.getParamTypes()", () => {
// GIVEN
class Test {
test() {}
}
describe("when a group token is given on constructor", () => {
it("should inject the expected provider", async () => {
const TOKEN_GROUPS = Symbol.for("groups:2");

// WHEN
Inject(String)(Test.prototype, "propertyKey", 0);
interface InterfaceGroup {
type: string;
}

// THEN
expect(Metadata.getParamTypes).toBeCalledWith(Test.prototype, "propertyKey");
expect(Metadata.setParamTypes).toBeCalledWith(Test.prototype, "propertyKey", [String]);
@Injectable({
type: TOKEN_GROUPS
})
class MyService1 implements InterfaceGroup {
readonly type: string = "service1";

constructor(@Inject(InjectorService) readonly injector: InjectorService) {}
}

@Injectable({
type: TOKEN_GROUPS
})
class MyService2 implements InterfaceGroup {
readonly type: string = "service2";

constructor(@Inject(InjectorService) readonly injector: InjectorService) {}
}

const TokenAsync = Symbol.for("MyService1");

registerProvider({
provide: TokenAsync,
type: TOKEN_GROUPS,
deps: [],
async useAsyncFactory() {
return {
type: "async"
};
}
});

@Injectable()
class MyInjectable {
constructor(@Inject(TOKEN_GROUPS) readonly instances: InterfaceGroup[]) {}
}

const injector = new InjectorService();

await injector.load();

const instance = await injector.invoke<MyInjectable>(MyInjectable);

expect(instance.instances).toBeInstanceOf(Array);
expect(instance.instances).toHaveLength(3);
expect(instance.instances[0].type).toEqual("service1");
expect(instance.instances[1].type).toEqual("service2");
expect(instance.instances[2].type).toEqual("async");
});
});
});
});
44 changes: 28 additions & 16 deletions packages/di/src/decorators/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {decoratorTypeOf, DecoratorTypes, isPromise, Metadata, Store, Unsupported
import {DI_PARAM_OPTIONS, INJECTABLE_PROP} from "../constants/constants";
import {InvalidPropertyTokenError} from "../errors/InvalidPropertyTokenError";
import type {InjectablePropertyOptions} from "../interfaces/InjectableProperties";
import {TokenProvider} from "../interfaces/TokenProvider";
import {getContext} from "../utils/asyncHookContext";

export function injectProperty(target: any, propertyKey: string, options: Partial<InjectablePropertyOptions>) {
Expand All @@ -27,28 +28,30 @@ export function injectProperty(target: any, propertyKey: string, options: Partia
* }
* ```
*
* @param symbol
* @param token A token provider or token provider group
* @param onGet Use the given name method to inject
* @returns {Function}
* @decorator
*/
export function Inject(symbol?: any, onGet = (bean: any) => bean): Function {
export function Inject(token?: TokenProvider | (() => TokenProvider), onGet = (bean: any) => bean): Function {
return (target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<Function> | number): any | void => {
const bindingType = decoratorTypeOf([target, propertyKey, descriptor]);

switch (bindingType) {
case DecoratorTypes.PARAM:
case DecoratorTypes.PARAM_CTOR:
if (symbol) {
if (token) {
const paramTypes = Metadata.getParamTypes(target, propertyKey);
const type = paramTypes[descriptor as number];

paramTypes[descriptor as number] = type === Array ? [token] : token;

paramTypes[descriptor as number] = symbol;
Metadata.setParamTypes(target, propertyKey, paramTypes);
}
break;

case DecoratorTypes.PROP:
const useType = symbol || Metadata.getType(target, propertyKey);
const useType = token || Metadata.getType(target, propertyKey);
const originalType = Metadata.getType(target, propertyKey);

if (useType === Object) {
throw new InvalidPropertyTokenError(target, propertyKey);
Expand All @@ -58,6 +61,25 @@ export function Inject(symbol?: any, onGet = (bean: any) => bean): Function {
resolver(injector, locals, {options, ...invokeOptions}) {
locals.set(DI_PARAM_OPTIONS, {...options});

if (originalType === Array) {
let bean: any[] | undefined;

if (!bean) {
bean = injector.getMany(token, locals, invokeOptions);
locals.delete(DI_PARAM_OPTIONS);
}

bean.forEach((instance: any, index) => {
if (isPromise(bean)) {
instance.then((result: any) => {
bean![index] = result;
});
}
});

return () => onGet(bean);
}

let bean: any;

if (!bean) {
Expand All @@ -76,16 +98,6 @@ export function Inject(symbol?: any, onGet = (bean: any) => bean): Function {
});
break;

case DecoratorTypes.METHOD:
Store.from(target).merge(INJECTABLE_PROP, {
[propertyKey]: {
bindingType,
propertyKey
}
});

return descriptor;

default:
throw new UnsupportedDecoratorType(Inject, [target, propertyKey, descriptor]);
}
Expand Down
Loading

0 comments on commit 5c21b57

Please sign in to comment.