diff --git a/README.md b/README.md index 7c32672..8242043 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,58 @@ Supported features are: - `snapshot-restore`: Restore a snapshot - `snapshot-clean`: Delete all snapshots +### Jest test database manager +`TestDatabaseManager` is a helper with managing databases in Jest tests. +The ultimate purpose is to make sure that all test suites are run in isolation. +Data created in one tests shouldn't affect other tests. +How it works is: +- During Jest setup process, an empty base database is created. Then all the migration scripts are run on the base database. +- For each test suite, a brand new database is created by cloning the base database. All tests in the suite are run against this database. + When the test suite finishes running, the database is dropped. +- When all Jest tests finishes, the base database is dropped. + +To use it, please follow the below steps: + +- In Jest setup, initialise the test database manager +```typescript +export const testDbManager = new TestDatabaseManager({ + typeOrmOptions: typeOrmConfig, + dbPrefix: 'db_prefix_', + adminUsername: 'admin', + adminPassword: 'password', + postgresDatabaseName: 'postgres', +}) + +// in jest.setup,ts +export default async (): Promise => { + config({ override: true }) + await testDbManager.init() +} +``` +- In each test suite, create new database at the beginning and drop it at the end +```typescript +describe('reviewRaise mutation', () => { + let dispose: () => Promise = () => Promise.resolve() + + beforeAll(async () => { + const res = await testDbManager.prepareTestDatabaseSource() + dispose = res.dispose + }) + afterAll(async () => { + await dispose() + }) + + // Other tests +}) +``` +- Don't forget the drop the base database in Jest teardown +```typescript +// In jest.teardown.ts +export default async (): Promise => { + await testDbManager.dispose() +} +``` + ## Environment variables This toolkit expect you to set the below environment variables diff --git a/package.json b/package.json index cb18075..78ffd12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makerx/typeorm-pg-toolkit", - "version": "1.0.3", + "version": "1.1.0", "private": false, "description": "MakerX TypeORM Postgres toolkit", "author": "MakerX", @@ -15,8 +15,10 @@ "lint:fix": "eslint ./src/ --ext .ts --fix" }, "bin": { - "typeorm-pg-toolkit": "./index.js" + "typeorm-pg-toolkit": "./script.js" }, + "main": "index.js", + "types": "index.d.ts", "bugs": { "url": "https://github.com/MakerXStudio/typeorm-pg-toolkit/issues" }, diff --git a/src/index.ts b/src/index.ts index 9fb1fe5..cc712b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,235 +1 @@ -#!/usr/bin/env node - -// eslint-disable-next-line node/shebang -import { requestText, runChildProc, writeError, writeText, writeWarning, yeahNah } from './helpers' -import { Client } from 'pg' - -type commands = - | 'migration-generate' - | 'migration-create' - | 'migration-check' - | 'migration-revert' - | 'snapshot-create' - | 'snapshot-restore' - | 'snapshot-clean' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - | unknown - -const databaseConfig = { - host: process.env.TYPEORM_TOOLKIT_DATABASE_HOST!, - port: Number(process.env.TYPEORM_TOOLKIT_DATABASE_PORT!), - user: process.env.TYPEORM_TOOLKIT_DATABASE_USERNAME!, - password: process.env.TYPEORM_TOOLKIT_DATABASE_PASSWORD!, - database: process.env.TYPEORM_TOOLKIT_DATABASE_NAME!, -} - -run(process.argv[2]) - .then((code) => process.exit(code)) - .catch((e) => { - writeError(e instanceof Error ? e.message : `${e}`) - process.exit(-1) - }) - -async function run(command: commands): Promise { - switch (command) { - case 'snapshot-create': { - let snapshotName = process.argv[3] - if (!snapshotName) { - snapshotName = requestText('Enter a name for the snapshot: ') - } - await createSnapshot(snapshotName) - return 0 - } - case 'snapshot-restore': { - await restoreSnapshot(process.argv[3]) - return 0 - } - case 'snapshot-clean': - await cleanSnapshots() - return 0 - case 'migration-generate': - generateMigration(process.argv[3]) - return 0 - case 'migration-create': - createMigration(process.argv[3]) - return 0 - case 'migration-check': - return checkMigration() ?? 0 - case 'migration-revert': - revertMigration() - return 0 - default: - throw new Error('Missing command: Expected "create" or "restore"') - } -} - -async function createPgClient(): Promise { - const client = new Client({ ...databaseConfig, database: 'postgres' }) - writeText('Connecting to postgres') - await client.connect() - return client -} - -async function cleanSnapshots() { - const client = await createPgClient() - const databases = await getDatabases(client, databaseConfig.database) - if (databases.length === 0) { - writeText('There are no snapshot databases to remove.') - return - } - writeWarning(`This will drop the following snapshot databases: \n${databases.map((db) => ` - ${db}`).join('\n')}`) - - const confirmationText = `Yes I'm sure` - const confirmation = requestText(`Enter "${confirmationText}" to confirm this action: `) - if (confirmation !== confirmationText) throw new Error('Aborted by user') - for (const db of databases) await dropDatabase(client, db) -} - -async function createSnapshot(snapshotName: string | undefined) { - if (snapshotName === undefined || !/^[a-z_]+$/i.test(snapshotName)) { - throw new Error(`Invalid snapshot name ${snapshotName ?? ''}. Snapshot name must only contain letters and underscores`) - } - const snapshotDbName = `${databaseConfig.database}_${snapshotName}` - - const client = await createPgClient() - - const existingDatabases = await getDatabases(client, databaseConfig.database) - - if (existingDatabases.some((db) => db === snapshotDbName)) { - writeWarning(`Snapshot db with the name ${snapshotDbName} already exists.`) - if (yeahNah('Would you like to override this snapshot database?')) { - await dropDatabase(client, snapshotDbName) - } else throw new Error('Aborted by user') - } - - await closeOtherConnections(client, databaseConfig.database) - - writeText(`Creating snapshot database ${snapshotDbName}`) - - await client.query( - ` - create database "${snapshotDbName}" with template = "${databaseConfig.database}" - ` - ) -} - -async function restoreSnapshot(snapshotName: string | undefined) { - const client = await createPgClient() - - const snapshotDatabaseName = await getSnapshotDatabaseName(client, snapshotName) - - writeWarning('This will drop the main database and override it with the specified snapshot.') - - if (!yeahNah('Are you sure you want to continue?')) throw new Error('Aborted by user') - - await closeOtherConnections(client, databaseConfig.database) - await closeOtherConnections(client, snapshotDatabaseName) - - await dropDatabase(client, databaseConfig.database) - - writeText(`Restoring snapshot from ${snapshotDatabaseName}`) - await client.query(`CREATE DATABASE ${databaseConfig.database} WITH TEMPLATE = ${snapshotDatabaseName}`) - - if (yeahNah('Would you like to remove the snapshot?')) { - await dropDatabase(client, snapshotDatabaseName) - } -} - -async function getSnapshotDatabaseName(client: Client, snapshotName: string | undefined): Promise { - const databases = await getDatabases(client, databaseConfig.database) - - if (snapshotName) { - const matchedDbName = databases.find((db) => db === `${databaseConfig.database}_${snapshotName}`) - if (matchedDbName) { - return matchedDbName - } - writeWarning(`Couldn't find snapshot with the name "${snapshotName}"`) - } - writeText( - `Available snapshots: \n${databases.map((db, i) => ` ${i + 1}: ${db.substring(databaseConfig.database.length + 1)}`).join('\n')}` - ) - - const snapshotNumber = Number(requestText('Enter the number of the snapshot to restore: ')) - - if (isNaN(snapshotNumber) || !databases[snapshotNumber - 1]) throw new Error('Invalid snapshot selection') - return databases[snapshotNumber - 1]! -} - -async function dropDatabase(client: Client, databaseName: string) { - writeText(`Dropping snapshot database ${databaseName}`) - await client.query(`DROP DATABASE ${databaseName}`) -} - -async function getDatabases(client: Client, baseName: string) { - const data = await client.query<{ datname: string }>( - ` - SELECT datname - FROM pg_database - WHERE datname like $1 and datname <> $2 - ORDER BY datname - `, - [`${baseName}_%`, baseName] - ) - - return data.rows.map((r) => r.datname) -} - -async function closeOtherConnections(client: Client, dbName: string) { - writeText(`Closing active connections to ${dbName}`) - const query = ` - SELECT pg_terminate_backend(pg_stat_activity.pid) - FROM pg_stat_activity - WHERE pg_stat_activity.datname = $1::text - AND pid <> pg_backend_pid();` - - await client.query(query, [dbName]) -} - -function generateMigration(name: string) { - if (!name) { - writeText( - `No migration name provided. You can provide one by invoking 'npm run typeorm-pg-toolkit migration-generate -- MIGRATION_NAME'` - ) - name = requestText('Enter a name for the migrations: ') - } - - writeText(`Generating migration with name: ${name}`) - - runChildProc('typeorm-ts-node-commonjs', [ - `migration:generate`, - '--dataSource', - process.env.TYPEORM_TOOLKIT_MIGRATION_DATASOURCE_CONFIG!, - '--pretty', - `${process.env.TYPEORM_TOOLKIT_MIGRATION_ROOT_DIR}/${name}`, - ]) -} - -function createMigration(name: string) { - if (!name) { - writeText(`No migration name provided. You can provide one by invoking 'npm run typeorm-pg-toolkit migration-create -- MIGRATION_NAME'`) - name = requestText('Enter a name for the migrations: ') - } - - writeText(`Creating migration with name: ${name}`) - - runChildProc('typeorm-ts-node-commonjs', [`migration:create`, `${process.env.TYPEORM_TOOLKIT_MIGRATION_ROOT_DIR}/${name}`]) -} - -function checkMigration() { - writeText(`Checking if migration is needed`) - - return runChildProc('typeorm-ts-node-commonjs', [ - `migration:generate`, - '--dryrun', - '--dataSource', - process.env.TYPEORM_TOOLKIT_MIGRATION_DATASOURCE_CONFIG!, - '--check', - 'some/path', - ]) -} - -function revertMigration() { - writeText(`Reverting the latest migration`) - - runChildProc('typeorm-ts-node-commonjs', [`migration:revert`, '--dataSource', process.env.TYPEORM_TOOLKIT_MIGRATION_DATASOURCE_CONFIG!]) -} +export * from './test-database-manager' diff --git a/src/script.ts b/src/script.ts new file mode 100644 index 0000000..9fb1fe5 --- /dev/null +++ b/src/script.ts @@ -0,0 +1,235 @@ +#!/usr/bin/env node + +// eslint-disable-next-line node/shebang +import { requestText, runChildProc, writeError, writeText, writeWarning, yeahNah } from './helpers' +import { Client } from 'pg' + +type commands = + | 'migration-generate' + | 'migration-create' + | 'migration-check' + | 'migration-revert' + | 'snapshot-create' + | 'snapshot-restore' + | 'snapshot-clean' + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + | unknown + +const databaseConfig = { + host: process.env.TYPEORM_TOOLKIT_DATABASE_HOST!, + port: Number(process.env.TYPEORM_TOOLKIT_DATABASE_PORT!), + user: process.env.TYPEORM_TOOLKIT_DATABASE_USERNAME!, + password: process.env.TYPEORM_TOOLKIT_DATABASE_PASSWORD!, + database: process.env.TYPEORM_TOOLKIT_DATABASE_NAME!, +} + +run(process.argv[2]) + .then((code) => process.exit(code)) + .catch((e) => { + writeError(e instanceof Error ? e.message : `${e}`) + process.exit(-1) + }) + +async function run(command: commands): Promise { + switch (command) { + case 'snapshot-create': { + let snapshotName = process.argv[3] + if (!snapshotName) { + snapshotName = requestText('Enter a name for the snapshot: ') + } + await createSnapshot(snapshotName) + return 0 + } + case 'snapshot-restore': { + await restoreSnapshot(process.argv[3]) + return 0 + } + case 'snapshot-clean': + await cleanSnapshots() + return 0 + case 'migration-generate': + generateMigration(process.argv[3]) + return 0 + case 'migration-create': + createMigration(process.argv[3]) + return 0 + case 'migration-check': + return checkMigration() ?? 0 + case 'migration-revert': + revertMigration() + return 0 + default: + throw new Error('Missing command: Expected "create" or "restore"') + } +} + +async function createPgClient(): Promise { + const client = new Client({ ...databaseConfig, database: 'postgres' }) + writeText('Connecting to postgres') + await client.connect() + return client +} + +async function cleanSnapshots() { + const client = await createPgClient() + const databases = await getDatabases(client, databaseConfig.database) + if (databases.length === 0) { + writeText('There are no snapshot databases to remove.') + return + } + writeWarning(`This will drop the following snapshot databases: \n${databases.map((db) => ` - ${db}`).join('\n')}`) + + const confirmationText = `Yes I'm sure` + const confirmation = requestText(`Enter "${confirmationText}" to confirm this action: `) + if (confirmation !== confirmationText) throw new Error('Aborted by user') + for (const db of databases) await dropDatabase(client, db) +} + +async function createSnapshot(snapshotName: string | undefined) { + if (snapshotName === undefined || !/^[a-z_]+$/i.test(snapshotName)) { + throw new Error(`Invalid snapshot name ${snapshotName ?? ''}. Snapshot name must only contain letters and underscores`) + } + const snapshotDbName = `${databaseConfig.database}_${snapshotName}` + + const client = await createPgClient() + + const existingDatabases = await getDatabases(client, databaseConfig.database) + + if (existingDatabases.some((db) => db === snapshotDbName)) { + writeWarning(`Snapshot db with the name ${snapshotDbName} already exists.`) + if (yeahNah('Would you like to override this snapshot database?')) { + await dropDatabase(client, snapshotDbName) + } else throw new Error('Aborted by user') + } + + await closeOtherConnections(client, databaseConfig.database) + + writeText(`Creating snapshot database ${snapshotDbName}`) + + await client.query( + ` + create database "${snapshotDbName}" with template = "${databaseConfig.database}" + ` + ) +} + +async function restoreSnapshot(snapshotName: string | undefined) { + const client = await createPgClient() + + const snapshotDatabaseName = await getSnapshotDatabaseName(client, snapshotName) + + writeWarning('This will drop the main database and override it with the specified snapshot.') + + if (!yeahNah('Are you sure you want to continue?')) throw new Error('Aborted by user') + + await closeOtherConnections(client, databaseConfig.database) + await closeOtherConnections(client, snapshotDatabaseName) + + await dropDatabase(client, databaseConfig.database) + + writeText(`Restoring snapshot from ${snapshotDatabaseName}`) + await client.query(`CREATE DATABASE ${databaseConfig.database} WITH TEMPLATE = ${snapshotDatabaseName}`) + + if (yeahNah('Would you like to remove the snapshot?')) { + await dropDatabase(client, snapshotDatabaseName) + } +} + +async function getSnapshotDatabaseName(client: Client, snapshotName: string | undefined): Promise { + const databases = await getDatabases(client, databaseConfig.database) + + if (snapshotName) { + const matchedDbName = databases.find((db) => db === `${databaseConfig.database}_${snapshotName}`) + if (matchedDbName) { + return matchedDbName + } + writeWarning(`Couldn't find snapshot with the name "${snapshotName}"`) + } + writeText( + `Available snapshots: \n${databases.map((db, i) => ` ${i + 1}: ${db.substring(databaseConfig.database.length + 1)}`).join('\n')}` + ) + + const snapshotNumber = Number(requestText('Enter the number of the snapshot to restore: ')) + + if (isNaN(snapshotNumber) || !databases[snapshotNumber - 1]) throw new Error('Invalid snapshot selection') + return databases[snapshotNumber - 1]! +} + +async function dropDatabase(client: Client, databaseName: string) { + writeText(`Dropping snapshot database ${databaseName}`) + await client.query(`DROP DATABASE ${databaseName}`) +} + +async function getDatabases(client: Client, baseName: string) { + const data = await client.query<{ datname: string }>( + ` + SELECT datname + FROM pg_database + WHERE datname like $1 and datname <> $2 + ORDER BY datname + `, + [`${baseName}_%`, baseName] + ) + + return data.rows.map((r) => r.datname) +} + +async function closeOtherConnections(client: Client, dbName: string) { + writeText(`Closing active connections to ${dbName}`) + const query = ` + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = $1::text + AND pid <> pg_backend_pid();` + + await client.query(query, [dbName]) +} + +function generateMigration(name: string) { + if (!name) { + writeText( + `No migration name provided. You can provide one by invoking 'npm run typeorm-pg-toolkit migration-generate -- MIGRATION_NAME'` + ) + name = requestText('Enter a name for the migrations: ') + } + + writeText(`Generating migration with name: ${name}`) + + runChildProc('typeorm-ts-node-commonjs', [ + `migration:generate`, + '--dataSource', + process.env.TYPEORM_TOOLKIT_MIGRATION_DATASOURCE_CONFIG!, + '--pretty', + `${process.env.TYPEORM_TOOLKIT_MIGRATION_ROOT_DIR}/${name}`, + ]) +} + +function createMigration(name: string) { + if (!name) { + writeText(`No migration name provided. You can provide one by invoking 'npm run typeorm-pg-toolkit migration-create -- MIGRATION_NAME'`) + name = requestText('Enter a name for the migrations: ') + } + + writeText(`Creating migration with name: ${name}`) + + runChildProc('typeorm-ts-node-commonjs', [`migration:create`, `${process.env.TYPEORM_TOOLKIT_MIGRATION_ROOT_DIR}/${name}`]) +} + +function checkMigration() { + writeText(`Checking if migration is needed`) + + return runChildProc('typeorm-ts-node-commonjs', [ + `migration:generate`, + '--dryrun', + '--dataSource', + process.env.TYPEORM_TOOLKIT_MIGRATION_DATASOURCE_CONFIG!, + '--check', + 'some/path', + ]) +} + +function revertMigration() { + writeText(`Reverting the latest migration`) + + runChildProc('typeorm-ts-node-commonjs', [`migration:revert`, '--dataSource', process.env.TYPEORM_TOOLKIT_MIGRATION_DATASOURCE_CONFIG!]) +} diff --git a/src/test-database-manager.ts b/src/test-database-manager.ts new file mode 100644 index 0000000..fbed1ff --- /dev/null +++ b/src/test-database-manager.ts @@ -0,0 +1,142 @@ +import { randomUUID } from 'crypto' +import { Client } from 'pg' +import { DataSource, Logger } from 'typeorm' +import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions' + +const noop = () => void 0 +const errorOnlyLogger: Logger = { + logQueryError(error: string | Error, query: string, parameters?: any[]) { + // eslint-disable-next-line no-console + console.error('TYPEORM:ERROR', error, query, parameters) + }, + logQuery: noop, + logQuerySlow: noop, + logSchemaBuild: noop, + logMigration: noop, + log: noop, +} + +export class TestDatabaseManager { + private _toDispose: Array<() => Promise> = [] + private readonly typeOrmOptions: PostgresConnectionOptions + private readonly dbPrefix: string + private readonly baseDbName: string + private readonly adminUsername: string + private readonly adminPassword: string + private readonly postgresDatabaseName: string + + constructor({ + typeOrmOptions, + dbPrefix, + postgresDatabaseName, + adminUsername, + adminPassword, + }: { + typeOrmOptions: PostgresConnectionOptions + dbPrefix: string + postgresDatabaseName: string + adminUsername: string + adminPassword: string + }) { + this.dbPrefix = dbPrefix + this.baseDbName = `${dbPrefix}base` + + this.typeOrmOptions = { + ...typeOrmOptions, + logger: errorOnlyLogger, + } + + this.postgresDatabaseName = postgresDatabaseName + this.adminUsername = adminUsername + this.adminPassword = adminPassword + } + + async init() { + await this.dropDatabase(this.baseDbName) + await this.createBaseDatabase() + this._toDispose.push(() => this.dropDatabase(this.baseDbName)) + } + + async prepareTestDatabaseSource() { + const dbName = `${this.dbPrefix}${randomUUID().replaceAll('-', '_')}` + await this.cloneDatabase(this.baseDbName, dbName) + + const dataSource = new DataSource({ + ...this.typeOrmOptions, + username: this.adminUsername, + database: dbName, + }) + await dataSource.initialize() + + const dispose = async () => { + await dataSource.destroy() + await this.dropDatabase(dbName) + } + this._toDispose.push(dispose) + + return { + dataSource, + dispose, + } + } + + async dispose() { + return Promise.allSettled(this._toDispose.map((x) => x())) + } + + async createBaseDatabase() { + await this.runPgQuery(`create database ${this.baseDbName}`) + const datasource = new DataSource({ + ...this.typeOrmOptions, + username: this.adminUsername, + database: this.baseDbName, + }) + await datasource.initialize() + await datasource.runMigrations({ + transaction: 'each', + }) + await datasource.destroy() + } + + async dropDatabase(databaseName: string) { + if (!databaseName.startsWith(this.dbPrefix)) throw new Error(`Cannot drop non test database ${databaseName}`) + await this.closeOtherConnections(databaseName) + await this.runPgQuery(`DROP DATABASE IF EXISTS ${databaseName} `) + } + + cloneDatabase(sourceDatabaseName: string, copyDatabaseName: string) { + return this.runPgQuery(`create database "${copyDatabaseName}" with template = "${sourceDatabaseName}"`) + } + + closeOtherConnections(databaseName: string) { + return this.runPgQuery( + ` + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = $1::text + AND pid <> pg_backend_pid(); + `, + [databaseName] + ) + } + + async runPgQuery(query: string, ...args: any[]) { + const pg = new Client({ + host: this.typeOrmOptions.host, + port: this.typeOrmOptions.port, + password: this.adminPassword, + user: this.adminUsername, + database: this.postgresDatabaseName, + }) + try { + await pg.connect() + return await pg.query(query, args) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Query failed', query, e) + throw e + } finally { + await pg.end() + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 06a919c..2b5d52e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "./dist", "module": "commonjs", - "moduleResolution": "node" + "moduleResolution": "node", + "declaration": true } }