Skip to content

Commit 95acd11

Browse files
committed
feat(context): allow tags to have an optional value
1 parent 43383f5 commit 95acd11

File tree

9 files changed

+265
-67
lines changed

9 files changed

+265
-67
lines changed

packages/context/src/binding.ts

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
isPromiseLike,
1313
BoundValue,
1414
ValueOrPromise,
15+
MapObject,
1516
} from './value-promise';
1617
import {Provider} from './provider';
1718

@@ -90,17 +91,55 @@ export enum BindingScope {
9091
SINGLETON = 'Singleton',
9192
}
9293

94+
/**
95+
* Type of the binding source
96+
*/
9397
export enum BindingType {
98+
/**
99+
* A fixed value
100+
*/
94101
CONSTANT = 'Constant',
102+
/**
103+
* A function to get the value
104+
*/
95105
DYNAMIC_VALUE = 'DynamicValue',
106+
/**
107+
* A class to be instantiated as the value
108+
*/
96109
CLASS = 'Class',
110+
/**
111+
* A provider class with `value()` function to get the value
112+
*/
97113
PROVIDER = 'Provider',
98114
}
99115

116+
// tslint:disable-next-line:no-any
117+
export type TagMap = MapObject<any>;
118+
119+
/**
120+
* Binding represents an entry in the `Context`. Each binding has a key and a
121+
* corresponding value getter.
122+
*/
100123
export class Binding<T = BoundValue> {
124+
/**
125+
* Key of the binding
126+
*/
101127
public readonly key: string;
102-
public readonly tags: Set<string> = new Set();
128+
129+
/**
130+
* Map for tag name/value pairs
131+
*/
132+
133+
public readonly tagMap: TagMap = {};
134+
135+
/**
136+
* Scope of the binding to control how the value is cached/shared
137+
*/
103138
public scope: BindingScope = BindingScope.TRANSIENT;
139+
140+
/**
141+
* Type of the binding value getter
142+
*/
104143
public type: BindingType;
105144

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

112-
// For bindings bound via toClass, this property contains the constructor
113-
// function
151+
/**
152+
* For bindings bound via toClass, this property contains the constructor
153+
* function
154+
*/
114155
public valueConstructor: Constructor<T>;
115156

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

218-
tag(tagName: string | string[]): this {
219-
if (typeof tagName === 'string') {
220-
this.tags.add(tagName);
221-
} else {
222-
tagName.forEach(t => {
223-
this.tags.add(t);
224-
});
259+
/**
260+
* Tag the binding with names or name/value objects. A tag has a name and
261+
* an optional value. If not supplied, the tag name is used as the value.
262+
*
263+
* @param tags A list of names or name/value objects. Each
264+
* parameter can be in one of the following forms:
265+
* - string: A tag name without value
266+
* - string[]: An array of tag names
267+
* - TagMap: A map of tag name/value pairs
268+
*
269+
* @example
270+
* ```ts
271+
* // Add a named tag `controller`
272+
* binding.tag('controller');
273+
*
274+
* // Add two named tags: `controller` and `rest`
275+
* binding.tag('controller', 'rest');
276+
*
277+
* // Add two tags
278+
* // - `controller` (name = 'controller')
279+
* // `{name: 'my-controller'}` (name = 'name', value = 'my-controller')
280+
* binding.tag('controller', {name: 'my-controller'});
281+
*
282+
* ```
283+
*/
284+
tag(...tags: (string | TagMap)[]): this {
285+
for (const t of tags) {
286+
if (typeof t === 'string') {
287+
this.tagMap[t] = t;
288+
} else if (Array.isArray(t)) {
289+
// Throw an error as TypeScript cannot exclude array from TagMap
290+
throw new Error(
291+
'Tag must be a string or an object (but not array): ' + t,
292+
);
293+
} else {
294+
Object.assign(this.tagMap, t);
295+
}
225296
}
226297
return this;
227298
}
228299

300+
/**
301+
* Get an array of tag names
302+
*/
303+
get tagNames() {
304+
return Object.keys(this.tagMap);
305+
}
306+
229307
inScope(scope: BindingScope): this {
230308
this.scope = scope;
231309
return this;
@@ -367,7 +445,7 @@ export class Binding<T = BoundValue> {
367445
const json: {[name: string]: any} = {
368446
key: this.key,
369447
scope: this.scope,
370-
tags: Array.from(this.tags),
448+
tags: this.tagMap,
371449
isLocked: this.isLocked,
372450
};
373451
if (this.type != null) {

packages/context/src/context.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {Binding} from './binding';
6+
import {Binding, TagMap} from './binding';
77
import {BindingKey, BindingAddress} from './binding-key';
88
import {isPromiseLike, getDeepProperty, BoundValue} from './value-promise';
99
import {ResolutionOptions, ResolutionSession} from './resolution-session';
@@ -185,19 +185,38 @@ export class Context {
185185
}
186186

187187
/**
188-
* Find bindings using the tag pattern
189-
* @param pattern A regexp or wildcard pattern with optional `*` and `?`. If
190-
* it matches one of the binding tags, the binding is included. For a
191-
* wildcard:
192-
* - `*` matches zero or more characters except `.` and `:`
193-
* - `?` matches exactly one character except `.` and `:`
188+
* Find bindings using the tag filter. If the filter matches one of the
189+
* binding tags, the binding is included.
190+
*
191+
* @param tagFilter A filter for tags. It can be in one of the following
192+
* forms:
193+
* - A regular expression, such as `/controller/`
194+
* - A wildcard pattern string with optional `*` and `?`, such as `'con*'`
195+
* For a wildcard:
196+
* - `*` matches zero or more characters except `.` and `:`
197+
* - `?` matches exactly one character except `.` and `:`
198+
* - An object containing tag name/value pairs, such as
199+
* `{name: 'my-controller'}`
194200
*/
195201
findByTag<ValueType = BoundValue>(
196-
pattern: string | RegExp,
202+
tagFilter: string | RegExp | TagMap,
197203
): Readonly<Binding<ValueType>>[] {
198-
const regexp =
199-
typeof pattern === 'string' ? this.wildcardToRegExp(pattern) : pattern;
200-
return this.find(b => Array.from(b.tags).some(t => regexp.test(t)));
204+
if (typeof tagFilter === 'string' || tagFilter instanceof RegExp) {
205+
const regexp =
206+
typeof tagFilter === 'string'
207+
? this.wildcardToRegExp(tagFilter)
208+
: tagFilter;
209+
return this.find(b => Array.from(b.tagNames).some(t => regexp!.test(t)));
210+
}
211+
212+
return this.find(b => {
213+
for (const t in tagFilter) {
214+
// One tag name/value does not match
215+
if (b.tagMap[t] !== tagFilter[t]) return false;
216+
}
217+
// All tag name/value pairs match
218+
return true;
219+
});
201220
}
202221

203222
protected _mergeWithParent<ValueType>(

packages/context/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export {
1717
getDeepProperty,
1818
} from './value-promise';
1919

20-
export {Binding, BindingScope, BindingType} from './binding';
20+
export {Binding, BindingScope, BindingType, TagMap} from './binding';
2121

2222
export {Context} from './context';
2323
export {BindingKey, BindingAddress} from './binding-key';

packages/context/test/acceptance/tagged-bindings.acceptance.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,41 @@ describe('Context bindings - Tagged bindings', () => {
1717
before(tagBinding);
1818

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

2323
function tagBinding() {
24-
binding.tag('qux');
24+
binding.tag('controller');
2525
}
2626
});
2727

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

3131
it('has tags', () => {
32-
expect(binding.tags.has('x')).to.be.true();
33-
expect(binding.tags.has('y')).to.be.true();
32+
expect(binding.tagNames).to.containEql('controller');
33+
expect(binding.tagNames).to.containEql('rest');
3434
});
3535

3636
function tagBinding() {
37-
binding.tag(['x', 'y']);
37+
binding.tag('controller', 'rest');
38+
}
39+
});
40+
41+
context('when the binding is tagged with name/value objects', () => {
42+
before(tagBinding);
43+
44+
it('has tags', () => {
45+
expect(binding.tagNames).to.containEql('controller');
46+
expect(binding.tagNames).to.containEql('name');
47+
expect(binding.tagMap).to.containEql({
48+
name: 'my-controller',
49+
controller: 'controller',
50+
});
51+
});
52+
53+
function tagBinding() {
54+
binding.tag({name: 'my-controller'}, 'controller');
3855
}
3956
});
4057
});

packages/context/test/acceptance/tagged-bindings.feature.md

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
- I want to tag bindings
66
- So I can group dependencies together via a known name
77

8-
## Scenario: Single tag
8+
## Scenario: Single named tag
99

1010
- Given a context
1111
- And a binding named `foo` with value `bar`
12-
- When I tag it `qux`
13-
- Then it should be tagged with `qux`
12+
- When I tag it `controller`
13+
- Then it should be tagged with `controller`
1414

1515
```ts
1616
// create a container for bindings
@@ -20,7 +20,48 @@ let ctx = new Context();
2020
let binding = ctx
2121
.bind('foo')
2222
.to('bar')
23-
.tag('qux');
23+
.tag('controller');
2424

25-
console.log(binding.tags); // => Set { 'qux' }
25+
console.log(binding.tagNames); // => ['controller']
26+
```
27+
28+
## Scenario: Multiple named tags
29+
30+
- Given a context
31+
- And a binding named `foo` with value `bar`
32+
- When I tag it `controller` and `rest`
33+
- Then it should be tagged with `controller` and `rest`
34+
35+
```ts
36+
// create a container for bindings
37+
let ctx = new Context();
38+
39+
// create a tagged binding
40+
let binding = ctx
41+
.bind('foo')
42+
.to('bar')
43+
.tag('controller', 'rest');
44+
45+
console.log(binding.tagNames); // => ['controller', 'rest']
46+
```
47+
48+
## Scenario: Tags with both name and value
49+
50+
- Given a context
51+
- And a binding named `foo` with value `bar`
52+
- When I tag it `{name: 'my-controller'}` and `controller`
53+
- Then it should be tagged with `{name: 'my-controller', controller: 'controller'}`
54+
55+
```ts
56+
// create a container for bindings
57+
let ctx = new Context();
58+
59+
// create a tagged binding
60+
let binding = ctx
61+
.bind('foo')
62+
.to('bar')
63+
.tag({name: 'my-controller'}, 'controller');
64+
65+
console.log(binding.tagNames); // => ['name', 'controller']
66+
console.log(binding.tagMap); // => {name: 'my-controller', controller: 'controller'}
2667
```

packages/context/test/unit/binding.unit.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,37 @@ describe('Binding', () => {
4141
describe('tag', () => {
4242
it('tags the binding', () => {
4343
binding.tag('t1');
44-
expect(binding.tags.has('t1')).to.be.true();
44+
expect(binding.tagNames).to.eql(['t1']);
4545
binding.tag('t2');
46-
expect(binding.tags.has('t1')).to.be.true();
47-
expect(binding.tags.has('t2')).to.be.true();
46+
expect(binding.tagNames).to.eql(['t1', 't2']);
47+
expect(binding.tagMap).to.eql({t1: 't1', t2: 't2'});
4848
});
4949

50-
it('tags the binding with an array', () => {
51-
binding.tag(['t1', 't2']);
52-
expect(binding.tags.has('t1')).to.be.true();
53-
expect(binding.tags.has('t2')).to.be.true();
50+
it('tags the binding with rest args', () => {
51+
binding.tag('t1', 't2');
52+
expect(binding.tagNames).to.eql(['t1', 't2']);
53+
});
54+
55+
it('tags the binding with name/value', () => {
56+
binding.tag({name: 'my-controller'});
57+
expect(binding.tagNames).to.eql(['name']);
58+
expect(binding.tagMap).to.eql({name: 'my-controller'});
59+
});
60+
61+
it('tags the binding with names and name/value objects', () => {
62+
binding.tag('controller', {name: 'my-controller'}, 'rest');
63+
expect(binding.tagNames).to.eql(['controller', 'name', 'rest']);
64+
expect(binding.tagMap).to.eql({
65+
controller: 'controller',
66+
name: 'my-controller',
67+
rest: 'rest',
68+
});
69+
});
70+
71+
it('throws an error if one of the arguments is an array', () => {
72+
expect(() => binding.tag(['t1', 't2'])).to.throw(
73+
/Tag must be a string or an object \(but not array\):/,
74+
);
5475
});
5576
});
5677

@@ -158,21 +179,21 @@ describe('Binding', () => {
158179
expect(json).to.eql({
159180
key: key,
160181
scope: BindingScope.TRANSIENT,
161-
tags: [],
182+
tags: {},
162183
isLocked: false,
163184
});
164185
});
165186

166187
it('converts a binding with more attributes to plain JSON object', () => {
167188
const myBinding = new Binding(key, true)
168189
.inScope(BindingScope.CONTEXT)
169-
.tag('model')
190+
.tag('model', {name: 'my-model'})
170191
.to('a');
171192
const json = myBinding.toJSON();
172193
expect(json).to.eql({
173194
key: key,
174195
scope: BindingScope.CONTEXT,
175-
tags: ['model'],
196+
tags: {model: 'model', name: 'my-model'},
176197
isLocked: true,
177198
type: BindingType.CONSTANT,
178199
});

0 commit comments

Comments
 (0)