├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.json ├── .npmrc ├── README.md ├── cspell.json ├── docker-compose.yml ├── eslint.config.js ├── lib ├── collections │ ├── entity.collection.ts │ └── pagination.collection.ts ├── constants │ └── type.constant.ts ├── containers │ └── data-source-container.ts ├── decorators │ ├── custom-repository.decorator.ts │ └── relation-condition.decorator.ts ├── entities │ └── base.entity.ts ├── helper.ts ├── helpers │ └── relation.helper.ts ├── index.ts ├── interfaces │ ├── query.interface.ts │ ├── relation-condition.interface.ts │ └── where-expression.interface.ts ├── metadata │ ├── entity-metadata.ts │ └── metadata-args-storage.ts ├── modules │ └── typeorm-module.module.ts ├── operators │ └── array.operator.ts ├── queries │ ├── base.query.ts │ └── collection.query.ts ├── query-builders │ ├── relation.query-builder.ts │ └── select.query-builder.ts ├── repositories │ ├── base.repository.ts │ ├── mongo.repository.ts │ └── repository.ts ├── transformers │ └── updated-at-timestamp.transformer.ts ├── types │ └── pagination-options.type.ts └── where-expression │ ├── base.where-expression.ts │ └── collection.where-expression.ts ├── nest-cli.json ├── ormconfig.json ├── package-lock.json ├── package.json ├── prettier.config.js ├── renovate.json ├── sample ├── entities │ ├── category.entity.ts │ ├── post-category.entity.ts │ ├── post.entity.ts │ └── user.entity.ts ├── queries │ └── post-of-user.query.ts ├── repositories │ ├── category.repository.ts │ ├── post.repository.ts │ └── user.repository.ts └── where-expression │ └── belong-to-user.where-expression.ts ├── tests ├── many-to-many-relation-not-owner.spec.ts ├── many-to-many-relation-owner.spec.ts ├── many-to-one-relation.spec.ts ├── one-to-many-relation.spec.ts ├── one-to-one-custom.spec.ts ├── query-builder.spec.ts └── test-helper.ts └── tsconfig.json /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | lint: 8 | uses: hodfords-solutions/actions/.github/workflows/lint.yaml@main 9 | build: 10 | uses: hodfords-solutions/actions/.github/workflows/publish.yaml@main 11 | with: 12 | build_path: dist/lib 13 | secrets: 14 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 15 | update-docs: 16 | uses: hodfords-solutions/actions/.github/workflows/update-doc.yaml@main 17 | needs: build 18 | secrets: 19 | DOC_SSH_PRIVATE_KEY: ${{ secrets.DOC_SSH_PRIVATE_KEY }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | src/**/*.js 4 | src/**/*.js.map 5 | .idea -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | changedFiles="$(git diff --name-only --cached)" 2 | npm run cspell --no-must-find-files ${changedFiles} 3 | npm run lint-staged 4 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "libs/**/*.ts": ["eslint --fix --max-warnings 0"] 3 | } -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} 2 | registry=https://registry.npmjs.org/ 3 | always-auth=true -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Hodfords Logo 3 |

4 | 5 |

nestjs-validation enhances validation in your NestJS projects by providing a customized `ValidationPipe` that returns custom error messages. This library simplifies error handling by offering localized and user-friendly responses

