diff --git a/docs/site/Context.md b/docs/site/Context.md index 141c3a6a4206..a8bfe4621d06 100644 --- a/docs/site/Context.md +++ b/docs/site/Context.md @@ -435,7 +435,8 @@ should be able to pick up these new routes without restarting. To support the dynamic tracking of such artifacts registered within a context chain, we introduce `ContextObserver` interface and `ContextView` class that can be used to watch a list of bindings matching certain criteria depicted by a -`BindingFilter` function. +`BindingFilter` function and an optional `BindingSorter` function to sort +matched bindings. ```ts import {Context, ContextView} from '@loopback/context'; diff --git a/docs/site/Decorators_inject.md b/docs/site/Decorators_inject.md index 5f8e5115ace2..bbe7866c27b2 100644 --- a/docs/site/Decorators_inject.md +++ b/docs/site/Decorators_inject.md @@ -83,6 +83,23 @@ class MyControllerWithValues { } ``` +To sort matched bindings found by the binding filter function, `@inject` honors +`bindingSorter` in `metadata`: + +```ts +class MyControllerWithValues { + constructor( + @inject(binding => binding.tagNames.includes('foo'), { + bindingSorter: (a, b) => { + // Sort by value of `foo` tag + return a.tagMap.foo.localCompare(b.tagMap.foo); + }, + }) + public values: string[], + ) {} +} +``` + A few variants of `@inject` are provided to declare special forms of dependencies. diff --git a/packages/context/src/__tests__/acceptance/inject-multiple-values.acceptance.ts b/packages/context/src/__tests__/acceptance/inject-multiple-values.acceptance.ts index 474d81bfb48a..2a5f844e0ce2 100644 --- a/packages/context/src/__tests__/acceptance/inject-multiple-values.acceptance.ts +++ b/packages/context/src/__tests__/acceptance/inject-multiple-values.acceptance.ts @@ -4,7 +4,14 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; -import {Context, ContextView, filterByTag, Getter, inject} from '../..'; +import { + Context, + ContextView, + createSorterByGroup, + filterByTag, + Getter, + inject, +} from '../..'; let app: Context; let server: Context; @@ -29,6 +36,26 @@ describe('@inject.* to receive multiple values matching a filter', async () => { expect(await getter()).to.eql([3, 7, 5]); }); + it('injects as getter with bindingSorter', async () => { + class MyControllerWithGetter { + @inject.getter(workloadMonitorFilter, { + bindingSorter: createSorterByGroup('name'), + }) + getter: Getter; + } + + server.bind('my-controller').toClass(MyControllerWithGetter); + const inst = await server.get('my-controller'); + const getter = inst.getter; + // app-reporter, server-reporter + expect(await getter()).to.eql([5, 3]); + // Add a new binding that matches the filter + givenWorkloadMonitor(server, 'server-reporter-2', 7); + // The getter picks up the new binding by order + // // app-reporter, server-reporter, server-reporter-2 + expect(await getter()).to.eql([5, 3, 7]); + }); + describe('@inject', () => { class MyControllerWithValues { constructor( @@ -48,6 +75,23 @@ describe('@inject.* to receive multiple values matching a filter', async () => { const inst = server.getSync('my-controller'); expect(inst.values).to.eql([3, 5]); }); + + it('injects as values with bindingSorter', async () => { + class MyControllerWithBindingSorter { + constructor( + @inject(workloadMonitorFilter, { + bindingSorter: createSorterByGroup('name'), + }) + public values: number[], + ) {} + } + server.bind('my-controller').toClass(MyControllerWithBindingSorter); + const inst = await server.get( + 'my-controller', + ); + // app-reporter, server-reporter + expect(inst.values).to.eql([5, 3]); + }); }); it('injects as a view', async () => { @@ -68,6 +112,24 @@ describe('@inject.* to receive multiple values matching a filter', async () => { expect(await view.values()).to.eql([3, 5]); }); + it('injects as a view with bindingSorter', async () => { + class MyControllerWithView { + @inject.view(workloadMonitorFilter, { + bindingSorter: createSorterByGroup('name'), + }) + view: ContextView; + } + + server.bind('my-controller').toClass(MyControllerWithView); + const inst = await server.get('my-controller'); + const view = inst.view; + expect(view.bindings.map(b => b.tagMap.name)).to.eql([ + 'app-reporter', + 'server-reporter', + ]); + expect(await view.values()).to.eql([5, 3]); + }); + function givenWorkloadMonitors() { givenServerWithinAnApp(); givenWorkloadMonitor(server, 'server-reporter', 3); @@ -84,7 +146,8 @@ describe('@inject.* to receive multiple values matching a filter', async () => { return ctx .bind(`workloadMonitors.${name}`) .to(workload) - .tag('workloadMonitor'); + .tag('workloadMonitor') + .tag({name}); } }); diff --git a/packages/context/src/__tests__/unit/context-view.unit.ts b/packages/context/src/__tests__/unit/context-view.unit.ts index 0ae0cf458c9b..a2a41579438c 100644 --- a/packages/context/src/__tests__/unit/context-view.unit.ts +++ b/packages/context/src/__tests__/unit/context-view.unit.ts @@ -9,6 +9,7 @@ import { BindingScope, Context, ContextView, + createSorterByGroup, createViewGetter, filterByTag, } from '../..'; @@ -26,6 +27,15 @@ describe('ContextView', () => { expect(taggedAsFoo.bindings).to.eql(bindings); }); + it('sorts matched bindings', () => { + const view = new ContextView( + server, + filterByTag('foo'), + createSorterByGroup('foo', ['b', 'a']), + ); + expect(view.bindings).to.eql([bindings[1], bindings[0]]); + }); + it('resolves bindings', async () => { expect(await taggedAsFoo.resolve()).to.eql(['BAR', 'FOO']); expect(await taggedAsFoo.values()).to.eql(['BAR', 'FOO']); @@ -154,6 +164,22 @@ describe('ContextView', () => { .tag('foo'); expect(await getter()).to.eql(['BAR', 'XYZ', 'FOO']); }); + + it('creates a getter function for the binding filter and sorter', async () => { + const getter = createViewGetter(server, filterByTag('foo'), (a, b) => { + return a.key.localeCompare(b.key); + }); + expect(await getter()).to.eql(['BAR', 'FOO']); + server + .bind('abc') + .to('ABC') + .tag('abc'); + server + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(await getter()).to.eql(['BAR', 'FOO', 'XYZ']); + }); }); function givenContextView() { @@ -169,14 +195,14 @@ describe('ContextView', () => { server .bind('bar') .toDynamicValue(() => Promise.resolve('BAR')) - .tag('foo', 'bar') + .tag({foo: 'a'}) .inScope(BindingScope.SINGLETON), ); bindings.push( app .bind('foo') .to('FOO') - .tag('foo', 'bar'), + .tag({foo: 'b'}), ); } }); diff --git a/packages/context/src/context-view.ts b/packages/context/src/context-view.ts index 1b643c5db521..8df6222bc61f 100644 --- a/packages/context/src/context-view.ts +++ b/packages/context/src/context-view.ts @@ -8,6 +8,7 @@ import {EventEmitter} from 'events'; import {promisify} from 'util'; import {Binding} from './binding'; import {BindingFilter} from './binding-filter'; +import {BindingSorter} from './binding-sorter'; import {Context} from './context'; import { ContextEventType, @@ -43,6 +44,7 @@ export class ContextView extends EventEmitter constructor( protected readonly context: Context, public readonly filter: BindingFilter, + public readonly sorter?: BindingSorter, ) { super(); } @@ -88,6 +90,9 @@ export class ContextView extends EventEmitter protected findBindings(): Readonly>[] { debug('Finding matching bindings'); const found = this.context.find(this.filter); + if (typeof this.sorter === 'function') { + found.sort(this.sorter); + } this._cachedBindings = found; return found; } @@ -158,14 +163,23 @@ export class ContextView extends EventEmitter * Create a context view as a getter * @param ctx Context object * @param bindingFilter A function to match bindings + * @param bindingSorter A function to sort matched bindings * @param session Resolution session */ export function createViewGetter( ctx: Context, bindingFilter: BindingFilter, + bindingSorterOrSession?: BindingSorter | ResolutionSession, session?: ResolutionSession, ): Getter { - const view = new ContextView(ctx, bindingFilter); + let bindingSorter: BindingSorter | undefined = undefined; + if (typeof bindingSorterOrSession === 'function') { + bindingSorter = bindingSorterOrSession; + } else if (bindingSorterOrSession instanceof ResolutionSession) { + session = bindingSorterOrSession; + } + + const view = new ContextView(ctx, bindingFilter, bindingSorter); view.open(); return view.asGetter(session); } diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 88733c48f60d..f280f8ef6b83 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -9,6 +9,7 @@ import {v1 as uuidv1} from 'uuid'; import {Binding, BindingTag} from './binding'; import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; import {BindingAddress, BindingKey} from './binding-key'; +import {BindingSorter} from './binding-sorter'; import { ContextEventObserver, ContextEventType, @@ -436,9 +437,10 @@ export class Context extends EventEmitter { /** * Create a view of the context chain with the given binding filter * @param filter A function to match bindings + * @param sorter A function to sort matched bindings */ - createView(filter: BindingFilter) { - const view = new ContextView(this, filter); + createView(filter: BindingFilter, sorter?: BindingSorter) { + const view = new ContextView(this, filter, sorter); view.open(); return view; } diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index 304a2ad67113..e8e350570d14 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -20,6 +20,7 @@ import { isBindingAddress, } from './binding-filter'; import {BindingAddress} from './binding-key'; +import {BindingSorter} from './binding-sorter'; import {BindingCreationPolicy, Context} from './context'; import {ContextView, createViewGetter} from './context-view'; import {ResolutionSession} from './resolution-session'; @@ -56,6 +57,10 @@ export interface InjectionMetadata { * Control if the dependency is optional, default to false */ optional?: boolean; + /** + * Optional sorter for matched bindings + */ + bindingSorter?: BindingSorter; /** * Other attributes */ @@ -345,7 +350,7 @@ export namespace inject { * @param bindingFilter A binding filter function * @param metadata */ - export const view = function injectByFilter( + export const view = function injectContextView( bindingFilter: BindingFilter, metadata?: InjectionMetadata, ) { @@ -531,7 +536,11 @@ function resolveValuesByFilter( ) { assertTargetType(injection, Array); const bindingFilter = injection.bindingSelector as BindingFilter; - const view = new ContextView(ctx, bindingFilter); + const view = new ContextView( + ctx, + bindingFilter, + injection.metadata.bindingSorter, + ); return view.resolve(session); } @@ -550,7 +559,12 @@ function resolveAsGetterByFilter( ) { assertTargetType(injection, Function, 'Getter function'); const bindingFilter = injection.bindingSelector as BindingFilter; - return createViewGetter(ctx, bindingFilter, session); + return createViewGetter( + ctx, + bindingFilter, + injection.metadata.bindingSorter, + session, + ); } /** @@ -563,7 +577,11 @@ function resolveAsContextView(ctx: Context, injection: Readonly) { assertTargetType(injection, ContextView); const bindingFilter = injection.bindingSelector as BindingFilter; - const view = new ContextView(ctx, bindingFilter); + const view = new ContextView( + ctx, + bindingFilter, + injection.metadata.bindingSorter, + ); view.open(); return view; } diff --git a/packages/core/src/extension-point.ts b/packages/core/src/extension-point.ts index cf86c2bea350..d177b58b5232 100644 --- a/packages/core/src/extension-point.ts +++ b/packages/core/src/extension-point.ts @@ -69,7 +69,12 @@ export function extensions(extensionPointName?: string) { inferExtensionPointName(injection.target, session.currentBinding); const bindingFilter = extensionFilter(extensionPointName); - return createViewGetter(ctx, bindingFilter, session); + return createViewGetter( + ctx, + bindingFilter, + injection.metadata.bindingSorter, + session, + ); }); }