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 20, 2021
1 parent e9366b3 commit 9a20482
Show file tree
Hide file tree
Showing 21 changed files with 434 additions and 16 deletions.
23 changes: 23 additions & 0 deletions docs/entity-manager-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/repository-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/driver/Driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/driver/aurora-data-api/AuroraDataApiDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/driver/cockroachdb/CockroachDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/driver/mysql/MysqlDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/driver/postgres/PostgresDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/driver/sqlite-abstract/AbstractSqliteDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions src/driver/types/UpsertType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type UpsertType = "on-conflict-do-update" | "on-duplicate-key-update";
66 changes: 65 additions & 1 deletion src/entity-manager/EntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -478,6 +479,69 @@ export class EntityManager {
.execute();
}

async upsert<Entity>(
target: EntityTarget<Entity>,
conditionsOrConflictPropertyPaths: string[] | QueryDeepPartialEntity<Entity>,
entityOrEntities: QueryDeepPartialEntity<Entity> | (QueryDeepPartialEntity<Entity>[])): Promise<InsertResult> {
const metadata = this.connection.getMetadata(target);

let conditions: QueryDeepPartialEntity<Entity>;
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.
Expand Down
15 changes: 14 additions & 1 deletion src/metadata/EntityMetadata.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 7 additions & 4 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 @@ -352,7 +353,7 @@ export class InsertQueryBuilder<Entity> extends QueryBuilder<Entity> {
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) {
Expand All @@ -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.supportedUpsertType === "on-duplicate-key-update") {
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
11 changes: 11 additions & 0 deletions src/repository/BaseEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends BaseEntity>(this: ObjectType<T> & typeof BaseEntity,
conditionsOrConflictPropertyPaths: string[]|QueryDeepPartialEntity<T>,
entityOrEntities: QueryDeepPartialEntity<T> | (QueryDeepPartialEntity<T>[])): Promise<InsertResult> {
return this.getRepository<T>().upsert(conditionsOrConflictPropertyPaths, entityOrEntities);
}

/**
* Deletes entities by a given criteria.
* Unlike remove method executes a primitive operation without cascades, relations and other operations included.
Expand Down
11 changes: 11 additions & 0 deletions src/repository/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,17 @@ 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(
conditionsOrConflictPropertyPaths: string[]|QueryDeepPartialEntity<Entity>,
entityOrEntities: QueryDeepPartialEntity<Entity> | (QueryDeepPartialEntity<Entity>[])): Promise<InsertResult> {
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.
Expand Down
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 @@ -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) {
Expand Down Expand Up @@ -73,5 +93,4 @@ describe("entity-model", () => {
});
}
});

});
6 changes: 6 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,12 @@ export class Post extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;

@Column({
nullable: true,
unique: true
})
externalId?: string;

@Column()
title: string;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit 9a20482

Please sign in to comment.