Browse Source

Refactor updates to Product / ProductVariant, remove Repositories

Extracted the translation-diffing logic into own class & service, and was thus able to simplify things and get rid of all custom repositories.
Michael Bromley 7 years ago
parent
commit
aa8e5336bb

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

@@ -11,6 +11,7 @@ import { ProductResolver } from './api/product/product.resolver';
 import { AuthService } from './auth/auth.service';
 import { JwtStrategy } from './auth/jwt.strategy';
 import { PasswordService } from './auth/password.service';
+import { TranslationUpdaterService } from './locale/translation-updater.service';
 import { AdministratorService } from './service/administrator.service';
 import { CustomerService } from './service/customer.service';
 import { ProductOptionGroupService } from './service/product-option-group.service';
@@ -49,6 +50,7 @@ import { ProductService } from './service/product.service';
         ProductVariantService,
         ProductResolver,
         PasswordService,
+        TranslationUpdaterService,
     ],
 })
 export class AppModule implements NestModule {

+ 11 - 0
modules/core/common/common-types.ts

@@ -1 +1,12 @@
+/**
+ * A recursive implementation of the Partial<T> type.
+ */
 export type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> };
+
+// tslint:disable:ban-types
+/**
+ * A type representing the type rather than instance of a class.
+ */
+export type Type<T> = {
+    new (): T;
+} & Function;

+ 8 - 1
modules/core/entity/product-variant/product-variant-translation.entity.ts

@@ -1,10 +1,17 @@
 import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { DeepPartial } from '../../common/common-types';
 import { LanguageCode } from '../../locale/language-code';
 import { Translation } from '../../locale/locale-types';
 import { ProductVariant } from './product-variant.entity';
 
 @Entity('product_variant_translation')
