Procházet zdrojové kódy

Merge branch 'entity-id-strategy'

Michael Bromley před 7 roky
rodič
revize
9dbe8f5439
52 změnil soubory, kde provedl 678 přidání a 264 odebrání
  1. 1 1
      server/mock-data/mock-data-client.service.ts
  2. 1 1
      server/src/api/administrator/administrator.api.graphql
  3. 2 2
      server/src/api/customer/customer.api.graphql
  4. 17 11
      server/src/api/customer/customer.resolver.ts
  5. 1 1
      server/src/api/product-option/product-option.api.graphql
  6. 1 1
      server/src/api/product/product.api.graphql
  7. 18 8
      server/src/api/product/product.resolver.ts
  8. 8 14
      server/src/app.module.ts
  9. 1 1
      server/src/common/common-types.graphql
  10. 5 0
      server/src/common/common-types.ts
  11. 15 0
      server/src/config/auto-increment-id-strategy.ts
  12. 21 0
      server/src/config/entity-id-strategy.ts
  13. 15 0
      server/src/config/uuid-id-strategy.ts
  14. 32 0
      server/src/config/vendure-config.ts
  15. 5 12
      server/src/entity/address/address.entity.ts
  16. 1 1
      server/src/entity/address/address.graphql
  17. 5 20
      server/src/entity/administrator/administrator.entity.ts
  18. 1 1
      server/src/entity/administrator/administrator.graphql
  19. 23 0
      server/src/entity/base/base.entity.ts
  20. 5 21
      server/src/entity/customer/customer.entity.ts
  21. 1 1
      server/src/entity/customer/customer.graphql
  22. 5 8
      server/src/entity/product-option-group/product-option-group-translation.entity.ts
  23. 1 1
      server/src/entity/product-option-group/product-option-group.dto.ts
  24. 5 12
      server/src/entity/product-option-group/product-option-group.entity.ts
  25. 4 4
      server/src/entity/product-option-group/product-option-group.graphql
  26. 5 8
      server/src/entity/product-option/product-option-translation.entity.ts
  27. 6 21
      server/src/entity/product-option/product-option.entity.ts
  28. 3 3
      server/src/entity/product-option/product-option.graphql
  29. 5 8
      server/src/entity/product-variant/product-variant-translation.entity.ts
  30. 5 22
      server/src/entity/product-variant/product-variant.entity.ts
  31. 2 2
      server/src/entity/product-variant/product-variant.graphql
  32. 5 8
      server/src/entity/product/product-translation.entity.ts
  33. 1 1
      server/src/entity/product/product.dto.ts
  34. 6 22
      server/src/entity/product/product.entity.ts
  35. 4 4
      server/src/entity/product/product.graphql
  36. 8 9
      server/src/entity/user/user.entity.ts
  37. 1 1
      server/src/entity/user/user.graphql
  38. 4 2
      server/src/locale/locale-types.ts
  39. 12 12
      server/src/locale/translate-entity.spec.ts
  40. 1 0
      server/src/locale/translate-entity.ts
  41. 6 8
      server/src/locale/translation-updater.spec.ts
  42. 1 1
      server/src/service/administrator.service.ts
  43. 19 0
      server/src/service/config.service.mock.ts
  44. 26 0
      server/src/service/config.service.ts
  45. 4 4
      server/src/service/customer.service.ts
  46. 261 0
      server/src/service/id-codec.service.spec.ts
  47. 91 0
      server/src/service/id-codec.service.ts
  48. 2 1
      server/src/service/product-option-group.service.ts
  49. 2 1
      server/src/service/product-option.service.ts
  50. 1 1
      server/src/service/product.service.spec.ts
  51. 2 3
      server/src/service/product.service.ts
  52. 1 1
      server/src/testing/testing-types.ts

+ 1 - 1
server/mock-data/mock-data-client.service.ts

