From 3227c0b7b9f79868f755eb40e72287bdb60d71b6 Mon Sep 17 00:00:00 2001 From: Winston Lee <904317764@qq.com> Date: Tue, 25 Feb 2020 21:42:37 +0800 Subject: [PATCH] feat: soft delete (#5034) * added @DeleteDateColumn * updated test for embedded-with-special-columns * added the softDelete and restore methods to QueryBuilder * added test for query builder > soft-delete * added the softDelete and restore methods to repository * added test for repository > soft-delete and restore * added the softRemove and recover methods to repository * added test for repository > soft-remove and recover * fixed the title of the test for repository > soft-delete * added the support of the cascades soft-remove and recover * added test: support of the cascades soft-remove and recover * fixed test for should perform restory with limit correctly: missing applying the limit method * fixed the wrong comment for recover operation * added the global condition of non-deleted to query-builder for the entity with delete date columns * added the global condition of non-deleted to repository for the entity with delete date columns * updated test for the global condition of non-deleted * added the test for soft-delete and restore properties inside embeds as well * added test to query-builder for the global condition of non-deleted * updated test to repository for the global condition of non-deleted * added test to repository for the global condition of non-deleted * fixed comment for the test 'find with the global condition of non-deleted and eager relation' * fixed can't add the corrent global condition as the missing of aliasNamePrefix * fixed can't get the correct result as the missing of the ordering by id * fixed should use propertyName instead of databaseName * added deleteDate and deleteDateNullable for sap --- src/decorator/columns/DeleteDateColumn.ts | 17 + src/decorator/options/RelationOptions.ts | 4 +- src/decorator/tree/TreeChildren.ts | 4 +- .../aurora-data-api/AuroraDataApiDriver.ts | 3 + src/driver/cockroachdb/CockroachDriver.ts | 2 + src/driver/mongodb/MongoDriver.ts | 2 + src/driver/mysql/MysqlDriver.ts | 3 + src/driver/oracle/OracleDriver.ts | 2 + src/driver/postgres/PostgresDriver.ts | 2 + src/driver/sap/SapDriver.ts | 2 + .../sqlite-abstract/AbstractSqliteDriver.ts | 2 + src/driver/sqlserver/SqlServerDriver.ts | 2 + src/driver/types/MappedColumnTypes.ts | 15 + src/entity-manager/EntityManager.ts | 182 +++++++ .../EntitySchemaColumnOptions.ts | 5 + .../EntitySchemaRelationOptions.ts | 2 +- src/entity-schema/EntitySchemaTransformer.ts | 2 + src/error/MissingDeleteDateColumnError.ts | 14 + src/find-options/FindOneOptions.ts | 5 + src/find-options/FindOptionsUtils.ts | 7 +- src/index.ts | 1 + src/metadata-args/types/ColumnMode.ts | 2 +- src/metadata-builder/EntityMetadataBuilder.ts | 1 + src/metadata/ColumnMetadata.ts | 14 + src/metadata/EntityMetadata.ts | 5 + src/metadata/RelationMetadata.ts | 17 + src/persistence/EntityPersistExecutor.ts | 10 +- src/persistence/Subject.ts | 40 ++ .../SubjectDatabaseEntityLoader.ts | 10 +- src/persistence/SubjectExecutor.ts | 204 +++++++- .../subject-builder/CascadesSubjectBuilder.ts | 22 +- src/query-builder/QueryBuilder.ts | 37 +- src/query-builder/QueryExpressionMap.ts | 9 +- .../ReturningResultsEntityUpdator.ts | 1 + src/query-builder/SelectQueryBuilder.ts | 8 + src/query-builder/SoftDeleteQueryBuilder.ts | 459 ++++++++++++++++++ src/repository/Repository.ts | 74 +++ .../embedded-with-special-columns.ts | 5 +- .../entity/Counters.ts | 4 + .../cascades-soft-remove.ts | 54 +++ .../cascades-soft-remove/entity/Photo.ts | 27 ++ .../cascades-soft-remove/entity/User.ts | 29 ++ .../entity/PostWithDeleteDateColumn.ts | 35 ++ .../persistence-options-listeners.ts | 21 + .../soft-delete/entity/Counters.ts | 16 + .../query-builder/soft-delete/entity/Photo.ts | 18 + .../query-builder/soft-delete/entity/User.ts | 21 + .../entity/UserWithoutDeleteDateColumn.ts | 17 + .../entity/CategoryWithRelation.ts | 18 + .../entity/Post.ts | 18 + .../entity/PostWithRelation.ts | 24 + ...ndition-non-deleted-with-eager-relation.ts | 92 ++++ ...ry-builder-global-condition-non-deleted.ts | 89 ++++ .../soft-delete/query-builder-soft-delete.ts | 238 +++++++++ .../repository/soft-delete/entity/Post.ts | 16 + .../entity/PostWithoutDeleteDateColumn.ts | 12 + .../entity/CategoryWithRelation.ts | 18 + .../entity/Post.ts | 18 + .../entity/PostWithRelation.ts | 25 + ...ndition-non-deleted-with-eager-relation.ts | 91 ++++ ...repository-global-condition-non-deleted.ts | 90 ++++ .../soft-delete/repository-soft-delete.ts | 70 +++ .../soft-delete/repository-soft-remove.ts | 103 ++++ 63 files changed, 2332 insertions(+), 28 deletions(-) create mode 100644 src/decorator/columns/DeleteDateColumn.ts create mode 100644 src/error/MissingDeleteDateColumnError.ts create mode 100644 src/query-builder/SoftDeleteQueryBuilder.ts create mode 100644 test/functional/persistence/cascades/cascades-soft-remove/cascades-soft-remove.ts create mode 100644 test/functional/persistence/cascades/cascades-soft-remove/entity/Photo.ts create mode 100644 test/functional/persistence/cascades/cascades-soft-remove/entity/User.ts create mode 100644 test/functional/persistence/persistence-options/listeners/entity/PostWithDeleteDateColumn.ts create mode 100644 test/functional/query-builder/soft-delete/entity/Counters.ts create mode 100644 test/functional/query-builder/soft-delete/entity/Photo.ts create mode 100644 test/functional/query-builder/soft-delete/entity/User.ts create mode 100644 test/functional/query-builder/soft-delete/entity/UserWithoutDeleteDateColumn.ts create mode 100644 test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/CategoryWithRelation.ts create mode 100644 test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/Post.ts create mode 100644 test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/PostWithRelation.ts create mode 100644 test/functional/query-builder/soft-delete/global-condition-non-deleted/query-builder-global-condition-non-deleted-with-eager-relation.ts create mode 100644 test/functional/query-builder/soft-delete/global-condition-non-deleted/query-builder-global-condition-non-deleted.ts create mode 100644 test/functional/query-builder/soft-delete/query-builder-soft-delete.ts create mode 100644 test/functional/repository/soft-delete/entity/Post.ts create mode 100644 test/functional/repository/soft-delete/entity/PostWithoutDeleteDateColumn.ts create mode 100644 test/functional/repository/soft-delete/global-condition-non-deleted/entity/CategoryWithRelation.ts create mode 100644 test/functional/repository/soft-delete/global-condition-non-deleted/entity/Post.ts create mode 100644 test/functional/repository/soft-delete/global-condition-non-deleted/entity/PostWithRelation.ts create mode 100644 test/functional/repository/soft-delete/global-condition-non-deleted/repository-global-condition-non-deleted-with-eager-relation.ts create mode 100644 test/functional/repository/soft-delete/global-condition-non-deleted/repository-global-condition-non-deleted.ts create mode 100644 test/functional/repository/soft-delete/repository-soft-delete.ts create mode 100644 test/functional/repository/soft-delete/repository-soft-remove.ts diff --git a/src/decorator/columns/DeleteDateColumn.ts b/src/decorator/columns/DeleteDateColumn.ts new file mode 100644 index 0000000000..35ea43b848 --- /dev/null +++ b/src/decorator/columns/DeleteDateColumn.ts @@ -0,0 +1,17 @@ +import { ColumnOptions, getMetadataArgsStorage } from "../../"; +import { ColumnMetadataArgs } from "../../metadata-args/ColumnMetadataArgs"; + +/** + * This column will store a delete date of the soft-deleted object. + * This date is being updated each time you soft-delete the object. + */ +export function DeleteDateColumn(options?: ColumnOptions): Function { + return function(object: Object, propertyName: string) { + getMetadataArgsStorage().columns.push({ + target: object.constructor, + propertyName: propertyName, + mode: "deleteDate", + options: options || {} + } as ColumnMetadataArgs); + }; +} diff --git a/src/decorator/options/RelationOptions.ts b/src/decorator/options/RelationOptions.ts index a0368f018e..b8ab7d575c 100644 --- a/src/decorator/options/RelationOptions.ts +++ b/src/decorator/options/RelationOptions.ts @@ -12,9 +12,9 @@ export interface RelationOptions { * If set to true then it means that related object can be allowed to be inserted or updated in the database. * You can separately restrict cascades to insertion or updation using following syntax: * - * cascade: ["insert", "update"] // include or exclude one of them + * cascade: ["insert", "update", "remove", "soft-remove", "recover"] // include or exclude one of them */ - cascade?: boolean|("insert"|"update"|"remove")[]; + cascade?: boolean|("insert"|"update"|"remove"|"soft-remove"|"recover")[]; /** * Indicates if relation column value can be nullable or not. diff --git a/src/decorator/tree/TreeChildren.ts b/src/decorator/tree/TreeChildren.ts index 7b12b282b2..70de9e5995 100644 --- a/src/decorator/tree/TreeChildren.ts +++ b/src/decorator/tree/TreeChildren.ts @@ -5,7 +5,7 @@ import {RelationMetadataArgs} from "../../metadata-args/RelationMetadataArgs"; * Marks a entity property as a children of the tree. * "Tree children" will contain all children (bind) of this entity. */ -export function TreeChildren(options?: { cascade?: boolean|("insert"|"update"|"remove")[] }): Function { +export function TreeChildren(options?: { cascade?: boolean|("insert"|"update"|"remove"|"soft-remove"|"recover")[] }): Function { return function (object: Object, propertyName: string) { if (!options) options = {} as RelationOptions; @@ -13,7 +13,7 @@ export function TreeChildren(options?: { cascade?: boolean|("insert"|"update"|"r const reflectedType = Reflect && (Reflect as any).getMetadata ? Reflect.getMetadata("design:type", object, propertyName) : undefined; const isLazy = (reflectedType && typeof reflectedType.name === "string" && reflectedType.name.toLowerCase() === "promise") || false; - // add one-to-many relation for this + // add one-to-many relation for this getMetadataArgsStorage().relations.push({ isTreeChildren: true, target: object.constructor, diff --git a/src/driver/aurora-data-api/AuroraDataApiDriver.ts b/src/driver/aurora-data-api/AuroraDataApiDriver.ts index 5015a2b9cb..e0c3509610 100644 --- a/src/driver/aurora-data-api/AuroraDataApiDriver.ts +++ b/src/driver/aurora-data-api/AuroraDataApiDriver.ts @@ -231,6 +231,9 @@ export class AuroraDataApiDriver implements Driver { updateDate: "datetime", updateDatePrecision: 6, updateDateDefault: "CURRENT_TIMESTAMP(6)", + deleteDate: "datetime", + deleteDatePrecision: 6, + deleteDateNullable: true, version: "int", treeLevel: "int", migrationId: "int", diff --git a/src/driver/cockroachdb/CockroachDriver.ts b/src/driver/cockroachdb/CockroachDriver.ts index 9a119218a0..3347250361 100644 --- a/src/driver/cockroachdb/CockroachDriver.ts +++ b/src/driver/cockroachdb/CockroachDriver.ts @@ -172,6 +172,8 @@ export class CockroachDriver implements Driver { createDateDefault: "now()", updateDate: "timestamptz", updateDateDefault: "now()", + deleteDate: "timestamptz", + deleteDateNullable: true, version: Number, treeLevel: Number, migrationId: Number, diff --git a/src/driver/mongodb/MongoDriver.ts b/src/driver/mongodb/MongoDriver.ts index 1de0187f2a..7106532467 100644 --- a/src/driver/mongodb/MongoDriver.ts +++ b/src/driver/mongodb/MongoDriver.ts @@ -94,6 +94,8 @@ export class MongoDriver implements Driver { createDateDefault: "", updateDate: "int", updateDateDefault: "", + deleteDate: "int", + deleteDateNullable: true, version: "int", treeLevel: "int", migrationId: "int", diff --git a/src/driver/mysql/MysqlDriver.ts b/src/driver/mysql/MysqlDriver.ts index c90ce49d5c..291beaa68f 100644 --- a/src/driver/mysql/MysqlDriver.ts +++ b/src/driver/mysql/MysqlDriver.ts @@ -236,6 +236,9 @@ export class MysqlDriver implements Driver { updateDate: "datetime", updateDatePrecision: 6, updateDateDefault: "CURRENT_TIMESTAMP(6)", + deleteDate: "datetime", + deleteDatePrecision: 6, + deleteDateNullable: true, version: "int", treeLevel: "int", migrationId: "int", diff --git a/src/driver/oracle/OracleDriver.ts b/src/driver/oracle/OracleDriver.ts index 10a20a7508..134ed6bba7 100644 --- a/src/driver/oracle/OracleDriver.ts +++ b/src/driver/oracle/OracleDriver.ts @@ -155,6 +155,8 @@ export class OracleDriver implements Driver { createDateDefault: "CURRENT_TIMESTAMP", updateDate: "timestamp", updateDateDefault: "CURRENT_TIMESTAMP", + deleteDate: "timestamp", + deleteDateNullable: true, version: "number", treeLevel: "number", migrationId: "number", diff --git a/src/driver/postgres/PostgresDriver.ts b/src/driver/postgres/PostgresDriver.ts index 61c89445f4..7be91177b9 100644 --- a/src/driver/postgres/PostgresDriver.ts +++ b/src/driver/postgres/PostgresDriver.ts @@ -203,6 +203,8 @@ export class PostgresDriver implements Driver { createDateDefault: "now()", updateDate: "timestamp", updateDateDefault: "now()", + deleteDate: "timestamp", + deleteDateNullable: true, version: "int4", treeLevel: "int4", migrationId: "int4", diff --git a/src/driver/sap/SapDriver.ts b/src/driver/sap/SapDriver.ts index c9ba11c6e6..eb6a12ea0d 100644 --- a/src/driver/sap/SapDriver.ts +++ b/src/driver/sap/SapDriver.ts @@ -148,6 +148,8 @@ export class SapDriver implements Driver { createDateDefault: "CURRENT_TIMESTAMP", updateDate: "timestamp", updateDateDefault: "CURRENT_TIMESTAMP", + deleteDate: "timestamp", + deleteDateNullable: true, version: "integer", treeLevel: "integer", migrationId: "integer", diff --git a/src/driver/sqlite-abstract/AbstractSqliteDriver.ts b/src/driver/sqlite-abstract/AbstractSqliteDriver.ts index 4f417411af..09cb044e17 100644 --- a/src/driver/sqlite-abstract/AbstractSqliteDriver.ts +++ b/src/driver/sqlite-abstract/AbstractSqliteDriver.ts @@ -146,6 +146,8 @@ export abstract class AbstractSqliteDriver implements Driver { createDateDefault: "datetime('now')", updateDate: "datetime", updateDateDefault: "datetime('now')", + deleteDate: "datetime", + deleteDateNullable: true, version: "integer", treeLevel: "integer", migrationId: "integer", diff --git a/src/driver/sqlserver/SqlServerDriver.ts b/src/driver/sqlserver/SqlServerDriver.ts index f57587af7e..20e7f9e11d 100644 --- a/src/driver/sqlserver/SqlServerDriver.ts +++ b/src/driver/sqlserver/SqlServerDriver.ts @@ -164,6 +164,8 @@ export class SqlServerDriver implements Driver { createDateDefault: "getdate()", updateDate: "datetime2", updateDateDefault: "getdate()", + deleteDate: "datetime2", + deleteDateNullable: true, version: "int", treeLevel: "int", migrationId: "int", diff --git a/src/driver/types/MappedColumnTypes.ts b/src/driver/types/MappedColumnTypes.ts index fb2c471064..2070fca754 100644 --- a/src/driver/types/MappedColumnTypes.ts +++ b/src/driver/types/MappedColumnTypes.ts @@ -36,6 +36,21 @@ export interface MappedColumnTypes { */ updateDateDefault: string; + /** + * Column type for the delete date column. + */ + deleteDate: ColumnType; + + /** + * Precision of datetime column. Used in MySql to define milliseconds. + */ + deleteDatePrecision?: number; + + /** + * Nullable value should be used by a database for "deleted date" column. + */ + deleteDateNullable: boolean; + /** * Column type for the version column. */ diff --git a/src/entity-manager/EntityManager.ts b/src/entity-manager/EntityManager.ts index 7812dee180..f5be6f286c 100644 --- a/src/entity-manager/EntityManager.ts +++ b/src/entity-manager/EntityManager.ts @@ -467,6 +467,112 @@ export class EntityManager { .then(() => entity); } + /** + * Records the delete date of all given entities. + */ + softRemove(entities: Entity[], options?: SaveOptions): Promise; + + /** + * Records the delete date of a given entity. + */ + softRemove(entity: Entity, options?: SaveOptions): Promise; + + /** + * Records the delete date of all given entities. + */ + softRemove>(targetOrEntity: ObjectType|EntitySchema, entities: T[], options?: SaveOptions): Promise; + + /** + * Records the delete date of a given entity. + */ + softRemove>(targetOrEntity: ObjectType|EntitySchema, entity: T, options?: SaveOptions): Promise; + + /** + * Records the delete date of all given entities. + */ + softRemove(targetOrEntity: string, entities: T[], options?: SaveOptions): Promise; + + /** + * Records the delete date of a given entity. + */ + softRemove(targetOrEntity: string, entity: T, options?: SaveOptions): Promise; + + /** + * Records the delete date of one or many given entities. + */ + softRemove>(targetOrEntity: (T|T[])|ObjectType|EntitySchema|string, maybeEntityOrOptions?: T|T[], maybeOptions?: SaveOptions): Promise { + + // normalize mixed parameters + let target = (arguments.length > 1 && (targetOrEntity instanceof Function || targetOrEntity instanceof EntitySchema || typeof targetOrEntity === "string")) ? targetOrEntity as Function|string : undefined; + const entity: T|T[] = target ? maybeEntityOrOptions as T|T[] : targetOrEntity as T|T[]; + const options = target ? maybeOptions : maybeEntityOrOptions as SaveOptions; + + if (target instanceof EntitySchema) + target = target.options.name; + + // if user passed empty array of entities then we don't need to do anything + if (entity instanceof Array && entity.length === 0) + return Promise.resolve(entity); + + // execute soft-remove operation + return new EntityPersistExecutor(this.connection, this.queryRunner, "soft-remove", target, entity, options) + .execute() + .then(() => entity); + } + + /** + * Recovers all given entities. + */ + recover(entities: Entity[], options?: SaveOptions): Promise; + + /** + * Recovers a given entity. + */ + recover(entity: Entity, options?: SaveOptions): Promise; + + /** + * Recovers all given entities. + */ + recover>(targetOrEntity: ObjectType|EntitySchema, entities: T[], options?: SaveOptions): Promise; + + /** + * Recovers a given entity. + */ + recover>(targetOrEntity: ObjectType|EntitySchema, entity: T, options?: SaveOptions): Promise; + + /** + * Recovers all given entities. + */ + recover(targetOrEntity: string, entities: T[], options?: SaveOptions): Promise; + + /** + * Recovers a given entity. + */ + recover(targetOrEntity: string, entity: T, options?: SaveOptions): Promise; + + /** + * Recovers one or many given entities. + */ + recover>(targetOrEntity: (T|T[])|ObjectType|EntitySchema|string, maybeEntityOrOptions?: T|T[], maybeOptions?: SaveOptions): Promise { + + // normalize mixed parameters + let target = (arguments.length > 1 && (targetOrEntity instanceof Function || targetOrEntity instanceof EntitySchema || typeof targetOrEntity === "string")) ? targetOrEntity as Function|string : undefined; + const entity: T|T[] = target ? maybeEntityOrOptions as T|T[] : targetOrEntity as T|T[]; + const options = target ? maybeOptions : maybeEntityOrOptions as SaveOptions; + + if (target instanceof EntitySchema) + target = target.options.name; + + // if user passed empty array of entities then we don't need to do anything + if (entity instanceof Array && entity.length === 0) + return Promise.resolve(entity); + + // execute recover operation + return new EntityPersistExecutor(this.connection, this.queryRunner, "recover", target, entity, options) + .execute() + .then(() => entity); + } + /** * Inserts a given entity into the database. * Unlike save method executes a primitive operation without cascades, relations and other operations included. @@ -564,6 +670,82 @@ export class EntityManager { } } + /** + * Records the delete date of entities by a given condition(s). + * Unlike save method executes a primitive operation without cascades, relations and other operations included. + * Executes fast and efficient DELETE query. + * Does not check if entity exist in the database. + * Condition(s) cannot be empty. + */ + softDelete(targetOrEntity: ObjectType|EntitySchema|string, criteria: string|string[]|number|number[]|Date|Date[]|ObjectID|ObjectID[]|any): Promise { + + // if user passed empty criteria or empty list of criterias, then throw an error + if (criteria === undefined || + criteria === null || + criteria === "" || + (criteria instanceof Array && criteria.length === 0)) { + + return Promise.reject(new Error(`Empty criteria(s) are not allowed for the delete method.`)); + } + + if (typeof criteria === "string" || + typeof criteria === "number" || + criteria instanceof Date || + criteria instanceof Array) { + + return this.createQueryBuilder() + .softDelete() + .from(targetOrEntity) + .whereInIds(criteria) + .execute(); + + } else { + return this.createQueryBuilder() + .softDelete() + .from(targetOrEntity) + .where(criteria) + .execute(); + } + } + + /** + * Restores entities by a given condition(s). + * Unlike save method executes a primitive operation without cascades, relations and other operations included. + * Executes fast and efficient DELETE query. + * Does not check if entity exist in the database. + * Condition(s) cannot be empty. + */ + restore(targetOrEntity: ObjectType|EntitySchema|string, criteria: string|string[]|number|number[]|Date|Date[]|ObjectID|ObjectID[]|any): Promise { + + // if user passed empty criteria or empty list of criterias, then throw an error + if (criteria === undefined || + criteria === null || + criteria === "" || + (criteria instanceof Array && criteria.length === 0)) { + + return Promise.reject(new Error(`Empty criteria(s) are not allowed for the delete method.`)); + } + + if (typeof criteria === "string" || + typeof criteria === "number" || + criteria instanceof Date || + criteria instanceof Array) { + + return this.createQueryBuilder() + .restore() + .from(targetOrEntity) + .whereInIds(criteria) + .execute(); + + } else { + return this.createQueryBuilder() + .restore() + .from(targetOrEntity) + .where(criteria) + .execute(); + } + } + /** * Counts entities that match given options. * Useful for pagination. diff --git a/src/entity-schema/EntitySchemaColumnOptions.ts b/src/entity-schema/EntitySchemaColumnOptions.ts index 17ecd7297f..80e75363fa 100644 --- a/src/entity-schema/EntitySchemaColumnOptions.ts +++ b/src/entity-schema/EntitySchemaColumnOptions.ts @@ -24,6 +24,11 @@ export interface EntitySchemaColumnOptions extends SpatialColumnOptions { */ updateDate?: boolean; + /** + * Indicates if this column is a delete date column. + */ + deleteDate?: boolean; + /** * Indicates if this column is a version column. */ diff --git a/src/entity-schema/EntitySchemaRelationOptions.ts b/src/entity-schema/EntitySchemaRelationOptions.ts index dab8fc4e81..953caaccd6 100644 --- a/src/entity-schema/EntitySchemaRelationOptions.ts +++ b/src/entity-schema/EntitySchemaRelationOptions.ts @@ -71,7 +71,7 @@ export interface EntitySchemaRelationOptions { * If set to true then it means that related object can be allowed to be inserted / updated / removed to the db. * This is option a shortcut if you would like to set cascadeInsert, cascadeUpdate and cascadeRemove to true. */ - cascade?: boolean|("insert"|"update"|"remove")[]; + cascade?: boolean|("insert"|"update"|"remove"|"soft-remove"|"recover")[]; /** * Default database value. diff --git a/src/entity-schema/EntitySchemaTransformer.ts b/src/entity-schema/EntitySchemaTransformer.ts index dce5cbfa1f..8180881613 100644 --- a/src/entity-schema/EntitySchemaTransformer.ts +++ b/src/entity-schema/EntitySchemaTransformer.ts @@ -54,6 +54,8 @@ export class EntitySchemaTransformer { mode = "createDate"; if (column.updateDate) mode = "updateDate"; + if (column.deleteDate) + mode = "deleteDate"; if (column.version) mode = "version"; if (column.treeChildrenCount) diff --git a/src/error/MissingDeleteDateColumnError.ts b/src/error/MissingDeleteDateColumnError.ts new file mode 100644 index 0000000000..f6e90e95ea --- /dev/null +++ b/src/error/MissingDeleteDateColumnError.ts @@ -0,0 +1,14 @@ +import {EntityMetadata} from "../metadata/EntityMetadata"; + +/** + */ +export class MissingDeleteDateColumnError extends Error { + name = "MissingDeleteDateColumnError"; + + constructor(entityMetadata: EntityMetadata) { + super(); + Object.setPrototypeOf(this, MissingDeleteDateColumnError.prototype); + this.message = `Entity "${entityMetadata.name}" does not have delete date columns.`; + } + +} \ No newline at end of file diff --git a/src/find-options/FindOneOptions.ts b/src/find-options/FindOneOptions.ts index 1a34cd2ff1..5f045c58f6 100644 --- a/src/find-options/FindOneOptions.ts +++ b/src/find-options/FindOneOptions.ts @@ -42,6 +42,11 @@ export interface FindOneOptions { */ lock?: { mode: "optimistic", version: number|Date } | { mode: "pessimistic_read"|"pessimistic_write"|"dirty_read" }; + /** + * Indicates if soft-deleted rows should be included in entity result. + */ + withDeleted?: boolean; + /** * If sets to true then loads all relation ids of the entity and maps them into relation values (not relation objects). * If array of strings is given then loads only relation ids of the given properties. diff --git a/src/find-options/FindOptionsUtils.ts b/src/find-options/FindOptionsUtils.ts index 2e9300eb62..bce42618ac 100644 --- a/src/find-options/FindOptionsUtils.ts +++ b/src/find-options/FindOptionsUtils.ts @@ -33,7 +33,8 @@ export class FindOptionsUtils { possibleOptions.lock instanceof Object || possibleOptions.loadRelationIds instanceof Object || typeof possibleOptions.loadRelationIds === "boolean" || - typeof possibleOptions.loadEagerRelations === "boolean" + typeof possibleOptions.loadEagerRelations === "boolean" || + typeof possibleOptions.withDeleted === "boolean" ); } @@ -179,6 +180,10 @@ export class FindOptionsUtils { qb.setLock(options.lock.mode); } } + + if (options.withDeleted) { + qb.withDeleted(); + } if (options.loadRelationIds === true) { qb.loadAllRelationIds(); diff --git a/src/index.ts b/src/index.ts index 5fb9d8c44e..1acbfe541c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export * from "./common/DeepPartial"; export * from "./error/QueryFailedError"; export * from "./decorator/columns/Column"; export * from "./decorator/columns/CreateDateColumn"; +export * from "./decorator/columns/DeleteDateColumn"; export * from "./decorator/columns/PrimaryGeneratedColumn"; export * from "./decorator/columns/PrimaryColumn"; export * from "./decorator/columns/UpdateDateColumn"; diff --git a/src/metadata-args/types/ColumnMode.ts b/src/metadata-args/types/ColumnMode.ts index 9051ad2716..26f162d465 100644 --- a/src/metadata-args/types/ColumnMode.ts +++ b/src/metadata-args/types/ColumnMode.ts @@ -4,4 +4,4 @@ * For example, "primary" means that it will be a primary column, or "createDate" means that it will create a create * date column. */ -export type ColumnMode = "regular"|"virtual"|"createDate"|"updateDate"|"version"|"treeChildrenCount"|"treeLevel"|"objectId"|"array"; +export type ColumnMode = "regular"|"virtual"|"createDate"|"updateDate"|"deleteDate"|"version"|"treeChildrenCount"|"treeLevel"|"objectId"|"array"; diff --git a/src/metadata-builder/EntityMetadataBuilder.ts b/src/metadata-builder/EntityMetadataBuilder.ts index ef92bb6794..d4c764d648 100644 --- a/src/metadata-builder/EntityMetadataBuilder.ts +++ b/src/metadata-builder/EntityMetadataBuilder.ts @@ -625,6 +625,7 @@ export class EntityMetadataBuilder { entityMetadata.hasUUIDGeneratedColumns = entityMetadata.columns.filter(column => column.isGenerated || column.generationStrategy === "uuid").length > 0; entityMetadata.createDateColumn = entityMetadata.columns.find(column => column.isCreateDate); entityMetadata.updateDateColumn = entityMetadata.columns.find(column => column.isUpdateDate); + entityMetadata.deleteDateColumn = entityMetadata.columns.find(column => column.isDeleteDate); entityMetadata.versionColumn = entityMetadata.columns.find(column => column.isVersion); entityMetadata.discriminatorColumn = entityMetadata.columns.find(column => column.isDiscriminator); entityMetadata.treeLevelColumn = entityMetadata.columns.find(column => column.isTreeLevel); diff --git a/src/metadata/ColumnMetadata.ts b/src/metadata/ColumnMetadata.ts index e07c97fda8..395c0f4faf 100644 --- a/src/metadata/ColumnMetadata.ts +++ b/src/metadata/ColumnMetadata.ts @@ -246,6 +246,11 @@ export class ColumnMetadata { */ isUpdateDate: boolean = false; + /** + * Indicates if this column contains an entity delete date. + */ + isDeleteDate: boolean = false; + /** * Indicates if this column contains an entity version. */ @@ -392,6 +397,7 @@ export class ColumnMetadata { this.isTreeLevel = options.args.mode === "treeLevel"; this.isCreateDate = options.args.mode === "createDate"; this.isUpdateDate = options.args.mode === "updateDate"; + this.isDeleteDate = options.args.mode === "deleteDate"; this.isVersion = options.args.mode === "version"; this.isObjectId = options.args.mode === "objectId"; } @@ -419,6 +425,14 @@ export class ColumnMetadata { if (this.precision === undefined && options.connection.driver.mappedDataTypes.updateDatePrecision) this.precision = options.connection.driver.mappedDataTypes.updateDatePrecision; } + if (this.isDeleteDate) { + if (!this.type) + this.type = options.connection.driver.mappedDataTypes.deleteDate; + if (!this.isNullable) + this.isNullable = options.connection.driver.mappedDataTypes.deleteDateNullable; + if (this.precision === undefined && options.connection.driver.mappedDataTypes.deleteDatePrecision) + this.precision = options.connection.driver.mappedDataTypes.deleteDatePrecision; + } if (this.isVersion) this.type = options.connection.driver.mappedDataTypes.version; if (options.closureType) diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index 7b5e0ae8bf..d4da9d2f61 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -269,6 +269,11 @@ export class EntityMetadata { */ updateDateColumn?: ColumnMetadata; + /** + * Gets entity column which contains a delete date value. + */ + deleteDateColumn?: ColumnMetadata; + /** * Gets entity column which contains an entity version. */ diff --git a/src/metadata/RelationMetadata.ts b/src/metadata/RelationMetadata.ts index dfe192c89e..f51d20ff0b 100644 --- a/src/metadata/RelationMetadata.ts +++ b/src/metadata/RelationMetadata.ts @@ -124,6 +124,16 @@ export class RelationMetadata { */ isCascadeRemove: boolean = false; + /** + * If set to true then related objects are allowed to be soft-removed from the database. + */ + isCascadeSoftRemove: boolean = false; + + /** + * If set to true then related objects are allowed to be recovered from the database. + */ + isCascadeRecover: boolean = false; + /** * Indicates if relation column value can be nullable or not. */ @@ -271,9 +281,16 @@ export class RelationMetadata { this.givenInverseSidePropertyFactory = args.inverseSideProperty; this.isLazy = args.isLazy || false; + this.isCascadeInsert = args.options.cascade === true || (args.options.cascade instanceof Array && args.options.cascade.indexOf("insert") !== -1); + this.isCascadeUpdate = args.options.cascade === true || (args.options.cascade instanceof Array && args.options.cascade.indexOf("update") !== -1); + this.isCascadeRemove = args.options.cascade === true || (args.options.cascade instanceof Array && args.options.cascade.indexOf("remove") !== -1); + this.isCascadeSoftRemove = args.options.cascade === true || (args.options.cascade instanceof Array && args.options.cascade.indexOf("soft-remove") !== -1); + this.isCascadeRecover = args.options.cascade === true || (args.options.cascade instanceof Array && args.options.cascade.indexOf("recover") !== -1); this.isCascadeInsert = args.options.cascade === true || (Array.isArray(args.options.cascade) && args.options.cascade.indexOf("insert") !== -1); this.isCascadeUpdate = args.options.cascade === true || (Array.isArray(args.options.cascade) && args.options.cascade.indexOf("update") !== -1); this.isCascadeRemove = args.options.cascade === true || (Array.isArray(args.options.cascade) && args.options.cascade.indexOf("remove") !== -1); + this.isCascadeSoftRemove = args.options.cascade === true || (Array.isArray(args.options.cascade) && args.options.cascade.indexOf("soft-remove") !== -1); + this.isCascadeRecover = args.options.cascade === true || (Array.isArray(args.options.cascade) && args.options.cascade.indexOf("recover") !== -1); this.isPrimary = args.options.primary || false; this.isNullable = args.options.nullable === false || this.isPrimary ? false : true; this.onDelete = args.options.onDelete; diff --git a/src/persistence/EntityPersistExecutor.ts b/src/persistence/EntityPersistExecutor.ts index 9971c40e08..ec04e3459c 100644 --- a/src/persistence/EntityPersistExecutor.ts +++ b/src/persistence/EntityPersistExecutor.ts @@ -26,7 +26,7 @@ export class EntityPersistExecutor { constructor(protected connection: Connection, protected queryRunner: QueryRunner|undefined, - protected mode: "save"|"remove", + protected mode: "save"|"remove"|"soft-remove"|"recover", protected target: Function|string|undefined, protected entity: ObjectLiteral|ObjectLiteral[], protected options?: SaveOptions & RemoveOptions) { @@ -78,7 +78,9 @@ export class EntityPersistExecutor { entity: entity, canBeInserted: this.mode === "save", canBeUpdated: this.mode === "save", - mustBeRemoved: this.mode === "remove" + mustBeRemoved: this.mode === "remove", + canBeSoftRemoved: this.mode === "soft-remove", + canBeRecovered: this.mode === "recover" })); }); @@ -88,7 +90,7 @@ export class EntityPersistExecutor { subjects.forEach(subject => { // next step we build list of subjects we will operate with // these subjects are subjects that we need to insert or update alongside with main persisted entity - cascadesSubjectBuilder.build(subject); + cascadesSubjectBuilder.build(subject, this.mode); }); // console.timeEnd("building cascades..."); @@ -100,7 +102,7 @@ export class EntityPersistExecutor { // console.time("other subjects..."); // build all related subjects and change maps - if (this.mode === "save") { + if (this.mode === "save" || this.mode === "soft-remove" || this.mode === "recover") { new OneToManySubjectBuilder(subjects).build(); new OneToOneInverseSideSubjectBuilder(subjects).build(); new ManyToManySubjectBuilder(subjects).build(); diff --git a/src/persistence/Subject.ts b/src/persistence/Subject.ts index ab1481feeb..988509fbb7 100644 --- a/src/persistence/Subject.ts +++ b/src/persistence/Subject.ts @@ -100,6 +100,18 @@ export class Subject { */ mustBeRemoved: boolean = false; + /** + * Indicates if this subject can be soft-removed from the database. + * This means that this subject either was soft-removed, either was soft-removed by cascades. + */ + canBeSoftRemoved: boolean = false; + + /** + * Indicates if this subject can be recovered from the database. + * This means that this subject either was recovered, either was recovered by cascades. + */ + canBeRecovered: boolean = false; + /** * Relations updated by the change maps. */ @@ -126,6 +138,8 @@ export class Subject { canBeInserted?: boolean, canBeUpdated?: boolean, mustBeRemoved?: boolean, + canBeSoftRemoved?: boolean, + canBeRecovered?: boolean, identifier?: ObjectLiteral, changeMaps?: SubjectChangeMap[] }) { @@ -138,6 +152,10 @@ export class Subject { this.canBeUpdated = options.canBeUpdated; if (options.mustBeRemoved !== undefined) this.mustBeRemoved = options.mustBeRemoved; + if (options.canBeSoftRemoved !== undefined) + this.canBeSoftRemoved = options.canBeSoftRemoved; + if (options.canBeRecovered !== undefined) + this.canBeRecovered = options.canBeRecovered; if (options.identifier !== undefined) this.identifier = options.identifier; if (options.changeMaps !== undefined) @@ -172,6 +190,28 @@ export class Subject { this.changeMaps.length > 0; } + /** + * Checks if this subject must be soft-removed into the database. + * Subject can be updated in the database if it is allowed to be soft-removed (explicitly persisted or by cascades) + * and if it does have differentiated columns or relations. + */ + get mustBeSoftRemoved() { + return this.canBeSoftRemoved && + this.identifier && + (this.databaseEntityLoaded === false || (this.databaseEntityLoaded && this.databaseEntity)); + } + + /** + * Checks if this subject must be recovered into the database. + * Subject can be updated in the database if it is allowed to be recovered (explicitly persisted or by cascades) + * and if it does have differentiated columns or relations. + */ + get mustBeRecovered() { + return this.canBeRecovered && + this.identifier && + (this.databaseEntityLoaded === false || (this.databaseEntityLoaded && this.databaseEntity)); + } + // ------------------------------------------------------------------------- // Public Methods // ------------------------------------------------------------------------- diff --git a/src/persistence/SubjectDatabaseEntityLoader.ts b/src/persistence/SubjectDatabaseEntityLoader.ts index 2c590b1ffd..3bc80a9858 100644 --- a/src/persistence/SubjectDatabaseEntityLoader.ts +++ b/src/persistence/SubjectDatabaseEntityLoader.ts @@ -29,7 +29,7 @@ export class SubjectDatabaseEntityLoader { * loadAllRelations flag is used to load all relation ids of the object, no matter if they present in subject entity or not. * This option is used for deletion. */ - async load(operationType: "save"|"remove"): Promise { + async load(operationType: "save"|"remove"|"soft-remove"|"recover"): Promise { // we are grouping subjects by target to perform more optimized queries using WHERE IN operator // go through the groups and perform loading of database entities of each subject in the group @@ -54,12 +54,12 @@ export class SubjectDatabaseEntityLoader { const loadRelationPropertyPaths: string[] = []; - // for the save operation + // for the save, soft-remove and recover operation // extract all property paths of the relations we need to load relation ids for // this is for optimization purpose - this way we don't load relation ids for entities // whose relations are undefined, and since they are undefined its really pointless to // load something for them, since undefined properties are skipped by the orm - if (operationType === "save") { + if (operationType === "save" || operationType === "soft-remove" || operationType === "recover") { subjectGroup.subjects.forEach(subject => { // gets all relation property paths that exist in the persisted entity. @@ -84,7 +84,9 @@ export class SubjectDatabaseEntityLoader { loadRelationIds: { relations: loadRelationPropertyPaths, disableMixedMap: true - } + }, + // the soft-deleted entities should be included in the loaded entities for recover operation + withDeleted: true }; // load database entities for all given ids diff --git a/src/persistence/SubjectExecutor.ts b/src/persistence/SubjectExecutor.ts index ccf63dea33..9db8f273a9 100644 --- a/src/persistence/SubjectExecutor.ts +++ b/src/persistence/SubjectExecutor.ts @@ -68,6 +68,16 @@ export class SubjectExecutor { */ protected removeSubjects: Subject[] = []; + /** + * Subjects that must be soft-removed. + */ + protected softRemoveSubjects: Subject[] = []; + + /** + * Subjects that must be recovered. + */ + protected recoverSubjects: Subject[] = []; + // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- @@ -107,6 +117,8 @@ export class SubjectExecutor { this.insertSubjects.forEach(subject => subject.recompute()); this.updateSubjects.forEach(subject => subject.recompute()); this.removeSubjects.forEach(subject => subject.recompute()); + this.softRemoveSubjects.forEach(subject => subject.recompute()); + this.recoverSubjects.forEach(subject => subject.recompute()); this.recompute(); // console.timeEnd(".recompute"); } @@ -136,6 +148,18 @@ export class SubjectExecutor { await this.executeRemoveOperations(); // console.timeEnd(".removal"); + // recompute soft-remove operations + this.softRemoveSubjects = this.allSubjects.filter(subject => subject.mustBeSoftRemoved); + + // execute soft-remove operations + await this.executeSoftRemoveOperations(); + + // recompute recover operations + this.recoverSubjects = this.allSubjects.filter(subject => subject.mustBeRecovered); + + // execute recover operations + await this.executeRecoverOperations(); + // update all special columns in persisted entities, like inserted id or remove ids from the removed entities // console.time(".updateSpecialColumnsInPersistedEntities"); await this.updateSpecialColumnsInPersistedEntities(); @@ -173,7 +197,9 @@ export class SubjectExecutor { this.insertSubjects = this.allSubjects.filter(subject => subject.mustBeInserted); this.updateSubjects = this.allSubjects.filter(subject => subject.mustBeUpdated); this.removeSubjects = this.allSubjects.filter(subject => subject.mustBeRemoved); - this.hasExecutableOperations = this.insertSubjects.length > 0 || this.updateSubjects.length > 0 || this.removeSubjects.length > 0; + this.softRemoveSubjects = this.allSubjects.filter(subject => subject.mustBeSoftRemoved); + this.recoverSubjects = this.allSubjects.filter(subject => subject.mustBeRecovered); + this.hasExecutableOperations = this.insertSubjects.length > 0 || this.updateSubjects.length > 0 || this.removeSubjects.length > 0 || this.softRemoveSubjects.length > 0 || this.recoverSubjects.length > 0; } /** @@ -187,6 +213,10 @@ export class SubjectExecutor { this.updateSubjects.forEach(subject => this.queryRunner.broadcaster.broadcastBeforeUpdateEvent(result, subject.metadata, subject.entity!, subject.databaseEntity, subject.diffColumns, subject.diffRelations)); if (this.removeSubjects.length) this.removeSubjects.forEach(subject => this.queryRunner.broadcaster.broadcastBeforeRemoveEvent(result, subject.metadata, subject.entity!, subject.databaseEntity)); + if (this.softRemoveSubjects.length) + this.softRemoveSubjects.forEach(subject => this.queryRunner.broadcaster.broadcastBeforeUpdateEvent(result, subject.metadata, subject.entity!, subject.databaseEntity, subject.diffColumns, subject.diffRelations)); + if (this.recoverSubjects.length) + this.recoverSubjects.forEach(subject => this.queryRunner.broadcaster.broadcastBeforeUpdateEvent(result, subject.metadata, subject.entity!, subject.databaseEntity, subject.diffColumns, subject.diffRelations)); return result; } @@ -203,6 +233,10 @@ export class SubjectExecutor { this.updateSubjects.forEach(subject => this.queryRunner.broadcaster.broadcastAfterUpdateEvent(result, subject.metadata, subject.entity!, subject.databaseEntity, subject.diffColumns, subject.diffRelations)); if (this.removeSubjects.length) this.removeSubjects.forEach(subject => this.queryRunner.broadcaster.broadcastAfterRemoveEvent(result, subject.metadata, subject.entity!, subject.databaseEntity)); + if (this.softRemoveSubjects.length) + this.softRemoveSubjects.forEach(subject => this.queryRunner.broadcaster.broadcastAfterUpdateEvent(result, subject.metadata, subject.entity!, subject.databaseEntity, subject.diffColumns, subject.diffRelations)); + if (this.recoverSubjects.length) + this.recoverSubjects.forEach(subject => this.queryRunner.broadcaster.broadcastAfterUpdateEvent(result, subject.metadata, subject.entity!, subject.databaseEntity, subject.diffColumns, subject.diffRelations)); return result; } @@ -462,6 +496,166 @@ export class SubjectExecutor { }); } + /** + * Soft-removes all given subjects in the database. + */ + protected async executeSoftRemoveOperations(): Promise { + await Promise.all(this.softRemoveSubjects.map(async subject => { + + if (!subject.identifier) + throw new SubjectWithoutIdentifierError(subject); + + // for mongodb we have a bit different updation logic + if (this.queryRunner instanceof MongoQueryRunner) { + const partialEntity = OrmUtils.mergeDeep({}, subject.entity!); + if (subject.metadata.objectIdColumn && subject.metadata.objectIdColumn.propertyName) { + delete partialEntity[subject.metadata.objectIdColumn.propertyName]; + } + + if (subject.metadata.createDateColumn && subject.metadata.createDateColumn.propertyName) { + delete partialEntity[subject.metadata.createDateColumn.propertyName]; + } + + if (subject.metadata.updateDateColumn && subject.metadata.updateDateColumn.propertyName) { + partialEntity[subject.metadata.updateDateColumn.propertyName] = new Date(); + } + + if (subject.metadata.deleteDateColumn && subject.metadata.deleteDateColumn.propertyName) { + partialEntity[subject.metadata.deleteDateColumn.propertyName] = new Date(); + } + + const manager = this.queryRunner.manager as MongoEntityManager; + + await manager.update(subject.metadata.target, subject.identifier, partialEntity); + + } else { + + // here we execute our soft-deletion query + // we need to enable entity soft-deletion because we update a subject identifier + // which is not same object as our entity that's why we don't need to worry about our entity to get dirty + // also, we disable listeners because we call them on our own in persistence layer + const softDeleteQueryBuilder = this.queryRunner + .manager + .createQueryBuilder() + .softDelete() + .from(subject.metadata.target) + .updateEntity(this.options && this.options.reload === false ? false : true) + .callListeners(false); + + if (subject.entity) { + softDeleteQueryBuilder.whereEntity(subject.identifier); + + } else { // in this case identifier is just conditions object to update by + softDeleteQueryBuilder.where(subject.identifier); + } + + const updateResult = await softDeleteQueryBuilder.execute(); + subject.generatedMap = updateResult.generatedMaps[0]; + if (subject.generatedMap) { + subject.metadata.columns.forEach(column => { + const value = column.getEntityValue(subject.generatedMap!); + if (value !== undefined && value !== null) { + const preparedValue = this.queryRunner.connection.driver.prepareHydratedValue(value, column); + column.setEntityValue(subject.generatedMap!, preparedValue); + } + }); + } + + // experiments, remove probably, need to implement tree tables children removal + // if (subject.updatedRelationMaps.length > 0) { + // await Promise.all(subject.updatedRelationMaps.map(async updatedRelation => { + // if (!updatedRelation.relation.isTreeParent) return; + // if (!updatedRelation.value !== null) return; + // + // if (subject.metadata.treeType === "closure-table") { + // await new ClosureSubjectExecutor(this.queryRunner).deleteChildrenOf(subject); + // } + // })); + // } + } + })); + } + + /** + * Recovers all given subjects in the database. + */ + protected async executeRecoverOperations(): Promise { + await Promise.all(this.recoverSubjects.map(async subject => { + + if (!subject.identifier) + throw new SubjectWithoutIdentifierError(subject); + + // for mongodb we have a bit different updation logic + if (this.queryRunner instanceof MongoQueryRunner) { + const partialEntity = OrmUtils.mergeDeep({}, subject.entity!); + if (subject.metadata.objectIdColumn && subject.metadata.objectIdColumn.propertyName) { + delete partialEntity[subject.metadata.objectIdColumn.propertyName]; + } + + if (subject.metadata.createDateColumn && subject.metadata.createDateColumn.propertyName) { + delete partialEntity[subject.metadata.createDateColumn.propertyName]; + } + + if (subject.metadata.updateDateColumn && subject.metadata.updateDateColumn.propertyName) { + partialEntity[subject.metadata.updateDateColumn.propertyName] = new Date(); + } + + if (subject.metadata.deleteDateColumn && subject.metadata.deleteDateColumn.propertyName) { + partialEntity[subject.metadata.deleteDateColumn.propertyName] = null; + } + + const manager = this.queryRunner.manager as MongoEntityManager; + + await manager.update(subject.metadata.target, subject.identifier, partialEntity); + + } else { + + // here we execute our restory query + // we need to enable entity restory because we update a subject identifier + // which is not same object as our entity that's why we don't need to worry about our entity to get dirty + // also, we disable listeners because we call them on our own in persistence layer + const softDeleteQueryBuilder = this.queryRunner + .manager + .createQueryBuilder() + .restore() + .from(subject.metadata.target) + .updateEntity(this.options && this.options.reload === false ? false : true) + .callListeners(false); + + if (subject.entity) { + softDeleteQueryBuilder.whereEntity(subject.identifier); + + } else { // in this case identifier is just conditions object to update by + softDeleteQueryBuilder.where(subject.identifier); + } + + const updateResult = await softDeleteQueryBuilder.execute(); + subject.generatedMap = updateResult.generatedMaps[0]; + if (subject.generatedMap) { + subject.metadata.columns.forEach(column => { + const value = column.getEntityValue(subject.generatedMap!); + if (value !== undefined && value !== null) { + const preparedValue = this.queryRunner.connection.driver.prepareHydratedValue(value, column); + column.setEntityValue(subject.generatedMap!, preparedValue); + } + }); + } + + // experiments, remove probably, need to implement tree tables children removal + // if (subject.updatedRelationMaps.length > 0) { + // await Promise.all(subject.updatedRelationMaps.map(async updatedRelation => { + // if (!updatedRelation.relation.isTreeParent) return; + // if (!updatedRelation.value !== null) return; + // + // if (subject.metadata.treeType === "closure-table") { + // await new ClosureSubjectExecutor(this.queryRunner).deleteChildrenOf(subject); + // } + // })); + // } + } + })); + } + /** * Updates all special columns of the saving entities (create date, update date, version, etc.). * Also updates nullable columns and columns with default values. @@ -476,6 +670,14 @@ export class SubjectExecutor { if (this.updateSubjects.length) this.updateSpecialColumnsInInsertedAndUpdatedEntities(this.updateSubjects); + // update soft-removed entity properties + if (this.updateSubjects.length) + this.updateSpecialColumnsInInsertedAndUpdatedEntities(this.softRemoveSubjects); + + // update recovered entity properties + if (this.updateSubjects.length) + this.updateSpecialColumnsInInsertedAndUpdatedEntities(this.recoverSubjects); + // remove ids from the entities that were removed if (this.removeSubjects.length) { this.removeSubjects.forEach(subject => { diff --git a/src/persistence/subject-builder/CascadesSubjectBuilder.ts b/src/persistence/subject-builder/CascadesSubjectBuilder.ts index 0e4a8c2713..75ad3a45e5 100644 --- a/src/persistence/subject-builder/CascadesSubjectBuilder.ts +++ b/src/persistence/subject-builder/CascadesSubjectBuilder.ts @@ -21,16 +21,16 @@ export class CascadesSubjectBuilder { /** * Builds a cascade subjects tree and pushes them in into the given array of subjects. */ - build(subject: Subject) { + build(subject: Subject, operationType: "save"|"remove"|"soft-remove"|"recover") { subject.metadata .extractRelationValuesFromEntity(subject.entity!, subject.metadata.relations) // todo: we can create EntityMetadata.cascadeRelations .forEach(([relation, relationEntity, relationEntityMetadata]) => { - // we need only defined values and insert or update cascades of the relation should be set + // we need only defined values and insert, update, soft-remove or recover cascades of the relation should be set if (relationEntity === undefined || relationEntity === null || - (!relation.isCascadeInsert && !relation.isCascadeUpdate)) + (!relation.isCascadeInsert && !relation.isCascadeUpdate && !relation.isCascadeSoftRemove && !relation.isCascadeRecover)) return; // if relation entity is just a relation id set (for example post.tag = 1) @@ -42,9 +42,13 @@ export class CascadesSubjectBuilder { const alreadyExistRelationEntitySubject = this.findByPersistEntityLike(relationEntityMetadata.target, relationEntity); if (alreadyExistRelationEntitySubject) { if (alreadyExistRelationEntitySubject.canBeInserted === false) // if its not marked for insertion yet - alreadyExistRelationEntitySubject.canBeInserted = relation.isCascadeInsert === true; + alreadyExistRelationEntitySubject.canBeInserted = relation.isCascadeInsert === true && operationType === "save"; if (alreadyExistRelationEntitySubject.canBeUpdated === false) // if its not marked for update yet - alreadyExistRelationEntitySubject.canBeUpdated = relation.isCascadeUpdate === true; + alreadyExistRelationEntitySubject.canBeUpdated = relation.isCascadeUpdate === true && operationType === "save"; + if (alreadyExistRelationEntitySubject.canBeSoftRemoved === false) // if its not marked for removal yet + alreadyExistRelationEntitySubject.canBeSoftRemoved = relation.isCascadeSoftRemove === true && operationType === "soft-remove"; + if (alreadyExistRelationEntitySubject.canBeRecovered === false) // if its not marked for recovery yet + alreadyExistRelationEntitySubject.canBeRecovered = relation.isCascadeRecover === true && operationType === "recover"; return; } @@ -54,13 +58,15 @@ export class CascadesSubjectBuilder { metadata: relationEntityMetadata, parentSubject: subject, entity: relationEntity, - canBeInserted: relation.isCascadeInsert === true, - canBeUpdated: relation.isCascadeUpdate === true + canBeInserted: relation.isCascadeInsert === true && operationType === "save", + canBeUpdated: relation.isCascadeUpdate === true && operationType === "save", + canBeSoftRemoved: relation.isCascadeSoftRemove === true && operationType === "soft-remove", + canBeRecovered: relation.isCascadeRecover === true && operationType === "recover" }); this.allSubjects.push(relationEntitySubject); // go recursively and find other entities we need to insert/update - this.build(relationEntitySubject); + this.build(relationEntitySubject, operationType); }); } diff --git a/src/query-builder/QueryBuilder.ts b/src/query-builder/QueryBuilder.ts index 2a6dcc64d3..5c2cdb2a0a 100644 --- a/src/query-builder/QueryBuilder.ts +++ b/src/query-builder/QueryBuilder.ts @@ -5,6 +5,7 @@ import {QueryExpressionMap} from "./QueryExpressionMap"; import {SelectQueryBuilder} from "./SelectQueryBuilder"; import {UpdateQueryBuilder} from "./UpdateQueryBuilder"; import {DeleteQueryBuilder} from "./DeleteQueryBuilder"; +import {SoftDeleteQueryBuilder} from "./SoftDeleteQueryBuilder"; import {InsertQueryBuilder} from "./InsertQueryBuilder"; import {RelationQueryBuilder} from "./RelationQueryBuilder"; import {ObjectType} from "../common/ObjectType"; @@ -239,6 +240,28 @@ export abstract class QueryBuilder { return new DeleteQueryBuilderCls(this); } + softDelete(): SoftDeleteQueryBuilder { + this.expressionMap.queryType = "soft-delete"; + + // loading it dynamically because of circular issue + const SoftDeleteQueryBuilderCls = require("./SoftDeleteQueryBuilder").SoftDeleteQueryBuilder; + if (this instanceof SoftDeleteQueryBuilderCls) + return this as any; + + return new SoftDeleteQueryBuilderCls(this); + } + + restore(): SoftDeleteQueryBuilder { + this.expressionMap.queryType = "restore"; + + // loading it dynamically because of circular issue + const SoftDeleteQueryBuilderCls = require("./SoftDeleteQueryBuilder").SoftDeleteQueryBuilder; + if (this instanceof SoftDeleteQueryBuilderCls) + return this as any; + + return new SoftDeleteQueryBuilderCls(this); + } + /** * Sets entity's relation with which this query builder gonna work. */ @@ -574,10 +597,20 @@ export abstract class QueryBuilder { * Creates "WHERE" expression. */ protected createWhereExpression() { - const conditions = this.createWhereExpressionString(); + let conditions = this.createWhereExpressionString(); if (this.expressionMap.mainAlias!.hasMetadata) { const metadata = this.expressionMap.mainAlias!.metadata; + // Adds the global condition of "non-deleted" for the entity with delete date columns in select query. + if (this.expressionMap.queryType === "select" && !this.expressionMap.withDeleted && metadata.deleteDateColumn) { + const column = this.expressionMap.aliasNamePrefixingEnabled + ? this.expressionMap.mainAlias!.name + "." + metadata.deleteDateColumn.propertyName + : metadata.deleteDateColumn.propertyName; + + const condition = `${this.replacePropertyNames(column)} IS NULL`; + conditions = `${ conditions.length ? "(" + conditions + ") AND" : "" } ${condition}`; + } + if (metadata.discriminatorColumn && metadata.parentEntityMetadata) { const column = this.expressionMap.aliasNamePrefixingEnabled ? this.expressionMap.mainAlias!.name + "." + metadata.discriminatorColumn.databaseName @@ -618,7 +651,7 @@ export abstract class QueryBuilder { let columnsExpression = columns.map(column => { const name = this.escape(column.databaseName); if (driver instanceof SqlServerDriver) { - if (this.expressionMap.queryType === "insert" || this.expressionMap.queryType === "update") { + if (this.expressionMap.queryType === "insert" || this.expressionMap.queryType === "update" || this.expressionMap.queryType === "soft-delete" || this.expressionMap.queryType === "restore") { return "INSERTED." + name; } else { return this.escape(this.getMainTableName()) + "." + name; diff --git a/src/query-builder/QueryExpressionMap.ts b/src/query-builder/QueryExpressionMap.ts index 77cd90af86..b88310dcaa 100644 --- a/src/query-builder/QueryExpressionMap.ts +++ b/src/query-builder/QueryExpressionMap.ts @@ -39,7 +39,7 @@ export class QueryExpressionMap { /** * Represents query type. QueryBuilder is able to build SELECT, UPDATE and DELETE queries. */ - queryType: "select"|"update"|"delete"|"insert"|"relation" = "select"; + queryType: "select"|"update"|"delete"|"insert"|"relation"|"soft-delete"|"restore" = "select"; /** * Data needs to be SELECT-ed. @@ -157,6 +157,12 @@ export class QueryExpressionMap { */ lockVersion?: number|Date; + /** + * Indicates if soft-deleted rows should be included in entity result. + * By default the soft-deleted rows are not included. + */ + withDeleted: boolean = false; + /** * Parameters used to be escaped in final query. */ @@ -405,6 +411,7 @@ export class QueryExpressionMap { map.take = this.take; map.lockMode = this.lockMode; map.lockVersion = this.lockVersion; + map.withDeleted = this.withDeleted; map.parameters = Object.assign({}, this.parameters); map.disableEscaping = this.disableEscaping; map.enableRelationIdValues = this.enableRelationIdValues; diff --git a/src/query-builder/ReturningResultsEntityUpdator.ts b/src/query-builder/ReturningResultsEntityUpdator.ts index 8392c9a16b..7dcd9f100d 100644 --- a/src/query-builder/ReturningResultsEntityUpdator.ts +++ b/src/query-builder/ReturningResultsEntityUpdator.ts @@ -162,6 +162,7 @@ export class ReturningResultsEntityUpdator { (needToCheckGenerated && column.isGenerated) || column.isCreateDate || column.isUpdateDate || + column.isDeleteDate || column.isVersion; }); } diff --git a/src/query-builder/SelectQueryBuilder.ts b/src/query-builder/SelectQueryBuilder.ts index baeaff24f9..483b2dced8 100644 --- a/src/query-builder/SelectQueryBuilder.ts +++ b/src/query-builder/SelectQueryBuilder.ts @@ -979,6 +979,14 @@ export class SelectQueryBuilder extends QueryBuilder implements } + /** + * Disables the global condition of "non-deleted" for the entity with delete date columns. + */ + withDeleted(): this { + this.expressionMap.withDeleted = true; + return this; + } + /** * Gets first raw result returned by execution of generated query builder sql. */ diff --git a/src/query-builder/SoftDeleteQueryBuilder.ts b/src/query-builder/SoftDeleteQueryBuilder.ts new file mode 100644 index 0000000000..cd9b11e605 --- /dev/null +++ b/src/query-builder/SoftDeleteQueryBuilder.ts @@ -0,0 +1,459 @@ +import {CockroachDriver} from "../driver/cockroachdb/CockroachDriver"; +import {QueryBuilder} from "./QueryBuilder"; +import {ObjectLiteral} from "../common/ObjectLiteral"; +import {ObjectType} from "../common/ObjectType"; +import {Connection} from "../connection/Connection"; +import {QueryRunner} from "../query-runner/QueryRunner"; +import {SqlServerDriver} from "../driver/sqlserver/SqlServerDriver"; +import {PostgresDriver} from "../driver/postgres/PostgresDriver"; +import {WhereExpression} from "./WhereExpression"; +import {Brackets} from "./Brackets"; +import {UpdateResult} from "./result/UpdateResult"; +import {ReturningStatementNotSupportedError} from "../error/ReturningStatementNotSupportedError"; +import {ReturningResultsEntityUpdator} from "./ReturningResultsEntityUpdator"; +import {SqljsDriver} from "../driver/sqljs/SqljsDriver"; +import {MysqlDriver} from "../driver/mysql/MysqlDriver"; +import {BroadcasterResult} from "../subscriber/BroadcasterResult"; +import {AbstractSqliteDriver} from "../driver/sqlite-abstract/AbstractSqliteDriver"; +import {OrderByCondition} from "../find-options/OrderByCondition"; +import {LimitOnUpdateNotSupportedError} from "../error/LimitOnUpdateNotSupportedError"; +import {MissingDeleteDateColumnError} from "../error/MissingDeleteDateColumnError"; +import {OracleDriver} from "../driver/oracle/OracleDriver"; +import {UpdateValuesMissingError} from "../error/UpdateValuesMissingError"; +import {EntitySchema} from "../entity-schema/EntitySchema"; + +/** + * Allows to build complex sql queries in a fashion way and execute those queries. + */ +export class SoftDeleteQueryBuilder extends QueryBuilder implements WhereExpression { + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + constructor(connectionOrQueryBuilder: Connection|QueryBuilder, queryRunner?: QueryRunner) { + super(connectionOrQueryBuilder as any, queryRunner); + this.expressionMap.aliasNamePrefixingEnabled = false; + } + + // ------------------------------------------------------------------------- + // Public Implemented Methods + // ------------------------------------------------------------------------- + + /** + * Gets generated sql query without parameters being replaced. + */ + getQuery(): string { + let sql = this.createUpdateExpression(); + sql += this.createOrderByExpression(); + sql += this.createLimitExpression(); + return sql.trim(); + } + + /** + * Executes sql generated by query builder and returns raw database results. + */ + async execute(): Promise { + const queryRunner = this.obtainQueryRunner(); + let transactionStartedByUs: boolean = false; + + try { + + // start transaction if it was enabled + if (this.expressionMap.useTransaction === true && queryRunner.isTransactionActive === false) { + await queryRunner.startTransaction(); + transactionStartedByUs = true; + } + + // call before updation methods in listeners and subscribers + if (this.expressionMap.callListeners === true && this.expressionMap.mainAlias!.hasMetadata) { + const broadcastResult = new BroadcasterResult(); + queryRunner.broadcaster.broadcastBeforeUpdateEvent(broadcastResult, this.expressionMap.mainAlias!.metadata); + if (broadcastResult.promises.length > 0) await Promise.all(broadcastResult.promises); + } + + // if update entity mode is enabled we may need extra columns for the returning statement + const returningResultsEntityUpdator = new ReturningResultsEntityUpdator(queryRunner, this.expressionMap); + if (this.expressionMap.updateEntity === true && + this.expressionMap.mainAlias!.hasMetadata && + this.expressionMap.whereEntities.length > 0) { + this.expressionMap.extraReturningColumns = returningResultsEntityUpdator.getUpdationReturningColumns(); + } + + // execute update query + const [sql, parameters] = this.getQueryAndParameters(); + const updateResult = new UpdateResult(); + const result = await queryRunner.query(sql, parameters); + + const driver = queryRunner.connection.driver; + if (driver instanceof PostgresDriver) { + updateResult.raw = result[0]; + updateResult.affected = result[1]; + } + else { + updateResult.raw = result; + } + + // if we are updating entities and entity updation is enabled we must update some of entity columns (like version, update date, etc.) + if (this.expressionMap.updateEntity === true && + this.expressionMap.mainAlias!.hasMetadata && + this.expressionMap.whereEntities.length > 0) { + await returningResultsEntityUpdator.update(updateResult, this.expressionMap.whereEntities); + } + + // call after updation methods in listeners and subscribers + if (this.expressionMap.callListeners === true && this.expressionMap.mainAlias!.hasMetadata) { + const broadcastResult = new BroadcasterResult(); + queryRunner.broadcaster.broadcastAfterUpdateEvent(broadcastResult, this.expressionMap.mainAlias!.metadata); + if (broadcastResult.promises.length > 0) await Promise.all(broadcastResult.promises); + } + + // close transaction if we started it + if (transactionStartedByUs) + await queryRunner.commitTransaction(); + + return updateResult; + + } catch (error) { + + // rollback transaction if we started it + if (transactionStartedByUs) { + try { + await queryRunner.rollbackTransaction(); + } catch (rollbackError) { } + } + throw error; + + } finally { + if (queryRunner !== this.queryRunner) { // means we created our own query runner + await queryRunner.release(); + } + if (this.connection.driver instanceof SqljsDriver && !queryRunner.isTransactionActive) { + await this.connection.driver.autoSave(); + } + } + } + + // ------------------------------------------------------------------------- + // Public Methods + // ------------------------------------------------------------------------- + + /** + * Specifies FROM which entity's table select/update/delete/soft-delete will be executed. + * Also sets a main string alias of the selection data. + */ + from(entityTarget: ObjectType|EntitySchema|string, aliasName?: string): SoftDeleteQueryBuilder { + entityTarget = entityTarget instanceof EntitySchema ? entityTarget.options.name : entityTarget; + const mainAlias = this.createFromAlias(entityTarget, aliasName); + this.expressionMap.setMainAlias(mainAlias); + return (this as any) as SoftDeleteQueryBuilder; + } + + /** + * Sets WHERE condition in the query builder. + * If you had previously WHERE expression defined, + * calling this function will override previously set WHERE conditions. + * Additionally you can add parameters used in where expression. + */ + where(where: string|((qb: this) => string)|Brackets|ObjectLiteral|ObjectLiteral[], parameters?: ObjectLiteral): this { + this.expressionMap.wheres = []; // don't move this block below since computeWhereParameter can add where expressions + const condition = this.computeWhereParameter(where); + if (condition) + this.expressionMap.wheres = [{ type: "simple", condition: condition }]; + if (parameters) + this.setParameters(parameters); + return this; + } + + /** + * Adds new AND WHERE condition in the query builder. + * Additionally you can add parameters used in where expression. + */ + andWhere(where: string|((qb: this) => string)|Brackets, parameters?: ObjectLiteral): this { + this.expressionMap.wheres.push({ type: "and", condition: this.computeWhereParameter(where) }); + if (parameters) this.setParameters(parameters); + return this; + } + + /** + * Adds new OR WHERE condition in the query builder. + * Additionally you can add parameters used in where expression. + */ + orWhere(where: string|((qb: this) => string)|Brackets, parameters?: ObjectLiteral): this { + this.expressionMap.wheres.push({ type: "or", condition: this.computeWhereParameter(where) }); + if (parameters) this.setParameters(parameters); + return this; + } + + /** + * Adds new AND WHERE with conditions for the given ids. + */ + whereInIds(ids: any|any[]): this { + return this.where(this.createWhereIdsExpression(ids)); + } + + /** + * Adds new AND WHERE with conditions for the given ids. + */ + andWhereInIds(ids: any|any[]): this { + return this.andWhere(this.createWhereIdsExpression(ids)); + } + + /** + * Adds new OR WHERE with conditions for the given ids. + */ + orWhereInIds(ids: any|any[]): this { + return this.orWhere(this.createWhereIdsExpression(ids)); + } + /** + * Optional returning/output clause. + * This will return given column values. + */ + output(columns: string[]): this; + + /** + * Optional returning/output clause. + * Returning is a SQL string containing returning statement. + */ + output(output: string): this; + + /** + * Optional returning/output clause. + */ + output(output: string|string[]): this; + + /** + * Optional returning/output clause. + */ + output(output: string|string[]): this { + return this.returning(output); + } + + /** + * Optional returning/output clause. + * This will return given column values. + */ + returning(columns: string[]): this; + + /** + * Optional returning/output clause. + * Returning is a SQL string containing returning statement. + */ + returning(returning: string): this; + + /** + * Optional returning/output clause. + */ + returning(returning: string|string[]): this; + + /** + * Optional returning/output clause. + */ + returning(returning: string|string[]): this { + + // not all databases support returning/output cause + if (!this.connection.driver.isReturningSqlSupported()) + throw new ReturningStatementNotSupportedError(); + + this.expressionMap.returning = returning; + return this; + } + + /** + * Sets ORDER BY condition in the query builder. + * If you had previously ORDER BY expression defined, + * calling this function will override previously set ORDER BY conditions. + * + * Calling order by without order set will remove all previously set order bys. + */ + orderBy(): this; + + /** + * Sets ORDER BY condition in the query builder. + * If you had previously ORDER BY expression defined, + * calling this function will override previously set ORDER BY conditions. + */ + orderBy(sort: string, order?: "ASC"|"DESC", nulls?: "NULLS FIRST"|"NULLS LAST"): this; + + /** + * Sets ORDER BY condition in the query builder. + * If you had previously ORDER BY expression defined, + * calling this function will override previously set ORDER BY conditions. + */ + orderBy(order: OrderByCondition): this; + + /** + * Sets ORDER BY condition in the query builder. + * If you had previously ORDER BY expression defined, + * calling this function will override previously set ORDER BY conditions. + */ + orderBy(sort?: string|OrderByCondition, order: "ASC"|"DESC" = "ASC", nulls?: "NULLS FIRST"|"NULLS LAST"): this { + if (sort) { + if (sort instanceof Object) { + this.expressionMap.orderBys = sort as OrderByCondition; + } else { + if (nulls) { + this.expressionMap.orderBys = { [sort as string]: { order, nulls } }; + } else { + this.expressionMap.orderBys = { [sort as string]: order }; + } + } + } else { + this.expressionMap.orderBys = {}; + } + return this; + } + + /** + * Adds ORDER BY condition in the query builder. + */ + addOrderBy(sort: string, order: "ASC"|"DESC" = "ASC", nulls?: "NULLS FIRST"|"NULLS LAST"): this { + if (nulls) { + this.expressionMap.orderBys[sort] = { order, nulls }; + } else { + this.expressionMap.orderBys[sort] = order; + } + return this; + } + + /** + * Sets LIMIT - maximum number of rows to be selected. + */ + limit(limit?: number): this { + this.expressionMap.limit = limit; + return this; + } + + /** + * Indicates if entity must be updated after update operation. + * This may produce extra query or use RETURNING / OUTPUT statement (depend on database). + * Enabled by default. + */ + whereEntity(entity: Entity|Entity[]): this { + if (!this.expressionMap.mainAlias!.hasMetadata) + throw new Error(`.whereEntity method can only be used on queries which update real entity table.`); + + this.expressionMap.wheres = []; + const entities: Entity[] = entity instanceof Array ? entity : [entity]; + entities.forEach(entity => { + + const entityIdMap = this.expressionMap.mainAlias!.metadata.getEntityIdMap(entity); + if (!entityIdMap) + throw new Error(`Provided entity does not have ids set, cannot perform operation.`); + + this.orWhereInIds(entityIdMap); + }); + + this.expressionMap.whereEntities = entities; + return this; + } + + /** + * Indicates if entity must be updated after update operation. + * This may produce extra query or use RETURNING / OUTPUT statement (depend on database). + * Enabled by default. + */ + updateEntity(enabled: boolean): this { + this.expressionMap.updateEntity = enabled; + return this; + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Creates UPDATE express used to perform insert query. + */ + protected createUpdateExpression() { + const metadata = this.expressionMap.mainAlias!.hasMetadata ? this.expressionMap.mainAlias!.metadata : undefined; + if (!metadata) + throw new Error(`Cannot get entity metadata for the given alias "${this.expressionMap.mainAlias}"`); + if (!metadata.deleteDateColumn) { + throw new MissingDeleteDateColumnError(metadata); + } + + // prepare columns and values to be updated + const updateColumnAndValues: string[] = []; + const newParameters: ObjectLiteral = {}; + + switch (this.expressionMap.queryType) { + case "soft-delete": + updateColumnAndValues.push(this.escape(metadata.deleteDateColumn.databaseName) + " = CURRENT_TIMESTAMP"); + break; + case "restore": + updateColumnAndValues.push(this.escape(metadata.deleteDateColumn.databaseName) + " = NULL"); + break; + default: + throw new Error(`The queryType must be "soft-delete" or "restore"`); + } + if (metadata.versionColumn) + updateColumnAndValues.push(this.escape(metadata.versionColumn.databaseName) + " = " + this.escape(metadata.versionColumn.databaseName) + " + 1"); + if (metadata.updateDateColumn) + updateColumnAndValues.push(this.escape(metadata.updateDateColumn.databaseName) + " = CURRENT_TIMESTAMP"); // todo: fix issue with CURRENT_TIMESTAMP(6) being used, can "DEFAULT" be used?! + + if (updateColumnAndValues.length <= 0) { + throw new UpdateValuesMissingError(); + } + + // we re-write parameters this way because we want our "UPDATE ... SET" parameters to be first in the list of "nativeParameters" + // because some drivers like mysql depend on order of parameters + if (this.connection.driver instanceof MysqlDriver || + this.connection.driver instanceof OracleDriver || + this.connection.driver instanceof AbstractSqliteDriver) { + this.expressionMap.nativeParameters = Object.assign(newParameters, this.expressionMap.nativeParameters); + } + + // get a table name and all column database names + const whereExpression = this.createWhereExpression(); + const returningExpression = this.createReturningExpression(); + + // generate and return sql update query + if (returningExpression && (this.connection.driver instanceof PostgresDriver || this.connection.driver instanceof OracleDriver || this.connection.driver instanceof CockroachDriver)) { + return `UPDATE ${this.getTableName(this.getMainTableName())} SET ${updateColumnAndValues.join(", ")}${whereExpression} RETURNING ${returningExpression}`; + + } else if (returningExpression && this.connection.driver instanceof SqlServerDriver) { + return `UPDATE ${this.getTableName(this.getMainTableName())} SET ${updateColumnAndValues.join(", ")} OUTPUT ${returningExpression}${whereExpression}`; + + } else { + return `UPDATE ${this.getTableName(this.getMainTableName())} SET ${updateColumnAndValues.join(", ")}${whereExpression}`; // todo: how do we replace aliases in where to nothing? + } + } + + /** + * Creates "ORDER BY" part of SQL query. + */ + protected createOrderByExpression() { + const orderBys = this.expressionMap.orderBys; + if (Object.keys(orderBys).length > 0) + return " ORDER BY " + Object.keys(orderBys) + .map(columnName => { + if (typeof orderBys[columnName] === "string") { + return this.replacePropertyNames(columnName) + " " + orderBys[columnName]; + } else { + return this.replacePropertyNames(columnName) + " " + (orderBys[columnName] as any).order + " " + (orderBys[columnName] as any).nulls; + } + }) + .join(", "); + + return ""; + } + + /** + * Creates "LIMIT" parts of SQL query. + */ + protected createLimitExpression(): string { + let limit: number|undefined = this.expressionMap.limit; + + if (limit) { + if (this.connection.driver instanceof MysqlDriver) { + return " LIMIT " + limit; + } else { + throw new LimitOnUpdateNotSupportedError(); + } + } + + return ""; + } + +} \ No newline at end of file diff --git a/src/repository/Repository.ts b/src/repository/Repository.ts index a91a795a73..2a696de0f3 100644 --- a/src/repository/Repository.ts +++ b/src/repository/Repository.ts @@ -167,6 +167,60 @@ export class Repository { return this.manager.remove(this.metadata.target as any, entityOrEntities as any, options); } + /** + * Records the delete date of all given entities. + */ + softRemove>(entities: T[], options: SaveOptions & { reload: false }): Promise; + + /** + * Records the delete date of all given entities. + */ + softRemove>(entities: T[], options?: SaveOptions): Promise<(T & Entity)[]>; + + /** + * Records the delete date of a given entity. + */ + softRemove>(entity: T, options: SaveOptions & { reload: false }): Promise; + + /** + * Records the delete date of a given entity. + */ + softRemove>(entity: T, options?: SaveOptions): Promise; + + /** + * Records the delete date of one or many given entities. + */ + softRemove>(entityOrEntities: T|T[], options?: SaveOptions): Promise { + return this.manager.softRemove(this.metadata.target as any, entityOrEntities as any, options); + } + + /** + * Recovers all given entities in the database. + */ + recover>(entities: T[], options: SaveOptions & { reload: false }): Promise; + + /** + * Recovers all given entities in the database. + */ + recover>(entities: T[], options?: SaveOptions): Promise<(T & Entity)[]>; + + /** + * Recovers a given entity in the database. + */ + recover>(entity: T, options: SaveOptions & { reload: false }): Promise; + + /** + * Recovers a given entity in the database. + */ + recover>(entity: T, options?: SaveOptions): Promise; + + /** + * Recovers one or many given entities. + */ + recover>(entityOrEntities: T|T[], options?: SaveOptions): Promise { + return this.manager.recover(this.metadata.target as any, entityOrEntities as any, options); + } + /** * Inserts a given entity into the database. * Unlike save method executes a primitive operation without cascades, relations and other operations included. @@ -197,6 +251,26 @@ export class Repository { return this.manager.delete(this.metadata.target as any, criteria as any); } + /** + * Records the delete date of entities by a given criteria. + * Unlike save method executes a primitive operation without cascades, relations and other operations included. + * Executes fast and efficient SOFT-DELETE query. + * Does not check if entity exist in the database. + */ + softDelete(criteria: string|string[]|number|number[]|Date|Date[]|ObjectID|ObjectID[]|FindConditions): Promise { + return this.manager.softDelete(this.metadata.target as any, criteria as any); + } + + /** + * Restores entities by a given criteria. + * Unlike save method executes a primitive operation without cascades, relations and other operations included. + * Executes fast and efficient SOFT-DELETE query. + * Does not check if entity exist in the database. + */ + restore(criteria: string|string[]|number|number[]|Date|Date[]|ObjectID|ObjectID[]|FindConditions): Promise { + return this.manager.restore(this.metadata.target as any, criteria as any); + } + /** * Counts entities that match given options. */ diff --git a/test/functional/embedded/embedded-with-special-columns/embedded-with-special-columns.ts b/test/functional/embedded/embedded-with-special-columns/embedded-with-special-columns.ts index a239a1023e..248f9dc192 100644 --- a/test/functional/embedded/embedded-with-special-columns/embedded-with-special-columns.ts +++ b/test/functional/embedded/embedded-with-special-columns/embedded-with-special-columns.ts @@ -20,7 +20,7 @@ describe("embedded > embedded-with-special-columns", () => { beforeEach(() => reloadTestingDatabases(connections)); after(() => closeTestingConnections(connections)); - it("should insert, load, update and remove entities with embeddeds when embeds contains special columns (e.g. CreateDateColumn, UpdateDateColumn, VersionColumn", () => Promise.all(connections.map(async connection => { + it("should insert, load, update and remove entities with embeddeds when embeds contains special columns (e.g. CreateDateColumn, UpdateDateColumn, DeleteDateColumn, VersionColumn", () => Promise.all(connections.map(async connection => { const post1 = new Post(); post1.id = 1; @@ -51,9 +51,11 @@ describe("embedded > embedded-with-special-columns", () => { expect(loadedPosts[0].counters.createdDate.should.be.instanceof(Date)); expect(loadedPosts[0].counters.updatedDate.should.be.instanceof(Date)); + expect(loadedPosts[0].counters.deletedDate).to.be.null; expect(loadedPosts[0].counters.subcounters.version.should.be.equal(1)); expect(loadedPosts[1].counters.createdDate.should.be.instanceof(Date)); expect(loadedPosts[1].counters.updatedDate.should.be.instanceof(Date)); + expect(loadedPosts[1].counters.deletedDate).to.be.null; expect(loadedPosts[1].counters.subcounters.version.should.be.equal(1)); let loadedPost = await connection.manager @@ -64,6 +66,7 @@ describe("embedded > embedded-with-special-columns", () => { expect(loadedPost!.counters.createdDate.should.be.instanceof(Date)); expect(loadedPost!.counters.updatedDate.should.be.instanceof(Date)); + expect(loadedPost!.counters.deletedDate).to.be.null; expect(loadedPost!.counters.subcounters.version.should.be.equal(1)); const prevUpdateDate = loadedPost!.counters.updatedDate; diff --git a/test/functional/embedded/embedded-with-special-columns/entity/Counters.ts b/test/functional/embedded/embedded-with-special-columns/entity/Counters.ts index cd91ad38fb..9f32765e1f 100644 --- a/test/functional/embedded/embedded-with-special-columns/entity/Counters.ts +++ b/test/functional/embedded/embedded-with-special-columns/entity/Counters.ts @@ -1,8 +1,10 @@ import {Column} from "../../../../../src/decorator/columns/Column"; import {CreateDateColumn} from "../../../../../src/decorator/columns/CreateDateColumn"; import {UpdateDateColumn} from "../../../../../src/decorator/columns/UpdateDateColumn"; +import {DeleteDateColumn} from "../../../../../src/decorator/columns/DeleteDateColumn"; import {Subcounters} from "./Subcounters"; + export class Counters { @Column() @@ -23,4 +25,6 @@ export class Counters { @UpdateDateColumn() updatedDate: Date; + @DeleteDateColumn() + deletedDate: Date; } \ No newline at end of file diff --git a/test/functional/persistence/cascades/cascades-soft-remove/cascades-soft-remove.ts b/test/functional/persistence/cascades/cascades-soft-remove/cascades-soft-remove.ts new file mode 100644 index 0000000000..f764ababea --- /dev/null +++ b/test/functional/persistence/cascades/cascades-soft-remove/cascades-soft-remove.ts @@ -0,0 +1,54 @@ +import "reflect-metadata"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../../utils/test-utils"; +import {Connection} from "../../../../../src/connection/Connection"; +import {Photo} from "./entity/Photo"; +import {User} from "./entity/User"; +import { IsNull } from "../../../../../src"; + +// todo: fix later +describe.skip("persistence > cascades > remove", () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ __dirname, enabledDrivers: ["mysql"] })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it("should soft-remove everything by cascades properly", () => Promise.all(connections.map(async connection => { + + await connection.manager.save(new Photo("Photo #1")); + + const user = new User(); + user.id = 1; + user.name = "Mr. Cascade Danger"; + user.manyPhotos = [new Photo("one-to-many #1"), new Photo("one-to-many #2")]; + user.manyToManyPhotos = [new Photo("many-to-many #1"), new Photo("many-to-many #2"), new Photo("many-to-many #3")]; + await connection.manager.save(user); + + const loadedUser = await connection.manager + .createQueryBuilder(User, "user") + .leftJoinAndSelect("user.manyPhotos", "manyPhotos") + .leftJoinAndSelect("user.manyToManyPhotos", "manyToManyPhotos") + .getOne(); + + loadedUser!.id.should.be.equal(1); + loadedUser!.name.should.be.equal("Mr. Cascade Danger"); + + const manyPhotoNames = loadedUser!.manyPhotos.map(photo => photo.name); + manyPhotoNames.length.should.be.equal(2); + manyPhotoNames.should.deep.include("one-to-many #1"); + manyPhotoNames.should.deep.include("one-to-many #2"); + + const manyToManyPhotoNames = loadedUser!.manyToManyPhotos.map(photo => photo.name); + manyToManyPhotoNames.length.should.be.equal(3); + manyToManyPhotoNames.should.deep.include("many-to-many #1"); + manyToManyPhotoNames.should.deep.include("many-to-many #2"); + manyToManyPhotoNames.should.deep.include("many-to-many #3"); + + await connection.manager.softRemove(user); + + const allPhotos = await connection.manager.find(Photo, {deletedAt: IsNull()}); + allPhotos.length.should.be.equal(1); + allPhotos[0].name.should.be.equal("Photo #1"); + }))); + +}); diff --git a/test/functional/persistence/cascades/cascades-soft-remove/entity/Photo.ts b/test/functional/persistence/cascades/cascades-soft-remove/entity/Photo.ts new file mode 100644 index 0000000000..64032a4708 --- /dev/null +++ b/test/functional/persistence/cascades/cascades-soft-remove/entity/Photo.ts @@ -0,0 +1,27 @@ +import {Entity} from "../../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {ManyToOne} from "../../../../../../src/decorator/relations/ManyToOne"; +import {User} from "./User"; +import {Column} from "../../../../../../src/decorator/columns/Column"; +import {DeleteDateColumn} from "../../../../../../src/decorator/columns/DeleteDateColumn"; + +@Entity() +export class Photo { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @DeleteDateColumn() + deletedAt: Date; + + @ManyToOne(type => User, user => user.manyPhotos) + user: User; + + constructor(name: string) { + this.name = name; + } + +} \ No newline at end of file diff --git a/test/functional/persistence/cascades/cascades-soft-remove/entity/User.ts b/test/functional/persistence/cascades/cascades-soft-remove/entity/User.ts new file mode 100644 index 0000000000..a3ce7e54a5 --- /dev/null +++ b/test/functional/persistence/cascades/cascades-soft-remove/entity/User.ts @@ -0,0 +1,29 @@ +import {PrimaryColumn} from "../../../../../../src/decorator/columns/PrimaryColumn"; +import {Entity} from "../../../../../../src/decorator/entity/Entity"; +import {ManyToMany} from "../../../../../../src/decorator/relations/ManyToMany"; +import {Photo} from "./Photo"; +import {OneToMany} from "../../../../../../src/decorator/relations/OneToMany"; +import {JoinTable} from "../../../../../../src/decorator/relations/JoinTable"; +import {Column} from "../../../../../../src/decorator/columns/Column"; +import {DeleteDateColumn} from "../../../../../../src/decorator/columns/DeleteDateColumn"; + +@Entity() +export class User { // todo: check one-to-one relation as well, but in another model or test + + @PrimaryColumn() + id: number; + + @Column() + name: string; + + @DeleteDateColumn() + deletedAt: Date; + + @OneToMany(type => Photo, photo => photo.user, { cascade: true }) + manyPhotos: Photo[]; + + @ManyToMany(type => Photo, { cascade: true }) + @JoinTable() + manyToManyPhotos: Photo[]; + +} diff --git a/test/functional/persistence/persistence-options/listeners/entity/PostWithDeleteDateColumn.ts b/test/functional/persistence/persistence-options/listeners/entity/PostWithDeleteDateColumn.ts new file mode 100644 index 0000000000..23d2cfb39d --- /dev/null +++ b/test/functional/persistence/persistence-options/listeners/entity/PostWithDeleteDateColumn.ts @@ -0,0 +1,35 @@ +import {Entity} from "../../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../../../src/decorator/columns/Column"; +import {BeforeUpdate} from "../../../../../../src/decorator/listeners/BeforeUpdate"; +import {AfterUpdate} from "../../../../../../src/decorator/listeners/AfterUpdate"; +import {DeleteDateColumn} from "../../../../../../src/decorator/columns/DeleteDateColumn"; + +@Entity() +export class PostWithDeleteDateColumn { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @Column() + description: string; + + @DeleteDateColumn() + deletedAt: Date; + + isSoftRemoved: boolean = false; + + @BeforeUpdate() + beforeUpdate() { + this.title += "!"; + } + + @AfterUpdate() + afterUpdate() { + this.isSoftRemoved = true; + } + +} \ No newline at end of file diff --git a/test/functional/persistence/persistence-options/listeners/persistence-options-listeners.ts b/test/functional/persistence/persistence-options/listeners/persistence-options-listeners.ts index 1a42b6af59..946104fbf3 100644 --- a/test/functional/persistence/persistence-options/listeners/persistence-options-listeners.ts +++ b/test/functional/persistence/persistence-options/listeners/persistence-options-listeners.ts @@ -2,6 +2,7 @@ import "reflect-metadata"; import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../../utils/test-utils"; import {Post} from "./entity/Post"; import {Connection} from "../../../../../src/connection/Connection"; +import {PostWithDeleteDateColumn} from "./entity/PostWithDeleteDateColumn"; // import {expect} from "chai"; describe("persistence > persistence options > listeners", () => { @@ -53,4 +54,24 @@ describe("persistence > persistence options > listeners", () => { post.isRemoved.should.be.equal(false); }))); + it("soft-remove listeners should work by default", () => Promise.all(connections.map(async connection => { + const post = new PostWithDeleteDateColumn(); + post.title = "Bakhrom"; + post.description = "Hello"; + await connection.manager.save(post); + await connection.manager.softRemove(post); + post.title.should.be.equal("Bakhrom!"); + post.isSoftRemoved.should.be.equal(true); + }))); + + it("soft-remove listeners should be disabled if remove option is specified", () => Promise.all(connections.map(async connection => { + const post = new PostWithDeleteDateColumn(); + post.title = "Bakhrom"; + post.description = "Hello"; + await connection.manager.save(post); + await connection.manager.softRemove(post, { listeners: false }); + post.title.should.be.equal("Bakhrom"); + post.isSoftRemoved.should.be.equal(false); + }))); + }); diff --git a/test/functional/query-builder/soft-delete/entity/Counters.ts b/test/functional/query-builder/soft-delete/entity/Counters.ts new file mode 100644 index 0000000000..1dfc13a2bd --- /dev/null +++ b/test/functional/query-builder/soft-delete/entity/Counters.ts @@ -0,0 +1,16 @@ +import {Column} from "../../../../../src/decorator/columns/Column"; +import {DeleteDateColumn} from "../../../../../src/decorator/columns/DeleteDateColumn"; +export class Counters { + + @Column({ default: 1 }) + likes: number; + + @Column({ nullable: true }) + favorites: number; + + @Column({ default: 0 }) + comments: number; + + @DeleteDateColumn() + deletedAt: Date; +} \ No newline at end of file diff --git a/test/functional/query-builder/soft-delete/entity/Photo.ts b/test/functional/query-builder/soft-delete/entity/Photo.ts new file mode 100644 index 0000000000..2a7124f21d --- /dev/null +++ b/test/functional/query-builder/soft-delete/entity/Photo.ts @@ -0,0 +1,18 @@ +import {Entity} from "../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../../src/decorator/columns/Column"; +import {Counters} from "./Counters"; + +@Entity() +export class Photo { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + url: string; + + @Column(type => Counters) + counters: Counters; + +} \ No newline at end of file diff --git a/test/functional/query-builder/soft-delete/entity/User.ts b/test/functional/query-builder/soft-delete/entity/User.ts new file mode 100644 index 0000000000..92b68796be --- /dev/null +++ b/test/functional/query-builder/soft-delete/entity/User.ts @@ -0,0 +1,21 @@ +import {Entity} from "../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {DeleteDateColumn} from "../../../../../src/decorator/columns/DeleteDateColumn"; +import {Column} from "../../../../../src/decorator/columns/Column"; + +@Entity() +export class User { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column() + likesCount: number = 0; + + @DeleteDateColumn() + deletedAt: Date; + +} \ No newline at end of file diff --git a/test/functional/query-builder/soft-delete/entity/UserWithoutDeleteDateColumn.ts b/test/functional/query-builder/soft-delete/entity/UserWithoutDeleteDateColumn.ts new file mode 100644 index 0000000000..3f27d84ec5 --- /dev/null +++ b/test/functional/query-builder/soft-delete/entity/UserWithoutDeleteDateColumn.ts @@ -0,0 +1,17 @@ +import {Entity} from "../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../../src/decorator/columns/Column"; + +@Entity() +export class UserWithoutDeleteDateColumn { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column() + likesCount: number = 0; + +} \ No newline at end of file diff --git a/test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/CategoryWithRelation.ts b/test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/CategoryWithRelation.ts new file mode 100644 index 0000000000..c15b5800c9 --- /dev/null +++ b/test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/CategoryWithRelation.ts @@ -0,0 +1,18 @@ +import {Column} from "../../../../../../src/decorator/columns/Column"; +import {PrimaryColumn} from "../../../../../../src/decorator/columns/PrimaryColumn"; +import {Entity} from "../../../../../../src/decorator/entity/Entity"; +import {OneToOne} from "../../../../../../src/decorator/relations/OneToOne"; +import {PostWithRelation} from "./PostWithRelation"; + +@Entity() +export class CategoryWithRelation { + + @PrimaryColumn() + id: number; + + @Column({ unique: true }) + name: string; + + @OneToOne(type => PostWithRelation, post => post.category) + post: PostWithRelation; +} \ No newline at end of file diff --git a/test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/Post.ts b/test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/Post.ts new file mode 100644 index 0000000000..f269871669 --- /dev/null +++ b/test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/Post.ts @@ -0,0 +1,18 @@ +import {Entity} from "../../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../../../src/decorator/columns/Column"; +import {DeleteDateColumn} from "../../../../../../src/decorator/columns/DeleteDateColumn"; + +@Entity() +export class Post { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @DeleteDateColumn() + deletedAt: Date; + +} diff --git a/test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/PostWithRelation.ts b/test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/PostWithRelation.ts new file mode 100644 index 0000000000..2e7558b580 --- /dev/null +++ b/test/functional/query-builder/soft-delete/global-condition-non-deleted/entity/PostWithRelation.ts @@ -0,0 +1,24 @@ +import {Entity} from "../../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../../../src/decorator/columns/Column"; +import {OneToOne} from "../../../../../../src/decorator/relations/OneToOne"; +import {JoinColumn} from "../../../../../../src/decorator/relations/JoinColumn"; +import {DeleteDateColumn} from "../../../../../../src/decorator/columns/DeleteDateColumn"; +import {CategoryWithRelation} from "./CategoryWithRelation"; + +@Entity() +export class PostWithRelation { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @OneToOne(type => CategoryWithRelation, category => category.post, { eager: true }) + @JoinColumn() + category: CategoryWithRelation; + + @DeleteDateColumn() + deletedAt: Date; +} diff --git a/test/functional/query-builder/soft-delete/global-condition-non-deleted/query-builder-global-condition-non-deleted-with-eager-relation.ts b/test/functional/query-builder/soft-delete/global-condition-non-deleted/query-builder-global-condition-non-deleted-with-eager-relation.ts new file mode 100644 index 0000000000..c632e4d2fc --- /dev/null +++ b/test/functional/query-builder/soft-delete/global-condition-non-deleted/query-builder-global-condition-non-deleted-with-eager-relation.ts @@ -0,0 +1,92 @@ +import "reflect-metadata"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../../utils/test-utils"; +import {Connection} from "../../../../../src/connection/Connection"; +import {PostWithRelation} from "./entity/PostWithRelation"; + +// This test is neccessary because finding with eager relation will be run in the different way +describe(`query builder > find with the global condition of "non-deleted" and eager relation`, () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it(`The global condition of "non-deleted" should be set for the entity with delete date columns and eager relation`, + () => Promise.all(connections.map(async connection => { + + const post1 = new PostWithRelation(); + post1.title = "title#1"; + const post2 = new PostWithRelation(); + post2.title = "title#2"; + const post3 = new PostWithRelation(); + post3.title = "title#3"; + + await connection.manager.save(post1); + await connection.manager.save(post2); + await connection.manager.save(post3); + + await connection.manager.softRemove(post1); + + const loadedWithPosts = await connection + .createQueryBuilder() + .select("post") + .from(PostWithRelation, "post") + .orderBy("post.id") + .getMany(); + loadedWithPosts!.length.should.be.equal(2); + loadedWithPosts![0].title.should.be.equals("title#2"); + loadedWithPosts![1].title.should.be.equals("title#3"); + + const loadedWithPost = await connection + .createQueryBuilder() + .select("post") + .from(PostWithRelation, "post") + .orderBy("post.id") + .getOne(); + loadedWithPost!.title.should.be.equals("title#2"); + + })) + ); + + + it(`The global condition of "non-deleted" should not be set when "withDeleted" is called`, () => Promise.all(connections.map(async connection => { + + const post1 = new PostWithRelation(); + post1.title = "title#1"; + const post2 = new PostWithRelation(); + post2.title = "title#2"; + const post3 = new PostWithRelation(); + post3.title = "title#3"; + + await connection.manager.save(post1); + await connection.manager.save(post2); + await connection.manager.save(post3); + + await connection.manager.softRemove(post1); + + const loadedPosts = await connection + .createQueryBuilder() + .select("post") + .from(PostWithRelation, "post") + .withDeleted() + .orderBy("post.id") + .getMany(); + + loadedPosts!.length.should.be.equal(3); + loadedPosts![0].title.should.be.equals("title#1"); + loadedPosts![1].title.should.be.equals("title#2"); + loadedPosts![2].title.should.be.equals("title#3"); + + const loadedWithoutScopePost = await connection + .createQueryBuilder() + .select("post") + .from(PostWithRelation, "post") + .withDeleted() + .orderBy("post.id") + .getOne(); + loadedWithoutScopePost!.title.should.be.equals("title#1"); + + }))); +}); \ No newline at end of file diff --git a/test/functional/query-builder/soft-delete/global-condition-non-deleted/query-builder-global-condition-non-deleted.ts b/test/functional/query-builder/soft-delete/global-condition-non-deleted/query-builder-global-condition-non-deleted.ts new file mode 100644 index 0000000000..d52b321c1f --- /dev/null +++ b/test/functional/query-builder/soft-delete/global-condition-non-deleted/query-builder-global-condition-non-deleted.ts @@ -0,0 +1,89 @@ +import "reflect-metadata"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../../utils/test-utils"; +import {Connection} from "../../../../../src/connection/Connection"; +import {Post} from "./entity/Post"; + +describe(`query builder > find with the global condition of "non-deleted"`, () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it(`The global condition of "non-deleted" should be set for the entity with delete date columns`, () => Promise.all(connections.map(async connection => { + + const post1 = new Post(); + post1.title = "title#1"; + const post2 = new Post(); + post2.title = "title#2"; + const post3 = new Post(); + post3.title = "title#3"; + + await connection.manager.save(post1); + await connection.manager.save(post2); + await connection.manager.save(post3); + + await connection.manager.softRemove(post1); + + const loadedPosts = await connection + .createQueryBuilder() + .select("post") + .from(Post, "post") + .orderBy("post.id") + .getMany(); + + loadedPosts!.length.should.be.equal(2); + loadedPosts![0].title.should.be.equals("title#2"); + loadedPosts![1].title.should.be.equals("title#3"); + + const loadedPost = await connection + .createQueryBuilder() + .select("post") + .from(Post, "post") + .orderBy("post.id") + .getOne(); + loadedPost!.title.should.be.equals("title#2"); + + }))); + + it(`The global condition of "non-deleted" should not be set when "withDeleted" is called`, () => Promise.all(connections.map(async connection => { + + const post1 = new Post(); + post1.title = "title#1"; + const post2 = new Post(); + post2.title = "title#2"; + const post3 = new Post(); + post3.title = "title#3"; + + await connection.manager.save(post1); + await connection.manager.save(post2); + await connection.manager.save(post3); + + await connection.manager.softRemove(post1); + + const loadedPosts = await connection + .createQueryBuilder() + .select("post") + .from(Post, "post") + .withDeleted() + .orderBy("post.id") + .getMany(); + + loadedPosts!.length.should.be.equal(3); + loadedPosts![0].title.should.be.equals("title#1"); + loadedPosts![1].title.should.be.equals("title#2"); + loadedPosts![2].title.should.be.equals("title#3"); + + const loadedPost = await connection + .createQueryBuilder() + .select("post") + .from(Post, "post") + .withDeleted() + .orderBy("post.id") + .getOne(); + loadedPost!.title.should.be.equals("title#1"); + + }))); +}); \ No newline at end of file diff --git a/test/functional/query-builder/soft-delete/query-builder-soft-delete.ts b/test/functional/query-builder/soft-delete/query-builder-soft-delete.ts new file mode 100644 index 0000000000..1d38f57f59 --- /dev/null +++ b/test/functional/query-builder/soft-delete/query-builder-soft-delete.ts @@ -0,0 +1,238 @@ +import "reflect-metadata"; +import {expect} from "chai"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../utils/test-utils"; +import {Connection} from "../../../../src/connection/Connection"; +import {User} from "./entity/User"; +import {MysqlDriver} from "../../../../src/driver/mysql/MysqlDriver"; +import {LimitOnUpdateNotSupportedError} from "../../../../src/error/LimitOnUpdateNotSupportedError"; +import {Not, IsNull} from "../../../../src"; +import {MissingDeleteDateColumnError} from "../../../../src/error/MissingDeleteDateColumnError"; +import {UserWithoutDeleteDateColumn} from "./entity/UserWithoutDeleteDateColumn"; +import {Photo} from "./entity/Photo"; + +describe("query builder > soft-delete", () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it("should perform soft deletion and recovery correctly", () => Promise.all(connections.map(async connection => { + + const user = new User(); + user.name = "Alex Messer"; + + await connection.manager.save(user); + + await connection + .createQueryBuilder() + .softDelete() + .from(User) + .where("name = :name", { name: "Alex Messer" }) + .execute(); + + const loadedUser1 = await connection.getRepository(User).findOne( + { name: "Alex Messer" }, + { withDeleted: true } + ); + expect(loadedUser1).to.exist; + expect(loadedUser1!.deletedAt).to.be.instanceof(Date); + + await connection.getRepository(User) + .createQueryBuilder() + .restore() + .from(User) + .where("name = :name", { name: "Alex Messer" }) + .execute(); + + const loadedUser2 = await connection.getRepository(User).findOne({ name: "Alex Messer" }); + expect(loadedUser2).to.exist; + expect(loadedUser2!.deletedAt).to.be.equals(null); + + }))); + + it("should soft-delete and restore properties inside embeds as well", () => Promise.all(connections.map(async connection => { + + // save few photos + await connection.manager.save(Photo, { + url: "1.jpg", + counters: { + likes: 2, + favorites: 1, + comments: 1, + } + }); + await connection.manager.save(Photo, { + url: "2.jpg", + counters: { + likes: 0, + favorites: 1, + comments: 1, + } + }); + + // soft-delete photo now + await connection.getRepository(Photo) + .createQueryBuilder("photo") + .softDelete() + .where({ + counters: { + likes: 2 + } + }) + .execute(); + + const loadedPhoto1 = await connection.getRepository(Photo).findOne({ url: "1.jpg" }); + expect(loadedPhoto1).to.be.undefined; + + const loadedPhoto2 = await connection.getRepository(Photo).findOne({ url: "2.jpg" }); + loadedPhoto2!.should.be.eql({ + id: 2, + url: "2.jpg", + counters: { + likes: 0, + favorites: 1, + comments: 1, + deletedAt: null + } + }); + + // restore photo now + await connection.getRepository(Photo) + .createQueryBuilder("photo") + .restore() + .where({ + counters: { + likes: 2 + } + }) + .execute(); + + const restoredPhoto2 = await connection.getRepository(Photo).findOne({ url: "1.jpg" }); + restoredPhoto2!.should.be.eql({ + id: 1, + url: "1.jpg", + counters: { + likes: 2, + favorites: 1, + comments: 1, + deletedAt: null + } + }); + + + }))); + + it("should perform soft delete with limit correctly", () => Promise.all(connections.map(async connection => { + + const user1 = new User(); + user1.name = "Alex Messer"; + const user2 = new User(); + user2.name = "Muhammad Mirzoev"; + const user3 = new User(); + user3.name = "Brad Porter"; + + await connection.manager.save([user1, user2, user3]); + + const limitNum = 2; + + if (connection.driver instanceof MysqlDriver) { + await connection.createQueryBuilder() + .softDelete() + .from(User) + .limit(limitNum) + .execute(); + + const loadedUsers = await connection.getRepository(User).find({ + where: { + deletedAt: Not(IsNull()) + }, + withDeleted: true + }); + expect(loadedUsers).to.exist; + loadedUsers!.length.should.be.equal(limitNum); + } else { + await connection.createQueryBuilder() + .softDelete() + .from(User) + .limit(limitNum) + .execute().should.be.rejectedWith(LimitOnUpdateNotSupportedError); + } + + }))); + + + it("should perform restory with limit correctly", () => Promise.all(connections.map(async connection => { + + const user1 = new User(); + user1.name = "Alex Messer"; + const user2 = new User(); + user2.name = "Muhammad Mirzoev"; + const user3 = new User(); + user3.name = "Brad Porter"; + + await connection.manager.save([user1, user2, user3]); + + const limitNum = 2; + + if (connection.driver instanceof MysqlDriver) { + await connection.createQueryBuilder() + .softDelete() + .from(User) + .execute(); + + await connection.createQueryBuilder() + .restore() + .from(User) + .limit(limitNum) + .execute(); + + const loadedUsers = await connection.getRepository(User).find(); + expect(loadedUsers).to.exist; + loadedUsers!.length.should.be.equal(limitNum); + } else { + await connection.createQueryBuilder() + .restore() + .from(User) + .limit(limitNum) + .execute().should.be.rejectedWith(LimitOnUpdateNotSupportedError); + } + + }))); + + it("should throw error when delete date column is missing", () => Promise.all(connections.map(async connection => { + + const user = new UserWithoutDeleteDateColumn(); + user.name = "Alex Messer"; + + await connection.manager.save(user); + + let error1: Error | undefined; + try { + await connection.createQueryBuilder() + .softDelete() + .from(UserWithoutDeleteDateColumn) + .where("name = :name", { name: "Alex Messer" }) + .execute(); + } catch (err) { + error1 = err; + } + expect(error1).to.be.an.instanceof(MissingDeleteDateColumnError); + + let error2: Error | undefined; + try { + await connection.createQueryBuilder() + .restore() + .from(UserWithoutDeleteDateColumn) + .where("name = :name", { name: "Alex Messer" }) + .execute(); + } catch (err) { + error2 = err; + } + expect(error2).to.be.an.instanceof(MissingDeleteDateColumnError); + + }))); + +}); diff --git a/test/functional/repository/soft-delete/entity/Post.ts b/test/functional/repository/soft-delete/entity/Post.ts new file mode 100644 index 0000000000..8931d66597 --- /dev/null +++ b/test/functional/repository/soft-delete/entity/Post.ts @@ -0,0 +1,16 @@ +import {Entity} from "../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../../src/decorator/columns/Column"; +import {DeleteDateColumn} from "../../../../../src/decorator/columns/DeleteDateColumn"; + +@Entity() +export class Post { + @PrimaryGeneratedColumn() + id: number; + + @DeleteDateColumn() + deletedAt: Date; + + @Column() + name: string; +} \ No newline at end of file diff --git a/test/functional/repository/soft-delete/entity/PostWithoutDeleteDateColumn.ts b/test/functional/repository/soft-delete/entity/PostWithoutDeleteDateColumn.ts new file mode 100644 index 0000000000..2001119958 --- /dev/null +++ b/test/functional/repository/soft-delete/entity/PostWithoutDeleteDateColumn.ts @@ -0,0 +1,12 @@ +import {Entity} from "../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../../src/decorator/columns/Column"; + +@Entity() +export class PostWithoutDeleteDateColumn { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; +} \ No newline at end of file diff --git a/test/functional/repository/soft-delete/global-condition-non-deleted/entity/CategoryWithRelation.ts b/test/functional/repository/soft-delete/global-condition-non-deleted/entity/CategoryWithRelation.ts new file mode 100644 index 0000000000..c15b5800c9 --- /dev/null +++ b/test/functional/repository/soft-delete/global-condition-non-deleted/entity/CategoryWithRelation.ts @@ -0,0 +1,18 @@ +import {Column} from "../../../../../../src/decorator/columns/Column"; +import {PrimaryColumn} from "../../../../../../src/decorator/columns/PrimaryColumn"; +import {Entity} from "../../../../../../src/decorator/entity/Entity"; +import {OneToOne} from "../../../../../../src/decorator/relations/OneToOne"; +import {PostWithRelation} from "./PostWithRelation"; + +@Entity() +export class CategoryWithRelation { + + @PrimaryColumn() + id: number; + + @Column({ unique: true }) + name: string; + + @OneToOne(type => PostWithRelation, post => post.category) + post: PostWithRelation; +} \ No newline at end of file diff --git a/test/functional/repository/soft-delete/global-condition-non-deleted/entity/Post.ts b/test/functional/repository/soft-delete/global-condition-non-deleted/entity/Post.ts new file mode 100644 index 0000000000..f269871669 --- /dev/null +++ b/test/functional/repository/soft-delete/global-condition-non-deleted/entity/Post.ts @@ -0,0 +1,18 @@ +import {Entity} from "../../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../../../src/decorator/columns/Column"; +import {DeleteDateColumn} from "../../../../../../src/decorator/columns/DeleteDateColumn"; + +@Entity() +export class Post { + + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @DeleteDateColumn() + deletedAt: Date; + +} diff --git a/test/functional/repository/soft-delete/global-condition-non-deleted/entity/PostWithRelation.ts b/test/functional/repository/soft-delete/global-condition-non-deleted/entity/PostWithRelation.ts new file mode 100644 index 0000000000..790bf29172 --- /dev/null +++ b/test/functional/repository/soft-delete/global-condition-non-deleted/entity/PostWithRelation.ts @@ -0,0 +1,25 @@ +import {Entity} from "../../../../../../src/decorator/entity/Entity"; +import {PrimaryGeneratedColumn} from "../../../../../../src/decorator/columns/PrimaryGeneratedColumn"; +import {Column} from "../../../../../../src/decorator/columns/Column"; +import {OneToOne} from "../../../../../../src/decorator/relations/OneToOne"; +import {JoinColumn} from "../../../../../../src/decorator/relations/JoinColumn"; +import {CategoryWithRelation} from "./CategoryWithRelation"; +import {DeleteDateColumn} from "../../../../../../src/decorator/columns/DeleteDateColumn"; + +@Entity() +export class PostWithRelation { + + + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @OneToOne(type => CategoryWithRelation, category => category.post, { eager: true }) + @JoinColumn() + category: CategoryWithRelation; + + @DeleteDateColumn() + deletedAt: Date; +} diff --git a/test/functional/repository/soft-delete/global-condition-non-deleted/repository-global-condition-non-deleted-with-eager-relation.ts b/test/functional/repository/soft-delete/global-condition-non-deleted/repository-global-condition-non-deleted-with-eager-relation.ts new file mode 100644 index 0000000000..1fb09626bf --- /dev/null +++ b/test/functional/repository/soft-delete/global-condition-non-deleted/repository-global-condition-non-deleted-with-eager-relation.ts @@ -0,0 +1,91 @@ +import "reflect-metadata"; +import {expect} from "chai"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../../utils/test-utils"; +import {Connection} from "../../../../../src/connection/Connection"; +import {PostWithRelation} from "./entity/PostWithRelation"; + +// This test is neccessary because finding with eager relation will be run in the different way +describe(`repository > the global condtion of "non-deleted" with eager relation`, () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it(`The global condition of "non-deleted" should be set for the entity with delete date columns and eager relation`, () => Promise.all(connections.map(async connection => { + + const post1 = new PostWithRelation(); + post1.title = "title#1"; + const post2 = new PostWithRelation(); + post2.title = "title#2"; + const post3 = new PostWithRelation(); + post3.title = "title#3"; + + await connection.manager.save(post1); + await connection.manager.save(post2); + await connection.manager.save(post3); + + await connection.manager.softRemove(post1); + + const loadedPosts = await connection + .getRepository(PostWithRelation) + .find(); + loadedPosts!.length.should.be.equal(2); + const loadedPost2 = loadedPosts.find(p => p.id === 2); + expect(loadedPost2).to.exist; + expect(loadedPost2!.deletedAt).to.equals(null); + expect(loadedPost2!.title).to.equals("title#2"); + const loadedPost3 = loadedPosts.find(p => p.id === 3); + expect(loadedPost3).to.exist; + expect(loadedPost3!.deletedAt).to.equals(null); + expect(loadedPost3!.title).to.equals("title#3"); + + }))); + + it(`The global condition of "non-deleted" should not be set when the option "withDeleted" is set to true`, () => Promise.all(connections.map(async connection => { + + const post1 = new PostWithRelation(); + post1.title = "title#1"; + const post2 = new PostWithRelation(); + post2.title = "title#2"; + const post3 = new PostWithRelation(); + post3.title = "title#3"; + + await connection.manager.save(post1); + await connection.manager.save(post2); + await connection.manager.save(post3); + + await connection.manager.softRemove(post1); + + const loadedPosts = await connection + .getRepository(PostWithRelation) + .find({ + withDeleted: true, + }); + + loadedPosts!.length.should.be.equal(3); + const loadedPost1 = loadedPosts.find(p => p.id === 1); + expect(loadedPost1).to.exist; + expect(loadedPost1!.deletedAt).to.be.instanceof(Date); + expect(loadedPost1!.title).to.equals("title#1"); + const loadedPost2 = loadedPosts.find(p => p.id === 2); + expect(loadedPost2).to.exist; + expect(loadedPost2!.deletedAt).to.equals(null); + expect(loadedPost2!.title).to.equals("title#2"); + const loadedPost3 = loadedPosts.find(p => p.id === 3); + expect(loadedPost3).to.exist; + expect(loadedPost3!.deletedAt).to.equals(null); + expect(loadedPost3!.title).to.equals("title#3"); + + const loadedPost = await connection + .getRepository(PostWithRelation) + .findOne(1, { + withDeleted: true, + }); + expect(loadedPost).to.exist; + expect(loadedPost!.title).to.equals("title#1"); + + }))); +}); \ No newline at end of file diff --git a/test/functional/repository/soft-delete/global-condition-non-deleted/repository-global-condition-non-deleted.ts b/test/functional/repository/soft-delete/global-condition-non-deleted/repository-global-condition-non-deleted.ts new file mode 100644 index 0000000000..2b9e23d88d --- /dev/null +++ b/test/functional/repository/soft-delete/global-condition-non-deleted/repository-global-condition-non-deleted.ts @@ -0,0 +1,90 @@ +import "reflect-metadata"; +import {expect} from "chai"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../../utils/test-utils"; +import {Connection} from "../../../../../src/connection/Connection"; +import {Post} from "./entity/Post"; + +describe(`repository > the global condtion of "non-deleted"`, () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it(`The global condition of "non-deleted" should be set for the entity with delete date columns`, () => Promise.all(connections.map(async connection => { + + const post1 = new Post(); + post1.title = "title#1"; + const post2 = new Post(); + post2.title = "title#2"; + const post3 = new Post(); + post3.title = "title#3"; + + await connection.manager.save(post1); + await connection.manager.save(post2); + await connection.manager.save(post3); + + await connection.manager.softRemove(post1); + + const loadedPosts = await connection + .getRepository(Post) + .find(); + loadedPosts!.length.should.be.equal(2); + const loadedPost2 = loadedPosts.find(p => p.id === 2); + expect(loadedPost2).to.exist; + expect(loadedPost2!.deletedAt).to.equals(null); + expect(loadedPost2!.title).to.equals("title#2"); + const loadedPost3 = loadedPosts.find(p => p.id === 3); + expect(loadedPost3).to.exist; + expect(loadedPost3!.deletedAt).to.equals(null); + expect(loadedPost3!.title).to.equals("title#3"); + + }))); + + it(`The global condition of "non-deleted" should not be set when the option "withDeleted" is set to true`, () => Promise.all(connections.map(async connection => { + + const post1 = new Post(); + post1.title = "title#1"; + const post2 = new Post(); + post2.title = "title#2"; + const post3 = new Post(); + post3.title = "title#3"; + + await connection.manager.save(post1); + await connection.manager.save(post2); + await connection.manager.save(post3); + + await connection.manager.softRemove(post1); + + const loadedPosts = await connection + .getRepository(Post) + .find({ + withDeleted: true, + }); + + loadedPosts!.length.should.be.equal(3); + const loadedPost1 = loadedPosts.find(p => p.id === 1); + expect(loadedPost1).to.exist; + expect(loadedPost1!.deletedAt).to.be.instanceof(Date); + expect(loadedPost1!.title).to.equals("title#1"); + const loadedPost2 = loadedPosts.find(p => p.id === 2); + expect(loadedPost2).to.exist; + expect(loadedPost2!.deletedAt).to.equals(null); + expect(loadedPost2!.title).to.equals("title#2"); + const loadedPost3 = loadedPosts.find(p => p.id === 3); + expect(loadedPost3).to.exist; + expect(loadedPost3!.deletedAt).to.equals(null); + expect(loadedPost3!.title).to.equals("title#3"); + + const loadedPost = await connection + .getRepository(Post) + .findOne(1, { + withDeleted: true, + }); + expect(loadedPost).to.exist; + expect(loadedPost!.title).to.equals("title#1"); + + }))); +}); \ No newline at end of file diff --git a/test/functional/repository/soft-delete/repository-soft-delete.ts b/test/functional/repository/soft-delete/repository-soft-delete.ts new file mode 100644 index 0000000000..3f52c5c9b6 --- /dev/null +++ b/test/functional/repository/soft-delete/repository-soft-delete.ts @@ -0,0 +1,70 @@ +import "reflect-metadata"; +import {expect} from "chai"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../utils/test-utils"; +import {Connection} from "../../../../src/connection/Connection"; +import {Post} from "./entity/Post"; + +describe("repository > soft-delete", () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it("should perform soft deletion and restoration correctly", () => Promise.all(connections.map(async connection => { + + const postRepository = connection.getRepository(Post); + + // save a new posts + const newPost1 = postRepository.create({ + id: 1, + name: "post#1" + }); + const newPost2 = postRepository.create({ + id: 2, + name: "post#2" + }); + + await postRepository.save(newPost1); + await postRepository.save(newPost2); + + // soft-delete one + await postRepository.softDelete({ id: 1, name: "post#1" }); + + // load to check + const loadedPosts = await postRepository.find({ withDeleted: true }); + + // assert + loadedPosts.length.should.be.equal(2); + + const loadedPost1 = loadedPosts.find(p => p.id === 1); + expect(loadedPost1).to.exist; + expect(loadedPost1!.deletedAt).to.be.instanceof(Date); + expect(loadedPost1!.name).to.equals("post#1"); + const loadedPost2 = loadedPosts.find(p => p.id === 2); + expect(loadedPost2).to.exist; + expect(loadedPost2!.deletedAt).to.equals(null); + expect(loadedPost2!.name).to.equals("post#2"); + + // restore one + await postRepository.restore({ id: 1, name: "post#1" }); + // load to check + const restoredPosts = await postRepository.find({ withDeleted: true }); + + // assert + restoredPosts.length.should.be.equal(2); + + const restoredPost1 = restoredPosts.find(p => p.id === 1); + expect(restoredPost1).to.exist; + expect(restoredPost1!.deletedAt).to.equals(null); + expect(restoredPost1!.name).to.equals("post#1"); + const restoredPost2 = restoredPosts.find(p => p.id === 2); + expect(restoredPost2).to.exist; + expect(restoredPost2!.deletedAt).to.equals(null); + expect(restoredPost2!.name).to.equals("post#2"); + + }))); + +}); diff --git a/test/functional/repository/soft-delete/repository-soft-remove.ts b/test/functional/repository/soft-delete/repository-soft-remove.ts new file mode 100644 index 0000000000..aa09e4d9ec --- /dev/null +++ b/test/functional/repository/soft-delete/repository-soft-remove.ts @@ -0,0 +1,103 @@ +import "reflect-metadata"; +import {expect} from "chai"; +import {closeTestingConnections, createTestingConnections, reloadTestingDatabases} from "../../../utils/test-utils"; +import {Connection} from "../../../../src/connection/Connection"; +import {Post} from "./entity/Post"; +import { PostWithoutDeleteDateColumn } from "./entity/PostWithoutDeleteDateColumn"; +import { MissingDeleteDateColumnError } from "../../../../src/error/MissingDeleteDateColumnError"; + +describe("repository > soft-remove", () => { + + let connections: Connection[]; + before(async () => connections = await createTestingConnections({ + entities: [__dirname + "/entity/*{.js,.ts}"], + })); + beforeEach(() => reloadTestingDatabases(connections)); + after(() => closeTestingConnections(connections)); + + it("should perform soft removal and recovery correctly", () => Promise.all(connections.map(async connection => { + + const postRepository = connection.getRepository(Post); + + // save a new posts + const newPost1 = postRepository.create({ + id: 1, + name: "post#1" + }); + const newPost2 = postRepository.create({ + id: 2, + name: "post#2" + }); + + await postRepository.save(newPost1); + await postRepository.save(newPost2); + + // soft-remove one + await postRepository.softRemove(newPost1); + + // load to check + const loadedPosts = await postRepository.find({ withDeleted: true }); + + // assert + loadedPosts.length.should.be.equal(2); + + const loadedPost1 = loadedPosts.find(p => p.id === 1); + expect(loadedPost1).to.exist; + expect(loadedPost1!.deletedAt).to.be.instanceof(Date); + expect(loadedPost1!.name).to.equals("post#1"); + const loadedPost2 = loadedPosts.find(p => p.id === 2); + expect(loadedPost2).to.exist; + expect(loadedPost2!.deletedAt).to.equals(null); + expect(loadedPost2!.name).to.equals("post#2"); + + // recover one + await postRepository.recover(loadedPost1!); + // load to check + const recoveredPosts = await postRepository.find({ withDeleted: true }); + + // assert + recoveredPosts.length.should.be.equal(2); + + const recoveredPost1 = recoveredPosts.find(p => p.id === 1); + expect(recoveredPost1).to.exist; + expect(recoveredPost1!.deletedAt).to.equals(null); + expect(recoveredPost1!.name).to.equals("post#1"); + const recoveredPost2 = recoveredPosts.find(p => p.id === 2); + expect(recoveredPost2).to.exist; + expect(recoveredPost2!.deletedAt).to.equals(null); + expect(recoveredPost2!.name).to.equals("post#2"); + + }))); + + it("should throw error when delete date column is missing", () => Promise.all(connections.map(async connection => { + + const postRepository = connection.getRepository(PostWithoutDeleteDateColumn); + + // save a new posts + const newPost1 = postRepository.create({ + id: 1, + name: "post#1" + }); + + await postRepository.save(newPost1); + + let error1: Error | undefined; + try { + // soft-remove one + await postRepository.softRemove(newPost1); + } catch (err) { + error1 = err; + } + expect(error1).to.be.an.instanceof(MissingDeleteDateColumnError); + + let error2: Error | undefined; + try { + // recover one + await postRepository.recover(newPost1); + } catch (err) { + error2 = err; + } + expect(error2).to.be.an.instanceof(MissingDeleteDateColumnError); + + }))); +});