Bläddra i källkod

feat(core): Implement "relation" custom field type

Relates to #308, Relates to #464
Michael Bromley 5 år sedan
förälder
incheckning
3e1a9000e9
40 ändrade filer med 1753 tillägg och 191 borttagningar
  1. 84 3
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 2 0
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 14 1
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 15 1
      packages/common/src/generated-shop-types.ts
  5. 14 1
      packages/common/src/generated-types.ts
  6. 11 2
      packages/common/src/shared-types.ts
  7. 14 0
      packages/common/src/shared-utils.ts
  8. 859 0
      packages/core/e2e/custom-field-relations.e2e-spec.ts
  9. 15 1
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  10. 14 1
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  11. 11 5
      packages/core/src/api/api-internal-modules.ts
  12. 1 0
      packages/core/src/api/api.module.ts
  13. 89 0
      packages/core/src/api/common/custom-field-relation-resolver.service.ts
  14. 1 0
      packages/core/src/api/common/validate-custom-field-value.ts
  15. 19 97
      packages/core/src/api/config/configure-graphql-module.ts
  16. 205 0
      packages/core/src/api/config/generate-resolvers.ts
  17. 59 20
      packages/core/src/api/config/graphql-custom-fields.ts
  18. 0 1
      packages/core/src/api/resolvers/admin/collection.resolver.ts
  19. 7 7
      packages/core/src/api/resolvers/admin/facet.resolver.ts
  20. 63 11
      packages/core/src/api/resolvers/admin/global-settings.resolver.ts
  21. 20 1
      packages/core/src/api/schema/common/custom-field-types.graphql
  22. 10 2
      packages/core/src/config/custom-field/custom-field-types.ts
  23. 69 31
      packages/core/src/entity/register-custom-entity-fields.ts
  24. 69 0
      packages/core/src/service/helpers/custom-field-relation/custom-field-relation.service.ts
  25. 2 0
      packages/core/src/service/service.module.ts
  26. 4 0
      packages/core/src/service/services/collection.service.ts
  27. 6 1
      packages/core/src/service/services/customer.service.ts
  28. 9 0
      packages/core/src/service/services/facet-value.service.ts
  29. 4 0
      packages/core/src/service/services/facet.service.ts
  30. 7 1
      packages/core/src/service/services/global-settings.service.ts
  31. 3 0
      packages/core/src/service/services/order.service.ts
  32. 8 1
      packages/core/src/service/services/product-option-group.service.ts
  33. 13 1
      packages/core/src/service/services/product-option.service.ts
  34. 4 0
      packages/core/src/service/services/product-variant.service.ts
  35. 4 0
      packages/core/src/service/services/product.service.ts
  36. 9 1
      packages/core/src/service/services/shipping-method.service.ts
  37. 14 1
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  38. 0 0
      schema-admin.json
  39. 0 0
      schema-shop.json
  40. 1 0
      scripts/codegen/generate-graphql-types.ts

+ 84 - 3
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -3330,13 +3330,24 @@ export type DateTimeCustomFieldConfig = CustomField & {
   step?: Maybe<Scalars['Int']>;
 };
 
+export type RelationCustomFieldConfig = CustomField & {
+  __typename?: 'RelationCustomFieldConfig';
+  name: Scalars['String'];
+  type: Scalars['String'];
+  list: Scalars['Boolean'];
+  label?: Maybe<Array<LocalizedString>>;
+  description?: Maybe<Array<LocalizedString>>;
+  readonly?: Maybe<Scalars['Boolean']>;
+  internal?: Maybe<Scalars['Boolean']>;
+};
+
 export type LocalizedString = {
   __typename?: 'LocalizedString';
   languageCode: LanguageCode;
   value: Scalars['String'];
 };
 
-export type CustomFieldConfig = StringCustomFieldConfig | LocaleStringCustomFieldConfig | IntCustomFieldConfig | FloatCustomFieldConfig | BooleanCustomFieldConfig | DateTimeCustomFieldConfig;
+export type CustomFieldConfig = StringCustomFieldConfig | LocaleStringCustomFieldConfig | IntCustomFieldConfig | FloatCustomFieldConfig | BooleanCustomFieldConfig | DateTimeCustomFieldConfig | RelationCustomFieldConfig;
 
 export type CustomerGroup = Node & {
   __typename?: 'CustomerGroup';
@@ -6283,6 +6294,10 @@ export type CreateAssetsMutationVariables = Exact<{
 
 export type CreateAssetsMutation = { createAssets: Array<(
     { __typename?: 'Asset' }
+    & { tags: Array<(
+      { __typename?: 'Tag' }
+      & TagFragment
+    )> }
     & AssetFragment
   ) | (
     { __typename?: 'MimeTypeError' }
@@ -6296,6 +6311,10 @@ export type UpdateAssetMutationVariables = Exact<{
 
 export type UpdateAssetMutation = { updateAsset: (
     { __typename?: 'Asset' }
+    & { tags: Array<(
+      { __typename?: 'Tag' }
+      & TagFragment
+    )> }
     & AssetFragment
   ) };
 
@@ -7142,7 +7161,19 @@ type CustomFieldConfig_DateTimeCustomFieldConfig_Fragment = (
   )>> }
 );
 
-export type CustomFieldConfigFragment = CustomFieldConfig_StringCustomFieldConfig_Fragment | CustomFieldConfig_LocaleStringCustomFieldConfig_Fragment | CustomFieldConfig_IntCustomFieldConfig_Fragment | CustomFieldConfig_FloatCustomFieldConfig_Fragment | CustomFieldConfig_BooleanCustomFieldConfig_Fragment | CustomFieldConfig_DateTimeCustomFieldConfig_Fragment;
+type CustomFieldConfig_RelationCustomFieldConfig_Fragment = (
+  { __typename?: 'RelationCustomFieldConfig' }
+  & Pick<RelationCustomFieldConfig, 'name' | 'type' | 'list' | 'readonly'>
+  & { description?: Maybe<Array<(
+    { __typename?: 'LocalizedString' }
+    & Pick<LocalizedString, 'languageCode' | 'value'>
+  )>>, label?: Maybe<Array<(
+    { __typename?: 'LocalizedString' }
+    & Pick<LocalizedString, 'languageCode' | 'value'>
+  )>> }
+);
+
+export type CustomFieldConfigFragment = CustomFieldConfig_StringCustomFieldConfig_Fragment | CustomFieldConfig_LocaleStringCustomFieldConfig_Fragment | CustomFieldConfig_IntCustomFieldConfig_Fragment | CustomFieldConfig_FloatCustomFieldConfig_Fragment | CustomFieldConfig_BooleanCustomFieldConfig_Fragment | CustomFieldConfig_DateTimeCustomFieldConfig_Fragment | CustomFieldConfig_RelationCustomFieldConfig_Fragment;
 
 export type StringCustomFieldFragment = (
   { __typename?: 'StringCustomFieldConfig' }
@@ -7217,7 +7248,9 @@ type CustomFields_DateTimeCustomFieldConfig_Fragment = (
   & DateTimeCustomFieldFragment
 );
 
-export type CustomFieldsFragment = CustomFields_StringCustomFieldConfig_Fragment | CustomFields_LocaleStringCustomFieldConfig_Fragment | CustomFields_IntCustomFieldConfig_Fragment | CustomFields_FloatCustomFieldConfig_Fragment | CustomFields_BooleanCustomFieldConfig_Fragment | CustomFields_DateTimeCustomFieldConfig_Fragment;
+type CustomFields_RelationCustomFieldConfig_Fragment = { __typename?: 'RelationCustomFieldConfig' };
+
+export type CustomFieldsFragment = CustomFields_StringCustomFieldConfig_Fragment | CustomFields_LocaleStringCustomFieldConfig_Fragment | CustomFields_IntCustomFieldConfig_Fragment | CustomFields_FloatCustomFieldConfig_Fragment | CustomFields_BooleanCustomFieldConfig_Fragment | CustomFields_DateTimeCustomFieldConfig_Fragment | CustomFields_RelationCustomFieldConfig_Fragment;
 
 export type GetServerConfigQueryVariables = Exact<{ [key: string]: never; }>;
 
@@ -7254,6 +7287,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, Collection: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7272,6 +7308,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, Customer: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7290,6 +7329,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, Facet: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7308,6 +7350,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, FacetValue: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7326,6 +7371,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, Fulfillment: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7344,6 +7392,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, GlobalSettings: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7362,6 +7413,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, Order: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7380,6 +7434,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, OrderLine: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7398,6 +7455,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, Product: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7416,6 +7476,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, ProductOption: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7434,6 +7497,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, ProductOptionGroup: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7452,6 +7518,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, ProductVariant: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7470,6 +7539,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, ShippingMethod: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7488,6 +7560,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )>, User: Array<(
           { __typename?: 'StringCustomFieldConfig' }
           & CustomFields_StringCustomFieldConfig_Fragment
@@ -7506,6 +7581,9 @@ export type GetServerConfigQuery = { globalSettings: (
         ) | (
           { __typename?: 'DateTimeCustomFieldConfig' }
           & CustomFields_DateTimeCustomFieldConfig_Fragment
+        ) | (
+          { __typename?: 'RelationCustomFieldConfig' }
+          & CustomFields_RelationCustomFieldConfig_Fragment
         )> }
       ) }
     ) }
@@ -8638,6 +8716,8 @@ export namespace CreateAssets {
   export type Variables = CreateAssetsMutationVariables;
   export type Mutation = CreateAssetsMutation;
   export type CreateAssets = NonNullable<(NonNullable<CreateAssetsMutation['createAssets']>)[number]>;
+  export type AssetInlineFragment = (DiscriminateUnion<NonNullable<(NonNullable<CreateAssetsMutation['createAssets']>)[number]>, { __typename?: 'Asset' }>);
+  export type Tags = NonNullable<(NonNullable<(DiscriminateUnion<NonNullable<(NonNullable<CreateAssetsMutation['createAssets']>)[number]>, { __typename?: 'Asset' }>)['tags']>)[number]>;
   export type ErrorResultInlineFragment = (DiscriminateUnion<NonNullable<(NonNullable<CreateAssetsMutation['createAssets']>)[number]>, { __typename?: 'ErrorResult' }>);
 }
 
