Commit b602a0e3 authored by nanahira's avatar nanahira

first

parent e351ee15
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
/data
/output
/config.yaml
.git*
Dockerfile
.dockerignore
/tests
webpack.config.js
dist/*
build/*
*.js
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
/data
/output
/config.yaml
stages:
- install
- build
- deploy
variables:
GIT_DEPTH: "1"
npm_ci:
stage: install
tags:
- linux
script:
- npm ci
artifacts:
paths:
- node_modules
.build_base:
stage: build
tags:
- linux
dependencies:
- npm_ci
build:
extends:
- .build_base
script:
- npm run build
artifacts:
paths:
- dist/
unit-test:
extends:
- .build_base
script:
- npm run test
deploy_npm:
stage: deploy
dependencies:
- build
tags:
- linux
script:
- apt update;apt -y install coreutils
- echo $NPMRC | base64 --decode > ~/.npmrc
- npm publish . || true
only:
- master
/install-npm.sh
.git*
/data
/output
/config.yaml
.idea
.dockerignore
Dockerfile
/src
/coverage
/tests
/dist/tests
{
"singleQuote": true,
"trailingComma": "all"
}
\ No newline at end of file
FROM node:lts-bullseye-slim as base
LABEL Author="Nanahira <nanahira@momobako.com>"
RUN apt update && apt -y install python3 build-essential && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/*
WORKDIR /usr/src/app
COPY ./package*.json ./
FROM base as builder
RUN npm ci && npm cache clean --force
COPY . ./
RUN npm run build
FROM base
ENV NODE_ENV production
RUN npm ci && npm cache clean --force
COPY --from=builder /usr/src/app/dist ./dist
CMD [ "npm", "start" ]
The MIT License (MIT)
Copyright (c) 2021 Nanahira
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# nicot # nicot
Nest.js interacting with class-validator + OpenAPI + typeorm Nest.js interacting with class-validator + OpenAPI + TypeORM for Nest.js Restful API development.
\ No newline at end of file
export * from './src/crud-base';
export * from './src/bases';
export * from './src/decorators';
export * from './src/dto';
export * from './src/utility';
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "nicot",
"description": "Nest.js interacting with class-validator + OpenAPI + TypeORM for Nest.js Restful API development.",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"lint": "eslint --fix .",
"build": "rimraf dist && npm run build",
"test": "jest --passWithNoTests",
"start": "node dist/index.js"
},
"repository": {
"type": "git",
"url": "https://code.mycard.moe/3rdeye/nicot.git"
},
"author": "Nanahira <nanahira@momobako.com>",
"license": "MIT",
"keywords": [],
"bugs": {
"url": "https://code.mycard.moe/3rdeye/nicot/issues"
},
"homepage": "https://code.mycard.moe/3rdeye/nicot",
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "tests",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"devDependencies": {
"@types/jest": "^28.1.5",
"@types/lodash": "^4.14.182",
"@types/node": "^18.0.4",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^3.4.1",
"jest": "^28.1.3",
"prettier": "^2.7.1",
"rimraf": "^3.0.2",
"ts-jest": "^28.0.6",
"typescript": "^4.7.4"
},
"peerDependencies": {
"@nestjs/common": "^9.0.3",
"@nestjs/swagger": "^6.0.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"typeorm": "^0.3.7"
},
"dependencies": {
"lodash": "^4.17.21"
}
}
import { TimeBase } from './time-base';
import { Generated, SelectQueryBuilder } from 'typeorm';
import { applyQueryProperty } from '../utility/query';
import {
IntColumn,
MergePropertyDecorators,
NotChangeable,
NotWritable,
StringColumn,
} from '../decorators';
import { IsNotEmpty } from 'class-validator';
export interface IdOptions {
description?: string;
}
export function IdBase(idOptions: IdOptions = {}) {
const cl = class IdBase extends TimeBase {
id: number;
override applyQuery(qb: SelectQueryBuilder<IdBase>, entityName: string) {
super.applyQuery(qb, entityName);
qb.orderBy(`${entityName}.id`, 'DESC');
applyQueryProperty(this, qb, entityName, 'id');
}
};
const dec = MergePropertyDecorators([
NotWritable(),
IntColumn('bigint', {
unsigned: true,
description: idOptions.description,
columnExtras: { nullable: false, primary: true },
}),
Reflect.metadata('design:type', Number),
Generated('increment'),
]);
dec(cl.prototype, 'id');
return cl;
}
export interface StringIdOptions extends IdOptions {
length: number;
uuid?: boolean;
}
export function StringIdBase(idOptions: StringIdOptions) {
const cl = class StringIdBase extends TimeBase {
id: string;
override applyQuery(
qb: SelectQueryBuilder<StringIdBase>,
entityName: string,
) {
super.applyQuery(qb, entityName);
qb.orderBy(`${entityName}.id`, 'ASC');
applyQueryProperty(this, qb, entityName, 'id');
}
};
const decs = [
NotChangeable(),
StringColumn(idOptions.length, {
required: !idOptions.uuid,
description: idOptions.description,
columnExtras: { primary: true, nullable: false },
}),
Reflect.metadata('design:type', String),
IsNotEmpty(),
];
if (idOptions.uuid) {
decs.push(Generated('uuid'));
}
const dec = MergePropertyDecorators(decs);
dec(cl.prototype, 'id');
return cl;
}
export * from './time-base';
export * from './id-base';
import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
import { PageSettingsDto } from '../dto/page-settings';
import { NotColumn } from '../decorators';
export interface DeletionWise {
deleteTime?: Date;
}
export interface ImportWise {
isValidInCreation(): string | undefined;
prepareForSaving(): Promise<void>;
afterSaving(): void;
}
export class TimeBase
extends PageSettingsDto
implements DeletionWise, ImportWise
{
@CreateDateColumn({ select: false })
@NotColumn()
createTime: Date;
@UpdateDateColumn({ select: false })
@NotColumn()
updateTime: Date;
@DeleteDateColumn({ select: false })
@NotColumn()
deleteTime: Date;
toObject() {
return JSON.parse(JSON.stringify(this));
}
isValidInCreation(): string | undefined {
return;
}
async prepareForSaving(): Promise<void> {}
afterSaving() {}
}
export const TimeBaseFields: (keyof TimeBase)[] = [
'createTime',
'updateTime',
'deleteTime',
];
import {
BlankReturnMessageDto,
PageSettingsFactory,
PaginatedReturnMessageDto,
QueryWise,
ReturnMessageDto,
} from './dto';
import {
DeepPartial,
DeleteResult,
FindOptionsWhere,
Repository,
SelectQueryBuilder,
UpdateResult,
} from 'typeorm';
import { DeletionWise, ImportWise } from './bases';
import { ImportEntry } from './dto/import-entry';
import { ConsoleLogger } from '@nestjs/common';
import { camelCase } from 'typeorm/util/StringUtils';
import _ from 'lodash';
import { ClassType } from './utility/insert-field';
export type EntityId<T extends { id: any }> = T['id'];
export interface RelationDef {
name: string;
inner?: boolean;
}
export const Inner = (name: string): RelationDef => {
return { name, inner: true };
};
export type ValidCrudEntity<T> = Record<string, any> & {
id: any;
} & QueryWise<T> &
DeletionWise &
ImportWise &
PageSettingsFactory;
export interface CrudOptions<T extends ValidCrudEntity<T>> {
relations: (string | RelationDef)[];
extraGetQuery: (qb: SelectQueryBuilder<T>) => void;
hardDelete?: boolean;
}
export class CrudBase<T extends ValidCrudEntity<T>> {
readonly entityName = this.entityClass.name;
readonly entityReturnMessageDto = ReturnMessageDto(this.entityClass);
readonly entityPaginatedReturnMessageDto = PaginatedReturnMessageDto(
this.entityClass,
);
readonly entityRelations: (string | RelationDef)[];
readonly extraGetQuery: (qb: SelectQueryBuilder<T>) => void;
readonly log = new ConsoleLogger(`${this.entityClass.name}Service`);
constructor(
public entityClass: ClassType<T>,
public repo: Repository<T>,
public crudOptions: CrudOptions<T>,
) {
this.entityRelations = crudOptions.relations || [];
// eslint-disable-next-line @typescript-eslint/no-empty-function
this.extraGetQuery = crudOptions.extraGetQuery || ((qb) => {});
}
async batchCreate(
ents: T[],
beforeCreate?: (repo: Repository<T>) => Promise<void>,
skipErrors = false,
) {
const entsWithId = ents.filter((ent) => ent.id != null);
return await this.repo.manager.transaction(async (mdb) => {
let skipped: { result: string; entry: T }[] = [];
const repo = mdb.getRepository(this.entityClass);
if (entsWithId.length) {
const existingEnts = await repo.find({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
where: { id: In<string | number>(entsWithId.map((ent) => ent.id)) },
select: ['id', 'deleteTime'],
withDeleted: true,
});
if (existingEnts.length) {
const existingEntsWithoutDeleteTime = existingEnts.filter(
(ent) => ent.deleteTime == null,
);
const existingEntsWithDeleteTime = existingEnts.filter(
(ent) => ent.deleteTime != null,
);
if (existingEntsWithoutDeleteTime.length) {
if (!skipErrors) {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${existingEntsWithoutDeleteTime.join(
',',
)} already exists`,
).toException();
}
const skippedEnts = ents.filter((ent) =>
existingEntsWithoutDeleteTime.some((e) => e.id === ent.id),
);
skipped = skippedEnts.map((ent) => ({
result: 'Already exists',
entry: ent,
}));
const skippedEntsSet = new Set(skippedEnts);
ents = ents.filter((ent) => !skippedEntsSet.has(ent));
}
if (existingEntsWithDeleteTime.length) {
await repo.delete(
existingEntsWithDeleteTime.map((ent) => ent.id) as any[],
);
}
}
}
if (beforeCreate) {
await beforeCreate(repo);
}
try {
const results = await repo.save(ents as DeepPartial<T>[]);
return {
results,
skipped,
};
} catch (e) {
this.log.error(
`Failed to create entity ${JSON.stringify(ents)}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
});
}
async create(ent: T, beforeCreate?: (repo: Repository<T>) => Promise<void>) {
const savedEnt = await this.repo.manager.transaction(async (mdb) => {
const repo = mdb.getRepository(this.entityClass);
if (ent.id != null) {
const existingEnt = await repo.findOne({
where: { id: ent.id },
select: ['id', 'deleteTime'],
withDeleted: true,
});
if (existingEnt) {
if (existingEnt.deleteTime) {
await repo.delete(existingEnt.id);
} else {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${ent.id} already exists`,
).toException();
}
}
}
if (beforeCreate) {
await beforeCreate(repo);
}
try {
return await repo.save(ent as DeepPartial<T>);
} catch (e) {
this.log.error(
`Failed to create entity ${JSON.stringify(ent)}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
});
return new this.entityReturnMessageDto(201, 'success', savedEnt);
}
get entityAliasName() {
return camelCase(this.entityName);
}
applyRelationToQuery(qb: SelectQueryBuilder<T>, relation: RelationDef) {
const { name } = relation;
const relationUnit = name.split('.');
const base =
relationUnit.length === 1
? this.entityAliasName
: relationUnit.slice(0, relationUnit.length - 1).join('_');
const property = relationUnit[relationUnit.length - 1];
const properyAlias = relationUnit.join('_');
const methodName = relation.inner
? 'innerJoinAndSelect'
: ('leftJoinAndSelect' as const);
qb[methodName](`${base}.${property}`, properyAlias);
}
applyRelationsToQuery(qb: SelectQueryBuilder<T>) {
for (const relation of this.entityRelations) {
if (typeof relation === 'string') {
this.applyRelationToQuery(qb, { name: relation });
} else {
this.applyRelationToQuery(qb, relation);
}
}
}
queryBuilder() {
return this.repo.createQueryBuilder(this.entityAliasName);
}
async findOne(
id: EntityId<T>,
// eslint-disable-next-line @typescript-eslint/no-empty-function
extraQuery: (qb: SelectQueryBuilder<T>) => void = () => {},
) {
const query = this.queryBuilder()
.where(`${this.entityAliasName}.id = :id`, { id })
.take(1);
this.applyRelationsToQuery(query);
this.extraGetQuery(query);
extraQuery(query);
query.take(1);
let ent: T;
try {
ent = await query.getOne();
} catch (e) {
const [sql, params] = query.getQueryAndParameters();
this.log.error(
`Failed to read entity ID ${id} with SQL ${sql} param ${params.join(
',',
)}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
if (!ent) {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${id} not found.`,
).toException();
}
return new this.entityReturnMessageDto(200, 'success', ent);
}
async findAll(
ent?: Partial<T>,
// eslint-disable-next-line @typescript-eslint/no-empty-function
extraQuery: (qb: SelectQueryBuilder<T>) => void = () => {},
) {
const query = this.queryBuilder();
if (ent) {
const newEnt = new this.entityClass();
Object.assign(newEnt, ent);
newEnt.applyQuery(query, this.entityAliasName);
}
this.applyRelationsToQuery(query);
this.extraGetQuery(query);
extraQuery(query);
try {
const [ents, count] = await query.getManyAndCount();
return new this.entityPaginatedReturnMessageDto(
200,
'success',
ents,
count,
ent.getActualPageSettings(),
);
} catch (e) {
const [sql, params] = query.getQueryAndParameters();
this.log.error(
`Failed to read entity cond ${JSON.stringify(
ent,
)} with SQL ${sql} param ${params.join(',')}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
}
async update(
id: EntityId<T>,
entPart: Partial<T>,
cond: FindOptionsWhere<T> = {},
) {
let result: UpdateResult;
try {
result = await this.repo.update(
{
id,
...cond,
},
entPart,
);
} catch (e) {
this.log.error(
`Failed to update entity ID ${id} to ${JSON.stringify(
entPart,
)}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
if (!result.affected) {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${id} not found.`,
).toException();
}
return new BlankReturnMessageDto(200, 'success');
}
async remove(id: EntityId<T>, cond: FindOptionsWhere<T> = {}) {
let result: UpdateResult | DeleteResult;
const searchCond = {
id,
...cond,
};
try {
result = await (this.crudOptions.hardDelete
? this.repo.delete(searchCond)
: this.repo.softDelete(searchCond));
} catch (e) {
this.log.error(`Failed to delete entity ID ${id}: ${e.toString()}`);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
if (!result.affected) {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${id} not found.`,
).toException();
}
return new BlankReturnMessageDto(204, 'success');
}
async importEntities(
ents: T[],
extraChecking?: (ent: T) => string | Promise<string>,
): Promise<ImportEntry<T>[]> {
const invalidResults = _.compact(
await Promise.all(
ents.map(async (ent) => {
const reason = ent.isValidInCreation();
if (reason) {
return { entry: ent, result: reason };
}
if (extraChecking) {
const reason = await extraChecking(ent);
if (reason) {
return { entry: ent, result: reason };
}
}
}),
),
);
const remainingEnts = ents.filter(
(ent) => !invalidResults.find((result) => result.entry === ent),
);
await Promise.all(remainingEnts.map((ent) => ent.prepareForSaving()));
const data = await this.batchCreate(remainingEnts, undefined, true);
data.results.forEach((e) => {
if (e.afterSaving) {
e.afterSaving();
}
});
const results = [
...invalidResults,
...data.skipped,
...data.results.map((e) => ({ entry: e, result: 'OK' })),
];
return results;
}
async exists(id: EntityId<T>): Promise<boolean> {
const ent = await this.repo.findOne({ where: { id }, select: ['id'] });
return !!ent;
}
}
export function CrudService<T extends ValidCrudEntity<T>>(
entityClass: ClassType<T>,
crudOptions: CrudOptions<T>,
) {
return class CrudServiceImpl extends CrudBase<T> {
constructor(repo: Repository<T>) {
super(entityClass, repo, crudOptions);
}
};
}
import { Expose } from 'class-transformer';
import { IsOptional } from 'class-validator';
import { MergePropertyDecorators } from './merge';
export const NotWritable = () =>
MergePropertyDecorators([Expose({ groups: ['r'] }), IsOptional()]);
export const NotChangeable = () => Expose({ groups: ['r', 'c'] });
export * from './access';
export * from './merge';
export * from './property';
export * from './restful';
export * from './pipes';
export function MergePropertyDecorators(
decs: PropertyDecorator[],
): PropertyDecorator {
return (obj, key) => {
for (const dec of decs) {
dec(obj, key);
}
};
}
export function MergeMethodDecorators(
decs: MethodDecorator[],
): MethodDecorator {
return (obj, key, descriptor) => {
for (const dec of decs) {
dec(obj, key, descriptor);
}
};
}
export function MergeClassDecorators(decs: ClassDecorator[]): ClassDecorator {
return (obj) => {
for (const dec of decs) {
dec(obj);
}
};
}
export function MergeParameterDecorators(
decs: ParameterDecorator[],
): ParameterDecorator {
return (obj, key, index) => {
for (const dec of decs) {
dec(obj, key, index);
}
};
}
import { ValidationPipe } from '@nestjs/common';
export const CreatePipe = new ValidationPipe({
transform: true,
transformOptions: { groups: ['c'], enableImplicitConversion: true },
});
export const GetPipe = new ValidationPipe({
transform: true,
transformOptions: { groups: ['r'], enableImplicitConversion: true },
skipMissingProperties: true,
skipNullProperties: true,
skipUndefinedProperties: true,
});
export const UpdatePipe = new ValidationPipe({
transform: true,
transformOptions: { groups: ['u'], enableImplicitConversion: true },
skipMissingProperties: true,
skipNullProperties: true,
skipUndefinedProperties: true,
});
import { ColumnCommonOptions } from 'typeorm/decorator/options/ColumnCommonOptions';
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
import { ColumnWithLengthOptions } from 'typeorm/decorator/options/ColumnWithLengthOptions';
import { MergePropertyDecorators } from './merge';
import { Column, Index } from 'typeorm';
import {
IsDate,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
import {
WithPrecisionColumnType,
WithWidthColumnType,
} from 'typeorm/driver/types/ColumnTypes';
import { ColumnWithWidthOptions } from 'typeorm/decorator/options/ColumnWithWidthOptions';
import { ColumnNumericOptions } from 'typeorm/decorator/options/ColumnNumericOptions';
import { Exclude } from 'class-transformer';
import { BigintTransformer } from '../utility/bigint';
export interface OpenAPIOptions<T> {
description?: string;
propertyExtras?: ApiPropertyOptions;
default?: T;
required?: boolean;
}
function swaggerDecorator(
options: OpenAPIOptions<any>,
injected: ApiPropertyOptions = {},
) {
return ApiProperty({
default: options.default,
required: options.required && options.default == null,
example: options.default,
description: options.description,
...injected,
...(options.propertyExtras || {}),
});
}
function validatorDecorator(options: OpenAPIOptions<any>) {
const decs: PropertyDecorator[] = [];
if (!options.required) {
decs.push(IsOptional());
}
return MergePropertyDecorators(decs);
}
export interface PropertyOptions<T, ColumnEx = unknown>
extends OpenAPIOptions<T> {
columnExtras?: ColumnCommonOptions & ColumnEx;
}
function columnDecoratorOptions<T>(
options: PropertyOptions<T>,
): ColumnCommonOptions {
return {
default: options.default,
nullable: !options.required && options.default == null,
comment: options.description,
...options.columnExtras,
};
}
export const StringColumn = (
length: number,
options: PropertyOptions<string, ColumnWithLengthOptions> = {},
): PropertyDecorator => {
return MergePropertyDecorators([
Column('varchar', { length, ...columnDecoratorOptions(options) }),
IsString(),
MaxLength(length),
validatorDecorator(options),
swaggerDecorator(options, { type: String, maxLength: length }),
]);
};
export const IntColumn = (
type: WithWidthColumnType,
options: PropertyOptions<string, ColumnWithWidthOptions> & {
unsigned?: boolean;
} = {},
): PropertyDecorator => {
const decs = [
Column(type, {
default: options.default,
unsigned: options.unsigned,
...(type === 'bigint' ? { transformer: new BigintTransformer() } : {}),
...columnDecoratorOptions(options),
}),
IsInt(),
validatorDecorator(options),
swaggerDecorator(options, {
type: Number,
minimum: options.unsigned ? 0 : undefined,
}),
];
if (options.unsigned) {
decs.push(Min(0));
}
return MergePropertyDecorators(decs);
};
export const FloatColumn = (
type: WithPrecisionColumnType,
options: PropertyOptions<string, ColumnNumericOptions> & {
unsigned?: boolean;
} = {},
): PropertyDecorator => {
const decs = [
Column(type, {
default: options.default,
unsigned: options.unsigned,
...columnDecoratorOptions(options),
}),
IsNumber(),
validatorDecorator(options),
swaggerDecorator(options, {
type: Number,
minimum: options.unsigned ? 0 : undefined,
}),
];
if (options.unsigned) {
decs.push(Min(0));
}
return MergePropertyDecorators(decs);
};
export const DateColumn = (
options: PropertyOptions<Date> = {},
): PropertyDecorator => {
return MergePropertyDecorators([
Column('timestamp', columnDecoratorOptions(options)),
IsDate(),
validatorDecorator(options),
swaggerDecorator(options, { type: Date }),
]);
};
export const EnumColumn = <T>(
targetEnum: Record<string, T>,
options: PropertyOptions<T> = {},
): PropertyDecorator => {
return MergePropertyDecorators([
Index(),
Column('enum', {
enum: targetEnum,
...columnDecoratorOptions(options),
}),
IsEnum(targetEnum),
validatorDecorator(options),
swaggerDecorator(options, { type: 'enum', enum: targetEnum }),
]);
};
export const NotColumn = (
options: OpenAPIOptions<any> = {},
): PropertyDecorator =>
MergePropertyDecorators([Exclude(), swaggerDecorator(options)]);
import { Body, Delete, Get, Patch, Post, Query } from '@nestjs/common';
import {
BlankReturnMessageDto,
PaginatedReturnMessageDto,
ReturnMessageDto,
} from '../dto';
import { MergeMethodDecorators } from './merge';
import { ClassType } from '../utility/insert-field';
import { TimeBase, TimeBaseFields } from '../bases';
import {
ApiBody,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { CreatePipe, GetPipe, UpdatePipe } from './pipes';
import { OperationObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
export interface CrudFactoryOptions<T extends TimeBase> {
fieldsToOmit?: (keyof T)[];
}
export class RestfulFactory<T extends TimeBase> {
readonly createDto: ClassType<Omit<T, keyof T>>;
readonly updateDto: ClassType<Partial<Omit<T, keyof T>>>;
readonly entityReturnMessageDto = ReturnMessageDto(this.entityClass);
readonly entityArrayReturnMessageDto = PaginatedReturnMessageDto(
this.entityClass,
);
// eslint-disable-next-line @typescript-eslint/ban-types
readonly idType: Function;
constructor(
public readonly entityClass: ClassType<T>,
private options: CrudFactoryOptions<T> = {},
) {
this.createDto = OmitType(this.entityClass, [
...TimeBaseFields,
...(options.fieldsToOmit || []),
]);
this.updateDto = PartialType(this.createDto);
this.idType = Reflect.getMetadata(
'design:type',
this.entityClass.prototype,
'id',
);
}
create(extras: Partial<OperationObject> = {}): MethodDecorator {
return MergeMethodDecorators([
Post(),
ApiOperation({
summary: `Create a new ${this.entityClass.name}`,
...extras,
}),
ApiBody({ type: this.createDto }),
ApiCreatedResponse({ type: this.entityReturnMessageDto }),
]);
}
createParam() {
return Body(CreatePipe);
}
findOne(extras: Partial<OperationObject> = {}): MethodDecorator {
return MergeMethodDecorators([
Get(':id'),
ApiOperation({
summary: `Find a ${this.entityClass.name} by id`,
...extras,
}),
ApiParam({ name: 'id', type: this.idType, required: true }),
ApiOkResponse({ type: this.entityReturnMessageDto }),
]);
}
findAll(extras: Partial<OperationObject> = {}): MethodDecorator {
return MergeMethodDecorators([
Get(),
ApiOperation({ summary: `Find all ${this.entityClass.name}`, ...extras }),
ApiOkResponse({ type: this.entityArrayReturnMessageDto }),
]);
}
findAllParam() {
return Query(GetPipe);
}
update(extras: Partial<OperationObject> = {}): MethodDecorator {
return MergeMethodDecorators([
Patch(':id'),
ApiOperation({
summary: `Update a ${this.entityClass.name} by id`,
...extras,
}),
ApiParam({ name: 'id', type: this.idType, required: true }),
ApiBody({ type: this.updateDto }),
ApiOkResponse({ type: BlankReturnMessageDto }),
]);
}
updateParam() {
return Body(UpdatePipe);
}
delete(extras: Partial<OperationObject> = {}): MethodDecorator {
return MergeMethodDecorators([
Delete(':id'),
ApiOperation({
summary: `Delete a ${this.entityClass.name} by id`,
...extras,
}),
ApiParam({ name: 'id', type: this.idType, required: true }),
ApiNoContentResponse({ type: BlankReturnMessageDto }),
]);
}
}
import { ApiProperty } from '@nestjs/swagger';
import {
ClassOrArray,
getClassFromClassOrArray,
InsertField,
} from '../utility/insert-field';
export class ImportEntryBaseDto {
@ApiProperty({ description: 'Import result', type: String })
result: string;
}
export interface ImportEntry<T> {
entry: T;
result: string;
}
export function ImportEntryDto<C extends ClassOrArray>(type: C) {
return InsertField(
ImportEntryBaseDto,
{
entry: { type, options: { description: 'Import entry' } },
},
`${getClassFromClassOrArray(type).name}ImportEntry`,
);
}
export * from './page-settings';
export * from './return-message';
import { NotWritable } from '../decorators';
import { SelectQueryBuilder } from 'typeorm';
import { IsInt, IsPositive } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export interface PageSettingsWise {
pageCount: number;
recordsPerPage: number;
}
export interface PageSettingsFactory {
getActualPageSettings(): PageSettingsWise;
}
export interface QueryWise<T> {
applyQuery(qb: SelectQueryBuilder<T>, entityName: string): void;
}
export class PageSettingsDto
implements PageSettingsWise, PageSettingsFactory, QueryWise<PageSettingsDto>
{
@NotWritable()
@IsPositive()
@IsInt()
@ApiProperty({
description: 'The nth page, starting with 1.',
required: false,
type: Number,
minimum: 1,
})
pageCount: number;
@NotWritable()
@IsPositive()
@IsInt()
@ApiProperty({
description: 'Records per page.',
required: false,
type: Number,
minimum: 1,
})
recordsPerPage: number;
getActualPageSettings(): PageSettingsWise {
return {
pageCount: this.getPageCount(),
recordsPerPage: this.getRecordsPerPage(),
};
}
getPageCount() {
return this.pageCount || 1;
}
getRecordsPerPage() {
return this.recordsPerPage || 25;
}
getStartingFrom() {
return (this.getPageCount() - 1) * this.getRecordsPerPage();
}
applyQuery(qb: SelectQueryBuilder<PageSettingsDto>, entityName: string) {
qb.take(this.getRecordsPerPage()).skip(this.getStartingFrom());
}
}
import { ApiProperty } from '@nestjs/swagger';
import { HttpException } from '@nestjs/common';
import { PageSettingsWise } from './page-settings';
import {
AnyClass,
ClassOrArray,
getClassFromClassOrArray,
InsertField,
} from '../utility/insert-field';
export interface BlankReturnMessage {
statusCode: number;
message: string;
success: boolean;
}
export interface ReturnMessage<T> extends BlankReturnMessage {
data?: T;
}
export class BlankReturnMessageDto implements BlankReturnMessage {
@ApiProperty({ description: 'Return code', type: Number })
statusCode: number;
@ApiProperty({ description: 'Return message', type: String })
message: string;
@ApiProperty({ description: 'Whether success.', type: Boolean })
success: boolean;
constructor(statusCode: number, message?: string) {
this.statusCode = statusCode;
this.message = message || 'success';
this.success = statusCode < 400;
}
toException() {
return new HttpException(this, this.statusCode);
}
}
export class BlankPaginatedReturnMessageDto
extends BlankReturnMessageDto
implements PageSettingsWise
{
@ApiProperty({ description: 'Total record count.', type: Number })
total: number;
@ApiProperty({ description: 'Total page count.', type: Number })
totalPages: number;
@ApiProperty({ description: 'Current page.', type: Number })
pageCount: number;
@ApiProperty({ description: 'Records per page.', type: Number })
recordsPerPage: number;
constructor(
statusCode: number,
message: string,
total: number,
pageSettings: PageSettingsWise,
) {
super(statusCode, message);
this.total = total;
this.pageCount = pageSettings.pageCount;
this.recordsPerPage = pageSettings.recordsPerPage;
this.totalPages = Math.ceil(total / pageSettings.recordsPerPage);
}
}
export class GenericReturnMessageDto<T>
extends BlankReturnMessageDto
implements ReturnMessage<T>
{
data?: T;
constructor(statusCode: number, message?: string, data?: T) {
super(statusCode, message);
this.data = data;
}
}
export function ReturnMessageDto<T extends ClassOrArray>(type: T) {
return InsertField(
GenericReturnMessageDto,
{
data: {
type,
options: {
required: false,
description: 'Return data.',
},
},
},
`${getClassFromClassOrArray(type).name}ReturnMessageDto`,
);
}
export class GenericPaginatedReturnMessageDto<T>
extends BlankPaginatedReturnMessageDto
implements PageSettingsWise, ReturnMessage<T[]>
{
data: T[];
constructor(
statusCode: number,
message: string,
data: T[],
total: number,
pageSettings: PageSettingsWise,
) {
super(statusCode, message, total, pageSettings);
this.data = data;
}
}
export function PaginatedReturnMessageDto<T extends AnyClass>(type: T) {
return InsertField(
GenericPaginatedReturnMessageDto,
{
data: {
type: [type],
options: {
required: false,
description: 'Return data.',
},
},
},
`${getClassFromClassOrArray(type).name}PaginatedReturnMessageDto`,
);
}
export class StringReturnMessageDto extends GenericReturnMessageDto<string> {
@ApiProperty({ description: 'Return data.', type: String, required: false })
data?: string;
}
import { ValueTransformer } from 'typeorm';
export class BigintTransformer implements ValueTransformer {
from(dbValue) {
if (dbValue == null) {
return dbValue;
}
return parseInt(dbValue);
}
to(entValue): any {
return entValue;
}
}
export * from './query';
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
export type AnyClass = new (...args: any[]) => any;
export type ClassOrArray = AnyClass | [AnyClass];
export type ClassType<T> = new (...args: any[]) => T;
export type TypeFromClass<T> = T extends new (...args: any[]) => infer U
? U
: never;
export type ParamsFromClass<T> = T extends new (...args: infer U) => any
? U
: never;
export type ParseType<IC extends ClassOrArray> = IC extends [infer U]
? TypeFromClass<U>[]
: TypeFromClass<IC>;
export function getClassFromClassOrArray(o: ClassOrArray) {
return o instanceof Array ? o[0] : o;
}
export interface InsertOptions<C extends ClassOrArray = ClassOrArray> {
type: C;
options?: ApiPropertyOptions;
}
type TypeFromInsertOptions<O extends InsertOptions> = O extends InsertOptions<
infer C
>
? ParseType<C>
: never;
export function InsertField<
C extends AnyClass,
M extends Record<string, InsertOptions>,
>(
cl: C,
map: M,
newName?: string,
): new (...args: ParamsFromClass<C>) => TypeFromClass<C> & {
[F in keyof M]: TypeFromInsertOptions<M[F]>;
} {
const extendedCl = class extends cl {};
for (const key in map) {
ApiProperty({
type: map[key].type,
...(map[key].options || {}),
})(extendedCl.prototype, key);
}
Object.defineProperty(cl, 'name', {
value: newName || cl.name,
});
return extendedCl;
}
import { SelectQueryBuilder } from 'typeorm';
import { TimeBase } from '../bases/time-base';
export function applyQueryProperty<T extends TimeBase>(
obj: T,
qb: SelectQueryBuilder<T>,
entityName: string,
...fields: (keyof T & string)[]
) {
for (const field of fields) {
if (obj[field] == null) {
continue;
}
qb.andWhere(`${entityName}.${field} = :${field}`, { [field]: obj[field] });
}
}
export function applyQueryPropertyLike<T extends TimeBase>(
obj: T,
qb: SelectQueryBuilder<T>,
entityName: string,
...fields: (keyof T & string)[]
) {
for (const field of fields) {
if (obj[field] == null) {
continue;
}
qb.andWhere(`${entityName}.${field} like (:${field} || '%')`, {
[field]: obj[field],
});
}
}
export function applyQueryPropertySearch<T extends TimeBase>(
obj: T,
qb: SelectQueryBuilder<T>,
entityName: string,
...fields: (keyof T & string)[]
) {
for (const field of fields) {
if (obj[field] == null) {
continue;
}
qb.andWhere(`${entityName}.${field} like ('%' || :${field} || '%')`, {
[field]: obj[field],
});
}
}
describe('Sample test.', () => {
it('should pass', () => {
expect(true).toBe(true);
});
});
{
"compilerOptions": {
"outDir": "dist",
"module": "commonjs",
"target": "es2021",
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"declaration": true,
"sourceMap": true
},
"compileOnSave": true,
"allowJs": true,
"include": [
"*.ts",
"src/**/*.ts",
"test/**/*.ts",
"tests/**/*.ts"
]
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment