瀏覽代碼

fix(core): Fix edge case with nested eager custom field relations

Relates to #1664.
Michael Bromley 3 年之前
父節點
當前提交
ca9848c0ec

+ 100 - 72
packages/core/e2e/custom-field-relations.e2e-spec.ts

@@ -3,10 +3,12 @@ import {
     Collection,
     Country,
     CustomFields,
+    DefaultLogger,
     defaultShippingCalculator,
     defaultShippingEligibilityChecker,
     Facet,
     FacetValue,
+    LogLevel,
     manualFulfillmentHandler,
     mergeConfig,
     Product,
@@ -20,7 +22,7 @@ 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 { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import { TestPlugin1636_1664 } from './fixtures/test-plugins/issue-1636-1664/issue-1636-1664-plugin';
@@ -739,104 +741,130 @@ describe('Custom field relations', () => {
                 assertCustomFieldIds(updateProductVariants[0].customFields, 'T_2', ['T_3', 'T_4']);
             });
 
-            // https://github.com/vendure-ecommerce/vendure/issues/1664
-            it('successfully gets product by id with eager-loading custom field relation', async () => {
-                const { product } = await shopClient.query(gql`
-                    query {
-                        product(id: "T_1") {
-                            id
-                            customFields {
-                                cfVendor {
-                                    featuredProduct {
-                                        id
+            describe('issue 1664', () => {
+                // https://github.com/vendure-ecommerce/vendure/issues/1664
+                it('successfully gets product by id with eager-loading custom field relation', async () => {
+                    const { product } = await shopClient.query(gql`
+                        query {
+                            product(id: "T_1") {
+                                id
+                                customFields {
+                                    cfVendor {
+                                        featuredProduct {
+                                            id
+                                        }
                                     }
                                 }
                             }
                         }
-                    }
-                `);
+                    `);
 
-                expect(product).toBeDefined();
-            });
+                    expect(product).toBeDefined();
+                });
 
