فهرست منبع

feat(server): Initial impl of customFields

Relates to #3
Michael Bromley 7 سال پیش
والد
کامیت
18001a445f

+ 3 - 0
server/dev-config.ts

@@ -20,4 +20,7 @@ export const devConfig: VendureConfig = {
         password: '',
         database: 'vendure-dev',
     },
+    customFields: {
+        Product: [{ name: 'infoUrl', type: 'string' }, { name: 'color', type: 'string' }],
+    },
 };

+ 2 - 0
server/mock-data/populate.ts

@@ -1,9 +1,11 @@
 import { devConfig } from '../dev-config';
+import { setConfig } from '../src/config/vendure-config';
 
 import { clearAllTables } from './clear-all-tables';
 import { MockDataClientService } from './mock-data-client.service';
 
 async function populate() {
+    setConfig(devConfig);
     await clearAllTables(devConfig.dbConnectionOptions);
 
     const mockDataClientService = new MockDataClientService(devConfig);

+ 6 - 4
server/src/app.module.ts

@@ -13,7 +13,8 @@ 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 { I18nRequest, I18nService } from './i18n/i18n.service';
+import { generateGraphQlCustomFieldsTypes } from './entity/graphql-custom-fields';
+import { I18nService } from './i18n/i18n.service';
 import { TranslationUpdaterService } from './locale/translation-updater.service';
 import { AdministratorService } from './service/administrator.service';
 import { ConfigService } from './service/config.service';
@@ -23,10 +24,10 @@ import { ProductOptionService } from './service/product-option.service';
 import { ProductVariantService } from './service/product-variant.service';
 import { ProductService } from './service/product.service';
 
-const connectionOptions = getConfig().dbConnectionOptions;
+const config = getConfig();
 
 @Module({
-    imports: [GraphQLModule, TypeOrmModule.forRoot(connectionOptions)],
+    imports: [GraphQLModule, TypeOrmModule.forRoot(config.dbConnectionOptions)],
     controllers: [AuthController, CustomerController],
     providers: [
         AdministratorResolver,
@@ -79,8 +80,9 @@ export class AppModule implements NestModule {
 
     createSchema() {
         const typeDefs = this.graphQLFactory.mergeTypesByPaths(__dirname + '/**/*.graphql');
+        const customFieldTypeDefs = generateGraphQlCustomFieldsTypes(config.customFields);
         return this.graphQLFactory.createSchema({
-            typeDefs,
+            typeDefs: typeDefs + customFieldTypeDefs,
             resolverValidationOptions: {
                 requireResolversForResolveType: false,
             },

+ 3 - 3
server/src/bootstrap.ts

@@ -5,7 +5,7 @@ import { getConfig, setConfig, VendureConfig } from './config/vendure-config';
 /**
  * Bootstrap the Vendure server.
  */
-export async function bootstrap(userConfig?: Partial<VendureConfig>) {
+export async function bootstrap(userConfig: Partial<VendureConfig>) {
     if (userConfig) {
         setConfig(userConfig);
     }
@@ -14,10 +14,10 @@ export async function bootstrap(userConfig?: Partial<VendureConfig>) {
     // base VendureEntity to be correctly configured with the primary key type
     // specified in the EntityIdStrategy.
     // tslint:disable-next-line:whitespace
-    const entities = await import('./entity/entities');
+    const { coreEntities } = await import('./entity/entities');
     setConfig({
         dbConnectionOptions: {
-            entities: entities.coreEntities as any,
+            entities: coreEntities as any,
         },
     });
 

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

@@ -9,6 +9,23 @@ import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
 
+export type CustomFieldType = 'string' | 'localeString' | 'int' | 'float' | 'boolean' | 'datetime';
+
+export interface CustomFieldConfig {
+    name: string;
+    type: CustomFieldType;
+}
+
+export interface CustomFields {
+    Address?: CustomFieldConfig[];
+    Customer?: CustomFieldConfig[];
+    Product?: CustomFieldConfig[];
+    ProductOption?: CustomFieldConfig[];
+    ProductOptionGroup?: CustomFieldConfig[];
+    ProductVariant?: CustomFieldConfig[];
+    User?: CustomFieldConfig[];
+}
+
 export interface VendureConfig {
     /**
      * The default languageCode of the app.
@@ -45,6 +62,10 @@ export interface VendureConfig {
      * The connection options used by TypeORM to connect to the database.
      */
     dbConnectionOptions: ConnectionOptions;
+    /**
+     * Defines custom fields which can be used to extend the built-in entities.
+     */
+    customFields?: CustomFields;
 }
 
 const defaultConfig: ReadOnlyRequired<VendureConfig> = {
@@ -57,6 +78,7 @@ const defaultConfig: ReadOnlyRequired<VendureConfig> = {
     dbConnectionOptions: {
         type: 'mysql',
     },
+    customFields: {},
 };
 
 let activeConfig = defaultConfig;

+ 6 - 1
server/src/entity/address/address.entity.ts

@@ -2,10 +2,12 @@ import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
 import { VendureEntity } from '../base/base.entity';
+import { HasCustomFields } from '../base/has-custom-fields';
+import { CustomAddressFields } from '../custom-entity-fields';
 import { Customer } from '../customer/customer.entity';
 
 @Entity()
-export class Address extends VendureEntity {
+export class Address extends VendureEntity implements HasCustomFields {
     constructor(input?: DeepPartial<Address>) {
         super(input);
     }
@@ -34,4 +36,7 @@ export class Address extends VendureEntity {
     @Column() defaultShippingAddress: boolean;
 
     @Column() defaultBillingAddress: boolean;
+
+    @Column(type => CustomAddressFields)
+    customFields: CustomAddressFields;
 }

+ 7 - 0
server/src/entity/base/has-custom-fields.ts

@@ -0,0 +1,7 @@
+/**
+ * This interface should be implemented by any entity which can be extended
+ * with custom fields.
+ */
+export interface HasCustomFields {
+    customFields: any;
+}

+ 61 - 0
server/src/entity/custom-entity-fields.ts

@@ -0,0 +1,61 @@
+import { Column, ColumnType, Entity } from 'typeorm';
+
+import { assertNever } from '../../../shared/shared-utils';
+import { CustomFields, CustomFieldType, getConfig } from '../config/vendure-config';
+
+const config = getConfig();
+
+@Entity()
+export class CustomAddressFields {}
+@Entity()
+export class CustomCustomerFields {}
+@Entity()
+export class CustomProductFields {}
+@Entity()
+export class CustomProductOptionFields {}
+@Entity()
+export class CustomProductOptionGroupFields {}
+@Entity()
+export class CustomProductVariantFields {}
+@Entity()
+export class CustomUserFields {}
+
+/**
+ * Dynamically add columns to the custom field entity based on the CustomFields config.
+ */
+function registerEntityCustomFields(entityName: keyof CustomFields, ctor: { new (): any }) {
+    const customFields = config.customFields && config.customFields[entityName];
+    if (customFields) {
+        for (const customField of customFields) {
+            const { name, type } = customField;
+            Column({ type: getColumnType(type), name })(new ctor(), name);
+        }
+    }
+}
+
+function getColumnType(type: CustomFieldType): ColumnType {
+    switch (type) {
+        case 'string':
+        case 'localeString':
+            return 'varchar';
+        case 'boolean':
+            return 'bool';
+        case 'int':
+            return 'int';
+        case 'float':
+            return 'double';
+        case 'datetime':
+            return 'datetime';
+        default:
+            assertNever(type);
+    }
+    return 'varchar';
+}
+
+registerEntityCustomFields('Address', CustomAddressFields);
+registerEntityCustomFields('Customer', CustomCustomerFields);
+registerEntityCustomFields('Product', CustomProductFields);
+registerEntityCustomFields('ProductOption', CustomProductOptionFields);
+registerEntityCustomFields('ProductOptionGroup', CustomProductOptionGroupFields);
+registerEntityCustomFields('ProductVariant', CustomProductVariantFields);
+registerEntityCustomFields('User', CustomUserFields);

+ 6 - 1
server/src/entity/customer/customer.entity.ts

@@ -3,10 +3,12 @@ import { Column, Entity, JoinColumn, OneToMany, OneToOne } from 'typeorm';
 import { DeepPartial } from '../../../../shared/shared-types';
 import { Address } from '../address/address.entity';
 import { VendureEntity } from '../base/base.entity';
+import { HasCustomFields } from '../base/has-custom-fields';
+import { CustomCustomerFields } from '../custom-entity-fields';
 import { User } from '../user/user.entity';
 
 @Entity()
-export class Customer extends VendureEntity {
+export class Customer extends VendureEntity implements HasCustomFields {
     constructor(input?: DeepPartial<Customer>) {
         super(input);
     }
@@ -26,4 +28,7 @@ export class Customer extends VendureEntity {
     @OneToOne(type => User, { eager: true })
     @JoinColumn()
     user?: User;
+
+    @Column(type => CustomCustomerFields)
+    customFields: CustomCustomerFields;
 }

+ 51 - 0
server/src/entity/graphql-custom-fields.ts

@@ -0,0 +1,51 @@
+import { assertNever } from '../../../shared/shared-utils';
+import { CustomFields, CustomFieldType } from '../config/vendure-config';
+
+/**
+ * Given a CustomFields config object, generates an SDL string extending the built-in
+ * types with a customFields property.
+ */
+export function generateGraphQlCustomFieldsTypes(customFieldConfig?: CustomFields): string {
+    if (!customFieldConfig) {
+        return '';
+    }
+
+    let customFieldTypeDefs = '';
+    for (const entityName of Object.keys(customFieldConfig)) {
+        const customEntityFields = customFieldConfig[entityName as keyof CustomFields];
+
+        if (customEntityFields) {
+            customFieldTypeDefs += `
+            type ${entityName}CustomFields {
+                ${customEntityFields.map(field => `${field.name}: ${getGraphQlType(field.type)}`).join('\n')}
+            }
+
+            extend type ${entityName} {
+                customFields: ${entityName}CustomFields
+            }
+        `;
+        }
+    }
+
+    return customFieldTypeDefs;
+}
+
+type GraphQLSDLType = 'String' | 'Int' | 'Float' | 'Boolean' | 'ID';
+
+function getGraphQlType(type: CustomFieldType): GraphQLSDLType {
+    switch (type) {
+        case 'string':
+        case 'datetime':
+        case 'localeString':
+            return 'String';
+        case 'boolean':
+            return 'Boolean';
+        case 'int':
+            return 'Int';
+        case 'float':
+            return 'Float';
+        default:
+            assertNever(type);
+    }
+    return 'String';
+}

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

@@ -3,12 +3,14 @@ import { Column, Entity, OneToMany } from 'typeorm';
 import { DeepPartial } from '../../../../shared/shared-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
+import { HasCustomFields } from '../base/has-custom-fields';
+import { CustomProductOptionGroupFields } from '../custom-entity-fields';
 import { ProductOption } from '../product-option/product-option.entity';
 
 import { ProductOptionGroupTranslation } from './product-option-group-translation.entity';
 
 @Entity()
-export class ProductOptionGroup extends VendureEntity implements Translatable {
+export class ProductOptionGroup extends VendureEntity implements Translatable, HasCustomFields {
     constructor(input?: DeepPartial<ProductOptionGroup>) {
         super(input);
     }
@@ -23,4 +25,7 @@ export class ProductOptionGroup extends VendureEntity implements Translatable {
 
     @OneToMany(type => ProductOption, option => option.group)
     options: ProductOption[];
+
+    @Column(type => CustomProductOptionGroupFields)
+    customFields: CustomProductOptionGroupFields;
 }

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

@@ -3,12 +3,14 @@ import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 import { DeepPartial } from '../../../../shared/shared-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
+import { HasCustomFields } from '../base/has-custom-fields';
+import { CustomProductOptionFields } from '../custom-entity-fields';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
 
 import { ProductOptionTranslation } from './product-option-translation.entity';
 
 @Entity()
-export class ProductOption extends VendureEntity implements Translatable {
+export class ProductOption extends VendureEntity implements Translatable, HasCustomFields {
     constructor(input?: DeepPartial<ProductOption>) {
         super(input);
     }
@@ -22,4 +24,7 @@ export class ProductOption extends VendureEntity implements Translatable {
 
     @ManyToOne(type => ProductOptionGroup, group => group.options)
     group: ProductOptionGroup;
+
+    @Column(type => CustomProductOptionFields)
+    customFields: CustomProductOptionFields;
 }

+ 6 - 1
server/src/entity/product-variant/product-variant.entity.ts

@@ -3,13 +3,15 @@ import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typ
 import { DeepPartial } from '../../../../shared/shared-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
+import { HasCustomFields } from '../base/has-custom-fields';
+import { CustomProductVariantFields } from '../custom-entity-fields';
 import { ProductOption } from '../product-option/product-option.entity';
 import { Product } from '../product/product.entity';
 
 import { ProductVariantTranslation } from './product-variant-translation.entity';
 
 @Entity()
-export class ProductVariant extends VendureEntity implements Translatable {
+export class ProductVariant extends VendureEntity implements Translatable, HasCustomFields {
     constructor(input?: DeepPartial<ProductVariant>) {
         super(input);
     }
@@ -31,4 +33,7 @@ export class ProductVariant extends VendureEntity implements Translatable {
     @ManyToMany(type => ProductOption)
     @JoinTable()
     options: ProductOption[];
+
+    @Column(type => CustomProductVariantFields)
+    customFields: CustomProductVariantFields;
 }

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

@@ -3,17 +3,18 @@ import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
 import { DeepPartial } from '../../../../shared/shared-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
+import { HasCustomFields } from '../base/has-custom-fields';
+import { CustomProductFields } from '../custom-entity-fields';
 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()
-export class Product extends VendureEntity implements Translatable {
+export class Product extends VendureEntity implements Translatable, HasCustomFields {
     constructor(input?: DeepPartial<Product>) {
         super(input);
     }
-
     name: LocaleString;
 
     slug: LocaleString;
@@ -31,4 +32,7 @@ export class Product extends VendureEntity implements Translatable {
     @ManyToMany(type => ProductOptionGroup)
     @JoinTable()
     optionGroups: ProductOptionGroup[];
+
+    @Column(type => CustomProductFields)
+    customFields: CustomProductFields;
 }

+ 6 - 1
server/src/entity/user/user.entity.ts

@@ -3,9 +3,11 @@ import { Column, Entity } from 'typeorm';
 import { DeepPartial } from '../../../../shared/shared-types';
 import { Role } from '../../auth/role';
 import { VendureEntity } from '../base/base.entity';
+import { HasCustomFields } from '../base/has-custom-fields';
+import { CustomUserFields } from '../custom-entity-fields';
 
 @Entity()
-export class User extends VendureEntity {
+export class User extends VendureEntity implements HasCustomFields {
     constructor(input?: DeepPartial<User>) {
         super(input);
     }
@@ -18,4 +20,7 @@ export class User extends VendureEntity {
     @Column('simple-array') roles: Role[];
 
     @Column() lastLogin: string;
+
+    @Column(type => CustomUserFields)
+    customFields: CustomUserFields;
 }

+ 7 - 0
shared/shared-utils.ts

@@ -6,6 +6,13 @@ export function notNullOrUndefined<T>(val: T | undefined | null): val is T {
     return val !== undefined && val !== null;
 }
 
+/**
+ * Used in exhaustiveness checks to assert a codepath should never be reached.
+ */
+export function assertNever(value: never): never {
+    throw new Error(`Expected never, got ${typeof value}`);
+}
+
 /**
  * Given an array of option arrays `[['red, 'blue'], ['small', 'large']]`, this method returns a new array
  * containing all the combinations of those options: