Browse Source

fix(core): Fix nested relations in ListQueryBuilder customPropertyMap

Fixes #1851. Fixes #1774. This commit makes it possible to use the actual relation path rather than
table names in the customPropertyMap config. Use of table names is still supported for backward
compatibility but will be removed in v2.
Michael Bromley 3 years ago
parent
commit
839fa37bed

+ 51 - 5
packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts

@@ -1,13 +1,18 @@
-import { OnApplicationBootstrap } from '@nestjs/common';
+import { Logger, OnApplicationBootstrap } from '@nestjs/common';
 import { Args, Query, Resolver } from '@nestjs/graphql';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import {
     Ctx,
+    Customer,
+    CustomerService,
     ListQueryBuilder,
     LocaleString,
+    Order,
+    OrderService,
     PluginCommonModule,
     RequestContext,
+    RequestContextService,
     TransactionalConnection,
     Translatable,
     translateDeep,
@@ -16,10 +21,9 @@ import {
     VendurePlugin,
 } from '@vendure/core';
 import gql from 'graphql-tag';
-import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
+import { Column, Entity, JoinColumn, JoinTable, ManyToOne, OneToMany, OneToOne } from 'typeorm';
 
 import { Calculated } from '../../../src/common/calculated-decorator';
-import { EntityId } from '../../../src/entity/entity-id.decorator';
 
 @Entity()
 export class TestEntity extends VendureEntity implements Translatable {
@@ -83,6 +87,10 @@ export class TestEntity extends VendureEntity implements Translatable {
 
     @OneToMany(type => TestEntityTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<TestEntity>>;
+
+    @OneToOne(type => Order)
+    @JoinColumn()
+    orderRelation: Order;
 }
 
 @Entity()
@@ -122,7 +130,13 @@ export class ListQueryResolver {
     @Query()
     testEntities(@Ctx() ctx: RequestContext, @Args() args: any) {
         return this.listQueryBuilder
-            .build(TestEntity, args.options, { ctx })
+            .build(TestEntity, args.options, {
+                ctx,
+                relations: ['orderRelation', 'orderRelation.customer'],
+                customPropertyMap: {
+                    customerLastName: 'orderRelation.customer.lastName',
+                },
+            })
             .getManyAndCount()
             .then(([items, totalItems]) => {
                 for (const item of items) {
@@ -178,6 +192,7 @@ const apiExtensions = gql`
         price: Int!
         ownerId: ID!
         translations: [TestEntityTranslation!]!
+        orderRelation: Order
     }
 
     type TestEntityList implements PaginatedList {
@@ -190,6 +205,14 @@ const apiExtensions = gql`
         testEntitiesGetMany(options: TestEntityListOptions): [TestEntity!]!
     }
 
+    input TestEntityFilterParameter {
+        customerLastName: StringOperators
+    }
+
+    input TestEntitySortParameter {
+        customerLastName: SortOrder
+    }
+
     input TestEntityListOptions
 `;
 
@@ -206,7 +229,12 @@ const apiExtensions = gql`
     },
 })
 export class ListQueryPlugin implements OnApplicationBootstrap {
-    constructor(private connection: TransactionalConnection) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private requestContextService: RequestContextService,
+        private customerService: CustomerService,
+        private orderService: OrderService,
+    ) {}
 
     async onApplicationBootstrap() {
         const count = await this.connection.getRepository(TestEntity).count();
@@ -298,6 +326,24 @@ export class ListQueryPlugin implements OnApplicationBootstrap {
                     }
                 }
             }
+        } else {
+            const testEntities = await this.connection.getRepository(TestEntity).find();
+            const ctx = await this.requestContextService.create({ apiType: 'admin' });
+            const customers = await this.connection.rawConnection.getRepository(Customer).find();
+            let i = 0;
+
+            for (const testEntity of testEntities) {
+                const customer = customers[i % customers.length];
+                try {
+                    // tslint:disable-next-line:no-non-null-assertion
+                    const order = await this.orderService.create(ctx, customer.user!.id);
+                    testEntity.orderRelation = order;
+                    await this.connection.getRepository(TestEntity).save(testEntity);
+                } catch (e: any) {
+                    Logger.error(e);
+                }
+                i++;
+            }
         }
     }
 }

