diff --git a/packages/context/src/__tests__/unit/binding.unit.ts b/packages/context/src/__tests__/unit/binding.unit.ts index 28cb6837a4d0..4055de5f7b3b 100644 --- a/packages/context/src/__tests__/unit/binding.unit.ts +++ b/packages/context/src/__tests__/unit/binding.unit.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {expect} from '@loopback/testlab'; +import {expect, sinon, SinonSpy} from '@loopback/testlab'; import { Binding, BindingScope, @@ -214,6 +214,68 @@ describe('Binding', () => { }); }); + describe('cache', () => { + let spy: SinonSpy; + beforeEach(() => { + spy = sinon.spy(); + }); + + it('clears cache if scope changes', () => { + const indexBinding = ctx + .bind('index') + .toDynamicValue(spy) + .inScope(BindingScope.SINGLETON); + + ctx.getSync(indexBinding.key); + sinon.assert.calledOnce(spy); + spy.resetHistory(); + + // Singleton + ctx.getSync(indexBinding.key); + sinon.assert.notCalled(spy); + spy.resetHistory(); + + indexBinding.inScope(BindingScope.CONTEXT); + ctx.getSync(indexBinding.key); + sinon.assert.calledOnce(spy); + }); + + it('clears cache if _getValue changes', () => { + const providerSpy = sinon.spy(); + class IndexProvider implements Provider { + value() { + return providerSpy(); + } + } + const indexBinding = ctx + .bind('index') + .toDynamicValue(spy) + .inScope(BindingScope.SINGLETON); + + ctx.getSync(indexBinding.key); + sinon.assert.calledOnce(spy); + spy.resetHistory(); + + // Singleton + ctx.getSync(indexBinding.key); + sinon.assert.notCalled(spy); + spy.resetHistory(); + + // Now change the value getter + indexBinding.toProvider(IndexProvider); + ctx.getSync(indexBinding.key); + sinon.assert.notCalled(spy); + sinon.assert.calledOnce(providerSpy); + spy.resetHistory(); + providerSpy.resetHistory(); + + // Singleton + ctx.getSync(indexBinding.key); + sinon.assert.notCalled(spy); + sinon.assert.notCalled(providerSpy); + }); + }); + describe('toJSON()', () => { it('converts a keyed binding to plain JSON object', () => { const json = binding.toJSON(); diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index 3045bdee3f7c..accbbab2a7bf 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -3,11 +3,16 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import * as debugModule from 'debug'; +import * as debugFactory from 'debug'; import {BindingAddress, BindingKey} from './binding-key'; import {Context} from './context'; import {Provider} from './provider'; -import {ResolutionSession} from './resolution-session'; +import { + asResolutionOptions, + ResolutionOptions, + ResolutionOptionsOrSession, + ResolutionSession, +} from './resolution-session'; import {instantiateClass} from './resolver'; import { BoundValue, @@ -18,7 +23,7 @@ import { ValueOrPromise, } from './value-promise'; -const debug = debugModule('loopback:context:binding'); +const debug = debugFactory('loopback:context:binding'); /** * Scope for binding values @@ -128,6 +133,11 @@ export type BindingTag = TagMap | string; */ export type BindingTemplate = (binding: Binding) => void; +type ValueGetter = ( + ctx: Context, + options: ResolutionOptions, +) => ValueOrPromise; + /** * Binding represents an entry in the `Context`. Each binding has a key and a * corresponding value getter. @@ -161,10 +171,7 @@ export class Binding { } private _cache: WeakMap; - private _getValue: ( - ctx?: Context, - session?: ResolutionSession, - ) => ValueOrPromise; + private _getValue: ValueGetter; private _valueConstructor?: Constructor; /** @@ -204,6 +211,15 @@ export class Binding { }); } + /** + * Clear the cache + */ + private _clearCache() { + if (!this._cache) return; + // WeakMap does not have a `clear` method + this._cache = new WeakMap(); + } + /** * This is an internal function optimized for performance. * Users should use `@inject(key)` or `ctx.get(key)` instead. @@ -228,7 +244,25 @@ export class Binding { * @param ctx Context for the resolution * @param session Optional session for binding and dependency resolution */ - getValue(ctx: Context, session?: ResolutionSession): ValueOrPromise { + getValue(ctx: Context, session?: ResolutionSession): ValueOrPromise; + + /** + * Returns a value or promise for this binding in the given context. The + * resolved value can be `undefined` if `optional` is set to `true` in + * `options`. + * @param ctx Context for the resolution + * @param options Optional options for binding and dependency resolution + */ + getValue( + ctx: Context, + options?: ResolutionOptions, + ): ValueOrPromise; + + // Implementation + getValue( + ctx: Context, + optionsOrSession?: ResolutionOptionsOrSession, + ): ValueOrPromise { /* istanbul ignore if */ if (debug.enabled) { debug('Get value for binding %s', this.key); @@ -246,11 +280,15 @@ export class Binding { } } } + const options = asResolutionOptions(optionsOrSession); if (this._getValue) { let result = ResolutionSession.runWithBinding( - s => this._getValue(ctx, s), + s => { + const optionsWithSession = Object.assign({}, options, {session: s}); + return this._getValue(ctx, optionsWithSession); + }, this, - session, + options.session, ); return this._cacheValue(ctx, result); } @@ -317,6 +355,7 @@ export class Binding { * @param scope Binding scope */ inScope(scope: BindingScope): this { + if (this._scope !== scope) this._clearCache(); this._scope = scope; return this; } @@ -328,11 +367,21 @@ export class Binding { */ applyDefaultScope(scope: BindingScope): this { if (!this._scope) { - this._scope = scope; + this.inScope(scope); } return this; } + /** + * Set the `_getValue` function + * @param getValue getValue function + */ + private _setValueGetter(getValue: ValueGetter) { + // Clear the cache + this._clearCache(); + this._getValue = getValue; + } + /** * Bind the key to a constant value. The value must be already available * at binding time, it is not allowed to pass a Promise instance. @@ -372,7 +421,7 @@ export class Binding { debug('Bind %s to constant:', this.key, value); } this._type = BindingType.CONSTANT; - this._getValue = () => value; + this._setValueGetter(() => value); return this; } @@ -400,7 +449,7 @@ export class Binding { debug('Bind %s to dynamic value:', this.key, factoryFn); } this._type = BindingType.DYNAMIC_VALUE; - this._getValue = ctx => factoryFn(); + this._setValueGetter(ctx => factoryFn()); return this; } @@ -426,14 +475,14 @@ export class Binding { debug('Bind %s to provider %s', this.key, providerClass.name); } this._type = BindingType.PROVIDER; - this._getValue = (ctx, session) => { + this._setValueGetter((ctx, options) => { const providerOrPromise = instantiateClass>( providerClass, - ctx!, - session, + ctx, + options.session, ); return transformValueOrPromise(providerOrPromise, p => p.value()); - }; + }); return this; } @@ -450,11 +499,16 @@ export class Binding { debug('Bind %s to class %s', this.key, ctor.name); } this._type = BindingType.CLASS; - this._getValue = (ctx, session) => instantiateClass(ctor, ctx!, session); + this._setValueGetter((ctx, options) => + instantiateClass(ctor, ctx, options.session), + ); this._valueConstructor = ctor; return this; } + /** + * Unlock the binding + */ unlock(): this { this.isLocked = false; return this; diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 3d32c221e321..9b74d65dfd2a 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -3,14 +3,12 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import * as debugModule from 'debug'; +import * as debugFactory from 'debug'; import {EventEmitter} from 'events'; import {v1 as uuidv1} from 'uuid'; -import {ValueOrPromise} from '.'; import {Binding, BindingTag} from './binding'; import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; import {BindingAddress, BindingKey} from './binding-key'; -import {ContextView} from './context-view'; import { ContextEventObserver, ContextEventType, @@ -18,8 +16,19 @@ import { Notification, Subscription, } from './context-observer'; -import {ResolutionOptions, ResolutionSession} from './resolution-session'; -import {BoundValue, getDeepProperty, isPromiseLike} from './value-promise'; +import {ContextView} from './context-view'; +import { + asResolutionOptions, + ResolutionOptions, + ResolutionOptionsOrSession, + ResolutionSession, +} from './resolution-session'; +import { + BoundValue, + getDeepProperty, + isPromiseLike, + ValueOrPromise, +} from './value-promise'; /** * Polyfill Symbol.asyncIterator as required by TypeScript for Node 8.x. @@ -32,7 +41,7 @@ if (!Symbol.asyncIterator) { // This import must happen after the polyfill import {iterator, multiple} from 'p-event'; -const debug = debugModule('loopback:context'); +const debug = debugFactory('loopback:context'); /** * Context provides an implementation of Inversion of Control (IoC) container @@ -574,9 +583,14 @@ export class Context extends EventEmitter { * * @param keyWithPath The binding key, optionally suffixed with a path to the * (deeply) nested property to retrieve. + * @param session Optional session for resolution (accepted for backward + * compatibility) * @returns A promise of the bound value. */ - get(keyWithPath: BindingAddress): Promise; + get( + keyWithPath: BindingAddress, + session?: ResolutionSession, + ): Promise; /** * Get the value bound to the given key, optionally return a (deep) property @@ -594,20 +608,19 @@ export class Context extends EventEmitter { * * @param keyWithPath The binding key, optionally suffixed with a path to the * (deeply) nested property to retrieve. - * @param optionsOrSession Options or session for resolution. An instance of - * `ResolutionSession` is accepted for backward compatibility. + * @param options Options for resolution. * @returns A promise of the bound value, or a promise of undefined when * the optional binding is not found. */ get( keyWithPath: BindingAddress, - optionsOrSession?: ResolutionOptions | ResolutionSession, + options: ResolutionOptions, ): Promise; // Implementation async get( keyWithPath: BindingAddress, - optionsOrSession?: ResolutionOptions | ResolutionSession, + optionsOrSession?: ResolutionOptionsOrSession, ): Promise { this._debug('Resolving binding: %s', keyWithPath); return await this.getValueOrPromise( @@ -636,11 +649,13 @@ export class Context extends EventEmitter { * * @param keyWithPath The binding key, optionally suffixed with a path to the * (deeply) nested property to retrieve. - * * @param optionsOrSession Options or session for resolution. An instance of - * `ResolutionSession` is accepted for backward compatibility. + * @param session Session for resolution (accepted for backward compatibility) * @returns A promise of the bound value. */ - getSync(keyWithPath: BindingAddress): ValueType; + getSync( + keyWithPath: BindingAddress, + session?: ResolutionSession, + ): ValueType; /** * Get the synchronous value bound to the given key, optionally @@ -662,19 +677,18 @@ export class Context extends EventEmitter { * * @param keyWithPath The binding key, optionally suffixed with a path to the * (deeply) nested property to retrieve. - * * @param optionsOrSession Options or session for resolution. An instance of - * `ResolutionSession` is accepted for backward compatibility. + * @param options Options for resolution. * @returns The bound value, or undefined when an optional binding is not found. */ getSync( keyWithPath: BindingAddress, - optionsOrSession?: ResolutionOptions | ResolutionSession, + options?: ResolutionOptions, ): ValueType | undefined; // Implementation getSync( keyWithPath: BindingAddress, - optionsOrSession?: ResolutionOptions | ResolutionSession, + optionsOrSession?: ResolutionOptionsOrSession, ): ValueType | undefined { this._debug('Resolving binding synchronously: %s', keyWithPath); @@ -766,22 +780,16 @@ export class Context extends EventEmitter { */ getValueOrPromise( keyWithPath: BindingAddress, - optionsOrSession?: ResolutionOptions | ResolutionSession, + optionsOrSession?: ResolutionOptionsOrSession, ): ValueOrPromise { const {key, propertyPath} = BindingKey.parseKeyWithPath(keyWithPath); - // backwards compatibility - if (optionsOrSession instanceof ResolutionSession) { - optionsOrSession = {session: optionsOrSession}; - } + optionsOrSession = asResolutionOptions(optionsOrSession); const binding = this.getBinding(key, optionsOrSession); if (binding == null) return undefined; - const boundValue = binding.getValue( - this, - optionsOrSession && optionsOrSession.session, - ); + const boundValue = binding.getValue(this, optionsOrSession); if (propertyPath === undefined || propertyPath === '') { return boundValue; } diff --git a/packages/context/src/resolution-session.ts b/packages/context/src/resolution-session.ts index 4bfc53cfa19b..39ebeaf74cc9 100644 --- a/packages/context/src/resolution-session.ts +++ b/packages/context/src/resolution-session.ts @@ -3,19 +3,15 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {DecoratorFactory} from '@loopback/metadata'; +import * as debugModule from 'debug'; import {Binding} from './binding'; import {Injection} from './inject'; -import {ValueOrPromise, BoundValue, tryWithFinally} from './value-promise'; -import * as debugModule from 'debug'; -import {DecoratorFactory} from '@loopback/metadata'; +import {BoundValue, tryWithFinally, ValueOrPromise} from './value-promise'; const debugSession = debugModule('loopback:context:resolver:session'); const getTargetName = DecoratorFactory.getTargetName; -// NOTE(bajtos) The following import is required to satisfy TypeScript compiler -// tslint:disable-next-line:no-unused -import {BindingKey} from './binding-key'; - /** * A function to be executed with the resolution session */ @@ -169,7 +165,7 @@ export class ResolutionSession { ); return { targetName: name, - bindingKey: injection.bindingSelector, + bindingSelector: injection.bindingSelector, // Cast to Object so that we don't have to expose InjectionMetadata metadata: injection.metadata as Object, }; @@ -343,3 +339,22 @@ export interface ResolutionOptions { */ optional?: boolean; } + +/** + * Resolution options or session + */ +export type ResolutionOptionsOrSession = ResolutionOptions | ResolutionSession; + +/** + * Normalize ResolutionOptionsOrSession to ResolutionOptions + * @param optionsOrSession resolution options or session + */ +export function asResolutionOptions( + optionsOrSession?: ResolutionOptionsOrSession, +): ResolutionOptions { + // backwards compatibility + if (optionsOrSession instanceof ResolutionSession) { + return {session: optionsOrSession}; + } + return optionsOrSession || {}; +} diff --git a/packages/rest/src/__tests__/acceptance/routing/routing.acceptance.ts b/packages/rest/src/__tests__/acceptance/routing/routing.acceptance.ts index 56b77ede2499..c7f8c752cd22 100644 --- a/packages/rest/src/__tests__/acceptance/routing/routing.acceptance.ts +++ b/packages/rest/src/__tests__/acceptance/routing/routing.acceptance.ts @@ -262,7 +262,7 @@ describe('Routing', () => { server.bind('flag').to('original'); // create a special binding returning the current context instance - server.bind('context').getValue = ctx => ctx; + server.bind('context').getValue = (ctx: Context) => ctx; const spec = anOpenApiSpec() .withOperationReturningString('put', '/flag', 'setFlag')