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 23, 2021
1 parent e9366b3 commit 5f37460
Show file tree
Hide file tree
Showing 25 changed files with 641 additions and 18 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";
103 changes: 102 additions & 1 deletion src/entity-manager/EntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ 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";
import { UpsertOptions } from "../repository/UpsertOptions";

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

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

let conditions: QueryDeepPartialEntity<Entity>|null;
let conflictPropertyPaths: string[];

if (Array.isArray(conditionsOrConflictPropertyPaths)) {
conflictPropertyPaths = conditionsOrConflictPropertyPaths;
conditions = null;
} 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(", ")}`);
}

let entities: QueryDeepPartialEntity<Entity>[];

if (!Array.isArray(entityOrEntities)) {
entities = [entityOrEntities];
} else {
entities = entityOrEntities;
}

const conflictColumns = metadata.mapPropertyPathsToColumns(conflictPropertyPaths);

const overwriteColumns = metadata.columns
.filter((col) => (conditions != null || !conflictColumns.includes(col)) && entities.some(entity => typeof col.getEntityValue(entity) !== "undefined"));

const insertOnlyColumns = metadata.columns
.filter(col => typeof col.getEntityValue(insertOnly) !== "undefined");

if (!OrmUtils.areMutuallyExclusive(conflictColumns, overwriteColumns, insertOnlyColumns)) {
throw new TypeORMError(`Columns should be specified as a condition, an update, or an insertOnly exclusively`);
}

entities = entities.map(entity => OrmUtils.mergeDeep({}, conditions, entity, insertOnly));

/**
* cannot perform upsert with an insertOnly if the values only inserted could possibly conflict on multiple unique indices
*
* given a table:
* test(external_id UNIQUE, email UNIQUE, name)
*
* with values:
* ('abc123', 'jdoe@foo.com', 'jane doe')
* ('abc234', 'jsmith@foo.com', 'john smith')
*
* this query is unsafe as the results vary depending on column order: (https://mariadb.com/kb/en/insert-on-duplicate-key-update/)
*
* INSERT INTO test(external_id, email, name) VALUES('abc123', 'jsmith@foo.com', 'Jane Smith') ON DUPLICATE KEY UPDATE name = VALUES(name);
*
* either the id=abc123 row or the email=jsmith@foo.com row will be updated, depending on row order
*
* it is safe if the values are being overwritten, as that will result in the database engine throwing a duplicate entry error if there is a conflict
*
* INSERT INTO test(external_id, email, name) VALUES('abc123', 'jsmith@foo.com', 'Jane Smith') ON DUPLICATE KEY UPDATE external_id = VALUES(external_id), email = VALUES(email), name = VALUES(name);
*/

if (!allowUnsafeInsertOnly && this.connection.driver.supportedUpsertType === "on-duplicate-key-update") {
const overwritePossibleConflict = insertOnlyColumns.some(col => uniqueColumnConstraints.some(uq => uq.includes(col)));
if (overwritePossibleConflict) {
throw new TypeORMError(`You are attempting to insert-only a value with a unique constraint, this database type cannot guarantee that the row you may update is the row that you specify with your conflict properties.`);
}
}

return this.createQueryBuilder()
.insert()
.into(target)
.values(entities)
.orUpdate(
[...conflictColumns, ...overwriteColumns].map((col) => col.databaseName),
conflictColumns.map((col) => col.databaseName)
)
.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
*/
mapPropertyPathsToColumns(propertyPaths: string[]) {
return propertyPaths.map(propertyPath => {
const column = this.findColumnWithPropertyPath(propertyPath);
if (column == null) {
throw new EntityColumnNotFound(propertyPath);
}
return column;
});
}

/**
* 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
13 changes: 13 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,18 @@ 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>[]),
options?: UpsertOptions<T>): Promise<InsertResult> {
return this.getRepository<T>().upsert(conditionsOrConflictPropertyPaths, entityOrEntities, options);
}

/**
* Deletes entities by a given criteria.
* Unlike remove method executes a primitive operation without cascades, relations and other operations included.
Expand Down
13 changes: 13 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,18 @@ 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>[]),
options?: UpsertOptions<Entity>): Promise<InsertResult> {
return this.manager.upsert(this.metadata.target as any, conditionsOrConflictPropertyPaths, entityOrEntities, options);
}

/**
* Deletes entities by a given criteria.
* Unlike save method executes a primitive operation without cascades, relations and other operations included.
Expand Down
17 changes: 17 additions & 0 deletions src/repository/UpsertOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { QueryDeepPartialEntity } from "../query-builder/QueryPartialEntity";

/**
* Special options passed to Repository#upsert
*/
export interface UpsertOptions<Entity> {

/**
* Allow potentially unsafe insert only
*/
allowUnsafeInsertOnly?: boolean;

/**
* Some values that will be written on insert but not on update
*/
insertOnly?: QueryDeepPartialEntity<Entity>
}

0 comments on commit 5f37460

Please sign in to comment.