Skip to content

Commit

Permalink
feat: add upsert methods for the drivers that support it
Browse files Browse the repository at this point in the history
This adds EntityManager#upsert, BaseEntity#upsert and EntityManager#upsert

Closes: typeorm#2363
  • Loading branch information
joeflateau committed Aug 18, 2021
1 parent e9366b3 commit a3b5f01
Show file tree
Hide file tree
Showing 10 changed files with 175 additions and 12 deletions.
47 changes: 46 additions & 1 deletion src/entity-manager/EntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {EntityNotFoundError} from "../error/EntityNotFoundError";
import {QueryRunnerProviderAlreadyReleasedError} from "../error/QueryRunnerProviderAlreadyReleasedError";
import {FindOneOptions} from "../find-options/FindOneOptions";
import {DeepPartial} from "../common/DeepPartial";
import {ColumnMetadata} from "../metadata/ColumnMetadata";
import {RemoveOptions} from "../repository/RemoveOptions";
import {SaveOptions} from "../repository/SaveOptions";
import {UpsertOptions} from "../repository/UpsertOptions";
import {NoNeedToReleaseEntityManagerError} from "../error/NoNeedToReleaseEntityManagerError";
import {MongoRepository} from "../repository/MongoRepository";
import {TreeRepository} from "../repository/TreeRepository";
Expand Down Expand Up @@ -37,7 +39,7 @@ import {ObjectUtils} from "../util/ObjectUtils";
import {EntitySchema} from "../entity-schema/EntitySchema";
import {ObjectLiteral} from "../common/ObjectLiteral";
import {getMetadataArgsStorage} from "../globals";
import { TypeORMError } from "../error";
import {EntityColumnNotFound,TypeORMError} from "../error";

/**
* Entity manager supposed to work with any entity, automatically find its repository and call its methods,
Expand Down Expand Up @@ -478,6 +480,49 @@ export class EntityManager {
.execute();
}

async upsert<Entity>(target: EntityTarget<Entity>, entity: QueryDeepPartialEntity<Entity> | (QueryDeepPartialEntity<Entity>[]), options: UpsertOptions<Entity> = {}): Promise<InsertResult> {
const metadata = this.connection.getMetadata(target);

const conflictColumns =
options.conflictProperties?.map(
(prop) =>
{
const column = metadata.columns.find(col => col.propertyName === prop);
if (column == null) {
throw new EntityColumnNotFound(prop);
}
return column.databaseName;
}
) ??
metadata.columns
.filter((col) => col.isPrimary)
.map((col) => col.databaseName);


const insertQueryBuilder = this.createQueryBuilder()
.insert()
.into(target)
.values(entity);

const insertedColumns: ColumnMetadata[] = (insertQueryBuilder as any).getInsertedColumns();

const overwriteColumns = insertedColumns
.filter(
(col) =>
!conflictColumns.includes(col.databaseName) &&
!col.isCreateDate &&
!col.isGenerated
)
.map((col) => col.databaseName);

insertQueryBuilder
.orUpdate(overwriteColumns, conflictColumns);

const insertResult = await insertQueryBuilder.execute();

return insertResult;
}

/**
* Updates entity partially. Entity can be found by a given condition(s).
* Unlike save method executes a primitive operation without cascades, relations and other operations included.
Expand Down
9 changes: 6 additions & 3 deletions src/query-builder/InsertQueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {BroadcasterResult} from "../subscriber/BroadcasterResult";
import {EntitySchema} from "../entity-schema/EntitySchema";
import {OracleDriver} from "../driver/oracle/OracleDriver";
import {AuroraDataApiDriver} from "../driver/aurora-data-api/AuroraDataApiDriver";
import { TypeORMError } from "../error";

/**
* Allows to build complex sql queries in a fashion way and execute those queries.
Expand Down Expand Up @@ -378,9 +379,7 @@ export class InsertQueryBuilder<Entity> extends QueryBuilder<Entity> {
query += " ";
}
}
}

if (this.connection.driver instanceof MysqlDriver || this.connection.driver instanceof AuroraDataApiDriver) {
} else if (this.connection.driver instanceof MysqlDriver || this.connection.driver instanceof AuroraDataApiDriver) {
if (this.expressionMap.onUpdate) {
const { overwrite, columns } = this.expressionMap.onUpdate;

Expand All @@ -394,6 +393,10 @@ export class InsertQueryBuilder<Entity> extends QueryBuilder<Entity> {
query += " ";
}
}
} else {
if (this.expressionMap.onUpdate) {
throw new TypeORMError(`onUpdate is not supported by the current database driver`);
}
}

// add RETURNING expression
Expand Down
10 changes: 10 additions & 0 deletions src/repository/BaseEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {DeleteResult} from "../query-builder/result/DeleteResult";
import {ObjectID} from "../driver/mongodb/typings";
import {ObjectUtils} from "../util/ObjectUtils";
import {QueryDeepPartialEntity} from "../query-builder/QueryPartialEntity";
import {UpsertOptions} from "./UpsertOptions";

/**
* Base abstract entity for all entities, used in ActiveRecord patterns.
Expand Down Expand Up @@ -249,6 +250,15 @@ export class BaseEntity {
return (this as any).getRepository().update(criteria, partialEntity, options);
}

/**
* Inserts a given entity into the database, unless a unique constraint conflicts then updates the entity
* Unlike save method executes a primitive operation without cascades, relations and other operations included.
* Executes fast and efficient INSERT ... ON CONFLICT DO UPDATE/ON DUPLICATE KEY UPDATE query.
*/
static upsert<T extends BaseEntity>(this: ObjectType<T> & typeof BaseEntity, entity: QueryDeepPartialEntity<T> | (QueryDeepPartialEntity<T>[]), options?: UpsertOptions<T>): Promise<InsertResult> {
return this.getRepository<T>().upsert(entity, options);
}

