Sfoglia il codice sorgente

Generalise the translation helper, write some tests

Could be extended to work n-levels deep, if needed.
Michael Bromley 7 anni fa
parent
commit
4f1e08d636

+ 1 - 2
modules/core/api/product/product.service.ts

@@ -6,12 +6,11 @@ import { ProductVariant } from '../../entity/product-variant/product-variant.int
 import { ProductEntity } from '../../entity/product/product.entity';
 import { Product } from '../../entity/product/product.interface';
 import { Translatable, Translation } from '../../locale/locale-types';
-import { LocaleService } from '../../locale/locale.service';
 import { ProductRepository } from '../../repository/product-repository';
 
 @Injectable()
 export class ProductService {
-    constructor(@InjectConnection() private connection: Connection, private localeService: LocaleService) {}
+    constructor(@InjectConnection() private connection: Connection) {}
 
     findAll(lang?: string): Promise<Product[]> {
         return this.connection.getCustomRepository(ProductRepository).localeFind(lang);

+ 0 - 2
modules/core/app.module.ts

@@ -12,7 +12,6 @@ import { ProductService } from './api/product/product.service';
 import { AuthService } from './auth/auth.service';
 import { JwtStrategy } from './auth/jwt.strategy';
 import { PasswordService } from './auth/password.service';
-import { LocaleService } from './locale/locale.service';
 
 @Module({
     imports: [
@@ -38,7 +37,6 @@ import { LocaleService } from './locale/locale.service';
         CustomerResolver,
         ProductService,
         ProductResolver,
-        LocaleService,
         PasswordService,
     ],
 })

+ 2 - 2
modules/core/entity/product/product.entity.ts

@@ -8,7 +8,7 @@ import {
     PrimaryGeneratedColumn,
     UpdateDateColumn,
 } from 'typeorm';
-import { Translatable } from '../../locale/locale-types';
+import { Translatable, Translation } from '../../locale/locale-types';
 import { ProductOptionGroupEntity } from '../product-option-group/product-option-group.entity';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.interface';
 import { ProductOptionEntity } from '../product-option/product-option.entity';
@@ -29,7 +29,7 @@ export class ProductEntity implements Translatable<Product> {
     @UpdateDateColumn() updatedAt: string;
 
     @OneToMany(type => ProductTranslationEntity, translation => translation.base)
-    translations: ProductTranslationEntity[];
+    translations: Translation<Product>[];
 
     @OneToMany(type => ProductVariantEntity, variant => variant.product)
     variants: ProductVariantEntity[];

+ 9 - 1
modules/core/locale/locale-types.ts

@@ -2,6 +2,8 @@
  * This type should be used in any interfaces where the value is to be
  * localized into different languages.
  */
+import { Product } from '../entity/product/product.interface';
+
 export type LocaleString = string & { _opaqueType: 'LocaleString' };
 
 export type TranslatableKeys<T> = { [K in keyof T]: T[K] extends LocaleString ? K : never }[keyof T];
@@ -28,4 +30,10 @@ export type Translation<T> =
     // Translation must include the languageCode and a reference to the base Translatable entity it is associated with
     { languageCode: string; base: Translatable<T>; } &
     // Translation must include all translatable keys as a string type
-    { [K in TranslatableKeys<T>]: string };
+    { [K in TranslatableKeys<T>]: string } &
+    { [key: string]: any };
+
+export type TranslatedEntity<T> =
+    // Translatable must include all non-translatable keys of the interface
+    { [K in NonTranslateableKeys<T>]: T[K] extends Array<any> ? Array<Translatable<T[K][number]>> : T[K] } &
+        { [K in TranslatableKeys<T>]: string };

+ 0 - 27
modules/core/locale/locale.service.ts

@@ -1,27 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import { Translatable } from './locale-types';
-
-@Injectable()
-export class LocaleService {
-    translate<T>(translatable: Translatable<T>): T {
-        return translate(translatable);
-    }
-}
-
-/**
- * Converts a Translatable entity into the public-facing entity by unwrapping
- * the translated strings from the first of the Translation entities.
- */
-export function translate<T>(translatable: Translatable<T>): T {
-    const translation = translatable.translations[0];
-
-    const translated = { ...(translatable as any) };
-    delete translated.translations;
-
-    for (const [key, value] of Object.entries(translation)) {
-        if (key !== 'languageCode' && key !== 'id') {
-            translated[key] = value;
-        }
-    }
-    return translated;
-}

+ 121 - 0
modules/core/locale/translate-entity.spec.ts

@@ -0,0 +1,121 @@
+import { Entity } from 'typeorm';
+import { ProductOptionTranslationEntity } from '../entity/product-option/product-option-translation.entity';
+import { ProductOptionEntity } from '../entity/product-option/product-option.entity';
+import { ProductVariantTranslationEntity } from '../entity/product-variant/product-variant-translation.entity';
+import { ProductVariantEntity } from '../entity/product-variant/product-variant.entity';
+import { ProductTranslationEntity } from '../entity/product/product-translation.entity';
+import { ProductEntity } from '../entity/product/product.entity';
+import { LocaleString, Translatable, Translation } from './locale-types';
+import { translateDeep, translateEntity } from './translate-entity';
+
+const LANGUAGE_CODE = 'en';
+const PRODUCT_NAME = 'English Name';
+const VARIANT_NAME = 'English Variant';
+const OPTION_NAME = 'English Option';
+
+describe('translateEntity()', () => {
+    let product: ProductEntity;
+    let productTranslation: ProductTranslationEntity;
+
+    beforeEach(() => {
+        productTranslation = new ProductTranslationEntity();
+        productTranslation.id = 2;
+        productTranslation.languageCode = LANGUAGE_CODE;
+        productTranslation.name = PRODUCT_NAME;
+
+        product = new ProductEntity();
+        product.id = 1;
+        product.translations = [productTranslation];
+    });
+
+    it('should unwrap the first translation', () => {
+        const result = translateEntity(product);
+
+        expect(result).toHaveProperty('name', PRODUCT_NAME);
+    });
+
+    it('should not overwrite translatable id with translation id', () => {
+        const result = translateEntity(product);
+
+        expect(result).toHaveProperty('id', 1);
+    });
+
+    it('should not transfer the languageCode from the translation', () => {
+        const result = translateEntity(product);
+
+        expect(result).not.toHaveProperty('languageCode');
+    });
+
+    it('should remove the translations property from the translatable', () => {
+        const result = translateEntity(product);
+
+        expect(result).not.toHaveProperty('translations');
+    });
+
+    it('throw if there are no translations available', () => {
+        product.translations = [];
+
+        expect(() => translateEntity(product)).toThrow('Translatable entity "ProductEntity" has no translations');
+    });
+});
+
+describe('translateDeep()', () => {
+    let product: ProductEntity;
+    let productTranslation: ProductTranslationEntity;
+    let productVariant: ProductVariantEntity;
+    let productVariantTranslation: ProductVariantTranslationEntity;
+    let productOption: ProductOptionEntity;
+    let productOptionTranslation: ProductOptionTranslationEntity;
+
+    beforeEach(() => {
+        productTranslation = new ProductTranslationEntity();
+        productTranslation.id = 2;
+        productTranslation.languageCode = LANGUAGE_CODE;
+        productTranslation.name = PRODUCT_NAME;
+
+        productOptionTranslation = new ProductOptionTranslationEntity();
+        productOptionTranslation.id = 31;
+        productOptionTranslation.languageCode = LANGUAGE_CODE;
+        productOptionTranslation.name = OPTION_NAME;
+
+        productOption = new ProductOptionEntity();
+        productOption.id = 3;
+        productOption.translations = [productOptionTranslation];
+
+        productVariantTranslation = new ProductVariantTranslationEntity();
+        productVariantTranslation.id = 41;
+        productVariantTranslation.languageCode = LANGUAGE_CODE;
+        productVariantTranslation.name = VARIANT_NAME;
+
+        productVariant = new ProductVariantEntity();
+        productVariant.id = 3;
+        productVariant.translations = [productVariantTranslation];
+        productVariant.options = [productOption];
+
+        product = new ProductEntity();
+        product.id = 1;
+        product.translations = [productTranslation];
+        product.variants = [productVariant];
+    });
+
+    it('should translate the root entity', () => {
+        const result = translateDeep(product);
+
+        expect(result).toHaveProperty('name', PRODUCT_NAME);
+    });
+
+    it('should translate a first-level nested entity', () => {
+        const result = translateDeep(product, ['variants']);
+
+        expect(result).toHaveProperty('name', PRODUCT_NAME);
+        expect(result.variants[0]).toHaveProperty('name', VARIANT_NAME);
+    });
+
+    it('should translate a second-level nested entity', () => {
+        const result = translateDeep(product, ['variants', ['variants', 'options']]);
+
+        expect(result).toHaveProperty('name', PRODUCT_NAME);
+        expect(result.variants[0]).toHaveProperty('name', VARIANT_NAME);
+        expect(result.variants[0].options[0]).toHaveProperty('name', OPTION_NAME);
+    });
+});

+ 75 - 0
modules/core/locale/translate-entity.ts

@@ -0,0 +1,75 @@
+import { Translatable, TranslatedEntity } from './locale-types';
+
+/**
+ * Converts a Translatable entity into the public-facing entity by unwrapping
+ * the translated strings from the first of the Translation entities.
+ */
+export function translateEntity<T>(translatable: Translatable<T>): TranslatedEntity<T> {
+    if (!translatable.translations || translatable.translations.length === 0) {
+        throw new Error(`Translatable entity "${translatable.constructor.name}" has no translations`);
+    }
+    const translation = translatable.translations[0];
+
+    const translated = { ...(translatable as any) };
+    delete translated.translations;
+
+    for (const [key, value] of Object.entries(translation)) {
+        if (key !== 'languageCode' && key !== 'id') {
+            translated[key] = value;
+        }
+    }
+    return translated;
+}
+
+export type TranslatableRelationsKeys<T> = {
+    [K in keyof T]: T[K] extends string
+        ? never
+        : T[K] extends number
+            ? never
+            : T[K] extends boolean
+                ? never
+                : T[K] extends undefined
+                    ? never
+                    : T[K] extends Array<string>
+                        ? never
+                        : T[K] extends Array<number>
+                            ? never
+                            : T[K] extends Array<boolean> ? never : K extends 'translations' ? never : K
+}[keyof T];
+
+export type UnwrappedArray<T extends Array<any>> = T[number];
+
+export type NestedTranslatableRelations<T> = {
+    [K in TranslatableRelationsKeys<T>]: T[K] extends Array<any>
+        ? [K, TranslatableRelationsKeys<UnwrappedArray<T[K]>>]
+        : TranslatableRelationsKeys<T[K]>
+};
+
+export type NestedTranslatableRelationKeys<T> = NestedTranslatableRelations<T>[keyof NestedTranslatableRelations<T>];
+
+export type DeepTranslatableRelations<T> = Array<TranslatableRelationsKeys<T> | NestedTranslatableRelationKeys<T>>;
+
+export function translateDeep<T>(
+    translatable: Translatable<T>,
+    translatableRelations: DeepTranslatableRelations<T> = [],
+): TranslatedEntity<T> {
+    const translatedEntity = translateEntity(translatable);
+
+    for (const path of translatableRelations) {
+        if (Array.isArray(path) && path.length === 2) {
+            const [path0, path1] = path;
+            const value = translatable[path0].forEach((nested1, index) => {
+                translatedEntity[path0][index][path1] = nested1[path1].map(nested2 => translateEntity(nested2));
+            });
+        } else {
+            const value = (translatable as any)[path];
+            if (Array.isArray(value)) {
+                (translatedEntity as any)[path] = value.map(val => translateEntity(val));
+            } else {
+                (translatedEntity as any)[path] = translateEntity(value);
+            }
+        }
+    }
+
+    return translatedEntity as any;
+}

+ 5 - 18
modules/core/repository/product-repository.ts

@@ -4,7 +4,8 @@ import { ProductOption } from '../entity/product-option/product-option.interface
 import { ProductVariant } from '../entity/product-variant/product-variant.interface';
 import { ProductEntity } from '../entity/product/product.entity';
 import { Product } from '../entity/product/product.interface';
-import { translate } from '../locale/locale.service';
+import { Translatable, TranslatableKeys, TranslatedEntity } from '../locale/locale-types';
+import { translateDeep } from '../locale/translate-entity';
 
 @EntityRepository(ProductEntity)
 export class ProductRepository extends Repository<ProductEntity> {
@@ -15,7 +16,7 @@ export class ProductRepository extends Repository<ProductEntity> {
     localeFind(languageCode: string): Promise<Product[]> {
         return this.getProductQueryBuilder(languageCode)
             .getMany()
-            .then(result => this.translateProductAndVariants(result));
+            .then(result => result.map(res => translateDeep(res, ['variants', 'optionGroups']) as any));
     }
 
     /**
@@ -25,9 +26,8 @@ export class ProductRepository extends Repository<ProductEntity> {
     localeFindOne(id: number, languageCode: string): Promise<Product> {
         return this.getProductQueryBuilder(languageCode)
             .andWhere('product.id = :id', { id })
-            .getMany()
-            .then(result => this.translateProductAndVariants(result))
-            .then(res => res[0]);
+            .getOne()
+            .then(result => translateDeep(result, ['variants', 'optionGroups', ['variants', 'options']]) as any);
     }
 
     private getProductQueryBuilder(languageCode: string): SelectQueryBuilder<ProductEntity> {
@@ -60,17 +60,4 @@ export class ProductRepository extends Repository<ProductEntity> {
             )
             .setParameters({ code });
     }
-
-    private translateProductAndVariants(result: ProductEntity[]): Product[] {
-        return result.map(productEntity => {
-            const product = translate<Product>(productEntity);
-            product.variants = product.variants.map(variant => {
-                const v = translate<ProductVariant>(variant as any);
-                v.options = v.options.map(vop => translate<ProductOption>(vop as any));
-                return v;
-            });
-            product.optionGroups = product.optionGroups.map(group => translate<ProductOptionGroup>(group as any));
-            return product;
-        });
-    }
 }

+ 1 - 1
package.json

@@ -81,7 +81,7 @@
       "json",
       "ts"
     ],
-    "rootDir": "src",
+    "rootDir": "modules",
     "testRegex": ".spec.ts$",
     "transform": {
       "^.+\\.(t|j)s$": "ts-jest"