From 3ddadc0a365d2c1c0d4b52223da65b2004351a54 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Tue, 16 Mar 2021 12:22:51 +0100 Subject: [PATCH] fix(core): Resolve all LocaleString fields in GraphQL API Relates to #763 --- packages/core/src/api/api-internal-modules.ts | 2 + .../entity/collection-entity.resolver.ts | 17 ++++ .../entity/country-entity.resolver.ts | 16 ++++ .../resolvers/entity/facet-entity.resolver.ts | 11 ++- .../entity/facet-value-entity.resolver.ts | 9 ++- .../entity/product-entity.resolver.ts | 17 ++++ .../entity/product-option-entity.resolver.ts | 11 ++- .../product-option-group-entity.resolver.ts | 11 ++- .../entity/product-variant-entity.resolver.ts | 12 ++- .../locale-string-hydrator.ts | 79 +++++++++++++++++++ packages/core/src/service/index.ts | 1 + packages/core/src/service/service.module.ts | 3 +- 12 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/api/resolvers/entity/country-entity.resolver.ts create mode 100644 packages/core/src/service/helpers/locale-string-hydrator/locale-string-hydrator.ts diff --git a/packages/core/src/api/api-internal-modules.ts b/packages/core/src/api/api-internal-modules.ts index 790f777cb6..3dab41f82b 100644 --- a/packages/core/src/api/api-internal-modules.ts +++ b/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, diff --git a/packages/core/src/api/resolvers/entity/collection-entity.resolver.ts b/packages/core/src/api/resolvers/entity/collection-entity.resolver.ts index 2daf9d3ed5..f6e30700f6 100644 --- a/packages/core/src/api/resolvers/entity/collection-entity.resolver.ts +++ b/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 { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, collection, 'name'); + } + + @ResolveField() + slug(@Ctx() ctx: RequestContext, @Parent() collection: Collection): Promise { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, collection, 'slug'); + } + + @ResolveField() + description(@Ctx() ctx: RequestContext, @Parent() collection: Collection): Promise { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, collection, 'description'); + } + @ResolveField() async productVariants( @Ctx() ctx: RequestContext, diff --git a/packages/core/src/api/resolvers/entity/country-entity.resolver.ts b/packages/core/src/api/resolvers/entity/country-entity.resolver.ts new file mode 100644 index 0000000000..79156aa10c --- /dev/null +++ b/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 { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, country, 'name'); + } +} diff --git a/packages/core/src/api/resolvers/entity/facet-entity.resolver.ts b/packages/core/src/api/resolvers/entity/facet-entity.resolver.ts index dfd1ffa925..a55b1545d8 100644 --- a/packages/core/src/api/resolvers/entity/facet-entity.resolver.ts +++ b/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 { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, facetValue, 'name'); + } @ResolveField() async values(@Ctx() ctx: RequestContext, @Parent() facet: Facet): Promise { diff --git a/packages/core/src/api/resolvers/entity/facet-value-entity.resolver.ts b/packages/core/src/api/resolvers/entity/facet-value-entity.resolver.ts index fe232e53c7..1c7c1a7b0e 100644 --- a/packages/core/src/api/resolvers/entity/facet-value-entity.resolver.ts +++ b/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 { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, facetValue, 'name'); + } @ResolveField() async facet(@Ctx() ctx: RequestContext, @Parent() facetValue: FacetValue): Promise { diff --git a/packages/core/src/api/resolvers/entity/product-entity.resolver.ts b/packages/core/src/api/resolvers/entity/product-entity.resolver.ts index 9c305a6837..b238a08f4f 100644 --- a/packages/core/src/api/resolvers/entity/product-entity.resolver.ts +++ b/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 { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, product, 'name'); + } + + @ResolveField() + slug(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, product, 'slug'); + } + + @ResolveField() + description(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, product, 'description'); + } + @ResolveField() async variants( @Ctx() ctx: RequestContext, diff --git a/packages/core/src/api/resolvers/entity/product-option-entity.resolver.ts b/packages/core/src/api/resolvers/entity/product-option-entity.resolver.ts index f614a36755..3982caeb45 100644 --- a/packages/core/src/api/resolvers/entity/product-option-entity.resolver.ts +++ b/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 { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, productOption, 'name'); + } @ResolveField() @Allow(Permission.ReadCatalog, Permission.Public) diff --git a/packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts b/packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts index 826288003f..40efe8f634 100644 --- a/packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts +++ b/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 { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, optionGroup, 'name'); + } @ResolveField() @Allow(Permission.ReadCatalog, Permission.Public) diff --git a/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts b/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts index ca7a2ce2f0..3d01973dee 100644 --- a/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts +++ b/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 { + return this.localeStringHydrator.hydrateLocaleStringField(ctx, productVariant, 'name'); + } @ResolveField() async product( diff --git a/packages/core/src/service/helpers/locale-string-hydrator/locale-string-hydrator.ts b/packages/core/src/service/helpers/locale-string-hydrator/locale-string-hydrator.ts new file mode 100644 index 0000000000..de623efc78 --- /dev/null +++ b/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( + ctx: RequestContext, + entity: T, + fieldName: TranslatableKeys, + ): Promise { + 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( + ctx: RequestContext, + entity: T, + ): Promise> { + const entityType = entity.constructor.name; + if (!entity.translations?.length) { + const cacheKey = `hydrate-${entityType}-${entity.id}`; + let dbCallPromise = this.requestCache.get>(ctx, cacheKey); + + if (!dbCallPromise) { + dbCallPromise = this.connection.getRepository(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; + } +} diff --git a/packages/core/src/service/index.ts b/packages/core/src/service/index.ts index 8341b7160f..735db5cd0c 100644 --- a/packages/core/src/service/index.ts +++ b/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'; diff --git a/packages/core/src/service/service.module.ts b/packages/core/src/service/service.module.ts index 28c90dc0dd..b28cc5ee81 100644 --- a/packages/core/src/service/service.module.ts +++ b/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