/**
* Deletes entities by a given criteria.
* Unlike remove method executes a primitive operation without cascades, relations and other operations included.
Expand Down
10 changes: 10 additions & 0 deletions src/repository/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {InsertResult} from "../query-builder/result/InsertResult";
import {QueryDeepPartialEntity} from "../query-builder/QueryPartialEntity";
import {ObjectID} from "../driver/mongodb/typings";
import {FindConditions} from "../find-options/FindConditions";
import {UpsertOptions} from "./UpsertOptions";

/**
* Repository is supposed to work with your entity objects. Find entities, insert, update, delete, etc.
Expand Down Expand Up @@ -241,6 +242,15 @@ export class Repository<Entity extends ObjectLiteral> {
return this.manager.update(this.metadata.target as any, criteria as any, partialEntity);
}

/**
* Inserts a given entity into the database, unless a unique constraint conflicts then updates the entity
* Unlike save method executes a primitive operation without cascades, relations and other operations included.
* Executes fast and efficient INSERT ... ON CONFLICT DO UPDATE/ON DUPLICATE KEY UPDATE query.
*/
upsert(entity: QueryDeepPartialEntity<Entity> | (QueryDeepPartialEntity<Entity>[]), options?: UpsertOptions<Entity>): Promise<InsertResult> {
return this.manager.upsert(this.metadata.target as any, entity, options);
}

/**
* Deletes entities by a given criteria.
* Unlike save method executes a primitive operation without cascades, relations and other operations included.
Expand Down
10 changes: 10 additions & 0 deletions src/repository/UpsertOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Special options passed to Repository#upsert
*/
export interface UpsertOptions<T> {

/**
* Define which columns to conflict on
*/
conflictProperties?: ((keyof T) & string)[];
}
21 changes: 20 additions & 1 deletion test/functional/entity-model/entity-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ describe("entity-model", () => {
loadedPost!.id.should.be.eql(post.id);
loadedPost!.title.should.be.eql("About ActiveRecord");
loadedPost!.text.should.be.eql("Huge discussion how good or bad ActiveRecord is.");

const externalId = "external-entity";

await Post.upsert({
externalId,
title: "External post"
}, {
conflictProperties: ["externalId"]
});
const upsertInsertedExternalPost = await Post.findOneOrFail({ externalId });
await Post.upsert({
externalId,
title: "External post 2"
}, {
conflictProperties: ["externalId"]
});
const upsertUpdatedExternalPost = await Post.findOneOrFail({ externalId });

upsertInsertedExternalPost.id.should.be.equal(upsertUpdatedExternalPost.id);
upsertInsertedExternalPost.title.should.not.be.equal(upsertUpdatedExternalPost.title);
}
});

Expand Down Expand Up @@ -73,5 +93,4 @@ describe("entity-model", () => {
});
}
});

});
3 changes: 3 additions & 0 deletions test/functional/entity-model/entity/Post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export class Post extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;

