diff --git a/docs/site/Database-migrations.md b/docs/site/Database-migrations.md index eca800c79ed7..80446196aea7 100644 --- a/docs/site/Database-migrations.md +++ b/docs/site/Database-migrations.md @@ -20,123 +20,111 @@ LoopBack offers two ways to do this: - **Auto-update**: Change database schema objects if there is a difference between the objects and model definitions. Existing data will be kept. -## Implementation Example +{% include warning.html content="Auto-update will attempt to preserve data while +updating the schema in your target database, but this is not guaranteed to be +safe. -Below is an example of how to implement -[automigrate()](http://apidocs.loopback.io/loopback-datasource-juggler/#datasource-prototype-automigrate) -and -[autoupdate()](http://apidocs.loopback.io/loopback-datasource-juggler/#datasource-prototype-autoupdate), -shown with the -[TodoList](https://loopback.io/doc/en/lb4/todo-list-tutorial.html) example. +Please check the documentation for your specific connector(s) for a detailed +breakdown of behaviors for automigrate! " %} -Create a new file **src/migrate.ts** and add the following import statement: +## Examples -```ts -import {DataSource, Repository} from '@loopback/repository'; -``` - -Import your application and your repositories: +LoopBack applications are typically using `RepositoryMixin` to enhance the core +`Application` class with additional repository-related APIs. One of such methods +is `migrateSchema`, which iterates over all registered repositories and asks +them to migrate their schema. Repositories that do not support schema migrations +are silently skipped. -```ts -import {TodoListApplication} from './index'; -import {TodoRepository, TodoListRepository} from './repositories'; -``` +In the future, we would like to provide finer-grained control of database schema +updates, learn more in the GitHub issue +[#487 Database Migration Management Framework](https://github.com/strongloop/loopback-next/issues/487) -Create a function called _dsMigrate()_: +### Auto-update database at start -```ts -export async function dsMigrate(app: TodoListApplication) {} -``` - -In the _dsMigrate()_ function, get your datasource and instantiate your -repositories by retrieving them, so that the models are attached to the -corresponding datasource: - -```ts -const ds = await app.get('datasources.db'); -const todoRepo = await app.getRepository(TodoRepository); -const todoListRepo = await app.getRepository(TodoListRepository); -``` +To automatically update the database schema whenever the application is started, +modify your main script to execute `app.migrateSchema()` after the application +was bootstrapped (all repositories were registered) but before it is actually +started. -Then, in the same function, call _automigrate()_: +{% include code-caption.html content="src/index.ts" %} ```ts -await ds.automigrate(); -``` - -This call to automigrate will migrate all the models attached to the datasource -db. However if you want to only migrate some of your models, add the names of -the classes in the first parameter: +export async function main(options: ApplicationConfig = {}) { + const app = new TodoListApplication(options); + await app.boot(); + await app.migrateSchema(); + await app.start(); -```ts -// Migrate a single model -ds.automigrate('Todo'); -``` + const url = app.restServer.url; + console.log(`Server is running at ${url}`); -```ts -// Migrate multiple models -ds.automigrate(['Todo', 'TodoList']); + return app; +} ``` -The implementation for _autoupdate()_ is similar. Create a new function -_dsUpdate()_: - -```ts -export async function dsUpdate(app: TodoListApplication) { - const ds = await app.get('datasources.db'); - const todoRepo = await app.getRepository(TodoRepository); - const todoListRepo = await app.getRepository(TodoListRepository); +### Auto-update the database explicitly - await ds.autoupdate(); -} -``` +It's usually better to have more control about the database migration and +trigger the updates explicitly. To do so, you can implement a custom script as +shown below. -The completed **src/migrate.ts** should look similar to this: +{% include code-caption.html content="src/migrate.ts" %} ```ts -import {DataSource, Repository} from '@loopback/repository'; -import {TodoListApplication} from './index'; -import {TodoRepository, TodoListRepository} from './repositories'; +import {TodoListApplication} from './application'; -export async function dsMigrate(app: TodoListApplication) { - const ds = await app.get('datasources.db'); - const todoRepo = await app.getRepository(TodoRepository); - const todoListRepo = await app.getRepository(TodoListRepository); +export async function migrate(args: string[]) { + const dropExistingTables = args.includes('--rebuild'); + console.log('Migrating schemas (%s)', rebuild ? 'rebuild' : 'update'); - await ds.automigrate(); + const app = new TodoListApplication(); + await app.boot(); + await app.migrateSchema({dropExistingTables}); } -export async function dsUpdate(app: TodoListApplication) { - const ds = await app.get('datasources.db'); - const todoRepo = await app.getRepository(TodoRepository); - const todoListRepo = await app.getRepository(TodoListRepository); - - await ds.autoupdate(); -} +migrate(process.argv).catch(err => { + console.error('Cannot migrate database schema', err); + process.exit(1); +}); ``` -Finally, in **src/index.ts**, import and call the _dsMigrate()_ or _dsUpdate()_ -function: +After you have compiled your application via `npm run build`, you can update +your database by running `node dist/src/migrate` and rebuild it from scratch by +running `node dist/src/migrate --rebuild`. It is also possible to save this +commands as `npm` scripts in your `package.json` file. -```ts -import {TodoListApplication} from './application'; -import {ApplicationConfig} from '@loopback/core'; - -// Import the functions from src/migrate.ts -import {dsMigrate, dsUpdate} from './migrate'; +### Implement additional migration steps -export {TodoListApplication}; +In some scenarios, the application may need to define additional schema +constraints or seed the database with predefined model instances. This can be +achieved by overriding the `migrateSchema` method provided by the mixin. -export async function main(options: ApplicationConfig = {}) { - const app = new TodoListApplication(options); - await app.boot(); - await app.start(); +The example below shows how to do so in our Todo example application. - const url = app.restServer.url; - console.log(`Server is running at ${url}`); +{% include code-caption.html content="src/application.ts" %} - // The call to dsMigrate(), or replace with dsUpdate() - await dsMigrate(app); - return app; +```ts +import {TodoRepository} from './repositories'; +// skipped: other imports + +export class TodoListApplication extends BootMixin( + ServiceMixin(RepositoryMixin(RestApplication)), +) { + // skipped: the constructor, etc. + + async migrateSchema(options?: SchemaMigrationOptions) { + // 1. Run migration scripts provided by connectors + await super.migrateSchema(options); + + // 2. Make further changes. When creating predefined model instances, + // handle the case when these instances already exist. + const todoRepo = await this.getRepository(TodoRepository); + const found = await todoRepo.findOne({where: {title: 'welcome'}}); + if (found) { + todoRepo.updateById(found.id, {isComplete: false}); + } else { + await todoRepo.create({title: 'welcome', isComplete: false}); + } + } } ``` diff --git a/examples/todo/src/migrate.ts b/examples/todo/src/migrate.ts new file mode 100644 index 000000000000..f4d3b69813a5 --- /dev/null +++ b/examples/todo/src/migrate.ts @@ -0,0 +1,25 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-todo +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {TodoListApplication} from './application'; + +export async function migrate(args: string[]) { + const existingSchema = args.includes('--rebuild') ? 'drop' : 'alter'; + console.log('Migrating schemas (%s existing schema)', existingSchema); + + const app = new TodoListApplication(); + await app.boot(); + await app.migrateSchema({existingSchema}); + + // Connectors usually keep a pool of opened connections, + // this keeps the process running even after all work is done. + // We need to exit explicitly. + process.exit(0); +} + +migrate(process.argv).catch(err => { + console.error('Cannot migrate database schema', err); + process.exit(1); +}); diff --git a/packages/repository/src/datasource.ts b/packages/repository/src/datasource.ts index 05e6ca3d4293..3a7f5e9949e1 100644 --- a/packages/repository/src/datasource.ts +++ b/packages/repository/src/datasource.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {AnyObject} from './common-types'; +import {AnyObject, Options} from './common-types'; import {Connector} from './connectors'; /** @@ -17,3 +17,21 @@ export interface DataSource { // tslint:disable-next-line:no-any [property: string]: any; // Other properties that vary by connectors } + +export interface SchemaMigrationOptions extends Options { + /** + * When set to 'drop', schema migration will drop existing tables and recreate + * them from scratch, removing any existing data along the way. + * + * When set to 'alter', schema migration will try to preserve current schema + * and data, and perform a non-destructive incremental update. + */ + existingSchema?: 'drop' | 'alter'; + + /** + * List of model names to migrate. + * + * By default, all models are migrated. + */ + models?: string[]; +} diff --git a/packages/repository/src/mixins/repository.mixin.ts b/packages/repository/src/mixins/repository.mixin.ts index 382eb71eead2..9f6ea634b6c7 100644 --- a/packages/repository/src/mixins/repository.mixin.ts +++ b/packages/repository/src/mixins/repository.mixin.ts @@ -3,11 +3,14 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Class} from '../common-types'; -import {Repository} from '../repositories/repository'; -import {juggler} from '../repositories/legacy-juggler-bridge'; +import {BindingScope, Binding} from '@loopback/context'; import {Application} from '@loopback/core'; -import {BindingScope} from '@loopback/context'; +import * as debugFactory from 'debug'; +import {Class} from '../common-types'; +import {juggler, Repository} from '../repositories'; +import {SchemaMigrationOptions} from '../datasource'; + +const debug = debugFactory('loopback:repository:mixin'); /** * A mixin class for Application that creates a .repository() @@ -163,6 +166,46 @@ export function RepositoryMixin>(superClass: T) { } } } + + /** + * Update or recreate the database schema for all repositories. + * + * **WARNING**: By default, `migrateSchema()` will attempt to preserve data + * while updating the schema in your target database, but this is not + * guaranteed to be safe. + * + * Please check the documentation for your specific connector(s) for + * a detailed breakdown of behaviors for automigrate! + * + * @param options Migration options, e.g. whether to update tables + * preserving data or rebuild everything from scratch. + */ + async migrateSchema(options: SchemaMigrationOptions = {}): Promise { + const operation = + options.existingSchema === 'drop' ? 'automigrate' : 'autoupdate'; + + // Instantiate all repositories to ensure models are registered & attached + // to their datasources + const repoBindings: Readonly>[] = this.findByTag( + 'repository', + ); + await Promise.all(repoBindings.map(b => this.get(b.key))); + + // Look up all datasources and update/migrate schemas one by one + const dsBindings: Readonly>[] = this.findByTag( + 'datasource', + ); + for (const b of dsBindings) { + const ds = await this.get(b.key); + + if (operation in ds && typeof ds[operation] === 'function') { + debug('Migrating dataSource %s', b.key); + await ds[operation](options.models); + } else { + debug('Skipping migration of dataSource %s', b.key); + } + } + } }; } @@ -180,6 +223,7 @@ export interface ApplicationWithRepositories extends Application { ): void; component(component: Class<{}>): void; mountComponentRepositories(component: Class<{}>): void; + migrateSchema(options?: SchemaMigrationOptions): Promise; } /** @@ -293,4 +337,19 @@ export class RepositoryMixinDoc { * @param component The component to mount repositories of */ mountComponentRepository(component: Class<{}>) {} + + /** + * Update or recreate the database schema for all repositories. + * + * **WARNING**: By default, `migrateSchema()` will attempt to preserve data + * while updating the schema in your target database, but this is not + * guaranteed to be safe. + * + * Please check the documentation for your specific connector(s) for + * a detailed breakdown of behaviors for automigrate! + * + * @param options Migration options, e.g. whether to update tables + * preserving data or rebuild everything from scratch. + */ + async migrateSchema(options?: SchemaMigrationOptions): Promise {} } diff --git a/packages/repository/test/unit/mixins/repository.mixin.unit.ts b/packages/repository/test/unit/mixins/repository.mixin.unit.ts index 5f9f64129af1..e50c94c58152 100644 --- a/packages/repository/test/unit/mixins/repository.mixin.unit.ts +++ b/packages/repository/test/unit/mixins/repository.mixin.unit.ts @@ -3,10 +3,18 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {expect} from '@loopback/testlab'; - -import {juggler, RepositoryMixin, Class, Repository} from '../../../'; -import {Application, Component} from '@loopback/core'; +import {Application, BindingScope, Component} from '@loopback/core'; +import {expect, sinon} from '@loopback/testlab'; +import { + Class, + DataSource, + DefaultCrudRepository, + Entity, + juggler, + ModelDefinition, + Repository, + RepositoryMixin, +} from '../../..'; // tslint:disable:no-any @@ -63,6 +71,108 @@ describe('RepositoryMixin', () => { expectNoteRepoToBeBound(myApp); }); + context('migrateSchema', () => { + let app: AppWithRepoMixin; + let migrateStub: sinon.SinonStub; + let updateStub: sinon.SinonStub; + let DataSourceStub: typeof juggler.DataSource; + + beforeEach(setupTestHelpers); + + it('is a method provided by the mixin', () => { + expect(typeof app.migrateSchema).to.be.eql('function'); + }); + + it('calls autoupdate on registered datasources', async () => { + app.dataSource(DataSourceStub); + + await app.migrateSchema({existingSchema: 'alter'}); + + sinon.assert.called(updateStub); + sinon.assert.notCalled(migrateStub); + }); + + it('calls automigrate on registered datasources', async () => { + app.dataSource(DataSourceStub); + + await app.migrateSchema({existingSchema: 'drop'}); + + sinon.assert.called(migrateStub); + sinon.assert.notCalled(updateStub); + }); + + it('skips datasources not implementing schema migrations', async () => { + class OtherDataSource implements DataSource { + name: string = 'other'; + connector = undefined; + settings = {}; + } + + // Bypass app.dataSource type checks and bind a custom datasource class + app + .bind('datasources.other') + .toClass(OtherDataSource) + .tag('datasource') + .inScope(BindingScope.SINGLETON); + + await app.migrateSchema({existingSchema: 'drop'}); + // the test passes when migrateSchema() does not throw any error + }); + + it('attaches all models to datasources', async () => { + let modelsMigrated = ['no models were migrated']; + + const ds = new juggler.DataSource({name: 'db', connector: 'memory'}); + // FIXME(bajtos) typings for connectors are missing autoupdate/autoupgrade + (ds.connector as any).automigrate = function( + models: string[], + cb: Function, + ) { + modelsMigrated = models; + cb(); + }; + app.dataSource(ds); + + class Product extends Entity { + static definition = new ModelDefinition('Product').addProperty('id', { + type: 'number', + id: true, + }); + } + class ProductRepository extends DefaultCrudRepository { + constructor() { + super(Product, ds); + } + } + app.repository(ProductRepository); + + await app.migrateSchema({existingSchema: 'drop'}); + + expect(modelsMigrated).to.eql(['Product']); + }); + + it('migrates selected models only', async () => { + app.dataSource(DataSourceStub); + + await app.migrateSchema({existingSchema: 'drop', models: ['Category']}); + + sinon.assert.calledWith(migrateStub, ['Category']); + sinon.assert.notCalled(updateStub); + }); + + function setupTestHelpers() { + app = new AppWithRepoMixin(); + + migrateStub = sinon.stub().resolves(); + updateStub = sinon.stub().resolves(); + + DataSourceStub = class extends juggler.DataSource { + automigrate = migrateStub; + autoupdate = updateStub; + }; + } + }); + class AppWithRepoMixin extends RepositoryMixin(Application) {} class NoteRepo implements Repository {