Skip to content

Commit

Permalink
feat(context): allow tags to have an optional value
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed May 3, 2018
1 parent 43383f5 commit 95acd11
Show file tree
Hide file tree
Showing 9 changed files with 265 additions and 67 deletions.
100 changes: 89 additions & 11 deletions packages/context/src/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isPromiseLike,
BoundValue,
ValueOrPromise,
MapObject,
} from './value-promise';
import {Provider} from './provider';

Expand Down Expand Up @@ -90,17 +91,55 @@ export enum BindingScope {
SINGLETON = 'Singleton',
}

/**
* Type of the binding source
*/
export enum BindingType {
/**
* A fixed value
*/
CONSTANT = 'Constant',
/**
* A function to get the value
*/
DYNAMIC_VALUE = 'DynamicValue',
/**
* A class to be instantiated as the value
*/
CLASS = 'Class',
/**
* A provider class with `value()` function to get the value
*/
PROVIDER = 'Provider',
}

// tslint:disable-next-line:no-any
export type TagMap = MapObject<any>;

/**
* Binding represents an entry in the `Context`. Each binding has a key and a
* corresponding value getter.
*/
export class Binding<T = BoundValue> {
/**
* Key of the binding
*/
public readonly key: string;
public readonly tags: Set<string> = new Set();

/**
* Map for tag name/value pairs
*/

public readonly tagMap: TagMap = {};

/**
* Scope of the binding to control how the value is cached/shared
*/
public scope: BindingScope = BindingScope.TRANSIENT;

/**
* Type of the binding value getter
*/
public type: BindingType;

private _cache: WeakMap<Context, T>;
Expand All @@ -109,8 +148,10 @@ export class Binding<T = BoundValue> {
session?: ResolutionSession,
) => ValueOrPromise<T>;

// For bindings bound via toClass, this property contains the constructor
// function
/**
* For bindings bound via toClass, this property contains the constructor
* function
*/
public valueConstructor: Constructor<T>;

constructor(key: string, public isLocked: boolean = false) {
Expand Down Expand Up @@ -215,17 +256,54 @@ export class Binding<T = BoundValue> {
return this;
}

tag(tagName: string | string[]): this {
if (typeof tagName === 'string') {
this.tags.add(tagName);
} else {
tagName.forEach(t => {
this.tags.add(t);
});
/**
* Tag the binding with names or name/value objects. A tag has a name and
* an optional value. If not supplied, the tag name is used as the value.
*
* @param tags A list of names or name/value objects. Each
* parameter can be in one of the following forms:
* - string: A tag name without value
* - string[]: An array of tag names
* - TagMap: A map of tag name/value pairs
*
* @example
* ```ts
* // Add a named tag `controller`
* binding.tag('controller');
*
* // Add two named tags: `controller` and `rest`
* binding.tag('controller', 'rest');
*
* // Add two tags
* // - `controller` (name = 'controller')
* // `{name: 'my-controller'}` (name = 'name', value = 'my-controller')
* binding.tag('controller', {name: 'my-controller'});
*
* ```
*/
tag(...tags: (string | TagMap)[]): this {
for (const t of tags) {
if (typeof t === 'string') {
this.tagMap[t] = t;
} else if (Array.isArray(t)) {
// Throw an error as TypeScript cannot exclude array from TagMap
throw new Error(
'Tag must be a string or an object (but not array): ' + t,
);
} else {
Object.assign(this.tagMap, t);
}
}
return this;
}

/**
* Get an array of tag names
*/
get tagNames() {
return Object.keys(this.tagMap);
}

inScope(scope: BindingScope): this {
this.scope = scope;
return this;
Expand Down Expand Up @@ -367,7 +445,7 @@ export class Binding<T = BoundValue> {
const json: {[name: string]: any} = {
key: this.key,
scope: this.scope,
tags: Array.from(this.tags),
tags: this.tagMap,
isLocked: this.isLocked,
};
if (this.type != null) {
Expand Down
41 changes: 30 additions & 11 deletions packages/context/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Binding} from './binding';
import {Binding, TagMap} from './binding';
import {BindingKey, BindingAddress} from './binding-key';
import {isPromiseLike, getDeepProperty, BoundValue} from './value-promise';
import {ResolutionOptions, ResolutionSession} from './resolution-session';
Expand Down Expand Up @@ -185,19 +185,38 @@ export class Context {
}

/**
* Find bindings using the tag pattern
* @param pattern A regexp or wildcard pattern with optional `*` and `?`. If
* it matches one of the binding tags, the binding is included. For a
* wildcard:
* - `*` matches zero or more characters except `.` and `:`
* - `?` matches exactly one character except `.` and `:`
* Find bindings using the tag filter. If the filter matches one of the
* binding tags, the binding is included.
*
* @param tagFilter A filter for tags. It can be in one of the following
* forms:
* - A regular expression, such as `/controller/`
* - A wildcard pattern string with optional `*` and `?`, such as `'con*'`
* For a wildcard:
* - `*` matches zero or more characters except `.` and `:`
* - `?` matches exactly one character except `.` and `:`
* - An object containing tag name/value pairs, such as
* `{name: 'my-controller'}`
*/
findByTag<ValueType = BoundValue>(
pattern: string | RegExp,
tagFilter: string | RegExp | TagMap,
): Readonly<Binding<ValueType>>[] {
const regexp =
typeof pattern === 'string' ? this.wildcardToRegExp(pattern) : pattern;
return this.find(b => Array.from(b.tags).some(t => regexp.test(t)));
if (typeof tagFilter === 'string' || tagFilter instanceof RegExp) {
const regexp =
typeof tagFilter === 'string'
? this.wildcardToRegExp(tagFilter)
: tagFilter;
return this.find(b => Array.from(b.tagNames).some(t => regexp!.test(t)));
}

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;
});
}

protected _mergeWithParent<ValueType>(
Expand Down
2 changes: 1 addition & 1 deletion packages/context/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export {
getDeepProperty,
} from './value-promise';

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

export {Context} from './context';
export {BindingKey, BindingAddress} from './binding-key';
Expand Down
27 changes: 22 additions & 5 deletions packages/context/test/acceptance/tagged-bindings.acceptance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,41 @@ describe('Context bindings - Tagged bindings', () => {
before(tagBinding);

it('has a tag name', () => {
expect(binding.tags.has('qux')).to.be.true();
expect(binding.tagNames).to.containEql('controller');
});

function tagBinding() {
binding.tag('qux');
binding.tag('controller');
}
});

context('when the binding is tagged with multiple names', () => {
before(tagBinding);

it('has tags', () => {
expect(binding.tags.has('x')).to.be.true();
expect(binding.tags.has('y')).to.be.true();
expect(binding.tagNames).to.containEql('controller');
expect(binding.tagNames).to.containEql('rest');
});

function tagBinding() {
binding.tag(['x', 'y']);
binding.tag('controller', 'rest');
}
});

context('when the binding is tagged with name/value objects', () => {
before(tagBinding);

it('has tags', () => {
expect(binding.tagNames).to.containEql('controller');
expect(binding.tagNames).to.containEql('name');
expect(binding.tagMap).to.containEql({
name: 'my-controller',
controller: 'controller',
});
});

function tagBinding() {
binding.tag({name: 'my-controller'}, 'controller');
}
});
});
Expand Down
51 changes: 46 additions & 5 deletions packages/context/test/acceptance/tagged-bindings.feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
- I want to tag bindings
- So I can group dependencies together via a known name

## Scenario: Single tag
## Scenario: Single named tag

- Given a context
- And a binding named `foo` with value `bar`
- When I tag it `qux`
- Then it should be tagged with `qux`
- When I tag it `controller`
- Then it should be tagged with `controller`

```ts
// create a container for bindings
Expand All @@ -20,7 +20,48 @@ let ctx = new Context();
let binding = ctx
.bind('foo')
.to('bar')
.tag('qux');
.tag('controller');

console.log(binding.tags); // => Set { 'qux' }
console.log(binding.tagNames); // => ['controller']
```

## Scenario: Multiple named tags

- Given a context
- And a binding named `foo` with value `bar`
- When I tag it `controller` and `rest`
- Then it should be tagged with `controller` and `rest`

```ts
// create a container for bindings
let ctx = new Context();

// create a tagged binding
let binding = ctx
.bind('foo')
.to('bar')
.tag('controller', 'rest');

console.log(binding.tagNames); // => ['controller', 'rest']
```

## Scenario: Tags with both name and value

- Given a context
- And a binding named `foo` with value `bar`
- When I tag it `{name: 'my-controller'}` and `controller`
- Then it should be tagged with `{name: 'my-controller', controller: 'controller'}`

```ts
// create a container for bindings
let ctx = new Context();

// create a tagged binding
let binding = ctx
.bind('foo')
.to('bar')
.tag({name: 'my-controller'}, 'controller');

console.log(binding.tagNames); // => ['name', 'controller']
console.log(binding.tagMap); // => {name: 'my-controller', controller: 'controller'}
```
41 changes: 31 additions & 10 deletions packages/context/test/unit/binding.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,37 @@ describe('Binding', () => {
describe('tag', () => {
it('tags the binding', () => {
binding.tag('t1');
expect(binding.tags.has('t1')).to.be.true();
expect(binding.tagNames).to.eql(['t1']);
binding.tag('t2');
expect(binding.tags.has('t1')).to.be.true();
expect(binding.tags.has('t2')).to.be.true();
expect(binding.tagNames).to.eql(['t1', 't2']);
expect(binding.tagMap).to.eql({t1: 't1', t2: 't2'});
});

it('tags the binding with an array', () => {
binding.tag(['t1', 't2']);
expect(binding.tags.has('t1')).to.be.true();
expect(binding.tags.has('t2')).to.be.true();
it('tags the binding with rest args', () => {
binding.tag('t1', 't2');
expect(binding.tagNames).to.eql(['t1', 't2']);
});

it('tags the binding with name/value', () => {
binding.tag({name: 'my-controller'});
expect(binding.tagNames).to.eql(['name']);
expect(binding.tagMap).to.eql({name: 'my-controller'});
});

it('tags the binding with names and name/value objects', () => {
binding.tag('controller', {name: 'my-controller'}, 'rest');
expect(binding.tagNames).to.eql(['controller', 'name', 'rest']);
expect(binding.tagMap).to.eql({
controller: 'controller',
name: 'my-controller',
rest: 'rest',
});
});

it('throws an error if one of the arguments is an array', () => {
expect(() => binding.tag(['t1', 't2'])).to.throw(
/Tag must be a string or an object \(but not array\):/,
);
});
});

Expand Down Expand Up @@ -158,21 +179,21 @@ describe('Binding', () => {
expect(json).to.eql({
key: key,
scope: BindingScope.TRANSIENT,
tags: [],
tags: {},
isLocked: false,
});
});

it('converts a binding with more attributes to plain JSON object', () => {
const myBinding = new Binding(key, true)
.inScope(BindingScope.CONTEXT)
.tag('model')
.tag('model', {name: 'my-model'})
.to('a');
const json = myBinding.toJSON();
expect(json).to.eql({
key: key,
scope: BindingScope.CONTEXT,
tags: ['model'],
tags: {model: 'model', name: 'my-model'},
isLocked: true,
type: BindingType.CONSTANT,
});
Expand Down
Loading

0 comments on commit 95acd11

Please sign in to comment.