@Column()
externalId?: string;

@Column()
title: string;

Expand Down
21 changes: 19 additions & 2 deletions test/functional/repository/basic-methods/entity/Post.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
import {Entity} from "../../../../../src/decorator/entity/Entity";
import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn";
import {Column} from "../../../../../src/decorator/columns/Column";
import {CreateDateColumn,UpdateDateColumn} from "../../../../../src";

@Entity()
export class Post {

@PrimaryGeneratedColumn()
id: number|undefined|null|string;

@Column({
nullable: true,
unique: true,
name: "eXtErNal___id" // makes sure we test handling differing property/database names where necessary
})
externalId?: string;

@Column()
title: string;

@Column({
nullable: true,
type: "date",
transformer: {
from: (value: any) => new Date(value),
to: (value: Date) => value.toISOString(),
to: (value?: Date) => value?.toISOString(),
}
})
dateAdded: Date;
dateAdded?: Date;

@CreateDateColumn()
createdAt!: Date;

@UpdateDateColumn({
name: "uPdAtEd___At" // makes sure we test handling differing property/database names where necessary
})
updatedAt!: Date;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "reflect-metadata";
import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../utils/test-utils";
import {closeTestingConnections, createTestingConnections, reloadTestingDatabases, sleep} from "../../../utils/test-utils";
import {Connection} from "../../../../src/connection/Connection";
import {Post} from "./entity/Post";
import {QueryBuilder} from "../../../../src/query-builder/QueryBuilder";
Expand Down Expand Up @@ -341,8 +341,8 @@ describe("repository > basic methods", () => {

const dbPost = await postRepository.findOne(post.id) as Post;
dbPost.should.be.instanceOf(Post);
dbPost.dateAdded.should.be.instanceOf(Date);
dbPost.dateAdded.getTime().should.be.equal(date.getTime());
dbPost.dateAdded!.should.be.instanceOf(Date);
dbPost.dateAdded!.getTime().should.be.equal(date.getTime());

dbPost.title = "New title";
const saved = await postRepository.save(dbPost);
Expand All @@ -351,8 +351,53 @@ describe("repository > basic methods", () => {

saved.id!.should.be.equal(1);
saved.title.should.be.equal("New title");
saved.dateAdded.should.be.instanceof(Date);
saved.dateAdded.getTime().should.be.equal(date.getTime());
saved.dateAdded!.should.be.instanceof(Date);
saved.dateAdded!.getTime().should.be.equal(date.getTime());
})));
});

describe("upsert", function () {
it("should first create then update an entity", () => Promise.all(connections.map(async connection => {
const postRepository = connection.getRepository(Post);
const externalId = "external-1";

// create a new post and insert it
const post = new Post();
post.externalId = externalId;
post.title = "Post title initial";
await postRepository.upsert(post, {
conflictProperties: ["externalId"]
});
const initial = await postRepository.findOneOrFail({
externalId
});
initial.title.should.be.equal("Post title initial");

await sleep(1000); // need to let a second pass for the updatedAt time to have a new value

// update post with externalId
const update = new Post();
update.externalId = externalId;
update.title = "Post title updated";

await postRepository.upsert(update, {
conflictProperties: ["externalId"]
});
const updated = await postRepository.findOneOrFail({
externalId
});
// title should have changed
updated.title.should.be.equal("Post title updated");
// id should not have changed
updated.id!.should.be.equal(initial.id);

updated.createdAt.getTime().should.be.equal(initial.createdAt.getTime(), "created time should be the same");

updated.updatedAt.getTime().should.not.be.equal(initial.updatedAt.getTime(), "updated time should not be the same");

// unique constraint on externalId already enforces this, but test it anyways
const count = await postRepository.find({ externalId });
count.length.should.be.equal(1);
})));
});

Expand Down
1 change: 1 addition & 0 deletions test/utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export function setupTestingConnections(options?: TestingOptions): ConnectionOpt
subscribers: options && options.subscribers ? options.subscribers : [],
dropSchema: options && options.dropSchema !== undefined ? options.dropSchema : false,
cache: options ? options.cache : undefined,
logging: process.env.TYPEORM_LOGGING === "1"
});
if (options && options.driverSpecific)
newOptions = Object.assign({}, options.driverSpecific, newOptions);
Expand Down

0 comments on commit a3b5f01

Please sign in to comment.