+ 83 - 1
packages/core/e2e/list-query-builder.e2e-spec.ts

@@ -29,7 +29,7 @@ describe('ListQueryBuilder', () => {
         await server.init({
             initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-            customerCount: 1,
+            customerCount: 3,
         });
         await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
@@ -975,6 +975,7 @@ describe('ListQueryBuilder', () => {
                 { languageCode: LanguageCode.de, name: 'fahrrad' },
             ],
         ];
+
         function getTestEntityTranslations(testEntities: { items: any[] }) {
             // Explicitly sort the order of the translations as it was being non-deterministic on
             // the mysql CI tests.
@@ -1016,6 +1017,67 @@ describe('ListQueryBuilder', () => {
             expect(testEntityTranslations).toEqual(allTranslations);
         });
     });
+
+    describe('customPropertyMap', () => {
+        it('filter by custom string field', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_ORDERS, {
+                options: {
+                    sort: {
+                        label: SortOrder.ASC,
+                    },
+                    filter: {
+                        customerLastName: { contains: 'zieme' },
+                    },
+                },
+            });
+
+            expect(testEntities.items).toEqual([
+                {
+                    id: 'T_1',
+                    label: 'A',
+                    name: 'apple',
+                    orderRelation: {
+                        customer: {
+                            firstName: 'Hayden',
+                            lastName: 'Zieme',
+                        },
+                        id: 'T_1',
+                    },
+                },
+                {
+                    id: 'T_4',
+                    label: 'D',
+                    name: 'dog',
+                    orderRelation: {
+                        customer: {
+                            firstName: 'Hayden',
+                            lastName: 'Zieme',
+                        },
+                        id: 'T_4',
+                    },
+                },
+            ]);
+        });
+
+        it('sort by custom string field', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_ORDERS, {
+                options: {
+                    sort: {
+                        customerLastName: SortOrder.ASC,
+                    },
+                },
+            });
+
+            expect(testEntities.items.map((i: any) => i.orderRelation.customer)).toEqual([
+                { firstName: 'Trevor', lastName: 'Donnelly' },
+                { firstName: 'Trevor', lastName: 'Donnelly' },
+                { firstName: 'Marques', lastName: 'Sawayn' },
+                { firstName: 'Marques', lastName: 'Sawayn' },
+                { firstName: 'Hayden', lastName: 'Zieme' },
+                { firstName: 'Hayden', lastName: 'Zieme' },
+            ]);
+        });
+    });
 });
 
 const GET_LIST = gql`
@@ -1050,6 +1112,26 @@ const GET_LIST_WITH_TRANSLATIONS = gql`
     }
 `;
 
+const GET_LIST_WITH_ORDERS = gql`
+    query GetTestEntitiesWithTranslations($options: TestEntityListOptions) {
+        testEntities(options: $options) {
+            totalItems
+            items {
+                id
+                label
+                name
+                orderRelation {
+                    id
+                    customer {
+                        firstName
+                        lastName
+                    }
+                }
+            }
+        }
+    }
+`;
+
 const GET_ARRAY_LIST = gql`
     query GetTestEntitiesArray($options: TestEntityListOptions) {
         testEntitiesGetMany(options: $options) {

+ 56 - 15
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -90,15 +90,26 @@ export type ExtendedListQueryOptions<T extends VendureEntity> = {
      *   customPropertyMap: {
      *     // Tell TypeORM how to map that custom
      *     // sort/filter field to the property on a
-     *     // related entity. Note that the `customer`
-     *     // part needs to match the *table name* of the
-     *     // related entity. So, e.g. if you are mapping to
-     *     // a `FacetValue` relation's `id` property, the value
-     *     // would be `facet_value.id`.
+     *     // related entity.
      *     customerLastName: 'customer.lastName',
      *   },
      * };
      * ```
+     * We can now use the `customerLastName` property to filter or sort
+     * on the list query:
+     *
+     * @example
+     * ```GraphQL
+     * query {
+     *   myOrderQuery(options: {
+     *     filter: {
+     *       customerLastName: { contains: "sm" }
+     *     }
+     *   }) {
+     *     # ...
+     *   }
+     * }
+     * ```
      */
     customPropertyMap?: { [name: string]: string };
 };
@@ -322,14 +333,41 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
                     // to join the associated relations.
                     continue;
                 }
-                const tableNameLower = path.split('.')[0];
-                const entityMetadata = repository.manager.connection.entityMetadatas.find(
-                    em => em.tableNameWithoutPrefix === tableNameLower,
-                );
-                if (entityMetadata) {
-                    const relationMetadata = metadata.relations.find(r => r.type === entityMetadata.target);
+
+                // TODO: Delete for v2
+                // This is a work-around to allow the use of the legacy table-name-based
+                // customPropertyMap syntax
+                let relationPathYieldedMatch = false;
+
+                const relationPath = path.split('.').slice(0, -1);
+                let targetMetadata = metadata;
+                const recontructedPath = [];
+                for (const relationPathPart of relationPath) {
+                    const relationMetadata = targetMetadata.findRelationWithPropertyPath(relationPathPart);
                     if (relationMetadata) {
-                        requiredRelations.push(relationMetadata.propertyName);
+                        recontructedPath.push(relationMetadata.propertyName);
+                        requiredRelations.push(recontructedPath.join('.'));
+                        targetMetadata = relationMetadata.inverseEntityMetadata;
+                        relationPathYieldedMatch = true;
+                    }
+                }
+
+                if (!relationPathYieldedMatch) {
+                    // TODO: Delete this in v2.
+                    // Legacy behaviour that uses the table name to reference relations.
+                    // This causes a bunch of issues and is also a bad, unintuitive way to
+                    // reference relations. See https://github.com/vendure-ecommerce/vendure/issues/1774
+                    const tableNameLower = path.split('.')[0];
+                    const entityMetadata = repository.manager.connection.entityMetadatas.find(
+                        em => em.tableNameWithoutPrefix === tableNameLower,
+                    );
+                    if (entityMetadata) {
+                        const relationMetadata = metadata.relations.find(
+                            r => r.type === entityMetadata.target,
+                        );
+                        if (relationMetadata) {
+                            requiredRelations.push(relationMetadata.propertyName);
+                        }
                     }
                 }
             }
@@ -478,9 +516,12 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
             const parts = customPropertyMap[property].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,
-            );
+
+            const relationMetadata =
+                qb.expressionMap.mainAlias?.metadata.findRelationWithPropertyPath(entityPart);
+            const relationAlias =
+                qb.expressionMap.aliases.find(a => a.metadata.tableNameWithoutPrefix === entityPart) ??
+                qb.expressionMap.joinAttributes.find(ja => ja.relationCache === relationMetadata)?.alias;
             if (relationAlias) {
                 customPropertyMap[property] = `${relationAlias.name}.${columnPart}`;
             } else {

+ 1 - 1
packages/core/src/service/services/customer.service.ts

@@ -99,7 +99,7 @@ export class CustomerService {
         const hasPostalCodeFilter = !!(options as CustomerListOptions)?.filter?.postalCode;
         if (hasPostalCodeFilter) {
             relations.push('addresses');
-            customPropertyMap.postalCode = 'address.postalCode';
+            customPropertyMap.postalCode = 'addresses.postalCode';
         }
         return this.listQueryBuilder
             .build(Customer, options, {

+ 1 - 1
packages/core/src/service/services/order.service.ts

@@ -201,7 +201,7 @@ export class OrderService {
                 channelId: ctx.channelId,
                 customPropertyMap: {
                     customerLastName: 'customer.lastName',
-                    transactionId: 'payment.transactionId',
+                    transactionId: 'payments.transactionId',
                 },
             })
             .getManyAndCount()