From 8733a1d8b046e61d2912cff515dfe66a9dc62c81 Mon Sep 17 00:00:00 2001 From: Luca Nardelli Date: Fri, 21 Jan 2022 09:06:20 +0100 Subject: [PATCH] feat(graphql+typeorm): Custom filters - Force UTC timezone to make some tests (e.g. typeorm-query-service.spec) deterministic - Allow to define and register custom filters on types and entities (virtual fields as well) at the Typeorm (persistence) layer - Allow extending filters on the built-in graphql types - Implement custom filters on custom graphql scalars - Implement allowedComparisons for extended filters and for custom scalar defined graphql filters - Implement custom graphql filters on virtual properties - Documentation - Tests --- documentation/docs/graphql/custom-filters.md | 203 +++++++++++++++ .../persistence/typeorm/custom-filters.md | 123 +++++++++ documentation/sidebars.js | 2 + examples/package.json | 3 +- examples/typeorm/e2e/graphql-fragments.ts | 1 + .../typeorm/e2e/sub-task.resolver.spec.ts | 2 + .../typeorm/e2e/todo-item.resolver.spec.ts | 239 ++++++++++++++++-- examples/typeorm/src/app.module.ts | 4 +- .../src/filters/is-multiple-of.filter.ts | 34 +++ .../filters/todo-item-low-priority.filter.ts | 12 + .../src/todo-item/dto/todo-item.dto.ts | 17 +- .../typeorm/src/todo-item/todo-item.entity.ts | 13 +- .../typeorm/src/todo-item/todo-item.module.ts | 12 +- examples/utils/randomString.ts | 7 + package.json | 7 +- packages/core/src/helpers/filter.builder.ts | 2 +- .../filter-field-comparison.interface.ts | 14 +- .../core/src/interfaces/filter.interface.ts | 10 +- .../__tests__/__fixtures__/index.ts | 12 +- .../__tests__/__fixtures__/scalars.ts | 34 +++ .../__snapshots__/filter.type.spec.ts.snap | 116 +++++++++ .../__tests__/types/query/filter.type.spec.ts | 113 +++++++-- packages/query-graphql/src/index.ts | 1 + .../custom-field-comparison.type.ts | 20 ++ .../field-comparison.factory.ts | 128 +++++++++- .../field-comparison.registry.ts | 84 ++++++ .../src/types/query/field-comparison/index.ts | 3 +- .../src/types/query/filter.type.ts | 32 ++- .../__fixtures__/connection.fixture.ts | 9 +- .../__fixtures__/custom-filters.services.ts | 84 ++++++ .../__tests__/__fixtures__/seeds.ts | 13 +- .../__fixtures__/test-relation.entity.ts | 5 +- .../__tests__/__fixtures__/test.entity.ts | 15 +- .../__tests__/__fixtures__/types.ts | 19 ++ .../metadata/typeorm-metadata.spec.ts | 72 ++++++ .../query-typeorm/__tests__/module.spec.ts | 3 +- .../query-typeorm/__tests__/providers.spec.ts | 23 +- .../filter-query.builder.spec.ts.snap | 28 ++ .../relation-query.builder.spec.ts.snap | 43 +++- .../__snapshots__/where.builder.spec.ts.snap | 47 ++++ .../__tests__/query/aggregate.builder.spec.ts | 4 +- .../query/filter-query.builder.spec.ts | 124 ++++++--- .../query/relation-query.builder.spec.ts | 30 ++- .../__tests__/query/where.builder.spec.ts | 60 ++++- .../services/custom-filter-registry.spec.ts | 103 ++++++++ .../services/typeorm-query.service.spec.ts | 95 ++++++- packages/query-typeorm/__tests__/utils.ts | 30 +++ packages/query-typeorm/package.json | 1 + packages/query-typeorm/src/common/index.ts | 1 + packages/query-typeorm/src/common/typeorm.ts | 75 ++++++ .../query-typeorm/src/decorators/constants.ts | 2 + .../query-typeorm/src/decorators/index.ts | 2 + .../typeorm-query-filter.decorator.ts | 61 +++++ .../query-typeorm/src/decorators/utils.ts | 3 + .../with-typeorm-query-filter.decorator.ts | 37 +++ packages/query-typeorm/src/index.ts | 7 + packages/query-typeorm/src/module.ts | 78 +++++- packages/query-typeorm/src/providers.ts | 15 +- .../src/query/custom-filter.registry.ts | 90 +++++++ .../src/query/filter-query.builder.ts | 130 +++++++--- packages/query-typeorm/src/query/index.ts | 1 + .../src/query/relation-query.builder.ts | 22 +- .../src/query/sql-comparison.builder.ts | 2 +- .../query-typeorm/src/query/where.builder.ts | 135 +++++++--- 64 files changed, 2467 insertions(+), 250 deletions(-) create mode 100644 documentation/docs/graphql/custom-filters.md create mode 100644 documentation/docs/persistence/typeorm/custom-filters.md create mode 100644 examples/typeorm/src/filters/is-multiple-of.filter.ts create mode 100644 examples/typeorm/src/filters/todo-item-low-priority.filter.ts create mode 100644 examples/utils/randomString.ts create mode 100644 packages/query-graphql/__tests__/__fixtures__/scalars.ts create mode 100644 packages/query-graphql/src/types/query/field-comparison/custom-field-comparison.type.ts create mode 100644 packages/query-graphql/src/types/query/field-comparison/field-comparison.registry.ts create mode 100644 packages/query-typeorm/__tests__/__fixtures__/custom-filters.services.ts create mode 100644 packages/query-typeorm/__tests__/__fixtures__/types.ts create mode 100644 packages/query-typeorm/__tests__/metadata/typeorm-metadata.spec.ts create mode 100644 packages/query-typeorm/__tests__/services/custom-filter-registry.spec.ts create mode 100644 packages/query-typeorm/__tests__/utils.ts create mode 100644 packages/query-typeorm/src/common/typeorm.ts create mode 100644 packages/query-typeorm/src/decorators/constants.ts create mode 100644 packages/query-typeorm/src/decorators/index.ts create mode 100644 packages/query-typeorm/src/decorators/typeorm-query-filter.decorator.ts create mode 100644 packages/query-typeorm/src/decorators/utils.ts create mode 100644 packages/query-typeorm/src/decorators/with-typeorm-query-filter.decorator.ts create mode 100644 packages/query-typeorm/src/query/custom-filter.registry.ts diff --git a/documentation/docs/graphql/custom-filters.md b/documentation/docs/graphql/custom-filters.md new file mode 100644 index 000000000..a241cdf0e --- /dev/null +++ b/documentation/docs/graphql/custom-filters.md @@ -0,0 +1,203 @@ +--- +title: Custom Filters +--- + +In addition to the built in filters, you can also define custom filtering operations. + +There are 2 types of custom filters: + +- Global type-based filters, that automatically work on all fields of a given GraphQL type. +- Custom entity-specific filters, that are custom-tailored for DTOs and do not require a specific backing field (more on + that below). + +[//]: # (TODO Add link to page) +:::important + +This page describes how to implement custom filters at the GraphQL Level. The persistence layer needs to support them as +well. For now, only TypeOrm is implemented. See here: + +- [TypeOrm Custom Filters](/docs/persistence/typeorm/custom-filters) + +::: + +## Global type-based filters + +Type based filters are applied globally to all DTOs, based only on the underlying GraphQL Type. + +### Extending the existing filters + +Let's assume our persistence layer exposes a `isMultipleOf` filter which allows us to filter numeric fields and choose +only multiples of a user-supplied value. In order to expose that filter on all numeric GraphQL fields, we can do the +following in any typescript file (ideally this should run before the app is initialized): + +```ts +import { registerTypeComparison } from '@nestjs-query/query-graphql'; +import { IsBoolean, IsInt } from 'class-validator'; +import { Float, Int } from '@nestjs/graphql'; + +registerTypeComparison(Number, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] }); +registerTypeComparison(Int, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] }); +registerTypeComparison(Float, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] }); + +// Note, this also works +// registerTypeComparison([Number, Int, Float], 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] }); +``` + +Where: + +- FilterType is the typescript type of the filter +- GqlType is the GraphQL type that will be used in the schema +- decorators represents a list of decorators that will be applied to the filter class at the specific field used for the + operation, used e.g. for validation purposes + +The above snippet patches the existing Number/Int/Float FieldComparisons so that they expose the new +field/operation `isMultipleOf`. Example: + +```graphql +input NumberFieldComparison { + is: Boolean + isNot: Boolean + eq: Float + neq: Float + gt: Float + gte: Float + lt: Float + lte: Float + in: [Float!] + notIn: [Float!] + between: NumberFieldComparisonBetween + notBetween: NumberFieldComparisonBetween + isMultipleOf: Int +} +``` + +### Defining a filter on a custom scalar + +Let's assume we have a custom scalar, to represent e.g. a geo point (i.e. {lat, lng}): + +```ts +@Scalar('Point', (type) => Object) +export class PointScalar implements CustomScalar { + description = 'Point custom scalar type'; + + parseValue(value: any): any { + return { lat: value.lat, lng: value.lng }; + } + + serialize(value: any): any { + return { lat: value.lat, lng: value.lng }; + } + + parseLiteral(ast: ValueNode): any { + if (ast.kind === Kind.OBJECT) { + return ast.fields; + } + return null; + } +} +``` + +Now, we want to add a radius filter to all Point scalars. A radius filter is a filter that returns all entities whose +location is within a given distance from another point. + +First, we need to define the filter type: + +```ts +@InputType('RadiusFilter') +export class RadiusFilter { + @Field(() => Number) + lat!: number; + + @Field(() => Number) + lng!: number; + + @Field(() => Number) + radius!: number; +} +``` + +Then, we need to register said filter: + +```ts +registerTypeComparison(PointScalar, 'distanceFrom', { + FilterType: RadiusFilter, + GqlType: RadiusFilter, +}); +``` + +The above snippet creates a new comparison type for the Point scalar and adds the distanceFrom operations to it. +Example: + +```graphql +input RadiusFilter { + lat: Float! + lng: Float! + radius: Float! +} + +input PointScalarFilterComparison { + distanceFrom: RadiusFilter +} +``` + +Now, our persistence layer will be able to receive this new `distanceFrom` key for every property that is represented as +a Point scalar. + +:::important + +If the shape of the filter at the GraphQL layer is different from what the persistence layer expects, remember to use +an [Assembler and its convertQuery method!](/docs/concepts/advanced/assemblers#converting-the-query) + +::: + +### Disabling a type-based custom filter on specific fields of a DTO + +Global filters are fully compatible with the [allowedComparisons](/docs/graphql/dtos/#example---allowedcomparisons) +option of the `@FilterableField` decorator. + +## DTO-based custom filters + +These custom filters are explicitly registered on a single DTO field, rather than at the type level. This can be useful +if the persistence layer exposes some specific filters only on some entities (e.g. "Filter all projects who more than 5 +pending tasks" where we need to compute the number of pending tasks using a SQL sub-query in the where clause, instead +of having a computed field in the project entity). + +:::important + +DTO-based custom filters cannot be registered on existing DTO filterable fields, use type-based filters for that! + +::: + +In order to register a "pending tasks count" filter on our ProjectDto, we can do as follows: + +```ts +registerDTOFieldComparison(TestDto, 'pendingTaskCount', 'gt', { + FilterType: Number, + GqlType: Int, + decorators: [IsInt()], +}); +``` + +Where: + +- FilterType is the typescript type of the filter +- GqlType is the GraphQL type that will be used in the schema +- decorators represents a list of decorators that will be applied to the filter class at the specific field used for the + operation, used e.g. for validation purposes + +This will add a new operation to the GraphQL `TestDto` input type + +```graphql +input TestPendingTaskCountFilterComparison { + gt: Int +} + +input TestDtoFilter { + """ + ...Other fields defined in TestDTO + """ + pendingTaskCount: TestPendingTaskCountFilterComparison +} +``` + +Now, graphQL will accept the new filter and our persistence layer will be able to receive the key `pendingTaskCount` for all filtering operations related to the "TestDto" DTO. diff --git a/documentation/docs/persistence/typeorm/custom-filters.md b/documentation/docs/persistence/typeorm/custom-filters.md new file mode 100644 index 000000000..d0e18d99e --- /dev/null +++ b/documentation/docs/persistence/typeorm/custom-filters.md @@ -0,0 +1,123 @@ +--- +title: Custom Filters +--- + +In addition to the built in filters, which work for a lot of common scenarios, @nestjs-query/typeorm supports custom filters. + +There are 2 types of custom filters: +- Global type-based filters, that automatically work on all fields of a given database type. +- Custom entity-specific filters, that are custom-tailored for entities and do not require a backing database field (more on that below). + +[//]: # (TODO Add link to page) +:::important +This page describes how to implement custom filters. In order to expose them in Graphql see the [relevant page](/docs/graphql/custom-filters)! +::: + +## Global custom filters + +Let's assume we want to create a filter that allows us to filter for integer fields where the value is a multiple of a given number. The custom filter would look like this + +```ts title="is-multiple-of.filter.ts" +import { TypeOrmQueryFilter, CustomFilter, CustomFilterResult } from '@nestjs-query/query-typeorm'; + +@TypeOrmQueryFilter({ + types: [Number, 'integer'], + operations: ['isMultipleOf'], +}) +export class IsMultipleOfCustomFilter implements CustomFilter { + apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult { + alias = alias ? alias : ''; + const pname = `param${randomString()}`; + return { + sql: `(${alias}.${field} % :${pname}) == 0`, + params: { [pname]: val }, + }; + } +} +``` + +Then, you need to register the filter in your NestjsQueryTypeOrmModule definition + +```ts +NestjsQueryTypeOrmModule.forFeature( + [], // Entities + undefined, // Connection, undefined means "use the default one" + { + providers: [ + IsMultipleOfCustomFilter, + ], + }, +); +``` + +That's it! Now the filter will be automatically used whenever a filter like `{: {isMultipleOf: }}` is passed! + +## Entity custom filters + +Let's assume that we have a Project entity and a Task entity, where Project has many tasks and where tasks can be either complete or not. We want to create a filter on Project that returns only projects with X pending tasks. + +Our entities look like this: + +```ts +@Entity() +// Note how the custom filter is registered here +@WithTypeormQueryFilter({ + filter: TestEntityTestRelationCountFilter, + fields: ['pendingTasks'], + operations: ['gt'], +}) +export class Project { + @PrimaryColumn({ name: 'id' }) + id!: string; + + @OneToMany('TestRelation', 'testEntity') + tasks?: Task[]; +} + +@Entity() +export class Task { + @PrimaryColumn({ name: 'id' }) + id!: string; + + @Column({ name: 'status' }) + status!: string; + + @ManyToOne(() => TestEntity, (te) => te.tasks, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project?: Project; + + @Column({ name: 'project_id', nullable: true }) + projectId?: string; +} +``` + +The custom filter, instead, looks like this: + +```ts title="project-pending-tasks-count.filter.ts" +import { TypeOrmQueryFilter, CustomFilter, CustomFilterResult } from '@nestjs-query/query-typeorm'; +import { EntityManager } from 'typeorm'; + +// No operations or types here, which means that the filter is not registered globally on types. We will be registering the filter individually on the Project entity. +@TypeOrmQueryFilter() +export class TestEntityTestRelationCountFilter implements CustomFilter { + // Since the filter is an Injectable, we can inject other services here, such as an entity manager to create the subquery + constructor(private em: EntityManager) {} + + apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult { + alias = alias ? alias : ''; + const pname = `param${randomString()}`; + + const subQb = this.em + .createQueryBuilder(Task, 't') + .select('COUNT(*)') + .where(`t.status = 'pending' AND t.project_id = ${alias}.id`); + + return { + sql: `(${subQb.getSql()}) > :${pname}`, + params: { [pname]: val }, + }; + } +} +``` + +That's it! Now the filter will be automatically used whenever a filter like `{pendingTasks: {gt: }}` is used, but only when said filter refers to the Project entity. diff --git a/documentation/sidebars.js b/documentation/sidebars.js index 1721434d1..2da5ac374 100755 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -22,6 +22,7 @@ module.exports = { TypeOrm: [ 'persistence/typeorm/getting-started', 'persistence/typeorm/custom-service', + 'persistence/typeorm/custom-filters', 'persistence/typeorm/multiple-databases', 'persistence/typeorm/soft-delete', 'persistence/typeorm/testing-services', @@ -50,6 +51,7 @@ module.exports = { 'graphql/dtos', 'graphql/resolvers', 'graphql/queries', + 'graphql/custom-filters', 'graphql/mutations', 'graphql/paging', 'graphql/hooks', diff --git a/examples/package.json b/examples/package.json index e8268846a..f762b80af 100644 --- a/examples/package.json +++ b/examples/package.json @@ -49,7 +49,8 @@ "sequelize": "6.9.0", "sequelize-typescript": "2.1.1", "typeorm": "0.2.40", - "typeorm-seeding": "1.6.1" + "typeorm-seeding": "1.6.1", + "uuid": "^8.3.2" }, "devDependencies": { "@nestjs/cli": "8.1.4", diff --git a/examples/typeorm/e2e/graphql-fragments.ts b/examples/typeorm/e2e/graphql-fragments.ts index 34341bac1..4d18f588b 100644 --- a/examples/typeorm/e2e/graphql-fragments.ts +++ b/examples/typeorm/e2e/graphql-fragments.ts @@ -3,6 +3,7 @@ export const todoItemFields = ` title completed description + priority age `; diff --git a/examples/typeorm/e2e/sub-task.resolver.spec.ts b/examples/typeorm/e2e/sub-task.resolver.spec.ts index 485bafda3..9dd14296d 100644 --- a/examples/typeorm/e2e/sub-task.resolver.spec.ts +++ b/examples/typeorm/e2e/sub-task.resolver.spec.ts @@ -177,6 +177,7 @@ describe('SubTaskResolver (typeorm - e2e)', () => { title: 'Create Nest App', completed: true, description: null, + priority: 0, age: expect.any(Number), }, }, @@ -830,6 +831,7 @@ describe('SubTaskResolver (typeorm - e2e)', () => { title: 'Create Entity', completed: false, description: null, + priority: 1, age: expect.any(Number), }, }, diff --git a/examples/typeorm/e2e/todo-item.resolver.spec.ts b/examples/typeorm/e2e/todo-item.resolver.spec.ts index de77eadff..9f826509a 100644 --- a/examples/typeorm/e2e/todo-item.resolver.spec.ts +++ b/examples/typeorm/e2e/todo-item.resolver.spec.ts @@ -1,8 +1,8 @@ import { AggregateResponse, getQueryServiceToken, QueryService } from '@nestjs-query/core'; import { CursorConnectionType } from '@nestjs-query/query-graphql'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Connection } from 'typeorm'; import { AppModule } from '../src/app.module'; import { config } from '../src/config'; @@ -10,18 +10,18 @@ import { AUTH_HEADER_NAME, USER_HEADER_NAME } from '../src/constants'; import { SubTaskDTO } from '../src/sub-task/dto/sub-task.dto'; import { TagDTO } from '../src/tag/dto/tag.dto'; import { TodoItemDTO } from '../src/todo-item/dto/todo-item.dto'; +import { TodoItemEntity } from '../src/todo-item/todo-item.entity'; import { refresh } from './fixtures'; import { edgeNodes, pageInfoField, + subTaskAggregateFields, subTaskFields, + tagAggregateFields, tagFields, - todoItemFields, todoItemAggregateFields, - tagAggregateFields, - subTaskAggregateFields, + todoItemFields, } from './graphql-fragments'; -import { TodoItemEntity } from '../src/todo-item/todo-item.entity'; describe('TodoItemResolver (typeorm - e2e)', () => { let app: INestApplication; @@ -70,6 +70,7 @@ describe('TodoItemResolver (typeorm - e2e)', () => { title: 'Create Nest App', completed: true, description: null, + priority: 0, age: expect.any(Number), }, }, @@ -238,15 +239,44 @@ describe('TodoItemResolver (typeorm - e2e)', () => { expect(totalCount).toBe(5); expect(edges).toHaveLength(5); expect(edges.map((e) => e.node)).toEqual([ - { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, - { id: '2', title: 'Create Entity', completed: false, description: null, age: expect.any(Number) }, - { id: '3', title: 'Create Entity Service', completed: false, description: null, age: expect.any(Number) }, - { id: '4', title: 'Add Todo Item Resolver', completed: false, description: null, age: expect.any(Number) }, + { + id: '1', + title: 'Create Nest App', + completed: true, + description: null, + priority: 0, + age: expect.any(Number), + }, + { + id: '2', + title: 'Create Entity', + completed: false, + description: null, + priority: 1, + age: expect.any(Number), + }, + { + id: '3', + title: 'Create Entity Service', + completed: false, + description: null, + priority: 2, + age: expect.any(Number), + }, + { + id: '4', + title: 'Add Todo Item Resolver', + completed: false, + description: null, + priority: 3, + age: expect.any(Number), + }, { id: '5', title: 'How to create item With Sub Tasks', completed: false, description: null, + priority: 4, age: expect.any(Number), }, ]); @@ -278,10 +308,94 @@ describe('TodoItemResolver (typeorm - e2e)', () => { expect(totalCount).toBe(3); expect(edges).toHaveLength(3); expect(edges.map((e) => e.node)).toEqual([ - { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, - { id: '2', title: 'Create Entity', completed: false, description: null, age: expect.any(Number) }, - { id: '3', title: 'Create Entity Service', completed: false, description: null, age: expect.any(Number) }, + { + id: '1', + title: 'Create Nest App', + completed: true, + description: null, + priority: 0, + age: expect.any(Number), + }, + { + id: '2', + title: 'Create Entity', + completed: false, + description: null, + priority: 1, + age: expect.any(Number), + }, + { + id: '3', + title: 'Create Entity Service', + completed: false, + description: null, + priority: 2, + age: expect.any(Number), + }, + ]); + })); + + it(`should allow querying with type-based custom filters`, () => + request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems(filter: { priority: { isMultipleOf: 3 } }) { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; + expect(totalCount).toBe(2); + expect(edges).toHaveLength(2); + expect(edges.map((e) => e.node)).toMatchObject([ + { id: '1', title: 'Create Nest App', completed: true, priority: 0 }, + { id: '4', title: 'Add Todo Item Resolver', completed: false, priority: 3 }, + ]); + expect(pageInfo).toEqual({ + endCursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImlkIiwidmFsdWUiOjR9XX0=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImlkIiwidmFsdWUiOjF9XX0=', + }); + })); + + it(`should allow querying with virtual field custom filters`, () => + request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems(filter: { lowPriority: { is: true } }) { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; + expect(totalCount).toBe(3); + expect(edges).toHaveLength(3); + expect(edges.map((e) => e.node)).toMatchObject([ + { id: '1', title: 'Create Nest App', completed: true, priority: 0 }, + { id: '2', title: 'Create Entity', completed: false, priority: 1 }, + { id: '3', title: 'Create Entity Service', completed: false, priority: 2 }, ]); + expect(pageInfo).toEqual({ + startCursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImlkIiwidmFsdWUiOjF9XX0=', + endCursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImlkIiwidmFsdWUiOjN9XX0=', + hasNextPage: false, + hasPreviousPage: false, + }); })); it(`should allow querying on subTasks`, () => @@ -311,8 +425,22 @@ describe('TodoItemResolver (typeorm - e2e)', () => { expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual([ - { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, - { id: '2', title: 'Create Entity', completed: false, description: null, age: expect.any(Number) }, + { + id: '1', + title: 'Create Nest App', + completed: true, + description: null, + priority: 0, + age: expect.any(Number), + }, + { + id: '2', + title: 'Create Entity', + completed: false, + description: null, + priority: 1, + age: expect.any(Number), + }, ]); })); @@ -343,8 +471,22 @@ describe('TodoItemResolver (typeorm - e2e)', () => { expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual([ - { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, - { id: '4', title: 'Add Todo Item Resolver', completed: false, description: null, age: expect.any(Number) }, + { + id: '1', + title: 'Create Nest App', + completed: true, + description: null, + priority: 0, + age: expect.any(Number), + }, + { + id: '4', + title: 'Add Todo Item Resolver', + completed: false, + description: null, + priority: 3, + age: expect.any(Number), + }, ]); })); @@ -379,12 +521,41 @@ describe('TodoItemResolver (typeorm - e2e)', () => { title: 'How to create item With Sub Tasks', completed: false, description: null, + priority: 4, + age: expect.any(Number), + }, + { + id: '4', + title: 'Add Todo Item Resolver', + completed: false, + description: null, + priority: 3, + age: expect.any(Number), + }, + { + id: '3', + title: 'Create Entity Service', + completed: false, + description: null, + priority: 2, + age: expect.any(Number), + }, + { + id: '2', + title: 'Create Entity', + completed: false, + description: null, + priority: 1, + age: expect.any(Number), + }, + { + id: '1', + title: 'Create Nest App', + completed: true, + description: null, + priority: 0, age: expect.any(Number), }, - { id: '4', title: 'Add Todo Item Resolver', completed: false, description: null, age: expect.any(Number) }, - { id: '3', title: 'Create Entity Service', completed: false, description: null, age: expect.any(Number) }, - { id: '2', title: 'Create Entity', completed: false, description: null, age: expect.any(Number) }, - { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, ]); })); @@ -415,8 +586,22 @@ describe('TodoItemResolver (typeorm - e2e)', () => { expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual([ - { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, - { id: '2', title: 'Create Entity', completed: false, description: null, age: expect.any(Number) }, + { + id: '1', + title: 'Create Nest App', + completed: true, + description: null, + priority: 0, + age: expect.any(Number), + }, + { + id: '2', + title: 'Create Entity', + completed: false, + description: null, + priority: 1, + age: expect.any(Number), + }, ]); })); @@ -446,12 +631,20 @@ describe('TodoItemResolver (typeorm - e2e)', () => { expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual([ - { id: '3', title: 'Create Entity Service', completed: false, description: null, age: expect.any(Number) }, + { + id: '3', + title: 'Create Entity Service', + completed: false, + description: null, + priority: 2, + age: expect.any(Number), + }, { id: '4', title: 'Add Todo Item Resolver', completed: false, description: null, + priority: 3, age: expect.any(Number), }, ]); diff --git a/examples/typeorm/src/app.module.ts b/examples/typeorm/src/app.module.ts index bcab55c34..381f119a2 100644 --- a/examples/typeorm/src/app.module.ts +++ b/examples/typeorm/src/app.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; import { GraphQLModule } from '@nestjs/graphql'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { typeormOrmConfig } from '../../helpers'; import { GqlContext } from './auth.guard'; +import { SubTaskModule } from './sub-task/sub-task.module'; import { TagModule } from './tag/tag.module'; import { TodoItemModule } from './todo-item/todo-item.module'; -import { SubTaskModule } from './sub-task/sub-task.module'; -import { typeormOrmConfig } from '../../helpers'; @Module({ imports: [ diff --git a/examples/typeorm/src/filters/is-multiple-of.filter.ts b/examples/typeorm/src/filters/is-multiple-of.filter.ts new file mode 100644 index 000000000..66f8094fc --- /dev/null +++ b/examples/typeorm/src/filters/is-multiple-of.filter.ts @@ -0,0 +1,34 @@ +import { registerTypeComparison } from '@nestjs-query/query-graphql'; +import { CustomFilter, CustomFilterResult, TypeOrmQueryFilter } from '@nestjs-query/query-typeorm'; +import { Float, Int } from '@nestjs/graphql'; +import { IsInt } from 'class-validator'; +// import { IsInt } from 'class-validator'; +import { ColumnType } from 'typeorm'; +import { randomString } from '../../../utils/randomString'; + +@TypeOrmQueryFilter({ + types: IsMultipleOfCustomFilter.types, + operations: IsMultipleOfCustomFilter.operations, +}) +export class IsMultipleOfCustomFilter implements CustomFilter { + static readonly types: ColumnType[] = [Number, 'integer']; + + static readonly operations = ['isMultipleOf']; + + apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult { + alias = alias ? alias : ''; + const pname = `param${randomString()}`; + return { + sql: `(${alias}.${field} % :${pname}) = 0`, + params: { [pname]: val }, + }; + } +} + +// Register the filter at the graphql level +// eslint-disable-next-line @typescript-eslint/no-unsafe-call +registerTypeComparison([Number, Float, Int], 'isMultipleOf', { + FilterType: Number, + GqlType: Int, + decorators: [IsInt()], +}); diff --git a/examples/typeorm/src/filters/todo-item-low-priority.filter.ts b/examples/typeorm/src/filters/todo-item-low-priority.filter.ts new file mode 100644 index 000000000..c241b6583 --- /dev/null +++ b/examples/typeorm/src/filters/todo-item-low-priority.filter.ts @@ -0,0 +1,12 @@ +import { CustomFilter, CustomFilterResult, TypeOrmQueryFilter } from '@nestjs-query/query-typeorm'; + +@TypeOrmQueryFilter() +export class TodoItemLowPriorityFilter implements CustomFilter { + apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult { + alias = alias ? alias : ''; + return { + sql: `(${alias}.priority < 3)`, + params: {}, + }; + } +} diff --git a/examples/typeorm/src/todo-item/dto/todo-item.dto.ts b/examples/typeorm/src/todo-item/dto/todo-item.dto.ts index 04445df3f..a25fa4b07 100644 --- a/examples/typeorm/src/todo-item/dto/todo-item.dto.ts +++ b/examples/typeorm/src/todo-item/dto/todo-item.dto.ts @@ -1,8 +1,15 @@ -import { FilterableField, FilterableCursorConnection, KeySet, QueryOptions } from '@nestjs-query/query-graphql'; +import { + FilterableField, + FilterableCursorConnection, + KeySet, + QueryOptions, + registerDTOFieldComparison, +} from '@nestjs-query/query-graphql'; import { ObjectType, ID, GraphQLISODateTime, Field } from '@nestjs/graphql'; import { AuthGuard } from '../../auth.guard'; import { SubTaskDTO } from '../../sub-task/dto/sub-task.dto'; import { TagDTO } from '../../tag/dto/tag.dto'; +import { IsBoolean } from 'class-validator'; @ObjectType('TodoItem') @KeySet(['id']) @@ -40,3 +47,11 @@ export class TodoItemDTO { @FilterableField({ nullable: true }) updatedBy?: string; } + +// Register the filter at the graphql level +// eslint-disable-next-line @typescript-eslint/no-unsafe-call +registerDTOFieldComparison(TodoItemDTO, 'lowPriority', 'is', { + FilterType: Boolean, + GqlType: Boolean, + decorators: [IsBoolean()], +}); diff --git a/examples/typeorm/src/todo-item/todo-item.entity.ts b/examples/typeorm/src/todo-item/todo-item.entity.ts index 2deb3f0cc..0e5874678 100644 --- a/examples/typeorm/src/todo-item/todo-item.entity.ts +++ b/examples/typeorm/src/todo-item/todo-item.entity.ts @@ -1,17 +1,24 @@ +import { WithTypeormQueryFilter } from '@nestjs-query/query-typeorm'; import { Column, CreateDateColumn, Entity, + JoinTable, + ManyToMany, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, - OneToMany, - ManyToMany, - JoinTable, } from 'typeorm'; +import { TodoItemLowPriorityFilter } from '../filters/todo-item-low-priority.filter'; import { SubTaskEntity } from '../sub-task/sub-task.entity'; import { TagEntity } from '../tag/tag.entity'; @Entity({ name: 'todo_item' }) +@WithTypeormQueryFilter({ + filter: TodoItemLowPriorityFilter, + fields: ['lowPriority'], + operations: ['is'], +}) export class TodoItemEntity { @PrimaryGeneratedColumn() id!: number; diff --git a/examples/typeorm/src/todo-item/todo-item.module.ts b/examples/typeorm/src/todo-item/todo-item.module.ts index 87598e075..0bb47e9cd 100644 --- a/examples/typeorm/src/todo-item/todo-item.module.ts +++ b/examples/typeorm/src/todo-item/todo-item.module.ts @@ -2,6 +2,8 @@ import { NestjsQueryGraphQLModule } from '@nestjs-query/query-graphql'; import { NestjsQueryTypeOrmModule } from '@nestjs-query/query-typeorm'; import { Module } from '@nestjs/common'; import { AuthGuard } from '../auth.guard'; +import { IsMultipleOfCustomFilter } from '../filters/is-multiple-of.filter'; +import { TodoItemLowPriorityFilter } from '../filters/todo-item-low-priority.filter'; import { TodoItemInputDTO } from './dto/todo-item-input.dto'; import { TodoItemUpdateDTO } from './dto/todo-item-update.dto'; import { TodoItemDTO } from './dto/todo-item.dto'; @@ -10,11 +12,19 @@ import { TodoItemEntity } from './todo-item.entity'; import { TodoItemResolver } from './todo-item.resolver'; const guards = [AuthGuard]; +// prettier-ignore @Module({ providers: [TodoItemResolver], imports: [ NestjsQueryGraphQLModule.forFeature({ - imports: [NestjsQueryTypeOrmModule.forFeature([TodoItemEntity])], + imports: [ + NestjsQueryTypeOrmModule.forFeature([TodoItemEntity], undefined, { + providers: [ + IsMultipleOfCustomFilter, + TodoItemLowPriorityFilter, + ], + }), + ], assemblers: [TodoItemAssembler], resolvers: [ { diff --git a/examples/utils/randomString.ts b/examples/utils/randomString.ts new file mode 100644 index 000000000..25f1c5234 --- /dev/null +++ b/examples/utils/randomString.ts @@ -0,0 +1,7 @@ +import { v4 } from 'uuid'; + +const replacer = /-/g; + +export function randomString(): string { + return v4().replace(replacer, ''); +} diff --git a/package.json b/package.json index dc91c6fbd..a34975c02 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "bootstrap": "lerna bootstrap", "clean": "lerna run clean", "build": "lerna run build", + "build:pkg": "lerna run build --include-dependencies --scope", "compile": "lerna run compile", "lerna:pub": "lerna publish from-git", "lerna:version": "lerna version", @@ -14,9 +15,9 @@ "lint": "eslint --cache --ext=.ts .", "lint:no-cache": "eslint --ext=.ts .", "lint:fix": "eslint --fix --ext=.ts .", - "jest": "jest --runInBand --coverage", - "jest:e2e": "jest --runInBand --config=./jest.e2e.config.js", - "jest:unit": "jest --coverage --config=./jest.unit.config.js", + "jest": "TZ=UTC jest --runInBand --coverage", + "jest:e2e": "TZ=UTC jest --runInBand --config=./jest.e2e.config.js", + "jest:unit": "TZ=UTC jest --coverage --config=./jest.unit.config.js", "coverage": "cat ./coverage/lcov.info | coveralls", "prepare": "husky install" }, diff --git a/packages/core/src/helpers/filter.builder.ts b/packages/core/src/helpers/filter.builder.ts index 7b4f7e9a4..e2383f4f9 100644 --- a/packages/core/src/helpers/filter.builder.ts +++ b/packages/core/src/helpers/filter.builder.ts @@ -58,6 +58,6 @@ export class FilterBuilder { throw new Error(`unknown comparison ${JSON.stringify(fieldOrNested)}`); } const nestedFilterFn = this.build(value); - return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : null); + return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : undefined); } } diff --git a/packages/core/src/interfaces/filter-field-comparison.interface.ts b/packages/core/src/interfaces/filter-field-comparison.interface.ts index fcb46ad89..70b4e11d1 100644 --- a/packages/core/src/interfaces/filter-field-comparison.interface.ts +++ b/packages/core/src/interfaces/filter-field-comparison.interface.ts @@ -181,19 +181,7 @@ export interface StringFieldComparisons extends CommonFieldComparisonType = { * } * ``` */ -type FilterGrouping = { +type FilterGrouping> = { /** * Group an array of filters with an AND operation. */ - and?: Filter[]; + and?: Filter[]; /** * Group an array of filters with an OR operation. */ - or?: Filter[]; + or?: Filter[]; }; /** @@ -111,5 +111,7 @@ type FilterGrouping = { * ``` * * @typeparam T - the type of object to filter on. + * @typeparam C - custom filters defined for the filter. */ -export type Filter = FilterGrouping & FilterComparisons; +export type Filter> = FilterGrouping & + FilterComparisons & { [K in keyof C]: C[K] }; diff --git a/packages/query-graphql/__tests__/__fixtures__/index.ts b/packages/query-graphql/__tests__/__fixtures__/index.ts index c9f2b79c8..93264a636 100644 --- a/packages/query-graphql/__tests__/__fixtures__/index.ts +++ b/packages/query-graphql/__tests__/__fixtures__/index.ts @@ -9,6 +9,7 @@ import { TestService } from './test-resolver.service'; import { TestResolverDTO } from './test-resolver.dto'; import { TestResolverAuthorizer } from './test-resolver.authorizer'; import { getAuthorizerToken } from '../../src/auth'; +import { PointScalar } from './scalars'; export { TestResolverInputDTO } from './test-resolver-input.dto'; export { TestResolverDTO } from './test-resolver.dto'; @@ -16,17 +17,14 @@ export { TestResolverAuthorizer } from './test-resolver.authorizer'; export { TestService } from './test-resolver.service'; export { TestRelationDTO } from './test-relation.dto'; -const getOrCreateSchemaFactory = async (): Promise => { +// eslint-disable-next-line @typescript-eslint/ban-types +export const generateSchema = async (resolvers: Function[]): Promise => { const moduleRef = await Test.createTestingModule({ imports: [GraphQLSchemaBuilderModule], }).compile(); - return moduleRef.get(GraphQLSchemaFactory); -}; + const sf = moduleRef.get(GraphQLSchemaFactory); -// eslint-disable-next-line @typescript-eslint/ban-types -export const generateSchema = async (resolvers: Function[]): Promise => { - const sf = await getOrCreateSchemaFactory(); - const schema = await sf.create(resolvers); + const schema = await sf.create(resolvers, [PointScalar]); return printSchema(schema); }; diff --git a/packages/query-graphql/__tests__/__fixtures__/scalars.ts b/packages/query-graphql/__tests__/__fixtures__/scalars.ts new file mode 100644 index 000000000..9917ba3d4 --- /dev/null +++ b/packages/query-graphql/__tests__/__fixtures__/scalars.ts @@ -0,0 +1,34 @@ +import { CustomScalar, Field, InputType, Scalar } from '@nestjs/graphql'; +import { Kind, ValueNode } from 'graphql'; + +@Scalar('Point', () => Object) +export class PointScalar implements CustomScalar { + description = 'Date custom scalar type'; + + parseValue(value: unknown): unknown { + return value; + } + + serialize(value: unknown): unknown { + return value; + } + + parseLiteral(ast: ValueNode): unknown { + if (ast.kind === Kind.OBJECT) { + return ast.fields; + } + return null; + } +} + +@InputType('DistanceFilter') +export class DistanceFilter { + @Field(() => Number) + lat!: number; + + @Field(() => Number) + lng!: number; + + @Field(() => Number) + radius!: number; +} diff --git a/packages/query-graphql/__tests__/types/query/__snapshots__/filter.type.spec.ts.snap b/packages/query-graphql/__tests__/types/query/__snapshots__/filter.type.spec.ts.snap index 1236a709d..0c34c199a 100644 --- a/packages/query-graphql/__tests__/types/query/__snapshots__/filter.type.spec.ts.snap +++ b/packages/query-graphql/__tests__/types/query/__snapshots__/filter.type.spec.ts.snap @@ -18,6 +18,8 @@ input TestDtoFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + pointField: PointScalarFilterComparison + pendingTaskCount: TestFilterDtoPendingTaskCountFilterComparison } input TestFilterDtoDeleteFilter { @@ -33,6 +35,8 @@ input TestFilterDtoDeleteFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + pointField: PointScalarFilterComparison + pendingTaskCount: TestFilterDtoPendingTaskCountFilterComparison } input NumberFieldComparison { @@ -48,6 +52,7 @@ input NumberFieldComparison { notIn: [Float!] between: NumberFieldComparisonBetween notBetween: NumberFieldComparisonBetween + isMultipleOf: Int } input NumberFieldComparisonBetween { @@ -98,6 +103,7 @@ input FloatFieldComparison { notIn: [Float!] between: FloatFieldComparisonBetween notBetween: FloatFieldComparisonBetween + isMultipleOf: Int } input FloatFieldComparisonBetween { @@ -118,6 +124,7 @@ input IntFieldComparison { notIn: [Int!] between: IntFieldComparisonBetween notBetween: IntFieldComparisonBetween + isMultipleOf: Int } input IntFieldComparisonBetween { @@ -215,6 +222,20 @@ input TimestampFieldComparisonBetween { upper: Timestamp! } +input PointScalarFilterComparison { + distanceFrom: DistanceFilter +} + +input DistanceFilter { + lat: Float! + lng: Float! + radius: Float! +} + +input TestFilterDtoPendingTaskCountFilterComparison { + gt: Int +} + `; exports[`filter types FilterType allowedBooleanExpressions option no boolean expressions should only expose allowed comparisons 1`] = ` @@ -240,6 +261,7 @@ input NumberFieldComparison { notIn: [Float!] between: NumberFieldComparisonBetween notBetween: NumberFieldComparisonBetween + isMultipleOf: Int } input NumberFieldComparisonBetween { @@ -279,6 +301,7 @@ input NumberFieldComparison { notIn: [Float!] between: NumberFieldComparisonBetween notBetween: NumberFieldComparisonBetween + isMultipleOf: Int } input NumberFieldComparisonBetween { @@ -318,6 +341,7 @@ input NumberFieldComparison { notIn: [Float!] between: NumberFieldComparisonBetween notBetween: NumberFieldComparisonBetween + isMultipleOf: Int } input NumberFieldComparisonBetween { @@ -342,6 +366,8 @@ input TestComparisonDtoFilter { intField: TestAllowedComparisonIntFieldFilterComparison numberField: TestAllowedComparisonNumberFieldFilterComparison stringField: TestAllowedComparisonStringFieldFilterComparison + pointField: TestAllowedComparisonPointFieldFilterComparison + testVirtualProperty: TestAllowedComparisonTestVirtualPropertyFilterComparison } input TestAllowedComparisonFilter { @@ -354,6 +380,8 @@ input TestAllowedComparisonFilter { intField: TestAllowedComparisonIntFieldFilterComparison numberField: TestAllowedComparisonNumberFieldFilterComparison stringField: TestAllowedComparisonStringFieldFilterComparison + pointField: TestAllowedComparisonPointFieldFilterComparison + testVirtualProperty: TestAllowedComparisonTestVirtualPropertyFilterComparison } input NumberFieldComparison { @@ -369,6 +397,7 @@ input NumberFieldComparison { notIn: [Float!] between: NumberFieldComparisonBetween notBetween: NumberFieldComparisonBetween + isMultipleOf: Int } input NumberFieldComparisonBetween { @@ -398,6 +427,7 @@ input TestAllowedComparisonFloatFieldFilterComparison { input TestAllowedComparisonIntFieldFilterComparison { lt: Int lte: Int + isMultipleOf: Int } input TestAllowedComparisonNumberFieldFilterComparison { @@ -407,6 +437,7 @@ input TestAllowedComparisonNumberFieldFilterComparison { gte: Float lt: Float lte: Float + isMultipleOf: Int } input TestAllowedComparisonStringFieldFilterComparison { @@ -414,6 +445,21 @@ input TestAllowedComparisonStringFieldFilterComparison { notLike: String } +input TestAllowedComparisonPointFieldFilterComparison { + distanceFrom: DistanceFilter +} + +input DistanceFilter { + lat: Float! + lng: Float! + radius: Float! +} + +input TestAllowedComparisonTestVirtualPropertyFilterComparison { + is: Boolean + isNot: Boolean +} + `; exports[`filter types FilterType filterRequired option should only expose allowed comparisons 1`] = ` @@ -452,6 +498,7 @@ input NumberFieldComparison { notIn: [Float!] between: NumberFieldComparisonBetween notBetween: NumberFieldComparisonBetween + isMultipleOf: Int } input NumberFieldComparisonBetween { @@ -509,6 +556,8 @@ input TestDtoFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + pointField: PointScalarFilterComparison + pendingTaskCount: TestFilterDtoPendingTaskCountFilterComparison filterableRelation: TestFilterDtoFilterTestRelationDtoFilter filterableCursorConnection: TestFilterDtoFilterTestRelationDtoFilter filterableOffsetConnection: TestFilterDtoFilterTestRelationDtoFilter @@ -528,6 +577,8 @@ input TestFilterDtoFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + pointField: PointScalarFilterComparison + pendingTaskCount: TestFilterDtoPendingTaskCountFilterComparison filterableRelation: TestFilterDtoFilterTestRelationDtoFilter filterableCursorConnection: TestFilterDtoFilterTestRelationDtoFilter filterableOffsetConnection: TestFilterDtoFilterTestRelationDtoFilter @@ -547,6 +598,7 @@ input NumberFieldComparison { notIn: [Float!] between: NumberFieldComparisonBetween notBetween: NumberFieldComparisonBetween + isMultipleOf: Int } input NumberFieldComparisonBetween { @@ -597,6 +649,7 @@ input FloatFieldComparison { notIn: [Float!] between: FloatFieldComparisonBetween notBetween: FloatFieldComparisonBetween + isMultipleOf: Int } input FloatFieldComparisonBetween { @@ -617,6 +670,7 @@ input IntFieldComparison { notIn: [Int!] between: IntFieldComparisonBetween notBetween: IntFieldComparisonBetween + isMultipleOf: Int } input IntFieldComparisonBetween { @@ -714,12 +768,32 @@ input TimestampFieldComparisonBetween { upper: Timestamp! } +input PointScalarFilterComparison { + distanceFrom: DistanceFilter +} + +input DistanceFilter { + lat: Float! + lng: Float! + radius: Float! +} + +input TestFilterDtoPendingTaskCountFilterComparison { + gt: Int +} + input TestFilterDtoFilterTestRelationDtoFilter { and: [TestFilterDtoFilterTestRelationDtoFilter!] or: [TestFilterDtoFilterTestRelationDtoFilter!] id: NumberFieldComparison relationName: StringFieldComparison relationAge: NumberFieldComparison + testVirtualProperty: TestRelationDtoTestVirtualPropertyFilterComparison +} + +input TestRelationDtoTestVirtualPropertyFilterComparison { + is: Boolean + isNot: Boolean } `; @@ -742,6 +816,8 @@ input TestDtoFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + pointField: PointScalarFilterComparison + pendingTaskCount: TestFilterDtoPendingTaskCountFilterComparison } input TestFilterDtoSubscriptionFilter { @@ -757,6 +833,8 @@ input TestFilterDtoSubscriptionFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + pointField: PointScalarFilterComparison + pendingTaskCount: TestFilterDtoPendingTaskCountFilterComparison } input NumberFieldComparison { @@ -772,6 +850,7 @@ input NumberFieldComparison { notIn: [Float!] between: NumberFieldComparisonBetween notBetween: NumberFieldComparisonBetween + isMultipleOf: Int } input NumberFieldComparisonBetween { @@ -822,6 +901,7 @@ input FloatFieldComparison { notIn: [Float!] between: FloatFieldComparisonBetween notBetween: FloatFieldComparisonBetween + isMultipleOf: Int } input FloatFieldComparisonBetween { @@ -842,6 +922,7 @@ input IntFieldComparison { notIn: [Int!] between: IntFieldComparisonBetween notBetween: IntFieldComparisonBetween + isMultipleOf: Int } input IntFieldComparisonBetween { @@ -939,6 +1020,20 @@ input TimestampFieldComparisonBetween { upper: Timestamp! } +input PointScalarFilterComparison { + distanceFrom: DistanceFilter +} + +input DistanceFilter { + lat: Float! + lng: Float! + radius: Float! +} + +input TestFilterDtoPendingTaskCountFilterComparison { + gt: Int +} + `; exports[`filter types UpdateFilterType should create the correct filter graphql schema 1`] = ` @@ -959,6 +1054,8 @@ input TestDtoFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + pointField: PointScalarFilterComparison + pendingTaskCount: TestFilterDtoPendingTaskCountFilterComparison } input TestFilterDtoUpdateFilter { @@ -974,6 +1071,8 @@ input TestFilterDtoUpdateFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + pointField: PointScalarFilterComparison + pendingTaskCount: TestFilterDtoPendingTaskCountFilterComparison } input NumberFieldComparison { @@ -989,6 +1088,7 @@ input NumberFieldComparison { notIn: [Float!] between: NumberFieldComparisonBetween notBetween: NumberFieldComparisonBetween + isMultipleOf: Int } input NumberFieldComparisonBetween { @@ -1039,6 +1139,7 @@ input FloatFieldComparison { notIn: [Float!] between: FloatFieldComparisonBetween notBetween: FloatFieldComparisonBetween + isMultipleOf: Int } input FloatFieldComparisonBetween { @@ -1059,6 +1160,7 @@ input IntFieldComparison { notIn: [Int!] between: IntFieldComparisonBetween notBetween: IntFieldComparisonBetween + isMultipleOf: Int } input IntFieldComparisonBetween { @@ -1156,4 +1258,18 @@ input TimestampFieldComparisonBetween { upper: Timestamp! } +input PointScalarFilterComparison { + distanceFrom: DistanceFilter +} + +input DistanceFilter { + lat: Float! + lng: Float! + radius: Float! +} + +input TestFilterDtoPendingTaskCountFilterComparison { + gt: Int +} + `; diff --git a/packages/query-graphql/__tests__/types/query/filter.type.spec.ts b/packages/query-graphql/__tests__/types/query/filter.type.spec.ts index 21553cfc8..1869daad4 100644 --- a/packages/query-graphql/__tests__/types/query/filter.type.spec.ts +++ b/packages/query-graphql/__tests__/types/query/filter.type.spec.ts @@ -1,35 +1,38 @@ // eslint-disable-next-line max-classes-per-file import { Class, Filter } from '@nestjs-query/core'; -import { plainToClass } from 'class-transformer'; import { - ObjectType, - Int, - Resolver, - Query, Args, + Field, Float, GraphQLTimestamp, - Field, InputType, + Int, + ObjectType, + Query, registerEnumType, + Resolver, } from '@nestjs/graphql'; +import { plainToClass } from 'class-transformer'; +import { IsBoolean, IsInt } from 'class-validator'; import { - FilterableField, - FilterType, - Relation, - UpdateFilterType, - DeleteFilterType, - SubscriptionFilterType, - FilterableRelation, - OffsetConnection, CursorConnection, + DeleteFilterType, FilterableCursorConnection, + FilterableField, FilterableOffsetConnection, - UnPagedRelation, + FilterableRelation, FilterableUnPagedRelation, + FilterType, + OffsetConnection, QueryOptions, + Relation, + SubscriptionFilterType, + UnPagedRelation, + UpdateFilterType, } from '../../../src'; +import { registerDTOFieldComparison, registerTypeComparison } from '../../../src/types/query/field-comparison'; import { generateSchema } from '../../__fixtures__'; +import { DistanceFilter, PointScalar } from '../../__fixtures__/scalars'; describe('filter types', (): void => { enum NumberEnum { @@ -106,12 +109,51 @@ describe('filter types', (): void => { @FilterableField(() => GraphQLTimestamp) timestampField!: Date; + // Custom scalar for testing custom filters + @FilterableField(() => PointScalar) + pointField!: unknown; + @Field() nonFilterField!: number; } + // Field comparisons on built in properties + registerTypeComparison([Number, Int], 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] }); + registerTypeComparison(Float, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] }); + // Field comparison on custom scalar property + registerTypeComparison(PointScalar, 'distanceFrom', { + FilterType: DistanceFilter, + GqlType: DistanceFilter, + }); + // Field comparison on a virtual property + registerDTOFieldComparison(TestRelation, 'testVirtualProperty', 'is', { + FilterType: Boolean, + decorators: [IsBoolean()], + }); + registerDTOFieldComparison(TestRelation, 'testVirtualProperty', 'isNot', { + FilterType: Boolean, + decorators: [IsBoolean()], + }); + registerDTOFieldComparison(TestDto, 'pendingTaskCount', 'gt', { + FilterType: Number, + GqlType: Int, + decorators: [IsInt()], + }); + + describe('DTOField custom filter', () => { + it('should throw an error if trying to apply a custom filter on a concrete property', () => { + expect(() => + registerDTOFieldComparison(TestDto, 'boolField', 'virtualIs', { + FilterType: Boolean, + decorators: [IsBoolean()], + }), + ).toThrow('Cannot define a custom field filter on a non-virtual property'); + }); + }); + describe('FilterType', () => { const TestGraphQLFilter: Class> = FilterType(TestDto); + @InputType() class TestDtoFilter extends TestGraphQLFilter {} @@ -132,6 +174,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -149,6 +192,7 @@ describe('filter types', (): void => { enum EnumField { ONE = 'one', } + @ObjectType('TestBadField') class TestInvalidFilter { @FilterableField(() => EnumField) @@ -186,17 +230,37 @@ describe('filter types', (): void => { @FilterableField(() => Float, { allowedComparisons: ['gt', 'gte'] }) floatField!: number; - @FilterableField(() => Int, { allowedComparisons: ['lt', 'lte'] }) + @FilterableField(() => Int, { allowedComparisons: ['lt', 'lte', 'isMultipleOf'] }) intField!: number; - @FilterableField({ allowedComparisons: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'] }) + @FilterableField({ allowedComparisons: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'isMultipleOf'] }) numberField!: number; @FilterableField({ allowedComparisons: ['like', 'notLike'] }) stringField!: string; + + // Custom scalar for testing custom filters + @FilterableField(() => PointScalar, { allowedComparisons: ['distanceFrom'] }) + pointField!: unknown; } + // Field comparison on a virtual property + registerDTOFieldComparison(TestAllowedComparisonsDto, 'testVirtualProperty', 'is', { + FilterType: Boolean, + decorators: [IsBoolean()], + }); + registerDTOFieldComparison(TestAllowedComparisonsDto, 'testVirtualProperty', 'isNot', { + FilterType: Boolean, + decorators: [IsBoolean()], + }); + registerDTOFieldComparison(TestDto, 'pendingTaskCount', 'gt', { + FilterType: Number, + GqlType: Int, + decorators: [IsInt()], + }); + const TestGraphQLComparisonFilter: Class> = FilterType(TestAllowedComparisonsDto); + @InputType() class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} @@ -209,6 +273,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -224,6 +289,7 @@ describe('filter types', (): void => { } const TestGraphQLComparisonFilter: Class> = FilterType(TestOnlyAndBooleanExpressionsDto); + @InputType() class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} @@ -236,6 +302,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -250,6 +317,7 @@ describe('filter types', (): void => { } const TestGraphQLComparisonFilter: Class> = FilterType(TestOnlyOrBooleanExpressionsDto); + @InputType() class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} @@ -262,6 +330,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -276,6 +345,7 @@ describe('filter types', (): void => { } const TestGraphQLComparisonFilter: Class> = FilterType(TestNoBooleanExpressionsDto); + @InputType() class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} @@ -288,6 +358,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -308,6 +379,7 @@ describe('filter types', (): void => { } const TestGraphQLComparisonFilter: Class> = FilterType(TestFilterRequiredDto); + @InputType() class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} @@ -320,6 +392,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -349,6 +422,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -366,6 +440,7 @@ describe('filter types', (): void => { enum EnumField { ONE = 'one', } + @ObjectType('TestBadField') class TestInvalidFilter { @FilterableField(() => EnumField) @@ -417,6 +492,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -434,6 +510,7 @@ describe('filter types', (): void => { enum EnumField { ONE = 'one', } + @ObjectType('TestBadField') class TestInvalidFilter { @FilterableField(() => EnumField) @@ -485,6 +562,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -502,6 +580,7 @@ describe('filter types', (): void => { enum EnumField { ONE = 'one', } + @ObjectType('TestBadField') class TestInvalidFilter { @FilterableField(() => EnumField) diff --git a/packages/query-graphql/src/index.ts b/packages/query-graphql/src/index.ts index 3d8745b14..a39dece6c 100644 --- a/packages/query-graphql/src/index.ts +++ b/packages/query-graphql/src/index.ts @@ -57,3 +57,4 @@ export { BeforeFindOneHook, } from './hooks'; export { AuthorizerInterceptor, AuthorizerContext, HookInterceptor, HookContext } from './interceptors'; +export { registerTypeComparison, registerDTOFieldComparison } from './types/query/field-comparison'; diff --git a/packages/query-graphql/src/types/query/field-comparison/custom-field-comparison.type.ts b/packages/query-graphql/src/types/query/field-comparison/custom-field-comparison.type.ts new file mode 100644 index 000000000..c11a4d968 --- /dev/null +++ b/packages/query-graphql/src/types/query/field-comparison/custom-field-comparison.type.ts @@ -0,0 +1,20 @@ +import { Class } from '@nestjs-query/core'; +import { InputType } from '@nestjs/graphql'; + +/** @internal */ +const customFieldComparisonMap = new Map>(); + +/** @internal */ +export function getOrCreateCustomFieldComparison(inputName: string): Class { + let existing = customFieldComparisonMap.get(inputName); + if (existing) { + return existing; + } + + @InputType(inputName) + class FieldComparison {} + + existing = FieldComparison; + customFieldComparisonMap.set(inputName, existing); + return existing; +} diff --git a/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts b/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts index ebd0533a3..1548858e3 100644 --- a/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts +++ b/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts @@ -1,16 +1,16 @@ -import { Class, FilterFieldComparison, FilterComparisonOperators, isNamed } from '@nestjs-query/core'; -import { IsBoolean, IsOptional } from 'class-validator'; +import { Class, FilterComparisonOperators, FilterFieldComparison, isNamed } from '@nestjs-query/core'; +import { IsBoolean, IsOptional, ValidateNested } from 'class-validator'; import { upperCaseFirst } from 'upper-case-first'; import { Field, + Float, + GraphQLISODateTime, + GraphQLTimestamp, + ID, InputType, + Int, ReturnTypeFunc, ReturnTypeFuncValue, - Int, - Float, - ID, - GraphQLTimestamp, - GraphQLISODateTime, } from '@nestjs/graphql'; import { Type } from 'class-transformer'; import { IsUndefined } from '../../validators'; @@ -21,12 +21,14 @@ import { getOrCreateBooleanFieldComparison } from './boolean-field-comparison.ty import { getOrCreateNumberFieldComparison } from './number-field-comparison.type'; import { getOrCreateDateFieldComparison } from './date-field-comparison.type'; import { getOrCreateTimestampFieldComparison } from './timestamp-field-comparison.type'; -import { SkipIf } from '../../../decorators'; -import { getGraphqlEnumMetadata } from '../../../common'; +import { getFilterableFields, SkipIf } from '../../../decorators'; +import { getDTONames, getGraphqlEnumMetadata } from '../../../common'; import { isInAllowedList } from '../helpers'; +import { FieldComparisonRegistry, FieldComparisonSpec } from './field-comparison.registry'; +import { getOrCreateCustomFieldComparison } from './custom-field-comparison.type'; /** @internal */ -const filterComparisonMap = new Map Class>>(); +const filterComparisonMap = new Map Class | unknown>>(); filterComparisonMap.set('StringFilterComparison', getOrCreateStringFieldComparison); filterComparisonMap.set('NumberFilterComparison', getOrCreateNumberFieldComparison); filterComparisonMap.set('IntFilterComparison', getOrCreateIntFieldComparison); @@ -48,6 +50,9 @@ const knownTypes: Set = new Set([ GraphQLTimestamp, ]); +/** @internal */ +export const fieldComparisonRegistry = new FieldComparisonRegistry(); + /** @internal */ const getTypeName = (SomeType: ReturnTypeFuncValue): string => { if (knownTypes.has(SomeType) || isNamed(SomeType)) { @@ -72,6 +77,72 @@ const getComparisonTypeName = (fieldType: ReturnTypeFuncValue, options: Filte return `${getTypeName(fieldType)}FilterComparison`; }; +const primitiveTypes = new Set([Number, Date, String, Boolean]); + +/** + * Patches a class prototype to add fields dynamically based on the provided spec + * @internal + * */ +export function patchFieldComparison( + fc: Class, + operation: string | symbol, + spec: FieldComparisonSpec, +): void { + Object.defineProperty(fc, operation, { writable: true, enumerable: true }); + // Only apply validateNested if FilterType is not a primitive type, otherwise class-validator expects an object or array + if (!primitiveTypes.has(spec.FilterType as never)) { + ValidateNested()(fc.prototype, operation); + } + Field(() => spec.GqlType ?? spec.FilterType, { nullable: true })(fc.prototype, operation); + Type(() => spec.FilterType)(fc.prototype, operation); + IsOptional()(fc.prototype, operation); + for (const dec of spec.decorators ?? []) { + dec(fc.prototype, operation); + } +} + +export function registerTypeComparison( + FieldTypeOrTypes: ReturnTypeFuncValue | ReturnTypeFuncValue[], + operation: string, + spec: FieldComparisonSpec, +): void { + const fieldTypes = Array.isArray(FieldTypeOrTypes) ? FieldTypeOrTypes : [FieldTypeOrTypes]; + for (const fieldType of fieldTypes) { + knownTypes.add(fieldType); + const inputName = `${getTypeName(fieldType)}FilterComparison`; + // Register field + fieldComparisonRegistry.registerTypeOperation(fieldType, operation, spec); + // Since we are operating on types, it could be that we need to patch the built in comparison operations + // The following code does exactly that + if (!filterComparisonMap.has(inputName)) { + filterComparisonMap.set(inputName, () => getOrCreateCustomFieldComparison(inputName)); + } + const generator = filterComparisonMap.get(inputName); + if (!generator) { + throw new Error(`Cannot register field comparison for unknown type ${inputName}`); + } + const fc = generator(); + patchFieldComparison(fc, operation, spec); + } +} + +export function registerDTOFieldComparison( + DTOClass: Class, + fieldName: string, + operation: string, + spec: FieldComparisonSpec, +): void { + const filterableFields = getFilterableFields(DTOClass); + const isFieldConcrete = filterableFields.some((v) => v.propertyName === fieldName); + // TODO Maybe we can relax this constraint, TBD + if (isFieldConcrete) { + throw new Error( + `Cannot define a custom field filter on a non-virtual property: DTO: ${DTOClass?.name} Field: ${fieldName}`, + ); + } + fieldComparisonRegistry.registerFieldOperation(DTOClass, fieldName, operation, spec); +} + type FilterComparisonOptions = { FieldType: Class; fieldName: string; @@ -90,6 +161,7 @@ export function createFilterComparisonType(options: FilterComparisonOptions) => () => !isInAllowedList(options.allowedComparisons, val as unknown); + @InputType(inputName) class Fc { @SkipIf(isNotAllowed('is'), Field(() => Boolean, { nullable: true })) @@ -163,6 +235,42 @@ export function createFilterComparisonType(options: FilterComparisonOptions Fc); return Fc as Class>; } + +export function createVirtualFilterComparisonType(options: { + DTOClass: Class; + field: string; +}): Class { + const { baseName } = getDTONames(options.DTOClass); + const field = options.field; + const inputName = `${baseName}${upperCaseFirst(options.field)}FilterComparison`; + + const generator = filterComparisonMap.get(inputName); + if (generator) { + return generator(); + } + + @InputType(inputName) + class Fc {} + + const comparisons = fieldComparisonRegistry.getFieldComparisons(options.DTOClass, field); + + for (const cmp of comparisons) { + patchFieldComparison(Fc, cmp.operation, cmp.spec); + } + + filterComparisonMap.set(inputName, () => Fc); + return Fc; +} diff --git a/packages/query-graphql/src/types/query/field-comparison/field-comparison.registry.ts b/packages/query-graphql/src/types/query/field-comparison/field-comparison.registry.ts new file mode 100644 index 000000000..6ebfe1ff9 --- /dev/null +++ b/packages/query-graphql/src/types/query/field-comparison/field-comparison.registry.ts @@ -0,0 +1,84 @@ +import { Class } from '@nestjs-query/core'; +import { ReturnTypeFuncValue } from '@nestjs/graphql'; + +// TODO Maybe we can properly type these? +function deepMapSet(map: Map, keys: unknown[], val: unknown) { + let m = map; + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]; + if (!m.has(k)) { + m.set(k, new Map()); + } + m = m.get(k) as Map; + } + m.set(keys[keys.length - 1], val); +} + +function deepMapGet(map: Map, keys: unknown[]): unknown | undefined { + let m = map; + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]; + m = map.get(k) as Map; + if (m === undefined) { + return undefined; + } + } + return m.get(keys[keys.length - 1]); +} + +type OperationType = string | symbol; + +/** @internal */ +export interface FieldComparisonSpec { + FilterType: Class; + GqlType?: ReturnTypeFuncValue; + decorators?: PropertyDecorator[]; +} + +/** @internal */ +export class FieldComparisonRegistry { + // (type, operation) registry + private toRegistry: Map>> = new Map(); + + // (class, field, operation) registry + private cfoRegistry: Map, Map>>> = new Map(); + + /** + * Registers a FieldComparison operation on a given FieldType (i.e. GraphQL Scalar type) + */ + registerTypeOperation(FieldType: ReturnTypeFuncValue, operation: string, spec: FieldComparisonSpec): void { + deepMapSet(this.toRegistry, [FieldType, operation], spec); + } + + /** + * Registers a FieldComparison operation on a given field of a given DTO, this is useful for non type based filters + * or virtual properties, since the field does not have to exist on the DTO itself. + */ + registerFieldOperation( + DTOClass: Class, + fieldName: string, + operation: OperationType, + spec: FieldComparisonSpec, + ): void { + deepMapSet(this.cfoRegistry, [DTOClass, fieldName, operation], spec); + } + + getTypeComparison( + FieldType: ReturnTypeFuncValue, + operation?: OperationType, + ): FieldComparisonSpec | undefined { + return deepMapGet(this.toRegistry, [FieldType, operation]) as FieldComparisonSpec; + } + + getDefinedFieldsForDTO(DTOClass: Class): string[] { + return Array.from(this.cfoRegistry.get(DTOClass)?.keys() ?? []); + } + + getFieldComparisons( + DTOClass: Class, + fieldName: string, + ): { operation: string; spec: FieldComparisonSpec }[] { + const opMap = deepMapGet(this.cfoRegistry, [DTOClass, fieldName]) as Map>; + return Array.from(opMap.entries()).map(([operation, spec]) => ({ operation, spec })); + } +} diff --git a/packages/query-graphql/src/types/query/field-comparison/index.ts b/packages/query-graphql/src/types/query/field-comparison/index.ts index e49049fe8..b20de69d2 100644 --- a/packages/query-graphql/src/types/query/field-comparison/index.ts +++ b/packages/query-graphql/src/types/query/field-comparison/index.ts @@ -1,8 +1,9 @@ export * from './boolean-field-comparison.type'; export * from './date-field-comparison.type'; export * from './float-field-comparison.type'; -export * from './field-comparison.factory'; export * from './int-field-comparison.type'; export * from './number-field-comparison.type'; export * from './string-field-comparison.type'; export * from './timestamp-field-comparison.type'; +export * from './field-comparison.factory'; +export * from './field-comparison.registry'; diff --git a/packages/query-graphql/src/types/query/filter.type.ts b/packages/query-graphql/src/types/query/filter.type.ts index 93b99cba0..13a735e52 100644 --- a/packages/query-graphql/src/types/query/filter.type.ts +++ b/packages/query-graphql/src/types/query/filter.type.ts @@ -1,12 +1,16 @@ import { Class, Filter, MapReflector } from '@nestjs-query/core'; -import { InputType, Field } from '@nestjs/graphql'; +import { Field, InputType } from '@nestjs/graphql'; import { Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; import { upperCaseFirst } from 'upper-case-first'; -import { ResolverRelation } from '../../resolvers/relations'; -import { createFilterComparisonType } from './field-comparison'; import { getDTONames, getGraphqlObjectName } from '../../common'; import { getFilterableFields, getQueryOptions, getRelations, SkipIf } from '../../decorators'; +import { ResolverRelation } from '../../resolvers/relations'; +import { + createFilterComparisonType, + createVirtualFilterComparisonType, + fieldComparisonRegistry, +} from './field-comparison'; import { isInAllowedList } from './helpers'; const reflector = new MapReflector('nestjs-query:filter-type'); @@ -15,8 +19,10 @@ export type FilterTypeOptions = { allowedBooleanExpressions?: ('and' | 'or')[]; }; export type FilterableRelations = Record>; + export interface FilterConstructor { hasRequiredFilters: boolean; + new (): Filter; } @@ -54,6 +60,8 @@ function getOrCreateFilterType( } const { baseName } = getDTONames(TClass); + // TODO should we allow combining the built in filterablefields with the (class, field, operation) filters that may be present on the same (field, operation) pair? + // Process FilterableFields fields.forEach(({ propertyName, target, advancedOptions, returnTypeFunc }) => { const FC = createFilterComparisonType({ FieldType: target, @@ -66,6 +74,24 @@ function getOrCreateFilterType( Field(() => FC, { nullable })(GraphQLFilter.prototype, propertyName); Type(() => FC)(GraphQLFilter.prototype, propertyName); }); + + // Process virtual field based filters + const concreteFieldNames = fields.map((f) => f.propertyName); + const virtualFields = fieldComparisonRegistry + .getDefinedFieldsForDTO(TClass) + .filter((f) => !concreteFieldNames.includes(f)); + for (const field of virtualFields) { + const FC = createVirtualFilterComparisonType({ + DTOClass: TClass, + field, + }); + Object.defineProperty(FC, field, { writable: true, enumerable: true }); + ValidateNested()(GraphQLFilter.prototype, field); + Field(() => FC, { nullable: true })(GraphQLFilter.prototype, field); + Type(() => FC)(GraphQLFilter.prototype, field); + } + + // Process relations Object.keys(filterableRelations).forEach((field) => { const FieldType = filterableRelations[field]; if (FieldType) { diff --git a/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts b/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts index e17750ecc..338158070 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts @@ -16,8 +16,13 @@ export const CONNECTION_OPTIONS: ConnectionOptions = { logging: false, }; -export function createTestConnection(): Promise { - return createConnection(CONNECTION_OPTIONS); +// eslint-disable-next-line @typescript-eslint/ban-types +export function createTestConnection(opts?: { extraEntities: (string | Function)[] }): Promise { + const connOpts: ConnectionOptions = { + ...CONNECTION_OPTIONS, + entities: [...(CONNECTION_OPTIONS.entities || []), ...(opts?.extraEntities ?? [])], + }; + return createConnection(connOpts); } export function closeTestConnection(): Promise { diff --git a/packages/query-typeorm/__tests__/__fixtures__/custom-filters.services.ts b/packages/query-typeorm/__tests__/__fixtures__/custom-filters.services.ts new file mode 100644 index 000000000..22e247c1b --- /dev/null +++ b/packages/query-typeorm/__tests__/__fixtures__/custom-filters.services.ts @@ -0,0 +1,84 @@ +import { ColumnType, EntityManager } from 'typeorm'; +import { randomString } from '../../src/common'; +import { TypeOrmQueryFilter } from '../../src/decorators/typeorm-query-filter.decorator'; +import { CustomFilter, CustomFilterResult } from '../../src/query'; +import { TestRelation } from './test-relation.entity'; + +@TypeOrmQueryFilter({ + types: IsMultipleOfCustomFilter.types, + operations: IsMultipleOfCustomFilter.operations, +}) +export class IsMultipleOfCustomFilter implements CustomFilter { + static readonly types: ColumnType[] = [Number, 'integer']; + + static readonly operations = ['isMultipleOf']; + + apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult { + alias = alias ? alias : ''; + const pname = `param${randomString()}`; + return { + sql: `(${alias}.${field} % :${pname}) == 0`, + params: { [pname]: val }, + }; + } +} + +@TypeOrmQueryFilter({ + types: IsMultipleOfDateCustomFilter.types, + operations: IsMultipleOfDateCustomFilter.operations, +}) +export class IsMultipleOfDateCustomFilter implements CustomFilter { + static readonly types: ColumnType[] = [Date, 'date', 'datetime']; + + static readonly operations = ['isMultipleOf']; + + apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult { + alias = alias ? alias : ''; + const pname = `param${randomString()}`; + return { + sql: `(EXTRACT(EPOCH FROM ${alias}.${field}) / 3600 / 24) % :${pname}) == 0`, + params: { [pname]: val }, + }; + } +} + +@TypeOrmQueryFilter() +export class RadiusCustomFilter implements CustomFilter { + static readonly operations = ['distanceFrom']; + + apply( + field: string, + cmp: string, + val: { point: { lat: number; lng: number }; radius: number }, + alias?: string, + ): CustomFilterResult { + alias = alias ? alias : ''; + const plat = `param${randomString()}`; + const plng = `param${randomString()}`; + const prad = `param${randomString()}`; + return { + sql: `ST_Distance(${alias}.${field}, ST_MakePoint(:${plat},:${plng})) <= :${prad}`, + params: { [plat]: val.point.lat, [plng]: val.point.lng, [prad]: val.radius }, + }; + } +} + +@TypeOrmQueryFilter() +export class TestEntityTestRelationCountFilter implements CustomFilter { + constructor(private em: EntityManager) {} + + apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult { + alias = alias ? alias : ''; + const pname = `param${randomString()}`; + + const subQb = this.em + .createQueryBuilder(TestRelation, 'tr') + .select('COUNT(*)') + .where(`tr.numberType > 82 AND tr.test_entity_id = ${alias}.testEntityPk`); + + return { + sql: `(${subQb.getSql()}) > :${pname}`, + params: { [pname]: val }, + }; + } +} diff --git a/packages/query-typeorm/__tests__/__fixtures__/seeds.ts b/packages/query-typeorm/__tests__/__fixtures__/seeds.ts index 1876c6fa7..17ffb60e1 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/seeds.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/seeds.ts @@ -23,30 +23,33 @@ export const TEST_SOFT_DELETE_ENTITIES: TestSoftDeleteEntity[] = [1, 2, 3, 4, 5, }; }); -export const TEST_RELATIONS: TestRelation[] = TEST_ENTITIES.reduce( - (relations, te) => [ +// Generate different numberTypes so we can use them for filters later on +export const TEST_RELATIONS: TestRelation[] = TEST_ENTITIES.reduce((relations, te) => { + return [ ...relations, { testRelationPk: `test-relations-${te.testEntityPk}-1`, relationName: `${te.stringType}-test-relation-one`, testEntityId: te.testEntityPk, uniDirectionalTestEntityId: te.testEntityPk, + numberType: te.numberType * 10 + 1, }, { testRelationPk: `test-relations-${te.testEntityPk}-2`, relationName: `${te.stringType}-test-relation-two`, testEntityId: te.testEntityPk, uniDirectionalTestEntityId: te.testEntityPk, + numberType: te.numberType * 10 + 2, }, { testRelationPk: `test-relations-${te.testEntityPk}-3`, relationName: `${te.stringType}-test-relation-three`, testEntityId: te.testEntityPk, uniDirectionalTestEntityId: te.testEntityPk, + numberType: te.numberType * 10 + 3, }, - ], - [] as TestRelation[], -); + ]; +}, [] as TestRelation[]); export const TEST_RELATIONS_OF_RELATION = TEST_RELATIONS.map>((testRelation) => ({ relationName: `test-relation-of-${testRelation.relationName}`, diff --git a/packages/query-typeorm/__tests__/__fixtures__/test-relation.entity.ts b/packages/query-typeorm/__tests__/__fixtures__/test-relation.entity.ts index 0e40cf7d4..8009feea3 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/test-relation.entity.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/test-relation.entity.ts @@ -1,4 +1,4 @@ -import { ManyToOne, Column, Entity, JoinColumn, ManyToMany, OneToOne, OneToMany, PrimaryColumn } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToMany, ManyToOne, OneToMany, OneToOne, PrimaryColumn } from 'typeorm'; import { TestEntityRelationEntity } from './test-entity-relation.entity'; import { TestEntity } from './test.entity'; import { RelationOfTestRelationEntity } from './relation-of-test-relation.entity'; @@ -17,6 +17,9 @@ export class TestRelation { @Column({ name: 'uni_directional_test_entity_id', nullable: true }) uniDirectionalTestEntityId?: string; + @Column({ name: 'number_type' }) + numberType?: number; + @ManyToOne(() => TestEntity, (te) => te.testRelations, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'test_entity_id' }) testEntity?: TestEntity; diff --git a/packages/query-typeorm/__tests__/__fixtures__/test.entity.ts b/packages/query-typeorm/__tests__/__fixtures__/test.entity.ts index 632d46693..0bd72f6bc 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/test.entity.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/test.entity.ts @@ -1,8 +1,21 @@ -import { Column, Entity, OneToMany, ManyToMany, JoinTable, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { Column, Entity, JoinColumn, JoinTable, ManyToMany, OneToMany, OneToOne, PrimaryColumn } from 'typeorm'; +import { WithTypeormQueryFilter } from '../../src'; +import { RadiusCustomFilter, TestEntityTestRelationCountFilter } from './custom-filters.services'; import { TestEntityRelationEntity } from './test-entity-relation.entity'; import { TestRelation } from './test-relation.entity'; @Entity() +// Field level registration +@WithTypeormQueryFilter({ + filter: RadiusCustomFilter, + fields: ['fakePointType'], + operations: ['distanceFrom'], +}) +@WithTypeormQueryFilter({ + filter: TestEntityTestRelationCountFilter, + fields: ['pendingTestRelations'], + operations: ['gt'], +}) export class TestEntity { @PrimaryColumn({ name: 'test_entity_pk' }) testEntityPk!: string; diff --git a/packages/query-typeorm/__tests__/__fixtures__/types.ts b/packages/query-typeorm/__tests__/__fixtures__/types.ts new file mode 100644 index 000000000..f3498da2d --- /dev/null +++ b/packages/query-typeorm/__tests__/__fixtures__/types.ts @@ -0,0 +1,19 @@ +// Declaration merging to extend the built in comparison operations +import { TestEntity } from './test.entity'; +import { Filter } from '@nestjs-query/core'; + +// Declaration merging to enhance global type-based filters +declare module '@nestjs-query/core' { + interface CommonFieldComparisonType { + isMultipleOf?: FieldType extends Date ? number : FieldType; + } +} + +// Concrete Entity filter types, extended with virtual property filters +export type TestEntityFilter = Filter< + TestEntity, + { + fakePointType?: { distanceFrom?: { point: { lat: number; lng: number }; radius: number } }; + pendingTestRelations?: { gt?: number }; + } +>; diff --git a/packages/query-typeorm/__tests__/metadata/typeorm-metadata.spec.ts b/packages/query-typeorm/__tests__/metadata/typeorm-metadata.spec.ts new file mode 100644 index 000000000..7d2d6ef93 --- /dev/null +++ b/packages/query-typeorm/__tests__/metadata/typeorm-metadata.spec.ts @@ -0,0 +1,72 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { getQueryTypeormMetadata, QueryTypeormEntityMetadata } from '../../src/common'; +import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; +import { TestEntityRelationEntity } from '../__fixtures__/test-entity-relation.entity'; +import { TestRelation } from '../__fixtures__/test-relation.entity'; +import { TestEntity } from '../__fixtures__/test.entity'; + +describe('TypeormMetadata', (): void => { + class TestEmbedded { + @Column({ type: 'text' }) + stringType!: string; + + @Column({ type: 'boolean' }) + boolType!: boolean; + } + + @Entity() + class TestMetadataEntity { + @PrimaryColumn({ type: 'text' }) + pk!: string; + + @Column({ type: 'text' }) + stringType!: string; + + @Column({ type: 'boolean' }) + boolType!: boolean; + + @Column({ type: 'integer' }) + numberType!: number; + + @Column({ type: 'date' }) + dateType!: Date; + + @Column({ type: 'datetime' }) + datetimeType!: Date; + + @Column({ type: 'simple-json' }) + jsonType!: any; + + @Column(() => TestEmbedded) + embeddedType!: TestEmbedded; + } + + beforeEach(() => createTestConnection({ extraEntities: [TestMetadataEntity] })); + afterEach(() => closeTestConnection()); + + it('Test metadata', (): void => { + const meta = getQueryTypeormMetadata(getTestConnection()); + // Implicit column types + expect(meta.get(TestEntity)).toMatchObject({ + testEntityPk: { metaType: 'property', type: String }, + stringType: { metaType: 'property', type: String }, + dateType: { metaType: 'property', type: Date }, + boolType: { metaType: 'property', type: Boolean }, + oneTestRelation: { metaType: 'relation', type: TestRelation }, + testRelations: { metaType: 'relation', type: TestRelation }, + manyTestRelations: { metaType: 'relation', type: TestRelation }, + manyToManyUniDirectional: { metaType: 'relation', type: TestRelation }, + testEntityRelation: { metaType: 'relation', type: TestEntityRelationEntity }, + } as QueryTypeormEntityMetadata); + // Explicit column types + expect(meta.get(TestMetadataEntity)).toMatchObject({ + pk: { metaType: 'property', type: 'text' }, + stringType: { metaType: 'property', type: 'text' }, + boolType: { metaType: 'property', type: 'boolean' }, + numberType: { metaType: 'property', type: 'integer' }, + dateType: { metaType: 'property', type: 'date' }, + datetimeType: { metaType: 'property', type: 'datetime' }, + jsonType: { metaType: 'property', type: 'simple-json' }, + } as QueryTypeormEntityMetadata); + }); +}); diff --git a/packages/query-typeorm/__tests__/module.spec.ts b/packages/query-typeorm/__tests__/module.spec.ts index 5575ce413..59888e9bf 100644 --- a/packages/query-typeorm/__tests__/module.spec.ts +++ b/packages/query-typeorm/__tests__/module.spec.ts @@ -3,10 +3,11 @@ import { NestjsQueryTypeOrmModule } from '../src'; describe('NestjsQueryTypeOrmModule', () => { it('should create a module', () => { class TestEntity {} + const typeOrmModule = NestjsQueryTypeOrmModule.forFeature([TestEntity]); expect(typeOrmModule.imports).toHaveLength(1); expect(typeOrmModule.module).toBe(NestjsQueryTypeOrmModule); - expect(typeOrmModule.providers).toHaveLength(1); + expect(typeOrmModule.providers).toHaveLength(3); expect(typeOrmModule.exports).toHaveLength(2); }); }); diff --git a/packages/query-typeorm/__tests__/providers.spec.ts b/packages/query-typeorm/__tests__/providers.spec.ts index a82289a5c..565071656 100644 --- a/packages/query-typeorm/__tests__/providers.spec.ts +++ b/packages/query-typeorm/__tests__/providers.spec.ts @@ -1,18 +1,31 @@ import { getQueryServiceToken } from '@nestjs-query/core'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { mock, instance } from 'ts-mockito'; +import { createConnection, Repository } from 'typeorm'; +import { instance, mock } from 'ts-mockito'; import { createTypeOrmQueryServiceProviders } from '../src/providers'; import { TypeOrmQueryService } from '../src/services'; +import { CustomFilterRegistry } from '../src/query'; describe('createTypeOrmQueryServiceProviders', () => { - it('should create a provider for the entity', () => { + it('should create a provider for the entity', async () => { class TestEntity {} + + // We need a connection in order to extract entity metadata + const conn = await createConnection({ + type: 'sqlite', + database: ':memory:', + dropSchema: true, + entities: [TestEntity], + synchronize: true, + logging: false, + }); const mockRepo = mock>(Repository); - const providers = createTypeOrmQueryServiceProviders([TestEntity]); + const providers = createTypeOrmQueryServiceProviders([TestEntity], conn); expect(providers).toHaveLength(1); expect(providers[0].provide).toBe(getQueryServiceToken(TestEntity)); - expect(providers[0].inject).toEqual([getRepositoryToken(TestEntity)]); + expect(providers[0].inject).toEqual([getRepositoryToken(TestEntity), CustomFilterRegistry]); expect(providers[0].useFactory(instance(mockRepo))).toBeInstanceOf(TypeOrmQueryService); + + await conn.close(); }); }); diff --git a/packages/query-typeorm/__tests__/query/__snapshots__/filter-query.builder.spec.ts.snap b/packages/query-typeorm/__tests__/query/__snapshots__/filter-query.builder.spec.ts.snap index e3e27b0f5..298eb88d4 100644 --- a/packages/query-typeorm/__tests__/query/__snapshots__/filter-query.builder.spec.ts.snap +++ b/packages/query-typeorm/__tests__/query/__snapshots__/filter-query.builder.spec.ts.snap @@ -12,6 +12,34 @@ exports[`FilterQueryBuilder #delete with sorting should ignore sorting 1`] = `DE exports[`FilterQueryBuilder #delete with sorting should ignore sorting 2`] = `Array []`; +exports[`FilterQueryBuilder #select with custom filter should add custom filters 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ("TestEntity"."number_type" >= ? OR "TestEntity"."number_type" <= ? OR ("TestEntity"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0) AND (ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)`; + +exports[`FilterQueryBuilder #select with custom filter should add custom filters 2`] = ` +Array [ + 1, + 10, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + +exports[`FilterQueryBuilder #select with custom filter should add custom filters with aggregate 1`] = `SELECT MAX("TestEntity"."number_type") AS "MAX_numberType" FROM "test_entity" "TestEntity" WHERE ("TestEntity"."number_type" >= ? OR "TestEntity"."number_type" <= ? OR ("TestEntity"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0) AND (ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)`; + +exports[`FilterQueryBuilder #select with custom filter should add custom filters with aggregate 2`] = ` +Array [ + 1, + 10, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + exports[`FilterQueryBuilder #select with filter should call whereBuilder#build if there is a filter 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE "TestEntity"."string_type" = 'foo'`; exports[`FilterQueryBuilder #select with filter should call whereBuilder#build if there is a filter 2`] = `Array []`; diff --git a/packages/query-typeorm/__tests__/query/__snapshots__/relation-query.builder.spec.ts.snap b/packages/query-typeorm/__tests__/query/__snapshots__/relation-query.builder.spec.ts.snap index 85b0f109a..b5b9fbaeb 100644 --- a/packages/query-typeorm/__tests__/query/__snapshots__/relation-query.builder.spec.ts.snap +++ b/packages/query-typeorm/__tests__/query/__snapshots__/relation-query.builder.spec.ts.snap @@ -16,7 +16,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select many to many on owning side should work with one entity 1`] = `SELECT "manyTestRelations"."test_relation_pk" AS "manyTestRelations_test_relation_pk", "manyTestRelations"."relation_name" AS "manyTestRelations_relation_name", "manyTestRelations"."test_entity_id" AS "manyTestRelations_test_entity_id", "manyTestRelations"."uni_directional_test_entity_id" AS "manyTestRelations_uni_directional_test_entity_id", "manyTestRelations"."uni_directional_relation_test_entity_id" AS "manyTestRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "manyTestRelations" INNER JOIN "test_entity_many_test_relations_test_relation" "test_entity_many_test_relations_test_relation" ON "test_entity_many_test_relations_test_relation"."testRelationTestRelationPk" = "manyTestRelations"."test_relation_pk" WHERE ("test_entity_many_test_relations_test_relation"."testEntityTestEntityPk" = ?)`; +exports[`RelationQueryBuilder #select many to many on owning side should work with one entity 1`] = `SELECT "manyTestRelations"."test_relation_pk" AS "manyTestRelations_test_relation_pk", "manyTestRelations"."relation_name" AS "manyTestRelations_relation_name", "manyTestRelations"."test_entity_id" AS "manyTestRelations_test_entity_id", "manyTestRelations"."uni_directional_test_entity_id" AS "manyTestRelations_uni_directional_test_entity_id", "manyTestRelations"."number_type" AS "manyTestRelations_number_type", "manyTestRelations"."uni_directional_relation_test_entity_id" AS "manyTestRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "manyTestRelations" INNER JOIN "test_entity_many_test_relations_test_relation" "test_entity_many_test_relations_test_relation" ON "test_entity_many_test_relations_test_relation"."testRelationTestRelationPk" = "manyTestRelations"."test_relation_pk" WHERE ("test_entity_many_test_relations_test_relation"."testEntityTestEntityPk" = ?)`; exports[`RelationQueryBuilder #select many to many on owning side should work with one entity 2`] = ` Array [ @@ -24,7 +24,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select many to many uni-directional many to many should create the correct sql 1`] = `SELECT "manyToManyUniDirectional"."test_relation_pk" AS "manyToManyUniDirectional_test_relation_pk", "manyToManyUniDirectional"."relation_name" AS "manyToManyUniDirectional_relation_name", "manyToManyUniDirectional"."test_entity_id" AS "manyToManyUniDirectional_test_entity_id", "manyToManyUniDirectional"."uni_directional_test_entity_id" AS "manyToManyUniDirectional_uni_directional_test_entity_id", "manyToManyUniDirectional"."uni_directional_relation_test_entity_id" AS "manyToManyUniDirectional_uni_directional_relation_test_entity_id" FROM "test_relation" "manyToManyUniDirectional" INNER JOIN "test_entity_many_to_many_uni_directional_test_relation" "test_entity_many_to_many_uni_directional_test_relation" ON "test_entity_many_to_many_uni_directional_test_relation"."testRelationTestRelationPk" = "manyToManyUniDirectional"."test_relation_pk" WHERE ("test_entity_many_to_many_uni_directional_test_relation"."testEntityTestEntityPk" = ?)`; +exports[`RelationQueryBuilder #select many to many uni-directional many to many should create the correct sql 1`] = `SELECT "manyToManyUniDirectional"."test_relation_pk" AS "manyToManyUniDirectional_test_relation_pk", "manyToManyUniDirectional"."relation_name" AS "manyToManyUniDirectional_relation_name", "manyToManyUniDirectional"."test_entity_id" AS "manyToManyUniDirectional_test_entity_id", "manyToManyUniDirectional"."uni_directional_test_entity_id" AS "manyToManyUniDirectional_uni_directional_test_entity_id", "manyToManyUniDirectional"."number_type" AS "manyToManyUniDirectional_number_type", "manyToManyUniDirectional"."uni_directional_relation_test_entity_id" AS "manyToManyUniDirectional_uni_directional_relation_test_entity_id" FROM "test_relation" "manyToManyUniDirectional" INNER JOIN "test_entity_many_to_many_uni_directional_test_relation" "test_entity_many_to_many_uni_directional_test_relation" ON "test_entity_many_to_many_uni_directional_test_relation"."testRelationTestRelationPk" = "manyToManyUniDirectional"."test_relation_pk" WHERE ("test_entity_many_to_many_uni_directional_test_relation"."testEntityTestEntityPk" = ?)`; exports[`RelationQueryBuilder #select many to many uni-directional many to many should create the correct sql 2`] = ` Array [ @@ -48,7 +48,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select one to many should query with a single entity 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?)`; +exports[`RelationQueryBuilder #select one to many should query with a single entity 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?)`; exports[`RelationQueryBuilder #select one to many should query with a single entity 2`] = ` Array [ @@ -64,7 +64,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select one to one on owning side 1`] = `SELECT "oneTestRelation"."test_relation_pk" AS "oneTestRelation_test_relation_pk", "oneTestRelation"."relation_name" AS "oneTestRelation_relation_name", "oneTestRelation"."test_entity_id" AS "oneTestRelation_test_entity_id", "oneTestRelation"."uni_directional_test_entity_id" AS "oneTestRelation_uni_directional_test_entity_id", "oneTestRelation"."uni_directional_relation_test_entity_id" AS "oneTestRelation_uni_directional_relation_test_entity_id" FROM "test_relation" "oneTestRelation" INNER JOIN "test_entity" "TestEntity" ON "TestEntity"."oneTestRelationTestRelationPk" = "oneTestRelation"."test_relation_pk" WHERE ("TestEntity"."test_entity_pk" = ?)`; +exports[`RelationQueryBuilder #select one to one on owning side 1`] = `SELECT "oneTestRelation"."test_relation_pk" AS "oneTestRelation_test_relation_pk", "oneTestRelation"."relation_name" AS "oneTestRelation_relation_name", "oneTestRelation"."test_entity_id" AS "oneTestRelation_test_entity_id", "oneTestRelation"."uni_directional_test_entity_id" AS "oneTestRelation_uni_directional_test_entity_id", "oneTestRelation"."number_type" AS "oneTestRelation_number_type", "oneTestRelation"."uni_directional_relation_test_entity_id" AS "oneTestRelation_uni_directional_relation_test_entity_id" FROM "test_relation" "oneTestRelation" INNER JOIN "test_entity" "TestEntity" ON "TestEntity"."oneTestRelationTestRelationPk" = "oneTestRelation"."test_relation_pk" WHERE ("TestEntity"."test_entity_pk" = ?)`; exports[`RelationQueryBuilder #select one to one on owning side 2`] = ` Array [ @@ -72,7 +72,22 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with filter should call whereBuilder#build if there is a filter 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) AND ("testRelations"."relation_name" = ?)`; +exports[`RelationQueryBuilder #select with custom filters should accept custom filters 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) AND ("testRelations"."number_type" >= ? OR "testRelations"."number_type" <= ? OR ("testRelations"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM testRelations.dateType) / 3600 / 24) % ?) == 0) AND (ST_Distance(testRelations.fakePointType, ST_MakePoint(?,?)) <= ?)`; + +exports[`RelationQueryBuilder #select with custom filters should accept custom filters 2`] = ` +Array [ + test-entity-id-1, + 1, + 10, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + +exports[`RelationQueryBuilder #select with filter should call whereBuilder#build if there is a filter 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) AND ("testRelations"."relation_name" = ?)`; exports[`RelationQueryBuilder #select with filter should call whereBuilder#build if there is a filter 2`] = ` Array [ @@ -81,7 +96,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with paging should apply paging args going backward 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) LIMIT 10 OFFSET 10`; +exports[`RelationQueryBuilder #select with paging should apply paging args going backward 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) LIMIT 10 OFFSET 10`; exports[`RelationQueryBuilder #select with paging should apply paging args going backward 2`] = ` Array [ @@ -89,7 +104,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with paging should apply paging args going forward 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) LIMIT 10 OFFSET 11`; +exports[`RelationQueryBuilder #select with paging should apply paging args going forward 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) LIMIT 10 OFFSET 11`; exports[`RelationQueryBuilder #select with paging should apply paging args going forward 2`] = ` Array [ @@ -97,7 +112,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_FIRST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC NULLS FIRST`; +exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_FIRST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC NULLS FIRST`; exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_FIRST sorting 2`] = ` Array [ @@ -105,7 +120,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_LAST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC NULLS LAST`; +exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_LAST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC NULLS LAST`; exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_LAST sorting 2`] = ` Array [ @@ -113,7 +128,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply ASC sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC`; +exports[`RelationQueryBuilder #select with sorting should apply ASC sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC`; exports[`RelationQueryBuilder #select with sorting should apply ASC sorting 2`] = ` Array [ @@ -121,7 +136,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_FIRST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC NULLS FIRST`; +exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_FIRST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC NULLS FIRST`; exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_FIRST sorting 2`] = ` Array [ @@ -129,7 +144,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_LAST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC NULLS LAST`; +exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_LAST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC NULLS LAST`; exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_LAST sorting 2`] = ` Array [ @@ -137,7 +152,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply DESC sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC`; +exports[`RelationQueryBuilder #select with sorting should apply DESC sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC`; exports[`RelationQueryBuilder #select with sorting should apply DESC sorting 2`] = ` Array [ @@ -145,7 +160,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply multiple sorts 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC, "testRelations"."test_relation_pk" DESC`; +exports[`RelationQueryBuilder #select with sorting should apply multiple sorts 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC, "testRelations"."test_relation_pk" DESC`; exports[`RelationQueryBuilder #select with sorting should apply multiple sorts 2`] = ` Array [ diff --git a/packages/query-typeorm/__tests__/query/__snapshots__/where.builder.spec.ts.snap b/packages/query-typeorm/__tests__/query/__snapshots__/where.builder.spec.ts.snap index a0618ae70..ff887db9f 100644 --- a/packages/query-typeorm/__tests__/query/__snapshots__/where.builder.spec.ts.snap +++ b/packages/query-typeorm/__tests__/query/__snapshots__/where.builder.spec.ts.snap @@ -11,6 +11,22 @@ Array [ ] `; +exports[`WhereBuilder and and multiple expressions together with custom filters 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ((("TestEntity"."number_type" > ?)) AND (("TestEntity"."number_type" < ?)) AND (("TestEntity"."number_type" >= ?)) AND (("TestEntity"."number_type" <= ?)) AND ((("TestEntity"."number_type" % ?) == 0)) AND (((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0)) AND ((ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)))`; + +exports[`WhereBuilder and and multiple expressions together with custom filters 2`] = ` +Array [ + 10, + 20, + 30, + 40, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + exports[`WhereBuilder and and multiple filters together with multiple fields 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ((("TestEntity"."number_type" > ?) AND ("TestEntity"."string_type" LIKE ?)) AND (("TestEntity"."number_type" < ?) AND ("TestEntity"."string_type" LIKE ?)))`; exports[`WhereBuilder and and multiple filters together with multiple fields 2`] = ` @@ -85,6 +101,22 @@ Array [ ] `; +exports[`WhereBuilder or or multiple expressions together with custom filter 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ((("TestEntity"."number_type" > ?)) OR (("TestEntity"."number_type" < ?)) OR (("TestEntity"."number_type" >= ?)) OR (("TestEntity"."number_type" <= ?)) OR ((("TestEntity"."number_type" % ?) == 0)) OR (((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0)) OR ((ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)))`; + +exports[`WhereBuilder or or multiple expressions together with custom filter 2`] = ` +Array [ + 10, + 20, + 30, + 40, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + exports[`WhereBuilder or should properly group OR with a sibling field comparison 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ((("TestEntity"."number_type" = ?)) OR (("TestEntity"."number_type" > ?))) AND ("TestEntity"."string_type" = ?)`; exports[`WhereBuilder or should properly group OR with a sibling field comparison 2`] = ` @@ -109,3 +141,18 @@ Array [ exports[`WhereBuilder should accept a empty filter 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity"`; exports[`WhereBuilder should accept a empty filter 2`] = `Array []`; + +exports[`WhereBuilder should accept custom filters alongside regular filters 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ("TestEntity"."number_type" >= ? OR "TestEntity"."number_type" <= ? OR ("TestEntity"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0) AND (ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?) AND ((SELECT COUNT(*) FROM "test_relation" "tr" WHERE "tr"."number_type" > 82 AND "tr"."test_entity_id" = "TestEntity"."test_entity_pk") > ?)`; + +exports[`WhereBuilder should accept custom filters alongside regular filters 2`] = ` +Array [ + 1, + 10, + 5, + 3, + 45.3, + 9.5, + 50000, + 5, +] +`; diff --git a/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts b/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts index b4d335764..955d58fb3 100644 --- a/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts @@ -5,8 +5,8 @@ import { TestEntity } from '../__fixtures__/test.entity'; import { AggregateBuilder } from '../../src/query'; describe('AggregateBuilder', (): void => { - beforeEach(createTestConnection); - afterEach(closeTestConnection); + beforeEach(() => createTestConnection()); + afterEach(() => closeTestConnection()); const getRepo = () => getTestConnection().getRepository(TestEntity); const getQueryBuilder = () => getRepo().createQueryBuilder(); 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 5bda27a77..b59c80caa 100644 --- a/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts @@ -1,14 +1,16 @@ -import { anything, instance, mock, verify, when, deepEqual } from 'ts-mockito'; +import { AggregateQuery, Class, Filter, Query, SortDirection, SortNulls } from '@nestjs-query/core'; +import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; import { QueryBuilder, WhereExpression } from 'typeorm'; -import { Class, Filter, Query, SortDirection, SortNulls } from '@nestjs-query/core'; +import { FilterQueryBuilder, WhereBuilder } from '../../src/query'; 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'; +import { getCustomFilterRegistry } from '../utils'; +import { getQueryTypeormMetadata } from '../../src/common'; describe('FilterQueryBuilder', (): void => { - beforeEach(createTestConnection); - afterEach(closeTestConnection); + beforeEach(() => createTestConnection()); + afterEach(() => closeTestConnection()); const getEntityQueryBuilder = ( entity: Class, @@ -94,41 +96,97 @@ describe('FilterQueryBuilder', (): void => { expectSQLSnapshot(selectQueryBuilder); }; + const expectAggregateSQLSnapshot = ( + query: Query, + aggregate: AggregateQuery, + whereBuilder: WhereBuilder, + ): void => { + const aggregateQueryBuilder = getEntityQueryBuilder(TestEntity, whereBuilder).aggregate(query, aggregate); + expectSQLSnapshot(aggregateQueryBuilder); + }; + describe('with filter', () => { it('should not call whereBuilder#build', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSelectSQLSnapshot({}, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder = mock>(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), 'TestEntity')).thenCall( - (where: WhereExpression, field: Filter, relationNames: string[], alias: string) => - where.andWhere(`${alias}.stringType = 'foo'`), + when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, 'TestEntity')).thenCall( + ( + where: WhereExpression, + field: Filter, + relationNames: string[], + klass: Class, + alias: string, + ) => where.andWhere(`${alias}.stringType = 'foo'`), ); expectSelectSQLSnapshot(query, instance(mockWhereBuilder)); }); }); + describe('with custom filter', () => { + it('should add custom filters', () => { + expectSelectSQLSnapshot( + { + filter: { + // This has the global isMultipleOf filter + numberType: { gte: 1, lte: 10, isMultipleOf: 5 }, + // Here, the isMultipleOf filter was overridden for dateType only + dateType: { isMultipleOf: 3 }, + // This is a more complex filter involving geospatial queries + fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } }, + } as any, // TODO Fix any typing + }, + new WhereBuilder(getQueryTypeormMetadata(getTestConnection()), { + customFilterRegistry: getCustomFilterRegistry(getTestConnection()), + }), + ); + }); + + // eslint-disable-next-line jest/expect-expect + it('should add custom filters with aggregate', () => { + expectAggregateSQLSnapshot( + { + filter: { + // This has the global isMultipleOf filter + numberType: { gte: 1, lte: 10, isMultipleOf: 5 }, + // Here, the isMultipleOf filter was overridden for dateType only + dateType: { isMultipleOf: 3 }, + // This is a more complex filter involving geospatial queries + fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } }, + } as any, // TODO Fix any typing + }, + { + max: ['numberType'], + }, + new WhereBuilder(getQueryTypeormMetadata(getTestConnection()), { + customFilterRegistry: getCustomFilterRegistry(getTestConnection()), + }), + ); + }); + }); + describe('with paging', () => { it('should apply empty paging args', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSelectSQLSnapshot({}, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), TestEntity, 'TestEntity')).never(); }); it('should apply paging args going forward', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSelectSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), TestEntity, 'TestEntity')).never(); }); it('should apply paging args going backward', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSelectSQLSnapshot({ paging: { limit: 10, offset: 10 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); }); @@ -139,7 +197,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply ASC NULLS_FIRST sorting', () => { @@ -148,7 +206,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply ASC NULLS_LAST sorting', () => { @@ -157,7 +215,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply DESC sorting', () => { @@ -166,7 +224,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply DESC NULLS_FIRST sorting', () => { @@ -183,7 +241,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply multiple sorts', () => { @@ -199,7 +257,7 @@ describe('FilterQueryBuilder', (): void => { }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); }); }); @@ -214,7 +272,7 @@ describe('FilterQueryBuilder', (): void => { it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder = mock>(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall( + when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined)).thenCall( (where: WhereExpression) => where.andWhere(`stringType = 'foo'`), ); expectUpdateSQLSnapshot(query, instance(mockWhereBuilder)); @@ -224,7 +282,7 @@ describe('FilterQueryBuilder', (): void => { it('should ignore paging args', () => { const mockWhereBuilder = mock>(WhereBuilder); expectUpdateSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); @@ -235,7 +293,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply ASC NULLS_FIRST sorting', () => { @@ -244,7 +302,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply ASC NULLS_LAST sorting', () => { @@ -253,7 +311,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply DESC sorting', () => { @@ -262,7 +320,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply DESC NULLS_FIRST sorting', () => { @@ -271,7 +329,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_FIRST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply DESC NULLS_LAST sorting', () => { @@ -280,7 +338,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply multiple sorts', () => { @@ -296,7 +354,7 @@ describe('FilterQueryBuilder', (): void => { }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); }); @@ -311,7 +369,7 @@ describe('FilterQueryBuilder', (): void => { it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder = mock>(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall( + when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined)).thenCall( (where: WhereExpression) => where.andWhere(`stringType = 'foo'`), ); expectDeleteSQLSnapshot(query, instance(mockWhereBuilder)); @@ -321,7 +379,7 @@ describe('FilterQueryBuilder', (): void => { it('should ignore paging args', () => { const mockWhereBuilder = mock>(WhereBuilder); expectDeleteSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); @@ -339,7 +397,7 @@ describe('FilterQueryBuilder', (): void => { }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); }); @@ -357,7 +415,7 @@ describe('FilterQueryBuilder', (): void => { it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder = mock>(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall( + when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestSoftDeleteEntity, undefined)).thenCall( (where: WhereExpression) => where.andWhere(`stringType = 'foo'`), ); expectSoftDeleteSQLSnapshot(query, instance(mockWhereBuilder)); @@ -367,7 +425,7 @@ describe('FilterQueryBuilder', (): void => { it('should ignore paging args', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSoftDeleteSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); @@ -383,7 +441,7 @@ describe('FilterQueryBuilder', (): void => { }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); }); diff --git a/packages/query-typeorm/__tests__/query/relation-query.builder.spec.ts b/packages/query-typeorm/__tests__/query/relation-query.builder.spec.ts index 26d73cfb8..96fdeec03 100644 --- a/packages/query-typeorm/__tests__/query/relation-query.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/relation-query.builder.spec.ts @@ -1,18 +1,24 @@ import { Class, Query, SortDirection, SortNulls } from '@nestjs-query/core'; +import { CustomFilterRegistry, RelationQueryBuilder } from '../../src/query'; import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; import { TestRelation } from '../__fixtures__/test-relation.entity'; import { TestEntity } from '../__fixtures__/test.entity'; -import { RelationQueryBuilder } from '../../src/query'; +import { getCustomFilterRegistry } from '../utils'; describe('RelationQueryBuilder', (): void => { - beforeEach(createTestConnection); - afterEach(closeTestConnection); + let customFilterRegistry: CustomFilterRegistry; + + beforeEach(async () => { + await createTestConnection(); + customFilterRegistry = getCustomFilterRegistry(getTestConnection()); + }); + afterEach(() => closeTestConnection()); const getRelationQueryBuilder = ( EntityClass: Class, relationName: string, ): RelationQueryBuilder => - new RelationQueryBuilder(getTestConnection().getRepository(EntityClass), relationName); + new RelationQueryBuilder(getTestConnection().getRepository(EntityClass), relationName, { customFilterRegistry }); const expectSQLSnapshot = ( EntityClass: Class, @@ -161,5 +167,21 @@ describe('RelationQueryBuilder', (): void => { }); }); }); + describe('with custom filters', () => { + // TODO Fix typings to avoid usage of any + it('should accept custom filters', (): void => { + const query: Query = { + filter: { + // This has the global isMultipleOf filter + numberType: { gte: 1, lte: 10, isMultipleOf: 5 }, + // Here, the isMultipleOf filter was overridden for dateType only + dateType: { isMultipleOf: 3 }, + // This is a more complex filter involving geospatial queries + fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } }, + } as any, // TODO Fix any typing + }; + expectSQLSnapshot(TestEntity, testEntity, 'testRelations', query); + }); + }); }); }); diff --git a/packages/query-typeorm/__tests__/query/where.builder.spec.ts b/packages/query-typeorm/__tests__/query/where.builder.spec.ts index 2e386092e..1bb3357d2 100644 --- a/packages/query-typeorm/__tests__/query/where.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/where.builder.spec.ts @@ -1,18 +1,23 @@ -import { Filter } from '@nestjs-query/core'; +import { getQueryTypeormMetadata } from '../../src/common'; +import { WhereBuilder } from '../../src/query'; import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; import { TestEntity } from '../__fixtures__/test.entity'; -import { WhereBuilder } from '../../src/query'; +import { getCustomFilterRegistry } from '../utils'; +import { TestEntityFilter } from '../__fixtures__/types'; describe('WhereBuilder', (): void => { - beforeEach(createTestConnection); - afterEach(closeTestConnection); + beforeEach(() => createTestConnection()); + afterEach(() => closeTestConnection()); const getRepo = () => getTestConnection().getRepository(TestEntity); const getQueryBuilder = () => getRepo().createQueryBuilder(); - const createWhereBuilder = () => new WhereBuilder(); + const createWhereBuilder = () => + new WhereBuilder(getQueryTypeormMetadata(getTestConnection()), { + customFilterRegistry: getCustomFilterRegistry(getTestConnection()), + }); - const expectSQLSnapshot = (filter: Filter): void => { - const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, {}, 'TestEntity'); + const expectSQLSnapshot = (filter: TestEntityFilter): void => { + const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, {}, TestEntity, 'TestEntity'); const [sql, params] = selectQueryBuilder.getQueryAndParameters(); expect(sql).toMatchSnapshot(); expect(params).toMatchSnapshot(); @@ -30,6 +35,19 @@ describe('WhereBuilder', (): void => { expectSQLSnapshot({ numberType: { eq: 1 }, stringType: { like: 'foo%' }, boolType: { is: true } }); }); + it('should accept custom filters alongside regular filters', (): void => { + expectSQLSnapshot({ + // This has the global isMultipleOf filter + numberType: { gte: 1, lte: 10, isMultipleOf: 5 }, + // Here, the isMultipleOf filter was overridden for dateType only + dateType: { isMultipleOf: 3 }, + // This is a more complex filter involving geospatial queries + fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } }, + // This is a custom filter that does a subquery + pendingTestRelations: { gt: 5 }, + }); + }); + describe('and', (): void => { it('and multiple expressions together', (): void => { expectSQLSnapshot({ @@ -42,6 +60,20 @@ describe('WhereBuilder', (): void => { }); }); + it('and multiple expressions together with custom filters', (): void => { + expectSQLSnapshot({ + and: [ + { numberType: { gt: 10 } }, + { numberType: { lt: 20 } }, + { numberType: { gte: 30 } }, + { numberType: { lte: 40 } }, + { numberType: { isMultipleOf: 5 } }, + { dateType: { isMultipleOf: 3 } }, + { fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } } }, + ], + }); + }); + it('and multiple filters together with multiple fields', (): void => { expectSQLSnapshot({ and: [ @@ -77,6 +109,20 @@ describe('WhereBuilder', (): void => { }); }); + it('or multiple expressions together with custom filter', (): void => { + expectSQLSnapshot({ + or: [ + { numberType: { gt: 10 } }, + { numberType: { lt: 20 } }, + { numberType: { gte: 30 } }, + { numberType: { lte: 40 } }, + { numberType: { isMultipleOf: 5 } }, + { dateType: { isMultipleOf: 3 } }, + { fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } } }, + ], + }); + }); + it('and multiple and filters together', (): void => { expectSQLSnapshot({ or: [ diff --git a/packages/query-typeorm/__tests__/services/custom-filter-registry.spec.ts b/packages/query-typeorm/__tests__/services/custom-filter-registry.spec.ts new file mode 100644 index 000000000..5862c7004 --- /dev/null +++ b/packages/query-typeorm/__tests__/services/custom-filter-registry.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ColumnType } from 'typeorm'; +import { NestjsQueryTypeOrmModule } from '../../src'; +import { CustomFilterRegistry, CustomFilterResult } from '../../src/query'; +import { closeTestConnection, CONNECTION_OPTIONS, refresh } from '../__fixtures__/connection.fixture'; +import { + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, + TestEntityTestRelationCountFilter, +} from '../__fixtures__/custom-filters.services'; +import { TestEntityRelationEntity } from '../__fixtures__/test-entity-relation.entity'; +import { TestRelation } from '../__fixtures__/test-relation.entity'; +import { TestSoftDeleteEntity } from '../__fixtures__/test-soft-delete.entity'; +import { TestEntity } from '../__fixtures__/test.entity'; + +describe('CustomFilterRegistry', (): void => { + let moduleRef: TestingModule; + + afterEach(() => closeTestConnection()); + + beforeEach(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(CONNECTION_OPTIONS), + // Use the full module so we can test custom filters well + NestjsQueryTypeOrmModule.forFeature( + [TestEntity, TestRelation, TestEntityRelationEntity, TestSoftDeleteEntity], + undefined, + { + providers: [ + // Custom filters + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, + TestEntityTestRelationCountFilter, + ], + }, + ), + ], + providers: [], + }).compile(); + // Trigger onModuleInit() + await moduleRef.init(); + await refresh(); + }); + + describe('#standalone', () => { + it('Test for errors', () => { + const cf = new CustomFilterRegistry(); + const filter = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult { + return { sql: '', params: {} }; + }, + }; + expect(() => cf.setFilter(filter, { types: [], operations: ['testOperation'] })).toThrow( + 'Cannot register a (type) filter without types, please define the types array', + ); + expect(() => cf.setFilter(filter, { types: ['number'], operations: [] })).toThrow( + 'Cannot register a filter without operations, please define the operations array', + ); + }); + }); + + describe('#custom-filters', () => { + it('Verify that custom filters are registered', () => { + const customFilterRegistry = moduleRef.get(CustomFilterRegistry); + expect(customFilterRegistry).toBeDefined(); + expect(customFilterRegistry.getFilter('isMultipleOf')).toBeUndefined(); + + // Test for 2 different property type based filters + for (const type of ['integer', Number] as ColumnType[]) { + expect(customFilterRegistry.getFilter('isMultipleOf', type)).toBeInstanceOf(IsMultipleOfCustomFilter); + expect(customFilterRegistry.getFilter('isMultipleOf', type, TestEntity)).toBeInstanceOf( + IsMultipleOfCustomFilter, + ); + expect(customFilterRegistry.getFilter('isMultipleOf', type, TestEntity, 'numberType')).toBeInstanceOf( + IsMultipleOfCustomFilter, + ); + } + + for (const type of ['date', 'datetime', Date] as ColumnType[]) { + expect(customFilterRegistry.getFilter('isMultipleOf', type)).toBeDefined(); + expect(customFilterRegistry.getFilter('isMultipleOf', type, TestEntity)).toBeInstanceOf( + IsMultipleOfDateCustomFilter, + ); + expect(customFilterRegistry.getFilter('isMultipleOf', type, TestEntity, 'dateType')).toBeInstanceOf( + IsMultipleOfDateCustomFilter, + ); + } + + // Test for (class, field, entity) filter + expect(customFilterRegistry.getFilter('distanceFrom', undefined, TestEntity, 'fakePointType')).toBeInstanceOf( + RadiusCustomFilter, + ); + expect(customFilterRegistry.getFilter('gt', undefined, TestEntity, 'pendingTestRelations')).toBeInstanceOf( + TestEntityTestRelationCountFilter, + ); + }); + }); +}); 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 351c6a269..a040b6d8c 100644 --- a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts +++ b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts @@ -1,9 +1,9 @@ -import { Filter, SortDirection } from '@nestjs-query/core'; +import { Filter, getQueryServiceToken, QueryService, SortDirection } from '@nestjs-query/core'; import { Test, TestingModule } from '@nestjs/testing'; +import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; import { plainToClass } from 'class-transformer'; import { Repository } from 'typeorm'; -import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; -import { TypeOrmQueryService } from '../../src'; +import { NestjsQueryTypeOrmModule, TypeOrmQueryService } from '../../src'; import { FilterQueryBuilder } from '../../src/query'; import { closeTestConnection, @@ -12,11 +12,18 @@ import { refresh, truncate, } from '../__fixtures__/connection.fixture'; +import { + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, + TestEntityTestRelationCountFilter, +} from '../__fixtures__/custom-filters.services'; import { TEST_ENTITIES, TEST_RELATIONS, TEST_SOFT_DELETE_ENTITIES } from '../__fixtures__/seeds'; import { TestEntityRelationEntity } from '../__fixtures__/test-entity-relation.entity'; import { TestRelation } from '../__fixtures__/test-relation.entity'; import { TestSoftDeleteEntity } from '../__fixtures__/test-soft-delete.entity'; import { TestEntity } from '../__fixtures__/test.entity'; +import { TestEntityFilter } from '../__fixtures__/types'; describe('TypeOrmQueryService', (): void => { let moduleRef: TestingModule; @@ -39,16 +46,32 @@ describe('TypeOrmQueryService', (): void => { } } - afterEach(closeTestConnection); + afterEach(() => closeTestConnection()); beforeEach(async () => { moduleRef = await Test.createTestingModule({ imports: [ TypeOrmModule.forRoot(CONNECTION_OPTIONS), - TypeOrmModule.forFeature([TestEntity, TestRelation, TestEntityRelationEntity, TestSoftDeleteEntity]), + // Use the full module so we can test custom filters well + NestjsQueryTypeOrmModule.forFeature( + [TestEntity, TestRelation, TestEntityRelationEntity, TestSoftDeleteEntity], + undefined, + { + providers: [ + // Custom filters + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, + TestEntityTestRelationCountFilter, + ], + }, + ), + // TypeOrmModule.forFeature([TestEntity, TestRelation, TestEntityRelationEntity, TestSoftDeleteEntity]), ], providers: [TestEntityService, TestRelationService, TestSoftDeleteEntityService], }).compile(); + // Trigger onModuleInit() + await moduleRef.init(); await refresh(); }); @@ -1864,4 +1887,66 @@ describe('TypeOrmQueryService', (): void => { }); }); }); + + describe('#custom-filters', () => { + it('Simple query without relations', async () => { + const queryService: QueryService = moduleRef.get(getQueryServiceToken(TestEntity)); + expect(queryService).toBeDefined(); + // // TODO Remove any typing + // prettier-ignore + const queryResult = await queryService.query({ + filter: { + and: [ + { numberType: { gte: 6 } }, + { numberType: { isMultipleOf: 3 } }, + ], + } as any, + }); + expect(queryResult).toHaveLength(2); + // prettier-ignore + expect(queryResult).toMatchObject([ + { testEntityPk: 'test-entity-6' }, + { testEntityPk: 'test-entity-9' }, + ]); + }); + + it('Query relations', async () => { + const queryService: QueryService = moduleRef.get(getQueryServiceToken(TestEntity)); + expect(queryService).toBeDefined(); + // // TODO Remove any typing + // prettier-ignore + const queryResult = await queryService.query({ + filter: { + and: [ + { testRelations: { numberType: { gte: 30 } } }, + { testRelations: { numberType: { isMultipleOf: 21 } } }, + ], + } as any, + }); + expect(queryResult).toHaveLength(2); + // prettier-ignore + expect(queryResult).toMatchObject([ + { testEntityPk: 'test-entity-4' }, + { testEntityPk: 'test-entity-6' }, + ]); + }); + + it('Subquery filter', async () => { + const queryService: QueryService = moduleRef.get(getQueryServiceToken(TestEntity)); + expect(queryService).toBeDefined(); + // // TODO Remove any typing + // prettier-ignore + const queryResult = await queryService.query({ + filter: { + pendingTestRelations: {gt: 2}, + } as TestEntityFilter, + }); + expect(queryResult).toHaveLength(2); + // prettier-ignore + expect(queryResult).toMatchObject([ + { testEntityPk: 'test-entity-9' }, // result of the count subquery is = 3 (91,92,93) + { testEntityPk: 'test-entity-10' }, // result of the count subquery is = 3 (101,102,103) + ]); + }); + }); }); diff --git a/packages/query-typeorm/__tests__/utils.ts b/packages/query-typeorm/__tests__/utils.ts new file mode 100644 index 000000000..738c7041b --- /dev/null +++ b/packages/query-typeorm/__tests__/utils.ts @@ -0,0 +1,30 @@ +import { Connection } from 'typeorm'; +import { CustomFilterRegistry } from '../src/query'; +import { + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, + TestEntityTestRelationCountFilter, +} from './__fixtures__/custom-filters.services'; +import { TestEntity } from './__fixtures__/test.entity'; + +export function getCustomFilterRegistry(connection: Connection): CustomFilterRegistry { + const customFilterRegistry = new CustomFilterRegistry(); + // Test for (type, operation) filter registration (this is valid for all fields of all entities) + customFilterRegistry.setFilter(new IsMultipleOfCustomFilter(), IsMultipleOfCustomFilter); + // Test for (type, operation) filter on another type + customFilterRegistry.setFilter(new IsMultipleOfDateCustomFilter(), IsMultipleOfDateCustomFilter); + // Test for (class, field, operation) filter on a virtual property 'fakePointType' that does not really exist on the entity + customFilterRegistry.setFilter(new RadiusCustomFilter(), { + klass: TestEntity, + field: 'fakePointType', + operations: RadiusCustomFilter.operations, + }); + // Test for (class, field, operation) filter with a complex subquery + customFilterRegistry.setFilter(new TestEntityTestRelationCountFilter(connection.createEntityManager()), { + klass: TestEntity, + field: 'pendingTestRelations', + operations: ['gt'], + }); + return customFilterRegistry; +} diff --git a/packages/query-typeorm/package.json b/packages/query-typeorm/package.json index 007d1a34a..8fd280d2f 100644 --- a/packages/query-typeorm/package.json +++ b/packages/query-typeorm/package.json @@ -28,6 +28,7 @@ }, "peerDependencies": { "@nestjs/common": "^8.0.4", + "@nestjs/core": "^8.0.0", "@nestjs/typeorm": "^8.0.0", "class-transformer": "^0.2.3 || 0.3.1 || 0.4", "typeorm": "^0.2.25" diff --git a/packages/query-typeorm/src/common/index.ts b/packages/query-typeorm/src/common/index.ts index 02e438c20..d967a84a4 100644 --- a/packages/query-typeorm/src/common/index.ts +++ b/packages/query-typeorm/src/common/index.ts @@ -1 +1,2 @@ export * from './randomString'; +export * from './typeorm'; diff --git a/packages/query-typeorm/src/common/typeorm.ts b/packages/query-typeorm/src/common/typeorm.ts new file mode 100644 index 000000000..8551f233a --- /dev/null +++ b/packages/query-typeorm/src/common/typeorm.ts @@ -0,0 +1,75 @@ +import { Class } from '@nestjs-query/core'; +import { ColumnType, Connection, ConnectionOptions, getConnection } from 'typeorm'; + +interface QueryTypeormPropertyMetadata { + metaType: 'property'; + type: ColumnType; +} + +interface QueryTypeormRelationMetadata { + metaType: 'relation'; + type: Class; +} + +export type QueryTypeormEntityMetadata = Record< + keyof T | string, + QueryTypeormPropertyMetadata | QueryTypeormRelationMetadata +>; +export type QueryTypeormMetadata = Map, QueryTypeormEntityMetadata>; + +export function buildQueryTypeormMetadata(connection: Connection): QueryTypeormMetadata { + const meta: QueryTypeormMetadata = new Map(); + for (const entity of connection.entityMetadatas) { + const entityMeta: QueryTypeormEntityMetadata = {}; + for (const field of [...entity.ownColumns]) { + entityMeta[field.propertyName] = { + metaType: 'property', + type: field.type, + }; + } + for (const field of [...entity.ownRelations]) { + // Skip strings + if (typeof field.inverseEntityMetadata.target === 'function') { + entityMeta[field.propertyName] = { + metaType: 'relation', + type: field.inverseEntityMetadata.target as Class, + }; + } + } + + // Ignore things like junction tables + if (typeof entity.target === 'string') { + continue; + } + meta.set(entity.target as Class, entityMeta); + } + return meta; +} + +function getConnectionFromOpts(connection?: Connection | ConnectionOptions | string): Connection { + if (!connection) { + return getConnection('default'); + } + if (typeof connection === 'string') { + return getConnection(connection); + } + if (!connection.name) { + return getConnection('default'); + } + return connection as Connection; +} + +const cache = new Map(); + +export function getQueryTypeormMetadata( + connectionOpts?: Connection | ConnectionOptions | string, +): QueryTypeormMetadata { + const connection = getConnectionFromOpts(connectionOpts); + + let meta = cache.get(connection); + if (!meta) { + meta = buildQueryTypeormMetadata(connection); + cache.set(connection, meta); + } + return meta; +} diff --git a/packages/query-typeorm/src/decorators/constants.ts b/packages/query-typeorm/src/decorators/constants.ts new file mode 100644 index 000000000..d969147b6 --- /dev/null +++ b/packages/query-typeorm/src/decorators/constants.ts @@ -0,0 +1,2 @@ +export const TYPEORM_QUERY_FILTER_KEY = 'nestjs-query:typeorm:query-filter'; +export const WITH_TYPEORM_QUERY_FILTER_KEY = 'nestjs-query:typeorm:entity-query-filter'; diff --git a/packages/query-typeorm/src/decorators/index.ts b/packages/query-typeorm/src/decorators/index.ts new file mode 100644 index 000000000..e06bd7ac9 --- /dev/null +++ b/packages/query-typeorm/src/decorators/index.ts @@ -0,0 +1,2 @@ +export * from './typeorm-query-filter.decorator'; +export * from './with-typeorm-query-filter.decorator'; diff --git a/packages/query-typeorm/src/decorators/typeorm-query-filter.decorator.ts b/packages/query-typeorm/src/decorators/typeorm-query-filter.decorator.ts new file mode 100644 index 000000000..9f0edfe04 --- /dev/null +++ b/packages/query-typeorm/src/decorators/typeorm-query-filter.decorator.ts @@ -0,0 +1,61 @@ +import { Class, ValueReflector } from '@nestjs-query/core'; +import { Injectable } from '@nestjs/common'; +import { TYPEORM_QUERY_FILTER_KEY } from './constants'; +import { TypedClassDecorator } from './utils'; +import { CustomFilter } from '../query'; +import { ColumnType } from 'typeorm'; + +const reflector = new ValueReflector(TYPEORM_QUERY_FILTER_KEY); + +export interface TypeOrmQueryFilterOpts { + /** + * Automatically register this filter on all available entities. + * Default: true + */ + autoRegister?: boolean; + + /** + * Operations that this filters listens on + */ + operations?: string[]; + + /** + * Typeorm database types this filters listens on + */ + types?: ColumnType[]; +} + +/** + * @internal + */ +export interface TypeOrmQueryFilterMetadata { + filter: Class; + autoRegister: boolean; + operations?: string[]; + types?: ColumnType[]; +} + +const FilterList: Class[] = []; +const FilterMeta: TypeOrmQueryFilterMetadata[] = []; + +export function TypeOrmQueryFilter(opts: TypeOrmQueryFilterOpts = {}): TypedClassDecorator { + return >(FilterClass: Cls): Cls | void => { + FilterList.push(FilterClass); + const meta: TypeOrmQueryFilterMetadata = { + filter: FilterClass, + autoRegister: opts.autoRegister ?? true, + operations: opts.operations, + types: opts.types, + }; + reflector.set(FilterClass, meta); + FilterMeta.push(meta); + return Injectable()(FilterClass); + }; +} + +/** + * @internal + */ +export function getTypeOrmQueryFilters(): TypeOrmQueryFilterMetadata[] { + return FilterMeta; +} diff --git a/packages/query-typeorm/src/decorators/utils.ts b/packages/query-typeorm/src/decorators/utils.ts new file mode 100644 index 000000000..26ad5a3a8 --- /dev/null +++ b/packages/query-typeorm/src/decorators/utils.ts @@ -0,0 +1,3 @@ +import { Class } from '@nestjs-query/core'; + +export type TypedClassDecorator = >(DTOClass: Cls) => Cls | void; diff --git a/packages/query-typeorm/src/decorators/with-typeorm-query-filter.decorator.ts b/packages/query-typeorm/src/decorators/with-typeorm-query-filter.decorator.ts new file mode 100644 index 000000000..47f7ea221 --- /dev/null +++ b/packages/query-typeorm/src/decorators/with-typeorm-query-filter.decorator.ts @@ -0,0 +1,37 @@ +import { ArrayReflector, Class } from '@nestjs-query/core'; +import { CustomFilter } from '../query'; +import { WITH_TYPEORM_QUERY_FILTER_KEY } from './constants'; + +const reflector = new ArrayReflector(WITH_TYPEORM_QUERY_FILTER_KEY); + +export interface WithTypeormQueryFilterOpts { + /** + * Filter class (injection token) + */ + filter: Class; + /** + * Used to register a filter on specific fields instead of types. + * Note that arbitrary field names can be used, to support filters that are not mapped to real entity fields + */ + fields: (string | keyof Entity)[]; + + /** + * Operations that this filters listens on + */ + operations: string[]; +} + +export function WithTypeormQueryFilter(opts: WithTypeormQueryFilterOpts) { + return >(EntityClass: Cls): Cls | void => { + reflector.append(EntityClass, opts); + }; +} + +/** + * @internal + */ +export function getTypeormEntityQueryFilters( + EntityClass: Class, +): WithTypeormQueryFilterOpts[] { + return reflector.get(EntityClass) ?? []; +} diff --git a/packages/query-typeorm/src/index.ts b/packages/query-typeorm/src/index.ts index 34fe2dd43..1b088ec4a 100644 --- a/packages/query-typeorm/src/index.ts +++ b/packages/query-typeorm/src/index.ts @@ -1,2 +1,9 @@ export { TypeOrmQueryService, TypeOrmQueryServiceOpts } from './services'; +export { + TypeOrmQueryFilter, + WithTypeormQueryFilter, + TypeOrmQueryFilterOpts, + WithTypeormQueryFilterOpts, +} from './decorators'; +export { CustomFilter, CustomFilterResult, CustomFilterContext } from './query'; export { NestjsQueryTypeOrmModule } from './module'; diff --git a/packages/query-typeorm/src/module.ts b/packages/query-typeorm/src/module.ts index 77a342b0f..6ed31f618 100644 --- a/packages/query-typeorm/src/module.ts +++ b/packages/query-typeorm/src/module.ts @@ -1,18 +1,88 @@ import { Class } from '@nestjs-query/core'; +import { DynamicModule, Inject, OnModuleInit, Provider } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { DynamicModule } from '@nestjs/common'; import { Connection, ConnectionOptions } from 'typeorm'; +import { getTypeormEntityQueryFilters, getTypeOrmQueryFilters } from './decorators'; import { createTypeOrmQueryServiceProviders } from './providers'; +import { CustomFilterRegistry } from './query'; -export class NestjsQueryTypeOrmModule { - static forFeature(entities: Class[], connection?: Connection | ConnectionOptions | string): DynamicModule { +export const CONFIG_KEY = 'nestjs-query:typeorm:config'; + +interface NestjsQueryTypeOrmModuleOpts { + providers?: Provider[]; +} + +interface NestjsQueryTypeOrmModuleConfig { + entities: Class[]; +} + +export class NestjsQueryTypeOrmModule implements OnModuleInit { + constructor( + @Inject(CONFIG_KEY) private readonly config: NestjsQueryTypeOrmModuleConfig, + private readonly ref: ModuleRef, + private readonly customFilterRegistry: CustomFilterRegistry, + ) {} + + static forFeature( + entities: Class[], + connection?: Connection | ConnectionOptions | string, + opts?: NestjsQueryTypeOrmModuleOpts, + ): DynamicModule { const queryServiceProviders = createTypeOrmQueryServiceProviders(entities, connection); const typeOrmModule = TypeOrmModule.forFeature(entities, connection); return { imports: [typeOrmModule], module: NestjsQueryTypeOrmModule, - providers: [...queryServiceProviders], + providers: [ + ...queryServiceProviders, + ...(opts?.providers ?? []), + { + provide: CONFIG_KEY, + useValue: { entities }, + }, + { + provide: CustomFilterRegistry, + useFactory: () => new CustomFilterRegistry(), + }, + ], exports: [...queryServiceProviders, typeOrmModule], }; } + + onModuleInit(): void { + for (const entity of this.config.entities) { + const globalCustomFilters = getTypeOrmQueryFilters(); + // Register global (type) custom filters + for (const cf of globalCustomFilters) { + if (cf.types && cf.operations) { + try { + const instance = this.ref.get(cf.filter); + this.customFilterRegistry.setFilter(instance, { + types: cf.types, + operations: cf.operations, + }); + } catch (e) { + // Suppress get errors + // TODO Only catch UnknownElementException (when nest will expose it) + } + } + } + // Register entity specific custom filters + const customFilters = getTypeormEntityQueryFilters(entity); + if (customFilters.length > 0) { + for (const cf of customFilters) { + try { + const instance = this.ref.get(cf.filter); + for (const field of cf.fields) { + this.customFilterRegistry.setFilter(instance, { klass: entity, field, operations: cf.operations }); + } + } catch (e) { + // Suppress get errors + // TODO Only catch UnknownElementException (when nest will expose it) + } + } + } + } + } } diff --git a/packages/query-typeorm/src/providers.ts b/packages/query-typeorm/src/providers.ts index d00542d7b..a0fcbcb53 100644 --- a/packages/query-typeorm/src/providers.ts +++ b/packages/query-typeorm/src/providers.ts @@ -3,6 +3,8 @@ import { FactoryProvider } from '@nestjs/common'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository, Connection, ConnectionOptions } from 'typeorm'; import { TypeOrmQueryService } from './services'; +import { CustomFilterRegistry, FilterQueryBuilder, WhereBuilder } from './query'; +import { getQueryTypeormMetadata } from './common'; function createTypeOrmQueryServiceProvider( EntityClass: Class, @@ -10,10 +12,17 @@ function createTypeOrmQueryServiceProvider( ): FactoryProvider { return { provide: getQueryServiceToken(EntityClass), - useFactory(repo: Repository) { - return new TypeOrmQueryService(repo); + useFactory(repo: Repository, customFilterRegistry: CustomFilterRegistry) { + return new TypeOrmQueryService(repo, { + filterQueryBuilder: new FilterQueryBuilder( + repo, + new WhereBuilder(getQueryTypeormMetadata(connection), { + customFilterRegistry, + }), + ), + }); }, - inject: [getRepositoryToken(EntityClass, connection)], + inject: [getRepositoryToken(EntityClass, connection), CustomFilterRegistry], }; } diff --git a/packages/query-typeorm/src/query/custom-filter.registry.ts b/packages/query-typeorm/src/query/custom-filter.registry.ts new file mode 100644 index 000000000..8cf40f787 --- /dev/null +++ b/packages/query-typeorm/src/query/custom-filter.registry.ts @@ -0,0 +1,90 @@ +import { Class } from '@nestjs-query/core'; +import { ColumnType, ObjectLiteral } from 'typeorm'; + +export type CustomFilterResult = { sql: string; params: ObjectLiteral }; + +export interface CustomFilterContext { + fieldType?: ColumnType; +} + +export interface CustomFilter { + apply( + field: keyof Entity | string, + cmp: OperationType, + val: unknown, + alias?: string, + context?: CustomFilterContext, + ): CustomFilterResult; +} + +type OperationCustomFilters = Record; + +type SetFilterSpec = + | { types: ColumnType[]; operations: string[] } + | { klass: Class; field: string; operations: string[] }; + +export class CustomFilterRegistry { + // Registry for (class, field) filters + private cfoRegistry: Map, Record> = new Map(); + + // Registry for (type) filters + private tRegistry: Map = new Map(); + + getFilter( + operation: string, + type?: ColumnType, // Type is optional, since we might have non property-backed filters + klass?: Class, + field?: keyof Entity | string, + ): CustomFilter | undefined { + // Most specific: (class, field) filters. + if (klass && field) { + const flt = this.cfoRegistry.get(klass)?.[field]?.[operation]; + if (flt) { + return flt; + } + } + // Type filters + if (type) { + return this.tRegistry.get(type)?.[operation]; + } + return undefined; + } + + /** + * We have 2 types of filters: + * - (type, operation) global filters + * - (class, field, operation) filters + * type is the database column type (TypeORM's ColumnType) + * Specificity of the filters increases from top to bottom + */ + setFilter(filter: CustomFilter, opts: SetFilterSpec): void { + if ('klass' in opts && 'field' in opts) { + const { klass, field, operations } = opts; + if (!operations || operations.length === 0) { + throw new Error('Cannot register a filter without operations, please define the operations array'); + } + const entityFilters = this.cfoRegistry.get(klass) || {}; + entityFilters[field] = this.createCustomFilterOperationMap(filter, operations); + this.cfoRegistry.set(klass, entityFilters); + } else if ('types' in opts) { + const { types, operations } = opts; + if (!types || types.length === 0) { + throw new Error('Cannot register a (type) filter without types, please define the types array'); + } + if (!operations || operations.length === 0) { + throw new Error('Cannot register a filter without operations, please define the operations array'); + } + for (const type of types) { + this.tRegistry.set(type, this.createCustomFilterOperationMap(filter, operations)); + } + } + } + + private createCustomFilterOperationMap(cf: CustomFilter, operations: string[]): OperationCustomFilters { + const ocf: OperationCustomFilters = {}; + for (const op of operations) { + ocf[op] = cf; + } + return ocf; + } +} diff --git a/packages/query-typeorm/src/query/filter-query.builder.ts b/packages/query-typeorm/src/query/filter-query.builder.ts index 13d1383f6..4c4402a92 100644 --- a/packages/query-typeorm/src/query/filter-query.builder.ts +++ b/packages/query-typeorm/src/query/filter-query.builder.ts @@ -1,17 +1,18 @@ -import { Filter, Paging, Query, SortField, getFilterFields, AggregateQuery } from '@nestjs-query/core'; +import { AggregateQuery, Class, Filter, getFilterFields, Paging, Query, SortField } from '@nestjs-query/core'; +import merge from 'lodash.merge'; import { DeleteQueryBuilder, + EntityMetadata, QueryBuilder, Repository, SelectQueryBuilder, UpdateQueryBuilder, WhereExpression, - EntityMetadata, } from 'typeorm'; import { SoftDeleteQueryBuilder } from 'typeorm/query-builder/SoftDeleteQueryBuilder'; +import { getQueryTypeormMetadata } from '../common'; import { AggregateBuilder } from './aggregate.builder'; import { WhereBuilder } from './where.builder'; -import merge from 'lodash.merge'; /** * @internal @@ -33,8 +34,11 @@ interface Groupable extends QueryBuilder { */ interface Pageable extends QueryBuilder { limit(limit?: number): this; + offset(offset?: number): this; + skip(skip?: number): this; + take(take?: number): this; } @@ -55,6 +59,13 @@ interface R { */ export type NestedRecord = R; +export interface RelationMeta { + targetKlass: Class; + relations: Record; +} + +export type RelationsMeta = Record; + /** * @internal * @@ -63,10 +74,16 @@ export type NestedRecord = R; export class FilterQueryBuilder { constructor( readonly repo: Repository, - readonly whereBuilder: WhereBuilder = new WhereBuilder(), + readonly whereBuilder: WhereBuilder = new WhereBuilder( + getQueryTypeormMetadata(repo.manager.connection), + ), readonly aggregateBuilder: AggregateBuilder = new AggregateBuilder(), ) {} + private get relationNames(): string[] { + return this.repo.metadata.relations.map((r) => r.propertyName); + } + /** * Create a `typeorm` SelectQueryBuilder with `WHERE`, `ORDER BY` and `LIMIT/OFFSET` clauses. * @@ -75,10 +92,11 @@ export class FilterQueryBuilder { select(query: Query): SelectQueryBuilder { const hasRelations = this.filterHasRelations(query.filter); let qb = this.createQueryBuilder(); + const klass = this.repo.metadata.target as Class; qb = hasRelations ? this.applyRelationJoinsRecursive(qb, this.getReferencedRelationsRecursive(this.repo.metadata, query.filter)) : qb; - qb = this.applyFilter(qb, query.filter, qb.alias); + qb = this.applyFilter(qb, klass, query.filter, qb.alias); qb = this.applySorting(qb, query.sorting, qb.alias); qb = this.applyPaging(qb, query.paging, hasRelations); return qb; @@ -87,11 +105,12 @@ export class FilterQueryBuilder { selectById(id: string | number | (string | number)[], query: Query): SelectQueryBuilder { const hasRelations = this.filterHasRelations(query.filter); let qb = this.createQueryBuilder(); + const klass = this.repo.metadata.target as Class; qb = hasRelations ? this.applyRelationJoinsRecursive(qb, this.getReferencedRelationsRecursive(this.repo.metadata, query.filter)) : qb; qb = qb.andWhereInIds(id); - qb = this.applyFilter(qb, query.filter, qb.alias); + qb = this.applyFilter(qb, klass, query.filter, qb.alias); qb = this.applySorting(qb, query.sorting, qb.alias); qb = this.applyPaging(qb, query.paging, hasRelations); return qb; @@ -99,8 +118,9 @@ export class FilterQueryBuilder { aggregate(query: Query, aggregate: AggregateQuery): SelectQueryBuilder { let qb = this.createQueryBuilder(); + const klass = this.repo.metadata.target as Class; qb = this.applyAggregate(qb, aggregate, qb.alias); - qb = this.applyFilter(qb, query.filter, qb.alias); + qb = this.applyFilter(qb, klass, query.filter, qb.alias); qb = this.applyAggregateSorting(qb, aggregate.groupBy, qb.alias); qb = this.applyGroupBy(qb, aggregate.groupBy, qb.alias); return qb; @@ -112,7 +132,9 @@ export class FilterQueryBuilder { * @param query - the query to apply. */ delete(query: Query): DeleteQueryBuilder { - return this.applyFilter(this.repo.createQueryBuilder().delete(), query.filter); + const qb = this.repo.createQueryBuilder().delete(); + const klass = this.repo.metadata.target as Class; + return this.applyFilter(qb, klass, query.filter); } /** @@ -121,10 +143,9 @@ export class FilterQueryBuilder { * @param query - the query to apply. */ softDelete(query: Query): SoftDeleteQueryBuilder { - return this.applyFilter( - this.repo.createQueryBuilder().softDelete() as SoftDeleteQueryBuilder, - query.filter, - ); + const qb = this.repo.createQueryBuilder().softDelete() as SoftDeleteQueryBuilder; + const klass = this.repo.metadata.target as Class; + return this.applyFilter(qb, klass, query.filter); } /** @@ -133,7 +154,9 @@ export class FilterQueryBuilder { * @param query - the query to apply. */ update(query: Query): UpdateQueryBuilder { - const qb = this.applyFilter(this.repo.createQueryBuilder().update(), query.filter); + const qb = this.repo.createQueryBuilder().update(); + const klass = this.repo.metadata.target as Class; + this.applyFilter(qb, klass, query.filter); return this.applySorting(qb, query.sorting); } @@ -170,14 +193,26 @@ export class FilterQueryBuilder { * Applies the filter from a Query to a `typeorm` QueryBuilder. * * @param qb - the `typeorm` QueryBuilder. + * @param klass - the class currently being processed * @param filter - the filter. * @param alias - optional alias to use to qualify an identifier */ - applyFilter(qb: Where, filter?: Filter, alias?: string): Where { + applyFilter( + qb: Where, + klass: Class, + filter?: Filter, + alias?: string, + ): Where { if (!filter) { return qb; } - return this.whereBuilder.build(qb, filter, this.getReferencedRelationsRecursive(this.repo.metadata, filter), alias); + return this.whereBuilder.build( + qb, + filter, + this.getReferencedRelationsMetaRecursive(this.repo.metadata, filter), + klass, + alias, + ); } /** @@ -216,14 +251,6 @@ export class FilterQueryBuilder { }, qb); } - /** - * Create a `typeorm` SelectQueryBuilder which can be used as an entry point to create update, delete or insert - * QueryBuilders. - */ - private createQueryBuilder(): SelectQueryBuilder { - return this.repo.createQueryBuilder(); - } - /** * Gets relations referenced in the filter and adds joins for them to the query builder * @param qb - the `typeorm` QueryBuilder. @@ -263,17 +290,11 @@ export class FilterQueryBuilder { return this.getReferencedRelations(filter).length > 0; } - private getReferencedRelations(filter: Filter): string[] { - const { relationNames } = this; - const referencedFields = getFilterFields(filter); - return referencedFields.filter((f) => relationNames.includes(f)); - } - getReferencedRelationsRecursive(metadata: EntityMetadata, filter: Filter = {}): NestedRecord { - const referencedFields = Array.from(new Set(Object.keys(filter) as (keyof Filter)[])); + const referencedFields = Array.from(new Set(Object.keys(filter))); return referencedFields.reduce((prev, curr) => { - const currFilterValue = filter[curr]; - if ((curr === 'and' || curr === 'or') && currFilterValue) { + if (curr === 'and' || curr === 'or') { + const currFilterValue = filter[curr] ?? []; for (const subFilter of currFilterValue) { prev = merge(prev, this.getReferencedRelationsRecursive(metadata, subFilter)); } @@ -284,13 +305,52 @@ export class FilterQueryBuilder { ...prev, [curr]: merge( (prev as NestedRecord)[curr], - this.getReferencedRelationsRecursive(referencedRelation.inverseEntityMetadata, currFilterValue), + this.getReferencedRelationsRecursive( + referencedRelation.inverseEntityMetadata, + filter[curr] as Filter, // If we're here, it means that we need to recurse into a relation + ), ), }; }, {}); } - private get relationNames(): string[] { - return this.repo.metadata.relations.map((r) => r.propertyName); + getReferencedRelationsMetaRecursive(metadata: EntityMetadata, filter: Filter = {}): RelationsMeta { + const referencedFields = Array.from(new Set(Object.keys(filter))); + let meta: RelationsMeta = {}; + for (const referencedField of referencedFields) { + if (referencedField === 'and' || referencedField === 'or') { + const currFilterValue = filter[referencedField] ?? []; + for (const subFilter of currFilterValue) { + meta = merge(meta, this.getReferencedRelationsMetaRecursive(metadata, subFilter)); + } + } + const referencedRelation = metadata.relations.find((r) => r.propertyName === referencedField); + if (!referencedRelation) continue; + meta[referencedField] = { + targetKlass: referencedRelation.inverseEntityMetadata.target as Class, + relations: merge( + meta?.[referencedField]?.relations, + this.getReferencedRelationsMetaRecursive( + referencedRelation.inverseEntityMetadata, + filter[referencedField] as Filter, + ), + ), + }; + } + return meta; + } + + /** + * Create a `typeorm` SelectQueryBuilder which can be used as an entry point to create update, delete or insert + * QueryBuilders. + */ + private createQueryBuilder(): SelectQueryBuilder { + return this.repo.createQueryBuilder(); + } + + private getReferencedRelations(filter: Filter): string[] { + const { relationNames } = this; + const referencedFields = getFilterFields(filter); + return referencedFields.filter((f) => relationNames.includes(f)); } } diff --git a/packages/query-typeorm/src/query/index.ts b/packages/query-typeorm/src/query/index.ts index 852faaf42..37a3c7968 100644 --- a/packages/query-typeorm/src/query/index.ts +++ b/packages/query-typeorm/src/query/index.ts @@ -3,3 +3,4 @@ export * from './where.builder'; export * from './sql-comparison.builder'; export * from './relation-query.builder'; export * from './aggregate.builder'; +export * from './custom-filter.registry'; diff --git a/packages/query-typeorm/src/query/relation-query.builder.ts b/packages/query-typeorm/src/query/relation-query.builder.ts index 7bdd8d5b3..190ce5a1f 100644 --- a/packages/query-typeorm/src/query/relation-query.builder.ts +++ b/packages/query-typeorm/src/query/relation-query.builder.ts @@ -3,8 +3,11 @@ import { AggregateQuery, Class, Query } from '@nestjs-query/core'; import { Repository, SelectQueryBuilder, ObjectLiteral, Brackets } from 'typeorm'; import { RelationMetadata } from 'typeorm/metadata/RelationMetadata'; import { DriverUtils } from 'typeorm/driver/DriverUtils'; +import { getQueryTypeormMetadata } from '../common'; +import { CustomFilterRegistry } from './custom-filter.registry'; import { FilterQueryBuilder } from './filter-query.builder'; import { AggregateBuilder } from './aggregate.builder'; +import { WhereBuilder } from './where.builder'; interface JoinCondition { leftHand: string; @@ -64,9 +67,18 @@ export class RelationQueryBuilder { private paramCount: number; - constructor(readonly repo: Repository, readonly relation: string) { + constructor( + readonly repo: Repository, + readonly relation: string, + readonly opts?: { customFilterRegistry?: CustomFilterRegistry }, + ) { this.relationRepo = this.repo.manager.getRepository(this.relationMeta.from); - this.filterQueryBuilder = new FilterQueryBuilder(this.relationRepo); + this.filterQueryBuilder = new FilterQueryBuilder( + this.relationRepo, + new WhereBuilder(getQueryTypeormMetadata(repo.manager.connection), { + customFilterRegistry: opts?.customFilterRegistry, + }), + ); this.paramCount = 0; } @@ -79,7 +91,8 @@ export class RelationQueryBuilder { this.filterQueryBuilder.getReferencedRelationsRecursive(this.relationRepo.metadata, query.filter), ) : relationBuilder; - relationBuilder = this.filterQueryBuilder.applyFilter(relationBuilder, query.filter, relationBuilder.alias); + const klass = this.repo.metadata.target as Class; + relationBuilder = this.filterQueryBuilder.applyFilter(relationBuilder, klass, query.filter, relationBuilder.alias); relationBuilder = this.filterQueryBuilder.applyPaging(relationBuilder, query.paging); return this.filterQueryBuilder.applySorting(relationBuilder, query.sorting, relationBuilder.alias); } @@ -120,8 +133,9 @@ export class RelationQueryBuilder { aggregateQuery: AggregateQuery, ): SelectQueryBuilder { let relationBuilder = this.createRelationQueryBuilder(entity); + const klass = this.repo.metadata.target as Class; relationBuilder = this.filterQueryBuilder.applyAggregate(relationBuilder, aggregateQuery, relationBuilder.alias); - relationBuilder = this.filterQueryBuilder.applyFilter(relationBuilder, query.filter, relationBuilder.alias); + relationBuilder = this.filterQueryBuilder.applyFilter(relationBuilder, klass, query.filter, relationBuilder.alias); relationBuilder = this.filterQueryBuilder.applyAggregateSorting( relationBuilder, aggregateQuery.groupBy, diff --git a/packages/query-typeorm/src/query/sql-comparison.builder.ts b/packages/query-typeorm/src/query/sql-comparison.builder.ts index 8e3e854c7..c886411e0 100644 --- a/packages/query-typeorm/src/query/sql-comparison.builder.ts +++ b/packages/query-typeorm/src/query/sql-comparison.builder.ts @@ -89,7 +89,7 @@ export class SQLComparisonBuilder { // notBetween comparison (field NOT BETWEEN x AND y) return this.notBetweenComparisonSQL(col, val); } - throw new Error(`unknown operator ${JSON.stringify(cmp)}`); + throw new Error(`unknown operator ${JSON.stringify(cmp)} for field ${JSON.stringify(field)}`); } private createComparisonSQL( diff --git a/packages/query-typeorm/src/query/where.builder.ts b/packages/query-typeorm/src/query/where.builder.ts index 79c47f317..5365dd0f3 100644 --- a/packages/query-typeorm/src/query/where.builder.ts +++ b/packages/query-typeorm/src/query/where.builder.ts @@ -1,36 +1,57 @@ +import { Class, Filter, FilterComparisons, FilterFieldComparison } from '@nestjs-query/core'; import { Brackets, WhereExpression } from 'typeorm'; -import { Filter, FilterComparisons, FilterFieldComparison } from '@nestjs-query/core'; +import { QueryTypeormMetadata } from '../common'; +import { CustomFilterContext, CustomFilterRegistry } from './custom-filter.registry'; +import { RelationsMeta } from './filter-query.builder'; import { EntityComparisonField, SQLComparisonBuilder } from './sql-comparison.builder'; -import { NestedRecord } from './filter-query.builder'; + +interface WhereBuilderOpts { + sqlComparisonBuilder?: SQLComparisonBuilder; + customFilterRegistry?: CustomFilterRegistry; + queryTypeormMetadata?: QueryTypeormMetadata; +} /** * @internal * Builds a WHERE clause from a Filter. */ export class WhereBuilder { - constructor(readonly sqlComparisonBuilder: SQLComparisonBuilder = new SQLComparisonBuilder()) {} + private sqlComparisonBuilder: SQLComparisonBuilder; + + private customFilterRegistry: CustomFilterRegistry; + + // prettier-ignore + constructor( + private queryTypeormMetadata: QueryTypeormMetadata, + opts?: WhereBuilderOpts + ) { + this.sqlComparisonBuilder = opts?.sqlComparisonBuilder ?? new SQLComparisonBuilder(); + this.customFilterRegistry = opts?.customFilterRegistry ?? new CustomFilterRegistry(); + } /** * Builds a WHERE clause from a Filter. * @param where - the `typeorm` WhereExpression * @param filter - the filter to build the WHERE clause from. - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ build( where: Where, filter: Filter, - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { const { and, or } = filter; if (and && and.length) { - this.filterAnd(where, and, relationNames, alias); + this.filterAnd(where, and, relationMeta, klass, alias); } if (or && or.length) { - this.filterOr(where, or, relationNames, alias); + this.filterOr(where, or, relationMeta, klass, alias); } - return this.filterFields(where, filter, relationNames, alias); + return this.filterFields(where, filter, relationMeta, klass, alias); } /** @@ -38,17 +59,21 @@ export class WhereBuilder { * * @param where - the `typeorm` WhereExpression * @param filters - the array of filters to AND together - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ private filterAnd( where: Where, filters: Filter[], - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { return where.andWhere( - new Brackets((qb) => filters.reduce((w, f) => qb.andWhere(this.createBrackets(f, relationNames, alias)), qb)), + new Brackets((qb) => + filters.reduce((w, f) => qb.andWhere(this.createBrackets(f, relationMeta, klass, alias)), qb), + ), ); } @@ -57,17 +82,19 @@ export class WhereBuilder { * * @param where - the `typeorm` WhereExpression * @param filter - the array of filters to OR together - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ private filterOr( where: Where, filter: Filter[], - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { return where.andWhere( - new Brackets((qb) => filter.reduce((w, f) => qb.orWhere(this.createBrackets(f, relationNames, alias)), qb)), + new Brackets((qb) => filter.reduce((w, f) => qb.orWhere(this.createBrackets(f, relationMeta, klass, alias)), qb)), ); } @@ -78,33 +105,42 @@ export class WhereBuilder { * {a: { eq: 1 }, b: { gt: 2 } } // "((a = 1) AND (b > 2))" * ``` * @param filter - the filter to wrap in brackets. - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ - private createBrackets(filter: Filter, relationNames: NestedRecord, alias?: string): Brackets { - return new Brackets((qb) => this.build(qb, filter, relationNames, alias)); + private createBrackets( + filter: Filter, + relationMeta: RelationsMeta, + klass: Class, + alias?: string, + ): Brackets { + return new Brackets((qb) => this.build(qb, filter, relationMeta, klass, alias)); } /** * Creates field comparisons from a filter. This method will ignore and/or properties. * @param where - the `typeorm` WhereExpression * @param filter - the filter with fields to create comparisons for. - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ private filterFields( where: Where, filter: Filter, - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { return Object.keys(filter).reduce((w, field) => { if (field !== 'and' && field !== 'or') { return this.withFilterComparison( where, - field as keyof Entity, + field as keyof Entity & string, this.getField(filter, field as keyof Entity), - relationNames, + relationMeta, + klass, alias, ); } @@ -121,21 +157,48 @@ export class WhereBuilder { private withFilterComparison( where: Where, - field: T, + field: T & string, cmp: FilterFieldComparison, - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { - if (relationNames[field as string]) { - return this.withRelationFilter(where, field, cmp as Filter, relationNames[field as string]); + if (relationMeta && relationMeta[field as string]) { + return this.withRelationFilter( + where, + field, + cmp as Filter, + relationMeta[field as string].relations, + relationMeta[field as string].targetKlass, + ); } + // This could be null if we are targeting a virtual field for special (class, field, operation) filters + const fieldMeta = this.queryTypeormMetadata.get(klass)?.[field]; return where.andWhere( new Brackets((qb) => { - const opts = Object.keys(cmp) as (keyof FilterFieldComparison)[]; - const sqlComparisons = opts.map((cmpType) => - this.sqlComparisonBuilder.build(field, cmpType, cmp[cmpType] as EntityComparisonField, alias), - ); - sqlComparisons.map(({ sql, params }) => qb.orWhere(sql, params)); + const opts = Object.keys(cmp) as (keyof FilterFieldComparison & string)[]; + const sqlComparisons = opts.map((cmpType) => { + // If we have a registered customfilter, this has priority over the standard sqlComparisonBuilder + if (!fieldMeta || fieldMeta.metaType === 'property') { + const customFilter = this.customFilterRegistry?.getFilter(cmpType, fieldMeta?.type, klass, field); + if (customFilter) { + const context: CustomFilterContext = { + fieldType: fieldMeta?.type, + }; + return customFilter.apply(field, cmpType, cmp[cmpType], alias, context); + } + } + // Fallback to sqlComparisonBuilder + return this.sqlComparisonBuilder.build( + field, + cmpType, + cmp[cmpType] as EntityComparisonField, + alias, + ); + }); + sqlComparisons.map(({ sql, params }) => { + qb.orWhere(sql, params); + }); }), ); } @@ -144,12 +207,18 @@ export class WhereBuilder { where: Where, field: T, cmp: Filter, - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, ): Where { return where.andWhere( new Brackets((qb) => { - const relationWhere = new WhereBuilder(); - return relationWhere.build(qb, cmp, relationNames, field as string); + // const relationWhere = new WhereBuilder({ + // customFilterRegistry: this.customFilterRegistry, + // sqlComparisonBuilder: this.sqlComparisonBuilder, + // }); + // return relationWhere.build(qb, cmp, relationMeta, klass, field as string); + // No need to create a new builder since we are stateless and we can reuse the same instance + return (this as unknown as WhereBuilder).build(qb, cmp, relationMeta, klass, field as string); }), ); }