Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add upsert methods for the drivers that support onUpdate #8104

Merged
merged 7 commits into from
Nov 9, 2021
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. Supported by AuroraDataApi, Cockroach, Mysql, Postgres, and Sqlite database drivers.

```typescript
await manager.upsert(User, { externalId: "abc123" }, { firstName: "Rizzrak" });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH I find this signature confusing. I prefer to have only second signature:

await manager.update(User, ["externalId"], [
    { externalId:"abc123", firstName: "Rizzrak" },
    { externalId:"bca321", firstName: "Karzzir" },
    ]);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I would swap parameters order, it makes more logically sense to have:

await manager.update(
  User, 
  [
      { externalId:"abc123", firstName: "Rizzrak" },
      { externalId:"bca321", firstName: "Karzzir" },
  ], 
  ["externalId"]
);

Copy link
Contributor Author

@joeflateau joeflateau Nov 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put a lot of thought into the signature and think it is best to match the style used by https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndUpdate/. In addition, since the definition is essentially upsert(Entity, ${things to match/conflict on}, ${things to update}) I felt that parameter order made more sense for the second signature. I feel strongly about keeping the first signature, but a little less strongly about the order of the second signature.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

${things to match/conflict on} - but when there is no conflict, this value will be used for insertion, right? So it's actually ${things to match/conflict on/insert if no conflict merged with next parameter values} - and this is what it makes this signature complicated in understanding.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is where i'm hung up:

await postRepository.upsert({ externalId }, { title: "Post title initial" });

vs

await postRepository.upsert({ externalId, title: "Post title initial" }, ["externalId"]);

I feel that the first signature flows significantly better than the second. The second feels unnecessarily redundant. MongoRepository#updateOne has the same basic signature and I don't believe it's complicated to understand: await mongoRepository.updateOne({ externalId }, { title: "Post title initial" }, { upsert: true });

Perhaps there's a middle ground here? Maybe rather than 1 upsert method with 2 signatures I should introduce 2 upsert methods? Repository#upsertOne(conditions, partial), a signature similar to MongoRepository#updateOne(conditions, partial, { upsert: true }) and also Repository#upsertMany(conflictPaths: string[], partialEntities: DeepPartial<Entity>[])

I really don't want to lose the MongoDb style signature.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking for both simplicity and obviousness. Adding new method won't help. Following MongoDB convention doesn't have to be there, maybe some people can appreciate it, but most prefer simplicity and obviousness. And I don't find ${things to match/conflict on/insert if no conflict merged with next parameter values} parameter obvious. Maybe because I'm not MongoDB user. I would prefer to type extra code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I don't like it, but you're in charge here. I'll update the PR with your preferred style.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry about that =( If you want we can wait for other people who are interested in this feature to provide their feedback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no worries, I'm going to make the change. we can always reintroduce the second signature if feedback warrants it. thanks for everything you do for typeorm.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you 🤞

/** executes
* INSERT INTO user
* VALUES (externalId = abc123, firstName = Rizzrak)
* ON CONFLICT (externalId) DO UPDATE firstName = EXCLUDED.firstName
**/

await manager.update(User, ["externalId"], [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo, update must be upsert

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all set here

{ 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to specify list of supported drivers here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

incoming


```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>
}
Loading