简体   繁体   中英

TypeORM+nestjs: How to serialize+CRUD child entity classes using parent generics, with single controller, repository, validation on subtypes?

I've been having a tough time getting child entities to work automatically with a REST api.

I have a base class:

class Block {

    @PrimaryGeneratedColumn('uuid')
    public id: string;

    @Column()
    public type: string;

}

Then have extended this to other block types, for instance:

@Entity('sites_blocks_textblock')
class TextBlock extends Block {

    @Column()
    public text: string;

}

I made each block type their own entity so the columns would serialize to the database properly, and have validations on each property.

So... I have 10+ block types, and I am trying to avoid a separate Controller and endpoints to CRUD each block type. I would just like one BlockController, one /block endpoint, POSTing to create, and PUT on /block/:id for update, where it can infer the type of the block from the request's 'type' body parameter.

The problem is that in the request, the last @Body() parameter won't validate (request won't go through) unless I use type 'any'... because each custom block type is passing it's extra/custom properties. Otherwise I would have to use each specific Block child class as the parameter type, requiring custom methods for each type.

To achieve this I'm trying to use a custom validation Pipe and generics, where I can look at the incoming 'type' body parameter, and cast or instantiate the incoming data as a specific Block type.

Controller handler:

@Post()
@UseGuards(PrincipalGuard)
public create(@Principal() principal: User,
          @Param('siteId', ParseUUIDPipe) siteId: string,
          @Body(new BlockValidationPipe()) blockCreate: any): Promise<Block> {

    return this.blockService.create(principal.organization, siteId, blockCreate);

}

BlockValidationPipe (this is supposed to cast the incoming data object as a specific block type, and then validate it, return the incoming data object as that type):

@Injectable()
export class BlockValidationPipe implements PipeTransform<any> {
    async transform(value: any, { metatype }: ArgumentMetadata) {
        if (value.type) {
            if (value.type.id) {
                metatype = getBlockTypeFromId(value.type.id);
            }
        }

        if (!metatype || !this.toValidate(metatype)) {
            return value;
        }

                // MAGIC: ==========>
        let object = objectToBlockByType(value, value.type.id, metatype);

        const errors = await validate(object);
        if (errors.length > 0) {
            throw new BadRequestException(errors, 'Validation failed');
        }

        return object ? object : value;
    }


    private toValidate(metatype: Function): boolean {
        const types: Function[] = [String, Boolean, Number, Array, Object];
        return !types.includes(metatype);
    }
}

