Browse Source

feat(core): Use query relations data to optimize DB joins

Relates to #1506. This commit applies the new `@Relations()` decorator to most of the resolvers and
some key entity field resolvers. Also relates to #1407 in that we are introducing a new "relations"
argument to many of the `find*()` methods of the service layer. However, I've not been able to spend
the time needed to get the corresponding type-safety part done. This can be explored separately
later.
Michael Bromley 3 years ago
parent
commit
0421285137
46 changed files with 560 additions and 226 deletions
  1. 1 0
      packages/core/e2e/custom-field-relations.e2e-spec.ts
  2. 2 0
      packages/core/package.json
  3. 1 1
      packages/core/src/api/decorators/relations.decorator.ts
  4. 5 2
      packages/core/src/api/resolvers/admin/administrator.resolver.ts
  5. 14 4
      packages/core/src/api/resolvers/admin/asset.resolver.ts
  6. 6 3
      packages/core/src/api/resolvers/admin/collection.resolver.ts
  7. 5 2
      packages/core/src/api/resolvers/admin/country.resolver.ts
  8. 3 0
      packages/core/src/api/resolvers/admin/customer-group.resolver.ts
  9. 5 2
      packages/core/src/api/resolvers/admin/customer.resolver.ts
  10. 5 2
      packages/core/src/api/resolvers/admin/facet.resolver.ts
  11. 13 4
      packages/core/src/api/resolvers/admin/order.resolver.ts
  12. 5 2
      packages/core/src/api/resolvers/admin/payment-method.resolver.ts
  13. 3 0
      packages/core/src/api/resolvers/admin/product-option.resolver.ts
  14. 8 3
      packages/core/src/api/resolvers/admin/product.resolver.ts
  15. 10 4
      packages/core/src/api/resolvers/admin/promotion.resolver.ts
  16. 13 4
      packages/core/src/api/resolvers/admin/role.resolver.ts
  17. 5 2
      packages/core/src/api/resolvers/admin/shipping-method.resolver.ts
  18. 9 3
      packages/core/src/api/resolvers/admin/tax-rate.resolver.ts
  19. 3 1
      packages/core/src/api/resolvers/entity/collection-entity.resolver.ts
  20. 3 1
      packages/core/src/api/resolvers/entity/customer-entity.resolver.ts
  21. 7 2
      packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts
  22. 10 2
      packages/core/src/api/resolvers/entity/product-entity.resolver.ts
  23. 24 4
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  24. 17 10
      packages/core/src/api/resolvers/shop/shop-products.resolver.ts
  25. 1 1
      packages/core/src/common/types/entity-relation-paths.ts
  26. 5 0
      packages/core/src/connection/transactional-connection.ts
  27. 3 2
      packages/core/src/entity/order/order.entity.ts
  28. 15 5
      packages/core/src/service/services/administrator.service.ts
  29. 11 4
      packages/core/src/service/services/asset.service.ts
  30. 22 12
      packages/core/src/service/services/collection.service.ts
  31. 9 5
      packages/core/src/service/services/country.service.ts
  32. 13 4
      packages/core/src/service/services/customer-group.service.ts
  33. 8 2
      packages/core/src/service/services/customer.service.ts
  34. 15 7
      packages/core/src/service/services/facet.service.ts
  35. 78 39
      packages/core/src/service/services/order.service.ts
  36. 11 3
      packages/core/src/service/services/payment-method.service.ts
  37. 13 4
      packages/core/src/service/services/product-option-group.service.ts
  38. 46 47
      packages/core/src/service/services/product-variant.service.ts
  39. 32 7
      packages/core/src/service/services/product.service.ts
  40. 13 3
      packages/core/src/service/services/promotion.service.ts
  41. 9 4
      packages/core/src/service/services/role.service.ts
  42. 5 2
      packages/core/src/service/services/shipping-method.service.ts
  43. 13 4
      packages/core/src/service/services/tax-rate.service.ts
  44. 5 5
      packages/core/src/service/services/zone.service.ts
  45. 15 6
      scripts/codegen/generate-graphql-types.ts
  46. 41 2
      yarn.lock

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

