Skip to content

Commit

Permalink
feat(context): add basic context event notification for binding trackers
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Dec 3, 2018
1 parent 68080d5 commit 8488da2
Show file tree
Hide file tree
Showing 4 changed files with 206 additions and 19 deletions.
34 changes: 26 additions & 8 deletions packages/context/src/binding-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {Binding} from './binding';
import {ResolutionSession} from './resolution-session';
import {resolveList, ValueOrPromise} from './value-promise';
import {Getter} from './inject';
import * as debugFactory from 'debug';
const debug = debugFactory('loopback:context:binding-tracker');

/**
* Tracking a given context chain to maintain a live list of matching bindings
Expand All @@ -22,16 +24,22 @@ export class BindingTracker<T = unknown> {
private _cachedBindings: Readonly<Binding<T>>[] | undefined;
private _cachedValues: ValueOrPromise<T[]> | undefined;

constructor(private ctx: Context, private filter: BindingFilter) {
// TODO: [rfeng] We need to listen/observe events emitted by the context
// chain so that the cache can be refreshed if necessary
constructor(
protected readonly ctx: Context,
public readonly filter: BindingFilter,
) {}

watch() {
debug('Starting to watch context %s', this.ctx.name);
this.ctx.subscribe(this);
}

/**
* Get the list of matched bindings. If they are not cached, it tries to find
* them from the context.
*/
get bindings(): Readonly<Binding<T>>[] {
debug('Reading bindings');
if (this._cachedBindings == null) {
this._cachedBindings = this.findBindings();
}
Expand All @@ -42,6 +50,7 @@ export class BindingTracker<T = unknown> {
* Find matching bindings and refresh the cache
*/
findBindings() {
debug('Finding matching bindings');
this._cachedBindings = this.ctx.find(this.filter);
return this._cachedBindings;
}
Expand All @@ -50,6 +59,7 @@ export class BindingTracker<T = unknown> {
* Invalidate the cache
*/
reset() {
debug('Invalidating cache');
this._cachedBindings = undefined;
this._cachedValues = undefined;
}
Expand All @@ -59,6 +69,7 @@ export class BindingTracker<T = unknown> {
* @param session
*/
resolve(session?: ResolutionSession) {
debug('Resolving values');
this._cachedValues = resolveList(this.bindings, b => {
return b.getValue(this.ctx, ResolutionSession.fork(session));
});
Expand All @@ -69,11 +80,18 @@ export class BindingTracker<T = unknown> {
* Get the list of resolved values. If they are not cached, it tries tp find
* and resolve them.
*/
async values() {
if (this._cachedValues == null) {
this._cachedValues = this.resolve();
}
return await this._cachedValues;
values() {
debug('Reading values');
// [REVIEW] We need to get values in the next tick so that it can pick up
// binding changes as `Context` publishes such events in `process.nextTick`
return new Promise<T[]>(resolve => {
process.nextTick(async () => {
if (this._cachedValues == null) {
this._cachedValues = this.resolve();
}
resolve(await this._cachedValues);
});
});
}

/**
Expand Down
74 changes: 72 additions & 2 deletions packages/context/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {v1 as uuidv1} from 'uuid';

import * as debugModule from 'debug';
import {ValueOrPromise} from '.';
import {BindingTracker} from './binding-tracker';
const debug = debugModule('loopback:context');

export type BindingFilter = (binding: Readonly<Binding<unknown>>) => boolean;
Expand Down Expand Up @@ -40,6 +41,13 @@ export class Context {
this.name = name || uuidv1();
}

/**
* Get the parent context
*/
get parent() {
return this._parent;
}

/**
* Create a binding with the given key in the context. If a locked binding
* already exists with the same key, an error will be thrown.
Expand All @@ -66,14 +74,21 @@ export class Context {
debug('Adding binding: %s', key);
}

let existingBinding: Binding | undefined;
const keyExists = this.registry.has(key);
if (keyExists) {
const existingBinding = this.registry.get(key);
existingBinding = this.registry.get(key);
const bindingIsLocked = existingBinding && existingBinding.isLocked;
if (bindingIsLocked)
throw new Error(`Cannot rebind key "${key}" to a locked binding`);
}
this.registry.set(key, binding);
if (existingBinding !== binding) {
if (existingBinding != null) {
this.publish('unbind', existingBinding);
}
this.publish('bind', binding);
}
return this;
}

Expand All @@ -93,7 +108,62 @@ export class Context {
if (binding == null) return false;
if (binding && binding.isLocked)
throw new Error(`Cannot unbind key "${key}" of a locked binding`);
return this.registry.delete(key);
const found = this.registry.delete(key);
this.publish('unbind', binding);
return found;
}

protected trackers: BindingTracker[] = [];

/**
* Add the binding tracker as an event listener to the context chain
* @param bindingTracker
*/
subscribe(bindingTracker: BindingTracker) {
let ctx: Context | undefined = this;
while (ctx != null) {
if (!ctx.trackers.includes(bindingTracker)) {
ctx.trackers.push(bindingTracker);
}
ctx = ctx._parent;
}
}

/**
* Remove the binding tracker from the context chain
* @param bindingTracker
*/
unsubscribe(bindingTracker: BindingTracker) {
let ctx: Context | undefined = this;
while (ctx != null) {
const index = ctx.trackers.indexOf(bindingTracker);
if (index !== -1) {
ctx.trackers.splice(index, 1);
}
ctx = ctx._parent;
}
}

/**
* Publish an event to the registered binding trackers
* @param event Bind or unbind events
* @param binding Binding
*/
protected publish(
event: 'bind' | 'unbind',
binding: Readonly<Binding<unknown>>,
) {
// Reset trackers in the next tick so that we allow fluent APIs such as
// ctx.bind('key').to(...).tag(...);
process.nextTick(() => {
for (const tracker of this.trackers) {
if (tracker.filter(binding)) {
// FIXME: [rfeng] We just reset the tracker to invalidate the cache
// for now
tracker.reset();
}
}
});
}

/**
Expand Down
39 changes: 38 additions & 1 deletion packages/context/src/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
MetadataAccessor,
InspectionOptions,
} from '@loopback/metadata';
import {BoundValue, ValueOrPromise, resolveList} from './value-promise';
import {BoundValue, ValueOrPromise} from './value-promise';
import {Context, BindingFilter} from './context';
import {BindingKey, BindingAddress} from './binding-key';
import {ResolutionSession} from './resolution-session';
Expand Down Expand Up @@ -263,6 +263,30 @@ export namespace inject {
return filter(Context.bindingTagFilter(bindingTag), metadata);
};

/**
* Inject matching bound values by the filter function
*
* ```ts
* class MyControllerWithGetter {
* @inject.filter(Context.bindingTagFilter('foo'))
* getter: Getter<string[]>;
* }
*
* class MyControllerWithValues {
* constructor(
* @inject.filter(Context.bindingTagFilter('foo'))
* public values: string[],
* ) {}
* }
*
* class MyControllerWithTracker {
* @inject.filter(Context.bindingTagFilter('foo'))
* tracker: BindingTracker<string[]>;
* }
* ```
* @param bindingFilter A binding filter function
* @param metadata
*/
export const filter = function injectByFilter(
bindingFilter: BindingFilter,
metadata?: InjectionMetadata,
Expand Down Expand Up @@ -343,6 +367,10 @@ export function describeInjectedArguments(
return meta || [];
}

/**
* Inspect the target type
* @param injection
*/
function inspectTargetType(injection: Readonly<Injection>) {
let type = MetadataInspector.getDesignTypeForProperty(
injection.target,
Expand All @@ -362,18 +390,27 @@ function inspectTargetType(injection: Readonly<Injection>) {
return type;
}

/**
* Resolve
* @param ctx
* @param injection
* @param session
*/
function resolveByFilter(
ctx: Context,
injection: Readonly<Injection>,
session?: ResolutionSession,
) {
const bindingFilter = injection.metadata!.bindingFilter;
const tracker = new BindingTracker(ctx, bindingFilter);
const watch = injection.metadata!.watch;

const targetType = inspectTargetType(injection);
if (targetType === Function) {
if (watch !== false) tracker.watch();
return tracker.asGetter();
} else if (targetType === BindingTracker) {
if (watch !== false) tracker.watch();
return tracker;
} else {
return tracker.resolve(session);
Expand Down
78 changes: 70 additions & 8 deletions packages/context/test/unit/binding-tracker.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,55 @@ describe('BindingTracker', () => {
.to('XYZ')
.tag('foo');
expect(bindingTracker.bindings).to.containEql(xyzBinding);
// `abc` does not have the matching tag
expect(bindingTracker.bindings).to.not.containEql(abcBinding);
expect(await bindingTracker.values()).to.eql(['BAR', 'XYZ', 'FOO']);
});

it('reloads bindings if context bindings are added', async () => {
bindingTracker.watch();
const abcBinding = ctx
.bind('abc')
.to('ABC')
.tag('abc');
const xyzBinding = ctx
.bind('xyz')
.to('XYZ')
.tag('foo');
expect(bindingTracker.bindings).to.containEql(xyzBinding);
// `abc` does not have the matching tag
expect(bindingTracker.bindings).to.not.containEql(abcBinding);
expect(await bindingTracker.values()).to.eql(['BAR', 'XYZ', 'FOO']);
});

it('reloads bindings if context bindings are removed', async () => {
bindingTracker.watch();
ctx.unbind('bar');
expect(await bindingTracker.values()).to.eql(['FOO']);
});

it('reloads bindings if context bindings are rebound', async () => {
bindingTracker.watch();
ctx.bind('bar').to('BAR'); // No more tagged with `foo`
expect(await bindingTracker.values()).to.eql(['FOO']);
});

it('reloads bindings if parent context bindings are added', async () => {
bindingTracker.watch();
const xyzBinding = ctx
.parent!.bind('xyz')
.to('XYZ')
.tag('foo');
expect(bindingTracker.bindings).to.containEql(xyzBinding);
expect(await bindingTracker.values()).to.eql(['BAR', 'FOO', 'XYZ']);
});

it('reloads bindings if parent context bindings are removed', async () => {
bindingTracker.watch();
ctx.parent!.unbind('foo');
expect(await bindingTracker.values()).to.eql(['BAR']);
});

function givenBindingTracker() {
bindings = [];
ctx = givenContext(bindings);
Expand All @@ -59,14 +104,16 @@ describe('@inject.filter', async () => {
let ctx: Context;
beforeEach(() => (ctx = givenContext()));

class MyController {
@inject.filter(Context.bindingTagFilter('foo'))
class MyControllerWithGetter {
@inject.filter(Context.bindingTagFilter('foo'), {watch: true})
getter: Getter<string[]>;
}

class MyControllerWithValues {
@inject.filter(Context.bindingTagFilter('foo'))
values: string[];
constructor(
@inject.filter(Context.bindingTagFilter('foo'))
public values: string[],
) {}
}

class MyControllerWithTracker {
Expand All @@ -75,10 +122,18 @@ describe('@inject.filter', async () => {
}

it('injects as getter', async () => {
ctx.bind('my-controller').toClass(MyController);
const inst = await ctx.get<MyController>('my-controller');
expect(inst.getter).to.be.a.Function();
expect(await inst.getter()).to.eql(['BAR', 'FOO']);
ctx.bind('my-controller').toClass(MyControllerWithGetter);
const inst = await ctx.get<MyControllerWithGetter>('my-controller');
const getter = inst.getter;
expect(getter).to.be.a.Function();
expect(await getter()).to.eql(['BAR', 'FOO']);
// Add a new binding that matches the filter
ctx
.bind('xyz')
.to('XYZ')
.tag('foo');
// The getter picks up the new binding
expect(await getter()).to.eql(['BAR', 'XYZ', 'FOO']);
});

it('injects as values', async () => {
Expand All @@ -92,6 +147,13 @@ describe('@inject.filter', async () => {
const inst = await ctx.get<MyControllerWithTracker>('my-controller');
expect(inst.tracker).to.be.instanceOf(BindingTracker);
expect(await inst.tracker.values()).to.eql(['BAR', 'FOO']);
// Add a new binding that matches the filter
ctx
.bind('xyz')
.to('XYZ')
.tag('foo');
// The tracker picks up the new binding
expect(await inst.tracker.values()).to.eql(['BAR', 'XYZ', 'FOO']);
});
});

Expand Down

0 comments on commit 8488da2

Please sign in to comment.