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 { Args, Query, Resolver } from '@nestjs/graphql';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import {
 import {
     Ctx,
     Ctx,
+    Customer,
+    CustomerService,
     ListQueryBuilder,
     ListQueryBuilder,
     LocaleString,
     LocaleString,
+    Order,
+    OrderService,
     PluginCommonModule,
     PluginCommonModule,
     RequestContext,
     RequestContext,
+    RequestContextService,
     TransactionalConnection,
     TransactionalConnection,
     Translatable,
     Translatable,
     translateDeep,
     translateDeep,
@@ -16,10 +21,9 @@ import {
     VendurePlugin,
     VendurePlugin,
 } from '@vendure/core';
 } from '@vendure/core';
 import gql from 'graphql-tag';
 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 { Calculated } from '../../../src/common/calculated-decorator';
-import { EntityId } from '../../../src/entity/entity-id.decorator';
 
 
 @Entity()
 @Entity()
 export class TestEntity extends VendureEntity implements Translatable {
 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 })
     @OneToMany(type => TestEntityTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<TestEntity>>;
     translations: Array<Translation<TestEntity>>;
+
+    @OneToOne(type => Order)
+    @JoinColumn()
+    orderRelation: Order;
 }
 }
 
 
 @Entity()
 @Entity()
@@ -122,7 +130,13 @@ export class ListQueryResolver {
     @Query()
     @Query()
     testEntities(@Ctx() ctx: RequestContext, @Args() args: any) {
     testEntities(@Ctx() ctx: RequestContext, @Args() args: any) {
         return this.listQueryBuilder
         return this.listQueryBuilder
-            .build(TestEntity, args.options, { ctx })
+            .build(TestEntity, args.options, {
+                ctx,
+                relations: ['orderRelation', 'orderRelation.customer'],
+                customPropertyMap: {
+                    customerLastName: 'orderRelation.customer.lastName',
+                },
+            })
             .getManyAndCount()
             .getManyAndCount()
             .then(([items, totalItems]) => {
             .then(([items, totalItems]) => {
                 for (const item of items) {
                 for (const item of items) {
@@ -178,6 +192,7 @@ const apiExtensions = gql`
         price: Int!
         price: Int!
         ownerId: ID!
         ownerId: ID!
         translations: [TestEntityTranslation!]!
         translations: [TestEntityTranslation!]!
+        orderRelation: Order
     }
     }
 
 
     type TestEntityList implements PaginatedList {
     type TestEntityList implements PaginatedList {
@@ -190,6 +205,14 @@ const apiExtensions = gql`
         testEntitiesGetMany(options: TestEntityListOptions): [TestEntity!]!
         testEntitiesGetMany(options: TestEntityListOptions): [TestEntity!]!
     }
     }
 
 
+    input TestEntityFilterParameter {
+        customerLastName: StringOperators
+    }
+
+    input TestEntitySortParameter {
+        customerLastName: SortOrder
+    }
+
     input TestEntityListOptions
     input TestEntityListOptions
 `;
 `;
 
 
@@ -206,7 +229,12 @@ const apiExtensions = gql`
     },
     },
 })
 })
 export class ListQueryPlugin implements OnApplicationBootstrap {
 export class ListQueryPlugin implements OnApplicationBootstrap {
-    constructor(private connection: TransactionalConnection) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private requestContextService: RequestContextService,
+        private customerService: CustomerService,
+        private orderService: OrderService,
+    ) {}
 
 
     async onApplicationBootstrap() {
     async onApplicationBootstrap() {
         const count = await this.connection.getRepository(TestEntity).count();
         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({
         await server.init({
             initialData,
             initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-            customerCount: 1,
+            customerCount: 3,
         });
         });
         await adminClient.asSuperAdmin();
         await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
     }, TEST_SETUP_TIMEOUT_MS);
@@ -975,6 +975,7 @@ describe('ListQueryBuilder', () => {
                 { languageCode: LanguageCode.de, name: 'fahrrad' },
                 { languageCode: LanguageCode.de, name: 'fahrrad' },
             ],
             ],
         ];
         ];
+
         function getTestEntityTranslations(testEntities: { items: any[] }) {
         function getTestEntityTranslations(testEntities: { items: any[] }) {
             // Explicitly sort the order of the translations as it was being non-deterministic on
             // Explicitly sort the order of the translations as it was being non-deterministic on
             // the mysql CI tests.
             // the mysql CI tests.
@@ -1016,6 +1017,67 @@ describe('ListQueryBuilder', () => {
             expect(testEntityTranslations).toEqual(allTranslations);
             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`
 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`
 const GET_ARRAY_LIST = gql`
     query GetTestEntitiesArray($options: TestEntityListOptions) {
     query GetTestEntitiesArray($options: TestEntityListOptions) {
         testEntitiesGetMany(options: $options) {
         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: {
      *   customPropertyMap: {
      *     // Tell TypeORM how to map that custom
      *     // Tell TypeORM how to map that custom
      *     // sort/filter field to the property on a
      *     // 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',
      *     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 };
     customPropertyMap?: { [name: string]: string };
 };
 };
@@ -322,14 +333,41 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
                     // to join the associated relations.
                     // to join the associated relations.
                     continue;
                     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) {
                     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 parts = customPropertyMap[property].split('.');
             const entityPart = 2 <= parts.length ? parts[parts.length - 2] : qb.alias;
             const entityPart = 2 <= parts.length ? parts[parts.length - 2] : qb.alias;
             const columnPart = parts[parts.length - 1];
             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) {
             if (relationAlias) {
                 customPropertyMap[property] = `${relationAlias.name}.${columnPart}`;
                 customPropertyMap[property] = `${relationAlias.name}.${columnPart}`;
             } else {
             } 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;
         const hasPostalCodeFilter = !!(options as CustomerListOptions)?.filter?.postalCode;
         if (hasPostalCodeFilter) {
         if (hasPostalCodeFilter) {
             relations.push('addresses');
             relations.push('addresses');
-            customPropertyMap.postalCode = 'address.postalCode';
+            customPropertyMap.postalCode = 'addresses.postalCode';
         }
         }
         return this.listQueryBuilder
         return this.listQueryBuilder
             .build(Customer, options, {
             .build(Customer, options, {

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

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