likes
comments
collection
share

NestJS最佳实践--#6 数据关联与树形嵌套结构的分类和评论功能实现

作者站长头像
站长
· 阅读数 32

文章发布在专栏NestJS最佳实践

代码地址: github.com/slashspaces…

准备

学习目标

  • Entity实体(数据表)之间的关联关系及相应的CRUD操作

  • Tree Entity(树形嵌套实体)的crud操作

    • 分类相关接口的开发
    • 评论相关接口的开发
    • 文章模块涉及分类和评论相关接口的开发
  • 调整src/modules/business下的目录结构

Nest CLI 快速生成模板代码

# 创建分类模块
nest g res category modules/business --no-spec
# 创建评论模块
nest g res comment modules/business --no-spec
  • g: generate 缩写,生成的意思
  • res: resource 缩写,Generate a new CRUD resource
  • categorycomment: 模块名
  • modules/business: 在该目录下生成模板代码
  • —no-spec: 不生成测试文件

NestJS最佳实践--#6 数据关联与树形嵌套结构的分类和评论功能实现

而且cli已经贴心的帮我们更新了business.module.ts 文件。完美!

分类模块

  • 分类与文章 之间是多对多(many-to-may)的关系,即一篇文章属于多个分类,一个分类下有多篇文章

Entity

category.entity.ts 中添加以下字段:

@Entity('category')
export class Category {
    @PrimaryGeneratedColumn('uuid')
    id!: string;

    @Column({ comment: '分类名称' })
    name!: string;

    @Column({ comment: '分类排序', default: 0 })
    customOrder!: number;
}

多对多关联

Post与Category要建立多对多关联

// post.entity.ts
@Exclude()
@Entity('post')
export class Post {
    // ...

    @ManyToMany((type) => Category, (category) => category.posts, {
        cascade: true, // 级联
        createForeignKeyConstraints: false, // 禁用外键
    })
    @JoinTable()
    categories: Category[];
}

// category.entity.ts
@Entity('category')
export class Category {
    // ...
		
    @ManyToMany((type) => Post, (post) => post.categories, {
        createForeignKeyConstraints: false,
    })
    posts!: Post[];
}

多对多关联,将生成一张关联表:post_categories_category

+-------------+--------------+----------------------------+
|              post_categories_category                   |
+-------------+--------------+----------------------------+
| postId      | int(11)      | PRIMARY KEY                |
| categoryId  | int(11)      | PRIMARY KEY                |
+-------------+--------------+----------------------------+

Cascades级联

可以看到我们在Post一侧的@ManyToMany的第三个选项参数中加入了cascade,这会在我们操作文章时可以触发一些额外的action

它可以是一个字符串数组,支持("insert" | "update" | "remove" | "soft-remove" | "recover")[]这些action,如果设置为true而不是一个字符串数组的话,那么默认包含全部action,这些所谓的action会出现如下作用,比如

  • insert action会在你创建文章时,如果添加一些没有保存到数据库的分类,那么这些分类会自动在保存文章时被保存到数据库
  • updateaction会在你更新文章时,同样的会把没有保存到数据库的分类给保存进数据库
  • removeaction一般用在一对一或者一对多关联,比如在你删除文章时同时删除文章下的评论
  • soft-removerecover是用于软删除和恢复的,这部分内容我们会留到后面的课时再讲

同时在casecade的另一侧,比如Categoryposts的上,或者Commentpost上,他们的关联装饰器中可以设置OnDelete或者OnUpdate的操作以对应casecade,默认为CASCADE

比如我们在删除文章时,可以把评论一侧的OnDelete给设置为CASCADE,这样的话在删除文章时会自动删除它下面关联的所有评论,也可以设置成SET NULL(除非评论可以不属于一篇文章,也就是评论模型关联的文章字段可以是null),这样的话会在删除文章时,评论不会被删除

树形嵌套

什么是树形嵌套?比如下面这种:

-山东
    -济南
    -青岛
        -黄岛区
            -长江路街道
