Browse Source

fix(core): Resolve all LocaleString fields in GraphQL API

Relates to #763
Michael Bromley 4 years ago
parent
commit
3ddadc0a36

+ 2 - 0
packages/core/src/api/api-internal-modules.ts

@@ -36,6 +36,7 @@ import { ZoneResolver } from './resolvers/admin/zone.resolver';
 import { AdministratorEntityResolver } from './resolvers/entity/administrator-entity.resolver';
 import { AssetEntityResolver } from './resolvers/entity/asset-entity.resolver';
 import { CollectionEntityResolver } from './resolvers/entity/collection-entity.resolver';
+import { CountryEntityResolver } from './resolvers/entity/country-entity.resolver';
 import {
     CustomerAdminEntityResolver,
     CustomerEntityResolver,
@@ -112,6 +113,7 @@ const shopResolvers = [
 
 export const entityResolvers = [
     CollectionEntityResolver,
+    CountryEntityResolver,
     CustomerEntityResolver,
     CustomerGroupEntityResolver,
     FacetEntityResolver,

+ 17 - 0
packages/core/src/api/resolvers/entity/collection-entity.resolver.ts

@@ -5,6 +5,7 @@ import { PaginatedList } from '@vendure/common/lib/shared-types';
 import { ListQueryOptions } from '../../../common/types/common-types';
 import { Translated } from '../../../common/types/locale-types';
 import { Asset, Collection, Product, ProductVariant } from '../../../entity';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
 import { AssetService } from '../../../service/services/asset.service';
 import { CollectionService } from '../../../service/services/collection.service';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
@@ -19,8 +20,24 @@ export class CollectionEntityResolver {
         private productVariantService: ProductVariantService,
         private collectionService: CollectionService,
         private assetService: AssetService,
+        private localeStringHydrator: LocaleStringHydrator,
     ) {}
 
+    @ResolveField()
+    name(@Ctx() ctx: RequestContext, @Parent() collection: Collection): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, collection, 'name');
+    }
+
+    @ResolveField()
+    slug(@Ctx() ctx: RequestContext, @Parent() collection: Collection): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, collection, 'slug');
+    }
+
+    @ResolveField()
+    description(@Ctx() ctx: RequestContext, @Parent() collection: Collection): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, collection, 'description');
+    }
+
     @ResolveField()
     async productVariants(
         @Ctx() ctx: RequestContext,

+ 16 - 0
packages/core/src/api/resolvers/entity/country-entity.resolver.ts

@@ -0,0 +1,16 @@
+import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
+
+import { Country } from '../../../entity/country/country.entity';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('Country')
+export class CountryEntityResolver {
+    constructor(private localeStringHydrator: LocaleStringHydrator) {}
+
+    @ResolveField()
+    name(@Ctx() ctx: RequestContext, @Parent() country: Country): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, country, 'name');
+    }
+}

+ 10 - 1
packages/core/src/api/resolvers/entity/facet-entity.resolver.ts

