vendure icon indicating copy to clipboard operation
vendure copied to clipboard

[Regression] v1.6.3 introduces entity__customFields error with custom field relations

Open michaelbromley opened this issue 2 years ago • 7 comments

Describe the bug

In v1.6.3 I made commit https://github.com/vendure-ecommerce/vendure/commit/9834225 which was an attempt to solve #1636. Unfortunately that commit introduced a regression when defining custom field relations on certain ChannelAware entities:

TypeORMError: "entity__customFields" alias was not found. Maybe you forgot to join it?

To Reproduce

Repro instructions courtesy of @vrosa:

I'm getting the same entity__customFields error after upgrading to v1.6.3. It fails when trying to edit a product in the Admin UI - GetProductWithVariants query. I've added 2 custom fields to Product, one of type "relation", which is public, and a non-public "number".

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.Product.push({
  name: 'shopifyId',
  type: 'float',
  public: false,
});

Vendor is a custom entity that has a one-to-many relationship with Product:

@OneToMany(() => Product, (product) => product.customFields.vendor)
products: Product[];
query
query GetProductWithVariants(
  $id: ID!
  $variantListOptions: ProductVariantListOptions
) {
  product(id: $id) {
    ...ProductDetail
    variantList(options: $variantListOptions) {
      items {
        ...ProductVariant
        __typename
      }
      totalItems
      __typename
    }
    __typename
  }
}

fragment ProductDetail on Product {
  id
  createdAt
  updatedAt
  enabled
  languageCode
  name
  slug
  description
  featuredAsset {
    ...Asset
    __typename
  }
  assets {
    ...Asset
    __typename
  }
  translations {
    id
    languageCode
    name
    slug
    description
    __typename
  }
  optionGroups {
    ...ProductOptionGroup
    __typename
  }
  facetValues {
    id
    code
    name
    facet {
      id
      name
      __typename
    }
    __typename
  }
  channels {
    id
    code
    __typename
  }
  customFields {
    vendor {
      id
      createdAt
      updatedAt
      name
      __typename
    }
    __typename
  }
  __typename
}

fragment ProductOptionGroup on ProductOptionGroup {
  id
  createdAt
  updatedAt
  code
  languageCode
  name
  translations {
    id
    languageCode
    name
    __typename
  }
  __typename
}

fragment Asset on Asset {
  id
  createdAt
  updatedAt
  name
  fileSize
  mimeType
  type
  preview
  source
  width
  height
  focalPoint {
    x
    y
    __typename
  }
  __typename
}

fragment ProductVariant on ProductVariant {
  id
  createdAt
  updatedAt
  enabled
  languageCode
  name
  price
  currencyCode
  priceWithTax
  stockOnHand
  stockAllocated
  trackInventory
  outOfStockThreshold
  useGlobalOutOfStockThreshold
  taxRateApplied {
    id
    name
    value
    __typename
  }
  taxCategory {
    id
    name
    __typename
  }
  sku
  options {
    ...ProductOption
    __typename
  }
  facetValues {
    id
    code
    name
    facet {
      id
      name
      __typename
    }
    __typename
  }
  featuredAsset {
    ...Asset
    __typename
  }
  assets {
    ...Asset
    __typename
  }
  translations {
    id
    languageCode
    name
    __typename
  }
  channels {
    id
    code
    __typename
  }
  customFields {
    __typename
  }
  __typename
}

fragment ProductOption on ProductOption {
  id
  createdAt
  updatedAt
  code
  languageCode
  name
  groupId
  translations {
    id
    languageCode
    name
    __typename
  }
  __typename
}
{
  "id": "5",
  "variantListOptions": {
    "take": 10
  }
}

Expected behavior No error

Environment (please complete the following information):

  • @vendure/core version: 1.6.3
  • Nodejs version: any
  • Database (mysql/postgres etc): any?

michaelbromley avatar Jul 19 '22 10:07 michaelbromley

I attempted to reproduce based on Vinicius' repro steps, resulting in the following plugin:

import { LanguageCode, PluginCommonModule, Product, VendureEntity, VendurePlugin } from '@vendure/core';
import gql from 'graphql-tag';
import { Column, Entity, OneToMany } from 'typeorm';