using this helper (but it might not be working exactly as intended, haven't gotten passing types down totally):

function castOrNull<C extends Block>(value: C, type): C | null {
    return value as typeof type;
}

export function objectToBlockByType(object, typeId, metatype) {
    switch(typeId) {
        case 'text':
            return castOrNull<TextBlock>(object, TextBlock);
        case 'avatar':
            return castOrNull<AvatarBlock>(object, AvatarBlock);
        case 'button':
            return castOrNull<ButtonBlock>(object, ButtonBlock);
                // etc....
        default:
            return castOrNull<Block>(object, Block);
    }
}

... That's all supposed to just give me a proper Block subclass instantiation for the controller to use, but I'm not sure how to pass this specific subclass type to the underlying service calls to update the specific block repositories for each entity type. Is this possible to do using generics?

For Instance, in BlockService, but I should pass the specific block type (TextBlock, ButtonBlock, etc) to the repository.save() method, so that it will serialize the sub-class types to their respective tables properly. I'm assuming this is possible to do, but someone please correct me if I'm wrong here...

Am trying to do this, where I pass the block data as its Block parent type, and try to then get its specific class type to pass to save, but it's not working...

public async create(organization: Organization, siteId: string, blockCreate: Block): Promise<Block> {

    let blockType: Type<any> = getBlockTypeFromId(blockCreate.type.id);
    console.log("create block", typeof blockCreate, blockCreate.constructor.name, blockCreate, typeof blockType, blockType);

        ///
    let r = await this.blockRepository.save<typeof blockCreate>({

        organization: organization,
        site: await this.siteService.getByIdAndOrganization(siteId, organization),
        type: await this.blockTypeService.getById(blockCreate.type.id),
        ...blockCreate

    });

    //r.data = JSON.parse(r.data);
    return r;
}

Problem here is that the 'typeof blockCreate' always returns 'object', I have to call 'blockCreate.constructor.name' to get the proper subclass block type name, but can't pass this as a type T.

So I'm wondering... is there anyway to return the subclass Type T parameter all the way from the controller helper (where it is supposed to cast and validate the subtype) to the repository so I can pass this type T to the save(entity) call... and have that commit it properly? Or is there any other way to get this type T from the object instance itself, if 'typeof block' isn't returning the specific subclass type? I don't think it is possible to do the former during compile time... ?

I'm really just trying to get subclass serialization and validation working with just hopefully one set of controller endpoints, and service layer/repository calls... Should I be looking into Partial entities?

Anyone know of any direction I can look to accomplish this?

Lets simply set two base / generic classes:

  • a db/rest service class and
  • a db/rest controller class,

each with the generic type of: <E> - for the Entity generic type.

Then you just extend them for your specific entities and supply the Entity.

Note that in essence, the whole thing is just a couple of generic wrapping classes around the functionality available from TypeOrm.

Here are the skeletons of the idea, but I tested them and they worked fine for me. (The code comes with comments).

Lets start with a generic service class with some common db / REST functions:

import { Repository, DeepPartial, SaveOptions } from "typeorm";
import { Injectable } from '@nestjs/common';


            /**
             * Provides common/general functionality for working with db data
             * via TypeOrm API.
             * 
             * see:
             *  https://github.com/typeorm/typeorm/blob/master/docs/repository-api.md
             * 
             * You can extend this service class for functionalities specific
             * to your given Entity.
             * 
             * A service is the work-horse for handling the tasks 
             * (such as fetching the data from data source / db)
             * delegated by a controller.
             * The service is injected in the controller who delegates the tasks
             * to the service for specific data sets / Entities / db tables.
             */
@Injectable()
export class DbGenService<E> {

            /**
             * @param repo 
             *    is TypeOrm repository for your given Entity <E>.
             *    (the intermediary object, which does all the work on the db end).
             */
  constructor(readonly repo: Repository<E>) {}

            /**
             * (AUX function to create entity object):
             * Creates a new entity/entities and copies all entity properties 
             * from given objects into their new entities.
             * Note that it copies only properties that are present in the entity schema.
             * @param obj 
             */
  async createE(obj): Promise<E[]> {
    return this.repo.create(obj);
  }
            /**
             * (AUX function to merge two entity objects, 1st can be set empty):
             * Merges multiple entities (or entity-like objects) into a given entity.
             *
             * @param mergeIntoEntity 
             *    the initial / source and 
             *    finally the target/resulting/merged entity
             *    Can be initilized with an empty object e.g:
             *    let e: E = {} as E;
             * @param entityLikes
             *    partial entity or an object looking like the entity
             */
  async mergeEs(mergeIntoEntity: E, ...entityLikes: DeepPartial<E>[]): Promise<E> {
    return this.repo.merge(mergeIntoEntity, ...entityLikes);
  }

            /**
             * Saves a given entity in the database.
             * If entity does not exist in the database,
             * then inserts, otherwise updates.
             */
  async saveRecord(recordEntity: E): Promise<E> {
    return await this.repo.save(recordEntity);
  }
            /**
             * Saves all given entities (array) in the database.
             * If entities do not exist in the database,
             * then inserts, otherwise updates.
             */
  async saveRecords<T extends DeepPartial<E>>(entities: T[], options?: SaveOptions): Promise<(T & E)[]> {
    return await this.repo.save(entities, options);
  }

            /**
             * Return all the records of the db table for this Entity
             */
  async getAllRecords(): Promise<E[]> {
    return await this.repo.find();
  } 

            /**
             * Return the record of the db table for this Entity
             * having 
             * @param id = id
             */
  async getRecordById(recID: number): Promise<E> {
    return await this.repo.findOne(recID);
  }


            /**
             * Deletes the records of the db table for this Entity
             * having query statement:
             * @param query = query
             */
  async deleteAllRecords(): Promise<void> {
    await this.repo.clear();
  }

            /**
             * deletes the record of the db table for this Entity
             * having 
             * @param id = id
             */
  async deleteRecord(id): Promise<void> {
    await this.repo.delete(id);
  }

  // ... + add your common db functions here 
  //      and match them with the generic controller ....
}

Next you write a generic controller that will delegate the workload to the service - matching the service functions - something like this:

import { DeepPartial } from 'typeorm';
import { Controller, Get, Query, Post, Body, Put, Param, Delete } from '@nestjs/common';
import { DbGenService } from './db-gen.service';

            /**
             * General/base controller - handles basic HTTP requests of:
             *    Get, Query, Post, Body, Put, Param, Delete.
             * 
             * Provides general/base/shared db functionality 
             * (layed out in the service class: DbGenService<E> - via TypeOrm API)
             * to exteded controllers of this DbGenController class.
             * 
             * You can use this controller as a base class for your
             * specific controllers that share the same functionalities
             * with this controller.
             * 
             * Simply extend it like this:
             * 
             * @Controller('myRoute')
             * export class MyController extends DbGenController<MyEntity> { ... }
             * 
             * the extended router than handles requests such as
             * e.g:
             *    http://localhost:3000/myRoute
             *    http://localhost:3000/myRoute/1
             * 
             * 
             */
@Controller()
export class DbGenController<E> {

            /**
             * DbGenService is the class with the generic working functions
             * behind the controller
             */
  constructor(private dbGenService: DbGenService<E>) {}

            /**
             * Saves all given entities (array) in the database.
             * If entities do not exist in the database,
             * then inserts, otherwise updates.
             */
  @Post()
  async saveRecord(@Body() dto: DeepPartial<E>) {
              // create the Entity from the DTO
    let e: E[] = await this.dbGenService.createE(dto);
    // OR:
    // let e: E = {} as E;
    // e = await this.dbGenService.mergeEs(e, dto);
    const records = await this.dbGenService.saveRecords(e);
    return records;
  }

            /**
             * Return all the records of the db table for this Entity
             */
  @Get()
  async getAllRecords(): Promise<E[]> {
    const records = await this.dbGenService.getAllRecords();
    return records;
  }

            /**
             * Return the record of the db table for this Entity
             * having 
             * @param id = id
             */
  @Get(':id')
  async getRecordById(@Param('id') id): Promise<E> {
      const records = await this.dbGenService.getRecordById(id);
      return records;
  }

            /**
             * Return the record of the db table for this Entity
             * having 
             * @param id = id
             */
  @Get()
  async getRecordByFVs(@Param('id') id): Promise<E> {
      const records = await this.dbGenService.getRecordById(id);
      return records;
  }

            /**
             * Deletes all the records of the db table for this Entity
             */
  @Delete()
  async deleteAllRecords(): Promise<void> {
      const records = await this.dbGenService.deleteAllRecords();
      return records;
  }

            /**
             * Deletes the records of the db table for this Entity
             * having query statement:
             * @param query = query
             */
  @Delete()
  async deleteRecord(@Query() query): Promise<void> {
      const records = await this.dbGenService.deleteRecord(query.ID);
      return records;
  }

            /**
             * Deletes the record of the db table for this Entity
             * having 
             * @param id = id
             */
  @Delete(':id')
  deleteRecordById(@Param('id') id): Promise<void> {
    return this.dbGenService.deleteRecord(id);
  }

}

... and now the beautiful/fun part - use these for any entity you want - example UsersEntity - service:

import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Injectable } from '@nestjs/common';
import { DbGenService } from '../../generic/db-gen.service';
import { UsersEntity } from '../../../entities/users.entity';

            /**
             * Users db records service.
             * 
             * General doc:
             * ------------
             * A db service is the work-horse for handling the tasks
             * (such as fetching the data from data source / db)
             * delegated by a controller.
             * The service is injected in the controller.
             * 
             * This service extends the usege of the common/generic
             * db taks/functions of the service class: DbGenService<E>,
             * where <E> is the given Entity type, which we we pass to the
             * DbGenService instance, reflecting so exactly the Entity
             * of this extended class - in this case the: UsersEntity
             */
