فهرست منبع

feat(core): Create Relations decorator

Relates to #1506. This commit introduces a new decorator, `@Relations()` to be used in GraphQL
resolvers. It injects an array of DB relations that are required to be joined in the subsequent
DB call in order to service the specific selection in the GraphQL query.
Michael Bromley 3 سال پیش
والد
کامیت
063b5fef04

+ 73 - 0
packages/core/e2e/fixtures/test-plugins/relations-decorator-test-plugin.ts

@@ -0,0 +1,73 @@
+import { Injectable } from '@nestjs/common';
+import { Args, Query, Resolver } from '@nestjs/graphql';
+import { QueryOrdersArgs } from '@vendure/common/lib/generated-types';
+import { PaginatedList } from '@vendure/common/lib/shared-types';
+import {
+    Ctx,
+    Order,
+    OrderService,
+    RequestContext,
+    VendurePlugin,
+    Relations,
+    RelationPaths,
+    PluginCommonModule,
+} from '@vendure/core';
+import gql from 'graphql-tag';
+
+@Injectable()
+export class RelationDecoratorTestService {
+    private lastRelations: string[];
+
+    getRelations() {
+        return this.lastRelations;
+    }
+
+    reset() {
+        this.lastRelations = [];
+    }
+
+    recordRelations(relations: string[]) {
+        this.lastRelations = relations;
+    }
+}
+
+@Resolver()
+export class TestResolver {
+    constructor(private orderService: OrderService, private testService: RelationDecoratorTestService) {}
+
+    @Query()
+    orders(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryOrdersArgs,
+        @Relations(Order) relations: RelationPaths<Order>,
+    ): Promise<PaginatedList<Order>> {
+        this.testService.recordRelations(relations);
+        return this.orderService.findAll(ctx, args.options || undefined, relations);
+    }
+
+    @Query()
+    ordersWithDepth5(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryOrdersArgs,
+        @Relations({ entity: Order, depth: 5 }) relations: RelationPaths<Order>,
+    ): Promise<PaginatedList<Order>> {
+        this.testService.recordRelations(relations);
+        return this.orderService.findAll(ctx, args.options || undefined, relations);
+    }
+}
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    shopApiExtensions: {
+        resolvers: () => [TestResolver],
+        schema: () => gql`
+            extend type Query {
+                orders(options: OrderListOptions): OrderList!
+                ordersWithDepth5(options: OrderListOptions): OrderList!
+            }
+        `,
+    },
+    providers: [RelationDecoratorTestService],
+    exports: [RelationDecoratorTestService],
+})
+export class RelationsDecoratorTestPlugin {}

+ 168 - 0
packages/core/e2e/relations-decorator.e2e-spec.ts

