implemented @transaction decorators

This commit is contained in:
Umed Khudoiberdiev 2017-01-14 00:31:15 +05:00
parent 1f7065fa67
commit 6d81649a27
11 changed files with 277 additions and 8 deletions

View File

@ -4,18 +4,33 @@ import {Post} from "./entity/Post";
const options: ConnectionOptions = {
driver: {
type: "sqlite",
storage: "temp/sqlitedb.db"
// type: "postgres",
// host: "localhost",
// port: 5432,
// username: "root",
// password: "admin",
// database: "test"
type: "oracle",
host: "localhost",
username: "system",
password: "oracle",
port: 1521,
sid: "xe.oracle.docker",
// type: "mssql",
// host: "192.168.1.10",
// username: "sa",
// password: "admin12345",
// database: "test",
// port: 1521
// type: "sqlite",
// storage: "temp/sqlitedb.db"
},
logging: {
logQueries: true,
logSchemaCreation: true
},
autoSchemaSync: true,
dropSchemaOnConnection: true,
entities: [
Post
]
entities: [Post]
};
createConnection(options).then(connection => {

View File

@ -9,7 +9,6 @@ export class Post {
id: number;
@Column()
@Index({ unique: true })
title: string;
@Column()

View File

@ -0,0 +1,44 @@
import {getMetadataArgsStorage, getConnection} from "../../index";
/**
* Wraps some method into the transaction.
* Note, method result will return a promise if this decorator applied.
* Note, all database operations in the wrapped method should be executed using entity managed passed as a first parameter
* into the wrapped method.
* If you want to control at what position in your method parameters entity manager should be injected,
* then use @TransactionEntityManager() decorator.
*/
export function Transaction(connectionName: string = "default"): Function {
return function (target: Object, methodName: string, descriptor: PropertyDescriptor) {
// save original method - we gonna need it
const originalMethod = descriptor.value;
// override method descriptor with proxy method
descriptor.value = function(...args: any[]) {
return getConnection(connectionName)
.entityManager
.transaction(entityManager => {
// gets all @TransactionEntityManager() decorator usages for this method
const indices = getMetadataArgsStorage()
.transactionEntityManagers
.filterByTarget(target.constructor)
.toArray()
.filter(transactionEntityManager => transactionEntityManager.methodName === methodName)
.map(transactionEntityManager => transactionEntityManager.index);
let argsWithInjectedEntityManager: any[];
if (indices.length) { // if there are @TransactionEntityManager() decorator usages the inject them
argsWithInjectedEntityManager = [...args];
indices.forEach(index => argsWithInjectedEntityManager.splice(index, 0, entityManager));
} else { // otherwise inject it as a first parameter
argsWithInjectedEntityManager = [entityManager, ...args];
}
return originalMethod.apply(this, argsWithInjectedEntityManager);
});
};
};
}

View File

@ -0,0 +1,16 @@
import {getMetadataArgsStorage} from "../../index";
import {TransactionEntityMetadataArgs} from "../../metadata-args/TransactionEntityMetadataArgs";
/**
* Injects transaction's entity manager into the method wrapped with @Transaction decorator.
*/
export function TransactionEntityManager(): Function {
return function (object: Object, methodName: string, index: number) {
const args: TransactionEntityMetadataArgs = {
target: object.constructor,
methodName: methodName,
index: index,
};
getMetadataArgsStorage().transactionEntityManagers.add(args);
};
}

View File

@ -5,7 +5,7 @@ import {ConnectionManager} from "./connection/ConnectionManager";
import {Connection} from "./connection/Connection";
import {MetadataArgsStorage} from "./metadata-args/MetadataArgsStorage";
import {ConnectionOptions} from "./connection/ConnectionOptions";
import {getFromContainer, defaultContainer} from "./container";
import {getFromContainer} from "./container";
import {ObjectType} from "./common/ObjectType";
import {Repository} from "./repository/Repository";
import {EntityManager} from "./entity-manager/EntityManager";
@ -54,6 +54,8 @@ export * from "./decorator/entity/EmbeddableEntity";
export * from "./decorator/entity/SingleEntityChild";
export * from "./decorator/entity/Entity";
export * from "./decorator/entity/TableInheritance";
export * from "./decorator/transaction/Transaction";
export * from "./decorator/transaction/TransactionEntityManager";
export * from "./decorator/tree/TreeLevelColumn";
export * from "./decorator/tree/TreeParent";
export * from "./decorator/tree/TreeChildren";

View File

@ -15,6 +15,7 @@ import {RelationIdMetadataArgs} from "./RelationIdMetadataArgs";
import {InheritanceMetadataArgs} from "./InheritanceMetadataArgs";
import {DiscriminatorValueMetadataArgs} from "./DiscriminatorValueMetadataArgs";
import {EntityRepositoryMetadataArgs} from "./EntityRepositoryMetadataArgs";
import {TransactionEntityMetadataArgs} from "./TransactionEntityMetadataArgs";
/**
* Storage all metadatas of all available types: tables, fields, subscribers, relations, etc.
@ -33,6 +34,7 @@ export class MetadataArgsStorage {
readonly tables = new TargetMetadataArgsCollection<TableMetadataArgs>();
readonly entityRepositories = new TargetMetadataArgsCollection<EntityRepositoryMetadataArgs>();
readonly transactionEntityManagers = new TargetMetadataArgsCollection<TransactionEntityMetadataArgs>();
readonly namingStrategies = new TargetMetadataArgsCollection<NamingStrategyMetadataArgs>();
readonly entitySubscribers = new TargetMetadataArgsCollection<EntitySubscriberMetadataArgs>();
readonly indices = new TargetMetadataArgsCollection<IndexMetadataArgs>();

View File

@ -0,0 +1,21 @@
/**
* Used to inject transaction's entity managed into the method wrapped with @Transaction decorator.
*/
export interface TransactionEntityMetadataArgs {
/**
* Target class on which decorator is used.
*/
readonly target: Function;
/**
* Method on which decorator is used.
*/
readonly methodName: string;
/**
* Index of the parameter on which decorator is used.
*/
readonly index: number;
}

View File

@ -0,0 +1,21 @@
import {Transaction} from "../../../../../src/decorator/transaction/Transaction";
import {Post} from "../entity/Post";
import {EntityManager} from "../../../../../src/entity-manager/EntityManager";
import {TransactionEntityManager} from "../../../../../src/decorator/transaction/TransactionEntityManager";
import {Category} from "../entity/Category";
export class PostController {
@Transaction("mysql") // "mysql" is a connection name. you can not pass it if you are using default connection.
async save(post: Post, category: Category, @TransactionEntityManager() entityManager: EntityManager) {
await entityManager.persist(post);
await entityManager.persist(category);
}
// this save is not wrapped into the transaction
async nonSafeSave(entityManager: EntityManager, post: Post, category: Category) {
await entityManager.persist(post);
await entityManager.persist(category);
}
}

View File

@ -0,0 +1,14 @@
import {Entity} from "../../../../../src/decorator/entity/Entity";
import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn";
import {Column} from "../../../../../src/decorator/columns/Column";
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}

View File

@ -0,0 +1,14 @@
import {Entity} from "../../../../../src/decorator/entity/Entity";
import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn";
import {Column} from "../../../../../src/decorator/columns/Column";
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
}

View File

@ -0,0 +1,121 @@
import "reflect-metadata";
import {createTestingConnections, closeTestingConnections, reloadTestingDatabases} from "../../../utils/test-utils";
import {Connection} from "../../../../src/connection/Connection";
import {Post} from "./entity/Post";
import {expect} from "chai";
import {PostController} from "./controller/PostController";
import {Category} from "./entity/Category";
describe("transaction > method wrapped into transaction decorator", () => {
let connections: Connection[];
before(async () => connections = await createTestingConnections({
entities: [__dirname + "/entity/*{.js,.ts}"],
schemaCreate: true,
dropSchemaOnConnection: true,
enabledDrivers: ["mysql"] // since @Transaction accepts a specific connection name we can use only one connection and its name
}));
beforeEach(() => reloadTestingDatabases(connections));
after(() => closeTestingConnections(connections));
// create a fake controller
const controller = new PostController();
it("should execute all operations in the method in a transaction", () => Promise.all(connections.map(async connection => {
const post = new Post();
post.title = "successfully saved post";
const category = new Category();
category.name = "successfully saved category";
// call controller method
await controller.save.apply(controller, [post, category]);
// controller should have saved both post and category successfully
const loadedPost = await connection.entityManager.findOne(Post, { title: "successfully saved post" });
expect(loadedPost).not.to.be.empty;
loadedPost!.should.be.eql(post);
const loadedCategory = await connection.entityManager.findOne(Category, { name: "successfully saved category" });
expect(loadedCategory).not.to.be.empty;
loadedCategory!.should.be.eql(category);
})));
it("should rollback transaction if any operation in the method failed", () => Promise.all(connections.map(async connection => {
const post = new Post();
post.title = "successfully saved post";
const category = new Category(); // this will fail because no name set
// call controller method and make its rejected since controller action should fail
let throwError: any;
try {
await controller.save.apply(controller, [post, category]);
} catch (err) {
throwError = err;
}
expect(throwError).not.to.be.empty;
const loadedPost = await connection.entityManager.findOne(Post, { title: "successfully saved post" });
expect(loadedPost).to.be.empty;
const loadedCategory = await connection.entityManager.findOne(Category, { name: "successfully saved category" });
expect(loadedCategory).to.be.empty;
})));
it("should rollback transaction if any operation in the method failed", () => Promise.all(connections.map(async connection => {
const post = new Post(); // this will fail because no title set
const category = new Category();
category.name = "successfully saved category";
// call controller method and make its rejected since controller action should fail
let throwError: any;
try {
await controller.save.apply(controller, [post, category]);
} catch (err) {
throwError = err;
}
expect(throwError).not.to.be.empty;
const loadedPost = await connection.entityManager.findOne(Post, { title: "successfully saved post" });
expect(loadedPost).to.be.empty;
const loadedCategory = await connection.entityManager.findOne(Category, { name: "successfully saved category" });
expect(loadedCategory).to.be.empty;
})));
it("should save even if second operation failed in method not wrapped into @Transaction decorator", () => Promise.all(connections.map(async connection => {
const post = new Post(); // this will be saved in any cases because its valid
post.title = "successfully saved post";
const category = new Category(); // this will fail because no name set
// call controller method and make its rejected since controller action should fail
let throwError: any;
try {
await controller.nonSafeSave.apply(controller, [connection.entityManager, post, category]);
} catch (err) {
throwError = err;
}
expect(throwError).not.to.be.empty;
// controller should have saved both post and category successfully
const loadedPost = await connection.entityManager.findOne(Post, { title: "successfully saved post" });
expect(loadedPost).not.to.be.empty;
loadedPost!.should.be.eql(post);
const loadedCategory = await connection.entityManager.findOne(Category, { name: "successfully saved category" });
expect(loadedCategory).to.be.empty;
})));
});