├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .tool-versions ├── .vscode └── launch.json ├── LICENSE.md ├── README.md ├── config └── default.json ├── lib ├── error │ └── index.ts ├── filter │ ├── builder.filter.ts │ ├── decorator.filter.ts │ ├── index.ts │ └── parser.filter.ts ├── helper │ └── index.ts ├── index.ts ├── loader │ ├── decorator.loader.ts │ ├── index.ts │ ├── many-to-one.loader.ts │ ├── many.loader.ts │ ├── one-to-many.loader.ts │ └── one-to-one.loader.ts ├── order │ ├── builder.order.ts │ ├── decorator.order.ts │ ├── index.ts │ └── parser.order.ts ├── pagination │ ├── decorator.pagination.ts │ ├── index.ts │ └── parser.pagination.ts └── store │ ├── graphql.ts │ ├── index.ts │ └── typeorm.ts ├── nest-cli.json ├── nodemon-debug.json ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── app.module.ts ├── cors.options.ts ├── database │ ├── database-ormconfig.cli.ts │ ├── database-ormconfig.constant.ts │ ├── database.module.ts │ ├── database.types.ts │ └── migrations │ │ ├── .gitkeep │ │ └── 1588955268370-seed.ts ├── entities │ ├── author │ │ ├── author.entity.ts │ │ ├── author.module.ts │ │ └── author.resolver.ts │ ├── book │ │ ├── book.entity.ts │ │ ├── book.module.ts │ │ └── book.resolver.ts │ ├── entities.module.ts │ ├── item-image │ │ ├── item-image.entity.ts │ │ └── item-image.module.ts │ ├── item-text │ │ ├── item-text.entity.ts │ │ └── item-text.module.ts │ ├── item │ │ ├── item.entity.ts │ │ ├── item.itemable.ts │ │ ├── item.module.ts │ │ └── item.resolver.ts │ ├── section-title │ │ ├── section-title.entity.ts │ │ ├── section-title.module.ts │ │ └── section-title.resolver.ts │ └── section │ │ ├── section.entity.ts │ │ ├── section.module.ts │ │ └── section.resolver.ts ├── errors │ └── index.ts ├── graphql │ ├── graphql.module.ts │ └── graphql.options.ts ├── healthz │ ├── healthz.controller.ts │ └── healthz.module.ts ├── helpers │ ├── array.helper.ts │ ├── req.helper.ts │ ├── string.helper.ts │ └── validate.helper.ts ├── logger │ ├── logger.middleware.ts │ ├── logger.module.ts │ ├── logger.service.ts │ └── logger.store.ts └── main.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.lib.json └── types └── global.d.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | '@typescript-eslint/no-unused-vars': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /dist-lib 4 | /node_modules 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | src/graphql/module/schema.graphql 38 | src/graphql/schema.graphql 39 | src/graphql_old -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "printWidth": 140, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 20.5.0 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "debug", 11 | "cwd": "${workspaceFolder}", 12 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/ts-node", 13 | "args": [ 14 | "${workspaceFolder}/src/main.ts", 15 | "--runInBand", 16 | "--no-cache", 17 | ], 18 | "runtimeArgs": [ 19 | "--files", 20 | "-r", 21 | "${workspaceFolder}/node_modules/tsconfig-paths/register" 22 | ], 23 | "env": { 24 | "NODE_ENV": "development", 25 | "TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.json" 26 | }, 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS-GraphQL-Easy 2 | 3 |

4 | NPM Version 5 | Package License 6 | NPM Downloads 7 |