@@ -8645,6 +8725,7 @@ export namespace UpdateAsset {
   export type Variables = UpdateAssetMutationVariables;
   export type Mutation = UpdateAssetMutation;
   export type UpdateAsset = (NonNullable<UpdateAssetMutation['updateAsset']>);
+  export type Tags = NonNullable<(NonNullable<(NonNullable<UpdateAssetMutation['updateAsset']>)['tags']>)[number]>;
 }
 
 export namespace DeleteAssets {

+ 2 - 0
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -172,6 +172,7 @@ const result: PossibleTypesResultData = {
             'FloatCustomFieldConfig',
             'BooleanCustomFieldConfig',
             'DateTimeCustomFieldConfig',
+            'RelationCustomFieldConfig',
         ],
         CustomFieldConfig: [
             'StringCustomFieldConfig',
@@ -180,6 +181,7 @@ const result: PossibleTypesResultData = {
             'FloatCustomFieldConfig',
             'BooleanCustomFieldConfig',
             'DateTimeCustomFieldConfig',
+            'RelationCustomFieldConfig',
         ],
         SearchResultPrice: ['PriceRange', 'SinglePrice'],
     },

+ 14 - 1
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -3088,6 +3088,18 @@ export type DateTimeCustomFieldConfig = CustomField & {
     step?: Maybe<Scalars['Int']>;
 };
 
+export type RelationCustomFieldConfig = CustomField & {
+    name: Scalars['String'];
+    type: Scalars['String'];
+    list: Scalars['Boolean'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    readonly?: Maybe<Scalars['Boolean']>;
+    internal?: Maybe<Scalars['Boolean']>;
+    entity: Scalars['String'];
+    scalarFields: Array<Scalars['String']>;
+};
+
 export type LocalizedString = {
     languageCode: LanguageCode;
     value: Scalars['String'];
@@ -3099,7 +3111,8 @@ export type CustomFieldConfig =
     | IntCustomFieldConfig
     | FloatCustomFieldConfig
     | BooleanCustomFieldConfig
-    | DateTimeCustomFieldConfig;
+    | DateTimeCustomFieldConfig
+    | RelationCustomFieldConfig;
 
 export type CustomerGroup = Node & {
     id: Scalars['ID'];

+ 15 - 1
packages/common/src/generated-shop-types.ts

@@ -1234,6 +1234,19 @@ export type DateTimeCustomFieldConfig = CustomField & {
     step?: Maybe<Scalars['Int']>;
 };
 
+export type RelationCustomFieldConfig = CustomField & {
+    __typename?: 'RelationCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    list: Scalars['Boolean'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    readonly?: Maybe<Scalars['Boolean']>;
+    internal?: Maybe<Scalars['Boolean']>;
+    entity: Scalars['String'];
+    scalarFields: Array<Scalars['String']>;
+};
+
 export type LocalizedString = {
     __typename?: 'LocalizedString';
     languageCode: LanguageCode;
@@ -1246,7 +1259,8 @@ export type CustomFieldConfig =
     | IntCustomFieldConfig
     | FloatCustomFieldConfig
     | BooleanCustomFieldConfig
-    | DateTimeCustomFieldConfig;
+    | DateTimeCustomFieldConfig
+    | RelationCustomFieldConfig;
 
 export type CustomerGroup = Node & {
     __typename?: 'CustomerGroup';

+ 14 - 1
packages/common/src/generated-types.ts

@@ -3292,13 +3292,26 @@ export type DateTimeCustomFieldConfig = CustomField & {
   step?: Maybe<Scalars['Int']>;
 };
 
+export type RelationCustomFieldConfig = CustomField & {
+  __typename?: 'RelationCustomFieldConfig';
+  name: Scalars['String'];
+  type: Scalars['String'];
+  list: Scalars['Boolean'];
+  label?: Maybe<Array<LocalizedString>>;
+  description?: Maybe<Array<LocalizedString>>;
+  readonly?: Maybe<Scalars['Boolean']>;
+  internal?: Maybe<Scalars['Boolean']>;
+  entity: Scalars['String'];
+  scalarFields: Array<Scalars['String']>;
+};
+
 export type LocalizedString = {
   __typename?: 'LocalizedString';
   languageCode: LanguageCode;
   value: Scalars['String'];
 };
 
-export type CustomFieldConfig = StringCustomFieldConfig | LocaleStringCustomFieldConfig | IntCustomFieldConfig | FloatCustomFieldConfig | BooleanCustomFieldConfig | DateTimeCustomFieldConfig;
+export type CustomFieldConfig = StringCustomFieldConfig | LocaleStringCustomFieldConfig | IntCustomFieldConfig | FloatCustomFieldConfig | BooleanCustomFieldConfig | DateTimeCustomFieldConfig | RelationCustomFieldConfig;
 
 export type CustomerGroup = Node & {
   __typename?: 'CustomerGroup';

+ 11 - 2
packages/common/src/shared-types.ts

@@ -92,7 +92,14 @@ export type ID = string | number;
  *
  * @docsCategory custom-fields
  */
-export type CustomFieldType = 'string' | 'localeString' | 'int' | 'float' | 'boolean' | 'datetime';
+export type CustomFieldType =
+    | 'string'
+    | 'localeString'
+    | 'int'
+    | 'float'
+    | 'boolean'
+    | 'datetime'
+    | 'relation';
 
 /**
  * @description
@@ -125,7 +132,8 @@ export type DefaultFormComponentId =
     | 'product-selector-form-input'
     | 'customer-group-form-input'
     | 'text-form-input'
-    | 'password-form-input';
+    | 'password-form-input'
+    | 'relation-form-input';
 
 /**
  * @description
@@ -146,6 +154,7 @@ type DefaultFormConfigHash = {
     'customer-group-form-input': {};
     'text-form-input': {};
     'password-form-input': {};
+    'relation-form-input': {};
 };
 
 export type DefaultFormComponentConfig<T extends DefaultFormComponentId> = DefaultFormConfigHash[T];

+ 14 - 0
packages/common/src/shared-utils.ts

@@ -1,3 +1,5 @@
+import { CustomFieldConfig } from './generated-types';
+
 /**
  * Predicate with type guard, used to filter out null or undefined values
  * in a filter operation.
@@ -79,3 +81,15 @@ export function generateAllCombinations<T>(
         return output;
     }
 }
+
+/**
+ * @description
+ * Returns the input field name of a relation custom field.
+ */
+export function getGraphQlInputName(config: { name: string; type: string; list?: boolean }): string {
+    if (config.type === 'relation') {
+        return config.list === true ? `${config.name}Ids` : `${config.name}Id`;
+    } else {
+        return config.name;
+    }
+}

+ 859 - 0
packages/core/e2e/custom-field-relations.e2e-spec.ts

@@ -0,0 +1,859 @@
+import {
+    Asset,
+    Collection,
+    Country,
+    CustomFields,
+    defaultShippingCalculator,
+    defaultShippingEligibilityChecker,
+    Facet,
+    FacetValue,
+    manualFulfillmentHandler,
+    mergeConfig,
+    Product,
+    ProductOption,
+    ProductOptionGroup,
+    ProductVariant,
+    ShippingMethod,
+} 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 { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+
+import { AddItemToOrder } from './graphql/generated-e2e-shop-types';
+import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
+import { sortById } from './utils/test-order-utils';
+
+// From https://github.com/microsoft/TypeScript/issues/13298#issuecomment-654906323
+// to ensure that we _always_ test all entities which support custom fields
+type ValueOf<T> = T[keyof T];
+type NonEmptyArray<T> = [T, ...T[]];
+type MustInclude<T, U extends T[]> = [T] extends [ValueOf<U>] ? U : never;
+const enumerate = <T>() => <U extends NonEmptyArray<T>>(...elements: MustInclude<T, U>) => elements;
+
+const entitiesWithCustomFields = enumerate<keyof CustomFields>()(
+    'Address',
+    'Collection',
+    'Customer',
+    'Facet',
+    'FacetValue',
+    'Fulfillment',
+    'GlobalSettings',
+    'Order',
+    'OrderLine',
+    'Product',
+    'ProductOption',
+    'ProductOptionGroup',
+    'ProductVariant',
+    'User',
+    'ShippingMethod',
+);
+
+const customFieldConfig: CustomFields = {};
+for (const entity of entitiesWithCustomFields) {
+    customFieldConfig[entity] = [
+        { name: 'single', type: 'relation', entity: Asset, graphQLType: 'Asset', list: false },
+        { name: 'multi', type: 'relation', entity: Asset, graphQLType: 'Asset', list: true },
+    ];
+}
+customFieldConfig.Product?.push(
+    { name: 'cfCollection', type: 'relation', entity: Collection, list: false },
+    { name: 'cfCountry', type: 'relation', entity: Country, list: false },
+    { name: 'cfFacetValue', type: 'relation', entity: FacetValue, list: false },
+    { name: 'cfFacet', type: 'relation', entity: Facet, list: false },
+    { name: 'cfProductOptionGroup', type: 'relation', entity: ProductOptionGroup, list: false },
+    { name: 'cfProductOption', type: 'relation', entity: ProductOption, list: false },
+    { name: 'cfProductVariant', type: 'relation', entity: ProductVariant, list: false },
+    { name: 'cfProduct', type: 'relation', entity: Product, list: false },
+    { name: 'cfShippingMethod', type: 'relation', entity: ShippingMethod, list: false },
+);
+
+const customConfig = mergeConfig(testConfig, {
+    dbConnectionOptions: {
+        timezone: 'Z',
+    },
+    customFields: customFieldConfig,
+});
+
+describe('Custom field relations', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(customConfig);
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 3,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('customFieldConfig query returns entity and scalar fields', async () => {
+        const { globalSettings } = await adminClient.query(gql`
+            query {
+                globalSettings {
+                    serverConfig {
+                        customFieldConfig {
+                            Customer {
+                                ... on RelationCustomFieldConfig {
+                                    name
+                                    entity
+                                    scalarFields
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        `);
+
+        const single = globalSettings.serverConfig.customFieldConfig.Customer[0];
+        expect(single.entity).toBe('Asset');
+        expect(single.scalarFields).toEqual([
+            'id',
+            'createdAt',
+            'updatedAt',
+            'name',
+            'type',
+            'fileSize',
+            'mimeType',
+            'width',
+            'height',
+            'source',
+            'preview',
+        ]);
+    });
+
+    describe('special data resolution', () => {
+        let productId: string;
+
+        it('translatable entities get translated', async () => {
+            const { createProduct } = await adminClient.query(gql`
+                mutation {
+                    createProduct(
+                        input: {
+                            translations: [
+                                {
+                                    languageCode: en
+                                    name: "Test product"
+                                    description: ""
+                                    slug: "test-product"
+                                }
+                            ]
+                            customFields: {
+                                cfCollectionId: "T_1"
+                                cfCountryId: "T_1"
+                                cfFacetValueId: "T_1"
+                                cfFacetId: "T_1"
+                                cfProductOptionGroupId: "T_1"
+                                cfProductOptionId: "T_1"
+                                cfProductVariantId: "T_1"
+                                cfProductId: "T_1"
+                                cfShippingMethodId: "T_1"
+                            }
+                        }
+                    ) {
+                        id
+                        customFields {
+                            cfCollection {
+                                languageCode
+                                name
+                            }
+                            cfCountry {
+                                languageCode
+                                name
+                            }
+                            cfFacetValue {
+                                languageCode
+                                name
+                            }
+                            cfFacet {
+                                languageCode
+                                name
+                            }
+                            cfProductOptionGroup {
+                                languageCode
+                                name
+                            }
+                            cfProductOption {
+                                languageCode
+                                name
+                            }
+                            cfProductVariant {
+                                languageCode
+                                name
+                            }
+                            cfProduct {
+                                languageCode
+                                name
+                            }
+                            cfShippingMethod {
+                                name
+                            }
+                        }
+                    }
+                }
+            `);
+
+            productId = createProduct.id;
+
+            expect(createProduct.customFields.cfCollection).toEqual({
+                languageCode: 'en',
+                name: '__root_collection__',
+            });
+            expect(createProduct.customFields.cfCountry).toEqual({ languageCode: 'en', name: 'Australia' });
+            expect(createProduct.customFields.cfFacetValue).toEqual({
+                languageCode: 'en',
+                name: 'electronics',
+            });
+            expect(createProduct.customFields.cfFacet).toEqual({ languageCode: 'en', name: 'category' });
+            expect(createProduct.customFields.cfProductOptionGroup).toEqual({
+                languageCode: 'en',
+                name: 'screen size',
+            });
+            expect(createProduct.customFields.cfProductOption).toEqual({
+                languageCode: 'en',
+                name: '13 inch',
+            });
+            expect(createProduct.customFields.cfProductVariant).toEqual({
+                languageCode: 'en',
+                name: 'Laptop 13 inch 8GB',
+            });
+            expect(createProduct.customFields.cfProduct).toEqual({ languageCode: 'en', name: 'Laptop' });
+            expect(createProduct.customFields.cfShippingMethod).toEqual({ name: 'Standard Shipping' });
+        });
+
+        it('ProductVariant prices get resolved', async () => {
+            const { product } = await adminClient.query(gql`
+                query {
+                    product(id: "${productId}") {
+                        id
+                        customFields {
+                            cfProductVariant {
+                                price
+                                currencyCode
+                                priceWithTax
+                            }
+                        }
+                    }
+            }`);
+
+            expect(product.customFields.cfProductVariant).toEqual({
+                price: 129900,
+                currencyCode: 'USD',
+                priceWithTax: 155880,
+            });
+        });
+    });
+
+    describe('entity-specific implementation', () => {
+        function assertCustomFieldIds(customFields: any, single: string, multi: string[]) {
+            expect(customFields.single).toEqual({ id: single });
+            expect(customFields.multi.sort(sortById)).toEqual(multi.map(id => ({ id })));
+        }
+        const customFieldsSelection = `
+            customFields {
+                single {
+                    id
+                }
+                multi {
+                    id
+                }
+            }`;
+
+        describe('Address entity', () => {
+            it('admin createCustomerAddress', async () => {
+                const { createCustomerAddress } = await adminClient.query(gql`
+                        mutation {
+                            createCustomerAddress(
+                                customerId: "T_1"
+                                input: {
+                                    countryCode: "GB"
+                                    streetLine1: "Test Street"
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createCustomerAddress.customFields, 'T_1', ['T_1', 'T_2']);
+            });
+
+            it('shop createCustomerAddress', async () => {
+                await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+                const { createCustomerAddress } = await shopClient.query(gql`
+                        mutation {
+                            createCustomerAddress(
+                                input: {
+                                    countryCode: "GB"
+                                    streetLine1: "Test Street"
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createCustomerAddress.customFields, 'T_1', ['T_1', 'T_2']);
+            });
+
+            it('admin updateCustomerAddress', async () => {
+                const { updateCustomerAddress } = await adminClient.query(gql`
+                        mutation {
+                            updateCustomerAddress(
+                                input: { id: "T_1", customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] } }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(updateCustomerAddress.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+
+            it('shop updateCustomerAddress', async () => {
+                const { updateCustomerAddress } = await shopClient.query(gql`
+                        mutation {
+                            updateCustomerAddress(
+                                input: { id: "T_1", customFields: { singleId: "T_3", multiIds: ["T_4", "T_2"] } }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(updateCustomerAddress.customFields, 'T_3', ['T_2', 'T_4']);
+            });
+        });
+
+        describe('Collection entity', () => {
+            let collectionId: string;
+            it('admin createCollection', async () => {
+                const { createCollection } = await adminClient.query(gql`
+                        mutation {
+                            createCollection(
+                                input: {
+                                    translations: [
+                                        { languageCode: en, name: "Test", description: "test", slug: "test" }
+                                    ]
+                                    filters: []
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(createCollection.customFields, 'T_1', ['T_1', 'T_2']);
+                collectionId = createCollection.id;
+            });
+
+            it('admin updateCollection', async () => {
+                const { updateCollection } = await adminClient.query(gql`
+                        mutation {
+                            updateCollection(
+                                input: {
+                                    id: "${collectionId}"
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(updateCollection.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+        });
+
+        describe('Customer entity', () => {
+            let customerId: string;
+            it('admin createCustomer', async () => {
+                const { createCustomer } = await adminClient.query(gql`
+                        mutation {
+                            createCustomer(
+                                input: {
+                                    emailAddress: "test@test.com"
+                                    firstName: "Test"
+                                    lastName: "Person"
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                ... on Customer {
+                                    id
+                                    ${customFieldsSelection}
+                                }
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createCustomer.customFields, 'T_1', ['T_1', 'T_2']);
+                customerId = createCustomer.id;
+            });
+
+            it('admin updateCustomer', async () => {
+                const { updateCustomer } = await adminClient.query(gql`
+                        mutation {
+                            updateCustomer(
+                                input: {
+                                    id: "${customerId}"
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                ...on Customer {
+                                    id
+                                    ${customFieldsSelection}
+                                }
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateCustomer.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+
+            it('shop updateCustomer', async () => {
+                const { updateCustomer } = await shopClient.query(gql`
+                        mutation {
+                            updateCustomer(input: { customFields: { singleId: "T_4", multiIds: ["T_2", "T_4"] } }) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateCustomer.customFields, 'T_4', ['T_2', 'T_4']);
+            });
+        });
+
+        describe('Facet entity', () => {
+            let facetId: string;
+            it('admin createFacet', async () => {
+                const { createFacet } = await adminClient.query(gql`
+                        mutation {
+                            createFacet(
+                                input: {
+                                    code: "test"
+                                    isPrivate: false
+                                    translations: [{ languageCode: en, name: "test" }]
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createFacet.customFields, 'T_1', ['T_1', 'T_2']);
+                facetId = createFacet.id;
+            });
+
+            it('admin updateFacet', async () => {
+                const { updateFacet } = await adminClient.query(gql`
+                        mutation {
+                            updateFacet(
+                                input: {
+                                    id: "${facetId}"
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateFacet.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+        });
+
+        describe('FacetValue entity', () => {
+            let facetValueId: string;
+            it('admin createFacetValues', async () => {
+                const { createFacetValues } = await adminClient.query(gql`
+                        mutation {
+                            createFacetValues(
+                                input: {
+                                    code: "test"
+                                    facetId: "T_1"
+                                    translations: [{ languageCode: en, name: "test" }]
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createFacetValues[0].customFields, 'T_1', ['T_1', 'T_2']);
+                facetValueId = createFacetValues[0].id;
+            });
+
+            it('admin updateFacetValues', async () => {
+                const { updateFacetValues } = await adminClient.query(gql`
+                        mutation {
+                            updateFacetValues(
+                                input: {
+                                    id: "${facetValueId}"
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateFacetValues[0].customFields, 'T_2', ['T_3', 'T_4']);
+            });
+        });
+
+        describe('Fulfillment entity', () => {
+            // Currently no GraphQL API to set customFields on fulfillments
+        });
+
+        describe('GlobalSettings entity', () => {
+            it('admin updateGlobalSettings', async () => {
+                const { updateGlobalSettings } = await adminClient.query(gql`
+                        mutation {
+                            updateGlobalSettings(
+                                input: {
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                ... on GlobalSettings {
+                                    id
+                                    ${customFieldsSelection}
+                                }
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateGlobalSettings.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+        });
+
+        describe('Order entity', () => {
+            let orderId: string;
+
+            beforeAll(async () => {
+                const { addItemToOrder } = await shopClient.query<any, AddItemToOrder.Variables>(
+                    ADD_ITEM_TO_ORDER,
+                    {
+                        productVariantId: 'T_1',
+                        quantity: 1,
+                    },
+                );
+
+                orderId = addItemToOrder.id;
+            });
+
+            it('shop setOrderCustomFields', async () => {
+                const { setOrderCustomFields } = await shopClient.query(gql`
+                        mutation {
+                            setOrderCustomFields(
+                                input: {
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                ... on Order {
+                                    id
+                                    ${customFieldsSelection}
+                                }
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(setOrderCustomFields.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+
+            it('admin setOrderCustomFields', async () => {
+                const { setOrderCustomFields } = await adminClient.query(gql`
+                        mutation {
+                            setOrderCustomFields(
+                                input: {
+                                    id: "${orderId}"
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                ... on Order {
+                                    id
+                                    ${customFieldsSelection}
+                                }
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(setOrderCustomFields.customFields, 'T_1', ['T_1', 'T_2']);
+            });
+        });
+
+        describe('OrderLine entity', () => {
+            it('shop addItemToOrder', async () => {
+                const { addItemToOrder } = await shopClient.query(
+                    gql`mutation {
+                            addItemToOrder(productVariantId: "T_1", quantity: 1, customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }) {
+                                ... on Order {
+                                    id
+                                    ${customFieldsSelection}
+                                }
+                            }
+                        }`,
+                );
+
+                assertCustomFieldIds(addItemToOrder.customFields, 'T_1', ['T_1', 'T_2']);
+            });
+        });
+
+        describe('Product, ProductVariant entity', () => {
+            let productId: string;
+            it('admin createProduct', async () => {
+                const { createProduct } = await adminClient.query(gql`
+                        mutation {
+                            createProduct(
+                                input: {
+                                    translations: [{ languageCode: en, name: "test" slug: "test" description: "test" }]
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createProduct.customFields, 'T_1', ['T_1', 'T_2']);
+                productId = createProduct.id;
+            });
+
+            it('admin updateProduct', async () => {
+                const { updateProduct } = await adminClient.query(gql`
+                        mutation {
+                            updateProduct(
+                                input: {
+                                    id: "${productId}"
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateProduct.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+
+            let productVariantId: string;
+            it('admin createProductVariant', async () => {
+                const { createProductVariants } = await adminClient.query(gql`
+                        mutation {
+                            createProductVariants(
+                                input: [{
+                                    sku: "TEST01"
+                                    productId: "${productId}"
+                                    translations: [{ languageCode: en, name: "test" }]
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }]
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createProductVariants[0].customFields, 'T_1', ['T_1', 'T_2']);
+                productVariantId = createProductVariants[0].id;
+            });
+
+            it('admin updateProductVariant', async () => {
+                const { updateProductVariants } = await adminClient.query(gql`
+                        mutation {
+                            updateProductVariants(
+                                input: [{
+                                    id: "${productVariantId}"
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }]
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateProductVariants[0].customFields, 'T_2', ['T_3', 'T_4']);
+            });
+        });
+
+        describe('ProductOptionGroup, ProductOption entity', () => {
+            let productOptionGroupId: string;
+            it('admin createProductOptionGroup', async () => {
+                const { createProductOptionGroup } = await adminClient.query(gql`
+                        mutation {
+                            createProductOptionGroup(
+                                input: {
+                                    code: "test"
+                                    options: []
+                                    translations: [{ languageCode: en, name: "test" }]
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createProductOptionGroup.customFields, 'T_1', ['T_1', 'T_2']);
+                productOptionGroupId = createProductOptionGroup.id;
+            });
+
+            it('admin updateProductOptionGroup', async () => {
+                const { updateProductOptionGroup } = await adminClient.query(gql`
+                        mutation {
+                            updateProductOptionGroup(
+                                input: {
+                                    id: "${productOptionGroupId}"
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateProductOptionGroup.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+
+            let productOptionId: string;
+            it('admin createProductOption', async () => {
+                const { createProductOption } = await adminClient.query(gql`
+                        mutation {
+                            createProductOption(
+                                input: {
+                                    productOptionGroupId: "${productOptionGroupId}"
+                                    code: "test-option"
+                                    translations: [{ languageCode: en, name: "test-option" }]
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createProductOption.customFields, 'T_1', ['T_1', 'T_2']);
+                productOptionId = createProductOption.id;
+            });
+
+            it('admin updateProductOption', async () => {
+                const { updateProductOption } = await adminClient.query(gql`
+                        mutation {
+                            updateProductOption(
+                                input: {
+                                    id: "${productOptionId}"
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateProductOption.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+        });
+
+        describe('User entity', () => {
+            // Currently no GraphQL API to set User custom fields
+        });
+
+        describe('ShippingMethod entity', () => {
+            let shippingMethodId: string;
+            it('admin createShippingMethod', async () => {
+                const { createShippingMethod } = await adminClient.query(gql`
+                        mutation {
+                            createShippingMethod(
+                                input: {
+                                    code: "test"
+                                    calculator: {
+                                        code: "${defaultShippingCalculator.code}"
+                                        arguments: [
+                                            { name: "rate" value: "10"},
+                                            { name: "includesTax" value: "true"},
+                                            { name: "taxRate" value: "10"},
+                                        ]
+                                    }
+                                    checker: {
+                                        code: "${defaultShippingEligibilityChecker.code}"
+                                        arguments: [
+                                            { name: "orderMinimum" value: "0"},
+                                        ]
+                                    }
+                                    fulfillmentHandler: "${manualFulfillmentHandler.code}"
+                                    translations: [{ languageCode: en, name: "test" description: "test" }]
+                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+
+                assertCustomFieldIds(createShippingMethod.customFields, 'T_1', ['T_1', 'T_2']);
+                shippingMethodId = createShippingMethod.id;
+            });
+
+            it('admin updateShippingMethod', async () => {
+                const { updateShippingMethod } = await adminClient.query(gql`
+                        mutation {
+                            updateShippingMethod(
+                                input: {
+                                    id: "${shippingMethodId}"
+                                    translations: []
+                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                                }
+                            ) {
+                                id
+                                ${customFieldsSelection}
+                            }
+                        }
+                    `);
+                assertCustomFieldIds(updateShippingMethod.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+        });
+    });
+
+    it('null values', async () => {
+        const { updateCustomerAddress } = await adminClient.query(gql`
+            mutation {
+                updateCustomerAddress(
+                    input: { id: "T_1", customFields: { singleId: null, multiIds: ["T_1", "null"] } }
+                ) {
+                    id
+                    customFields {
+                        single {
+                            id
+                        }
+                        multi {
+                            id
+                        }
+                    }
+                }
+            }
+        `);
+
+        expect(updateCustomerAddress.customFields.single).toEqual(null);
+        expect(updateCustomerAddress.customFields.multi).toEqual([{ id: 'T_1' }]);
+    });
+});

+ 15 - 1
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -3088,6 +3088,18 @@ export type DateTimeCustomFieldConfig = CustomField & {
     step?: Maybe<Scalars['Int']>;
 };
 
+export type RelationCustomFieldConfig = CustomField & {
+    name: Scalars['String'];
+    type: Scalars['String'];
+    list: Scalars['Boolean'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    readonly?: Maybe<Scalars['Boolean']>;
+    internal?: Maybe<Scalars['Boolean']>;
+    entity: Scalars['String'];
+    scalarFields: Array<Scalars['String']>;
+};
+
 export type LocalizedString = {
     languageCode: LanguageCode;
     value: Scalars['String'];
@@ -3099,7 +3111,8 @@ export type CustomFieldConfig =
     | IntCustomFieldConfig
     | FloatCustomFieldConfig
     | BooleanCustomFieldConfig
-    | DateTimeCustomFieldConfig;
+    | DateTimeCustomFieldConfig
+    | RelationCustomFieldConfig;
 
 export type CustomerGroup = Node & {
     id: Scalars['ID'];
@@ -5151,6 +5164,7 @@ export type GlobalSettingsFragment = Pick<
                 | Pick<FloatCustomFieldConfig, 'name'>
                 | Pick<BooleanCustomFieldConfig, 'name'>
                 | Pick<DateTimeCustomFieldConfig, 'name'>
+                | Pick<RelationCustomFieldConfig, 'name'>
             >;
         };
     };

+ 14 - 1
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1195,6 +1195,18 @@ export type DateTimeCustomFieldConfig = CustomField & {
     step?: Maybe<Scalars['Int']>;
 };
 
+export type RelationCustomFieldConfig = CustomField & {
+    name: Scalars['String'];
+    type: Scalars['String'];
+    list: Scalars['Boolean'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    readonly?: Maybe<Scalars['Boolean']>;
+    internal?: Maybe<Scalars['Boolean']>;
+    entity: Scalars['String'];
+    scalarFields: Array<Scalars['String']>;
+};
+
 export type LocalizedString = {
     languageCode: LanguageCode;
     value: Scalars['String'];
@@ -1206,7 +1218,8 @@ export type CustomFieldConfig =
     | IntCustomFieldConfig
     | FloatCustomFieldConfig
     | BooleanCustomFieldConfig
-    | DateTimeCustomFieldConfig;
+    | DateTimeCustomFieldConfig
+    | RelationCustomFieldConfig;
 
 export type CustomerGroup = Node & {
     id: Scalars['ID'];

+ 11 - 5
packages/core/src/api/api-internal-modules.ts

@@ -7,6 +7,7 @@ import { createDynamicGraphQlModulesForPlugins } from '../plugin/dynamic-plugin-
 import { ServiceModule } from '../service/service.module';
 
 import { ConfigurableOperationCodec } from './common/configurable-operation-codec';
+import { CustomFieldRelationResolverService } from './common/custom-field-relation-resolver.service';
 import { IdCodecService } from './common/id-codec.service';
 import { AdministratorResolver } from './resolvers/admin/administrator.resolver';
 import { AssetResolver } from './resolvers/admin/asset.resolver';
@@ -141,9 +142,15 @@ export const adminEntityResolvers = [
  * one API module.
  */
 @Module({
-    imports: [ConfigModule],
-    providers: [IdCodecService, ConfigurableOperationCodec],
-    exports: [IdCodecService, ConfigModule, ConfigurableOperationCodec],
+    imports: [ConfigModule, ServiceModule.forRoot()],
+    providers: [IdCodecService, ConfigurableOperationCodec, CustomFieldRelationResolverService],
+    exports: [
+        IdCodecService,
+        ConfigModule,
+        ConfigurableOperationCodec,
+        CustomFieldRelationResolverService,
+        ServiceModule.forRoot(),
+    ],
 })
 export class ApiSharedModule {}
 
@@ -154,7 +161,6 @@ export class ApiSharedModule {}
     imports: [
         ApiSharedModule,
         JobQueueModule,
-        ServiceModule.forRoot(),
         DataImportModule,
         ...createDynamicGraphQlModulesForPlugins('admin'),
     ],
@@ -167,7 +173,7 @@ export class AdminApiModule {}
  * The internal module containing the Shop GraphQL API resolvers
  */
 @Module({
-    imports: [ApiSharedModule, ServiceModule.forRoot(), ...createDynamicGraphQlModulesForPlugins('shop')],
+    imports: [ApiSharedModule, ...createDynamicGraphQlModulesForPlugins('shop')],
     providers: [...shopResolvers, ...entityResolvers],
     exports: [...shopResolvers],
 })

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

@@ -7,6 +7,7 @@ import { I18nModule } from '../i18n/i18n.module';
 import { ServiceModule } from '../service/service.module';
 
 import { AdminApiModule, ApiSharedModule, ShopApiModule } from './api-internal-modules';
+import { CustomFieldRelationResolverService } from './common/custom-field-relation-resolver.service';
 import { RequestContextService } from './common/request-context.service';
 import { configureGraphQLModule } from './config/configure-graphql-module';
 import { AuthGuard } from './middleware/auth-guard';

+ 89 - 0
packages/core/src/api/common/custom-field-relation-resolver.service.ts

@@ -0,0 +1,89 @@
+import { Injectable } from '@nestjs/common';
+import { ID } from '@vendure/common/lib/shared-types';
+import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
+
+import { Translatable } from '../../common/types/locale-types';
+import { ConfigService } from '../../config/config.service';
+import { RelationCustomFieldConfig } from '../../config/custom-field/custom-field-types';
+import { VendureEntity } from '../../entity/base/base.entity';
+import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
+import { translateDeep } from '../../service/helpers/utils/translate-entity';
+import { ProductVariantService } from '../../service/services/product-variant.service';
+import { TransactionalConnection } from '../../service/transaction/transactional-connection';
+
+import { RequestContext } from './request-context';
+
+export interface ResolveRelationConfig {
+    ctx: RequestContext;
+    entityId: ID;
+    entityName: string;
+    fieldDef: RelationCustomFieldConfig;
+}
+
+@Injectable()
+export class CustomFieldRelationResolverService {
+    constructor(
+        private connection: TransactionalConnection,
+        private configService: ConfigService,
+        private productVariantService: ProductVariantService,
+    ) {}
+    /**
+     * @description
+     * Used to dynamically resolve related entities in custom fields. Based on the field
+     * config, this method is able to query the correct entity or entities from the database
+     * to be returned through the GraphQL API.
+     */
+    async resolveRelation(config: ResolveRelationConfig): Promise<VendureEntity | VendureEntity[]> {
+        const { ctx, entityId, entityName, fieldDef } = config;
+
+        const subQb = this.connection
+            .getRepository(ctx, entityName)
+            .createQueryBuilder('entity')
+            .leftJoin(`entity.customFields.${fieldDef.name}`, 'relationEntity')
+            .select('relationEntity.id')
+            .where('entity.id = :id');
+
+        const qb = this.connection
+            .getRepository(ctx, fieldDef.entity)
+            .createQueryBuilder('relation')
+            .where(`relation.id IN (${subQb.getQuery()})`)
+            .setParameters({ id: entityId });
+        // tslint:disable-next-line:no-non-null-assertion
+        FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
+
+        const result = fieldDef.list ? await qb.getMany() : await qb.getOne();
+
+        if (fieldDef.entity === ProductVariant) {
+            if (Array.isArray(result)) {
+                await Promise.all(result.map(r => this.applyVariantPrices(ctx, r as any)));
+            } else {
+                await this.applyVariantPrices(ctx, result as any);
+            }
+        }
+
+        const translated: any = Array.isArray(result)
+            ? result.map(r => (this.isTranslatable(r) ? translateDeep(r, ctx.languageCode) : r))
+            : this.isTranslatable(result)
+            ? translateDeep(result, ctx.languageCode)
+            : result;
+
+        return translated;
+    }
+
+    private isTranslatable(input: unknown): input is Translatable {
+        return typeof input === 'object' && input != null && input.hasOwnProperty('translations');
+    }
+
+    private async applyVariantPrices(ctx: RequestContext, variant: ProductVariant): Promise<ProductVariant> {
+        const taxCategory = await this.connection
+            .getRepository(ctx, ProductVariant)
+            .createQueryBuilder()
+            .relation('taxCategory')
+            .of(variant)
+            .loadOne();
+        variant.taxCategory = taxCategory;
+        // We use the ModuleRef to resolve the ProductVariantService here to
+        // avoid a circular dependency in the Nest DI.
+        return this.productVariantService.applyChannelPriceAndTax(variant, ctx);
+    }
+}

+ 1 - 0
packages/core/src/api/common/validate-custom-field-value.ts

@@ -44,6 +44,7 @@ export function validateCustomFieldValue(
             validateDateTimeField(config, value);
             break;
         case 'boolean':
+        case 'relation':
             break;
         default:
             assertNever(config);

+ 19 - 97
packages/core/src/api/config/configure-graphql-module.ts

@@ -1,26 +1,22 @@
 import { DynamicModule } from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
 import { GqlModuleOptions, GraphQLModule, GraphQLTypesLoader } from '@nestjs/graphql';
-import { StockMovementType } from '@vendure/common/lib/generated-types';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
-import { GraphQLUpload } from 'apollo-server-core';
 import { buildSchema, extendSchema, printSchema } from 'graphql';
-import { GraphQLDateTime } from 'graphql-iso-date';
-import GraphQLJSON from 'graphql-type-json';
 import path from 'path';
 
-import {
-    adminErrorOperationTypeResolvers,
-    ErrorResult,
-} from '../../common/error/generated-graphql-admin-errors';
-import { shopErrorOperationTypeResolvers } from '../../common/error/generated-graphql-shop-errors';
 import { ConfigModule } from '../../config/config.module';
 import { ConfigService } from '../../config/config.service';
 import { I18nModule } from '../../i18n/i18n.module';
 import { I18nService } from '../../i18n/i18n.service';
 import { getDynamicGraphQlModulesForPlugins } from '../../plugin/dynamic-plugin-api.module';
 import { getPluginAPIExtensions } from '../../plugin/plugin-metadata';
+import { CustomFieldRelationService } from '../../service/helpers/custom-field-relation/custom-field-relation.service';
+import { ServiceModule } from '../../service/service.module';
+import { ProductVariantService } from '../../service/services/product-variant.service';
+import { TransactionalConnection } from '../../service/transaction/transactional-connection';
 import { ApiSharedModule } from '../api-internal-modules';
-import { ApiType } from '../common/get-api-type';
+import { CustomFieldRelationResolverService } from '../common/custom-field-relation-resolver.service';
 import { IdCodecService } from '../common/id-codec.service';
 import { AssetInterceptorPlugin } from '../middleware/asset-interceptor-plugin';
 import { IdCodecPlugin } from '../middleware/id-codec-plugin';
@@ -30,6 +26,7 @@ import { generateAuthenticationTypes } from './generate-auth-types';
 import { generateErrorCodeEnum } from './generate-error-code-enum';
 import { generateListOptions } from './generate-list-options';
 import { generatePermissionEnum } from './generate-permissions';
+import { generateResolvers } from './generate-resolvers';
 import {
     addGraphQLCustomFields,
     addModifyOrderCustomFields,
@@ -60,17 +57,25 @@ export function configureGraphQLModule(
             i18nService: I18nService,
             idCodecService: IdCodecService,
             typesLoader: GraphQLTypesLoader,
+            customFieldRelationResolverService: CustomFieldRelationResolverService,
         ) => {
             return createGraphQLOptions(
                 i18nService,
                 configService,
                 idCodecService,
                 typesLoader,
+                customFieldRelationResolverService,
                 getOptions(configService),
             );
         },
-        inject: [ConfigService, I18nService, IdCodecService, GraphQLTypesLoader],
-        imports: [ConfigModule, I18nModule, ApiSharedModule],
+        inject: [
+            ConfigService,
+            I18nService,
+            IdCodecService,
+            GraphQLTypesLoader,
+            CustomFieldRelationResolverService,
+        ],
+        imports: [ConfigModule, I18nModule, ApiSharedModule, ServiceModule.forRoot()],
     });
 }
 
@@ -79,13 +84,14 @@ async function createGraphQLOptions(
     configService: ConfigService,
     idCodecService: IdCodecService,
     typesLoader: GraphQLTypesLoader,
+    customFieldRelationResolverService: CustomFieldRelationResolverService,
     options: GraphQLApiOptions,
 ): Promise<GqlModuleOptions> {
     return {
         path: '/' + options.apiPath,
         typeDefs: await createTypeDefs(options.apiType),
         include: [options.resolverModule, ...getDynamicGraphQlModulesForPlugins(options.apiType)],
-        resolvers: createResolvers(options.apiType),
+        resolvers: generateResolvers(configService, customFieldRelationResolverService, options.apiType),
         uploads: {
             maxFileSize: configService.assetOptions.uploadMaxFileSize,
         },
@@ -141,87 +147,3 @@ async function createGraphQLOptions(
         return printSchema(schema);
     }
 }
-
-function createResolvers(apiType: ApiType) {
-    // Prevent `Type "Node" is missing a "resolveType" resolver.` warnings.
-    // See https://github.com/apollographql/apollo-server/issues/1075
-    const dummyResolveType = {
-        __resolveType() {
-            return null;
-        },
-    };
-
-    const stockMovementResolveType = {
-        __resolveType(value: any) {
-            switch (value.type) {
-                case StockMovementType.ADJUSTMENT:
-                    return 'StockAdjustment';
-                case StockMovementType.ALLOCATION:
-                    return 'Allocation';
-                case StockMovementType.SALE:
-                    return 'Sale';
-                case StockMovementType.CANCELLATION:
-                    return 'Cancellation';
-                case StockMovementType.RETURN:
-                    return 'Return';
-                case StockMovementType.RELEASE:
-                    return 'Release';
-            }
-        },
-    };
-
-    const customFieldsConfigResolveType = {
-        __resolveType(value: any) {
-            switch (value.type) {
-                case 'string':
-                    return 'StringCustomFieldConfig';
-                case 'localeString':
-                    return 'LocaleStringCustomFieldConfig';
-                case 'int':
-                    return 'IntCustomFieldConfig';
-                case 'float':
-                    return 'FloatCustomFieldConfig';
-                case 'boolean':
-                    return 'BooleanCustomFieldConfig';
-                case 'datetime':
-                    return 'DateTimeCustomFieldConfig';
-            }
-        },
-    };
-
-    const commonResolvers = {
-        JSON: GraphQLJSON,
-        DateTime: GraphQLDateTime,
-        Node: dummyResolveType,
-        PaginatedList: dummyResolveType,
-        Upload: GraphQLUpload || dummyResolveType,
-        SearchResultPrice: {
-            __resolveType(value: any) {
-                return value.hasOwnProperty('value') ? 'SinglePrice' : 'PriceRange';
-            },
-        },
-        CustomFieldConfig: customFieldsConfigResolveType,
-        CustomField: customFieldsConfigResolveType,
-        ErrorResult: {
-            __resolveType(value: ErrorResult) {
-                return value.__typename;
-            },
-        },
-    };
-
-    const adminResolvers = {
-        StockMovementItem: stockMovementResolveType,
-        StockMovement: stockMovementResolveType,
-        ...adminErrorOperationTypeResolvers,
-    };
-
-    const shopResolvers = {
-        ...shopErrorOperationTypeResolvers,
-    };
-
-    const resolvers =
-        apiType === 'admin'
-            ? { ...commonResolvers, ...adminResolvers }
-            : { ...commonResolvers, ...shopResolvers };
-    return resolvers;
-}

+ 205 - 0
packages/core/src/api/config/generate-resolvers.ts

@@ -0,0 +1,205 @@
+import { StockMovementType } from '@vendure/common/lib/generated-types';
+import { GraphQLUpload } from 'apollo-server-core';
+import { IFieldResolver, IResolvers } from 'apollo-server-express';
+import { GraphQLDateTime } from 'graphql-iso-date';
+import GraphQLJSON from 'graphql-type-json';
+
+import { REQUEST_CONTEXT_KEY } from '../../common/constants';
+import {
+    adminErrorOperationTypeResolvers,
+    ErrorResult,
+} from '../../common/error/generated-graphql-admin-errors';
+import { shopErrorOperationTypeResolvers } from '../../common/error/generated-graphql-shop-errors';
+import { Translatable } from '../../common/types/locale-types';
+import { ConfigService } from '../../config/config.service';
+import { CustomFieldConfig, RelationCustomFieldConfig } from '../../config/custom-field/custom-field-types';
+import { CustomFieldRelationService } from '../../service/helpers/custom-field-relation/custom-field-relation.service';
+import { CustomFieldRelationResolverService } from '../common/custom-field-relation-resolver.service';
+import { ApiType } from '../common/get-api-type';
+import { RequestContext } from '../common/request-context';
+
+/**
+ * @description
+ * Generates additional resolvers required for things like resolution of union types,
+ * custom scalars and "relation"-type custom fields.
+ */
+export function generateResolvers(
+    configService: ConfigService,
+    customFieldRelationResolverService: CustomFieldRelationResolverService,
+    apiType: ApiType,
+) {
+    // Prevent `Type "Node" is missing a "resolveType" resolver.` warnings.
+    // See https://github.com/apollographql/apollo-server/issues/1075
+    const dummyResolveType = {
+        __resolveType() {
+            return null;
+        },
+    };
+
+    const stockMovementResolveType = {
+        __resolveType(value: any) {
+            switch (value.type) {
+                case StockMovementType.ADJUSTMENT:
+                    return 'StockAdjustment';
+                case StockMovementType.ALLOCATION:
+                    return 'Allocation';
+                case StockMovementType.SALE:
+                    return 'Sale';
+                case StockMovementType.CANCELLATION:
+                    return 'Cancellation';
+                case StockMovementType.RETURN:
+                    return 'Return';
+                case StockMovementType.RELEASE:
+                    return 'Release';
+            }
+        },
+    };
+
+    const customFieldsConfigResolveType = {
+        __resolveType(value: any) {
+            switch (value.type) {
+                case 'string':
+                    return 'StringCustomFieldConfig';
+                case 'localeString':
+                    return 'LocaleStringCustomFieldConfig';
+                case 'int':
+                    return 'IntCustomFieldConfig';
+                case 'float':
+                    return 'FloatCustomFieldConfig';
+                case 'boolean':
+                    return 'BooleanCustomFieldConfig';
+                case 'datetime':
+                    return 'DateTimeCustomFieldConfig';
+                case 'relation':
+                    return 'RelationCustomFieldConfig';
+            }
+        },
+    };
+
+    const commonResolvers = {
+        JSON: GraphQLJSON,
+        DateTime: GraphQLDateTime,
+        Node: dummyResolveType,
+        PaginatedList: dummyResolveType,
+        Upload: GraphQLUpload || dummyResolveType,
+        SearchResultPrice: {
+            __resolveType(value: any) {
+                return value.hasOwnProperty('value') ? 'SinglePrice' : 'PriceRange';
+            },
+        },
+        CustomFieldConfig: customFieldsConfigResolveType,
+        CustomField: customFieldsConfigResolveType,
+        ErrorResult: {
+            __resolveType(value: ErrorResult) {
+                return value.__typename;
+            },
+        },
+    };
+
+    const customFieldRelationResolvers = generateCustomFieldRelationResolvers(
+        configService,
+        customFieldRelationResolverService,
+    );
+
+    const adminResolvers = {
+        StockMovementItem: stockMovementResolveType,
+        StockMovement: stockMovementResolveType,
+        ...adminErrorOperationTypeResolvers,
+        ...customFieldRelationResolvers.adminResolvers,
+    };
+
+    const shopResolvers = {
+        ...shopErrorOperationTypeResolvers,
+        ...customFieldRelationResolvers.shopResolvers,
+    };
+
+    const resolvers =
+        apiType === 'admin'
+            ? { ...commonResolvers, ...adminResolvers }
+            : { ...commonResolvers, ...shopResolvers };
+    return resolvers;
+}
+
+/**
+ * @description
+ * Based on the CustomFields config, this function dynamically creates resolver functions to perform
+ * a DB query to fetch the related entity for any custom fields of type "relation".
+ */
+function generateCustomFieldRelationResolvers(
+    configService: ConfigService,
+    customFieldRelationResolverService: CustomFieldRelationResolverService,
+) {
+    const ENTITY_ID_KEY = '__entityId__';
+    const adminResolvers: IResolvers = {};
+    const shopResolvers: IResolvers = {};
+
+    for (const [entityName, customFields] of Object.entries(configService.customFields)) {
+        const relationCustomFields = customFields.filter(isRelationalType);
+        if (relationCustomFields.length === 0) {
+            continue;
+        }
+        const customFieldTypeName = `${entityName}CustomFields`;
+
+        // Some types are not exposed in the Shop API and therefore defining resolvers
+        // for them would lead to an Apollo error on bootstrap.
+        const excludeFromShopApi = ['GlobalSettings'].includes(entityName);
+
+        // In order to resolve the relations in the CustomFields type, we need
+        // access to the entity id. Therefore we attach it to the resolved value
+        // so that it is available to the `relationResolver` below.
+        const customFieldResolver: IFieldResolver<any, any> = (source: any) => {
+            return {
+                ...source.customFields,
+                [ENTITY_ID_KEY]: source.id,
+            };
+        };
+        adminResolvers[entityName] = {
+            customFields: customFieldResolver,
+        };
+        if (!excludeFromShopApi) {
+            shopResolvers[entityName] = {
+                customFields: customFieldResolver,
+            };
+        }
+        for (const fieldDef of relationCustomFields) {
+            const relationResolver: IFieldResolver<any, any> = async (
+                source: any,
+                args: any,
+                context: any,
+            ) => {
+                if (source[fieldDef.name] != null) {
+                    return source[fieldDef.name];
+                }
+                const ctx: RequestContext = context.req[REQUEST_CONTEXT_KEY];
+                const entityId = source[ENTITY_ID_KEY];
+                return customFieldRelationResolverService.resolveRelation({
+                    ctx,
+                    fieldDef,
+                    entityName,
+                    entityId,
+                });
+            };
+
+            adminResolvers[customFieldTypeName] = {
+                ...adminResolvers[customFieldTypeName],
+                [fieldDef.name]: relationResolver,
+            } as any;
+
+            if (fieldDef.public !== false && !excludeFromShopApi) {
+                shopResolvers[customFieldTypeName] = {
+                    ...shopResolvers[customFieldTypeName],
+                    [fieldDef.name]: relationResolver,
+                } as any;
+            }
+        }
+    }
+    return { adminResolvers, shopResolvers };
+}
+
+function isRelationalType(input: CustomFieldConfig): input is RelationCustomFieldConfig {
+    return input.type === 'relation';
+}
+
+function isTranslatable(input: unknown): input is Translatable {
+    return typeof input === 'object' && input != null && input.hasOwnProperty('translations');
+}

+ 59 - 20
packages/core/src/api/config/graphql-custom-fields.ts

@@ -1,9 +1,10 @@
 import { CustomFieldType } from '@vendure/common/lib/shared-types';
-import { assertNever } from '@vendure/common/lib/shared-utils';
+import { assertNever, getGraphQlInputName } from '@vendure/common/lib/shared-utils';
 import {
     buildSchema,
     extendSchema,
     GraphQLInputObjectType,
+    GraphQLList,
     GraphQLSchema,
     InputObjectTypeDefinitionNode,
     ObjectTypeDefinitionNode,
@@ -45,10 +46,21 @@ export function addGraphQLCustomFields(
             },
         );
 
+        for (const fieldDef of customEntityFields) {
+            if (fieldDef.type === 'relation') {
+                if (!schema.getType(fieldDef.graphQLType || fieldDef.entity.name)) {
+                    throw new Error(
+                        `The GraphQL type "${fieldDef.graphQLType}" specified by the ${entityName}.${fieldDef.name} custom field does not exist`,
+                    );
+                }
+            }
+        }
+
         const localeStringFields = customEntityFields.filter(field => field.type === 'localeString');
         const nonLocaleStringFields = customEntityFields.filter(field => field.type !== 'localeString');
         const writeableLocaleStringFields = localeStringFields.filter(field => !field.readonly);
         const writeableNonLocaleStringFields = nonLocaleStringFields.filter(field => !field.readonly);
+        const filterableFields = customEntityFields.filter(field => field.type !== 'relation');
 
         if (schema.getType(entityName)) {
             if (customEntityFields.length) {
@@ -86,7 +98,11 @@ export function addGraphQLCustomFields(
             if (writeableNonLocaleStringFields.length) {
                 customFieldTypeDefs += `
                     input Create${entityName}CustomFieldsInput {
-                       ${mapToFields(writeableNonLocaleStringFields, getGraphQlType)}
+                       ${mapToFields(
+                           writeableNonLocaleStringFields,
+                           getGraphQlInputType,
+                           getGraphQlInputName,
+                       )}
                     }
 
                     extend input Create${entityName}Input {
@@ -106,7 +122,11 @@ export function addGraphQLCustomFields(
             if (writeableNonLocaleStringFields.length) {
                 customFieldTypeDefs += `
                     input Update${entityName}CustomFieldsInput {
-                       ${mapToFields(writeableNonLocaleStringFields, getGraphQlType)}
+                       ${mapToFields(
+                           writeableNonLocaleStringFields,
+                           getGraphQlInputType,
+                           getGraphQlInputName,
+                       )}
                     }
 
                     extend input Update${entityName}Input {
@@ -130,10 +150,10 @@ export function addGraphQLCustomFields(
                 `;
         }
 
-        if (customEntityFields.length && schema.getType(`${entityName}FilterParameter`)) {
+        if (filterableFields.length && schema.getType(`${entityName}FilterParameter`)) {
             customFieldTypeDefs += `
                     extend input ${entityName}FilterParameter {
-                         ${mapToFields(customEntityFields, getFilterOperator)}
+                         ${mapToFields(filterableFields, getFilterOperator)}
                     }
                 `;
         }
@@ -233,7 +253,7 @@ export function addRegisterCustomerCustomFieldsInput(
     }
     const customFieldTypeDefs = `
         input RegisterCustomerCustomFieldsInput {
-            ${mapToFields(publicWritableCustomFields, getGraphQlType)}
+            ${mapToFields(publicWritableCustomFields, getGraphQlInputType, getGraphQlInputName)}
         }
 
         extend input RegisterCustomerInput {
@@ -288,7 +308,11 @@ export function addOrderLineCustomFieldsInput(
     const input = new GraphQLInputObjectType({
         name: 'OrderLineCustomFieldsInput',
         fields: orderLineCustomFields.reduce((fields, field) => {
-            return { ...fields, [field.name]: { type: schema.getType(getGraphQlType(field.type)) } };
+            const name = getGraphQlInputName(field);
+            // tslint:disable-next-line:no-non-null-assertion
+            const primitiveType = schema.getType(getGraphQlInputType(field))!;
+            const type = field.list === true ? new GraphQLList(primitiveType) : primitiveType;
+            return { ...fields, [name]: { type } };
         }, {}),
     });
     schemaConfig.types.push(input);
@@ -339,23 +363,30 @@ export function addOrderLineCustomFieldsInput(
     return extendedSchema;
 }
 
-type GraphQLFieldType = 'DateTime' | 'String' | 'Int' | 'Float' | 'Boolean' | 'ID';
-
 /**
  * Maps an array of CustomFieldConfig objects into a string of SDL fields.
  */
-function mapToFields(fieldDefs: CustomFieldConfig[], typeFn: (fieldType: CustomFieldType) => string): string {
-    return fieldDefs
+function mapToFields(
+    fieldDefs: CustomFieldConfig[],
+    typeFn: (def: CustomFieldConfig) => string | undefined,
+    nameFn?: (def: Pick<CustomFieldConfig, 'name' | 'type' | 'list'>) => string,
+): string {
+    const res = fieldDefs
         .map(field => {
-            const primitiveType = typeFn(field.type);
+            const primitiveType = typeFn(field);
+            if (!primitiveType) {
+                return;
+            }
             const finalType = field.list ? `[${primitiveType}!]` : primitiveType;
-            return `${field.name}: ${finalType}`;
+            const name = nameFn ? nameFn(field) : field.name;
+            return `${name}: ${finalType}`;
         })
-        .join('\n');
+        .filter(x => x != null);
+    return res.join('\n');
 }
 
-function getFilterOperator(type: CustomFieldType): string {
-    switch (type) {
+function getFilterOperator(config: CustomFieldConfig): string | undefined {
+    switch (config.type) {
         case 'datetime':
             return 'DateOperators';
         case 'string':
@@ -366,14 +397,20 @@ function getFilterOperator(type: CustomFieldType): string {
         case 'int':
         case 'float':
             return 'NumberOperators';
+        case 'relation':
+            return undefined;
         default:
-            assertNever(type);
+            assertNever(config);
     }
     return 'String';
 }
 
-function getGraphQlType(type: CustomFieldType): GraphQLFieldType {
-    switch (type) {
+function getGraphQlInputType(config: CustomFieldConfig): string {
+    return config.type === 'relation' ? `ID` : getGraphQlType(config);
+}
+
+function getGraphQlType(config: CustomFieldConfig): string {
+    switch (config.type) {
         case 'string':
         case 'localeString':
             return 'String';
@@ -385,8 +422,10 @@ function getGraphQlType(type: CustomFieldType): GraphQLFieldType {
             return 'Int';
         case 'float':
             return 'Float';
+        case 'relation':
+            return config.graphQLType || config.entity.name;
         default:
-            assertNever(type);
+            assertNever(config);
     }
     return 'String';
 }

+ 0 - 1
packages/core/src/api/resolvers/admin/collection.resolver.ts

@@ -19,7 +19,6 @@ import { Collection } from '../../../entity/collection/collection.entity';
 import { CollectionService } from '../../../service/services/collection.service';
 import { FacetValueService } from '../../../service/services/facet-value.service';
 import { ConfigurableOperationCodec } from '../../common/configurable-operation-codec';
-import { IdCodecService } from '../../common/id-codec.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';

+ 7 - 7
packages/core/src/api/resolvers/admin/facet.resolver.ts

@@ -91,7 +91,7 @@ export class FacetResolver {
         return this.facetService.delete(ctx, args.id, args.force || false);
     }
 
-    // @Transaction()
+    @Transaction()
     @Mutation()
     @Allow(Permission.CreateCatalog)
     async createFacetValues(
@@ -104,12 +104,12 @@ export class FacetResolver {
         if (!facet) {
             throw new EntityNotFoundError('Facet', facetId);
         }
-        return Promise.all(
-            input.map(async facetValue => {
-                const res = await this.facetValueService.create(ctx, facet, facetValue);
-                return res;
-            }),
-        );
+        const facetValues: Array<Translated<FacetValue>> = [];
+        for (const facetValue of input) {
+            const res = await this.facetValueService.create(ctx, facet, facetValue);
+            facetValues.push(res);
+        }
+        return facetValues;
     }
 
     @Transaction()

+ 63 - 11
packages/core/src/api/resolvers/admin/global-settings.resolver.ts

@@ -1,4 +1,4 @@
-import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
+import { Args, Info, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
 import {
     CustomFields as GraphQLCustomFields,
     MutationUpdateGlobalSettingsArgs,
@@ -7,13 +7,28 @@ import {
     ServerConfig,
     UpdateGlobalSettingsResult,
 } from '@vendure/common/lib/generated-types';
+import {
+    GraphQLOutputType,
+    GraphQLResolveInfo,
+    isEnumType,
+    isListType,
+    isNonNullType,
+    isObjectType,
+    isScalarType,
+    NamedTypeNode,
+    TypeNode,
+} from 'graphql';
 
 import { ErrorResultUnion } from '../../../common/error/error-result';
 import { UserInputError } from '../../../common/error/errors';
 import { ChannelDefaultLanguageError } from '../../../common/error/generated-graphql-admin-errors';
 import { getAllPermissionsMetadata } from '../../../common/permission-definition';
 import { ConfigService } from '../../../config/config.service';
-import { CustomFields } from '../../../config/custom-field/custom-field-types';
+import {
+    CustomFieldConfig,
+    CustomFields,
+    RelationCustomFieldConfig,
+} from '../../../config/custom-field/custom-field-types';
 import { GlobalSettings } from '../../../entity/global-settings/global-settings.entity';
 import { ChannelService } from '../../../service/services/channel.service';
 import { GlobalSettingsService } from '../../../service/services/global-settings.service';
@@ -42,19 +57,12 @@ export class GlobalSettingsResolver {
      * Exposes a subset of the VendureConfig which may be of use to clients.
      */
     @ResolveField()
-    serverConfig(a: ServerConfig): ServerConfig {
-        // Do not expose custom fields marked as "internal".
-        const exposedCustomFieldConfig: CustomFields = {};
-        for (const [entityType, customFields] of Object.entries(this.configService.customFields)) {
-            exposedCustomFieldConfig[entityType as keyof CustomFields] = customFields
-                .filter(c => !c.internal)
-                .map(c => ({ ...c, list: !!c.list as any }));
-        }
+    serverConfig(@Info() info: GraphQLResolveInfo): ServerConfig {
         const permissions = getAllPermissionsMetadata(
             this.configService.authOptions.customPermissions,
         ).filter(p => !p.internal);
         return {
-            customFieldConfig: exposedCustomFieldConfig as GraphQLCustomFields,
+            customFieldConfig: this.generateCustomFieldConfig(info),
             orderProcess: this.orderService.getOrderProcessStates(),
             permittedAssetTypes: this.configService.assetOptions.permittedFileTypes,
             permissions,
@@ -85,4 +93,48 @@ export class GlobalSettingsResolver {
         }
         return this.globalSettingsService.updateSettings(ctx, args.input);
     }
+
+    private generateCustomFieldConfig(info: GraphQLResolveInfo): GraphQLCustomFields {
+        const exposedCustomFieldConfig: CustomFields = {};
+        for (const [entityType, customFields] of Object.entries(this.configService.customFields)) {
+            exposedCustomFieldConfig[entityType as keyof CustomFields] = customFields
+                // Do not expose custom fields marked as "internal".
+                .filter(c => !c.internal)
+                .map(c => ({ ...c, list: !!c.list as any }))
+                .map((c: any) => {
+                    // In the VendureConfig, the relation entity is specified
+                    // as the class, but the GraphQL API exposes it as a string.
+                    if (c.type === 'relation') {
+                        c.entity = c.entity.name;
+                        c.scalarFields = this.getScalarFieldsOfType(info, c.graphQLType || c.entity);
+                    }
+                    return c;
+                });
+        }
+
+        return exposedCustomFieldConfig as GraphQLCustomFields;
+    }
+
+    private getScalarFieldsOfType(info: GraphQLResolveInfo, typeName: string): string[] {
+        const type = info.schema.getType(typeName);
+
+        if (type && isObjectType(type)) {
+            return Object.values(type.getFields())
+                .filter(field => {
+                    const namedType = this.getNamedType(field.type);
+                    return isScalarType(namedType) || isEnumType(namedType);
+                })
+                .map(field => field.name);
+        } else {
+            return [];
+        }
+    }
+
+    private getNamedType(type: GraphQLOutputType): GraphQLOutputType {
+        if (isNonNullType(type) || isListType(type)) {
+            return this.getNamedType(type.ofType);
+        } else {
+            return type;
+        }
+    }
 }

+ 20 - 1
packages/core/src/api/schema/common/custom-field-types.graphql

@@ -87,9 +87,28 @@ type DateTimeCustomFieldConfig implements CustomField {
     step: Int
 }
 
+type RelationCustomFieldConfig implements CustomField {
+    name: String!
+    type: String!
+    list: Boolean!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    readonly: Boolean
+    internal: Boolean
+    entity: String!
+    scalarFields: [String!]!
+}
+
 type LocalizedString {
     languageCode: LanguageCode!
     value: String!
 }
 
-union CustomFieldConfig = StringCustomFieldConfig | LocaleStringCustomFieldConfig | IntCustomFieldConfig | FloatCustomFieldConfig | BooleanCustomFieldConfig | DateTimeCustomFieldConfig
+union CustomFieldConfig =
+      StringCustomFieldConfig
+    | LocaleStringCustomFieldConfig
+    | IntCustomFieldConfig
+    | FloatCustomFieldConfig
+    | BooleanCustomFieldConfig
+    | DateTimeCustomFieldConfig
+    | RelationCustomFieldConfig

+ 10 - 2
packages/core/src/config/custom-field/custom-field-types.ts

@@ -6,9 +6,12 @@ import {
     IntCustomFieldConfig as GraphQLIntCustomFieldConfig,
     LocaleStringCustomFieldConfig as GraphQLLocaleStringCustomFieldConfig,
     LocalizedString,
+    RelationCustomFieldConfig as GraphQLRelationCustomFieldConfig,
     StringCustomFieldConfig as GraphQLStringCustomFieldConfig,
 } from '@vendure/common/lib/generated-types';
-import { CustomFieldsObject, CustomFieldType } from '@vendure/common/lib/shared-types';
+import { CustomFieldsObject, CustomFieldType, Type } from '@vendure/common/lib/shared-types';
+
+import { VendureEntity } from '../../entity/base/base.entity';
 
 // prettier-ignore
 export type DefaultValueType<T extends CustomFieldType> =
@@ -70,6 +73,10 @@ export type IntCustomFieldConfig = TypedCustomFieldConfig<'int', GraphQLIntCusto
 export type FloatCustomFieldConfig = TypedCustomFieldConfig<'float', GraphQLFloatCustomFieldConfig>;
 export type BooleanCustomFieldConfig = TypedCustomFieldConfig<'boolean', GraphQLBooleanCustomFieldConfig>;
 export type DateTimeCustomFieldConfig = TypedCustomFieldConfig<'datetime', GraphQLDateTimeCustomFieldConfig>;
+export type RelationCustomFieldConfig = TypedCustomFieldConfig<
+    'relation',
+    Omit<GraphQLRelationCustomFieldConfig, 'entity' | 'scalarFields'>
+> & { entity: Type<VendureEntity>; graphQLType?: string; eager?: boolean };
 
 /**
  * @description
@@ -83,7 +90,8 @@ export type CustomFieldConfig =
     | IntCustomFieldConfig
     | FloatCustomFieldConfig
     | BooleanCustomFieldConfig
-    | DateTimeCustomFieldConfig;
+    | DateTimeCustomFieldConfig
+    | RelationCustomFieldConfig;
 
 /**
  * @description

+ 69 - 31
packages/core/src/entity/register-custom-entity-fields.ts

@@ -1,6 +1,17 @@
-import { CustomFieldType } from '@vendure/common/lib/shared-types';
+import { CustomFieldType, Type } from '@vendure/common/lib/shared-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
-import { Column, ColumnOptions, ColumnType, ConnectionOptions } from 'typeorm';
+import {
+    Column,
+    ColumnOptions,
+    ColumnType,
+    ConnectionOptions,
+    JoinColumn,
+    JoinTable,
+    ManyToMany,
+    ManyToOne,
+    OneToMany,
+    OneToOne,
+} from 'typeorm';
 import { DateUtils } from 'typeorm/util/DateUtils';
 
 import { CustomFieldConfig, CustomFields } from '../config/custom-field/custom-field-types';
@@ -52,46 +63,70 @@ function registerCustomFieldsForEntity(
     const dbEngine = config.dbConnectionOptions.type;
     if (customFields) {
         for (const customField of customFields) {
-            const { name, type, list, defaultValue, nullable } = customField;
+            const { name, list, defaultValue, nullable } = customField;
+            const instance = new ctor();
             const registerColumn = () => {
-                const options: ColumnOptions = {
-                    type: list ? 'simple-json' : getColumnType(dbEngine, type),
-                    default: getDefault(customField, dbEngine),
-                    name,
-                    nullable: nullable === false ? false : true,
-                };
-                if ((customField.type === 'string' || customField.type === 'localeString') && !list) {
-                    const length = customField.length || 255;
-                    if (MAX_STRING_LENGTH < length) {
-                        throw new Error(
-                            `ERROR: The "length" property of the custom field "${customField.name}" is greater than the maximum allowed value of ${MAX_STRING_LENGTH}`,
-                        );
+                if (customField.type === 'relation') {
+                    if (customField.list) {
+                        ManyToMany(type => customField.entity, { eager: customField.eager })(instance, name);
+                        JoinTable()(instance, name);
+                    } else {
+                        ManyToOne(type => customField.entity, { eager: customField.eager })(instance, name);
+                        JoinColumn()(instance, name);
                     }
-                    options.length = length;
-                }
-                if (
-                    customField.type === 'datetime' &&
-                    options.precision == null &&
-                    // Setting precision on an sqlite datetime will cause
-                    // spurious migration commands. See https://github.com/typeorm/typeorm/issues/2333
-                    dbEngine !== 'sqljs' &&
-                    dbEngine !== 'sqlite' &&
-                    !list
-                ) {
-                    options.precision = 6;
+                } else {
+                    const options: ColumnOptions = {
+                        type: list ? 'simple-json' : getColumnType(dbEngine, customField.type),
+                        default: getDefault(customField, dbEngine),
+                        name,
+                        nullable: nullable === false ? false : true,
+                    };
+                    if ((customField.type === 'string' || customField.type === 'localeString') && !list) {
+                        const length = customField.length || 255;
+                        if (MAX_STRING_LENGTH < length) {
+                            throw new Error(
+                                `ERROR: The "length" property of the custom field "${customField.name}" is greater than the maximum allowed value of ${MAX_STRING_LENGTH}`,
+                            );
+                        }
+                        options.length = length;
+                    }
+                    if (
+                        customField.type === 'datetime' &&
+                        options.precision == null &&
+                        // Setting precision on an sqlite datetime will cause
+                        // spurious migration commands. See https://github.com/typeorm/typeorm/issues/2333
+                        dbEngine !== 'sqljs' &&
+                        dbEngine !== 'sqlite' &&
+                        !list
+                    ) {
+                        options.precision = 6;
+                    }
+                    Column(options)(instance, name);
                 }
-                Column(options)(new ctor(), name);
             };
 
             if (translation) {
-                if (type === 'localeString') {
+                if (customField.type === 'localeString') {
                     registerColumn();
                 }
             } else {
-                if (type !== 'localeString') {
+                if (customField.type !== 'localeString') {
                     registerColumn();
                 }
             }
+
+            if (customFields.filter(f => f.type === 'relation').length === customFields.length) {
+                // If there are _only_ relational customFields defined for an Entity, then TypeORM
+                // errors when attempting to load that entity ("Cannot set property <fieldName> of undefined").
+                // Therefore as a work-around we will add a "fake" column to the customFields embedded type
+                // to prevent this error from occurring.
+                Column({
+                    type: 'boolean',
+                    nullable: true,
+                    comment:
+                        'A work-around needed when only relational custom fields are defined on an entity',
+                })(instance, '__fix_relational_custom_fields__');
+            }
         }
     }
 }
@@ -112,7 +147,10 @@ function formatDefaultDatetime(dbEngine: ConnectionOptions['type'], datetime: an
     }
 }
 
-function getColumnType(dbEngine: ConnectionOptions['type'], type: CustomFieldType): ColumnType {
+function getColumnType(
+    dbEngine: ConnectionOptions['type'],
+    type: Exclude<CustomFieldType, 'relation'>,
+): ColumnType {
     switch (type) {
         case 'string':
         case 'localeString':

+ 69 - 0
packages/core/src/service/helpers/custom-field-relation/custom-field-relation.service.ts

@@ -0,0 +1,69 @@
+import { Injectable } from '@nestjs/common';
+import { ID, Type } from '@vendure/common/lib/shared-types';
+import { getGraphQlInputName } from '@vendure/common/lib/shared-utils';
+
+import { RequestContext } from '../../../api/common/request-context';
+import { ConfigService } from '../../../config/config.service';
+import {
+    CustomFieldConfig,
+    CustomFields,
+    HasCustomFields,
+    RelationCustomFieldConfig,
+} from '../../../config/custom-field/custom-field-types';
+import { VendureEntity } from '../../../entity/base/base.entity';
+import { TransactionalConnection } from '../../transaction/transactional-connection';
+
+@Injectable()
+export class CustomFieldRelationService {
+    constructor(private connection: TransactionalConnection, private configService: ConfigService) {}
+
+    /**
+     * @description
+     * If the entity being created or updated has any custom fields of type `relation`, this
+     * method will get the values from the input object and persist those relations in the
+     * database.
+     */
+    async updateRelations<T extends HasCustomFields & VendureEntity>(
+        ctx: RequestContext,
+        entityType: Type<T>,
+        input: { customFields?: { [key: string]: any } },
+        entity: T,
+    ) {
+        if (input.customFields) {
+            const relationCustomFields = this.configService.customFields[
+                entityType.name as keyof CustomFields
+            ].filter(this.isRelationalType);
+
+            for (const field of relationCustomFields) {
+                const inputIdName = getGraphQlInputName(field);
+                const idOrIds = input.customFields[inputIdName];
+                if (idOrIds !== undefined) {
+                    let relations: VendureEntity | VendureEntity[] | undefined | null;
+                    if (idOrIds === null) {
+                        // an explicitly `null` value means remove the relation
+                        relations = null;
+                    } else if (field.list && Array.isArray(idOrIds) && idOrIds.every(id => this.isId(id))) {
+                        relations = await this.connection.getRepository(ctx, field.entity).findByIds(idOrIds);
+                    } else if (!field.list && this.isId(idOrIds)) {
+                        relations = await this.connection.getRepository(ctx, field.entity).findOne(idOrIds);
+                    }
+                    if (relations !== undefined) {
+                        entity.customFields = { ...entity.customFields, [field.name]: relations };
+                        await this.connection
+                            .getRepository(ctx, entityType)
+                            .save(entity as any, { reload: false });
+                    }
+                }
+            }
+        }
+        return entity;
+    }
+
+    private isRelationalType(input: CustomFieldConfig): input is RelationCustomFieldConfig {
+        return input.type === 'relation';
+    }
+
+    private isId(input: unknown): input is ID {
+        return typeof input === 'string' || typeof input === 'number';
+    }
+}

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -11,6 +11,7 @@ import { WorkerServiceModule } from '../worker/worker-service.module';
 
 import { CollectionController } from './controllers/collection.controller';
 import { TaxRateController } from './controllers/tax-rate.controller';
+import { CustomFieldRelationService } from './helpers/custom-field-relation/custom-field-relation.service';
 import { ExternalAuthenticationService } from './helpers/external-authentication/external-authentication.service';
 import { FulfillmentStateMachine } from './helpers/fulfillment-state-machine/fulfillment-state-machine';
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
@@ -111,6 +112,7 @@ const helpers = [
     SlugValidator,
     ExternalAuthenticationService,
     TransactionalConnection,
+    CustomFieldRelationService,
 ];
 
 const workerControllers = [CollectionController, TaxRateController];

+ 4 - 0
packages/core/src/service/services/collection.service.ts

@@ -33,6 +33,7 @@ import { Job } from '../../job-queue/job';
 import { JobQueue } from '../../job-queue/job-queue';
 import { JobQueueService } from '../../job-queue/job-queue.service';
 import { WorkerService } from '../../worker/worker.service';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { SlugValidator } from '../helpers/slug-validator/slug-validator';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
@@ -62,6 +63,7 @@ export class CollectionService implements OnModuleInit {
         private jobQueueService: JobQueueService,
         private configService: ConfigService,
         private slugValidator: SlugValidator,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     onModuleInit() {
@@ -295,6 +297,7 @@ export class CollectionService implements OnModuleInit {
             },
         });
         await this.assetService.updateEntityAssets(ctx, collection, input);
+        await this.customFieldRelationService.updateRelations(ctx, Collection, input, collection);
         this.applyFiltersQueue.add({
             ctx: ctx.serialize(),
             collectionIds: [collection.id],
@@ -317,6 +320,7 @@ export class CollectionService implements OnModuleInit {
                 await this.assetService.updateEntityAssets(ctx, coll, input);
             },
         });
+        await this.customFieldRelationService.updateRelations(ctx, Collection, input, collection);
         if (input.filters) {
             this.applyFiltersQueue.add({
                 ctx: ctx.serialize(),

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

@@ -48,6 +48,7 @@ import { AccountRegistrationEvent } from '../../event-bus/events/account-registr
 import { IdentifierChangeEvent } from '../../event-bus/events/identifier-change-event';
 import { IdentifierChangeRequestEvent } from '../../event-bus/events/identifier-change-request-event';
 import { PasswordResetEvent } from '../../event-bus/events/password-reset-event';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { addressToLine } from '../helpers/utils/address-to-line';
 import { patchEntity } from '../helpers/utils/patch-entity';
@@ -70,6 +71,7 @@ export class CustomerService {
         private eventBus: EventBus,
         private historyService: HistoryService,
         private channelService: ChannelService,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     findAll(
@@ -206,7 +208,7 @@ export class CustomerService {
         }
         this.channelService.assignToCurrentChannel(customer, ctx);
         const createdCustomer = await this.connection.getRepository(ctx, Customer).save(customer);
-
+        await this.customFieldRelationService.updateRelations(ctx, Customer, input, createdCustomer);
         await this.historyService.createHistoryEntryForCustomer({
             ctx,
             customerId: createdCustomer.id,
@@ -262,6 +264,7 @@ export class CustomerService {
         });
         const updatedCustomer = patchEntity(customer, input);
         await this.connection.getRepository(ctx, Customer).save(updatedCustomer, { reload: false });
+        await this.customFieldRelationService.updateRelations(ctx, Customer, input, updatedCustomer);
         await this.historyService.createHistoryEntryForCustomer({
             customerId: customer.id,
             ctx,
@@ -551,6 +554,7 @@ export class CustomerService {
             country,
         });
         const createdAddress = await this.connection.getRepository(ctx, Address).save(address);
+        await this.customFieldRelationService.updateRelations(ctx, Address, input, createdAddress);
         customer.addresses.push(createdAddress);
         await this.connection.getRepository(ctx, Customer).save(customer, { reload: false });
         await this.enforceSingleDefaultAddress(ctx, createdAddress.id, input);
@@ -583,6 +587,7 @@ export class CustomerService {
         }
         let updatedAddress = patchEntity(address, input);
         updatedAddress = await this.connection.getRepository(ctx, Address).save(updatedAddress);
+        await this.customFieldRelationService.updateRelations(ctx, Address, input, updatedAddress);
         await this.enforceSingleDefaultAddress(ctx, input.id, input);
 
         await this.historyService.createHistoryEntryForCustomer({

+ 9 - 0
packages/core/src/service/services/facet-value.service.ts

@@ -17,6 +17,7 @@ import { Product, ProductVariant } from '../../entity';
 import { FacetValueTranslation } from '../../entity/facet-value/facet-value-translation.entity';
 import { FacetValue } from '../../entity/facet-value/facet-value.entity';
 import { Facet } from '../../entity/facet/facet.entity';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
@@ -27,6 +28,7 @@ export class FacetValueService {
         private connection: TransactionalConnection,
         private translatableSaver: TranslatableSaver,
         private configService: ConfigService,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     findAll(lang: LanguageCode): Promise<Array<Translated<FacetValue>>> {
@@ -79,6 +81,12 @@ export class FacetValueService {
             translationType: FacetValueTranslation,
             beforeSave: fv => (fv.facet = facet),
         });
+        await this.customFieldRelationService.updateRelations(
+            ctx,
+            FacetValue,
+            input as CreateFacetValueInput,
+            facetValue,
+        );
         return assertFound(this.findOne(ctx, facetValue.id));
     }
 
@@ -89,6 +97,7 @@ export class FacetValueService {
             entityType: FacetValue,
             translationType: FacetValueTranslation,
         });
+        await this.customFieldRelationService.updateRelations(ctx, FacetValue, input, facetValue);
         return assertFound(this.findOne(ctx, facetValue.id));
     }
 

+ 4 - 0
packages/core/src/service/services/facet.service.ts

@@ -15,6 +15,7 @@ import { assertFound } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { FacetTranslation } from '../../entity/facet/facet-translation.entity';
 import { Facet } from '../../entity/facet/facet.entity';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { translateDeep } from '../helpers/utils/translate-entity';
@@ -30,6 +31,7 @@ export class FacetService {
         private translatableSaver: TranslatableSaver,
         private listQueryBuilder: ListQueryBuilder,
         private configService: ConfigService,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     findAll(
@@ -94,6 +96,7 @@ export class FacetService {
             entityType: Facet,
             translationType: FacetTranslation,
         });
+        await this.customFieldRelationService.updateRelations(ctx, Facet, input, facet);
         return assertFound(this.findOne(ctx, facet.id));
     }
 
@@ -104,6 +107,7 @@ export class FacetService {
             entityType: Facet,
             translationType: FacetTranslation,
         });
+        await this.customFieldRelationService.updateRelations(ctx, Facet, input, facet);
         return assertFound(this.findOne(ctx, facet.id));
     }
 

+ 7 - 1
packages/core/src/service/services/global-settings.service.ts

@@ -5,12 +5,17 @@ import { RequestContext } from '../../api/common/request-context';
 import { InternalServerError } from '../../common/error/errors';
 import { ConfigService } from '../../config/config.service';
 import { GlobalSettings } from '../../entity/global-settings/global-settings.entity';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
 @Injectable()
 export class GlobalSettingsService {
-    constructor(private connection: TransactionalConnection, private configService: ConfigService) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private configService: ConfigService,
+        private customFieldRelationService: CustomFieldRelationService,
+    ) {}
 
     /**
      * Ensure there is a global settings row in the database.
@@ -37,6 +42,7 @@ export class GlobalSettingsService {
     async updateSettings(ctx: RequestContext, input: UpdateGlobalSettingsInput): Promise<GlobalSettings> {
         const settings = await this.getSettings(ctx);
         patchEntity(settings, input);
+        await this.customFieldRelationService.updateRelations(ctx, GlobalSettings, input, settings);
         return this.connection.getRepository(ctx, GlobalSettings).save(settings);
     }
 }

+ 3 - 0
packages/core/src/service/services/order.service.ts

@@ -87,6 +87,7 @@ import { EventBus } from '../../event-bus/event-bus';
 import { OrderStateTransitionEvent } from '../../event-bus/events/order-state-transition-event';
 import { PaymentStateTransitionEvent } from '../../event-bus/events/payment-state-transition-event';
 import { RefundStateTransitionEvent } from '../../event-bus/events/refund-state-transition-event';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillment-state';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from '../helpers/order-calculator/order-calculator';
@@ -141,6 +142,7 @@ export class OrderService {
         private eventBus: EventBus,
         private channelService: ChannelService,
         private orderModifier: OrderModifier,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     getOrderProcessStates(): OrderProcessState[] {
@@ -340,6 +342,7 @@ export class OrderService {
     async updateCustomFields(ctx: RequestContext, orderId: ID, customFields: any) {
         let order = await this.getOrderOrThrow(ctx, orderId);
         order = patchEntity(order, { customFields });
+        await this.customFieldRelationService.updateRelations(ctx, Order, { customFields }, order);
         return this.connection.getRepository(ctx, Order).save(order);
     }
 

+ 8 - 1
packages/core/src/service/services/product-option-group.service.ts

@@ -11,13 +11,18 @@ import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
 import { ProductOptionGroupTranslation } from '../../entity/product-option-group/product-option-group-translation.entity';
 import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
 @Injectable()
 export class ProductOptionGroupService {
-    constructor(private connection: TransactionalConnection, private translatableSaver: TranslatableSaver) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private translatableSaver: TranslatableSaver,
+        private customFieldRelationService: CustomFieldRelationService,
+    ) {}
 
     findAll(ctx: RequestContext, filterTerm?: string): Promise<Array<Translated<ProductOptionGroup>>> {
         const findOptions: FindManyOptions = {
@@ -68,6 +73,7 @@ export class ProductOptionGroupService {
             entityType: ProductOptionGroup,
             translationType: ProductOptionGroupTranslation,
         });
+        await this.customFieldRelationService.updateRelations(ctx, ProductOptionGroup, input, group);
         return assertFound(this.findOne(ctx, group.id));
     }
 
@@ -81,6 +87,7 @@ export class ProductOptionGroupService {
             entityType: ProductOptionGroup,
             translationType: ProductOptionGroupTranslation,
         });
+        await this.customFieldRelationService.updateRelations(ctx, ProductOptionGroup, input, group);
         return assertFound(this.findOne(ctx, group.id));
     }
 }

+ 13 - 1
packages/core/src/service/services/product-option.service.ts

@@ -12,13 +12,18 @@ import { assertFound } from '../../common/utils';
 import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
 import { ProductOptionTranslation } from '../../entity/product-option/product-option-translation.entity';
 import { ProductOption } from '../../entity/product-option/product-option.entity';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
 @Injectable()
 export class ProductOptionService {
-    constructor(private connection: TransactionalConnection, private translatableSaver: TranslatableSaver) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private translatableSaver: TranslatableSaver,
+        private customFieldRelationService: CustomFieldRelationService,
+    ) {}
 
     findAll(ctx: RequestContext): Promise<Array<Translated<ProductOption>>> {
         return this.connection
@@ -54,6 +59,12 @@ export class ProductOptionService {
             translationType: ProductOptionTranslation,
             beforeSave: po => (po.group = productOptionGroup),
         });
+        await this.customFieldRelationService.updateRelations(
+            ctx,
+            ProductOption,
+            input as CreateProductOptionInput,
+            option,
+        );
         return assertFound(this.findOne(ctx, option.id));
     }
 
@@ -64,6 +75,7 @@ export class ProductOptionService {
             entityType: ProductOption,
             translationType: ProductOptionTranslation,
         });
+        await this.customFieldRelationService.updateRelations(ctx, ProductOption, input, option);
         return assertFound(this.findOne(ctx, option.id));
     }
 }

+ 4 - 0
packages/core/src/service/services/product-variant.service.ts

@@ -27,6 +27,7 @@ import { Product } from '../../entity/product/product.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { ProductVariantChannelEvent } from '../../event-bus/events/product-variant-channel-event';
 import { ProductVariantEvent } from '../../event-bus/events/product-variant-event';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { samplesEach } from '../helpers/utils/samples-each';
@@ -60,6 +61,7 @@ export class ProductVariantService {
         private stockMovementService: StockMovementService,
         private channelService: ChannelService,
         private roleService: RoleService,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     findOne(ctx: RequestContext, productVariantId: ID): Promise<Translated<ProductVariant> | undefined> {
@@ -314,6 +316,7 @@ export class ProductVariantService {
                 taxCategoryId: input.taxCategoryId,
             },
         });
+        await this.customFieldRelationService.updateRelations(ctx, ProductVariant, input, createdVariant);
         await this.assetService.updateEntityAssets(ctx, createdVariant, input);
         if (input.stockOnHand != null && input.stockOnHand !== 0) {
             await this.stockMovementService.adjustProductVariantStock(
@@ -380,6 +383,7 @@ export class ProductVariantService {
                 taxCategoryId: input.taxCategoryId,
             },
         });
+        await this.customFieldRelationService.updateRelations(ctx, ProductVariant, input, existingVariant);
         if (input.price != null) {
             const variantPriceRepository = this.connection.getRepository(ctx, ProductVariantPrice);
             const variantPrice = await variantPriceRepository.findOne({

+ 4 - 0
packages/core/src/service/services/product.service.ts

@@ -26,6 +26,7 @@ import { Product } from '../../entity/product/product.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { ProductChannelEvent } from '../../event-bus/events/product-channel-event';
 import { ProductEvent } from '../../event-bus/events/product-event';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { SlugValidator } from '../helpers/slug-validator/slug-validator';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
@@ -57,6 +58,7 @@ export class ProductService {
         private translatableSaver: TranslatableSaver,
         private eventBus: EventBus,
         private slugValidator: SlugValidator,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     async findAll(
@@ -159,6 +161,7 @@ export class ProductService {
                 await this.assetService.updateFeaturedAsset(ctx, p, input);
             },
         });
+        await this.customFieldRelationService.updateRelations(ctx, Product, input, product);
         await this.assetService.updateEntityAssets(ctx, product, input);
         this.eventBus.publish(new ProductEvent(ctx, product, 'created'));
         return assertFound(this.findOne(ctx, product.id));
@@ -180,6 +183,7 @@ export class ProductService {
                 await this.assetService.updateEntityAssets(ctx, p, input);
             },
         });
+        await this.customFieldRelationService.updateRelations(ctx, Product, input, product);
         this.eventBus.publish(new ProductEvent(ctx, product, 'updated'));
         return assertFound(this.findOne(ctx, product.id));
     }

+ 9 - 1
packages/core/src/service/services/shipping-method.service.ts

@@ -18,10 +18,10 @@ import { Logger } from '../../config/logger/vendure-logger';
 import { Channel } from '../../entity/channel/channel.entity';
 import { ShippingMethodTranslation } from '../../entity/shipping-method/shipping-method-translation.entity';
 import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { ShippingConfiguration } from '../helpers/shipping-configuration/shipping-configuration';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
-import { patchEntity } from '../helpers/utils/patch-entity';
 import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
@@ -38,6 +38,7 @@ export class ShippingMethodService {
         private channelService: ChannelService,
         private shippingConfiguration: ShippingConfiguration,
         private translatableSaver: TranslatableSaver,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     async initShippingMethods() {
@@ -100,6 +101,7 @@ export class ShippingMethodService {
         const newShippingMethod = await this.connection
             .getRepository(ctx, ShippingMethod)
             .save(shippingMethod);
+        await this.customFieldRelationService.updateRelations(ctx, ShippingMethod, input, newShippingMethod);
         await this.updateActiveShippingMethods(ctx);
         return assertFound(this.findOne(ctx, newShippingMethod.id));
     }
@@ -132,6 +134,12 @@ export class ShippingMethodService {
         await this.connection
             .getRepository(ctx, ShippingMethod)
             .save(updatedShippingMethod, { reload: false });
+        await this.customFieldRelationService.updateRelations(
+            ctx,
+            ShippingMethod,
+            input,
+            updatedShippingMethod,
+        );
         await this.updateActiveShippingMethods(ctx);
         return assertFound(this.findOne(ctx, shippingMethod.id));
     }

+ 14 - 1
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -3088,6 +3088,18 @@ export type DateTimeCustomFieldConfig = CustomField & {
     step?: Maybe<Scalars['Int']>;
 };
 
+export type RelationCustomFieldConfig = CustomField & {
+    name: Scalars['String'];
+    type: Scalars['String'];
+    list: Scalars['Boolean'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    readonly?: Maybe<Scalars['Boolean']>;
+    internal?: Maybe<Scalars['Boolean']>;
+    entity: Scalars['String'];
+    scalarFields: Array<Scalars['String']>;
+};
+
 export type LocalizedString = {
     languageCode: LanguageCode;
     value: Scalars['String'];
@@ -3099,7 +3111,8 @@ export type CustomFieldConfig =
     | IntCustomFieldConfig
     | FloatCustomFieldConfig
     | BooleanCustomFieldConfig
-    | DateTimeCustomFieldConfig;
+    | DateTimeCustomFieldConfig
+    | RelationCustomFieldConfig;
 
 export type CustomerGroup = Node & {
     id: Scalars['ID'];

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
schema-admin.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
schema-shop.json


+ 1 - 0
scripts/codegen/generate-graphql-types.ts

@@ -16,6 +16,7 @@ const specFileToIgnore = [
     'plugin.e2e-spec',
     'shop-definitions',
     'custom-fields.e2e-spec',
+    'custom-field-relations.e2e-spec',
     'order-item-price-calculation-strategy.e2e-spec',
     'list-query-builder.e2e-spec',
     'shop-order.e2e-spec',

Vissa filer visades inte eftersom för många filer har ändrats