feat(leaning):完成NestJS整合TypeORM实现CRUD

- 增加CORE模块中的工具函数
- 增加数据库模块和数据库配置SQLite
- 增加数据分页处理函数
- 文章模块更新提供者、实体、控制器、存储库替换为TypeORM操作
- 数据库模块增加自定义存储库动态模块
This commit is contained in:
2023-11-20 16:35:17 +08:00
parent 9ac17e43e3
commit db1fcf05bc
37 changed files with 1230 additions and 471 deletions
+4 -11
View File
@@ -1,20 +1,13 @@
import { Module } from '@nestjs/common';
import { database } from '@/config';
import { ContentModule } from '@/modules/content/content.module';
import { CoreModule } from '@/modules/core/core.module';
import { DatabaseModule } from '@/modules/database/database.module';
import { WelcomeModule } from '@/modules/welcome/welcome.module';
import { CoreModule } from './modules/core/core.module';
@Module({
imports: [
ContentModule,
WelcomeModule,
CoreModule.forRoot({
config: {
name: '欢迎访问 Ink NestJS API !',
},
}),
],
imports: [DatabaseModule.forRoot(database), ContentModule, WelcomeModule, CoreModule.forRoot()],
controllers: [],
providers: [],
})
+23
View File
@@ -0,0 +1,23 @@
import { resolve } from 'path';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
/**
* 数据库配置
*/
export const database = (): TypeOrmModuleOptions => ({
// 以下为mysql配置
// charset: 'utf8mb4',
// logging: ['error'],
// type: 'mysql',
// host: '127.0.0.1',
// port: 3306,
// username: 'root',
// password: '123456789',
// database: 'ink_apps',
// 以下为sqlite配置
type: 'better-sqlite3',
database: resolve(__dirname, '../../back/database4.db'),
synchronize: true,
autoLoadEntities: true,
});
+1
View File
@@ -0,0 +1 @@
export * from './database.config';
+3 -1
View File
@@ -9,8 +9,10 @@ const bootstrap = async () => {
logger: ['error', 'warn'],
});
app.setGlobalPrefix('api');
await app.listen(2333, () => {
console.log('api: http://localhost:2333');
console.log('api: http://localhost:2333/api');
});
};
+17
View File
@@ -0,0 +1,17 @@
/**
* 文章内容类型
*/
export enum PostBodyType {
HTML = 'html',
MD = 'markdown',
}
/**
* 文章排序类型
*/
export enum PostOrderType {
CREATED = 'createdAt',
UPDATED = 'updatedAt',
PUBLISHED = 'publishedAt',
CUSTOM = 'custom',
}
+15 -4
View File
@@ -1,11 +1,22 @@
import { Module } from '@nestjs/common';
import { PostController } from './controllers';
import { PostService } from './services';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostController } from '@/modules/content/controllers';
import { PostEntity } from '@/modules/content/entities';
import { PostRepository } from '@/modules/content/repositories';
import { PostService, SanitizeService } from '@/modules/content/services';
import { PostSubscriber } from '@/modules/content/subscribers';
import { DatabaseModule } from '@/modules/database/database.module';
@Module({
imports: [
TypeOrmModule.forFeature([PostEntity]),
DatabaseModule.forRepository([PostRepository]),
],
controllers: [PostController],
providers: [PostService],
exports: [PostService],
providers: [PostService, PostSubscriber, SanitizeService],
exports: [PostService, DatabaseModule.forRepository([PostRepository])],
})
export class ContentModule {}
@@ -1,24 +1,38 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, ValidationPipe } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
ValidationPipe,
} from '@nestjs/common';
import { CreatePostDto, UpdatePostDto } from '../dtos';
import { PostService } from '../services';
import { PostService } from '@/modules/content/services';
import { PaginateOptions } from '@/modules/database/types';
/**
* 文章控制器
* 负责处理与文章相关的请求,如获取文章列表、创建新文章等。
*/
@Controller('post')
@Controller('posts')
export class PostController {
constructor(private postService: PostService) {}
@Get()
async index() {
return this.postService.findAll();
async list(
@Query()
options: PaginateOptions,
) {
return this.postService.paginate(options);
}
@Get(':id')
async show(@Param('id') id: number) {
return this.postService.findOne(id);
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.detail(id);
}
@Post()
@@ -32,7 +46,7 @@ export class PostController {
groups: ['create'],
}),
)
data: CreatePostDto,
data: RecordAny,
) {
return this.postService.create(data);
}
@@ -48,13 +62,13 @@ export class PostController {
groups: ['update'],
}),
)
data: UpdatePostDto,
data: RecordAny,
) {
return this.postService.update(data);
}
@Delete(':id')
async delete(@Param('id') id: number) {
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.delete(id);
}
}
+1
View File
@@ -0,0 +1 @@
export * from './post.entity';
@@ -0,0 +1,43 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { PostBodyType } from '@/modules/content/constants';
@Entity('content_posts')
export class PostEntity extends BaseEntity {
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: '36' })
id: string;
@Column({ comment: '文章标题' })
title: string;
@Column({ comment: '文章内容', type: 'text' })
body: string;
@Column({ comment: '文章摘要', nullable: true })
summary: string;
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
keywords?: string[];
@Column({ comment: '文章类型', type: 'varchar', default: PostBodyType.MD })
type: PostBodyType;
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
publishedAt?: Date | null;
@CreateDateColumn({ comment: '创建时间' })
createdAt: Date;
@UpdateDateColumn({ comment: '更新时间' })
updatedAt: Date;
@Column({ comment: '文章自定义排序', default: 0 })
customOrder: number;
}
@@ -0,0 +1 @@
export * from './post.repository';
@@ -0,0 +1,11 @@
import { Repository } from 'typeorm';
import { PostEntity } from '@/modules/content/entities';
import { CustomRepository } from '@/modules/database/decorators';
@CustomRepository(PostEntity)
export class PostRepository extends Repository<PostEntity> {
buildBaseQB() {
return this.createQueryBuilder('post');
}
}
+1
View File
@@ -1 +1,2 @@
export * from './post.service';
export * from './sanitize.service';
+99 -44
View File
@@ -1,64 +1,119 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { isNil } from 'lodash';
import { isFunction, isNil, omit } from 'lodash';
import { CreatePostDto, UpdatePostDto } from '../dtos';
import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { PostEntity } from '../types';
import { PostOrderType } from '@/modules/content/constants';
import { PostEntity } from '@/modules/content/entities';
import { PostRepository } from '@/modules/content/repositories';
import { paginate } from '@/modules/database/helpers';
import { PaginateOptions, QueryHook } from '@/modules/database/types';
@Injectable()
export class PostService {
protected posts: PostEntity[] = [
{ title: '第一篇文章标题', body: '第一篇文章内容' },
{ title: '第二篇文章标题', body: '第二篇文章内容' },
{ title: '第三篇文章标题', body: '第三篇文章内容' },
{ title: '第四篇文章标题', body: '第四篇文章内容' },
{ title: '第五篇文章标题', body: '第五篇文章内容' },
{ title: '第六篇文章标题', body: '第六篇文章内容' },
].map((v, id) => ({ ...v, id }));
constructor(protected repository: PostRepository) {}
async findAll() {
return this.posts;
/**
* 获取分页数据
* @param options 分页选项
* @param callback 添加额外的查询
*/
async paginate(options: PaginateOptions, callback?: QueryHook<PostEntity>) {
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
return paginate(qb, options);
}
async findOne(id: number) {
const post = this.posts.find((item) => item.id === id);
if (isNil(post)) throw new NotFoundException(`id: ${id} 文章不存在`);
return post;
/**
* 查询单篇文章
* @param id
* @param callback 添加额外的查询
*/
async detail(id: string, callback?: QueryHook<PostEntity>) {
let qb = this.repository.buildBaseQB();
qb.where(`post.id = :id`, { id });
qb = !isNil(callback) && isFunction(callback) ? await callback(qb) : qb;
const item = await qb.getOne();
if (!item) throw new EntityNotFoundError(PostEntity, `The post ${id} not exists!`);
return item;
}
async create(data: CreatePostDto) {
const newPost: PostEntity = {
id: Math.max(...this.posts.map(({ id }) => id + 1)),
...data,
};
/**
* 创建文章
* @param data
*/
async create(data: Record<string, any>) {
const item = await this.repository.save(data);
this.posts.push(newPost);
return newPost;
return this.detail(item.id);
}
async update(data: UpdatePostDto) {
let toUpdate = this.posts.find((item) => item.id === data.id);
if (isNil(toUpdate)) throw new NotFoundException(`id: ${data.id} 文章不存在`);
toUpdate = { ...toUpdate, ...data };
this.posts = this.posts.map((item) => (item.id === data.id ? toUpdate : item));
return toUpdate;
/**
* 更新文章
* @param data
*/
async update(data: Record<string, any>) {
await this.repository.update(data.id, omit(data, ['id']));
return this.detail(data.id);
}
async delete(id: number) {
const toDelete = this.posts.find((item) => item.id === id);
/**
* 删除文章
* @param id
*/
async delete(id: string) {
const item = await this.repository.findOneByOrFail({ id });
return this.repository.remove(item);
}
if (isNil(toDelete)) throw new NotFoundException(`id: ${id} 文章不存在`);
/**
* 构建文章列表查询器
* @param qb 初始查询构造器
* @param options 排查分页选项后的查询选项
* @param callback 添加额外的查询
*/
protected async buildListQuery(
qb: SelectQueryBuilder<PostEntity>,
options: Record<string, any>,
callback?: QueryHook<PostEntity>,
) {
const { orderBy, isPublished } = options;
let newQb = qb;
if (typeof isPublished === 'boolean') {
newQb = isPublished
? newQb.where({
publishedAt: Not(IsNull()),
})
: newQb.where({
publishedAt: IsNull(),
});
}
newQb = this.queryOrderBy(newQb, orderBy);
if (callback) return callback(newQb);
return newQb;
}
this.posts = this.posts.filter((item) => item.id !== id);
return toDelete;
/**
* 对文章进行排序的Query构建
* @param qb
* @param orderBy 排序方式
*/
protected queryOrderBy(qb: SelectQueryBuilder<PostEntity>, orderBy?: PostOrderType) {
switch (orderBy) {
case PostOrderType.CREATED:
return qb.orderBy('post.createdAt', 'DESC');
case PostOrderType.UPDATED:
return qb.orderBy('post.updatedAt', 'DESC');
case PostOrderType.PUBLISHED:
return qb.orderBy('post.publishedAt', 'DESC');
case PostOrderType.CUSTOM:
return qb.orderBy('customOrder', 'DESC');
default:
return qb
.orderBy('post.createdAt', 'DESC')
.addOrderBy('post.updatedAt', 'DESC')
.addOrderBy('post.publishedAt', 'DESC');
}
}
}
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import sanitizeHtml from 'sanitize-html';
import { deepMerge } from '@/modules/core/helpers';
@Injectable()
export class SanitizeService {
protected config: sanitizeHtml.IOptions = {};
constructor() {
this.config = {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'code']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
'*': ['class', 'style', 'height', 'width'],
},
parser: {
lowerCaseTags: true,
},
};
}
sanitize(body: string, options?: sanitizeHtml.IOptions) {
return sanitizeHtml(body, deepMerge(this.config, options ?? {}, 'replace'));
}
}
+1
View File
@@ -0,0 +1 @@
export * from './post.subscriber';
@@ -0,0 +1,28 @@
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';
@EventSubscriber()
export class PostSubscriber {
constructor(
protected dataSource: DataSource,
protected sanitizeService: SanitizeService,
protected postRepository: PostRepository,
) {}
listenTo() {
return PostEntity;
}
/**
* 加载文章数据的处理
*/
async afterLoad(entity: PostEntity) {
if (entity.type === PostBodyType.HTML) {
entity.body = this.sanitizeService.sanitize(entity.body);
}
}
}
-6
View File
@@ -1,6 +0,0 @@
export interface PostEntity {
id: number;
title: string;
summary?: string;
body: string;
}
+2 -14
View File
@@ -1,23 +1,11 @@
import { DynamicModule, Global, Module } from '@nestjs/common';
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './services/config.service';
@Global()
@Module({})
export class CoreModule {
static forRoot(options: { config: RecordAny }): DynamicModule {
static forRoot(): DynamicModule {
return {
module: CoreModule,
global: true,
providers: [
{
provide: ConfigService,
useFactory() {
return new ConfigService(options.config);
},
},
],
exports: [ConfigService],
};
}
}
+1
View File
@@ -0,0 +1 @@
export * from './utils';
+50
View File
@@ -0,0 +1,50 @@
import deepmerge from 'deepmerge';
import { isNil } from 'lodash';
/**
* 用于请求验证中的boolean数据转义
* @param value
*/
export const toBoolean = (value?: string | boolean): boolean => {
if (isNil(value)) return false;
if (typeof value === 'boolean') return value;
try {
return JSON.parse(value.toLowerCase());
} catch (error) {
return value as unknown as boolean;
}
};
/**
* 用于请求验证中转义null
* @param value
*/
export const toNull = (value?: string | null): string | null | undefined => {
return value === 'null' ? null : value;
};
/**
* 深度合并对象
* @param x 初始值
* @param y 新值
* @param arrayMode 对于数组采取的策略,`replace`为直接替换,`merge`为合并数组
*/
export const deepMerge = <T1, T2>(
x: Partial<T1>,
y: Partial<T2>,
arrayMode: 'replace' | 'merge' = 'merge',
) => {
const options: deepmerge.Options = {};
if (arrayMode === 'replace') {
options.arrayMerge = (target, source, _options) => source;
} else if (arrayMode === 'merge') {
options.arrayMerge = (target, source, _options) =>
Array.from(new Set({ ...target, ...source }));
}
return deepmerge(x, y, options) as T2 extends T1 ? T1 : T1 & T2;
};
@@ -1,15 +0,0 @@
import { Injectable } from '@nestjs/common';
import { get } from 'lodash';
@Injectable()
export class ConfigService {
protected config: RecordAny = {};
constructor(data: RecordAny) {
this.config = data;
}
get<T>(key: string, defaultValue?: T): T | undefined {
return get(this.config, key, defaultValue);
}
}
+4
View File
@@ -0,0 +1,4 @@
/**
* 自定义 Repository 元数据
*/
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
+47
View File
@@ -0,0 +1,47 @@
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
import { TypeOrmModule, TypeOrmModuleOptions, getDataSourceToken } from '@nestjs/typeorm';
import { DataSource, ObjectType } from 'typeorm';
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
@Module({})
export class DatabaseModule {
static forRoot(configRegister: () => TypeOrmModuleOptions): DynamicModule {
return {
global: true,
module: DatabaseModule,
imports: [TypeOrmModule.forRoot(configRegister())],
};
}
static forRepository<T extends Type<any>>(
repositories: T[],
dataSourceName?: string,
): DynamicModule {
const providers: Provider[] = [];
for (const Repo of repositories) {
const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repo);
if (!entity) {
continue;
}
providers.push({
inject: [getDataSourceToken(dataSourceName)],
provide: Repo,
useFactory: (dataSource: DataSource): InstanceType<typeof Repo> => {
const base = dataSource.getRepository<ObjectType<any>>(entity);
return new Repo(base.target, base.manager, base.queryRunner);
},
});
}
return {
exports: providers,
module: DatabaseModule,
providers,
};
}
}
+1
View File
@@ -0,0 +1 @@
export * from './repository.decorator';
@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
import { ObjectType } from 'typeorm';
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
/**
* 自定义存储库装饰器,用于将一个TypeORM实体类关联到一个自定义存储库类。
* @param {ObjectType<T>} entity - TypeORM实体类,将被关联到自定义存储库。
* @returns {ClassDecorator} - 一个类装饰器,将元数据关联到目标存储库类。
*/
export const CustomRepository = <T>(entity: ObjectType<T>): ClassDecorator =>
SetMetadata(CUSTOM_REPOSITORY_METADATA, entity);
+46
View File
@@ -0,0 +1,46 @@
import { isNil } from 'lodash';
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { PaginateOptions, PaginateReturn } from '@/modules/database/types';
/**
* 分页函数
* @params qb queryBuilder实例
* @params options 分页选项
*/
export const paginate = async <E extends ObjectLiteral>(
qb: SelectQueryBuilder<E>,
options: PaginateOptions,
): Promise<PaginateReturn<E>> => {
const limit = isNil(options.limit) || options.limit < 1 ? 1 : options.limit;
const page = isNil(options.page) || options.page < 1 ? 1 : options.page;
const start = page >= 1 ? page - 1 : 0;
const totalItems = await qb.getCount();
qb.take(limit).skip(start * limit);
const items = await qb.getMany();
const totalPages =
totalItems % limit === 0
? Math.floor(totalItems / limit)
: Math.floor(totalItems / limit) + 1;
const remainder = totalItems % limit !== 0 ? totalItems % limit : limit;
const itemCount = page < totalPages ? limit : remainder;
return {
items,
meta: {
totalItems,
totalPages,
itemCount,
perPage: limit,
currentPage: page,
},
};
};
+70
View File
@@ -0,0 +1,70 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
/**
* 一个查询钩子,用于修改或增强给定的查询构建器。
* @param {SelectQueryBuilder<Entity>} qb - TypeORM的查询构建器实例。
* @returns {Promise<SelectQueryBuilder<Entity>>} - 经过修改或增强的查询构建器实例。
*/
export type QueryHook<Entity> = (
qb: SelectQueryBuilder<Entity>,
) => Promise<SelectQueryBuilder<Entity>>;
/**
* 分页原数据
*/
export interface PaginateMeta {
/**
* 当前页项目数量
*/
itemCount: number;
/**
* 项目总数量
*/
totalItems?: number;
/**
* 每页显示数量
*/
perPage: number;
/**
* 总页数
*/
totalPages?: number;
/**
* 当前页数
*/
currentPage: number;
}
/**
* 分页选项
*/
export interface PaginateOptions {
/**
* 当前页数
*/
page: number;
/**
* 每页显示数量
*/
limit: number;
}
/**
* 分页数据返回
*/
export interface PaginateReturn<E extends ObjectLiteral> {
/**
* 项目列表
*/
items: E[];
/**
* 分页信息
*/
meta: PaginateMeta;
}
+2 -49
View File
@@ -1,59 +1,12 @@
import { Controller, Get, Inject } from '@nestjs/common';
import { ConfigService } from '../core/services/config.service';
import { FifthService } from './services/fifth.service';
import { FirstService } from './services/first.service';
import { FourthService } from './services/fourth.service';
import { SecondService } from './services/second.service';
import { Controller, Get } from '@nestjs/common';
/**
* 欢迎访问 Ink NestJS API
*/
@Controller()
export class WelcomeController {
constructor(
private configService: ConfigService,
private first: FirstService,
@Inject('ID-WELCOME') private idExp: FirstService,
@Inject('FACTORY-WELCOME') private ftExp: FourthService,
@Inject('ALIAS-WELCOME') private asExp: FirstService,
@Inject('ASYNC-WELCOME') private acExp: SecondService,
private fifth: FifthService,
) {}
@Get()
getMessage(): string {
return this.configService.get('name');
}
@Get('value')
async useValue() {
return this.first.useValue();
}
@Get('id')
async useId() {
return this.idExp.useId();
}
@Get('factory')
async useFactory() {
return this.ftExp.getContent();
}
@Get('alias')
async useAlias() {
return this.asExp.useAlias();
}
@Get('async')
async useAsync() {
return this.acExp.useAsync();
}
@Get('circular')
async useCircular() {
return this.fifth.circular();
return '欢迎访问 Ink NestJS API';
}
}
-50
View File
@@ -1,58 +1,8 @@
import { Module } from '@nestjs/common';
import { FirstService } from './services/first.service';
import { FourthService } from './services/fourth.service';
import { SecondService } from './services/second.service';
import { ThirdService } from './services/third.service';
import { WelcomeController } from './welcome.controller';
import { FifthService } from './services/fifth.service';
import { SixthService } from './services/sixth.service';
const firstObject = {
useValue: () => 'firstObject useValue 提供者',
useAlias: () => 'firstObject 别名提供者',
};
const firstInstance = new FirstService();
@Module({
controllers: [WelcomeController],
providers: [
{
provide: FirstService,
useValue: firstObject,
},
{
provide: 'ID-WELCOME',
useValue: firstInstance,
},
{
provide: SecondService,
useClass: ThirdService,
},
{
provide: 'FACTORY-WELCOME',
useFactory(second: SecondService) {
const factory = new FourthService(second);
return factory;
},
inject: [SecondService],
},
{
provide: 'ALIAS-WELCOME',
useExisting: FirstService,
},
{
provide: 'ASYNC-WELCOME',
useFactory: async () => {
const factory = new FourthService(new SecondService());
return factory.getPromise();
},
},
FifthService,
SixthService,
],
})
export class WelcomeModule {}