소스 검색

Implement update for Product entity

Michael Bromley 7 년 전
부모
커밋
0aeb113154

+ 1 - 1
modules/core/api/product/product.api.graphql

@@ -7,5 +7,5 @@ type Mutation {
     "Create a new Product"
     createProduct(input: CreateProductInput): Product
     "Update an existing Product"
-    updateProduct(productId: Int, input: UpdateProductInput): Product
+    updateProduct(input: UpdateProductInput): Product
 }

+ 4 - 4
modules/core/api/product/product.resolver.ts

@@ -1,5 +1,5 @@
 import { Mutation, Query, Resolver } from '@nestjs/graphql';
-import { CreateProductDto } from '../../entity/product/create-product.dto';
+import { CreateProductDto } from '../../entity/product/product.dto';
 import { Product } from '../../entity/product/product.entity';
 import { ProductVariantService } from '../../service/product-variant.service';
 import { ProductService } from '../../service/product.service';
@@ -31,12 +31,12 @@ export class ProductResolver {
 
         return product;
     }
-    /*
+
     @Mutation()
     updateProduct(_, args): Promise<Product> {
         const { productId, input } = args;
-
-    }*/
+        return this.productService.update(input);
+    }
 }
 
 export interface MutationInput<T> {

+ 14 - 0
modules/core/common/utils.ts

@@ -0,0 +1,14 @@
+/**
+ * Takes a predicate function and returns a negated version.
+ */
+export function not(predicate: (...args: any[]) => boolean) {
+    return (...args: any[]) => !predicate(...args);
+}
+
+/**
+ * Returns a predicate function which returns true if the item is found in the set,
+ * as determined by a === equality check on the given compareBy property.
+ */
+export function foundIn<T>(set: Array<T>, compareBy: keyof T) {
+    return (item: T) => set.some(t => t[compareBy] === item[compareBy]);
+}

+ 13 - 1
modules/core/entity/product/product-translation.entity.ts

@@ -1,10 +1,22 @@
 import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
 import { LanguageCode } from '../../locale/language-code';
-import { Translation } from '../../locale/locale-types';
+import { Translation, TranslationInput } from '../../locale/locale-types';
 import { Product } from './product.entity';
 
 @Entity('product_translation')
 export class ProductTranslation implements Translation<Product> {
+    constructor(input?: TranslationInput<Product>) {
+        if (input) {
+            if (input.id !== undefined) {
+                this.id = input.id;
+            }
+            this.languageCode = input.languageCode;
+            this.name = input.name;
+            this.slug = input.slug;
+            this.description = input.description;
+        }
+    }
+
     @PrimaryGeneratedColumn() id: number;
 
     @Column() languageCode: LanguageCode;

+ 6 - 0
modules/core/entity/product/create-product.dto.ts → modules/core/entity/product/product.dto.ts

@@ -7,3 +7,9 @@ export interface CreateProductDto extends TranslatedInput<Product> {
     optionGroupCodes?: [string];
     variants?: CreateProductVariantDto[];
 }
+
+export interface UpdateProductDto extends TranslatedInput<Product> {
+    id: number;
+    image?: string;
+    optionGroupCodes?: [string];
+}

+ 4 - 2
modules/core/entity/product/product.graphql

@@ -18,6 +18,7 @@ type ProductTranslation {
 }
 
 input ProductTranslationInput {
+    id: Int
     languageCode: LanguageCode!
     name: String!
     slug: String
@@ -25,14 +26,15 @@ input ProductTranslationInput {
 }
 
 input CreateProductInput {
-    translations: [ProductTranslationInput]!
     image: String
+    translations: [ProductTranslationInput]!
     variants: [CreateProductVariantInput]
     optionGroupCodes: [String]
 }
 
 input UpdateProductInput {
-    translations: [ProductTranslationInput]!
+    id: Int!
     image: String
+    translations: [ProductTranslationInput]!
     optionGroupCodes: [String]
 }

+ 0 - 7
modules/core/entity/product/update-product.dto.ts

@@ -1,7 +0,0 @@
-import { TranslatedInput } from '../../locale/locale-types';
-import { Product } from './product.entity';
-
-export interface CreateProductDto extends TranslatedInput<Product> {
-    image?: string;
-    optionGroupCodes?: [string];
-}

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

@@ -26,12 +26,15 @@ export type Translation<T> =
     // Translation must include all translatable keys as a string type
     { [K in TranslatableKeys<T>]: string; };
 
-export type LocalizedInput<T> = { [K in TranslatableKeys<T>]: string } & { languageCode: LanguageCode };
+/**
+ * This is the type of a translation object when provided as input to a create or update operation.
+ */
+export type TranslationInput<T> = { [K in TranslatableKeys<T>]: string } & { id?: number; languageCode: LanguageCode };
 
 /**
  * This interface defines the shape of a DTO used to create / update an entity which has one or more LocaleString
  * properties.
  */
 export interface TranslatedInput<T> {
-    translations: Array<LocalizedInput<T>>;
+    translations: Array<TranslationInput<T>>;
 }

+ 45 - 1
modules/core/repository/product-repository.ts

@@ -1,8 +1,9 @@
 import { AbstractRepository, EntityRepository, SelectQueryBuilder } from 'typeorm';
-import { CreateProductDto } from '../entity/product/create-product.dto';
 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)
@@ -36,6 +37,49 @@ export class ProductRepository extends AbstractRepository<Product> {
         return this.manager.save(productEntity);
     }
 
+    async update(
+        product: UpdateProductDto,
+        translationsToUpdate: ProductTranslation[],
+        translationsToAdd: ProductTranslation[],
+        translationsToDelete: ProductTranslation[],
+    ): Promise<any> {
+        if (translationsToUpdate.length) {
+            for (const toUpdate of translationsToUpdate) {
+                await this.manager
+                    .createQueryBuilder()
+                    .update(ProductTranslation)
+                    .set(toUpdate)
+                    .where('id = :id', { id: toUpdate.id })
+                    .execute();
+            }
+        }
+
+        if (translationsToAdd.length) {
+            for (const toAdd of translationsToAdd) {
+                const translation = new ProductTranslation(toAdd);
+                translation.base = product as Product;
+                await this.manager.getRepository(ProductTranslation).save(translation);
+                product.translations.push(translation);
+            }
+        }
+
+        if (translationsToDelete.length) {
+            const toDeleteEntities = translationsToDelete.map(toDelete => {
+                const translation = new ProductTranslation(toDelete);
+                translation.base = product as Product;
+                return translation;
+            });
+            await this.manager.getRepository(ProductTranslation).remove(toDeleteEntities);
+        }
+
+        await this.manager
+            .createQueryBuilder()
+            .update(Product)
+            .set(product)
+            .where('id = :id', { id: product.id })
+            .execute();
+    }
+
     private getProductQueryBuilder(languageCode: LanguageCode): SelectQueryBuilder<Product> {
         return this.manager
             .createQueryBuilder(Product, 'product')

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

@@ -56,6 +56,7 @@ export class MockProductRepository implements MockClass<ProductRepository> {
     find = stubReturningPromise();
     findOne = stubReturningPromise();
     create = stubReturningPromise();
+    update = stubReturningPromise();
 }
 
 export class MockProductVariantRepository implements MockClass<ProductVariantRepository> {

+ 171 - 0
modules/core/service/product.service.spec.ts

@@ -2,8 +2,10 @@ import { Test } from '@nestjs/testing';
 import { Connection } from 'typeorm';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
 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 { ProductService } from './product.service';
@@ -69,4 +71,173 @@ describe('ProductService', () => {
             expect(arg1.optionGroups).toEqual([mockOptionGroups[1]]);
         });
     });
+
+    describe('update()', () => {
+        it('calls ProductRepository.update with the UpdateProductDto', async () => {
+            connection.registerMockRepository(ProductTranslation).find.mockReturnValue([]);
+            const dto: UpdateProductDto = {
+                id: 1,
+                image: 'some-image',
+                translations: [],
+            };
+            await productService.update(dto);
+
+            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]]);
+            });
+        });
+    });
 });

