diff --git a/packages/boot/src/__tests__/acceptance/component-application.booter.acceptance.ts b/packages/boot/src/__tests__/acceptance/component-application.booter.acceptance.ts index a11d49b1adcb..8517868ae3b2 100644 --- a/packages/boot/src/__tests__/acceptance/component-application.booter.acceptance.ts +++ b/packages/boot/src/__tests__/acceptance/component-application.booter.acceptance.ts @@ -6,11 +6,9 @@ import {Application, Component} from '@loopback/core'; import {expect, givenHttpServerConfig, TestSandbox} from '@loopback/testlab'; import {resolve} from 'path'; -import { - BootBindings, - BootMixin, - createComponentApplicationBooterBinding, -} from '../..'; +import {BootMixin, createComponentApplicationBooterBinding} from '../..'; +import {bindingKeysExcludedFromSubApp} from '../../booters'; +import {Bootable} from '../../types'; import {BooterApp} from '../fixtures/application'; describe('component application booter acceptance tests', () => { @@ -31,29 +29,12 @@ describe('component application booter acceptance tests', () => { const mainApp = new MainApp(); mainApp.component(BooterAppComponent); - const appBindingsBeforeBoot = mainApp.find( - // Exclude boot related bindings - binding => - ![ - BootBindings.BOOT_OPTIONS.key, - BootBindings.PROJECT_ROOT.key, - BootBindings.BOOTSTRAPPER_KEY.key, - ].includes(binding.key), - ); - await mainApp.boot(); - const controllers = mainApp.find('controllers.*').map(b => b.key); - expect(controllers).to.eql([ - 'controllers.ArtifactOne', - 'controllers.ArtifactTwo', - ]); + await testSubAppBoot(mainApp); + }); - // Assert main app bindings before boot are not overridden - const appBindingsAfterBoot = mainApp.find(binding => - appBindingsBeforeBoot.includes(binding), - ); - expect(appBindingsAfterBoot.map(b => b.key)).to.eql( - appBindingsBeforeBoot.map(b => b.key), - ); + it('binds artifacts booted from the sub application', async () => { + const mainApp = new MainAppWithSubAppBooter(); + await testSubAppBoot(mainApp); }); it('binds artifacts booted from the component application by filter', async () => { @@ -97,6 +78,14 @@ describe('component application booter acceptance tests', () => { } } + class MainAppWithSubAppBooter extends BootMixin(Application) { + constructor() { + super(); + this.projectRoot = __dirname; + this.applicationBooter(app); + } + } + async function getApp() { await sandbox.copyFile(resolve(__dirname, '../fixtures/package.json')); await sandbox.copyFile(resolve(__dirname, '../fixtures/application.js')); @@ -110,4 +99,25 @@ describe('component application booter acceptance tests', () => { rest: givenHttpServerConfig(), }); } + + async function testSubAppBoot(mainApp: Application & Bootable) { + const appBindingsBeforeBoot = mainApp.find( + // Exclude boot related bindings + binding => !bindingKeysExcludedFromSubApp.includes(binding.key), + ); + await mainApp.boot(); + const controllers = mainApp.find('controllers.*').map(b => b.key); + expect(controllers).to.eql([ + 'controllers.ArtifactOne', + 'controllers.ArtifactTwo', + ]); + + // Assert main app bindings before boot are not overridden + const appBindingsAfterBoot = mainApp.find(binding => + appBindingsBeforeBoot.includes(binding), + ); + expect(appBindingsAfterBoot.map(b => b.key)).to.eql( + appBindingsBeforeBoot.map(b => b.key), + ); + } }); diff --git a/packages/boot/src/booters/component-application.booter.ts b/packages/boot/src/booters/component-application.booter.ts index dedc94234d51..a2115f3c32ad 100644 --- a/packages/boot/src/booters/component-application.booter.ts +++ b/packages/boot/src/booters/component-application.booter.ts @@ -18,6 +18,21 @@ import {Bootable, Booter, booter} from '../types'; const debug = debugFactory('loopback:boot:booter:component-application'); +/** + * Binding keys excluded from a sub application. These bindings booted from the + * sub application won't be added to the main application. + */ +export const bindingKeysExcludedFromSubApp = [ + BootBindings.BOOT_OPTIONS.key, + BootBindings.PROJECT_ROOT.key, + BootBindings.BOOTSTRAPPER_KEY.key, + CoreBindings.APPLICATION_CONFIG.key, + CoreBindings.APPLICATION_INSTANCE.key, + CoreBindings.APPLICATION_METADATA.key, + CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY.key, + CoreBindings.LIFE_CYCLE_OBSERVER_OPTIONS.key, +]; + /** * Create a booter that boots the component application. Bindings that exist * in the component application before `boot` are skipped. Locked bindings in @@ -40,11 +55,6 @@ export function createBooterForComponentApplication( ) {} async load() { - const bootBindingKeys = [ - BootBindings.BOOT_OPTIONS.key, - BootBindings.PROJECT_ROOT.key, - BootBindings.BOOTSTRAPPER_KEY.key, - ]; /** * List all bindings before boot */ @@ -58,7 +68,7 @@ export function createBooterForComponentApplication( bindings = componentApp.find(filter); for (const binding of bindings) { // Exclude boot related bindings - if (bootBindingKeys.includes(binding.key)) continue; + if (bindingKeysExcludedFromSubApp.includes(binding.key)) continue; // Exclude bindings from the app before boot if (bindingsBeforeBoot.has(binding)) { diff --git a/packages/boot/src/booters/model.booter.ts b/packages/boot/src/booters/model.booter.ts index 37dc8fbca926..12e9814eeb91 100644 --- a/packages/boot/src/booters/model.booter.ts +++ b/packages/boot/src/booters/model.booter.ts @@ -4,8 +4,11 @@ // License text available at https://opensource.org/licenses/MIT import {config, Constructor, inject} from '@loopback/context'; -import {Application, CoreBindings} from '@loopback/core'; -import {ModelMetadataHelper} from '@loopback/repository'; +import {CoreBindings} from '@loopback/core'; +import { + ApplicationWithRepositories, + ModelMetadataHelper, +} from '@loopback/repository'; import debugFactory from 'debug'; import {BootBindings} from '../keys'; import {ArtifactOptions, booter} from '../types'; @@ -26,7 +29,7 @@ const debug = debugFactory('loopback:boot:model-booter'); export class ModelBooter extends BaseArtifactBooter { constructor( @inject(CoreBindings.APPLICATION_INSTANCE) - public app: Application, + public app: ApplicationWithRepositories, @inject(BootBindings.PROJECT_ROOT) projectRoot: string, @config() public modelConfig: ArtifactOptions = {}, @@ -53,7 +56,7 @@ export class ModelBooter extends BaseArtifactBooter { debug('Bind class: %s', cls.name); // We are binding the model class itself - const binding = this.app.bind(`models.${cls.name}`).to(cls).tag('model'); + const binding = this.app.model(cls); debug('Binding created for model class %s: %j', cls.name, binding); } } diff --git a/packages/boot/src/mixins/boot.mixin.ts b/packages/boot/src/mixins/boot.mixin.ts index 4ff5e05378f6..8230ed0c083b 100644 --- a/packages/boot/src/mixins/boot.mixin.ts +++ b/packages/boot/src/mixins/boot.mixin.ts @@ -5,6 +5,7 @@ import { Binding, + BindingFilter, BindingFromClassOptions, BindingScope, Constructor, @@ -13,6 +14,7 @@ import { } from '@loopback/context'; import {Application, Component, MixinTarget} from '@loopback/core'; import {BootComponent} from '../boot.component'; +import {createComponentApplicationBooterBinding} from '../booters/component-application.booter'; import {Bootstrapper} from '../bootstrapper'; import {BootBindings, BootTags} from '../keys'; import {Bootable, Booter, BootOptions} from '../types'; @@ -104,6 +106,20 @@ export function BootMixin>(superClass: T) { ); } + /** + * Register a booter to boot a sub-application. See + * {@link createComponentApplicationBooterBinding} for more details. + * + * @param subApp - A sub-application with artifacts to be booted + * @param filter - A binding filter to select what bindings from the sub + * application should be added to the main application. + */ + applicationBooter(subApp: Application & Bootable, filter?: BindingFilter) { + const binding = createComponentApplicationBooterBinding(subApp, filter); + this.add(binding); + return binding; + } + /** * Override to ensure any Booter's on a Component are also mounted. * diff --git a/packages/repository/src/__tests__/unit/mixins/repository.mixin.unit.ts b/packages/repository/src/__tests__/unit/mixins/repository.mixin.unit.ts index dd34b91e9575..05ee94b7cf0e 100644 --- a/packages/repository/src/__tests__/unit/mixins/repository.mixin.unit.ts +++ b/packages/repository/src/__tests__/unit/mixins/repository.mixin.unit.ts @@ -15,6 +15,7 @@ import { Repository, RepositoryMixin, } from '../../..'; +import {model, property} from '../../../decorators'; describe('RepositoryMixin', () => { it('mixed class has .repository()', () => { @@ -212,6 +213,7 @@ describe('RepositoryMixin', () => { expect(boundRepositories).to.containEql('repositories.NoteRepo'); const binding = myApp.getBinding('repositories.NoteRepo'); expect(binding.scope).to.equal(BindingScope.TRANSIENT); + expect(binding.tagMap).to.have.property('repository'); const repoInstance = myApp.getSync('repositories.NoteRepo'); expect(repoInstance).to.be.instanceOf(NoteRepo); } @@ -247,7 +249,8 @@ describe('RepositoryMixin dataSource', () => { it('binds dataSource class using the dataSourceName property', () => { const myApp = new AppWithRepoMixin(); - myApp.dataSource(FooDataSource); + const binding = myApp.dataSource(FooDataSource); + expect(binding.tagMap).to.have.property('datasource'); expectDataSourceToBeBound(myApp, FooDataSource, 'foo'); }); @@ -292,6 +295,9 @@ describe('RepositoryMixin dataSource', () => { expect(app.find('datasources.*').map(d => d.key)).to.containEql( `datasources.${name}`, ); + expect(app.findByTag('datasource').map(d => d.key)).to.containEql( + `datasources.${name}`, + ); expect(app.getSync(`datasources.${name}`)).to.be.instanceOf(ds); }; @@ -316,3 +322,26 @@ describe('RepositoryMixin dataSource', () => { } } }); + +describe('RepositoryMixin model', () => { + it('mixes into the target class', () => { + const myApp = new AppWithRepoMixin(); + expect(typeof myApp.model).to.be.eql('function'); + }); + + it('binds a model class', () => { + const myApp = new AppWithRepoMixin(); + const binding = myApp.model(MyModel); + expect(binding.key).to.eql('models.MyModel'); + expect(binding.tagMap).to.have.property('model'); + expect(myApp.getSync('models.MyModel')).to.eql(MyModel); + }); + + @model() + class MyModel { + @property() + name: string; + } + + class AppWithRepoMixin extends RepositoryMixin(Application) {} +}); diff --git a/packages/repository/src/index.ts b/packages/repository/src/index.ts index 420c28b19cc1..cd69fe695254 100644 --- a/packages/repository/src/index.ts +++ b/packages/repository/src/index.ts @@ -24,13 +24,14 @@ export * from './connectors'; export * from './datasource'; export * from './decorators'; export * from './define-model-class'; +export * from './define-repository-class'; export * from './errors'; +export * from './keys'; export * from './mixins'; export * from './model'; export * from './query'; export * from './relations'; export * from './repositories'; -export * from './define-repository-class'; export * from './transaction'; export * from './type-resolver'; export * from './types'; diff --git a/packages/repository/src/keys.ts b/packages/repository/src/keys.ts new file mode 100644 index 000000000000..edd9a4a447b4 --- /dev/null +++ b/packages/repository/src/keys.ts @@ -0,0 +1,40 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * Binding tags for repository related bindings + */ +export namespace RepositoryTags { + /** + * Tag for model class bindings + */ + export const MODEL = 'model'; + /** + * Tag for repository bindings + */ + export const REPOSITORY = 'repository'; + /** + * Tag for datasource bindings + */ + export const DATASOURCE = 'datasource'; +} + +/** + * Binding keys and namespaces for repository related bindings + */ +export namespace RepositoryBindings { + /** + * Namespace for model class bindings + */ + export const MODELS = 'models'; + /** + * Namespace for repository bindings + */ + export const REPOSITORIES = 'repositories'; + /** + * Namespace for datasource bindings + */ + export const DATASOURCES = 'datasources'; +} diff --git a/packages/repository/src/mixins/repository.mixin.ts b/packages/repository/src/mixins/repository.mixin.ts index db149636ff8b..3c9fce1fc079 100644 --- a/packages/repository/src/mixins/repository.mixin.ts +++ b/packages/repository/src/mixins/repository.mixin.ts @@ -9,10 +9,17 @@ import { BindingScope, createBindingFromClass, } from '@loopback/context'; -import {Application, Component, Constructor, MixinTarget} from '@loopback/core'; +import { + Application, + Component, + Constructor, + CoreBindings, + MixinTarget, +} from '@loopback/core'; import debugFactory from 'debug'; import {Class} from '../common-types'; import {SchemaMigrationOptions} from '../datasource'; +import {RepositoryBindings, RepositoryTags} from '../keys'; import {Model} from '../model'; import {juggler, Repository} from '../repositories'; @@ -71,11 +78,11 @@ export function RepositoryMixin>( nameOrOptions?: string | BindingFromClassOptions, ): Binding { const binding = createBindingFromClass(repoClass, { - namespace: 'repositories', - type: 'repository', + namespace: RepositoryBindings.REPOSITORIES, + type: RepositoryTags.REPOSITORY, defaultScope: BindingScope.TRANSIENT, ...toOptions(nameOrOptions), - }); + }).tag(RepositoryTags.REPOSITORY); this.add(binding); return binding; } @@ -122,15 +129,15 @@ export function RepositoryMixin>( if (dataSource instanceof juggler.DataSource) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const name = options.name || dataSource.name; - const namespace = options.namespace ?? 'datasources'; + const namespace = options.namespace ?? RepositoryBindings.DATASOURCES; const key = `${namespace}.${name}`; - return this.bind(key).to(dataSource).tag('datasource'); + return this.bind(key).to(dataSource).tag(RepositoryTags.DATASOURCE); } else if (typeof dataSource === 'function') { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing options.name = options.name || dataSource.dataSourceName; const binding = createBindingFromClass(dataSource, { - namespace: 'datasources', - type: 'datasource', + namespace: RepositoryBindings.DATASOURCES, + type: RepositoryTags.DATASOURCE, defaultScope: BindingScope.SINGLETON, ...options, }); @@ -141,6 +148,16 @@ export function RepositoryMixin>( } } + /** + * Register a model class as a binding in the target context + * @param modelClass - Model class + */ + model>(modelClass: M) { + const binding = createModelClassBinding(modelClass); + this.add(binding); + return binding; + } + /** * Add a component to this application. Also mounts * all the components repositories. @@ -184,7 +201,7 @@ export function RepositoryMixin>( * @param component - The component to mount repositories of */ mountComponentRepositories(component: Class) { - const componentKey = `components.${component.name}`; + const componentKey = `${CoreBindings.COMPONENTS}.${component.name}`; const compInstance = this.getSync<{ repositories?: Class>[]; }>(componentKey); @@ -222,7 +239,7 @@ export function RepositoryMixin>( // Look up all datasources and update/migrate schemas one by one const dsBindings: Readonly>[] = this.findByTag( - 'datasource', + RepositoryTags.DATASOURCE, ); for (const b of dsBindings) { const ds = await this.get(b.key); @@ -264,6 +281,7 @@ export interface ApplicationWithRepositories extends Application { dataSource: Class | D, name?: string, ): Binding; + model>(modelClass: M): Binding; component(component: Class, name?: string): Binding; mountComponentRepositories(component: Class): void; migrateSchema(options?: SchemaMigrationOptions): Promise; @@ -405,3 +423,15 @@ export class RepositoryMixinDoc { */ async migrateSchema(options?: SchemaMigrationOptions): Promise {} } + +/** + * Create a binding for the given model class + * @param modelClass - Model class + */ +export function createModelClassBinding>( + modelClass: M, +) { + return Binding.bind(`${RepositoryBindings.MODELS}.${modelClass.name}`) + .to(modelClass) + .tag(RepositoryTags.MODEL); +}