Skip to content

Commit

Permalink
feat: implement "FOR UPDATE OF" for postgres driver (#7040)
Browse files Browse the repository at this point in the history
  • Loading branch information
ejose19 committed Feb 9, 2021
1 parent 5a3f9ff commit fde9f07
Show file tree
Hide file tree
Showing 18 changed files with 517 additions and 22 deletions.
4 changes: 3 additions & 1 deletion src/find-options/FindOneOptions.ts
Expand Up @@ -40,8 +40,10 @@ export interface FindOneOptions<Entity = any> {

/**
* Indicates what locking mode should be used.
*
* Note: For lock tables, you must specify the table names and not the relation names
*/
lock?: { mode: "optimistic", version: number|Date } | { mode: "pessimistic_read"|"pessimistic_write"|"dirty_read"|"pessimistic_partial_write"|"pessimistic_write_or_fail" };
lock?: { mode: "optimistic", version: number|Date } | { mode: "pessimistic_read"|"pessimistic_write"|"dirty_read"|"pessimistic_partial_write"|"pessimistic_write_or_fail"|"for_no_key_update", tables?: string[] };

/**
* Indicates if soft-deleted rows should be included in entity result.
Expand Down
13 changes: 11 additions & 2 deletions src/find-options/FindOptionsUtils.ts
Expand Up @@ -180,9 +180,18 @@ export class FindOptionsUtils {

if (options.lock) {
if (options.lock.mode === "optimistic") {
qb.setLock(options.lock.mode, options.lock.version as any);
qb.setLock(options.lock.mode, options.lock.version);
} else if (options.lock.mode === "pessimistic_read" || options.lock.mode === "pessimistic_write" || options.lock.mode === "dirty_read" || options.lock.mode === "pessimistic_partial_write" || options.lock.mode === "pessimistic_write_or_fail") {
qb.setLock(options.lock.mode);
const tableNames = options.lock.tables ? options.lock.tables.map((table) => {
const tableAlias = qb.expressionMap.aliases.find((alias) => {
return alias.metadata.tableNameWithoutPrefix === table;
});
if (!tableAlias) {
throw new Error(`"${table}" is not part of this query`);
}
return qb.escape(tableAlias.name);
}) : undefined;
qb.setLock(options.lock.mode, undefined, tableNames);
}
}

Expand Down
20 changes: 16 additions & 4 deletions src/find-options/JoinOptions.ts
Expand Up @@ -38,22 +38,34 @@ export interface JoinOptions {
alias: string;

/**
* Array of columns to LEFT JOIN.
* Object where each key represents the LEFT JOIN alias,
* and the corresponding value represents the relation path.
*
* The columns of the joined table are included in the selection.
*/
leftJoinAndSelect?: { [key: string]: string };

/**
* Array of columns to INNER JOIN.
* Object where each key represents the INNER JOIN alias,
* and the corresponding value represents the relation path.
*
* The columns of the joined table are included in the selection.
*/
innerJoinAndSelect?: { [key: string]: string };

/**
* Array of columns to LEFT JOIN.
* Object where each key represents the LEFT JOIN alias,
* and the corresponding value represents the relation path.
*
* This method does not select the columns of the joined table.
*/
leftJoin?: { [key: string]: string };

/**
* Array of columns to INNER JOIN.
* Object where each key represents the INNER JOIN alias,
* and the corresponding value represents the relation path.
*
* This method does not select the columns of the joined table.
*/
innerJoin?: { [key: string]: string };

Expand Down
2 changes: 1 addition & 1 deletion src/query-builder/QueryBuilder.ts
Expand Up @@ -688,7 +688,7 @@ export abstract class QueryBuilder<Entity> {
}

if (!conditionsArray.length) {
return " ";
return "";
} else if (conditionsArray.length === 1) {
return ` WHERE ${conditionsArray[0]}`;
} else {
Expand Down
6 changes: 6 additions & 0 deletions src/query-builder/QueryExpressionMap.ts
Expand Up @@ -157,6 +157,11 @@ export class QueryExpressionMap {
*/
lockVersion?: number|Date;

/**
* Tables to be specified in the "FOR UPDATE OF" clause, referred by their alias
*/
lockTables?: string[];

/**
* Indicates if soft-deleted rows should be included in entity result.
* By default the soft-deleted rows are not included.
Expand Down Expand Up @@ -416,6 +421,7 @@ export class QueryExpressionMap {
map.take = this.take;
map.lockMode = this.lockMode;
map.lockVersion = this.lockVersion;
map.lockTables = this.lockTables;
map.withDeleted = this.withDeleted;
map.parameters = Object.assign({}, this.parameters);
map.disableEscaping = this.disableEscaping;
Expand Down
47 changes: 33 additions & 14 deletions src/query-builder/SelectQueryBuilder.ts
Expand Up @@ -960,26 +960,21 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
/**
* Sets locking mode.
*/
setLock(lockMode: "optimistic", lockVersion: number): this;
setLock(lockMode: "optimistic", lockVersion: number | Date): this;

/**
* Sets locking mode.
*/
setLock(lockMode: "optimistic", lockVersion: Date): this;
setLock(lockMode: "pessimistic_read"|"pessimistic_write"|"dirty_read"|"pessimistic_partial_write"|"pessimistic_write_or_fail"|"for_no_key_update", lockVersion?: undefined, lockTables?: string[]): this;

/**
* Sets locking mode.
*/
setLock(lockMode: "pessimistic_read"|"pessimistic_write"|"dirty_read"|"pessimistic_partial_write"|"pessimistic_write_or_fail"|"for_no_key_update"): this;

/**
* Sets locking mode.
*/
setLock(lockMode: "optimistic"|"pessimistic_read"|"pessimistic_write"|"dirty_read"|"pessimistic_partial_write"|"pessimistic_write_or_fail"|"for_no_key_update", lockVersion?: number|Date): this {
setLock(lockMode: "optimistic"|"pessimistic_read"|"pessimistic_write"|"dirty_read"|"pessimistic_partial_write"|"pessimistic_write_or_fail"|"for_no_key_update", lockVersion?: number|Date, lockTables?: string[]): this {
this.expressionMap.lockMode = lockMode;
this.expressionMap.lockVersion = lockVersion;
this.expressionMap.lockTables = lockTables;
return this;

}

/**
Expand Down Expand Up @@ -1664,13 +1659,26 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
*/
protected createLockExpression(): string {
const driver = this.connection.driver;

let lockTablesClause = "";

if (this.expressionMap.lockTables) {
if (!(driver instanceof PostgresDriver)) {
throw new Error("Lock tables not supported in selected driver");
}
if (this.expressionMap.lockTables.length < 1) {
throw new Error("lockTables cannot be an empty array");
}
lockTablesClause = " OF " + this.expressionMap.lockTables.join(", ");
}

switch (this.expressionMap.lockMode) {
case "pessimistic_read":
if (driver instanceof MysqlDriver || driver instanceof AuroraDataApiDriver) {
return " LOCK IN SHARE MODE";

} else if (driver instanceof PostgresDriver) {
return " FOR SHARE";
return " FOR SHARE" + lockTablesClause;

} else if (driver instanceof OracleDriver) {
return " FOR UPDATE";
Expand All @@ -1682,32 +1690,43 @@ export class SelectQueryBuilder<Entity> extends QueryBuilder<Entity> implements
throw new LockNotSupportedOnGivenDriverError();
}
case "pessimistic_write":
if (driver instanceof MysqlDriver || driver instanceof AuroraDataApiDriver || driver instanceof PostgresDriver || driver instanceof OracleDriver) {
if (driver instanceof MysqlDriver || driver instanceof AuroraDataApiDriver || driver instanceof OracleDriver) {
return " FOR UPDATE";

}
else if (driver instanceof PostgresDriver ) {
return " FOR UPDATE" + lockTablesClause;

} else if (driver instanceof SqlServerDriver) {
return "";

} else {
throw new LockNotSupportedOnGivenDriverError();
}
case "pessimistic_partial_write":
if (driver instanceof PostgresDriver || driver instanceof MysqlDriver) {
if (driver instanceof PostgresDriver) {
return " FOR UPDATE" + lockTablesClause + " SKIP LOCKED";

} else if (driver instanceof MysqlDriver) {
return " FOR UPDATE SKIP LOCKED";

} else {
throw new LockNotSupportedOnGivenDriverError();
}
case "pessimistic_write_or_fail":
if (driver instanceof PostgresDriver || driver instanceof MysqlDriver) {
if (driver instanceof PostgresDriver) {
return " FOR UPDATE" + lockTablesClause + " NOWAIT";

} else if (driver instanceof MysqlDriver) {
return " FOR UPDATE NOWAIT";

} else {
throw new LockNotSupportedOnGivenDriverError();
}

case "for_no_key_update":
if (driver instanceof PostgresDriver) {
return " FOR NO KEY UPDATE";
return " FOR NO KEY UPDATE" + lockTablesClause;
} else {
throw new LockNotSupportedOnGivenDriverError();
}
Expand Down
32 changes: 32 additions & 0 deletions test/functional/query-builder/locking/entity/Category.ts
@@ -0,0 +1,32 @@
import {Entity} from "../../../../../src/decorator/entity/Entity";
import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn";
import {Column} from "../../../../../src/decorator/columns/Column";
import {ManyToMany} from "../../../../../src/decorator/relations/ManyToMany";
import {JoinTable} from "../../../../../src/decorator/relations/JoinTable";
import {Post} from "./Post";
import {Image} from "./Image";

@Entity()
export class Category {

@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

@Column()
isRemoved: boolean = false;

@ManyToMany(type => Post, post => post.categories)
posts: Post[];

@ManyToMany(type => Image)
@JoinTable()
images: Image[];

titleImage: Image;

removedImages: Image[];

}
17 changes: 17 additions & 0 deletions test/functional/query-builder/locking/entity/Image.ts
@@ -0,0 +1,17 @@
import {Entity} from "../../../../../src/decorator/entity/Entity";
import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn";
import {Column} from "../../../../../src/decorator/columns/Column";

@Entity()
export class Image {

@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

@Column()
isRemoved: boolean = false;

}
40 changes: 40 additions & 0 deletions test/functional/query-builder/locking/entity/Post.ts
@@ -0,0 +1,40 @@
import {Entity} from "../../../../../src/decorator/entity/Entity";
import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn";
import {Column} from "../../../../../src/decorator/columns/Column";
import {ManyToOne} from "../../../../../src/decorator/relations/ManyToOne";
import {ManyToMany} from "../../../../../src/decorator/relations/ManyToMany";
import {JoinTable} from "../../../../../src/decorator/relations/JoinTable";
import {OneToOne} from "../../../../../src/decorator/relations/OneToOne";
import {JoinColumn} from "../../../../../src/decorator/relations/JoinColumn";
import {User} from "./User";
import {Category} from "./Category";
import {Tag} from "./Tag";
import {Image} from "./Image";

@Entity()
export class Post {

@PrimaryGeneratedColumn()
id: number;

@Column()
title: string;

@ManyToOne(type => Tag)
tag: Tag;

@OneToOne(type => User)
@JoinColumn()
author: User;

@ManyToMany(type => Category, category => category.posts)
@JoinTable()
categories: Category[];

subcategories: Category[];

removedCategories: Category[];

images: Image[];

}
14 changes: 14 additions & 0 deletions test/functional/query-builder/locking/entity/Tag.ts
@@ -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 Tag {

@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

}
14 changes: 14 additions & 0 deletions test/functional/query-builder/locking/entity/User.ts
@@ -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 User {

@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

}

0 comments on commit fde9f07

Please sign in to comment.