From 2ab42faee2802abae4d8496e2529b8eb23860ed4 Mon Sep 17 00:00:00 2001 From: doug-martin Date: Sat, 25 Apr 2020 00:44:39 -0500 Subject: [PATCH] feat(typeorm): Add support for soft deletes --- .../__fixtures__/connection.fixture.ts | 3 +- .../__fixtures__/test-soft-delete.entity.ts | 13 ++ .../query/filter-query.builder.spec.ts | 77 +++++++- .../services/typeorm-query.service.spec.ts | 185 +++++++++++++++--- packages/query-typeorm/src/index.ts | 2 +- .../src/query/filter-query.builder.ts | 10 + .../src/services/typeorm-query.service.ts | 67 ++++++- 7 files changed, 319 insertions(+), 38 deletions(-) create mode 100644 packages/query-typeorm/__tests__/__fixtures__/test-soft-delete.entity.ts diff --git a/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts b/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts index b944b99a6..5aad96731 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts @@ -2,6 +2,7 @@ import { Connection, createConnection, getConnection } from 'typeorm'; import { TestEntityRelationEntity } from './test-entity-relation.entity'; import { TestRelation } from './test-relation.entity'; +import { TestSoftDeleteEntity } from './test-soft-delete.entity'; import { TestEntity } from './test.entity'; export function createTestConnection(): Promise { @@ -9,7 +10,7 @@ export function createTestConnection(): Promise { type: 'sqlite', database: ':memory:', dropSchema: true, - entities: [TestEntity, TestRelation, TestEntityRelationEntity], + entities: [TestEntity, TestSoftDeleteEntity, TestRelation, TestEntityRelationEntity], synchronize: true, logging: false, }); diff --git a/packages/query-typeorm/__tests__/__fixtures__/test-soft-delete.entity.ts b/packages/query-typeorm/__tests__/__fixtures__/test-soft-delete.entity.ts new file mode 100644 index 000000000..ca1ce0325 --- /dev/null +++ b/packages/query-typeorm/__tests__/__fixtures__/test-soft-delete.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm'; + +@Entity() +export class TestSoftDeleteEntity { + @PrimaryGeneratedColumn('uuid') + testEntityPk!: string; + + @Column({ name: 'string_type' }) + stringType!: string; + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt?: Date; +} diff --git a/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts b/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts index b7dbe0648..2b08b7fc5 100644 --- a/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts @@ -1,7 +1,8 @@ import { anything, instance, mock, verify, when } from 'ts-mockito'; import { QueryBuilder, WhereExpression } from 'typeorm'; -import { Filter, Query, SortDirection, SortNulls } from '@nestjs-query/core'; +import { Class, Filter, Query, SortDirection, SortNulls } from '@nestjs-query/core'; import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; +import { TestSoftDeleteEntity } from '../__fixtures__/test-soft-delete.entity'; import { TestEntity } from '../__fixtures__/test.entity'; import { FilterQueryBuilder, WhereBuilder } from '../../src/query'; @@ -23,10 +24,14 @@ describe('FilterQueryBuilder', (): void => { const baseDeleteQuery = 'DELETE FROM "test_entity"'; - const getEntityQueryBuilder = (whereBuilder: WhereBuilder): FilterQueryBuilder => - new FilterQueryBuilder(getTestConnection().getRepository(TestEntity), whereBuilder); + const baseSoftDeleteQuery = 'UPDATE "test_soft_delete_entity" SET "deleted_at" = CURRENT_TIMESTAMP'; - const assertSQL = (query: QueryBuilder, expectedSql: string, expectedArgs: any[]): void => { + const getEntityQueryBuilder = ( + entity: Class, + whereBuilder: WhereBuilder, + ): FilterQueryBuilder => new FilterQueryBuilder(getTestConnection().getRepository(entity), whereBuilder); + + const assertSQL = (query: QueryBuilder, expectedSql: string, expectedArgs: any[]): void => { const [sql, params] = query.getQueryAndParameters(); expect(sql).toEqual(expectedSql); expect(params).toEqual(expectedArgs); @@ -38,7 +43,7 @@ describe('FilterQueryBuilder', (): void => { expectedSql: string, expectedArgs: any[], ): void => { - const selectQueryBuilder = getEntityQueryBuilder(whereBuilder).select(query); + const selectQueryBuilder = getEntityQueryBuilder(TestEntity, whereBuilder).select(query); assertSQL(selectQueryBuilder, `${baseSelectQuery}${expectedSql}`, expectedArgs); }; @@ -48,17 +53,27 @@ describe('FilterQueryBuilder', (): void => { expectedSql: string, expectedArgs: any[], ): void => { - const selectQueryBuilder = getEntityQueryBuilder(whereBuilder).delete(query); + const selectQueryBuilder = getEntityQueryBuilder(TestEntity, whereBuilder).delete(query); assertSQL(selectQueryBuilder, `${baseDeleteQuery}${expectedSql}`, expectedArgs); }; + const assertSoftDeleteSQL = ( + query: Query, + whereBuilder: WhereBuilder, + expectedSql: string, + expectedArgs: any[], + ): void => { + const selectQueryBuilder = getEntityQueryBuilder(TestSoftDeleteEntity, whereBuilder).softDelete(query); + assertSQL(selectQueryBuilder, `${baseSoftDeleteQuery}${expectedSql}`, expectedArgs); + }; + const assertUpdateSQL = ( query: Query, whereBuilder: WhereBuilder, expectedSql: string, expectedArgs: any[], ): void => { - const queryBuilder = getEntityQueryBuilder(whereBuilder).update(query).set({ stringType: 'baz' }); + const queryBuilder = getEntityQueryBuilder(TestEntity, whereBuilder).update(query).set({ stringType: 'baz' }); assertSQL(queryBuilder, `${baseUpdateQuery}${expectedSql}`, ['baz', ...expectedArgs]); }; @@ -397,4 +412,52 @@ describe('FilterQueryBuilder', (): void => { }); }); }); + + describe('#softDelete', () => { + describe('with filter', () => { + it('should call whereBuilder#build if there is a filter', () => { + const mockWhereBuilder: WhereBuilder = mock(WhereBuilder); + const query = { filter: { stringType: { eq: 'foo' } } }; + when(mockWhereBuilder.build(anything(), query.filter, undefined)).thenCall((where: WhereExpression) => { + return where.andWhere(`stringType = 'foo'`); + }); + assertSoftDeleteSQL(query, instance(mockWhereBuilder), ` WHERE "string_type" = 'foo'`, []); + }); + }); + describe('with paging', () => { + it('should ignore paging args', () => { + const mockWhereBuilder: WhereBuilder = mock(WhereBuilder); + assertSoftDeleteSQL( + { + paging: { + limit: 10, + offset: 11, + }, + }, + instance(mockWhereBuilder), + '', + [], + ); + verify(mockWhereBuilder.build(anything(), anything())).never(); + }); + }); + + describe('with sorting', () => { + it('should ignore sorting', () => { + const mockWhereBuilder: WhereBuilder = mock(WhereBuilder); + assertSoftDeleteSQL( + { + sorting: [ + { field: 'stringType', direction: SortDirection.ASC }, + { field: 'testEntityPk', direction: SortDirection.DESC }, + ], + }, + instance(mockWhereBuilder), + '', + [], + ); + verify(mockWhereBuilder.build(anything(), anything())).never(); + }); + }); + }); }); diff --git a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts index 7af642c6b..e064c915e 100644 --- a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts +++ b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts @@ -1,4 +1,4 @@ -import { Filter, Query, QueryService } from '@nestjs-query/core'; +import { Filter, Query } from '@nestjs-query/core'; import { plainToClass } from 'class-transformer'; import { deepEqual, instance, mock, objectContaining, when } from 'ts-mockito'; import { @@ -8,9 +8,11 @@ import { SelectQueryBuilder, UpdateQueryBuilder, } from 'typeorm'; -import { TypeOrmQueryService } from '../../src'; +import { SoftDeleteQueryBuilder } from 'typeorm/query-builder/SoftDeleteQueryBuilder'; +import { TypeOrmQueryService, TypeOrmQueryServiceOpts } from '../../src'; import { FilterQueryBuilder, RelationQueryBuilder } from '../../src/query'; import { TestRelation } from '../__fixtures__/test-relation.entity'; +import { TestSoftDeleteEntity } from '../__fixtures__/test-soft-delete.entity'; import { TestEntity } from '../__fixtures__/test.entity'; describe('TypeOrmQueryService', (): void => { @@ -26,38 +28,38 @@ describe('TypeOrmQueryService', (): void => { const relationName = 'testRelations'; - @QueryService(TestEntity) - class TestTypeOrmQueryService extends TypeOrmQueryService { + class TestTypeOrmQueryService extends TypeOrmQueryService { constructor( - readonly repo: Repository, - filterQueryBuilder?: FilterQueryBuilder, - readonly relationQueryBuilder?: RelationQueryBuilder, + readonly repo: Repository, + readonly relationQueryBuilder?: RelationQueryBuilder, + opts?: TypeOrmQueryServiceOpts, ) { - super(repo, filterQueryBuilder); + super(repo, opts); } // eslint-disable-next-line @typescript-eslint/no-unused-vars - getRelationQueryBuilder(name: string): RelationQueryBuilder { - return this.relationQueryBuilder as RelationQueryBuilder; + getRelationQueryBuilder(name: string): RelationQueryBuilder { + return this.relationQueryBuilder as RelationQueryBuilder; } } - type MockQueryService = { - mockRepo: Repository; - queryService: QueryService; - mockQueryBuilder: FilterQueryBuilder; - mockRelationQueryBuilder: RelationQueryBuilder; + type MockQueryService = { + mockRepo: Repository; + queryService: TypeOrmQueryService; + mockQueryBuilder: FilterQueryBuilder; + mockRelationQueryBuilder: RelationQueryBuilder; }; - function createQueryService(): MockQueryService { - const mockQueryBuilder = mock>(FilterQueryBuilder); - const mockRepo = mock>(Repository); - const mockRelationQueryBuilder = mock>(RelationQueryBuilder); - const queryService = new TestTypeOrmQueryService( - instance(mockRepo), - instance(mockQueryBuilder), - instance(mockRelationQueryBuilder), - ); + function createQueryService( + opts?: TypeOrmQueryServiceOpts, + ): MockQueryService { + const mockQueryBuilder = mock>(FilterQueryBuilder); + const mockRepo = mock>(Repository); + const mockRelationQueryBuilder = mock>(RelationQueryBuilder); + const queryService = new TestTypeOrmQueryService(instance(mockRepo), instance(mockRelationQueryBuilder), { + filterQueryBuilder: instance(mockQueryBuilder), + ...opts, + }); return { mockQueryBuilder, mockRepo, queryService, mockRelationQueryBuilder }; } @@ -89,7 +91,7 @@ describe('TypeOrmQueryService', (): void => { const entity = testEntities()[0]; const relations = testRelations(entity.testEntityPk); const query: Query = { filter: { relationName: { eq: 'name' } } }; - const { queryService, mockRepo, mockRelationQueryBuilder } = createQueryService(); + const { queryService, mockRepo, mockRelationQueryBuilder } = createQueryService(); const selectQueryBuilder: SelectQueryBuilder = mock(SelectQueryBuilder); // @ts-ignore when(mockRepo.metadata).thenReturn({ relations: [{ propertyName: relationName, type: TestRelation }] }); @@ -576,4 +578,137 @@ describe('TypeOrmQueryService', (): void => { return expect(queryService.updateOne(updateId, update)).rejects.toThrowError(err); }); }); + + describe('#isSoftDelete', () => { + describe('#deleteMany', () => { + it('create a delete query builder and call execute', async () => { + const affected = 10; + const deleteMany: Filter = { stringType: { eq: 'foo' } }; + const { queryService, mockQueryBuilder, mockRepo } = createQueryService({ + useSoftDelete: true, + }); + const deleteQueryBuilder: SoftDeleteQueryBuilder = mock(SoftDeleteQueryBuilder); + when(mockRepo.target).thenReturn(TestSoftDeleteEntity); + when(mockQueryBuilder.softDelete(objectContaining({ filter: deleteMany }))).thenReturn( + instance(deleteQueryBuilder), + ); + when(deleteQueryBuilder.execute()).thenResolve({ raw: undefined, affected, generatedMaps: [] }); + const queryResult = await queryService.deleteMany(deleteMany); + return expect(queryResult).toEqual({ deletedCount: affected }); + }); + + it('should return 0 if affected is not returned', async () => { + const deleteMany: Filter = { stringType: { eq: 'foo' } }; + const { queryService, mockQueryBuilder, mockRepo } = createQueryService({ + useSoftDelete: true, + }); + const deleteQueryBuilder: SoftDeleteQueryBuilder = mock(SoftDeleteQueryBuilder); + when(mockRepo.target).thenReturn(TestSoftDeleteEntity); + when(mockQueryBuilder.softDelete(objectContaining({ filter: deleteMany }))).thenReturn( + instance(deleteQueryBuilder), + ); + when(deleteQueryBuilder.execute()).thenResolve({ raw: undefined, generatedMaps: [] }); + const queryResult = await queryService.deleteMany(deleteMany); + return expect(queryResult).toEqual({ deletedCount: 0 }); + }); + }); + + describe('#deleteOne', () => { + it('call getOne and then remove the entity', async () => { + const entity = testEntities()[0]; + const { testEntityPk } = entity; + const { queryService, mockRepo } = createQueryService({ useSoftDelete: true }); + when(mockRepo.target).thenReturn(TestSoftDeleteEntity); + when(mockRepo.findOneOrFail(testEntityPk)).thenResolve(entity); + when(mockRepo.softRemove(entity)).thenResolve(entity); + const queryResult = await queryService.deleteOne(testEntityPk); + return expect(queryResult).toEqual(entity); + }); + + it('call fail if the entity is not found', async () => { + const entity = testEntities()[0]; + const { testEntityPk } = entity; + const err = new Error('not found'); + const { queryService, mockRepo } = createQueryService({ useSoftDelete: true }); + when(mockRepo.findOneOrFail(testEntityPk)).thenReject(err); + return expect(queryService.deleteOne(testEntityPk)).rejects.toThrowError(err); + }); + }); + + describe('#restoreOne', () => { + it('restore the entity', async () => { + const entity = testEntities()[0]; + const { testEntityPk } = entity; + const { queryService, mockRepo } = createQueryService({ useSoftDelete: true }); + when(mockRepo.target).thenReturn(TestSoftDeleteEntity); + when(mockRepo.restore(entity.testEntityPk)).thenResolve({ generatedMaps: [], raw: undefined, affected: 1 }); + when(mockRepo.findOneOrFail(testEntityPk)).thenResolve(entity); + const queryResult = await queryService.restoreOne(testEntityPk); + return expect(queryResult).toEqual(entity); + }); + + it('should fail if the entity is not found', async () => { + const entity = testEntities()[0]; + const { testEntityPk } = entity; + const err = new Error('not found'); + const { queryService, mockRepo } = createQueryService({ useSoftDelete: true }); + when(mockRepo.restore(entity.testEntityPk)).thenResolve({ generatedMaps: [], raw: undefined, affected: 1 }); + when(mockRepo.findOneOrFail(testEntityPk)).thenReject(err); + return expect(queryService.restoreOne(testEntityPk)).rejects.toThrowError(err); + }); + + it('should fail if the useSoftDelete is not enabled', async () => { + const entity = testEntities()[0]; + const { testEntityPk } = entity; + const { queryService, mockRepo } = createQueryService(); + when(mockRepo.target).thenReturn(TestSoftDeleteEntity); + return expect(queryService.restoreOne(testEntityPk)).rejects.toThrowError( + 'Restore not allowed for non soft deleted entity TestSoftDeleteEntity.', + ); + }); + }); + + describe('#restoreMany', () => { + it('should restore multiple entities', async () => { + const affected = 10; + const deleteMany: Filter = { stringType: { eq: 'foo' } }; + const { queryService, mockQueryBuilder, mockRepo } = createQueryService({ + useSoftDelete: true, + }); + const deleteQueryBuilder: SoftDeleteQueryBuilder = mock(SoftDeleteQueryBuilder); + when(mockRepo.target).thenReturn(TestSoftDeleteEntity); + when(mockQueryBuilder.softDelete(objectContaining({ filter: deleteMany }))).thenReturn( + instance(deleteQueryBuilder), + ); + when(deleteQueryBuilder.restore()).thenReturn(instance(deleteQueryBuilder)); + when(deleteQueryBuilder.execute()).thenResolve({ raw: undefined, affected, generatedMaps: [] }); + const queryResult = await queryService.restoreMany(deleteMany); + return expect(queryResult).toEqual({ updatedCount: affected }); + }); + + it('should return 0 if affected is not returned', async () => { + const deleteMany: Filter = { stringType: { eq: 'foo' } }; + const { queryService, mockQueryBuilder, mockRepo } = createQueryService({ + useSoftDelete: true, + }); + const deleteQueryBuilder: SoftDeleteQueryBuilder = mock(SoftDeleteQueryBuilder); + when(mockRepo.target).thenReturn(TestSoftDeleteEntity); + when(mockQueryBuilder.softDelete(objectContaining({ filter: deleteMany }))).thenReturn( + instance(deleteQueryBuilder), + ); + when(deleteQueryBuilder.restore()).thenReturn(instance(deleteQueryBuilder)); + when(deleteQueryBuilder.execute()).thenResolve({ raw: undefined, generatedMaps: [] }); + const queryResult = await queryService.restoreMany(deleteMany); + return expect(queryResult).toEqual({ updatedCount: 0 }); + }); + + it('should fail if the useSoftDelete is not enabled', async () => { + const { queryService, mockRepo } = createQueryService(); + when(mockRepo.target).thenReturn(TestSoftDeleteEntity); + return expect(queryService.restoreMany({ stringType: { eq: 'foo' } })).rejects.toThrowError( + 'Restore not allowed for non soft deleted entity TestSoftDeleteEntity.', + ); + }); + }); + }); }); diff --git a/packages/query-typeorm/src/index.ts b/packages/query-typeorm/src/index.ts index e7249ea1a..66d0e04fd 100644 --- a/packages/query-typeorm/src/index.ts +++ b/packages/query-typeorm/src/index.ts @@ -1,3 +1,3 @@ -export { TypeOrmQueryService } from './services'; +export { TypeOrmQueryService, TypeOrmQueryServiceOpts } from './services'; export { InjectTypeOrmQueryService, getTypeOrmQueryServiceKey } from './decorators'; export { NestjsQueryTypeOrmModule } from './module'; diff --git a/packages/query-typeorm/src/query/filter-query.builder.ts b/packages/query-typeorm/src/query/filter-query.builder.ts index 27f4a81d2..93a7b77c3 100644 --- a/packages/query-typeorm/src/query/filter-query.builder.ts +++ b/packages/query-typeorm/src/query/filter-query.builder.ts @@ -7,6 +7,7 @@ import { UpdateQueryBuilder, WhereExpression, } from 'typeorm'; +import { SoftDeleteQueryBuilder } from 'typeorm/query-builder/SoftDeleteQueryBuilder'; import { WhereBuilder } from './where.builder'; /** @@ -61,6 +62,15 @@ export class FilterQueryBuilder { return this.applyFilter(this.repo.createQueryBuilder().delete(), query.filter); } + /** + * Create a `typeorm` DeleteQueryBuilder with a WHERE clause. + * + * @param query - the query to apply. + */ + softDelete(query: Query): SoftDeleteQueryBuilder { + return this.applyFilter(this.repo.createQueryBuilder().softDelete(), query.filter); + } + /** * Create a `typeorm` UpdateQueryBuilder with `WHERE` and `ORDER BY` clauses * diff --git a/packages/query-typeorm/src/services/typeorm-query.service.ts b/packages/query-typeorm/src/services/typeorm-query.service.ts index c4515994e..dd3c66175 100644 --- a/packages/query-typeorm/src/services/typeorm-query.service.ts +++ b/packages/query-typeorm/src/services/typeorm-query.service.ts @@ -7,11 +7,17 @@ import { QueryService, Filter, } from '@nestjs-query/core'; -import { Repository } from 'typeorm'; +import { Repository, DeleteResult } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { MethodNotAllowedException } from '@nestjs/common'; import { FilterQueryBuilder } from '../query'; import { RelationQueryService } from './relation-query.service'; +export interface TypeOrmQueryServiceOpts { + useSoftDelete?: boolean; + filterQueryBuilder?: FilterQueryBuilder; +} + /** * Base class for all query services that use a `typeorm` Repository. * @@ -31,9 +37,12 @@ import { RelationQueryService } from './relation-query.service'; export class TypeOrmQueryService extends RelationQueryService implements QueryService { readonly filterQueryBuilder: FilterQueryBuilder; - constructor(readonly repo: Repository, filterQueryBuilder?: FilterQueryBuilder) { + readonly useSoftDelete: boolean; + + constructor(readonly repo: Repository, opts?: TypeOrmQueryServiceOpts) { super(); - this.filterQueryBuilder = filterQueryBuilder ?? new FilterQueryBuilder(this.repo); + this.filterQueryBuilder = opts?.filterQueryBuilder ?? new FilterQueryBuilder(this.repo); + this.useSoftDelete = opts?.useSoftDelete ?? false; } get EntityClass(): Class { @@ -169,6 +178,9 @@ export class TypeOrmQueryService extends RelationQueryService im */ async deleteOne(id: string | number): Promise { const entity = await this.repo.findOneOrFail(id); + if (this.useSoftDelete) { + return this.repo.softRemove(entity); + } return this.repo.remove(entity); } @@ -186,10 +198,51 @@ export class TypeOrmQueryService extends RelationQueryService im * @param filter - A `Filter` to find records to delete. */ async deleteMany(filter: Filter): Promise { - const deleteResult = await this.filterQueryBuilder.delete({ filter }).execute(); + let deleteResult: DeleteResult; + if (this.useSoftDelete) { + deleteResult = await this.filterQueryBuilder.softDelete({ filter }).execute(); + } else { + deleteResult = await this.filterQueryBuilder.delete({ filter }).execute(); + } return { deletedCount: deleteResult.affected || 0 }; } + /** + * Restore an entity by `id`. + * + * @example + * + * ```ts + * const restoredTodo = await this.service.restoreOne(1); + * ``` + * + * @param id - The `id` of the entity to restore. + */ + async restoreOne(id: string | number): Promise { + await this.ensureSoftDeleteEnabled(); + await this.repo.restore(id); + return this.getById(id); + } + + /** + * Restores multiple records with a `@nestjs-query/core` `Filter`. + * + * @example + * + * ```ts + * const { updatedCount } = this.service.restoreMany({ + * created: { lte: new Date('2020-1-1') } + * }); + * ``` + * + * @param filter - A `Filter` to find records to delete. + */ + async restoreMany(filter: Filter): Promise { + await this.ensureSoftDeleteEnabled(); + const result = await this.filterQueryBuilder.softDelete({ filter }).restore().execute(); + return { updatedCount: result.affected || 0 }; + } + async ensureEntityDoesNotExist(e: DeepPartial): Promise { if (this.repo.hasId((e as unknown) as Entity)) { const found = await this.repo.findOne(this.repo.getId((e as unknown) as Entity)); @@ -204,4 +257,10 @@ export class TypeOrmQueryService extends RelationQueryService im throw new Error('Id cannot be specified when updating'); } } + + async ensureSoftDeleteEnabled(): Promise { + if (!this.useSoftDelete) { + throw new MethodNotAllowedException(`Restore not allowed for non soft deleted entity ${this.EntityClass.name}.`); + } + } }