Skip to content
This repository was archived by the owner on Feb 26, 2024. It is now read-only.

feat(injector): implement a different approach to injector scopes #50

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/annotations.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ import {isFunction} from './util';
// A class constructor can ask for this.
class SuperConstructor {}

// All scopes must
// extend this
class ScopeAnnotation {}

// A built-in scope.
// Never cache.
class TransientScope {}
class TransientScope extends ScopeAnnotation {}

class Inject {
constructor(...tokens) {
Expand Down Expand Up @@ -96,7 +100,10 @@ function readAnnotations(fn) {
// - token (anything)
// - isPromise (boolean)
// - isLazy (boolean)
params: []
params: [],

// List of Scopes
scopes: []
};

if (fn.annotations && fn.annotations.length) {
Expand All @@ -111,6 +118,10 @@ function readAnnotations(fn) {
collectedAnnotations.provide.token = annotation.token;
collectedAnnotations.provide.isPromise = annotation.isPromise;
}

if (annotation instanceof ScopeAnnotation) {
collectedAnnotations.scopes.push(annotation);
}
}
}

Expand Down Expand Up @@ -146,6 +157,7 @@ export {
readAnnotations,

SuperConstructor,
ScopeAnnotation,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's call it Scope to make it consistent. Or DefaultScope, BaseScope or something?

I think you are right and we might rename all of the annotations to *Annotation but that can be done in a separate commit. The reason why I didn't do that already was that then people have to import it as import {InjectAnnotation as Inject} from 'di';... and that kind of sucks....

We should probably separate all these annotations to a separate module because often projects only need the annotations (so that their code can be used with DI) but they don't need the actual DI. Then, I think Inject, Scope, etc. will be fine...

What do you think?

TransientScope,
Inject,
InjectPromise,
Expand Down
58 changes: 45 additions & 13 deletions src/injector.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
readAnnotations,
hasAnnotation,
Provide as ProvideAnnotation,
TransientScope as TransientScopeAnnotation
TransientScope as TransientScopeAnnotation,
ScopeAnnotation
} from './annotations';
import {isFunction, toString} from './util';
import {profileInjector} from './profiler';
Expand Down Expand Up @@ -39,7 +40,22 @@ function constructResolvingMessage(resolving, token) {
// - loading different "providers" and modules
class Injector {

constructor(modules = [], parentInjector = null, providers = new Map(), scopes = []) {
constructor(modules = [], scopes = [], parentInjector = null, providers = new Map()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be noted as a breaking change - in the commit message.

for (var Scope of scopes) {
if (!(Scope.prototype instanceof ScopeAnnotation)) {
throw new Error(`Cannot create injector, '${toString(Scope)}' is not a ScopeAnnotation`);
}
}

// Always force new instance of TransientScope.
scopes.push(TransientScopeAnnotation);

if (parentInjector) {
for (var scope of scopes) {
parentInjector._collectProvidersWithAnnotation(scope, providers);
}
}

this._cache = new Map();
this._providers = providers;
this._parent = parentInjector;
Expand Down Expand Up @@ -127,6 +143,16 @@ class Injector {
return this._parent._instantiateDefaultProvider(provider, token, resolving, wantPromise, wantLazy);
}

// returns true if the current injector can handle a given scope
_handlesScopes(scope) {
for(var handeldScope of this._scopes) {
if (scope instanceof handeldScope) {
return true;
}
}
return false;
}


// Return an instance for given token.
get(token, resolving = [], wantPromise = false, wantLazy = false) {
Expand Down Expand Up @@ -154,6 +180,7 @@ class Injector {
return function createLazyInstance() {
var lazyInjector = injector;

// TODO remove this
if (arguments.length) {
var locals = [];
var args = arguments;
Expand Down Expand Up @@ -198,7 +225,21 @@ class Injector {

// No provider defined (overriden), use the default provider (token).
if (!provider && isFunction(token) && !this._hasProviderFor(token)) {
provider = createProviderFromFnOrClass(token, readAnnotations(token));
var annotations = readAnnotations(token);
var cantHandle = false;
for (var scope of annotations.scopes) {
if (!this._handlesScopes(scope)) {
cantHandle = scope;
}
}
if (cantHandle) {
if (this._parent) {
return this._parent.get(token, resolving, wantPromise, wantLazy);
} else {
throw new Error(`Can't instantiate service '${toString(token)}', ${toString(cantHandle.constructor)} not handled by this injector`);
}
}
provider = createProviderFromFnOrClass(token, annotations);
return this._instantiateDefaultProvider(provider, token, resolving, wantPromise, wantLazy);
}

Expand Down Expand Up @@ -303,16 +344,7 @@ class Injector {
// Create a child injector, which encapsulate shorter life scope.
// It is possible to add additional providers and also force new instances of existing providers.
createChild(modules = [], forceNewInstancesOf = []) {
var forcedProviders = new Map();

// Always force new instance of TransientScope.
forceNewInstancesOf.push(TransientScopeAnnotation);

for (var annotation of forceNewInstancesOf) {
this._collectProvidersWithAnnotation(annotation, forcedProviders);
}

return new Injector(modules, this, forcedProviders, forceNewInstancesOf);
return new Injector(modules, forceNewInstancesOf, this);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/testing.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function inject(...params) {
}
});

currentSpec.$$injector = new Injector(modules, null, providers);
currentSpec.$$injector = new Injector(modules, [], null, providers);
}

currentSpec.$$injector.get(behavior);
Expand Down
81 changes: 74 additions & 7 deletions test/injector.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Injector} from '../src/injector';
import {Inject, Provide, SuperConstructor, InjectLazy, TransientScope} from '../src/annotations';
import {Inject, Provide, SuperConstructor, InjectLazy, TransientScope, ScopeAnnotation} from '../src/annotations';

import {Car, CyclicEngine} from './fixtures/car';
import {module as houseModule} from './fixtures/house';
Expand Down Expand Up @@ -446,7 +446,7 @@ describe('injector', function() {


it('should force new instances by annotation', function() {
class RouteScope {}
class RouteScope extends ScopeAnnotation {}

class Engine {
start() {}
Expand Down Expand Up @@ -474,7 +474,7 @@ describe('injector', function() {


it('should force new instances by annotation using overriden provider', function() {
class RouteScope {}
class RouteScope extends ScopeAnnotation {}

class Engine {
start() {}
Expand All @@ -500,7 +500,7 @@ describe('injector', function() {


it('should force new instance by annotation using the lowest overriden provider', function() {
class RouteScope {}
class RouteScope extends ScopeAnnotation {}

@RouteScope
class Engine {
Expand Down Expand Up @@ -568,13 +568,13 @@ describe('injector', function() {


it('should force new instance by annotation for default provider', function() {
class RequestScope {}
class RequestScope extends ScopeAnnotation {}

@Inject
@RequestScope
class Foo {}

var parent = new Injector();
var parent = new Injector([], [RequestScope]);
var child = parent.createChild([], [RequestScope]);

var fooFromChild = child.get(Foo);
Expand Down Expand Up @@ -651,7 +651,7 @@ describe('injector', function() {
});


describe('with locals', function() {
describe('with locals', function() {5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I fully understand this "5" ;-)

it('should always create a new instance', function() {
var constructorSpy = jasmine.createSpy('constructor');

Expand Down Expand Up @@ -684,4 +684,71 @@ describe('injector', function() {
});
});
});

describe('Scopes', function () {

it('should only accepts scopes that inherit ScopeAnnotation', function () {
class BadRouteScope{}
class RouteScope extends ScopeAnnotation {}

expect(() => new Injector([], [BadRouteScope]))
.toThrowError("Cannot create injector, 'BadRouteScope' is not a ScopeAnnotation");

expect(() => new Injector([], [RouteScope])).not.toThrow();
});

it('should only instantiate a scoped service if the injector can handle that scope', function () {
class RouteScope extends ScopeAnnotation {}

@RouteScope
function Service(){}

var injector = new Injector(),
child = injector.createChild([], [RouteScope]);

expect(() => { child.get(Service); }).not.toThrow();
expect(() => { injector.get(Service); })
.toThrowError("Can't instantiate service 'Service', RouteScope not handled by this injector");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's sufficient if there is a parent injector with that scope (it does not have to be this scope) so the error might say something like "... or parent injector" or Maybe "no injector for RouteScope"?
What do you think?

Also, this exception should show the dependency path such as "(Foo -> Service)" (the same way other exceptions do).


expect(injector._cache.has(Service)).toBe(false);
expect(child._cache.has(Service)).toBe(true);
});

it('should delegate the instantiation to the parent if it doesn\'t handle the scope', function () {
class RouteScope extends ScopeAnnotation {}

@RouteScope
function Service(){}

var injector = new Injector(),
child = injector.createChild([], [RouteScope]),
grantChild = child.createChild();

expect(() => { grantChild.get(Service); }).not.toThrow();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd remove this assertion. If it throws, it will fail on line 729. I like it explicit in the previous test, where we say "this one throws, this one not" but I don't think we need it here.


expect(grantChild.get(Service)).toBe(child.get(Service));

expect(injector._cache.has(Service)).toBe(false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we assert this without using private API?
Wouldn't asserting that injector.get(Service) throws be enough?

Same for other tests using ._cache.

expect(child._cache.has(Service)).toBe(true);
expect(grantChild._cache.has(Service)).toBe(false);
});

describe('No Scopes', function () {
it('should be instantiated by the root injector if a service doesn\'t have a scope', function () {
function Service(){}

var injector = new Injector(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you change this fancy indentation to:

var a = b;
var c = d;
var e = f;

I know we use this in Angular codebase, but I prefer this old-school style, it's much easier to add new var or re-order...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for other places....

child = injector.createChild(),
grantChild = child.createChild();

expect(grantChild.get(Service)).toBe(child.get(Service));
expect(child.get(Service)).toBe(injector.get(Service));
expect(grantChild.get(Service)).toBe(injector.get(Service));

expect(injector._cache.has(Service)).toBe(true);
expect(child._cache.has(Service)).toBe(false);
expect(grantChild._cache.has(Service)).toBe(false);
});
});
});
});