8 | 9 | ## Overview 10 | 11 | - [NestJS-GraphQL-Easy](#nestjs-graphql-easy) 12 | - [Overview](#overview) 13 | - [Description](#description) 14 | - [Introduction](#introduction) 15 | - [Installation](#installation) 16 | - [Note](#note) 17 | - [Important!](#important) 18 | - [Datasource](#datasource) 19 | - [Dataloader (n + 1 problem solver)](#dataloader-n--1-problem-solver) 20 | - [many](#many) 21 | - [one-to-many](#one-to-many) 22 | - [many-to-one](#many-to-one) 23 | - [one-to-one](#one-to-one) 24 | - [polymorphic](#polymorphic) 25 | - [Filtering](#filtering) 26 | - [Scalar](#scalar) 27 | - [Ordering](#ordering) 28 | - [Pagination](#pagination) 29 | - [Cursor pagination](#cursor-pagination) 30 | - [Permanent filters](#permanent-filters) 31 | 32 | ## Description 33 | 34 | A library for NestJS that implements a dataloader (including for polymorphic relation) for graphql, as well as automatic generation of arguments for filters, sorting and pagination, and their processing in the dataloader. 35 | 36 | ## Introduction 37 | 38 | With this library you will be able to easily create complex queries 39 | 40 | ```gql 41 | { 42 | authors( 43 | ORDER: { name: { SORT: ASC } } 44 | PAGINATION: { page: 0, per_page: 10 } 45 | ) { 46 | id 47 | name 48 | gender 49 | books( 50 | WHERE: { is_private: { EQ: false } } 51 | ORDER: { created_at: { SORT: DESC } } 52 | ) { 53 | id 54 | author_id 55 | title 56 | created_at 57 | } 58 | } 59 | } 60 | ``` 61 | 62 | ## Installation 63 | 64 | ```bash 65 | npm i nestjs-graphql-easy 66 | ``` 67 | 68 | ## Note 69 | 70 | **This library requires**: 71 | * NestJS 9 or higher version 72 | * TypeORM 0.3 or higher version 73 | 74 | **A fully working example with all the functionality is located in the `src` folder** 75 | 76 | **The library itself is located in the `lib` folder** 77 | 78 | `If you have questions or need help, please create GitHub Issue in this repository `[https://github.com/tkosminov/nestjs-graphql-easy](https://github.com/tkosminov/nestjs-graphql-easy) 79 | 80 | ## Important! 81 | 82 | 1. The typeorm model and the graphql object must be the same class. 83 | 2. Decorators `PolymorphicColumn`, `Column`, `Entity`, `CreateDateColumn`, `UpdateDateColumn`, `PrimaryColumn`, `PrimaryGeneratedColumn` from `typeorm` must be imported from `nestjs-graphql-easy` 84 | 3. Decorators `Field` (only for columns from tables), `ObjectType`, `Query`, `Mutation`, `ResolveField` from `graphql` must be imported from `nestjs-graphql-easy` 85 | 86 | * Points 2 and 3 are caused by the fact that it is necessary to collect data for auto-generation of filters and sorts, as well as not to deal with casting the names `graphql field <-> class property <-> typeorm column` and `graphql object <-> class name < -> typeorm table` (imported decorators from `nestjs-graphql-easy` removed the ability to set a name) 87 | 88 | 4. Decorators `Filter`, `Order` from `nestjs-graphql-easy` work only with loader types `ELoaderType.MANY` and `ELoaderType.ONE_TO_MANY` 89 | 5. Decorators `Pagination` from `nestjs-graphql-easy` work only with loader types `ELoaderType.MANY` 90 | 91 | ## Datasource 92 | 93 | Need to pass `DataSource` to `GraphQLExecutionContext`. 94 | 95 | I do this by creating a `GraphQLModule` using a class 96 | 97 | ```ts 98 | import { GraphQLModule } from '@nestjs/graphql'; 99 | import { ApolloDriver } from '@nestjs/apollo'; 100 | 101 | import { GraphqlOptions } from './graphql.options'; 102 | 103 | export default GraphQLModule.forRootAsync({ 104 | imports: [], 105 | useClass: GraphqlOptions, // <-- ADD 106 | inject: [], 107 | driver: ApolloDriver, 108 | }); 109 | ``` 110 | 111 | ```ts 112 | import { Injectable } from '@nestjs/common'; 113 | import { GqlOptionsFactory } from '@nestjs/graphql'; 114 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 115 | ... 116 | import { Request } from 'express'; 117 | import { DataSource } from 'typeorm'; 118 | import { setDataSource } from 'nestjs-graphql-easy' // <-- ADD 119 | 120 | @Injectable() 121 | export class GraphqlOptions implements GqlOptionsFactory { 122 | constructor(private readonly dataSource: DataSource) { // <-- ADD 123 | setDataSource(this.dataSource); // <-- ADD 124 | } 125 | 126 | public createGqlOptions(): Promise | ApolloDriverConfig { 127 | return { 128 | ... 129 | driver: ApolloDriver, 130 | context: ({ req }: { req: Request }) => ({ 131 | req, 132 | }), 133 | ... 134 | }; 135 | } 136 | } 137 | ``` 138 | 139 | ## Dataloader (n + 1 problem solver) 140 | 141 | Loader usage guide: 142 | 143 | 1. Add the `@Loader` parameter 144 | 1. Specify the type of relationship `loader_type` 145 | 2. Specify field name `field_name` 146 | 3. Specify entity `@Entity()` that is also an `@ObjectType()` using the return type function 147 | 4. Specify the name of the key in the entity for which the selection should be 148 | 2. Add the `@Context` parameter 149 | 3. In the body of the resolver, use the loader by passing the value of the key to fetch into it 150 | 151 | ### many 152 | 153 | ```ts 154 | @Resolver(() => Author) 155 | export class AuthorResolver { 156 | ... 157 | @Query(() => [Author]) 158 | public async authors( 159 | @Loader({ // <-- ADD 160 | loader_type: ELoaderType.MANY, 161 | field_name: 'authors', 162 | entity: () => Author, 163 | entity_fk_key: 'id', 164 | }) field_alias: string, 165 | @Context() ctx: GraphQLExecutionContext // <-- ADD 166 | ) { 167 | return await ctx[field_alias]; // <-- ADD 168 | } 169 | ... 170 | } 171 | ``` 172 | 173 | ### one-to-many 174 | 175 | ```ts 176 | @Resolver(() => Author) 177 | export class AuthorResolver { 178 | ... 179 | @ResolveField(() => [Book], { nullable: true }) 180 | public async books( 181 | @Parent() author: Author, // <-- ADD 182 | @Loader({ // <-- ADD 183 | loader_type: ELoaderType.ONE_TO_MANY, 184 | field_name: 'books', 185 | entity: () => Book, 186 | entity_fk_key: 'author_id', 187 | }) 188 | field_alias: string, 189 | @Context() ctx: GraphQLExecutionContext // <-- ADD 190 | ): Promise { 191 | return await ctx[field_alias].load(author.id); // <-- ADD 192 | } 193 | ... 194 | } 195 | ``` 196 | 197 | ### many-to-one 198 | 199 | ```ts 200 | @Resolver(() => Book) 201 | export class BookResolver { 202 | ... 203 | @ResolveField(() => Author, { nullable: false }) 204 | public async author( 205 | @Parent() book: Book, // <-- ADD 206 | @Loader({ // <-- ADD 207 | loader_type: ELoaderType.MANY_TO_ONE, 208 | field_name: 'author', 209 | entity: () => Author, 210 | entity_fk_key: 'id', 211 | }) 212 | field_alias: string, 213 | @Context() ctx: GraphQLExecutionContext // <-- ADD 214 | ): Promise { 215 | return await ctx[field_alias].load(book.author_id); // <-- ADD 216 | } 217 | ... 218 | } 219 | ``` 220 | 221 | ### one-to-one 222 | 223 | ```ts 224 | @Resolver(() => Section) 225 | export class SectionResolver { 226 | ... 227 | @ResolveField(() => SectionTitle, { nullable: true }) 228 | public async section_title( 229 | @Parent() section: Section, // <-- ADD 230 | @Loader({ // <-- ADD 231 | loader_type: ELoaderType.ONE_TO_ONE, 232 | field_name: 'section_title', 233 | entity: () => SectionTitle, 234 | entity_fk_key: 'section_id', 235 | }) 236 | field_alias: string, 237 | @Context() ctx: GraphQLExecutionContext // <-- ADD 238 | ): Promise { 239 | return await ctx[field_alias].load(section.id); // <-- ADD 240 | } 241 | ... 242 | } 243 | 244 | @Resolver(() => SectionTitle) 245 | export class SectionTitleResolver { 246 | ... 247 | @ResolveField(() => Section, { nullable: false }) 248 | public async section( 249 | @Parent() section_title: SectionTitle, // <-- ADD 250 | @Loader({ // <-- ADD 251 | loader_type: ELoaderType.ONE_TO_ONE, 252 | field_name: 'section', 253 | entity: () => Section, 254 | entity_fk_key: 'id', 255 | }) 256 | field_alias: string, 257 | @Context() ctx: GraphQLExecutionContext // <-- ADD 258 | ): Promise { 259 | return await ctx[field_alias].load(section_title.section_id); // <-- ADD 260 | } 261 | ... 262 | } 263 | ``` 264 | 265 | ### polymorphic 266 | 267 | For a polymorphic relationship, you need to create a `UnionType`: 268 | 269 | ```ts 270 | export const ItemableType = createUnionType({ // <-- ADD 271 | name: 'ItemableType', 272 | types: () => [ItemText, ItemImage], 273 | resolveType(value) { 274 | if (value instanceof ItemText) { 275 | return ItemText; 276 | } else if (value instanceof ItemImage) { 277 | return ItemImage; 278 | } 279 | }, 280 | }); 281 | ``` 282 | 283 | In the model entity, add two columns to indicate the foreign key and the name of the foreign model: 284 | 285 | ```ts 286 | @ObjectType() 287 | @Entity() 288 | export class Item { 289 | ... 290 | /** 291 | * For a polymorphic relationship, the relationship in the Entity is not specified. 292 | * But you need to create columns for foreign key and table type. 293 | */ 294 | 295 | @Field(() => ID) 296 | @Index() 297 | @Column('uuid', { nullable: false }) 298 | @PolymorphicColumn() // <-- ADD 299 | public itemable_id: string; // foreign key 300 | 301 | @Field(() => String) 302 | @Index() 303 | @Column({ nullable: false }) 304 | @PolymorphicColumn() // <-- ADD 305 | public itemable_type: string; // foreign type 306 | ... 307 | } 308 | ``` 309 | 310 | ```ts 311 | @Resolver(() => Item) 312 | export class ItemResolver { 313 | ... 314 | @ResolveField(() => ItemableType, { nullable: true }) 315 | public async itemable( 316 | @Parent() item: Item, 317 | @Loader({ 318 | loader_type: ELoaderType.POLYMORPHIC, 319 | field_name: 'itemable', 320 | entity: () => ItemableType, // For a polymorphic relation, it is necessary to specify here not the Entity, but the Union type. 321 | entity_fk_key: 'id', 322 | entity_fk_type: 'itemable_type', 323 | }) field_alias: string, 324 | @Context() ctx: GraphQLExecutionContext 325 | ) { 326 | return await ctx[field_alias].load(item.itemable_id); 327 | } 328 | ... 329 | } 330 | ``` 331 | 332 | Polymorphic query example: 333 | 334 | ```gql 335 | { 336 | items { 337 | id 338 | itemable_id 339 | itemable_type 340 | itemable { 341 | __typename 342 | ... on ItemText { 343 | id 344 | value 345 | } 346 | ... on ItemImage { 347 | id 348 | file_url 349 | created_at 350 | } 351 | } 352 | } 353 | } 354 | ``` 355 | 356 | ## Filtering 357 | 358 | Filters work in tandem with the dataloader and make it possible to filter entities by conditions: 359 | 360 | ```ts 361 | enum EFilterOperation { 362 | EQ = '=', 363 | NOT_EQ = '!=', 364 | NULL = 'IS NULL', 365 | NOT_NULL = 'IS NOT NULL', 366 | IN = 'IN', 367 | NOT_IN = 'NOT IN', 368 | ILIKE = 'ILIKE', 369 | NOT_ILIKE = 'NOT ILIKE', 370 | GT = '>', 371 | GTE = '>=', 372 | LT = '<', 373 | LTE = '<=', 374 | } 375 | ``` 376 | 377 | Depending on the type of field to be used in the filter, the following operations apply: 378 | 379 | * basic (all types) operations: 380 | ```ts 381 | ['EQ', 'NOT_EQ', 'NULL', 'NOT_NULL', 'IN', 'NOT_IN'] 382 | ``` 383 | * string (String) operations: 384 | ```ts 385 | ['ILIKE', 'NOT_ILIKE'] 386 | ``` 387 | * precision (Number, Int, Float, Date, ID) operations: 388 | ```ts 389 | ['GT', 'GTE', 'LT', 'LTE'] 390 | ``` 391 | 392 | Filters are generated based on the information specified in the `@Field` provided in the model: 393 | 394 | ```ts 395 | @ObjectType() 396 | @Entity() 397 | export class Author { 398 | @Field(() => ID, { filterable: true }) // <-- ADD 399 | @PrimaryGeneratedColumn('uuid') 400 | public id: string; 401 | ... 402 | } 403 | ``` 404 | 405 | ```ts 406 | @Resolver(() => Author) 407 | export class AuthorResolver { 408 | ... 409 | @Query(() => [Author]) 410 | public async authors( 411 | @Loader({ 412 | loader_type: ELoaderType.MANY, 413 | field_name: 'authors', 414 | entity: () => Author, 415 | entity_fk_key: 'id', 416 | }) field_alias: string, 417 | @Filter(() => Author) _filter: unknown, // <-- ADD 418 | @Context() ctx: GraphQLExecutionContext 419 | ) { 420 | return await ctx[field_alias]; 421 | } 422 | ... 423 | } 424 | ``` 425 | 426 | This will add arguments to the query for filtering: 427 | 428 | ```gql 429 | { 430 | authors(WHERE: { id: { EQ: 1 } }) { 431 | id 432 | name 433 | } 434 | } 435 | ``` 436 | 437 | When working with filters, it is important to remember [point 4 of the important section](#important). 438 | 439 | ### Scalar 440 | 441 | If the field type is a scalar, then by default only basic filtering operations can be used for such a field. 442 | 443 | If you need to add the use of other operations, you can specify this: 444 | 445 | ```ts 446 | import { DateTimeISOResolver } from 'graphql-scalars'; 447 | 448 | @ObjectType() 449 | @Entity() 450 | export class Author { 451 | @Field(() => DateTimeISOResolver, { 452 | filterable: true, 453 | allow_filters_from: [EDataType.PRECISION], 454 | }) 455 | @UpdateDateColumn({ 456 | type: 'timestamp without time zone', 457 | precision: 3, 458 | default: () => 'CURRENT_TIMESTAMP', 459 | }) 460 | public updated_at: Date; 461 | ... 462 | } 463 | ``` 464 | 465 | If the field type is not a scalar, then this option will be ignored. 466 | 467 | ## Ordering 468 | 469 | Ordering works in tandem with the data loader and allows you to sort entities. Arguments for the query are created based on the information provided in the model in `@Field` 470 | 471 | ```ts 472 | @ObjectType() 473 | @Entity() 474 | export class Author { 475 | @Field(() => ID, { sortable: true }) // <-- ADD 476 | @PrimaryGeneratedColumn('uuid') 477 | public id: string; 478 | ... 479 | } 480 | ``` 481 | 482 | ```ts 483 | @Resolver(() => Author) 484 | export class AuthorResolver { 485 | ... 486 | @Query(() => [Author]) 487 | public async authors( 488 | @Loader({ 489 | loader_type: ELoaderType.MANY, 490 | field_name: 'authors', 491 | entity: () => Author, 492 | entity_fk_key: 'id', 493 | }) field_alias: string, 494 | @Order(() => Author) _order: unknown, // <-- ADD 495 | @Context() ctx: GraphQLExecutionContext 496 | ) { 497 | return await ctx[field_alias]; 498 | } 499 | ... 500 | } 501 | ``` 502 | 503 | This will add arguments to the query for ordering: 504 | 505 | ```gql 506 | { 507 | authors(ORDER: { id: { SORT: ASC, NULLS: LAST } }) { 508 | id 509 | name 510 | } 511 | } 512 | ``` 513 | 514 | When working with ordering, it is important to remember [point 4 of the important section](#important). 515 | 516 | ## Pagination 517 | 518 | Pagination works in tandem with a dataloader and allows you to limit the number of records received from the database 519 | 520 | ```ts 521 | @Resolver(() => Author) 522 | export class AuthorResolver { 523 | ... 524 | @Query(() => [Author]) 525 | public async authors( 526 | @Loader({ 527 | loader_type: ELoaderType.MANY, 528 | field_name: 'authors', 529 | entity: () => Author, 530 | entity_fk_key: 'id', 531 | }) field_alias: string, 532 | @Pagination() _pagination: unknown, // <-- ADD 533 | @Context() ctx: GraphQLExecutionContext 534 | ) { 535 | return await ctx[field_alias]; 536 | } 537 | ... 538 | } 539 | ``` 540 | 541 | This will add arguments to the query for pagination: 542 | 543 | ```gql 544 | { 545 | authors(PAGINATION: { page: 0, per_page: 10 }) { 546 | id 547 | name 548 | } 549 | } 550 | ``` 551 | 552 | When working with pagination, it is important to remember [point 5 of the important section](#important). 553 | 554 | ## [Cursor pagination](https://the-guild.dev/blog/graphql-cursor-pagination-with-postgresql) 555 | 556 | Pagination works in tandem with a data loader, filters, and sorting and allows you to limit the number of records received from the database 557 | 558 | ```ts 559 | @Resolver(() => Author) 560 | export class AuthorResolver { 561 | ... 562 | @Query(() => [Author]) 563 | public async authors( 564 | @Loader({ 565 | loader_type: ELoaderType.MANY, 566 | field_name: 'authors', 567 | entity: () => Author, 568 | entity_fk_key: 'id', 569 | }) field_alias: string, 570 | @Filter(() => Author) _filter: unknown, // <-- ADD 571 | @Order(() => Author) _order: unknown, // <-- ADD 572 | @Pagination() _pagination: unknown, // <-- ADD 573 | @Context() ctx: GraphQLExecutionContext 574 | ) { 575 | return await ctx[field_alias]; 576 | } 577 | ... 578 | } 579 | ``` 580 | 581 | Then you can get the first page using the query: 582 | 583 | ```gql 584 | query firstPage { 585 | authors( 586 | ORDER: { id: { SORT: ASC } } 587 | PAGINATION: { per_page: 10 } 588 | ) { 589 | id 590 | } 591 | } 592 | ``` 593 | 594 | Then you can get the next page using the query: 595 | 596 | ```gql 597 | query nextPage($ID_of_the_last_element_from_the_previous_page: ID!) { 598 | authors( 599 | WHERE: { id: { GT: $ID_of_the_last_element_from_the_previous_page }} 600 | ORDER: { id: { SORT: ASC } } 601 | PAGINATION: { per_page: 10 } 602 | ) { 603 | id 604 | } 605 | } 606 | ``` 607 | 608 | Fields that are planned to be used as a cursor must be allowed for filtering and sorting in the `@Field` decorator, and it is also recommended to index them indicating the sort order. 609 | 610 | With such pagination, it is important to take into account the order in which the fields specified in the sorting are listed. 611 | 612 | You can also use several fields as cursors. The main thing is to maintain order. 613 | 614 | Then you can get the first page using the query: 615 | 616 | ```gql 617 | query firstPage{ 618 | authors( 619 | ORDER: { updated_at: { SORT: DESC }, id: { SORT: ASC } } 620 | PAGINATION: { per_page: 10 } 621 | ) { 622 | id 623 | } 624 | } 625 | 626 | ``` 627 | 628 | Then you can get the next page using the query: 629 | 630 | ```gql 631 | query nextPage( 632 | $UPDATED_AT_of_the_last_element_from_the_previous_page: DateTime! 633 | $ID_of_the_last_element_from_the_previous_page: ID! 634 | ) { 635 | authors( 636 | WHERE: { 637 | updated_at: { LT: $UPDATED_AT_of_the_last_element_from_the_previous_page } 638 | OR: { 639 | updated_at: { 640 | EQ: $UPDATED_AT_of_the_last_element_from_the_previous_page 641 | } 642 | id: { GT: $ID_of_the_last_element_from_the_previous_page } 643 | } 644 | } 645 | ORDER: { updated_at: { SORT: DESC }, id: { SORT: ASC } } 646 | PAGINATION: { per_page: 10 } 647 | ) { 648 | id 649 | } 650 | } 651 | ``` 652 | 653 | However, it is recommended to limit the time columns to milliseconds: 654 | 655 | ```ts 656 | @ObjectType() 657 | @Entity() 658 | export class Author { 659 | ... 660 | @Field(() => Date, { filterable: true, sortable: true }) 661 | @UpdateDateColumn({ 662 | type: 'timestamp without time zone', 663 | precision: 3, // <-- ADD 664 | default: () => 'CURRENT_TIMESTAMP', 665 | }) 666 | public updated_at: Date; 667 | ... 668 | } 669 | ``` 670 | 671 | ## Permanent filters 672 | 673 | You can also specify permanent filters that will always be applied regardless of the query 674 | 675 | To do this, you need to pass `entity_wheres` to the data loader: 676 | 677 | ```ts 678 | @Resolver(() => Author) 679 | export class AuthorResolver { 680 | @ResolveField(() => [Book], { nullable: true }) 681 | ... 682 | public async books( 683 | @Parent() author: Author, 684 | @Loader({ 685 | loader_type: ELoaderType.ONE_TO_MANY, 686 | field_name: 'books', 687 | entity: () => Book, 688 | entity_fk_key: 'author_id', 689 | entity_wheres: [ // <-- ADD 690 | { 691 | query: 'book.is_private = :is_private', 692 | params: { is_private: false }, 693 | }, 694 | ], 695 | }) 696 | field_alias: string, 697 | @Context() ctx: GraphQLExecutionContext 698 | ): Promise { 699 | return await ctx[field_alias].load(author.id); 700 | } 701 | ... 702 | } 703 | ``` 704 | 705 | Such a filter can use the columns of entities joined via `entity_joins`: 706 | 707 | ```ts 708 | @Resolver(() => Author) 709 | export class AuthorResolver { 710 | @ResolveField(() => [Book], { nullable: true }) 711 | ... 712 | public async books( 713 | @Parent() author: Author, 714 | @Loader({ 715 | loader_type: ELoaderType.ONE_TO_MANY, 716 | field_name: 'books', 717 | entity: () => Book, 718 | entity_fk_key: 'author_id', 719 | entity_wheres: [ // <-- ADD 720 | { 721 | query: 'book.is_private = :is_private', 722 | params: { is_private: false }, 723 | }, 724 | { // <-- ADD 725 | query: 'sections.title IS NOT NULL', 726 | }, 727 | ], 728 | entity_joins: [ // <-- ADD 729 | { 730 | query: 'book.sections', 731 | alias: 'sections', 732 | }, 733 | ], 734 | }) 735 | field_alias: string, 736 | @Context() ctx: GraphQLExecutionContext 737 | ): Promise { 738 | return await ctx[field_alias].load(author.id); 739 | } 740 | ... 741 | } 742 | ``` 743 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "APP_SETTINGS": { 3 | "port": 8082, 4 | "wssPort": 8083, 5 | "wssPingInterval": 3000, 6 | "wssPingTimeout": 10000, 7 | "bodyLimit": "50mb", 8 | "bodyParameterLimit": 50000 9 | }, 10 | "LOGGER_SETTINGS": { 11 | "level": "info", 12 | "silence": [ 13 | "healthz" 14 | ] 15 | }, 16 | "DB_SETTINGS": { 17 | "host": "localhost", 18 | "port": 5432, 19 | "username": "postgres", 20 | "password": "postgres", 21 | "database": "nestjs_graphql_easy", 22 | "logging": "all", 23 | "synchronize": true 24 | }, 25 | "CORS_SETTINGS": { 26 | "allowedOrigins": [], 27 | "allowedUrls": [], 28 | "allowedMethods": [ 29 | "GET", 30 | "POST", 31 | "PUT", 32 | "PATCH", 33 | "DELETE", 34 | "OPTIONS" 35 | ], 36 | "allowedCredentials": false, 37 | "allowedHeaders": [] 38 | }, 39 | "GRAPHQL_SETTINGS": { 40 | "playground": true, 41 | "debug": true, 42 | "introspection": true, 43 | "installSubscriptionHandlers": true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/error/index.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '@nestjs/common'; 2 | 3 | interface IErrData { 4 | msg?: string; 5 | raise?: boolean; 6 | } 7 | 8 | export const bad_request = (data?: IErrData) => { 9 | const err = new HttpException( 10 | { 11 | status: 400, 12 | error: data?.msg || 'BAD_REQUEST', 13 | }, 14 | 400 15 | ); 16 | 17 | if (data?.raise) { 18 | throw err; 19 | } 20 | 21 | return err; 22 | }; 23 | 24 | export const invalid_data_source = (data?: IErrData) => { 25 | const err = new HttpException( 26 | { 27 | status: 500, 28 | error: data?.msg || 'INVALID_DATA_SOURCE', 29 | }, 30 | 500 31 | ); 32 | 33 | if (data?.raise) { 34 | throw err; 35 | } 36 | 37 | return err; 38 | }; 39 | -------------------------------------------------------------------------------- /lib/filter/builder.filter.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import { InputType, ReturnTypeFunc, Int, Float, GqlTypeReference, ID } from '@nestjs/graphql'; 3 | 4 | import { decorateField, where_field_input_types, where_input_types, gql_fields, gql_enums, IField, EDataType } from '../store/graphql'; 5 | 6 | export enum EFilterOperator { 7 | AND = 'AND', 8 | OR = 'OR', 9 | } 10 | 11 | export enum EFilterOperation { 12 | EQ = '=', 13 | NOT_EQ = '!=', 14 | NULL = 'IS NULL', 15 | NOT_NULL = 'IS NOT NULL', 16 | IN = 'IN', 17 | NOT_IN = 'NOT IN', 18 | ILIKE = 'ILIKE', 19 | NOT_ILIKE = 'NOT ILIKE', 20 | GT = '>', 21 | GTE = '>=', 22 | LT = '<', 23 | LTE = '<=', 24 | } 25 | 26 | const basic_operations = ['EQ', 'NOT_EQ', 'NULL', 'IN', 'NOT_IN']; 27 | const string_operations = ['ILIKE', 'NOT_ILIKE']; 28 | const precision_operations = ['GT', 'GTE', 'LT', 'LTE']; 29 | 30 | const string_types: GqlTypeReference[] = [String]; 31 | const precision_types: GqlTypeReference[] = [ID, Int, Float, Number, Date]; 32 | const other_types: GqlTypeReference[] = [Boolean]; 33 | 34 | function findEnumName(col_type: GqlTypeReference) { 35 | let col_type_name: string = null; 36 | 37 | gql_enums.forEach((e) => { 38 | if (e.type_function() === col_type) { 39 | col_type_name = e.name; 40 | 41 | return; 42 | } 43 | }); 44 | 45 | return col_type_name; 46 | } 47 | 48 | const buildFilterField = (column: IField): ReturnTypeFunc => { 49 | const col_type: GqlTypeReference = column.type_function(); 50 | 51 | let col_type_name: string = col_type['name']; 52 | 53 | if (col_type_name == null) { 54 | col_type_name = findEnumName(col_type); 55 | } 56 | 57 | const name = `${col_type_name}_FilterInputType`; 58 | 59 | if (where_field_input_types.has(name)) { 60 | return where_field_input_types.get(name); 61 | } 62 | 63 | const field_input_type = function fieldInputType() {}; 64 | 65 | basic_operations.forEach((operation) => { 66 | switch (EFilterOperation[operation]) { 67 | case EFilterOperation.NULL: 68 | case EFilterOperation.NOT_NULL: 69 | decorateField(field_input_type, operation, () => Boolean, { 70 | nullable: true, 71 | }); 72 | break; 73 | case EFilterOperation.IN: 74 | case EFilterOperation.NOT_IN: 75 | decorateField(field_input_type, operation, () => [col_type], { 76 | nullable: true, 77 | }); 78 | break; 79 | default: 80 | decorateField(field_input_type, operation, () => col_type, { 81 | nullable: true, 82 | }); 83 | break; 84 | } 85 | }); 86 | 87 | let allow_filters_from = column.options?.allow_filters_from; 88 | 89 | if ((string_types.includes(col_type) || precision_types.includes(col_type) || other_types.includes(col_type)) && allow_filters_from) { 90 | allow_filters_from = undefined; 91 | } 92 | 93 | if (string_types.includes(col_type) || allow_filters_from?.includes(EDataType.STRING)) { 94 | string_operations.forEach((operation) => { 95 | decorateField(field_input_type, operation, () => col_type, { 96 | nullable: true, 97 | }); 98 | }); 99 | } 100 | 101 | if (precision_types.includes(col_type) || allow_filters_from?.includes(EDataType.PRECISION)) { 102 | precision_operations.forEach((operation) => { 103 | decorateField(field_input_type, operation, () => col_type, { 104 | nullable: true, 105 | }); 106 | }); 107 | } 108 | 109 | Object.defineProperty(field_input_type, 'name', { 110 | value: name, 111 | }); 112 | 113 | InputType()(field_input_type); 114 | 115 | where_field_input_types.set(name, () => field_input_type); 116 | 117 | return () => field_input_type; 118 | }; 119 | 120 | export const buildFilter = (enity: ReturnTypeFunc): ReturnTypeFunc => { 121 | const entity_class_name = enity()['name']; 122 | 123 | if (where_input_types.has(entity_class_name)) { 124 | return where_input_types.get(entity_class_name); 125 | } 126 | 127 | const where_input_type = function whereInputType() {}; 128 | 129 | gql_fields.get(entity_class_name).forEach((col) => { 130 | if (col.options?.filterable) { 131 | decorateField(where_input_type, col.name, buildFilterField(col)); 132 | } 133 | }); 134 | 135 | Object.values(EFilterOperator).forEach((operator) => { 136 | decorateField(where_input_type, operator, () => [where_input_type]); 137 | }); 138 | 139 | Object.defineProperty(where_input_type, 'name', { 140 | value: `${entity_class_name}_FilterInputType`, 141 | }); 142 | 143 | InputType()(where_input_type); 144 | 145 | where_input_types.set(entity_class_name, () => where_input_type); 146 | 147 | return () => where_input_type; 148 | }; 149 | -------------------------------------------------------------------------------- /lib/filter/decorator.filter.ts: -------------------------------------------------------------------------------- 1 | import { Args, ReturnTypeFunc } from '@nestjs/graphql'; 2 | 3 | import { buildFilter } from './builder.filter'; 4 | 5 | export const Filter = (enity: ReturnTypeFunc) => { 6 | return Args({ 7 | name: 'WHERE', 8 | nullable: true, 9 | type: buildFilter(enity), 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /lib/filter/index.ts: -------------------------------------------------------------------------------- 1 | export { Filter } from './decorator.filter'; 2 | export { parseFilter } from './parser.filter'; 3 | -------------------------------------------------------------------------------- /lib/filter/parser.filter.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid'; 2 | 3 | import { EFilterOperator, EFilterOperation } from './builder.filter'; 4 | 5 | export type IFilterValue = Record; 6 | 7 | export interface IParsedFilter { 8 | query: string; 9 | params: IFilterValue; 10 | } 11 | 12 | const nanoid = customAlphabet('1234567890abcdef', 10); 13 | 14 | function recursiveParseFilter(relation_table: string, data: IFilterValue, field?: string) { 15 | let query = ''; 16 | let params = {}; 17 | 18 | Object.entries(data).forEach(([key, value], index) => { 19 | if (key === EFilterOperator.AND || key === EFilterOperator.OR) { 20 | let results = ` ${EFilterOperator[key]} (`; 21 | 22 | (value as IFilterValue[]).forEach((v, i) => { 23 | const res = recursiveParseFilter(relation_table, v); 24 | 25 | if (i > 0 && !v[EFilterOperator.AND] && !v[EFilterOperator.OR]) { 26 | results += ' AND '; 27 | } 28 | 29 | results += res.query; 30 | 31 | params = { ...params, ...res.params }; 32 | }); 33 | 34 | results += ')'; 35 | 36 | query += results; 37 | } else if (field) { 38 | const param_key = nanoid(); 39 | 40 | if (index > 0) { 41 | query += ' AND '; 42 | } 43 | 44 | switch (EFilterOperation[key]) { 45 | case EFilterOperation.EQ: 46 | case EFilterOperation.NOT_EQ: 47 | case EFilterOperation.GT: 48 | case EFilterOperation.GTE: 49 | case EFilterOperation.LT: 50 | case EFilterOperation.LTE: 51 | params[param_key] = value; 52 | query += `${relation_table}.${field} ${EFilterOperation[key]} :${param_key}`; 53 | break; 54 | case EFilterOperation.NULL: 55 | if (!!value) { 56 | query += `${relation_table}.${field} ${EFilterOperation.NULL}`; 57 | } else { 58 | query += `${relation_table}.${field} ${EFilterOperation.NOT_NULL}`; 59 | } 60 | break; 61 | case EFilterOperation.NOT_NULL: 62 | if (!!value) { 63 | query += `${relation_table}.${field} ${EFilterOperation.NOT_NULL}`; 64 | } else { 65 | query += `${relation_table}.${field} ${EFilterOperation.NULL}`; 66 | } 67 | break; 68 | case EFilterOperation.IN: 69 | case EFilterOperation.NOT_IN: 70 | params[param_key] = value; 71 | query += `${relation_table}.${field} ${EFilterOperation[key]} (:...${param_key})`; 72 | break; 73 | case EFilterOperation.ILIKE: 74 | case EFilterOperation.NOT_ILIKE: 75 | params[param_key] = value; 76 | query += `${relation_table}.${field} ${EFilterOperation[key]} '%' || :${param_key} || '%'`; 77 | break; 78 | } 79 | } else { 80 | const res = recursiveParseFilter(relation_table, value as IFilterValue, key); 81 | 82 | if (index > 0) { 83 | query += ' AND '; 84 | } 85 | 86 | query += res.query; 87 | params = { ...params, ...res.params }; 88 | } 89 | }); 90 | 91 | return { query, params }; 92 | } 93 | 94 | export function parseFilter(relation_table: string, data: IFilterValue) { 95 | return recursiveParseFilter(relation_table, data); 96 | } 97 | -------------------------------------------------------------------------------- /lib/helper/index.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor, plainToInstance } from 'class-transformer'; 2 | import { validateSync, ValidationError } from 'class-validator'; 3 | 4 | import { bad_request } from '../error'; 5 | 6 | export function capitalize(str: string) { 7 | const capitalized_chars = str.replace(/[-_\s.]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')); 8 | const capital_char = capitalized_chars[0].toUpperCase(); 9 | 10 | return capital_char + capitalized_chars.slice(1); 11 | } 12 | 13 | export function underscore(str: string) { 14 | return str 15 | .replace(/(?:^|\.?)([A-Z])/g, function (x, y) { 16 | return '_' + y.toLowerCase(); 17 | }) 18 | .replace(/^_/, ''); 19 | } 20 | 21 | export function pluck(array: T[], key: string): K[] { 22 | return array.map((a) => a[key]); 23 | } 24 | 25 | export function shuffle(array: T[]): T[] { 26 | return array.sort(() => Math.random() - 0.5); 27 | } 28 | 29 | export function uniq(array: T[]): T[] { 30 | return Array.from(new Set(array)); 31 | } 32 | 33 | export function reduceToObject(array: T[], key: string): { [K: string]: T } { 34 | return array.reduce((acc, curr) => { 35 | acc[curr[key]] = curr; 36 | 37 | return acc; 38 | }, {}); 39 | } 40 | 41 | export function groupBy(array: T[], key: string): { [key: string]: T[] } { 42 | return array.reduce( 43 | (acc, curr) => { 44 | if (!acc.hasOwnProperty(curr[key])) { 45 | acc[curr[key]] = []; 46 | } 47 | 48 | acc[curr[key]].push(curr); 49 | 50 | return acc; 51 | }, 52 | {} as { [key: string]: T[] } 53 | ); 54 | } 55 | 56 | export function validateDTO(type: ClassConstructor, value: unknown) { 57 | const errors: ValidationError[] = validateSync(plainToInstance(type, value) as object, { skipMissingProperties: true }); 58 | 59 | if (errors.length > 0) { 60 | const msg = errors.map((error) => Object.values(error.constraints)).join(', '); 61 | 62 | bad_request({ raise: true, msg }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './helper'; 2 | export * from './filter'; 3 | export * from './order'; 4 | export * from './pagination'; 5 | export * from './loader'; 6 | export * from './store'; 7 | -------------------------------------------------------------------------------- /lib/loader/decorator.loader.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLExecutionContext, ReturnTypeFunc } from '@nestjs/graphql'; 2 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 3 | 4 | import { FragmentDefinitionNode, GraphQLResolveInfo, SelectionNode } from 'graphql'; 5 | import { DataSource, EntityManager } from 'typeorm'; 6 | 7 | import { underscore } from '../helper'; 8 | 9 | import { IFilterValue, IParsedFilter, parseFilter } from '../filter/parser.filter'; 10 | import { TOrderValue, IParsedOrder, parseOrder } from '../order/parser.order'; 11 | import { IPaginationValue, IParsedPagination, parsePagination } from '../pagination/parser.pagination'; 12 | import { getTableColumns, getTableForeignKeys, getTablePrimaryKeys } from '../store'; 13 | import { invalid_data_source } from '../error'; 14 | 15 | import { manyToOneLoader } from './many-to-one.loader'; 16 | import { oneToManyLoader } from './one-to-many.loader'; 17 | import { manyLoader } from './many.loader'; 18 | import { oneToOneLoader } from './one-to-one.loader'; 19 | 20 | export enum ELoaderType { 21 | MANY_TO_ONE = 'MANY_TO_ONE', 22 | ONE_TO_MANY = 'ONE_TO_MANY', 23 | ONE_TO_ONE = 'ONE_TO_ONE', 24 | MANY = 'MANY', 25 | POLYMORPHIC = 'POLYMORPHIC', 26 | } 27 | 28 | export interface ILoaderData { 29 | field_name: string; 30 | loader_type: ELoaderType; 31 | entity: ReturnTypeFunc; 32 | entity_fk_key: string; 33 | entity_fk_type?: string; 34 | entity_joins?: Array<{ 35 | query: string; 36 | alias: string; 37 | }>; 38 | entity_wheres?: Array<{ 39 | query: string; 40 | params?: Record; 41 | }>; 42 | } 43 | 44 | export interface IPrivateLoaderData extends ILoaderData { 45 | entity_manager?: EntityManager; 46 | } 47 | 48 | let data_source: DataSource = null; 49 | 50 | export function setDataSource(ds: DataSource) { 51 | data_source = ds; 52 | } 53 | 54 | export const Loader = createParamDecorator((data: ILoaderData, ctx: ExecutionContext) => { 55 | const args = ctx.getArgs(); 56 | 57 | const _data: IPrivateLoaderData = data; 58 | 59 | const parent: Record | null = args[0]; 60 | const gargs: Record | undefined | null = args[1]; 61 | const gctx: GraphQLExecutionContext & { data_source: DataSource } = args[2]; 62 | const info: GraphQLResolveInfo = args[3]; 63 | 64 | let entity_class_name: string; 65 | 66 | if (_data.loader_type === ELoaderType.POLYMORPHIC) { 67 | entity_class_name = parent[_data.entity_fk_type] as string; 68 | } else { 69 | entity_class_name = _data.entity()['name']; 70 | } 71 | 72 | const entity_table_name = underscore(entity_class_name); 73 | const field_alias = entity_table_name; 74 | 75 | const filters: IFilterValue | undefined = gargs['WHERE']; 76 | let parsed_filters: IParsedFilter = null; 77 | 78 | if (filters) { 79 | parsed_filters = parseFilter(entity_table_name, filters); 80 | } 81 | 82 | const orders: TOrderValue | undefined = gargs['ORDER']; 83 | let parsed_orders: IParsedOrder[] = null; 84 | 85 | if (orders) { 86 | parsed_orders = parseOrder(entity_table_name, orders); 87 | } 88 | 89 | const paginations: IPaginationValue | undefined = gargs['PAGINATION']; 90 | let parsed_paginations: IParsedPagination = null; 91 | 92 | if (paginations) { 93 | parsed_paginations = parsePagination(paginations); 94 | } 95 | 96 | const selected_fields = recursiveSelectedFields(_data, info.fieldNodes, info.fragments); 97 | const entity_table_columns = getTableColumns(entity_class_name); 98 | const entity_table_foreign_keys = getTableForeignKeys(entity_class_name); 99 | const entity_table_primary_keys = getTablePrimaryKeys(entity_class_name); 100 | 101 | const selected_columns = new Set(Array.from(selected_fields).filter((field) => entity_table_columns.has(field))); 102 | 103 | entity_table_foreign_keys.forEach((fk) => { 104 | selected_columns.add(fk); 105 | }); 106 | 107 | entity_table_primary_keys.forEach((pk) => { 108 | selected_columns.add(pk); 109 | }); 110 | 111 | if (!_data.entity_manager) { 112 | if (!gctx['entity_manager']) { 113 | if (data_source) { 114 | gctx['entity_manager'] = data_source.createEntityManager(); 115 | } else if (gctx?.data_source) { 116 | gctx['entity_manager'] = gctx.data_source.createEntityManager(); 117 | } else { 118 | invalid_data_source({ raise: true }); 119 | } 120 | } 121 | 122 | _data.entity_manager = gctx['entity_manager']; 123 | } 124 | 125 | switch (_data.loader_type) { 126 | case ELoaderType.MANY_TO_ONE: 127 | gctx[field_alias] = manyToOneLoader(selected_columns, entity_table_name, _data); 128 | break; 129 | case ELoaderType.ONE_TO_MANY: 130 | gctx[field_alias] = oneToManyLoader(selected_columns, entity_table_name, _data, parsed_filters, parsed_orders); 131 | break; 132 | case ELoaderType.ONE_TO_ONE: 133 | gctx[field_alias] = oneToOneLoader(selected_columns, entity_table_name, _data); 134 | break; 135 | case ELoaderType.MANY: 136 | gctx[field_alias] = manyLoader(selected_columns, entity_table_name, _data, parsed_filters, parsed_orders, parsed_paginations); 137 | break; 138 | case ELoaderType.POLYMORPHIC: 139 | gctx[field_alias] = oneToOneLoader(selected_columns, entity_table_name, _data); 140 | break; 141 | default: 142 | break; 143 | } 144 | 145 | return field_alias; 146 | }); 147 | 148 | function recursiveSelectedFields( 149 | data: ILoaderData, 150 | selectionNodes: ReadonlyArray, 151 | fragments: Record 152 | ) { 153 | let results: Set = new Set([]); 154 | 155 | for (const node of selectionNodes) { 156 | if (node.kind === 'Field' && node.selectionSet && data.field_name === node.name.value) { 157 | for (const selection of node.selectionSet.selections) { 158 | if (selection.kind === 'Field' && !selection.selectionSet) { 159 | results.add(selection.name.value); 160 | } else if (selection.kind === 'FragmentSpread') { 161 | const fragment = fragments[selection.name.value]; 162 | 163 | if (fragment.selectionSet) { 164 | results = new Set([...results, ...recursiveSelectedFields(data, fragment.selectionSet.selections, fragments)]); 165 | } 166 | } else if (selection.kind === 'InlineFragment' && selection.selectionSet) { 167 | results = new Set([...results, ...recursiveSelectedFields(data, selection.selectionSet.selections, fragments)]); 168 | } 169 | } 170 | } else if (node.kind === 'Field' && !node.selectionSet) { 171 | results.add(node.name.value); 172 | } else if (node.kind === 'InlineFragment' && node.selectionSet) { 173 | results = new Set([...results, ...recursiveSelectedFields(data, node.selectionSet.selections, fragments)]); 174 | } else if (node.kind === 'FragmentSpread') { 175 | const fragment = fragments[node.name.value]; 176 | 177 | if (fragment.selectionSet) { 178 | results = new Set([...results, ...recursiveSelectedFields(data, fragment.selectionSet.selections, fragments)]); 179 | } 180 | } 181 | } 182 | 183 | return results; 184 | } 185 | -------------------------------------------------------------------------------- /lib/loader/index.ts: -------------------------------------------------------------------------------- 1 | export { Loader, ELoaderType, ILoaderData, setDataSource } from './decorator.loader'; 2 | -------------------------------------------------------------------------------- /lib/loader/many-to-one.loader.ts: -------------------------------------------------------------------------------- 1 | import Dataloader from 'dataloader'; 2 | 3 | import { reduceToObject } from '../helper'; 4 | 5 | import { IPrivateLoaderData } from './decorator.loader'; 6 | 7 | export const manyToOneLoader = (selected_columns: Set, entity_table_name: string, data: IPrivateLoaderData) => { 8 | return new Dataloader(async (keys: Array) => { 9 | const qb = data.entity_manager 10 | .getRepository(entity_table_name) 11 | .createQueryBuilder(entity_table_name) 12 | .select(Array.from(selected_columns).map((selected_column) => `${entity_table_name}.${selected_column}`)); 13 | 14 | if (data.entity_joins?.length) { 15 | for (const join of data.entity_joins) { 16 | qb.innerJoin(join.query, join.alias); 17 | } 18 | 19 | qb.distinct(); 20 | } 21 | 22 | qb.where(`${entity_table_name}.${data.entity_fk_key} IN (:...keys)`, { keys }); 23 | 24 | if (data.entity_wheres?.length) { 25 | for (const where of data.entity_wheres) { 26 | qb.andWhere(where.query, where.params); 27 | } 28 | } 29 | 30 | const poll_options = await qb.getMany(); 31 | 32 | const gs = reduceToObject(poll_options, data.entity_fk_key); 33 | 34 | return keys.map((k) => gs[k]); 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /lib/loader/many.loader.ts: -------------------------------------------------------------------------------- 1 | import { IParsedOrder } from '../order/parser.order'; 2 | import { IParsedFilter } from '../filter/parser.filter'; 3 | import { IParsedPagination } from '../pagination/parser.pagination'; 4 | 5 | import { IPrivateLoaderData } from './decorator.loader'; 6 | 7 | export const manyLoader = ( 8 | selected_columns: Set, 9 | entity_table_name: string, 10 | data: IPrivateLoaderData, 11 | filters: IParsedFilter | null, 12 | orders: IParsedOrder[] | null, 13 | paginations: IParsedPagination | null 14 | ) => { 15 | const qb = data.entity_manager 16 | .getRepository(entity_table_name) 17 | .createQueryBuilder(entity_table_name) 18 | .select(Array.from(selected_columns).map((selected_column) => `${entity_table_name}.${selected_column}`)); 19 | 20 | if (data.entity_joins?.length) { 21 | for (const join of data.entity_joins) { 22 | qb.innerJoin(join.query, join.alias); 23 | } 24 | 25 | qb.distinct(); 26 | } 27 | 28 | qb.where(`${entity_table_name}.${data.entity_fk_key} IS NOT NULL`); 29 | 30 | if (data.entity_wheres?.length) { 31 | for (const where of data.entity_wheres) { 32 | qb.andWhere(where.query, where.params); 33 | } 34 | } 35 | 36 | if (filters) { 37 | qb.andWhere(filters.query, filters.params); 38 | } 39 | 40 | if (orders?.length) { 41 | orders.forEach((order, index) => { 42 | if (index === 0) { 43 | qb.orderBy(order.sort, order.order, order.nulls); 44 | } else { 45 | qb.addOrderBy(order.sort, order.order, order.nulls); 46 | } 47 | }); 48 | } 49 | 50 | if (paginations) { 51 | qb.limit(paginations.limit); 52 | 53 | if (paginations.offset) { 54 | qb.offset(paginations.offset); 55 | } 56 | } 57 | 58 | return qb.getMany(); 59 | }; 60 | -------------------------------------------------------------------------------- /lib/loader/one-to-many.loader.ts: -------------------------------------------------------------------------------- 1 | import Dataloader from 'dataloader'; 2 | 3 | import { IParsedOrder } from '../order/parser.order'; 4 | import { groupBy } from '../helper'; 5 | import { IParsedFilter } from '../filter/parser.filter'; 6 | 7 | import { IPrivateLoaderData } from './decorator.loader'; 8 | 9 | export const oneToManyLoader = ( 10 | selected_columns: Set, 11 | entity_table_name: string, 12 | data: IPrivateLoaderData, 13 | filters: IParsedFilter | null, 14 | orders: IParsedOrder[] | null 15 | ) => { 16 | return new Dataloader(async (keys: Array) => { 17 | const qb = data.entity_manager 18 | .getRepository(entity_table_name) 19 | .createQueryBuilder(entity_table_name) 20 | .select(Array.from(selected_columns).map((selected_column) => `${entity_table_name}.${selected_column}`)); 21 | 22 | if (data.entity_joins?.length) { 23 | for (const join of data.entity_joins) { 24 | qb.innerJoin(join.query, join.alias); 25 | } 26 | 27 | qb.distinct(); 28 | } 29 | 30 | qb.where(`${entity_table_name}.${data.entity_fk_key} IN (:...keys)`, { 31 | keys, 32 | }); 33 | 34 | if (data.entity_wheres?.length) { 35 | for (const where of data.entity_wheres) { 36 | qb.andWhere(where.query, where.params); 37 | } 38 | } 39 | 40 | if (filters) { 41 | qb.andWhere(filters.query, filters.params); 42 | } 43 | 44 | if (orders?.length) { 45 | orders.forEach((order, index) => { 46 | if (index === 0) { 47 | qb.orderBy(order.sort, order.order, order.nulls); 48 | } else { 49 | qb.addOrderBy(order.sort, order.order, order.nulls); 50 | } 51 | }); 52 | } 53 | 54 | const poll_options = await qb.getMany(); 55 | 56 | const gs = groupBy(poll_options, data.entity_fk_key); 57 | 58 | return keys.map((k) => gs[k]); 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /lib/loader/one-to-one.loader.ts: -------------------------------------------------------------------------------- 1 | import Dataloader from 'dataloader'; 2 | 3 | import { reduceToObject } from '../helper'; 4 | 5 | import { IPrivateLoaderData } from './decorator.loader'; 6 | 7 | export const oneToOneLoader = (selected_columns: Set, entity_table_name: string, data: IPrivateLoaderData) => { 8 | return new Dataloader(async (keys: Array) => { 9 | const qb = data.entity_manager 10 | .getRepository(entity_table_name) 11 | .createQueryBuilder(entity_table_name) 12 | .select(Array.from(selected_columns).map((selected_column) => `${entity_table_name}.${selected_column}`)); 13 | 14 | if (data.entity_joins?.length) { 15 | for (const join of data.entity_joins) { 16 | qb.innerJoin(join.query, join.alias); 17 | } 18 | 19 | qb.distinct(); 20 | } 21 | 22 | qb.where(`${entity_table_name}.${data.entity_fk_key} IN (:...keys)`, { keys }); 23 | 24 | if (data.entity_wheres?.length) { 25 | for (const where of data.entity_wheres) { 26 | qb.andWhere(where.query, where.params); 27 | } 28 | } 29 | 30 | const poll_options = await qb.getMany(); 31 | 32 | const gs = reduceToObject(poll_options, data.entity_fk_key); 33 | 34 | return keys.map((k) => gs[k]); 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /lib/order/builder.order.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import { InputType, Int, ReturnTypeFunc } from '@nestjs/graphql'; 3 | 4 | import { decorateField, order_field_input_types, order_input_types, gql_fields, IField, registerEnumType } from '../store/graphql'; 5 | 6 | export enum EOrderQuery { 7 | SORT = 'SORT', 8 | NULLS = 'NULLS', 9 | PRIORITY = 'PRIORITY', 10 | } 11 | 12 | export enum EOrderMethod { 13 | ASC = 'ASC', 14 | DESC = 'DESC', 15 | } 16 | 17 | export enum EOrderNulls { 18 | LAST = 'LAST', 19 | FIRST = 'FIRST', 20 | } 21 | 22 | registerEnumType(EOrderMethod, { 23 | name: 'EOrderMethod', 24 | }); 25 | 26 | registerEnumType(EOrderNulls, { 27 | name: 'EOrderNulls', 28 | }); 29 | 30 | const buildOrderField = (_column: IField): ReturnTypeFunc => { 31 | const name = 'field_OrderInputType'; 32 | 33 | if (order_field_input_types.has(name)) { 34 | return order_field_input_types.get(name); 35 | } 36 | 37 | const field_input_type = function fieldInputType() {}; 38 | 39 | decorateField(field_input_type, EOrderQuery.SORT, () => EOrderMethod, { 40 | nullable: false, 41 | }); 42 | 43 | decorateField(field_input_type, EOrderQuery.NULLS, () => EOrderNulls, { 44 | nullable: true, 45 | }); 46 | 47 | decorateField(field_input_type, EOrderQuery.PRIORITY, () => Int, { 48 | nullable: true, 49 | }); 50 | 51 | Object.defineProperty(field_input_type, 'name', { 52 | value: name, 53 | }); 54 | 55 | InputType()(field_input_type); 56 | 57 | order_field_input_types.set(name, () => field_input_type); 58 | 59 | return () => field_input_type; 60 | }; 61 | 62 | export const buildOrder = (enity: ReturnTypeFunc): ReturnTypeFunc => { 63 | const entity_class_name = enity()['name']; 64 | 65 | if (order_input_types.has(entity_class_name)) { 66 | return order_input_types.get(entity_class_name); 67 | } 68 | 69 | const order_input_type = function orderInputType() {}; 70 | 71 | gql_fields.get(entity_class_name).forEach((col) => { 72 | if (col.options?.sortable) { 73 | decorateField(order_input_type, col.name, buildOrderField(col), { 74 | nullable: true, 75 | }); 76 | } 77 | }); 78 | 79 | Object.defineProperty(order_input_type, 'name', { 80 | value: `${entity_class_name}_OrderInputType`, 81 | }); 82 | 83 | InputType()(order_input_type); 84 | 85 | order_input_types.set(entity_class_name, () => order_input_type); 86 | 87 | return () => order_input_type; 88 | }; 89 | -------------------------------------------------------------------------------- /lib/order/decorator.order.ts: -------------------------------------------------------------------------------- 1 | import { Args, ReturnTypeFunc } from '@nestjs/graphql'; 2 | 3 | import { buildOrder } from './builder.order'; 4 | 5 | export const Order = (enity: ReturnTypeFunc) => { 6 | return Args({ 7 | name: 'ORDER', 8 | nullable: true, 9 | type: buildOrder(enity), 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /lib/order/index.ts: -------------------------------------------------------------------------------- 1 | export { Order } from './decorator.order'; 2 | export { parseOrder } from './parser.order'; 3 | -------------------------------------------------------------------------------- /lib/order/parser.order.ts: -------------------------------------------------------------------------------- 1 | import { EOrderMethod, EOrderNulls, EOrderQuery } from './builder.order'; 2 | 3 | export interface IOrderKeyValue { 4 | [EOrderQuery.SORT]: EOrderMethod; 5 | [EOrderQuery.NULLS]: EOrderNulls | null; 6 | [EOrderQuery.PRIORITY]: number | null; 7 | } 8 | 9 | export type TOrderValue = Record; 10 | 11 | export interface IParsedOrder { 12 | sort: string; 13 | order?: 'ASC' | 'DESC'; 14 | nulls?: 'NULLS FIRST' | 'NULLS LAST'; 15 | } 16 | 17 | export function parseOrder(relation_table: string, data: TOrderValue) { 18 | const orders: IParsedOrder[] = []; 19 | 20 | Object.entries(data) 21 | .sort(([_key_a, value_a], [_key_b, value_b]) => { 22 | return (value_a[EOrderQuery.PRIORITY] || 0) - (value_b[EOrderQuery.PRIORITY] || 0); 23 | }) 24 | .forEach(([key, value]) => { 25 | if (value && value[EOrderQuery.SORT]) { 26 | const parsed_order: IParsedOrder = { 27 | sort: `${relation_table}.${key}`, 28 | order: EOrderMethod[value[EOrderQuery.SORT]], 29 | }; 30 | 31 | if (value[EOrderQuery.NULLS]) { 32 | parsed_order.nulls = `${EOrderQuery.NULLS} ${EOrderNulls[value[EOrderQuery.NULLS]] as EOrderNulls}`; 33 | } 34 | 35 | orders.push(parsed_order); 36 | } 37 | }); 38 | 39 | return orders; 40 | } 41 | -------------------------------------------------------------------------------- /lib/pagination/decorator.pagination.ts: -------------------------------------------------------------------------------- 1 | import { Args, Field, InputType, Int } from '@nestjs/graphql'; 2 | 3 | import { IsInt, IsOptional, Min } from 'class-validator'; 4 | 5 | @InputType() 6 | export class PaginationInputType { 7 | @Field(() => Int, { nullable: true }) 8 | @IsOptional() 9 | @IsInt() 10 | @Min(0) 11 | page: number; 12 | 13 | @Field(() => Int, { nullable: false }) 14 | @IsInt() 15 | @Min(0) 16 | per_page: number; 17 | } 18 | 19 | export const Pagination = () => { 20 | return Args({ 21 | name: 'PAGINATION', 22 | nullable: true, 23 | type: () => PaginationInputType, 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /lib/pagination/index.ts: -------------------------------------------------------------------------------- 1 | export { Pagination } from './decorator.pagination'; 2 | export { parsePagination } from './parser.pagination'; 3 | -------------------------------------------------------------------------------- /lib/pagination/parser.pagination.ts: -------------------------------------------------------------------------------- 1 | import { validateDTO } from '../helper'; 2 | 3 | import { PaginationInputType } from './decorator.pagination'; 4 | 5 | export interface IPaginationValue { 6 | page: number; 7 | per_page: number; 8 | } 9 | 10 | export interface IParsedPagination { 11 | limit: number; 12 | offset?: number; 13 | } 14 | 15 | export function parsePagination(data: IPaginationValue) { 16 | validateDTO(PaginationInputType, data); 17 | 18 | const pagination: IParsedPagination = { 19 | limit: data.per_page, 20 | }; 21 | 22 | if (data.page != null) { 23 | pagination.offset = data.page * data.per_page; 24 | } 25 | 26 | return pagination; 27 | } 28 | -------------------------------------------------------------------------------- /lib/store/graphql.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReturnTypeFunc, 3 | Field as GqlField, 4 | FieldOptions, 5 | ObjectType as GqlObjectType, 6 | ObjectTypeOptions, 7 | Query as GqlQuery, 8 | QueryOptions, 9 | ResolveField as GqlResolveField, 10 | ResolveFieldOptions, 11 | Mutation as GqlMutation, 12 | MutationOptions, 13 | registerEnumType as gqlRegisterEnumType, 14 | EnumOptions, 15 | } from '@nestjs/graphql'; 16 | 17 | export interface IObjectType { 18 | entity_class_name: string; 19 | name: string; 20 | options?: ObjectTypeOptions; 21 | } 22 | 23 | export enum EDataType { 24 | STRING = 'string', 25 | PRECISION = 'precision', 26 | } 27 | 28 | interface IFieldOptions extends Omit { 29 | filterable?: boolean; 30 | sortable?: boolean; 31 | allow_filters_from?: EDataType[]; 32 | } 33 | 34 | export interface IField { 35 | entity_class_name: string; 36 | name: string; 37 | type_function: ReturnTypeFunc; 38 | options?: IFieldOptions; 39 | } 40 | 41 | export interface IQueryOrMutation { 42 | resolver_name: string; 43 | name: string; 44 | type_function: ReturnTypeFunc; 45 | } 46 | 47 | export interface IEnum { 48 | name: string; 49 | type_function: ReturnTypeFunc; 50 | } 51 | 52 | export interface IQuery extends IQueryOrMutation { 53 | options?: Omit; 54 | } 55 | 56 | export interface IMutation extends IQueryOrMutation { 57 | options?: Omit; 58 | } 59 | 60 | export interface IResolveField extends IQueryOrMutation { 61 | options?: Omit; 62 | } 63 | 64 | /** 65 | * Map 66 | */ 67 | export const gql_objects: Map = new Map(); 68 | 69 | /** 70 | * Map> 71 | */ 72 | export const gql_fields: Map> = new Map(); 73 | 74 | /** 75 | * Map> 76 | */ 77 | export const gql_enums: Map = new Map(); 78 | 79 | /** 80 | * Map> 81 | */ 82 | export const gql_queries: Map = new Map(); 83 | 84 | /** 85 | * Map> 86 | */ 87 | export const gql_mutations: Map = new Map(); 88 | 89 | /** 90 | * Map> 91 | */ 92 | export const gql_resolve_fields: Map = new Map(); 93 | 94 | /** 95 | * Map 96 | */ 97 | export const where_input_types: Map = new Map(); 98 | 99 | /** 100 | * Map<`${field_name}_FilterInputType`, ReturnTypeFunc> 101 | */ 102 | export const where_field_input_types: Map = new Map(); 103 | 104 | /** 105 | * Map 106 | */ 107 | export const order_input_types: Map = new Map(); 108 | 109 | /** 110 | * Map<`${field_name}_OrderInputType`, ReturnTypeFunc> 111 | */ 112 | export const order_field_input_types: Map = new Map(); 113 | 114 | export const decorateField = (fn: () => void, field_name: string, field_type: ReturnTypeFunc, field_options?: FieldOptions) => { 115 | fn.prototype[field_name] = GqlField(field_type, { 116 | nullable: true, 117 | ...field_options, 118 | })(fn.prototype, field_name); 119 | }; 120 | 121 | /** 122 | * Only for entity columns 123 | * For other use original decorator 124 | */ 125 | export function Field(returnTypeFunction: ReturnTypeFunc, options?: IFieldOptions): PropertyDecorator { 126 | return (prototype: any, property_key: string) => { 127 | if (!gql_fields.has(prototype['constructor']['name'])) { 128 | gql_fields.set(prototype['constructor']['name'], new Set([])); 129 | } 130 | 131 | const fields = gql_fields.get(prototype['constructor']['name']); 132 | 133 | fields.add({ 134 | entity_class_name: prototype['name'], 135 | name: property_key, 136 | type_function: returnTypeFunction, 137 | options, 138 | }); 139 | 140 | return GqlField(returnTypeFunction, options as FieldOptions)(prototype, property_key); 141 | }; 142 | } 143 | 144 | /** 145 | * Only for entity. 146 | * For other use original decorator 147 | */ 148 | export function ObjectType(options?: ObjectTypeOptions): ClassDecorator { 149 | return (prototype: any) => { 150 | if (!gql_objects.has(prototype['name'])) { 151 | gql_objects.set(prototype['name'], { 152 | entity_class_name: prototype['name'], 153 | name: prototype['name'], 154 | options: options, 155 | }); 156 | } 157 | 158 | return GqlObjectType(prototype['name'], options)(prototype); 159 | }; 160 | } 161 | 162 | export function Query(returnTypeFunction: ReturnTypeFunc, options?: Omit): MethodDecorator { 163 | return (prototype: any, property_key: string, descriptor: PropertyDescriptor) => { 164 | if (!gql_queries.has(property_key)) { 165 | gql_queries.set(property_key, { 166 | resolver_name: prototype.constructor['name'], 167 | name: property_key, 168 | type_function: returnTypeFunction, 169 | options, 170 | }); 171 | } 172 | 173 | return GqlQuery(returnTypeFunction, options as QueryOptions)(prototype, property_key, descriptor); 174 | }; 175 | } 176 | 177 | export function Mutation(returnTypeFunction: ReturnTypeFunc, options?: Omit): MethodDecorator { 178 | return (prototype: any, property_key: string, descriptor: PropertyDescriptor) => { 179 | if (!gql_mutations.has(property_key)) { 180 | gql_mutations.set(property_key, { 181 | resolver_name: prototype.constructor['name'], 182 | name: property_key, 183 | type_function: returnTypeFunction, 184 | options, 185 | }); 186 | } 187 | 188 | return GqlMutation(returnTypeFunction, options as MutationOptions)(prototype, property_key, descriptor); 189 | }; 190 | } 191 | 192 | export function ResolveField(returnTypeFunction: ReturnTypeFunc, options?: Omit): MethodDecorator { 193 | return (prototype: any, property_key: string, descriptor: PropertyDescriptor) => { 194 | if (!gql_resolve_fields.has(property_key)) { 195 | gql_resolve_fields.set(property_key, { 196 | resolver_name: prototype.constructor['name'], 197 | name: property_key, 198 | type_function: returnTypeFunction, 199 | options, 200 | }); 201 | } 202 | 203 | return GqlResolveField(returnTypeFunction, options as ResolveFieldOptions)(prototype, property_key, descriptor); 204 | }; 205 | } 206 | 207 | export function registerEnumType(enumRef: T, options: EnumOptions) { 208 | if (!gql_enums.has(options.name)) { 209 | gql_enums.set(options.name, { 210 | name: options.name, 211 | type_function: () => enumRef, 212 | }); 213 | } 214 | 215 | return gqlRegisterEnumType(enumRef, options); 216 | } 217 | -------------------------------------------------------------------------------- /lib/store/index.ts: -------------------------------------------------------------------------------- 1 | export { Field, ObjectType, Query, Mutation, ResolveField, registerEnumType, EDataType } from './graphql'; 2 | export { 3 | PolymorphicColumn, 4 | Column, 5 | Entity, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | PrimaryColumn, 9 | PrimaryGeneratedColumn, 10 | getTableColumns, 11 | getTableForeignKeys, 12 | getTablePrimaryKeys, 13 | } from './typeorm'; 14 | -------------------------------------------------------------------------------- /lib/store/typeorm.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getMetadataArgsStorage, 3 | Entity as OrmEntity, 4 | EntityOptions, 5 | CreateDateColumn as OrmCreateDateColumn, 6 | UpdateDateColumn as OrmUpdateDateColumn, 7 | Column as OrmColumn, 8 | ColumnOptions, 9 | PrimaryColumn as OrmPrimaryColumn, 10 | PrimaryColumnOptions, 11 | PrimaryGeneratedColumn as OrmPrimaryGeneratedColumn, 12 | } from 'typeorm'; 13 | import { ColumnCommonOptions } from 'typeorm/decorator/options/ColumnCommonOptions'; 14 | import { ColumnEmbeddedOptions } from 'typeorm/decorator/options/ColumnEmbeddedOptions'; 15 | import { ColumnEnumOptions } from 'typeorm/decorator/options/ColumnEnumOptions'; 16 | import { ColumnHstoreOptions } from 'typeorm/decorator/options/ColumnHstoreOptions'; 17 | import { ColumnNumericOptions } from 'typeorm/decorator/options/ColumnNumericOptions'; 18 | import { ColumnWithLengthOptions } from 'typeorm/decorator/options/ColumnWithLengthOptions'; 19 | import { ColumnWithWidthOptions } from 'typeorm/decorator/options/ColumnWithWidthOptions'; 20 | import { PrimaryGeneratedColumnIdentityOptions } from 'typeorm/decorator/options/PrimaryGeneratedColumnIdentityOptions'; 21 | import { PrimaryGeneratedColumnNumericOptions } from 'typeorm/decorator/options/PrimaryGeneratedColumnNumericOptions'; 22 | import { PrimaryGeneratedColumnUUIDOptions } from 'typeorm/decorator/options/PrimaryGeneratedColumnUUIDOptions'; 23 | import { SpatialColumnOptions } from 'typeorm/decorator/options/SpatialColumnOptions'; 24 | import { 25 | ColumnType, 26 | SimpleColumnType, 27 | SpatialColumnType, 28 | WithLengthColumnType, 29 | WithPrecisionColumnType, 30 | WithWidthColumnType, 31 | } from 'typeorm/driver/types/ColumnTypes'; 32 | 33 | /** 34 | * Map> 35 | */ 36 | export const table_columns: Map> = new Map(); 37 | 38 | /** 39 | * Map> 40 | */ 41 | export const table_foreign_keys: Map> = new Map(); 42 | 43 | /** 44 | * Map> 45 | */ 46 | export const table_primary_keys: Map> = new Map(); 47 | 48 | export function getTableColumns(entity_class_name: string) { 49 | if (table_columns.has(entity_class_name)) { 50 | return table_columns.get(entity_class_name); 51 | } 52 | 53 | const typeormArgs = getMetadataArgsStorage(); 54 | 55 | typeormArgs.columns.forEach((col) => { 56 | if (!table_columns.has(col.target['name'])) { 57 | table_columns.set(col.target['name'], new Set([])); 58 | } 59 | 60 | const columns = table_columns.get(col.target['name']); 61 | 62 | columns.add(col.propertyName); 63 | }); 64 | 65 | return table_columns.get(entity_class_name) || new Set(); 66 | } 67 | 68 | export function getTableForeignKeys(entity_class_name: string) { 69 | if (table_foreign_keys.has(entity_class_name)) { 70 | return table_foreign_keys.get(entity_class_name); 71 | } 72 | 73 | const typeormArgs = getMetadataArgsStorage(); 74 | 75 | typeormArgs.joinColumns.forEach((col) => { 76 | if (!table_foreign_keys.has(col.target['name'])) { 77 | table_foreign_keys.set(col.target['name'], new Set([])); 78 | } 79 | 80 | const fks = table_foreign_keys.get(col.target['name']); 81 | 82 | fks.add(col.name); 83 | }); 84 | 85 | return table_foreign_keys.get(entity_class_name) || new Set(); 86 | } 87 | 88 | export function getTablePrimaryKeys(entity_class_name: string) { 89 | if (table_primary_keys.has(entity_class_name)) { 90 | return table_primary_keys.get(entity_class_name); 91 | } 92 | 93 | const typeormArgs = getMetadataArgsStorage(); 94 | 95 | typeormArgs.columns.forEach((col) => { 96 | if (col.options?.primary) { 97 | if (!table_primary_keys.has(col.target['name'])) { 98 | table_primary_keys.set(col.target['name'], new Set([])); 99 | } 100 | 101 | const fks = table_primary_keys.get(col.target['name']); 102 | 103 | fks.add(col.propertyName); 104 | } 105 | }); 106 | 107 | return table_primary_keys.get(entity_class_name) || new Set(); 108 | } 109 | 110 | export function PolymorphicColumn(): PropertyDecorator { 111 | return (prototype: any, property_key: string) => { 112 | if (!table_foreign_keys.has(prototype['constructor']['name'])) { 113 | table_foreign_keys.set(prototype['constructor']['name'], new Set([])); 114 | } 115 | 116 | table_foreign_keys.get(prototype['constructor']['name']).add(property_key); 117 | }; 118 | } 119 | 120 | export function Entity(options?: Omit): ClassDecorator { 121 | return (prototype: any) => { 122 | return OrmEntity(options)(prototype); 123 | }; 124 | } 125 | 126 | export function CreateDateColumn(options?: Omit): PropertyDecorator { 127 | return (prototype: any, property_key: string) => { 128 | return OrmCreateDateColumn(options)(prototype, property_key); 129 | }; 130 | } 131 | 132 | export function UpdateDateColumn(options?: Omit): PropertyDecorator { 133 | return (prototype: any, property_key: string) => { 134 | return OrmUpdateDateColumn(options)(prototype, property_key); 135 | }; 136 | } 137 | 138 | export function PrimaryColumn(options?: Omit): PropertyDecorator; 139 | export function PrimaryColumn(type?: ColumnType, options?: Omit): PropertyDecorator; 140 | export function PrimaryColumn(type_or_options?: any, options?: any): PropertyDecorator { 141 | return (prototype: any, property_key: string) => { 142 | return OrmPrimaryColumn(type_or_options, options)(prototype, property_key); 143 | }; 144 | } 145 | 146 | export function PrimaryGeneratedColumn(): PropertyDecorator; 147 | export function PrimaryGeneratedColumn(options: Omit): PropertyDecorator; 148 | export function PrimaryGeneratedColumn( 149 | strategy: 'increment', 150 | options?: Omit 151 | ): PropertyDecorator; 152 | export function PrimaryGeneratedColumn(strategy: 'uuid', options?: Omit): PropertyDecorator; 153 | export function PrimaryGeneratedColumn(strategy: 'rowid', options?: Omit): PropertyDecorator; 154 | export function PrimaryGeneratedColumn( 155 | strategy: 'identity', 156 | options?: Omit 157 | ): PropertyDecorator; 158 | export function PrimaryGeneratedColumn(strategy_or_options?: any, options?: any): PropertyDecorator { 159 | return (prototype: any, property_key: string) => { 160 | return OrmPrimaryGeneratedColumn(strategy_or_options, options)(prototype, property_key); 161 | }; 162 | } 163 | 164 | export function Column(): PropertyDecorator; 165 | export function Column(options?: Omit): PropertyDecorator; 166 | export function Column(type?: SimpleColumnType, options?: Omit): PropertyDecorator; 167 | export function Column(type?: SpatialColumnType, options?: Omit): PropertyDecorator; 168 | export function Column( 169 | type?: WithLengthColumnType, 170 | options?: Omit 171 | ): PropertyDecorator; 172 | export function Column(type?: WithWidthColumnType, options?: Omit): PropertyDecorator; 173 | export function Column( 174 | type?: WithPrecisionColumnType, 175 | options?: Omit 176 | ): PropertyDecorator; 177 | export function Column(type?: 'enum', options?: Omit): PropertyDecorator; 178 | export function Column(type?: 'simple-enum', options?: Omit): PropertyDecorator; 179 | export function Column(type?: 'set', options?: Omit): PropertyDecorator; 180 | export function Column(type?: 'hstore', options?: Omit): PropertyDecorator; 181 | export function Column(type?: (type?: any) => void, options?: Omit): PropertyDecorator; 182 | export function Column(type_or_options?: any, options?: any): PropertyDecorator { 183 | return (prototype: any, property_key: string) => { 184 | return OrmColumn(type_or_options, options)(prototype, property_key); 185 | }; 186 | } 187 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /nodemon-debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src", 4 | "lib" 5 | ], 6 | "ext": "ts", 7 | "env": { 8 | "NODE_ENV": "development" 9 | }, 10 | "ignore": [ 11 | "src/**/*.spec.ts" 12 | ], 13 | "exec": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register src/main.ts" 14 | } -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src", 4 | "lib" 5 | ], 6 | "env": { 7 | "NODE_ENV": "development" 8 | }, 9 | "ext": "ts", 10 | "ignore": [ 11 | "src/**/*.spec.ts" 12 | ], 13 | "exec": "ts-node --files -r tsconfig-paths/register src/main.ts" 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-graphql-easy", 3 | "version": "3.1.5", 4 | "description": "A library for NestJS that implements a dataloader (including for polymorphic relation) for graphql, as well as automatic generation of arguments for filters, sorting and pagination, and their processing in the dataloader.", 5 | "author": "t.kosminov", 6 | "license": "MIT", 7 | "private": false, 8 | "main": "dist-lib/index.js", 9 | "types": "dist-lib/index.d.ts", 10 | "files": [ 11 | "dist-lib" 12 | ], 13 | "keywords": [ 14 | "graphql", 15 | "nestjs", 16 | "typeorm", 17 | "dataloader", 18 | "tools", 19 | "polymorphic", 20 | "filtering", 21 | "ordering", 22 | "sorting", 23 | "pagination", 24 | "cursor-pagination", 25 | "n+1" 26 | ], 27 | "repository": { 28 | "url": "https://github.com/tkosminov/nestjs-graphql-easy" 29 | }, 30 | "scripts": { 31 | "build": "rimraf dist-lib && tsc -p tsconfig.lib.json", 32 | "start:debug": "nodemon --config nodemon-debug.json", 33 | "start:dev": "nodemon", 34 | "build:dev": "rimraf dist && tsc -p tsconfig.build.json", 35 | "start:build:dev": "NODE_ENV=development node -r ts-node/register -r tsconfig-paths/register dist/src/main.js", 36 | "lint": "eslint \"{src,lib}/**/*.ts\"", 37 | "lint-fix": "npm run lint -- --fix", 38 | "typeorm:cli": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli", 39 | "db:migration:create": "npm run typeorm:cli -- migration:create ./src/database/migrations/created", 40 | "db:migration:run": "npm run typeorm:cli -- -d ./src/database/database-ormconfig.cli.ts migration:run", 41 | "db:schema:sync": "npm run typeorm:cli -- -d ./src/database/database-ormconfig.cli.ts schema:sync" 42 | }, 43 | "husky": { 44 | "hooks": { 45 | "pre-commit": "npm run lint" 46 | } 47 | }, 48 | "peerDependencies": { 49 | "@nestjs/common": ">= 9.0.0", 50 | "@nestjs/core": ">= 9.0.0", 51 | "@nestjs/graphql": ">= 10.0.0", 52 | "@nestjs/typeorm": ">= 9.0.0", 53 | "class-transformer": ">= 0.5.0", 54 | "class-validator": ">= 0.13.0", 55 | "graphql": ">= 16.0.0", 56 | "typeorm": ">= 0.3.0" 57 | }, 58 | "dependencies": { 59 | "dataloader": "^2.0.0", 60 | "nanoid": "^3.3.4" 61 | }, 62 | "devDependencies": { 63 | "@apollo/server": "^4.11.3", 64 | "@nestjs/apollo": "^13.0.1", 65 | "@nestjs/common": "^11.0.1", 66 | "@nestjs/core": "^11.0.2", 67 | "@nestjs/graphql": "^13.0.1", 68 | "@nestjs/platform-express": "^11.0.2", 69 | "@nestjs/schematics": "^11.0.0", 70 | "@nestjs/typeorm": "^11.0.0", 71 | "@types/body-parser": "^1.19.5", 72 | "@types/config": "^3.3.5", 73 | "@types/cookie-parser": "^1.4.8", 74 | "@types/express": "^5.0.0", 75 | "@types/node": "^22.10.7", 76 | "@types/nodemon": "^1.19.6", 77 | "@types/pg": "^8.11.10", 78 | "@types/uuid": "^10.0.0", 79 | "body-parser": "^1.20.3", 80 | "class-transformer": "^0.5.1", 81 | "class-validator": "^0.14.1", 82 | "config": "^3.3.12", 83 | "cookie-parser": "^1.4.7", 84 | "dataloader": "^2.2.3", 85 | "graphql": "^16.10.0", 86 | "graphql-scalars": "^1.24.0", 87 | "graphql-subscriptions": "^3.0.0", 88 | "graphql-ws": "^6.0.1", 89 | "helmet": "^8.0.0", 90 | "husky": "^9.1.7", 91 | "nodemon": "^3.1.9", 92 | "pg": "^8.13.1", 93 | "reflect-metadata": "^0.2.2", 94 | "rimraf": "^6.0.1", 95 | "rxjs": "^7.8.1", 96 | "ts-loader": "^9.5.2", 97 | "ts-node": "^10.9.2", 98 | "tsconfig-paths": "^4.2.0", 99 | "typeorm": "^0.3.20", 100 | "typeorm-extension": "^3.6.3", 101 | "typescript": "^5.7.3", 102 | "uuid": "^11.0.5" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module } from '@nestjs/common'; 2 | 3 | import { DatabaseModule } from './database/database.module'; 4 | import { EntitiesModule } from './entities/entities.module'; 5 | import { GraphQLModule } from './graphql/graphql.module'; 6 | import { HealthzModule } from './healthz/healthz.module'; 7 | import { LoggerModule } from './logger/logger.module'; 8 | import { LoggerMiddleware } from './logger/logger.middleware'; 9 | 10 | @Module({ 11 | imports: [LoggerModule, HealthzModule, DatabaseModule, GraphQLModule, EntitiesModule], 12 | controllers: [], 13 | providers: [], 14 | }) 15 | export class AppModule { 16 | public configure(consumer: MiddlewareConsumer): void | MiddlewareConsumer { 17 | consumer.apply(LoggerMiddleware).forRoutes('*'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/cors.options.ts: -------------------------------------------------------------------------------- 1 | import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; 2 | 3 | import config from 'config'; 4 | import { Request } from 'express'; 5 | 6 | import { cors_not_allowed } from './errors'; 7 | import { getOrigin, getPath } from './helpers/req.helper'; 8 | 9 | const corsSettings = config.get('CORS_SETTINGS'); 10 | 11 | export const corsOptionsDelegate: unknown = (req: Request, callback: (err: Error, options: CorsOptions) => void) => { 12 | const corsOptions: CorsOptions = { 13 | methods: corsSettings.allowedMethods, 14 | credentials: corsSettings.allowedCredentials, 15 | origin: false, 16 | }; 17 | let error: Error | null = null; 18 | 19 | const origin = getOrigin(req); 20 | const url = getPath(req); 21 | 22 | if (!origin || !corsSettings.allowedOrigins.length || corsSettings.allowedOrigins.indexOf(origin) !== -1) { 23 | corsOptions.origin = true; 24 | error = null; 25 | } else if (corsSettings.allowedUrls.length && corsSettings.allowedUrls.indexOf(url) !== -1) { 26 | corsOptions.origin = true; 27 | error = null; 28 | } else { 29 | corsOptions.origin = false; 30 | error = cors_not_allowed({ raise: false }); 31 | } 32 | 33 | callback(error, corsOptions); 34 | }; 35 | -------------------------------------------------------------------------------- /src/database/database-ormconfig.cli.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, DataSourceOptions } from 'typeorm'; 2 | 3 | import { getOrmConfig } from './database-ormconfig.constant'; 4 | 5 | export default new DataSource({ 6 | ...(getOrmConfig() as DataSourceOptions), 7 | }); 8 | -------------------------------------------------------------------------------- /src/database/database-ormconfig.constant.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | import config from 'config'; 3 | 4 | import { IDBSettings } from './database.types'; 5 | 6 | const settings: IDBSettings = config.get('DB_SETTINGS'); 7 | 8 | export function getOrmConfig(): TypeOrmModuleOptions { 9 | return { 10 | type: 'postgres', 11 | host: process.env.DB_HOST || settings.host, 12 | port: (process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : null) || settings.port || 5432, 13 | username: process.env.DB_USERNAME || settings.username, 14 | password: process.env.DB_PASSWORD || settings.password, 15 | database: `${process.env.NODE_ENV}_${settings.database}`, 16 | entities: [__dirname + '/../**/*.entity{.ts,.js}'], 17 | migrations: [__dirname + '/migrations/**/*{.ts,.js}'], 18 | synchronize: settings.synchronize || false, 19 | migrationsRun: false, 20 | logging: settings.logging, 21 | } as TypeOrmModuleOptions; 22 | } 23 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { getOrmConfig } from './database-ormconfig.constant'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forRoot(getOrmConfig())], 8 | providers: [], 9 | exports: [], 10 | }) 11 | export class DatabaseModule {} 12 | -------------------------------------------------------------------------------- /src/database/database.types.ts: -------------------------------------------------------------------------------- 1 | import { LoggerOptions } from 'typeorm/logger/LoggerOptions'; 2 | 3 | export interface IDBSettings { 4 | readonly host?: string; 5 | readonly port?: number; 6 | readonly username?: string; 7 | readonly password?: string; 8 | readonly database: string; 9 | readonly logging: LoggerOptions; 10 | readonly synchronize: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /src/database/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkosminov/nestjs-graphql-easy/017233b9605027439de66e0bb83cbf64f9e1e576/src/database/migrations/.gitkeep -------------------------------------------------------------------------------- /src/database/migrations/1588955268370-seed.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | 4 | import { MigrationInterface, QueryRunner } from 'typeorm'; 5 | 6 | import { Author, EAuthorGender, EAuthorType } from '../../entities/author/author.entity'; 7 | import { Book } from '../../entities/book/book.entity'; 8 | import { Section } from '../../entities/section/section.entity'; 9 | import { SectionTitle } from '../../entities/section-title/section-title.entity'; 10 | import { Item } from '../../entities/item/item.entity'; 11 | import { ItemText } from '../../entities/item-text/item-text.entity'; 12 | import { ItemImage } from '../../entities/item-image/item-image.entity'; 13 | 14 | export class seed1588955268370 implements MigrationInterface { 15 | public async up(queryRunner: QueryRunner): Promise { 16 | await queryRunner.connection 17 | .createQueryBuilder() 18 | .insert() 19 | .into(Author) 20 | .values([ 21 | { 22 | id: '1049e157-ff4c-45ff-a40c-2ed44030a5e6', 23 | name: 'Author 1', 24 | gender: EAuthorGender.MALE, 25 | author_type: EAuthorType.AUTHOR, 26 | }, 27 | { 28 | id: '9363ec6f-a3ef-4c13-91da-fd40352e1936', 29 | name: 'Author 2', 30 | gender: EAuthorGender.FEMALE, 31 | author_type: EAuthorType.AUTHOR, 32 | }, 33 | ]) 34 | .execute(); 35 | 36 | await queryRunner.connection 37 | .createQueryBuilder() 38 | .insert() 39 | .into(Book) 40 | .values([ 41 | { 42 | id: 'be5f23d4-82e9-4603-9282-84015adf081b', 43 | title: 'Book 1 Author 1', 44 | author_id: '1049e157-ff4c-45ff-a40c-2ed44030a5e6', 45 | }, 46 | { 47 | id: '0d38ac59-3803-47e0-a4fb-b95e5fd74c42', 48 | title: 'Book 1 Author 2', 49 | author_id: '9363ec6f-a3ef-4c13-91da-fd40352e1936', 50 | }, 51 | ]) 52 | .execute(); 53 | 54 | await queryRunner.connection 55 | .createQueryBuilder() 56 | .insert() 57 | .into(Section) 58 | .values([ 59 | { 60 | id: '57c82b1d-2f5e-4145-8dc6-b2d883236ef1', 61 | book_id: 'be5f23d4-82e9-4603-9282-84015adf081b', 62 | title: 'Book 1 Author 1 Section 1', 63 | }, 64 | { 65 | id: 'c3e29f78-1803-4e04-9979-ad6167a7c762', 66 | book_id: '0d38ac59-3803-47e0-a4fb-b95e5fd74c42', 67 | title: 'Book 1 Author 2 Section 1', 68 | }, 69 | ]) 70 | .execute(); 71 | 72 | await queryRunner.connection 73 | .createQueryBuilder() 74 | .insert() 75 | .into(SectionTitle) 76 | .values([ 77 | { 78 | id: '25c03eef-b317-42c2-8292-73794597856a', 79 | section_id: '57c82b1d-2f5e-4145-8dc6-b2d883236ef1', 80 | title: 'Book 1 Author 1 Section 1 SectionTitle 1', 81 | }, 82 | { 83 | id: '400b55ab-d336-446a-811b-466c66c6a612', 84 | section_id: 'c3e29f78-1803-4e04-9979-ad6167a7c762', 85 | title: 'Book 1 Author 2 Section 1 SectionTitle 1', 86 | }, 87 | ]) 88 | .execute(); 89 | 90 | await queryRunner.connection 91 | .createQueryBuilder() 92 | .insert() 93 | .into(ItemText) 94 | .values([ 95 | { 96 | id: '1acb98de-8295-4106-9988-612b1bdda4d4', 97 | value: 'Item Text 1', 98 | }, 99 | ]) 100 | .execute(); 101 | 102 | await queryRunner.connection 103 | .createQueryBuilder() 104 | .insert() 105 | .into(ItemImage) 106 | .values([ 107 | { 108 | id: '5c94c6b6-d923-45bb-8d6e-b133644a4010', 109 | file_url: 'Item Image 1', 110 | }, 111 | ]) 112 | .execute(); 113 | 114 | await queryRunner.connection 115 | .createQueryBuilder() 116 | .insert() 117 | .into(Item) 118 | .values([ 119 | { 120 | id: 'e4c162ea-f8a0-4656-8a2d-9755b5412da3', 121 | itemable_id: '1acb98de-8295-4106-9988-612b1bdda4d4', 122 | itemable_type: 'ItemText', 123 | section_id: '57c82b1d-2f5e-4145-8dc6-b2d883236ef1', 124 | }, 125 | { 126 | id: 'ebc4df69-a55c-454c-abe1-b8dee2986cc7', 127 | itemable_id: '5c94c6b6-d923-45bb-8d6e-b133644a4010', 128 | itemable_type: 'ItemImage', 129 | section_id: 'c3e29f78-1803-4e04-9979-ad6167a7c762', 130 | }, 131 | ]) 132 | .execute(); 133 | } 134 | 135 | public async down(_queryRunner: QueryRunner): Promise {} 136 | } 137 | -------------------------------------------------------------------------------- /src/entities/author/author.entity.ts: -------------------------------------------------------------------------------- 1 | import { Extensions, ID } from '@nestjs/graphql'; 2 | 3 | import { Index, OneToMany } from 'typeorm'; 4 | import { DateTimeISOResolver } from 'graphql-scalars'; 5 | import { 6 | ObjectType, 7 | Field, 8 | Column, 9 | Entity, 10 | CreateDateColumn, 11 | UpdateDateColumn, 12 | PrimaryGeneratedColumn, 13 | registerEnumType, 14 | EDataType, 15 | } from 'nestjs-graphql-easy'; 16 | 17 | import { Book } from '../book/book.entity'; 18 | 19 | export enum EAuthorGender { 20 | MALE = 'MALE', 21 | FEMALE = 'FEMALE', 22 | } 23 | 24 | registerEnumType(EAuthorGender, { 25 | name: 'EAuthorGender', 26 | }); 27 | 28 | export enum EAuthorType { 29 | AUTHOR = 1, 30 | CO_AUTHOR = 2, 31 | } 32 | 33 | registerEnumType(EAuthorType, { 34 | name: 'EAuthorType', 35 | }); 36 | 37 | @ObjectType() 38 | @Entity() 39 | export class Author { 40 | @Field(() => ID, { filterable: true, sortable: true }) 41 | @PrimaryGeneratedColumn('uuid') 42 | public id: string; 43 | 44 | @Field(() => Date) 45 | @CreateDateColumn({ 46 | type: 'timestamp without time zone', 47 | precision: 3, 48 | default: () => 'CURRENT_TIMESTAMP', 49 | }) 50 | public created_at: Date; 51 | 52 | @Field(() => DateTimeISOResolver, { 53 | filterable: true, 54 | sortable: true, 55 | allow_filters_from: [EDataType.PRECISION], 56 | }) 57 | @UpdateDateColumn({ 58 | type: 'timestamp without time zone', 59 | precision: 3, 60 | default: () => 'CURRENT_TIMESTAMP', 61 | }) 62 | public updated_at: Date; 63 | 64 | @Extensions({ role: 'ADMIN' }) 65 | @Field(() => String, { 66 | filterable: true, 67 | sortable: true, 68 | allow_filters_from: [EDataType.PRECISION], 69 | }) 70 | @Column() 71 | @Index({ unique: true }) 72 | public name: string; 73 | 74 | @Field(() => EAuthorGender, { filterable: true }) 75 | @Column('enum', { enum: EAuthorGender, nullable: false }) 76 | @Index() 77 | public gender: EAuthorGender; 78 | 79 | @Field(() => EAuthorType, { filterable: true }) 80 | @Column('enum', { enum: EAuthorType, nullable: false }) 81 | @Index() 82 | public author_type: EAuthorType; 83 | 84 | @OneToMany(() => Book, (book) => book.author) 85 | public books: Book[]; 86 | } 87 | -------------------------------------------------------------------------------- /src/entities/author/author.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Author } from './author.entity'; 5 | import { AuthorResolver } from './author.resolver'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Author])], 9 | providers: [AuthorResolver], 10 | exports: [], 11 | }) 12 | export class AuthorModule {} 13 | -------------------------------------------------------------------------------- /src/entities/author/author.resolver.ts: -------------------------------------------------------------------------------- 1 | import { not_found } from '@errors'; 2 | import { Inject } from '@nestjs/common'; 3 | import { Args, Context, GraphQLExecutionContext, ID, Parent, Resolver, Subscription } from '@nestjs/graphql'; 4 | 5 | import { PubSub } from 'graphql-subscriptions'; 6 | import { Query, ResolveField, ELoaderType, Loader, Filter, Order, Pagination, Mutation } from 'nestjs-graphql-easy'; 7 | import { DataSource } from 'typeorm'; 8 | 9 | import { GRAPHQL_SUBSCRIPTIONS_PUB_SUB } from '../../graphql/graphql.module'; 10 | import { Book } from '../book/book.entity'; 11 | import { Author } from './author.entity'; 12 | 13 | @Resolver(() => Author) 14 | export class AuthorResolver { 15 | constructor( 16 | private readonly dataSource: DataSource, 17 | @Inject(GRAPHQL_SUBSCRIPTIONS_PUB_SUB) private readonly pubSub: PubSub 18 | ) {} 19 | 20 | @Query(() => [Author]) 21 | public async authors( 22 | @Loader({ 23 | loader_type: ELoaderType.MANY, 24 | field_name: 'authors', 25 | entity: () => Author, 26 | entity_fk_key: 'id', 27 | }) 28 | field_alias: string, 29 | @Filter(() => Author) _filter: unknown, 30 | @Order(() => Author) _order: unknown, 31 | @Pagination() _pagination: unknown, 32 | @Context() ctx: GraphQLExecutionContext 33 | ) { 34 | return await ctx[field_alias]; 35 | } 36 | 37 | @ResolveField(() => [Book], { nullable: true }) 38 | public async books( 39 | @Parent() author: Author, 40 | @Loader({ 41 | loader_type: ELoaderType.ONE_TO_MANY, 42 | field_name: 'books', 43 | entity: () => Book, 44 | entity_fk_key: 'author_id', 45 | entity_wheres: [ 46 | { 47 | query: 'book.is_private = :is_private', 48 | params: { is_private: false }, 49 | }, 50 | ], 51 | }) 52 | field_alias: string, 53 | @Filter(() => Book) _filter: unknown, 54 | @Order(() => Book) _order: unknown, 55 | @Context() ctx: GraphQLExecutionContext 56 | ): Promise { 57 | return await ctx[field_alias].load(author.id); 58 | } 59 | 60 | @Mutation(() => Author) 61 | public async updateAuthor(@Args({ name: 'id', type: () => ID }) id: string, @Args({ name: 'name', type: () => String }) name: string) { 62 | const author = await this.dataSource.getRepository(Author).findOne({ where: { id } }); 63 | 64 | if (author == null) { 65 | not_found({ raise: true }); 66 | } 67 | 68 | author.name = name; 69 | 70 | await this.dataSource.getRepository(Author).update(author.id, { name: author.name }); 71 | 72 | this.pubSub.publish('updateAuthorEvent', { updateAuthorEvent: author, channel_ids: [null, '1'] }); 73 | 74 | return author; 75 | } 76 | 77 | @Subscription(() => Author, { filter: (payload, variables) => payload.channel_ids.includes(variables.channel_id) }) 78 | protected async updateAuthorEvent(@Args({ name: 'channel_id', type: () => ID, nullable: true }) _channel_id: string) { 79 | return this.pubSub.asyncIterableIterator('updateAuthorEvent'); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/entities/book/book.entity.ts: -------------------------------------------------------------------------------- 1 | import { ID } from '@nestjs/graphql'; 2 | 3 | import { Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; 4 | 5 | import { Field, ObjectType, Column, Entity, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn } from 'nestjs-graphql-easy'; 6 | 7 | import { Author } from '../author/author.entity'; 8 | import { Section } from '../section/section.entity'; 9 | 10 | @ObjectType() 11 | @Entity() 12 | export class Book { 13 | @Field(() => ID, { filterable: true, sortable: true }) 14 | @PrimaryGeneratedColumn('uuid') 15 | public id: string; 16 | 17 | @Field(() => Date) 18 | @CreateDateColumn({ 19 | type: 'timestamp without time zone', 20 | precision: 3, 21 | default: () => 'CURRENT_TIMESTAMP', 22 | }) 23 | public created_at: Date; 24 | 25 | @Field(() => Date) 26 | @UpdateDateColumn({ 27 | type: 'timestamp without time zone', 28 | precision: 3, 29 | default: () => 'CURRENT_TIMESTAMP', 30 | }) 31 | public updated_at: Date; 32 | 33 | @Field(() => String) 34 | @Column() 35 | public title: string; 36 | 37 | @Index() 38 | @Field(() => Boolean, { filterable: true }) 39 | @Column('boolean', { nullable: false, default: () => 'false' }) 40 | public is_private: boolean; 41 | 42 | @Field(() => ID, { filterable: true, sortable: true }) 43 | @Index() 44 | @Column('uuid', { nullable: false }) 45 | public author_id: string; 46 | 47 | @ManyToOne(() => Author, { nullable: false, onDelete: 'CASCADE' }) 48 | @JoinColumn({ name: 'author_id' }) 49 | public author: Author; 50 | 51 | @OneToMany(() => Section, (section) => section.book) 52 | public sections: Section[]; 53 | } 54 | -------------------------------------------------------------------------------- /src/entities/book/book.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Book } from './book.entity'; 5 | import { BookResolver } from './book.resolver'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Book])], 9 | providers: [BookResolver], 10 | exports: [], 11 | }) 12 | export class BookModule {} 13 | -------------------------------------------------------------------------------- /src/entities/book/book.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Context, GraphQLExecutionContext, Parent, Resolver } from '@nestjs/graphql'; 2 | 3 | import { Query, ResolveField, ELoaderType, Loader, Filter, Order, Pagination } from 'nestjs-graphql-easy'; 4 | 5 | import { Author } from '../author/author.entity'; 6 | import { Section } from '../section/section.entity'; 7 | import { Book } from './book.entity'; 8 | 9 | @Resolver(() => Book) 10 | export class BookResolver { 11 | @Query(() => [Book]) 12 | public async books( 13 | @Loader({ 14 | loader_type: ELoaderType.MANY, 15 | field_name: 'books', 16 | entity: () => Book, 17 | entity_fk_key: 'id', 18 | }) 19 | field_alias: string, 20 | @Filter(() => Book) _filter: unknown, 21 | @Order(() => Book) _order: unknown, 22 | @Pagination() _pagination: unknown, 23 | @Context() ctx: GraphQLExecutionContext 24 | ) { 25 | return await ctx[field_alias]; 26 | } 27 | 28 | @ResolveField(() => Author, { nullable: false }) 29 | public async author( 30 | @Parent() book: Book, 31 | @Loader({ 32 | loader_type: ELoaderType.MANY_TO_ONE, 33 | field_name: 'author', 34 | entity: () => Author, 35 | entity_fk_key: 'id', 36 | }) 37 | field_alias: string, 38 | @Context() ctx: GraphQLExecutionContext 39 | ): Promise { 40 | return await ctx[field_alias].load(book.author_id); 41 | } 42 | 43 | @ResolveField(() => [Section], { nullable: true }) 44 | public async sections( 45 | @Parent() book: Book, 46 | @Loader({ 47 | loader_type: ELoaderType.ONE_TO_MANY, 48 | field_name: 'sections', 49 | entity: () => Section, 50 | entity_fk_key: 'book_id', 51 | }) 52 | field_alias: string, 53 | @Filter(() => Section) _filter: unknown, 54 | @Order(() => Section) _order: unknown, 55 | @Context() ctx: GraphQLExecutionContext 56 | ): Promise { 57 | return await ctx[field_alias].load(book.id); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/entities/entities.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { AuthorModule } from './author/author.module'; 4 | import { BookModule } from './book/book.module'; 5 | import { ItemImageModule } from './item-image/item-image.module'; 6 | import { ItemTextModule } from './item-text/item-text.module'; 7 | import { ItemModule } from './item/item.module'; 8 | import { SectionModule } from './section/section.module'; 9 | import { SectionTitleModule } from './section-title/section-title.module'; 10 | 11 | @Module({ 12 | imports: [AuthorModule, BookModule, SectionModule, SectionTitleModule, ItemModule, ItemTextModule, ItemImageModule], 13 | providers: [], 14 | exports: [AuthorModule, BookModule, SectionModule, SectionTitleModule, ItemModule, ItemTextModule, ItemImageModule], 15 | }) 16 | export class EntitiesModule {} 17 | -------------------------------------------------------------------------------- /src/entities/item-image/item-image.entity.ts: -------------------------------------------------------------------------------- 1 | import { ID } from '@nestjs/graphql'; 2 | 3 | import { Field, ObjectType, Column, Entity, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn } from 'nestjs-graphql-easy'; 4 | 5 | @ObjectType() 6 | @Entity() 7 | export class ItemImage { 8 | @Field(() => ID, { filterable: true, sortable: true }) 9 | @PrimaryGeneratedColumn('uuid') 10 | public id: string; 11 | 12 | @Field(() => Date) 13 | @CreateDateColumn({ 14 | type: 'timestamp without time zone', 15 | precision: 3, 16 | default: () => 'CURRENT_TIMESTAMP', 17 | }) 18 | public created_at: Date; 19 | 20 | @Field(() => Date) 21 | @UpdateDateColumn({ 22 | type: 'timestamp without time zone', 23 | precision: 3, 24 | default: () => 'CURRENT_TIMESTAMP', 25 | }) 26 | public updated_at: Date; 27 | 28 | @Field(() => String, { nullable: false }) 29 | @Column() 30 | public file_url: string; 31 | } 32 | -------------------------------------------------------------------------------- /src/entities/item-image/item-image.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { ItemImage } from './item-image.entity'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([ItemImage])], 8 | providers: [], 9 | exports: [], 10 | }) 11 | export class ItemImageModule {} 12 | -------------------------------------------------------------------------------- /src/entities/item-text/item-text.entity.ts: -------------------------------------------------------------------------------- 1 | import { ID } from '@nestjs/graphql'; 2 | 3 | import { Field, ObjectType, Column, Entity, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn } from 'nestjs-graphql-easy'; 4 | 5 | @ObjectType() 6 | @Entity() 7 | export class ItemText { 8 | @Field(() => ID, { filterable: true, sortable: true }) 9 | @PrimaryGeneratedColumn('uuid') 10 | public id: string; 11 | 12 | @Field(() => Date) 13 | @CreateDateColumn({ 14 | type: 'timestamp without time zone', 15 | precision: 3, 16 | default: () => 'CURRENT_TIMESTAMP', 17 | }) 18 | public created_at: Date; 19 | 20 | @Field(() => Date) 21 | @UpdateDateColumn({ 22 | type: 'timestamp without time zone', 23 | precision: 3, 24 | default: () => 'CURRENT_TIMESTAMP', 25 | }) 26 | public updated_at: Date; 27 | 28 | @Field(() => String, { nullable: false }) 29 | @Column() 30 | public value: string; 31 | } 32 | -------------------------------------------------------------------------------- /src/entities/item-text/item-text.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { ItemText } from './item-text.entity'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([ItemText])], 8 | providers: [], 9 | exports: [], 10 | }) 11 | export class ItemTextModule {} 12 | -------------------------------------------------------------------------------- /src/entities/item/item.entity.ts: -------------------------------------------------------------------------------- 1 | import { ID } from '@nestjs/graphql'; 2 | 3 | import { Index, JoinColumn, ManyToOne } from 'typeorm'; 4 | 5 | import { 6 | Field, 7 | ObjectType, 8 | PolymorphicColumn, 9 | Column, 10 | Entity, 11 | CreateDateColumn, 12 | UpdateDateColumn, 13 | PrimaryGeneratedColumn, 14 | } from 'nestjs-graphql-easy'; 15 | 16 | import { Section } from '../section/section.entity'; 17 | 18 | @ObjectType() 19 | @Entity() 20 | export class Item { 21 | @Field(() => ID, { filterable: true, sortable: true }) 22 | @PrimaryGeneratedColumn('uuid') 23 | public id: string; 24 | 25 | @Field(() => Date) 26 | @CreateDateColumn({ 27 | type: 'timestamp without time zone', 28 | precision: 3, 29 | default: () => 'CURRENT_TIMESTAMP', 30 | }) 31 | public created_at: Date; 32 | 33 | @Field(() => Date) 34 | @UpdateDateColumn({ 35 | type: 'timestamp without time zone', 36 | precision: 3, 37 | default: () => 'CURRENT_TIMESTAMP', 38 | }) 39 | public updated_at: Date; 40 | 41 | @Field(() => ID, { filterable: true, sortable: true }) 42 | @Index() 43 | @Column('uuid', { nullable: false }) 44 | public section_id: string; 45 | 46 | @ManyToOne(() => Section, { nullable: false, onDelete: 'CASCADE' }) 47 | @JoinColumn({ name: 'section_id' }) 48 | public section: Section; 49 | 50 | @Field(() => ID, { filterable: true, sortable: true }) 51 | @Index() 52 | @Column('uuid', { nullable: false }) 53 | @PolymorphicColumn() 54 | public itemable_id: string; 55 | 56 | @Field(() => String, { filterable: true, sortable: true }) 57 | @Index() 58 | @Column({ nullable: false }) 59 | @PolymorphicColumn() 60 | public itemable_type: string; 61 | } 62 | -------------------------------------------------------------------------------- /src/entities/item/item.itemable.ts: -------------------------------------------------------------------------------- 1 | import { createUnionType } from '@nestjs/graphql'; 2 | 3 | import { ItemImage } from '../item-image/item-image.entity'; 4 | import { ItemText } from '../item-text/item-text.entity'; 5 | 6 | export const ItemableType = createUnionType({ 7 | name: 'ItemableType', 8 | types: () => [ItemText, ItemImage], 9 | resolveType(value) { 10 | if (value instanceof ItemText) { 11 | return ItemText; 12 | } else if (value instanceof ItemImage) { 13 | return ItemImage; 14 | } 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/entities/item/item.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Item } from './item.entity'; 5 | import { ItemResolver } from './item.resolver'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Item])], 9 | providers: [ItemResolver], 10 | exports: [], 11 | }) 12 | export class ItemModule {} 13 | -------------------------------------------------------------------------------- /src/entities/item/item.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Context, GraphQLExecutionContext, Parent, Resolver } from '@nestjs/graphql'; 2 | 3 | import { Query, ResolveField, ELoaderType, Loader, Filter, Order, Pagination } from 'nestjs-graphql-easy'; 4 | 5 | import { Section } from '../section/section.entity'; 6 | 7 | import { Item } from './item.entity'; 8 | import { ItemableType } from './item.itemable'; 9 | 10 | @Resolver(() => Item) 11 | export class ItemResolver { 12 | @Query(() => [Item]) 13 | public async items( 14 | @Loader({ 15 | loader_type: ELoaderType.MANY, 16 | field_name: 'items', 17 | entity: () => Item, 18 | entity_fk_key: 'id', 19 | }) 20 | field_alias: string, 21 | @Filter(() => Item) _filter: unknown, 22 | @Order(() => Item) _order: unknown, 23 | @Pagination() _pagination: unknown, 24 | @Context() ctx: GraphQLExecutionContext 25 | ) { 26 | return await ctx[field_alias]; 27 | } 28 | 29 | @ResolveField(() => Section, { nullable: false }) 30 | public async section( 31 | @Parent() item: Item, 32 | @Loader({ 33 | loader_type: ELoaderType.MANY_TO_ONE, 34 | field_name: 'section', 35 | entity: () => Section, 36 | entity_fk_key: 'id', 37 | }) 38 | field_alias: string, 39 | @Context() ctx: GraphQLExecutionContext 40 | ): Promise
{ 41 | return await ctx[field_alias].load(item.section_id); 42 | } 43 | 44 | @ResolveField(() => ItemableType, { nullable: true }) 45 | public async itemable( 46 | @Parent() item: Item, 47 | @Loader({ 48 | loader_type: ELoaderType.POLYMORPHIC, 49 | field_name: 'itemable', 50 | entity: () => ItemableType, 51 | entity_fk_key: 'id', 52 | entity_fk_type: 'itemable_type', 53 | }) 54 | field_alias: string, 55 | @Context() ctx: GraphQLExecutionContext 56 | ) { 57 | return await ctx[field_alias].load(item.itemable_id); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/entities/section-title/section-title.entity.ts: -------------------------------------------------------------------------------- 1 | import { ID } from '@nestjs/graphql'; 2 | 3 | import { Index, JoinColumn, OneToOne } from 'typeorm'; 4 | 5 | import { Field, ObjectType, Column, Entity, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn } from 'nestjs-graphql-easy'; 6 | 7 | import { Section } from '../section/section.entity'; 8 | 9 | @ObjectType() 10 | @Entity() 11 | export class SectionTitle { 12 | @Field(() => ID, { filterable: true, sortable: true }) 13 | @PrimaryGeneratedColumn('uuid') 14 | public id: string; 15 | 16 | @Field(() => Date) 17 | @CreateDateColumn({ 18 | type: 'timestamp without time zone', 19 | precision: 3, 20 | default: () => 'CURRENT_TIMESTAMP', 21 | }) 22 | public created_at: Date; 23 | 24 | @Field(() => Date) 25 | @UpdateDateColumn({ 26 | type: 'timestamp without time zone', 27 | precision: 3, 28 | default: () => 'CURRENT_TIMESTAMP', 29 | }) 30 | public updated_at: Date; 31 | 32 | @Field(() => String) 33 | @Column() 34 | public title: string; 35 | 36 | @Field(() => ID, { filterable: true, sortable: true }) 37 | @Index() 38 | @Column('uuid', { nullable: false }) 39 | public section_id: string; 40 | 41 | @OneToOne(() => Section, (section) => section.section_title) 42 | @JoinColumn({ name: 'section_id' }) 43 | public section: Section; 44 | } 45 | -------------------------------------------------------------------------------- /src/entities/section-title/section-title.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { SectionTitle } from './section-title.entity'; 5 | import { SectionTitleResolver } from './section-title.resolver'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([SectionTitle])], 9 | providers: [SectionTitleResolver], 10 | exports: [], 11 | }) 12 | export class SectionTitleModule {} 13 | -------------------------------------------------------------------------------- /src/entities/section-title/section-title.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Context, GraphQLExecutionContext, Parent, Resolver } from '@nestjs/graphql'; 2 | 3 | import { Query, ResolveField, ELoaderType, Loader, Filter, Order, Pagination } from 'nestjs-graphql-easy'; 4 | 5 | import { SectionTitle } from './section-title.entity'; 6 | 7 | import { Section } from '../section/section.entity'; 8 | 9 | @Resolver(() => SectionTitle) 10 | export class SectionTitleResolver { 11 | @Query(() => [SectionTitle]) 12 | public async section_titles( 13 | @Loader({ 14 | loader_type: ELoaderType.MANY, 15 | field_name: 'section_titles', 16 | entity: () => SectionTitle, 17 | entity_fk_key: 'id', 18 | }) 19 | field_alias: string, 20 | @Filter(() => SectionTitle) _filter: unknown, 21 | @Order(() => SectionTitle) _order: unknown, 22 | @Pagination() _pagination: unknown, 23 | @Context() ctx: GraphQLExecutionContext 24 | ) { 25 | return await ctx[field_alias]; 26 | } 27 | 28 | @ResolveField(() => Section, { nullable: false }) 29 | public async section( 30 | @Parent() section_title: SectionTitle, 31 | @Loader({ 32 | loader_type: ELoaderType.ONE_TO_ONE, 33 | field_name: 'section', 34 | entity: () => Section, 35 | entity_fk_key: 'id', 36 | }) 37 | field_alias: string, 38 | @Context() ctx: GraphQLExecutionContext 39 | ): Promise { 40 | return await ctx[field_alias].load(section_title.section_id); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/entities/section/section.entity.ts: -------------------------------------------------------------------------------- 1 | import { ID } from '@nestjs/graphql'; 2 | 3 | import { Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; 4 | 5 | import { Field, ObjectType, Column, Entity, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn } from 'nestjs-graphql-easy'; 6 | 7 | import { Book } from '../book/book.entity'; 8 | import { SectionTitle } from '../section-title/section-title.entity'; 9 | import { Item } from '../item/item.entity'; 10 | 11 | @ObjectType() 12 | @Entity() 13 | export class Section { 14 | @Field(() => ID, { filterable: true, sortable: true }) 15 | @PrimaryGeneratedColumn('uuid') 16 | public id: string; 17 | 18 | @Field(() => Date) 19 | @CreateDateColumn({ 20 | type: 'timestamp without time zone', 21 | precision: 3, 22 | default: () => 'CURRENT_TIMESTAMP', 23 | }) 24 | public created_at: Date; 25 | 26 | @Field(() => Date) 27 | @UpdateDateColumn({ 28 | type: 'timestamp without time zone', 29 | precision: 3, 30 | default: () => 'CURRENT_TIMESTAMP', 31 | }) 32 | public updated_at: Date; 33 | 34 | @Field(() => String) 35 | @Column() 36 | public title: string; 37 | 38 | @Field(() => ID, { filterable: true, sortable: true }) 39 | @Index() 40 | @Column('uuid', { nullable: false }) 41 | public book_id: string; 42 | 43 | @ManyToOne(() => Book, { nullable: false, onDelete: 'CASCADE' }) 44 | @JoinColumn({ name: 'book_id' }) 45 | public book: Book; 46 | 47 | @OneToOne(() => SectionTitle, (section_title) => section_title.section) 48 | public section_title: SectionTitle; 49 | 50 | @OneToMany(() => Item, (item) => item.section) 51 | public items: Item[]; 52 | } 53 | -------------------------------------------------------------------------------- /src/entities/section/section.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Section } from './section.entity'; 5 | import { SectionResolver } from './section.resolver'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Section])], 9 | providers: [SectionResolver], 10 | exports: [], 11 | }) 12 | export class SectionModule {} 13 | -------------------------------------------------------------------------------- /src/entities/section/section.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Context, GraphQLExecutionContext, Parent, Resolver } from '@nestjs/graphql'; 2 | 3 | import { Query, ResolveField, ELoaderType, Loader, Filter, Order, Pagination } from 'nestjs-graphql-easy'; 4 | 5 | import { Book } from '../book/book.entity'; 6 | import { Section } from './section.entity'; 7 | 8 | import { SectionTitle } from '../section-title/section-title.entity'; 9 | import { Item } from '../item/item.entity'; 10 | 11 | @Resolver(() => Section) 12 | export class SectionResolver { 13 | @Query(() => [Section]) 14 | public async sections( 15 | @Loader({ 16 | loader_type: ELoaderType.MANY, 17 | field_name: 'sections', 18 | entity: () => Section, 19 | entity_fk_key: 'id', 20 | }) 21 | field_alias: string, 22 | @Filter(() => Section) _filter: unknown, 23 | @Order(() => Section) _order: unknown, 24 | @Pagination() _pagination: unknown, 25 | @Context() ctx: GraphQLExecutionContext 26 | ) { 27 | return await ctx[field_alias]; 28 | } 29 | 30 | @ResolveField(() => Book, { nullable: false }) 31 | public async book( 32 | @Parent() section: Section, 33 | @Loader({ 34 | loader_type: ELoaderType.MANY_TO_ONE, 35 | field_name: 'book', 36 | entity: () => Book, 37 | entity_fk_key: 'id', 38 | }) 39 | field_alias: string, 40 | @Context() ctx: GraphQLExecutionContext 41 | ): Promise { 42 | return await ctx[field_alias].load(section.book_id); 43 | } 44 | 45 | @ResolveField(() => SectionTitle, { nullable: true }) 46 | public async section_title( 47 | @Parent() section: Section, 48 | @Loader({ 49 | loader_type: ELoaderType.ONE_TO_ONE, 50 | field_name: 'section_title', 51 | entity: () => SectionTitle, 52 | entity_fk_key: 'section_id', 53 | }) 54 | field_alias: string, 55 | @Context() ctx: GraphQLExecutionContext 56 | ): Promise { 57 | return await ctx[field_alias].load(section.id); 58 | } 59 | 60 | @ResolveField(() => [Item], { nullable: true }) 61 | public async items( 62 | @Parent() section: Section, 63 | @Loader({ 64 | loader_type: ELoaderType.ONE_TO_MANY, 65 | field_name: 'items', 66 | entity: () => Item, 67 | entity_fk_key: 'section_id', 68 | }) 69 | field_alias: string, 70 | @Filter(() => Item) _filter: unknown, 71 | @Order(() => Item) _order: unknown, 72 | @Context() ctx: GraphQLExecutionContext 73 | ): Promise { 74 | return await ctx[field_alias].load(section.id); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/errors/index.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '@nestjs/common'; 2 | 3 | interface IErrData { 4 | msg?: string; 5 | raise?: boolean; 6 | } 7 | 8 | export const authorization_failed = (data?: IErrData) => { 9 | const err = new HttpException( 10 | { 11 | status: 400, 12 | error: data?.msg || 'AUTHORIZATION_FAILED', 13 | }, 14 | 400 15 | ); 16 | 17 | if (data?.raise) { 18 | throw err; 19 | } 20 | 21 | return err; 22 | }; 23 | 24 | export const unauthorized = (data?: IErrData) => { 25 | const err = new HttpException( 26 | { 27 | status: 401, 28 | error: data?.msg || 'UNAUTHORIZED', 29 | }, 30 | 401 31 | ); 32 | 33 | if (data?.raise) { 34 | throw err; 35 | } 36 | 37 | return err; 38 | }; 39 | 40 | export const bad_request = (data?: IErrData) => { 41 | const err = new HttpException( 42 | { 43 | status: 400, 44 | error: data?.msg || 'BAD_REQUEST', 45 | }, 46 | 400 47 | ); 48 | 49 | if (data?.raise) { 50 | throw err; 51 | } 52 | 53 | return err; 54 | }; 55 | 56 | export const cors_not_allowed = (data?: IErrData) => { 57 | const err = new HttpException( 58 | { 59 | status: 400, 60 | error: data?.msg || 'CORS_NOT_ALLOWED', 61 | }, 62 | 400 63 | ); 64 | 65 | if (data?.raise) { 66 | throw err; 67 | } 68 | 69 | return err; 70 | }; 71 | 72 | export const access_denied = (data?: IErrData) => { 73 | const err = new HttpException( 74 | { 75 | status: 403, 76 | error: data?.msg || 'ACCESS_DENIED', 77 | }, 78 | 403 79 | ); 80 | 81 | if (data?.raise) { 82 | throw err; 83 | } 84 | 85 | return err; 86 | }; 87 | 88 | export const not_found = (data?: IErrData) => { 89 | const err = new HttpException( 90 | { 91 | status: 404, 92 | error: data?.msg || 'NOT_FOUND', 93 | }, 94 | 404 95 | ); 96 | 97 | if (data?.raise) { 98 | throw err; 99 | } 100 | 101 | return err; 102 | }; 103 | 104 | export const internal_server_error = (data?: IErrData) => { 105 | const err = new HttpException( 106 | { 107 | status: 500, 108 | error: data?.msg || 'INTERNAL_SERVER_ERROR', 109 | }, 110 | 500 111 | ); 112 | 113 | if (data?.raise) { 114 | throw err; 115 | } 116 | 117 | return err; 118 | }; 119 | 120 | export const service_unavailable = (data?: IErrData) => { 121 | const err = new HttpException( 122 | { 123 | status: 503, 124 | error: data?.msg || 'SERVICE_UNAVAILABLE', 125 | }, 126 | 503 127 | ); 128 | 129 | if (data?.raise) { 130 | throw err; 131 | } 132 | 133 | return err; 134 | }; 135 | -------------------------------------------------------------------------------- /src/graphql/graphql.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { GraphQLModule as NestJSGraphQLModule } from '@nestjs/graphql'; 3 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 4 | 5 | import { PubSub } from 'graphql-subscriptions'; 6 | 7 | import { GraphqlOptions } from './graphql.options'; 8 | 9 | export const GRAPHQL_SUBSCRIPTIONS_PUB_SUB = 'GRAPHQL_SUBSCRIPTIONS_PUB_SUB'; 10 | 11 | @Global() 12 | @Module({ 13 | imports: [ 14 | NestJSGraphQLModule.forRootAsync({ 15 | imports: [], 16 | useClass: GraphqlOptions, 17 | inject: [], 18 | driver: ApolloDriver, 19 | }), 20 | ], 21 | providers: [ 22 | { 23 | provide: GRAPHQL_SUBSCRIPTIONS_PUB_SUB, 24 | useFactory: () => new PubSub(), 25 | }, 26 | ], 27 | exports: [GRAPHQL_SUBSCRIPTIONS_PUB_SUB], 28 | }) 29 | export class GraphQLModule {} 30 | -------------------------------------------------------------------------------- /src/graphql/graphql.options.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { GqlOptionsFactory } from '@nestjs/graphql'; 3 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 4 | 5 | import { setDataSource } from 'nestjs-graphql-easy'; 6 | import { GraphQLError, GraphQLSchema } from 'graphql'; 7 | import config from 'config'; 8 | import { Request } from 'express'; 9 | import { DataSource } from 'typeorm'; 10 | 11 | import { LoggerStore } from '../logger/logger.store'; 12 | import { corsOptionsDelegate } from '../cors.options'; 13 | 14 | const appSettings = config.get('APP_SETTINGS'); 15 | const graphqlSettings = config.get('GRAPHQL_SETTINGS'); 16 | 17 | @Injectable() 18 | export class GraphqlOptions implements GqlOptionsFactory { 19 | constructor(private readonly dataSource: DataSource) { 20 | setDataSource(this.dataSource); 21 | } 22 | 23 | public createGqlOptions(): ApolloDriverConfig { 24 | return { 25 | ...graphqlSettings, 26 | installSubscriptionHandlers: true, 27 | subscriptions: { 28 | 'graphql-ws': true, 29 | 'subscriptions-transport-ws': false, 30 | }, 31 | driver: ApolloDriver, 32 | autoSchemaFile: __dirname + '/schema.graphql', 33 | formatError: (err: GraphQLError) => { 34 | return err; 35 | }, 36 | context: ({ req }: { req: Request & { logger_store: LoggerStore } }) => ({ 37 | req, 38 | logger_store: req.logger_store, 39 | }), 40 | transformSchema: (schema: GraphQLSchema) => { 41 | return schema; 42 | }, 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/healthz/healthz.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller('healthz') 4 | export class HealthzController { 5 | @Get() 6 | public async healthz() { 7 | return { message: 'OK' }; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/healthz/healthz.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { HealthzController } from './healthz.controller'; 4 | 5 | @Module({ 6 | imports: [], 7 | providers: [], 8 | controllers: [HealthzController], 9 | exports: [], 10 | }) 11 | export class HealthzModule {} 12 | -------------------------------------------------------------------------------- /src/helpers/array.helper.ts: -------------------------------------------------------------------------------- 1 | export function pluck(array: T[], key: string): K[] { 2 | return array.map((a) => a[key]); 3 | } 4 | 5 | export function shuffle(array: T[]): T[] { 6 | return array.sort(() => Math.random() - 0.5); 7 | } 8 | 9 | export function uniq(array: T[]): T[] { 10 | return Array.from(new Set(array)); 11 | } 12 | 13 | export function reduceToObject(array: T[], key: string): { [K: string]: T } { 14 | return array.reduce((acc, curr) => { 15 | acc[curr[key]] = curr; 16 | 17 | return acc; 18 | }, {}); 19 | } 20 | 21 | export function groupBy(array: T[], key: string): { [key: string]: T[] } { 22 | return array.reduce( 23 | (acc, curr) => { 24 | if (!acc.hasOwnProperty(curr[key])) { 25 | acc[curr[key]] = []; 26 | } 27 | 28 | acc[curr[key]].push(curr); 29 | 30 | return acc; 31 | }, 32 | {} as { [key: string]: T[] } 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/helpers/req.helper.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | export function getIp(req: Request): string { 4 | return req.ip || '-'; 5 | } 6 | 7 | export function getForwardedIp(req: Request): string { 8 | let ips = req.get('X-Forwarded-For'); 9 | 10 | if (ips?.length) { 11 | ips = ips.split(', ')[0]; 12 | } 13 | 14 | return ips || '-'; 15 | } 16 | 17 | export function getUrl(req: Request): string { 18 | return req.originalUrl || req.url || req.baseUrl || '-'; 19 | } 20 | 21 | export function getPath(req: Request): string { 22 | return getUrl(req).split('?')[0]; 23 | } 24 | 25 | export function getAction(req: Request): string { 26 | return getUrl(req).split('/')[1]; 27 | } 28 | 29 | export function getHttpVersion(req: Request): string { 30 | return req.httpVersionMajor + '.' + req.httpVersionMinor; 31 | } 32 | 33 | export function getResponseHeader(res: Response, field: string) { 34 | if (!res.headersSent) { 35 | return undefined; 36 | } 37 | 38 | const header = res.getHeader(field); 39 | 40 | return Array.isArray(header) ? header.join(', ') : header || '-'; 41 | } 42 | 43 | export function getReferrer(req: Request) { 44 | const referer = req.headers.referer || req.headers.referrer || '-'; 45 | 46 | if (typeof referer === 'string') { 47 | return referer; 48 | } 49 | 50 | return referer[0]; 51 | } 52 | 53 | export function getOrigin(req: Request) { 54 | const origin = req.headers.origin; 55 | 56 | if (!origin || typeof origin === 'string') { 57 | return origin as string; 58 | } 59 | 60 | return origin[0]; 61 | } 62 | 63 | export function getMethod(req: Request) { 64 | return req.method; 65 | } 66 | 67 | export function getRequestHeader(req: Request, field: string) { 68 | return req.headers[field] as string; 69 | } 70 | 71 | export function getUserAgent(req: Request) { 72 | return getRequestHeader(req, 'user-agent') || '-'; 73 | } 74 | -------------------------------------------------------------------------------- /src/helpers/string.helper.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(str: string) { 2 | const capitalized_chars = str.replace(/[-_\s.]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')); 3 | const capital_char = capitalized_chars[0].toUpperCase(); 4 | 5 | return capital_char + capitalized_chars.slice(1); 6 | } 7 | 8 | export function underscore(str: string) { 9 | return str 10 | .replace(/(?:^|\.?)([A-Z])/g, function (x, y) { 11 | return '_' + y.toLowerCase(); 12 | }) 13 | .replace(/^_/, ''); 14 | } 15 | -------------------------------------------------------------------------------- /src/helpers/validate.helper.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor, plainToClass } from 'class-transformer'; 2 | import { validateSync, ValidationError } from 'class-validator'; 3 | 4 | import { bad_request } from '@errors'; 5 | 6 | export function validateDTO(type: ClassConstructor, value: unknown) { 7 | const errors: ValidationError[] = validateSync(plainToClass(type, value) as object, { skipMissingProperties: true }); 8 | 9 | if (errors.length > 0) { 10 | const msg = errors.map((error) => Object.values(error.constraints)).join(', '); 11 | 12 | bad_request({ raise: true, msg }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/logger/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | 3 | import config from 'config'; 4 | import { NextFunction, Request, Response } from 'express'; 5 | 6 | import { getAction, getForwardedIp, getIp, getMethod, getOrigin, getPath, getReferrer, getUserAgent } from '../helpers/req.helper'; 7 | import { LoggerService } from './logger.service'; 8 | import { LoggerStore } from './logger.store'; 9 | 10 | @Injectable() 11 | export class LoggerMiddleware implements NestMiddleware { 12 | private readonly _settings: ILogSettings = config.get('LOGGER_SETTINGS'); 13 | 14 | constructor(private readonly logger: LoggerService) { 15 | if (process.env.COMMIT_SHORT_SHA) { 16 | this.logger.warn(`commit_short_sha: ${process.env.COMMIT_SHORT_SHA}`, 'BUILD_INFO'); 17 | } 18 | 19 | if (process.env.PIPELINE_CREATED_AT) { 20 | this.logger.warn(`pipeline_created_at: ${process.env.PIPELINE_CREATED_AT}`, 'BUILD_INFO'); 21 | } 22 | } 23 | 24 | public use(req: Request & { logger_store: LoggerStore }, res: Response, next: NextFunction) { 25 | const logger_store = new LoggerStore(this.logger); 26 | req.logger_store = logger_store; 27 | 28 | if (req.body && req.body.operationName === 'IntrospectionQuery') { 29 | return next(); 30 | } 31 | 32 | const action = getAction(req); 33 | 34 | if (this._settings.silence.includes(action)) { 35 | return next(); 36 | } 37 | 38 | req.on('error', (error: Error) => { 39 | logger_store.error(error.message, error.stack, { 40 | statusCode: req.statusCode, 41 | }); 42 | }); 43 | 44 | res.on('error', (error: Error) => { 45 | logger_store.error(error.message, error.stack, { 46 | statusCode: req.statusCode, 47 | }); 48 | }); 49 | 50 | res.on('finish', () => { 51 | const context = { 52 | method: getMethod(req), 53 | path: getPath(req), 54 | referrer: getReferrer(req), 55 | origin: getOrigin(req), 56 | userAgent: getUserAgent(req), 57 | remoteAddress: getIp(req), 58 | forwardedAddress: getForwardedIp(req), 59 | statusCode: res.statusCode, 60 | statusMessage: res.statusMessage, 61 | }; 62 | 63 | if (res.statusCode < 300) { 64 | logger_store.info(`${context.method} ${context.path}`, context); 65 | } else if (res.statusCode < 400) { 66 | logger_store.warn(`${context.method} ${context.path}`, context); 67 | } 68 | }); 69 | 70 | return next(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { LoggerService } from './logger.service'; 4 | 5 | @Module({ 6 | providers: [LoggerService], 7 | exports: [LoggerService], 8 | }) 9 | export class LoggerModule {} 10 | -------------------------------------------------------------------------------- /src/logger/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | 3 | import config from 'config'; 4 | 5 | export enum ELogLevel { 6 | debug = 'debug', 7 | info = 'info', 8 | warn = 'warn', 9 | error = 'error', 10 | } 11 | 12 | const log_level = config.get('LOGGER_SETTINGS').level; 13 | 14 | @Injectable() 15 | export class LoggerService extends Logger { 16 | private readonly _current_level: ELogLevel = ELogLevel[log_level]; 17 | 18 | constructor(private readonly _context?: string) { 19 | super(_context); 20 | } 21 | 22 | public log(message: unknown, context?: string | object) { 23 | if (this.isValidLevel(ELogLevel.debug)) { 24 | Logger.log(JSON.stringify(message), JSON.stringify(context) || this._context); 25 | } 26 | } 27 | 28 | public info(message: unknown, context?: string | object) { 29 | if (this.isValidLevel(ELogLevel.info)) { 30 | Logger.log(JSON.stringify(message), JSON.stringify(context) || this._context); 31 | } 32 | } 33 | 34 | public warn(message: unknown, context?: string | object) { 35 | if (this.isValidLevel(ELogLevel.warn)) { 36 | Logger.warn(JSON.stringify(message), JSON.stringify(context) || this._context); 37 | } 38 | } 39 | 40 | public error(message: unknown, trace?: string, context?: string | object) { 41 | if (this.isValidLevel(ELogLevel.error)) { 42 | Logger.error(JSON.stringify(message), trace, JSON.stringify(context) || this._context); 43 | } 44 | } 45 | 46 | public isValidLevel(level: ELogLevel): boolean { 47 | return level >= this._current_level; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/logger/logger.store.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | import { ELogLevel, LoggerService } from './logger.service'; 4 | 5 | interface ILog { 6 | level?: ELogLevel; 7 | message: unknown; 8 | context?: object; 9 | trace?: string; 10 | } 11 | 12 | export class LoggerStore { 13 | private readonly request_id: string = v4(); 14 | private readonly starts_at: Date = new Date(); 15 | private log_index = 0; 16 | private prev_log_time: number = Date.now(); 17 | 18 | constructor( 19 | private readonly logger: LoggerService, 20 | private readonly base_ctx: { [key: string]: unknown } = {} 21 | ) {} 22 | 23 | public log(message: unknown, context: object = {}) { 24 | this.addLog(ELogLevel.debug, { message, context }); 25 | } 26 | 27 | public info(message: unknown, context: object = {}) { 28 | this.addLog(ELogLevel.info, { message, context }); 29 | } 30 | 31 | public warn(message: unknown, context: object = {}) { 32 | this.addLog(ELogLevel.warn, { message, context }); 33 | } 34 | 35 | public error(message: unknown, trace?: string, context: object = {}) { 36 | this.addLog(ELogLevel.error, { message, context, trace }); 37 | } 38 | 39 | private addLog(level: ELogLevel, log_data: ILog) { 40 | if (this.logger.isValidLevel(level)) { 41 | const current_log_time = Date.now(); 42 | 43 | const log: ILog = { 44 | message: log_data.message, 45 | context: { 46 | request_id: this.request_id, 47 | log_index: this.log_index, 48 | starts_at: this.starts_at, 49 | handle_time: `${current_log_time - this.prev_log_time} ms`, 50 | ...this.base_ctx, 51 | ...log_data.context, 52 | }, 53 | trace: log_data.trace, 54 | level, 55 | }; 56 | 57 | this.prev_log_time = current_log_time; 58 | 59 | this.log_index++; 60 | 61 | this.dropLog(log); 62 | } 63 | } 64 | 65 | private dropLog(log: ILog) { 66 | switch (log.level) { 67 | case ELogLevel.debug: 68 | this.logger.log(log.message, log.context); 69 | break; 70 | case ELogLevel.info: 71 | this.logger.info(log.message, log.context); 72 | break; 73 | case ELogLevel.warn: 74 | this.logger.warn(log.message, log.context); 75 | break; 76 | case ELogLevel.error: 77 | this.logger.error(log.message, log.trace, log.context); 78 | break; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | 5 | import config from 'config'; 6 | import cookieParser from 'cookie-parser'; 7 | import express from 'express'; 8 | import helmet from 'helmet'; 9 | import { json, urlencoded } from 'body-parser'; 10 | import { DataSourceOptions } from 'typeorm'; 11 | import { createDatabase } from 'typeorm-extension'; 12 | 13 | import { AppModule } from './app.module'; 14 | import { corsOptionsDelegate } from './cors.options'; 15 | import { getOrmConfig } from './database/database-ormconfig.constant'; 16 | 17 | const appSettings = config.get('APP_SETTINGS'); 18 | 19 | async function bootstrap() { 20 | const server = express(); 21 | 22 | await createDatabase({ 23 | ifNotExist: true, 24 | options: getOrmConfig() as DataSourceOptions, 25 | }); 26 | 27 | const app = await NestFactory.create(AppModule, new ExpressAdapter(server), { 28 | bodyParser: true, 29 | }); 30 | 31 | app.use(json({ limit: appSettings.bodyLimit })); 32 | 33 | app.use( 34 | urlencoded({ 35 | extended: true, 36 | limit: appSettings.bodyLimit, 37 | parameterLimit: appSettings.bodyParameterLimit, 38 | }) 39 | ); 40 | 41 | app.use( 42 | helmet({ 43 | contentSecurityPolicy: false, 44 | crossOriginEmbedderPolicy: false, 45 | }) 46 | ); 47 | 48 | app.use(cookieParser()); 49 | 50 | app.enableCors(corsOptionsDelegate); 51 | 52 | app.useGlobalPipes( 53 | new ValidationPipe({ 54 | transform: true, 55 | }) 56 | ); 57 | 58 | await app.listen(appSettings.port); 59 | } 60 | 61 | bootstrap(); 62 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "**/*spec.ts", 7 | "**/*/schema.ts" 8 | ] 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "moduleResolution": "node", 5 | "skipLibCheck": true, 6 | "declaration": true, 7 | "removeComments": true, 8 | "noUnusedLocals": false, 9 | "noUnusedParameters": false, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "target": "ESNext", 15 | "sourceMap": true, 16 | "outDir": "./dist", 17 | "baseUrl": ".", 18 | "typeRoots": [ 19 | "./node_modules/@types", 20 | "./types" 21 | ], 22 | "paths": { 23 | "@helpers/*": ["./src/helpers/*"], 24 | "@errors": ["./src/errors/index"], 25 | "nestjs-graphql-easy": ["./lib/index"] 26 | } 27 | }, 28 | "exclude": [ 29 | ".git", 30 | ".vscode", 31 | "dist", 32 | "dist-lib", 33 | "node_modules" 34 | ] 35 | } -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "skipLibCheck": true, 6 | "declaration": true, 7 | "removeComments": true, 8 | "noUnusedLocals": false, 9 | "noUnusedParameters": false, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "target": "ES2019", 15 | "sourceMap": true, 16 | "outDir": "./dist-lib", 17 | "baseUrl": "lib", 18 | "typeRoots": [ 19 | "node_modules/@types", 20 | "types" 21 | ], 22 | "paths": { 23 | "@gql": ["./lib/index"] 24 | } 25 | }, 26 | "exclude": [ 27 | ".git", 28 | ".vscode", 29 | "config", 30 | "dist", 31 | "dist-lib", 32 | "node_modules", 33 | "src" 34 | ] 35 | } -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | interface ICorsSettings { 2 | readonly allowedOrigins: string[]; 3 | readonly allowedUrls: string[]; 4 | readonly allowedMethods: string[]; 5 | readonly allowedCredentials: boolean; 6 | readonly allowedHeaders: string[]; 7 | } 8 | 9 | interface IAppSettings { 10 | readonly port: number; 11 | readonly wssPort: number; 12 | readonly wssPingInterval: number; 13 | readonly wssPingTimeout: number; 14 | readonly bodyLimit: string; 15 | readonly bodyParameterLimit: number; 16 | } 17 | 18 | interface IGraphqlSettings { 19 | readonly playground: boolean; 20 | readonly debug: boolean; 21 | readonly introspection: boolean; 22 | readonly installSubscriptionHandlers: boolean; 23 | } 24 | 25 | interface ILogSettings { 26 | readonly level: string; 27 | readonly silence: string[]; 28 | } --------------------------------------------------------------------------------