Преглед изворни кода

feat(server): Create function to auto-generate list options inputs

Michael Bromley пре 7 година
родитељ
комит
c095bdd017

+ 8 - 1
server/package.json

@@ -6,7 +6,13 @@
     "type": "git",
     "url": "https://github.com/vendure-ecommerce/vendure/"
   },
-  "keywords": ["vendure", "ecommerce", "headless", "graphql", "typescript"],
+  "keywords": [
+    "vendure",
+    "ecommerce",
+    "headless",
+    "graphql",
+    "typescript"
+  ],
   "readme": "README.md",
   "private": false,
   "license": "MIT",
@@ -79,6 +85,7 @@
     "@types/express": "^4.0.39",
     "@types/faker": "^4.1.4",
     "@types/fs-extra": "^5.0.4",
+    "@types/graphql": "^14.0.7",
     "@types/gulp": "^4.0.5",
     "@types/handlebars": "^4.0.40",
     "@types/http-proxy-middleware": "^0.19.2",

+ 255 - 0
server/src/api/config/generate-list-options.spec.ts

@@ -0,0 +1,255 @@
+import { buildSchema, printType } from 'graphql';
+
+import { CustomFields } from '../../../../shared/shared-types';
+
+import { generateListOptions } from './generate-list-options';
+// tslint:disable:no-non-null-assertion
+
+describe('generateListOptions()', () => {
+    const COMMON_TYPES = `
+    scalar JSON
+    scalar DateTime
+
+    interface PaginatedList {
+        items: [Node!]!
+        totalItems: Int!
+    }
+
+    interface Node {
+        id: ID!
+    }
+
+    enum SortOrder {
+        ASC
+        DESC
+    }
+
+    input StringOperators { dummy: String }
+
+    input BooleanOperators { dummy: String }
+
+    input NumberRange { dummy: String }
+
+    input NumberOperators { dummy: String }
+
+    input DateRange { dummy: String }
+
+    input DateOperators { dummy: String }
+
+    type PersonList implements PaginatedList {
+        items: [Person!]!
+        totalItems: Int!
+    }
+    `;
+
+    const removeLeadingWhitespace = s => {
+        const indent = s.match(/^\s+/m)[0].replace(/\n/, '');
+        return s.replace(new RegExp(`^${indent}`, 'gm'), '').trim();
+    };
+
+    it('creates the required input types', () => {
+        const input = `
+                ${COMMON_TYPES}
+               type Query {
+                   people(options: PersonListOptions): PersonList
+               }
+
+               type Person {
+                   name: String!
+                   age: Int!
+               }
+
+               # Generated at runtime
+               input PersonListOptions
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonListOptions')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonListOptions {
+                     skip: Int
+                     take: Int
+                     sort: PersonSortParameter
+                     filter: PersonFilterParameter
+                   }`),
+        );
+
+        expect(printType(result.getType('PersonSortParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonSortParameter {
+                     name: SortOrder
+                     age: SortOrder
+                   }`),
+        );
+
+        expect(printType(result.getType('PersonFilterParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonFilterParameter {
+                     name: StringOperators
+                     age: NumberOperators
+                   }`),
+        );
+    });
+
+    it('works with a non-nullabel list type', () => {
+        const input = `
+                ${COMMON_TYPES}
+               type Query {
+                   people: PersonList!
+               }
+
+               type Person {
+                   name: String!
+                   age: Int!
+               }
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(result.getType('PersonListOptions')).toBeTruthy();
+    });
+
+    it('uses the correct filter operators', () => {
+        const input = `
+                ${COMMON_TYPES}
+               type Query {
+                   people(options: PersonListOptions): PersonList
+               }
+
+               type Person {
+                   name: String!
+                   age: Int!
+                   updatedAt: DateTime!
+                   admin: Boolean!
+                   score: Float
+                   personType: PersonType!
+               }
+
+               enum PersonType {
+                   TABS
+                   SPACES
+               }
+
+               # Generated at runtime
+               input PersonListOptions
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonFilterParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonFilterParameter {
+                     name: StringOperators
+                     age: NumberOperators
+                     updatedAt: DateOperators
+                     admin: BooleanOperators
+                     score: NumberOperators
+                     personType: StringOperators
+                   }`),
+        );
+    });
+
+    it('creates the ListOptions interface and argument if not defined', () => {
+        const input = `
+               ${COMMON_TYPES}
+               type Query {
+                   people: PersonList
+               }
+
+               type Person {
+                   name: String!
+               }
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonListOptions')!)).toBe(
+            removeLeadingWhitespace(`
+                    input PersonListOptions {
+                      skip: Int
+                      take: Int
+                      sort: PersonSortParameter
+                      filter: PersonFilterParameter
+                    }`),
+        );
+
+        const args = result.getQueryType()!.getFields().people.args;
+        expect(args.length).toBe(1);
+        expect(args[0].name).toBe('options');
+        expect(args[0].type.toString()).toBe('PersonListOptions');
+    });
+
+    it('extends the ListOptions interface if already defined', () => {
+        const input = `
+               ${COMMON_TYPES}
+               type Query {
+                   people(options: PersonListOptions): PersonList
+               }
+
+               type Person {
+                   name: String!
+               }
+
+               input PersonListOptions {
+                   categoryId: ID
+               }
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonListOptions')!)).toBe(
+            removeLeadingWhitespace(`
+                    input PersonListOptions {
+                      skip: Int
+                      take: Int
+                      sort: PersonSortParameter
+                      filter: PersonFilterParameter
+                      categoryId: ID
+                    }`),
+        );
+
+        const args = result.getQueryType()!.getFields().people.args;
+        expect(args.length).toBe(1);
+        expect(args[0].name).toBe('options');
+        expect(args[0].type.toString()).toBe('PersonListOptions');
+    });
+
+    it('ignores properties with types which cannot be sorted or filtered', () => {
+        const input = `
+               ${COMMON_TYPES}
+               type Query {
+                   people: PersonList
+               }
+
+               type Person {
+                   id: ID!
+                   name: String!
+                   vitals: [Int]
+                   meta: JSON
+                   user: User!
+               }
+
+               type User {
+                   identifier: String!
+               }
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonSortParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonSortParameter {
+                     id: SortOrder
+                     name: SortOrder
+                   }`),
+        );
+
+        expect(printType(result.getType('PersonFilterParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonFilterParameter {
+                     name: StringOperators
+                   }`),
+        );
+    });
+});