=江苏
    -南京
        -江宁区
    -苏州

关于树形嵌套的处理方案,可以参考这篇文章分层数据模型, 文章中分别列举了4种解决方案:

NestJS最佳实践--#6 数据关联与树形嵌套结构的分类和评论功能实现 其中Path enumeration物化路径)算是比较好的解决方案。下面我们就通过物化路径来处理树形嵌套。

@Tree('materialized-path')
@Entity('category')
export class Category {
    // ...
    
    @TreeParent({ onDelete: 'NO ACTION' })
    parent!: Category | null;
  
    @TreeChildren({ cascade: true })
    children!: Category[];
}
  • @Tree('materialized-path'): 构建一个物化路径树形嵌套

  • @TreeParent: 父类

    • parent!: Category | null : 顶级分类的父类为null
  • @TreeChildren: 子类

    • cascade: true : 级联

最后生成表结构如下:

NestJS最佳实践--#6 数据关联与树形嵌套结构的分类和评论功能实现

  • mpath: 代表物化路径如下图,用.分隔id,维护分类的树形嵌套结构 NestJS最佳实践--#6 数据关联与树形嵌套结构的分类和评论功能实现
  • parentId: 代表父类ID

序列化

@Exclude()
@Tree('materialized-path')
@Entity('category')
export class Category {
    @Expose()
    @PrimaryGeneratedColumn('uuid')
    id!: string;

    @Expose()
    @Column({ comment: '分类名称' })
    name!: string;

    @Expose({ groups: ['category-tree', 'category-list', 'category-detail'] })
    @Column({ comment: '分类排序', default: 0 })
    customOrder!: number;

    @Expose({ groups: ['category-detail', 'category-list'] })
    @Type(() => Category)
    @TreeParent({ onDelete: 'NO ACTION' })
    parent!: Category | null;

    @Expose({ groups: ['category-tree'] })
    @Type(() => Category)
    @TreeChildren({ cascade: true })
    children!: Category[];

    @ManyToMany((type) => Post, (post) => post.categories, {
        createForeignKeyConstraints: false,
    })
    posts!: Post[];
}

DTO

分类相关DTO

@DtoValidation({ groups: ['create'] })
export class CreateCategoryDto {
    @MaxLength(25, {
        always: true,
        message: '分类名称长度不得超过$constraint1',
    })
    @IsTreeUnique(Category, { always: true, message: '分类名称重复' })
    @IsNotEmpty({ groups: ['create'], message: '分类名称不得为空' })
    @IsOptional({ groups: ['update'] })
    name!: string;

    @Transform(({ value }) => (value === 'null' ? null : value))
    @IsExist(Category, { groups: ['create', 'update'], message: '父类ID不存在' })
    @IsUUID(undefined, { always: true, message: '父分类ID格式不正确' })
    @ValidateIf((value) => value.parent !== null && value.parent)
    @IsOptional({ always: true })
    parent?: string;

    @Transform(({ value }) => Number(value))
    @Min(0, { always: true, message: '排序值必须大于0' })
    @IsNumber(undefined, { always: true })
    @IsOptional({ always: true })
    customOrder = 0;
}
  • @IsTreeUnique: 同级别下,名称不能重复, 这是我们在章节3中自定义的校验规则
  • @IsExist(Category, { groups: ['create', 'update'], message: '父类ID不存在' }): 新增和创建时,parent必须存在
  • @ValidateIf: 内部参数的表达式返回false时,跳过校验
    • 此处用于处理定级分类的parent为null的场景,
@DtoValidation({ groups: ['update'] })
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {
    @IsExist(Category, { always: true, message: '分类ID不存在' })
    @IsUUID(undefined, { groups: ['update'], message: '分类ID格式错误' })
    @IsDefined({ groups: ['update'], message: '分类ID必须指定' })
    id!: string;
}
  • @IsExist(Category, { always: true, message: '分类ID不存在' }): 更新时id必须存在
