├── .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 |
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 |
--------------------------------------------------------------------------------