Skip to content

Commit

Permalink
Merge 8488da2 into cb57a42
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Dec 3, 2018
2 parents cb57a42 + 8488da2 commit 909906d
Show file tree
Hide file tree
Showing 5 changed files with 505 additions and 63 deletions.
103 changes: 103 additions & 0 deletions packages/context/src/binding-tracker.ts
@@ -0,0 +1,103 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/context
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Context, BindingFilter} from './context';
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
* and their resolved values within the context hierarchy.
*
* This class is the key utility to implement dynamic extensions for extension
* points. For example, the RestServer can react to `controller` bindings even
* they are added/removed/updated after the application starts.
*
*/
export class BindingTracker<T = unknown> {
private _cachedBindings: Readonly<Binding<T>>[] | undefined;
private _cachedValues: ValueOrPromise<T[]> | undefined;

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();
}
return this._cachedBindings;
}

/**
* Find matching bindings and refresh the cache
*/
findBindings() {
debug('Finding matching bindings');
this._cachedBindings = this.ctx.find(this.filter);
return this._cachedBindings;
}

/**
* Invalidate the cache
*/
reset() {
debug('Invalidating cache');
this._cachedBindings = undefined;
this._cachedValues = undefined;
}

/**
* Resolve values for the matching bindings
* @param session
*/
resolve(session?: ResolutionSession) {
debug('Resolving values');
this._cachedValues = resolveList(this.bindings, b => {
return b.getValue(this.ctx, ResolutionSession.fork(session));
});
return this._cachedValues;
}

/**
* Get the list of resolved values. If they are not cached, it tries tp find
* and resolve them.
*/
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);
});
});
}

/**
* As a `Getter` function
*/
asGetter(): Getter<T[]> {
return () => this.values();
}
}
189 changes: 139 additions & 50 deletions packages/context/src/context.ts
Expand Up @@ -12,8 +12,11 @@ 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;

/**
* Context provides an implementation of Inversion of Control (IoC) container
*/
Expand All @@ -38,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 @@ -64,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 @@ -91,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 Expand Up @@ -128,23 +200,6 @@ export class Context {
return undefined;
}

/**
* Convert a wildcard pattern to RegExp
* @param pattern A wildcard string with `*` and `?` as special characters.
* - `*` matches zero or more characters except `.` and `:`
* - `?` matches exactly one character except `.` and `:`
*/
private wildcardToRegExp(pattern: string): RegExp {
// Escape reserved chars for RegExp:
// `- \ ^ $ + . ( ) | { } [ ] :`
let regexp = pattern.replace(/[\-\[\]\/\{\}\(\)\+\.\\\^\$\|\:]/g, '\\$&');
// Replace wildcard chars `*` and `?`
// `*` matches zero or more characters except `.` and `:`
// `?` matches one character except `.` and `:`
regexp = regexp.replace(/\*/g, '[^.:]*').replace(/\?/g, '[^.:]');
return new RegExp(`^${regexp}$`);
}

/**
* Find bindings using the key pattern
* @param pattern A regexp or wildcard pattern with optional `*` and `?`. If
Expand All @@ -162,27 +217,19 @@ export class Context {
* include the binding or `false` to exclude the binding.
*/
find<ValueType = BoundValue>(
filter: (binding: Readonly<Binding<ValueType>>) => boolean,
filter: BindingFilter,
): Readonly<Binding<ValueType>>[];

