diff --git a/adonis-typings/database.ts b/adonis-typings/database.ts index 329e4331..16a2601a 100644 --- a/adonis-typings/database.ts +++ b/adonis-typings/database.ts @@ -212,7 +212,6 @@ declare module '@ioc:Adonis/Lucid/Database' { export type MigratorConfigContract = { disableTransactions?: boolean, paths?: string[], - schemaName?: string, tableName?: string, } diff --git a/adonis-typings/migrator.ts b/adonis-typings/migrator.ts new file mode 100644 index 00000000..8efd1c68 --- /dev/null +++ b/adonis-typings/migrator.ts @@ -0,0 +1,22 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +declare module '@ioc:Adonis/Lucid/Migrator' { + import { SchemaConstructorContract } from '@ioc:Adonis/Lucid/Schema' + + export type MigrationNode = { + absPath: string, + name: string, + source: SchemaConstructorContract, + } + + export interface MigratorContract { + migrate (): Promise + } +} diff --git a/adonis-typings/schema.ts b/adonis-typings/schema.ts new file mode 100644 index 00000000..94225f10 --- /dev/null +++ b/adonis-typings/schema.ts @@ -0,0 +1,35 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +declare module '@ioc:Adonis/Lucid/Schema' { + import { QueryClientContract, ExcutableQueryBuilderContract } from '@ioc:Adonis/Lucid/Database' + import { RawContract } from '@ioc:Adonis/Lucid/DatabaseQueryBuilder' + import { SchemaBuilder } from 'knex' + + export type DeferCallback = (client: QueryClientContract) => void | Promise + + export interface SchemaConstructorContract { + new (db: QueryClientContract, file: string, dryRun: boolean): SchemaContract + } + + export interface SchemaContract { + dryRun: boolean + db: QueryClientContract + schema: SchemaBuilder + file: string + disableTransactions: boolean + + now (precision?: number): RawContract & ExcutableQueryBuilderContract + defer: (cb: DeferCallback) => void + up (): Promise | void + down (): Promise | void + execUp (): Promise + execDown (): Promise + } +} diff --git a/src/Dialects/Mssql.ts b/src/Dialects/Mssql.ts new file mode 100644 index 00000000..635a8007 --- /dev/null +++ b/src/Dialects/Mssql.ts @@ -0,0 +1,25 @@ +/* + * @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 { DialectContract } from '@ioc:Adonis/Lucid/Database' + +export class MssqlDialect implements DialectContract { + public readonly name = 'mssql' + public supportsAdvisoryLocks = false + + public async getAdvisoryLock (): Promise { + throw new Error(`Support for advisory locks is not implemented for mssql. Create a PR to add the feature`) + } + + public async releaseAdvisoryLock (): Promise { + throw new Error(`Support for advisory locks is not implemented for mssql. Create a PR to add the feature`) + } +} diff --git a/src/Dialects/Mysql.ts b/src/Dialects/Mysql.ts new file mode 100644 index 00000000..e6304e55 --- /dev/null +++ b/src/Dialects/Mysql.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 { DialectContract, QueryClientContract } from '@ioc:Adonis/Lucid/Database' + +export class MysqlDialect implements DialectContract { + public readonly name = 'mysql' + public supportsAdvisoryLocks = true + + constructor (private _client: QueryClientContract) { + } + + /** + * Attempts to add advisory lock to the database and + * returns it's status. + */ + public async getAdvisoryLock (key: string, timeout: number = 0): Promise { + const response = await this._client.raw(`SELECT GET_LOCK('${key}', ${timeout}) as lock_status;`) + return response[0] && response[0][0] && response[0][0].lock_status === 1 + } + + /** + * Releases the advisory lock + */ + public async releaseAdvisoryLock (key: string): Promise { + const response = await this._client.raw(`SELECT RELEASE_LOCK('${key}') as lock_status;`) + return response[0] && response[0][0] && response[0][0].lock_status === 1 + } +} diff --git a/src/Dialects/Oracle.ts b/src/Dialects/Oracle.ts new file mode 100644 index 00000000..0036c999 --- /dev/null +++ b/src/Dialects/Oracle.ts @@ -0,0 +1,25 @@ +/* + * @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 { DialectContract } from '@ioc:Adonis/Lucid/Database' + +export class OracleDialect implements DialectContract { + public readonly name = 'oracledb' + public supportsAdvisoryLocks = false + + public async getAdvisoryLock (): Promise { + throw new Error(`Support for advisory locks is not implemented for oracledb. Create a PR to add the feature`) + } + + public async releaseAdvisoryLock (): Promise { + throw new Error(`Support for advisory locks is not implemented for oracledb. Create a PR to add the feature`) + } +} diff --git a/src/Dialects/Pg.ts b/src/Dialects/Pg.ts new file mode 100644 index 00000000..5dd51390 --- /dev/null +++ b/src/Dialects/Pg.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 { DialectContract, QueryClientContract } from '@ioc:Adonis/Lucid/Database' + +export class PgDialect implements DialectContract { + public readonly name = 'postgres' + public supportsAdvisoryLocks = true + + constructor (private _client: QueryClientContract) { + } + + /** + * Attempts to add advisory lock to the database and + * returns it's status. + */ + public async getAdvisoryLock (key: string): Promise { + const response = await this._client.raw(`SELECT PG_TRY_ADVISORY_LOCK('${key}') as lock_status;`) + return response.rows[0] && response.rows[0].lock_status === true + } + + /** + * Releases the advisory lock + */ + public async releaseAdvisoryLock (key: string): Promise { + const response = await this._client.raw(`SELECT PG_ADVISORY_UNLOCK('${key}') as lock_status;`) + return response.rows[0] && response.rows[0].lock_status === true + } +} diff --git a/src/Dialects/Redshift.ts b/src/Dialects/Redshift.ts new file mode 100644 index 00000000..9559e91a --- /dev/null +++ b/src/Dialects/Redshift.ts @@ -0,0 +1,33 @@ +/* + * @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 { DialectContract } from '@ioc:Adonis/Lucid/Database' + +export class RedshiftDialect implements DialectContract { + public readonly name = 'redshift' + public supportsAdvisoryLocks = false + + /** + * Redshift doesn't support advisory locks. Learn more: + * https://tableplus.com/blog/2018/10/redshift-vs-postgres-database-comparison.html + */ + public async getAdvisoryLock (): Promise { + throw new Error(`Redshift doesn't support advisory locks`) + } + + /** + * Redshift doesn't support advisory locks. Learn more: + * https://tableplus.com/blog/2018/10/redshift-vs-postgres-database-comparison.html + */ + public async releaseAdvisoryLock (): Promise { + throw new Error(`Redshift doesn't support advisory locks`) + } +} diff --git a/src/Dialects/Sqlite.ts b/src/Dialects/Sqlite.ts new file mode 100644 index 00000000..ff014f96 --- /dev/null +++ b/src/Dialects/Sqlite.ts @@ -0,0 +1,32 @@ +/* + * @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 { DialectContract } from '@ioc:Adonis/Lucid/Database' + +export class SqliteDialect implements DialectContract { + public readonly name = 'sqlite3' + public supportsAdvisoryLocks = false + + /** + * Attempts to add advisory lock to the database and + * returns it's status. + */ + public async getAdvisoryLock (): Promise { + throw new Error(`Sqlite doesn't support advisory locks`) + } + + /** + * Releases the advisory lock + */ + public async releaseAdvisoryLock (): Promise { + throw new Error(`Sqlite doesn't support advisory locks`) + } +} diff --git a/src/Dialects/index.ts b/src/Dialects/index.ts new file mode 100644 index 00000000..787bdb8a --- /dev/null +++ b/src/Dialects/index.ts @@ -0,0 +1,25 @@ +/* + * @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 { PgDialect } from './Pg' +import { MysqlDialect } from './Mysql' +import { MssqlDialect } from './Mssql' +import { SqliteDialect } from './Sqlite' +import { OracleDialect } from './Oracle' +import { RedshiftDialect } from './Redshift' + +export const dialects = { + 'mssql': MssqlDialect, + 'mysql': MysqlDialect, + 'mysql2': MysqlDialect, + 'oracledb': OracleDialect, + 'postgres': PgDialect, + 'redshift': RedshiftDialect, + 'sqlite3': SqliteDialect, +} diff --git a/src/Migrator/MigrationSource.ts b/src/Migrator/MigrationSource.ts new file mode 100644 index 00000000..112d5044 --- /dev/null +++ b/src/Migrator/MigrationSource.ts @@ -0,0 +1,74 @@ +/* + * @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 { readdir } from 'fs' +import { join, isAbsolute, extname } from 'path' +import { MigrationNode } from '@ioc:Adonis/Lucid/Migrator' +import { ApplicationContract } from '@ioc:Adonis/Core/Application' +import { ConnectionConfigContract } from '@ioc:Adonis/Lucid/Database' + +/** + * Migration source exposes the API to read the migration files + * from disk for a given connection. + */ +export class MigrationSource { + constructor ( + private _config: ConnectionConfigContract, + private _app: ApplicationContract, + ) {} + + /** + * Returns an array of files inside a given directory. Relative + * paths are resolved from the project root + */ + private _getDirectoryFiles (directoryPath: string): Promise { + return new Promise((resolve, reject) => { + const path = isAbsolute(directoryPath) ? directoryPath : join(this._app.appRoot, directoryPath) + readdir(path, (error, files) => { + if (error) { + reject(error) + return + } + + return resolve(files.sort().map((file) => { + return { + absPath: join(path, file), + name: file.replace(RegExp(`${extname(file)}$`), ''), + source: require(join(path, file)), + } + })) + }) + }) + } + + /** + * Returns an array of migrations paths for a given connection. If paths + * are not defined, then `database/migrations` fallback is used + */ + private _getMigrationsPath (): string[] { + return (this._config.migrations && this._config.migrations.paths) || ['database/migrations'] + } + + /** + * Returns an array of files for all defined directories + */ + public async getMigrations () { + const migrationPaths = this._getMigrationsPath().sort() + const directories = await Promise.all(migrationPaths.map((directoryPath) => { + return this._getDirectoryFiles(directoryPath) + })) + + return directories.reduce((result, directory) => { + result = result.concat(directory) + return result + }, []) + } +} diff --git a/src/Migrator/index.ts b/src/Migrator/index.ts new file mode 100644 index 00000000..4226f88d --- /dev/null +++ b/src/Migrator/index.ts @@ -0,0 +1,11 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +export class Migrator { +} diff --git a/src/Schema/index.ts b/src/Schema/index.ts new file mode 100644 index 00000000..2513174e --- /dev/null +++ b/src/Schema/index.ts @@ -0,0 +1,135 @@ +/* + * @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 { SchemaBuilder } from 'knex' +import { Exception } from '@poppinss/utils' +import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' +import { SchemaContract, DeferCallback } from '@ioc:Adonis/Lucid/Schema' + +/** + * Exposes the API to define table schema using deferred database + * calls. + */ +export class Schema implements SchemaContract { + /** + * All calls to `schema` and `defer` are tracked to be + * executed later + */ + private _trackedCalls: (SchemaBuilder | DeferCallback)[] = [] + + /** + * The lifecycle method that was invoked + */ + private _isFresh: boolean = true + + /** + * Enable/disable transactions for this schema + */ + public disableTransactions = false + + /** + * Returns the schema to build database tables + */ + public get schema () { + const schema = this.db.schema + this._trackedCalls.push(schema) + return schema + } + + constructor ( + public db: QueryClientContract, + public file: string, + public dryRun: boolean = false, + ) {} + + /** + * Returns schema queries sql without executing them + */ + private _getQueries (): string[] { + return this._trackedCalls + .filter((schema) => typeof (schema['toQuery']) === 'function') + .map((schema) => (schema as SchemaBuilder).toQuery()) + } + + /** + * Executes schema queries and defer calls in sequence + */ + private async _executeQueries () { + for (let trackedCall of this._trackedCalls) { + if (typeof (trackedCall) === 'function') { + await trackedCall(this.db) + } else { + await trackedCall + } + } + } + + /** + * Returns raw query for `now` + */ + public now (precision?: number) { + return precision + ? this.db.raw(`CURRENT_TIMESTAMP(${precision})`) + : this.db.raw('CURRENT_TIMESTAMP') + } + + /** + * Wrapping database calls inside defer ensures that they run + * in the right order and also they won't be executed when + * schema is invoked to return the SQL queries + */ + public defer (cb: DeferCallback): void { + this._trackedCalls.push(cb) + } + + /** + * Invokes schema `up` method. Returns an array of queries + * when `dryRun` is set to true + */ + public async execUp () { + if (!this._isFresh) { + throw new Exception('Cannot execute a given schema twice') + } + + await this.up() + this._isFresh = false + + if (this.dryRun) { + return this._getQueries() + } + + await this._executeQueries() + return true + } + + /** + * Invokes schema `down` method. Returns an array of queries + * when `dryRun` is set to true + */ + public async execDown () { + if (!this._isFresh) { + throw new Exception('Cannot execute a given schema twice') + } + + await this.down() + this._isFresh = false + + if (this.dryRun) { + return this._getQueries() + } + + await this._executeQueries() + return true + } + + public async up () {} + public async down () {} +} diff --git a/test/migrations/migration-source.spec.ts b/test/migrations/migration-source.spec.ts new file mode 100644 index 00000000..797e76ac --- /dev/null +++ b/test/migrations/migration-source.spec.ts @@ -0,0 +1,101 @@ +/* +* @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' +import { join } from 'path' +import { Filesystem } from '@poppinss/dev-utils' +import { Application } from '@adonisjs/application/build/standalone' + +import { MigrationSource } from '../../src/Migrator/MigrationSource' +import { setup, cleanup, getDb, resetTables } from '../../test-helpers' + +let db: ReturnType +const fs = new Filesystem(join(__dirname, 'app')) + +test.group('MigrationSource', (group) => { + group.before(async () => { + db = getDb() + await setup() + }) + + group.after(async () => { + await cleanup() + await db.manager.closeAll() + }) + + group.afterEach(async () => { + await resetTables() + await fs.cleanup() + }) + + test('get list of migration files from database/migrations.js', async (assert) => { + const app = new Application(fs.basePath, {} as any, {} as any, {}) + const migrationSource = new MigrationSource(db.getRawConnection('primary')!.config, app) + + await fs.add('database/migrations/foo.js', 'module.exports = class Foo {}') + await fs.add('database/migrations/bar.js', 'module.exports = class Bar {}') + + const directories = await migrationSource.getMigrations() + + assert.deepEqual(directories.map((file) => { + return { absPath: file.absPath, name: file.name } + }), [ + { + absPath: join(fs.basePath, 'database/migrations/bar.js'), + name: 'bar', + }, + { + absPath: join(fs.basePath, 'database/migrations/foo.js'), + name: 'foo', + }, + ]) + }) + + test('sort multiple migration directories seperately', async (assert) => { + const app = new Application(fs.basePath, {} as any, {} as any, {}) + const config = Object.assign(db.getRawConnection('primary')!.config, { + migrations: { + paths: ['database/secondary', 'database/primary'], + }, + }) + + const migrationSource = new MigrationSource(config, app) + + await fs.add('database/secondary/a.js', 'module.exports = class Foo {}') + await fs.add('database/secondary/c.js', 'module.exports = class Bar {}') + + await fs.add('database/primary/b.js', 'module.exports = class Foo {}') + await fs.add('database/primary/d.js', 'module.exports = class Bar {}') + + const files = await migrationSource.getMigrations() + + assert.deepEqual(files.map((file) => { + return { absPath: file.absPath, name: file.name } + }), [ + { + absPath: join(fs.basePath, 'database/primary/b.js'), + name: 'b', + }, + { + absPath: join(fs.basePath, 'database/primary/d.js'), + name: 'd', + }, + { + absPath: join(fs.basePath, 'database/secondary/a.js'), + name: 'a', + }, + { + absPath: join(fs.basePath, 'database/secondary/c.js'), + name: 'c', + }, + ]) + }) +}) diff --git a/test/migrations/schema.spec.ts b/test/migrations/schema.spec.ts new file mode 100644 index 00000000..b89496a4 --- /dev/null +++ b/test/migrations/schema.spec.ts @@ -0,0 +1,211 @@ +/* +* @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' +import { setup, cleanup, getDb, resetTables, getBaseSchema } from '../../test-helpers' + +let db: ReturnType + +test.group('Schema', (group) => { + group.before(async () => { + db = getDb() + await setup() + }) + + group.after(async () => { + await cleanup() + await db.manager.closeAll() + }) + + group.afterEach(async () => { + await resetTables() + }) + + test('get schema queries defined inside the up method in dry run', async (assert) => { + class UsersSchema extends getBaseSchema() { + public up () { + this.schema.createTable('users', (table) => { + table.increments('id') + table.string('username') + }) + } + } + + const schema = new UsersSchema(db.connection(), 'users.ts', true) + const queries = await schema.execUp() + + const knexSchema = db.connection().schema.createTable('users', (table) => { + table.increments('id') + table.string('username') + }).toQuery() + + assert.deepEqual(queries, [knexSchema]) + }) + + test('get schema queries defined inside the down method in dry run', async (assert) => { + class UsersSchema extends getBaseSchema() { + public down () { + this.schema.dropTable('users') + } + } + + const schema = new UsersSchema(db.connection(), 'users.ts', true) + const queries = await schema.execDown() + + const knexSchema = db.connection().schema.dropTable('users').toQuery() + assert.deepEqual(queries, [knexSchema]) + }) + + test('get raw query builder using now method', async (assert) => { + class UsersSchema extends getBaseSchema() { + public up () { + this.schema.createTable('users', (table) => { + table.increments('id') + table.string('username') + }) + } + } + + const schema = new UsersSchema(db.connection(), 'users.ts', true) + assert.equal(schema.now().toQuery(), 'CURRENT_TIMESTAMP') + }) + + test('do not execute defer calls in dry run', async (assert) => { + assert.plan(1) + + class UsersSchema extends getBaseSchema() { + public up () { + assert.isTrue(true) + this.defer(() => { + throw new Error('Not expected to be invoked') + }) + } + } + + const schema = new UsersSchema(db.connection(), 'foo.ts', true) + await schema.execUp() + }) + + test('execute up method queries on a given connection', async (assert) => { + class UsersSchema extends getBaseSchema() { + public up () { + this.schema.createTable('schema_users', (table) => { + table.increments('id') + table.string('username') + }) + + this.schema.createTable('schema_accounts', (table) => { + table.increments('id') + table.integer('user_id').unsigned().references('schema_users.id') + }) + } + } + + const trx = await db.transaction() + const schema = new UsersSchema(trx, 'users.ts', false) + + try { + await schema.execUp() + await trx.commit() + } catch (error) { + await trx.rollback() + } + + const hasUsers = await db.connection().schema.hasTable('schema_users') + const hasAccounts = await db.connection().schema.hasTable('schema_accounts') + + await db.connection().schema.dropTable('schema_accounts') + await db.connection().schema.dropTable('schema_users') + + assert.isTrue(hasUsers) + assert.isTrue(hasAccounts) + }) + + test('execute up method deferred actions in correct sequence', async (assert) => { + class UsersSchema extends getBaseSchema() { + public up () { + this.schema.createTable('schema_users', (table) => { + table.increments('id') + table.string('username') + }) + + this.defer(async () => { + await this.db.table('schema_users').insert({ username: 'virk' }) + }) + + this.schema.createTable('schema_accounts', (table) => { + table.increments('id') + table.integer('user_id').unsigned().references('schema_users.id') + }) + } + } + + const trx = await db.transaction() + const schema = new UsersSchema(trx, 'users.ts', false) + + try { + await schema.execUp() + await trx.commit() + } catch (error) { + await trx.rollback() + } + + const user = await db.connection().query().from('schema_users').first() + assert.equal(user.username, 'virk') + + await db.connection().schema.dropTable('schema_accounts') + await db.connection().schema.dropTable('schema_users') + }) + + test('execute down method queries on a given connection', async (assert) => { + class UsersSchema extends getBaseSchema() { + public up () { + this.schema.createTable('schema_users', (table) => { + table.increments('id') + table.string('username') + }) + + this.schema.createTable('schema_accounts', (table) => { + table.increments('id') + table.integer('user_id').unsigned().references('schema_users.id') + }) + } + + public down () { + if (this.db.dialect.name !== 'sqlite3') { + this.schema.table('schema_accounts', (table) => { + table.dropForeign(['user_id']) + }) + } + + this.schema.dropTable('schema_users') + this.schema.dropTable('schema_accounts') + } + } + + await new UsersSchema(db.connection(), 'users.ts', false).execUp() + + const trx = await db.transaction() + const schema = new UsersSchema(trx, 'users.ts', false) + + try { + await schema.execDown() + await trx.commit() + } catch (error) { + await trx.rollback() + console.log(error) + } + + const hasUsers = await db.connection().schema.hasTable('schema_users') + const hasAccounts = await db.connection().schema.hasTable('schema_accounts') + + assert.isFalse(hasUsers) + assert.isFalse(hasAccounts) + }) +})