Skip to content

Commit

Permalink
feat(graphql): Allow specifying allowed comparisons on filterable fields
Browse files Browse the repository at this point in the history
  • Loading branch information
doug-martin committed Jul 28, 2020
1 parent ffe2ae9 commit ced2792
Show file tree
Hide file tree
Showing 14 changed files with 240 additions and 66 deletions.
4 changes: 2 additions & 2 deletions packages/core/src/helpers/filter.builder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Filter, FilterComparisonOperators, FilterComparisons, FilterFieldComparison } from '../interfaces';
import { Filter, FilterComparisons, FilterFieldComparison } from '../interfaces';
import { ComparisonBuilder } from './comparison.builder';
import { ComparisonField, FilterFn } from './types';

Expand Down Expand Up @@ -48,7 +48,7 @@ export class FilterBuilder {
field: T,
cmp: FilterFieldComparison<DTO[T]>,
): FilterFn<DTO> {
const operators = Object.keys(cmp) as FilterComparisonOperators<DTO[T]>[];
const operators = Object.keys(cmp) as (keyof FilterFieldComparison<DTO[T]>)[];
return this.orFilterFn(
...operators.map((operator) =>
ComparisonBuilder.build(field, operator, cmp[operator] as ComparisonField<DTO, T>),
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/interfaces/filter-field-comparison.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ type BuiltInTypes =
* * all other types use [[CommonFieldComparisonType]]
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export type FilterFieldComparison<FieldType> = FieldType extends string | String
type FilterFieldComparisonType<FieldType, IsKeys extends true | false> = FieldType extends string | String
? StringFieldComparisons // eslint-disable-next-line @typescript-eslint/ban-types
: FieldType extends boolean | Boolean
? BooleanFieldComparisons
Expand All @@ -212,12 +212,24 @@ export type FilterFieldComparison<FieldType> = FieldType extends string | String
: FieldType extends number | Date | RegExp | bigint | BuiltInTypes[] | symbol
? CommonFieldComparisonType<FieldType>
: FieldType extends Array<infer U>
? CommonFieldComparisonType<U> | Filter<U>
? CommonFieldComparisonType<U> | Filter<U> // eslint-disable-next-line @typescript-eslint/ban-types
: IsKeys extends true
? CommonFieldComparisonType<FieldType> & StringFieldComparisons & Filter<FieldType>
: CommonFieldComparisonType<FieldType> | Filter<FieldType>;

/**
* Type for field comparisons.
*
* * `string` - [[StringFieldComparisons]]
* * `boolean|null|undefined|never` - [[BooleanFieldComparisons]]
* * all other types use [[CommonFieldComparisonType]]
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export type FilterFieldComparison<FieldType> = FilterFieldComparisonType<FieldType, false>;

/**
* Type for all comparison operators for a field type.
*
* @typeparam FieldType - The TS type of the field.
*/
export type FilterComparisonOperators<FieldType> = keyof FilterFieldComparison<FieldType>;
export type FilterComparisonOperators<FieldType> = keyof FilterFieldComparisonType<FieldType, true>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
type Query {
test(input: TestComparisonDtoFilter!): Int!
}

input TestComparisonDtoFilter {
and: [TestAllowedComparisonFilter!]
or: [TestAllowedComparisonFilter!]
id: NumberFieldComparison
boolField: TestAllowedComparisonBoolFieldFilterComparison
dateField: TestAllowedComparisonDateFieldFilterComparison
floatField: TestAllowedComparisonFloatFieldFilterComparison
intField: TestAllowedComparisonIntFieldFilterComparison
numberField: TestAllowedComparisonNumberFieldFilterComparison
stringField: TestAllowedComparisonStringFieldFilterComparison
}

input TestAllowedComparisonFilter {
and: [TestAllowedComparisonFilter!]
or: [TestAllowedComparisonFilter!]
id: NumberFieldComparison
boolField: TestAllowedComparisonBoolFieldFilterComparison
dateField: TestAllowedComparisonDateFieldFilterComparison
floatField: TestAllowedComparisonFloatFieldFilterComparison
intField: TestAllowedComparisonIntFieldFilterComparison
numberField: TestAllowedComparisonNumberFieldFilterComparison
stringField: TestAllowedComparisonStringFieldFilterComparison
}

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
}

input NumberFieldComparisonBetween {
lower: Float!
upper: Float!
}

input TestAllowedComparisonBoolFieldFilterComparison {
is: Boolean
}

input TestAllowedComparisonDateFieldFilterComparison {
eq: DateTime
neq: DateTime
}

"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
scalar DateTime

input TestAllowedComparisonFloatFieldFilterComparison {
gt: Float
gte: Float
}

input TestAllowedComparisonIntFieldFilterComparison {
lt: Int
lte: Int
}

input TestAllowedComparisonNumberFieldFilterComparison {
eq: Float
neq: Float
gt: Float
gte: Float
lt: Float
lte: Float
}

input TestAllowedComparisonStringFieldFilterComparison {
like: String
notLike: String
}
3 changes: 3 additions & 0 deletions packages/query-graphql/__tests__/__fixtures__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export const mutationArgsTypeSDL = readGraphql(resolve(__dirname, './mutation-ar
export const relationInputTypeSDL = readGraphql(resolve(__dirname, './relation-input-type.graphql'));
export const relationsInputTypeSDL = readGraphql(resolve(__dirname, './relations-input-type.graphql'));
export const filterInputTypeSDL = readGraphql(resolve(__dirname, './filter-input-type.graphql'));
export const filterAllowedComparisonsInputTypeSDL = readGraphql(
resolve(__dirname, './filter-allowed-comparisons-input-type.graphql'),
);
export const updateFilterInputTypeSDL = readGraphql(resolve(__dirname, './update-filter-input-type.graphql'));
export const deleteFilterInputTypeSDL = readGraphql(resolve(__dirname, './delete-filter-input-type.graphql'));
export const subscriptionFilterInputTypeSDL = readGraphql(
Expand Down
40 changes: 40 additions & 0 deletions packages/query-graphql/__tests__/types/query/filter.type.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
updateFilterInputTypeSDL,
deleteFilterInputTypeSDL,
subscriptionFilterInputTypeSDL,
filterAllowedComparisonsInputTypeSDL,
} from '../../__fixtures__';

describe('filter types', (): void => {
Expand Down Expand Up @@ -170,6 +171,45 @@ describe('filter types', (): void => {
const filterInstance = plainToClass(TestDtoFilter, filterObject);
expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter);
});

describe('allowedComparisons option', () => {
@ObjectType('TestAllowedComparison')
class TestAllowedComparisonsDto extends BaseType {
@FilterableField({ allowedComparisons: ['is'] })
boolField!: boolean;

@FilterableField({ allowedComparisons: ['eq', 'neq'] })
dateField!: Date;

@FilterableField(() => Float, { allowedComparisons: ['gt', 'gte'] })
floatField!: number;

@FilterableField(() => Int, { allowedComparisons: ['lt', 'lte'] })
intField!: number;

@FilterableField({ allowedComparisons: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'] })
numberField!: number;

@FilterableField({ allowedComparisons: ['like', 'notLike'] })
stringField!: string;
}

const TestGraphQLComparisonFilter: Class<Filter<TestDto>> = FilterType(TestAllowedComparisonsDto);
@InputType()
class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {}

it('should only expose allowed comparisons', () => {
@Resolver()
class FilterTypeSpec {
@Query(() => Int)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
test(@Args('input') input: TestComparisonDtoFilter): number {
return 1;
}
}
return expectSDL([FilterTypeSpec], filterAllowedComparisonsInputTypeSDL);
});
});
});

describe('UpdateFilterType', () => {
Expand Down
20 changes: 10 additions & 10 deletions packages/query-graphql/src/decorators/filterable-field.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Class } from '@nestjs-query/core';
import { Class, FilterComparisonOperators } from '@nestjs-query/core';
import { Field, FieldOptions, ReturnTypeFunc } from '@nestjs/graphql';
import { getMetadataStorage } from '../metadata';

/** @internal */
export const filterFieldMetaDataKey = 'filter:field';

export type FilterableFieldOptions = {
allowedComparisons?: FilterComparisonOperators<unknown>[];
} & FieldOptions;
/**
* Decorator for Fields that should be filterable through a [[FilterType]]
*
Expand Down Expand Up @@ -36,17 +36,17 @@ export const filterFieldMetaDataKey = 'filter:field';
* ```
*/
export function FilterableField(): PropertyDecorator & MethodDecorator;
export function FilterableField(options: FieldOptions): PropertyDecorator & MethodDecorator;
export function FilterableField(options: FilterableFieldOptions): PropertyDecorator & MethodDecorator;
export function FilterableField(
returnTypeFunction?: ReturnTypeFunc,
options?: FieldOptions,
options?: FilterableFieldOptions,
): PropertyDecorator & MethodDecorator;
export function FilterableField(
returnTypeFuncOrOptions?: ReturnTypeFunc | FieldOptions,
maybeOptions?: FieldOptions,
export function FilterableField<T>(
returnTypeFuncOrOptions?: ReturnTypeFunc | FilterableFieldOptions,
maybeOptions?: FilterableFieldOptions,
): MethodDecorator | PropertyDecorator {
let returnTypeFunc: ReturnTypeFunc | undefined;
let advancedOptions: FieldOptions | undefined;
let advancedOptions: FilterableFieldOptions | undefined;
if (typeof returnTypeFuncOrOptions === 'function') {
returnTypeFunc = returnTypeFuncOrOptions;
advancedOptions = maybeOptions;
Expand Down
2 changes: 1 addition & 1 deletion packages/query-graphql/src/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { FilterableField } from './filterable-field.decorator';
export { FilterableField, FilterableFieldOptions } from './filterable-field.decorator';
export { ResolverMethodOpts } from './resolver-method.decorator';
export {
Connection,
Expand Down
1 change: 1 addition & 0 deletions packages/query-graphql/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './types';
export {
FilterableField,
FilterableFieldOptions,
ResolverMethodOpts,
Relation,
FilterableRelation,
Expand Down
17 changes: 9 additions & 8 deletions packages/query-graphql/src/metadata/metadata-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@ import { LazyMetadataStorage } from '@nestjs/graphql/dist/schema-builder/storage
import { ObjectTypeMetadata } from '@nestjs/graphql/dist/schema-builder/metadata/object-type.metadata';
import { EnumMetadata } from '@nestjs/graphql/dist/schema-builder/metadata';
import { AggregateResponse, Class, Filter, SortField } from '@nestjs-query/core';
import { ReturnTypeFunc, FieldOptions, TypeMetadataStorage } from '@nestjs/graphql';
import { ReturnTypeFunc, TypeMetadataStorage } from '@nestjs/graphql';
import { ReferencesOpts, RelationsOpts, ResolverRelation, ResolverRelationReference } from '../resolvers/relations';
import { ReferencesKeys } from '../resolvers/relations/relations.interface';
import { EdgeType, StaticConnectionType } from '../types/connection';
import { FilterableFieldOptions } from '../decorators';

/**
* @internal
*/
export interface FilterableFieldDescriptor<T> {
export interface FilterableFieldDescriptor {
propertyName: string;
target: Class<T>;
target: Class<unknown>;
returnTypeFunc?: ReturnTypeFunc;
advancedOptions?: FieldOptions;
advancedOptions?: FilterableFieldOptions;
}

interface RelationDescriptor<Relation> {
Expand All @@ -37,7 +38,7 @@ type ConnectionTypes = 'cursor' | 'array';
* @internal
*/
export class GraphQLQueryMetadataStorage {
private readonly filterableObjectStorage: Map<Class<unknown>, FilterableFieldDescriptor<unknown>[]>;
private readonly filterableObjectStorage: Map<Class<unknown>, FilterableFieldDescriptor[]>;

private readonly filterTypeStorage: Map<string, Class<Filter<unknown>>>;

Expand All @@ -54,7 +55,7 @@ export class GraphQLQueryMetadataStorage {
private readonly aggregateStorage: Map<string, Class<AggregateResponse<unknown>>>;

constructor() {
this.filterableObjectStorage = new Map<Class<unknown>, FilterableFieldDescriptor<unknown>[]>();
this.filterableObjectStorage = new Map<Class<unknown>, FilterableFieldDescriptor[]>();
this.filterTypeStorage = new Map<string, Class<Filter<unknown>>>();
this.sortTypeStorage = new Map<Class<unknown>, Class<SortField<unknown>>>();
this.connectionTypeStorage = new Map<string, StaticConnectionType<unknown>>();
Expand All @@ -64,7 +65,7 @@ export class GraphQLQueryMetadataStorage {
this.aggregateStorage = new Map<string, Class<AggregateResponse<unknown>>>();
}

addFilterableObjectField<T>(type: Class<T>, field: FilterableFieldDescriptor<unknown>): void {
addFilterableObjectField<T>(type: Class<T>, field: FilterableFieldDescriptor): void {
let fields = this.filterableObjectStorage.get(type);
if (!fields) {
fields = [];
Expand All @@ -73,7 +74,7 @@ export class GraphQLQueryMetadataStorage {
fields.push(field);
}

getFilterableObjectFields<T>(type: Class<T>): FilterableFieldDescriptor<unknown>[] | undefined {
getFilterableObjectFields<T>(type: Class<T>): FilterableFieldDescriptor[] | undefined {
const typeFields = this.filterableObjectStorage.get(type) ?? [];
const fieldNames = typeFields.map((t) => t.propertyName);
const baseClass = Object.getPrototypeOf(type) as Class<unknown>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { UnregisteredObjectType } from '../type.errors';

function NumberAggregatedType<DTO>(
name: string,
fields: FilterableFieldDescriptor<unknown>[],
fields: FilterableFieldDescriptor[],
NumberType: GraphQLScalarType,
): Class<NumberAggregate<DTO>> {
const fieldNames = fields.map((f) => f.propertyName);
Expand All @@ -20,7 +20,7 @@ function NumberAggregatedType<DTO>(
return Aggregated;
}

function AggregatedType<DTO>(name: string, fields: FilterableFieldDescriptor<unknown>[]): Class<TypeAggregate<DTO>> {
function AggregatedType<DTO>(name: string, fields: FilterableFieldDescriptor[]): Class<TypeAggregate<DTO>> {
@ObjectType(name)
class Aggregated {}
fields.forEach(({ propertyName, target, returnTypeFunc }) => {
Expand Down
Loading

0 comments on commit ced2792

Please sign in to comment.