Browse Source

feat(server): Correctly type translated entities

Michael Bromley 7 years ago
parent
commit
1ea8dfe57c

+ 1 - 1
server/src/api/customer/customer.api.graphql

@@ -1,6 +1,6 @@
 type Query {
   customers(take: Int, skip: Int): CustomerList!
-  customer(id: ID!): Customer!
+  customer(id: ID!): Customer
 }
 
 type Mutation {

+ 5 - 5
server/src/api/product-option/product-option.resolver.ts

@@ -20,17 +20,17 @@ export class ProductOptionResolver {
     ) {}
 
     @Query('productOptionGroups')
-    productOptionGroups(obj, args): Promise<ProductOptionGroup[]> {
+    productOptionGroups(obj, args): Promise<Array<Translated<ProductOptionGroup>>> {
         return this.productOptionGroupService.findAll(args.languageCode, args.filterTerm);
     }
 
     @Query('productOptionGroup')
-    productOptionGroup(obj, args): Promise<ProductOptionGroup | undefined> {
+    productOptionGroup(obj, args): Promise<Translated<ProductOptionGroup> | undefined> {
         return this.productOptionGroupService.findOne(args.id, args.languageCode);
     }
 
     @ResolveProperty('options')
-    async options(optionGroup: Translated<ProductOptionGroup>): Promise<ProductOption[]> {
+    async options(optionGroup: Translated<ProductOptionGroup>): Promise<Array<Translated<ProductOption>>> {
         if (optionGroup.options) {
             return Promise.resolve(optionGroup.options);
         }
@@ -40,7 +40,7 @@ export class ProductOptionResolver {
     }
 
     @Mutation()
-    async createProductOptionGroup(_, args): Promise<ProductOptionGroup> {
+    async createProductOptionGroup(_, args): Promise<Translated<ProductOptionGroup>> {
         const { input } = args;
         const group = await this.productOptionGroupService.create(args.input);
 
@@ -54,7 +54,7 @@ export class ProductOptionResolver {
     }
 
     @Mutation()
-    async updateProductOptionGroup(_, args): Promise<ProductOptionGroup> {
+    async updateProductOptionGroup(_, args): Promise<Translated<ProductOptionGroup>> {
         const { input } = args;
         return this.productOptionGroupService.update(args.input);
     }

+ 1 - 1
server/src/api/product/product.api.graphql

@@ -1,6 +1,6 @@
 type Query {
     products(languageCode: LanguageCode, take: Int, skip: Int): ProductList!
-    product(id: ID!, languageCode: LanguageCode): Product!
+    product(id: ID!, languageCode: LanguageCode): Product
 }
 
 type Mutation {

+ 6 - 5
server/src/api/product/product.resolver.ts

@@ -2,6 +2,7 @@ import { Mutation, Query, Resolver } from '@nestjs/graphql';
 
 import { PaginatedList } from '../../../../shared/shared-types';
 import { Product } from '../../entity/product/product.entity';
+import { Translated } from '../../locale/locale-types';
 import { IdCodecService } from '../../service/id-codec.service';
 import { ProductVariantService } from '../../service/product-variant.service';
 import { ProductService } from '../../service/product.service';
@@ -15,21 +16,21 @@ export class ProductResolver {
     ) {}
 
     @Query('products')
-    async products(obj, args): Promise<PaginatedList<Product>> {
+    async products(obj, args): Promise<PaginatedList<Translated<Product>>> {
         return this.productService
             .findAll(args.languageCode, args.take, args.skip)
             .then(list => this.idCodecService.encode(list));
     }
 
     @Query('product')
-    async product(obj, args): Promise<Product | undefined> {
+    async product(obj, args): Promise<Translated<Product> | undefined> {
         return this.productService
             .findOne(this.idCodecService.decode(args).id, args.languageCode)
             .then(p => this.idCodecService.encode(p));
     }
 
     @Mutation()
-    async createProduct(_, args): Promise<Product> {
+    async createProduct(_, args): Promise<Translated<Product>> {
         const { input } = args;
         const product = await this.productService.create(input);
 
@@ -43,14 +44,14 @@ export class ProductResolver {
     }
 
     @Mutation()
-    async updateProduct(_, args): Promise<Product | undefined> {
+    async updateProduct(_, args): Promise<Translated<Product>> {
         const { input } = args;
         const product = await this.productService.update(this.idCodecService.decode(input));
         return this.idCodecService.decode(product);
     }
 
     @Mutation()
-    async addOptionGroupToProduct(_, args): Promise<Product | undefined> {
+    async addOptionGroupToProduct(_, args): Promise<Translated<Product>> {
         const { productId, optionGroupId } = args;
         const product = await this.productService.addOptionGroupToProduct(productId, optionGroupId);
         return this.idCodecService.decode(product);

+ 5 - 0
server/src/common/common-types.ts

@@ -3,3 +3,8 @@
  * and readonly.
  */
 export type ReadOnlyRequired<T> = { +readonly [K in keyof T]-?: T[K] };
+
+/**
+ * Given an array type e.g. Array<string>, return the inner type e.g. string.
+ */
+export type UnwrappedArray<T extends any[]> = T[number];

+ 10 - 0
server/src/common/utils.ts

@@ -12,3 +12,13 @@ export function not(predicate: (...args: any[]) => boolean) {
 export function foundIn<T>(set: T[], compareBy: keyof T) {
     return (item: T) => set.some(t => t[compareBy] === item[compareBy]);
 }
+
+/**
+ * Indentity function which asserts to the type system that a promise which can resolve to T or undefined
+ * does in fact resolve to T.
+ * Used when performing a "find" operation on an entity which we are sure exists, as in the case that we
+ * just successfully created or updated it.
+ */
+export function assertFound<T>(promise: Promise<T | undefined>): Promise<T> {
+    return promise as Promise<T>;
+}

+ 8 - 8
server/src/entity/product-option-group/product-option-group.graphql

@@ -1,10 +1,10 @@
 type ProductOptionGroup implements Node {
     id: ID!
-    languageCode: LanguageCode
-    code: String
-    name: String
-    options: [ProductOption]
-    translations: [ProductOptionGroupTranslation]
+    languageCode: LanguageCode!
+    code: String!
+    name: String!
+    options: [ProductOption!]!
+    translations: [ProductOptionGroupTranslation!]!
 }
 
 type ProductOptionGroupTranslation {
@@ -21,12 +21,12 @@ input ProductOptionGroupTranslationInput {
 
 input CreateProductOptionGroupInput {
     code: String!
-    translations: [ProductOptionGroupTranslationInput]!
-    options: [CreateProductOptionInput]
+    translations: [ProductOptionGroupTranslationInput!]!
+    options: [CreateProductOptionInput!]!
 }
 
 input UpdateProductOptionGroupInput {
     id: ID!
     code: String!
-    translations: [ProductOptionGroupTranslationInput]!
+    translations: [ProductOptionGroupTranslationInput!]!
 }

+ 1 - 1
server/src/entity/product-option/product-option.graphql

@@ -20,5 +20,5 @@ input ProductOptionTranslationInput {
 
 input CreateProductOptionInput {
     code: String!
-    translations: [ProductOptionGroupTranslationInput]!
+    translations: [ProductOptionGroupTranslationInput!]!
 }

+ 10 - 0
server/src/locale/locale-types.ts

@@ -1,7 +1,9 @@
 import { ID } from '../../../shared/shared-types';
+import { UnwrappedArray } from '../common/common-types';
 import { VendureEntity } from '../entity/base/base.entity';
 
 import { LanguageCode } from './language-code';
+import { TranslatableRelationsKeys } from './translate-entity';
 
 /**
  * This type should be used in any interfaces where the value is to be
@@ -48,3 +50,11 @@ export type TranslationInput<T> = { [K in TranslatableKeys<T>]: string } & {
 export interface TranslatedInput<T> {
     translations: Array<TranslationInput<T>>;
 }
+
+// prettier-ignore
+/**
+ * This is the type of a Translatable entity after it has been deep-translated into a given language.
+ */
+export type Translated<T> =  T & { languageCode: LanguageCode; } & {
+    [K in TranslatableRelationsKeys<T>]: T[K] extends any[] ? Array<Translated<UnwrappedArray<T[K]>>> : Translated<T[K]>;
+};

+ 12 - 12
server/src/locale/translate-entity.ts

@@ -1,7 +1,8 @@
+import { UnwrappedArray } from '../common/common-types';
 import { I18nError } from '../i18n/i18n-error';
 
 import { LanguageCode } from './language-code';
-import { Translatable } from './locale-types';
+import { Translatable, Translated } from './locale-types';
 
 // prettier-ignore
 export type TranslatableRelationsKeys<T> = {
@@ -15,8 +16,6 @@ export type TranslatableRelationsKeys<T> = {
     K extends 'translations' ? never : K
 }[keyof T];
 
-export type UnwrappedArray<T extends any[]> = T[number];
-
 // prettier-ignore
 export type NestedTranslatableRelations<T> = {
     [K in TranslatableRelationsKeys<T>]: T[K] extends any[] ?
@@ -24,19 +23,20 @@ export type NestedTranslatableRelations<T> = {
         [K, TranslatableRelationsKeys<T[K]>]
 };
 
-export type NestedTranslatableRelationKeys<T> = NestedTranslatableRelations<
-    T
->[keyof NestedTranslatableRelations<T>];
+// prettier-ignore
+export type NestedTranslatableRelationKeys<T> = NestedTranslatableRelations<T>[keyof NestedTranslatableRelations<T>];
 
-export type DeepTranslatableRelations<T> = Array<
-    TranslatableRelationsKeys<T> | NestedTranslatableRelationKeys<T>
->;
+// prettier-ignore
+export type DeepTranslatableRelations<T> = Array<TranslatableRelationsKeys<T> | NestedTranslatableRelationKeys<T>>;
 
 /**
  * Converts a Translatable entity into the public-facing entity by unwrapping
  * the translated strings from the matching Translation entity.
  */
-export function translateEntity<T extends Translatable>(translatable: T, languageCode: LanguageCode): T {
+export function translateEntity<T extends Translatable>(
+    translatable: T,
+    languageCode: LanguageCode,
+): Translated<T> {
     const translation =
         translatable.translations && translatable.translations.find(t => t.languageCode === languageCode);
 
@@ -65,8 +65,8 @@ export function translateDeep<T extends Translatable>(
     translatable: T,
     languageCode: LanguageCode,
     translatableRelations: DeepTranslatableRelations<T> = [],
-): T {
-    let translatedEntity: T;
+): Translated<T> {
+    let translatedEntity: Translated<T>;
     try {
         translatedEntity = translateEntity(translatable, languageCode);
     } catch (e) {

+ 3 - 6
server/src/service/product-option-group.service.ts

@@ -4,6 +4,7 @@ import { Connection, FindManyOptions, Like } from 'typeorm';
 
 import { ID } from '../../../shared/shared-types';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
+import { assertFound } from '../common/utils';
 import { ProductOptionGroupTranslation } from '../entity/product-option-group/product-option-group-translation.entity';
 import {
     CreateProductOptionGroupDto,
@@ -59,9 +60,7 @@ export class ProductOptionGroupService {
         optionGroup.translations = translations;
         const createdGroup = await this.connection.manager.save(optionGroup);
 
-        return this.findOne(createdGroup.id, DEFAULT_LANGUAGE_CODE) as Promise<
-            Translated<ProductOptionGroup>
-        >;
+        return assertFound(this.findOne(createdGroup.id, DEFAULT_LANGUAGE_CODE));
     }
 
     async update(
@@ -81,8 +80,6 @@ export class ProductOptionGroupService {
         );
         await this.connection.manager.save(productOptionGroup);
 
-        return this.findOne(productOptionGroup.id, DEFAULT_LANGUAGE_CODE) as Promise<
-            Translated<ProductOptionGroup>
-        >;
+        return assertFound(this.findOne(productOptionGroup.id, DEFAULT_LANGUAGE_CODE));
     }
 }

+ 6 - 4
server/src/service/product-option.service.ts

@@ -4,18 +4,20 @@ import { Connection } from 'typeorm';
 
 import { ID } from '../../../shared/shared-types';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
+import { assertFound } from '../common/utils';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
 import { ProductOptionTranslation } from '../entity/product-option/product-option-translation.entity';
 import { CreateProductOptionDto } from '../entity/product-option/product-option.dto';
 import { ProductOption } from '../entity/product-option/product-option.entity';
 import { LanguageCode } from '../locale/language-code';
+import { Translated } from '../locale/locale-types';
 import { translateDeep } from '../locale/translate-entity';
 
 @Injectable()
 export class ProductOptionService {
     constructor(@InjectConnection() private connection: Connection) {}
 
-    findAll(lang: LanguageCode): Promise<ProductOption[]> {
+    findAll(lang: LanguageCode): Promise<Array<Translated<ProductOption>>> {
         return this.connection.manager
             .find(ProductOption, {
                 relations: ['group'],
@@ -23,7 +25,7 @@ export class ProductOptionService {
             .then(groups => groups.map(group => translateDeep(group, lang)));
     }
 
-    findOne(id: ID, lang: LanguageCode): Promise<ProductOption | undefined> {
+    findOne(id: ID, lang: LanguageCode): Promise<Translated<ProductOption> | undefined> {
         return this.connection.manager
             .findOne(ProductOption, id, {
                 relations: ['group'],
@@ -34,7 +36,7 @@ export class ProductOptionService {
     async create(
         group: ProductOptionGroup,
         createProductOptionDto: CreateProductOptionDto,
-    ): Promise<ProductOption> {
+    ): Promise<Translated<ProductOption>> {
         const option = new ProductOption(createProductOptionDto);
         const translations: ProductOptionTranslation[] = [];
 
@@ -48,6 +50,6 @@ export class ProductOptionService {
         option.group = group;
         const createdGroup = await this.connection.manager.save(option);
 
-        return this.findOne(createdGroup.id, DEFAULT_LANGUAGE_CODE) as Promise<ProductOption>;
+        return assertFound(this.findOne(createdGroup.id, DEFAULT_LANGUAGE_CODE));
     }
 }

+ 10 - 8
server/src/service/product.service.ts

@@ -4,12 +4,14 @@ import { Connection } from 'typeorm';
 
 import { ID, PaginatedList } from '../../../shared/shared-types';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
+import { assertFound } from '../common/utils';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../entity/product/product-translation.entity';
 import { CreateProductDto, UpdateProductDto } from '../entity/product/product.dto';
 import { Product } from '../entity/product/product.entity';
 import { I18nError } from '../i18n/i18n-error';
 import { LanguageCode } from '../locale/language-code';
+import { Translated } from '../locale/locale-types';
 import { translateDeep } from '../locale/translate-entity';
 import { TranslationUpdaterService } from '../locale/translation-updater.service';
 
@@ -20,7 +22,7 @@ export class ProductService {
         private translationUpdaterService: TranslationUpdaterService,
     ) {}
 
-    findAll(lang: LanguageCode, take?: number, skip?: number): Promise<PaginatedList<Product>> {
+    findAll(lang: LanguageCode, take?: number, skip?: number): Promise<PaginatedList<Translated<Product>>> {
         const relations = ['variants', 'optionGroups', 'variants.options'];
 
         if (skip !== undefined && take === undefined) {
@@ -40,7 +42,7 @@ export class ProductService {
             });
     }
 
-    findOne(productId: ID, lang: LanguageCode): Promise<Product | undefined> {
+    findOne(productId: ID, lang: LanguageCode): Promise<Translated<Product> | undefined> {
         const relations = ['variants', 'optionGroups', 'variants.options'];
 
         return this.connection.manager
@@ -52,7 +54,7 @@ export class ProductService {
             );
     }
 
-    async create(createProductDto: CreateProductDto): Promise<Product> {
+    async create(createProductDto: CreateProductDto): Promise<Translated<Product>> {
         const { variants, optionGroupCodes, image, translations } = createProductDto;
         const product = new Product(createProductDto);
         const productTranslations: ProductTranslation[] = [];
@@ -72,10 +74,10 @@ export class ProductService {
         product.translations = productTranslations;
         const createdProduct = await this.connection.manager.save(product);
 
-        return this.findOne(createdProduct.id, DEFAULT_LANGUAGE_CODE) as Promise<Product>;
+        return assertFound(this.findOne(createdProduct.id, DEFAULT_LANGUAGE_CODE));
     }
 
-    async update(updateProductDto: UpdateProductDto): Promise<Product | undefined> {
+    async update(updateProductDto: UpdateProductDto): Promise<Translated<Product>> {
         const existingTranslations = await this.connection.getRepository(ProductTranslation).find({
             where: { base: updateProductDto.id },
             relations: ['base'],
@@ -87,10 +89,10 @@ export class ProductService {
         const product = await translationUpdater.applyDiff(new Product(updateProductDto), diff);
         await this.connection.manager.save(product);
 
-        return this.findOne(updateProductDto.id, DEFAULT_LANGUAGE_CODE);
+        return assertFound(this.findOne(updateProductDto.id, DEFAULT_LANGUAGE_CODE));
     }
 
-    async addOptionGroupToProduct(productId: ID, optionGroupId: ID): Promise<Product | undefined> {
+    async addOptionGroupToProduct(productId: ID, optionGroupId: ID): Promise<Translated<Product>> {
         const product = await this.connection
             .getRepository(Product)
             .findOne(productId, { relations: ['optionGroups'] });
@@ -110,6 +112,6 @@ export class ProductService {
 
         await this.connection.manager.save(product);
 
-        return this.findOne(productId, DEFAULT_LANGUAGE_CODE);
+        return assertFound(this.findOne(productId, DEFAULT_LANGUAGE_CODE));
     }
 }