@@ -2,13 +2,22 @@ import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
 import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
 import { Facet } from '../../../entity/facet/facet.entity';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
 import { FacetValueService } from '../../../service/services/facet-value.service';
 import { RequestContext } from '../../common/request-context';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Facet')
 export class FacetEntityResolver {
-    constructor(private facetValueService: FacetValueService) {}
+    constructor(
+        private facetValueService: FacetValueService,
+        private localeStringHydrator: LocaleStringHydrator,
+    ) {}
+
+    @ResolveField()
+    name(@Ctx() ctx: RequestContext, @Parent() facetValue: FacetValue): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, facetValue, 'name');
+    }
 
     @ResolveField()
     async values(@Ctx() ctx: RequestContext, @Parent() facet: Facet): Promise<FacetValue[]> {

+ 7 - 2
packages/core/src/api/resolvers/entity/facet-value-entity.resolver.ts

@@ -2,14 +2,19 @@ import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
 import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
 import { Facet } from '../../../entity/facet/facet.entity';
-import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
 import { FacetService } from '../../../service/services/facet.service';
 import { RequestContext } from '../../common/request-context';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('FacetValue')
 export class FacetValueEntityResolver {
-    constructor(private facetService: FacetService) {}
+    constructor(private facetService: FacetService, private localeStringHydrator: LocaleStringHydrator) {}
+
+    @ResolveField()
+    name(@Ctx() ctx: RequestContext, @Parent() facetValue: FacetValue): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, facetValue, 'name');
+    }
 
     @ResolveField()
     async facet(@Ctx() ctx: RequestContext, @Parent() facetValue: FacetValue): Promise<Facet | undefined> {

+ 17 - 0
packages/core/src/api/resolvers/entity/product-entity.resolver.ts

@@ -10,6 +10,7 @@ import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
 import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { Product } from '../../../entity/product/product.entity';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
 import { AssetService } from '../../../service/services/asset.service';
 import { CollectionService } from '../../../service/services/collection.service';
 import { ProductOptionGroupService } from '../../../service/services/product-option-group.service';
@@ -28,8 +29,24 @@ export class ProductEntityResolver {
         private productOptionGroupService: ProductOptionGroupService,
         private assetService: AssetService,
         private productService: ProductService,
+        private localeStringHydrator: LocaleStringHydrator,
     ) {}
 
+    @ResolveField()
+    name(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, product, 'name');
+    }
+
+    @ResolveField()
+    slug(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, product, 'slug');
+    }
+
+    @ResolveField()
+    description(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, product, 'description');
+    }
+
     @ResolveField()
     async variants(
         @Ctx() ctx: RequestContext,

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

@@ -5,6 +5,7 @@ import { Translated } from '../../../common/types/locale-types';
 import { assertFound } from '../../../common/utils';
 import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
 import { ProductOption } from '../../../entity/product-option/product-option.entity';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
 import { ProductOptionGroupService } from '../../../service/services/product-option-group.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
@@ -12,7 +13,15 @@ import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('ProductOption')
 export class ProductOptionEntityResolver {
-    constructor(private productOptionGroupService: ProductOptionGroupService) {}
+    constructor(
+        private productOptionGroupService: ProductOptionGroupService,
+        private localeStringHydrator: LocaleStringHydrator,
+    ) {}
+
+    @ResolveField()
+    name(@Ctx() ctx: RequestContext, @Parent() productOption: ProductOption): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, productOption, 'name');
+    }
 
     @ResolveField()
     @Allow(Permission.ReadCatalog, Permission.Public)

+ 10 - 1
packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts

@@ -4,6 +4,7 @@ import { Permission } from '@vendure/common/lib/generated-types';
 import { Translated } from '../../../common/types/locale-types';
 import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
 import { ProductOption } from '../../../entity/product-option/product-option.entity';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
 import { ProductOptionGroupService } from '../../../service/services/product-option-group.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
@@ -11,7 +12,15 @@ import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('ProductOptionGroup')
 export class ProductOptionGroupEntityResolver {
-    constructor(private productOptionGroupService: ProductOptionGroupService) {}
+    constructor(
+        private productOptionGroupService: ProductOptionGroupService,
+        private localeStringHydrator: LocaleStringHydrator,
+    ) {}
+
+    @ResolveField()
+    name(@Ctx() ctx: RequestContext, @Parent() optionGroup: ProductOptionGroup): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, optionGroup, 'name');
+    }
 
     @ResolveField()
     @Allow(Permission.ReadCatalog, Permission.Public)

+ 11 - 1
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -8,6 +8,7 @@ import { idsAreEqual } from '../../../common/utils';
 import { Asset, Channel, FacetValue, Product, ProductOption } from '../../../entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { StockMovement } from '../../../entity/stock-movement/stock-movement.entity';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
 import { AssetService } from '../../../service/services/asset.service';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
 import { StockMovementService } from '../../../service/services/stock-movement.service';