+ 194 - 0
server/src/api/config/generate-list-options.ts

@@ -0,0 +1,194 @@
+import {
+    buildSchema,
+    extendSchema,
+    GraphQLEnumType,
+    GraphQLField,
+    GraphQLInputFieldConfig,
+    GraphQLInputFieldConfigMap,
+    GraphQLInputObjectType,
+    GraphQLInputType,
+    GraphQLInt,
+    GraphQLNamedType,
+    GraphQLObjectType,
+    GraphQLOutputType,
+    GraphQLSchema,
+    isEnumType,
+    isListType,
+    isNonNullType,
+    isObjectType,
+} from 'graphql';
+import { mergeSchemas } from 'graphql-tools';
+
+/**
+ * Generates ListOptions inputs for queries which return PaginatedList types.
+ */
+export function generateListOptions(typeDefsOrSchema: string | GraphQLSchema): GraphQLSchema {
+    const schema = typeof typeDefsOrSchema === 'string' ? buildSchema(typeDefsOrSchema) : typeDefsOrSchema;
+    const queryType = schema.getQueryType();
+    if (!queryType) {
+        return schema;
+    }
+    const queries = queryType.getFields();
+    const { SortOrder, StringOperators, BooleanOperators, NumberOperators, DateOperators } = getCommonTypes(
+        schema,
+    );
+    const generatedTypes: GraphQLNamedType[] = [];
+
+    for (const query of Object.values(queries)) {
+        const type = isNonNullType(query.type) ? query.type.ofType : query.type;
+        const isListQuery =
+            isObjectType(type) && !!type.getInterfaces().find(i => i.name === 'PaginatedList');
+
+        if (isListQuery) {
+            const targetTypeName = type.toString().replace(/List$/, '');
+            const targetType = schema.getType(targetTypeName);
+            if (targetType && isObjectType(targetType)) {
+                const sortParameter = createSortParameter(schema, targetType);
+                const filterParameter = createFilterParameter(schema, targetType);
+                const existingListOptions = schema.getType(
+                    `${targetTypeName}ListOptions`,
+                ) as GraphQLInputObjectType | null;
+                const generatedListOptions = new GraphQLInputObjectType({
+                    name: `${targetTypeName}ListOptions`,
+                    fields: {
+                        skip: { type: GraphQLInt },
+                        take: { type: GraphQLInt },
+                        sort: { type: sortParameter },
+                        filter: { type: filterParameter },
+                        ...(existingListOptions ? existingListOptions.getFields() : {}),
+                    },
+                });
+                let listOptionsInput: GraphQLInputObjectType;
+                if (existingListOptions) {
+                    generatedTypes.push(existingListOptions);
+                }
+
+                listOptionsInput = generatedListOptions;
+
+                if (!query.args.find(a => a.type.toString() === `${targetTypeName}ListOptions`)) {
+                    query.args.push({
+                        name: 'options',
+                        type: listOptionsInput,
+                    });
+                }
+
+                generatedTypes.push(filterParameter);
+                generatedTypes.push(sortParameter);
+                generatedTypes.push(listOptionsInput);
+            }
+        }
+    }
+    return mergeSchemas({ schemas: [schema, generatedTypes] });
+}
+
+function createSortParameter(schema: GraphQLSchema, targetType: GraphQLObjectType) {
+    const fields = Object.values(targetType.getFields());
+    const targetTypeName = targetType.name;
+    const SortOrder = schema.getType('SortOrder') as GraphQLEnumType;
+
+    const sortableTypes = ['ID', 'String', 'Int', 'Float', 'DateTime'];
+    return new GraphQLInputObjectType({
+        name: `${targetTypeName}SortParameter`,
+        fields: fields
+            .filter(field => sortableTypes.includes(unwrapNonNullType(field.type).name))
+            .reduce(
+                (result, field) => {
+                    const fieldConfig: GraphQLInputFieldConfig = {
+                        type: SortOrder,
+                    };
+                    return {
+                        ...result,
+                        [field.name]: fieldConfig,
+                    };
+                },
+                {} as GraphQLInputFieldConfigMap,
+            ),
+    });
+}
+
+function createFilterParameter(schema: GraphQLSchema, targetType: GraphQLObjectType): GraphQLInputObjectType {
+    const fields = Object.values(targetType.getFields());
+    const targetTypeName = targetType.name;
+    const { StringOperators, BooleanOperators, NumberOperators, DateOperators } = getCommonTypes(schema);
+
+    return new GraphQLInputObjectType({
+        name: `${targetTypeName}FilterParameter`,
+        fields: fields.reduce(
+            (result, field) => {
+                const filterType = getFilterType(field);
+                if (!filterType) {
+                    return result;
+                }
+                const fieldConfig: GraphQLInputFieldConfig = {
+                    type: filterType,
+                };
+                return {
+                    ...result,
+                    [field.name]: fieldConfig,
+                };
+            },
+            {} as GraphQLInputFieldConfigMap,
+        ),
+    });
+
+    function getFilterType(field: GraphQLField<any, any>): GraphQLInputType | undefined {
+        if (isListType(field.type)) {
+            return;
+        }
+        const innerType = unwrapNonNullType(field.type);
+        if (isEnumType(innerType)) {
+            return StringOperators;
+        }
+        switch (innerType.name) {
+            case 'String':
+                return StringOperators;
+            case 'Boolean':
+                return BooleanOperators;
+            case 'Int':
+            case 'Float':
+                return NumberOperators;
+            case 'DateTime':
+                return DateOperators;
+            default:
+                return;
+        }
+    }
+}
+
+function getCommonTypes(schema: GraphQLSchema) {
+    const SortOrder = schema.getType('SortOrder') as GraphQLEnumType | null;
+    const StringOperators = schema.getType('StringOperators') as GraphQLInputType | null;
+    const BooleanOperators = schema.getType('BooleanOperators') as GraphQLInputType | null;
+    const NumberRange = schema.getType('NumberRange') as GraphQLInputType | null;
+    const NumberOperators = schema.getType('NumberOperators') as GraphQLInputType | null;
+    const DateRange = schema.getType('DateRange') as GraphQLInputType | null;
+    const DateOperators = schema.getType('DateOperators') as GraphQLInputType | null;
+    if (
+        !SortOrder ||
+        !StringOperators ||
+        !BooleanOperators ||
+        !NumberRange ||
+        !NumberOperators ||
+        !DateRange ||
+        !DateOperators
+    ) {
+        throw new Error(`A common type was not defined`);
+    }
+    return {
+        SortOrder,
+        StringOperators,
+        BooleanOperators,
+        NumberOperators,
+        DateOperators,
+    };
+}
+
+/**
+ * Unwraps the inner type if it is inside a non-nullable type
+ */
+function unwrapNonNullType(type: GraphQLOutputType): GraphQLNamedType {
+    if (isNonNullType(type)) {
+        return type.ofType;
+    }
+    return type;
+}

+ 5 - 0
server/yarn.lock

@@ -270,6 +270,11 @@
   version "0.12.6"
   resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-0.12.6.tgz#3d619198585fcabe5f4e1adfb5cf5f3388c66c13"
 
+"@types/graphql@^14.0.7":
+  version "14.0.7"
+  resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-14.0.7.tgz#daa09397220a68ce1cbb3f76a315ff3cd92312f6"
+  integrity sha512-BoLDjdvLQsXPZLJux3lEZANwGr3Xag56Ngy0U3y8uoRSDdeLcn43H3oBcgZlnd++iOQElBpaRVDHPzEDekyvXQ==
+
 "@types/gulp@^4.0.5":
   version "4.0.5"
   resolved "https://registry.yarnpkg.com/@types/gulp/-/gulp-4.0.5.tgz#f5f498d5bf9538364792de22490a12c0e6bc5eb4"