@@ -0,0 +1,168 @@
+import { mergeConfig, Zone } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import {
+    RelationDecoratorTestService,
+    RelationsDecoratorTestPlugin,
+} from './fixtures/test-plugins/relations-decorator-test-plugin';
+
+describe('Relations decorator', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            customFields: {
+                Order: [
+                    {
+                        name: 'zone',
+                        entity: Zone,
+                        type: 'relation',
+                    },
+                ],
+            },
+            plugins: [RelationsDecoratorTestPlugin],
+        }),
+    );
+    let testService: RelationDecoratorTestService;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            customerCount: 1,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+        });
+        await adminClient.asSuperAdmin();
+        testService = server.app.get(RelationDecoratorTestService);
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('empty relations', async () => {
+        testService.reset();
+        await shopClient.query(gql`
+            {
+                orders(options: { take: 5 }) {
+                    items {
+                        id
+                    }
+                }
+            }
+        `);
+        expect(testService.getRelations()).toEqual([]);
+    });
+
+    it('relations specified in query are included', async () => {
+        testService.reset();
+        await shopClient.query(gql`
+            {
+                orders(options: { take: 5 }) {
+                    items {
+                        customer {
+                            firstName
+                        }
+                        lines {
+                            featuredAsset {
+                                preview
+                            }
+                        }
+                    }
+                }
+            }
+        `);
+        expect(testService.getRelations()).toEqual(['customer', 'lines', 'lines.featuredAsset']);
+    });
+
+    it('custom field relations are included', async () => {
+        testService.reset();
+        await shopClient.query(gql`
+            {
+                orders(options: { take: 5 }) {
+                    items {
+                        customFields {
+                            zone {
+                                id
+                            }
+                        }
+                    }
+                }
+            }
+        `);
+        expect(testService.getRelations()).toEqual(['customFields.zone']);
+    });
+
+    it('relations specified in Calculated decorator are included', async () => {
+        testService.reset();
+        await shopClient.query(gql`
+            {
+                orders(options: { take: 5 }) {
+                    items {
+                        id
+                        totalQuantity
+                    }
+                }
+            }
+        `);
+        expect(testService.getRelations()).toEqual(['lines', 'lines.items']);
+    });
+
+    it('defaults to a depth of 3', async () => {
+        testService.reset();
+        await shopClient.query(gql`
+            {
+                orders(options: { take: 5 }) {
+                    items {
+                        lines {
+                            productVariant {
+                                product {
+                                    featuredAsset {
+                                        preview
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        `);
+        expect(testService.getRelations()).toEqual([
+            'lines',
+            'lines.productVariant',
+            'lines.productVariant.product',
+        ]);
+    });
+
+    it('manually set depth of 5', async () => {
+        testService.reset();
+        await shopClient.query(gql`
+            {
+                ordersWithDepth5(options: { take: 5 }) {
+                    items {
+                        lines {
+                            productVariant {
+                                product {
+                                    optionGroups {
+                                        options {
+                                            name
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        `);
+        expect(testService.getRelations()).toEqual([
+            'lines',
+            'lines.productVariant',
+            'lines.productVariant.product',
+            'lines.productVariant.product.optionGroups',
+            'lines.productVariant.product.optionGroups.options',
+        ]);
+    });
+});

+ 213 - 0
packages/core/src/api/decorators/relations.decorator.ts