@DtoValidation({ groups: ['query'] })
export class QueryCategoryDto extends PaginationDto {}

文章相关DTO

创建文章时,可以指定分类

@DtoValidation({ groups: ['create'] })
export class CreatePostDto {
    // ...
    
    @IsUUID(undefined, {
        each: true,
        always: true,
        message: '分类ID格式不正确',
    })
    @IsOptional({ always: true })
    categories?: string[];
}

Service

CategoryService

@Injectable()
export class CategoryService {
    constructor(private categoryRepository: CategoryRepository) {}

    async create(createCategoryDto: CreateCategoryDto) {
        const parentId = createCategoryDto.parent;
        const parent = isNil(parentId)
            ? null
            : await this.categoryRepository.findOneByOrFail({ id: parentId });
        const item = await this.categoryRepository.save({
            ...createCategoryDto,
            parent,
        });
        return item;
    }

    findTrees() {
        return this.categoryRepository.findTrees();
    }

    async findOne(id: string) {
        const result = await this.categoryRepository.findOne({
            where: { id },
            relations: ['parent'],
        });
        if (isNil(result)) {
            throw new BusinessException(`Category id ${id} does not exist`);
        }
        return result;
    }

    async update({ id, parent: parentId, ...updateCategoryDto }: UpdateCategoryDto) {
        const parent = await this.findOne(parentId);
        await this.categoryRepository.update(id, updateCategoryDto);
        const current = await this.findOne(id);
        const canUpdateParent =
            // 当前分类存在且要替换的父分类存在,且二者不相等
            (!isNil(current.parent) && !isNil(parent) && current.parent.id !== parent.id) ||
            // 当前为顶级分类降级
            (isNil(current.parent) && !isNil(parent)) ||
            // 提升为顶级分类
            (!isNil(current.parent) && isNil(parent));
        if (canUpdateParent) {
            current.parent = parent;
            await this.categoryRepository.save(current);
        }
        return current;
    }

    async remove(id: string) {
        // 当前待删除分类
        const current = await this.categoryRepository.findOneOrFail({
            where: { id },
            relations: ['parent', 'children'],
        });
        const childrens = current.children;
        // 子分类提升一级
        if (!isEmpty(childrens)) {
            const toUpdated = childrens.map((c) => {
                c.parent = current.parent;
                return c;
            });
            this.categoryRepository.save(toUpdated);
        }
        return this.categoryRepository.remove(current);
    }
}

PostService

@Injectable()
export class PostService {
    constructor(
        @InjectRepository(Post)
        private postsRepository: Repository<Post>,
        private categoryRepository: CategoryRepository,
    ) {}

    async create(createPostDto: CreatePostDto) {
        const categories = await this.categoryRepository.findBy({
            id: In(createPostDto.categories),
        });
        const post = this.postsRepository.create({ ...createPostDto, categories });
        await this.postsRepository.save({ ...createPostDto, categories });
        return post;
    }

    findAll() {
        return this.postsRepository.find();
    }

    findOne(id: string) {
        return this.postsRepository.findOne({
            where: { id },
            relations: ['categories'],
        });
    }

    async update({ id, ...updatePostDto }: UpdatePostDto) {
        const post = await this.findOne(id);

        // 更新关联表数据
        await this.postsRepository
            .createQueryBuilder('post')
            .relation(Post, 'categories')
            .of(post)
            .addAndRemove(updatePostDto.categories, post.categories ?? []);

        // 跟新文章
        await this.postsRepository.update({ id }, omit(updatePostDto, ['id', 'categories']));

        // 返回更新后的文章信息
        return this.findOne(id);
    }

    async remove(id: string) {
        const post = await this.postsRepository.findOneBy({ id });
        if (isNil(post)) {
            throw new BusinessException(`Post id ${id} not found`);
        }
        await this.postsRepository.remove(post);
    }

    /**
     * 获取分页数据
     * @param paginationDto 分页选项
     */
    paginate(paginationDto: PaginationDto) {
        return paginate(this.postsRepository, paginationDto, { relations: ['categories'] });
    }