@@ -236,6 +236,7 @@ describe('Custom field relations', () => {
         });
 
         it('ProductVariant prices get resolved', async () => {
+            debugger;
             const { product } = await adminClient.query(gql`
                 query {
                     product(id: "${productId}") {

+ 2 - 0
packages/core/package.json

@@ -60,6 +60,7 @@
     "express": "^4.17.1",
     "fs-extra": "^10.0.0",
     "graphql": "15.5.1",
+    "graphql-fields": "^2.0.3",
     "graphql-scalars": "^1.10.0",
     "graphql-tag": "^2.12.4",
     "graphql-upload": "^12.0.0",
@@ -83,6 +84,7 @@
     "@types/csv-parse": "^1.2.2",
     "@types/express": "^4.17.8",
     "@types/faker": "^4.1.7",
+    "@types/graphql-fields": "^1.3.4",
     "@types/graphql-upload": "^8.0.4",
     "@types/gulp": "^4.0.7",
     "@types/mime-types": "^2.1.0",

+ 1 - 1
packages/core/src/api/decorators/relations.decorator.ts

@@ -118,7 +118,7 @@ const cache = new TtlCache({ cacheSize: 500, ttl: 5 * 60 * 1000 });
 export const Relations = createParamDecorator<FieldsDecoratorConfig>((data, ctx: ExecutionContext) => {
     const info = ctx.getArgByIndex(3);
     if (data == null) {
-        throw new InternalServerError(`The @Fields() decorator requires an entity type argument`);
+        throw new InternalServerError(`The @Relations() decorator requires an entity type argument`);
     }
     if (!isGraphQLResolveInfo(info)) {
         return [];

+ 5 - 2
packages/core/src/api/resolvers/admin/administrator.resolver.ts

@@ -16,6 +16,7 @@ import { Administrator } from '../../../entity/administrator/administrator.entit
 import { AdministratorService } from '../../../service/services/administrator.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -28,8 +29,9 @@ export class AdministratorResolver {
     administrators(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryAdministratorsArgs,
+        @Relations(Administrator) relations: RelationPaths<Administrator>,
     ): Promise<PaginatedList<Administrator>> {
-        return this.administratorService.findAll(ctx, args.options || undefined);
+        return this.administratorService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
@@ -37,8 +39,9 @@ export class AdministratorResolver {
     administrator(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryAdministratorArgs,
+        @Relations(Administrator) relations: RelationPaths<Administrator>,
     ): Promise<Administrator | undefined> {
-        return this.administratorService.findOne(ctx, args.id);
+        return this.administratorService.findOne(ctx, args.id, relations);
     }
 
     @Query()

+ 14 - 4
packages/core/src/api/resolvers/admin/asset.resolver.ts

@@ -13,9 +13,11 @@ import {
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Asset } from '../../../entity/asset/asset.entity';
+import { Administrator } from '../../../entity/index';
 import { AssetService } from '../../../service/services/asset.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -25,14 +27,22 @@ export class AssetResolver {
 
     @Query()
     @Allow(Permission.ReadCatalog, Permission.ReadAsset)
-    async asset(@Ctx() ctx: RequestContext, @Args() args: QueryAssetArgs): Promise<Asset | undefined> {
-        return this.assetService.findOne(ctx, args.id);
+    async asset(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryAssetArgs,
+        @Relations(Asset) relations: RelationPaths<Asset>,
+    ): Promise<Asset | undefined> {
+        return this.assetService.findOne(ctx, args.id, relations);
     }
 
     @Query()
     @Allow(Permission.ReadCatalog, Permission.ReadAsset)
-    async assets(@Ctx() ctx: RequestContext, @Args() args: QueryAssetsArgs): Promise<PaginatedList<Asset>> {
-        return this.assetService.findAll(ctx, args.options || undefined);
+    async assets(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryAssetsArgs,
+        @Relations(Asset) relations: RelationPaths<Asset>,
+    ): Promise<PaginatedList<Asset>> {
+        return this.assetService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Transaction()

+ 6 - 3
packages/core/src/api/resolvers/admin/collection.resolver.ts

@@ -21,6 +21,7 @@ import { FacetValueService } from '../../../service/services/facet-value.service
 import { ConfigurableOperationCodec } from '../../common/configurable-operation-codec';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -46,8 +47,9 @@ export class CollectionResolver {
     async collections(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionsArgs,
+        @Relations(Collection) relations: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
-        return this.collectionService.findAll(ctx, args.options || undefined).then(res => {
+        return this.collectionService.findAll(ctx, args.options || undefined, relations).then(res => {
             res.items.forEach(this.encodeFilters);
             return res;
         });
@@ -58,15 +60,16 @@ export class CollectionResolver {
     async collection(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionArgs,
+        @Relations(Collection) relations: RelationPaths<Collection>,
     ): Promise<Translated<Collection> | undefined> {
         let collection: Translated<Collection> | undefined;
         if (args.id) {
-            collection = await this.collectionService.findOne(ctx, args.id);
+            collection = await this.collectionService.findOne(ctx, args.id, relations);
             if (args.slug && collection && collection.slug !== args.slug) {
                 throw new UserInputError(`error.collection-id-slug-mismatch`);
             }
         } else if (args.slug) {
-            collection = await this.collectionService.findOneBySlug(ctx, args.slug);
+            collection = await this.collectionService.findOneBySlug(ctx, args.slug, relations);
         } else {
             throw new UserInputError(`error.collection-id-or-slug-must-be-provided`);
         }

+ 5 - 2
packages/core/src/api/resolvers/admin/country.resolver.ts

@@ -15,6 +15,7 @@ import { Country } from '../../../entity/country/country.entity';
 import { CountryService } from '../../../service/services/country.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -27,8 +28,9 @@ export class CountryResolver {
     countries(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCountriesArgs,
+        @Relations(Country) relations: RelationPaths<Country>,
     ): Promise<PaginatedList<Translated<Country>>> {
-        return this.countryService.findAll(ctx, args.options || undefined);
+        return this.countryService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
@@ -36,8 +38,9 @@ export class CountryResolver {
     async country(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCountryArgs,
+        @Relations(Country) relations: RelationPaths<Country>,
     ): Promise<Translated<Country> | undefined> {
-        return this.countryService.findOne(ctx, args.id);
+        return this.countryService.findOne(ctx, args.id, relations);
     }
 
     @Transaction()

+ 3 - 0
packages/core/src/api/resolvers/admin/customer-group.resolver.ts

@@ -16,6 +16,7 @@ import { CustomerGroup } from '../../../entity/customer-group/customer-group.ent
 import { CustomerGroupService } from '../../../service/services/customer-group.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -28,6 +29,7 @@ export class CustomerGroupResolver {
     customerGroups(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCustomerGroupsArgs,
+        @Relations(CustomerGroup) relations: RelationPaths<CustomerGroup>,
     ): Promise<PaginatedList<CustomerGroup>> {
         return this.customerGroupService.findAll(ctx, args.options || undefined);
     }
@@ -37,6 +39,7 @@ export class CustomerGroupResolver {
     async customerGroup(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCustomerGroupArgs,
+        @Relations(CustomerGroup) relations: RelationPaths<CustomerGroup>,
     ): Promise<CustomerGroup | undefined> {
         return this.customerGroupService.findOne(ctx, args.id);
     }

+ 5 - 2
packages/core/src/api/resolvers/admin/customer.resolver.ts

@@ -26,6 +26,7 @@ import { CustomerService } from '../../../service/services/customer.service';
 import { OrderService } from '../../../service/services/order.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -38,8 +39,9 @@ export class CustomerResolver {
     async customers(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCustomersArgs,
+        @Relations(Customer) relations: RelationPaths<Customer>,
     ): Promise<PaginatedList<Customer>> {
-        return this.customerService.findAll(ctx, args.options || undefined);
+        return this.customerService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
@@ -47,8 +49,9 @@ export class CustomerResolver {
     async customer(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCustomerArgs,
+        @Relations(Customer) relations: RelationPaths<Customer>,
     ): Promise<Customer | undefined> {
-        return this.customerService.findOne(ctx, args.id);
+        return this.customerService.findOne(ctx, args.id, relations);
     }
 
     @Transaction()

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

@@ -22,6 +22,7 @@ import { FacetValueService } from '../../../service/services/facet-value.service
 import { FacetService } from '../../../service/services/facet.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -38,8 +39,9 @@ export class FacetResolver {
     facets(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryFacetsArgs,
+        @Relations(Facet) relations: RelationPaths<Facet>,
     ): Promise<PaginatedList<Translated<Facet>>> {
-        return this.facetService.findAll(ctx, args.options || undefined);
+        return this.facetService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
@@ -47,8 +49,9 @@ export class FacetResolver {
     async facet(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryFacetArgs,
+        @Relations(Facet) relations: RelationPaths<Facet>,
     ): Promise<Translated<Facet> | undefined> {
-        return this.facetService.findOne(ctx, args.id);
+        return this.facetService.findOne(ctx, args.id, relations);
     }
 
     @Transaction()

+ 13 - 4
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -37,6 +37,7 @@ import { OrderService } from '../../../service/services/order.service';
 import { ShippingMethodService } from '../../../service/services/shipping-method.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -46,14 +47,22 @@ export class OrderResolver {
 
     @Query()
     @Allow(Permission.ReadOrder)
-    orders(@Ctx() ctx: RequestContext, @Args() args: QueryOrdersArgs): Promise<PaginatedList<Order>> {
-        return this.orderService.findAll(ctx, args.options || undefined);
+    orders(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryOrdersArgs,
+        @Relations(Order) relations: RelationPaths<Order>,
+    ): Promise<PaginatedList<Order>> {
+        return this.orderService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
     @Allow(Permission.ReadOrder)
-    async order(@Ctx() ctx: RequestContext, @Args() args: QueryOrderArgs): Promise<Order | undefined> {
-        return this.orderService.findOne(ctx, args.id);
+    async order(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryOrderArgs,
+        @Relations(Order) relations: RelationPaths<Order>,
+    ): Promise<Order | undefined> {
+        return this.orderService.findOne(ctx, args.id, relations);
     }
 
     @Transaction()

+ 5 - 2
packages/core/src/api/resolvers/admin/payment-method.resolver.ts

@@ -15,6 +15,7 @@ import { PaymentMethod } from '../../../entity/payment-method/payment-method.ent
 import { PaymentMethodService } from '../../../service/services/payment-method.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -27,8 +28,9 @@ export class PaymentMethodResolver {
     paymentMethods(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryPaymentMethodsArgs,
+        @Relations(PaymentMethod) relations: RelationPaths<PaymentMethod>,
     ): Promise<PaginatedList<PaymentMethod>> {
-        return this.paymentMethodService.findAll(ctx, args.options || undefined);
+        return this.paymentMethodService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
@@ -36,8 +38,9 @@ export class PaymentMethodResolver {
     paymentMethod(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryPaymentMethodArgs,
+        @Relations(PaymentMethod) relations: RelationPaths<PaymentMethod>,
     ): Promise<PaymentMethod | undefined> {
-        return this.paymentMethodService.findOne(ctx, args.id);
+        return this.paymentMethodService.findOne(ctx, args.id, relations);
     }
 
     @Transaction()

+ 3 - 0
packages/core/src/api/resolvers/admin/product-option.resolver.ts

@@ -16,6 +16,7 @@ import { ProductOptionGroupService } from '../../../service/services/product-opt
 import { ProductOptionService } from '../../../service/services/product-option.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -31,6 +32,7 @@ export class ProductOptionResolver {
     productOptionGroups(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductOptionGroupsArgs,
+        @Relations(ProductOptionGroup) relations: RelationPaths<ProductOptionGroup>,
     ): Promise<Array<Translated<ProductOptionGroup>>> {
         return this.productOptionGroupService.findAll(ctx, args.filterTerm || undefined);
     }
@@ -40,6 +42,7 @@ export class ProductOptionResolver {
     productOptionGroup(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductOptionGroupArgs,
+        @Relations(ProductOptionGroup) relations: RelationPaths<ProductOptionGroup>,
     ): Promise<Translated<ProductOptionGroup> | undefined> {
         return this.productOptionGroupService.findOne(ctx, args.id);
     }

+ 8 - 3
packages/core/src/api/resolvers/admin/product.resolver.ts

@@ -32,6 +32,7 @@ import { ProductVariantService } from '../../../service/services/product-variant
 import { ProductService } from '../../../service/services/product.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -48,8 +49,9 @@ export class ProductResolver {
     async products(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductsArgs,
+        @Relations(Product) relations: RelationPaths<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
-        return this.productService.findAll(ctx, args.options || undefined);
+        return this.productService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
@@ -57,15 +59,16 @@ export class ProductResolver {
     async product(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductArgs,
+        @Relations(Product) relations: RelationPaths<Product>,
     ): Promise<Translated<Product> | undefined> {
         if (args.id) {
-            const product = await this.productService.findOne(ctx, args.id);
+            const product = await this.productService.findOne(ctx, args.id, relations);
             if (args.slug && product && product.slug !== args.slug) {
                 throw new UserInputError(`error.product-id-slug-mismatch`);
             }
             return product;
         } else if (args.slug) {
-            return this.productService.findOneBySlug(ctx, args.slug);
+            return this.productService.findOneBySlug(ctx, args.slug, relations);
         } else {
             throw new UserInputError(`error.product-id-or-slug-must-be-provided`);
         }
@@ -76,12 +79,14 @@ export class ProductResolver {
     async productVariants(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductVariantsArgs,
+        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
         if (args.productId) {
             return this.productVariantService.getVariantsByProductId(
                 ctx,
                 args.productId,
                 args.options || undefined,
+                relations,
             );
         }
 

+ 10 - 4
packages/core/src/api/resolvers/admin/promotion.resolver.ts

@@ -22,6 +22,7 @@ import { PromotionService } from '../../../service/services/promotion.service';
 import { ConfigurableOperationCodec } from '../../common/configurable-operation-codec';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -37,8 +38,9 @@ export class PromotionResolver {
     promotions(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryPromotionsArgs,
+        @Relations(Promotion) relations: RelationPaths<Promotion>,
     ): Promise<PaginatedList<Promotion>> {
-        return this.promotionService.findAll(ctx, args.options || undefined).then(res => {
+        return this.promotionService.findAll(ctx, args.options || undefined, relations).then(res => {
             res.items.forEach(this.encodeConditionsAndActions);
             return res;
         });
@@ -46,8 +48,12 @@ export class PromotionResolver {
 
     @Query()
     @Allow(Permission.ReadPromotion, Permission.ReadPromotion)
-    promotion(@Ctx() ctx: RequestContext, @Args() args: QueryPromotionArgs): Promise<Promotion | undefined> {
-        return this.promotionService.findOne(ctx, args.id).then(this.encodeConditionsAndActions);
+    promotion(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryPromotionArgs,
+        @Relations(Promotion) relations: RelationPaths<Promotion>,
+    ): Promise<Promotion | undefined> {
+        return this.promotionService.findOne(ctx, args.id, relations).then(this.encodeConditionsAndActions);
     }
 
     @Query()
@@ -136,7 +142,7 @@ export class PromotionResolver {
      * Encodes any entity IDs used in the filter arguments.
      */
     private encodeConditionsAndActions = <
-        T extends ErrorResultUnion<CreatePromotionResult, Promotion> | undefined
+        T extends ErrorResultUnion<CreatePromotionResult, Promotion> | undefined,
     >(
         maybePromotion: T,
     ): T => {

+ 13 - 4
packages/core/src/api/resolvers/admin/role.resolver.ts

@@ -14,6 +14,7 @@ import { Role } from '../../../entity/role/role.entity';
 import { RoleService } from '../../../service/services/role.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -23,14 +24,22 @@ export class RoleResolver {
 
     @Query()
     @Allow(Permission.ReadAdministrator)
-    roles(@Ctx() ctx: RequestContext, @Args() args: QueryRolesArgs): Promise<PaginatedList<Role>> {
-        return this.roleService.findAll(ctx, args.options || undefined);
+    roles(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryRolesArgs,
+        @Relations(Role) relations: RelationPaths<Role>,
+    ): Promise<PaginatedList<Role>> {
+        return this.roleService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
     @Allow(Permission.ReadAdministrator)
-    role(@Ctx() ctx: RequestContext, @Args() args: QueryRoleArgs): Promise<Role | undefined> {
-        return this.roleService.findOne(ctx, args.id);
+    role(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryRoleArgs,
+        @Relations(Role) relations: RelationPaths<Role>,
+    ): Promise<Role | undefined> {
+        return this.roleService.findOne(ctx, args.id, relations);
     }
 
     @Transaction()

+ 5 - 2
packages/core/src/api/resolvers/admin/shipping-method.resolver.ts

@@ -18,6 +18,7 @@ import { OrderTestingService } from '../../../service/services/order-testing.ser
 import { ShippingMethodService } from '../../../service/services/shipping-method.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -33,8 +34,9 @@ export class ShippingMethodResolver {
     shippingMethods(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryShippingMethodsArgs,
+        @Relations(ShippingMethod) relations: RelationPaths<ShippingMethod>,
     ): Promise<PaginatedList<ShippingMethod>> {
-        return this.shippingMethodService.findAll(ctx, args.options || undefined);
+        return this.shippingMethodService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
@@ -42,8 +44,9 @@ export class ShippingMethodResolver {
     shippingMethod(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryShippingMethodArgs,
+        @Relations(ShippingMethod) relations: RelationPaths<ShippingMethod>,
     ): Promise<ShippingMethod | undefined> {
-        return this.shippingMethodService.findOne(ctx, args.id);
+        return this.shippingMethodService.findOne(ctx, args.id, false, relations);
     }
 
     @Query()

+ 9 - 3
packages/core/src/api/resolvers/admin/tax-rate.resolver.ts

@@ -14,6 +14,7 @@ import { TaxRate } from '../../../entity/tax-rate/tax-rate.entity';
 import { TaxRateService } from '../../../service/services/tax-rate.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -26,14 +27,19 @@ export class TaxRateResolver {
     async taxRates(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryTaxRatesArgs,
+        @Relations(TaxRate) relations: RelationPaths<TaxRate>,
     ): Promise<PaginatedList<TaxRate>> {
-        return this.taxRateService.findAll(ctx, args.options || undefined);
+        return this.taxRateService.findAll(ctx, args.options || undefined, relations);
     }
 
     @Query()
     @Allow(Permission.ReadSettings, Permission.ReadCatalog, Permission.ReadTaxRate)
-    async taxRate(@Ctx() ctx: RequestContext, @Args() args: QueryTaxRateArgs): Promise<TaxRate | undefined> {
-        return this.taxRateService.findOne(ctx, args.id);
+    async taxRate(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryTaxRateArgs,
+        @Relations(TaxRate) relations: RelationPaths<TaxRate>,
+    ): Promise<TaxRate | undefined> {
+        return this.taxRateService.findOne(ctx, args.id, relations);
     }
 
     @Transaction()

+ 3 - 1
packages/core/src/api/resolvers/entity/collection-entity.resolver.ts

@@ -12,6 +12,7 @@ import { ProductVariantService } from '../../../service/services/product-variant
 import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
 import { Api } from '../../decorators/api.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Collection')
@@ -44,6 +45,7 @@ export class CollectionEntityResolver {
         @Parent() collection: Collection,
         @Args() args: { options: ProductVariantListOptions },
         @Api() apiType: ApiType,
+        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
         let options: ListQueryOptions<Product> = args.options;
         if (apiType === 'shop') {
@@ -55,7 +57,7 @@ export class CollectionEntityResolver {
                 },
             };
         }
-        return this.productVariantService.getVariantsByCollectionId(ctx, collection.id, options);
+        return this.productVariantService.getVariantsByCollectionId(ctx, collection.id, options, relations);
     }
 
     @ResolveField()

+ 3 - 1
packages/core/src/api/resolvers/entity/customer-entity.resolver.ts

@@ -12,6 +12,7 @@ import { UserService } from '../../../service/services/user.service';
 import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
 import { Api } from '../../decorators/api.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Customer')
@@ -40,12 +41,13 @@ export class CustomerEntityResolver {
         @Parent() customer: Customer,
         @Args() args: QueryOrdersArgs,
         @Api() apiType: ApiType,
+        @Relations(Order) relations: RelationPaths<Order>,
     ): Promise<PaginatedList<Order>> {
         if (apiType === 'shop' && !ctx.activeUserId) {
             // Guest customers should not be able to see this data
             return { items: [], totalItems: 0 };
         }
-        return this.orderService.findByCustomerId(ctx, customer.id, args.options || undefined);
+        return this.orderService.findByCustomerId(ctx, customer.id, args.options || undefined, relations);
     }
 
     @ResolveField()

+ 7 - 2
packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts

@@ -3,6 +3,7 @@ import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 import { Asset, Order, OrderLine, ProductVariant } from '../../../entity';
 import { AssetService, OrderService, ProductVariantService } from '../../../service';
 import { RequestContext } from '../../common/request-context';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('OrderLine')
@@ -37,7 +38,11 @@ export class OrderLineEntityResolver {
     }
 
     @ResolveField()
-    async order(@Ctx() ctx: RequestContext, @Parent() orderLine: OrderLine): Promise<Order | undefined> {
-        return this.orderService.findOneByOrderLineId(ctx, orderLine.id);
+    async order(
+        @Ctx() ctx: RequestContext,
+        @Parent() orderLine: OrderLine,
+        @Relations(Order) relations: RelationPaths<Order>,
+    ): Promise<Order | undefined> {
+        return this.orderService.findOneByOrderLineId(ctx, orderLine.id, relations);
     }
 }

+ 10 - 2
packages/core/src/api/resolvers/entity/product-entity.resolver.ts

@@ -21,6 +21,7 @@ import { ProductService } from '../../../service/services/product.service';
 import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
 import { Api } from '../../decorators/api.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Product')
@@ -53,8 +54,14 @@ export class ProductEntityResolver {
     async variants(
         @Ctx() ctx: RequestContext,
         @Parent() product: Product,
+        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
     ): Promise<Array<Translated<ProductVariant>>> {
-        const { items: variants } = await this.productVariantService.getVariantsByProductId(ctx, product.id);
+        const { items: variants } = await this.productVariantService.getVariantsByProductId(
+            ctx,
+            product.id,
+            {},
+            relations,
+        );
         return variants;
     }
 
@@ -63,8 +70,9 @@ export class ProductEntityResolver {
         @Ctx() ctx: RequestContext,
         @Parent() product: Product,
         @Args() args: { options: ProductVariantListOptions },
+        @Relations(ProductVariant) relations: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<ProductVariant>> {
-        return this.productVariantService.getVariantsByProductId(ctx, product.id, args.options);
+        return this.productVariantService.getVariantsByProductId(ctx, product.id, args.options, relations);
     }
 
     @ResolveField()

+ 24 - 4
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -26,6 +26,7 @@ import {
     UpdateOrderItemsResult,
 } from '@vendure/common/lib/generated-shop-types';
 import { QueryCountriesArgs } from '@vendure/common/lib/generated-types';
+import { unique } from '@vendure/common/lib/unique';
 
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../../common/error/error-result';
 import { ForbiddenError } from '../../../common/error/errors';
@@ -45,6 +46,7 @@ import { OrderService } from '../../../service/services/order.service';
 import { SessionService } from '../../../service/services/session.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
@@ -69,8 +71,17 @@ export class ShopOrderResolver {
 
     @Query()
     @Allow(Permission.Owner)
-    async order(@Ctx() ctx: RequestContext, @Args() args: QueryOrderArgs): Promise<Order | undefined> {
-        const order = await this.orderService.findOne(ctx, args.id);
+    async order(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryOrderArgs,
+        @Relations(Order) relations: RelationPaths<Order>,
+    ): Promise<Order | undefined> {
+        const requiredRelations: RelationPaths<Order> = ['customer', 'customer.user'];
+        const order = await this.orderService.findOne(
+            ctx,
+            args.id,
+            unique([...relations, ...requiredRelations]),
+        );
         if (order && ctx.authorizedAsOwnerOnly) {
             const orderUserId = order.customer && order.customer.user && order.customer.user.id;
             if (idsAreEqual(ctx.activeUserId, orderUserId)) {
@@ -83,7 +94,10 @@ export class ShopOrderResolver {
 
     @Query()
     @Allow(Permission.Owner)
-    async activeOrder(@Ctx() ctx: RequestContext): Promise<Order | undefined> {
+    async activeOrder(
+        @Ctx() ctx: RequestContext,
+        @Relations(Order) relations: RelationPaths<Order>,
+    ): Promise<Order | undefined> {
         if (ctx.authorizedAsOwnerOnly) {
             const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
             if (sessionOrder) {
@@ -99,9 +113,15 @@ export class ShopOrderResolver {
     async orderByCode(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryOrderByCodeArgs,
+        @Relations(Order) relations: RelationPaths<Order>,
     ): Promise<Order | undefined> {
         if (ctx.authorizedAsOwnerOnly) {
-            const order = await this.orderService.findOneByCode(ctx, args.code);
+            const requiredRelations: RelationPaths<Order> = ['customer', 'customer.user'];
+            const order = await this.orderService.findOneByCode(
+                ctx,
+                args.code,
+                unique([...relations, ...requiredRelations]),
+            );
 
             if (
                 order &&

+ 17 - 10
packages/core/src/api/resolvers/shop/shop-products.resolver.ts

@@ -5,9 +5,9 @@ import {
     QueryFacetArgs,
     QueryFacetsArgs,
     QueryProductArgs,
+    QueryProductsArgs,
     SearchResponse,
 } from '@vendure/common/lib/generated-shop-types';
-import { QueryProductsArgs } from '@vendure/common/lib/generated-shop-types';
 import { Omit } from '@vendure/common/lib/omit';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
@@ -22,6 +22,7 @@ import { FacetValueService } from '../../../service/services/facet-value.service
 import { ProductVariantService } from '../../../service/services/product-variant.service';
 import { ProductService } from '../../../service/services/product.service';
 import { RequestContext } from '../../common/request-context';
+import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver()
@@ -38,6 +39,7 @@ export class ShopProductsResolver {
     async products(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductsArgs,
+        @Relations(Product) relations: RelationPaths<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
         const options: ListQueryOptions<Product> = {
             ...args.options,
@@ -46,19 +48,20 @@ export class ShopProductsResolver {
                 enabled: { eq: true },
             },
         };
-        return this.productService.findAll(ctx, options);
+        return this.productService.findAll(ctx, options, relations);
     }
 
     @Query()
     async product(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryProductArgs,
+        @Relations(Product) relations: RelationPaths<Product>,
     ): Promise<Translated<Product> | undefined> {
         let result: Translated<Product> | undefined;
         if (args.id) {
-            result = await this.productService.findOne(ctx, args.id);
+            result = await this.productService.findOne(ctx, args.id, relations);
         } else if (args.slug) {
-            result = await this.productService.findOneBySlug(ctx, args.slug);
+            result = await this.productService.findOneBySlug(ctx, args.slug, relations);
         } else {
             throw new UserInputError(`error.product-id-or-slug-must-be-provided`);
         }
@@ -68,7 +71,7 @@ export class ShopProductsResolver {
         if (result.enabled === false) {
             return;
         }
-        result.facetValues = result.facetValues.filter(fv => !fv.facet.isPrivate) as any;
+        result.facetValues = result.facetValues?.filter(fv => !fv.facet.isPrivate) as any;
         return result;
     }
 
@@ -76,6 +79,7 @@ export class ShopProductsResolver {
     async collections(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionsArgs,
+        @Relations(Collection) relations: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
         const options: ListQueryOptions<Collection> = {
             ...args.options,
@@ -84,22 +88,23 @@ export class ShopProductsResolver {
                 isPrivate: { eq: false },
             },
         };
-        return this.collectionService.findAll(ctx, options || undefined);
+        return this.collectionService.findAll(ctx, options || undefined, relations);
     }
 
     @Query()
     async collection(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryCollectionArgs,
+        @Relations(Collection) relations: RelationPaths<Collection>,
     ): Promise<Translated<Collection> | undefined> {
         let collection: Translated<Collection> | undefined;
         if (args.id) {
-            collection = await this.collectionService.findOne(ctx, args.id);
+            collection = await this.collectionService.findOne(ctx, args.id, relations);
             if (args.slug && collection && collection.slug !== args.slug) {
                 throw new UserInputError(`error.collection-id-slug-mismatch`);
             }
         } else if (args.slug) {
-            collection = await this.collectionService.findOneBySlug(ctx, args.slug);
+            collection = await this.collectionService.findOneBySlug(ctx, args.slug, relations);
         } else {
             throw new UserInputError(`error.collection-id-or-slug-must-be-provided`);
         }
@@ -118,6 +123,7 @@ export class ShopProductsResolver {
     async facets(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryFacetsArgs,
+        @Relations(Facet) relations: RelationPaths<Facet>,
     ): Promise<PaginatedList<Translated<Facet>>> {
         const options: ListQueryOptions<Facet> = {
             ...args.options,
@@ -126,15 +132,16 @@ export class ShopProductsResolver {
                 isPrivate: { eq: false },
             },
         };
-        return this.facetService.findAll(ctx, options || undefined);
+        return this.facetService.findAll(ctx, options || undefined, relations);
     }
 
     @Query()
     async facet(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryFacetArgs,
+        @Relations(Facet) relations: RelationPaths<Facet>,
     ): Promise<Translated<Facet> | undefined> {
-        const facet = await this.facetService.findOne(ctx, args.id);
+        const facet = await this.facetService.findOne(ctx, args.id, relations);
         if (facet && facet.isPrivate) {
             return;
         }

+ 1 - 1
packages/core/src/common/types/entity-relation-paths.ts

@@ -49,7 +49,7 @@ export type PathsToStringProps2<T extends VendureEntity> = T extends string
     : {
           [K in EntityRelationKeys<T>]: T[K] extends VendureEntity[]
               ? [K, PathsToStringProps1<T[K][number]>]
-              : T[K] extends VendureEntity
+              : T[K] extends VendureEntity | undefined
               ? [K, PathsToStringProps1<T[K]>]
               : never;
       }[Extract<EntityRelationKeys<T>, string>];

+ 5 - 0
packages/core/src/connection/transactional-connection.ts

@@ -256,6 +256,11 @@ export class TransactionalConnection {
         options: FindOneOptions = {},
     ) {
         const qb = this.getRepository(ctx, entity).createQueryBuilder('entity');
+        if (options.relations) {
+            // Joining custom field relations here does not seem to work well,
+            // so we simply omit them and rely on the custom field relation resolvers.
+            options.relations = options.relations.filter(r => !r.startsWith('customFields.'));
+        }
         FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, options);
         if (options.loadEagerRelations !== false) {
             // tslint:disable-next-line:no-non-null-assertion

+ 3 - 2
packages/core/src/entity/order/order.entity.ts

@@ -163,7 +163,7 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     @Column({ default: 0 })
     shippingWithTax: number;
 
-    @Calculated()
+    @Calculated({ relations: ['lines', 'lines.items', 'shippingLines'] })
     get discounts(): Discount[] {
         this.throwIfLinesNotJoined('discounts');
         const groupedAdjustments = new Map<string, Discount>();
@@ -241,6 +241,7 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     }
 
     @Calculated({
+        relations: ['lines', 'lines.items'],
         query: qb => {
             qb.leftJoin(
                 qb1 => {
@@ -267,7 +268,7 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
      * @description
      * A summary of the taxes being applied to this Order.
      */
-    @Calculated()
+    @Calculated({ relations: ['lines', 'lines.items'] })
     get taxSummary(): OrderTaxSummary[] {
         this.throwIfLinesNotJoined('taxSummary');
         const taxRateMap = new Map<

+ 15 - 5
packages/core/src/service/services/administrator.service.ts

@@ -4,10 +4,10 @@ import {
     DeletionResult,
     UpdateAdministratorInput,
 } from '@vendure/common/lib/generated-types';
-import { SUPER_ADMIN_ROLE_CODE } from '@vendure/common/lib/shared-constants';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { EntityNotFoundError, InternalServerError } from '../../common/error/errors';
 import { idsAreEqual } from '../../common/index';
 import { ListQueryOptions } from '../../common/types/common-types';
@@ -58,10 +58,11 @@ export class AdministratorService {
     findAll(
         ctx: RequestContext,
         options?: ListQueryOptions<Administrator>,
+        relations?: RelationPaths<Administrator>,
     ): Promise<PaginatedList<Administrator>> {
         return this.listQueryBuilder
             .build(Administrator, options, {
-                relations: ['user', 'user.roles'],
+                relations: relations ?? ['user', 'user.roles'],
                 where: { deletedAt: null },
                 ctx,
             })
@@ -76,9 +77,13 @@ export class AdministratorService {
      * @description
      * Get an Administrator by id.
      */
-    findOne(ctx: RequestContext, administratorId: ID): Promise<Administrator | undefined> {
+    findOne(
+        ctx: RequestContext,
+        administratorId: ID,
+        relations?: RelationPaths<Administrator>,
+    ): Promise<Administrator | undefined> {
         return this.connection.getRepository(ctx, Administrator).findOne(administratorId, {
-            relations: ['user', 'user.roles'],
+            relations: relations ?? ['user', 'user.roles'],
             where: {
                 deletedAt: null,
             },
@@ -89,8 +94,13 @@ export class AdministratorService {
      * @description
      * Get an Administrator based on the User id.
      */
-    findOneByUserId(ctx: RequestContext, userId: ID): Promise<Administrator | undefined> {
+    findOneByUserId(
+        ctx: RequestContext,
+        userId: ID,
+        relations?: RelationPaths<Administrator>,
+    ): Promise<Administrator | undefined> {
         return this.connection.getRepository(ctx, Administrator).findOne({
+            relations,
             where: {
                 user: { id: userId },
                 deletedAt: null,

+ 11 - 4
packages/core/src/service/services/asset.service.ts

@@ -23,6 +23,7 @@ import { Readable, Stream } from 'stream';
 import { camelCase } from 'typeorm/util/StringUtils';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { isGraphQlErrorResult } from '../../common/error/error-result';
 import { ForbiddenError, InternalServerError } from '../../common/error/errors';
 import { MimeTypeError } from '../../common/error/generated-graphql-admin-errors';
@@ -106,14 +107,20 @@ export class AssetService {
             });
     }
 
-    findOne(ctx: RequestContext, id: ID): Promise<Asset | undefined> {
-        return this.connection.findOneInChannel(ctx, Asset, id, ctx.channelId);
+    findOne(ctx: RequestContext, id: ID, relations?: RelationPaths<Asset>): Promise<Asset | undefined> {
+        return this.connection.findOneInChannel(ctx, Asset, id, ctx.channelId, {
+            relations: relations ?? [],
+        });
     }
 
-    findAll(ctx: RequestContext, options?: AssetListOptions): Promise<PaginatedList<Asset>> {
+    findAll(
+        ctx: RequestContext,
+        options?: AssetListOptions,
+        relations?: RelationPaths<Asset>,
+    ): Promise<PaginatedList<Asset>> {
         const qb = this.listQueryBuilder.build(Asset, options, {
             ctx,
-            relations: options?.tags ? ['tags', 'channels'] : ['channels'],
+            relations: [...(relations ?? []), 'tags'],
             channelId: ctx.channelId,
         });
         const tags = options?.tags;

+ 22 - 12
packages/core/src/service/services/collection.service.ts

@@ -15,6 +15,7 @@ import { merge } from 'rxjs';
 import { debounceTime } from 'rxjs/operators';
 
 import { RequestContext, SerializedRequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { IllegalOperationError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
@@ -22,7 +23,7 @@ import { assertFound, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { Logger } from '../../config/logger/vendure-logger';
 import { TransactionalConnection } from '../../connection/transactional-connection';
-import { FacetValue } from '../../entity';
+import { Asset, FacetValue } from '../../entity';
 import { CollectionTranslation } from '../../entity/collection/collection-translation.entity';
 import { Collection } from '../../entity/collection/collection.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
@@ -130,12 +131,11 @@ export class CollectionService implements OnModuleInit {
     async findAll(
         ctx: RequestContext,
         options?: ListQueryOptions<Collection>,
+        relations?: RelationPaths<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
-        const relations = ['featuredAsset', 'parent', 'channels'];
-
         return this.listQueryBuilder
             .build(Collection, options, {
-                relations,
+                relations: relations ?? ['featuredAsset', 'parent', 'channels'],
                 channelId: ctx.channelId,
                 where: { isRoot: false },
                 orderBy: { position: 'ASC' },
@@ -153,15 +153,18 @@ export class CollectionService implements OnModuleInit {
             });
     }
 
-    async findOne(ctx: RequestContext, collectionId: ID): Promise<Translated<Collection> | undefined> {
-        const relations = ['featuredAsset', 'assets', 'channels', 'parent'];
+    async findOne(
+        ctx: RequestContext,
+        collectionId: ID,
+        relations?: RelationPaths<Collection>,
+    ): Promise<Translated<Collection> | undefined> {
         const collection = await this.connection.findOneInChannel(
             ctx,
             Collection,
             collectionId,
             ctx.channelId,
             {
-                relations,
+                relations: relations ?? ['featuredAsset', 'assets', 'channels', 'parent'],
                 loadEagerRelations: true,
             },
         );
@@ -171,10 +174,13 @@ export class CollectionService implements OnModuleInit {
         return translateDeep(collection, ctx.languageCode, ['parent']);
     }
 
-    async findByIds(ctx: RequestContext, ids: ID[]): Promise<Array<Translated<Collection>>> {
-        const relations = ['featuredAsset', 'assets', 'channels', 'parent'];
+    async findByIds(
+        ctx: RequestContext,
+        ids: ID[],
+        relations?: RelationPaths<Collection>,
+    ): Promise<Array<Translated<Collection>>> {
         const collections = this.connection.findByIdsInChannel(ctx, Collection, ids, ctx.channelId, {
-            relations,
+            relations: relations ?? ['featuredAsset', 'assets', 'channels', 'parent'],
             loadEagerRelations: true,
         });
         return collections.then(values =>
@@ -182,7 +188,11 @@ export class CollectionService implements OnModuleInit {
         );
     }
 
-    async findOneBySlug(ctx: RequestContext, slug: string): Promise<Translated<Collection> | undefined> {
+    async findOneBySlug(
+        ctx: RequestContext,
+        slug: string,
+        relations?: RelationPaths<Collection>,
+    ): Promise<Translated<Collection> | undefined> {
         const translations = await this.connection.getRepository(ctx, CollectionTranslation).find({
             relations: ['base'],
             where: { slug },
@@ -195,7 +205,7 @@ export class CollectionService implements OnModuleInit {
             translations.find(t => t.languageCode === ctx.languageCode) ??
             translations.find(t => t.languageCode === ctx.channel.defaultLanguageCode) ??
             translations[0];
-        return this.findOne(ctx, bestMatch.base.id);
+        return this.findOne(ctx, bestMatch.base.id, relations);
     }
 
     /**

+ 9 - 5
packages/core/src/service/services/country.service.ts

@@ -8,6 +8,7 @@ import {
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
@@ -22,8 +23,6 @@ import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-build
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
-import { ZoneService } from './zone.service';
-
 /**
  * @description
  * Contains methods relating to {@link Country} entities.
@@ -42,9 +41,10 @@ export class CountryService {
     findAll(
         ctx: RequestContext,
         options?: ListQueryOptions<Country>,
+        relations: RelationPaths<Country> = [],
     ): Promise<PaginatedList<Translated<Country>>> {
         return this.listQueryBuilder
-            .build(Country, options, { ctx })
+            .build(Country, options, { ctx, relations })
             .getManyAndCount()
             .then(([countries, totalItems]) => {
                 const items = countries.map(country => translateDeep(country, ctx.languageCode));
@@ -55,10 +55,14 @@ export class CountryService {
             });
     }
 
-    findOne(ctx: RequestContext, countryId: ID): Promise<Translated<Country> | undefined> {
+    findOne(
+        ctx: RequestContext,
+        countryId: ID,
+        relations: RelationPaths<Country> = [],
+    ): Promise<Translated<Country> | undefined> {
         return this.connection
             .getRepository(ctx, Country)
-            .findOne(countryId)
+            .findOne(countryId, { relations })
             .then(country => country && translateDeep(country, ctx.languageCode));
     }
 

+ 13 - 4
packages/core/src/service/services/customer-group.service.ts

@@ -13,6 +13,7 @@ import {
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { UserInputError } from '../../common/error/errors';
 import { assertFound, idsAreEqual } from '../../common/utils';
 import { TransactionalConnection } from '../../connection/transactional-connection';
@@ -43,15 +44,23 @@ export class CustomerGroupService {
         private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
-    findAll(ctx: RequestContext, options?: CustomerGroupListOptions): Promise<PaginatedList<CustomerGroup>> {
+    findAll(
+        ctx: RequestContext,
+        options?: CustomerGroupListOptions,
+        relations: RelationPaths<CustomerGroup> = [],
+    ): Promise<PaginatedList<CustomerGroup>> {
         return this.listQueryBuilder
-            .build(CustomerGroup, options, { ctx })
+            .build(CustomerGroup, options, { ctx, relations })
             .getManyAndCount()
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
 
-    findOne(ctx: RequestContext, customerGroupId: ID): Promise<CustomerGroup | undefined> {
-        return this.connection.getRepository(ctx, CustomerGroup).findOne(customerGroupId);
+    findOne(
+        ctx: RequestContext,
+        customerGroupId: ID,
+        relations: RelationPaths<CustomerGroup> = [],
+    ): Promise<CustomerGroup | undefined> {
+        return this.connection.getRepository(ctx, CustomerGroup).findOne(customerGroupId, { relations });
     }
 
     /**

+ 8 - 2
packages/core/src/service/services/customer.service.ts

@@ -22,6 +22,7 @@ import {
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../common/error/error-result';
 import { EntityNotFoundError, InternalServerError } from '../../common/error/errors';
 import { EmailAddressConflictError as EmailAddressConflictAdminError } from '../../common/error/generated-graphql-admin-errors';
@@ -89,8 +90,8 @@ export class CustomerService {
     findAll(
         ctx: RequestContext,
         options: ListQueryOptions<Customer> | undefined,
+        relations: RelationPaths<Customer> = [],
     ): Promise<PaginatedList<Customer>> {
-        const relations = ['channels'];
         const customPropertyMap: { [name: string]: string } = {};
         const hasPostalCodeFilter = !!(options as CustomerListOptions)?.filter?.postalCode;
         if (hasPostalCodeFilter) {
@@ -109,8 +110,13 @@ export class CustomerService {
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
 
-    findOne(ctx: RequestContext, id: ID): Promise<Customer | undefined> {
+    findOne(
+        ctx: RequestContext,
+        id: ID,
+        relations: RelationPaths<Customer> = [],
+    ): Promise<Customer | undefined> {
         return this.connection.findOneInChannel(ctx, Customer, id, ctx.channelId, {
+            relations,
             where: { deletedAt: null },
         });
     }

+ 15 - 7
packages/core/src/service/services/facet.service.ts

@@ -9,6 +9,7 @@ import {
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
@@ -48,11 +49,14 @@ export class FacetService {
     findAll(
         ctx: RequestContext,
         options?: ListQueryOptions<Facet>,
+        relations?: RelationPaths<Facet>,
     ): Promise<PaginatedList<Translated<Facet>>> {
-        const relations = ['values', 'values.facet', 'channels'];
-
         return this.listQueryBuilder
-            .build(Facet, options, { relations, ctx, channelId: ctx.channelId })
+            .build(Facet, options, {
+                relations: relations ?? ['values', 'values.facet', 'channels'],
+                ctx,
+                channelId: ctx.channelId,
+            })
             .getManyAndCount()
             .then(([facets, totalItems]) => {
                 const items = facets.map(facet =>
@@ -65,11 +69,15 @@ export class FacetService {
             });
     }
 
-    findOne(ctx: RequestContext, facetId: ID): Promise<Translated<Facet> | undefined> {
-        const relations = ['values', 'values.facet', 'channels'];
-
+    findOne(
+        ctx: RequestContext,
+        facetId: ID,
+        relations?: RelationPaths<Facet>,
+    ): Promise<Translated<Facet> | undefined> {
         return this.connection
-            .findOneInChannel(ctx, Facet, facetId, ctx.channelId, { relations })
+            .findOneInChannel(ctx, Facet, facetId, ctx.channelId, {
+                relations: relations ?? ['values', 'values.facet', 'channels'],
+            })
             .then(facet => facet && translateDeep(facet, ctx.languageCode, ['values', ['values', 'facet']]));
     }
 

+ 78 - 39
packages/core/src/service/services/order.service.ts

@@ -40,6 +40,7 @@ import { unique } from '@vendure/common/lib/unique';
 import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/decorators/relations.decorator';
 import { RequestContextCacheService } from '../../cache/request-context-cache.service';
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../common/error/error-result';
 import { EntityNotFoundError, InternalServerError, UserInputError } from '../../common/error/errors';
@@ -69,6 +70,7 @@ import {
     PaymentDeclinedError,
     PaymentFailedError,
 } from '../../common/error/generated-graphql-shop-errors';
+import { EntityRelationPaths, EntityRelations } from '../../common/index';
 import { grossPriceOf, netPriceOf } from '../../common/tax-utils';
 import { ListQueryOptions, PaymentMetadata } from '../../common/types/common-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
@@ -175,11 +177,15 @@ export class OrderService {
         })) as OrderProcessState[];
     }
 
-    findAll(ctx: RequestContext, options?: OrderListOptions): Promise<PaginatedList<Order>> {
+    findAll(
+        ctx: RequestContext,
+        options?: OrderListOptions,
+        relations?: RelationPaths<Order>,
+    ): Promise<PaginatedList<Order>> {
         return this.listQueryBuilder
             .build(Order, options, {
                 ctx,
-                relations: [
+                relations: relations ?? [
                     'lines',
                     'customer',
                     'lines.productVariant',
@@ -201,72 +207,103 @@ export class OrderService {
             });
     }
 
-    async findOne(ctx: RequestContext, orderId: ID): Promise<Order | undefined> {
-        const qb = this.connection
-            .getRepository(ctx, Order)
-            .createQueryBuilder('order')
-            .leftJoin('order.channels', 'channel')
-            .leftJoinAndSelect('order.customer', 'customer')
-            .leftJoinAndSelect('order.shippingLines', 'shippingLines')
-            .leftJoinAndSelect('order.surcharges', 'surcharges')
-            .leftJoinAndSelect('customer.user', 'user')
-            .leftJoinAndSelect('order.lines', 'lines')
-            .leftJoinAndSelect('lines.productVariant', 'productVariant')
-            .leftJoinAndSelect('productVariant.taxCategory', 'prodVariantTaxCategory')
-            .leftJoinAndSelect('productVariant.productVariantPrices', 'prices')
-            .leftJoinAndSelect('productVariant.translations', 'translations')
-            .leftJoinAndSelect('lines.featuredAsset', 'featuredAsset')
-            .leftJoinAndSelect('lines.items', 'items')
-            .leftJoinAndSelect('items.fulfillments', 'fulfillments')
-            .leftJoinAndSelect('lines.taxCategory', 'lineTaxCategory')
+    async findOne(
+        ctx: RequestContext,
+        orderId: ID,
+        relations?: RelationPaths<Order>,
+    ): Promise<Order | undefined> {
+        const qb = this.connection.getRepository(ctx, Order).createQueryBuilder('order');
+        const effectiveRelations = relations ?? [
+            'channels',
+            'customer',
+            'customer.user',
+            'lines',
+            'lines.items',
+            'lines.items.fulfillments',
+            'lines.productVariant',
+            'lines.productVariant.taxCategory',
+            'lines.productVariant.productVariantPrices',
+            'lines.productVariant.translations',
+            'lines.featuredAsset',
+            'lines.taxCategory',
+            'shippingLines',
+            'surcharges',
+        ];
+        if (
+            relations &&
+            effectiveRelations.includes('lines.productVariant') &&
+            !effectiveRelations.includes('lines.productVariant.taxCategory')
+        ) {
+            effectiveRelations.push('lines.productVariant.taxCategory');
+        }
+        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
+            relations: effectiveRelations,
+        });
+        qb.leftJoin('order.channels', 'channel')
             .where('order.id = :orderId', { orderId })
-            .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
-            .addOrderBy('lines.createdAt', 'ASC')
-            .addOrderBy('items.createdAt', 'ASC');
+            .andWhere('channel.id = :channelId', { channelId: ctx.channelId });
+        if (effectiveRelations.includes('lines') && effectiveRelations.includes('lines.items')) {
+            qb.addOrderBy('order__lines.createdAt', 'ASC').addOrderBy('order__lines__items.createdAt', 'ASC');
+        }
 
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
 
         const order = await qb.getOne();
         if (order) {
-            for (const line of order.lines) {
-                line.productVariant = translateDeep(
-                    await this.productVariantService.applyChannelPriceAndTax(line.productVariant, ctx, order),
-                    ctx.languageCode,
-                );
+            if (effectiveRelations.includes('lines.productVariant')) {
+                for (const line of order.lines) {
+                    line.productVariant = translateDeep(
+                        await this.productVariantService.applyChannelPriceAndTax(
+                            line.productVariant,
+                            ctx,
+                            order,
+                        ),
+                        ctx.languageCode,
+                    );
+                }
             }
             return order;
         }
     }
 
-    async findOneByCode(ctx: RequestContext, orderCode: string): Promise<Order | undefined> {
+    async findOneByCode(
+        ctx: RequestContext,
+        orderCode: string,
+        relations?: RelationPaths<Order>,
+    ): Promise<Order | undefined> {
         const order = await this.connection.getRepository(ctx, Order).findOne({
             relations: ['customer'],
             where: {
                 code: orderCode,
             },
         });
-        return order ? this.findOne(ctx, order.id) : undefined;
+        return order ? this.findOne(ctx, order.id, relations) : undefined;
     }
 
-    async findOneByOrderLineId(ctx: RequestContext, orderLineId: ID): Promise<Order | undefined> {
+    async findOneByOrderLineId(
+        ctx: RequestContext,
+        orderLineId: ID,
+        relations?: RelationPaths<Order>,
+    ): Promise<Order | undefined> {
         const order = await this.connection
             .getRepository(ctx, Order)
             .createQueryBuilder('order')
             .innerJoin('order.lines', 'line', 'line.id = :orderLineId', { orderLineId })
             .getOne();
 
-        return order ? this.findOne(ctx, order.id) : undefined;
+        return order ? this.findOne(ctx, order.id, relations) : undefined;
     }
 
     async findByCustomerId(
         ctx: RequestContext,
         customerId: ID,
         options?: ListQueryOptions<Order>,
+        relations?: RelationPaths<Order>,
     ): Promise<PaginatedList<Order>> {
         return this.listQueryBuilder
             .build(Order, options, {
-                relations: [
+                relations: relations ?? [
                     'lines',
                     'lines.items',
                     'lines.productVariant',
@@ -281,13 +318,15 @@ export class OrderService {
             .andWhere('order.customer.id = :customerId', { customerId })
             .getManyAndCount()
             .then(([items, totalItems]) => {
-                items.forEach(item => {
-                    item.lines.forEach(line => {
-                        line.productVariant = translateDeep(line.productVariant, ctx.languageCode, [
-                            'options',
-                        ]);
+                if (relations?.includes('lines.productVariant')) {
+                    items.forEach(item => {
+                        item.lines.forEach(line => {
+                            line.productVariant = translateDeep(line.productVariant, ctx.languageCode, [
+                                'options',
+                            ]);
+                        });
                     });
-                });
+                }
                 return {
                     items,
                     totalItems,

+ 11 - 3
packages/core/src/service/services/payment-method.service.ts

@@ -12,6 +12,7 @@ import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { idsAreEqual } from '../../common/utils';
@@ -49,9 +50,10 @@ export class PaymentMethodService {
     findAll(
         ctx: RequestContext,
         options?: ListQueryOptions<PaymentMethod>,
+        relations: RelationPaths<PaymentMethod> = [],
     ): Promise<PaginatedList<PaymentMethod>> {
         return this.listQueryBuilder
-            .build(PaymentMethod, options, { ctx, relations: ['channels'], channelId: ctx.channelId })
+            .build(PaymentMethod, options, { ctx, relations, channelId: ctx.channelId })
             .getManyAndCount()
             .then(([items, totalItems]) => ({
                 items,
@@ -59,8 +61,14 @@ export class PaymentMethodService {
             }));
     }
 
-    findOne(ctx: RequestContext, paymentMethodId: ID): Promise<PaymentMethod | undefined> {
-        return this.connection.findOneInChannel(ctx, PaymentMethod, paymentMethodId, ctx.channelId);
+    findOne(
+        ctx: RequestContext,
+        paymentMethodId: ID,
+        relations: RelationPaths<PaymentMethod> = [],
+    ): Promise<PaymentMethod | undefined> {
+        return this.connection.findOneInChannel(ctx, PaymentMethod, paymentMethodId, ctx.channelId, {
+            relations,
+        });
     }
 
     async create(ctx: RequestContext, input: CreatePaymentMethodInput): Promise<PaymentMethod> {

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

@@ -7,6 +7,7 @@ import { ID } from '@vendure/common/lib/shared-types';
 import { FindManyOptions, Like } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
 import { TransactionalConnection } from '../../connection/transactional-connection';
@@ -33,9 +34,13 @@ export class ProductOptionGroupService {
         private eventBus: EventBus,
     ) {}
 
-    findAll(ctx: RequestContext, filterTerm?: string): Promise<Array<Translated<ProductOptionGroup>>> {
+    findAll(
+        ctx: RequestContext,
+        filterTerm?: string,
+        relations?: RelationPaths<ProductOptionGroup>,
+    ): Promise<Array<Translated<ProductOptionGroup>>> {
         const findOptions: FindManyOptions = {
-            relations: ['options'],
+            relations: relations ?? ['options'],
         };
         if (filterTerm) {
             findOptions.where = {
@@ -48,11 +53,15 @@ export class ProductOptionGroupService {
             .then(groups => groups.map(group => translateDeep(group, ctx.languageCode, ['options'])));
     }
 
-    findOne(ctx: RequestContext, id: ID): Promise<Translated<ProductOptionGroup> | undefined> {
+    findOne(
+        ctx: RequestContext,
+        id: ID,
+        relations?: RelationPaths<ProductOptionGroup>,
+    ): Promise<Translated<ProductOptionGroup> | undefined> {
         return this.connection
             .getRepository(ctx, ProductOptionGroup)
             .findOne(id, {
-                relations: ['options'],
+                relations: relations ?? ['options'],
             })
             .then(group => group && translateDeep(group, ctx.languageCode, ['options']));
     }

+ 46 - 47
packages/core/src/service/services/product-variant.service.ts

@@ -10,8 +10,10 @@ import {
     UpdateProductVariantInput,
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/decorators/relations.decorator';
 import { RequestContextCacheService } from '../../cache/request-context-cache.service';
 import { ForbiddenError, InternalServerError, UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
@@ -90,11 +92,7 @@ export class ProductVariantService {
             })
             .getManyAndCount()
             .then(async ([variants, totalItems]) => {
-                const items = await Promise.all(
-                    variants.map(async variant =>
-                        translateDeep(await this.applyChannelPriceAndTax(variant, ctx), ctx.languageCode),
-                    ),
-                );
+                const items = await this.applyPricesAndTranslateVariants(ctx, variants);
                 return {
                     items,
                     totalItems,
@@ -102,11 +100,14 @@ export class ProductVariantService {
             });
     }
 
-    findOne(ctx: RequestContext, productVariantId: ID): Promise<Translated<ProductVariant> | undefined> {
-        const relations = ['product', 'product.featuredAsset', 'taxCategory'];
+    findOne(
+        ctx: RequestContext,
+        productVariantId: ID,
+        relations?: RelationPaths<ProductVariant>,
+    ): Promise<Translated<ProductVariant> | undefined> {
         return this.connection
             .findOneInChannel(ctx, ProductVariant, productVariantId, ctx.channelId, {
-                relations,
+                relations: [...(relations || ['product', 'product.featuredAsset']), 'taxCategory'],
                 where: { deletedAt: null },
             })
             .then(async result => {
@@ -130,36 +131,27 @@ export class ProductVariantService {
                     'featuredAsset',
                 ],
             })
-            .then(variants => {
-                return Promise.all(
-                    variants.map(async variant =>
-                        translateDeep(await this.applyChannelPriceAndTax(variant, ctx), ctx.languageCode, [
-                            'options',
-                            'facetValues',
-                            ['facetValues', 'facet'],
-                        ]),
-                    ),
-                );
-            });
+            .then(variants => this.applyPricesAndTranslateVariants(ctx, variants));
     }
 
     getVariantsByProductId(
         ctx: RequestContext,
         productId: ID,
         options: ListQueryOptions<ProductVariant> = {},
+        relations?: RelationPaths<ProductVariant>,
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
-        const relations = [
-            'options',
-            'facetValues',
-            'facetValues.facet',
-            'taxCategory',
-            'assets',
-            'featuredAsset',
-        ];
-
         const qb = this.listQueryBuilder
             .build(ProductVariant, options, {
-                relations,
+                relations: [
+                    ...(relations || [
+                        'options',
+                        'facetValues',
+                        'facetValues.facet',
+                        'assets',
+                        'featuredAsset',
+                    ]),
+                    'taxCategory',
+                ],
                 orderBy: { id: 'ASC' },
                 where: { deletedAt: null },
                 ctx,
@@ -176,17 +168,7 @@ export class ProductVariantService {
         }
 
         return qb.getManyAndCount().then(async ([variants, totalItems]) => {
-            const items = await Promise.all(
-                variants.map(async variant => {
-                    const variantWithPrices = await this.applyChannelPriceAndTax(variant, ctx);
-                    return translateDeep(variantWithPrices, ctx.languageCode, [
-                        'options',
-                        'facetValues',
-                        ['facetValues', 'facet'],
-                    ]);
-                }),
-            );
-
+            const items = await this.applyPricesAndTranslateVariants(ctx, variants);
             return {
                 items,
                 totalItems,
@@ -202,10 +184,11 @@ export class ProductVariantService {
         ctx: RequestContext,
         collectionId: ID,
         options: ListQueryOptions<ProductVariant>,
+        relations: RelationPaths<ProductVariant> = [],
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
         const qb = this.listQueryBuilder
             .build(ProductVariant, options, {
-                relations: ['taxCategory', 'channels'],
+                relations: unique([...relations, 'taxCategory']),
                 channelId: ctx.channelId,
                 ctx,
             })
@@ -220,12 +203,7 @@ export class ProductVariantService {
         }
 
         return qb.getManyAndCount().then(async ([variants, totalItems]) => {
-            const items = await Promise.all(
-                variants.map(async variant => {
-                    const variantWithPrices = await this.applyChannelPriceAndTax(variant, ctx);
-                    return translateDeep(variantWithPrices, ctx.languageCode);
-                }),
-            );
+            const items = await this.applyPricesAndTranslateVariants(ctx, variants);
             return {
                 items,
                 totalItems,
@@ -613,6 +591,27 @@ export class ProductVariantService {
         return hydratedVariant[priceField];
     }
 
+    /**
+     * @description
+     * Given an array of ProductVariants from the database, this method will apply the correct price and tax
+     * and translate each item.
+     */
+    private async applyPricesAndTranslateVariants(
+        ctx: RequestContext,
+        variants: ProductVariant[],
+    ): Promise<Array<Translated<ProductVariant>>> {
+        return await Promise.all(
+            variants.map(async variant => {
+                const variantWithPrices = await this.applyChannelPriceAndTax(variant, ctx);
+                return translateDeep(variantWithPrices, ctx.languageCode, [
+                    'options',
+                    'facetValues',
+                    ['facetValues', 'facet'],
+                ]);
+            }),
+        );
+    }
+
     /**
      * @description
      * Populates the `price` field with the price for the specified channel.

+ 32 - 7
packages/core/src/service/services/product.service.ts

@@ -13,6 +13,7 @@ import { unique } from '@vendure/common/lib/unique';
 import { FindOptionsUtils } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/decorators/relations.decorator';
 import { ErrorResultUnion } from '../../common/error/error-result';
 import { EntityNotFoundError } from '../../common/error/errors';
 import { ProductOptionInUseError } from '../../common/error/generated-graphql-admin-errors';
@@ -22,6 +23,7 @@ import { assertFound, idsAreEqual } from '../../common/utils';
 import { TransactionalConnection } from '../../connection/transactional-connection';
 import { Channel } from '../../entity/channel/channel.entity';
 import { FacetValue } from '../../entity/facet-value/facet-value.entity';
+import { Order } from '../../entity/index';
 import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../../entity/product/product-translation.entity';
 import { Product } from '../../entity/product/product.entity';
@@ -72,10 +74,11 @@ export class ProductService {
     async findAll(
         ctx: RequestContext,
         options?: ListQueryOptions<Product>,
+        relations?: RelationPaths<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
         return this.listQueryBuilder
             .build(Product, options, {
-                relations: this.relations,
+                relations: relations || this.relations,
                 channelId: ctx.channelId,
                 where: { deletedAt: null },
                 ctx,
@@ -92,9 +95,19 @@ export class ProductService {
             });
     }
 
-    async findOne(ctx: RequestContext, productId: ID): Promise<Translated<Product> | undefined> {
+    async findOne(
+        ctx: RequestContext,
+        productId: ID,
+        relations?: RelationPaths<Product>,
+    ): Promise<Translated<Product> | undefined> {
+        const effectiveRelations = relations ?? this.relations;
+        if (relations && effectiveRelations.includes('facetValues')) {
+            // We need the facet to determine with the FacetValues are public
+            // when serving via the Shop API.
+            effectiveRelations.push('facetValues.facet');
+        }
         const product = await this.connection.findOneInChannel(ctx, Product, productId, ctx.channelId, {
-            relations: this.relations,
+            relations: unique(effectiveRelations),
             where: {
                 deletedAt: null,
             },
@@ -105,9 +118,15 @@ export class ProductService {
         return translateDeep(product, ctx.languageCode, ['facetValues', ['facetValues', 'facet']]);
     }
 
-    async findByIds(ctx: RequestContext, productIds: ID[]): Promise<Array<Translated<Product>>> {
+    async findByIds(
+        ctx: RequestContext,
+        productIds: ID[],
+        relations?: RelationPaths<Product>,
+    ): Promise<Array<Translated<Product>>> {
         const qb = this.connection.getRepository(ctx, Product).createQueryBuilder('product');
-        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, { relations: this.relations });
+        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
+            relations: (relations && false) || this.relations,
+        });
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
         return qb
@@ -146,9 +165,15 @@ export class ProductService {
             );
     }
 
-    async findOneBySlug(ctx: RequestContext, slug: string): Promise<Translated<Product> | undefined> {
+    async findOneBySlug(
+        ctx: RequestContext,
+        slug: string,
+        relations?: RelationPaths<Product>,
+    ): Promise<Translated<Product> | undefined> {
         const qb = this.connection.getRepository(ctx, Product).createQueryBuilder('product');
-        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, { relations: this.relations });
+        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
+            relations: (relations && false) || this.relations,
+        });
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
         const translationQb = this.connection

+ 13 - 3
packages/core/src/service/services/promotion.service.ts

@@ -17,6 +17,7 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { ErrorResultUnion, JustErrorResults } from '../../common/error/error-result';
 import { IllegalOperationError, UserInputError } from '../../common/error/errors';
 import { MissingConditionsError } from '../../common/error/generated-graphql-admin-errors';
@@ -66,12 +67,16 @@ export class PromotionService {
         this.availableActions = this.configService.promotionOptions.promotionActions || [];
     }
 
-    findAll(ctx: RequestContext, options?: ListQueryOptions<Promotion>): Promise<PaginatedList<Promotion>> {
+    findAll(
+        ctx: RequestContext,
+        options?: ListQueryOptions<Promotion>,
+        relations: RelationPaths<Promotion> = [],
+    ): Promise<PaginatedList<Promotion>> {
         return this.listQueryBuilder
             .build(Promotion, options, {
                 where: { deletedAt: null },
                 channelId: ctx.channelId,
-                relations: ['channels'],
+                relations,
                 ctx,
             })
             .getManyAndCount()
@@ -81,9 +86,14 @@ export class PromotionService {
             }));
     }
 
-    async findOne(ctx: RequestContext, adjustmentSourceId: ID): Promise<Promotion | undefined> {
+    async findOne(
+        ctx: RequestContext,
+        adjustmentSourceId: ID,
+        relations: RelationPaths<Promotion> = [],
+    ): Promise<Promotion | undefined> {
         return this.connection.findOneInChannel(ctx, Promotion, adjustmentSourceId, ctx.channelId, {
             where: { deletedAt: null },
+            relations,
         });
     }
 

+ 9 - 4
packages/core/src/service/services/role.service.ts

@@ -16,6 +16,7 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { getAllPermissionsMetadata } from '../../common/constants';
 import {
     EntityNotFoundError,
@@ -60,9 +61,13 @@ export class RoleService {
         await this.ensureRolesHaveValidPermissions();
     }
 
-    findAll(ctx: RequestContext, options?: ListQueryOptions<Role>): Promise<PaginatedList<Role>> {
+    findAll(
+        ctx: RequestContext,
+        options?: ListQueryOptions<Role>,
+        relations?: RelationPaths<Role>,
+    ): Promise<PaginatedList<Role>> {
         return this.listQueryBuilder
-            .build(Role, options, { relations: ['channels'], ctx })
+            .build(Role, options, { relations: relations ?? ['channels'], ctx })
             .getManyAndCount()
             .then(([items, totalItems]) => ({
                 items,
@@ -70,9 +75,9 @@ export class RoleService {
             }));
     }
 
-    findOne(ctx: RequestContext, roleId: ID): Promise<Role | undefined> {
+    findOne(ctx: RequestContext, roleId: ID, relations?: RelationPaths<Role>): Promise<Role | undefined> {
         return this.connection.getRepository(ctx, Role).findOne(roleId, {
-            relations: ['channels'],
+            relations: relations ?? ['channels'],
         });
     }
 

+ 5 - 2
packages/core/src/service/services/shipping-method.service.ts

@@ -10,6 +10,7 @@ import { omit } from '@vendure/common/lib/omit';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { EntityNotFoundError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
@@ -61,10 +62,11 @@ export class ShippingMethodService {
     findAll(
         ctx: RequestContext,
         options?: ListQueryOptions<ShippingMethod>,
+        relations: RelationPaths<ShippingMethod> = [],
     ): Promise<PaginatedList<ShippingMethod>> {
         return this.listQueryBuilder
             .build(ShippingMethod, options, {
-                relations: ['channels'],
+                relations,
                 where: { deletedAt: null },
                 channelId: ctx.channelId,
                 ctx,
@@ -80,6 +82,7 @@ export class ShippingMethodService {
         ctx: RequestContext,
         shippingMethodId: ID,
         includeDeleted = false,
+        relations: RelationPaths<ShippingMethod> = [],
     ): Promise<ShippingMethod | undefined> {
         const shippingMethod = await this.connection.findOneInChannel(
             ctx,
@@ -87,7 +90,7 @@ export class ShippingMethodService {
             shippingMethodId,
             ctx.channelId,
             {
-                relations: ['channels'],
+                relations,
                 ...(includeDeleted === false ? { where: { deletedAt: null } } : {}),
             },
         );

+ 13 - 4
packages/core/src/service/services/tax-rate.service.ts

@@ -8,6 +8,7 @@ import {
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
+import { RelationPaths } from '../../api/index';
 import { RequestContextCacheService } from '../../cache';
 import { EntityNotFoundError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
@@ -47,9 +48,13 @@ export class TaxRateService {
         private cacheService: RequestContextCacheService,
     ) {}
 
-    findAll(ctx: RequestContext, options?: ListQueryOptions<TaxRate>): Promise<PaginatedList<TaxRate>> {
+    findAll(
+        ctx: RequestContext,
+        options?: ListQueryOptions<TaxRate>,
+        relations?: RelationPaths<TaxRate>,
+    ): Promise<PaginatedList<TaxRate>> {
         return this.listQueryBuilder
-            .build(TaxRate, options, { relations: ['category', 'zone', 'customerGroup'], ctx })
+            .build(TaxRate, options, { relations: relations ?? ['category', 'zone', 'customerGroup'], ctx })
             .getManyAndCount()
             .then(([items, totalItems]) => ({
                 items,
@@ -57,9 +62,13 @@ export class TaxRateService {
             }));
     }
 
-    findOne(ctx: RequestContext, taxRateId: ID): Promise<TaxRate | undefined> {
+    findOne(
+        ctx: RequestContext,
+        taxRateId: ID,
+        relations?: RelationPaths<TaxRate>,
+    ): Promise<TaxRate | undefined> {
         return this.connection.getRepository(ctx, TaxRate).findOne(taxRateId, {
-            relations: ['category', 'zone', 'customerGroup'],
+            relations: relations ?? ['category', 'zone', 'customerGroup'],
         });
     }
 

+ 5 - 5
packages/core/src/service/services/zone.service.ts

@@ -40,7 +40,7 @@ export class ZoneService {
         private connection: TransactionalConnection,
         private configService: ConfigService,
         private eventBus: EventBus,
-    ) { }
+    ) {}
 
     /** @internal */
     async initZones() {
@@ -48,7 +48,7 @@ export class ZoneService {
     }
 
     /**
-     * Creates a zones cache, that can be used to reduce number of zones queries to database 
+     * Creates a zones cache, that can be used to reduce number of zones queries to database
      *
      * @internal
      */
@@ -188,11 +188,11 @@ export class ZoneService {
     }
 
     /**
-    * Ensures zones cache exists. If not, this method creates one.
-    */
+     * Ensures zones cache exists. If not, this method creates one.
+     */
     private async ensureCacheExists() {
         if (this.zones) {
-            return
+            return;
         }
 
         this.zones = await this.createCache();

+ 15 - 6
scripts/codegen/generate-graphql-types.ts

@@ -25,6 +25,7 @@ const specFileToIgnore = [
     'parallel-transactions.e2e-spec',
     'order-merge.e2e-spec',
     'entity-hydrator.e2e-spec',
+    'relations-decorator.e2e-spec',
 ];
 const E2E_ADMIN_QUERY_FILES = path.join(
     __dirname,
@@ -181,12 +182,20 @@ Promise.all([
                         plugins: clientPlugins,
                         config: e2eConfig,
                     },
-                [path.join(__dirname, '../../packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts')]:
-                    {
-                        schema: [SHOP_SCHEMA_OUTPUT_FILE, path.join(__dirname, '../../packages/payments-plugin/src/mollie/mollie-shop-schema.ts')],
-                        plugins: clientPlugins,
-                        config,
-                    },
+                [path.join(
+                    __dirname,
+                    '../../packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts',
+                )]: {
+                    schema: [
+                        SHOP_SCHEMA_OUTPUT_FILE,
+                        path.join(
+                            __dirname,
+                            '../../packages/payments-plugin/src/mollie/mollie-shop-schema.ts',
+                        ),
+                    ],
+                    plugins: clientPlugins,
+                    config,
+                },
             },
         });
     })

+ 41 - 2
yarn.lock

@@ -3989,6 +3989,13 @@
   dependencies:
     "@types/node" "*"
 
+"@types/graphql-fields@^1.3.4":
+  version "1.3.4"
+  resolved "https://registry.npmjs.org/@types/graphql-fields/-/graphql-fields-1.3.4.tgz#868ffe444ba8027ea1eccb0909f9c331d1bd620a"
+  integrity sha512-McLJaAaqY7lk9d9y7E61iQrj0AwcEjSb8uHlPh7KgYV+XX1MSLlSt/alhd5k2BPRE8gy/f4lnkLGb5ke3iG66Q==
+  dependencies:
+    graphql "^15.3.0"
+
 "@types/graphql-upload@^8.0.4":
   version "8.0.7"
   resolved "https://registry.npmjs.org/@types/graphql-upload/-/graphql-upload-8.0.7.tgz#71dd5d4a8d9ddb598df91298d6e98a943061b255"
@@ -9700,6 +9707,11 @@ graphql-extensions@^0.15.0:
     apollo-server-env "^3.1.0"
     apollo-server-types "^0.9.0"
 
+graphql-fields@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.npmjs.org/graphql-fields/-/graphql-fields-2.0.3.tgz#5e68dff7afbb202be4f4f40623e983b22c96ab8f"
+  integrity sha512-x3VE5lUcR4XCOxPIqaO4CE+bTK8u6gVouOdpQX9+EKHr+scqtK5Pp/l8nIGqIpN1TUlkKE6jDCCycm/WtLRAwA==
+
 graphql-request@^3.3.0:
   version "3.5.0"
   resolved "https://registry.npmjs.org/graphql-request/-/graphql-request-3.5.0.tgz#7e69574e15875fb3f660a4b4be3996ecd0bbc8b7"
@@ -10262,13 +10274,20 @@ ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1:
   resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
-ignore-walk@^3.0.1:
+ignore-walk@^3.0.1, ignore-walk@^3.0.3:
   version "3.0.4"
   resolved "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz#c9a09f69b7c7b479a5d74ac1a3c0d4236d2a6335"
   integrity sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==
   dependencies:
     minimatch "^3.0.4"
 
+ignore-walk@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.npmjs.org/ignore-walk/-/ignore-walk-4.0.1.tgz#fc840e8346cf88a3a9380c5b17933cd8f4d39fa3"
+  integrity sha512-rzDQLaW4jQbh2YrOFlJdCtX8qgJTehFRYiUB2r1osqTeDzV/3+Jh8fz1oAPzUThf3iku8Ds4IDqawI5d8mUiQw==
+  dependencies:
+    minimatch "^3.0.4"
+
 ignore@^5.1.4:
   version "5.1.8"
   resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
@@ -14031,7 +14050,7 @@ npm-package-arg@8.1.5, npm-package-arg@^8.0.0, npm-package-arg@^8.0.1, npm-packa
     semver "^7.3.4"
     validate-npm-package-name "^3.0.0"
 
-npm-packlist@1.1.12, npm-packlist@^1.1.6, npm-packlist@^2.1.4, npm-packlist@^3.0.0:
+npm-packlist@^1.1.6:
   version "1.1.12"
   resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.1.12.tgz#22bde2ebc12e72ca482abd67afc51eb49377243a"
   integrity sha512-WJKFOVMeAlsU/pjXuqVdzU0WfgtIBCupkEVwn+1Y0ERAbUfWw8R4GjgVbaKnUjRoD2FoQbHOCbOyT5Mbs9Lw4g==
@@ -14039,6 +14058,26 @@ npm-packlist@1.1.12, npm-packlist@^1.1.6, npm-packlist@^2.1.4, npm-packlist@^3.0
     ignore-walk "^3.0.1"
     npm-bundled "^1.0.1"
 
+npm-packlist@^2.1.4:
+  version "2.2.2"
+  resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-2.2.2.tgz#076b97293fa620f632833186a7a8f65aaa6148c8"
+  integrity sha512-Jt01acDvJRhJGthnUJVF/w6gumWOZxO7IkpY/lsX9//zqQgnF7OJaxgQXcerd4uQOLu7W5bkb4mChL9mdfm+Zg==
+  dependencies:
+    glob "^7.1.6"
+    ignore-walk "^3.0.3"
+    npm-bundled "^1.1.1"
+    npm-normalize-package-bin "^1.0.1"
+
+npm-packlist@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-3.0.0.tgz#0370df5cfc2fcc8f79b8f42b37798dd9ee32c2a9"
+  integrity sha512-L/cbzmutAwII5glUcf2DBRNY/d0TFd4e/FnaZigJV6JD85RHZXJFGwCndjMWiiViiWSsWt3tiOLpI3ByTnIdFQ==
+  dependencies:
+    glob "^7.1.6"
+    ignore-walk "^4.0.1"
+    npm-bundled "^1.1.1"
+    npm-normalize-package-bin "^1.0.1"
+
 npm-pick-manifest@6.1.1, npm-pick-manifest@^6.0.0, npm-pick-manifest@^6.1.1:
   version "6.1.1"
   resolved "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-6.1.1.tgz#7b5484ca2c908565f43b7f27644f36bb816f5148"