6 | 7 | ## Installation 🤖 8 | 9 | Install the `typeorm-helper` package with: 10 | 11 | ```bash 12 | npm install @hodfords/typeorm-helper --save 13 | ``` 14 | 15 | ## Usage 🚀 16 | 17 | ### Defining custom repositories and entities 18 | 19 | When managing different entities, you can define custom repositories and entities. Below is an example for the Category entity and its corresponding repository. 20 | 21 | #### Entity 22 | 23 | The `Category` table in the database is modeled by the `CategoryEntity`, `typeorm` decorators should be used to define this entity. 24 | 25 | ```typescript 26 | import { BaseEntity } from '@hodfords/typeorm-helper'; 27 | import { Column, Entity, ManyToMany, JoinTable, PrimaryGeneratedColumn } from 'typeorm'; 28 | 29 | @Entity('Category') 30 | export class CategoryEntity extends BaseEntity { 31 | @PrimaryGeneratedColumn() 32 | id: number; 33 | 34 | @Column() 35 | name: string; 36 | 37 | @ManyToMany(() => PostEntity, (post) => post.categories) 38 | @JoinTable({ name: 'PostCategory' }) 39 | posts: PostEntity[]; 40 | } 41 | ``` 42 | 43 | #### Repository 44 | 45 | The `CategoryRepository` is a custom repository that handles all database operations related to the `CategoryEntity`. By using the `@CustomRepository` decorator and extending `BaseRepository`, you ensure that your repository has both common CRUD functionality and can be easily customized with entity-specific methods. 46 | 47 | ```typescript 48 | import { CustomRepository, BaseRepository } from '@hodfords/typeorm-helper'; 49 | 50 | @CustomRepository(CategoryEntity) 51 | export class CategoryRepository extends BaseRepository {} 52 | ``` 53 | 54 | ### Lazy Relations 55 | 56 | Lazy relations allow you to load related entities only when they are needed. This can significantly improve performance by preventing the fetching of unnecessary data upfront. 57 | 58 | This functionality supports handling single entity, collection of entities, and paginated collection. Below is an example of how to load a list of posts associated with a specific category. 59 | 60 | ##### Single entity 61 | 62 | ```typescript 63 | const categoryRepo = getDataSource().getCustomRepository(CategoryRepository); 64 | const category = await categoryRepo.findOne({}); 65 | await category.loadRelation(['posts']); 66 | ``` 67 | 68 | ##### Collection of entities 69 | 70 | ```typescript 71 | const categoryRepo = getDataSource().getCustomRepository(CategoryRepository); 72 | const categories = await categoryRepo.findOne({ name: ILIKE('%football' }); 73 | await this.categories.loadRelations(['posts']); 74 | ``` 75 | 76 | ##### Paginate collection 77 | 78 | ```typescript 79 | const categoryRepo = getDataSource().getCustomRepository(CategoryRepository); 80 | const pagedCategories = await categoryRepo.pagination({}, { page: 1, perPage: 10 }); 81 | await pagedCategories.loadRelation('posts'); 82 | ``` 83 | 84 | You can also make use of the loadRelations function to efficiently load and retrieve related data 85 | 86 | ```typescript 87 | await loadRelations(categories, ['posts']); 88 | ``` 89 | 90 | ### Relation Condition 91 | 92 | Sometimes, you need to add custom conditions when loading related entities. `typeorm-helper` provides the 93 | `@RelationCondition` decorator for this purpose. 94 | 95 | ##### Simple condition 96 | 97 | This ensures that the posts relation is only loaded when the condition `posts.id = :postId` is satisfied. 98 | 99 | ```typescript 100 | @Entity('User') 101 | export class UserEntity extends BaseEntity { 102 | @PrimaryGeneratedColumn() 103 | id: number; 104 | 105 | @Column() 106 | name: string; 107 | 108 | @RelationCondition((query: SelectQueryBuilder) => { 109 | query.where(' posts.id = :postId', { postId: 1 }); 110 | }) 111 | @OneToMany(() => PostEntity, (post) => post.user, { cascade: true }) 112 | posts: PostEntity[]; 113 | 114 | @RelationCondition((query: SelectQueryBuilder, entities) => { 115 | query.orderBy('id', 'DESC'); 116 | if (entities.length === 1) { 117 | query.limit(1); 118 | } else { 119 | query.andWhere( 120 | ' "latestPost".id in (select max(id) from "post" "maxPost" where "maxPost"."userId" = "latestPost"."userId")' 121 | ); 122 | } 123 | }) 124 | @OneToOne(() => PostEntity, (post) => post.user, { cascade: true }) 125 | latestPost: PostEntity; 126 | } 127 | ``` 128 | 129 | #### Complex condition 130 | 131 | Here, the condition applies a limit if only one entity is found, and fetches the latest post for each user if there are multiple entities. 132 | 133 | ```typescript 134 | @Entity('User') 135 | export class UserEntity extends BaseEntity { 136 | @PrimaryGeneratedColumn() 137 | id: number; 138 | 139 | @Column() 140 | name: string; 141 | 142 | @RelationCondition( 143 | (query: SelectQueryBuilder) => { 144 | query.where(' posts.id = :postId', { postId: 1 }); 145 | }, 146 | (entity, result, column) => { 147 | return entity.id !== 2; 148 | } 149 | ) 150 | @OneToMany(() => PostEntity, (post) => post.user, { cascade: true }) 151 | posts: PostEntity[]; 152 | } 153 | ``` 154 | 155 | ### Where Expression 156 | 157 | For complex queries that need to be reused or involve a lot of logic, it's best to put them in a class 158 | 159 | ```typescript 160 | export class BelongToUserWhereExpression extends BaseWhereExpression { 161 | constructor(private userId: number) { 162 | super(); 163 | } 164 | 165 | where(query: WhereExpression) { 166 | query.where({ userId: this.userId }); 167 | } 168 | } 169 | ``` 170 | 171 | ```typescript 172 | const posts = await this.postRepo.find({ where: new BelongToUserWhereExpression(1) }); 173 | ``` 174 | 175 | ### Query Builder 176 | 177 | For complex and reusable queries, it's helpful to put the logic inside a class. This makes it easier to manage and reuse the query, resulting in cleaner and more maintainable code. 178 | 179 | ```typescript 180 | export class PostOfUserQuery extends BaseQuery { 181 | constructor(private userId: number) { 182 | super(); 183 | } 184 | 185 | query(query: SelectQueryBuilder) { 186 | query.where({ userId: this.userId }).limit(10); 187 | } 188 | 189 | order(query: SelectQueryBuilder) { 190 | query.orderBy('id', 'DESC'); 191 | } 192 | } 193 | ``` 194 | 195 | ```typescript 196 | const posts = await this.postRepo.find(new PostOfUserQuery(1)); 197 | ``` 198 | 199 | ## License 📝 200 | 201 | This project is licensed under the MIT License 202 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en", 4 | "words": [ 5 | "nestjs", 6 | "typeorm", 7 | "Metadatas", 8 | "bytea", 9 | "hodfords", 10 | "Tien", 11 | "ormconfig", 12 | "npmjs", 13 | "ILIKE", 14 | "nextval", 15 | "oids", 16 | "postbuild" 17 | ], 18 | "flagWords": ["hte"], 19 | "ignorePaths": ["node_modules", "test", "*.spec.ts", "cspell.json"] 20 | } 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | adminer: 4 | image: adminer:latest 5 | ports: 6 | - 9080:8080 7 | networks: 8 | - webnet 9 | postgres: 10 | image: postgres:11 11 | volumes: 12 | - data-volume:/data/db 13 | ports: 14 | - 9432:5432 15 | environment: 16 | POSTGRES_PASSWORD: test 17 | POSTGRES_USER: test 18 | POSTGRES_DB: test 19 | networks: 20 | - webnet 21 | networks: 22 | webnet: 23 | volumes: 24 | data-volume: 25 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@hodfords/nestjs-eslint-config'); 2 | -------------------------------------------------------------------------------- /lib/collections/entity.collection.ts: -------------------------------------------------------------------------------- 1 | import { loadRelations, RelationParams } from '../helper'; 2 | 3 | export class EntityCollection extends Array { 4 | public collect(entities: Entity[]): EntityCollection { 5 | entities.forEach((entity) => this.push(entity)); 6 | return this; 7 | } 8 | 9 | async loadRelation(relationParams: RelationParams, columns?: string[]): Promise { 10 | await loadRelations(this, relationParams, columns); 11 | return this; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/collections/pagination.collection.ts: -------------------------------------------------------------------------------- 1 | import { RelationParams } from '../helper'; 2 | import { EntityCollection } from './entity.collection'; 3 | 4 | export class PaginationCollection { 5 | public items: EntityCollection; 6 | public total: number = 0; 7 | public lastPage: number = 0; 8 | public perPage: number = 0; 9 | public currentPage: number = 0; 10 | 11 | constructor(data: { items: any[]; total: number; lastPage: number; perPage: number; currentPage: number }) { 12 | if (!(this.items instanceof EntityCollection)) { 13 | this.items = new EntityCollection().collect(data.items); 14 | } 15 | this.total = data.total; 16 | this.lastPage = data.lastPage; 17 | this.perPage = data.perPage; 18 | this.currentPage = data.currentPage; 19 | } 20 | 21 | async loadRelation(relationParams: RelationParams, columns?: string[]): Promise { 22 | await this.items.loadRelation(relationParams, columns); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/constants/type.constant.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_DATA_SOURCE_NAME = 'default'; 2 | -------------------------------------------------------------------------------- /lib/containers/data-source-container.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from 'typeorm'; 2 | 3 | const dataSourceContainer: Map = new Map(); 4 | 5 | export function getDataSource(dataSourceName = 'default') { 6 | return dataSourceContainer.get(dataSourceName); 7 | } 8 | 9 | export function setDataSource(dataSource: DataSource, dataSourceName = 'default') { 10 | dataSourceContainer.set(dataSourceName, dataSource); 11 | } 12 | -------------------------------------------------------------------------------- /lib/decorators/custom-repository.decorator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-function-type */ 2 | import { SetMetadata } from '@nestjs/common'; 3 | 4 | export const TYPEORM_EX_CUSTOM_REPOSITORY = 'TYPEORM_EX_CUSTOM_REPOSITORY'; 5 | 6 | export function CustomRepository(entity: Function): ClassDecorator { 7 | return SetMetadata(TYPEORM_EX_CUSTOM_REPOSITORY, entity); 8 | } 9 | -------------------------------------------------------------------------------- /lib/decorators/relation-condition.decorator.ts: -------------------------------------------------------------------------------- 1 | import { getMetadataArgsStorage, SelectQueryBuilder } from 'typeorm'; 2 | import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; 3 | import { EntityCollection } from '../collections/entity.collection'; 4 | 5 | export function RelationCondition( 6 | query: (query: SelectQueryBuilder, entities: any[]) => void, 7 | map?: (entity, result, column: ColumnMetadata) => boolean 8 | ): PropertyDecorator { 9 | return function (object: object, propertyName: string) { 10 | const type = Reflect.getMetadata('design:type', object, propertyName); 11 | const metadataArgsStorage = getMetadataArgsStorage(); 12 | if (!metadataArgsStorage.relationConditions) { 13 | metadataArgsStorage.relationConditions = []; 14 | } 15 | metadataArgsStorage.relationConditions.push({ 16 | target: object.constructor, 17 | propertyName: propertyName, 18 | options: { query, map }, 19 | isArray: type === Array || type === EntityCollection 20 | }); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /lib/entities/base.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity as AbstractEntity } from 'typeorm'; 2 | import { loadRelations, RelationParams } from '../helper'; 3 | 4 | export abstract class BaseEntity extends AbstractEntity { 5 | async loadRelation(relationNames: RelationParams, columns?: string[]): Promise { 6 | await loadRelations(this, relationNames, columns); 7 | return this; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/helper.ts: -------------------------------------------------------------------------------- 1 | import { RelationQueryBuilder } from './query-builders/relation.query-builder'; 2 | import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; 3 | import { WhereExpressionInterface } from './interfaces/where-expression.interface'; 4 | import { CollectionWhereExpression } from './where-expression/collection.where-expression'; 5 | import { QueryInterface } from './interfaces/query.interface'; 6 | import { CollectionQuery } from './queries/collection.query'; 7 | import { getChildEntitiesAndRelationName, getEntities, groupRelationName } from './helpers/relation.helper'; 8 | 9 | export type RelationParams = 10 | | string 11 | | string[] 12 | | (string | { [key: string]: (name: SelectQueryBuilder) => void })[]; 13 | 14 | export async function loadRelations(entities, relationNames: RelationParams, columns?: string[]) { 15 | if (!entities) { 16 | return; 17 | } 18 | entities = getEntities(entities); 19 | if (!entities.length) { 20 | return; 21 | } 22 | if (typeof relationNames === 'string') { 23 | await loadRelation(entities, relationNames, columns); 24 | } else { 25 | const relationGroups = groupRelationName(relationNames); 26 | for (const relations of Object.values(relationGroups)) { 27 | await Promise.all( 28 | relations.map(async (relation) => { 29 | await loadRelation(entities, relation.name, columns, relation.customQuery); 30 | }) 31 | ); 32 | } 33 | } 34 | } 35 | 36 | async function loadRelation(entities, relationName: string, columns?: string[], customQuery = null) { 37 | entities = getEntities(entities); 38 | if (relationName.includes('.')) { 39 | const childEntity = getChildEntitiesAndRelationName(entities, relationName); 40 | entities = childEntity.entities; 41 | relationName = childEntity.relationName; 42 | if (!entities.length) { 43 | return; 44 | } 45 | } 46 | const relationQueryBuilder = new RelationQueryBuilder(entities, relationName); 47 | if (customQuery) { 48 | relationQueryBuilder.addCustomQuery(customQuery); 49 | } 50 | if (columns?.length) { 51 | columns = columns.map((column) => (column.includes('.') ? column : `${relationName}.${column}`)); 52 | relationQueryBuilder.addCustomQuery((query: SelectQueryBuilder) => { 53 | query.select(columns); 54 | }); 55 | } 56 | await relationQueryBuilder.load(); 57 | } 58 | 59 | export function collectExpression(whereExpressions: WhereExpressionInterface[]) { 60 | return new CollectionWhereExpression(whereExpressions); 61 | } 62 | 63 | export function collectQuery(queries: QueryInterface[]) { 64 | return new CollectionQuery(queries); 65 | } 66 | -------------------------------------------------------------------------------- /lib/helpers/relation.helper.ts: -------------------------------------------------------------------------------- 1 | import { concat, get, groupBy, last, orderBy } from 'lodash'; 2 | import { RelationParams } from '../helper'; 3 | import { SelectQueryBuilder } from 'typeorm'; 4 | 5 | export type RelationGroupType = { 6 | level: number; 7 | name: string; 8 | customQuery?: (name: SelectQueryBuilder) => void; 9 | }; 10 | 11 | export function getEntities(entities: T | T[]): T[] { 12 | return Array.isArray(entities) ? entities : [entities]; 13 | } 14 | 15 | export function getEntitiesByPaths(entities: any[], relationPaths: string[], index: number = 0) { 16 | let childEntities = []; 17 | for (const entity of entities) { 18 | const childEntity = get(entity, relationPaths[index]); 19 | if (!childEntity) { 20 | continue; 21 | } 22 | if (Array.isArray(childEntity)) { 23 | if (childEntity.length) { 24 | childEntities = childEntities.concat(childEntity); 25 | } 26 | } else { 27 | childEntities.push(childEntity); 28 | } 29 | } 30 | if (index < relationPaths.length - 1) { 31 | return getEntitiesByPaths(childEntities, relationPaths, index + 1); 32 | } 33 | return childEntities; 34 | } 35 | 36 | export function getChildEntitiesAndRelationName(entities: any[], relationName: string) { 37 | const relationPaths = relationName.split('.'); 38 | const childEntities = getEntitiesByPaths(entities, relationPaths.slice(0, -1)); 39 | let newEntities = []; 40 | for (const entity of childEntities) { 41 | if (Array.isArray(entity)) { 42 | newEntities = concat(newEntities, ...entity); 43 | } else { 44 | newEntities.push(entity); 45 | } 46 | } 47 | return { 48 | entities: newEntities, 49 | relationName: last(relationPaths) 50 | }; 51 | } 52 | 53 | export function groupRelationName(relationNames: RelationParams): Record { 54 | const relations: RelationGroupType[] = []; 55 | for (const relationName of relationNames) { 56 | if (typeof relationName === 'string') { 57 | relations.push({ 58 | level: relationName.split('.').length, 59 | name: relationName 60 | }); 61 | } else { 62 | const key = Object.keys(relationName)[0]; 63 | relations.push({ 64 | level: key.split('.').length, 65 | name: key, 66 | customQuery: relationName[key] 67 | }); 68 | } 69 | } 70 | return groupBy(orderBy(relations, 'level'), 'level'); 71 | } 72 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collections/entity.collection'; 2 | export * from './collections/pagination.collection'; 3 | export * from './containers/data-source-container'; 4 | export * from './decorators/custom-repository.decorator'; 5 | export * from './decorators/relation-condition.decorator'; 6 | export * from './entities/base.entity'; 7 | export * from './helper'; 8 | export * from './modules/typeorm-module.module'; 9 | export * from './operators/array.operator'; 10 | export * from './queries/base.query'; 11 | export * from './queries/collection.query'; 12 | export * from './query-builders/relation.query-builder'; 13 | export * from './repositories/base.repository'; 14 | export * from './transformers/updated-at-timestamp.transformer'; 15 | export * from './types/pagination-options.type'; 16 | export * from './where-expression/base.where-expression'; 17 | export * from './where-expression/collection.where-expression'; 18 | import './metadata/entity-metadata'; 19 | import './metadata/metadata-args-storage'; 20 | import './query-builders/select.query-builder'; 21 | import './repositories/repository'; 22 | -------------------------------------------------------------------------------- /lib/interfaces/query.interface.ts: -------------------------------------------------------------------------------- 1 | import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; 2 | 3 | export interface QueryInterface { 4 | query(query: SelectQueryBuilder): void; 5 | 6 | order?(query: SelectQueryBuilder): void; 7 | 8 | alias?(): string; 9 | } 10 | -------------------------------------------------------------------------------- /lib/interfaces/relation-condition.interface.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-function-type */ 2 | import { SelectQueryBuilder } from 'typeorm'; 3 | import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; 4 | 5 | export interface RelationConditionInterface { 6 | target: Function | string; 7 | propertyName: string; 8 | isArray: boolean; 9 | options: { 10 | query?: (query: SelectQueryBuilder, entities: any[]) => void; 11 | map?: (entity, result, column: ColumnMetadata) => boolean; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /lib/interfaces/where-expression.interface.ts: -------------------------------------------------------------------------------- 1 | import { WhereExpressionBuilder } from 'typeorm'; 2 | 3 | export interface WhereExpressionInterface { 4 | where(query: WhereExpressionBuilder): void; 5 | } 6 | -------------------------------------------------------------------------------- /lib/metadata/entity-metadata.ts: -------------------------------------------------------------------------------- 1 | import { RelationConditionInterface } from '../interfaces/relation-condition.interface'; 2 | 3 | declare module 'typeorm/metadata/EntityMetadata' { 4 | interface EntityMetadata { 5 | relationConditions: RelationConditionInterface[]; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/metadata/metadata-args-storage.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-function-type */ 2 | import { EntityMetadataBuilder } from 'typeorm/metadata-builder/EntityMetadataBuilder'; 3 | import { RelationConditionInterface } from '../interfaces/relation-condition.interface'; 4 | 5 | declare module 'typeorm/metadata-args/MetadataArgsStorage' { 6 | interface MetadataArgsStorage { 7 | relationConditions: RelationConditionInterface[]; 8 | } 9 | } 10 | 11 | (EntityMetadataBuilder.prototype as any).buildOrigin = (EntityMetadataBuilder.prototype as any).build; 12 | (EntityMetadataBuilder.prototype as any).build = function (entityClasses?: Function[]) { 13 | const entityMetadatas = this.buildOrigin(entityClasses); 14 | entityMetadatas.forEach((entityMetadata) => { 15 | if (this.metadataArgsStorage.relationConditions) { 16 | entityMetadata.relationConditions = this.metadataArgsStorage.relationConditions.filter( 17 | (custom) => custom.target === entityMetadata.target 18 | ); 19 | } else { 20 | entityMetadata.relationConditions = []; 21 | } 22 | }); 23 | return entityMetadatas; 24 | }; 25 | -------------------------------------------------------------------------------- /lib/modules/typeorm-module.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Provider } from '@nestjs/common'; 2 | import { getDataSourceToken, TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; 3 | import { EntitiesMetadataStorage } from '@nestjs/typeorm/dist/entities-metadata.storage'; 4 | import { DataSource } from 'typeorm'; 5 | import { DEFAULT_DATA_SOURCE_NAME } from '../constants/type.constant'; 6 | import { setDataSource } from '../containers/data-source-container'; 7 | import { TYPEORM_EX_CUSTOM_REPOSITORY } from '../decorators/custom-repository.decorator'; 8 | 9 | export class TypeOrmHelperModule { 10 | public static forRoot(config: TypeOrmModuleOptions) { 11 | return TypeOrmModule.forRootAsync({ 12 | useFactory: () => { 13 | return config; 14 | }, 15 | dataSourceFactory: async (options) => { 16 | const dataSource = new DataSource(options); 17 | setDataSource(dataSource); 18 | return dataSource; 19 | } 20 | }); 21 | } 22 | 23 | public static forCustomRepository any>(repositories: T[]): DynamicModule { 24 | const providers: Provider[] = []; 25 | 26 | EntitiesMetadataStorage.addEntitiesByDataSource(DEFAULT_DATA_SOURCE_NAME, [...repositories]); 27 | for (const repository of repositories) { 28 | const entity = Reflect.getMetadata(TYPEORM_EX_CUSTOM_REPOSITORY, repository); 29 | 30 | if (!entity) { 31 | continue; 32 | } 33 | 34 | providers.push({ 35 | inject: [getDataSourceToken()], 36 | provide: repository, 37 | useFactory: (dataSource: DataSource): typeof repository => { 38 | const baseRepository = dataSource.getRepository(entity); 39 | return new repository(baseRepository.target, baseRepository.manager, baseRepository.queryRunner); 40 | } 41 | }); 42 | } 43 | 44 | return { 45 | exports: providers, 46 | module: TypeOrmHelperModule, 47 | providers 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/operators/array.operator.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto'; 2 | import { FindOperator, Raw } from 'typeorm'; 3 | 4 | function arrayFindRaw(value, operator: string, type: string) { 5 | const param = `rawArrayParam${randomBytes(16).toString('hex')}`; 6 | const arrayValue = Array.isArray(value) ? value : [value]; 7 | return Raw((column) => ` ${column} ${operator} ARRAY[:...${param}]::${type}[] `, { [param]: arrayValue }); 8 | } 9 | 10 | export function ArrayContains(value: T | FindOperator, type: string) { 11 | return arrayFindRaw(value, '@>', type); 12 | } 13 | 14 | export function ArrayContainedBy(value: T | FindOperator, type: string) { 15 | return arrayFindRaw(value, '<@', type); 16 | } 17 | 18 | export function ArrayOverlap(value: T | FindOperator, type: string) { 19 | return arrayFindRaw(value, '&&', type); 20 | } 21 | 22 | export function ArrayGte(value: T | FindOperator, type: string) { 23 | return arrayFindRaw(value, '>=', type); 24 | } 25 | 26 | export function ArrayLte(value: T | FindOperator, type: string) { 27 | return arrayFindRaw(value, '<=', type); 28 | } 29 | 30 | export function ArrayGt(value: T | FindOperator, type: string) { 31 | return arrayFindRaw(value, '>', type); 32 | } 33 | 34 | export function ArrayLt(value: T | FindOperator, type: string) { 35 | return arrayFindRaw(value, '<', type); 36 | } 37 | 38 | export function ArrayEq(value: T | FindOperator, type: string) { 39 | return arrayFindRaw(value, '=', type); 40 | } 41 | 42 | export function ArrayNotEq(value: T | FindOperator, type: string) { 43 | return arrayFindRaw(value, '<>', type); 44 | } 45 | -------------------------------------------------------------------------------- /lib/queries/base.query.ts: -------------------------------------------------------------------------------- 1 | import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; 2 | import { QueryInterface } from '../interfaces/query.interface'; 3 | 4 | export abstract class BaseQuery implements QueryInterface { 5 | alias(): string { 6 | return undefined; 7 | } 8 | 9 | order(query: SelectQueryBuilder): void {} 10 | 11 | abstract query(query: SelectQueryBuilder): void; 12 | } 13 | -------------------------------------------------------------------------------- /lib/queries/collection.query.ts: -------------------------------------------------------------------------------- 1 | import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; 2 | import { QueryInterface } from '../interfaces/query.interface'; 3 | import { BaseQuery } from './base.query'; 4 | 5 | export class CollectionQuery extends BaseQuery { 6 | public constructor(private queries: QueryInterface[]) { 7 | super(); 8 | } 9 | 10 | query(query: SelectQueryBuilder): void { 11 | for (const queryBuilder of this.queries) { 12 | queryBuilder.query(query); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/query-builders/relation.query-builder.ts: -------------------------------------------------------------------------------- 1 | import { getDataSource } from '../containers/data-source-container'; 2 | import { uniq } from 'lodash'; 3 | import { Brackets, DataSource, ObjectLiteral, QueryRunner, SelectQueryBuilder } from 'typeorm'; 4 | import { RelationMetadata } from 'typeorm/metadata/RelationMetadata'; 5 | import { RelationConditionInterface } from '../interfaces/relation-condition.interface'; 6 | 7 | export class RelationQueryBuilder { 8 | public relation: RelationMetadata; 9 | public relationCondition: RelationConditionInterface; 10 | private dataSource: DataSource; 11 | private customQueries: ((queryBuilder: SelectQueryBuilder) => void)[] = []; 12 | public results: any[]; 13 | public entities: any[] = []; 14 | 15 | constructor( 16 | entityOrEntities: ObjectLiteral | ObjectLiteral[], 17 | private relationName: string, 18 | private queryRunner?: QueryRunner 19 | ) { 20 | if (queryRunner && !queryRunner.isReleased) { 21 | this.dataSource = queryRunner.connection; 22 | } else { 23 | this.queryRunner = undefined; 24 | this.dataSource = getDataSource(); 25 | } 26 | this.entities = Array.isArray(entityOrEntities) ? entityOrEntities : [entityOrEntities]; 27 | const entity = this.dataSource.getMetadata(this.entities[0].constructor); 28 | this.relation = entity.relations.find((relation) => relation.propertyName === relationName); 29 | this.relationCondition = entity.relationConditions.find((relation) => relation.propertyName === relationName); 30 | } 31 | 32 | async load() { 33 | if (this.relation.isManyToOne || this.relation.isOneToOneOwner) { 34 | this.results = await this.queryManyToOneOrOneToOneOwner(); 35 | } else if (this.relation.isOneToMany || this.relation.isOneToOneNotOwner) { 36 | this.results = await this.queryOneToManyOrOneToOneNotOwner(); 37 | } else if (this.relation.isManyToManyOwner) { 38 | this.results = await this.queryManyToManyOwner(); 39 | } else { 40 | this.results = await this.queryManyToManyNotOwner(); 41 | } 42 | this.assignData(); 43 | } 44 | 45 | assignData() { 46 | if (this.relation.isManyToOne || this.relation.isOneToOneOwner) { 47 | return this.assignDataManyToOneOrOneToOne(); 48 | } else if (this.relation.isOneToOneNotOwner) { 49 | return this.assignOneToOneNotOwner(); 50 | } else if (this.relation.isOneToMany) { 51 | return this.assignOneToMany(); 52 | } else if (this.relation.isManyToManyOwner) { 53 | return this.assignManyToManyOwner(); 54 | } else { 55 | return this.assignManyToManyNotOwner(); 56 | } 57 | } 58 | 59 | assignDataManyToOneOrOneToOne() { 60 | for (const entity of this.entities) { 61 | entity[this.relationName] = this.results.find((result) => { 62 | for (const column of this.relation.joinColumns) { 63 | if (entity[column.databaseName] !== result[column.referencedColumn.databaseName]) { 64 | return false; 65 | } 66 | if (this.relationCondition?.options?.map) { 67 | if (!this.relationCondition.options.map(entity, result, column)) { 68 | return false; 69 | } 70 | } 71 | } 72 | return true; 73 | }); 74 | } 75 | } 76 | 77 | assignOneToOneNotOwner() { 78 | for (const entity of this.entities) { 79 | entity[this.relationName] = this.results.find((result) => { 80 | for (const column of this.inverseRelation.joinColumns) { 81 | if (entity[column.referencedColumn.databaseName] !== result[column.databaseName]) { 82 | return false; 83 | } 84 | if (this.relationCondition?.options?.map) { 85 | if (!this.relationCondition.options.map(entity, result, column)) { 86 | return false; 87 | } 88 | } 89 | } 90 | return true; 91 | }); 92 | } 93 | } 94 | 95 | assignOneToMany() { 96 | for (const entity of this.entities) { 97 | entity[this.relationName] = this.results.filter((result) => { 98 | for (const column of this.inverseRelation.joinColumns) { 99 | if (entity[column.referencedColumn.databaseName] !== result[column.databaseName]) { 100 | return false; 101 | } 102 | if (this.relationCondition?.options?.map) { 103 | if (!this.relationCondition.options.map(entity, result, column)) { 104 | return false; 105 | } 106 | } 107 | } 108 | return true; 109 | }); 110 | } 111 | this.customAssign(); 112 | } 113 | 114 | assignManyToManyOwner() { 115 | for (const entity of this.entities) { 116 | entity[this.relationName] = this.results.filter((result) => { 117 | for (const column of this.relation.joinColumns) { 118 | if (!this.checkMapValueManyToMany(entity, result, column)) { 119 | return false; 120 | } 121 | } 122 | return true; 123 | }); 124 | } 125 | 126 | for (const result of this.results) { 127 | if (this.relation.junctionEntityMetadata) { 128 | delete result[`${this.relation.junctionEntityMetadata.tableName}`]; 129 | } 130 | } 131 | this.customAssign(); 132 | } 133 | 134 | customAssign() { 135 | if (this.relationCondition && !this.relationCondition.isArray) { 136 | for (const entity of this.entities) { 137 | entity[this.relationName] = entity[this.relationName][0]; 138 | } 139 | } 140 | } 141 | 142 | checkMapValueManyToMany(entity, result, column) { 143 | const junctionEntityMetadata = this.relation.junctionEntityMetadata; 144 | if ( 145 | junctionEntityMetadata && 146 | !result[junctionEntityMetadata.tableName].find((child) => { 147 | return child[column.databaseName] === entity[column.referencedColumn.databaseName]; 148 | }) 149 | ) { 150 | return false; 151 | } 152 | if (this.relationCondition?.options?.map) { 153 | if (!this.relationCondition.options.map(entity, result, column)) { 154 | return false; 155 | } 156 | } 157 | 158 | return true; 159 | } 160 | 161 | assignManyToManyNotOwner() { 162 | for (const entity of this.entities) { 163 | entity[this.relationName] = this.results.filter((result) => { 164 | for (const column of this.relation.inverseRelation.inverseJoinColumns) { 165 | if (!this.checkMapValueManyToMany(entity, result, column)) { 166 | return false; 167 | } 168 | } 169 | return true; 170 | }); 171 | } 172 | for (const result of this.results) { 173 | if (this.relation.junctionEntityMetadata) { 174 | delete result[`${this.relation.junctionEntityMetadata.tableName}`]; 175 | } 176 | } 177 | this.customAssign(); 178 | } 179 | 180 | addCustomQuery(customQuery: (name: SelectQueryBuilder) => void) { 181 | this.customQueries.push(customQuery); 182 | } 183 | 184 | public get type() { 185 | return this.relation.type; 186 | } 187 | 188 | get inverseRelation() { 189 | return this.relation.inverseRelation; 190 | } 191 | 192 | getValues(column) { 193 | return uniq(this.entities.map((entity) => entity[column])); 194 | } 195 | 196 | private applyQueryBuilder(queryBuilder: SelectQueryBuilder) { 197 | if (this.customQueries?.length) { 198 | for (const customQuery of this.customQueries) { 199 | customQuery(queryBuilder); 200 | } 201 | } 202 | if (this.relationCondition?.options?.query) { 203 | this.relationCondition.options.query(queryBuilder, this.entities); 204 | } 205 | } 206 | 207 | queryOneToManyOrOneToOneNotOwner() { 208 | const queryBuilder = this.dataSource 209 | .createQueryBuilder(this.queryRunner) 210 | .select(this.relationName) 211 | .from(this.inverseRelation.entityMetadata.target, this.relationName); 212 | queryBuilder.where( 213 | new Brackets((query) => { 214 | for (const column of this.inverseRelation.joinColumns) { 215 | query.where(`"${column.databaseName}" IN (:...values)`, { 216 | values: this.getValues(column.referencedColumn.databaseName) 217 | }); 218 | } 219 | }) 220 | ); 221 | this.applyQueryBuilder(queryBuilder); 222 | return queryBuilder.getMany(); 223 | } 224 | 225 | queryManyToOneOrOneToOneOwner() { 226 | const queryBuilder = this.dataSource 227 | .createQueryBuilder(this.queryRunner) 228 | .select(this.relationName) 229 | .from(this.type, this.relationName); 230 | queryBuilder.where( 231 | new Brackets((query) => { 232 | for (const column of this.relation.joinColumns) { 233 | query.where(` "${column.referencedColumn.databaseName}" IN (:...values)`, { 234 | values: this.getValues(column.databaseName) 235 | }); 236 | } 237 | }) 238 | ); 239 | this.applyQueryBuilder(queryBuilder); 240 | return queryBuilder.getMany(); 241 | } 242 | 243 | queryManyToManyOwner() { 244 | const joinAlias = this.relation.junctionEntityMetadata?.tableName || ''; 245 | const joinColumnConditions = this.relation.joinColumns.map((joinColumn) => { 246 | return `${joinAlias}.${joinColumn.propertyName} IN (:...${joinColumn.propertyName})`; 247 | }); 248 | const inverseJoinColumnConditions = this.relation.inverseJoinColumns.map((inverseJoinColumn) => { 249 | return `${joinAlias}.${inverseJoinColumn.propertyName}=${this.relation.propertyName}.${ 250 | inverseJoinColumn.referencedColumn?.propertyName || '' 251 | }`; 252 | }); 253 | const parameters = this.relation.joinColumns.reduce((parameters, joinColumn) => { 254 | if (joinColumn.referencedColumn) { 255 | parameters[joinColumn.propertyName] = this.entities.map((entity) => 256 | joinColumn.referencedColumn.getEntityValue(entity) 257 | ); 258 | } 259 | return parameters; 260 | }, {} as ObjectLiteral); 261 | 262 | return this.queryManyToMany(joinColumnConditions, inverseJoinColumnConditions, parameters); 263 | } 264 | 265 | queryManyToManyNotOwner() { 266 | const joinAlias = this.relation.junctionEntityMetadata?.tableName || ''; 267 | const joinColumns = this.inverseRelation?.joinColumns || []; 268 | const joinColumnConditions = joinColumns.map((joinColumn) => { 269 | return `${joinAlias}.${joinColumn.propertyName} = ${this.relation.propertyName}.${ 270 | joinColumn.referencedColumn?.propertyName || '' 271 | }`; 272 | }); 273 | 274 | const inverseJoinColumns = this.inverseRelation?.inverseJoinColumns || []; 275 | const inverseJoinColumnConditions = inverseJoinColumns.map((inverseJoinColumn) => { 276 | return `${joinAlias}.${inverseJoinColumn.propertyName} IN (:...${inverseJoinColumn.propertyName})`; 277 | }); 278 | const parameters = inverseJoinColumns.reduce((parameters, joinColumn) => { 279 | if (joinColumn.referencedColumn) { 280 | parameters[joinColumn.propertyName] = this.entities.map((entity) => 281 | joinColumn.referencedColumn.getEntityValue(entity) 282 | ); 283 | } 284 | return parameters; 285 | }, {} as ObjectLiteral); 286 | 287 | return this.queryManyToMany(joinColumnConditions, inverseJoinColumnConditions, parameters); 288 | } 289 | 290 | queryManyToMany(joinColumnConditions, inverseJoinColumnConditions, parameters) { 291 | const mainAlias = this.relation.propertyName; 292 | const joinAlias = this.relation.junctionEntityMetadata?.tableName || ''; 293 | const queryBuilder = this.dataSource 294 | .createQueryBuilder(this.type, mainAlias, this.queryRunner) 295 | .innerJoinAndMapMany( 296 | `${mainAlias}.${joinAlias}`, 297 | `${mainAlias}.${joinAlias}`, 298 | joinAlias, 299 | [...joinColumnConditions, ...inverseJoinColumnConditions].join(' AND ') 300 | ) 301 | .setParameters(parameters); 302 | 303 | this.applyQueryBuilder(queryBuilder); 304 | return queryBuilder.getMany() as any; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /lib/query-builders/select.query-builder.ts: -------------------------------------------------------------------------------- 1 | import { SelectQueryBuilder } from 'typeorm'; 2 | import { EntityCollection } from '../collections/entity.collection'; 3 | 4 | declare module 'typeorm/query-builder/SelectQueryBuilder' { 5 | interface SelectQueryBuilder { 6 | getMany(): Promise>; 7 | 8 | getManyOrigin(): Promise; 9 | 10 | getRawManyOrigin(): Promise; 11 | 12 | getRawMany(): Promise>; 13 | 14 | getManyAndCountOrigin(): Promise<[Entity[], number]>; 15 | 16 | getManyAndCount(): Promise<[EntityCollection, number]>; 17 | } 18 | } 19 | SelectQueryBuilder.prototype.getManyOrigin = SelectQueryBuilder.prototype.getMany; 20 | SelectQueryBuilder.prototype.getMany = async function () { 21 | const results = await this.getManyOrigin(); 22 | return new EntityCollection().collect(results); 23 | }; 24 | 25 | SelectQueryBuilder.prototype.getRawManyOrigin = SelectQueryBuilder.prototype.getRawMany; 26 | SelectQueryBuilder.prototype.getRawMany = async function () { 27 | const results = await this.getRawManyOrigin(); 28 | return new EntityCollection().collect(results); 29 | }; 30 | 31 | SelectQueryBuilder.prototype.getManyAndCountOrigin = SelectQueryBuilder.prototype.getManyAndCount; 32 | SelectQueryBuilder.prototype.getManyAndCount = async function () { 33 | const [results, count] = await this.getManyAndCountOrigin(); 34 | return [new EntityCollection().collect(results), count] as any; 35 | }; 36 | -------------------------------------------------------------------------------- /lib/repositories/base.repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntityManager, 3 | EntityNotFoundError, 4 | FindManyOptions, 5 | FindOneOptions, 6 | FindOptionsWhere, 7 | InsertResult, 8 | ObjectLiteral, 9 | Repository, 10 | SelectQueryBuilder, 11 | UpdateResult 12 | } from 'typeorm'; 13 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; 14 | import { EntityCollection } from '../collections/entity.collection'; 15 | import { PaginationCollection } from '../collections/pagination.collection'; 16 | import { getDataSource } from '../containers/data-source-container'; 17 | import { TYPEORM_EX_CUSTOM_REPOSITORY } from '../decorators/custom-repository.decorator'; 18 | import { BaseQuery } from '../queries/base.query'; 19 | import { PaginationOptions } from '../types/pagination-options.type'; 20 | 21 | export abstract class BaseRepository extends Repository { 22 | async runOnMaster(callback: (manager: EntityManager) => Promise): Promise { 23 | const queryRunner = this.manager.connection.createQueryRunner('master'); 24 | try { 25 | return await callback(queryRunner.manager); 26 | } finally { 27 | await queryRunner.release(); 28 | } 29 | } 30 | 31 | static make(this: new (...args: any[]) => T): T { 32 | const entity = Reflect.getMetadata(TYPEORM_EX_CUSTOM_REPOSITORY, this); 33 | const baseRepository = getDataSource().getRepository(entity); 34 | return new this(baseRepository.target, baseRepository.manager, baseRepository.queryRunner); 35 | } 36 | 37 | async createOne(entity: QueryDeepPartialEntity): Promise { 38 | const result: InsertResult = await this.createQueryBuilder() 39 | .insert() 40 | .into(this.metadata.target) 41 | .values(entity) 42 | .returning('*') 43 | .execute(); 44 | 45 | return result.raw[0] as Entity; 46 | } 47 | 48 | async findById(id: string | number, options?: FindOneOptions): Promise { 49 | if (!id) { 50 | throw new Error('Id can not null'); 51 | } 52 | return super.findOneOrFail({ where: { id } as any, ...options }); 53 | } 54 | 55 | async pagination( 56 | options: FindManyOptions | FindOptionsWhere | SelectQueryBuilder | BaseQuery, 57 | paginationOptions: PaginationOptions 58 | ): Promise> { 59 | if (options instanceof BaseQuery) { 60 | return this.paginateQueryBuilder(this.applyQueryBuilder(options), paginationOptions); 61 | } 62 | 63 | if (options instanceof SelectQueryBuilder) { 64 | return this.paginateQueryBuilder(options, paginationOptions); 65 | } 66 | 67 | if (!options) { 68 | options = {}; 69 | } 70 | 71 | const query: ObjectLiteral = options.where ? options : { where: options }; 72 | const { page = 1, perPage } = paginationOptions; 73 | const [itemCount, items] = await Promise.all([ 74 | super.count(options), 75 | super.find({ 76 | ...query, 77 | take: perPage, 78 | skip: (page - 1) * perPage 79 | }) 80 | ]); 81 | 82 | return new PaginationCollection({ 83 | items: items, 84 | total: itemCount, 85 | lastPage: Math.ceil(itemCount / perPage), 86 | perPage: perPage, 87 | currentPage: page 88 | }); 89 | } 90 | 91 | private async paginateQueryBuilder( 92 | query: SelectQueryBuilder, 93 | options: PaginationOptions 94 | ): Promise> { 95 | const { page = 1, perPage } = options; 96 | const [items, itemCount] = await query 97 | .take(perPage) 98 | .skip((page - 1) * perPage) 99 | .getManyAndCount(); 100 | return new PaginationCollection({ 101 | items: items, 102 | total: itemCount, 103 | lastPage: Math.ceil(itemCount / perPage), 104 | perPage: perPage, 105 | currentPage: options.page 106 | }); 107 | } 108 | 109 | async firstOrCreate(options: QueryDeepPartialEntity) { 110 | const item = await this.runOnMaster((manager) => 111 | manager.findOne(this.metadata.target, { 112 | where: options as NodeJS.Dict 113 | }) 114 | ); 115 | 116 | if (item) { 117 | return item; 118 | } 119 | 120 | return await this.createOne(options); 121 | } 122 | 123 | private applyQueryBuilder(query: BaseQuery): SelectQueryBuilder { 124 | const queryBuilder = this.createQueryBuilder(query.alias()); 125 | query.query(queryBuilder); 126 | query.order(queryBuilder); 127 | return queryBuilder; 128 | } 129 | 130 | async find(options?: FindManyOptions | BaseQuery): Promise> { 131 | if (options instanceof BaseQuery) { 132 | const queryBuilder = this.applyQueryBuilder(options); 133 | return queryBuilder.getMany(); 134 | } 135 | return super.find(options); 136 | } 137 | 138 | async findOne(options: FindOneOptions | BaseQuery): Promise { 139 | if (options instanceof BaseQuery) { 140 | const queryBuilder = this.applyQueryBuilder(options); 141 | return queryBuilder.limit(1).getOne(); 142 | } 143 | return super.findOne(options); 144 | } 145 | 146 | async findOneOrFail(options: FindOneOptions | BaseQuery): Promise { 147 | if (options instanceof BaseQuery) { 148 | const queryBuilder = this.applyQueryBuilder(options); 149 | return queryBuilder.limit(1).getOneOrFail(); 150 | } 151 | return super.findOneOrFail(options); 152 | } 153 | 154 | async findAndCount( 155 | options?: FindManyOptions | BaseQuery 156 | ): Promise<[EntityCollection, number]> { 157 | if (options instanceof BaseQuery) { 158 | const queryBuilder = this.applyQueryBuilder(options); 159 | return queryBuilder.getManyAndCount(); 160 | } 161 | return super.findAndCount(options); 162 | } 163 | 164 | async count(optionsOrConditions?: FindManyOptions | BaseQuery): Promise { 165 | if (optionsOrConditions instanceof BaseQuery) { 166 | const queryBuilder = this.applyQueryBuilder(optionsOrConditions); 167 | return queryBuilder.getCount(); 168 | } 169 | return await super.count(optionsOrConditions); 170 | } 171 | 172 | /** 173 | * Must use this method inside transaction for deleting multiple entities 174 | */ 175 | async deleteOrFail(criteria: FindOptionsWhere) { 176 | return this.runOnMaster(async (manager) => { 177 | const recordCount = await manager.count(this.metadata.target, { where: criteria }); 178 | const deleteResult = await manager.delete(this.metadata.target, criteria); 179 | 180 | if (deleteResult.affected === 0 || deleteResult.affected !== recordCount) { 181 | throw new EntityNotFoundError(this.metadata.target, criteria); 182 | } 183 | 184 | return deleteResult; 185 | }); 186 | } 187 | 188 | /** 189 | * Must use this method inside transaction for soft deleting multiple entities 190 | */ 191 | async softDeleteOrFail(criteria: FindOptionsWhere) { 192 | const recordCount = await this.count({ where: criteria }); 193 | const deleteResult = await this.softDelete(criteria); 194 | 195 | if (deleteResult.affected === 0 || deleteResult.affected !== recordCount) { 196 | throw new EntityNotFoundError(this.metadata.target, criteria); 197 | } 198 | 199 | return deleteResult; 200 | } 201 | 202 | /** 203 | * Must use this method inside transaction for update multiple entities 204 | */ 205 | async updateOrFail( 206 | criteria: FindOptionsWhere, 207 | partialEntity: QueryDeepPartialEntity 208 | ): Promise { 209 | return this.runOnMaster(async (manager) => { 210 | const recordCount = await manager.count(this.metadata.target, { where: criteria }); 211 | const updateResult = await manager.update(this.metadata.target, criteria, partialEntity); 212 | 213 | if (updateResult.affected === 0 || updateResult.affected !== recordCount) { 214 | throw new EntityNotFoundError(this.metadata.target, criteria); 215 | } 216 | return updateResult; 217 | }); 218 | } 219 | 220 | async existOrFail(criteria: FindOptionsWhere): Promise { 221 | const record = await this.findOneOrFail({ where: criteria, select: { id: true } as any }); 222 | return Boolean(record); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /lib/repositories/mongo.repository.ts: -------------------------------------------------------------------------------- 1 | import { FindManyOptions, FindOptionsWhere, ObjectLiteral } from 'typeorm'; 2 | import { EntityCollection } from '../collections/entity.collection'; 3 | 4 | declare module 'typeorm/repository/MongoRepository' { 5 | interface MongoRepository { 6 | find(options?: FindManyOptions): Promise>; 7 | 8 | find(conditions?: FindOptionsWhere): Promise>; 9 | 10 | findAndCount(options?: FindManyOptions): Promise<[EntityCollection, number]>; 11 | 12 | findAndCount(conditions?: FindOptionsWhere): Promise<[EntityCollection, number]>; 13 | 14 | findByIds(ids: any[], options?: FindManyOptions): Promise>; 15 | 16 | findByIds(ids: any[], conditions?: FindOptionsWhere): Promise>; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/repositories/repository.ts: -------------------------------------------------------------------------------- 1 | import { FindManyOptions, FindOneOptions, FindOptionsWhere, ObjectLiteral } from 'typeorm'; 2 | import { Repository } from 'typeorm/repository/Repository'; 3 | import { EntityCollection } from '../collections/entity.collection'; 4 | import { BaseQuery } from '../queries/base.query'; 5 | 6 | declare module 'typeorm/repository/Repository' { 7 | interface Repository { 8 | find(conditions?: FindManyOptions | BaseQuery): Promise>; 9 | 10 | findAndCount( 11 | options?: FindManyOptions | BaseQuery 12 | ): Promise<[EntityCollection, number]>; 13 | 14 | findByIds(ids: any[], options?: FindManyOptions | BaseQuery): Promise>; 15 | 16 | findByIds( 17 | ids: any[], 18 | conditions?: FindOptionsWhere | BaseQuery 19 | ): Promise>; 20 | 21 | findOne(options: FindOneOptions | BaseQuery): Promise; 22 | 23 | findOneOrFail(options: FindOneOptions | BaseQuery): Promise; 24 | 25 | count(options?: FindManyOptions | BaseQuery): Promise; 26 | } 27 | } 28 | declare module 'typeorm/repository/MongoRepository' { 29 | interface MongoRepository extends Repository { 30 | find(options?: FindManyOptions | BaseQuery): Promise>; 31 | 32 | findAndCount( 33 | options?: FindManyOptions | BaseQuery 34 | ): Promise<[EntityCollection, number]>; 35 | 36 | findByIds(ids: any[], options?: FindManyOptions | BaseQuery): Promise>; 37 | 38 | findByIds( 39 | ids: any[], 40 | conditions?: FindOptionsWhere | BaseQuery 41 | ): Promise>; 42 | 43 | findOne(options: FindOneOptions | BaseQuery): Promise; 44 | 45 | findOneOrFail(options: FindOneOptions | BaseQuery): Promise; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/transformers/updated-at-timestamp.transformer.ts: -------------------------------------------------------------------------------- 1 | import { ValueTransformer } from 'typeorm'; 2 | 3 | export class UpdatedAtTimestampTransformer implements ValueTransformer { 4 | to() { 5 | return () => 'now()'; 6 | } 7 | 8 | from(value) { 9 | if (!value) { 10 | return value; 11 | } 12 | return Math.round(+new Date(value) / 1000); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/types/pagination-options.type.ts: -------------------------------------------------------------------------------- 1 | export type PaginationOptions = { page?: number; perPage?: number }; 2 | -------------------------------------------------------------------------------- /lib/where-expression/base.where-expression.ts: -------------------------------------------------------------------------------- 1 | import { Brackets, WhereExpressionBuilder } from 'typeorm'; 2 | import { WhereExpressionInterface } from '../interfaces/where-expression.interface'; 3 | 4 | export abstract class BaseWhereExpression extends Brackets implements WhereExpressionInterface { 5 | public constructor() { 6 | super((query) => this.where(query)); 7 | } 8 | 9 | abstract where(query: WhereExpressionBuilder): void; 10 | } 11 | -------------------------------------------------------------------------------- /lib/where-expression/collection.where-expression.ts: -------------------------------------------------------------------------------- 1 | import { WhereExpressionBuilder } from 'typeorm'; 2 | import { WhereExpressionInterface } from '../interfaces/where-expression.interface'; 3 | import { BaseWhereExpression } from './base.where-expression'; 4 | 5 | export class CollectionWhereExpression extends BaseWhereExpression { 6 | public constructor(private whereExpressions: WhereExpressionInterface[]) { 7 | super(); 8 | } 9 | 10 | where(query: WhereExpressionBuilder): void { 11 | for (const whereExpression of this.whereExpressions) { 12 | whereExpression.where(query); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "sample", 4 | "projects": { 5 | "typeorm-helper": { 6 | "type": "library", 7 | "root": "lib", 8 | "entryFile": "index", 9 | "sourceRoot": "lib" 10 | } 11 | }, 12 | "compilerOptions": { 13 | "webpack": false, 14 | "assets": [ 15 | { 16 | "include": "../lib/public/**", 17 | "watchAssets": true 18 | } 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ormconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "default", 3 | "type": "postgres", 4 | "host": "localhost", 5 | "port": 9432, 6 | "username": "test", 7 | "password": "test", 8 | "database": "test", 9 | "synchronize": false, 10 | "logging": false, 11 | "keepConnectionAlive": true, 12 | "migrationsRun": true, 13 | "migrationsDir": "src/migration", 14 | "entities": [ 15 | "src/entity/*.ts" 16 | ], 17 | "subscribers": [ 18 | "src/subscriber/*.ts" 19 | ], 20 | "migrations": [ 21 | "src/migration/*.ts" 22 | ], 23 | "cli": { 24 | "entitiesDir": "src/entity", 25 | "migrationsDir": "src/migration", 26 | "subscribersDir": "src/subscriber" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hodfords/typeorm-helper", 3 | "version": "11.1.2", 4 | "description": "Simplifies TypeORM usage in NestJS apps", 5 | "license": "MIT", 6 | "readmeFilename": "README.md", 7 | "author": { 8 | "name": "Dung Nguyen Tien", 9 | "email": "nguyentiendung.dev@gmail.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/hodfords-solutions/typeorm-helper" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/hodfords-solutions/typeorm-helper/issues" 17 | }, 18 | "tags": [ 19 | "orm", 20 | "typescript", 21 | "typescript-orm", 22 | "typeorm-sample", 23 | "typeorm-example" 24 | ], 25 | "peerDependencies": { 26 | "typeorm": "*", 27 | "@nestjs/common": "*", 28 | "@nestjs/core": "*", 29 | "@nestjs/typeorm": "*", 30 | "@types/jest": "*", 31 | "@types/node": "*", 32 | "pg": "*", 33 | "lodash": "*" 34 | }, 35 | "devDependencies": { 36 | "@hodfords/nestjs-eslint-config": "^11.0.1", 37 | "@hodfords/nestjs-prettier-config": "^11.0.1", 38 | "@nestjs/common": "11.0.11", 39 | "@nestjs/core": "11.0.11", 40 | "@nestjs/typeorm": "11.0.0", 41 | "@types/jest": "29.5.14", 42 | "@types/node": "22.13.10", 43 | "cspell": "8.17.5", 44 | "eslint": "9.22.0", 45 | "husky": "9.1.7", 46 | "is-ci": "4.1.0", 47 | "jest": "29.7.0", 48 | "lint-staged": "15.5.0", 49 | "lodash": "4.17.21", 50 | "pg": "8.14.0", 51 | "prettier": "3.5.3", 52 | "reflect-metadata": "0.2.2", 53 | "rimraf": "^6.0.1", 54 | "ts-jest": "29.2.6", 55 | "ts-node": "10.9.2", 56 | "typeorm": "^0.3.21", 57 | "typescript": "5.8.2" 58 | }, 59 | "scripts": { 60 | "prebuild": "rimraf dist", 61 | "build": "tsc", 62 | "postbuild": "cp package.json dist/lib && cp README.md dist/lib && cp .npmrc dist/lib", 63 | "deploy": "npm run build && npm publish dist", 64 | "typeorm": "./node_modules/.bin/typeorm ", 65 | "format": "prettier --write \"**/*.ts\"", 66 | "check": "prettier --check \"**/*.ts\"", 67 | "test": "jest --passWithNoTests --testTimeout=450000 --runInBand", 68 | "cspell": "cspell", 69 | "prepare": "is-ci || husky", 70 | "lint": "eslint \"lib/**/*.ts\" \"sample/**/*.ts\" --fix --max-warnings 0", 71 | "lint-staged": "lint-staged" 72 | }, 73 | "jest": { 74 | "moduleFileExtensions": [ 75 | "js", 76 | "json", 77 | "ts" 78 | ], 79 | "rootDir": ".", 80 | "testRegex": ".*\\.spec\\.ts$", 81 | "transform": { 82 | "^.+\\.(t|j)s$": "ts-jest" 83 | }, 84 | "collectCoverageFrom": [ 85 | "**/*.(t|j)s" 86 | ], 87 | "coverageDirectory": "../coverage", 88 | "testEnvironment": "node", 89 | "moduleNameMapper": { 90 | "^@hodfords/typeorm-helper(|/.*)$": "/lib/$1" 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("@hodfords/nestjs-prettier-config"); 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "prConcurrentLimit": 5, 5 | "assignees": ["hodfords_dung_senior_dev"], 6 | "labels": ["renovate"] 7 | } 8 | -------------------------------------------------------------------------------- /sample/entities/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { PostEntity } from './post.entity'; 3 | import { PostCategoryEntity } from './post-category.entity'; 4 | import { BaseEntity } from '@hodfords/typeorm-helper'; 5 | 6 | @Entity('Category') 7 | export class CategoryEntity extends BaseEntity { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @Column() 12 | name: string; 13 | 14 | @ManyToMany(() => PostEntity, (post) => post.categories, { createForeignKeyConstraints: false }) 15 | @JoinTable({ name: 'postCategories' }) 16 | posts: PostEntity[]; 17 | 18 | @OneToMany(() => PostCategoryEntity, (postToCategory) => postToCategory.category) 19 | postCategories: PostCategoryEntity[]; 20 | } 21 | -------------------------------------------------------------------------------- /sample/entities/post-category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { PostEntity } from './post.entity'; 3 | import { CategoryEntity } from './category.entity'; 4 | import { BaseEntity } from '@hodfords/typeorm-helper'; 5 | 6 | @Entity('PostCategory') 7 | export class PostCategoryEntity extends BaseEntity { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @Column({ nullable: true }) 12 | postId: number; 13 | 14 | @Column({ nullable: true }) 15 | categoryId: number; 16 | 17 | @ManyToOne(() => PostEntity, (post) => post.postCategories) 18 | post: PostEntity; 19 | 20 | @ManyToOne(() => CategoryEntity, (category) => category.postCategories) 21 | category: CategoryEntity; 22 | } 23 | -------------------------------------------------------------------------------- /sample/entities/post.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn, ManyToOne, ManyToMany, JoinTable, OneToMany } from 'typeorm'; 2 | import { CategoryEntity } from './category.entity'; 3 | import { PostCategoryEntity } from './post-category.entity'; 4 | import { UserEntity } from './user.entity'; 5 | import { BaseEntity } from '@hodfords/typeorm-helper'; 6 | 7 | @Entity('Post') 8 | export class PostEntity extends BaseEntity { 9 | @PrimaryGeneratedColumn() 10 | id: number; 11 | 12 | @Column() 13 | title: string; 14 | 15 | @ManyToOne(() => UserEntity, (user) => user.posts) 16 | user: UserEntity; 17 | 18 | @Column({ nullable: true }) 19 | userId: number; 20 | 21 | @OneToMany(() => PostCategoryEntity, (postToCategory) => postToCategory.post) 22 | postCategories: PostCategoryEntity[]; 23 | 24 | @ManyToMany(() => CategoryEntity, (category) => category.posts, { createForeignKeyConstraints: false }) 25 | @JoinTable({ name: 'postCategories' }) 26 | categories: CategoryEntity[]; 27 | } 28 | -------------------------------------------------------------------------------- /sample/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, SelectQueryBuilder } from 'typeorm'; 2 | import { PostEntity } from './post.entity'; 3 | import { BaseEntity, RelationCondition } from '@hodfords/typeorm-helper'; 4 | 5 | @Entity('User') 6 | export class UserEntity extends BaseEntity { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @Column() 11 | name: string; 12 | 13 | @OneToMany(() => PostEntity, (post) => post.user, { cascade: true }) 14 | posts: PostEntity[]; 15 | 16 | @RelationCondition((query: SelectQueryBuilder, entities) => { 17 | query.orderBy('id', 'DESC'); 18 | if (entities.length === 1) { 19 | query.limit(1); 20 | } else { 21 | query.andWhere( 22 | ' "latestPost".id in (select max(id) from "Post" "maxPost" where "maxPost"."userId" = "latestPost"."userId")' 23 | ); 24 | } 25 | }) 26 | @OneToOne(() => PostEntity, (post) => post.user, { cascade: true }) 27 | latestPost: PostEntity; 28 | } 29 | -------------------------------------------------------------------------------- /sample/queries/post-of-user.query.ts: -------------------------------------------------------------------------------- 1 | import { BaseQuery } from '@hodfords/typeorm-helper'; 2 | import { PostEntity } from 'sample/entities/post.entity'; 3 | import { SelectQueryBuilder } from 'typeorm'; 4 | 5 | export class PostOfUserQuery extends BaseQuery { 6 | constructor(private userId: number) { 7 | super(); 8 | } 9 | 10 | query(query: SelectQueryBuilder) { 11 | query.where({ userId: this.userId }).limit(10); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sample/repositories/category.repository.ts: -------------------------------------------------------------------------------- 1 | import { CustomRepository, BaseRepository } from '@hodfords/typeorm-helper'; 2 | import { CategoryEntity } from '../entities/category.entity'; 3 | 4 | @CustomRepository(CategoryEntity) 5 | export class CategoryRepository extends BaseRepository {} 6 | -------------------------------------------------------------------------------- /sample/repositories/post.repository.ts: -------------------------------------------------------------------------------- 1 | import { CustomRepository, BaseRepository } from '@hodfords/typeorm-helper'; 2 | import { PostEntity } from '../entities/post.entity'; 3 | 4 | @CustomRepository(PostEntity) 5 | export class PostRepository extends BaseRepository {} 6 | -------------------------------------------------------------------------------- /sample/repositories/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { CustomRepository, BaseRepository } from '@hodfords/typeorm-helper'; 2 | import { UserEntity } from '../entities/user.entity'; 3 | 4 | @CustomRepository(UserEntity) 5 | export class UserRepository extends BaseRepository {} 6 | -------------------------------------------------------------------------------- /sample/where-expression/belong-to-user.where-expression.ts: -------------------------------------------------------------------------------- 1 | import { BaseWhereExpression } from '@hodfords/typeorm-helper'; 2 | import { WhereExpressionBuilder } from 'typeorm'; 3 | 4 | export class BelongToUserWhereExpression extends BaseWhereExpression { 5 | constructor(private userId: number) { 6 | super(); 7 | } 8 | 9 | where(query: WhereExpressionBuilder) { 10 | query.where({ userId: this.userId }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/many-to-many-relation-not-owner.spec.ts: -------------------------------------------------------------------------------- 1 | import { PostCategoryEntity } from '../sample/entities/post-category.entity'; 2 | import { CategoryEntity } from '../sample/entities/category.entity'; 3 | import { CategoryRepository } from '../sample/repositories/category.repository'; 4 | import { initializeTest } from './test-helper'; 5 | import { IsNull, Not } from 'typeorm'; 6 | 7 | describe('Many-To-Many Relation Not Owner Test Cases', () => { 8 | beforeAll(async () => { 9 | await initializeTest(); 10 | }); 11 | 12 | const assertSingle = async (category: CategoryEntity) => { 13 | const postCategories = await PostCategoryEntity.find({ where: { categoryId: category.id } }); 14 | expect(category.posts.length).toBe(postCategories.length); 15 | 16 | for (const post of category.posts) { 17 | expect(postCategories).toEqual(expect.arrayContaining([expect.objectContaining({ postId: post.id })])); 18 | } 19 | }; 20 | 21 | it('should validate a single category', async () => { 22 | const category = await CategoryRepository.make().findOneBy({ id: Not(IsNull()) }); 23 | await category.loadRelation('posts'); 24 | await assertSingle(category); 25 | }); 26 | 27 | it('should validate multiple categories', async () => { 28 | const categories = await CategoryRepository.make().find({ take: 5 }); 29 | await categories.loadRelation('posts'); 30 | for (const category of categories) { 31 | await assertSingle(category); 32 | } 33 | }); 34 | 35 | it('should validate category pagination', async () => { 36 | const pagination = await CategoryRepository.make().pagination({}, { page: 1, perPage: 10 }); 37 | await pagination.loadRelation('posts'); 38 | for (const category of pagination.items) { 39 | await assertSingle(category); 40 | } 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/many-to-many-relation-owner.spec.ts: -------------------------------------------------------------------------------- 1 | import { PostEntity } from '../sample/entities/post.entity'; 2 | import { PostRepository } from '../sample/repositories/post.repository'; 3 | import { PostCategoryEntity } from '../sample/entities/post-category.entity'; 4 | import { initializeTest } from './test-helper'; 5 | import { IsNull, Not } from 'typeorm'; 6 | 7 | describe('Many-To-Many Relation Owner Test Cases', () => { 8 | beforeAll(async () => { 9 | await initializeTest(); 10 | }); 11 | 12 | const assertSingle = async (post: PostEntity) => { 13 | const postCategories = await PostCategoryEntity.find({ where: { postId: post.id } }); 14 | expect(post.categories.length).toBe(postCategories.length); 15 | 16 | for (const category of post.categories) { 17 | expect(postCategories).toEqual( 18 | expect.arrayContaining([expect.objectContaining({ categoryId: category.id })]) 19 | ); 20 | } 21 | }; 22 | 23 | it('should validate a single post', async () => { 24 | const post = await PostRepository.make().findOneBy({ id: Not(IsNull()) }); 25 | await post.loadRelation('categories'); 26 | await assertSingle(post); 27 | }); 28 | 29 | it('should validate multiple posts', async () => { 30 | const posts = await PostRepository.make().find({ take: 5 }); 31 | await posts.loadRelation('categories'); 32 | for (const post of posts) { 33 | await assertSingle(post); 34 | } 35 | }); 36 | 37 | it('should validate post pagination', async () => { 38 | const pagination = await PostRepository.make().pagination({}, { page: 1, perPage: 10 }); 39 | await pagination.loadRelation('categories'); 40 | for (const post of pagination.items) { 41 | await assertSingle(post); 42 | } 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/many-to-one-relation.spec.ts: -------------------------------------------------------------------------------- 1 | import { PostEntity } from 'sample/entities/post.entity'; 2 | import { PostRepository } from '../sample/repositories/post.repository'; 3 | import { initializeTest } from './test-helper'; 4 | import { IsNull, Not } from 'typeorm'; 5 | 6 | describe('Many-To-One Relation Test Cases', () => { 7 | beforeAll(async () => { 8 | await initializeTest(); 9 | }); 10 | 11 | const assertSingle = async (post: PostEntity) => { 12 | expect(post.userId).toEqual(post.user.id); 13 | }; 14 | 15 | it('should validate a single post', async () => { 16 | const post = await PostRepository.make().findOneBy({ id: Not(IsNull()) }); 17 | await post.loadRelation('user'); 18 | assertSingle(post); 19 | }); 20 | 21 | it('should validate multiple posts', async () => { 22 | const posts = await PostRepository.make().find({ take: 5 }); 23 | await posts.loadRelation('user'); 24 | for (const post of posts) { 25 | assertSingle(post); 26 | } 27 | }); 28 | 29 | it('should validate post pagination', async () => { 30 | const pagination = await PostRepository.make().pagination({}, { page: 1, perPage: 10 }); 31 | await pagination.loadRelation('user'); 32 | for (const post of pagination.items) { 33 | assertSingle(post); 34 | } 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/one-to-many-relation.spec.ts: -------------------------------------------------------------------------------- 1 | import { IsNull, Not } from 'typeorm'; 2 | import { UserEntity } from '../sample/entities/user.entity'; 3 | import { UserRepository } from '../sample/repositories/user.repository'; 4 | import { initializeTest } from './test-helper'; 5 | 6 | describe('One-To-Many Relation Test Cases', () => { 7 | beforeAll(async () => { 8 | await initializeTest(); 9 | }); 10 | 11 | const assertSingle = async (user: UserEntity) => { 12 | for (const post of user.posts) { 13 | expect(post.userId).toEqual(user.id); 14 | } 15 | }; 16 | 17 | it('should validate a single user', async () => { 18 | const user = await UserRepository.make().findOneBy({ id: Not(IsNull()) }); 19 | await user.loadRelation('posts'); 20 | assertSingle(user); 21 | }); 22 | 23 | it('should validate multiple users', async () => { 24 | const users = await UserRepository.make().find({ take: 5 }); 25 | await users.loadRelation('posts'); 26 | for (const user of users) { 27 | assertSingle(user); 28 | } 29 | }); 30 | 31 | it('should validate user pagination', async () => { 32 | let pagination = await UserRepository.make().pagination({}, { page: 1, perPage: 10 }); 33 | await pagination.loadRelation('posts'); 34 | for (const user of pagination.items) { 35 | assertSingle(user); 36 | } 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/one-to-one-custom.spec.ts: -------------------------------------------------------------------------------- 1 | import { UserRepository } from '../sample/repositories/user.repository'; 2 | import { PostEntity } from '../sample/entities/post.entity'; 3 | import { initializeTest } from './test-helper'; 4 | import { PostRepository } from '../sample/repositories/post.repository'; 5 | import { IsNull, Not } from 'typeorm'; 6 | 7 | describe('One-To-One Relation Test Cases', () => { 8 | beforeAll(async () => { 9 | await initializeTest(); 10 | }); 11 | 12 | const assertLatestPost = async (userId: number, post: PostEntity) => { 13 | const latestPost = await PostRepository.make() 14 | .createQueryBuilder() 15 | .where('"userId" = :userId ', { userId }) 16 | .orderBy('id', 'DESC') 17 | .limit(1) 18 | .getOne(); 19 | expect(latestPost.id).toEqual(post.id); 20 | }; 21 | 22 | it('should validate the latest post for a single user', async () => { 23 | const user = await UserRepository.make().findOneBy({ id: Not(IsNull()) }); 24 | await user.loadRelation('latestPost'); 25 | await assertLatestPost(user.id, user.latestPost); 26 | }); 27 | 28 | it('should validate the latest post for multiple users', async () => { 29 | const users = await UserRepository.make().find({ take: 5 }); 30 | await users.loadRelation('latestPost'); 31 | for (const user of users) { 32 | await assertLatestPost(user.id, user.latestPost); 33 | } 34 | }); 35 | 36 | it('should validate pagination of users and their latest posts', async () => { 37 | const pagination = await UserRepository.make().pagination({}, { page: 1, perPage: 10 }); 38 | await pagination.loadRelation('latestPost'); 39 | for (const user of pagination.items) { 40 | await assertLatestPost(user.id, user.latestPost); 41 | } 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/query-builder.spec.ts: -------------------------------------------------------------------------------- 1 | import { PostRepository } from '../sample/repositories/post.repository'; 2 | import { PostOfUserQuery } from '../sample/queries/post-of-user.query'; 3 | import { initializeTest } from './test-helper'; 4 | import { UserRepository } from '../sample/repositories/user.repository'; 5 | import { IsNull, Not } from 'typeorm'; 6 | 7 | describe('Query Builder Test Cases', () => { 8 | beforeAll(async () => { 9 | await initializeTest(); 10 | }); 11 | 12 | it('should return posts of a specific user by id', async () => { 13 | const user = await UserRepository.make().findOneBy({ id: Not(IsNull()) }); 14 | await user.loadRelation('posts'); 15 | 16 | const posts = await PostRepository.make().find(new PostOfUserQuery(user.id)); 17 | for (const post of posts) { 18 | expect(post.userId).toEqual(user.id); 19 | } 20 | }); 21 | 22 | it('should update the user or fail if not found', async () => { 23 | const userRepo = UserRepository.make(); 24 | const user = await userRepo.save({ name: 'Old Name', email: 'test@example.com' }); 25 | 26 | const result = await userRepo.updateOrFail({ id: user.id }, { name: 'New Name' }); 27 | 28 | expect(result.affected).toBe(1); 29 | 30 | const updatedUser = await userRepo.findOneByOrFail({ id: user.id }); 31 | expect(updatedUser.name).toBe('New Name'); 32 | }); 33 | 34 | it('should throw EntityNotFoundError when updating non-existent user', async () => { 35 | const userRepo = UserRepository.make(); 36 | await expect( 37 | userRepo.updateOrFail({ id: -1 }, { name: 'No One' }), 38 | ).rejects.toThrow('Could not find any entity'); 39 | }); 40 | 41 | }); 42 | -------------------------------------------------------------------------------- /tests/test-helper.ts: -------------------------------------------------------------------------------- 1 | import { UserEntity } from '../sample/entities/user.entity'; 2 | import { DataSource, DataSourceOptions } from 'typeorm'; 3 | import { PostEntity } from '../sample/entities/post.entity'; 4 | import { CategoryEntity } from '../sample/entities/category.entity'; 5 | import { PostCategoryEntity } from '../sample/entities/post-category.entity'; 6 | import { setDataSource } from '@hodfords/typeorm-helper'; 7 | import { randomInt } from 'crypto'; 8 | import { PostRepository } from '../sample/repositories/post.repository'; 9 | import { CategoryRepository } from '../sample/repositories/category.repository'; 10 | import { UserRepository } from '../sample/repositories/user.repository'; 11 | 12 | export async function initializeTest(): Promise { 13 | const options: DataSourceOptions = { 14 | type: 'postgres', 15 | host: 'localhost', 16 | port: 5432, 17 | username: 'postgres', 18 | password: 'postgres', 19 | database: 'quickstart', 20 | entities: [UserEntity, PostEntity, CategoryEntity, PostCategoryEntity], 21 | synchronize: true, 22 | dropSchema: true 23 | }; 24 | 25 | const dataSource = new DataSource(options); 26 | await dataSource.initialize(); 27 | setDataSource(dataSource); 28 | await seedEntities(); 29 | } 30 | 31 | export async function seedEntities(): Promise { 32 | await seedUsers(); 33 | await seedPosts(); 34 | await seedCategories(); 35 | } 36 | 37 | async function seedUsers() { 38 | for (let i = 0; i < randomInt(15, 30); i++) { 39 | await UserRepository.make().createOne({ name: `user_${i}` }); 40 | } 41 | } 42 | 43 | async function seedPosts() { 44 | const users = await UserRepository.make().find(); 45 | for (const user of users) { 46 | for (let i = 0; i < randomInt(15, 30); i++) { 47 | await PostRepository.make().createOne({ 48 | title: `post_${i}`, 49 | userId: user.id 50 | }); 51 | } 52 | } 53 | } 54 | 55 | async function seedCategories() { 56 | for (let i = 0; i < randomInt(15, 30); i++) { 57 | const randomPosts = await PostRepository.make() 58 | .createQueryBuilder() 59 | .limit(randomInt(0, 5)) 60 | .orderBy('random()') 61 | .getMany(); 62 | await CategoryRepository.make().createOne({ 63 | name: `category_${i}`, 64 | posts: randomPosts 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "esModuleInterop": true, 6 | "declaration": true, 7 | "removeComments": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "allowSyntheticDefaultImports": true, 11 | "target": "ES2022", 12 | "sourceMap": true, 13 | "outDir": "dist", 14 | "baseUrl": "./", 15 | "incremental": true, 16 | "paths": { 17 | "@hodfords/typeorm-helper": ["lib"] 18 | } 19 | }, 20 | "include": [ 21 | "lib", 22 | "sample", 23 | "tests" 24 | ], 25 | "exclude": [ 26 | "dist", 27 | "node_modules" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------