    /**
     * 查询出分类及其后代分类下的所有文章
     * @param id 分类ID
     */
    async queryByCategory(id: string) {
        // 当前分类
        const category = await this.categoryRepository.findOneBy({ id });
        // 后代(排平)
        const flatDes = await this.categoryRepository.findDescendants(category);
        // ids
        const ids = [id, ...flatDes.map((item) => item.id)];
        return this.postsRepository
            .createQueryBuilder('post')
            .leftJoinAndSelect('post.categories', 'category')
            .where('category.id IN (:...ids)', { ids })
            .getMany();
    }
}

评论模块

  • 评论与文章 之间是多对一(many-to-one)的关系,即多个评论可以同属于一篇文章
  • 文章与评论 之间是一对多(one-to-many)的关系,即一篇文章可以拥有多个评论

Entity

comment.entity.ts中添加以下内容:

@Entity('comment')
export class Comment extends BaseEntity {
    @PrimaryGeneratedColumn('uuid')
    id!: string;

    @Column({ comment: '评论内容', type: 'longtext' })
    body!: string;

    @CreateDateColumn({ comment: '创建时间' })
    createdAt: Date;

    @UpdateDateColumn({ comment: '更新时间' })
    updatedAt: Date;

    @DeleteDateColumn()
    deletedAt: Date;
}

多对一/一对多关联

// post.entity.ts
@Exclude()
@Entity('post')
export class Post {
    // ...

    @OneToMany((type) => Comment, (comment) => comment.post, {
        createForeignKeyConstraints: false,
        cascade: true,
    })
    comments: Comment[];
}

// comment.entity.ts
@Exclude()
@Entity('comment')
export class Comment {
    // ...

    @ManyToOne((type) => Post, (post) => post.comments, {
        createForeignKeyConstraints: false,
        nullable: false, // 关联的文章不能为null
        onDelete: 'CASCADE',
        onUpdate: 'CASCADE',
    })
    post!: Post;
}

树形嵌套

@Exclude()
@Entity('comment')
export class Comment {
    // ...
    
    @TreeParent({ onDelete: 'CASCADE' })
    parent!: Comment | null;

    @TreeChildren({ cascade: true })
    children!: Comment[];
}

序列化

@Exclude()
@Tree('materialized-path')
@Entity('comment')
export class Comment extends BaseEntity {
    @Expose()
    @PrimaryGeneratedColumn('uuid')
    id!: string;

    @Expose()
    @Column({ comment: '评论内容', type: 'longtext' })
    body!: string;

    @Expose()
    @CreateDateColumn({ comment: '创建时间' })
    createdAt: Date;

    @Expose()
    @UpdateDateColumn({ comment: '更新时间' })
    updatedAt: Date;

    @Expose()
    @DeleteDateColumn()
    deletedAt: Date;

    @Expose({ groups: ['comment-detail', 'comment-list'] })
    @TreeParent({ onDelete: 'CASCADE' })
    parent!: Comment | null;

    @Expose({ groups: ['comment-tree'] })
    @TreeChildren({ cascade: true })
    children!: Comment[];

    @ManyToOne((type) => Post, (post) => post.comments, {
        createForeignKeyConstraints: false,
        nullable: false, // 关联的文章不能为null
        onDelete: 'CASCADE',
        onUpdate: 'CASCADE',
    })
    post!: Post;
}

最终将生成一张comment

NestJS最佳实践--#6 数据关联与树形嵌套结构的分类和评论功能实现

  • mpathparentId就不再重复解释
  • postId@ManyToOne 标记生成的关联字段,表示该条评论属于那篇文章

DTO

@DtoValidation({ groups: ['create'] })
export class CreateCommentDto {
    @MaxLength(1000, { message: '评论内容不能超过$constraint1个字' })
    @IsNotEmpty({ message: '评论内容不能为空' })
    body!: string;