@@ -0,0 +1,213 @@
+import { createParamDecorator, ExecutionContext } from '@nestjs/common';
+import { Type } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
+import { getNamedType, GraphQLResolveInfo, GraphQLSchema, isObjectType } from 'graphql';
+import graphqlFields from 'graphql-fields';
+import { getMetadataArgsStorage } from 'typeorm';
+
+import { CalculatedColumnDefinition, CALCULATED_PROPERTIES } from '../../common/calculated-decorator';
+import { EntityRelationPaths, InternalServerError, TtlCache } from '../../common/index';
+import { VendureEntity } from '../../entity/index';
+
+export type RelationPaths<T extends VendureEntity> = Array<EntityRelationPaths<T>>;
+
+export type FieldsDecoratorConfig =
+    | Type<VendureEntity>
+    | {
+          entity: Type<VendureEntity>;
+          depth: number;
+      };
+
+const DEFAULT_DEPTH = 3;
+
+const cache = new TtlCache({ cacheSize: 500, ttl: 5 * 60 * 1000 });
+
+/**
+ * @description
+ * Resolver param decorator which returns an array of relation paths which can be passed through
+ * to the TypeORM data layer in order to join only the required relations. This works by inspecting
+ * the GraphQL `info` object, examining the field selection, and then comparing this with information
+ * about the return type's relations.
+ *
+ * In addition to analyzing the field selection, this decorator also checks for any `@Calculated()`
+ * properties on the entity, and additionally includes relations from the `relations` array of the calculated
+ * metadata, if defined.
+ *
+ * So if, for example, the query only selects the `id` field of an Order, then no other relations need
+ * be joined in the resulting SQL query. This can massively speed up execution time for queries which do
+ * not include many deep nested relations.
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.ReadOrder)
+ * orders(
+ *     \@Ctx() ctx: RequestContext,
+ *     \@Args() args: QueryOrdersArgs,
+ *     \@Relations(Order) relations: RelationPaths<Order>,
+ * ): Promise<PaginatedList<Order>> {
+ *     return this.orderService.findAll(ctx, args.options || undefined, relations);
+ * }
+ * ```
+ *
+ * In the above example, given the following query:
+ *
+ * @example
+ * ```GraphQL
+ * {
+ *   orders(options: { take: 10 }) {
+ *     items {
+ *       id
+ *       customer {
+ *         id
+ *         firstName
+ *         lastName
+ *       }
+ *       totalQuantity
+ *       totalWithTax
+ *     }
+ *   }
+ * }
+ * ```
+ * then the value of `relations` will be
+ *
+ * ```
+ * ['customer', 'lines', 'lines.items']
+ * ```
+ * The `'customer'` comes from the fact that the query is nesting the "customer" object, and the `'lines'` & `'lines.items'` are taken
+ * from the `Order` entity's `totalQuantity` property, which uses {@link Calculated} decorator and defines those relations as dependencies
+ * for deriving the calculated value.
+ *
+ * ## Depth
+ *
+ * By default, when inspecting the GraphQL query, the Relations decorator will look 3 levels deep in any nested fields. So, e.g. if
+ * the above `orders` query were changed to:
+ *
+ * @example
+ * ```GraphQL
+ * {
+ *   orders(options: { take: 10 }) {
+ *     items {
+ *       id
+ *       lines {
+ *         productVariant {
+ *           product {
+ *             featuredAsset {
+ *               preview
+ *             }
+ *           }
+ *         }
+ *       }
+ *     }
+ *   }
+ * }
+ * ```
+ * then the `relations` array would include `'lines'`, `'lines.productVariant'`, & `'lines.productVariant.product'` - 3 levels deep - but it would
+ * _not_ include `'lines.productVariant.product.featuredAsset'` since that exceeds the default depth. To specify a custom depth, you would
+ * use the decorator like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Relations({ entity: Order, depth: 2 }) relations: RelationPaths<Order>,
+ * ```
+ *
+ * @docsCategory request
+ * @docsPage Api Decorator
+ * @since 1.6.0
+ */
+export const Relations = createParamDecorator<FieldsDecoratorConfig>((data, ctx: ExecutionContext) => {
+    const info = ctx.getArgByIndex(3);
+    if (data == null) {
+        throw new InternalServerError(`The @Fields() decorator requires an entity type argument`);
+    }
+    if (!isGraphQLResolveInfo(info)) {
+        return [];
+    }
+    const cacheKey = info.fieldName + '__' + ctx.getArgByIndex(2).req.body.query;
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+        return cachedResult;
+    }
+    const fields = graphqlFields(info);
+    const targetFields = isPaginatedListQuery(info) ? fields.items ?? {} : fields;
+    const entity = typeof data === 'function' ? data : data.entity;
+    const maxDepth = typeof data === 'function' ? DEFAULT_DEPTH : data.depth;
+    const relationFields = getRelationPaths(targetFields, entity, maxDepth);
+    const result = unique(relationFields);
+    cache.set(cacheKey, result);
+    return result;
+});
+
+function getRelationPaths(
+    fields: Record<string, Record<string, any>>,
+    entity: Type<VendureEntity>,
+    maxDepth: number,
+    depth = 1,
+): string[] {
+    const relations = getMetadataArgsStorage().filterRelations(entity);
+    const metadata = getMetadataArgsStorage();
+    const relationPaths: string[] = [];
+    for (const [property, value] of Object.entries(fields)) {
+        if (property === 'customFields') {
+            const customFieldEntity = metadata
+                .filterEmbeddeds(entity)
+                .find(e => e.propertyName === 'customFields')
+                ?.type();
+            if (customFieldEntity) {
+                if (depth < maxDepth) {
+                    depth++;
+                    const subPaths = getRelationPaths(
+                        value,
+                        customFieldEntity as Type<VendureEntity>,
+                        maxDepth,
+                        depth,
+                    );
+                    depth--;
+                    for (const subPath of subPaths) {
+                        relationPaths.push([property, subPath].join('.'));
+                    }
+                }
+            }
+        } else {
+            const relationMetadata = relations.find(r => r.propertyName === property);
+            if (relationMetadata) {
+                relationPaths.push(property);
+                const relatedEntity =
+                    typeof relationMetadata.type === 'function'
+                        ? relationMetadata.type()
+                        : relationMetadata.type;
+                if (depth < maxDepth) {
+                    depth++;
+                    const subPaths = getRelationPaths(
+                        value,
+                        relatedEntity as Type<VendureEntity>,
+                        maxDepth,
+                        depth,
+                    );
+                    depth--;
+                    for (const subPath of subPaths) {
+                        relationPaths.push([property, subPath].join('.'));
+                    }
+                }
+            }
+            const calculatedProperties: CalculatedColumnDefinition[] =
+                Object.getPrototypeOf(new entity())[CALCULATED_PROPERTIES] ?? [];
+            const selectedFields = new Set(Object.keys(fields));
+            const dependencyRelations = calculatedProperties
+                .filter(p => selectedFields.has(p.name as string) && p.listQuery?.relations?.length)
+                .map(p => p.listQuery?.relations ?? [])
+                .flat();
+            relationPaths.push(...dependencyRelations);
+        }
+    }
+    return relationPaths;
+}
+
+function isGraphQLResolveInfo(input: unknown): input is GraphQLResolveInfo {
+    return !!(input && typeof input === 'object' && (input as any).schema instanceof GraphQLSchema);
+}
+
+function isPaginatedListQuery(info: GraphQLResolveInfo): boolean {
+    const returnType = getNamedType(info.returnType);
+    return isObjectType(returnType) && !!returnType.getInterfaces().find(i => i.name === 'PaginatedList');
+}