+ 47 - 12
modules/core/service/product.service.ts

@@ -1,11 +1,13 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
+import { foundIn, not } from '../common/utils';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
-import { CreateProductDto } from '../entity/product/create-product.dto';
 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';
 
@@ -29,29 +31,62 @@ export class ProductService {
 
     async create(createProductDto: CreateProductDto): Promise<Product> {
         const { variants, optionGroupCodes, image, translations } = createProductDto;
-        const productEntity = new Product();
+        const product = new Product();
         const productTranslations: ProductTranslation[] = [];
 
         if (optionGroupCodes && optionGroupCodes.length) {
             const optionGroups = await this.connection.getRepository(ProductOptionGroup).find();
             const selectedOptionGroups = optionGroups.filter(og => optionGroupCodes.includes(og.code));
-            productEntity.optionGroups = selectedOptionGroups;
+            product.optionGroups = selectedOptionGroups;
         }
 
         for (const input of createProductDto.translations) {
-            const { languageCode, name, description, slug } = input;
-            const translation = new ProductTranslation();
-            translation.languageCode = languageCode;
-            translation.name = name;
-            translation.slug = slug;
-            translation.description = description;
-            productTranslations.push(translation);
+            productTranslations.push(new ProductTranslation(input));
         }
 
         return this.connection
             .getCustomRepository(ProductRepository)
-            .create(productEntity, productTranslations)
-            .then(product => translateDeep(product));
+            .create(product, productTranslations)
+            .then(createdProduct => translateDeep(createdProduct));
+    }
+
+    async update(updateProductDto: UpdateProductDto): Promise<Product> {
+        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 toDelete = existingTranslations.filter(not(foundIn(translationEntities, 'languageCode')));
+        const toAdd = translationEntities.filter(not(foundIn(existingTranslations, 'languageCode')));
+        const toUpdate = translationEntities.filter(foundIn(existingTranslations, 'languageCode'));
+
+        return this.connection
+            .getCustomRepository(ProductRepository)
+            .update(updateProductDto, toUpdate, toAdd, toDelete)
+            .then(createdProduct => translateDeep(createdProduct));
+    }
+
+    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;
+        });
     }
 
     private translateProductEntity(product: Product): Product {

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

@@ -1,10 +1,10 @@
 import * as faker from 'faker/locale/en_GB';
 import { request } from 'graphql-request';
 import { CreateProductVariantDto } from '../core/entity/product-variant/create-product-variant.dto';
-import { CreateProductDto } from '../core/entity/product/create-product.dto';
+import { CreateProductDto } from '../core/entity/product/product.dto';
 import { Product } from '../core/entity/product/product.entity';
 import { LanguageCode } from '../core/locale/language-code';
-import { LocalizedInput } from '../core/locale/locale-types';
+import { TranslationInput } from '../core/locale/locale-types';
 
 // tslint:disable:no-console
 /**
@@ -45,7 +45,7 @@ export class MockDataClientService {
         name: string,
         slug: string,
         description: string,
-    ): LocalizedInput<Product> {
+    ): TranslationInput<Product> {
         return {
             languageCode,
             name: `${languageCode} ${name}`,