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
16 changes: 16 additions & 0 deletions docs/entity-manager-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ 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" },
{ externalId:"bca321", firstName: "Karzzir" },
], ["externalId"]);
/** 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
16 changes: 16 additions & 0 deletions docs/repository-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,22 @@ 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.update([
{ externalId:"abc123", firstName: "Rizzrak" },
{ externalId:"bca321", firstName: "Karzzir" },
], ["externalId"]);
/** 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";
59 changes: 58 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 {UpsertOptions} from "../repository/UpsertOptions";
import {PropertyPath} from "../util/PropertyPath";

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

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

let options: UpsertOptions<Entity>;

if (Array.isArray(conflictPathsOrOptions)) {
options = {
conflictPaths: conflictPathsOrOptions
};
} else {
options = conflictPathsOrOptions;
}

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 === options.conflictPaths.length &&
options.conflictPaths.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: ${options.conflictPaths.join(", ")}`);
}

let entities: QueryDeepPartialEntity<Entity>[];

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

const conflictColumns = metadata.mapPropertyPathsToColumns(options.conflictPaths);

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

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,8 @@ 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";
import {PropertyPath} from "../util/PropertyPath";

/**
* Base abstract entity for all entities, used in ActiveRecord patterns.
Expand Down Expand Up @@ -249,6 +251,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,
entityOrEntities: QueryDeepPartialEntity<T> | (QueryDeepPartialEntity<T>[]),
conflictPathsOrOptions: PropertyPath<T>[] | UpsertOptions<T>): Promise<InsertResult> {
return this.getRepository<T>().upsert(entityOrEntities, conflictPathsOrOptions);
}

/**
* 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,8 @@ 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";
import {PropertyPath} from "../util/PropertyPath";

/**
* Repository is supposed to work with your entity objects. Find entities, insert, update, delete, etc.
Expand Down Expand Up @@ -241,6 +243,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(
entityOrEntities: QueryDeepPartialEntity<Entity> | (QueryDeepPartialEntity<Entity>[]),
conflictPathsOrOptions: PropertyPath<Entity>[] | UpsertOptions<Entity>): Promise<InsertResult> {
return this.manager.upsert(this.metadata.target as any, entityOrEntities, conflictPathsOrOptions);
}

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

/**
* Special options passed to Repository#upsert
*/
export interface UpsertOptions<Entity> {
conflictPaths: PropertyPath<Entity>[]
}
8 changes: 8 additions & 0 deletions src/util/OrmUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,14 @@ export class OrmUtils {
});
}

static areMutuallyExclusive<T>(...lists: T[][]): boolean {
const haveSharedObjects = lists.some(list => {
const otherLists = lists.filter(otherList => otherList !== list);
return list.some(item => otherLists.some(otherList => otherList.includes(item)));
});
return !haveSharedObjects;
}

// -------------------------------------------------------------------------
// Private methods
// -------------------------------------------------------------------------
Expand Down
35 changes: 35 additions & 0 deletions src/util/PropertyPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export type MaxPropertyPathRecursionDepth = 5;
Copy link
Member

Choose a reason for hiding this comment

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

This is going to be a breaking change for users since we support TypeScript versions far behind 4 where this syntax is supported. We have to use string for now, but in the future we have a strategy to use this syntax everywhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh, didn't realize pre-4 was still supported :(

Copy link
Member

Choose a reason for hiding this comment

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

yeah 0.2.x is quite old and we are trying to avoid breaking changes. I hope when we finally release 0.3.0 it will use the latest TypeScript version.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

let me know if I can be of assistance with 0.3.0 👍


export type PropertyPath<T> = PropertyPathInner<T, keyof T, MaxPropertyPathRecursionDepth, []>;

type PropertyPathInner<
T,
Key,
MaxDepth extends number,
AccumulatedDepth extends unknown[]
> = Key extends keyof T & string ?
Exclude<T[Key], null | undefined> extends Function ? never : Exclude<T[Key], null | undefined> extends string | symbol | number | Date ? Key : Key |
(AccumulatedDepth["length"] extends MaxDepth ? never : `${Key}.${PropertyPathInner<
Exclude<T[Key], null | undefined>,
keyof (Exclude<T[Key], null | undefined>),
MaxDepth,
[unknown, ...AccumulatedDepth]
>}`) : never;

export type MaxRelationPathRecursionDepth = 5;

export type RelationPath<T> = RelationPathInner<T, keyof T, MaxRelationPathRecursionDepth, []>;

type RelationPathInner<
T,
Key,
MaxDepth extends number,
AccumulatedDepth extends unknown[]
> = Key extends keyof T & string ?
Exclude<T[Key], null | undefined> extends Function ? never : Exclude<T[Key], null | undefined> extends string | symbol | number | Date ? Key : Key |
(AccumulatedDepth["length"] extends MaxDepth ? never : `${Key}.${RelationPathInner<
Exclude<T[Key], null | undefined> extends (infer R)[] ? R : T[Key],
keyof (Exclude<T[Key], null | undefined> extends (infer R)[] ? R : T[Key]),
MaxDepth,
[unknown, ...AccumulatedDepth]
>}`) : never;
Loading