+ 1 - 0
packages/core/src/api/index.ts

@@ -4,6 +4,7 @@ export * from './common/extract-session-token';
 export * from './decorators/allow.decorator';
 export * from './decorators/transaction.decorator';
 export * from './decorators/api.decorator';
+export * from './decorators/relations.decorator';
 export * from './decorators/request-context.decorator';
 export * from './resolvers/admin/search.resolver';
 export * from './middleware/auth-guard';

+ 16 - 3
packages/core/src/common/calculated-decorator.ts

@@ -7,13 +7,23 @@ import { OrderByCondition, SelectQueryBuilder } from 'typeorm';
 export const CALCULATED_PROPERTIES = '__calculatedProperties__';
 
 /**
- * Optional metadata used to tell the ListQueryBuilder how to deal with
- * calculated columns when sorting or filtering.
+ * @description
+ * Optional metadata used to tell the {@link ListQueryBuilder} & {@link Relations} decorator how to deal with
+ * calculated columns when sorting, filtering and deriving required relations from GraphQL operations.
+ *
+ * @docsCategory data-access
+ * @docsPage Calculated
  */
 export interface CalculatedColumnQueryInstruction {
+    /**
+     * @description
+     * If the calculated property depends on one or more relations being present
+     * on the entity (e.g. an `Order` entity calculating the `totalQuantity` by adding
+     * up the quantities of each `OrderLine`), then those relations should be defined here.
+     */
     relations?: string[];
     query?: (qb: SelectQueryBuilder<any>) => void;
-    expression: string;
+    expression?: string;
 }
 
 export interface CalculatedColumnDefinition {
@@ -26,6 +36,9 @@ export interface CalculatedColumnDefinition {
  * Used to define calculated entity getters. The decorator simply attaches an array of "calculated"
  * property names to the entity's prototype. This array is then used by the {@link CalculatedPropertySubscriber}
  * to transfer the getter function from the prototype to the entity instance.
+ *
+ * @docsCategory data-access
+ * @docsWeight 0
  */
 export function Calculated(queryInstruction?: CalculatedColumnQueryInstruction): MethodDecorator {
     return (