@@ -18,7 +19,16 @@ import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('ProductVariant')
 export class ProductVariantEntityResolver {
-    constructor(private productVariantService: ProductVariantService, private assetService: AssetService) {}
+    constructor(
+        private productVariantService: ProductVariantService,
+        private assetService: AssetService,
+        private localeStringHydrator: LocaleStringHydrator,
+    ) {}
+
+    @ResolveField()
+    async name(@Ctx() ctx: RequestContext, @Parent() productVariant: ProductVariant): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, productVariant, 'name');
+    }
 
     @ResolveField()
     async product(

+ 79 - 0
packages/core/src/service/helpers/locale-string-hydrator/locale-string-hydrator.ts

@@ -0,0 +1,79 @@
+import { Injectable } from '@nestjs/common';
+
+import { RequestContext } from '../../../api/common/request-context';
+import { RequestContextCacheService } from '../../../cache/request-context-cache.service';
+import { Translatable, TranslatableKeys, Translated } from '../../../common/types/locale-types';
+import { VendureEntity } from '../../../entity/base/base.entity';
+import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { TransactionalConnection } from '../../transaction/transactional-connection';
+import { translateDeep } from '../utils/translate-entity';
+
+/**
+ * This helper class is to be used in GraphQL entity resolvers, to resolve fields which depend on being
+ * translated (i.e. the corresponding entity field is of type `LocaleString`).
+ */
+@Injectable()
+export class LocaleStringHydrator {
+    constructor(
+        private connection: TransactionalConnection,
+        private requestCache: RequestContextCacheService,
+    ) {}
+
+    async hydrateLocaleStringField<T extends VendureEntity & Translatable>(
+        ctx: RequestContext,
+        entity: T,
+        fieldName: TranslatableKeys<T>,
+    ): Promise<string> {
+        if (entity[fieldName]) {
+            // Already hydrated, so return the value
+            return entity[fieldName] as any;
+        }
+        await this.hydrateLocaleStrings(ctx, entity);
+        return entity[fieldName] as any;
+    }
+
+    /**
+     * Takes a translatable entity and populates all the LocaleString fields
+     * by fetching the translations from the database (they will be eagerly loaded).
+     *
+     * This method includes a caching optimization to prevent multiple DB calls when many
+     * translatable fields are needed on the same entity in a resolver.
+     */
+    private async hydrateLocaleStrings<T extends VendureEntity & Translatable>(
+        ctx: RequestContext,
+        entity: T,
+    ): Promise<Translated<T>> {
+        const entityType = entity.constructor.name;
+        if (!entity.translations?.length) {
+            const cacheKey = `hydrate-${entityType}-${entity.id}`;
+            let dbCallPromise = this.requestCache.get<Promise<T | undefined>>(ctx, cacheKey);
+
+            if (!dbCallPromise) {
+                dbCallPromise = this.connection.getRepository<T>(ctx, entityType).findOne(entity.id);
+                this.requestCache.set(ctx, cacheKey, dbCallPromise);
+            }
+
+            await dbCallPromise.then(withTranslations => {
+                // tslint:disable-next-line:no-non-null-assertion
+                entity.translations = withTranslations!.translations;
+            });
+        }
+        if (entity.translations.length) {
+            const translated = translateDeep(entity, ctx.languageCode);
+            for (const localeStringProp of Object.keys(entity.translations[0])) {
+                if (localeStringProp === 'base' || localeStringProp === 'languageCode') {
+                    continue;
+                }
+                if (localeStringProp === 'customFields') {
+                    (entity as any)[localeStringProp] = Object.assign(
+                        (entity as any)[localeStringProp],
+                        (translated as any)[localeStringProp],
+                    );
+                } else {
+                    (entity as any)[localeStringProp] = (translated as any)[localeStringProp];
+                }
+            }
+        }
+        return entity as Translated<T>;
+    }
+}

+ 1 - 0
packages/core/src/service/index.ts

@@ -3,6 +3,7 @@ export * from './helpers/utils/patch-entity';
 export * from './helpers/utils/channel-aware-orm-utils';
 export * from './helpers/utils/get-entity-or-throw';
 export * from './helpers/list-query-builder/list-query-builder';
+export * from './helpers/locale-string-hydrator/locale-string-hydrator';
 export * from './helpers/external-authentication/external-authentication.service';
 export * from './helpers/order-calculator/order-calculator';
 export * from './helpers/order-merger/order-merger';

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

@@ -14,6 +14,7 @@ import { CustomFieldRelationService } from './helpers/custom-field-relation/cust
 import { ExternalAuthenticationService } from './helpers/external-authentication/external-authentication.service';
 import { FulfillmentStateMachine } from './helpers/fulfillment-state-machine/fulfillment-state-machine';
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
+import { LocaleStringHydrator } from './helpers/locale-string-hydrator/locale-string-hydrator';
 import { OrderCalculator } from './helpers/order-calculator/order-calculator';
 import { OrderMerger } from './helpers/order-merger/order-merger';
 import { OrderModifier } from './helpers/order-modifier/order-modifier';
@@ -113,10 +114,10 @@ const helpers = [
     ExternalAuthenticationService,
     TransactionalConnection,
     CustomFieldRelationService,
+    LocaleStringHydrator,
 ];
 
 let defaultTypeOrmModule: DynamicModule;
-let workerTypeOrmModule: DynamicModule;
 
 /**
  * The ServiceCoreModule is imported internally by the ServiceModule. It is arranged in this way so that