-            // https://github.com/vendure-ecommerce/vendure/issues/1664
-            it('successfully gets product by id with nested eager-loading custom field relation', async () => {
-                const { customer } = await adminClient.query(gql`
-                    query {
-                        customer(id: "T_1") {
-                            id
-                            firstName
-                            lastName
-                            emailAddress
-                            phoneNumber
-                            user {
-                                customFields {
-                                    cfVendor {
-                                        id
+                // https://github.com/vendure-ecommerce/vendure/issues/1664
+                it('successfully gets product by id with nested eager-loading custom field relation', async () => {
+                    const { customer } = await adminClient.query(gql`
+                        query {
+                            customer(id: "T_1") {
+                                id
+                                firstName
+                                lastName
+                                emailAddress
+                                phoneNumber
+                                user {
+                                    customFields {
+                                        cfVendor {
+                                            id
+                                        }
                                     }
                                 }
                             }
                         }
-                    }
-                `);
-
-                expect(customer).toBeDefined();
-            });
-
-            // https://github.com/vendure-ecommerce/vendure/issues/1664
-            it('successfully gets product.variants with nested custom field relation', async () => {
-                await adminClient.query(gql`
-                    mutation {
-                        updateProductVariants(
-                            input: [{ id: "T_1", customFields: { cfRelatedProductsIds: ["T_2"] } }]
-                        ) {
-                            id
+                    `);
+
+                    expect(customer).toBeDefined();
+                });
+
+                // https://github.com/vendure-ecommerce/vendure/issues/1664
+                it('successfully gets product.variants with nested custom field relation', async () => {
+                    await adminClient.query(gql`
+                        mutation {
+                            updateProductVariants(
+                                input: [{ id: "T_1", customFields: { cfRelatedProductsIds: ["T_2"] } }]
+                            ) {
+                                id
+                            }
                         }
-                    }
-                `);
-
-                const { product } = await adminClient.query(gql`
-                    query {
-                        product(id: "T_1") {
-                            variants {
+                    `);
+
+                    const { product } = await adminClient.query(gql`
+                        query {
+                            product(id: "T_1") {
+                                variants {
+                                    id
+                                    customFields {
+                                        cfRelatedProducts {
+                                            featuredAsset {
+                                                id
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    `);
+
+                    expect(product).toBeDefined();
+                    expect(product.variants[0].customFields.cfRelatedProducts).toEqual([
+                        {
+                            featuredAsset: { id: 'T_2' },
+                        },
+                    ]);
+                });
+
+                it('successfully gets product by slug with eager-loading custom field relation', async () => {
+                    const { product } = await shopClient.query(gql`
+                        query {
+                            product(slug: "laptop") {
                                 id
                                 customFields {
-                                    cfRelatedProducts {
-                                        featuredAsset {
+                                    cfVendor {
+                                        featuredProduct {
                                             id
                                         }
                                     }
                                 }
                             }
                         }
-                    }
-                `);
+                    `);
 
-                expect(product).toBeDefined();
-                expect(product.variants[0].customFields.cfRelatedProducts).toEqual([
-                    {
-                        featuredAsset: { id: 'T_2' },
-                    },
-                ]);
-            });
+                    expect(product).toBeDefined();
+                });
 
-            it('successfully gets product by slug with eager-loading custom field relation', async () => {
-                const { product } = await shopClient.query(gql`
-                    query {
-                        product(slug: "laptop") {
-                            id
-                            customFields {
-                                cfVendor {
-                                    featuredProduct {
+                it('does not error on custom field relation with eager custom field relation', async () => {
+                    const { product } = await adminClient.query(gql`
+                        query {
+                            product(slug: "laptop") {
+                                name
+                                customFields {
+                                    owner {
                                         id
+                                        code
+                                        customFields {
+                                            profile {
+                                                id
+                                                name
+                                            }
+                                        }
                                     }
                                 }
                             }
                         }
-                    }
-                `);
+                    `);
 
-                expect(product).toBeDefined();
+                    expect(product).toBeDefined();
+                });
             });
         });
 

+ 96 - 10
packages/core/e2e/fixtures/test-plugins/issue-1636-1664/issue-1636-1664-plugin.ts

@@ -1,18 +1,25 @@
+import { OnApplicationBootstrap } from '@nestjs/common';
 import { Args, Query, Resolver } from '@nestjs/graphql';
 import { ID } from '@vendure/common/lib/shared-types';
 import {
     Asset,
+    Channel,
     Ctx,
+    Customer,
     PluginCommonModule,
     Product,
     RequestContext,
     TransactionalConnection,
+    User,
     VendureEntity,
     VendurePlugin,
 } from '@vendure/core';
 import gql from 'graphql-tag';
 import { Entity, JoinColumn, OneToOne } from 'typeorm';
 
+import { ProfileAsset } from './profile-asset.entity';
+import { Profile } from './profile.entity';
+
 @Entity()
 export class Vendor extends VendureEntity {
     constructor() {
@@ -38,6 +45,16 @@ class TestResolver1636 {
     }
 }
 
+const profileType = gql`
+    type Profile implements Node {
+        id: ID!
+        createdAt: DateTime!
+        updatedAt: DateTime!
+        name: String!
+        user: User!
+    }
+`;
+
 /**
  * Testing https://github.com/vendure-ecommerce/vendure/issues/1636
  *
@@ -47,7 +64,7 @@ class TestResolver1636 {
  */
 @VendurePlugin({
     imports: [PluginCommonModule],
-    entities: [Vendor],
+    entities: [Vendor, Profile, ProfileAsset],
     shopApiExtensions: {
         schema: gql`
             extend type Query {
@@ -66,33 +83,102 @@ class TestResolver1636 {
                 id: ID
                 featuredProduct: Product
             }
+            type Profile implements Node {
+                id: ID!
+                createdAt: DateTime!
+                updatedAt: DateTime!
+                name: String!
+                user: User!
+            }
         `,
         resolvers: [],
     },
     configuration: config => {
-        config.customFields.Product.push({
+        config.customFields.Product.push(
+            {
+                name: 'cfVendor',
+                type: 'relation',
+                entity: Vendor,
+                graphQLType: 'Vendor',
+                list: false,
+                internal: false,
+                public: true,
+            },
+            {
+                name: 'owner',
+                nullable: true,
+                type: 'relation',
+                // Using the Channel entity rather than User as in the example comment at
+                // https://github.com/vendure-ecommerce/vendure/issues/1664#issuecomment-1293916504
+                // because using a User causes a recursive infinite loop in TypeORM between
+                // Product > User > Vendor > Product etc.
+                entity: Channel,
+                public: false,
+                eager: true, // needs to be eager to enable indexing of user->profile attributes like name, etc.
+                readonly: true,
+            },
+        );
+        config.customFields.User.push({
             name: 'cfVendor',
             type: 'relation',
             entity: Vendor,
             graphQLType: 'Vendor',
             list: false,
+            eager: true,
             internal: false,
             public: true,
         });
-        config.customFields.User.push({
-            name: 'cfVendor',
+
+        config.customFields.Channel.push({
+            name: 'profile',
             type: 'relation',
-            entity: Vendor,
-            graphQLType: 'Vendor',
-            list: false,
-            eager: true,
+            entity: Profile,
+            nullable: true,
+            public: false,
             internal: false,
-            public: true,
+            readonly: true,
+            eager: true, // needs to be eager to enable indexing of profile attributes like name, etc.
         });
+
         return config;
     },
 })
 // tslint:disable-next-line:class-name
-export class TestPlugin1636_1664 {
+export class TestPlugin1636_1664 implements OnApplicationBootstrap {
     static testResolverSpy = jest.fn();
+
+    constructor(private connection: TransactionalConnection) {}
+
+    async onApplicationBootstrap() {
+        const profilesCount = await this.connection.rawConnection.getRepository(Profile).count();
+        if (0 < profilesCount) {
+            return;
+        }
+        // Create a Profile and assign it to all the products
+        const channels = await this.connection.rawConnection.getRepository(Channel).find();
+        // tslint:disable-next-line:no-non-null-assertion
+        const channel = channels[0]!;
+        const profile = await this.connection.rawConnection.getRepository(Profile).save(
+            new Profile({
+                name: 'Test Profile',
+            }),
+        );
+
+        (channel.customFields as any).profile = profile;
+        await this.connection.rawConnection.getRepository(Channel).save(channel);
+
+        const asset = await this.connection.rawConnection.getRepository(Asset).findOne(1);
+        if (asset) {
+            const profileAsset = this.connection.rawConnection.getRepository(ProfileAsset).save({
+                asset,
+                profile,
+            });
+        }
+
+        const products = await this.connection.rawConnection.getRepository(Product).find();
+        for (const product of products) {
+            (product.customFields as any).owner = channel;
+            await this.connection.rawConnection.getRepository(Product).save(product);
+        }
+    }
 }

+ 19 - 0
packages/core/e2e/fixtures/test-plugins/issue-1636-1664/profile-asset.entity.ts

@@ -0,0 +1,19 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { Asset, VendureEntity } from '@vendure/core';
+import { Entity, JoinColumn, ManyToOne, OneToOne } from 'typeorm';
+
+import { Profile } from './profile.entity';
+
+@Entity()
+export class ProfileAsset extends VendureEntity {
+    constructor(input?: DeepPartial<ProfileAsset>) {
+        super(input);
+    }
+
+    @OneToOne(() => Asset, { eager: true, onDelete: 'CASCADE' })
+    @JoinColumn()
+    asset: Asset;
+
+    @ManyToOne(() => Profile, { onDelete: 'CASCADE' })
+    profile: Profile;
+}

+ 39 - 0
packages/core/e2e/fixtures/test-plugins/issue-1636-1664/profile.entity.ts

@@ -0,0 +1,39 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { User, VendureEntity } from '@vendure/core';
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
+
+import { ProfileAsset } from './profile-asset.entity';
+
+@Entity()
+export class Profile extends VendureEntity {
+    constructor(input?: DeepPartial<Profile>) {
+        super(input);
+    }
+    /**
+     * The reference to a user
+     */
+    @ManyToOne(() => User, user => (user as any).profileId, { onDelete: 'CASCADE' })
+    user: User;
+    /**
+     * Profile display name
+     */
+    @Column()
+    name: string;
+    /**
+     * The profile picture
+     */
+    @OneToOne(() => ProfileAsset, profileAsset => profileAsset.profile, {
+        onDelete: 'SET NULL',
+        nullable: true,
+    })
+    @JoinColumn()
+    featuredAsset: ProfileAsset;
+
+    /**
+     * Other assets
+     */
+    @OneToMany(() => ProfileAsset, profileAsset => profileAsset.profile, {
+        onDelete: 'CASCADE',
+    })
+    assets: ProfileAsset[];
+}

+ 8 - 3
packages/core/src/connection/remove-custom-fields-with-eager-relations.ts

@@ -45,9 +45,14 @@ export function removeCustomFieldsWithEagerRelations(
         metadata => metadata.propertyName === 'customFields',
     );
     if (customFieldsMetadata) {
-        const customFieldRelationsWithEagerRelations = customFieldsMetadata.relations.filter(
-            relation => !!relation.inverseEntityMetadata.ownRelations.find(or => or.isEager === true),
-        );
+        const customFieldRelationsWithEagerRelations = customFieldsMetadata.relations.filter(relation => {
+            return (
+                !!relation.inverseEntityMetadata.ownRelations.find(or => or.isEager === true) ||
+                relation.inverseEntityMetadata.embeddeds.find(
+                    em => em.propertyName === 'customFields' && em.relations.find(emr => emr.isEager),
+                )
+            );
+        });
         for (const relation of customFieldRelationsWithEagerRelations) {
             const propertyName = relation.propertyName;
             const relationsToRemove = relations.filter(r => r.startsWith(`customFields.${propertyName}`));

+ 0 - 109
packages/dev-server/test-plugins/issue-1664.ts

@@ -1,109 +0,0 @@
-import {
-    Facet,
-    LanguageCode,
-    LocaleString,
-    PluginCommonModule,
-    Product,
-    Translation,
-    VendureEntity,
-    VendurePlugin,
-} from '@vendure/core';
-import gql from 'graphql-tag';
-import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
-
-@Entity()
-class Vendor extends VendureEntity {
-    constructor(input: Partial<Vendor>) {
-        super(input);
-    }
-
-    description: LocaleString;
-
-    @Column()
-    name: string;
-
-    @OneToMany(() => Product, product => (product.customFields as any).vendor)
-    products: Product[];
-
-    @OneToMany(() => VendorTranslation, translation => translation.base, { eager: true })
-    translations: Array<Translation<Vendor>>;
-}
-
-@Entity()
-export class VendorTranslation extends VendureEntity implements Translation<Vendor> {
-    constructor(input?: Partial<Translation<Vendor>>) {
-        super(input);
-    }
-
-    @Column('varchar')
-    languageCode: LanguageCode;
-
-    @Column('text')
-    description: string;
-
-    @ManyToOne(() => Vendor, vendor => vendor.translations, { onDelete: 'CASCADE' })
-    base: Vendor;
-}
-
-const schema = gql`
-    type Vendor implements Node {
-        id: ID!
-        createdAt: DateTime!
-        updatedAt: DateTime!
-        name: String!
-        description: String!
-    }
-`;
-
-/**
- * Test plugin for https://github.com/vendure-ecommerce/vendure/issues/1664
- */
-@VendurePlugin({
-    imports: [PluginCommonModule],
-    entities: [Vendor, VendorTranslation],
-    shopApiExtensions: { schema, resolvers: [] },
-    adminApiExtensions: { schema, resolvers: [] },
-    configuration: config => {
-        config.customFields.Product.push({
-            name: 'vendor',
-            label: [{ languageCode: LanguageCode.en_AU, value: 'Vendor' }],
-            type: 'relation',
-            entity: Vendor,
-            eager: true,
-            nullable: false,
-            defaultValue: null,
-            ui: {
-                component: 'cp-product-vendor-selector',
-            },
-        });
-        config.customFields.User.push({
-            name: 'vendor',
-            label: [{ languageCode: LanguageCode.en_AU, value: 'Vendor' }],
-            type: 'relation',
-            entity: Vendor,
-            eager: true,
-            nullable: false,
-            defaultValue: null,
-            ui: {
-                component: 'cp-product-vendor-selector',
-            },
-        });
-        config.customFields.User.push({
-            name: 'facet',
-            label: [{ languageCode: LanguageCode.en_AU, value: 'Facet' }],
-            type: 'relation',
-            entity: Facet,
-            eager: true,
-            nullable: false,
-            defaultValue: null,
-        });
-
-        config.customFields.Product.push({
-            name: 'shopifyId',
-            type: 'float',
-            public: false,
-        });
-        return config;
-    },
-})
-export class Test1664Plugin {}

+ 114 - 0
packages/dev-server/test-plugins/issue-1664/issue-1664.ts

@@ -0,0 +1,114 @@
+import { OnApplicationBootstrap } from '@nestjs/common';
+import {
+    Asset,
+    PluginCommonModule,
+    Product,
+    TransactionalConnection,
+    User,
+    VendurePlugin,
+} from '@vendure/core';
+import gql from 'graphql-tag';
+
+import { ProfileAsset } from './profile-asset.entity';
+import { Profile } from './profile.entity';
+
+const schema = gql`
+    type Profile implements Node {
+        id: ID!
+        createdAt: DateTime!
+        updatedAt: DateTime!
+        name: String!
+        user: User!
+    }
+`;
+
+/**
+ * Test plugin for https://github.com/vendure-ecommerce/vendure/issues/1664
+ *
+ * Test query:
+ * ```graphql
+ * query {
+ *   product(id: 1) {
+ *     name
+ *     customFields {
+ *       owner {
+ *         id
+ *         identifier
+ *         customFields {
+ *           profile {
+ *             id
+ *             name
+ *           }
+ *         }
+ *       }
+ *     }
+ *   }
+ * }
+ * ```
+ */
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    entities: () => [Profile, ProfileAsset],
+    shopApiExtensions: { schema, resolvers: [] },
+    adminApiExtensions: { schema, resolvers: [] },
+    configuration: config => {
+        config.customFields.Product.push({
+            name: 'owner',
+            nullable: true,
+            type: 'relation',
+            entity: User,
+            public: false,
+            eager: true, // needs to be eager to enable indexing of user->profile attributes like name, etc.
+            readonly: true,
+        });
+        // User
+        config.customFields.User.push({
+            name: 'profile',
+            type: 'relation',
+            entity: Profile,
+            nullable: true,
+            public: false,
+            internal: false,
+            readonly: true,
+            eager: true, // needs to be eager to enable indexing of profile attributes like name, etc.
+        });
+        return config;
+    },
+})
+export class Test1664Plugin implements OnApplicationBootstrap {
+    constructor(private connection: TransactionalConnection) {}
+
+    async onApplicationBootstrap() {
+        const profilesCount = await this.connection.rawConnection.getRepository(Profile).count();
+        if (0 < profilesCount) {
+            return;
+        }
+        // Create a Profile and assign it to all the products
+        const users = await this.connection.rawConnection.getRepository(User).find();
+        // tslint:disable-next-line:no-non-null-assertion
+        const user = users[1]!;
+        const profile = await this.connection.rawConnection.getRepository(Profile).save(
+            new Profile({
+                name: 'Test Profile',
+                user,
+            }),
+        );
+
+        (user.customFields as any).profile = profile;
+        await this.connection.rawConnection.getRepository(User).save(user);
+
+        const asset = await this.connection.rawConnection.getRepository(Asset).findOne(1);
+        if (asset) {
+            const profileAsset = this.connection.rawConnection.getRepository(ProfileAsset).save({
+                asset,
+                profile,
+            });
+        }
+
+        const products = await this.connection.rawConnection.getRepository(Product).find();
+        for (const product of products) {
+            (product.customFields as any).owner = user;
+            await this.connection.rawConnection.getRepository(Product).save(product);
+        }
+    }
+}

+ 19 - 0
packages/dev-server/test-plugins/issue-1664/profile-asset.entity.ts

@@ -0,0 +1,19 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { Asset, VendureEntity } from '@vendure/core';
+import { Entity, JoinColumn, ManyToOne, OneToOne } from 'typeorm';
+
+import { Profile } from './profile.entity';
+
+@Entity()
+export class ProfileAsset extends VendureEntity {
+    constructor(input?: DeepPartial<ProfileAsset>) {
+        super(input);
+    }
+
+    @OneToOne(() => Asset, { eager: true, onDelete: 'CASCADE' })
+    @JoinColumn()
+    asset: Asset;
+
+    @ManyToOne(() => Profile, { onDelete: 'CASCADE' })
+    profile: Profile;
+}

+ 39 - 0
packages/dev-server/test-plugins/issue-1664/profile.entity.ts

@@ -0,0 +1,39 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { User, VendureEntity } from '@vendure/core';
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
+
+import { ProfileAsset } from './profile-asset.entity';
+
+@Entity()
+export class Profile extends VendureEntity {
+    constructor(input?: DeepPartial<Profile>) {
+        super(input);
+    }
+    /**
+     * The reference to a user
+     */
+    @ManyToOne(() => User, user => (user as any).profileId, { onDelete: 'CASCADE' })
+    user: User;
+    /**
+     * Profile display name
+     */
+    @Column()
+    name: string;
+    /**
+     * The profile picture
+     */
+    @OneToOne(() => ProfileAsset, profileAsset => profileAsset.profile, {
+        onDelete: 'SET NULL',
+        nullable: true,
+    })
+    @JoinColumn()
+    featuredAsset: ProfileAsset;
+
+    /**
+     * Other assets
+     */
+    @OneToMany(() => ProfileAsset, profileAsset => profileAsset.profile, {
+        onDelete: 'CASCADE',
+    })
+    assets: ProfileAsset[];
+}