Browse Source

feat(server): Implement list filter API

Relates to #4
Michael Bromley 7 years ago
parent
commit
88216d0fe3

+ 8 - 0
server/src/api/customer/customer.api.graphql

@@ -19,6 +19,7 @@ input CustomerListOptions {
     take: Int
     skip: Int
     sort: CustomerSortParameter
+    filter: CustomerFilterParameter
 }
 
 input CustomerSortParameter {
@@ -28,3 +29,10 @@ input CustomerSortParameter {
     phoneNumber: SortOrder
     emailAddress: SortOrder
 }
+
+input CustomerFilterParameter {
+    firstName: StringOperators
+    lastName: StringOperators
+    phoneNumber: StringOperators
+    emailAddress: StringOperators
+}

+ 7 - 0
server/src/api/product/product.api.graphql

@@ -27,6 +27,7 @@ input ProductListOptions {
     take: Int
     skip: Int
     sort: ProductSortParameter
+    filter: ProductFilterParameter
 }
 
 input ProductSortParameter {
@@ -36,3 +37,9 @@ input ProductSortParameter {
     description: SortOrder
     image: SortOrder
 }
+
+input ProductFilterParameter {
+    name: StringOperators
+    slug: StringOperators
+    description: StringOperators
+}

+ 12 - 2
server/src/common/build-list-query.ts

@@ -5,6 +5,7 @@ import { Type } from '../../../shared/shared-types';
 import { VendureEntity } from '../entity/base/base.entity';
 
 import { ListQueryOptions } from './common-types';
+import { parseFilterParams } from './parse-filter-params';
 import { parseSortParams } from './parse-sort-params';
 
 /**
@@ -21,12 +22,21 @@ export function buildListQuery<T extends VendureEntity>(
     if (options.skip !== undefined && options.take === undefined) {
         take = Number.MAX_SAFE_INTEGER;
     }
-    const order = parseSortParams(connection, entity, options.sort);
+    const sort = parseSortParams(connection, entity, options.sort);
+    const filter = parseFilterParams(connection, entity, options.filter);
 
     const qb = connection.createQueryBuilder<T>(entity, entity.name.toLowerCase());
     FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, { relations, take, skip });
     // tslint:disable-next-line:no-non-null-assertion
     FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
 
-    return qb.orderBy(order);
+    filter.forEach(({ clause, parameters }, index) => {
+        if (index === 0) {
+            qb.where(clause, parameters);
+        } else {
+            qb.andWhere(clause, parameters);
+        }
+    });
+
+    return qb.orderBy(sort);
 }

+ 35 - 0
server/src/common/common-types.graphql

@@ -12,4 +12,39 @@ enum SortOrder {
     DESC
 }
 
+input StringOperators {
+    eq: String
+    contains: String
+}
+
+input BooleanOperators {
+    eq: Boolean
+}
+
+input NumberRange {
+    start: Float!
+    end: Float!
+}
+
+input NumberOperators {
+    eq: Float
+    lt: Float
+    lte: Float
+    gt: Float
+    gte: Float
+    between: NumberRange
+}
+
+input DateRange {
+    start: String!
+    end: String!
+}
+
+input DateOperators {
+    eq: String
+    before: String
+    after: String
+    between: DateRange
+}
+
 scalar JSON

+ 51 - 3
server/src/common/common-types.ts

@@ -19,17 +19,65 @@ export interface ListQueryOptions<T extends VendureEntity> {
     take: number;
     skip: number;
     sort: SortParameter<T>;
+    filter: FilterParameter<T>;
 }
 
 export type SortOrder = 'ASC' | 'DESC';
 
+// prettier-ignore
 export type PrimitiveFields<T extends VendureEntity> = {
-    [K in keyof T]: T[K] extends LocaleString | number | string ? K : never
+    [K in keyof T]: T[K] extends LocaleString | number | string | boolean | Date ? K : never
 }[keyof T];
 
-export type SortParameter<T extends VendureEntity> = { [K in PrimitiveFields<T>]?: SortOrder } &
-    CustomFieldSortParameter;
+// prettier-ignore
+export type SortParameter<T extends VendureEntity> = {
+    [K in PrimitiveFields<T>]?: SortOrder
+} & CustomFieldSortParameter;
 
+// prettier-ignore
 export type CustomFieldSortParameter = {
     [customField: string]: SortOrder;
 };
+
+// prettier-ignore
+export type FilterParameter<T extends VendureEntity> = {
+    [K in PrimitiveFields<T>]?: T[K] extends string | LocaleString ? StringOperators
+        : T[K] extends number ? NumberOperators
+            : T[K] extends boolean ? BooleanOperators
+                : T[K] extends Date ? DateOperators : StringOperators;
+};
+
+export interface StringOperators {
+    eq?: string;
+    contains?: string;
+}
+
+export interface BooleanOperators {
+    eq?: boolean;
+}
+
+export interface NumberRange {
+    start: number;
+    end: number;
+}
+
+export interface NumberOperators {
+    eq?: number;
+    lt?: number;
+    lte?: number;
+    gt?: number;
+    gte?: number;
+    between?: NumberRange;
+}
+
+export interface DateRange {
+    start: string;
+    end: string;
+}
+
+export interface DateOperators {
+    eq?: string;
+    before?: string;
+    after?: string;
+    between?: DateRange;
+}

+ 254 - 0
server/src/common/parse-filter-params.spec.ts

@@ -0,0 +1,254 @@
+import { ProductTranslation } from '../entity/product/product-translation.entity';
+import { Product } from '../entity/product/product.entity';
+
+import { FilterParameter } from './common-types';
+import { parseFilterParams } from './parse-filter-params';
+import { MockConnection } from './parse-sort-params.spec';
+
+describe('parseFilterParams()', () => {
+    it('works with no params', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'image' }]);
+
+        const result = parseFilterParams(connection as any, Product, {});
+        expect(result).toEqual([]);
+    });
+
+    it('works with single param', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'name' }]);
+        const filterParams: FilterParameter<Product> = {
+            name: {
+                eq: 'foo',
+            },
+        };
+        const result = parseFilterParams(connection as any, Product, filterParams);
+        expect(result[0].clause).toBe(`product.name = :arg1`);
+        expect(result[0].parameters).toEqual({ arg1: 'foo' });
+    });
+
+    it('works with multiple params', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'name' }]);
+        const filterParams: FilterParameter<Product> = {
+            name: {
+                eq: 'foo',
+            },
+            id: {
+                eq: '123',
+            },
+        };
+        const result = parseFilterParams(connection as any, Product, filterParams);
+        expect(result[0].clause).toBe(`product.name = :arg1`);
+        expect(result[0].parameters).toEqual({ arg1: 'foo' });
+        expect(result[1].clause).toBe(`product.id = :arg1`);
+        expect(result[1].parameters).toEqual({ arg1: '123' });
+    });
+
+    it('works with localized fields', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'image' }]);
+        connection.setRelations(Product, [{ propertyName: 'translations', type: ProductTranslation }]);
+        connection.setColumns(ProductTranslation, [
+            { propertyName: 'id' },
+            { propertyName: 'name' },
+            { propertyName: 'base', relationMetadata: {} as any },
+        ]);
+        const filterParams: FilterParameter<Product> = {
+            name: {
+                eq: 'foo',
+            },
+            id: {
+                eq: '123',
+            },
+        };
+        const result = parseFilterParams(connection as any, Product, filterParams);
+        expect(result[0].clause).toBe(`product_translations.name = :arg1`);
+        expect(result[0].parameters).toEqual({ arg1: 'foo' });
+        expect(result[1].clause).toBe(`product.id = :arg1`);
+        expect(result[1].parameters).toEqual({ arg1: '123' });
+    });
+
+    describe('string operators', () => {
+        describe('eq', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'name', type: String }]);
+            const filterParams: FilterParameter<Product> = {
+                name: {
+                    eq: 'foo',
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.name = :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: 'foo' });
+        });
+
+        describe('contains', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'name', type: String }]);
+            const filterParams: FilterParameter<Product> = {
+                name: {
+                    contains: 'foo',
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.name LIKE :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: '%foo%' });
+        });
+    });
+
+    describe('number operators', () => {
+        describe('eq', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'price', type: Number }]);
+            const filterParams: FilterParameter<Product & { price: number }> = {
+                price: {
+                    eq: 123,
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.price = :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: 123 });
+        });
+
+        describe('lt', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'price', type: Number }]);
+            const filterParams: FilterParameter<Product & { price: number }> = {
+                price: {
+                    lt: 123,
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.price < :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: 123 });
+        });
+
+        describe('lte', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'price', type: Number }]);
+            const filterParams: FilterParameter<Product & { price: number }> = {
+                price: {
+                    lte: 123,
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.price <= :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: 123 });
+        });
+
+        describe('gt', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'price', type: Number }]);
+            const filterParams: FilterParameter<Product & { price: number }> = {
+                price: {
+                    gt: 123,
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.price > :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: 123 });
+        });
+
+        describe('gte', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'price', type: Number }]);
+            const filterParams: FilterParameter<Product & { price: number }> = {
+                price: {
+                    gte: 123,
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.price >= :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: 123 });
+        });
+
+        describe('between', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'price', type: Number }]);
+            const filterParams: FilterParameter<Product & { price: number }> = {
+                price: {
+                    between: {
+                        start: 10,
+                        end: 50,
+                    },
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.price BETWEEN :arg1 AND :arg2`);
+            expect(result[0].parameters).toEqual({ arg1: 10, arg2: 50 });
+        });
+    });
+
+    describe('date operators', () => {
+        describe('eq', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'createdAt', type: 'datetime' }]);
+            const filterParams: FilterParameter<Product> = {
+                createdAt: {
+                    eq: '2018-01-01',
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.createdAt = :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: '2018-01-01' });
+        });
+
+        describe('before', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'createdAt', type: 'datetime' }]);
+            const filterParams: FilterParameter<Product> = {
+                createdAt: {
+                    before: '2018-01-01',
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.createdAt < :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: '2018-01-01' });
+        });
+
+        describe('after', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'createdAt', type: 'datetime' }]);
+            const filterParams: FilterParameter<Product> = {
+                createdAt: {
+                    after: '2018-01-01',
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.createdAt > :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: '2018-01-01' });
+        });
+
+        describe('between', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'createdAt', type: 'datetime' }]);
+            const filterParams: FilterParameter<Product> = {
+                createdAt: {
+                    between: {
+                        start: '2018-01-01',
+                        end: '2018-02-01',
+                    },
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.createdAt BETWEEN :arg1 AND :arg2`);
+            expect(result[0].parameters).toEqual({ arg1: '2018-01-01', arg2: '2018-02-01' });
+        });
+    });
+
+    describe('boolean operators', () => {
+        describe('eq', () => {
+            const connection = new MockConnection();
+            connection.setColumns(Product, [{ propertyName: 'available', type: 'tinyint' }]);
+            const filterParams: FilterParameter<Product & { available: boolean }> = {
+                available: {
+                    eq: true,
+                },
+            };
+            const result = parseFilterParams(connection as any, Product, filterParams);
+            expect(result[0].clause).toBe(`product.available = :arg1`);
+            expect(result[0].parameters).toEqual({ arg1: true });
+        });
+    });
+});

+ 114 - 0
server/src/common/parse-filter-params.ts

@@ -0,0 +1,114 @@
+import { Connection } from 'typeorm';
+import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
+
+import { Type } from '../../../shared/shared-types';
+import { assertNever } from '../../../shared/shared-utils';
+import { VendureEntity } from '../entity/base/base.entity';
+import { I18nError } from '../i18n/i18n-error';
+
+import {
+    BooleanOperators,
+    DateOperators,
+    FilterParameter,
+    NumberOperators,
+    StringOperators,
+} from './common-types';
+
+export interface WhereCondition {
+    clause: string;
+    parameters: { [param: string]: string | number };
+}
+
+type AllOperators = StringOperators & BooleanOperators & NumberOperators & DateOperators;
+type Operator = { [K in keyof AllOperators]-?: K }[keyof AllOperators];
+
+export function parseFilterParams<T extends VendureEntity>(
+    connection: Connection,
+    entity: Type<T>,
+    filterParams: FilterParameter<T> | undefined,
+): WhereCondition[] {
+    if (!filterParams) {
+        return [];
+    }
+
+    const metadata = connection.getMetadata(entity);
+    const columns = metadata.columns;
+    let translationColumns: ColumnMetadata[] = [];
+    const relations = metadata.relations;
+
+    const translationRelation = relations.find(r => r.propertyName === 'translations');
+    if (translationRelation) {
+        const translationMetadata = connection.getMetadata(translationRelation.type);
+        translationColumns = columns.concat(translationMetadata.columns.filter(c => !c.relationMetadata));
+    }
+
+    const output: WhereCondition[] = [];
+    const alias = metadata.name.toLowerCase();
+
+    for (const [key, operation] of Object.entries(filterParams)) {
+        if (operation) {
+            for (const [operator, operand] of Object.entries(operation)) {
+                let fieldName: string;
+                if (columns.find(c => c.propertyName === key)) {
+                    fieldName = `${alias}.${key}`;
+                } else if (translationColumns.find(c => c.propertyName === key)) {
+                    fieldName = `${alias}_translations.${key}`;
+                } else {
+                    throw new I18nError('error.invalid-filter-field');
+                }
+                const condition = buildWhereCondition(fieldName, operator as Operator, operand);
+                output.push(condition);
+            }
+        }
+    }
+
+    return output;
+}
+
+function buildWhereCondition(fieldName: string, operator: Operator, operand: any): WhereCondition {
+    switch (operator) {
+        case 'eq':
+            return {
+                clause: `${fieldName} = :arg1`,
+                parameters: { arg1: operand },
+            };
+        case 'contains':
+            return {
+                clause: `${fieldName} LIKE :arg1`,
+                parameters: { arg1: `%${operand}%` },
+            };
+        case 'lt':
+        case 'before':
+            return {
+                clause: `${fieldName} < :arg1`,
+                parameters: { arg1: operand },
+            };
+        case 'gt':
+        case 'after':
+            return {
+                clause: `${fieldName} > :arg1`,
+                parameters: { arg1: operand },
+            };
+        case 'lte':
+            return {
+                clause: `${fieldName} <= :arg1`,
+                parameters: { arg1: operand },
+            };
+        case 'gte':
+            return {
+                clause: `${fieldName} >= :arg1`,
+                parameters: { arg1: operand },
+            };
+        case 'between':
+            return {
+                clause: `${fieldName} BETWEEN :arg1 AND :arg2`,
+                parameters: { arg1: operand.start, arg2: operand.end },
+            };
+        default:
+            assertNever(operator);
+    }
+    return {
+        clause: '1',
+        parameters: {},
+    };
+}

+ 1 - 5
server/src/common/parse-sort-params.spec.ts

@@ -132,11 +132,7 @@ describe('parseSortParams()', () => {
     });
 });
 
-type PropertiesMap = {
-    [name: string]: string | any;
-};
-
-class MockConnection {
+export class MockConnection {
     private columnsMap = new Map<Type<any>, Array<Partial<ColumnMetadata>>>();
     private relationsMap = new Map<Type<any>, Array<Partial<RelationMetadata>>>();
     setColumns(entity: Type<any>, value: Array<Partial<ColumnMetadata>>) {

+ 1 - 1
server/src/common/parse-sort-params.ts

@@ -35,9 +35,9 @@ export function parseSortParams<T extends VendureEntity>(
     }
 
     const output = {};
+    const alias = metadata.name.toLowerCase();
 
     for (const [key, order] of Object.entries(sortParams)) {
-        const alias = metadata.name.toLowerCase();
         if (columns.find(c => c.propertyName === key)) {
             output[`${alias}.${key}`] = order;
         } else if (translationColumns.find(c => c.propertyName === key)) {

+ 41 - 0
server/src/entity/__snapshots__/graphql-custom-fields.spec.ts.snap

@@ -14,6 +14,47 @@ type ProductCustomFields {
 "
 `;
 
+exports[`addGraphQLCustomFields() extends a type with FilterParameters 1`] = `
+"input BooleanOperators {
+  eq: Boolean
+}
+
+input DateOperators {
+  eq: String
+}
+
+scalar JSON
+
+input NumberOperators {
+  eq: Float
+}
+
+type Product {
+  name: String
+  customFields: ProductCustomFields
+}
+
+type ProductCustomFields {
+  available: Boolean
+  shortName: String
+  rating: Float
+  published: String
+}
+
+input ProductFilterParameter {
+  id: StringOperators
+  available: BooleanOperators
+  shortName: StringOperators
+  rating: NumberOperators
+  published: DateOperators
+}
+
+input StringOperators {
+  eq: String
+}
+"
+`;
+
 exports[`addGraphQLCustomFields() extends a type with SortParameters 1`] = `
 "scalar JSON
 

+ 2 - 2
server/src/entity/base/base.entity.ts

@@ -19,7 +19,7 @@ export abstract class VendureEntity {
 
     @PrimaryGeneratedColumn(primaryKeyType) id: ID;
 
-    @CreateDateColumn() createdAt: string;
+    @CreateDateColumn() createdAt: Date;
 
-    @UpdateDateColumn() updatedAt: string;
+    @UpdateDateColumn() updatedAt: Date;
 }

+ 39 - 0
server/src/entity/graphql-custom-fields.spec.ts

@@ -127,4 +127,43 @@ describe('addGraphQLCustomFields()', () => {
         const result = addGraphQLCustomFields(input, customFieldConfig);
         expect(result).toMatchSnapshot();
     });
+
+    it('extends a type with FilterParameters', () => {
+        const input = `
+                    type Product {
+                        name: String
+                    }
+
+                    input ProductFilterParameter {
+                        id: StringOperators
+                    }
+
+                    input StringOperators {
+                        eq: String
+                    }
+
+                    input NumberOperators {
+                        eq: Float
+                    }
+
+                    input DateOperators {
+                        eq: String
+                    }
+
+                    input BooleanOperators {
+                        eq: Boolean
+                    }
+
+                `;
+        const customFieldConfig: CustomFields = {
+            Product: [
+                { name: 'available', type: 'boolean' },
+                { name: 'shortName', type: 'localeString' },
+                { name: 'rating', type: 'float' },
+                { name: 'published', type: 'datetime' },
+            ],
+        };
+        const result = addGraphQLCustomFields(input, customFieldConfig);
+        expect(result).toMatchSnapshot();
+    });
 });

+ 33 - 11
server/src/entity/graphql-custom-fields.ts

@@ -29,7 +29,7 @@ export function addGraphQLCustomFields(typeDefs: string, customFieldConfig: Cust
             if (customEntityFields.length) {
                 customFieldTypeDefs += `
                     type ${entityName}CustomFields {
-                        ${mapToFields(customEntityFields)}
+                        ${mapToFields(customEntityFields, getGraphQlType)}
                     }
 
                     extend type ${entityName} {
@@ -48,7 +48,7 @@ export function addGraphQLCustomFields(typeDefs: string, customFieldConfig: Cust
         if (localeStringFields.length && schema.getType(`${entityName}Translation`)) {
             customFieldTypeDefs += `
                     type ${entityName}TranslationCustomFields {
-                         ${mapToFields(localeStringFields)}
+                         ${mapToFields(localeStringFields, getGraphQlType)}
                     }
 
                     extend type ${entityName}Translation {
@@ -61,7 +61,7 @@ export function addGraphQLCustomFields(typeDefs: string, customFieldConfig: Cust
             if (nonLocaleStringFields.length) {
                 customFieldTypeDefs += `
                     input Create${entityName}CustomFieldsInput {
-                       ${mapToFields(nonLocaleStringFields)}
+                       ${mapToFields(nonLocaleStringFields, getGraphQlType)}
                     }
 
                     extend input Create${entityName}Input {
@@ -81,7 +81,7 @@ export function addGraphQLCustomFields(typeDefs: string, customFieldConfig: Cust
             if (nonLocaleStringFields.length) {
                 customFieldTypeDefs += `
                     input Update${entityName}CustomFieldsInput {
-                       ${mapToFields(nonLocaleStringFields)}
+                       ${mapToFields(nonLocaleStringFields, getGraphQlType)}
                     }
 
                     extend input Update${entityName}Input {
@@ -105,11 +105,19 @@ export function addGraphQLCustomFields(typeDefs: string, customFieldConfig: Cust
                 `;
         }
 
+        if (customEntityFields.length && schema.getType(`${entityName}FilterParameter`)) {
+            customFieldTypeDefs += `
+                    extend input ${entityName}FilterParameter {
+                         ${mapToFields(customEntityFields, getFilterOperator)}
+                    }
+                `;
+        }
+
         if (localeStringFields && schema.getType(`${entityName}TranslationInput`)) {
             if (localeStringFields.length) {
                 customFieldTypeDefs += `
                     input ${entityName}TranslationCustomFieldsInput {
-                        ${mapToFields(localeStringFields)}
+                        ${mapToFields(localeStringFields, getGraphQlType)}
                     }
 
                     extend input ${entityName}TranslationInput {
@@ -134,12 +142,26 @@ type GraphQLFieldType = 'String' | 'Int' | 'Float' | 'Boolean' | 'ID';
 /**
  * Maps an array of CustomFieldConfig objects into a string of SDL fields.
  */
-function mapToFields(
-    fieldDefs: CustomFieldConfig[],
-    typeFn?: (fieldType: CustomFieldType) => string,
-): string {
-    const getType = typeFn || getGraphQlType;
-    return fieldDefs.map(field => `${field.name}: ${getType(field.type)}`).join('\n');
+function mapToFields(fieldDefs: CustomFieldConfig[], typeFn: (fieldType: CustomFieldType) => string): string {
+    return fieldDefs.map(field => `${field.name}: ${typeFn(field.type)}`).join('\n');
+}
+
+function getFilterOperator(type: CustomFieldType): string {
+    switch (type) {
+        case 'datetime':
+            return 'DateOperators';
+        case 'string':
+        case 'localeString':
+            return 'StringOperators';
+        case 'boolean':
+            return 'BooleanOperators';
+        case 'int':
+        case 'float':
+            return 'NumberOperators';
+        default:
+            assertNever(type);
+    }
+    return 'String';
 }
 
 function getGraphQlType(type: CustomFieldType): GraphQLFieldType {