-export class ProductVariantTranslationEntity implements Translation<ProductVariant> {
+export class ProductVariantTranslation implements Translation<ProductVariant> {
+    constructor(input?: DeepPartial<Translation<ProductVariant>>) {
+        if (input) {
+            Object.assign(this, input);
+        }
+    }
+
     @PrimaryGeneratedColumn() id: number;
 
     @Column() languageCode: LanguageCode;

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

@@ -9,13 +9,20 @@ import {
     PrimaryGeneratedColumn,
     UpdateDateColumn,
 } from 'typeorm';
+import { DeepPartial } from '../../common/common-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
 import { ProductOption } from '../product-option/product-option.entity';
 import { Product } from '../product/product.entity';
-import { ProductVariantTranslationEntity } from './product-variant-translation.entity';
+import { ProductVariantTranslation } from './product-variant-translation.entity';
 
 @Entity('product_variant')
 export class ProductVariant implements Translatable {
+    constructor(input?: DeepPartial<ProductVariant>) {
+        if (input) {
+            Object.assign(this, input);
+        }
+    }
+
     @PrimaryGeneratedColumn() id: number;
 
     name: LocaleString;
@@ -30,7 +37,7 @@ export class ProductVariant implements Translatable {
 
     @UpdateDateColumn() updatedAt: string;
 
-    @OneToMany(type => ProductVariantTranslationEntity, translation => translation.base, { eager: true })
+    @OneToMany(type => ProductVariantTranslation, translation => translation.base, { eager: true })
     translations: Translation<ProductVariant>[];
 
     @ManyToOne(type => Product, product => product.variants)

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

@@ -22,7 +22,11 @@ export interface Translatable { translations: Translation<any>[]; }
  */
 export type Translation<T> =
     // Translation must include the languageCode and a reference to the base Translatable entity it is associated with
-    { languageCode: LanguageCode; base: T; } &
+    {
+        id: number;
+        languageCode: LanguageCode;
+        base: T;
+    } &
     // Translation must include all translatable keys as a string type
     { [K in TranslatableKeys<T>]: string; };
 

+ 3 - 3
modules/core/locale/translate-entity.spec.ts

@@ -1,6 +1,6 @@
 import { ProductOptionTranslation } from '../entity/product-option/product-option-translation.entity';
 import { ProductOption } from '../entity/product-option/product-option.entity';
-import { ProductVariantTranslationEntity } from '../entity/product-variant/product-variant-translation.entity';
+import { ProductVariantTranslation } from '../entity/product-variant/product-variant-translation.entity';
 import { ProductVariant } from '../entity/product-variant/product-variant.entity';
 import { ProductTranslation } from '../entity/product/product-translation.entity';
 import { Product } from '../entity/product/product.entity';
@@ -114,7 +114,7 @@ describe('translateDeep()', () => {
     let product: Product;
     let productTranslation: ProductTranslation;
     let productVariant: ProductVariant;
-    let productVariantTranslation: ProductVariantTranslationEntity;
+    let productVariantTranslation: ProductVariantTranslation;
     let productOption: ProductOption;
     let productOptionTranslation: ProductOptionTranslation;
 
@@ -133,7 +133,7 @@ describe('translateDeep()', () => {
         productOption.id = 3;
         productOption.translations = [productOptionTranslation];
 
-        productVariantTranslation = new ProductVariantTranslationEntity();
+        productVariantTranslation = new ProductVariantTranslation();
         productVariantTranslation.id = 41;
         productVariantTranslation.languageCode = LANGUAGE_CODE;
         productVariantTranslation.name = VARIANT_NAME_EN;

+ 13 - 0
modules/core/locale/translation-updater.mock.ts

@@ -0,0 +1,13 @@
+import { MockClass } from '../testing/testing-types';
+import { TranslationUpdater } from './translation-updater';
+import { TranslationUpdaterService } from './translation-updater.service';
+
+export class MockTranslationUpdater implements MockClass<TranslationUpdater<any>> {
+    diff = jest.fn();
+    applyDiff = jest.fn();
+}
+
+export class MockTranslationUpdaterService implements MockClass<TranslationUpdaterService> {
+    mockUpdater = new MockTranslationUpdater();
+    create = jest.fn().mockReturnValue(this.mockUpdater);
+}

+ 14 - 0
modules/core/locale/translation-updater.service.ts

@@ -0,0 +1,14 @@
+import { Injectable } from '@nestjs/common';
+import { InjectEntityManager } from '@nestjs/typeorm';
+import { EntityManager } from 'typeorm';
+import { Translatable } from './locale-types';
+import { TranslationContructor, TranslationUpdater } from './translation-updater';
+
+@Injectable()
+export class TranslationUpdaterService {
+    constructor(@InjectEntityManager() private manager: EntityManager) {}
+
+    create<T extends Translatable>(translationCtor: TranslationContructor<T>): TranslationUpdater<T> {
+        return new TranslationUpdater(translationCtor, this.manager);
+    }
+}

+ 102 - 0
modules/core/locale/translation-updater.spec.ts

@@ -0,0 +1,102 @@
+import { ProductTranslation } from '../entity/product/product-translation.entity';
+import { Product } from '../entity/product/product.entity';
+import { MockEntityManager } from '../testing/connection.mock';
+import { LanguageCode } from './language-code';
+import { TranslationInput } from './locale-types';
+import { TranslationUpdater } from './translation-updater';
+
+describe('TranslationUpdater', () => {
+    describe('diff()', () => {
+        const existing: ProductTranslation[] = [
+            {
+                id: 10,
+                languageCode: LanguageCode.EN,
+                name: '',
+                slug: '',
+                description: '',
+                base: 1 as any,
+            },
+            {
+                id: 11,
+                languageCode: LanguageCode.DE,
+                name: '',
+                slug: '',
+                description: '',
+                base: 1 as any,
+            },
+        ];
+
+        let entityManager: any;
+
+        beforeEach(() => {
+            entityManager = new MockEntityManager() as any;
+        });
+
+        it('correctly marks translations for update', async () => {
+            const updated: Array<TranslationInput<Product>> = [
+                {
+                    languageCode: LanguageCode.EN,
+                    name: '',
+                    slug: '',
+                    description: '',
+                },
+                {
+                    languageCode: LanguageCode.DE,
+                    name: '',
+                    slug: '',
+                    description: '',
+                },
+            ];
+
+            const diff = new TranslationUpdater(ProductTranslation, entityManager).diff(existing, updated);
+            expect(diff.toUpdate).toEqual(existing);
+        });
+
+        it('correctly marks translations for addition', async () => {
+            const updated: Array<TranslationInput<Product>> = [
+                {
+                    languageCode: LanguageCode.AA,
+                    name: '',
+                    slug: '',
+                    description: '',
+                },
+                {
+                    languageCode: LanguageCode.ZA,
+                    name: '',
+                    slug: '',
+                    description: '',
+                },
+            ];
+            const diff = new TranslationUpdater(ProductTranslation, entityManager).diff(existing, updated);
+            expect(diff.toAdd).toEqual(updated);
+        });
+
+        it('correctly marks translations for removal', async () => {
+            const updated = [];
+
+            const diff = new TranslationUpdater(ProductTranslation, entityManager).diff(existing, updated);
+            expect(diff.toRemove).toEqual(existing);
+        });
+
+        it('correctly marks languages for update, addition and deletion', async () => {
+            const updated: TranslationInput<Product>[] = [
+                {
+                    languageCode: LanguageCode.EN,
+                    name: '',
+                    slug: '',
+                    description: '',
+                },
+                {
+                    languageCode: LanguageCode.ZA,
+                    name: '',
+                    slug: '',
+                    description: '',
+                },
+            ];
+            const diff = new TranslationUpdater(ProductTranslation, entityManager).diff(existing, updated);
+            expect(diff.toUpdate).toEqual([existing[0]]);
+            expect(diff.toAdd).toEqual([updated[1]]);
+            expect(diff.toRemove).toEqual([existing[1]]);
+        });
+    });
+});

+ 85 - 0
modules/core/locale/translation-updater.ts

@@ -0,0 +1,85 @@
+import { EntityManager } from 'typeorm';
+import { DeepPartial, Type } from '../common/common-types';
+import { foundIn, not } from '../common/utils';
+import { Translatable, Translation, TranslationInput } from './locale-types';
+
+export interface TranslationContructor<T> {
+    new (input?: DeepPartial<TranslationInput<T>> | DeepPartial<Translation<T>>): Translation<T>;
+}
+
+export interface TranslationDiff<T> {
+    toUpdate: Array<Translation<T>>;
+    toAdd: Array<Translation<T>>;
+    toRemove: Array<Translation<T>>;
+}
+
+/**
+ * This class is to be used when performing an update on a Translatable entity.
+ */
+export class TranslationUpdater<Entity extends Translatable> {
+    constructor(private translationCtor: TranslationContructor<Entity>, private manager: EntityManager) {}
+
+    /**
+     * Compares the existing translations with the updated translations and produces a diff of
+     * added, removed and updated translations.
+     */
+    diff(existing: Array<Translation<Entity>>, updated: Array<TranslationInput<Entity>>): TranslationDiff<Entity> {
+        const translationEntities = this.translationInputsToEntities(updated, existing);
+
+        const toDelete = existing.filter(not(foundIn(translationEntities, 'languageCode')));
+        const toAdd = translationEntities.filter(not(foundIn(existing, 'languageCode')));
+        const toUpdate = translationEntities.filter(foundIn(existing, 'languageCode'));
+
+        return { toUpdate, toAdd, toRemove: toDelete };
+    }
+
+    async applyDiff(entity: Entity, { toUpdate, toAdd, toRemove }: TranslationDiff<Entity>): Promise<Entity> {
+        entity.translations = [];
+
+        if (toUpdate.length) {
+            for (const translation of toUpdate) {
+                await this.manager
+                    .createQueryBuilder()
+                    .update(this.translationCtor)
+                    .set(translation)
+                    .where('id = :id', { id: translation.id })
+                    .execute();
+            }
+            entity.translations = entity.translations.concat(toUpdate);
+        }
+
+        if (toAdd.length) {
+            for (const translation of toAdd) {
+                translation.base = entity;
+                const newTranslation = await this.manager.getRepository(this.translationCtor).save(translation as any);
+                entity.translations.push(newTranslation);
+            }
+        }
+
+        if (toRemove.length) {
+            const toDeleteEntities = toRemove.map(translation => {
+                translation.base = entity;
+                return translation;
+            });
+            await this.manager.getRepository(this.translationCtor).remove(toDeleteEntities);
+        }
+
+        return entity;
+    }
+
+    private translationInputsToEntities(
+        inputs: Array<TranslationInput<Entity>>,
+        existing: Array<Translation<Entity>>,
+    ): Array<Translation<Entity>> {
+        return inputs.map(input => {
+            const counterpart = existing.find(e => e.languageCode === input.languageCode);
+            // any cast below is required due to TS issue: https://github.com/Microsoft/TypeScript/issues/21592
+            const entity = new this.translationCtor(input as any);
+            if (counterpart) {
+                entity.id = counterpart.id;
+                entity.base = counterpart.base;
+            }
+            return entity;
+        });
+    }
+}

+ 0 - 86
modules/core/repository/product-repository.ts

@@ -1,86 +0,0 @@
-import { AbstractRepository, EntityRepository, SelectQueryBuilder } from 'typeorm';
-import { ProductTranslation } from '../entity/product/product-translation.entity';
-import { CreateProductDto, UpdateProductDto } from '../entity/product/product.dto';
-import { Product } from '../entity/product/product.entity';
-import { LanguageCode } from '../locale/language-code';
-import { TranslationInput } from '../locale/locale-types';
-import { translateDeep } from '../locale/translate-entity';
-
-@EntityRepository(Product)
-export class ProductRepository extends AbstractRepository<Product> {
-    /**
-     * Returns an array of Products including ProductVariants, translated into the
-     * specified language.
-     */
-    find(languageCode: LanguageCode): Promise<Product[]> {
-        return this.manager.find(Product, {
-            relations: ['variants', 'optionGroups', 'variants.options'],
-        });
-    }
-
-    /**
-     * Returns single Product including ProductVariants, translated into the
-     * specified language.
-     */
-    findOne(id: number, languageCode: LanguageCode): Promise<Product | undefined> {
-        return this.manager.findOne(Product, id, {
-            relations: ['variants', 'optionGroups', 'variants.options'],
-        });
-    }
-
-    /**
-     * Creates a new Product with one or more ProductTranslations.
-     */
-    async create(productEntity: Product, translations: ProductTranslation[]): Promise<Product> {
-        for (const translation of translations) {
-            await this.manager.save(translation);
-        }
-        productEntity.translations = translations;
-        return this.manager.save(productEntity);
-    }
-
-    /**
-     * Updates an existing Product and manages its ProductTranslations.
-     */
-    async update(
-        productDto: UpdateProductDto,
-        translationsToUpdate: ProductTranslation[],
-        translationsToAdd: ProductTranslation[],
-        translationsToDelete: ProductTranslation[],
-    ): Promise<Product> {
-        const product = new Product(productDto);
-        product.translations = [];
-
-        if (translationsToUpdate.length) {
-            for (const toUpdate of translationsToUpdate) {
-                await this.manager
-                    .createQueryBuilder()
-                    .update(ProductTranslation)
-                    .set(toUpdate)
-                    .where('id = :id', { id: toUpdate.id })
-                    .execute();
-            }
-            product.translations = product.translations.concat(translationsToUpdate);
-        }
-
-        if (translationsToAdd.length) {
-            for (const toAdd of translationsToAdd) {
-                const translation = new ProductTranslation(toAdd);
-                translation.base = product;
-                const newTranslation = await this.manager.getRepository(ProductTranslation).save(translation);
-                product.translations.push(newTranslation);
-            }
-        }
-
-        if (translationsToDelete.length) {
-            const toDeleteEntities = translationsToDelete.map(toDelete => {
-                const translation = new ProductTranslation(toDelete);
-                translation.base = product;
-                return translation;
-            });
-            await this.manager.getRepository(ProductTranslation).remove(toDeleteEntities);
-        }
-
-        return this.manager.save(product);
-    }
-}

+ 0 - 58
modules/core/repository/product-variant-repository.ts

@@ -1,58 +0,0 @@
-import { AbstractRepository, EntityRepository, SelectQueryBuilder } from 'typeorm';
-import { ProductVariantTranslationEntity } from '../entity/product-variant/product-variant-translation.entity';
-import { ProductVariant } from '../entity/product-variant/product-variant.entity';
-import { ProductTranslation } from '../entity/product/product-translation.entity';
-import { Product } from '../entity/product/product.entity';
-import { LanguageCode } from '../locale/language-code';
-import { translateDeep } from '../locale/translate-entity';
-
-@EntityRepository(Product)
-export class ProductVariantRepository extends AbstractRepository<ProductVariant> {
-    /**
-     * Returns an array of Products including ProductVariants, translated into the
-     * specified language.
-     */
-    localeFindByProductId(productId: number, languageCode: LanguageCode): Promise<ProductVariant[]> {
-        return this.getProductVariantQueryBuilder(productId, languageCode).getMany();
-    }
-
-    /**
-     * Creates a new Product with one or more ProductTranslations.
-     */
-    async create(
-        product: Product,
-        productVariantEntity: ProductVariant,
-        translations: ProductVariantTranslationEntity[],
-    ): Promise<ProductVariant> {
-        for (const translation of translations) {
-            await this.manager.save(translation);
-        }
-        productVariantEntity.product = product as any;
-        productVariantEntity.translations = translations;
-        return this.manager.save(productVariantEntity);
-    }
-
-    private getProductVariantQueryBuilder(
-        productId: number,
-        languageCode: LanguageCode,
-    ): SelectQueryBuilder<ProductVariant> {
-        const code = languageCode || LanguageCode.EN;
-
-        return this.manager
-            .createQueryBuilder(ProductVariant, 'variant')
-            .leftJoinAndSelect('product_variant.options', 'option')
-            .leftJoinAndSelect(
-                'variant.translations',
-                'product_variant_translation',
-                'product_variant_translation.languageCode = :code',
-            )
-            .leftJoinAndSelect('variant.options', 'variant_options')
-            .leftJoinAndSelect(
-                'variant_options.translations',
-                'variant_options_translation',
-                'variant_options_translation.languageCode = :code',
-            )
-            .where('variant.productId = :productId')
-            .setParameters({ productId, code });
-    }
-}

+ 0 - 97
modules/core/repository/repository.mock.ts

@@ -1,97 +0,0 @@
-import { AbstractRepository, Repository } from 'typeorm';
-import { MockClass } from '../testing/testing-types';
-import { ProductRepository } from './product-repository';
-import { ProductVariantRepository } from './product-variant-repository';
-
-export interface Type<T> {
-    new (): T;
-}
-
-/**
- * A mock of the TypeORM Connection class for use in testing.
- */
-export class MockConnection {
-    private customRepositoryMap = new Map<Type<AbstractRepository<any>>, any>([
-        [ProductRepository, new MockProductRepository()],
-        [ProductVariantRepository, new MockProductVariantRepository()],
-    ]);
-    private repositoryMap = new Map<Type<any>, any>();
-
-    constructor() {}
-
-    registerMockCustomRepository(token: Type<AbstractRepository<any>>, use: AbstractRepository<any>): void {
-        this.customRepositoryMap.set(token, use);
-    }
-
-    registerMockRepository<T extends Type<any>>(entity: T): MockRepository<T> {
-        const repository = new MockRepository();
-        this.repositoryMap.set(entity, repository);
-        return repository;
-    }
-
-    getRepository<T extends Type<any>>(entity: T): MockRepository<T> {
-        const repository = this.repositoryMap.get(entity);
-        if (repository) {
-            return repository;
-        } else {
-            throw new Error(`No mock repository registered for "${entity.name}". Use registerRepository() first.`);
-        }
-    }
-
-    getCustomRepository<T extends AbstractRepository<any>>(customRepository: Type<T>): MockClass<T> {
-        const repository = this.customRepositoryMap.get(customRepository);
-        if (repository) {
-            return repository;
-        } else {
-            throw new Error(
-                `No mock repository registered for "${customRepository.name}". Use registerCustomRepository() first.`,
-            );
-        }
-    }
-}
-
-const stubReturningPromise = () => jest.fn().mockReturnValue(Promise.resolve({}));
-
-export class MockProductRepository implements MockClass<ProductRepository> {
-    find = stubReturningPromise();
-    findOne = stubReturningPromise();
-    create = stubReturningPromise();
-    update = stubReturningPromise();
-}
-
-export class MockProductVariantRepository implements MockClass<ProductVariantRepository> {
-    localeFindByProductId = stubReturningPromise();
-    create = stubReturningPromise();
-}
-
-export class MockRepository<T> implements MockClass<Repository<T>> {
-    manager: any;
-    metadata: any;
-    queryRunner: any;
-    target: any;
-    createQueryBuilder = jest.fn();
-    hasId = jest.fn();
-    getId = jest.fn();
-    create = jest.fn();
-    merge = jest.fn();
-    preload = jest.fn();
-    save = jest.fn();
-    remove = jest.fn();
-    insert = jest.fn();
-    update = jest.fn();
-    delete = jest.fn();
-    count = jest.fn();
-    find = jest.fn();
-    findAndCount = jest.fn();
-    findByIds = jest.fn();
-    findOne = jest.fn();
-    findOneOrFail = jest.fn();
-    query = jest.fn();
-    clear = jest.fn();
-    increment = jest.fn();
-    decrement = jest.fn();
-}
-
-class MockProductOptionGroupRepository {
-    find = jest.fn();
-}

+ 43 - 15
modules/core/service/product-variant.service.spec.ts

@@ -1,12 +1,13 @@
 import { Test } from '@nestjs/testing';
 import { Connection } from 'typeorm';
 import { ProductOption } from '../entity/product-option/product-option.entity';
-import { ProductVariantTranslationEntity } from '../entity/product-variant/product-variant-translation.entity';
+import { ProductVariantTranslation } from '../entity/product-variant/product-variant-translation.entity';
 import { ProductVariant } from '../entity/product-variant/product-variant.entity';
 import { Product } from '../entity/product/product.entity';
 import { LanguageCode } from '../locale/language-code';
-import { ProductVariantRepository } from '../repository/product-variant-repository';
-import { MockConnection } from '../repository/repository.mock';
+import { MockTranslationUpdaterService } from '../locale/translation-updater.mock';
+import { TranslationUpdaterService } from '../locale/translation-updater.service';
+import { MockConnection } from '../testing/connection.mock';
 import { ProductVariantService } from './product-variant.service';
 
 describe('ProductVariantService', () => {
@@ -15,7 +16,11 @@ describe('ProductVariantService', () => {
 
     beforeEach(async () => {
         const module = await Test.createTestingModule({
-            providers: [ProductVariantService, { provide: Connection, useClass: MockConnection }],
+            providers: [
+                ProductVariantService,
+                { provide: TranslationUpdaterService, useClass: MockTranslationUpdaterService },
+                { provide: Connection, useClass: MockConnection },
+            ],
         }).compile();
 
         productVariantService = module.get(ProductVariantService);
@@ -23,7 +28,7 @@ describe('ProductVariantService', () => {
     });
 
     describe('create()', () => {
-        it('calls ProductVariantRepository.create with product and translation entities', async () => {
+        it('saves a new ProductVariant with the correct properties', async () => {
             const productEntity = new Product();
             await productVariantService.create(productEntity, {
                 sku: '123456',
@@ -40,19 +45,42 @@ describe('ProductVariantService', () => {
                 ],
             });
 
-            const [arg1, arg2, arg3] = connection.getCustomRepository(ProductVariantRepository).create.mock.calls[0];
-            expect(arg1).toBe(productEntity);
-            expect(arg2 instanceof ProductVariant).toBe(true);
-            expect(Array.isArray(arg3)).toBe(true);
-            expect(arg3.length).toBe(2);
-            expect(arg3[0] instanceof ProductVariantTranslationEntity).toBe(true);
+            const savedProductVariant = connection.manager.save.mock.calls[2][0];
+            expect(savedProductVariant instanceof ProductVariant).toBe(true);
+            expect(savedProductVariant.product).toBe(productEntity);
+        });
+
+        it('saves each ProductVariantTranslation', async () => {
+            const productEntity = new Product();
+            await productVariantService.create(productEntity, {
+                sku: '123456',
+                price: 123,
+                translations: [
+                    {
+                        languageCode: LanguageCode.EN,
+                        name: 'Test EN',
+                    },
+                    {
+                        languageCode: LanguageCode.DE,
+                        name: 'Test DE',
+                    },
+                ],
+            });
+
+            const savedTranslation1 = connection.manager.save.mock.calls[0][0];
+            const savedTranslation2 = connection.manager.save.mock.calls[1][0];
+            const savedProductVariant = connection.manager.save.mock.calls[2][0];
+            expect(savedTranslation1 instanceof ProductVariantTranslation).toBe(true);
+            expect(savedTranslation2 instanceof ProductVariantTranslation).toBe(true);
+            expect(savedProductVariant.translations).toEqual([savedTranslation1, savedTranslation2]);
         });
 
         it('adds Options to the productVariant when specified', async () => {
             const productEntity = new Product();
-            const productOptionRepository = connection.registerMockRepository(ProductOption);
             const mockOptions = [{ code: 'option1' }, { code: 'option2' }, { code: 'option3' }];
-            productOptionRepository.find.mockReturnValue(mockOptions);
+            const productOptionRepository = connection
+                .registerMockRepository(ProductOption)
+                .find.mockReturnValue(mockOptions);
 
             await productVariantService.create(productEntity, {
                 sku: '123456',
@@ -66,8 +94,8 @@ describe('ProductVariantService', () => {
                 optionCodes: ['option2'],
             });
 
-            const [arg1, arg2] = connection.getCustomRepository(ProductVariantRepository).create.mock.calls[0];
-            expect(arg2.options).toEqual([mockOptions[1]]);
+            const savedProductVariant = connection.manager.save.mock.calls[1][0];
+            expect(savedProductVariant.options).toEqual([mockOptions[1]]);
         });
     });
 });

+ 18 - 22
modules/core/service/product-variant.service.ts

@@ -1,45 +1,41 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
-import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { ProductOption } from '../entity/product-option/product-option.entity';
 import { CreateProductVariantDto } from '../entity/product-variant/create-product-variant.dto';
-import { ProductVariantTranslationEntity } from '../entity/product-variant/product-variant-translation.entity';
+import { ProductVariantTranslation } from '../entity/product-variant/product-variant-translation.entity';
 import { ProductVariant } from '../entity/product-variant/product-variant.entity';
 import { Product } from '../entity/product/product.entity';
-import { translateDeep } from '../locale/translate-entity';
-import { ProductVariantRepository } from '../repository/product-variant-repository';
+import { TranslationUpdaterService } from '../locale/translation-updater.service';
 
 @Injectable()
 export class ProductVariantService {
-    constructor(@InjectConnection() private connection: Connection) {}
+    constructor(
+        @InjectConnection() private connection: Connection,
+        private translationUpdaterService: TranslationUpdaterService,
+    ) {}
 
     async create(product: Product, createProductVariantDto: CreateProductVariantDto): Promise<ProductVariant> {
-        const { sku, price, image, optionCodes, translations } = createProductVariantDto;
-        const productVariant = new ProductVariant();
-        productVariant.sku = sku;
-        productVariant.price = price;
-        productVariant.image = image!;
+        const { optionCodes, translations } = createProductVariantDto;
+        const variant = new ProductVariant(createProductVariantDto);
+        const variantTranslations: ProductVariantTranslation[] = [];
 
         if (optionCodes && optionCodes.length) {
             const options = await this.connection.getRepository(ProductOption).find();
-            const selectedOptions = options.filter(o => optionCodes.includes(o.code));
-            productVariant.options = selectedOptions;
+            const selectedOptions = options.filter(og => optionCodes.includes(og.code));
+            variant.options = selectedOptions;
         }
 
-        const variantTranslations: ProductVariantTranslationEntity[] = [];
-
         for (const input of translations) {
-            const { languageCode, name } = input;
-            const translation = new ProductVariantTranslationEntity();
-            translation.languageCode = languageCode;
-            translation.name = name;
+            const translation = new ProductVariantTranslation(input);
             variantTranslations.push(translation);
+            await this.connection.manager.save(translation);
         }
 
-        return this.connection
-            .getCustomRepository(ProductVariantRepository)
-            .create(product, productVariant, variantTranslations)
-            .then(variant => translateDeep(variant, DEFAULT_LANGUAGE_CODE));
+        variant.product = product;
+        variant.translations = variantTranslations;
+        const createdVariant = await this.connection.manager.save(variant);
+
+        return createdVariant;
     }
 }

+ 57 - 172
modules/core/service/product.service.spec.ts

@@ -5,26 +5,37 @@ import { ProductTranslation } from '../entity/product/product-translation.entity
 import { UpdateProductDto } from '../entity/product/product.dto';
 import { Product } from '../entity/product/product.entity';
 import { LanguageCode } from '../locale/language-code';
-import { TranslationInput } from '../locale/locale-types';
-import { ProductRepository } from '../repository/product-repository';
-import { MockConnection } from '../repository/repository.mock';
+import { MockTranslationUpdaterService } from '../locale/translation-updater.mock';
+import { TranslationUpdaterService } from '../locale/translation-updater.service';
+import { MockConnection } from '../testing/connection.mock';
 import { ProductService } from './product.service';
 
 describe('ProductService', () => {
     let productService: ProductService;
+    let translationUpdaterService: MockTranslationUpdaterService;
     let connection: MockConnection;
 
     beforeEach(async () => {
         const module = await Test.createTestingModule({
-            providers: [ProductService, { provide: Connection, useClass: MockConnection }],
+            providers: [
+                ProductService,
+                { provide: TranslationUpdaterService, useClass: MockTranslationUpdaterService },
+                { provide: Connection, useClass: MockConnection },
+            ],
         }).compile();
 
         productService = module.get(ProductService);
+        translationUpdaterService = module.get(TranslationUpdaterService) as any;
         connection = module.get(Connection) as any;
     });
 
     describe('create()', () => {
-        it('calls ProductRepository.create with product and translation entities', async () => {
+        beforeEach(() => {
+            connection.manager.save.mockReturnValue({ id: 1 });
+            connection.manager.findOne.mockReturnValue(Promise.resolve());
+        });
+
+        it('saves a new Product with the correct properties', async () => {
             await productService.create({
                 translations: [
                     {
@@ -42,18 +53,39 @@ describe('ProductService', () => {
                 ],
             });
 
-            const [arg1, arg2] = connection.getCustomRepository(ProductRepository).create.mock.calls[0];
-            expect(arg1 instanceof Product).toBe(true);
-            expect(Array.isArray(arg2)).toBe(true);
-            expect(arg2.length).toBe(2);
-            expect(arg2[0] instanceof ProductTranslation).toBe(true);
+            const savedProduct = connection.manager.save.mock.calls[2][0];
+            expect(savedProduct instanceof Product).toBe(true);
+        });
+
+        it('saves each ProductTranslation', async () => {
+            await productService.create({
+                translations: [
+                    {
+                        languageCode: LanguageCode.EN,
+                        name: 'Test EN',
+                        slug: 'test-en',
+                        description: 'Test description EN',
+                    },
+                    {
+                        languageCode: LanguageCode.DE,
+                        name: 'Test DE',
+                        slug: 'test-de',
+                        description: 'Test description DE',
+                    },
+                ],
+            });
+
+            const savedTranslation1 = connection.manager.save.mock.calls[0][0];
+            const savedTranslation2 = connection.manager.save.mock.calls[1][0];
+            const savedProduct = connection.manager.save.mock.calls[2][0];
+            expect(savedTranslation1 instanceof ProductTranslation).toBe(true);
+            expect(savedTranslation2 instanceof ProductTranslation).toBe(true);
+            expect(savedProduct.translations).toEqual([savedTranslation1, savedTranslation2]);
         });
 
         it('adds OptionGroups to the product when specified', async () => {
-            const productOptionGroupRepository = connection.registerMockRepository(ProductOptionGroup);
-            const productRepository = connection.getCustomRepository(ProductRepository);
             const mockOptionGroups = [{ code: 'optionGroup1' }, { code: 'optionGroup2' }, { code: 'optionGroup3' }];
-            productOptionGroupRepository.find.mockReturnValue(mockOptionGroups);
+            connection.registerMockRepository(ProductOptionGroup).find.mockReturnValue(mockOptionGroups);
 
             await productService.create({
                 translations: [
@@ -67,177 +99,30 @@ describe('ProductService', () => {
                 optionGroupCodes: ['optionGroup2'],
             });
 
-            const [arg1] = productRepository.create.mock.calls[0];
-            expect(arg1.optionGroups).toEqual([mockOptionGroups[1]]);
+            const savedProduct = connection.manager.save.mock.calls[1][0];
+            expect(savedProduct.optionGroups).toEqual([mockOptionGroups[1]]);
         });
     });
 
     describe('update()', () => {
-        it('calls ProductRepository.update with the UpdateProductDto', async () => {
+        it('uses the TranslationUpdater to diff the translations', async () => {
+            connection.manager.findOne.mockReturnValue(Promise.resolve());
             connection.registerMockRepository(ProductTranslation).find.mockReturnValue([]);
+            const productFromApplyDiffCall = {};
+            const translationUpdater = translationUpdaterService.mockUpdater;
+            translationUpdater.applyDiff.mockReturnValue(Promise.resolve(productFromApplyDiffCall));
+
             const dto: UpdateProductDto = {
                 id: 1,
                 image: 'some-image',
                 translations: [],
             };
             await productService.update(dto);
+            const savedProduct = connection.manager.save.mock.calls[0][0];
 
-            const update = connection.getCustomRepository(ProductRepository).update;
-            expect(update.mock.calls[0][0]).toBe(dto);
-        });
-
-        describe('translation handling', () => {
-            const existingTranslations: ProductTranslation[] = [
-                {
-                    id: 10,
-                    languageCode: LanguageCode.EN,
-                    name: '',
-                    slug: '',
-                    description: '',
-                    base: 1 as any,
-                },
-                {
-                    id: 11,
-                    languageCode: LanguageCode.DE,
-                    name: '',
-                    slug: '',
-                    description: '',
-                    base: 1 as any,
-                },
-            ];
-
-            beforeEach(() => {
-                connection.registerMockRepository(ProductTranslation).find.mockReturnValue(existingTranslations);
-            });
-
-            it('correctly marks translations for update', async () => {
-                const dto: UpdateProductDto = {
-                    id: 1,
-                    image: 'some-image',
-                    translations: [
-                        {
-                            languageCode: LanguageCode.EN,
-                            name: '',
-                            slug: '',
-                            description: '',
-                        },
-                        {
-                            languageCode: LanguageCode.DE,
-                            name: '',
-                            slug: '',
-                            description: '',
-                        },
-                    ],
-                };
-                await productService.update(dto);
-
-                const update = connection.getCustomRepository(ProductRepository).update;
-                expect(update.mock.calls[0][1]).toEqual(existingTranslations);
-            });
-
-            it('translations to update always have ids', async () => {
-                const dto: UpdateProductDto = {
-                    id: 1,
-                    image: 'some-image',
-                    translations: [
-                        {
-                            languageCode: LanguageCode.EN,
-                            name: '',
-                            slug: '',
-                            description: '',
-                        },
-                        {
-                            languageCode: LanguageCode.DE,
-                            name: '',
-                            slug: '',
-                            description: '',
-                        },
-                    ],
-                };
-                await productService.update(dto);
-
-                const update = connection.getCustomRepository(ProductRepository).update;
-                expect(update.mock.calls[0][1][0]).toHaveProperty('id', existingTranslations[0].id);
-                expect(update.mock.calls[0][1][1]).toHaveProperty('id', existingTranslations[1].id);
-            });
-
-            it('correctly marks translations for addition', async () => {
-                const languagesToAdd: TranslationInput<Product>[] = [
-                    {
-                        languageCode: LanguageCode.AA,
-                        name: '',
-                        slug: '',
-                        description: '',
-                    },
-                    {
-                        languageCode: LanguageCode.ZA,
-                        name: '',
-                        slug: '',
-                        description: '',
-                    },
-                ];
-                const dto: UpdateProductDto = {
-                    id: 1,
-                    image: 'some-image',
-                    translations: languagesToAdd,
-                };
-                await productService.update(dto);
-
-                const update = connection.getCustomRepository(ProductRepository).update;
-                expect(update.mock.calls[0][2]).toEqual(languagesToAdd);
-            });
-
-            it('correctly marks translations for deletion', async () => {
-                const dto: UpdateProductDto = {
-                    id: 1,
-                    image: 'some-image',
-                    translations: [],
-                };
-                await productService.update(dto);
-
-                const update = connection.getCustomRepository(ProductRepository).update;
-                expect(update.mock.calls[0][3]).toEqual(existingTranslations);
-            });
-
-            it('translations for deletion always have ids', async () => {
-                const dto: UpdateProductDto = {
-                    id: 1,
-                    image: 'some-image',
-                    translations: [],
-                };
-                await productService.update(dto);
-
-                const update = connection.getCustomRepository(ProductRepository).update;
-                expect(update.mock.calls[0][3]).toEqual(existingTranslations);
-            });
-
-            it('correctly marks languages for update, addition and deletion', async () => {
-                const updatedTranslations: TranslationInput<Product>[] = [
-                    {
-                        languageCode: LanguageCode.EN,
-                        name: '',
-                        slug: '',
-                        description: '',
-                    },
-                    {
-                        languageCode: LanguageCode.ZA,
-                        name: '',
-                        slug: '',
-                        description: '',
-                    },
-                ];
-                const dto: UpdateProductDto = {
-                    id: 1,
-                    image: 'some-image',
-                    translations: updatedTranslations,
-                };
-                await productService.update(dto);
-
-                const update = connection.getCustomRepository(ProductRepository).update;
-                expect(update.mock.calls[0][1]).toEqual([existingTranslations[0]]);
-                expect(update.mock.calls[0][2]).toEqual([updatedTranslations[1]]);
-                expect(update.mock.calls[0][3]).toEqual([existingTranslations[1]]);
-            });
+            expect(translationUpdater.diff).toHaveBeenCalledTimes(1);
+            expect(translationUpdater.applyDiff).toHaveBeenCalledTimes(1);
+            expect(savedProduct).toBe(productFromApplyDiffCall);
         });
     });
 });

+ 27 - 44
modules/core/service/product.service.ts

@@ -2,24 +2,27 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
-import { foundIn, not } 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 { LanguageCode } from '../locale/language-code';
-import { TranslationInput } from '../locale/locale-types';
 import { translateDeep } from '../locale/translate-entity';
-import { ProductRepository } from '../repository/product-repository';
+import { TranslationUpdater } from '../locale/translation-updater';
+import { TranslationUpdaterService } from '../locale/translation-updater.service';
 
 @Injectable()
 export class ProductService {
-    constructor(@InjectConnection() private connection: Connection) {}
+    constructor(
+        @InjectConnection() private connection: Connection,
+        private translationUpdaterService: TranslationUpdaterService,
+    ) {}
 
     findAll(lang: LanguageCode): Promise<Product[]> {
-        return this.connection
-            .getCustomRepository(ProductRepository)
-            .find(lang)
+        const relations = ['variants', 'optionGroups', 'variants.options'];
+
+        return this.connection.manager
+            .find(Product, { relations })
             .then(products =>
                 products.map(product =>
                     translateDeep(product, lang, ['optionGroups', 'variants', ['variants', 'options']]),
@@ -28,9 +31,10 @@ export class ProductService {
     }
 
     findOne(productId: number, lang: LanguageCode): Promise<Product | undefined> {
-        return this.connection
-            .getCustomRepository(ProductRepository)
-            .findOne(productId, lang)
+        const relations = ['variants', 'optionGroups', 'variants.options'];
+
+        return this.connection.manager
+            .findOne(Product, productId, { relations })
             .then(
                 product =>
                     product && translateDeep(product, lang, ['optionGroups', 'variants', ['variants', 'options']]),
@@ -39,7 +43,7 @@ export class ProductService {
 
     async create(createProductDto: CreateProductDto): Promise<Product> {
         const { variants, optionGroupCodes, image, translations } = createProductDto;
-        const product = new Product();
+        const product = new Product(createProductDto);
         const productTranslations: ProductTranslation[] = [];
 
         if (optionGroupCodes && optionGroupCodes.length) {
@@ -48,51 +52,30 @@ export class ProductService {
             product.optionGroups = selectedOptionGroups;
         }
 
-        for (const input of createProductDto.translations) {
-            productTranslations.push(new ProductTranslation(input));
+        for (const input of translations) {
+            const translation = new ProductTranslation(input);
+            productTranslations.push(translation);
+            await this.connection.manager.save(translation);
         }
 
-        return this.connection
-            .getCustomRepository(ProductRepository)
-            .create(product, productTranslations)
-            .then(createdProduct => translateDeep(createdProduct, DEFAULT_LANGUAGE_CODE));
+        product.translations = productTranslations;
+        const createdProduct = await this.connection.manager.save(product);
+
+        return this.findOne(createdProduct.id, DEFAULT_LANGUAGE_CODE) as Promise<Product>;
     }
 
     async update(updateProductDto: UpdateProductDto): Promise<Product | undefined> {
-        const { optionGroupCodes, image, translations } = updateProductDto;
-        const productTranslations: ProductTranslation[] = [];
-
-        // get current translations
         const existingTranslations = await this.connection.getRepository(ProductTranslation).find({
             where: { base: updateProductDto.id },
             relations: ['base'],
         });
 
-        const translationEntities = this.translationInputsToEntities(translations, existingTranslations);
+        const translationUpdater = this.translationUpdaterService.create(ProductTranslation);
+        const diff = translationUpdater.diff(existingTranslations, updateProductDto.translations);
 
-        const toDelete = existingTranslations.filter(not(foundIn(translationEntities, 'languageCode')));
-        const toAdd = translationEntities.filter(not(foundIn(existingTranslations, 'languageCode')));
-        const toUpdate = translationEntities.filter(foundIn(existingTranslations, 'languageCode'));
-
-        await this.connection
-            .getCustomRepository(ProductRepository)
-            .update(updateProductDto, toUpdate, toAdd, toDelete);
+        const product = await translationUpdater.applyDiff(new Product(updateProductDto), diff);
+        await this.connection.manager.save(product);
 
         return this.findOne(updateProductDto.id, DEFAULT_LANGUAGE_CODE);
     }
-
-    private translationInputsToEntities(
-        inputs: Array<TranslationInput<Product>>,
-        existing: Array<ProductTranslation>,
-    ): Array<ProductTranslation> {
-        return inputs.map(input => {
-            const counterpart = existing.find(e => e.languageCode === input.languageCode);
-            const entity = new ProductTranslation(input);
-            if (counterpart) {
-                entity.id = counterpart.id;
-                entity.base = counterpart.base;
-            }
-            return entity;
-        });
-    }
 }

+ 92 - 0
modules/core/testing/connection.mock.ts

@@ -0,0 +1,92 @@
+import { AbstractRepository, EntityManager, Repository } from 'typeorm';
+import { Type } from '../common/common-types';
+import { MockClass } from './testing-types';
+
+/**
+ * A mock of the TypeORM Connection class for use in testing.
+ */
+export class MockConnection {
+    manager: MockEntityManager;
+
+    private repositoryMap = new Map<Type<any>, any>();
+
+    constructor() {
+        this.manager = new MockEntityManager();
+        this.manager.connection = this;
+    }
+
+    registerMockRepository<T extends Type<any>>(entity: T): MockRepository<T> {
+        const repository = new MockRepository();
+        this.repositoryMap.set(entity, repository);
+        return repository;
+    }
+
+    getRepository<T extends Type<any>>(entity: T): MockRepository<T> {
+        const repository = this.repositoryMap.get(entity);
+        if (repository) {
+            return repository;
+        } else {
+            throw new Error(`No mock repository registered for "${entity.name}". Use registerRepository() first.`);
+        }
+    }
+}
+
+export class MockRepository<T> implements MockClass<Repository<T>> {
+    manager: any;
+    metadata: any;
+    queryRunner: any;
+    target: any;
+    createQueryBuilder = jest.fn();
+    hasId = jest.fn();
+    getId = jest.fn();
+    create = jest.fn();
+    merge = jest.fn();
+    preload = jest.fn();
+    save = jest.fn();
+    remove = jest.fn();
+    insert = jest.fn();
+    update = jest.fn();
+    delete = jest.fn();
+    count = jest.fn();
+    find = jest.fn();
+    findAndCount = jest.fn();
+    findByIds = jest.fn();
+    findOne = jest.fn();
+    findOneOrFail = jest.fn();
+    query = jest.fn();
+    clear = jest.fn();
+    increment = jest.fn();
+    decrement = jest.fn();
+}
+
+export class MockEntityManager implements MockClass<EntityManager> {
+    connection: any = {};
+    queryRunner: any = {};
+    transaction = jest.fn();
+    query = jest.fn();
+    createQueryBuilder = jest.fn();
+    hasId = jest.fn();
+    getId = jest.fn();
+    create = jest.fn();
+    merge = jest.fn();
+    preload = jest.fn();
+    save = jest.fn();
+    remove = jest.fn();
+    insert = jest.fn();
+    update = jest.fn();
+    delete = jest.fn();
+    count = jest.fn();
+    find = jest.fn();
+    findAndCount = jest.fn();
+    findByIds = jest.fn();
+    findOne = jest.fn();
+    findOneOrFail = jest.fn();
+    clear = jest.fn();
+    increment = jest.fn();
+    decrement = jest.fn();
+    getRepository = jest.fn();
+    getTreeRepository = jest.fn();
+    getMongoRepository = jest.fn();
+    getCustomRepository = jest.fn();
+    release = jest.fn();
+}

+ 3 - 3
modules/mock-data/mock-data.service.ts

@@ -9,7 +9,7 @@ import { ProductOptionGroupTranslation } from '../core/entity/product-option-gro
 import { ProductOptionGroup } from '../core/entity/product-option-group/product-option-group.entity';
 import { ProductOptionTranslation } from '../core/entity/product-option/product-option-translation.entity';
 import { ProductOption } from '../core/entity/product-option/product-option.entity';
-import { ProductVariantTranslationEntity } from '../core/entity/product-variant/product-variant-translation.entity';
+import { ProductVariantTranslation } from '../core/entity/product-variant/product-variant-translation.entity';
 import { ProductVariant } from '../core/entity/product-variant/product-variant.entity';
 import { ProductTranslation } from '../core/entity/product/product-translation.entity';
 import { Product } from '../core/entity/product/product.entity';
@@ -233,8 +233,8 @@ export class MockDataService {
         return productTranslation;
     }
 
-    private makeProductVariantTranslation(langCode: LanguageCode, name: string): ProductVariantTranslationEntity {
-        const productVariantTranslation = new ProductVariantTranslationEntity();
+    private makeProductVariantTranslation(langCode: LanguageCode, name: string): ProductVariantTranslation {
+        const productVariantTranslation = new ProductVariantTranslation();
         productVariantTranslation.languageCode = langCode;
         productVariantTranslation.name = `${langCode} ${name}`;
         return productVariantTranslation;