@Entity()
class Vendor extends VendureEntity {
    constructor(input: Partial<Vendor>) {
        super(input);
    }

    @Column()
    name: string;

    @OneToMany(() => Product, product => (product.customFields as any).vendor)
    products: Product[];
}

const schema = gql`
    type Vendor implements Node {
        id: ID!
        createdAt: DateTime!
        updatedAt: DateTime!
        name: String!
    }
`;

@VendurePlugin({
    imports: [PluginCommonModule],
    entities: [Vendor],
    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.Product.push({
            name: 'shopifyId',
            type: 'float',
            public: false,
        });
        return config;
    },
})
export class Test1664Plugin {}

However, when using this plugin and opening the product detail page in the Admin UI, I do not see the error. Tried without and with a related Vendor assigned to the Product. Also tried in MariaDB and Postgres.

@vrosa, @Draykee are either of you able to modify this plugin to cause the error to show?

michaelbromley avatar Jul 19 '22 10:07 michaelbromley

@michaelbromley It took me a while but I found the trigger:

- import { LanguageCode, PluginCommonModule, Product, VendureEntity, VendurePlugin } from '@vendure/core';
+ import { LanguageCode, PluginCommonModule, Product, Translation, VendureEntity, VendurePlugin } from '@vendure/core'; 
import gql from 'graphql-tag';
import { Column, Entity, 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!
    }
