Просмотр исходного кода

feat(core): Allow ListQuery sort/filter inputs to be manually extended

Relates to #572
Michael Bromley 4 лет назад
Родитель
Сommit
834ea2d842

+ 31 - 8
packages/core/src/api/config/generate-list-options.ts

@@ -1,8 +1,10 @@
 import { stitchSchemas } from '@graphql-tools/stitch';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import {
     buildSchema,
     GraphQLEnumType,
     GraphQLField,
+    GraphQLInputField,
     GraphQLInputFieldConfig,
     GraphQLInputFieldConfigMap,
     GraphQLInputObjectType,
@@ -13,6 +15,7 @@ import {
     GraphQLOutputType,
     GraphQLSchema,
     isEnumType,
+    isInputObjectType,
     isListType,
     isNonNullType,
     isObjectType,
@@ -80,15 +83,28 @@ function isListQueryType(type: GraphQLOutputType): type is GraphQLObjectType {
 }
 
 function createSortParameter(schema: GraphQLSchema, targetType: GraphQLObjectType) {
-    const fields = Object.values(targetType.getFields());
+    const fields: Array<GraphQLField<any, any> | GraphQLInputField> = Object.values(targetType.getFields());
     const targetTypeName = targetType.name;
     const SortOrder = schema.getType('SortOrder') as GraphQLEnumType;
 
+    const inputName = `${targetTypeName}SortParameter`;
+    const existingInput = schema.getType(inputName);
+    if (isInputObjectType(existingInput)) {
+        fields.push(...Object.values(existingInput.getFields()));
+    }
+
     const sortableTypes = ['ID', 'String', 'Int', 'Float', 'DateTime'];
     return new GraphQLInputObjectType({
-        name: `${targetTypeName}SortParameter`,
+        name: inputName,
         fields: fields
-            .filter(field => sortableTypes.includes(unwrapNonNullType(field.type).name))
+            .map(field => {
+                if (unwrapNonNullType(field.type) === SortOrder) {
+                    return field;
+                } else {
+                    return sortableTypes.includes(unwrapNonNullType(field.type).name) ? field : undefined;
+                }
+            })
+            .filter(notNullOrUndefined)
             .reduce((result, field) => {
                 const fieldConfig: GraphQLInputFieldConfig = {
                     type: SortOrder,
@@ -102,14 +118,21 @@ function createSortParameter(schema: GraphQLSchema, targetType: GraphQLObjectTyp
 }
 
 function createFilterParameter(schema: GraphQLSchema, targetType: GraphQLObjectType): GraphQLInputObjectType {
-    const fields = Object.values(targetType.getFields());
+    const fields: Array<GraphQLField<any, any> | GraphQLInputField> = Object.values(targetType.getFields());
     const targetTypeName = targetType.name;
     const { StringOperators, BooleanOperators, NumberOperators, DateOperators } = getCommonTypes(schema);
 
+    const inputName = `${targetTypeName}FilterParameter`;
+    const existingInput = schema.getType(inputName);
+    if (isInputObjectType(existingInput)) {
+        fields.push(...Object.values(existingInput.getFields()));
+    }
+
     return new GraphQLInputObjectType({
-        name: `${targetTypeName}FilterParameter`,
+        name: inputName,
         fields: fields.reduce((result, field) => {
-            const filterType = getFilterType(field);
+            const fieldType = field.type;
+            const filterType = isInputObjectType(fieldType) ? fieldType : getFilterType(field);
             if (!filterType) {
                 return result;
             }
@@ -123,7 +146,7 @@ function createFilterParameter(schema: GraphQLSchema, targetType: GraphQLObjectT
         }, {} as GraphQLInputFieldConfigMap),
     });
 
-    function getFilterType(field: GraphQLField<any, any>): GraphQLInputType | undefined {
+    function getFilterType(field: GraphQLField<any, any> | GraphQLInputField): GraphQLInputType | undefined {
         if (isListType(field.type)) {
             return;
         }
@@ -178,7 +201,7 @@ function getCommonTypes(schema: GraphQLSchema) {
 /**
  * Unwraps the inner type if it is inside a non-nullable type
  */
-function unwrapNonNullType(type: GraphQLOutputType): GraphQLNamedType {
+function unwrapNonNullType(type: GraphQLOutputType | GraphQLInputType): GraphQLNamedType {
     if (isNonNullType(type)) {
         return type.ofType;
     }

+ 83 - 17
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -6,10 +6,12 @@ import { BetterSqlite3Driver } from 'typeorm/driver/better-sqlite3/BetterSqlite3
 import { SqljsDriver } from 'typeorm/driver/sqljs/SqljsDriver';
 import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
 
+import { ApiType } from '../../../api/common/get-api-type';
 import { RequestContext } from '../../../api/common/request-context';
 import { UserInputError } from '../../../common/error/errors';
 import { ListQueryOptions } from '../../../common/types/common-types';
 import { ConfigService } from '../../../config/config.service';
+import { Logger } from '../../../config/logger/vendure-logger';
 import { VendureEntity } from '../../../entity/base/base.entity';
 import { TransactionalConnection } from '../../transaction/transactional-connection';
 
@@ -29,6 +31,27 @@ export type ExtendedListQueryOptions<T extends VendureEntity> = {
      * executed as part of any outer transaction.
      */
     ctx?: RequestContext;
+    /**
+     * One of the main tasks of the ListQueryBuilder is to auto-generate filter and sort queries based on the
+     * available columns of a given entity. However, it may also be sometimes desirable to allow filter/sort
+     * on a property of a relation. In this case, the `customPropertyMap` can be used to define a property
+     * of the `options.sort` or `options.filter` which does not correspond to a direct column of the current
+     * entity, and then provide a mapping to the related property to be sorted/filtered.
+     *
+     * Example: we want to allow sort/filter by and Order's `customerLastName`. The actual lastName property is
+     * not a column in the Order table, it exists on the Customer entity, and Order has a relation to Customer via
+     * `Order.customer`. Therefore we can define a customPropertyMap like this:
+     *
+     * ```ts
+     * const qb = this.listQueryBuilder.build(Order, options, {
+     *   relations: ['customer'],
+     *   customPropertyMap: {
+     *       customerLastName: 'customer.lastName',
+     *   },
+     * };
+     * ```
+     */
+    customPropertyMap?: { [name: string]: string };
 };
 
 @Injectable()
@@ -48,24 +71,8 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         extendedOptions: ExtendedListQueryOptions<T> = {},
     ): SelectQueryBuilder<T> {
         const apiType = extendedOptions.ctx?.apiType ?? 'shop';
-        const { shopListQueryLimit, adminListQueryLimit } = this.configService.apiOptions;
-        const takeLimit = apiType === 'admin' ? adminListQueryLimit : shopListQueryLimit;
-        if (options.take && options.take > takeLimit) {
-            throw new UserInputError('error.list-query-limit-exceeded', { limit: takeLimit });
-        }
         const rawConnection = this.connection.rawConnection;
-        const skip = Math.max(options.skip ?? 0, 0);
-        // `take` must not be negative, and must not be greater than takeLimit
-        let take = Math.min(Math.max(options.take ?? 0, 0), takeLimit) || takeLimit;
-        if (options.skip !== undefined && options.take === undefined) {
-            take = takeLimit;
-        }
-        const sort = parseSortParams(
-            rawConnection,
-            entity,
-            Object.assign({}, options.sort, extendedOptions.orderBy),
-        );
-        const filter = parseFilterParams(rawConnection, entity, options.filter);
+        const { take, skip } = this.parseTakeSkipParams(apiType, options);
 
         const repo = extendedOptions.ctx
             ? this.connection.getRepository(extendedOptions.ctx, entity)
@@ -85,6 +92,18 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         // join the tables required by calculated columns
         this.joinCalculatedColumnRelations(qb, entity, options);
 
+        const { customPropertyMap } = extendedOptions;
+        if (customPropertyMap) {
+            this.normalizeCustomPropertyMap(customPropertyMap, qb);
+        }
+        const sort = parseSortParams(
+            rawConnection,
+            entity,
+            Object.assign({}, options.sort, extendedOptions.orderBy),
+            customPropertyMap,
+        );
+        const filter = parseFilterParams(rawConnection, entity, options.filter, customPropertyMap);
+
         filter.forEach(({ clause, parameters }) => {
             qb.andWhere(clause, parameters);
         });
@@ -100,6 +119,53 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         return qb;
     }
 
+    private parseTakeSkipParams(
+        apiType: ApiType,
+        options: ListQueryOptions<any>,
+    ): { take: number; skip: number } {
+        const { shopListQueryLimit, adminListQueryLimit } = this.configService.apiOptions;
+        const takeLimit = apiType === 'admin' ? adminListQueryLimit : shopListQueryLimit;
+        if (options.take && options.take > takeLimit) {
+            throw new UserInputError('error.list-query-limit-exceeded', { limit: takeLimit });
+        }
+        const rawConnection = this.connection.rawConnection;
+        const skip = Math.max(options.skip ?? 0, 0);
+        // `take` must not be negative, and must not be greater than takeLimit
+        let take = Math.min(Math.max(options.take ?? 0, 0), takeLimit) || takeLimit;
+        if (options.skip !== undefined && options.take === undefined) {
+            take = takeLimit;
+        }
+        return { take, skip };
+    }
+
+    /**
+     * If a customPropertyMap is provided, we need to take the path provided and convert it to the actual
+     * relation aliases being used by the SelectQueryBuilder.
+     *
+     * This method mutates the customPropertyMap object.
+     */
+    private normalizeCustomPropertyMap(
+        customPropertyMap: { [name: string]: string },
+        qb: SelectQueryBuilder<any>,
+    ) {
+        for (const [key, value] of Object.entries(customPropertyMap)) {
+            const parts = customPropertyMap[key].split('.');
+            const entityPart = 2 <= parts.length ? parts[parts.length - 2] : qb.alias;
+            const columnPart = parts[parts.length - 1];
+            const relationAlias = qb.expressionMap.aliases.find(
+                a => a.metadata.tableNameWithoutPrefix === entityPart,
+            );
+            if (relationAlias) {
+                customPropertyMap[key] = `${relationAlias.name}.${columnPart}`;
+            } else {
+                Logger.error(
+                    `The customPropertyMap entry "${key}:${value}" could not be resolved to a related table`,
+                );
+                delete customPropertyMap[key];
+            }
+        }
+    }
+
     /**
      * Some calculated columns (those with the `@Calculated()` decorator) require extra joins in order
      * to derive the data needed for their expressions.

+ 3 - 0
packages/core/src/service/helpers/list-query-builder/parse-filter-params.ts

@@ -29,6 +29,7 @@ export function parseFilterParams<T extends VendureEntity>(
     connection: Connection,
     entity: Type<T>,
     filterParams?: NullOptionals<FilterParameter<T>> | null,
+    customPropertyMap?: { [name: string]: string },
 ): WhereCondition[] {
     if (!filterParams) {
         return [];
@@ -55,6 +56,8 @@ export function parseFilterParams<T extends VendureEntity>(
                     fieldName = `${translationsAlias}.${key}`;
                 } else if (calculatedColumnExpression) {
                     fieldName = escapeCalculatedColumnExpression(connection, calculatedColumnExpression);
+                } else if (customPropertyMap?.[key]) {
+                    fieldName = customPropertyMap[key];
                 } else {
                     throw new UserInputError('error.invalid-filter-field');
                 }

+ 3 - 0
packages/core/src/service/helpers/list-query-builder/parse-sort-params.ts

@@ -21,6 +21,7 @@ export function parseSortParams<T extends VendureEntity>(
     connection: Connection,
     entity: Type<T>,
     sortParams?: NullOptionals<SortParameter<T>> | null,
+    customPropertyMap?: { [name: string]: string },
 ): OrderByCondition {
     if (!sortParams || Object.keys(sortParams).length === 0) {
         return {};
@@ -41,6 +42,8 @@ export function parseSortParams<T extends VendureEntity>(
             if (instruction) {
                 output[escapeCalculatedColumnExpression(connection, instruction.expression)] = order as any;
             }
+        } else if (customPropertyMap?.[key]) {
+            output[customPropertyMap[key]] = order as any;
         } else {
             throw new UserInputError('error.invalid-sort-field', {
                 fieldName: key,