From 215631e80658f9f6ab7ce362ebec0d2385f0df68 Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Wed, 26 Oct 2022 10:13:49 +0200 Subject: [PATCH] Add `db:truncate` command with TestUtils helper (#889) * fix: truncate a table with reserved name was throwing with PG Like "user" that is a reserved word in postgres * feat: add `db:truncate` command + TestUtils helper * test: fix failing tests * chore: fix container ports * fix: cleanup table created in another test --- adonis-typings/test-utils.ts | 1 + commands/DbTruncate.ts | 139 +++++++++++++++++++++++++++++ commands/index.ts | 3 +- scripts/docker-compose.yml | 4 +- src/Bindings/TestUtils.ts | 4 + src/Dialects/Pg.ts | 4 +- src/Dialects/Redshift.ts | 4 +- src/TestUtils/Truncator.ts | 37 ++++++++ test/commands/db-truncate.spec.ts | 79 ++++++++++++++++ test/database/drop-tables.spec.ts | 2 +- test/database/query-client.spec.ts | 16 ++++ 11 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 commands/DbTruncate.ts create mode 100644 src/TestUtils/Truncator.ts create mode 100644 test/commands/db-truncate.spec.ts diff --git a/adonis-typings/test-utils.ts b/adonis-typings/test-utils.ts index 8f057313..51f0ea06 100644 --- a/adonis-typings/test-utils.ts +++ b/adonis-typings/test-utils.ts @@ -15,6 +15,7 @@ declare module '@ioc:Adonis/Core/TestUtils' { db(connectionName?: string): { seed: HookCallback migrate: HookCallback + truncate: HookCallback } } } diff --git a/commands/DbTruncate.ts b/commands/DbTruncate.ts new file mode 100644 index 00000000..62799b0a --- /dev/null +++ b/commands/DbTruncate.ts @@ -0,0 +1,139 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BaseCommand, flags } from '@adonisjs/core/build/standalone' +import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' + +export default class DbTruncate extends BaseCommand { + public static commandName = 'db:truncate' + public static description = 'Truncate all tables in database' + public static settings = { + loadApp: true, + } + + /** + * Choose a custom pre-defined connection. Otherwise, we use the + * default connection + */ + @flags.string({ description: 'Define a custom database connection', alias: 'c' }) + public connection: string + + /** + * Force command execution in production + */ + @flags.boolean({ description: 'Explicitly force command to run in production' }) + public force: boolean + + /** + * Not a valid connection + */ + private printNotAValidConnection(connection: string) { + this.logger.error( + `"${connection}" is not a valid connection name. Double check "config/database" file` + ) + } + + /** + * Prompts to take consent when truncating the database in production + */ + private async takeProductionConstent(): Promise { + /** + * Do not prompt when CLI is not interactive + */ + if (!this.isInteractive) { + return false + } + + const question = 'You are in production environment. Want to continue truncating the database?' + try { + return await this.prompt.confirm(question) + } catch (error) { + return false + } + } + + /** + * Truncate all tables except adonis migrations table + */ + private async performTruncate(client: QueryClientContract) { + let tables = await client.getAllTables(['public']) + tables = tables.filter((table) => !['adonis_schema', 'adonis_schema_versions'].includes(table)) + + await Promise.all(tables.map((table) => client.truncate(table, true))) + this.logger.success('Truncated tables successfully') + } + + /** + * Run as a subcommand. Never close database connections or exit + * process inside this method + */ + private async runAsSubCommand() { + const db = this.application.container.use('Adonis/Lucid/Database') + this.connection = this.connection || db.primaryConnectionName + const connection = db.connection(this.connection || db.primaryConnectionName) + + /** + * Continue with clearing the database when not in production + * or force flag is passed + */ + let continueTruncate = !this.application.inProduction || this.force + if (!continueTruncate) { + continueTruncate = await this.takeProductionConstent() + } + + /** + * Do not continue when in prod and the prompt was cancelled + */ + if (!continueTruncate) { + return + } + + /** + * Invalid database connection + */ + if (!db.manager.has(this.connection)) { + this.printNotAValidConnection(this.connection) + this.exitCode = 1 + return + } + + await this.performTruncate(connection) + } + + /** + * Branching out, so that if required we can implement + * "runAsMain" separately from "runAsSubCommand". + * + * For now, they both are the same + */ + private async runAsMain() { + await this.runAsSubCommand() + } + + /** + * Handle command + */ + public async run(): Promise { + if (this.isMain) { + await this.runAsMain() + } else { + await this.runAsSubCommand() + } + } + + /** + * Lifecycle method invoked by ace after the "run" + * method. + */ + public async completed() { + if (this.isMain) { + await this.application.container.use('Adonis/Lucid/Database').manager.closeAll(true) + } + } +} diff --git a/commands/index.ts b/commands/index.ts index bc08c693..e0bcead3 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -9,11 +9,12 @@ export default [ '@adonisjs/lucid/build/commands/DbSeed', + '@adonisjs/lucid/build/commands/DbWipe', + '@adonisjs/lucid/build/commands/DbTruncate', '@adonisjs/lucid/build/commands/MakeModel', '@adonisjs/lucid/build/commands/MakeMigration', '@adonisjs/lucid/build/commands/MakeSeeder', '@adonisjs/lucid/build/commands/MakeFactory', - '@adonisjs/lucid/build/commands/DbWipe', '@adonisjs/lucid/build/commands/Migration/Run', '@adonisjs/lucid/build/commands/Migration/Rollback', '@adonisjs/lucid/build/commands/Migration/Status', diff --git a/scripts/docker-compose.yml b/scripts/docker-compose.yml index ce039f38..4075e961 100644 --- a/scripts/docker-compose.yml +++ b/scripts/docker-compose.yml @@ -49,7 +49,7 @@ services: MYSQL_PASSWORD: password MYSQL_ROOT_PASSWORD: password ports: - - '3305:3306' + - '3306:3306' expose: - '3306' @@ -67,7 +67,7 @@ services: mssql: image: mcr.microsoft.com/mssql/server:2019-latest ports: - - 21433:1433 + - 1433:1433 expose: - '1433' environment: diff --git a/src/Bindings/TestUtils.ts b/src/Bindings/TestUtils.ts index 8fa3b8bc..61e0bcd7 100644 --- a/src/Bindings/TestUtils.ts +++ b/src/Bindings/TestUtils.ts @@ -13,6 +13,7 @@ import type Ace from '@ioc:Adonis/Core/Ace' import { TestsSeeder } from '../TestUtils/Seeder' import { TestsMigrator } from '../TestUtils/Migration' +import { TestsTruncator } from '../TestUtils/Truncator' /** * Define database testing utilities @@ -26,6 +27,9 @@ export function defineTestUtils(testUtils: TestUtilsContract, ace: typeof Ace) { seed() { return new TestsSeeder(ace, connectionName).run() }, + truncate() { + return new TestsTruncator(ace, connectionName).run() + }, } }) } diff --git a/src/Dialects/Pg.ts b/src/Dialects/Pg.ts index 750a5008..0355e6c9 100644 --- a/src/Dialects/Pg.ts +++ b/src/Dialects/Pg.ts @@ -79,8 +79,8 @@ export class PgDialect implements DialectContract { */ public async truncate(table: string, cascade: boolean = false) { return cascade - ? this.client.rawQuery(`TRUNCATE ${table} RESTART IDENTITY CASCADE;`) - : this.client.rawQuery(`TRUNCATE ${table};`) + ? this.client.rawQuery(`TRUNCATE "${table}" RESTART IDENTITY CASCADE;`) + : this.client.rawQuery(`TRUNCATE "${table}";`) } /** diff --git a/src/Dialects/Redshift.ts b/src/Dialects/Redshift.ts index 9a9c065c..4fc0b4bd 100644 --- a/src/Dialects/Redshift.ts +++ b/src/Dialects/Redshift.ts @@ -87,8 +87,8 @@ export class RedshiftDialect implements DialectContract { */ public async truncate(table: string, cascade: boolean = false) { return cascade - ? this.client.rawQuery(`TRUNCATE ${table} RESTART IDENTITY CASCADE;`) - : this.client.rawQuery(`TRUNCATE ${table};`) + ? this.client.rawQuery(`TRUNCATE "${table}" RESTART IDENTITY CASCADE;`) + : this.client.rawQuery(`TRUNCATE "${table}";`) } /** diff --git a/src/TestUtils/Truncator.ts b/src/TestUtils/Truncator.ts new file mode 100644 index 00000000..6b3295d5 --- /dev/null +++ b/src/TestUtils/Truncator.ts @@ -0,0 +1,37 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type Ace from '@ioc:Adonis/Core/Ace' + +/** + * Migrator class to be used for testing. + */ +export class TestsTruncator { + constructor(private ace: typeof Ace, private connectionName?: string) {} + + private async runCommand(commandName: string, args: string[] = []) { + if (this.connectionName) { + args.push(`--connection=${this.connectionName}`) + } + + const command = await this.ace.exec(commandName, args) + if (command.exitCode) { + if (command.error) { + throw command.error + } else { + throw new Error(`"${commandName}" failed`) + } + } + } + + public async run() { + await this.runCommand('migration:run', ['--compact-output']) + return () => this.runCommand('db:truncate') + } +} diff --git a/test/commands/db-truncate.spec.ts b/test/commands/db-truncate.spec.ts new file mode 100644 index 00000000..eb90f081 --- /dev/null +++ b/test/commands/db-truncate.spec.ts @@ -0,0 +1,79 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Kernel } from '@adonisjs/core/build/standalone' +import { ApplicationContract } from '@ioc:Adonis/Core/Application' +import { fs, setup, cleanup, getDb, setupApplication } from '../../test-helpers' +import DbTruncate from '../../commands/DbTruncate' + +let app: ApplicationContract +let db: ReturnType + +test.group('db:truncate', (group) => { + group.each.setup(async () => { + app = await setupApplication() + return () => fs.cleanup() + }) + + group.each.setup(async () => { + db = getDb(app) + app.container.bind('Adonis/Lucid/Database', () => db) + await setup() + + return async () => { + await cleanup() + await cleanup(['adonis_schema', 'adonis_schema_versions']) + await db.manager.closeAll() + } + }) + + test('should truncate all tables', async ({ assert }) => { + const kernel = new Kernel(app).mockConsoleOutput() + kernel.register([DbTruncate]) + + await db.table('users').insert({ username: 'bonjour' }) + await db.table('users').insert({ username: 'bonjour2' }) + await db.table('friends').insert({ username: 'bonjour' }) + + await kernel.exec('db:truncate', []) + + const usersCount = await db.from('users').count('*', 'total') + const friendsCount = await db.from('friends').count('*', 'total') + + assert.equal(usersCount[0]['total'], 0) + assert.equal(friendsCount[0]['total'], 0) + }) + + test('should not truncate adonis migrations tables', async ({ assert }) => { + const kernel = new Kernel(app).mockConsoleOutput() + kernel.register([DbTruncate]) + + await db.connection().schema.createTable('adonis_schema', (table) => { + table.increments('id') + table.string('name') + }) + + await db.connection().schema.createTable('adonis_schema_versions', (table) => { + table.increments('id') + table.string('name') + }) + + await db.table('adonis_schema').insert({ name: 'bonjour' }) + await db.table('adonis_schema_versions').insert({ name: 'bonjour' }) + + await kernel.exec('db:truncate', []) + + const adonisSchemaCount = await db.from('adonis_schema').count('*', 'total') + const adonisSchemaVersionsCount = await db.from('adonis_schema_versions').count('*', 'total') + + assert.equal(adonisSchemaCount[0]['total'], 1) + assert.equal(adonisSchemaVersionsCount[0]['total'], 1) + }) +}) diff --git a/test/database/drop-tables.spec.ts b/test/database/drop-tables.spec.ts index b2a98d50..d6d83cc4 100644 --- a/test/database/drop-tables.spec.ts +++ b/test/database/drop-tables.spec.ts @@ -26,7 +26,7 @@ test.group('Query client | drop tables', (group) => { }) group.teardown(async () => { - await cleanup(['temp_posts', 'temp_users', 'table_that_should_not_be_dropped']) + await cleanup(['temp_posts', 'temp_users', 'table_that_should_not_be_dropped', 'ignore_me']) await cleanup() await fs.cleanup() }) diff --git a/test/database/query-client.spec.ts b/test/database/query-client.spec.ts index 196bd123..b6cc94c4 100644 --- a/test/database/query-client.spec.ts +++ b/test/database/query-client.spec.ts @@ -143,6 +143,22 @@ test.group('Query client', (group) => { await connection.disconnect() }) } + + test('truncate a table with reserved keywork', async () => { + const connection = new Connection('primary', getConfig(), app.logger) + connection.connect() + + await connection.client?.schema.createTableIfNotExists('user', (table) => { + table.increments('id').primary() + table.string('username') + }) + + const client = new QueryClient('write', connection, app.container.use('Adonis/Core/Event')) + await client.truncate('user', true) + + await connection.client?.schema.dropTable('user') + await connection.disconnect() + }) }) test.group('Query client | dual mode', (group) => {