`;

@VendurePlugin({
    imports: [PluginCommonModule],
-    entities: [Vendor],
+    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.Product.push({
            name: 'shopifyId',
            type: 'float',
            public: false,
        });
        return config;
    },
})
export class Test1664Plugin {}

Seems like the problem happens when the entity associated to a custom field has relationships of its own and loads those relations eagerly. Unfortunately I haven't found a fix but FindOptionsUtils.joinEagerRelations is what throws the error.

vrosa avatar Jul 20 '22 05:07 vrosa

Oh my that was a tough one. I've implemented a "fix" - more of a work-around of a TypeORM issue for now. Will release a patch with this in the coming day or so.

michaelbromley avatar Jul 20 '22 15:07 michaelbromley

Awesome! Even though I discovered this bug I wasn't really able to pinpoint the exact reason. Thanks, I'm already looking forward for the next patch!

Draykee avatar Jul 20 '22 17:07 Draykee

image

There is still an error for nested relations.

Draykee avatar Jul 22 '22 08:07 Draykee

Hi @Draykee, are you able to modify the test plugin above (source here) to reproduce that error? And also include the minimal query that reproduces it?

michaelbromley avatar Jul 22 '22 09:07 michaelbromley

I think you just need to add

config.customFields.User.push({ // and then resolve customer -> user -> customFields -> vendor
            name: 'vendor',
            label: [{ languageCode: LanguageCode.en_AU, value: 'Vendor' }],
            type: 'relation',
            entity: Vendor,
            eager: true,
            nullable: false,
            defaultValue: null
        });

But I'm not too sure on how to use this test plugin within the dev-server directory

Draykee avatar Jul 29 '22 16:07 Draykee

I finally found one of the errors. I have the relation Product -> User -> Profile (-> Asset) and I need it to get eagerly loaded to access the properties for the elasticsearch indexing.

// Product
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.
});
        

With profile:

@Entity('profile')
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[];
}

@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;
}

For indexing I want this data to be present:

ownerProfileName: {
                graphQlType: 'String!',
                valueFn: (product: Product) => (product.customFields as any).owner.customFields.profile.name,
            },

Basically every time I try to eagerly load the User I get the error.

Draykee avatar Oct 27 '22 18:10 Draykee

Hi @michaelbromley , try this plugin to reproduce the error when accessing an order detail page. I used PostgreSQL and received an error like this:

image

I still believe it may have something to do with same relations being eagerly loaded:

  • Product -> User -> Profile -> Asset
  • Order -> User -> Profile -> Asset

Here is the updated plugin code:

import { OnApplicationBootstrap } from '@nestjs/common';
import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
import {
    Asset,
    Channel,
    CustomOrderFields,
    CustomProductFields,
    Order,
    OrderService,
    PluginCommonModule,
    Product, RequestContext,
    TransactionalConnection,
    User,
    VendurePlugin,
} from '@vendure/core';
import gql from 'graphql-tag';

import { ProfileAsset } from './profile-asset.entity';
import { Profile } from './profile.entity';

declare module '@vendure/core' {
    interface CustomOrderFields {
        productOwner: User;
    }
    interface CustomProductFields {
        owner: User;
    }
}

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 => {
        // Order
        config.customFields.Order.push({
            name: 'productOwner', // because orders are always c2c (and should be stored redundant in case product get's deleted)
            nullable: true,
            type: 'relation',
            entity: User,
            public: false,
            eager: true,
            readonly: true,
        });
        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, private orderService: OrderService) {}

    async onApplicationBootstrap() {
        await this.createDummyProfiles();
        await this.createDummyOrder();
    }

    async createDummyProfiles() {
        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);
        }
    }

    async createDummyOrder() {
        const orderCount = await this.connection.rawConnection.getRepository(Order).count();
        if (0 < orderCount) {
            return;
        }

        const defaultChannel = await this.connection.getRepository(Channel).findOne({
            relations: ['defaultShippingZone', 'defaultTaxZone'],
            where: {
                code: DEFAULT_CHANNEL_CODE,
            },
        });

        if (!defaultChannel) {
            throw new Error(`Channel with code ${DEFAULT_CHANNEL_CODE} could not be found.`);
        }

        const ctx = new RequestContext({
            apiType: 'shop',
            authorizedAsOwnerOnly: false,
            channel: defaultChannel,
            isAuthorized: true,
            languageCode: defaultChannel.defaultLanguageCode,
        });

        // Create order
        const users = await this.connection.rawConnection.getRepository(User).find();
        // tslint:disable-next-line:no-non-null-assertion
        const customer = users[1]!;
        const created = await this.orderService.create(ctx, customer.id);

        // Add products
        const products = await this.connection.rawConnection.getRepository(Product).find({relations: ['variants']});
        const product = products[0];
        await this.orderService.addItemToOrder(ctx, created.id, product.variants[0].id, 1);
        // Add the product owner to order
        const productOwner = product.customFields.owner;
        await this.orderService.updateCustomFields(ctx, created.id, { productOwner })
    }
}

Draykee avatar Nov 19 '22 12:11 Draykee

With that last commit, it seems this can be closed. If anyone still encounters this after v1.8.5, please comment below.

michaelbromley avatar Dec 08 '22 13:12 michaelbromley

In Vendure 1.9.1 the following plugin causes the same error when you try to open the product page in the Admin (GetProductWithVariants)

Using the @RelationId decorator causes the error. If you remove that, it works fine.

import { Asset, DeepPartial, LanguageCode, PluginCommonModule, VendureEntity, VendurePlugin } from '@vendure/core';
import { gql } from 'apollo-server-core';
import { Entity, ManyToOne, RelationId } from 'typeorm';

@Entity('dropme_widget')
export class Widget extends VendureEntity {
  constructor(input?: DeepPartial<Widget>) {
    super(input);
  }

  @ManyToOne(() => Asset, { nullable: true })
  asset: Asset | null;

  @RelationId((widget: Widget) => widget.asset)
  assetId: string;
}

export const schema = gql`
  type Widget {
    asset: Asset!
  }
`;

declare module '@vendure/core/dist/entity/custom-entity-fields' {
  interface CustomProductFields {
    widget: Widget | null;
  }
}

@VendurePlugin({
  imports: [PluginCommonModule],
  providers: [],
  entities: [Widget],
  controllers: [],
  adminApiExtensions: { schema },
  shopApiExtensions: { schema },
  configuration: config => {
    config.customFields.Product.push({
      name: 'widget',
      public: true,
      type: 'relation',
      entity: Widget,
      nullable: true,
      description: [{ languageCode: LanguageCode.en, value: 'Widget' }],
      label: [{ languageCode: LanguageCode.en, value: 'Widget' }],
    });

    return config;
  },
})
export class WidgetErrorPlugin {
  constructor() {}
}

skid avatar Jan 01 '23 22:01 skid

Possibly related:

  • https://github.com/typeorm/typeorm/issues/9944

michaelbromley avatar Apr 12 '23 11:04 michaelbromley