NestJS最佳实践--#6 数据关联与树形嵌套结构的分类和评论功能实现
文章发布在专栏NestJS最佳实践中
代码地址: github.com/slashspaces…
准备
-
通读typeorm官网relations 章节
-
通读typeorm官网 tree-entities 章节
学习目标
-
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 resourcecategory
、comment
: 模块名modules/business
: 在该目录下生成模板代码—no-spec
: 不生成测试文件
而且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[];
}
- @JoinTable(): 多对多关联时,必须有且只有一方声明@JoinTable(), 以示关系的拥有者
- createForeignKeyConstraints: false: 禁用外键
多对多关联,将生成一张关联表: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会在你创建文章时,如果添加一些没有保存到数据库的分类,那么这些分类会自动在保存文章时被保存到数据库
update
action会在你更新文章时,同样的会把没有保存到数据库的分类给保存进数据库
remove
action一般用在一对一或者一对多关联,比如在你删除文章时同时删除文章下的评论
soft-remove
和recover
是用于软删除和恢复的,这部分内容我们会留到后面的课时再讲
同时在casecade
的另一侧,比如Category
的posts
的上,或者Comment
的post
上,他们的关联装饰器中可以设置OnDelete
或者OnUpdate
的操作以对应casecade
,默认为CASCADE
比如我们在删除文章时,可以把评论一侧的OnDelete
给设置为CASCADE
,这样的话在删除文章时会自动删除它下面关联的所有评论,也可以设置成SET NULL
(除非评论可以不属于一篇文章,也就是评论模型关联的文章字段可以是null
),这样的话会在删除文章时,评论不会被删除
树形嵌套
什么是树形嵌套?比如下面这种:
-山东
-济南
-青岛
-黄岛区
-长江路街道
=江苏
-南京
-江宁区
-苏州
关于树形嵌套的处理方案,可以参考这篇文章分层数据模型, 文章中分别列举了4种解决方案:
其中
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
: 级联
最后生成表结构如下:
mpath
: 代表物化路径
如下图,用.
分隔id,维护分类的树形嵌套结构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
表
mpath
和parentId
就不再重复解释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等固定目录,为了保持代码简洁,因此将结构调整为以下目录结构:
总结
通过本章节我们学到了
-
typeorm常见的关系
- 多对多
- 多对一/一对多
-
拥有关系的双方的级联操作
-
树形嵌套结构的处理
如果对文章内容有任何疑虑的地方,欢迎在评论区讨论!
转载自:https://juejin.cn/post/7214726637785579576