@Injectable()
export class UsersService<UsersEntity> extends DbGenService<UsersEntity> {

  constructor(@InjectRepository(UsersEntity) repo: Repository<UsersEntity>) {
    super(repo);
  }

}

and now UsersEntity - controller:

import { Controller } from '@nestjs/common';
import { appCfg } from '../../../../config/app-config.service';
import { DbGenController } from '../../generic/db-gen.controller';
import { UsersEntity } from '../../../entities/users.entity';
import { UsersService } from './users.service';

            /**
             * Controller - handles HTTP requests.
             * 
             * This controller handles routes of HTTP requests with suffix:
             *    /users
             * due to the decorator:
             *    @Controller('users')
             * e.g:
             *    http://localhost:3000/users
             *    http://localhost:3000/users/1
             * 
             * This service extends the usage of the common/generic
             * db controller class: DbGenController<E>,
             * where <E> is the given Entity type, which we we pass to the
             * DbGenController instance, reflecting so exactly the Entity
             * of this extended class - in this case the: UsersEntity
             */
@Controller('users')
export class UsersController extends DbGenController<UsersEntity> {

  constructor(private usersService: UsersService<UsersEntity>) {
    super(usersService);
  }

}

... and of course, linking it toghether:

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { UsersEntity } from '../../../entities/users.entity';
import { TypeOrmModule } from '@nestjs/typeorm';

            /**
             * UsersModule is used to export the UsersService,
             * so that other modules, specifically the AuthModule, 
             * can communicate with the database to perform 
             * its user authentication functions via an access to UsersService.
             */
@Module({
  imports: [TypeOrmModule.forFeature([UsersEntity])],
  controllers: [UsersController],
  providers: [UsersService]
})
export class UsersModule {}

Similarly to the 'UsersEntity', you can now apply all the above REST functionality you place in the generic service and the generic controller to any other entity without rewriting any of it inside their controllers or services. And still, you will have the flexibility of applying specific REST / db functionalities to each entity controller/service inside their individual extended classes.

Now, remember, this is just a basic, skeleton design and needs all the other essentials, but should get you started with this kind of approach, which again may fit some and some not.

Some syntax of the REST examples comes directly from NestJs docs / website.

(TS gurus please feel free to provide improvements, suggestions, etc. especially around the decorators, where I luck experience ...)

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM