Skip to content

Commit

Permalink
feat(sequelize): Add support for querying for nested relations
Browse files Browse the repository at this point in the history
  • Loading branch information
doug-martin committed Jun 30, 2020
1 parent cd9d0b5 commit 92a51c1
Show file tree
Hide file tree
Showing 9 changed files with 221 additions and 49 deletions.
2 changes: 2 additions & 0 deletions packages/query-sequelize/__tests__/__fixtures__/seeds.ts
Expand Up @@ -34,11 +34,13 @@ export const PLAIN_TEST_RELATIONS: Pick<
testRelationPk: `test-relations-${te.testEntityPk}-2`,
relationName: `${te.stringType}-test-relation`,
testEntityId: te.testEntityPk,
oneTestEntityId: null,
},
{
testRelationPk: `test-relations-${te.testEntityPk}-3`,
relationName: `${te.stringType}-test-relation`,
testEntityId: te.testEntityPk,
oneTestEntityId: null,
},
];
}, [] as Pick<TestRelation, 'testRelationPk' | 'relationName' | 'testEntityId'>[]);
Expand Down
@@ -1,12 +1,12 @@
import { FindOptions, Op, UpdateOptions, DestroyOptions } from 'sequelize';
import { anything, instance, mock, verify, when } from 'ts-mockito';
import { anything, instance, mock, verify, when, deepEqual } from 'ts-mockito';
import { Query, SortDirection, SortNulls } from '@nestjs-query/core';
import { TestEntity } from '../__fixtures__/test.entity';
import { FilterQueryBuilder, WhereBuilder } from '../../src/query';

describe('FilterQueryBuilder', (): void => {
const getEntityQueryBuilder = (whereBuilder: WhereBuilder<TestEntity>): FilterQueryBuilder<TestEntity> =>
new FilterQueryBuilder(whereBuilder);
new FilterQueryBuilder(TestEntity, whereBuilder);

const assertFindOptions = (
query: Query<TestEntity>,
Expand Down Expand Up @@ -37,13 +37,13 @@ describe('FilterQueryBuilder', (): void => {
it('should not call whereBuilder#build', () => {
const mockWhereBuilder: WhereBuilder<TestEntity> = mock(WhereBuilder);
assertFindOptions({}, instance(mockWhereBuilder), {});
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should call whereBuilder#build if there is a filter', () => {
const mockWhereBuilder: WhereBuilder<TestEntity> = mock(WhereBuilder);
const query = { filter: { stringType: { eq: 'foo' } } };
when(mockWhereBuilder.build(query.filter)).thenCall(() => {
when(mockWhereBuilder.build(query.filter, deepEqual(new Map()))).thenCall(() => {
return { [Op.and]: { stringType: 'foo' } };
});
assertFindOptions(query, instance(mockWhereBuilder), {
Expand All @@ -56,7 +56,7 @@ describe('FilterQueryBuilder', (): void => {
it('should apply empty paging args', () => {
const mockWhereBuilder: WhereBuilder<TestEntity> = mock(WhereBuilder);
assertFindOptions({}, instance(mockWhereBuilder), {});
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should apply paging args going forward', () => {
Expand All @@ -71,7 +71,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ limit: 10, offset: 11 },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should apply paging args going backward', () => {
Expand All @@ -86,7 +86,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ limit: 10, offset: 10 },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should apply paging with just a limit', () => {
Expand All @@ -100,7 +100,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ limit: 10 },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should apply paging with just an offset', () => {
Expand All @@ -114,7 +114,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ offset: 10 },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});
});

Expand All @@ -128,7 +128,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ order: [['numberType', 'ASC']] },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should apply ASC NULLS_FIRST sorting', () => {
Expand All @@ -140,7 +140,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ order: [['numberType', 'ASC NULLS FIRST']] },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should apply ASC NULLS_LAST sorting', () => {
Expand All @@ -152,7 +152,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ order: [['numberType', 'ASC NULLS LAST']] },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should apply DESC sorting', () => {
Expand All @@ -164,7 +164,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ order: [['numberType', 'DESC']] },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should apply DESC NULLS_FIRST sorting', () => {
Expand All @@ -187,7 +187,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ order: [['numberType', 'DESC NULLS LAST']] },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});

