From 4ec73cc0e7de6013bbea7da281f9c6ae8bc5e195 Mon Sep 17 00:00:00 2001 From: xidongdong-153 Date: Mon, 18 Dec 2023 17:09:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=95=B0=E6=8D=AE=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8A=BD=E8=B1=A1=E5=8C=96=20-=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=9B=B4=E7=AE=80=E6=B4=81=E4=BA=86=20-=20=E5=80=9F?= =?UTF-8?q?=E9=89=B4=E4=BA=86https://git.3rcd.com/classroom/nestjs/pulls/5?= =?UTF-8?q?=20=E6=9B=B4=E6=96=B0=E4=BA=86Meilisearch=E8=BD=AF=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/content/content.module.ts | 3 + .../content/entities/comment.entity.ts | 12 +- src/modules/content/entities/post.entity.ts | 4 +- .../repositories/category.repository.ts | 164 +---------- .../repositories/comment.repository.ts | 144 ++------- .../content/repositories/post.repository.ts | 7 +- .../content/repositories/tag.repository.ts | 7 +- .../content/services/category.service.ts | 81 +---- .../content/services/comment.service.ts | 70 ++--- src/modules/content/services/post.service.ts | 58 ++-- .../content/services/search.service.ts | 79 ++--- src/modules/content/services/tag.service.ts | 102 +------ .../content/subscribers/post.subscriber.ts | 13 +- src/modules/database/base/index.ts | 4 + src/modules/database/base/repository.ts | 46 +++ src/modules/database/base/service.ts | 215 ++++++++++++++ src/modules/database/base/subcriber.ts | 91 ++++++ src/modules/database/base/tree.repository.ts | 276 ++++++++++++++++++ src/modules/database/constants.ts | 17 ++ src/modules/database/helpers.ts | 45 ++- src/modules/database/types.ts | 65 ++++- 21 files changed, 939 insertions(+), 564 deletions(-) create mode 100644 src/modules/database/base/index.ts create mode 100644 src/modules/database/base/repository.ts create mode 100644 src/modules/database/base/service.ts create mode 100644 src/modules/database/base/subcriber.ts create mode 100644 src/modules/database/base/tree.repository.ts diff --git a/src/modules/content/content.module.ts b/src/modules/content/content.module.ts index 0b7ee3e..3ae05c1 100644 --- a/src/modules/content/content.module.ts +++ b/src/modules/content/content.module.ts @@ -32,6 +32,7 @@ export class ContentModule { repositories.CategoryRepository, services.CategoryService, repositories.TagRepository, + repositories.CommentRepository, { token: services.SearchService, optional: true }, ], useFactory( @@ -39,6 +40,7 @@ export class ContentModule { categoryRepository: repositories.CategoryRepository, categoryService: services.CategoryService, tagRepository: repositories.TagRepository, + commentRepository: repositories.CommentRepository, searchService: services.SearchService, ) { return new PostService( @@ -46,6 +48,7 @@ export class ContentModule { categoryRepository, categoryService, tagRepository, + commentRepository, searchService, config.searchType, ); diff --git a/src/modules/content/entities/comment.entity.ts b/src/modules/content/entities/comment.entity.ts index 7caffad..a48fa7b 100644 --- a/src/modules/content/entities/comment.entity.ts +++ b/src/modules/content/entities/comment.entity.ts @@ -1,8 +1,9 @@ -import { Exclude, Expose } from 'class-transformer'; +import { Exclude, Expose, Type } from 'class-transformer'; import { BaseEntity, Column, CreateDateColumn, + DeleteDateColumn, Entity, Index, ManyToOne, @@ -46,10 +47,17 @@ export class CommentEntity extends BaseEntity { depth = 0; @Expose({ groups: ['comment-detail', 'comment-list'] }) - @TreeParent({ onDelete: 'NO ACTION' }) + @TreeParent({ onDelete: 'CASCADE' }) parent: Relation | null; @Expose({ groups: ['comment-tree'] }) @TreeChildren({ cascade: true }) children: Relation; + + @Expose() + @Type(() => Date) + @DeleteDateColumn({ + comment: '删除时间', + }) + deletedAt: Date; } diff --git a/src/modules/content/entities/post.entity.ts b/src/modules/content/entities/post.entity.ts index 9aa1a52..6272082 100644 --- a/src/modules/content/entities/post.entity.ts +++ b/src/modules/content/entities/post.entity.ts @@ -102,9 +102,7 @@ export class PostEntity extends BaseEntity { category: Relation; @Expose() - @ManyToMany(() => TagEntity, (tag) => tag.posts, { - cascade: true, - }) + @ManyToMany(() => TagEntity, (tag) => tag.posts) @JoinTable() tags: Relation[]; diff --git a/src/modules/content/repositories/category.repository.ts b/src/modules/content/repositories/category.repository.ts index a7b0437..3bdfc12 100644 --- a/src/modules/content/repositories/category.repository.ts +++ b/src/modules/content/repositories/category.repository.ts @@ -1,165 +1,13 @@ -import { pick, unset } from 'lodash'; -import { FindOptionsUtils, FindTreeOptions, TreeRepository } from 'typeorm'; - import { CategoryEntity } from '@/modules/content/entities'; +import { BaseTreeRepository } from '@/modules/database/base'; +import { OrderType, TreeChildrenResolve } from '@/modules/database/constants'; import { CustomRepository } from '@/modules/database/decorators'; @CustomRepository(CategoryEntity) -export class CategoryRepository extends TreeRepository { - /** - * 构建基础查询器 - */ - buildBaseQB() { - return this.createQueryBuilder('category').leftJoinAndSelect('category.parent', 'parent'); - } +export class CategoryRepository extends BaseTreeRepository { + protected _qbName = 'category'; - /** - * 树形结构查询 - * @param options - */ - async findTrees( - options?: FindTreeOptions & { - onlyTrashed?: boolean; - withTrashed?: boolean; - }, - ) { - const roots = await this.findRoots(options); - await Promise.all(roots.map((root) => this.findDescendantsTree(root, options))); - return roots; - } + protected orderBy = { name: 'customOrder', order: OrderType.ASC }; - /** - * 查询顶级分类 - * @param options - */ - findRoots( - options?: FindTreeOptions & { - onlyTrashed?: boolean; - withTrashed?: boolean; - }, - ) { - const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias); - const escapeColumn = (column: string) => this.manager.connection.driver.escape(column); - - const joinColumn = this.metadata.treeParentRelation!.joinColumns[0]; - const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName; - const qb = this.buildBaseQB().orderBy('category.customOrder', 'ASC'); - qb.where(`${escapeAlias('category')}.${escapeColumn(parentPropertyName)} IS NULL`); - - FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth'])); - - if (options?.withTrashed) { - qb.withDeleted(); - if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`); - } - - return qb.getMany(); - } - - /** - * 查询后代分类 - * @param entity - * @param options - */ - findDescendants( - entity: CategoryEntity, - options?: FindTreeOptions & { - onlyTrashed?: boolean; - withTrashed?: boolean; - }, - ) { - const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity); - FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); - qb.orderBy('category.customOrder', 'ASC'); - - if (options?.withTrashed) { - qb.withDeleted(); - if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`); - } - - return qb.getMany(); - } - - /** - * 查询祖先分类 - * @param entity - * @param options - */ - findAncestors( - entity: CategoryEntity, - options?: FindTreeOptions & { - onlyTrashed?: boolean; - withTrashed?: boolean; - }, - ) { - const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity); - FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); - qb.orderBy('category.customOrder', 'ASC'); - - if (options?.withTrashed) { - qb.withDeleted(); - if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`); - } - - return qb.getMany(); - } - - /** - * 打平并展开树 - * @param trees - * @param depth - * @param parent - */ - async toFlatTrees(trees: CategoryEntity[], depth = 0, parent: CategoryEntity | null = null) { - const data: Omit[] = []; - - for (const tree of trees) { - tree.depth = depth; - tree.parent = parent; - const { children } = tree; - unset(tree, 'children'); - data.push(tree); - data.push(...(await this.toFlatTrees(children, depth + 1, tree))); - } - - return data as CategoryEntity[]; - } - - /** - * 统计后代元素数量 - * @param entity - * @param options - */ - async countDescendants( - entity: CategoryEntity, - options?: { withTrashed?: boolean; onlyTrashed?: boolean }, - ) { - const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity); - - if (options?.withTrashed) { - qb.withDeleted(); - if (options?.onlyTrashed) qb.where(`category.deleteAt IS NOT NULL`); - } - - return qb.getCount(); - } - - /** - * 统计祖先元素数量 - * @param entity - * @param options - */ - async countAncestors( - entity: CategoryEntity, - options?: { withTrashed?: boolean; onlyTrashed?: boolean }, - ) { - const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity); - - if (options?.withTrashed) { - qb.withDeleted(); - if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`); - } - - return qb.getCount(); - } + protected _childrenResolve = TreeChildrenResolve.UP; } diff --git a/src/modules/content/repositories/comment.repository.ts b/src/modules/content/repositories/comment.repository.ts index b5f6b4a..a271b7d 100644 --- a/src/modules/content/repositories/comment.repository.ts +++ b/src/modules/content/repositories/comment.repository.ts @@ -1,137 +1,29 @@ -import { pick, unset } from 'lodash'; -import { - FindOptionsUtils, - FindTreeOptions, - SelectQueryBuilder, - TreeRepository, - TreeRepositoryUtils, -} from 'typeorm'; +import { isNil } from 'lodash'; +import { FindTreeOptions, SelectQueryBuilder } from 'typeorm'; import { CommentEntity } from '@/modules/content/entities'; +import { BaseTreeRepository } from '@/modules/database/base'; import { CustomRepository } from '@/modules/database/decorators'; - -type FindCommentTreeOptions = FindTreeOptions & { - addQuery?: (query: SelectQueryBuilder) => SelectQueryBuilder; -}; +import { QueryParams } from '@/modules/database/types'; @CustomRepository(CommentEntity) -export class CommentRepository extends TreeRepository { - /** - * 构建基础查询器 - */ +export class CommentRepository extends BaseTreeRepository { + protected _qbName = 'comment'; + + protected orderBy = 'createdAt'; + buildBaseQB(qb: SelectQueryBuilder): SelectQueryBuilder { - return qb - .leftJoinAndSelect(`comment.parent`, 'parent') - .leftJoinAndSelect(`comment.post`, 'post') - .orderBy('comment.createdAt', 'DESC'); + return super.buildBaseQB(qb).leftJoinAndSelect(`${this.qbName}.post`, 'post'); } - /** - * 查询树 - * @param options - */ - async findTrees(options: FindCommentTreeOptions = {}) { - options.relations = ['parent', 'children']; - - const roots = await this.findRoots(options); - await Promise.all(roots.map((root) => this.findDescendantsTree(root, options))); - - return roots; - } - - /** - * 查询顶级评论 - * @param options - */ - findRoots(options: FindCommentTreeOptions = {}) { - const { addQuery, ...rest } = options; - const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias); - const escapeColumn = (column: string) => this.manager.connection.driver.escape(column); - - const joinColumn = this.metadata.treeParentRelation!.joinColumns[0]; - const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName; - - let qb = this.buildBaseQB(this.createQueryBuilder('comment')); - FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, rest); - qb.where(`${escapeAlias('comment')}.${escapeColumn(parentPropertyName)} IS NULL`); - qb = addQuery ? addQuery(qb) : qb; - - return qb.getMany(); - } - - /** - * 创建后代查询器 - * @param closureTableAlias - * @param entity - * @param options - */ - createDtsQueryBuilder( - closureTableAlias: string, - entity: CommentEntity, - options: FindCommentTreeOptions = {}, - ): SelectQueryBuilder { - const { addQuery } = options; - const qb = this.buildBaseQB( - super.createDescendantsQueryBuilder('comment', closureTableAlias, entity), - ); - - return addQuery ? addQuery(qb) : qb; - } - - /** - * 查询后代树 - * @param entity - * @param options - */ - async findDescendantsTree( - entity: CommentEntity, - options: FindCommentTreeOptions = {}, - ): Promise { - const qb: SelectQueryBuilder = this.createDtsQueryBuilder( - 'treeClosure', - entity, - options, - ); - FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth'])); - - const entities = await qb.getRawAndEntities(); - const relationMaps = TreeRepositoryUtils.createRelationMaps( - this.manager, - this.metadata, - 'comment', - entities.raw, - ); - - TreeRepositoryUtils.buildChildrenEntityTree( - this.metadata, - entity, - entities.entities, - relationMaps, - { - depth: -1, - ...pick(options, ['relations']), + async findTrees( + options: FindTreeOptions & QueryParams & { post?: string } = {}, + ): Promise { + return super.findTrees({ + ...options, + addQuery: async (qb) => { + return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post }); }, - ); - - return entity; - } - - /** - * 打平并展开树 - * @param trees - * @param depth - */ - async toFlatTrees(trees: CommentEntity[], depth = 0) { - const data: Omit[] = []; - - for (const tree of trees) { - tree.depth = depth; - const { children } = tree; - unset(tree, 'children'); - data.push(tree); - data.push(...(await this.toFlatTrees(children, depth + 1))); - } - - return data as CommentEntity[]; + }); } } diff --git a/src/modules/content/repositories/post.repository.ts b/src/modules/content/repositories/post.repository.ts index eff5608..77085ad 100644 --- a/src/modules/content/repositories/post.repository.ts +++ b/src/modules/content/repositories/post.repository.ts @@ -1,10 +1,11 @@ -import { Repository } from 'typeorm'; - import { CommentEntity, PostEntity } from '@/modules/content/entities'; +import { BaseRepository } from '@/modules/database/base'; import { CustomRepository } from '@/modules/database/decorators'; @CustomRepository(PostEntity) -export class PostRepository extends Repository { +export class PostRepository extends BaseRepository { + protected _qbName = 'post'; + buildBaseQB() { // 在查询之前先查询出评论数量在添加到commentCount字段上 return this.createQueryBuilder('post') diff --git a/src/modules/content/repositories/tag.repository.ts b/src/modules/content/repositories/tag.repository.ts index ada14a3..4f1317f 100644 --- a/src/modules/content/repositories/tag.repository.ts +++ b/src/modules/content/repositories/tag.repository.ts @@ -1,10 +1,11 @@ -import { Repository } from 'typeorm'; - import { PostEntity, TagEntity } from '@/modules/content/entities'; +import { BaseRepository } from '@/modules/database/base'; import { CustomRepository } from '@/modules/database/decorators'; @CustomRepository(TagEntity) -export class TagRepository extends Repository { +export class TagRepository extends BaseRepository { + protected _qbName = 'tag'; + buildBaseQB() { return this.createQueryBuilder('tag') .leftJoinAndSelect('tag.posts', 'posts') diff --git a/src/modules/content/services/category.service.ts b/src/modules/content/services/category.service.ts index 3a542f4..0262be4 100644 --- a/src/modules/content/services/category.service.ts +++ b/src/modules/content/services/category.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { isNil, omit } from 'lodash'; -import { EntityNotFoundError, In } from 'typeorm'; +import { EntityNotFoundError } from 'typeorm'; import { CreateCategoryDto, @@ -11,6 +11,7 @@ import { } from '@/modules/content/dtos'; import { CategoryEntity } from '@/modules/content/entities'; import { CategoryRepository } from '@/modules/content/repositories'; +import { BaseService } from '@/modules/database/base'; import { SelectTrashMode } from '@/modules/database/constants'; import { treePaginate } from '@/modules/database/helpers'; @@ -18,15 +19,18 @@ import { treePaginate } from '@/modules/database/helpers'; * 分类数据操作 */ @Injectable() -export class CategoryService { - constructor(protected repository: CategoryRepository) {} +export class CategoryService extends BaseService { + protected enableTrash = true; + + constructor(protected repository: CategoryRepository) { + super(repository); + } /** * 查询分类树 */ async findTrees(options: QueryCategoryTreeDto) { const { trashed = SelectTrashMode.NONE } = options; - return this.repository.findTrees({ withTrashed: trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY, onlyTrashed: trashed === SelectTrashMode.ONLY, @@ -39,13 +43,11 @@ export class CategoryService { */ async paginate(options: QueryCategoryDto) { const { trashed = SelectTrashMode.NONE } = options; - const tree = await this.repository.findTrees({ withTrashed: trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY, onlyTrashed: trashed === SelectTrashMode.ONLY, }); const data = await this.repository.toFlatTrees(tree); - return treePaginate(options, data); } @@ -69,7 +71,6 @@ export class CategoryService { ...data, parent: await this.getParent(undefined, data.parent), }); - return this.detail(item.id); } @@ -79,58 +80,24 @@ export class CategoryService { */ async update(data: UpdateCategoryDto) { await this.repository.update(data.id, omit(data, ['id', 'parent'])); - const item = await this.detail(data.id); + await this.detail(data.id); + const item = await this.repository.findOneOrFail({ + where: { id: data.id }, + relations: ['parent'], + }); const parent = await this.getParent(item.parent?.id, data.parent); - const sholdUpdateParent = + const shouldUpdateParent = (!isNil(item.parent) && !isNil(parent) && item.parent.id !== parent.id) || (isNil(item.parent) && !isNil(parent)) || (!isNil(item.parent) && isNil(parent)); - // 父分类单独更新 - if (parent !== undefined && sholdUpdateParent) { + if (parent !== undefined && shouldUpdateParent) { item.parent = parent; await this.repository.save(item, { reload: true }); } - return item; } - /** - * 删除分类 - * @param id - */ - async delete(ids: string[], trash?: boolean) { - const items = await this.repository.find({ - where: { id: In(ids) }, - withDeleted: true, - relations: ['parent', 'children'], - }); - - // 把子分类提升一级 - for (const item of items) { - if (!isNil(item.children) && item.children.length > 0) { - const nchildren = [...item.children].map((c) => { - c.parent = item.parent; - return c; - }); - - await this.repository.save(nchildren); - } - } - - if (trash) { - const directs = items.filter((item) => !isNil(item.deletedAt)); - const sorts = items.filter((item) => isNil(item.deletedAt)); - - return [ - ...(await this.repository.remove(directs)), - ...(await this.repository.softRemove(sorts)), - ]; - } - - return this.repository.remove(items); - } - /** * 获取请求传入的父分类 * @param current 当前分类的ID @@ -150,22 +117,4 @@ export class CategoryService { } return parent; } - - /** - * 恢复分类 - * @param ids - */ - async restore(ids: string[]) { - const items = await this.repository.find({ - where: { id: In(ids) } as any, - withDeleted: true, - }); - - const trasheds = items.filter((item) => !isNil(item)).map((item) => item.id); - if (trasheds.length < 1) return []; - await this.repository.restore(trasheds); - const qb = this.repository.buildBaseQB(); - qb.andWhereInIds(trasheds); - return qb.getMany(); - } } diff --git a/src/modules/content/services/comment.service.ts b/src/modules/content/services/comment.service.ts index 725820c..450b3d0 100644 --- a/src/modules/content/services/comment.service.ts +++ b/src/modules/content/services/comment.service.ts @@ -2,22 +2,24 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { isNil } from 'lodash'; -import { EntityNotFoundError, In, SelectQueryBuilder } from 'typeorm'; +import { EntityNotFoundError, SelectQueryBuilder } from 'typeorm'; import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos'; import { CommentEntity } from '@/modules/content/entities'; import { CommentRepository, PostRepository } from '@/modules/content/repositories'; -import { treePaginate } from '@/modules/database/helpers'; +import { BaseService } from '@/modules/database/base'; /** * 评论数据操作 */ @Injectable() -export class CommentService { +export class CommentService extends BaseService { constructor( protected repository: CommentRepository, protected postRepository: PostRepository, - ) {} + ) { + super(repository); + } /** * 直接查询评论树 @@ -25,7 +27,7 @@ export class CommentService { */ async findTrees(options: QueryCommentTreeDto = {}) { return this.repository.findTrees({ - addQuery: (qb) => { + addQuery: async (qb) => { return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post }); }, }); @@ -35,28 +37,17 @@ export class CommentService { * 查找一篇文章的评论并分页 * @param dto */ - async paginate(dto: QueryCommentDto) { - const { post, ...query } = dto; + async paginate(options: QueryCommentDto) { + const { post } = options; const addQuery = (qb: SelectQueryBuilder) => { const condition: Record = {}; if (!isNil(post)) condition.post = post; return Object.keys(condition).length > 0 ? qb.andWhere(condition) : qb; }; - - const data = await this.repository.findRoots({ addQuery }); - - let comments: CommentEntity[] = []; - - for (let i = 0; i < data.length; i++) { - const c = data[i]; - comments.push( - await this.repository.findDescendantsTree(c, { - addQuery, - }), - ); - } - comments = await this.repository.toFlatTrees(comments); - return treePaginate(query, comments); + return super.paginate({ + ...options, + addQuery, + }); } /** @@ -66,32 +57,20 @@ export class CommentService { */ async create(data: CreateCommentDto) { const parent = await this.getParent(undefined, data.parent); - if (!isNil(parent) && parent.post.id !== data.post) { throw new ForbiddenException('Parent comment and child comment must belong same post!'); } - const item = await this.repository.save({ ...data, parent, post: await this.getPost(data.post), }); - return this.repository.findOneOrFail({ where: { id: item.id } }); } - /** - * 删除评论 - * @param ids - */ - async delete(ids: string[]) { - const comments = await this.repository.find({ where: { id: In(ids) } }); - return this.repository.remove(comments); - } - /** * 获取评论所属文章实例 - * @parem id + * @param id */ protected async getPost(id: string) { return !isNil(id) ? this.postRepository.findOneOrFail({ where: { id } }) : id; @@ -100,24 +79,21 @@ export class CommentService { /** * 获取请求传入的父分类 * @param current 当前分类的ID - * @param parentId + * @param id */ - protected async getParent(current?: string, parentId?: string) { - if (current === parentId) return undefined; + protected async getParent(current?: string, id?: string) { + if (current === id) return undefined; let parent: CommentEntity | undefined; - if (parentId !== undefined) { - if (parentId === null) return null; + if (id !== undefined) { + if (id === null) return null; parent = await this.repository.findOne({ relations: ['parent', 'post'], - where: { id: parentId }, + where: { id }, }); - if (!parent) - throw new EntityNotFoundError( - CommentEntity, - `Parent comment ${parentId} not exists !`, - ); + if (!parent) { + throw new EntityNotFoundError(CommentEntity, `Parent comment ${id} not exists!`); + } } - return parent; } } diff --git a/src/modules/content/services/post.service.ts b/src/modules/content/services/post.service.ts index 1f8728b..09e908d 100644 --- a/src/modules/content/services/post.service.ts +++ b/src/modules/content/services/post.service.ts @@ -7,11 +7,17 @@ import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeor import { PostOrderType } from '@/modules/content/constants'; import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos'; import { PostEntity } from '@/modules/content/entities'; -import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories'; +import { + CategoryRepository, + CommentRepository, + PostRepository, + TagRepository, +} from '@/modules/content/repositories'; import { CategoryService } from '@/modules/content/services/category.service'; import { SearchService } from '@/modules/content/services/search.service'; import { SearchType } from '@/modules/content/types'; +import { BaseService } from '@/modules/database/base'; import { SelectTrashMode } from '@/modules/database/constants'; import { paginate } from '@/modules/database/helpers'; import { QueryHook } from '@/modules/database/types'; @@ -20,16 +26,24 @@ type FindParams = { [key in keyof Omit]: QueryPostDto[key]; }; +/** + * 文章数据操作 + */ @Injectable() -export class PostService { +export class PostService extends BaseService { + protected enableTrash = true; + constructor( protected repository: PostRepository, protected categoryRepository: CategoryRepository, protected categoryService: CategoryService, protected tagRepository: TagRepository, + protected commentRepository: CommentRepository, protected searchService?: SearchService, protected search_type: SearchType = 'against', - ) {} + ) { + super(repository); + } /** * 获取分页数据 @@ -41,7 +55,7 @@ export class PostService { return this.searchService.search( options.search, pick(options, ['trashed', 'page', 'limit']), - ); + ) as any; } const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback); return paginate(qb, options); @@ -80,9 +94,7 @@ export class PostService { : [], }; const item = await this.repository.save(createPostDto); - if (!isNil(this.searchService)) await this.searchService.create(item); - return this.detail(item.id); } @@ -92,16 +104,14 @@ export class PostService { */ async update(data: UpdatePostDto) { const post = await this.detail(data.id); - if (data.category !== undefined) { // 更新分类 const category = isNil(data.category) ? null : await this.categoryRepository.findOneByOrFail({ id: data.category }); post.category = category; - this.repository.save(post, { reload: true }); + await this.repository.save(post); } - if (isArray(data.tags)) { // 更新文章关联标签 await this.repository @@ -110,11 +120,9 @@ export class PostService { .of(post) .addAndRemove(data.tags, post.tags ?? []); } - await this.repository.update(data.id, omit(data, ['id', 'tags', 'category'])); const result = await this.detail(data.id); if (!isNil(this.searchService)) await this.searchService.update([post]); - return result; } @@ -126,25 +134,27 @@ export class PostService { const items = await this.repository.find({ where: { id: In(ids) }, withDeleted: true, + relations: ['category', 'comments', 'tags'], }); - let result: PostEntity[] = []; if (trash) { // 对已软删除的数据再次删除时直接通过remove方法从数据库中清除 const directs = items.filter((item) => !isNil(item.deletedAt)); + // 需要记录直接删除的ids,因为remove(directs)会使directs的id为undefined,再次删除meili数据会有问题!!! + const directIds = directs.map(({ id }) => id); const softs = items.filter((item) => isNil(item.deletedAt)); result = [ ...(await this.repository.remove(directs)), ...(await this.repository.softRemove(softs)), ]; if (!isNil(this.searchService)) { - await this.searchService.delete(directs.map(({ id }) => id)); + await this.searchService.delete(directIds); await this.searchService.update(softs); } } else { result = await this.repository.remove(items); if (!isNil(this.searchService)) { - await this.searchService.delete(result.map(({ id }) => id)); + await this.searchService.delete(ids); } } return result; @@ -163,6 +173,14 @@ export class PostService { const trasheds = items.filter((item) => !isNil(item)).map((item) => item.id); if (trasheds.length < 1) return []; await this.repository.restore(trasheds); + + for (const post of trasheds) { + await this.commentRepository.restore({ + post: { id: post }, + deletedAt: Not(IsNull()), + }); + } + const qb = await this.buildListQuery(this.repository.buildBaseQB(), {}, async (qbuilder) => qbuilder.andWhereInIds(trasheds), ); @@ -181,7 +199,7 @@ export class PostService { callback?: QueryHook, ) { const { category, tag, orderBy, isPublished, trashed = SelectTrashMode.NONE } = options; - + // 是否查询回收站 if (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) { qb.withDeleted(); if (trashed === SelectTrashMode.ONLY) qb.where(`post.deletedAt is not null`); @@ -210,8 +228,12 @@ export class PostService { qb.andWhere('title LIKE :search', { search: `%${search}%` }) .orWhere('body LIKE :search', { search: `%${search}%` }) .orWhere('summary LIKE :search', { search: `%${search}%` }) - .orWhere('category.name LIKE :search', { search: `%${search}%` }) - .orWhere('tags.name LIKE :search', { search: `%${search}%` }); + .orWhere('category.name LIKE :search', { + search: `%${search}%`, + }) + .orWhere('tags.name LIKE :search', { + search: `%${search}%`, + }); } else if (this.search_type === 'against') { qb.andWhere('MATCH(title) AGAINST (:search IN BOOLEAN MODE)', { search: `${search}*`, @@ -259,7 +281,7 @@ export class PostService { } /** - * 查询出分类及后代分类下的所有文章的Query构建 + * 查询出分类及其后代分类下的所有文章的Query构建 * @param id * @param qb */ diff --git a/src/modules/content/services/search.service.ts b/src/modules/content/services/search.service.ts index 692d2ca..c7c17b3 100644 --- a/src/modules/content/services/search.service.ts +++ b/src/modules/content/services/search.service.ts @@ -23,41 +23,48 @@ async function getPostData( cmtRepo: CommentRepository, post: PostEntity, ) { - const categories = [ - ...(await catRepo.findAncestors(post.category)).map((item) => { - return { + let categories: { id: string; name: string }[] = []; + if (post.category) { + categories = [ + ...(await catRepo.findAncestors(post.category)).map((item) => ({ id: item.id, name: item.name, - }; - }), - { id: post.category.id, name: post.category.name }, - ]; + })), + { id: post.category.id, name: post.category.name }, + ]; + } - const comments = ( + let comments: { id: string; body: string }[] = []; + + comments = ( await cmtRepo.find({ relations: ['post'], where: { post: { id: post.id } }, }) ).map((item) => ({ id: item.id, body: item.body })); - return [ - { - ...pick(instanceToPlain(post), [ - 'id', - 'title', - 'body', - 'summary', - 'commentCount', - 'deletedAt', - 'publishedAt', - 'createdAt', - 'updatedAt', - ]), - categories, - tags: post.tags.map((item) => ({ id: item.id, name: item.name })), - comments, - }, - ]; + let tags: { id: string; name: string }[] = []; + if (post.tags) { + tags = post.tags.map((item) => ({ id: item.id, name: item.name })); + } + + return { + ...pick(instanceToPlain(post), [ + 'id', + 'title', + 'body', + 'summary', + 'commentCount', + 'deletedAt', + 'publishedAt', + 'createdAt', + 'updatedAt', + ]), + body: post.body, + categories, + tags, + comments, + }; } @Injectable() @@ -101,7 +108,6 @@ export class SearchService { sort: ['updatedAt:desc', 'commentCount:desc'], filter, }); - return { items: result.hits, currentPage: result.page, @@ -115,19 +121,18 @@ export class SearchService { async create(post: PostEntity) { return this.client .index(this.index) - .addDocuments(await getPostData(this.categoryRepository, this.commentRepository, post)); + .addDocuments([ + await getPostData(this.categoryRepository, this.commentRepository, post), + ]); } async update(posts: PostEntity[]) { - return this.client - .index(this.index) - .updateDocuments( - await Promise.all( - posts.map((post) => - getPostData(this.categoryRepository, this.commentRepository, post), - ), - ), - ); + const payload = await Promise.all( + posts?.map((post) => + getPostData(this.categoryRepository, this.commentRepository, post), + ), + ); + return this.client.index(this.index).updateDocuments(payload); } async delete(ids: string[]) { diff --git a/src/modules/content/services/tag.service.ts b/src/modules/content/services/tag.service.ts index 96f6b5a..52b82d8 100644 --- a/src/modules/content/services/tag.service.ts +++ b/src/modules/content/services/tag.service.ts @@ -1,46 +1,21 @@ import { Injectable } from '@nestjs/common'; -import { isNil, omit } from 'lodash'; +import { omit } from 'lodash'; -import { In, SelectQueryBuilder } from 'typeorm'; - -import { CreateTagDto, QueryTagsDto, UpdateTagDto } from '@/modules/content/dtos'; +import { CreateTagDto, UpdateTagDto } from '@/modules/content/dtos'; import { TagEntity } from '@/modules/content/entities'; import { TagRepository } from '@/modules/content/repositories'; -import { SelectTrashMode } from '@/modules/database/constants'; -import { paginate } from '@/modules/database/helpers'; -import { QueryHook } from '@/modules/database/types'; - -type FindParams = { - [key in keyof Omit]: QueryTagsDto[key]; -}; +import { BaseService } from '@/modules/database/base'; /** * 标签数据操作 */ @Injectable() -export class TagService { - constructor(protected repository: TagRepository) {} +export class TagService extends BaseService { + protected enableTrash = true; - /** - * 获取标签数据 - * @param options 分页选项 - * @param callback 添加额外的查询 - */ - async paginate(options: QueryTagsDto) { - const qb = await this.buildListQuery(this.repository.buildBaseQB(), options); - return paginate(qb, options); - } - - /** - * 查询单个标签信息 - * @param id - * @param callback 添加额外的查询 - */ - async detail(id: string) { - const qb = this.repository.buildBaseQB(); - qb.where(`tag.id = :id`, { id }); - return qb.getOneOrFail(); + constructor(protected repository: TagRepository) { + super(repository); } /** @@ -60,67 +35,4 @@ export class TagService { await this.repository.update(data.id, omit(data, ['id'])); return this.detail(data.id); } - - /** - * 删除标签 - * @param ids - */ - async delete(ids: string[], trash: boolean) { - const items = await this.repository.find({ - where: { id: In(ids) } as any, - withDeleted: true, - }); - - if (trash) { - const directs = items.filter((item) => !isNil(item.deletedAt)); - const sorts = items.filter((item) => isNil(item.deletedAt)); - - return [ - ...(await this.repository.remove(directs)), - ...(await this.repository.softRemove(sorts)), - ]; - } - - return this.repository.remove(items); - } - - /** - * 软删除标签 - * @param ids - */ - async restore(ids: string[]) { - const items = await this.repository.find({ - where: { id: In(ids) } as any, - withDeleted: true, - }); - - // 过滤掉不在回收站的标签 - const trashed = items.filter((item) => !isNil(item.deletedAt)).map((item) => item.id); - if (trashed.length < 1) return trashed; - await this.repository.restore(trashed); - const qb = this.repository.buildBaseQB().where({ id: In(trashed) }); - return qb.getMany(); - } - - /** - * 构建标签列表查询器(需要查到软删除) - * @param qb - * @param options - * @param callback - */ - protected async buildListQuery( - qb: SelectQueryBuilder, - options: FindParams, - callback?: QueryHook, - ) { - const { trashed } = options; - - if (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) { - qb.withDeleted(); - if (trashed === SelectTrashMode.ONLY) qb.where(`tag.deletedAt IS NOT NULL`); - } - - if (callback) return callback(qb); - return qb; - } } diff --git a/src/modules/content/subscribers/post.subscriber.ts b/src/modules/content/subscribers/post.subscriber.ts index b1e4b70..a33edf9 100644 --- a/src/modules/content/subscribers/post.subscriber.ts +++ b/src/modules/content/subscribers/post.subscriber.ts @@ -1,17 +1,23 @@ +import { Optional } from '@nestjs/common'; import { DataSource, EventSubscriber } from 'typeorm'; import { PostBodyType } from '@/modules/content/constants'; import { PostEntity } from '@/modules/content/entities'; import { PostRepository } from '@/modules/content/repositories'; import { SanitizeService } from '@/modules/content/services/sanitize.service'; +import { BaseSubscriber } from '@/modules/database/base'; @EventSubscriber() -export class PostSubscriber { +export class PostSubscriber extends BaseSubscriber { + protected entity = PostEntity; + constructor( protected dataSource: DataSource, - protected sanitizeService: SanitizeService, protected postRepository: PostRepository, - ) {} + @Optional() protected sanitizeService?: SanitizeService, + ) { + super(dataSource); + } listenTo() { return PostEntity; @@ -19,6 +25,7 @@ export class PostSubscriber { /** * 加载文章数据的处理 + * @param entity */ async afterLoad(entity: PostEntity) { if (entity.type === PostBodyType.HTML) { diff --git a/src/modules/database/base/index.ts b/src/modules/database/base/index.ts new file mode 100644 index 0000000..2320459 --- /dev/null +++ b/src/modules/database/base/index.ts @@ -0,0 +1,4 @@ +export * from './repository'; +export * from './service'; +export * from './subcriber'; +export * from './tree.repository'; diff --git a/src/modules/database/base/repository.ts b/src/modules/database/base/repository.ts new file mode 100644 index 0000000..c8ec69d --- /dev/null +++ b/src/modules/database/base/repository.ts @@ -0,0 +1,46 @@ +import { isNil } from 'lodash'; + +import { ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; + +import { OrderType } from '@/modules/database/constants'; +import { getOrderByQuery } from '@/modules/database/helpers'; +import { OrderQueryType } from '@/modules/database/types'; + +/** + * 基础存储类 + */ +export abstract class BaseRepository extends Repository { + /** + * 构建查询时默认的模型对应的查询名称 + */ + protected abstract _qbName: string; + + /** + * 返回查询器名称 + */ + get qbName() { + return this._qbName; + } + + /** + * 构建基础查询器 + */ + buildBaseQB(): SelectQueryBuilder { + return this.createQueryBuilder(this.qbName); + } + + /** + * 默认排序规则,可以通过每个方法的orderBy选项进行覆盖 + */ + protected orderBy?: string | { name: string; order: `${OrderType}` }; + + /** + * 生成排序的QueryBuilder + * @param qb + * @param orderBy + */ + addOrderByQuery(qb: SelectQueryBuilder, orderBy?: OrderQueryType) { + const orderByQuery = orderBy ?? this.orderBy; + return !isNil(orderByQuery) ? getOrderByQuery(qb, this.qbName, orderByQuery) : qb; + } +} diff --git a/src/modules/database/base/service.ts b/src/modules/database/base/service.ts new file mode 100644 index 0000000..9fc8f13 --- /dev/null +++ b/src/modules/database/base/service.ts @@ -0,0 +1,215 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { isNil } from 'lodash'; +import { In, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { SelectTrashMode, TreeChildrenResolve } from '../constants'; +import { paginate, treePaginate } from '../helpers'; +import { PaginateOptions, PaginateReturn, QueryHook, ServiceListQueryOption } from '../types'; + +import { BaseRepository } from './repository'; +import { BaseTreeRepository } from './tree.repository'; +/** + * CRUD操作服务 + */ +export abstract class BaseService< + E extends ObjectLiteral, + R extends BaseRepository | BaseTreeRepository, + P extends ServiceListQueryOption = ServiceListQueryOption, +> { + /** + * 服务默认存储类 + */ + protected repository: R; + + /** + * 是否开启软删除功能 + */ + protected enableTrash = true; + + constructor(repository: R) { + this.repository = repository; + if ( + !( + this.repository instanceof BaseRepository || + this.repository instanceof BaseTreeRepository + ) + ) { + throw new Error( + 'Repository must instance of BaseRepository or BaseTreeRepository in DataService!', + ); + } + } + + /** + * 获取数据列表 + * @param params 查询参数 + * @param callback 回调查询 + */ + async list(options?: P, callback?: QueryHook): Promise { + const { trashed: isTrashed = false } = options ?? {}; + const trashed = isTrashed || SelectTrashMode.NONE; + if (this.repository instanceof BaseTreeRepository) { + const withTrashed = + this.enableTrash && + (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY); + const onlyTrashed = this.enableTrash && trashed === SelectTrashMode.ONLY; + const tree = await this.repository.findTrees({ + ...options, + withTrashed, + onlyTrashed, + }); + return this.repository.toFlatTrees(tree); + } + const qb = await this.buildListQB(this.repository.buildBaseQB(), options, callback); + return qb.getMany(); + } + + /** + * 获取分页数据 + * @param options 分页选项 + * @param callback 回调查询 + */ + async paginate( + options?: PaginateOptions & P, + callback?: QueryHook, + ): Promise> { + const queryOptions = (options ?? {}) as P; + if (this.repository instanceof BaseTreeRepository) { + const data = await this.list(queryOptions, callback); + return treePaginate(options, data) as PaginateReturn; + } + const qb = await this.buildListQB(this.repository.buildBaseQB(), queryOptions, callback); + return paginate(qb, options); + } + + /** + * 获取数据详情 + * @param id + * @param trashed 查询时是否包含已软删除的数据 + * @param callback 回调查询 + */ + async detail(id: string, callback?: QueryHook): Promise { + const qb = await this.buildItemQB(id, this.repository.buildBaseQB(), callback); + const item = await qb.getOne(); + if (!item) throw new NotFoundException(`${this.repository.qbName} ${id} not exists!`); + return item; + } + + /** + * 创建数据,如果子类没有实现则抛出404 + * @param data 请求数据 + * @param others 其它参数 + */ + create(data: any, ...others: any[]): Promise { + throw new ForbiddenException(`Can not to create ${this.repository.qbName}!`); + } + + /** + * 更新数据,如果子类没有实现则抛出404 + * @param data 请求数据 + * @param others 其它参数 + */ + update(data: any, ...others: any[]): Promise { + throw new ForbiddenException(`Can not to update ${this.repository.qbName}!`); + } + + /** + * 批量删除数据 + * @param data 需要删除的id列表 + * @param trash 是否只扔到回收站,如果为true则软删除 + */ + async delete(ids: string[], trash?: boolean) { + let items: E[] = []; + if (this.repository instanceof BaseTreeRepository) { + items = await this.repository.find({ + where: { id: In(ids) as any }, + withDeleted: this.enableTrash ? true : undefined, + relations: ['parent', 'children'], + }); + if (this.repository.childrenResolve === TreeChildrenResolve.UP) { + for (const item of items) { + if (isNil(item.children) || item.children.length <= 0) continue; + const nchildren = [...item.children].map((c) => { + c.parent = item.parent; + return item; + }); + await this.repository.save(nchildren); + } + } + } else { + items = await this.repository.find({ + where: { id: In(ids) as any }, + withDeleted: this.enableTrash ? true : undefined, + }); + } + if (this.enableTrash && trash) { + const directs = items.filter((item) => !isNil(item.deletedAt)); + const softs = items.filter((item) => isNil(item.deletedAt)); + return [ + ...(await this.repository.remove(directs)), + ...(await this.repository.softRemove(softs)), + ]; + } + return this.repository.remove(items); + } + + /** + * 批量恢复回收站中的数据 + * @param data 需要恢复的id列表 + */ + async restore(ids: string[]) { + if (!this.enableTrash) { + throw new ForbiddenException( + `Can not to retore ${this.repository.qbName},because trash not enabled!`, + ); + } + const items = await this.repository.find({ + where: { id: In(ids) as any }, + withDeleted: true, + }); + const trasheds = items.filter((item) => !isNil(item)); + if (trasheds.length < 0) return []; + await this.repository.restore(trasheds.map((item) => item.id)); + const qb = await this.buildListQB( + this.repository.buildBaseQB(), + undefined, + async (builder) => builder.andWhereInIds(trasheds), + ); + return qb.getMany(); + } + + /** + * 获取查询单个项目的QueryBuilder + * @param id 查询数据的ID + * @param qb querybuilder实例 + * @param callback 查询回调 + */ + protected async buildItemQB(id: string, qb: SelectQueryBuilder, callback?: QueryHook) { + qb.where(`${this.repository.qbName}.id = :id`, { id }); + if (callback) return callback(qb); + return qb; + } + + /** + * 获取查询数据列表的 QueryBuilder + * @param qb querybuilder实例 + * @param options 查询选项 + * @param callback 查询回调 + */ + protected async buildListQB(qb: SelectQueryBuilder, options?: P, callback?: QueryHook) { + const { trashed } = options ?? {}; + const queryName = this.repository.qbName; + // 是否查询回收站 + if ( + this.enableTrash && + (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) + ) { + qb.withDeleted(); + if (trashed === SelectTrashMode.ONLY) { + qb.where(`${queryName}.deletedAt is not null`); + } + } + if (callback) return callback(qb); + return qb; + } +} diff --git a/src/modules/database/base/subcriber.ts b/src/modules/database/base/subcriber.ts new file mode 100644 index 0000000..15ffd52 --- /dev/null +++ b/src/modules/database/base/subcriber.ts @@ -0,0 +1,91 @@ +import { Optional } from '@nestjs/common'; +import { isNil } from 'lodash'; +import { + DataSource, + EntitySubscriberInterface, + EntityTarget, + EventSubscriber, + InsertEvent, + ObjectLiteral, + ObjectType, + RecoverEvent, + RemoveEvent, + SoftRemoveEvent, + TransactionCommitEvent, + TransactionRollbackEvent, + TransactionStartEvent, + UpdateEvent, +} from 'typeorm'; + +import { getCustomRepository } from '@/modules/database/helpers'; +import { RepositoryType } from '@/modules/database/types'; + +/** + * 基础模型观察者 + */ +type SubscriberEvent = + | InsertEvent + | UpdateEvent + | SoftRemoveEvent + | RemoveEvent + | RecoverEvent + | TransactionStartEvent + | TransactionCommitEvent + | TransactionRollbackEvent; + +/** + * 基础模型观察者 + */ +@EventSubscriber() +export abstract class BaseSubscriber + implements EntitySubscriberInterface +{ + /** + * 监听的模型 + */ + protected abstract entity: ObjectType; + + /** + * 构造函数 + * @param dataSource 数据连接池 + */ + constructor(@Optional() protected dataSource?: DataSource) { + if (!isNil(this.dataSource)) this.dataSource.subscribers.push(this); + } + + protected getDataSource(event: SubscriberEvent) { + return this.dataSource ?? event.connection; + } + + protected getManage(event: SubscriberEvent) { + return this.dataSource ? this.dataSource.manager : event.manager; + } + + listenTo() { + return this.entity; + } + + async afterLoad(entity: any) { + // 是否启用树形 + if ('parent' in entity && isNil(entity.depth)) entity.depth = 0; + } + + protected getRepositoy< + C extends ClassType, + T extends RepositoryType, + A extends EntityTarget, + >(event: SubscriberEvent, repository?: C, entity?: A) { + return isNil(repository) + ? this.getDataSource(event).getRepository(entity ?? this.entity) + : getCustomRepository(this.getDataSource(event), repository); + } + + /** + * 判断某个字段是否被更新 + * @param cloumn + * @param event + */ + protected isUpdated(cloumn: keyof E, event: UpdateEvent) { + return !!event.updatedColumns.find((item) => item.propertyName === cloumn); + } +} diff --git a/src/modules/database/base/tree.repository.ts b/src/modules/database/base/tree.repository.ts new file mode 100644 index 0000000..d3c6a16 --- /dev/null +++ b/src/modules/database/base/tree.repository.ts @@ -0,0 +1,276 @@ +import { isNil, pick, unset } from 'lodash'; +import { + EntityManager, + EntityTarget, + FindOptionsUtils, + FindTreeOptions, + ObjectLiteral, + QueryRunner, + SelectQueryBuilder, + TreeRepository, + TreeRepositoryUtils, +} from 'typeorm'; + +import { OrderType, TreeChildrenResolve } from '../constants'; +import { getOrderByQuery } from '../helpers'; +import { OrderQueryType, QueryParams } from '../types'; + +/** + * 基础树形存储类 + */ +export class BaseTreeRepository extends TreeRepository { + /** + * 查询器名称 + */ + protected _qbName = 'treeEntity'; + + /** + * 删除父分类后是否提升子分类的等级 + */ + protected _childrenResolve?: TreeChildrenResolve; + + /** + * 默认排序规则,可以通过每个方法的orderBy选项进行覆盖 + */ + protected orderBy?: string | { name: string; order: `${OrderType}` }; + + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(target: EntityTarget, manager: EntityManager, queryRunner?: QueryRunner) { + super(target, manager, queryRunner); + } + + /** + * 返回查询器名称 + */ + get qbName() { + return this._qbName; + } + + /** + * 返回子分类的等级 + */ + get childrenResolve() { + return this._childrenResolve; + } + + /** + * 构建基础查询器 + */ + buildBaseQB(qb?: SelectQueryBuilder): SelectQueryBuilder { + const queryBuilder = qb ?? this.createQueryBuilder(this.qbName); + return queryBuilder.leftJoinAndSelect(`${this.qbName}.parent`, 'parent'); + } + + /** + * 生成排序的QueryBuilder + * @param qb + * @param orderBy + */ + addOrderByQuery(qb: SelectQueryBuilder, orderBy?: OrderQueryType) { + const orderByQuery = orderBy ?? this.orderBy; + return !isNil(orderByQuery) ? getOrderByQuery(qb, this.qbName, orderByQuery) : qb; + } + + /** + * 查询树形分类 + * @param options + */ + async findTrees(options?: FindTreeOptions & QueryParams) { + const roots = await this.findRoots(options); + await Promise.all(roots.map((root) => this.findDescendantsTree(root, options))); + return roots; + } + + /** + * 查询顶级分类 + * @param options + */ + async findRoots(options?: FindTreeOptions & QueryParams) { + const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {}; + const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias); + const escapeColumn = (column: string) => this.manager.connection.driver.escape(column); + + const joinColumn = this.metadata.treeParentRelation!.joinColumns[0]; + const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName; + + let qb = this.addOrderByQuery(this.buildBaseQB(), orderBy); + qb.where(`${escapeAlias(this.qbName)}.${escapeColumn(parentPropertyName)} IS NULL`); + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth'])); + qb = addQuery ? await addQuery(qb) : qb; + if (withTrashed) { + qb.withDeleted(); + if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`); + } + return qb.getMany(); + } + + /** + * 查询后代树 + * @param entity + * @param options + */ + async findDescendantsTree(entity: E, options?: FindTreeOptions & QueryParams) { + const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {}; + let qb = this.buildBaseQB( + this.createDescendantsQueryBuilder(this.qbName, 'treeClosure', entity), + ); + qb = addQuery + ? await addQuery(this.addOrderByQuery(qb, orderBy)) + : this.addOrderByQuery(qb, orderBy); + if (withTrashed) { + qb.withDeleted(); + if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`); + } + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth'])); + const entities = await qb.getRawAndEntities(); + const relationMaps = TreeRepositoryUtils.createRelationMaps( + this.manager, + this.metadata, + this.qbName, + entities.raw, + ); + TreeRepositoryUtils.buildChildrenEntityTree( + this.metadata, + entity, + entities.entities, + relationMaps, + { + depth: -1, + ...pick(options, ['relations']), + }, + ); + + return entity; + } + + /** + * 查询祖先树 + * @param entity + * @param options + */ + async findAncestorsTree(entity: E, options?: FindTreeOptions & QueryParams) { + const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {}; + let qb = this.buildBaseQB( + this.createDescendantsQueryBuilder(this.qbName, 'treeClosure', entity), + ); + qb = addQuery + ? await addQuery(this.addOrderByQuery(qb, orderBy)) + : this.addOrderByQuery(qb, orderBy); + if (withTrashed) { + qb.withDeleted(); + if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`); + } + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); + + const entities = await qb.getRawAndEntities(); + const relationMaps = TreeRepositoryUtils.createRelationMaps( + this.manager, + this.metadata, + 'treeEntity', + entities.raw, + ); + TreeRepositoryUtils.buildParentEntityTree( + this.metadata, + entity, + entities.entities, + relationMaps, + ); + return entity; + } + + /** + * 查询后代元素 + * @param entity + * @param options + */ + async findDescendants(entity: E, options?: FindTreeOptions & QueryParams) { + const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {}; + let qb = this.buildBaseQB( + this.createDescendantsQueryBuilder(this.qbName, 'treeClosure', entity), + ); + qb = addQuery + ? await addQuery(this.addOrderByQuery(qb, orderBy)) + : this.addOrderByQuery(qb, orderBy); + if (withTrashed) { + qb.withDeleted(); + if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`); + } + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); + return qb.getMany(); + } + + /** + * 查询祖先元素 + * @param entity + * @param options + */ + async findAncestors(entity: E, options?: FindTreeOptions & QueryParams) { + const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {}; + let qb = this.buildBaseQB( + this.createAncestorsQueryBuilder(this.qbName, 'treeClosure', entity), + ); + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); + qb = addQuery + ? await addQuery(this.addOrderByQuery(qb, orderBy)) + : this.addOrderByQuery(qb, orderBy); + if (withTrashed) { + qb.withDeleted(); + if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`); + } + return qb.getMany(); + } + + /** + * 统计后代元素数量 + * @param entity + * @param options + */ + async countDescendants(entity: E, options?: FindTreeOptions & QueryParams) { + const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {}; + let qb = this.createDescendantsQueryBuilder(this.qbName, 'treeClosure', entity); + qb = addQuery + ? await addQuery(this.addOrderByQuery(qb, orderBy)) + : this.addOrderByQuery(qb, orderBy); + if (withTrashed) { + qb.withDeleted(); + if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`); + } + return qb.getCount(); + } + + /** + * 统计祖先元素数量 + * @param entity + * @param options + */ + async countAncestors(entity: E, options?: FindTreeOptions & QueryParams) { + const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {}; + let qb = this.createAncestorsQueryBuilder(this.qbName, 'treeClosure', entity); + qb = addQuery + ? await addQuery(this.addOrderByQuery(qb, orderBy)) + : this.addOrderByQuery(qb, orderBy); + if (withTrashed) { + qb.withDeleted(); + if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`); + } + return qb.getCount(); + } + + /** + * 打平并展开树 + * @param trees + * @param level + */ + async toFlatTrees(trees: E[], depth = 0, parent: E | null = null): Promise { + const data: Omit[] = []; + for (const item of trees) { + (item as any).depth = depth; + (item as any).parent = parent; + const { children } = item; + unset(item, 'children'); + data.push(item); + data.push(...(await this.toFlatTrees(children, depth + 1, item))); + } + return data as E[]; + } +} diff --git a/src/modules/database/constants.ts b/src/modules/database/constants.ts index 72d8716..7ae4466 100644 --- a/src/modules/database/constants.ts +++ b/src/modules/database/constants.ts @@ -11,3 +11,20 @@ export enum SelectTrashMode { ONLY = 'only', NONE = 'none', } + +/** + * 排序方式 + */ +export enum OrderType { + ASC = 'ASC', + DESC = 'DESC', +} + +/** + * 树形模型在删除父级后子级的处理方式 + */ +export enum TreeChildrenResolve { + DELETE = 'delete', + UP = 'up', + ROOT = 'root', +} diff --git a/src/modules/database/helpers.ts b/src/modules/database/helpers.ts index 5fe2fde..8ab397f 100644 --- a/src/modules/database/helpers.ts +++ b/src/modules/database/helpers.ts @@ -1,7 +1,8 @@ import { isNil } from 'lodash'; -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { DataSource, ObjectLiteral, ObjectType, Repository, SelectQueryBuilder } from 'typeorm'; -import { PaginateOptions, PaginateReturn } from '@/modules/database/types'; +import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants'; +import { OrderQueryType, PaginateOptions, PaginateReturn } from '@/modules/database/types'; /** * 分页函数 @@ -71,3 +72,43 @@ export const treePaginate = ( items, }; }; + +/** + * 为查询添加排序,默认排序规则为DESC + * @param qb 原查询 + * @param alias 别名 + * @param orderBy 查询排序 + */ +export const getOrderByQuery = ( + qb: SelectQueryBuilder, + alias: string, + orderBy?: OrderQueryType, +) => { + if (isNil(orderBy)) return qb; + if (typeof orderBy === 'string') return qb.orderBy(`${alias}.${orderBy}`, 'DESC'); + if (Array.isArray(orderBy)) { + for (const item of orderBy) { + typeof item === 'string' + ? qb.addOrderBy(`${alias}.${item}`, 'DESC') + : qb.addOrderBy(`${alias}.${item.name}`, item.order); + } + return qb; + } + return qb.orderBy(`${alias}.${(orderBy as any).name}`, (orderBy as any).order); +}; + +/** + * 获取自定义Repository的实例 + * @param dataSource 数据连接池 + * @param Repo repository类 + */ +export const getCustomRepository = , E extends ObjectLiteral>( + dataSource: DataSource, + Repo: ClassType, +): T => { + if (isNil(Repo)) return null; + const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repo); + if (!entity) return null; + const base = dataSource.getRepository>(entity); + return new Repo(base.target, base.manager, base.queryRunner) as T; +}; diff --git a/src/modules/database/types.ts b/src/modules/database/types.ts index a390bd9..1ea7c65 100644 --- a/src/modules/database/types.ts +++ b/src/modules/database/types.ts @@ -1,4 +1,13 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { + FindTreeOptions, + ObjectLiteral, + Repository, + SelectQueryBuilder, + TreeRepository, +} from 'typeorm'; + +import { BaseRepository, BaseTreeRepository } from '@/modules/database/base'; +import { OrderType, SelectTrashMode } from '@/modules/database/constants'; /** * 一个查询钩子,用于修改或增强给定的查询构建器。 @@ -68,3 +77,57 @@ export interface PaginateReturn { */ meta: PaginateMeta; } + +/** + * 排序类型,{字段名称: 排序方法} + * 如果多个值则传入数组即可 + * 排序方法不设置,默认DESC + */ +export type OrderQueryType = + | string + | { name: string; order: `${OrderType}` } + | Array<{ name: string; order: `${OrderType}` } | string>; + +/** + * 数据列表查询类型 + */ +export interface QueryParams { + addQuery?: QueryHook; + orderBy?: OrderQueryType; + withTrashed?: boolean; + onlyTrashed?: boolean; +} + +/** + * 服务类数据列表查询类型 + */ +export type ServiceListQueryOption = + | ServiceListQueryOptionWithTrashed + | ServiceListQueryOptionNotWithTrashed; + +/** + * 带有软删除的服务类数据列表查询类型 + */ +type ServiceListQueryOptionWithTrashed = Omit< + FindTreeOptions & QueryParams, + 'withTrashed' +> & { + trashed?: `${SelectTrashMode}`; +} & Record; + +/** + * 不带软删除的服务类数据列表查询类型 + */ +type ServiceListQueryOptionNotWithTrashed = Omit< + ServiceListQueryOptionWithTrashed, + 'trashed' +>; + +/** + * Repository类型 + */ +export type RepositoryType = + | Repository + | TreeRepository + | BaseRepository + | BaseTreeRepository;