From 95acd11aab1fed9911d4f40312f85b34faeab94f Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 16 Apr 2018 14:15:48 -0700 Subject: [PATCH] feat(context): allow tags to have an optional value --- packages/context/src/binding.ts | 100 ++++++++++++++++-- packages/context/src/context.ts | 41 +++++-- packages/context/src/index.ts | 2 +- .../acceptance/tagged-bindings.acceptance.ts | 27 ++++- .../acceptance/tagged-bindings.feature.md | 51 ++++++++- packages/context/test/unit/binding.unit.ts | 41 +++++-- packages/context/test/unit/context.unit.ts | 56 +++++++--- packages/core/test/unit/application.unit.ts | 10 +- .../rest.server.open-api-spec.unit.ts | 4 +- 9 files changed, 265 insertions(+), 67 deletions(-) diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index 365f694f6ca4..502a48ccb82d 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -12,6 +12,7 @@ import { isPromiseLike, BoundValue, ValueOrPromise, + MapObject, } from './value-promise'; import {Provider} from './provider'; @@ -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; + +/** + * Binding represents an entry in the `Context`. Each binding has a key and a + * corresponding value getter. + */ export class Binding { + /** + * Key of the binding + */ public readonly key: string; - public readonly tags: Set = 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; @@ -109,8 +148,10 @@ export class Binding { session?: ResolutionSession, ) => ValueOrPromise; - // 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; constructor(key: string, public isLocked: boolean = false) { @@ -215,17 +256,54 @@ export class Binding { 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; @@ -367,7 +445,7 @@ export class Binding { 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) { diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 31909a04a721..7878c5a41efd 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -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'; @@ -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( - pattern: string | RegExp, + tagFilter: string | RegExp | TagMap, ): Readonly>[] { - 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( diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 52c7af22271e..6fc47884b8fe 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -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'; diff --git a/packages/context/test/acceptance/tagged-bindings.acceptance.ts b/packages/context/test/acceptance/tagged-bindings.acceptance.ts index d96424a85aa6..2a946f8bf5b2 100644 --- a/packages/context/test/acceptance/tagged-bindings.acceptance.ts +++ b/packages/context/test/acceptance/tagged-bindings.acceptance.ts @@ -17,11 +17,11 @@ 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'); } }); @@ -29,12 +29,29 @@ describe('Context bindings - Tagged bindings', () => { 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'); } }); }); diff --git a/packages/context/test/acceptance/tagged-bindings.feature.md b/packages/context/test/acceptance/tagged-bindings.feature.md index e0a4d2fccf0c..dc5bed934b0d 100644 --- a/packages/context/test/acceptance/tagged-bindings.feature.md +++ b/packages/context/test/acceptance/tagged-bindings.feature.md @@ -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 @@ -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'} ``` diff --git a/packages/context/test/unit/binding.unit.ts b/packages/context/test/unit/binding.unit.ts index 24076bdd3247..19bb277b71de 100644 --- a/packages/context/test/unit/binding.unit.ts +++ b/packages/context/test/unit/binding.unit.ts @@ -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\):/, + ); }); }); @@ -158,7 +179,7 @@ describe('Binding', () => { expect(json).to.eql({ key: key, scope: BindingScope.TRANSIENT, - tags: [], + tags: {}, isLocked: false, }); }); @@ -166,13 +187,13 @@ describe('Binding', () => { 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, }); diff --git a/packages/context/test/unit/context.unit.ts b/packages/context/test/unit/context.unit.ts index b5e84b0b8aea..fe762e5ba563 100644 --- a/packages/context/test/unit/context.unit.ts +++ b/packages/context/test/unit/context.unit.ts @@ -242,34 +242,56 @@ describe('Context', () => { expect(result).to.be.eql([b2, b3]); result = ctx.find(binding => binding.scope === BindingScope.SINGLETON); expect(result).to.be.eql([b1]); - result = ctx.find(binding => binding.tags.has('b')); + result = ctx.find(binding => binding.tagNames.includes('b')); expect(result).to.be.eql([b2, b3]); }); }); - describe('findByTag', () => { + describe('findByTag with name pattern', () => { it('returns matching binding', () => { - const b1 = ctx.bind('foo').tag('t1'); - ctx.bind('bar').tag('t2'); - const result = ctx.findByTag('t1'); + const b1 = ctx.bind('controllers.ProductController').tag('controller'); + ctx.bind('repositories.ProductRepository').tag('repository'); + const result = ctx.findByTag('controller'); expect(result).to.be.eql([b1]); }); it('returns matching binding with *', () => { - const b1 = ctx.bind('foo').tag('t1'); - const b2 = ctx.bind('bar').tag('t2'); - const result = ctx.findByTag('t*'); + const b1 = ctx.bind('controllers.ProductController').tag('controller'); + const b2 = ctx.bind('controllers.OrderController').tag('controller'); + const result = ctx.findByTag('c*'); expect(result).to.be.eql([b1, b2]); }); it('returns matching binding with regexp', () => { - const b1 = ctx.bind('foo').tag('t1'); - const b2 = ctx.bind('bar').tag('t2'); - let result = ctx.findByTag(/t/); + const b1 = ctx.bind('controllers.ProductController').tag('controller'); + const b2 = ctx + .bind('controllers.OrderController') + .tag('controller', 'rest'); + let result = ctx.findByTag(/controller/); expect(result).to.be.eql([b1, b2]); - result = ctx.findByTag(/t1/); + result = ctx.findByTag(/rest/); + expect(result).to.be.eql([b2]); + }); + }); + + describe('findByTag with name/value filter', () => { + it('returns matching binding', () => { + const b1 = ctx + .bind('controllers.ProductController') + .tag({name: 'my-controller'}); + ctx.bind('controllers.OrderController').tag('controller'); + ctx.bind('dataSources.mysql').tag({dbType: 'mysql'}); + const result = ctx.findByTag({name: 'my-controller'}); expect(result).to.be.eql([b1]); }); + + it('returns empty array if no matching tag value is found', () => { + ctx.bind('controllers.ProductController').tag({name: 'my-controller'}); + ctx.bind('controllers.OrderController').tag('controller'); + ctx.bind('dataSources.mysql').tag({dbType: 'mysql'}); + const result = ctx.findByTag({name: 'your-controller'}); + expect(result).to.be.eql([]); + }); }); describe('getBinding', () => { @@ -604,30 +626,30 @@ describe('Context', () => { .bind('b') .toDynamicValue(() => 2) .inScope(BindingScope.SINGLETON) - .tag(['X', 'Y']); + .tag('X', 'Y'); ctx .bind('c') .to(3) - .tag('Z'); + .tag('Z', {a: 1}); expect(ctx.toJSON()).to.eql({ a: { key: 'a', scope: BindingScope.TRANSIENT, - tags: [], + tags: {}, isLocked: true, type: BindingType.CONSTANT, }, b: { key: 'b', scope: BindingScope.SINGLETON, - tags: ['X', 'Y'], + tags: {X: 'X', Y: 'Y'}, isLocked: false, type: BindingType.DYNAMIC_VALUE, }, c: { key: 'c', scope: BindingScope.TRANSIENT, - tags: ['Z'], + tags: {Z: 'Z', a: 1}, isLocked: false, type: BindingType.CONSTANT, }, diff --git a/packages/core/test/unit/application.unit.ts b/packages/core/test/unit/application.unit.ts index 5fd3ece0ccc3..85ddf3e85659 100644 --- a/packages/core/test/unit/application.unit.ts +++ b/packages/core/test/unit/application.unit.ts @@ -16,14 +16,14 @@ describe('Application', () => { it('binds a controller', () => { const binding = app.controller(MyController); - expect(Array.from(binding.tags)).to.containEql('controller'); + expect(Array.from(binding.tagNames)).to.containEql('controller'); expect(binding.key).to.equal('controllers.MyController'); expect(findKeysByTag(app, 'controller')).to.containEql(binding.key); }); it('binds a controller with custom name', () => { const binding = app.controller(MyController, 'my-controller'); - expect(Array.from(binding.tags)).to.containEql('controller'); + expect(Array.from(binding.tagNames)).to.containEql('controller'); expect(binding.key).to.equal('controllers.my-controller'); expect(findKeysByTag(app, 'controller')).to.containEql(binding.key); }); @@ -65,7 +65,7 @@ describe('Application', () => { it('defaults to constructor name', async () => { const app = new Application(); const binding = app.server(FakeServer); - expect(Array.from(binding.tags)).to.containEql('server'); + expect(Array.from(binding.tagNames)).to.containEql('server'); const result = await app.getServer(FakeServer.name); expect(result.constructor.name).to.equal(FakeServer.name); }); @@ -81,8 +81,8 @@ describe('Application', () => { it('allows binding of multiple servers as an array', async () => { const app = new Application(); const bindings = app.servers([FakeServer, AnotherServer]); - expect(Array.from(bindings[0].tags)).to.containEql('server'); - expect(Array.from(bindings[1].tags)).to.containEql('server'); + expect(Array.from(bindings[0].tagNames)).to.containEql('server'); + expect(Array.from(bindings[1].tagNames)).to.containEql('server'); const fakeResult = await app.getServer(FakeServer); expect(fakeResult.constructor.name).to.equal(FakeServer.name); const AnotherResult = await app.getServer(AnotherServer); diff --git a/packages/rest/test/unit/rest.server/rest.server.open-api-spec.unit.ts b/packages/rest/test/unit/rest.server/rest.server.open-api-spec.unit.ts index cbdd5e18399f..c114129737fd 100644 --- a/packages/rest/test/unit/rest.server/rest.server.open-api-spec.unit.ts +++ b/packages/rest/test/unit/rest.server/rest.server.open-api-spec.unit.ts @@ -55,7 +55,7 @@ describe('RestServer.getApiSpec()', () => { new Route('get', '/greet', {responses: {}}, greet), ); expect(binding.key).to.eql('routes.get %2Fgreet'); - expect(binding.tags.has('route')).to.be.true(); + expect(binding.tagNames).containEql('route'); }); it('binds a route via app.route(..., Controller, method)', () => { @@ -72,7 +72,7 @@ describe('RestServer.getApiSpec()', () => { 'greet', ); expect(binding.key).to.eql('routes.get %2Fgreet%2Ejson'); - expect(binding.tags.has('route')).to.be.true(); + expect(binding.tagNames).containEql('route'); }); it('returns routes registered via app.route(route)', () => {