it('should apply multiple sorts', () => {
Expand All @@ -211,7 +211,7 @@ describe('FilterQueryBuilder', (): void => {
],
},
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});
});
});
Expand All @@ -221,7 +221,7 @@ describe('FilterQueryBuilder', (): void => {
it('should call whereBuilder#build if there is a filter', () => {
const mockWhereBuilder: WhereBuilder<TestEntity> = mock(WhereBuilder);
const query = { filter: { stringType: { eq: 'foo' } } };
when(mockWhereBuilder.build(query.filter)).thenCall(() => {
when(mockWhereBuilder.build(query.filter, deepEqual(new Map()))).thenCall(() => {
return { [Op.and]: { stringType: 'foo' } };
});
assertUpdateOptions(query, instance(mockWhereBuilder), {
Expand All @@ -241,7 +241,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ where: {}, limit: 10 },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});
});
});
Expand All @@ -251,7 +251,7 @@ describe('FilterQueryBuilder', (): void => {
it('should call whereBuilder#build if there is a filter', () => {
const mockWhereBuilder: WhereBuilder<TestEntity> = mock(WhereBuilder);
const query = { filter: { stringType: { eq: 'foo' } } };
when(mockWhereBuilder.build(query.filter)).thenCall(() => {
when(mockWhereBuilder.build(query.filter, deepEqual(new Map()))).thenCall(() => {
return { [Op.and]: { stringType: 'foo' } };
});
assertDestroyOptions(query, instance(mockWhereBuilder), {
Expand All @@ -271,7 +271,7 @@ describe('FilterQueryBuilder', (): void => {
instance(mockWhereBuilder),
{ limit: 10 },
);
verify(mockWhereBuilder.build(anything())).never();
verify(mockWhereBuilder.build(anything(), anything())).never();
});
});
});
Expand Down
Expand Up @@ -7,7 +7,7 @@ describe('WhereBuilder', (): void => {
const createWhereBuilder = () => new WhereBuilder<TestEntity>();

const assertSQL = (filter: Filter<TestEntity>, expectedWhereOpts: WhereOptions): void => {
const actual = createWhereBuilder().build(filter);
const actual = createWhereBuilder().build(filter, new Map());
expect(actual).toEqual(expectedWhereOpts);
};

Expand Down
Expand Up @@ -51,6 +51,57 @@ describe('SequelizeQueryService', (): void => {
const queryResult = await queryService.query({ filter: { stringType: { eq: 'foo1' } } });
return expect(queryResult.map((e) => e.get({ plain: true }))).toEqual([PLAIN_TEST_ENTITIES[0]]);
});
describe('filter on relations', () => {
describe('oneToOne', () => {
it('should allow filtering on a one to one relation', async () => {
const entity = PLAIN_TEST_ENTITIES[0];
const queryService = moduleRef.get(TestEntityService);
const queryResult = await queryService.query({
filter: {
oneTestRelation: {
testRelationPk: {
in: [`test-relations-${entity.testEntityPk}-1`, `test-relations-${entity.testEntityPk}-3`],
},
},
},
});
expect(queryResult.map((e) => e.get({ plain: true }))).toEqual([entity]);
});
});

describe('manyToOne', () => {
it('should allow filtering on a many to one relation', async () => {
const queryService = moduleRef.get(TestRelationService);
const queryResults = await queryService.query({
filter: {
testEntity: {
testEntityPk: {
in: [PLAIN_TEST_ENTITIES[0].testEntityPk, PLAIN_TEST_ENTITIES[1].testEntityPk],
},
},
},
});
expect(queryResults.map((e) => e.get({ plain: true }))).toEqual(PLAIN_TEST_RELATIONS.slice(0, 6));
});
});

describe('oneToMany', () => {
it('should allow filtering on a many to one relation', async () => {
const entity = PLAIN_TEST_ENTITIES[0];
const queryService = moduleRef.get(TestEntityService);
const queryResult = await queryService.query({
filter: {
testRelations: {
relationName: {
in: [PLAIN_TEST_RELATIONS[0].relationName, PLAIN_TEST_RELATIONS[1].relationName],
},
},
},
});
expect(queryResult.map((e) => e.get({ plain: true }))).toEqual([entity]);
});
});
});
});

describe('#count', () => {
Expand All @@ -59,6 +110,52 @@ describe('SequelizeQueryService', (): void => {
const queryResult = await queryService.count({ stringType: { like: 'foo%' } });
return expect(queryResult).toBe(10);
});

describe('with relations', () => {
describe('oneToOne', () => {
it('should properly count the number of records with the associated relations', async () => {
const entity = PLAIN_TEST_ENTITIES[0];
const queryService = moduleRef.get(TestEntityService);
const count = await queryService.count({
oneTestRelation: {
testRelationPk: {
in: [`test-relations-${entity.testEntityPk}-1`, `test-relations-${entity.testEntityPk}-3`],
},
},
});
expect(count).toEqual(1);
});
});

describe('manyToOne', () => {
it('set the relation to null', async () => {
const queryService = moduleRef.get(TestRelationService);
const count = await queryService.count({
testEntity: {
testEntityPk: {
in: [PLAIN_TEST_ENTITIES[0].testEntityPk, PLAIN_TEST_ENTITIES[2].testEntityPk],
},
},
});
expect(count).toEqual(6);
});
});

describe('oneToMany', () => {
it('set the relation to null', async () => {
const relation = PLAIN_TEST_RELATIONS[0];
const queryService = moduleRef.get(TestEntityService);
const count = await queryService.count({
testRelations: {
testEntityId: {
in: [relation.testEntityId as string],
},
},
});
expect(count).toEqual(1);
});
});
});
});

describe('#queryRelations', () => {
Expand Down
56 changes: 49 additions & 7 deletions packages/query-sequelize/src/query/filter-query.builder.ts
@@ -1,5 +1,15 @@
import { Filter, Paging, Query, SortField } from '@nestjs-query/core';
import { FindOptions, Filterable, DestroyOptions, Order, OrderItem, UpdateOptions, CountOptions } from 'sequelize';
import { Filter, getFilterFields, Paging, Query, SortField } from '@nestjs-query/core';
import {
FindOptions,
Filterable,
DestroyOptions,
Order,
OrderItem,
UpdateOptions,
CountOptions,
Association,
} from 'sequelize';
import { Model, ModelCtor } from 'sequelize-typescript';
import { WhereBuilder } from './where.builder';

/**
Expand All @@ -26,24 +36,28 @@ interface Pageable<Entity> {
*
* Class that will convert a Query into a `sequelize` Query Builder.
*/
export class FilterQueryBuilder<Entity> {
constructor(readonly whereBuilder: WhereBuilder<Entity> = new WhereBuilder<Entity>()) {}
export class FilterQueryBuilder<Entity extends Model<Entity>> {
constructor(
readonly model: ModelCtor<Entity>,
readonly whereBuilder: WhereBuilder<Entity> = new WhereBuilder<Entity>(),
) {}

/**
* Create a `sequelize` SelectQueryBuilder with `WHERE`, `ORDER BY` and `LIMIT/OFFSET` clauses.
*
* @param query - the query to apply.
*/
findOptions(query: Query<Entity>): FindOptions {
let opts: FindOptions = {};
let opts: FindOptions = this.applyAssociationIncludes({}, query.filter);
opts = this.applyFilter(opts, query.filter);
opts = this.applySorting(opts, query.sorting);
opts = this.applyPaging(opts, query.paging);
return opts;
}

countOptions(query: Query<Entity>): CountOptions {
let opts: CountOptions = {};
let opts: CountOptions = this.applyAssociationIncludes({}, query.filter);
opts.distinct = true;
opts = this.applyFilter(opts, query.filter);
return opts;
}
Expand Down Expand Up @@ -103,7 +117,7 @@ export class FilterQueryBuilder<Entity> {
return filterable;
}
// eslint-disable-next-line no-param-reassign
filterable.where = this.whereBuilder.build(filter);
filterable.where = this.whereBuilder.build(filter, this.getReferencedRelations(filter));
return filterable;
}

Expand All @@ -129,4 +143,32 @@ export class FilterQueryBuilder<Entity> {
);
return qb;
}

private applyAssociationIncludes<Opts extends FindOptions | CountOptions>(
findOpts: Opts,
filter?: Filter<Entity>,
): Opts {
if (!filter) {
return findOpts;
}
const referencedRelations = this.getReferencedRelations(filter);
return [...referencedRelations.values()].reduce((find, association) => {
// eslint-disable-next-line no-param-reassign
find.include = [...(find.include || []), { association, attributes: [] }];
return find;
}, findOpts);
}

private getReferencedRelations(filter: Filter<Entity>): Map<string, Association> {
const { relationNames } = this;
const referencedFields = getFilterFields(filter);
const referencedRelations = referencedFields.filter((f) => relationNames.includes(f));
return referencedRelations.reduce((map, r) => {
return map.set(r, this.model.associations[r]);
}, new Map<string, Association>());
}

private get relationNames(): string[] {
return Object.keys(this.model.associations || {});
}
}

0 comments on commit 92a51c1

Please sign in to comment.