From 9a20482e5aae5255b98842fe91497e1d09194368 Mon Sep 17 00:00:00 2001 From: Joe Flateau Date: Fri, 20 Aug 2021 17:21:20 -0400 Subject: [PATCH] feat: add upsert methods for the drivers that support it This adds EntityManager#upsert, BaseEntity#upsert and EntityManager#upsert Closes: #2363 --- docs/entity-manager-api.md | 23 +++ docs/repository-api.md | 23 +++ src/driver/Driver.ts | 6 + .../aurora-data-api/AuroraDataApiDriver.ts | 5 + src/driver/cockroachdb/CockroachDriver.ts | 5 + src/driver/mysql/MysqlDriver.ts | 5 + src/driver/postgres/PostgresDriver.ts | 5 + .../sqlite-abstract/AbstractSqliteDriver.ts | 5 + src/driver/types/UpsertType.ts | 1 + src/entity-manager/EntityManager.ts | 66 +++++++- src/metadata/EntityMetadata.ts | 15 +- src/query-builder/InsertQueryBuilder.ts | 11 +- src/repository/BaseEntity.ts | 11 ++ src/repository/Repository.ts | 11 ++ test/functional/entity-model/entity-model.ts | 21 ++- test/functional/entity-model/entity/Post.ts | 6 + .../entity/EmbeddedUniqueConstraintEntity.ts | 19 +++ .../entity/ExternalIdPrimaryKeyEntity.ts | 21 +++ .../repository/basic-methods/entity/Post.ts | 21 ++- .../basic-methods/repository-basic-methods.ts | 149 +++++++++++++++++- test/utils/test-utils.ts | 21 +++ 21 files changed, 434 insertions(+), 16 deletions(-) create mode 100644 src/driver/types/UpsertType.ts create mode 100644 test/functional/repository/basic-methods/entity/EmbeddedUniqueConstraintEntity.ts create mode 100644 test/functional/repository/basic-methods/entity/ExternalIdPrimaryKeyEntity.ts diff --git a/docs/entity-manager-api.md b/docs/entity-manager-api.md index cde5abf5bc1..41d4e70f2c9 100644 --- a/docs/entity-manager-api.md +++ b/docs/entity-manager-api.md @@ -147,6 +147,29 @@ await manager.update(User, 1, { firstName: "Rizzrak" }); // executes UPDATE user SET firstName = Rizzrak WHERE id = 1 ``` +* `upsert` - Inserts a new entity or array of entities unless they already exist in which case they are updated instead. + +```typescript +await manager.upsert(User, { externalId: "abc123" }, { firstName: "Rizzrak" }); +/** executes + * INSERT INTO user + * VALUES (externalId = abc123, firstName = Rizzrak) + * ON CONFLICT (externalId) DO UPDATE firstName = EXCLUDED.firstName + **/ + +await manager.update(User, ["externalId"], [ + { externalId:"abc123", firstName: "Rizzrak" }, + { externalId:"bca321", firstName: "Karzzir" }, + ]); +/** executes + * INSERT INTO user + * VALUES + * (externalId = abc123, firstName = Rizzrak), + * (externalId = cba321, firstName = Karzzir), + * ON CONFLICT (externalId) DO UPDATE firstName = EXCLUDED.firstName + **/ +``` + * `delete` - Deletes entities by entity id, ids or given conditions: ```typescript diff --git a/docs/repository-api.md b/docs/repository-api.md index e77bb252668..c2269b0284c 100644 --- a/docs/repository-api.md +++ b/docs/repository-api.md @@ -154,6 +154,29 @@ await repository.update(1, { firstName: "Rizzrak" }); // executes UPDATE user SET firstName = Rizzrak WHERE id = 1 ``` +* `upsert` - Inserts a new entity or array of entities unless they already exist in which case they are updated instead. + +```typescript +await repository.upsert({ externalId: "abc123" }, { firstName: "Rizzrak" }); +/** executes + * INSERT INTO user + * VALUES (externalId = abc123, firstName = Rizzrak) + * ON CONFLICT (externalId) DO UPDATE firstName = EXCLUDED.firstName + **/ + +await repository.update(["externalId"], [ + { externalId:"abc123", firstName: "Rizzrak" }, + { externalId:"bca321", firstName: "Karzzir" }, + ]); +/** executes + * INSERT INTO user + * VALUES + * (externalId = abc123, firstName = Rizzrak), + * (externalId = cba321, firstName = Karzzir), + * ON CONFLICT (externalId) DO UPDATE firstName = EXCLUDED.firstName + **/ +``` + * `delete` - Deletes entities by entity id, ids or given conditions: ```typescript diff --git a/src/driver/Driver.ts b/src/driver/Driver.ts index 9192f3269da..cbf2fc1b737 100644 --- a/src/driver/Driver.ts +++ b/src/driver/Driver.ts @@ -12,6 +12,7 @@ import {ReplicationMode} from "./types/ReplicationMode"; import { Table } from "../schema-builder/table/Table"; import { View } from "../schema-builder/view/View"; import { TableForeignKey } from "../schema-builder/table/TableForeignKey"; +import { UpsertType } from "./types/UpsertType"; /** * Driver organizes TypeORM communication with specific database management system. @@ -50,6 +51,11 @@ export interface Driver { */ supportedDataTypes: ColumnType[]; + /** + * Returns type of upsert supported by driver if any + */ + supportedUpsertType?: UpsertType; + /** * Default values of length, precision and scale depends on column data type. * Used in the cases when length/precision/scale is not specified by user. diff --git a/src/driver/aurora-data-api/AuroraDataApiDriver.ts b/src/driver/aurora-data-api/AuroraDataApiDriver.ts index c7467e9fa45..74993023b0d 100644 --- a/src/driver/aurora-data-api/AuroraDataApiDriver.ts +++ b/src/driver/aurora-data-api/AuroraDataApiDriver.ts @@ -142,6 +142,11 @@ export class AuroraDataApiDriver implements Driver { "geometrycollection" ]; + /** + * Returns type of upsert supported by driver if any + */ + readonly supportedUpsertType = "on-duplicate-key-update"; + /** * Gets list of spatial column data types. */ diff --git a/src/driver/cockroachdb/CockroachDriver.ts b/src/driver/cockroachdb/CockroachDriver.ts index 229079fb539..dd29317a123 100644 --- a/src/driver/cockroachdb/CockroachDriver.ts +++ b/src/driver/cockroachdb/CockroachDriver.ts @@ -148,6 +148,11 @@ export class CockroachDriver implements Driver { "uuid", ]; + /** + * Returns type of upsert supported by driver if any + */ + readonly supportedUpsertType = "on-conflict-do-update"; + /** * Gets list of spatial column data types. */ diff --git a/src/driver/mysql/MysqlDriver.ts b/src/driver/mysql/MysqlDriver.ts index 0b0114b1a9b..d7508fd4ad2 100644 --- a/src/driver/mysql/MysqlDriver.ts +++ b/src/driver/mysql/MysqlDriver.ts @@ -141,6 +141,11 @@ export class MysqlDriver implements Driver { "geometrycollection" ]; + /** + * Returns type of upsert supported by driver if any + */ + readonly supportedUpsertType = "on-duplicate-key-update"; + /** * Gets list of spatial column data types. */ diff --git a/src/driver/postgres/PostgresDriver.ts b/src/driver/postgres/PostgresDriver.ts index b9cd05e050b..af677eeeb8c 100644 --- a/src/driver/postgres/PostgresDriver.ts +++ b/src/driver/postgres/PostgresDriver.ts @@ -173,6 +173,11 @@ export class PostgresDriver implements Driver { "ltree" ]; + /** + * Returns type of upsert supported by driver if any + */ + readonly supportedUpsertType = "on-conflict-do-update"; + /** * Gets list of spatial column data types. */ diff --git a/src/driver/sqlite-abstract/AbstractSqliteDriver.ts b/src/driver/sqlite-abstract/AbstractSqliteDriver.ts index ac0cad41dff..da6da9d74ad 100644 --- a/src/driver/sqlite-abstract/AbstractSqliteDriver.ts +++ b/src/driver/sqlite-abstract/AbstractSqliteDriver.ts @@ -113,6 +113,11 @@ export abstract class AbstractSqliteDriver implements Driver { "datetime" ]; + /** + * Returns type of upsert supported by driver if any + */ + readonly supportedUpsertType = "on-conflict-do-update"; + /** * Gets list of column data types that support length by a driver. */ diff --git a/src/driver/types/UpsertType.ts b/src/driver/types/UpsertType.ts new file mode 100644 index 00000000000..ce59a210f7b --- /dev/null +++ b/src/driver/types/UpsertType.ts @@ -0,0 +1 @@ +export type UpsertType = "on-conflict-do-update" | "on-duplicate-key-update"; \ No newline at end of file diff --git a/src/entity-manager/EntityManager.ts b/src/entity-manager/EntityManager.ts index e30dd1b1995..cba3ca127ae 100644 --- a/src/entity-manager/EntityManager.ts +++ b/src/entity-manager/EntityManager.ts @@ -37,7 +37,8 @@ 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 {TypeORMError} from "../error"; +import {OrmUtils} from "../util/OrmUtils"; /** * Entity manager supposed to work with any entity, automatically find its repository and call its methods, @@ -478,6 +479,69 @@ export class EntityManager { .execute(); } + async upsert( + target: EntityTarget, + conditionsOrConflictPropertyPaths: string[] | QueryDeepPartialEntity, + entityOrEntities: QueryDeepPartialEntity | (QueryDeepPartialEntity[])): Promise { + const metadata = this.connection.getMetadata(target); + + let conditions: QueryDeepPartialEntity; + let conflictPropertyPaths: string[]; + + if (Array.isArray(conditionsOrConflictPropertyPaths)) { + conflictPropertyPaths = conditionsOrConflictPropertyPaths; + conditions = {}; + } else { + conflictPropertyPaths = metadata.columns + .filter(col => typeof col.getEntityValue(conditionsOrConflictPropertyPaths) !== "undefined") + .map(col => col.propertyPath); + conditions = conditionsOrConflictPropertyPaths; + } + + const uniqueColumnConstraints = [ + metadata.primaryColumns, + ...metadata.indices.filter(ix => ix.isUnique).map(ix => ix.columns), + ...metadata.uniques.map(uq => uq.columns) + ]; + + const useIndex = uniqueColumnConstraints.find((ix) => + ix.length === conflictPropertyPaths.length && + conflictPropertyPaths.every((conflictPropertyPath) => ix.some((col) => col.propertyPath === conflictPropertyPath)) + ); + + if (useIndex == null) { + throw new TypeORMError(`An upsert requires conditions that have a unique constraint but none was found for conflict properties: ${conflictPropertyPaths.join(", ")}`); + } + + if (!Array.isArray(entityOrEntities)) { + entityOrEntities = [entityOrEntities]; + } + + entityOrEntities = entityOrEntities.map(entity => OrmUtils.mergeDeep({}, conditions, entity)); + + const conflictColumns = conflictPropertyPaths + ? metadata.mapPropertyPathsToDatabasePaths(conflictPropertyPaths) + : metadata.columns + .filter((col) => col.isPrimary) + .map((col) => col.databaseName); + + const overwriteColumns = metadata.columns + .filter((col) => + !conflictColumns.includes(col.databaseName) && + !col.isCreateDate && + !col.isGenerated && + col.isInsert + ) + .map((col) => col.databaseName); + + return this.createQueryBuilder() + .insert() + .into(target) + .values(entityOrEntities) + .orUpdate(overwriteColumns, conflictColumns) + .execute(); + } + /** * 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. diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index 6e023493b78..9354bde59dd 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -1,4 +1,4 @@ -import {QueryRunner, SelectQueryBuilder} from ".."; +import {EntityColumnNotFound, QueryRunner, SelectQueryBuilder} from ".."; import {ObjectLiteral} from "../common/ObjectLiteral"; import {Connection} from "../connection/Connection"; import {CannotCreateEntityIdMapError} from "../error/CannotCreateEntityIdMapError"; @@ -713,6 +713,19 @@ export class EntityMetadata { return this.allEmbeddeds.find(embedded => embedded.propertyPath === propertyPath); } + /** + * Returns an array of databaseNames mapped from provided propertyPaths + */ + mapPropertyPathsToDatabasePaths(propertyPaths: string[]) { + return propertyPaths.map(propertyPath => { + const column = this.findColumnWithPropertyPath(propertyPath); + if (column == null) { + throw new EntityColumnNotFound(propertyPath); + } + return column.databaseName; + }); + } + /** * Iterates through entity and finds and extracts all values from relations in the entity. * If relation value is an array its being flattened. diff --git a/src/query-builder/InsertQueryBuilder.ts b/src/query-builder/InsertQueryBuilder.ts index 5f51af38720..be3344cc71e 100644 --- a/src/query-builder/InsertQueryBuilder.ts +++ b/src/query-builder/InsertQueryBuilder.ts @@ -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. @@ -352,7 +353,7 @@ export class InsertQueryBuilder extends QueryBuilder { query += ` DEFAULT VALUES`; } } - if (this.connection.driver instanceof PostgresDriver || this.connection.driver instanceof AbstractSqliteDriver || this.connection.driver instanceof CockroachDriver) { + if (this.connection.driver.supportedUpsertType === "on-conflict-do-update") { if (this.expressionMap.onIgnore) { query += " ON CONFLICT DO NOTHING "; } else if (this.expressionMap.onConflict) { @@ -378,9 +379,7 @@ export class InsertQueryBuilder extends QueryBuilder { query += " "; } } - } - - if (this.connection.driver instanceof MysqlDriver || this.connection.driver instanceof AuroraDataApiDriver) { + } else if (this.connection.driver.supportedUpsertType === "on-duplicate-key-update") { if (this.expressionMap.onUpdate) { const { overwrite, columns } = this.expressionMap.onUpdate; @@ -394,6 +393,10 @@ export class InsertQueryBuilder extends QueryBuilder { query += " "; } } + } else { + if (this.expressionMap.onUpdate) { + throw new TypeORMError(`onUpdate is not supported by the current database driver`); + } } // add RETURNING expression diff --git a/src/repository/BaseEntity.ts b/src/repository/BaseEntity.ts index a414533a9ea..e0beccc8ede 100644 --- a/src/repository/BaseEntity.ts +++ b/src/repository/BaseEntity.ts @@ -249,6 +249,17 @@ 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(this: ObjectType & typeof BaseEntity, + conditionsOrConflictPropertyPaths: string[]|QueryDeepPartialEntity, + entityOrEntities: QueryDeepPartialEntity | (QueryDeepPartialEntity[])): Promise { + return this.getRepository().upsert(conditionsOrConflictPropertyPaths, entityOrEntities); + } + /** * Deletes entities by a given criteria. * Unlike remove method executes a primitive operation without cascades, relations and other operations included. diff --git a/src/repository/Repository.ts b/src/repository/Repository.ts index 6050de50de2..e51465e1cfa 100644 --- a/src/repository/Repository.ts +++ b/src/repository/Repository.ts @@ -241,6 +241,17 @@ export class Repository { 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( + conditionsOrConflictPropertyPaths: string[]|QueryDeepPartialEntity, + entityOrEntities: QueryDeepPartialEntity | (QueryDeepPartialEntity[])): Promise { + return this.manager.upsert(this.metadata.target as any, conditionsOrConflictPropertyPaths, entityOrEntities); + } + /** * Deletes entities by a given criteria. * Unlike save method executes a primitive operation without cascades, relations and other operations included. diff --git a/test/functional/entity-model/entity-model.ts b/test/functional/entity-model/entity-model.ts index baf250b4732..9dd00c9b593 100644 --- a/test/functional/entity-model/entity-model.ts +++ b/test/functional/entity-model/entity-model.ts @@ -32,6 +32,26 @@ describe("entity-model", () => { } }); + describe("upsert", function () { + it("should upsert successfully", async () => { + // These must run sequentially as we have the global context of the `Post` ActiveRecord class + for (const connection of connections.filter((c) => c.driver.supportedUpsertType != null)) { + Post.useConnection(connection); // change connection each time because of AR specifics + + const externalId = "external-entity"; + + await Post.upsert({ externalId }, { title: "External post" }); + const upsertInsertedExternalPost = await Post.findOneOrFail({ externalId }); + + await Post.upsert({ externalId }, { title: "External post 2" }); + const upsertUpdatedExternalPost = await Post.findOneOrFail({ externalId }); + + upsertInsertedExternalPost.id.should.be.equal(upsertUpdatedExternalPost.id); + upsertInsertedExternalPost.title.should.not.be.equal(upsertUpdatedExternalPost.title); + } + }); + }); + it("should reload given entity successfully", async () => { // These must run sequentially as we have the global context of the `Post` ActiveRecord class for (const connection of connections) { @@ -73,5 +93,4 @@ describe("entity-model", () => { }); } }); - }); diff --git a/test/functional/entity-model/entity/Post.ts b/test/functional/entity-model/entity/Post.ts index fc77fe36fcd..e8f2099944e 100644 --- a/test/functional/entity-model/entity/Post.ts +++ b/test/functional/entity-model/entity/Post.ts @@ -11,6 +11,12 @@ export class Post extends BaseEntity { @PrimaryGeneratedColumn() id: number; + @Column({ + nullable: true, + unique: true + }) + externalId?: string; + @Column() title: string; diff --git a/test/functional/repository/basic-methods/entity/EmbeddedUniqueConstraintEntity.ts b/test/functional/repository/basic-methods/entity/EmbeddedUniqueConstraintEntity.ts new file mode 100644 index 00000000000..6bf423b16c0 --- /dev/null +++ b/test/functional/repository/basic-methods/entity/EmbeddedUniqueConstraintEntity.ts @@ -0,0 +1,19 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "../../../../../src"; + + +@Entity() +export class EmbeddedUniqueConstraintEntity { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column(() => EmbeddedEntityWithUniqueColumn) + embedded: EmbeddedEntityWithUniqueColumn; +} + +export class EmbeddedEntityWithUniqueColumn { + @Column({ nullable: true, unique: true }) + id: string; + + @Column({ nullable: true }) + value: string; +} diff --git a/test/functional/repository/basic-methods/entity/ExternalIdPrimaryKeyEntity.ts b/test/functional/repository/basic-methods/entity/ExternalIdPrimaryKeyEntity.ts new file mode 100644 index 00000000000..c6cc96aafc0 --- /dev/null +++ b/test/functional/repository/basic-methods/entity/ExternalIdPrimaryKeyEntity.ts @@ -0,0 +1,21 @@ +import { Column, Entity, PrimaryColumn } from "../../../../../src"; + +@Entity() +export class ExternalIdPrimaryKeyEntity { + @PrimaryColumn() + externalId: string; + + @Column() + title: string; + + @Column(() => EmbeddedEntity) + embedded: EmbeddedEntity; +} + +export class EmbeddedEntity { + @Column({ nullable: true }) + foo: string; + + @Column({ nullable: true }) + bar: string; +} \ No newline at end of file diff --git a/test/functional/repository/basic-methods/entity/Post.ts b/test/functional/repository/basic-methods/entity/Post.ts index 63d10c8180d..03b5ce887aa 100644 --- a/test/functional/repository/basic-methods/entity/Post.ts +++ b/test/functional/repository/basic-methods/entity/Post.ts @@ -1,6 +1,7 @@ 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 { @@ -8,15 +9,31 @@ 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; } \ No newline at end of file diff --git a/test/functional/repository/basic-methods/repository-basic-methods.ts b/test/functional/repository/basic-methods/repository-basic-methods.ts index a37d09358aa..fc277e781b4 100644 --- a/test/functional/repository/basic-methods/repository-basic-methods.ts +++ b/test/functional/repository/basic-methods/repository-basic-methods.ts @@ -1,5 +1,5 @@ import "reflect-metadata"; -import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../utils/test-utils"; +import {asyncMapper, 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"; @@ -9,7 +9,9 @@ import {Question} from "./model/Question"; import {Blog} from "./entity/Blog"; import {Category} from "./entity/Category"; import {DeepPartial} from "../../../../src/common/DeepPartial"; -import {EntitySchema, Repository} from "../../../../src"; +import {EntitySchema, Repository, TypeORMError,Like} from "../../../../src"; +import { ExternalIdPrimaryKeyEntity } from "./entity/ExternalIdPrimaryKeyEntity"; +import { EmbeddedUniqueConstraintEntity } from "./entity/EmbeddedUniqueConstraintEntity"; describe("repository > basic methods", () => { @@ -26,7 +28,7 @@ describe("repository > basic methods", () => { let connections: Connection[]; before(async () => connections = await createTestingConnections({ - entities: [Post, Blog, Category, UserEntity, QuestionEntity], + entities: [Post, Blog, Category, UserEntity, QuestionEntity, ExternalIdPrimaryKeyEntity, EmbeddedUniqueConstraintEntity], })); beforeEach(() => reloadTestingDatabases(connections)); after(() => closeTestingConnections(connections)); @@ -341,8 +343,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); @@ -351,11 +353,144 @@ 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 () { + function upsertableConnections(able: boolean) { + return function () { + return connections.filter( + (c) => (c.driver.supportedUpsertType != null) === able + ); + }; + } + it("should first create then update an entity", asyncMapper(upsertableConnections(true), async (connection) => { + const externalIdObjects = connection.getRepository(ExternalIdPrimaryKeyEntity); + const postRepository = connection.getRepository(Post); + const externalId = "external-1"; + + // create a new post and insert it + await postRepository.upsert({ externalId }, { title: "Post title initial" }); + + 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 + await postRepository.upsert({ externalId }, { title: "Post title updated" }); + 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); + + // upserts on primary key without specifying conflict columns should upsert + await externalIdObjects.upsert({ externalId }, { title: "foo" }); + (await externalIdObjects.findOneOrFail(externalId))!.title.should.be.equal("foo"); + + await externalIdObjects.upsert({ externalId }, { title: "bar" }); + (await externalIdObjects.findOneOrFail(externalId))!.title.should.be.equal("bar"); + })); + it("should bulk upsert", asyncMapper(upsertableConnections(true), async (connection) => { + const externalIdObjects = connection.getRepository(ExternalIdPrimaryKeyEntity); + + const entitiesToInsert = Array.from({ length: 5 }, (v, i) => ({ + externalId: `external-bulk-${i + 1}`, + title: "Initially inserted", + })); + + await externalIdObjects.upsert(["externalId"], entitiesToInsert); + + (await externalIdObjects.find({ externalId: Like("external-bulk-%") })).forEach((inserted, i) => { + inserted.title.should.be.equal("Initially inserted"); + }); + + const entitiesToUpdate = Array.from({ length: 5 }, (v, i) => ({ + externalId: `external-bulk-${i + 1}`, + title: "Updated", + })); + + await externalIdObjects.upsert(["externalId"], entitiesToUpdate); + + (await externalIdObjects.find({ externalId: Like("external-bulk-%") })).forEach((updated, i) => { + updated.title.should.be.equal("Updated"); + }); + })); + it("should upsert with embedded columns", asyncMapper(upsertableConnections(true), async (connection) => { + const externalIdObjects = connection.getRepository(ExternalIdPrimaryKeyEntity); + const embeddedConstraintObjects = connection.getRepository(EmbeddedUniqueConstraintEntity); + const externalId = "external-embedded"; + + // update properties of embedded + await externalIdObjects.upsert({ externalId }, { title: "embedded", embedded: { foo: "foo 1" } }); + + (await externalIdObjects.findOneOrFail({ externalId })).embedded.foo.should.be.equal("foo 1"); + + await externalIdObjects.upsert({ externalId }, { title: "embedded", embedded: { foo: "foo 2" } }); + + (await externalIdObjects.findOneOrFail({ externalId })).embedded.foo.should.be.equal("foo 2"); + + // upsert on embedded + await embeddedConstraintObjects.upsert({ embedded: { id: "bar1" } }, { embedded: { value: "foo 1" } }); + (await embeddedConstraintObjects.findOneOrFail({ embedded: { id: "bar1" } })).embedded.value.should.be.equal("foo 1"); + await embeddedConstraintObjects.upsert({ embedded: { id: "bar1" } }, { embedded: { value: "foo 2" } }); + (await embeddedConstraintObjects.findOneOrFail({ embedded: { id: "bar1" } })).embedded.value.should.be.equal("foo 2"); + })); + it("should bulk upsert with embedded columns", asyncMapper(upsertableConnections(true), async (connection) => { + const embeddedConstraintObjects = connection.getRepository(EmbeddedUniqueConstraintEntity); + + await embeddedConstraintObjects.upsert(["embedded.id"], [{ + embedded: { id: "bar2", value: "value2" }, + }, + { + embedded: { id: "bar3", value: "value3" }, + }]); + (await embeddedConstraintObjects.findOneOrFail({ embedded: { id: "bar2" } })).embedded.value.should.be.equal("value2"); + (await embeddedConstraintObjects.findOneOrFail({ embedded: { id: "bar3" } })).embedded.value.should.be.equal("value3"); + + await embeddedConstraintObjects.upsert(["embedded.id"], [{ + embedded: { id: "bar2", value: "value2 2" }, + }, + { + embedded: { id: "bar3", value: "value3 2" }, + }]); + (await embeddedConstraintObjects.findOneOrFail({ embedded: { id: "bar2" } })).embedded.value.should.be.equal("value2 2"); + (await embeddedConstraintObjects.findOneOrFail({ embedded: { id: "bar3" } })).embedded.value.should.be.equal("value3 2"); + })); + it("should throw if attempting to conflict on properties with no unique constraint", asyncMapper(upsertableConnections(true), async (connection) => { + const externalIdObjects = connection.getRepository(ExternalIdPrimaryKeyEntity); + // cannot conflict on a column with no unique index + await externalIdObjects.upsert({ title: "foo" }, {}) + .should.be.rejectedWith(TypeORMError); + })); + it("should throw if using an unsupported driver", asyncMapper(upsertableConnections(false), async (connection) => { + const postRepository = connection.getRepository(Post); + const externalId = "external-2"; + await postRepository.upsert({ externalId }, { title: "Post title initial" }) + .should.be.rejectedWith(TypeORMError); + })); + }); + describe("preload also should also implement merge functionality", function() { it("if we preload entity from the plain object and merge preloaded object with plain object we'll have an object from the db with the replaced properties by a plain object's properties", () => Promise.all(connections.map(async connection => { diff --git a/test/utils/test-utils.ts b/test/utils/test-utils.ts index 58f2f8b8341..481a2ebd654 100644 --- a/test/utils/test-utils.ts +++ b/test/utils/test-utils.ts @@ -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); @@ -338,3 +339,23 @@ export function sleep(ms: number): Promise { setTimeout(ok, ms); }); } + +export function asyncMapper( + list: TIn[] | (() => TIn[]), + mapper: (value: TIn) => Promise +): () => Promise { + return async function () { + if (typeof list === "function") { + list = list(); + } + if (process.env.PARALLEL_MAP === "0") { + const out: TOut[] = []; + for (const value of list) { + out.push(await mapper(value)); + } + return out; + } else { + return Promise.all(list.map((value) => mapper(value))); + } + }; +} \ No newline at end of file