find<ValueType = BoundValue>(
pattern?:
| string
| RegExp
| ((binding: Readonly<Binding<ValueType>>) => boolean),
pattern?: string | RegExp | BindingFilter,
): Readonly<Binding<ValueType>>[] {
let bindings: Readonly<Binding>[] = [];
let filter: (binding: Readonly<Binding>) => boolean;
if (!pattern) {
filter = binding => true;
} else if (typeof pattern === 'string') {
const regex = this.wildcardToRegExp(pattern);
filter = binding => regex.test(binding.key);
} else if (pattern instanceof RegExp) {
filter = binding => pattern.test(binding.key);
} else {
filter = pattern;
}
const bindings: Readonly<Binding>[] = [];
const filter: BindingFilter =
pattern == null ||
typeof pattern === 'string' ||
pattern instanceof RegExp
? Context.bindingKeyFilter(pattern)
: pattern;

for (const b of this.registry.values()) {
if (filter(b)) bindings.push(b);
Expand All @@ -192,6 +239,21 @@ export class Context {
return this._mergeWithParent(bindings, parentBindings);
}

/**
* Create a binding filter from key pattern
* @param keyPattern Binding key, wildcard, or regexp
*/
static bindingKeyFilter(keyPattern?: string | RegExp) {
let filter: BindingFilter = binding => true;
if (typeof keyPattern === 'string') {
const regex = wildcardToRegExp(keyPattern);
filter = binding => regex.test(binding.key);
} else if (keyPattern instanceof RegExp) {
filter = binding => keyPattern.test(binding.key);
}
return filter;
}

/**
* Find bindings using the tag filter. If the filter matches one of the
* binding tags, the binding is included.
Expand All @@ -209,22 +271,32 @@ export class Context {
findByTag<ValueType = BoundValue>(
tagFilter: string | RegExp | TagMap,
): Readonly<Binding<ValueType>>[] {
if (typeof tagFilter === 'string' || tagFilter instanceof RegExp) {
return this.find(Context.bindingTagFilter(tagFilter));
}

/**
* Create a binding filter for the tag pattern
* @param tagPattern
*/
static bindingTagFilter(tagPattern: string | RegExp | TagMap) {
let bindingFilter: BindingFilter;
if (typeof tagPattern === 'string' || tagPattern instanceof RegExp) {
const regexp =
typeof tagFilter === 'string'
? this.wildcardToRegExp(tagFilter)
: tagFilter;
return this.find(b => Array.from(b.tagNames).some(t => regexp!.test(t)));
typeof tagPattern === 'string'
? wildcardToRegExp(tagPattern)
: tagPattern;
bindingFilter = b => Array.from(b.tagNames).some(t => regexp!.test(t));
} else {
bindingFilter = b => {
for (const t in tagPattern) {
// One tag name/value does not match
if (b.tagMap[t] !== tagPattern[t]) return false;
}
// All tag name/value pairs match
return true;
};
}

return this.find(b => {
for (const t in tagFilter) {
// One tag name/value does not match
if (b.tagMap[t] !== tagFilter[t]) return false;
}
// All tag name/value pairs match
return true;
});
return bindingFilter;
}

protected _mergeWithParent<ValueType>(
Expand Down Expand Up @@ -494,3 +566,20 @@ export class Context {
return json;
}
}

/**
* Convert a wildcard pattern to RegExp
* @param pattern A wildcard string with `*` and `?` as special characters.
* - `*` matches zero or more characters except `.` and `:`
* - `?` matches exactly one character except `.` and `:`
*/
function wildcardToRegExp(pattern: string): RegExp {
// Escape reserved chars for RegExp:
// `- \ ^ $ + . ( ) | { } [ ] :`
let regexp = pattern.replace(/[\-\[\]\/\{\}\(\)\+\.\\\^\$\|\:]/g, '\\$&');
// Replace wildcard chars `*` and `?`
// `*` matches zero or more characters except `.` and `:`
// `?` matches one character except `.` and `:`
regexp = regexp.replace(/\*/g, '[^.:]*').replace(/\?/g, '[^.:]');
return new RegExp(`^${regexp}$`);
}
4 changes: 3 additions & 1 deletion packages/context/src/index.ts
Expand Up @@ -21,12 +21,14 @@ export {

export {Binding, BindingScope, BindingType, TagMap} from './binding';

export {Context} from './context';
export {Context, BindingFilter} from './context';
export {BindingKey, BindingAddress} from './binding-key';
export {ResolutionSession} from './resolution-session';
export {inject, Setter, Getter, Injection, InjectionMetadata} from './inject';
export {Provider} from './provider';

export {BindingTracker} from './binding-tracker';

export {instantiateClass, invokeMethod} from './resolver';
// internals for testing
export {describeInjectedArguments, describeInjectedProperties} from './inject';

0 comments on commit 909906d

Please sign in to comment.