@@ -97,7 +97,7 @@ export class MockDataClientService {
             );
 
             if (customer) {
-                const query2 = `mutation($customerId: Int!, $input: CreateAddressInput) {
+                const query2 = `mutation($customerId: String!, $input: CreateAddressInput) {
                                 createCustomerAddress(customerId: $customerId, input: $input) {
                                     id
                                     streetLine1

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

@@ -1,6 +1,6 @@
 type Query {
   administrators: [Administrator]
-  administrator(id: Int!): Administrator
+  administrator(id: ID!): Administrator
 }
 
 type Mutation {

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

@@ -1,13 +1,13 @@
 type Query {
   customers(take: Int, skip: Int): CustomerList
-  customer(id: Int!): Customer
+  customer(id: ID!): Customer
 }
 
 type Mutation {
   "Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer."
   createCustomer(input: CreateCustomerInput!, password: String): Customer
   "Create a new Address and associate it with the Customer specified by customerId"
-  createCustomerAddress(customerId: Int, input: CreateAddressInput): Address
+  createCustomerAddress(customerId: ID, input: CreateAddressInput): Address
 }
 
 type CustomerList implements PaginatedList {

+ 17 - 11
server/src/api/customer/customer.resolver.ts

@@ -4,35 +4,41 @@ import { Address } from '../../entity/address/address.entity';
 import { CreateCustomerDto } from '../../entity/customer/customer.dto';
 import { Customer } from '../../entity/customer/customer.entity';
 import { CustomerService } from '../../service/customer.service';
+import { IdCodecService } from '../../service/id-codec.service';
 
 @Resolver('Customer')
 export class CustomerResolver {
-    constructor(private customerService: CustomerService) {}
+    constructor(private customerService: CustomerService, private idCodecService: IdCodecService) {}
 
     @Query('customers')
-    customers(obj, args): Promise<PaginatedList<Customer>> {
-        return this.customerService.findAll(args.take, args.skip);
+    async customers(obj, args): Promise<PaginatedList<Customer>> {
+        return this.customerService.findAll(args.take, args.skip).then(list => this.idCodecService.encode(list));
     }
 
     @Query('customer')
-    customer(obj, args): Promise<Customer | undefined> {
-        return this.customerService.findOne(args.id);
+    async customer(obj, args): Promise<Customer | undefined> {
+        return this.customerService
+            .findOne(this.idCodecService.decode(args).id)
+            .then(c => this.idCodecService.encode(c));
     }
 
     @ResolveProperty('addresses')
-    addresses(customer: Customer): Promise<Address[]> {
-        return this.customerService.findAddressesByCustomerId(customer.id);
+    async addresses(customer: Customer): Promise<Address[]> {
+        const address = await this.customerService.findAddressesByCustomerId(this.idCodecService.decode(customer).id);
+        return this.idCodecService.encode(address);
     }
 
     @Mutation()
-    createCustomer(_, args): Promise<Customer> {
+    async createCustomer(_, args): Promise<Customer> {
         const { input, password } = args;
-        return this.customerService.create(input, password);
+        const customer = await this.customerService.create(input, password);
+        return this.idCodecService.encode(customer);
     }
 
     @Mutation()
-    createCustomerAddress(_, args): Promise<Address> {
+    async createCustomerAddress(_, args): Promise<Address> {
         const { customerId, input } = args;
-        return this.customerService.createAddress(customerId, input);
+        const address = await this.customerService.createAddress(this.idCodecService.decode(customerId), input);
+        return this.idCodecService.encode(address);
     }
 }

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

@@ -1,6 +1,6 @@
 type Query {
     productOptionGroups(languageCode: LanguageCode): [ProductOptionGroup]
-    productOptionGroup(id: Int!, languageCode: LanguageCode): ProductOptionGroup
+    productOptionGroup(id: ID!, languageCode: LanguageCode): ProductOptionGroup
 }
 
 type Mutation {

+ 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: Int!, languageCode: LanguageCode): Product
+    product(id: ID!, languageCode: LanguageCode): Product
 }
 
 type Mutation {

+ 18 - 8
server/src/api/product/product.resolver.ts

@@ -1,21 +1,30 @@
 import { Mutation, Query, Resolver } from '@nestjs/graphql';
 import { PaginatedList } from '../../common/common-types';
 import { Product } from '../../entity/product/product.entity';
+import { IdCodecService } from '../../service/id-codec.service';
 import { ProductVariantService } from '../../service/product-variant.service';
 import { ProductService } from '../../service/product.service';
 
 @Resolver('Product')
 export class ProductResolver {
-    constructor(private productService: ProductService, private productVariantService: ProductVariantService) {}
+    constructor(
+        private productService: ProductService,
+        private idCodecService: IdCodecService,
+        private productVariantService: ProductVariantService,
+    ) {}
 
     @Query('products')
-    products(obj, args): Promise<PaginatedList<Product>> {
-        return this.productService.findAll(args.languageCode, args.take, args.skip);
+    async products(obj, args): Promise<PaginatedList<Product>> {
+        return this.productService
+            .findAll(args.languageCode, args.take, args.skip)
+            .then(list => this.idCodecService.encode(list));
     }
 
     @Query('product')
-    product(obj, args): Promise<Product | undefined> {
-        return this.productService.findOne(args.id, args.languageCode);
+    async product(obj, args): Promise<Product | undefined> {
+        return this.productService
+            .findOne(this.idCodecService.decode(args).id, args.languageCode)
+            .then(p => this.idCodecService.encode(p));
     }
 
     @Mutation()
@@ -29,12 +38,13 @@ export class ProductResolver {
             }
         }
 
-        return product;
+        return this.idCodecService.encode(product);
     }
 
     @Mutation()
-    updateProduct(_, args): Promise<Product | undefined> {
+    async updateProduct(_, args): Promise<Product | undefined> {
         const { input } = args;
-        return this.productService.update(input);
+        const product = await this.productService.update(this.idCodecService.decode(input));
+        return this.idCodecService.decode(product);
     }
 }

+ 8 - 14
server/src/app.module.ts

@@ -11,35 +11,29 @@ 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 { getConfig } from './config/vendure-config';
 import { TranslationUpdaterService } from './locale/translation-updater.service';
 import { AdministratorService } from './service/administrator.service';
+import { ConfigService } from './service/config.service';
 import { CustomerService } from './service/customer.service';
+import { IdCodecService } from './service/id-codec.service';
 import { ProductOptionGroupService } from './service/product-option-group.service';
 import { ProductOptionService } from './service/product-option.service';
 import { ProductVariantService } from './service/product-variant.service';
 import { ProductService } from './service/product.service';
 
+const connectionOptions = getConfig().connectionOptions;
+
 @Module({
-    imports: [
-        GraphQLModule,
-        TypeOrmModule.forRoot({
-            type: 'mysql',
-            entities: ['./**/entity/**/*.entity.ts'],
-            synchronize: true,
-            logging: true,
-            host: '192.168.99.100',
-            port: 3306,
-            username: 'root',
-            password: '',
-            database: 'test',
-        }),
-    ],
+    imports: [GraphQLModule, TypeOrmModule.forRoot(connectionOptions)],
     controllers: [AuthController, CustomerController],
     providers: [
         AdministratorResolver,
         AdministratorService,
         AuthService,
+        ConfigService,
         JwtStrategy,
+        IdCodecService,
         PasswordService,
         CustomerService,
         CustomerResolver,

+ 1 - 1
server/src/common/common-types.graphql

@@ -4,5 +4,5 @@ interface PaginatedList {
 }
 
 interface Node {
-    id: Int!
+    id: ID!
 }

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

@@ -18,3 +18,8 @@ export type PaginatedList<T> = {
     items: T[];
     totalItems: number;
 };
+
+/**
+ * An entity ID
+ */
+export type ID = string | number;

+ 15 - 0
server/src/config/auto-increment-id-strategy.ts

@@ -0,0 +1,15 @@
+import { IntegerIdStrategy } from './entity-id-strategy';
+
+/**
+ * An id strategy which uses auto-increment integers as primary keys
+ * for all entities.
+ */
+export class AutoIncrementIdStrategy implements IntegerIdStrategy {
+    readonly primaryKeyType = 'increment';
+    decodeId(id: string): number {
+        return +id;
+    }
+    encodeId(primaryKey: number): string {
+        return primaryKey.toString();
+    }
+}

+ 21 - 0
server/src/config/entity-id-strategy.ts

@@ -0,0 +1,21 @@
+import { ID } from '../common/common-types';
+
+export type PrimaryKeyType = 'increment' | 'uuid';
+
+export interface EntityIdStrategy<T extends ID = ID> {
+    readonly primaryKeyType: PrimaryKeyType;
+    encodeId: (primaryKey: T) => string;
+    decodeId: (id: string) => T;
+}
+
+export interface IntegerIdStrategy extends EntityIdStrategy<number> {
+    readonly primaryKeyType: 'increment';
+    encodeId: (primaryKey: number) => string;
+    decodeId: (id: string) => number;
+}
+
+export interface StringIdStrategy extends EntityIdStrategy<string> {
+    readonly primaryKeyType: 'uuid';
+    encodeId: (primaryKey: string) => string;
+    decodeId: (id: string) => string;
+}

+ 15 - 0
server/src/config/uuid-id-strategy.ts

@@ -0,0 +1,15 @@
+import { StringIdStrategy } from './entity-id-strategy';
+
+/**
+ * An id strategy which uses string uuids as primary keys
+ * for all entities.
+ */
+export class UuidIdStrategy implements StringIdStrategy {
+    readonly primaryKeyType = 'uuid';
+    decodeId(id: string): string {
+        return id;
+    }
+    encodeId(primaryKey: string): string {
+        return primaryKey;
+    }
+}

+ 32 - 0
server/src/config/vendure-config.ts

@@ -0,0 +1,32 @@
+import { ConnectionOptions } from 'typeorm';
+import { LanguageCode } from '../locale/language-code';
+import { AutoIncrementIdStrategy } from './auto-increment-id-strategy';
+import { EntityIdStrategy } from './entity-id-strategy';
+
+export interface VendureConfig {
+    defaultLanguageCode: LanguageCode;
+    entityIdStrategy: EntityIdStrategy<any>;
+    connectionOptions: ConnectionOptions;
+}
+
+const defaultConfig: VendureConfig = {
+    defaultLanguageCode: LanguageCode.EN,
+    entityIdStrategy: new AutoIncrementIdStrategy(),
+    connectionOptions: {
+        type: 'mysql',
+    },
+};
+
+let activeConfig = defaultConfig;
+
+export function setConfig(userConfig: Partial<VendureConfig>): void {
+    activeConfig = Object.assign({}, defaultConfig, userConfig);
+}
+
+export function getConfig(): VendureConfig {
+    return activeConfig;
+}
+
+export function getEntityIdStrategy(): EntityIdStrategy {
+    return getConfig().entityIdStrategy;
+}

+ 5 - 12
server/src/entity/address/address.entity.ts

@@ -1,17 +1,14 @@
-import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
+import { Column, Entity, ManyToOne } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
+import { VendureEntity } from '../base/base.entity';
 import { Customer } from '../customer/customer.entity';
 
-@Entity('address')
-export class Address {
+@Entity()
+export class Address extends VendureEntity {
     constructor(input?: DeepPartial<Address>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     @ManyToOne(type => Customer, customer => customer.addresses)
     customer: Customer;
 
@@ -36,8 +33,4 @@ export class Address {
     @Column() defaultShippingAddress: boolean;
 
     @Column() defaultBillingAddress: boolean;
-
-    @CreateDateColumn() createdAt: string;
-
-    @UpdateDateColumn() updatedAt: string;
 }

+ 1 - 1
server/src/entity/address/address.graphql

@@ -1,5 +1,5 @@
 type Address implements Node {
-  id: Int!
+  id: ID!
   fullName: String
   company: String
   streetLine1: String

+ 5 - 20
server/src/entity/administrator/administrator.entity.ts

@@ -1,25 +1,14 @@
-import {
-    Column,
-    CreateDateColumn,
-    Entity,
-    JoinColumn,
-    OneToOne,
-    PrimaryGeneratedColumn,
-    UpdateDateColumn,
-} from 'typeorm';
+import { Column, Entity, JoinColumn, OneToOne } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
+import { VendureEntity } from '../base/base.entity';
 import { User } from '../user/user.entity';
 
-@Entity('administrator')
-export class Administrator {
+@Entity()
+export class Administrator extends VendureEntity {
     constructor(input?: DeepPartial<Administrator>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     @Column() firstName: string;
 
     @Column() lastName: string;
@@ -30,8 +19,4 @@ export class Administrator {
     @OneToOne(type => User, { eager: true })
     @JoinColumn()
     user: User;
-
-    @CreateDateColumn() createdAt: string;
-
-    @UpdateDateColumn() updatedAt: string;
 }

+ 1 - 1
server/src/entity/administrator/administrator.graphql

@@ -1,5 +1,5 @@
 type Administrator implements Node {
-    id: Int!
+    id: ID!
     firstName: String
     lastName: String
     emailAddress: String

+ 23 - 0
server/src/entity/base/base.entity.ts

@@ -0,0 +1,23 @@
+import { CreateDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
+import { DeepPartial, ID } from '../../common/common-types';
+import { getEntityIdStrategy } from '../../config/vendure-config';
+
+/**
+ * This is the base class from which all entities inherit.
+ */
+export abstract class VendureEntity {
+    protected constructor(input?: DeepPartial<VendureEntity>) {
+        if (input) {
+            for (const [key, value] of Object.entries(input)) {
+                this[key] = value;
+            }
+        }
+    }
+
+    @PrimaryGeneratedColumn(getEntityIdStrategy().primaryKeyType as any)
+    id: ID;
+
+    @CreateDateColumn() createdAt: string;
+
+    @UpdateDateColumn() updatedAt: string;
+}

+ 5 - 21
server/src/entity/customer/customer.entity.ts

@@ -1,27 +1,15 @@
-import {
-    Column,
-    CreateDateColumn,
-    Entity,
-    JoinColumn,
-    OneToMany,
-    OneToOne,
-    PrimaryGeneratedColumn,
-    UpdateDateColumn,
-} from 'typeorm';
+import { Column, Entity, JoinColumn, OneToMany, OneToOne } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
 import { Address } from '../address/address.entity';
+import { VendureEntity } from '../base/base.entity';
 import { User } from '../user/user.entity';
 
-@Entity('customer')
-export class Customer {
+@Entity()
+export class Customer extends VendureEntity {
     constructor(input?: DeepPartial<Customer>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     @Column() firstName: string;
 
     @Column() lastName: string;
@@ -37,8 +25,4 @@ export class Customer {
     @OneToOne(type => User, { eager: true })
     @JoinColumn()
     user?: User;
-
-    @CreateDateColumn() createdAt: string;
-
-    @UpdateDateColumn() updatedAt: string;
 }

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

@@ -1,5 +1,5 @@
 type Customer implements Node {
-  id: Int!
+  id: ID!
   firstName: String
   lastName: String
   phoneNumber: String

+ 5 - 8
server/src/entity/product-option-group/product-option-group-translation.entity.ts

@@ -1,19 +1,16 @@
-import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { Column, Entity, ManyToOne } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
 import { LanguageCode } from '../../locale/language-code';
 import { Translation } from '../../locale/locale-types';
+import { VendureEntity } from '../base/base.entity';
 import { ProductOptionGroup } from './product-option-group.entity';
 
-@Entity('product_option_group_translation')
-export class ProductOptionGroupTranslation implements Translation<ProductOptionGroup> {
+@Entity()
+export class ProductOptionGroupTranslation extends VendureEntity implements Translation<ProductOptionGroup> {
     constructor(input?: DeepPartial<Translation<ProductOptionGroup>>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     @Column() languageCode: LanguageCode;
 
     @Column() name: string;

+ 1 - 1
server/src/entity/product-option-group/product-option-group.dto.ts

@@ -8,6 +8,6 @@ export interface CreateProductOptionGroupDto extends TranslatedInput<ProductOpti
 }
 
 export interface UpdateProductOptionGroupDto extends TranslatedInput<ProductOptionGroup> {
-    id: number;
+    id: string;
     code: string;
 }

+ 5 - 12
server/src/entity/product-option-group/product-option-group.entity.ts

@@ -1,28 +1,21 @@
-import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
+import { Column, Entity, OneToMany } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
+import { VendureEntity } from '../base/base.entity';
 import { ProductOption } from '../product-option/product-option.entity';
 import { ProductOptionGroupTranslation } from './product-option-group-translation.entity';
 
-@Entity('product_option_group')
-export class ProductOptionGroup implements Translatable {
+@Entity()
+export class ProductOptionGroup extends VendureEntity implements Translatable {
     constructor(input?: DeepPartial<ProductOptionGroup>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     name: LocaleString;
 
     @Column({ unique: true })
     code: string;
 
-    @CreateDateColumn() createdAt: string;
-
-    @UpdateDateColumn() updatedAt: string;
-
     @OneToMany(type => ProductOptionGroupTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<ProductOptionGroup>>;
 

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

@@ -1,5 +1,5 @@
 type ProductOptionGroup implements Node {
-    id: Int!
+    id: ID!
     languageCode: LanguageCode
     code: String
     name: String
@@ -8,13 +8,13 @@ type ProductOptionGroup implements Node {
 }
 
 type ProductOptionGroupTranslation {
-    id: Int!
+    id: ID!
     languageCode: LanguageCode!
     name: String!
 }
 
 input ProductOptionGroupTranslationInput {
-    id: Int
+    id: ID
     languageCode: LanguageCode!
     name: String!
 }
@@ -26,7 +26,7 @@ input CreateProductOptionGroupInput {
 }
 
 input UpdateProductOptionGroupInput {
-    id: Int!
+    id: ID!
     code: String!
     translations: [ProductOptionGroupTranslationInput]!
 }

+ 5 - 8
server/src/entity/product-option/product-option-translation.entity.ts

@@ -1,19 +1,16 @@
-import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { Column, Entity, ManyToOne } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
 import { LanguageCode } from '../../locale/language-code';
 import { Translation } from '../../locale/locale-types';
+import { VendureEntity } from '../base/base.entity';
 import { ProductOption } from './product-option.entity';
 
-@Entity('product_option_translation')
-export class ProductOptionTranslation implements Translation<ProductOption> {
+@Entity()
+export class ProductOptionTranslation extends VendureEntity implements Translation<ProductOption> {
     constructor(input?: DeepPartial<Translation<ProductOption>>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     @Column() languageCode: LanguageCode;
 
     @Column() name: string;

+ 6 - 21
server/src/entity/product-option/product-option.entity.ts

@@ -1,35 +1,20 @@
-import {
-    Column,
-    CreateDateColumn,
-    Entity,
-    ManyToOne,
-    OneToMany,
-    PrimaryGeneratedColumn,
-    UpdateDateColumn,
-} from 'typeorm';
+import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
-import { LocaleString, Translatable, Translation, TranslationInput } from '../../locale/locale-types';
+import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
+import { VendureEntity } from '../base/base.entity';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
 import { ProductOptionTranslation } from './product-option-translation.entity';
 
-@Entity('product_option')
-export class ProductOption implements Translatable {
+@Entity()
+export class ProductOption extends VendureEntity implements Translatable {
     constructor(input?: DeepPartial<ProductOption>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     name: LocaleString;
 
     @Column() code: string;
 
-    @CreateDateColumn() createdAt: string;
-
-    @UpdateDateColumn() updatedAt: string;
-
     @OneToMany(type => ProductOptionTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<ProductOption>>;
 

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

@@ -1,5 +1,5 @@
 type ProductOption implements Node {
-    id: Int!
+    id: ID!
     languageCode: LanguageCode
     code: String
     name: String
@@ -7,13 +7,13 @@ type ProductOption implements Node {
 }
 
 type ProductOptionTranslation {
-    id: Int!
+    id: ID!
     languageCode: LanguageCode!
     name: String!
 }
 
 input ProductOptionTranslationInput {
-    id: Int
+    id: ID
     languageCode: LanguageCode!
     name: String!
 }

+ 5 - 8
server/src/entity/product-variant/product-variant-translation.entity.ts

@@ -1,19 +1,16 @@
-import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
 import { LanguageCode } from '../../locale/language-code';
 import { Translation } from '../../locale/locale-types';
+import { VendureEntity } from '../base/base.entity';
 import { ProductVariant } from './product-variant.entity';
 
-@Entity('product_variant_translation')
-export class ProductVariantTranslation implements Translation<ProductVariant> {
+@Entity()
+export class ProductVariantTranslation extends VendureEntity implements Translation<ProductVariant> {
     constructor(input?: DeepPartial<Translation<ProductVariant>>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     @Column() languageCode: LanguageCode;
 
     @Column() name: string;

+ 5 - 22
server/src/entity/product-variant/product-variant.entity.ts

@@ -1,30 +1,17 @@
-import {
-    Column,
-    CreateDateColumn,
-    Entity,
-    JoinTable,
-    ManyToMany,
-    ManyToOne,
-    OneToMany,
-    PrimaryGeneratedColumn,
-    UpdateDateColumn,
-} from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
+import { VendureEntity } from '../base/base.entity';
 import { ProductOption } from '../product-option/product-option.entity';
 import { Product } from '../product/product.entity';
 import { ProductVariantTranslation } from './product-variant-translation.entity';
 
-@Entity('product_variant')
-export class ProductVariant implements Translatable {
+@Entity()
+export class ProductVariant extends VendureEntity implements Translatable {
     constructor(input?: DeepPartial<ProductVariant>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     name: LocaleString;
 
     @Column() sku: string;
@@ -33,10 +20,6 @@ export class ProductVariant implements Translatable {
 
     @Column() price: number;
 
-    @CreateDateColumn() createdAt: string;
-
-    @UpdateDateColumn() updatedAt: string;
-
     @OneToMany(type => ProductVariantTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<ProductVariant>>;
 

+ 2 - 2
server/src/entity/product-variant/product-variant.graphql

@@ -1,5 +1,5 @@
 type ProductVariant implements Node {
-    id: Int!
+    id: ID!
     sku: String
     name: String
     image: String
@@ -9,7 +9,7 @@ type ProductVariant implements Node {
 }
 
 type ProductVariantTranslation {
-    id: Int!
+    id: ID!
     languageCode: LanguageCode!
     name: String!
 }

+ 5 - 8
server/src/entity/product/product-translation.entity.ts

@@ -1,19 +1,16 @@
-import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { Column, Entity, ManyToOne } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
 import { LanguageCode } from '../../locale/language-code';
 import { Translation, TranslationInput } from '../../locale/locale-types';
+import { VendureEntity } from '../base/base.entity';
 import { Product } from './product.entity';
 
-@Entity('product_translation')
-export class ProductTranslation implements Translation<Product> {
+@Entity()
+export class ProductTranslation extends VendureEntity implements Translation<Product> {
     constructor(input?: DeepPartial<TranslationInput<Product>>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     @Column() languageCode: LanguageCode;
 
     @Column() name: string;

+ 1 - 1
server/src/entity/product/product.dto.ts

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

+ 6 - 22
server/src/entity/product/product.entity.ts

@@ -1,29 +1,17 @@
-import {
-    Column,
-    CreateDateColumn,
-    Entity,
-    JoinTable,
-    ManyToMany,
-    OneToMany,
-    PrimaryGeneratedColumn,
-    UpdateDateColumn,
-} from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
 import { DeepPartial } from '../../common/common-types';
-import { LocaleString, Translatable, Translation, TranslationInput } from '../../locale/locale-types';
+import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
+import { VendureEntity } from '../base/base.entity';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
 import { ProductTranslation } from './product-translation.entity';
 
-@Entity('product')
-export class Product implements Translatable {
+@Entity()
+export class Product extends VendureEntity implements Translatable {
     constructor(input?: DeepPartial<Product>) {
-        if (input) {
-            Object.assign(this, input);
-        }
+        super(input);
     }
 
-    @PrimaryGeneratedColumn() id: number;
-
     name: LocaleString;
 
     slug: LocaleString;
@@ -32,10 +20,6 @@ export class Product implements Translatable {
 
     @Column() image: string;
 
-    @CreateDateColumn() createdAt: string;
-
-    @UpdateDateColumn() updatedAt: string;
-
     @OneToMany(type => ProductTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<Product>>;
 

+ 4 - 4
server/src/entity/product/product.graphql

@@ -1,5 +1,5 @@
 type Product implements Node {
-    id: Int!
+    id: ID!
     languageCode: LanguageCode
     name: String
     slug: String
@@ -11,7 +11,7 @@ type Product implements Node {
 }
 
 type ProductTranslation {
-    id: Int!
+    id: ID!
     languageCode: LanguageCode!
     name: String!
     slug: String!
@@ -19,7 +19,7 @@ type ProductTranslation {
 }
 
 input ProductTranslationInput {
-    id: Int
+    id: ID
     languageCode: LanguageCode!
     name: String!
     slug: String
@@ -34,7 +34,7 @@ input CreateProductInput {
 }
 
 input UpdateProductInput {
-    id: Int!
+    id: ID!
     image: String
     translations: [ProductTranslationInput]!
     optionGroupCodes: [String]

+ 8 - 9
server/src/entity/user/user.entity.ts

@@ -1,10 +1,13 @@
-import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
+import { Column, Entity } from 'typeorm';
 import { Role } from '../../auth/role';
-import { Address } from '../address/address.entity';
+import { DeepPartial } from '../../common/common-types';
+import { VendureEntity } from '../base/base.entity';
 
-@Entity('user')
-export class User {
-    @PrimaryGeneratedColumn() id: number;
+@Entity()
+export class User extends VendureEntity {
+    constructor(input?: DeepPartial<User>) {
+        super(input);
+    }
 
     @Column({ unique: true })
     identifier: string;
@@ -14,8 +17,4 @@ export class User {
     @Column('simple-array') roles: Role[];
 
     @Column() lastLogin: string;
-
-    @CreateDateColumn() createdAt: string;
-
-    @UpdateDateColumn() updatedAt: string;
 }

+ 1 - 1
server/src/entity/user/user.graphql

@@ -1,5 +1,5 @@
 type User implements Node {
-    id: Int!
+    id: ID!
     identifier: String
     passwordHash: String
     roles: [String]

+ 4 - 2
server/src/locale/locale-types.ts

@@ -1,3 +1,5 @@
+import { ID } from '../common/common-types';
+import { VendureEntity } from '../entity/base/base.entity';
 import { LanguageCode } from './language-code';
 
 /**
@@ -23,7 +25,7 @@ export interface Translatable { translations: Array<Translation<any>>; }
 export type Translation<T> =
     // Translation must include the languageCode and a reference to the base Translatable entity it is associated with
     {
-        id: number;
+        id: ID;
         languageCode: LanguageCode;
         base: T;
     } &
@@ -33,7 +35,7 @@ export type Translation<T> =
 /**
  * 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 };
+export type TranslationInput<T> = { [K in TranslatableKeys<T>]: string } & { id?: ID; languageCode: LanguageCode };
 
 /**
  * This interface defines the shape of a DTO used to create / update an entity which has one or more LocaleString

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

@@ -23,7 +23,7 @@ describe('translateEntity()', () => {
 
     beforeEach(() => {
         productTranslationEN = new ProductTranslation({
-            id: 2,
+            id: '2',
             languageCode: LanguageCode.EN,
             name: PRODUCT_NAME_EN,
             slug: '',
@@ -32,7 +32,7 @@ describe('translateEntity()', () => {
         productTranslationEN.base = { id: 1 } as any;
 
         productTranslationDE = new ProductTranslation({
-            id: 3,
+            id: '3',
             languageCode: LanguageCode.DE,
             name: PRODUCT_NAME_DE,
             slug: '',
@@ -41,7 +41,7 @@ describe('translateEntity()', () => {
         productTranslationDE.base = { id: 1 } as any;
 
         product = new Product();
-        product.id = 1;
+        product.id = '1';
         product.translations = [productTranslationEN, productTranslationDE];
     });
 
@@ -54,7 +54,7 @@ describe('translateEntity()', () => {
     it('should not overwrite translatable id with translation id', () => {
         const result = translateEntity(product, LanguageCode.EN);
 
-        expect(result).toHaveProperty('id', 1);
+        expect(result).toHaveProperty('id', '1');
     });
 
     it('should note transfer the base from the selected translation', () => {
@@ -93,7 +93,7 @@ describe('translateDeep()', () => {
     }
 
     class TestProductEntity implements Translatable {
-        id: number;
+        id: string;
         singleTestVariant: TestVariantEntity;
         singleRealVariant: ProductVariant;
         translations: Array<Translation<TestProduct>>;
@@ -104,7 +104,7 @@ describe('translateDeep()', () => {
     }
 
     class TestVariantEntity implements Translatable {
-        id: number;
+        id: string;
         singleOption: ProductOption;
         translations: Array<Translation<TestVariant>>;
     }
@@ -120,31 +120,31 @@ describe('translateDeep()', () => {
 
     beforeEach(() => {
         productTranslation = new ProductTranslation();
-        productTranslation.id = 2;
+        productTranslation.id = '2';
         productTranslation.languageCode = LANGUAGE_CODE;
         productTranslation.name = PRODUCT_NAME_EN;
 
         productOptionTranslation = new ProductOptionTranslation();
-        productOptionTranslation.id = 31;
+        productOptionTranslation.id = '31';
         productOptionTranslation.languageCode = LANGUAGE_CODE;
         productOptionTranslation.name = OPTION_NAME_EN;
 
         productOption = new ProductOption();
-        productOption.id = 3;
+        productOption.id = '3';
         productOption.translations = [productOptionTranslation];
 
         productVariantTranslation = new ProductVariantTranslation();
-        productVariantTranslation.id = 41;
+        productVariantTranslation.id = '41';
         productVariantTranslation.languageCode = LANGUAGE_CODE;
         productVariantTranslation.name = VARIANT_NAME_EN;
 
         productVariant = new ProductVariant();
-        productVariant.id = 3;
+        productVariant.id = '3';
         productVariant.translations = [productVariantTranslation];
         productVariant.options = [productOption];
 
         product = new Product();
-        product.id = 1;
+        product.id = '1';
         product.translations = [productTranslation];
         product.variants = [productVariant];
 

+ 1 - 0
server/src/locale/translate-entity.ts

@@ -45,6 +45,7 @@ export function translateEntity<T extends Translatable>(translatable: T, languag
     }
 
     const translated = { ...(translatable as any) };
+    Object.setPrototypeOf(translated, Object.getPrototypeOf(translatable));
 
     for (const [key, value] of Object.entries(translation)) {
         if (key !== 'base' && key !== 'id') {

+ 6 - 8
server/src/locale/translation-updater.spec.ts

@@ -8,22 +8,20 @@ import { TranslationUpdater } from './translation-updater';
 describe('TranslationUpdater', () => {
     describe('diff()', () => {
         const existing: ProductTranslation[] = [
-            {
-                id: 10,
+            new ProductTranslation({
+                id: '10',
                 languageCode: LanguageCode.EN,
                 name: '',
                 slug: '',
                 description: '',
-                base: 1 as any,
-            },
-            {
-                id: 11,
+            }),
+            new ProductTranslation({
+                id: '11',
                 languageCode: LanguageCode.DE,
                 name: '',
                 slug: '',
                 description: '',
-                base: 1 as any,
-            },
+            }),
         ];
 
         let entityManager: any;

+ 1 - 1
server/src/service/administrator.service.ts

@@ -15,7 +15,7 @@ export class AdministratorService {
         return this.connection.manager.find(Administrator);
     }
 
-    findOne(administratorId: number): Promise<Administrator | undefined> {
+    findOne(administratorId: string): Promise<Administrator | undefined> {
         return this.connection.manager.findOne(Administrator, administratorId);
     }
 

+ 19 - 0
server/src/service/config.service.mock.ts

@@ -0,0 +1,19 @@
+import { EntityIdStrategy, PrimaryKeyType } from '../config/entity-id-strategy';
+import { VendureEntity } from '../entity/base/base.entity';
+import { MockClass } from '../testing/testing-types';
+import { ConfigService } from './config.service';
+
+export class MockConfigService implements MockClass<ConfigService> {
+    defaultLanguageCode: jest.Mock<any>;
+    entityIdStrategy = new MockIdStrategy();
+    connectionOptions = {};
+}
+
+export const ENCODED = 'encoded';
+export const DECODED = 'decoded';
+
+export class MockIdStrategy implements EntityIdStrategy {
+    primaryKeyType = 'integer' as any;
+    encodeId = jest.fn().mockReturnValue(ENCODED);
+    decodeId = jest.fn().mockReturnValue(DECODED);
+}

+ 26 - 0
server/src/service/config.service.ts

@@ -0,0 +1,26 @@
+import { Injectable } from '@nestjs/common';
+import { ConnectionOptions } from 'typeorm';
+import { EntityIdStrategy } from '../config/entity-id-strategy';
+import { getConfig, VendureConfig } from '../config/vendure-config';
+import { LanguageCode } from '../locale/language-code';
+
+@Injectable()
+export class ConfigService implements VendureConfig {
+    get defaultLanguageCode(): LanguageCode {
+        return this.activeConfig.defaultLanguageCode;
+    }
+
+    get entityIdStrategy(): EntityIdStrategy {
+        return this.activeConfig.entityIdStrategy;
+    }
+
+    get connectionOptions(): ConnectionOptions {
+        return this.activeConfig.connectionOptions;
+    }
+
+    private activeConfig: VendureConfig;
+
+    constructor() {
+        this.activeConfig = getConfig();
+    }
+}

+ 4 - 4
server/src/service/customer.service.ts

@@ -3,7 +3,7 @@ import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 import { PasswordService } from '../auth/password.service';
 import { Role } from '../auth/role';
-import { PaginatedList } from '../common/common-types';
+import { ID, PaginatedList } from '../common/common-types';
 import { CreateAddressDto } from '../entity/address/address.dto';
 import { Address } from '../entity/address/address.entity';
 import { CreateCustomerDto } from '../entity/customer/customer.dto';
@@ -24,11 +24,11 @@ export class CustomerService {
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
 
-    findOne(userId: number): Promise<Customer | undefined> {
+    findOne(userId: string): Promise<Customer | undefined> {
         return this.connection.manager.findOne(Customer, userId);
     }
 
-    findAddressesByCustomerId(customerId: number): Promise<Address[]> {
+    findAddressesByCustomerId(customerId: ID): Promise<Address[]> {
         return this.connection
             .getRepository(Address)
             .createQueryBuilder('address')
@@ -51,7 +51,7 @@ export class CustomerService {
         return this.connection.getRepository(Customer).save(customer);
     }
 
-    async createAddress(customerId: number, createAddressDto: CreateAddressDto): Promise<Address> {
+    async createAddress(customerId: string, createAddressDto: CreateAddressDto): Promise<Address> {
         const customer = await this.connection.manager.findOne(Customer, customerId, { relations: ['addresses'] });
 
         if (!customer) {

+ 261 - 0
server/src/service/id-codec.service.spec.ts

@@ -0,0 +1,261 @@
+import { Test } from '@nestjs/testing';
+import { ConfigService } from './config.service';
+import { DECODED, ENCODED, MockConfigService } from './config.service.mock';
+import { IdCodecService } from './id-codec.service';
+
+describe('IdCodecService', () => {
+    let idCodecService: IdCodecService;
+    let configService: MockConfigService;
+
+    beforeEach(async () => {
+        const module = await Test.createTestingModule({
+            providers: [IdCodecService, { provide: ConfigService, useClass: MockConfigService }],
+        }).compile();
+
+        idCodecService = module.get(IdCodecService);
+        configService = module.get(ConfigService) as any;
+    });
+
+    describe('encode()', () => {
+        it('works with a string', () => {
+            const input = 'id';
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual(ENCODED);
+        });
+
+        it('works with a number', () => {
+            const input = 123;
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual(ENCODED);
+        });
+
+        it('works with simple entity', () => {
+            const input = { id: 'id', name: 'foo' };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({ id: ENCODED, name: 'foo' });
+        });
+
+        it('works with 2-level nested entities', () => {
+            const input = {
+                id: 'id',
+                friend: { id: 'id' },
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({
+                id: ENCODED,
+                friend: { id: ENCODED },
+            });
+        });
+
+        it('works with 3-level nested entities', () => {
+            const input = {
+                id: 'id',
+                friend: {
+                    dog: { id: 'id' },
+                },
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({
+                id: ENCODED,
+                friend: {
+                    dog: { id: ENCODED },
+                },
+            });
+        });
+
+        it('works with list of simple entities', () => {
+            const input = [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }];
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual([{ id: ENCODED, name: 'foo' }, { id: ENCODED, name: 'bar' }]);
+        });
+
+        it('does not throw with an empty list', () => {
+            const input = [];
+
+            const result = idCodecService.encode(input);
+            expect(() => idCodecService.encode(input)).not.toThrow();
+        });
+
+        it('works with nested list of simple entities', () => {
+            const input = {
+                items: [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }],
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({
+                items: [{ id: ENCODED, name: 'foo' }, { id: ENCODED, name: 'bar' }],
+            });
+        });
+
+        it('works with large and nested list', () => {
+            const length = 100;
+            const input = {
+                items: Array.from({ length }).map(() => ({
+                    id: 'id',
+                    name: { bar: 'baz' },
+                    foo: 'yo',
+                    friends: [{ id: 'id', name: { first: 'boris', id: 'id' } }],
+                })),
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result.items.length).toBe(length);
+            expect(result.items[0].id).toBe(ENCODED);
+            expect(result.items[0].friends[0].id).toBe(ENCODED);
+            expect(result.items[0].friends[0].name.id).toBe(ENCODED);
+        });
+
+        it('works with nested list of nested lists', () => {
+            const input = {
+                items: [
+                    {
+                        id: 'id',
+                        friends: [{ id: 'id' }, { id: 'id' }],
+                    },
+                    {
+                        id: 'id',
+                        friends: [{ id: 'id' }, { id: 'id' }],
+                    },
+                ],
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({
+                items: [
+                    {
+                        id: ENCODED,
+                        friends: [{ id: ENCODED }, { id: ENCODED }],
+                    },
+                    {
+                        id: ENCODED,
+                        friends: [{ id: ENCODED }, { id: ENCODED }],
+                    },
+                ],
+            });
+        });
+    });
+
+    describe('decode()', () => {
+        it('works with a string', () => {
+            const input = 'id';
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual(DECODED);
+        });
+
+        it('works with a number', () => {
+            const input = 123;
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual(DECODED);
+        });
+
+        it('works with simple entity', () => {
+            const input = { id: 'id', name: 'foo' };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({ id: DECODED, name: 'foo' });
+        });
+
+        it('works with 2-level nested entities', () => {
+            const input = {
+                id: 'id',
+                friend: { id: 'id' },
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({
+                id: DECODED,
+                friend: { id: DECODED },
+            });
+        });
+
+        it('works with 3-level nested entities', () => {
+            const input = {
+                id: 'id',
+                friend: {
+                    dog: { id: 'id' },
+                },
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({
+                id: DECODED,
+                friend: {
+                    dog: { id: DECODED },
+                },
+            });
+        });
+
+        it('works with list of simple entities', () => {
+            const input = [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }];
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual([{ id: DECODED, name: 'foo' }, { id: DECODED, name: 'bar' }]);
+        });
+
+        it('works with nested list of simple entities', () => {
+            const input = {
+                items: [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }],
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({
+                items: [{ id: DECODED, name: 'foo' }, { id: DECODED, name: 'bar' }],
+            });
+        });
+
+        it('works with large and nested list', () => {
+            const length = 100;
+            const input = {
+                items: Array.from({ length }).map(() => ({
+                    id: 'id',
+                    name: { bar: 'baz' },
+                    foo: 'yo',
+                    friends: [{ id: 'id', name: { first: 'boris', id: 'id' } }],
+                })),
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result.items.length).toBe(length);
+            expect(result.items[0].id).toBe(DECODED);
+            expect(result.items[0].friends[0].id).toBe(DECODED);
+            expect(result.items[0].friends[0].name.id).toBe(DECODED);
+        });
+
+        it('works with nested list of nested lists', () => {
+            const input = {
+                items: [
+                    {
+                        id: 'id',
+                        friends: [{ id: 'id' }, { id: 'id' }],
+                    },
+                    {
+                        id: 'id',
+                        friends: [{ id: 'id' }, { id: 'id' }],
+                    },
+                ],
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({
+                items: [
+                    {
+                        id: DECODED,
+                        friends: [{ id: DECODED }, { id: DECODED }],
+                    },
+                    {
+                        id: DECODED,
+                        friends: [{ id: DECODED }, { id: DECODED }],
+                    },
+                ],
+            });
+        });
+    });
+});

+ 91 - 0
server/src/service/id-codec.service.ts

@@ -0,0 +1,91 @@
+import { Injectable } from '@nestjs/common';
+import { ID, PaginatedList } from '../common/common-types';
+import { VendureEntity } from '../entity/base/base.entity';
+import { ConfigService } from './config.service';
+
+/**
+ * This service is responsible for encoding/decoding entity IDs according to the configured EntityIdStrategy.
+ * It should only need to be used in resolvers - the design is that once a request hits the business logic layer
+ * (ProductService etc) all entity IDs are in the form used as the primary key in the database.
+ */
+@Injectable()
+export class IdCodecService {
+    constructor(private configService: ConfigService) {}
+
+    /**
+     * Decode an id from the client into the format used as the database primary key.
+     * Acts recursively on all objects containing an "id" property.
+     */
+    decode<T extends string | number | object | undefined>(target: T): T {
+        return this.transformRecursive(target, input => this.configService.entityIdStrategy.decodeId(input));
+    }
+
+    /**
+     * Encode any entity ids according to the encode.
+     * Acts recursively on all objects containing an "id" property.
+     */
+    encode<T extends string | number | object | undefined>(target: T): T {
+        return this.transformRecursive(target, input => this.configService.entityIdStrategy.encodeId(input));
+    }
+
+    private transformRecursive<T>(target: T, transformFn: (input: any) => ID): T {
+        if (typeof target === 'string' || typeof target === 'number') {
+            return transformFn(target as any) as any;
+        }
+        this.transform(target, transformFn);
+
+        if (Array.isArray(target)) {
+            if (target.length === 0) {
+                return target;
+            }
+            const isSimpleObject = this.isSimpleObject(target[0]);
+            const isEntity = this.isEntity(target[0]);
+            if (isSimpleObject && !isEntity) {
+                return target;
+            }
+            if (isSimpleObject) {
+                const length = target.length;
+                for (let i = 0; i < length; i++) {
+                    this.transform(target[i], transformFn);
+                }
+            } else {
+                const length = target.length;
+                for (let i = 0; i < length; i++) {
+                    target[i] = this.transformRecursive(target[i], transformFn);
+                }
+            }
+        } else {
+            for (const key of Object.keys(target)) {
+                if (this.isObject(target[key])) {
+                    this.transformRecursive(target[key], transformFn);
+                }
+            }
+        }
+        return target;
+    }
+
+    private transform<T>(target: T, transformFn: (input: any) => ID): T {
+        if (this.isEntity(target)) {
+            target.id = transformFn(target.id);
+        }
+        return target;
+    }
+
+    private isSimpleObject(target: any): boolean {
+        const values = Object.values(target);
+        for (const value of values) {
+            if (this.isObject(value)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private isEntity(target: any): target is VendureEntity {
+        return target && target.hasOwnProperty('id');
+    }
+
+    private isObject(target: any): target is object {
+        return typeof target === 'object' && target != null;
+    }
+}

+ 2 - 1
server/src/service/product-option-group.service.ts

@@ -1,6 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
+import { ID } from '../common/common-types';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { ProductOptionGroupTranslation } from '../entity/product-option-group/product-option-group-translation.entity';
 import {
@@ -27,7 +28,7 @@ export class ProductOptionGroupService {
             .then(groups => groups.map(group => translateDeep(group, lang, ['options'])));
     }
 
-    findOne(id: number, lang: LanguageCode): Promise<ProductOptionGroup | undefined> {
+    findOne(id: ID, lang: LanguageCode): Promise<ProductOptionGroup | undefined> {
         return this.connection.manager
             .findOne(ProductOptionGroup, id, {
                 relations: ['options'],

+ 2 - 1
server/src/service/product-option.service.ts

@@ -1,6 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
+import { ID } from '../common/common-types';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
 import { ProductOptionTranslation } from '../entity/product-option/product-option-translation.entity';
@@ -21,7 +22,7 @@ export class ProductOptionService {
             .then(groups => groups.map(group => translateDeep(group, lang)));
     }
 
-    findOne(id: number, lang: LanguageCode): Promise<ProductOption | undefined> {
+    findOne(id: ID, lang: LanguageCode): Promise<ProductOption | undefined> {
         return this.connection.manager
             .findOne(ProductOption, id, {
                 relations: ['group'],

+ 1 - 1
server/src/service/product.service.spec.ts

@@ -113,7 +113,7 @@ describe('ProductService', () => {
             translationUpdater.applyDiff.mockReturnValue(Promise.resolve(productFromApplyDiffCall));
 
             const dto: UpdateProductDto = {
-                id: 1,
+                id: '1',
                 image: 'some-image',
                 translations: [],
             };

+ 2 - 3
server/src/service/product.service.ts

@@ -1,7 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
-import { PaginatedList } from '../common/common-types';
+import { ID, PaginatedList } from '../common/common-types';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../entity/product/product-translation.entity';
@@ -9,7 +9,6 @@ import { CreateProductDto, UpdateProductDto } from '../entity/product/product.dt
 import { Product } from '../entity/product/product.entity';
 import { LanguageCode } from '../locale/language-code';
 import { translateDeep } from '../locale/translate-entity';
-import { TranslationUpdater } from '../locale/translation-updater';
 import { TranslationUpdaterService } from '../locale/translation-updater.service';
 
 @Injectable()
@@ -39,7 +38,7 @@ export class ProductService {
             });
     }
 
-    findOne(productId: number, lang: LanguageCode): Promise<Product | undefined> {
+    findOne(productId: ID, lang: LanguageCode): Promise<Product | undefined> {
         const relations = ['variants', 'optionGroups', 'variants.options'];
 
         return this.connection.manager

+ 1 - 1
server/src/testing/testing-types.ts

@@ -1 +1 @@
-export type MockClass<T> = { [K in keyof T]: jest.Mock<any> };
+export type MockClass<T> = { [K in keyof T]: jest.Mock<any> | any };