From 02b4946f0e1f622e9fdb9385a9cab1ddd06da66b Mon Sep 17 00:00:00 2001 From: Nico Jansen Date: Mon, 11 Feb 2019 10:54:38 +0100 Subject: [PATCH] feat(dispose): Add functionality to explicit disposing of dependencies (#1) Add the `dispose` method. Calling `dispose` on an `Injector` automatically disposes all dependencies created by the injector. It also disposes the parent injectors. See readme for more details. --- README.md | 172 +++++++++++++++++++++++++---- package-lock.json | 145 ++++++++++++++++++++++++ package.json | 4 + src/InjectorImpl.ts | 146 ++++++++++++++++-------- src/api/Disposable.ts | 3 + src/api/Injector.ts | 1 + src/index.ts | 1 + test/helpers/initSinon.ts | 8 ++ test/unit/Injector.spec.ts | 221 +++++++++++++++++++++++++++++++------ 9 files changed, 600 insertions(+), 101 deletions(-) create mode 100644 src/api/Disposable.ts create mode 100644 test/helpers/initSinon.ts diff --git a/README.md b/README.md index bd9d78e..0c8144e 100644 --- a/README.md +++ b/README.md @@ -98,27 +98,6 @@ const myService = appInjector.injectClass(MyService); The error messages are a bit cryptic at times, but it sure is better than running into them at runtime. -## ✨ Magic tokens - -Any `Injector` instance can always inject the following tokens: - -| Token name | Token value | Description | -| - | - | - | -| `INJECTOR_TOKEN` | `'$injector'` | Injects the current injector | -| `TARGET_TOKEN` | `'$target'` | The class or function in which the current values is injected, or `undefined` if resolved directly | - -An example: - -```ts -import { rootInjector, Injector, tokens, TARGET_TOKEN, INJECTOR_TOKEN } from 'typed-inject'; - -class Foo { - constructor(injector: Injector<{}>, target: Function | undefined) {} - static inject = tokens(INJECTOR_TOKEN, TARGET_TOKEN); -} - -const foo = rootInjector.inject(Foo); -``` ## 💭 Motivation @@ -144,6 +123,133 @@ Type safe dependency injection works by combining awesome TypeScript features. S Please read [my blog article on Medium](https://medium.com/@jansennico/advanced-typescript-type-safe-dependency-injection-873426e2cc96) if you want to know how this works. +## 👶 Creating child injectors + +The `Injector` interface is responsible for injecting classes of functions. However, `typed-inject` only comes with one implementation: the `rootInjector`. It does not provide any dependencies (expect for [magic tokens](#-magic-tokens)). + +In order to do anything useful with the `rootInjector`, you'll need to create child injectors. This what you do with the `provideXXX` methods. + +```ts +import { rootInjector, tokens } from 'typed-inject'; +function barFactory(foo: number){ return foo + 1}; +barFactory.inject = tokens('foo'); +class Baz { + constructor(bar: number){ console.log(`bar is: ${bar}`)}; + static inject = tokens('bar'); +} + +const childInjector = rootInjector + .provideValue('foo', 42) + .provideFactory('bar', barFactory) + .provideClass('baz', Baz); +``` + +In the example above, a child injector is created. It can provide values for the tokens `'foo'`, `'bar'` and `'baz'`. You can create as many child injectors as you want. + +The `rootInjector` always remains stateless. So don't worry about reusing it in your tests or reusing it for different parts of your application. However, +any ChildInjector _is stateful_. For example, it can [cache the injected value](#-control-lifecycle) or [keep track of stuff to dispose](#-disposing-provided-stuff) + +## ♻ Control lifecycle + +You can determine the lifecycle of dependencies with the third `Scope` parameter of `provideFactory` and `provideClass` methods. + +```ts +function loggerFactory(target: Function | null){ + return getLogger((target && target.name) || 'UNKNOWN'); +} +loggerFactory.inject('target'); +class Foo { + constructor(public log: Logger) { log.info('Foo created'); } + static inject = tokens('log'); +} + +const fooProvider = injector + .provideFactory('log', loggerFactory, Scope.Transient) + .provideClass('foo', Foo, Scope.Singleton); +const foo = fooProvider.resolve('foo'); +const fooCopy = fooProvider.resolve('foo'); +const log = fooProvider.resolve('log'); +console.log(foo === fooCopy); // => true +console.log(log === foo.log); // => false +``` + +A scope has 2 possible values. + +* `Scope.Singleton` (default value) +Use `Scope.Singleton` to enable caching. Every time the dependency needs to be provided by the injector, the same instance is returned. Other injectors will still create their own instances, so it's only a `Singleton` for the specific injector (and child injectors created from it). In other words, +the instance will be _scoped to the `Injector`_ +* `Scope.Transient` +Use `Scope.Transient` to completely disable cashing. You'll always get fresh instances. + +## 🚮 Disposing provided stuff + +Memory in JavaScript is garbage collected, so usually we don't care about cleaning up after ourselves. However, there might be a need to explicit clean up. For example removing a temp folder, or killing a child process. + +As `typed-inject` is responsible for creating (providing) your dependencies, it only makes sense it is also responsible for the disposing of them. + +Any `Injector` has a `dispose` method. If you call it, the injector in turn will call `dispose` on any instance that was ever created from it (if it has one). + +```ts +import { rootInjector } from 'typed-inject'; + +class Foo { + constructor() { console.log('Foo created'); } + dispose(){ console.log('Foo disposed');} +} +const fooProvider = rootInjector.provideClass('foo', Foo); +fooProvider.resolve('foo'); // => "Foo created" +fooProvider.dispose(); // => "Foo disposed" +fooProvider.resolve('foo'); // Error: Injector already disposed +``` + +To help you implementing the `dispose` method correctly, `typed-inject` exports the `Disposable` interface for convenience: + +```ts +import { Disposable } from 'typed-inject'; +class Foo implements Disposable { + dispose(){ } +} +``` + +Using `dispose` on an injector will automatically dispose it's parent injectors as well: + +```ts +import { rootInjector } from 'typed-inject'; +class Foo { } +class Bar { } +const fooProvider = rootInjector.provideClass('foo', Foo); +const barProvider = fooProvider.provideClass('bar', Bar); +barProvider.dispose(); // => fooProvider is also disposed! +fooProvider.resolve('foo'); // => Error: Injector already disposed +``` + +Disposing of provided values is done in order of parent first. So they are disposed in the order of respective `providedXXX` calls. + +Any instance created with `injectClass` or `injectFactory` will _not_ be disposed when `dispose` is called. You were responsible for creating it, so you are also responsible for the disposing of it. In the same vain, anything provided as a value with `providedValue` will also _not_ be disposed when `dispose` is called on it's injector. + +## ✨ Magic tokens + +Any `Injector` instance can always inject the following tokens: + +| Token name | Token value | Description | +| - | - | - | +| `INJECTOR_TOKEN` | `'$injector'` | Injects the current injector | +| `TARGET_TOKEN` | `'$target'` | The class or function in which the current values is injected, or `undefined` if resolved directly | + +An example: + +```ts +import { rootInjector, Injector, tokens, TARGET_TOKEN, INJECTOR_TOKEN } from 'typed-inject'; + +class Foo { + constructor(injector: Injector<{}>, target: Function | undefined) {} + static inject = tokens(INJECTOR_TOKEN, TARGET_TOKEN); +} + +const foo = rootInjector.inject(Foo); +``` + + ## 📖 API reference _Note: some generic parameters are omitted for clarity._ @@ -154,7 +260,7 @@ The `Injector` is the core interface of typed-inject. It provides the The `TContext` generic arguments is a [lookup type](https://blog.mariusschulz.com/2017/01/06/typescript-2-1-keyof-and-lookup-types). The keys in this type are the tokens that can be injected, the values are the exact types of those tokens. For example, if `TContext extends { foo: string, bar: number }`, you can let a token `'foo'` be injected of type `string`, and a token `'bar'` of type `number`. -Typed inject comes with only one implementation. The `rootInjector`. It implements `Injector<{}>` interface, meaning that it does not provide any tokens (except for [magic tokens](#magic-tokens)) Import it with `import { rootInjector } from 'typed-inject'`. From the `rootInjector`, you can create child injectors. +Typed inject comes with only one implementation. The `rootInjector`. It implements `Injector<{}>` interface, meaning that it does not provide any tokens (except for [magic tokens](#-magic-tokens)). Import it with `import { rootInjector } from 'typed-inject'`. From the `rootInjector`, you can create child injectors. See [creating child injectors](#-creating-child-injectors) for more information. Don't worry about reusing the `rootInjector` in your application. It is stateless and read-only, so safe for concurrent use. @@ -227,6 +333,14 @@ Create a child injector that can provide a value using instances of `Class` for Scope is also supported here, for more info, see `provideFactory`. +#### `injector.dispose()` + +Use `dispose` to explicitly dispose the `injector`. It will in turn call `dispose` on it's parent injector as well as calling `dispose` on any dependency created by the injector (if it exists) using `provideClass` or `provideFactory` (**not** `provideValue` or `injectXXX`). + +After a child injector is disposed, you cannot us it any more. Any attempt to use it will result in a `Injector already disposed` error. + +The `rootInjector` will never be disposed. + ### `Scope` The `Scope` enum indicates the scope of a provided injectable (class or factory). Possible values: `Scope.Transient` (new injection per resolve) or `Scope.Singleton` (inject once, and reuse values). It generally defaults to `Singleton`. @@ -260,6 +374,20 @@ In other words, it makes sure that the `inject` tokens is corresponding with the Comparable to `InjectableClass`, but for (non-constructor) functions. +### `Disposable` + +You can implement the `Disposable` interface in your dependencies. It looks like this: + +```ts +interface Disposable { + dispose(): void; +} +``` + +With this, you can let the `Injector` call [your dispose method](#-disposing-provided-stuff). + +_Note:_ This is just a convenience interface. Due to TypeScripts structural typing system `typed-inject` calls your `dispose` method without you having to explicitly implement it. + ## 🤝 Commendation This entire framework would not be possible without the awesome guys working on TypeScript. Guys like [Ryan](https://github.com/RyanCavanaugh), [Anders](https://github.com/ahejlsberg) and the rest of the team: a heartfelt thanks! 💖 diff --git a/package-lock.json b/package-lock.json index e2962ed..e1995c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,6 +128,35 @@ "to-fast-properties": "^2.0.0" } }, + "@sinonjs/commons": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.0.tgz", + "integrity": "sha512-j4ZwhaHmwsCb4DlDOIWnI5YyKDNMoNThsmwEpfHx6a1EpsGZ9qYLxP++LMlmBRjtGptGHFsGItJ768snllFWpA==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/formatio": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.1.0.tgz", + "integrity": "sha512-ZAR2bPHOl4Xg6eklUGpsdiIJ4+J1SNag1DHHrG/73Uz/nVwXqjgUtRPLoS+aVyieN9cSbc0E4LsU984tWcDyNg==", + "dev": true, + "requires": { + "@sinonjs/samsam": "^2 || ^3" + } + }, + "@sinonjs/samsam": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.1.0.tgz", + "integrity": "sha512-IXio+GWY+Q8XUjHUOgK7wx8fpvr7IFffgyXb1bnJFfX3001KmHt35Zq4tp7MXZyjJPCLPuadesDYNk41LYtVjw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.0.2", + "array-from": "^2.1.1", + "lodash.get": "^4.4.2" + } + }, "@stryker-mutator/util": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-0.0.3.tgz", @@ -152,6 +181,22 @@ "integrity": "sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ==", "dev": true }, + "@types/sinon": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.0.5.tgz", + "integrity": "sha512-4DShbH857bZVOY4tPi1RQJNrLcf89hEtU0klZ9aYTMbtt95Ok4XdPqqcbtGOHIbAHMLSzQP8Uw/6qtBBqyloww==", + "dev": true + }, + "@types/sinon-chai": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/sinon-chai/-/sinon-chai-3.2.2.tgz", + "integrity": "sha512-5zSs2AslzyPZdOsbm2NRtuSNAI2aTWzNKOHa/GRecKo7a5efYD7qGcPxMZXQDayVXT2Vnd5waXxBvV31eCZqiA==", + "dev": true, + "requires": { + "@types/chai": "*", + "@types/sinon": "*" + } + }, "JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", @@ -223,6 +268,12 @@ "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", "dev": true }, + "array-from": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", + "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=", + "dev": true + }, "array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", @@ -1387,6 +1438,12 @@ "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", "dev": true }, + "just-extend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.0.2.tgz", + "integrity": "sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==", + "dev": true + }, "levn": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", @@ -1437,6 +1494,12 @@ "integrity": "sha1-74y/QI9uSCaGYzRTBcaswLd4cC4=", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, "lodash.template": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.4.0.tgz", @@ -1469,6 +1532,12 @@ "streamroller": "0.7.0" } }, + "lolex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-3.0.0.tgz", + "integrity": "sha512-hcnW80h3j2lbUfFdMArd5UPA/vxZJ+G8vobd+wg3nVEQA0EigStbYcrG030FJxL6xiDDPEkoMatV9xIh5OecQQ==", + "dev": true + }, "loud-rejection": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", @@ -1610,6 +1679,27 @@ "thenify-all": "^1.0.0" } }, + "nise": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.8.tgz", + "integrity": "sha512-kGASVhuL4tlAV0tvA34yJYZIVihrUt/5bDwpp4tTluigxUr2bBlJeDXmivb6NuEdFkqvdv/Ybb9dm16PSKUhtw==", + "dev": true, + "requires": { + "@sinonjs/formatio": "^3.1.0", + "just-extend": "^4.0.2", + "lolex": "^2.3.2", + "path-to-regexp": "^1.7.0", + "text-encoding": "^0.6.4" + }, + "dependencies": { + "lolex": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz", + "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==", + "dev": true + } + } + }, "nock": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/nock/-/nock-9.6.1.tgz", @@ -3055,6 +3145,23 @@ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "dev": true }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, "path-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", @@ -3286,6 +3393,38 @@ "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true }, + "sinon": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.2.3.tgz", + "integrity": "sha512-i6j7sqcLEqTYqUcMV327waI745VASvYuSuQMCjbAwlpAeuCgKZ3LtrjDxAbu+GjNQR0FEDpywtwGCIh8GicNyg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.3.0", + "@sinonjs/formatio": "^3.1.0", + "@sinonjs/samsam": "^3.0.2", + "diff": "^3.5.0", + "lolex": "^3.0.0", + "nise": "^1.4.8", + "supports-color": "^5.5.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "sinon-chai": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.3.0.tgz", + "integrity": "sha512-r2JhDY7gbbmh5z3Q62pNbrjxZdOAjpsqW/8yxAZRSqLZqowmfGZPGUZPFf3UX36NLis0cv8VEM5IJh9HgkSOAA==", + "dev": true + }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -3560,6 +3699,12 @@ "uuid": "^2.0.1" } }, + "text-encoding": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", + "dev": true + }, "text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", diff --git a/package.json b/package.json index 7452cc7..7c1a834 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,15 @@ "@types/chai": "^4.1.7", "@types/mocha": "^5.2.5", "@types/node": "^10.12.18", + "@types/sinon": "^7.0.5", + "@types/sinon-chai": "^3.2.2", "chai": "^4.2.0", "conventional-changelog-cli": "^2.0.11", "mocha": "^5.2.0", "nyc": "^13.1.0", "rimraf": "^2.6.3", + "sinon": "^7.2.3", + "sinon-chai": "^3.3.0", "source-map-support": "^0.5.10", "stryker": "^0.34.0", "stryker-api": "^0.23.0", diff --git a/src/InjectorImpl.ts b/src/InjectorImpl.ts index e4c6037..73e725c 100644 --- a/src/InjectorImpl.ts +++ b/src/InjectorImpl.ts @@ -3,6 +3,7 @@ import { InjectionToken, INJECTOR_TOKEN, TARGET_TOKEN } from './api/InjectionTok import { InjectableClass, InjectableFunction, Injectable } from './api/Injectable'; import { Injector } from './api/Injector'; import { Exception } from './Exception'; +import { Disposable } from './api/Disposable'; const DEFAULT_SCOPE = Scope.Singleton; @@ -13,22 +14,23 @@ const DEFAULT_SCOPE = Scope.Singleton; ┏━━━━━━━━━━━━━━━━━━┓ ┃ AbstractInjector ┃ ┗━━━━━━━━━━━━━━━━━━┛ - ▲ - ┃ - ┏━━━━━━┻━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ ┃ ┃ - ┏━━━━━━━━┻━━━━━┓ ┏━━━━━━━━━━━━┻━━━━━━━━━━━┓ ┏━━━━━━━┻━━━━━━━┓ - ┃ RootInjector ┃ ┃ AbstractCachedInjector ┃ ┃ ValueInjector ┃ - ┗━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━┛ - ▲ - ┃ - ┏━━━━━━━┻━━━━━━━━━━━━┓ - ┏━━━━━━━━┻━━━━━━━━┓ ┏━━━━━━━━┻━━━━━━┓ - ┃ FactoryInjector ┃ ┃ ClassInjector ┃ - ┗━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━┛ + ▲ + ┃ + ┏━━━━━━━━┻━━━━━━━━┓ + ┃ ┃ + ┏━━━━━━━━┻━━━━━┓ ┏━━━━━━━┻━━━━━━━┓ + ┃ RootInjector ┃ ┃ ChildInjector ┃ + ┗━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━┛ + ▲ + ┃ + ┏━━━━━━━━━━━━━━━━━┻━┳━━━━━━━━━━━━━━━━┓ + ┏━━━━━━━━┻━━━━━━━━┓ ┏━━━━━━━━┻━━━━━━┓ ┏━━━━━━━┻━━━━━━━┓ + ┃ FactoryInjector ┃ ┃ ClassInjector ┃ ┃ ValueInjector ┃ + ┗━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━┛ */ abstract class AbstractInjector implements Injector { + public injectClass[]>(Class: InjectableClass, providedIn?: Function): R { try { const args: any[] = this.resolveParametersToInject(Class, providedIn); @@ -56,74 +58,110 @@ abstract class AbstractInjector implements Injector { case INJECTOR_TOKEN: return this as any; default: - return this.resolve(key, injectable); + return this.resolveInternal(key, injectable); } }); } public provideValue(token: Token, value: R): AbstractInjector<{ [k in Token]: R; } & TContext> { - return new ValueInjector(this, token, value); + return new ValueProvider(this, token, value); } public provideClass[]>(token: Token, Class: InjectableClass, scope = DEFAULT_SCOPE) : AbstractInjector<{ [k in Token]: R; } & TContext> { - return new ClassInjector(this, token, scope, Class); + return new ClassProvider(this, token, scope, Class); } public provideFactory[]>(token: Token, factory: InjectableFunction, scope = DEFAULT_SCOPE) : AbstractInjector<{ [k in Token]: R; } & TContext> { - return new FactoryInjector(this, token, scope, factory); + return new FactoryProvider(this, token, scope, factory); } - public resolve(token: Token, providedIn?: Function): TContext[Token] { - return this.resolveInternal(token, providedIn); - + public resolve(token: Token, target?: Function): TContext[Token] { + return this.resolveInternal(token, target); } + public abstract dispose(): void; + protected abstract resolveInternal(token: Token, target?: Function): TContext[Token]; } +function isDisposable(maybeDisposable: any): maybeDisposable is Disposable { + return maybeDisposable && maybeDisposable.dispose && typeof maybeDisposable.dispose === 'function'; +} + class RootInjector extends AbstractInjector<{}> { public resolveInternal(token: never) : never { throw new Error(`No provider found for "${token}"!.`); } + public dispose() { + // noop, root injector cannot be disposed + } } -type ChildContext = TParentContext & { [k in Token]: R }; +type ChildContext = TParentContext & { [K in CurrentToken]: TProvided }; + +abstract class ChildInjector extends AbstractInjector> { -class ValueInjector extends AbstractInjector> { + private cached: { value?: any } | undefined; + private readonly disposables = new Set(); - constructor(private readonly parent: AbstractInjector, private readonly token: Token, private readonly value: R) { + constructor(protected readonly parent: AbstractInjector, + protected readonly token: CurrentToken, + private readonly scope: Scope) { super(); } - protected resolveInternal>(token: SearchToken, target: Function) - : ChildContext[SearchToken] { - if (token === this.token) { - return this.value as any; - } else { - return this.parent.resolve(token as any, target) as any; - } + protected abstract responsibleForDisposing: boolean; + protected abstract result(target: Function | undefined): TProvided; + + protected isDisposed = false; + + public injectClass>[]>(Class: InjectableClass, R, Tokens>, providedIn?: Function): R { + this.throwIfDisposed(Class); + return super.injectClass(Class, providedIn); + } + public injectFunction>[]>(fn: InjectableFunction, R, Tokens>, providedIn?: Function): R { + this.throwIfDisposed(fn); + return super.injectFunction(fn, providedIn); } -} -abstract class AbstractCachedInjector extends AbstractInjector> { + public resolve>(token: Token, target?: Function): ChildContext[Token] { + this.throwIfDisposed(token); + return super.resolve(token, target); + } - private cached: { value?: any } | undefined; + private throwIfDisposed(injectableOrToken: Function | Symbol | number | string | undefined) { + if (this.isDisposed) { + throw new Exception(`Injector is already disposed. Please don't use it anymore.${additionalErrorMessage()}`); + } + function additionalErrorMessage() { + if (typeof injectableOrToken === 'function') { + return ` Tried to inject "${injectableOrToken.name}".`; + } else { + return ` Tried to resolve "${injectableOrToken}".`; + } + } + } - constructor(protected readonly parent: AbstractInjector, - protected readonly token: Token, - private readonly scope: Scope) { - super(); + public dispose() { + if (!this.isDisposed) { + this.parent.dispose(); + this.isDisposed = true; + this.disposables.forEach(disposable => disposable.dispose()); + } } - protected resolveInternal>(token: SearchToken, target: Function | undefined) - : ChildContext[SearchToken] { + protected resolveInternal>(token: SearchToken, target: Function | undefined) + : ChildContext[SearchToken] { if (token === this.token) { if (this.cached) { return this.cached.value as any; } else { const value = this.result(target); + if (this.responsibleForDisposing && isDisposable(value)) { + this.disposables.add(value); + } if (this.scope === Scope.Singleton) { this.cached = { value }; } @@ -134,31 +172,43 @@ abstract class AbstractCachedInjector e } } - protected abstract result(target: Function | undefined): R; } -class FactoryInjector[]> extends AbstractCachedInjector { +class ValueProvider extends ChildInjector { + constructor(parent: AbstractInjector, token: ProvidedToken, private readonly value: TProvided) { + super(parent, token, Scope.Transient); + } + protected result(): TProvided { + return this.value; + } + protected readonly responsibleForDisposing = false; +} + +class FactoryProvider[]> + extends ChildInjector { constructor(parent: AbstractInjector, - token: Token, + token: ProvidedToken, scope: Scope, - private readonly injectable: InjectableFunction) { + private readonly injectable: InjectableFunction) { super(parent, token, scope); } - protected result(target: Function): R { + protected result(target: Function): TProvided { return this.injectFunction(this.injectable as any, target); } + protected readonly responsibleForDisposing = true; } -class ClassInjector[]> extends AbstractCachedInjector { +class ClassProvider[]> extends ChildInjector { constructor(parent: AbstractInjector, - token: Token, + token: ProvidedToken, scope: Scope, - private readonly injectable: InjectableClass) { + private readonly injectable: InjectableClass) { super(parent, token, scope); } - protected result(target: Function): R { + protected result(target: Function): TProvided { return this.injectClass(this.injectable as any, target); } + protected readonly responsibleForDisposing = true; } export const rootInjector = new RootInjector() as Injector<{}>; diff --git a/src/api/Disposable.ts b/src/api/Disposable.ts new file mode 100644 index 0000000..315397c --- /dev/null +++ b/src/api/Disposable.ts @@ -0,0 +1,3 @@ +export interface Disposable { + dispose(): void; +} diff --git a/src/api/Injector.ts b/src/api/Injector.ts index 81677a0..baa70ec 100644 --- a/src/api/Injector.ts +++ b/src/api/Injector.ts @@ -11,4 +11,5 @@ export interface Injector { : Injector<{ [k in Token]: R } & TContext>; provideFactory[]>(token: Token, factory: InjectableFunction, scope?: Scope) : Injector<{ [k in Token]: R } & TContext>; + dispose(): void; } diff --git a/src/index.ts b/src/index.ts index 0e6c995..de8eff2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,4 @@ export * from './api/Injector'; export * from './api/Scope'; export * from './InjectorImpl'; export * from './tokens'; +export * from './api/Disposable'; diff --git a/test/helpers/initSinon.ts b/test/helpers/initSinon.ts new file mode 100644 index 0000000..0898947 --- /dev/null +++ b/test/helpers/initSinon.ts @@ -0,0 +1,8 @@ +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import * as sinon from 'sinon'; + +chai.use(sinonChai); +afterEach(() => { + sinon.restore(); +}); diff --git a/test/unit/Injector.spec.ts b/test/unit/Injector.spec.ts index c758c3e..4b51eb6 100644 --- a/test/unit/Injector.spec.ts +++ b/test/unit/Injector.spec.ts @@ -5,6 +5,8 @@ import { rootInjector } from '../../src/InjectorImpl'; import { TARGET_TOKEN, INJECTOR_TOKEN } from '../../src/api/InjectionToken'; import { Exception } from '../../src/Exception'; import { Scope } from '../../src/api/Scope'; +import * as sinon from 'sinon'; +import { Disposable } from '../../src/api/Disposable'; describe('InjectorImpl', () => { @@ -132,9 +134,56 @@ describe('InjectorImpl', () => { const actualFoo = barBazInjector.injectClass(Foo); expect(actualFoo.injector).eq(barBazInjector); }); + }); - describe('ValueInjector', () => { + describe('ChildInjector', () => { + + it('should cache the value if scope = Singleton', () => { + // Arrange + let n = 0; + function count() { + return n++; + } + count.inject = tokens(); + const countInjector = rootInjector.provideFactory('count', count); + class Injectable { + constructor(public count: number) { } + public static inject = tokens('count'); + } + + // Act + const first = countInjector.injectClass(Injectable); + const second = countInjector.injectClass(Injectable); + + // Assert + expect(first.count).eq(second.count); + }); + + it('should _not_ cache the value if scope = Transient', () => { + // Arrange + let n = 0; + function count() { + return n++; + } + count.inject = tokens(); + const countInjector = rootInjector.provideFactory('count', count, Scope.Transient); + class Injectable { + constructor(public count: number) { } + public static inject = tokens('count'); + } + + // Act + const first = countInjector.injectClass(Injectable); + const second = countInjector.injectClass(Injectable); + + // Assert + expect(first.count).eq(0); + expect(second.count).eq(1); + }); + }); + + describe('ValueProvider', () => { it('should be able to provide a value', () => { const sut = rootInjector.provideValue('foo', 42); const actual = sut.injectClass(class { @@ -150,9 +199,17 @@ describe('InjectorImpl', () => { expect(sut.resolve('bar')).eq('baz'); expect(sut.resolve('foo')).eq(42); }); + it('should throw after disposed', () => { + const sut = rootInjector + .provideValue('foo', 42); + sut.dispose(); + expect(() => sut.resolve('foo')).throws('Injector is already disposed. Please don\'t use it anymore. Tried to resolve "foo".'); + expect(() => sut.injectClass(class Bar { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "Bar".'); + expect(() => sut.injectFunction(function baz() { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "baz".'); + }); }); - describe('FactoryInjector', () => { + describe('FactoryProvider', () => { it('should be able to provide the return value of the factoryMethod', () => { const expectedValue = { foo: 'bar' }; function foobar() { @@ -172,56 +229,158 @@ describe('InjectorImpl', () => { function answer() { return 42; } - const factoryInjector = rootInjector.provideFactory('answer', answer); - const actual = factoryInjector.injectClass(class { + const factoryProvider = rootInjector.provideFactory('answer', answer); + const actual = factoryProvider.injectClass(class { constructor(public injector: Injector<{ answer: number }>, public answer: number) { } public static inject = tokens(INJECTOR_TOKEN, 'answer'); }); - expect(actual.injector).eq(factoryInjector); + expect(actual.injector).eq(factoryProvider); expect(actual.answer).eq(42); }); - it('should cache the value if scope = Singleton', () => { + it('should throw after disposed', () => { + const sut = rootInjector.provideFactory('answer', function answer() { + return 42; + }); + sut.dispose(); + expect(() => sut.resolve('answer')).throws('Injector is already disposed. Please don\'t use it anymore. Tried to resolve "answer".'); + expect(() => sut.injectClass(class Bar { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "Bar".'); + expect(() => sut.injectFunction(function baz() { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "baz".'); + }); + }); + + describe('ClassProvider', () => { + it('should throw after disposed', () => { + const sut = rootInjector.provideClass('foo', class Foo { }); + sut.dispose(); + expect(() => sut.resolve('foo')).throws('Injector is already disposed. Please don\'t use it anymore. Tried to resolve "foo".'); + expect(() => sut.injectClass(class Bar { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "Bar".'); + expect(() => sut.injectFunction(function baz() { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "baz".'); + }); + }); + + describe('dispose', () => { + + it('should dispose all disposable singleton dependencies', () => { // Arrange - let n = 0; - function count() { - return n++; + class Foo { + public dispose2 = sinon.stub(); + public dispose = sinon.stub(); } - count.inject = tokens(); - const countInjector = rootInjector.provideFactory('count', count); - class Injectable { - constructor(public count: number) { } - public static inject = tokens('count'); + function barFactory(): Disposable & { dispose3(): void; } { + return { dispose: sinon.stub(), dispose3: sinon.stub() }; + } + class Baz { + constructor(public readonly bar: Disposable & { dispose3(): void; }, public readonly foo: Foo) { } + public static inject = tokens('bar', 'foo'); } + const bazInjector = rootInjector + .provideClass('foo', Foo) + .provideFactory('bar', barFactory); + const baz = bazInjector + .injectClass(Baz); // Act - const first = countInjector.injectClass(Injectable); - const second = countInjector.injectClass(Injectable); + bazInjector.dispose(); // Assert - expect(first.count).eq(second.count); + expect(baz.bar.dispose).called; + expect(baz.foo.dispose).called; + expect(baz.foo.dispose2).not.called; + expect(baz.bar.dispose3).not.called; }); - it('should _not_ cache the value if scope = Transient', () => { - // Arrange - let n = 0; - function count() { - return n++; + it('should also dispose transient dependencies', () => { + class Foo { public dispose = sinon.stub(); } + function barFactory(): Disposable { return { dispose: sinon.stub() }; } + class Baz { + constructor(public readonly bar: Disposable, public readonly foo: Foo) { } + public static inject = tokens('bar', 'foo'); } - count.inject = tokens(); - const countInjector = rootInjector.provideFactory('count', count, Scope.Transient); - class Injectable { - constructor(public count: number) { } - public static inject = tokens('count'); + const bazInjector = rootInjector + .provideClass('foo', Foo, Scope.Transient) + .provideFactory('bar', barFactory, Scope.Transient); + const baz = bazInjector + .injectClass(Baz); + + // Act + bazInjector.dispose(); + + // Assert + expect(baz.bar.dispose).called; + expect(baz.foo.dispose).called; + }); + + it('should dispose dependencies in correct order', () => { + class Foo { public dispose = sinon.stub(); } + class Bar { public dispose = sinon.stub(); } + class Baz { + constructor(public readonly bar: Bar, public readonly foo: Foo) { } + public static inject = tokens('bar', 'foo'); + public dispose = sinon.stub(); } + const bazProvider = rootInjector + .provideClass('foo', Foo, Scope.Transient) + .provideClass('bar', Bar) + .provideClass('baz', Baz); + const baz = bazProvider.resolve('baz'); + const newFoo = bazProvider.resolve('foo'); // Act - const first = countInjector.injectClass(Injectable); - const second = countInjector.injectClass(Injectable); + bazProvider.dispose(); // Assert - expect(first.count).eq(0); - expect(second.count).eq(1); + expect(baz.foo.dispose).calledBefore(baz.bar.dispose); + expect(newFoo.dispose).calledBefore(baz.bar.dispose); + expect(baz.bar.dispose).calledBefore(baz.dispose); + }); + + it('should not dispose injected classes or functions', () => { + class Foo { public dispose = sinon.stub(); } + function barFactory(): Disposable { return { dispose: sinon.stub() }; } + const foo = rootInjector.injectClass(Foo); + const bar = rootInjector.injectFunction(barFactory); + rootInjector.dispose(); + expect(foo.dispose).not.called; + expect(bar.dispose).not.called; + }); + + it('should not dispose providedValues', () => { + const disposable: Disposable = { dispose: sinon.stub() }; + const disposableProvider = rootInjector.provideValue('disposable', disposable); + disposableProvider.resolve('disposable'); + disposableProvider.dispose(); + expect(disposable.dispose).not.called; + }); + + it('should not break on non-disposable dependencies', () => { + class Foo { public dispose = true; } + function barFactory(): { dispose: string } { return { dispose: 'no-fn' }; } + class Baz { + constructor(public readonly bar: { dispose: string }, public readonly foo: Foo) { } + public static inject = tokens('bar', 'foo'); + } + const bazInjector = rootInjector + .provideClass('foo', Foo) + .provideFactory('bar', barFactory); + const baz = bazInjector + .injectClass(Baz); + + // Act + bazInjector.dispose(); + + // Assert + expect(baz.bar.dispose).eq('no-fn'); + expect(baz.foo.dispose).eq(true); + }); + + it('should not dispose dependencies twice', () => { + const fooProvider = rootInjector + .provideClass('foo', class Foo implements Disposable { public dispose = sinon.stub(); }); + const foo = fooProvider.resolve('foo'); + fooProvider.dispose(); + fooProvider.dispose(); + expect(foo.dispose).calledOnce; }); });