diff --git a/adonis-typings/migrator.ts b/adonis-typings/migrator.ts index 59654744..94b10209 100644 --- a/adonis-typings/migrator.ts +++ b/adonis-typings/migrator.ts @@ -15,10 +15,10 @@ declare module '@ioc:Adonis/Lucid/Migrator' { * Migration node returned by the migration source * implementation */ - export type MigrationNode = { + export type FileNode<T extends any> = { absPath: string, name: string, - source: SchemaConstructorContract, + source: T, } /** @@ -41,7 +41,7 @@ declare module '@ioc:Adonis/Lucid/Migrator' { export type MigratedFileNode = { status: 'completed' | 'error' | 'pending', queries: string[], - migration: MigrationNode, + migration: FileNode<SchemaConstructorContract>, batch: number, } diff --git a/adonis-typings/seeds.ts b/adonis-typings/seeds.ts new file mode 100644 index 00000000..f8fbe4d9 --- /dev/null +++ b/adonis-typings/seeds.ts @@ -0,0 +1,34 @@ +/* +* @adonisjs/lucid +* +* (c) Harminder Virk <virk@adonisjs.com> +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +declare module '@ioc:Adonis/Lucid/Seeder' { + import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' + + /** + * Shape of seeder class + */ + export type SeederConstructorContract = { + developmentOnly: boolean, + client: QueryClientContract, + new (client: QueryClientContract): { + run (): Promise<void> + } + } + + /** + * Shape of file node returned by the run method + */ + export type SeederFileNode = { + absPath: string, + name: string, + source: SeederConstructorContract, + status: 'pending' | 'completed' | 'failed' | 'ignored', + error?: any, + } +} diff --git a/commands/DbSeed.ts b/commands/DbSeed.ts new file mode 100644 index 00000000..c3670b48 --- /dev/null +++ b/commands/DbSeed.ts @@ -0,0 +1,155 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk <virk@adonisjs.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +import { inject } from '@adonisjs/fold' +import { SeederFileNode } from '@ioc:Adonis/Lucid/Seeder' +import { BaseCommand, Kernel, flags } from '@adonisjs/ace' +import { DatabaseContract } from '@ioc:Adonis/Lucid/Database' +import { ApplicationContract } from '@ioc:Adonis/Core/Application' + +@inject([null, null, 'Adonis/Lucid/Database']) +export default class DbSeed extends BaseCommand { + public static commandName = 'db:seed' + public static description = 'Execute database seeder files' + + /** + * Choose a custom pre-defined connection. Otherwise, we use the + * default connection + */ + @flags.string({ description: 'Define a custom database connection for the seeders', alias: 'c' }) + public connection: string + + /** + * Interactive mode allows selecting seeder files + */ + @flags.boolean({ description: 'Run seeders in interactive mode', alias: 'i' }) + public interactive: boolean + + /** + * Define a custom set of seeder files. Interactive and files together ignores + * the interactive mode. + */ + @flags.array({ description: 'Define a custom set of seeders files names to run', alias: 'f' }) + public files: string[] + + /** + * This command loads the application, since we need the runtime + * to find the migration directories for a given connection + */ + public static settings = { + loadApp: true, + } + + constructor (app: ApplicationContract, kernel: Kernel, private db: DatabaseContract) { + super(app, kernel) + } + + /** + * Print log message to the console + */ + private printLogMessage (file: SeederFileNode) { + const colors = this['colors'] + + let color: keyof typeof colors = 'gray' + let message: string = '' + let prefix: string = '' + + switch (file.status) { + case 'pending': + message = 'pending ' + color = 'gray' + break + case 'failed': + message = 'error ' + prefix = file.error!.message + color = 'red' + break + case 'ignored': + message = 'ignored ' + prefix = 'Enabled only in development environment' + color = 'dim' + break + case 'completed': + message = 'completed' + color = 'green' + break + } + + console.log(`${colors[color]('❯')} ${colors[color](message)} ${file.name}`) + if (prefix) { + console.log(` ${colors[color](prefix)}`) + } + } + + /** + * Execute command + */ + public async handle (): Promise<void> { + const client = this.db.connection(this.connection || this.db.primaryConnectionName) + + /** + * Ensure the define connection name does exists in the + * config file + */ + if (!client) { + this.logger.error( + `${this.connection} is not a valid connection name. Double check config/database file`, + ) + return + } + + const { SeedsRunner } = await import('../src/SeedsRunner') + const runner = new SeedsRunner(this.application.seedsPath(), process.env.NODE_ENV === 'development') + + /** + * List of available files + */ + const files = await runner.listSeeders() + + /** + * List of selected files. Initially, all files are selected and one can + * define cherry pick using the `--interactive` or `--files` flag. + */ + let selectedFileNames: string[] = files.map(({ name }) => name) + + if (this.files.length) { + selectedFileNames = this.files + if (this.interactive) { + this.logger.warn('Cannot use "--interactive" and "--files" together. Ignoring "--interactive"') + } + } else if (this.interactive) { + selectedFileNames = await this.prompt.multiple('Select files to run', files.map((file) => { + return { + disabled: file.status === 'ignored', + name: file.name, + hint: file.status === 'ignored' ? '(Enabled only in development environment)' : '', + } + })) + } + + /** + * Execute selected seeders + */ + for (let fileName of selectedFileNames) { + const sourceFile = files.find(({ name }) => fileName === name) + if (!sourceFile) { + this.printLogMessage({ + name: fileName, + status: 'failed', + error: new Error('Invalid file path. Pass relative path from the "database/seeds" directory'), + source: {} as any, + absPath: fileName, + }) + } else { + await runner.run(sourceFile, client) + this.printLogMessage(sourceFile) + } + } + } +} diff --git a/commands/MakeSeeder.ts b/commands/MakeSeeder.ts new file mode 100644 index 00000000..8329923d --- /dev/null +++ b/commands/MakeSeeder.ts @@ -0,0 +1,46 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk <virk@adonisjs.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +import { join } from 'path' +import { BaseCommand, args } from '@adonisjs/ace' + +export default class MakeSeeder extends BaseCommand { + public static commandName = 'make:seeder' + public static description = 'Make a new Seeder file' + + /** + * The name of the seeder file. + */ + @args.string({ description: 'Name of the seeder class' }) + public name: string + + /** + * Execute command + */ + public async handle (): Promise<void> { + const stub = join( + __dirname, + '..', + 'templates', + 'seeder.txt', + ) + + const path = this.application.seedsPath() + + this + .generator + .addFile(this.name, { pattern: 'pascalcase', form: 'singular' }) + .stub(stub) + .destinationDir(path || 'database/Seeders') + .useMustache() + .appRoot(this.application.cliCwd || this.application.appRoot) + + await this.generator.run() + } +} diff --git a/commands/index.ts b/commands/index.ts index 74d84e21..ff41456f 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -8,8 +8,10 @@ */ export default [ + '@adonisjs/lucid/build/commands/DbSeed', '@adonisjs/lucid/build/commands/MakeModel', '@adonisjs/lucid/build/commands/MakeMigration', + '@adonisjs/lucid/build/commands/MakeSeeder', '@adonisjs/lucid/build/commands/Migration/Run', '@adonisjs/lucid/build/commands/Migration/Rollback', '@adonisjs/lucid/build/commands/Migration/Status', diff --git a/package.json b/package.json index 394da50a..8cfb9b6a 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ "@poppinss/utils": "^2.2.6", "@types/faker": "^4.1.12", "cli-table3": "^0.6.0", - "fast-deep-equal": "^3.1.1", "faker": "^4.1.0", + "fast-deep-equal": "^3.1.1", "kleur": "^3.0.3", "knex": "^0.21.1", "knex-dynamic-connection": "^1.0.5", diff --git a/src/Migrator/MigrationSource.ts b/src/Migrator/MigrationSource.ts index ff7bada8..30f3748e 100644 --- a/src/Migrator/MigrationSource.ts +++ b/src/Migrator/MigrationSource.ts @@ -10,9 +10,10 @@ /// <reference path="../../adonis-typings/index.ts" /> import { join, isAbsolute, extname } from 'path' +import { FileNode } from '@ioc:Adonis/Lucid/Migrator' import { esmRequire, fsReadAll } from '@poppinss/utils' -import { MigrationNode } from '@ioc:Adonis/Lucid/Migrator' import { ConnectionConfig } from '@ioc:Adonis/Lucid/Database' +import { SchemaConstructorContract } from '@ioc:Adonis/Lucid/Schema' import { ApplicationContract } from '@ioc:Adonis/Core/Application' /** @@ -29,7 +30,7 @@ export class MigrationSource { * Returns an array of files inside a given directory. Relative * paths are resolved from the project root */ - private getDirectoryFiles (directoryPath: string): Promise<MigrationNode[]> { + private getDirectoryFiles (directoryPath: string): Promise<FileNode<SchemaConstructorContract>[]> { const basePath = this.app.appRoot return new Promise((resolve, reject) => { diff --git a/src/Migrator/index.ts b/src/Migrator/index.ts index db00e012..f6d0a342 100644 --- a/src/Migrator/index.ts +++ b/src/Migrator/index.ts @@ -14,7 +14,7 @@ import { Exception } from '@poppinss/utils' import { ApplicationContract } from '@ioc:Adonis/Core/Application' import { - MigrationNode, + FileNode, MigratorOptions, MigratedFileNode, MigratorContract, @@ -26,6 +26,7 @@ import { QueryClientContract, TransactionClientContract, } from '@ioc:Adonis/Lucid/Database' +import { SchemaConstructorContract } from '@ioc:Adonis/Lucid/Schema' import { MigrationSource } from './MigrationSource' @@ -182,7 +183,7 @@ export class Migrator extends EventEmitter implements MigratorContract { * Executes a given migration node and cleans up any created transactions * in case of failure */ - private async executeMigration (migration: MigrationNode) { + private async executeMigration (migration: FileNode<SchemaConstructorContract>) { const client = await this.getClient(migration.source.disableTransactions) try { diff --git a/src/SeedsRunner/index.ts b/src/SeedsRunner/index.ts new file mode 100644 index 00000000..473ccbc0 --- /dev/null +++ b/src/SeedsRunner/index.ts @@ -0,0 +1,81 @@ +/* +* @adonisjs/lucid +* +* (c) Harminder Virk <virk@adonisjs.com> +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +import { join, extname } from 'path' +import { esmRequire, fsReadAll } from '@poppinss/utils' +import { SeederFileNode } from '@ioc:Adonis/Lucid/Seeder' +import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' + +/** + * Seeds runner exposes the API to list files from the seeds and + * also run a collection of seeders + */ +export class SeedsRunner { + constructor ( + private seedsDir: string, + private isInDevelopment: boolean, + ) {} + + /** + * Returns an array of files inside a given directory. Relative + * paths are resolved from the project root + */ + public listSeeders (): Promise<SeederFileNode[]> { + return new Promise((resolve, reject) => { + const files = fsReadAll(this.seedsDir) + try { + resolve(files.sort().map((file) => { + const source = esmRequire(join(this.seedsDir, file)) + const ignored = source.developmentOnly && !this.isInDevelopment + + return { + absPath: join(this.seedsDir, file), + name: file.replace(RegExp(`${extname(file)}$`), ''), + source: esmRequire(join(this.seedsDir, file)), + status: ignored ? 'ignored' : 'pending', + } + })) + } catch (error) { + reject(error) + } + }) + } + + /** + * Returns an array of files inside a given directory. Relative + * paths are resolved from the project root + */ + public async run ( + seeder: SeederFileNode, + client: QueryClientContract, + ): Promise<SeederFileNode> { + /** + * Ignore when running in non-development environment and seeder is development + * only + */ + if (seeder.source.developmentOnly && !this.isInDevelopment) { + return seeder + } + + try { + const seederInstance = new seeder.source(client) + if (typeof (seederInstance.run) !== 'function') { + throw new Error(`Missing method "run" on "${seeder.name}" seeder`) + } + + await seederInstance.run() + seeder.status = 'completed' + } catch (error) { + seeder.status = 'failed' + seeder.error = error + } + + return seeder + } +} diff --git a/templates/seeder.txt b/templates/seeder.txt new file mode 100644 index 00000000..06a4262b --- /dev/null +++ b/templates/seeder.txt @@ -0,0 +1,10 @@ +import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' + +export default class {{ filename }} { + constructor (protected client: QueryClientContract) { + } + + public run () { + // Write your database queries inside the run method + } +} diff --git a/test/seeds/seeder.spec.ts b/test/seeds/seeder.spec.ts new file mode 100644 index 00000000..d12ec98f --- /dev/null +++ b/test/seeds/seeder.spec.ts @@ -0,0 +1,144 @@ +/* +* @adonisjs/lucid +* +* (c) Harminder Virk <virk@adonisjs.com> +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +import test from 'japa' +import { join } from 'path' +import { Filesystem } from '@poppinss/dev-utils' +import { SeedsRunner } from '../../src/SeedsRunner' +import { getDb, setup, cleanup } from '../../test-helpers' + +const fs = new Filesystem(join(__dirname, 'app')) +let db: ReturnType<typeof getDb> + +test.group('Seeder', (group) => { + group.before(async () => { + db = getDb() + await setup() + }) + + group.after(async () => { + await cleanup() + await db.manager.closeAll() + }) + + group.afterEach(async () => { + await fs.cleanup() + }) + + test('get list of seed files recursively', async (assert) => { + const runner = new SeedsRunner(fs.basePath, false) + await fs.add('User.ts', '') + await fs.add('Tenant/User.ts', '') + await fs.add('Country/Post.ts', '') + + const files = await runner.listSeeders() + assert.deepEqual(files, [ + { + absPath: join(fs.basePath, 'Country/Post.ts'), + name: 'Country/Post', + source: {} as any, + status: 'pending', + }, + { + absPath: join(fs.basePath, 'Tenant/User.ts'), + name: 'Tenant/User', + source: {} as any, + status: 'pending', + }, + { + absPath: join(fs.basePath, 'User.ts'), + name: 'User', + source: {} as any, + status: 'pending', + }, + ]) + }) + + test('only pick .ts/.js files', async (assert) => { + const runner = new SeedsRunner(fs.basePath, false) + await fs.add('User.ts', '') + await fs.add('Tenant/User.ts', '') + await fs.add('Country/Post.ts', '') + await fs.add('foo.bar', '') + await fs.add('foo.js', '') + + const files = await runner.listSeeders() + assert.deepEqual(files, [ + { + absPath: join(fs.basePath, 'Country/Post.ts'), + name: 'Country/Post', + source: {} as any, + status: 'pending', + }, + { + absPath: join(fs.basePath, 'Tenant/User.ts'), + name: 'Tenant/User', + source: {} as any, + status: 'pending', + }, + { + absPath: join(fs.basePath, 'User.ts'), + name: 'User', + source: {} as any, + status: 'pending', + }, + { + absPath: join(fs.basePath, 'foo.js'), + name: 'foo', + source: {} as any, + status: 'pending', + }, + ]) + }) + + test('run a seeder file', async (assert) => { + const runner = new SeedsRunner(fs.basePath, false) + await fs.add('User.ts', `export default class FooSeeder { + public static invoked = false + + run () { + (this.constructor as any).invoked = true + } + }`) + + const files = await runner.listSeeders() + const report = await runner.run(files[0], db.connection()) + assert.equal(report.source['invoked'], true) + assert.equal(report.status, 'completed') + }) + + test('catch and return seeder errors', async (assert) => { + const runner = new SeedsRunner(fs.basePath, false) + await fs.add('User.ts', `export default class FooSeeder { + run () { + throw new Error('Failed') + } + }`) + + const files = await runner.listSeeders() + const report = await runner.run(files[0], db.connection()) + assert.equal(report.status, 'failed') + assert.exists(report.error) + }) + + test('mark file as ignored when "developmentOnly = true" and not running in development mode', async (assert) => { + const runner = new SeedsRunner(fs.basePath, false) + await fs.add('User.ts', `export default class FooSeeder { + public static invoked = false + public static developmentOnly = true + + run () { + (this.constructor as any).invoked = true + } + }`) + + const files = await runner.listSeeders() + assert.equal(files[0].status, 'ignored') + }) +})