    @Transform(({ value }) => (value === 'null' ? null : value))
    @IsExist(Comment, { groups: ['create', 'update'], message: '父类ID不存在' })
    @IsUUID(undefined, { always: true, message: '父类ID格式不正确' })
    @ValidateIf((value) => value.parent !== null && value.parent)
    @IsOptional({ always: true })
    parent?: string;

    @IsExist(Post, { groups: ['create', 'update'], message: '文章ID不存在' })
    @IsUUID(undefined, { message: '文章ID格式错误' })
    @IsDefined({ message: '文章ID必须指定' })
    post!: string;
}

@DtoValidation({ groups: ['query'] })
export class QueryCommentDto extends PaginationDto {
    @IsExist(Post, { always: true, message: '文章ID不存在' })
    @IsUUID(undefined, { message: '文章ID格式错误' })
    @IsOptional()
    post: string;
}

@DtoValidation({ groups: ['query'] })
export class QueryPostCommentDto extends PickType(QueryCommentDto, ['post']) {}


Service

@Injectable()
export class CommentService {
    constructor(
        @InjectRepository(Comment)
        private commentRepository: TreeRepository<Comment>,
        private postService: PostService,
    ) {}

    /**
     * 查找评论树
     * 1. 所有评论
     * 2. 文章评论
     */
    async findCommentsTree({ post: postId }: QueryPostCommentDto) {
        let postTopComments: Comment[] = [];

        if (!isEmpty(postId)) {
            postTopComments = await this.commentRepository
                .createQueryBuilder('comment')
                .leftJoinAndSelect('comment.post', 'post')
                .where('post.id = :postId', { postId })
                .andWhere('comment.parent IS NULL')
                .orderBy('comment.createdAt', 'DESC')
                .getMany();
        } else {
            postTopComments = await this.commentRepository.findRoots();
        }

        await Promise.all(
            postTopComments.map((comment) => this.commentRepository.findDescendantsTree(comment)),
        );
        return postTopComments;
    }

    /**
     * 查找一篇文章的评论,并分页
     */
    async pageCommentsByPost({ post: postId, limit: take, page }: QueryCommentDto) {
        const skip = (page - 1) * take;
        const [items, count] = await this.commentRepository
            .createQueryBuilder('comment')
            .leftJoinAndSelect('comment.post', 'post')
            .where('post.id = :postId', { postId })
            .andWhere('comment.parent IS NULL')
            .orderBy('comment.createdAt', 'DESC')
            .skip(skip)
            .take(take)
            .getManyAndCount();
        return {
            items,
            count,
        };
    }

    /**
     * 新增评论
     */
    async create({ body, post: postID, parent }: CreateCommentDto) {
        let parentComment: Comment = null;
        if (parent) {
            parentComment = await this.commentRepository.findOne({
                where: { id: parent },
                relations: ['post'],
            });

            if (!isNil(parentComment) && parentComment.post.id !== postID)
                throw new BusinessException(
                    'Parent comment and child comment must belong same post!',
                );
        }
        const post = await this.postService.findOne(postID);

        const comment = this.commentRepository.create({
            body,
            post,
            parent: parentComment,
        });
        return this.commentRepository.save(comment);
    }

    /**
     * 删除评论
     * @param id
     */
    async remove(id: string) {
        const comment = await this.commentRepository.findOneOrFail({ where: { id: id ?? null } });
        return this.commentRepository.remove(comment);
    }
}

业务模块目录调整

随着模块的增加,业务代码会越来越多,但是各业务模块的内部目录结构基本上保持一致,如 controller,service, repository, entiy, dto等固定目录,为了保持代码简洁,因此将结构调整为以下目录结构:

NestJS最佳实践--#6 数据关联与树形嵌套结构的分类和评论功能实现

总结

通过本章节我们学到了

  1. typeorm常见的关系

    • 多对多
    • 多对一/一对多
  2. 拥有关系的双方的级联操作

  3. 树形嵌套结构的处理

如果对文章内容有任何疑虑的地方,欢迎在评论区讨论!