From 0f9287ad8e889decaacf0f104492185eee39138c Mon Sep 17 00:00:00 2001 From: Umed Khudoiberdiev Date: Mon, 12 Dec 2016 12:49:05 +0500 Subject: [PATCH] added migrations execute and revert functionality --- sample/sample32-migrations/app.ts | 44 ++- .../1481283582-first-release-changes.ts | 2 +- .../1481521933-second-release-changes.ts | 15 + src/connection/Connection.ts | 38 +++ src/connection/ConnectionManager.ts | 8 + src/connection/ConnectionOptions.ts | 7 + src/driver/mysql/MysqlQueryRunner.ts | 30 +- src/driver/oracle/OracleQueryRunner.ts | 47 ++-- src/driver/postgres/PostgresQueryRunner.ts | 27 +- src/driver/sqlite/SqliteDriver.ts | 2 +- src/driver/sqlite/SqliteQueryRunner.ts | 29 +- src/driver/sqlserver/SqlServerQueryRunner.ts | 11 +- src/driver/websql/WebsqlQueryRunner.ts | 27 +- src/metadata/ColumnMetadata.ts | 13 - src/migration/Migration.ts | 37 +++ src/migration/MigrationExecutor.ts | 262 ++++++++++++++++++ src/migration/MigrationInterface.ts | 3 + src/query-builder/QueryBuilder.ts | 4 +- src/query-runner/QueryRunner.ts | 3 +- src/schema-builder/schema/ColumnSchema.ts | 26 ++ src/schema-builder/schema/TableSchema.ts | 14 +- src/util/PromiseUtils.ts | 23 ++ 22 files changed, 565 insertions(+), 107 deletions(-) create mode 100644 sample/sample32-migrations/migrations/1481521933-second-release-changes.ts create mode 100644 src/migration/Migration.ts create mode 100644 src/migration/MigrationExecutor.ts create mode 100644 src/util/PromiseUtils.ts diff --git a/sample/sample32-migrations/app.ts b/sample/sample32-migrations/app.ts index ffd8842d9..10540dbf6 100644 --- a/sample/sample32-migrations/app.ts +++ b/sample/sample32-migrations/app.ts @@ -2,6 +2,7 @@ import "reflect-metadata"; import {ConnectionOptions, createConnection} from "../../src/index"; import {Post} from "./entity/Post"; import {Author} from "./entity/Author"; +import {MigrationExecutor} from "../../src/migration/MigrationExecutor"; const options: ConnectionOptions = { driver: { @@ -14,15 +15,14 @@ const options: ConnectionOptions = { }, autoSchemaSync: true, logging: { - // logQueries: true, - // logSchemaCreation: true, - // logFailedQueryError: true + logQueries: true, }, entities: [Post, Author], }; createConnection(options).then(async connection => { + // first insert all the data let author = new Author(); author.firstName = "Umed"; author.lastName = "Khudoiberdiev"; @@ -34,6 +34,42 @@ createConnection(options).then(async connection => { let postRepository = connection.getRepository(Post); await postRepository.persist(post); - console.log("Post has been saved"); + console.log("Database schema was created and data has been inserted into the database."); + + // close connection now + await connection.close(); + + // now create a new connection + connection = await createConnection({ + name: "mysql", + driver: { + type: "mysql", + host: "localhost", + port: 3306, + username: "test", + password: "test", + database: "test" + }, + logging: { + logQueries: true + }, + entities: [ + Post, + Author + ], + migrations: [ + __dirname + "/migrations/*{.js,.ts}" + ] + }); + + // run all migrations + const migrationExecutor = new MigrationExecutor(connection); + await migrationExecutor.executePendingMigrations(); + + // and undo migrations two times (because we have two migrations) + await migrationExecutor.undoLastMigration(); + await migrationExecutor.undoLastMigration(); + + console.log("Done. We run two migrations then reverted them."); }).catch(error => console.log("Error: ", error)); \ No newline at end of file diff --git a/sample/sample32-migrations/migrations/1481283582-first-release-changes.ts b/sample/sample32-migrations/migrations/1481283582-first-release-changes.ts index 9e8fd3a54..89c5a79a3 100644 --- a/sample/sample32-migrations/migrations/1481283582-first-release-changes.ts +++ b/sample/sample32-migrations/migrations/1481283582-first-release-changes.ts @@ -2,7 +2,7 @@ import {MigrationInterface} from "../../../src/migration/MigrationInterface"; import {Connection} from "../../../src/connection/Connection"; import {QueryRunner} from "../../../src/query-runner/QueryRunner"; -export class FirstReleaseMigration implements MigrationInterface { +export class FirstReleaseMigration1481283582 implements MigrationInterface { async up(queryRunner: QueryRunner, connection: Connection): Promise { await queryRunner.query("ALTER TABLE `post` CHANGE `title` `name` VARCHAR(255)"); diff --git a/sample/sample32-migrations/migrations/1481521933-second-release-changes.ts b/sample/sample32-migrations/migrations/1481521933-second-release-changes.ts new file mode 100644 index 000000000..bde1aeea0 --- /dev/null +++ b/sample/sample32-migrations/migrations/1481521933-second-release-changes.ts @@ -0,0 +1,15 @@ +import {MigrationInterface} from "../../../src/migration/MigrationInterface"; +import {Connection} from "../../../src/connection/Connection"; +import {QueryRunner} from "../../../src/query-runner/QueryRunner"; + +export class SecondReleaseMigration1481521933 implements MigrationInterface { + + async up(queryRunner: QueryRunner, connection: Connection): Promise { + await queryRunner.query("ALTER TABLE `post` CHANGE `name` `title` VARCHAR(500)"); + } + + async down(queryRunner: QueryRunner, connection: Connection): Promise { + await queryRunner.query("ALTER TABLE `post` CHANGE `title` `name` VARCHAR(255)"); + } + +} \ No newline at end of file diff --git a/src/connection/Connection.ts b/src/connection/Connection.ts index 5eeb45607..fa7efc5e9 100644 --- a/src/connection/Connection.ts +++ b/src/connection/Connection.ts @@ -28,6 +28,7 @@ import {SchemaBuilder} from "../schema-builder/SchemaBuilder"; import {Logger} from "../logger/Logger"; import {QueryRunnerProvider} from "../query-runner/QueryRunnerProvider"; import {EntityMetadataNotFound} from "../metadata-args/error/EntityMetadataNotFound"; +import {MigrationInterface} from "../migration/MigrationInterface"; /** * Connection is a single database connection to a specific database of a database management system. @@ -108,6 +109,11 @@ export class Connection { */ private readonly namingStrategyClasses: Function[] = []; + /** + * Registered migration classes to be used for this connection. + */ + private readonly migrationClasses: Function[] = []; + /** * Naming strategy to be used in this connection. */ @@ -252,6 +258,14 @@ export class Connection { return this; } + /** + * Imports migrations from the given paths (directories) and registers them in the current connection. + */ + importMigrationsFromDirectories(paths: string[]): this { + this.importMigrations(importClassesFromDirectories(paths)); + return this; + } + /** * Imports entities and registers them in the current connection. */ @@ -296,6 +310,17 @@ export class Connection { return this; } + /** + * Imports migrations and registers them in the current connection. + */ + importMigrations(migrations: Function[]): this { + if (this.isConnected) + throw new CannotImportAlreadyConnectedError("migrations", this.name); + + migrations.forEach(cls => this.migrationClasses.push(cls)); + return this; + } + /** * Sets given naming strategy to be used. * Naming strategy must be set to be used before connection is established. @@ -430,6 +455,19 @@ export class Connection { return new EntityManager(this, queryRunnerProvider); } + /** + * Gets migration instances that are registered for this connection. + */ + getMigrations(): MigrationInterface[] { + if (this.migrationClasses && this.migrationClasses.length) { + return this.migrationClasses.map(migrationClass => { + return getFromContainer(migrationClass); + }); + } + + return []; + } + // ------------------------------------------------------------------------- // Protected Methods // ------------------------------------------------------------------------- diff --git a/src/connection/ConnectionManager.ts b/src/connection/ConnectionManager.ts index 06af3c04a..8b09e9c18 100644 --- a/src/connection/ConnectionManager.ts +++ b/src/connection/ConnectionManager.ts @@ -99,6 +99,14 @@ export class ConnectionManager { .importNamingStrategiesFromDirectories(directories); } + // import migrations + if (options.migrations) { + const [directories, classes] = this.splitStringsAndClasses(options.migrations); + connection + .importMigrations(classes) + .importMigrationsFromDirectories(directories); + } + // set naming strategy to be used for this connection if (options.usedNamingStrategy) connection.useNamingStrategy(options.usedNamingStrategy as any); diff --git a/src/connection/ConnectionOptions.ts b/src/connection/ConnectionOptions.ts index 1828df4eb..c9adc04ac 100644 --- a/src/connection/ConnectionOptions.ts +++ b/src/connection/ConnectionOptions.ts @@ -53,6 +53,13 @@ export interface ConnectionOptions { */ readonly entitySchemas?: EntitySchema[]|string[]; + /** + * Migrations to be loaded for this connection. + * Accepts both migration classes and directories where from migrations need to be loaded. + * Directories support glob patterns. + */ + readonly migrations?: Function[]|string[]; + /** * Logging options. */ diff --git a/src/driver/mysql/MysqlQueryRunner.ts b/src/driver/mysql/MysqlQueryRunner.ts index adf824b16..1129363d0 100644 --- a/src/driver/mysql/MysqlQueryRunner.ts +++ b/src/driver/mysql/MysqlQueryRunner.ts @@ -14,6 +14,7 @@ import {PrimaryKeySchema} from "../../schema-builder/schema/PrimaryKeySchema"; import {IndexSchema} from "../../schema-builder/schema/IndexSchema"; import {QueryRunnerAlreadyReleasedError} from "../../query-runner/error/QueryRunnerAlreadyReleasedError"; import {NamingStrategyInterface} from "../../naming-strategy/NamingStrategyInterface"; +import {ColumnType} from "../../metadata/types/ColumnTypes"; /** * Runs queries on a single mysql database connection. @@ -608,35 +609,36 @@ export class MysqlQueryRunner implements QueryRunner { /** * Creates a database type from a given column metadata. */ - normalizeType(column: ColumnMetadata) { - switch (column.normalizedDataType) { + normalizeType(typeOptions: { type: ColumnType, length?: string|number, precision?: number, scale?: number, timezone?: boolean }) { + + switch (typeOptions.type) { case "string": - return "varchar(" + (column.length ? column.length : 255) + ")"; + return "varchar(" + (typeOptions.length ? typeOptions.length : 255) + ")"; case "text": return "text"; case "boolean": return "tinyint(1)"; case "integer": case "int": - return "int(" + (column.length ? column.length : 11) + ")"; + return "int(" + (typeOptions.length ? typeOptions.length : 11) + ")"; case "smallint": - return "smallint(" + (column.length ? column.length : 11) + ")"; + return "smallint(" + (typeOptions.length ? typeOptions.length : 11) + ")"; case "bigint": - return "bigint(" + (column.length ? column.length : 11) + ")"; + return "bigint(" + (typeOptions.length ? typeOptions.length : 11) + ")"; case "float": return "float"; case "double": case "number": return "double"; case "decimal": - if (column.precision && column.scale) { - return `decimal(${column.precision},${column.scale})`; + if (typeOptions.precision && typeOptions.scale) { + return `decimal(${typeOptions.precision},${typeOptions.scale})`; - } else if (column.scale) { - return `decimal(${column.scale})`; + } else if (typeOptions.scale) { + return `decimal(${typeOptions.scale})`; - } else if (column.precision) { - return `decimal(${column.precision})`; + } else if (typeOptions.precision) { + return `decimal(${typeOptions.precision})`; } else { return "decimal"; @@ -651,10 +653,10 @@ export class MysqlQueryRunner implements QueryRunner { case "json": return "text"; case "simple_array": - return column.length ? "varchar(" + column.length + ")" : "text"; + return typeOptions.length ? "varchar(" + typeOptions.length + ")" : "text"; } - throw new DataTypeNotSupportedByDriverError(column.type, "MySQL"); + throw new DataTypeNotSupportedByDriverError(typeOptions.type, "MySQL"); } // ------------------------------------------------------------------------- diff --git a/src/driver/oracle/OracleQueryRunner.ts b/src/driver/oracle/OracleQueryRunner.ts index 0727183f9..63530ab09 100644 --- a/src/driver/oracle/OracleQueryRunner.ts +++ b/src/driver/oracle/OracleQueryRunner.ts @@ -14,6 +14,7 @@ import {PrimaryKeySchema} from "../../schema-builder/schema/PrimaryKeySchema"; import {IndexSchema} from "../../schema-builder/schema/IndexSchema"; import {QueryRunnerAlreadyReleasedError} from "../../query-runner/error/QueryRunnerAlreadyReleasedError"; import {NamingStrategyInterface} from "../../naming-strategy/NamingStrategyInterface"; +import {ColumnType} from "../../metadata/types/ColumnTypes"; /** * Runs queries on a single mysql database connection. @@ -664,10 +665,10 @@ AND cons.constraint_name = cols.constraint_name AND cons.owner = cols.owner ORDE /** * Creates a database type from a given column metadata. */ - normalizeType(column: ColumnMetadata) { - switch (column.normalizedDataType) { + normalizeType(typeOptions: { type: ColumnType, length?: string|number, precision?: number, scale?: number, timezone?: boolean }) { + switch (typeOptions.type) { case "string": - return "varchar2(" + (column.length ? column.length : 255) + ")"; + return "varchar2(" + (typeOptions.length ? typeOptions.length : 255) + ")"; case "text": return "clob"; case "boolean": @@ -676,12 +677,12 @@ AND cons.constraint_name = cols.constraint_name AND cons.owner = cols.owner ORDE case "int": // if (column.isGenerated) // return `number(22)`; - if (column.precision && column.scale) - return `number(${column.precision},${column.scale})`; - if (column.precision) - return `number(${column.precision},0)`; - if (column.scale) - return `number(0,${column.scale})`; + if (typeOptions.precision && typeOptions.scale) + return `number(${typeOptions.precision},${typeOptions.scale})`; + if (typeOptions.precision) + return `number(${typeOptions.precision},0)`; + if (typeOptions.scale) + return `number(0,${typeOptions.scale})`; return "number(10,0)"; case "smallint": @@ -689,26 +690,26 @@ AND cons.constraint_name = cols.constraint_name AND cons.owner = cols.owner ORDE case "bigint": return "number(20)"; case "float": - if (column.precision && column.scale) - return `float(${column.precision},${column.scale})`; - if (column.precision) - return `float(${column.precision},0)`; - if (column.scale) - return `float(0,${column.scale})`; + if (typeOptions.precision && typeOptions.scale) + return `float(${typeOptions.precision},${typeOptions.scale})`; + if (typeOptions.precision) + return `float(${typeOptions.precision},0)`; + if (typeOptions.scale) + return `float(0,${typeOptions.scale})`; return `float(126)`; case "double": case "number": return "float(126)"; case "decimal": - if (column.precision && column.scale) { - return `decimal(${column.precision},${column.scale})`; + if (typeOptions.precision && typeOptions.scale) { + return `decimal(${typeOptions.precision},${typeOptions.scale})`; - } else if (column.scale) { - return `decimal(0,${column.scale})`; + } else if (typeOptions.scale) { + return `decimal(0,${typeOptions.scale})`; - } else if (column.precision) { - return `decimal(${column.precision})`; + } else if (typeOptions.precision) { + return `decimal(${typeOptions.precision})`; } else { return "decimal"; @@ -722,10 +723,10 @@ AND cons.constraint_name = cols.constraint_name AND cons.owner = cols.owner ORDE case "json": return "clob"; case "simple_array": - return column.length ? "varchar2(" + column.length + ")" : "text"; + return typeOptions.length ? "varchar2(" + typeOptions.length + ")" : "text"; } - throw new DataTypeNotSupportedByDriverError(column.type, "Oracle"); + throw new DataTypeNotSupportedByDriverError(typeOptions.type, "Oracle"); } // ------------------------------------------------------------------------- diff --git a/src/driver/postgres/PostgresQueryRunner.ts b/src/driver/postgres/PostgresQueryRunner.ts index d6abe6f5a..b0fefbab4 100644 --- a/src/driver/postgres/PostgresQueryRunner.ts +++ b/src/driver/postgres/PostgresQueryRunner.ts @@ -14,6 +14,7 @@ import {ForeignKeySchema} from "../../schema-builder/schema/ForeignKeySchema"; import {PrimaryKeySchema} from "../../schema-builder/schema/PrimaryKeySchema"; import {QueryRunnerAlreadyReleasedError} from "../../query-runner/error/QueryRunnerAlreadyReleasedError"; import {NamingStrategyInterface} from "../../naming-strategy/NamingStrategyInterface"; +import {ColumnType} from "../../metadata/types/ColumnTypes"; /** * Runs queries on a single postgres database connection. @@ -663,10 +664,10 @@ where constraint_type = 'PRIMARY KEY' and tc.table_catalog = '${this.dbName}'`; /** * Creates a database type from a given column metadata. */ - normalizeType(column: ColumnMetadata): string { - switch (column.normalizedDataType) { + normalizeType(typeOptions: { type: ColumnType, length?: string|number, precision?: number, scale?: number, timezone?: boolean }): string { + switch (typeOptions.type) { case "string": - return "character varying(" + (column.length ? column.length : 255) + ")"; + return "character varying(" + (typeOptions.length ? typeOptions.length : 255) + ")"; case "text": return "text"; case "boolean": @@ -684,14 +685,14 @@ where constraint_type = 'PRIMARY KEY' and tc.table_catalog = '${this.dbName}'`; case "number": return "double precision"; case "decimal": - if (column.precision && column.scale) { - return `decimal(${column.precision},${column.scale})`; + if (typeOptions.precision && typeOptions.scale) { + return `decimal(${typeOptions.precision},${typeOptions.scale})`; - } else if (column.scale) { - return `decimal(${column.scale})`; + } else if (typeOptions.scale) { + return `decimal(${typeOptions.scale})`; - } else if (column.precision) { - return `decimal(${column.precision})`; + } else if (typeOptions.precision) { + return `decimal(${typeOptions.precision})`; } else { return "decimal"; @@ -700,13 +701,13 @@ where constraint_type = 'PRIMARY KEY' and tc.table_catalog = '${this.dbName}'`; case "date": return "date"; case "time": - if (column.timezone) { + if (typeOptions.timezone) { return "time with time zone"; } else { return "time without time zone"; } case "datetime": - if (column.timezone) { + if (typeOptions.timezone) { return "timestamp with time zone"; } else { return "timestamp without time zone"; @@ -714,10 +715,10 @@ where constraint_type = 'PRIMARY KEY' and tc.table_catalog = '${this.dbName}'`; case "json": return "json"; case "simple_array": - return column.length ? "character varying(" + column.length + ")" : "text"; + return typeOptions.length ? "character varying(" + typeOptions.length + ")" : "text"; } - throw new DataTypeNotSupportedByDriverError(column.type, "Postgres"); + throw new DataTypeNotSupportedByDriverError(typeOptions.type, "Postgres"); } // ------------------------------------------------------------------------- diff --git a/src/driver/sqlite/SqliteDriver.ts b/src/driver/sqlite/SqliteDriver.ts index 43ed697c7..5b3609114 100644 --- a/src/driver/sqlite/SqliteDriver.ts +++ b/src/driver/sqlite/SqliteDriver.ts @@ -92,7 +92,7 @@ export class SqliteDriver implements Driver { // we need to enable foreign keys in sqlite to make sure all foreign key related features // working properly. this also makes onDelete to work with sqlite. - connection.run(`PRAGMA foreign_keys = ON;`, (err: any, result: any) => { + connection.executePendingMigrations(`PRAGMA foreign_keys = ON;`, (err: any, result: any) => { ok(); }); }); diff --git a/src/driver/sqlite/SqliteQueryRunner.ts b/src/driver/sqlite/SqliteQueryRunner.ts index cfa01bd5c..91051f396 100644 --- a/src/driver/sqlite/SqliteQueryRunner.ts +++ b/src/driver/sqlite/SqliteQueryRunner.ts @@ -14,6 +14,7 @@ import {ForeignKeySchema} from "../../schema-builder/schema/ForeignKeySchema"; import {PrimaryKeySchema} from "../../schema-builder/schema/PrimaryKeySchema"; import {QueryRunnerAlreadyReleasedError} from "../../query-runner/error/QueryRunnerAlreadyReleasedError"; import {NamingStrategyInterface} from "../../naming-strategy/NamingStrategyInterface"; +import {ColumnType} from "../../metadata/types/ColumnTypes"; /** * Runs queries on a single sqlite database connection. @@ -167,7 +168,7 @@ export class SqliteQueryRunner implements QueryRunner { this.logger.logQuery(sql, parameters); return new Promise((ok, fail) => { const __this = this; - this.databaseConnection.connection.run(sql, parameters, function (err: any): void { + this.databaseConnection.connection.executePendingMigrations(sql, parameters, function (err: any): void { if (err) { __this.logger.logFailedQuery(sql, parameters); __this.logger.logQueryError(err); @@ -650,10 +651,10 @@ export class SqliteQueryRunner implements QueryRunner { /** * Creates a database type from a given column metadata. */ - normalizeType(column: ColumnMetadata) { - switch (column.normalizedDataType) { + normalizeType(typeOptions: { type: ColumnType, length?: string|number, precision?: number, scale?: number, timezone?: boolean }) { + switch (typeOptions.type) { case "string": - return "character varying(" + (column.length ? column.length : 255) + ")"; + return "character varying(" + (typeOptions.length ? typeOptions.length : 255) + ")"; case "text": return "text"; case "boolean": @@ -671,14 +672,14 @@ export class SqliteQueryRunner implements QueryRunner { case "number": return "double precision"; case "decimal": - if (column.precision && column.scale) { - return `decimal(${column.precision},${column.scale})`; + if (typeOptions.precision && typeOptions.scale) { + return `decimal(${typeOptions.precision},${typeOptions.scale})`; - } else if (column.scale) { - return `decimal(${column.scale})`; + } else if (typeOptions.scale) { + return `decimal(${typeOptions.scale})`; - } else if (column.precision) { - return `decimal(${column.precision})`; + } else if (typeOptions.precision) { + return `decimal(${typeOptions.precision})`; } else { return "decimal"; @@ -687,13 +688,13 @@ export class SqliteQueryRunner implements QueryRunner { case "date": return "date"; case "time": - if (column.timezone) { + if (typeOptions.timezone) { return "time with time zone"; } else { return "time without time zone"; } case "datetime": - if (column.timezone) { + if (typeOptions.timezone) { return "timestamp with time zone"; } else { return "timestamp without time zone"; @@ -701,10 +702,10 @@ export class SqliteQueryRunner implements QueryRunner { case "json": return "json"; case "simple_array": - return column.length ? "character varying(" + column.length + ")" : "text"; + return typeOptions.length ? "character varying(" + typeOptions.length + ")" : "text"; } - throw new DataTypeNotSupportedByDriverError(column.type, "SQLite"); + throw new DataTypeNotSupportedByDriverError(typeOptions.type, "SQLite"); } // ------------------------------------------------------------------------- diff --git a/src/driver/sqlserver/SqlServerQueryRunner.ts b/src/driver/sqlserver/SqlServerQueryRunner.ts index d20de7063..af2517541 100644 --- a/src/driver/sqlserver/SqlServerQueryRunner.ts +++ b/src/driver/sqlserver/SqlServerQueryRunner.ts @@ -14,6 +14,7 @@ import {PrimaryKeySchema} from "../../schema-builder/schema/PrimaryKeySchema"; import {IndexSchema} from "../../schema-builder/schema/IndexSchema"; import {QueryRunnerAlreadyReleasedError} from "../../query-runner/error/QueryRunnerAlreadyReleasedError"; import {NamingStrategyInterface} from "../../naming-strategy/NamingStrategyInterface"; +import {ColumnType} from "../../metadata/types/ColumnTypes"; /** * Runs queries on a single mysql database connection. @@ -713,10 +714,10 @@ WHERE columnUsages.TABLE_CATALOG = '${this.dbName}' AND tableConstraints.TABLE_C /** * Creates a database type from a given column metadata. */ - normalizeType(column: ColumnMetadata) { - switch (column.normalizedDataType) { + normalizeType(typeOptions: { type: ColumnType, length?: string|number, precision?: number, scale?: number, timezone?: boolean }) { + switch (typeOptions.type) { case "string": - return "nvarchar(" + (column.length ? column.length : 255) + ")"; + return "nvarchar(" + (typeOptions.length ? typeOptions.length : 255) + ")"; case "text": return "ntext"; case "boolean": @@ -755,10 +756,10 @@ WHERE columnUsages.TABLE_CATALOG = '${this.dbName}' AND tableConstraints.TABLE_C case "json": return "text"; case "simple_array": - return column.length ? "nvarchar(" + column.length + ")" : "text"; + return typeOptions.length ? "nvarchar(" + typeOptions.length + ")" : "text"; } - throw new DataTypeNotSupportedByDriverError(column.type, "MySQL"); + throw new DataTypeNotSupportedByDriverError(typeOptions.type, "SQLServer"); } // ------------------------------------------------------------------------- diff --git a/src/driver/websql/WebsqlQueryRunner.ts b/src/driver/websql/WebsqlQueryRunner.ts index d0268f1fb..41d428f28 100644 --- a/src/driver/websql/WebsqlQueryRunner.ts +++ b/src/driver/websql/WebsqlQueryRunner.ts @@ -12,6 +12,7 @@ import {ForeignKeySchema} from "../../schema-builder/schema/ForeignKeySchema"; import {IndexSchema} from "../../schema-builder/schema/IndexSchema"; import {QueryRunnerAlreadyReleasedError} from "../../query-runner/error/QueryRunnerAlreadyReleasedError"; import {WebsqlDriver} from "./WebsqlDriver"; +import {ColumnType} from "../../metadata/types/ColumnTypes"; /** * Runs queries on a single websql database connection. @@ -659,10 +660,10 @@ export class WebsqlQueryRunner implements QueryRunner { /** * Creates a database type from a given column metadata. */ - normalizeType(column: ColumnMetadata) { - switch (column.normalizedDataType) { + normalizeType(typeOptions: { type: ColumnType, length?: string|number, precision?: number, scale?: number, timezone?: boolean }) { + switch (typeOptions.type) { case "string": - return "character varying(" + (column.length ? column.length : 255) + ")"; + return "character varying(" + (typeOptions.length ? typeOptions.length : 255) + ")"; case "text": return "text"; case "boolean": @@ -680,14 +681,14 @@ export class WebsqlQueryRunner implements QueryRunner { case "number": return "double precision"; case "decimal": - if (column.precision && column.scale) { - return `decimal(${column.precision},${column.scale})`; + if (typeOptions.precision && typeOptions.scale) { + return `decimal(${typeOptions.precision},${typeOptions.scale})`; - } else if (column.scale) { - return `decimal(${column.scale})`; + } else if (typeOptions.scale) { + return `decimal(${typeOptions.scale})`; - } else if (column.precision) { - return `decimal(${column.precision})`; + } else if (typeOptions.precision) { + return `decimal(${typeOptions.precision})`; } else { return "decimal"; @@ -696,13 +697,13 @@ export class WebsqlQueryRunner implements QueryRunner { case "date": return "date"; case "time": - if (column.timezone) { + if (typeOptions.timezone) { return "time with time zone"; } else { return "time without time zone"; } case "datetime": - if (column.timezone) { + if (typeOptions.timezone) { return "timestamp with time zone"; } else { return "timestamp without time zone"; @@ -710,10 +711,10 @@ export class WebsqlQueryRunner implements QueryRunner { case "json": return "json"; case "simple_array": - return column.length ? "character varying(" + column.length + ")" : "text"; + return typeOptions.length ? "character varying(" + typeOptions.length + ")" : "text"; } - throw new DataTypeNotSupportedByDriverError(column.type, "SQLite"); + throw new DataTypeNotSupportedByDriverError(typeOptions.type, "WebSQL"); } // ------------------------------------------------------------------------- diff --git a/src/metadata/ColumnMetadata.ts b/src/metadata/ColumnMetadata.ts index fe8f4b8de..262607a58 100644 --- a/src/metadata/ColumnMetadata.ts +++ b/src/metadata/ColumnMetadata.ts @@ -306,17 +306,4 @@ export class ColumnMetadata { } } - get normalizedDataType() { - if (typeof this.type === "string") { - return this.type.toLowerCase(); - - } else if (typeof this.type === "object" && - (this.type as any).name && - typeof (this.type as any).name === "string") { - return (this.type as any).toLowerCase(); // todo: shouldnt be a .name here? - } - - throw new Error(`Column data type cannot be normalized. Make sure you have supplied a correct column type.`); - } - } \ No newline at end of file diff --git a/src/migration/Migration.ts b/src/migration/Migration.ts new file mode 100644 index 000000000..0cc6e115f --- /dev/null +++ b/src/migration/Migration.ts @@ -0,0 +1,37 @@ +import {MigrationInterface} from "./MigrationInterface"; + +/** + * Represents entity of the migration in the database. + */ +export class Migration { + + // ------------------------------------------------------------------------- + // Public Properties + // ------------------------------------------------------------------------- + + /** + * Timestamp of the migration. + */ + timestamp: number; + + /** + * Name of the migration (class name). + */ + name: string; + + /** + * Migration instance that needs to be run. + */ + instance?: MigrationInterface; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + constructor(timestamp: number, name: string, instance?: MigrationInterface) { + this.timestamp = timestamp; + this.name = name; + this.instance = instance; + } + +} \ No newline at end of file diff --git a/src/migration/MigrationExecutor.ts b/src/migration/MigrationExecutor.ts new file mode 100644 index 000000000..8de4ac700 --- /dev/null +++ b/src/migration/MigrationExecutor.ts @@ -0,0 +1,262 @@ +import {TableSchema} from "../schema-builder/schema/TableSchema"; +import {ColumnSchema} from "../schema-builder/schema/ColumnSchema"; +import {ColumnTypes} from "../metadata/types/ColumnTypes"; +import {QueryBuilder} from "../query-builder/QueryBuilder"; +import {Connection} from "../connection/Connection"; +import {QueryRunnerProvider} from "../query-runner/QueryRunnerProvider"; +import {Migration} from "./Migration"; +import {ObjectLiteral} from "../common/ObjectLiteral"; +import {PromiseUtils} from "../util/PromiseUtils"; + +/** + * Executes migrations: runs pending and reverts previously executed migrations. + */ +export class MigrationExecutor { + + // ------------------------------------------------------------------------- + // Protected Properties + // ------------------------------------------------------------------------- + + protected queryRunnerProvider: QueryRunnerProvider; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + constructor(protected connection: Connection, queryRunnerProvider?: QueryRunnerProvider) { + this.queryRunnerProvider = queryRunnerProvider || new QueryRunnerProvider(connection.driver, true); + } + + // ------------------------------------------------------------------------- + // Public Methods + // ------------------------------------------------------------------------- + + /** + * Executes all pending migrations. Pending migrations are migrations that are not yet executed, + * thus not saved in the database. + */ + async executePendingMigrations(): Promise { + const queryRunner = await this.queryRunnerProvider.provide(); + + // create migrations table if its not created yet + await this.createMigrationsTableIfNotExist(); + + // get all migrations that are executed and saved in the database + const executedMigrations = await this.loadExecutedMigrations(); + + // get the time when last migration was executed + let lastTimeExecutedMigration = this.getLatestMigration(executedMigrations); + + // get all user's migrations in the source code + const allMigrations = this.getMigrations(); + + // find all migrations that needs to be executed + const pendingMigrations = allMigrations.filter(migration => { + // check if we already have executed migration + const executedMigration = executedMigrations.find(executedMigration => executedMigration.name === migration.name); + if (executedMigration) + return false; + + // migration is new and not executed. now check if its timestamp is correct + if (lastTimeExecutedMigration && migration.timestamp < lastTimeExecutedMigration.timestamp) + throw new Error(`New migration found: ${migration.name}, however this migration's timestamp is not valid. Migration's timestamp should not be older then migrations already executed in the database.`); + + // every check is passed means that migration was not run yet and we need to run it + return true; + }); + + // if no migrations are pending then nothing to do here + if (!pendingMigrations.length) { + this.connection.logger.log("info", `No migrations are pending`); + return; + } + + // log information about migration execution + this.connection.logger.log("info", `${executedMigrations.length} migrations are already loaded in the database.`); + this.connection.logger.log("info", `${allMigrations.length} migrations were found in the source code.`); + if (lastTimeExecutedMigration) + this.connection.logger.log("info", `${lastTimeExecutedMigration.name} is the last executed migration. It was executed on ${new Date(lastTimeExecutedMigration.timestamp * 1000).toString()}.`); + this.connection.logger.log("info", `${pendingMigrations.length} migrations are new migrations that needs to be executed.`); + + // start transaction if its not started yet + let transactionStartedByUs = false; + if (!queryRunner.isTransactionActive()) { + await queryRunner.beginTransaction(); + transactionStartedByUs = true; + } + + // run all pending migrations in a sequence + try { + await PromiseUtils.runInSequence(pendingMigrations, migration => { + return migration.instance!.up(queryRunner, this.connection) + .then(() => { // now when migration is executed we need to insert record about it into the database + return this.insertExecutedMigration(migration); + }) + .then(() => { // informative log about migration success + this.connection.logger.log("info", `Migration ${migration.name} has been executed successfully.`); + }); + }); + + // commit transaction if we started it + if (transactionStartedByUs) + await queryRunner.commitTransaction(); + + } catch (err) { // rollback transaction if we started it + if (transactionStartedByUs) + await queryRunner.rollbackTransaction(); + + throw err; + } + + } + + /** + * Reverts last migration that were run. + */ + async undoLastMigration(): Promise { + const queryRunner = await this.queryRunnerProvider.provide(); + + // create migrations table if its not created yet + await this.createMigrationsTableIfNotExist(); + + // get all migrations that are executed and saved in the database + const executedMigrations = await this.loadExecutedMigrations(); + + // get the time when last migration was executed + let lastTimeExecutedMigration = this.getLatestMigration(executedMigrations); + + // if no migrations found in the database then nothing to revert + if (!lastTimeExecutedMigration) { + this.connection.logger.log("info", `No migrations was found in the database. Nothing to revert!`); + return; + } + + // get all user's migrations in the source code + const allMigrations = this.getMigrations(); + + // find the instance of the migration we need to remove + const migrationToRevert = allMigrations.find(migration => migration.name === lastTimeExecutedMigration!.name); + + // if no migrations found in the database then nothing to revert + if (!migrationToRevert) + throw new Error(`No migration ${lastTimeExecutedMigration.name} was found in the source code. Make sure you have this migration in your codebase and its included in the connection options.`); + + // log information about migration execution + this.connection.logger.log("info", `${executedMigrations.length} migrations are already loaded in the database.`); + this.connection.logger.log("info", `${lastTimeExecutedMigration.name} is the last executed migration. It was executed on ${new Date(lastTimeExecutedMigration.timestamp * 1000).toString()}.`); + this.connection.logger.log("info", `Now reverting it...`); + + // start transaction if its not started yet + let transactionStartedByUs = false; + if (!queryRunner.isTransactionActive()) { + await queryRunner.beginTransaction(); + transactionStartedByUs = true; + } + + try { + await migrationToRevert.instance!.down(queryRunner, this.connection); + await this.deleteExecutedMigration(migrationToRevert); + this.connection.logger.log("info", `Migration ${migrationToRevert.name} has been reverted successfully.`); + + // commit transaction if we started it + if (transactionStartedByUs) + await queryRunner.commitTransaction(); + + } catch (err) { // rollback transaction if we started it + if (transactionStartedByUs) + await queryRunner.rollbackTransaction(); + + throw err; + } + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Creates table "migrations" that will store information about executed migrations. + */ + protected async createMigrationsTableIfNotExist(): Promise { + const queryRunner = await this.queryRunnerProvider.provide(); + const tableExist = await queryRunner.hasTable("migrations"); // todo: table name should be configurable + if (!tableExist) { + await queryRunner.createTable(new TableSchema("migrations", [ + new ColumnSchema({ + name: "timestamp", + type: queryRunner.normalizeType({ + type: ColumnTypes.NUMBER + }), + isPrimary: true, + isNullable: false + }), + new ColumnSchema({ + name: "name", + type: queryRunner.normalizeType({ + type: ColumnTypes.STRING + }), + isNullable: false + }), + ])); + } + } + + /** + * Loads all migrations that were executed and saved into the database. + */ + protected async loadExecutedMigrations(): Promise { + const migrationsRaw = await new QueryBuilder(this.connection, this.queryRunnerProvider) + .select() + .fromTable("migrations", "migrations") + .getScalarMany(); + + return migrationsRaw.map(migrationRaw => { + return new Migration(parseInt(migrationRaw["timestamp"]), migrationRaw["name"]); + }); + } + + /** + * Gets all migrations that setup for this connection. + */ + protected getMigrations(): Migration[] { + return this.connection.getMigrations().map(migration => { + const migrationClassName = (migration.constructor as any).name; + const migrationTimestamp = parseInt(migrationClassName.substr(-10)); + if (!migrationTimestamp) + throw new Error(`Migration class name should contain a class name at the end of the file. ${migrationClassName} migration name is wrong.`); + + return new Migration(migrationTimestamp, migrationClassName, migration); + }); + } + + /** + * Finds the latest migration (sorts by timestamp) in the given array of migrations. + */ + protected getLatestMigration(migrations: Migration[]): Migration|undefined { + const sortedMigrations = migrations.map(migration => migration).sort((a, b) => (a.timestamp - b.timestamp) * -1); + return sortedMigrations.length > 0 ? sortedMigrations[0] : undefined; + } + + /** + * Inserts new executed migration's data into migrations table. + */ + protected async insertExecutedMigration(migration: Migration): Promise { + const queryRunner = await this.queryRunnerProvider.provide(); + await queryRunner.insert("migrations", { + timestamp: migration.timestamp, + name: migration.name, + }); + } + + /** + * Delete previously executed migration's data from the migrations table. + */ + protected async deleteExecutedMigration(migration: Migration): Promise { + const queryRunner = await this.queryRunnerProvider.provide(); + await queryRunner.delete("migrations", { + timestamp: migration.timestamp, + name: migration.name, + }); + } + +} \ No newline at end of file diff --git a/src/migration/MigrationInterface.ts b/src/migration/MigrationInterface.ts index cff6acde2..3d4ff857a 100644 --- a/src/migration/MigrationInterface.ts +++ b/src/migration/MigrationInterface.ts @@ -1,6 +1,9 @@ import {Connection} from "../connection/Connection"; import {QueryRunner} from "../query-runner/QueryRunner"; +/** + * Migrations should implement this interface and all its methods. + */ export interface MigrationInterface { /** diff --git a/src/query-builder/QueryBuilder.ts b/src/query-builder/QueryBuilder.ts index cb68d09c9..7908495ff 100644 --- a/src/query-builder/QueryBuilder.ts +++ b/src/query-builder/QueryBuilder.ts @@ -1095,14 +1095,14 @@ export class QueryBuilder { /** * Gets all scalar results returned by execution of generated query builder sql. */ - getScalarMany(): Promise { + getScalarMany(): Promise { // todo: rename to getRawMany return this.execute(); } /** * Gets first scalar result returned by execution of generated query builder sql. */ - getScalarOne(): Promise { + getScalarOne(): Promise { // todo: rename to getRawOne return this.getScalarMany().then(results => results[0]); } diff --git a/src/query-runner/QueryRunner.ts b/src/query-runner/QueryRunner.ts index b1a8ce428..96c3e0537 100644 --- a/src/query-runner/QueryRunner.ts +++ b/src/query-runner/QueryRunner.ts @@ -3,6 +3,7 @@ import {ColumnMetadata} from "../metadata/ColumnMetadata"; import {TableSchema} from "../schema-builder/schema/TableSchema"; import {ForeignKeySchema} from "../schema-builder/schema/ForeignKeySchema"; import {IndexSchema} from "../schema-builder/schema/IndexSchema"; +import {ColumnType} from "../metadata/types/ColumnTypes"; /** * Runs queries on a single database connection. @@ -76,7 +77,7 @@ export interface QueryRunner { /** * Converts a column type of the metadata to the database column's type. */ - normalizeType(column: ColumnMetadata): any; + normalizeType(typeOptions: { type: ColumnType, length?: string|number, precision?: number, scale?: number, timezone?: boolean }): any; /** * Loads all tables (with given names) from the database and creates a TableSchema from them. diff --git a/src/schema-builder/schema/ColumnSchema.ts b/src/schema-builder/schema/ColumnSchema.ts index 88c389bd4..33fe8352d 100644 --- a/src/schema-builder/schema/ColumnSchema.ts +++ b/src/schema-builder/schema/ColumnSchema.ts @@ -49,6 +49,32 @@ export class ColumnSchema { */ comment: string|undefined; + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + constructor(options?: { + name?: string, + type?: string, + default?: string, + isNullable?: boolean, + isGenerated?: boolean, + isPrimary?: boolean, + isUnique?: boolean, + comment?: string + }) { + if (options) { + this.name = options.name || ""; + this.type = options.type || ""; + this.default = options.default || ""; + this.isNullable = options.isNullable || false; + this.isGenerated = options.isGenerated || false; + this.isPrimary = options.isPrimary || false; + this.isUnique = options.isUnique || false; + this.comment = options.comment; + } + } + // ------------------------------------------------------------------------- // Public Methods // ------------------------------------------------------------------------- diff --git a/src/schema-builder/schema/TableSchema.ts b/src/schema-builder/schema/TableSchema.ts index c21c0fead..ad6302ada 100644 --- a/src/schema-builder/schema/TableSchema.ts +++ b/src/schema-builder/schema/TableSchema.ts @@ -4,6 +4,7 @@ import {ForeignKeySchema} from "./ForeignKeySchema"; import {PrimaryKeySchema} from "./PrimaryKeySchema"; import {ColumnMetadata} from "../../metadata/ColumnMetadata"; import {QueryRunner} from "../../query-runner/QueryRunner"; +import {ObjectLiteral} from "../../common/ObjectLiteral"; /** * Table schema in the database represented in this class. @@ -50,10 +51,17 @@ export class TableSchema { // Constructor // ------------------------------------------------------------------------- - constructor(name: string, columns?: ColumnSchema[], justCreated?: boolean) { + constructor(name: string, columns?: ColumnSchema[]|ObjectLiteral[], justCreated?: boolean) { this.name = name; - if (columns) - this.columns = columns; + if (columns) { + this.columns = columns.map(column => { + if (column instanceof ColumnSchema) { + return column; + } else { + return new ColumnSchema(column); + } + }); + } if (justCreated !== undefined) this.justCreated = justCreated; diff --git a/src/util/PromiseUtils.ts b/src/util/PromiseUtils.ts new file mode 100644 index 000000000..aa3f1893d --- /dev/null +++ b/src/util/PromiseUtils.ts @@ -0,0 +1,23 @@ +/** + * Utils to help to work with Promise objects. + */ +export class PromiseUtils { + + /** + * Runs given callback that returns promise for each item in the given collection in order. + * Operations executed after each other, right after previous promise being resolved. + */ + static runInSequence(collection: T[], callback: (item: T) => Promise): Promise { + const results: U[] = []; + return collection.reduce((promise, item) => { + return promise.then(() => { + return callback(item); + }).then(result => { + results.push(result); + }); + }, Promise.resolve()).then(() => { + return results; + }); + } + +} \ No newline at end of file