Skip to content

Commit

Permalink
feat(typeorm): Add support for soft deletes
Browse files Browse the repository at this point in the history
  • Loading branch information
doug-martin committed Apr 26, 2020
1 parent a67ac24 commit 2ab42fa
Show file tree
Hide file tree
Showing 7 changed files with 319 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
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<Connection> {
return createConnection({
type: 'sqlite',
database: ':memory:',
dropSchema: true,
entities: [TestEntity, TestRelation, TestEntityRelationEntity],
entities: [TestEntity, TestSoftDeleteEntity, TestRelation, TestEntityRelationEntity],
synchronize: true,
logging: false,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -23,10 +24,14 @@ describe('FilterQueryBuilder', (): void => {

const baseDeleteQuery = 'DELETE FROM "test_entity"';

const getEntityQueryBuilder = (whereBuilder: WhereBuilder<TestEntity>): FilterQueryBuilder<TestEntity> =>
new FilterQueryBuilder(getTestConnection().getRepository(TestEntity), whereBuilder);
const baseSoftDeleteQuery = 'UPDATE "test_soft_delete_entity" SET "deleted_at" = CURRENT_TIMESTAMP';

const assertSQL = (query: QueryBuilder<TestEntity>, expectedSql: string, expectedArgs: any[]): void => {
const getEntityQueryBuilder = <Entity>(
entity: Class<Entity>,
whereBuilder: WhereBuilder<Entity>,
): FilterQueryBuilder<Entity> => new FilterQueryBuilder(getTestConnection().getRepository(entity), whereBuilder);

const assertSQL = <Entity>(query: QueryBuilder<Entity>, expectedSql: string, expectedArgs: any[]): void => {
const [sql, params] = query.getQueryAndParameters();
expect(sql).toEqual(expectedSql);
expect(params).toEqual(expectedArgs);
Expand All @@ -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);
};

Expand All @@ -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<TestSoftDeleteEntity>,
whereBuilder: WhereBuilder<TestSoftDeleteEntity>,
expectedSql: string,
expectedArgs: any[],
): void => {
const selectQueryBuilder = getEntityQueryBuilder(TestSoftDeleteEntity, whereBuilder).softDelete(query);
assertSQL(selectQueryBuilder, `${baseSoftDeleteQuery}${expectedSql}`, expectedArgs);
};

const assertUpdateSQL = (
query: Query<TestEntity>,
whereBuilder: WhereBuilder<TestEntity>,
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]);
};

Expand Down Expand Up @@ -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<TestSoftDeleteEntity> = 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<TestSoftDeleteEntity> = 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<TestSoftDeleteEntity> = mock(WhereBuilder);
assertSoftDeleteSQL(
{
sorting: [
{ field: 'stringType', direction: SortDirection.ASC },
{ field: 'testEntityPk', direction: SortDirection.DESC },
],
},
instance(mockWhereBuilder),
'',
[],
);
verify(mockWhereBuilder.build(anything(), anything())).never();
});
});
});
});
185 changes: 160 additions & 25 deletions packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 => {
Expand All @@ -26,38 +28,38 @@ describe('TypeOrmQueryService', (): void => {

const relationName = 'testRelations';

@QueryService(TestEntity)
class TestTypeOrmQueryService extends TypeOrmQueryService<TestEntity> {
class TestTypeOrmQueryService<Entity> extends TypeOrmQueryService<Entity> {
constructor(
readonly repo: Repository<TestEntity>,
filterQueryBuilder?: FilterQueryBuilder<TestEntity>,
readonly relationQueryBuilder?: RelationQueryBuilder<TestEntity, unknown>,
readonly repo: Repository<Entity>,
readonly relationQueryBuilder?: RelationQueryBuilder<Entity, unknown>,
opts?: TypeOrmQueryServiceOpts<Entity>,
) {
super(repo, filterQueryBuilder);
super(repo, opts);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
getRelationQueryBuilder<Relation>(name: string): RelationQueryBuilder<TestEntity, Relation> {
return this.relationQueryBuilder as RelationQueryBuilder<TestEntity, Relation>;
getRelationQueryBuilder<Relation>(name: string): RelationQueryBuilder<Entity, Relation> {
return this.relationQueryBuilder as RelationQueryBuilder<Entity, Relation>;
}
}

type MockQueryService<Relation = unknown> = {
mockRepo: Repository<TestEntity>;
queryService: QueryService<TestEntity>;
mockQueryBuilder: FilterQueryBuilder<TestEntity>;
mockRelationQueryBuilder: RelationQueryBuilder<TestEntity, Relation>;
type MockQueryService<Entity, Relation = unknown> = {
mockRepo: Repository<Entity>;
queryService: TypeOrmQueryService<Entity>;
mockQueryBuilder: FilterQueryBuilder<Entity>;
mockRelationQueryBuilder: RelationQueryBuilder<Entity, Relation>;
};

function createQueryService<Relation = unknown>(): MockQueryService<Relation> {
const mockQueryBuilder = mock<FilterQueryBuilder<TestEntity>>(FilterQueryBuilder);
const mockRepo = mock<Repository<TestEntity>>(Repository);
const mockRelationQueryBuilder = mock<RelationQueryBuilder<TestEntity, Relation>>(RelationQueryBuilder);
const queryService = new TestTypeOrmQueryService(
instance(mockRepo),
instance(mockQueryBuilder),
instance(mockRelationQueryBuilder),
);
function createQueryService<Entity = TestEntity, Relation = unknown>(
opts?: TypeOrmQueryServiceOpts<Entity>,
): MockQueryService<Entity, Relation> {
const mockQueryBuilder = mock<FilterQueryBuilder<Entity>>(FilterQueryBuilder);
const mockRepo = mock<Repository<Entity>>(Repository);
const mockRelationQueryBuilder = mock<RelationQueryBuilder<Entity, Relation>>(RelationQueryBuilder);
const queryService = new TestTypeOrmQueryService(instance(mockRepo), instance(mockRelationQueryBuilder), {
filterQueryBuilder: instance(mockQueryBuilder),
...opts,
});
return { mockQueryBuilder, mockRepo, queryService, mockRelationQueryBuilder };
}

Expand Down Expand Up @@ -89,7 +91,7 @@ describe('TypeOrmQueryService', (): void => {
const entity = testEntities()[0];
const relations = testRelations(entity.testEntityPk);
const query: Query<TestRelation> = { filter: { relationName: { eq: 'name' } } };
const { queryService, mockRepo, mockRelationQueryBuilder } = createQueryService<TestRelation>();
const { queryService, mockRepo, mockRelationQueryBuilder } = createQueryService<TestEntity, TestRelation>();
const selectQueryBuilder: SelectQueryBuilder<TestRelation> = mock(SelectQueryBuilder);
// @ts-ignore
when(mockRepo.metadata).thenReturn({ relations: [{ propertyName: relationName, type: TestRelation }] });
Expand Down Expand Up @@ -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<TestSoftDeleteEntity> = { stringType: { eq: 'foo' } };
const { queryService, mockQueryBuilder, mockRepo } = createQueryService<TestSoftDeleteEntity>({
useSoftDelete: true,
});
const deleteQueryBuilder: SoftDeleteQueryBuilder<TestSoftDeleteEntity> = 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<TestSoftDeleteEntity> = { stringType: { eq: 'foo' } };
const { queryService, mockQueryBuilder, mockRepo } = createQueryService<TestSoftDeleteEntity>({
useSoftDelete: true,
});
const deleteQueryBuilder: SoftDeleteQueryBuilder<TestSoftDeleteEntity> = 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<TestSoftDeleteEntity>({ 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<TestSoftDeleteEntity>({ 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<TestSoftDeleteEntity> = { stringType: { eq: 'foo' } };
const { queryService, mockQueryBuilder, mockRepo } = createQueryService<TestSoftDeleteEntity>({
useSoftDelete: true,
});
const deleteQueryBuilder: SoftDeleteQueryBuilder<TestSoftDeleteEntity> = 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<TestSoftDeleteEntity> = { stringType: { eq: 'foo' } };
const { queryService, mockQueryBuilder, mockRepo } = createQueryService<TestSoftDeleteEntity>({
useSoftDelete: true,
});
const deleteQueryBuilder: SoftDeleteQueryBuilder<TestSoftDeleteEntity> = 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.',
);
});
});
});
});
2 changes: 1 addition & 1 deletion packages/query-typeorm/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { TypeOrmQueryService } from './services';
export { TypeOrmQueryService, TypeOrmQueryServiceOpts } from './services';
export { InjectTypeOrmQueryService, getTypeOrmQueryServiceKey } from './decorators';
export { NestjsQueryTypeOrmModule } from './module';
Loading

0 comments on commit 2ab42fa

Please sign in to comment.