├── .eslintrc.js ├── .gitignore ├── .gitlab-ci.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── md └── pagination.md ├── package.json ├── pnpm-lock.yaml ├── src ├── ConfigureLoader.ts ├── GraphQLDatabaseLoader.ts ├── GraphQLQueryBuilder.ts ├── GraphQLQueryManager.ts ├── GraphQLQueryResolver.ts ├── __tests__ │ ├── basic.test.ts │ ├── builderOptions.test.ts │ ├── decoratorUtils.test.ts │ ├── decorators.test.ts │ ├── embeddedEntities.test.ts │ ├── entity │ │ ├── Address.ts │ │ ├── Author.ts │ │ ├── Book.ts │ │ ├── DecoratorTest.ts │ │ ├── PaginatedReviews.ts │ │ ├── Publisher.ts │ │ ├── Review.ts │ │ └── index.ts │ ├── pagination.test.ts │ ├── resolvers │ │ ├── AddressResolver.ts │ │ ├── AuthorResolver.ts │ │ ├── BookResolver.ts │ │ ├── DecoratorTestResolver.ts │ │ ├── PublisherResolver.ts │ │ ├── ReviewResolver.ts │ │ └── index.ts │ ├── searching.test.ts │ └── util │ │ ├── DecoratorContext.ts │ │ ├── Seeder.ts │ │ └── testStartup.ts ├── enums │ ├── LoaderNamingStrategy.ts │ └── LoaderSearchMethod.ts ├── index.ts ├── lib │ ├── Formatter.ts │ ├── GraphQLInfoParser.ts │ └── filters.ts └── types.ts ├── tsconfig.json └── typedoc.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | }, 5 | parser: "@typescript-eslint/parser", 6 | parserOptions: { 7 | project: "./tsconfig.json", 8 | sourceType: "module", 9 | }, 10 | plugins: ["@typescript-eslint", "prettier"], 11 | rules: { 12 | "@typescript-eslint/await-thenable": "error", 13 | "@typescript-eslint/indent": "off", 14 | "@typescript-eslint/member-delimiter-style": [ 15 | "off", 16 | { 17 | multiline: { 18 | delimiter: "none", 19 | requireLast: true, 20 | }, 21 | singleline: { 22 | delimiter: "semi", 23 | requireLast: false, 24 | }, 25 | }, 26 | ], 27 | "@typescript-eslint/member-ordering": [ 28 | "error", 29 | { 30 | default: [ 31 | "public-static-field", 32 | "protected-static-field", 33 | "private-static-field", 34 | "public-instance-field", 35 | "protected-instance-field", 36 | "private-instance-field", 37 | "public-constructor", 38 | "protected-constructor", 39 | "private-constructor", 40 | "public-static-method", 41 | "protected-static-method", 42 | "private-static-method", 43 | "public-instance-method", 44 | "protected-instance-method", 45 | "private-instance-method", 46 | ], 47 | }, 48 | ], 49 | "@typescript-eslint/quotes": [ 50 | "error", 51 | "double", 52 | { avoidEscape: true, allowTemplateLiterals: true }, 53 | ], 54 | "@typescript-eslint/semi": ["error", "always"], 55 | "@typescript-eslint/type-annotation-spacing": "off", 56 | "arrow-parens": ["off", "as-needed"], 57 | "comma-dangle": "off", 58 | "eol-last": "off", 59 | "linebreak-style": "off", 60 | "max-len": "off", 61 | "new-parens": "off", 62 | "newline-per-chained-call": "off", 63 | "no-console": "off", 64 | "no-extra-semi": "off", 65 | "no-irregular-whitespace": "off", 66 | "no-multiple-empty-lines": "off", 67 | "no-trailing-spaces": "off", 68 | "object-curly-spacing": ["error", "always"], 69 | "prefer-const": ["error"], 70 | "quote-props": "off", 71 | "space-before-function-paren": "off", 72 | "space-in-parens": ["off", "never"], 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build/ 5 | tmp/ 6 | temp/ 7 | *.sqlite3 8 | .nyc_output/ 9 | coverage/ 10 | dist/ 11 | yarn-error.log 12 | public/ 13 | testSchema.graphql 14 | .run/ 15 | *.tgz 16 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:16 2 | 3 | cache: 4 | paths: 5 | - node_modules/ 6 | 7 | before_script: 8 | - apt-get update -qq && apt-get install 9 | - curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@6 10 | - pnpm config set store-dir .pnpm-store 11 | - pnpm prune 12 | - pnpm install --frozen-lockfile 13 | 14 | stages: 15 | - build 16 | - test 17 | - deploy 18 | 19 | lint-package: 20 | stage: build 21 | script: 22 | - pnpm lint 23 | 24 | build-package: 25 | stage: build 26 | script: 27 | - pnpm build 28 | 29 | test-package: 30 | stage: test 31 | script: 32 | - pnpm test 33 | artifacts: 34 | name: coverage 35 | paths: 36 | - coverage/ 37 | expire_in: 30 days 38 | 39 | pages: 40 | stage: deploy 41 | script: 42 | - pnpm publish:docs 43 | artifacts: 44 | paths: 45 | - public 46 | only: 47 | - master 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.7.5] 4 | 5 | ### Fixed 6 | 7 | Update package dependencies to include security fixes for several dev dependencies 8 | 9 | ## [1.7.4] 10 | 11 | ### Fixed 12 | 13 | Updated package dependencies to include security fixes for lodash 14 | 15 | ## [1.7.2] 16 | 17 | ### Fixed 18 | 19 | Bug where different queries can have the same cache key due to identical field sets. 20 | Can now be avoided by using different table aliases in your resolver. 21 | 22 | ## [1.7.1] 23 | 24 | ### Added 25 | 26 | Add `sqlJoinAlias` option to `ConfigureLoader` so users can specify the alias of a given table's join in the SQL query. 27 | 28 | ## [1.7.0] 29 | 30 | ### Fixed 31 | 32 | Not all types were not being exported properly. This should be addressed. 33 | 34 | DecoratorResolverPredict was getting an empty list for requested fields. This was a bug introduced during the refactor of the InfoParser and has been fixed. 35 | 36 | ### Added 37 | 38 | `graphQLName` option to `ConfigureLoader` decorator. Allows consumers to specify the name of a TypeORM 39 | entity field in the GraphQL schema so that it is properly selected if requested. 40 | 41 | ## [1.6.0] 42 | 43 | ### Changed 44 | 45 | The `GraphQLQueryBuilder#orWhere` method now accepts an instance of Brackets as a parameter. This brings the loader back into parity with the TypeORM `SelectQueryBuilder#orWhere` method. 46 | 47 | ### Added 48 | 49 | `GraphQLQueryBuilder#ejectQueryBuilder` method that accepts a callback which can be used to customize the TypeORM SelectQueryBuilder instance before executing against the database. 50 | 51 | ## [1.5.0] 52 | 53 | ### Fixed 54 | Fixed an issue with the loader not being able to load fragments on Union types. Thanks to Nico Britos for providing the fix for the issue. 55 | 56 | ### Changed 57 | Internally the loader now uses the graphql-parse-resolve-info package to get the requested selection of 58 | fields instead of the home-grown method used before. 59 | 60 | Some internal loader type definitions have changed due to the migration to the new info parser. It is for this reason I bumped the package a minor version over just a patch. 61 | 62 | ## [1.4.2] 63 | 64 | ### Fixed 65 | 66 | Queries are no longer run in a transaction, preventing a transaction error in some dbs. Thanks to Andrey Vasilev for identifying the issue and providing the fix. 67 | 68 | ## [1.4.1] 69 | 70 | Fixed issue with pagination manifesting from internal TypeORM bug in skip/take. Reverted back to using offset/limit 71 | 72 | ## [1.4.0] 73 | 74 | ### Fixed 75 | 76 | Paginated records with order by conditions could come back in the incorrect order due to an internal TypeORM. A different TypeORM bug prevented the loader from using the suggested `skip/take` TypeORM API. That `skip/take` bug has been fixed, so the loader is able to switch to `skip/take` and address the ordering bug. 77 | 78 | ### Changed 79 | 80 | The `ignore` and `requried` options in the `ConfigureLoader` decorator now also accept a predicate function in addition to primitive booleans. If given, the predicate function will be called at resolve time of that field during the GraphQL query resolution. For more information, read the [documentation](https://gql-loader.bmuller.net/globals.html#fieldconfigurationpredicate) 81 | 82 | Updated package dependencies and peer dependencies to latest versions 83 | 84 | 85 | ### Added 86 | 87 | A new `context` method to the GraphQLQueryBuilder that receives a user defined context and passes it to the configuration decorator predicates at resolve time. See the [documentation](https://gql-loader.bmuller.net/classes/graphqlquerybuilder.html#context). 88 | 89 | ## [1.3.0] 90 | 91 | ### Added 92 | A new decorator called `ConfigureLoader` that allows for more control over how entity fields/relations are resolved by the loader. For the initial version, the decorator allows you to ignore or require fields/embeds/relations during query resolution. This is still experimental and may require some hardening. For more information, see the [documentation](https://gql-loader.bmuller.net/globals.html#configureloader) 93 | 94 | ### Deprecated 95 | 96 | `GraphQLQueryBuilder#selectFields`. This was always a rather flaky solution to the problem it was trying to solve. With the release of the configuration decorator, I don't plan on supporting or fixing any bugs with this anymore. Once the decorator API is solidified, this will be removed in a 2.0 release. 97 | 98 | ## [1.2.0] 99 | 100 | ### Added 101 | 102 | * Changelog 103 | 104 | ### Changed 105 | 106 | * Fixed an issue with table aliases growing too long. The QueryBuilder now generates a hash for any tables that are joined during query resolution. See [The Gitlab Issue](https://gitlab.com/Mando75/typeorm-graphql-loader/-/issues/7) for more details. Thanks to Kees van Lierop and Roemer Bakker for the fix. 107 | 108 | * The loader now uses the entity metadata to query the primary key column for each relation joined, regardless of whether the field was queried. This is to ensure that custom resolvers can access the parent object with at least the primary key. This renders the `primaryKeyColumn` option in `LoaderOptions` obsolete. 109 | 110 | ### Deprecated 111 | 112 | * `primaryKeyColumn` field in `LoaderOptions`. See changes for reasoning. 113 | 114 | 115 | ## [1.1.1] 116 | 117 | ### Fixed 118 | 119 | * Issue with the loader not being able to query columns that had a different name from the entity propertyName 120 | 121 | ## [1.1.0] 122 | 123 | ### Added 124 | 125 | * Support for querying fields from embedded entities 126 | 127 | ### Changed 128 | 129 | * The `GraphQLQueryBuilder#info()` method to support using a path to fetch a nested field as entity root 130 | 131 | ## [1.0.0] 132 | 133 | ### Changed 134 | * Initial full release 135 | * Full refactor of codebase to a query-builder format. 136 | * Created a [documentation website](https://gql-loader.bmuller.net) 137 | 138 | 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Bryan Muller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeORM GraphQL Relation Loader 2 | 3 | A dataloader for TypeORM that makes it easy to load TypeORM relations for 4 | GraphQL query resolvers. 5 | 6 | 7 | [![npm version](https://badge.fury.io/js/%40mando75%2Ftypeorm-graphql-loader.svg)](https://badge.fury.io/js/%40mando75%2Ftypeorm-graphql-loader) 8 | ![npm](https://img.shields.io/npm/dm/@mando75/typeorm-graphql-loader) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 10 | [![pipeline status](https://gitlab.com/Mando75/typeorm-graphql-loader/badges/master/pipeline.svg)](https://gitlab.com/Mando75/typeorm-graphql-loader/commits/master) 11 | [![coverage report](https://gitlab.com/Mando75/typeorm-graphql-loader/badges/master/coverage.svg)](https://gitlab.com/Mando75/typeorm-graphql-loader/-/commits/master) 12 | 13 | 14 | 15 | ## UPGRADE NOTICE 16 | 17 | The 1.0.0 release of this package includes almost a complete rewrite 18 | of the source code. The public interface of the loader has changed significantly. 19 | As such, upgrading from the older versions will require significant work. 20 | 21 | For those upgrading, I highly recommend reading through the [new documentation](https://gql-loader.bmuller.net) to get an idea of the changes required. 22 | 23 | ## Contents 24 | 25 | - [Description](#Description) 26 | - [Installation](#Installation) 27 | - [Usage](#Usage) 28 | - [Gotchas](#Gotchas) 29 | - [Roadmap](#Roadmap) 30 | - [Contributing](#Contributing) 31 | - [Problem](#Problem) 32 | - [Solution](#Solution) 33 | - [Acknowledgments](#Acknowledgments) 34 | 35 | ## Description 36 | 37 | This package provides a `GraphQLDatabaseLoader` class, which is a caching 38 | loader that will parse a GraphQL query info object and load the 39 | TypeORM fields and relations needed to resolve the query. For a more in-depth 40 | explanation, see the [Problem](#Problem) and [Solution](#Solution) sections below. 41 | 42 | ## Installation 43 | 44 | ```bash 45 | yarn add @mando75/typeorm-graphql-loader 46 | 47 | # OR 48 | 49 | npm install @mando75/typeorm-graphql-loader 50 | ``` 51 | 52 | This package requires that you have TypeORM installed as a peer dependency 53 | 54 | ## Usage 55 | 56 | You should create a new GraphQLDatabaseLoader instance in each user session, 57 | generally via the GraphQLContext object. This is to help with caching and 58 | prevent user data from leaking between requests. The constructor takes a TypeORM 59 | connection as the first argument, and a [LoaderOptions](https://gql-loader.bmuller.net/interfaces/loaderoptions.html) type as an 60 | optional second parameter. 61 | 62 | ### Apollo Server Example 63 | ```typescript 64 | import { GraphQLDatabaseLoader } from '@mando75/typeorm-graphql-loader'; 65 | const connection = createConnection({...}); // Create your TypeORM connection 66 | 67 | const apolloServer = new ApolloServer({ 68 | schema, 69 | context: { 70 | loader: new GraphQLDatabaseLoader(connection, {/** additional options if needed**/}) 71 | }, 72 | }); 73 | ``` 74 | 75 | The loader will now appear in your resolver's context object: 76 | 77 | ```typescript 78 | { 79 | Query: { 80 | getBookById(object: any, args: {id: string }, context: MyGraphQLContext, info: GraphQLResolveInfo) { 81 | return context.loader 82 | .loadEntity(Book, "book") 83 | .where("book.id = :id", { id }) 84 | .info(info) 85 | .loadOne(); 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | Please note that the loader will only return the fields and relations that 92 | the client requested in the query. You can configure certain entity fields to be required or ignored via the [ConfigureLoader](https://gql-loader.bmuller.net/globals.html#configureloader) decorator. 93 | 94 | The loader provides a thin wrapper around the TypeORM SelectQueryBuilder 95 | with utility functions to help with things like adding where conditions, searching and pagination. 96 | For more advanced query building, see the documentation for the 97 | [ejectQueryBuilder](https://gql-loader.bmuller.net/classes/graphqlquerybuilder.html#ejectquerybuilder) 98 | method. 99 | 100 | Please refer to the [full documentation](https://gql-loader.bmuller.net) for more details on what 101 | options and utilities the loader provides. 102 | 103 | ## Gotchas 104 | 105 | Because this package reads which relations and fields to load from the GraphQL query info object, the loader only works if your schema field names match your TypeORM entity field names. If it cannot find a requested GraphQL query field, it will not return it. In this case, you will need to provide a custom resolver for that field in your GraphQL resolvers file. In this case, the loader will provide the resolver function with an `object` parameter which is an entity loaded with whichever other fields your query requested. The loader will always return an object with at least the primary key loaded, so basic method calls should be possible. The loader will automatically scan your entity and include whatever column marked as primary key in the query. 106 | 107 | This is not a complete replacement for the [dataloader](https://github.com/graphql/dataloader) package, its purpose is different. While it does provide some batching, its primary purpose is to load the relations and fields needed to resolve the query. In most cases, you will most likely not need to use dataloader when using this package. However, I have noticed in my own use that there are occasions where this may need to be combined with dataloader to remove N + 1 queries. One such case was a custom resolver for a many-to-many relation that existed in the GraphQL Schema but not on a database level. In order to completely remove the N+1 queries from that resolver, I had to wrap the TypeORM GraphQL loader in a Facebook DataLoader. If you find that you are in a situation where the TypeORM GraphQL loader is not solving the N+1 problem, please open an issue, and I'll do my best to help you out with it. 108 | 109 | This package has currently only been tested with Postgresql and SQLite. In theory, everything should work with the other SQL variants that TypeORM supports, as it uses the TypeORM Query Builder API to construct the database queries. If you run into any issues with other SQL dialects, please open an issue. 110 | 111 | For help with pagination, first read [Pagination Advice](https://gitlab.com/Mando75/typeorm-graphql-loader/-/blob/master/md/pagination.md) 112 | 113 | ## Roadmap 114 | 115 | ### Relay Support 116 | 117 | Currently, the loader only supports offset pagination. I would like to add the ability to support Relay-style pagination out of the box. 118 | 119 | [Track Progress](https://gitlab.com/Mando75/typeorm-graphql-loader/-/issues/8) 120 | 121 | ## Contributing 122 | 123 | This project is developed on [GitLab.com](https://gitlab.com/Mando75/typeorm-graphql-loader). However, I realize that many developers use GitHub as their primary development platform. If you do not use and do not wish to create a GitLab account, you can open an issue in the mirrored [GitHub Repository](https://github.com/Mando75/typeorm-graphql-loader). Please note that all merge requests must be done via GitLab as the GitHub repo is a read-only mirror. 124 | 125 | When opening an issue, please include the following information: 126 | 127 | - Package Version 128 | - Database and version used 129 | - TypeORM version 130 | - GraphQL library used 131 | - Description of the problem 132 | - Example code 133 | 134 | Please open an issue before opening any Merge Requests. 135 | 136 | ## Problem 137 | 138 | TypeORM is a pretty powerful tool, and it gives you quite a bit of flexibility 139 | in how you manage entity relations. TypeORM provides 3 ways to load your 140 | relations, eagerly, manually, or lazily. For more info on how this works, see 141 | the [TypeORM Documentation](https://typeorm.io/#/eager-and-lazy-relations). 142 | 143 | While this API is great for having fine-grained control of you data layer, it 144 | can get frustrating to use in a GraphQL schema. For example, lets say we have 145 | three entities, User, Author, and Book. Each Book has an Author, and each Author 146 | has a User. We want to expose these relations via a GraphQL API. Our issue now 147 | becomes how to resolve these relations. Let's look at how an example resolver 148 | function might try to resolve this query: 149 | 150 | Query 151 | 152 | ```graphql 153 | query bookById($id: ID!) { 154 | book(id: $id) { 155 | id 156 | name 157 | author { 158 | id 159 | user { 160 | id 161 | name 162 | } 163 | } 164 | } 165 | } 166 | ``` 167 | 168 | We could do something simple like this: 169 | 170 | ```ts 171 | function findBookById(object, args, context, info) { 172 | return Book.findOne(args.id); 173 | } 174 | ``` 175 | 176 | but then the author and user relations won't be loaded. We can remedy that by 177 | specifying them in our find options like so: 178 | 179 | ```ts 180 | function findBookById(object, args, context, info) { 181 | return Book.findOne(args.id, { relations: ["author", "author.user"] }); 182 | } 183 | ``` 184 | 185 | however, this could get really nasty if we have many relations we may need. 186 | Well, we could just set all of our relations to eagerly load so we don't need to 187 | specify them, but then we may start loading a bunch of data we may never use 188 | which isn't very performant at scale. 189 | 190 | How about just defining a resolver for every relation and loading them as 191 | needed? That could work, but it seems like a lot of work and duplication of 192 | effort when we've already specified our relations on the entity level. This will also lead us to a path where we will need to start creating custom loaders via [dataloader](https://github.com/graphql/dataloader) to deal with impending [N + 1](https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping) problems. 193 | 194 | Another possible, and probably intuitive solution is to use lazy relations. 195 | Because lazy relations return Promises, as long as we give the resolver an 196 | instance of our Book entity, it will call each relation and wait for the Promise 197 | to resolve, fixing our problem. It lets us use our original resolver function: 198 | 199 | ```ts 200 | function findBookById(object, args, context, info) { 201 | return Book.findOne(args.id); 202 | } 203 | ``` 204 | 205 | and GraphQL will just automatically resolve the relation promises for us 206 | and return the data. Seems great right? It's not. This introduces a massive N+1 207 | problem. Now every time you query a sub-relation, GraphQL will inadvertently 208 | perform another database query to load the lazy relation. At small scale this 209 | isn't a problem, but the more complex your schema becomes, the harder it will 210 | hit your performance. 211 | 212 | ## Solution 213 | 214 | This package offers a solution to take away all the worry of how you manage 215 | your entity relations in the resolvers. GraphQL provides a parameter in each 216 | resolver function called `info`. This `info` parameter contains the entire query 217 | graph, which means we can traverse it and figure out exactly which fields need to 218 | be selected, and which relations need to be loaded. This is used to 219 | create one SQL query that can get all the information at once. 220 | 221 | Because the loader uses the queryBuilder API, it does not matter if you have all 222 | "normal", "lazy", "eager" relations, or a mix of all of them. You give it your 223 | starting entity, and the GraphQL query info, and it will figure out what data you 224 | need and give it back to you in a structured TypeORM entity. Additionally, it 225 | provides some caching functionality as well, which will dedupe identical query 226 | signatures executed in the same tick. 227 | 228 | ## Acknowledgments 229 | 230 | This project inspired by the work of [Weboptimizer's typeorm-loader 231 | package](https://github.com/Webtomizer/typeorm-loader). I work quite a bit with 232 | Apollo Server + TypeORM and I was looking to find a way to more efficiently pull 233 | data via TypeORM for GraphQL via intelligently loading the needed relations for 234 | a given query. I stumbled across his package, which seemed to 235 | promise all the functionality, but it seemed to be in a broken/unmaintained 236 | state. After several months of no response from the author, and with significant 237 | bug fixes/features added in my fork, I decided to just make my own package. So 238 | thanks to Weboptimizer for doing a lot of the groundwork. Since then, I have 239 | almost completely rewritten the library to be a bit more maintainable and feature 240 | rich, but I would still like to acknowledge the inspiration his project gave me. 241 | -------------------------------------------------------------------------------- /md/pagination.md: -------------------------------------------------------------------------------- 1 | # Pagination Advice 2 | 3 | Pagination can be tricky, especially if you are using a package like this. 4 | 5 | I've thought about the best way to support pagination, and decided it would be best if I 6 | didn't lock other developers into my way of doing things. As such, I tried to keep the 7 | pagination helper methods in the loader as unopinionated as possible. 8 | It allows you to pass in an offset and limit to your paginated query and will return the 9 | limited set of records with the total count of records (the count ignores the limit). 10 | Under the hood, this uses TypeORM's `getManyAndCount()` method, so see their 11 | documentation for further information on that method's behavior in relation to 12 | pagination. My hope is that this will allow you to easily adapt your method of pagination 13 | to the loader without too much of a hassle. 14 | 15 | One other tricky part of using pagination when using the loader comes from the structure 16 | of GraphQL types. Typically when you have a paginated field in GraphQL, you provide a 17 | pagination wrapper around the actual type so you can get page data back from the query. 18 | For example, take this paginated user query: 19 | 20 | ```graphql 21 | type User { 22 | id: ID! 23 | name: String 24 | } 25 | 26 | type PaginatedUsers { 27 | users: [User] 28 | nextOffset: Int 29 | hasMore: Boolean 30 | totalCount: Int 31 | } 32 | 33 | type Query { 34 | paginatedUsers(offset: Int, limit: Int): PaginatedUsers 35 | } 36 | ``` 37 | and its resolver 38 | ```typescript 39 | async function paginatedUserResolver(obj, args, context, info) { 40 | const [records, totalCount] = await context 41 | .loader 42 | .loadEntity(User) 43 | .info(info) 44 | .pagination({offset: args.offset, limit: args.limit}) 45 | .loadManyPaginated() 46 | 47 | return { 48 | users: records, 49 | // this is not a good way to calculate the nextOffset or 50 | // hasMore, but you get the idea 51 | nextOffset: args.offset + args.limit, 52 | hasMore: args.offset + args.limit < totalCount, 53 | totalCount: totalCount 54 | } 55 | } 56 | ``` 57 | Seems pretty straightforward right? Well you are going to run into problems when trying 58 | to use the loader this way. When you pass `User` to `loadEntity`, the loader is going to 59 | assume that the root type of the request in the info object is a `User`, except it isn't, 60 | it is a `PaginatedUser`. This means that the loader will try to query for the `nextOffset`, 61 | `hasMore`, and `totalCount` fields on the user record, which of course don't exist. 62 | 63 | In order to get this to work, you need to tell the loader to ignore the root type of the 64 | query, and instead only try to resolve everything under the `users` field. To do this, 65 | we can pass the name of what we want the root resolve field to be when we call the `info()` 66 | method, like so: 67 | 68 | ```typescript 69 | async function paginatedUserResolver(obj, args, context, info) { 70 | const [records, totalCount] = await context 71 | .loader 72 | .loadEntity(User) 73 | .info(info, "users") 74 | .pagination({offset: args.offset, limit: args.limit}) 75 | .loadManyPaginated() 76 | 77 | return { 78 | users: records, 79 | // this is not a good way to calculate the nextOffset or 80 | // hasMore, but you get the idea 81 | nextOffset: args.offset + args.limit, 82 | hasMore: args.offset + args.limit < totalCount, 83 | totalCount: totalCount 84 | } 85 | } 86 | ``` 87 | 88 | The loader will then try and find the users field in the GraphQL Info object and 89 | use it as the root field to resolve things for. 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mando75/typeorm-graphql-loader", 3 | "version": "1.7.5", 4 | "description": "A dataloader which intelligently selects/joins the fields/relations from your TypeORM entities needed to resolve a GraphQL query", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "prebuild": "pnpm lint && rimraf -rf dist", 9 | "build": "tsc --declaration", 10 | "publish:docs": "typedoc --options typedoc.json", 11 | "lint": "eslint \"./src/**/*.ts\"", 12 | "lint:fix": "pnpm lint --fix", 13 | "test": "nyc mocha -r ts-node/register -r tslib -r source-map-support/register --full-trace src/__tests__/**/*.test.ts --timeout 5000" 14 | }, 15 | "files": [ 16 | "dist/*", 17 | "yarn.lock", 18 | ".gitignore", 19 | "!/dist/__tests__" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://gitlab.com/Mando75/typeorm-graphql-loader" 24 | }, 25 | "keywords": [ 26 | "typeorm", 27 | "database", 28 | "graphql", 29 | "data", 30 | "apollo", 31 | "typegraphql", 32 | "loader", 33 | "batching", 34 | "caching", 35 | "resolvers", 36 | "dataloader" 37 | ], 38 | "author": "Bryan Muller", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://gitlab.com/Mando75/typeorm-graphql-loader/issues" 42 | }, 43 | "homepage": "https://gql-loader.bmuller.net", 44 | "dependencies": { 45 | "graphql-parse-resolve-info": "^4.12.0" 46 | }, 47 | "devDependencies": { 48 | "@types/chai": "^4.3.0", 49 | "@types/chai-spies": "^1.0.3", 50 | "@types/chance": "^1.1.3", 51 | "@types/deep-equal": "^1.0.1", 52 | "@types/faker": "^5.5.9", 53 | "@types/mocha": "^9.0.0", 54 | "@types/node": "^17.0.8", 55 | "@types/object-path": "^0.11.1", 56 | "@types/validator": "^13.7.1", 57 | "@typescript-eslint/eslint-plugin": "^5.9.1", 58 | "@typescript-eslint/parser": "^5.9.1", 59 | "chai": "^4.3.4", 60 | "chai-spies": "^1.0.0", 61 | "chai-things": "^0.2.0", 62 | "chance": "^1.1.8", 63 | "class-validator": "^0.13.2", 64 | "deep-equal-in-any-order": "^1.1.15", 65 | "eslint": "^8.6.0", 66 | "eslint-plugin-prettier": "^4.0.0", 67 | "faker": "^5.5.3", 68 | "graphql": "^15.8.0", 69 | "mocha": "^9.1.4", 70 | "mocha-lcov-reporter": "^1.3.0", 71 | "nyc": "15.1.0", 72 | "prettier": "^2.5.1", 73 | "reflect-metadata": "^0.1.13", 74 | "rimraf": "^3.0.2", 75 | "source-map-support": "^0.5.21", 76 | "sqlite3": "^5.0.2", 77 | "ts-node": "^10.4.0", 78 | "tslib": "^2.3.1", 79 | "type-graphql": "^1.1.1", 80 | "typedoc": "^0.22.10", 81 | "typeorm": "^0.2.41", 82 | "typescript": "^4.5.4" 83 | }, 84 | "peerDependencies": { 85 | "graphql": ">=15.0.0", 86 | "typeorm": ">=0.2.8" 87 | }, 88 | "nyc": { 89 | "include": [ 90 | "src/**/*.ts" 91 | ], 92 | "extension": [ 93 | ".ts" 94 | ], 95 | "require": [ 96 | "ts-node/register", 97 | "tslib" 98 | ], 99 | "reporter": [ 100 | "lcov", 101 | "text" 102 | ], 103 | "sourceMap": true, 104 | "instrument": true 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/ConfigureLoader.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { 3 | FieldConfigurationPredicate, 4 | GraphQLEntityFields, 5 | LoaderFieldConfiguration, 6 | RequireOrIgnoreSettings, 7 | } from "./types"; 8 | 9 | /** 10 | * Internal keys for mapping entity metadata 11 | * @hidden 12 | */ 13 | const keys = { 14 | IGNORE_FIELD: Symbol("gqlLoader:ignoreField"), 15 | REQUIRED_FIELD: Symbol("gqlLoader:requiredField"), 16 | GRAPHQL_NAME: Symbol("gqlLoader:graphQLName"), 17 | SQL_JOIN_ALIAS: Symbol("gqlLoader:sqlJoinAlias"), 18 | }; 19 | 20 | /** 21 | * Default args 22 | * @hidden 23 | */ 24 | const defaultLoaderFieldConfiguration: LoaderFieldConfiguration = { 25 | ignore: false, 26 | required: false, 27 | }; 28 | 29 | /** 30 | * An experimental decorator that can be used 31 | * to annotate fields or relations in your TypeORM entities 32 | * and customize the loader resolution logic. 33 | * 34 | * The decorator implementation is still being developed 35 | * and the API may change in the future prior to a 2.0 release. 36 | * 37 | * @example 38 | * ```typescript 39 | * @Entity() 40 | * class Author extends BaseEntity { 41 | * 42 | * // This relation will never be fetched by the dataloader 43 | * @ConfigureLoader({ignore: true}) 44 | * @OneToMany() 45 | * books: [Book] 46 | * 47 | * // This relation will always be fetched by the dataloader 48 | * @ConfigureLoader({required: true}) 49 | * @OneToOne() 50 | * user: User 51 | * } 52 | * ``` 53 | * 54 | * @param options - See {@link LoaderFieldConfiguration} 55 | */ 56 | export const ConfigureLoader = (options: LoaderFieldConfiguration) => { 57 | const { required, ignore, graphQLName, sqlJoinAlias } = { 58 | ...defaultLoaderFieldConfiguration, 59 | ...options, 60 | }; 61 | 62 | return (target: any, propertyKey: string) => { 63 | const ignoreSettings: RequireOrIgnoreSettings = 64 | Reflect.getMetadata(keys.IGNORE_FIELD, target.constructor) ?? new Map(); 65 | ignoreSettings.set(propertyKey, ignore); 66 | Reflect.defineMetadata( 67 | keys.IGNORE_FIELD, 68 | ignoreSettings, 69 | target.constructor 70 | ); 71 | 72 | const requiredSettings: RequireOrIgnoreSettings = 73 | Reflect.getMetadata(keys.REQUIRED_FIELD, target.constructor) ?? new Map(); 74 | requiredSettings.set(propertyKey, required); 75 | Reflect.defineMetadata( 76 | keys.REQUIRED_FIELD, 77 | requiredSettings, 78 | target.constructor 79 | ); 80 | 81 | const graphQLFieldNames: Map = 82 | Reflect.getMetadata(keys.GRAPHQL_NAME, target.constructor) ?? new Map(); 83 | graphQLFieldNames.set(propertyKey, graphQLName ?? propertyKey); 84 | Reflect.defineMetadata( 85 | keys.GRAPHQL_NAME, 86 | graphQLFieldNames, 87 | target.constructor 88 | ); 89 | 90 | const sqlJoinAliases: Map< 91 | string, 92 | string | undefined 93 | > = 94 | Reflect.getMetadata(keys.SQL_JOIN_ALIAS, target.constructor) ?? new Map(); 95 | sqlJoinAliases.set(propertyKey, sqlJoinAlias); 96 | Reflect.defineMetadata( 97 | keys.SQL_JOIN_ALIAS, 98 | sqlJoinAliases, 99 | target.constructor 100 | ); 101 | }; 102 | }; 103 | 104 | /** 105 | * Fetch the required fields from entity metadata 106 | * @hidden 107 | * @param target 108 | */ 109 | export const getLoaderRequiredFields = (target: any): RequireOrIgnoreSettings => 110 | Reflect.getMetadata(keys.REQUIRED_FIELD, target) ?? new Map(); 111 | 112 | /** 113 | * Fetch the ignored fields from entity metadata 114 | * @hidden 115 | * @param target 116 | */ 117 | export const getLoaderIgnoredFields = (target: any): RequireOrIgnoreSettings => 118 | Reflect.getMetadata(keys.IGNORE_FIELD, target) ?? new Map(); 119 | 120 | /** 121 | * Returns mapping of TypeORM entity fields with their GraphQL schema names 122 | * @hidden 123 | * @param target 124 | */ 125 | export const getGraphQLFieldNames = (target: any): Map => 126 | Reflect.getMetadata(keys.GRAPHQL_NAME, target) ?? new Map(); 127 | 128 | /** 129 | * Determines if predicate needs to be called as a function and passes 130 | * the proper arguments if so 131 | * @hidden 132 | * @param predicate 133 | * @param context 134 | * @param selection 135 | */ 136 | export const resolvePredicate = ( 137 | predicate: boolean | FieldConfigurationPredicate | undefined, 138 | context: any, 139 | selection: GraphQLEntityFields | undefined 140 | ): boolean | undefined => 141 | typeof predicate === "function" 142 | ? predicate( 143 | context, 144 | Object.getOwnPropertyNames(selection ?? {}), 145 | selection ?? {} 146 | ) 147 | : predicate; 148 | 149 | /** 150 | * Get the user-defined table aliases for a given entity 151 | * @hidden 152 | * @param target 153 | */ 154 | export const getSQLJoinAliases = ( 155 | target: any 156 | ): Map => 157 | Reflect.getMetadata(keys.SQL_JOIN_ALIAS, target) ?? new Map(); 158 | 159 | -------------------------------------------------------------------------------- /src/GraphQLDatabaseLoader.ts: -------------------------------------------------------------------------------- 1 | import { LoaderOptions } from "./types"; 2 | import { BaseEntity, Connection } from "typeorm"; 3 | import { GraphQLQueryBuilder } from "./GraphQLQueryBuilder"; 4 | import { GraphQLQueryManager } from "./GraphQLQueryManager"; 5 | 6 | /** 7 | * This is the base class for the loader. An instance of this class should 8 | * be passed to your request context. It is advised that a new instance be created 9 | * for every request to prevent data leaking. 10 | * 11 | * @example 12 | * ```typescript 13 | * import { GraphQLDatabaseLoader } from '@mando75/typeorm-graphql-loader'; 14 | * const connection = createConnection({...}); // Create your TypeORM connection 15 | * 16 | * const apolloServer = new ApolloServer({ 17 | * schema, 18 | * context: { 19 | * loader: new GraphQLDatabaseLoader(connection, {/** additional options if needed**}) 20 | * }, 21 | * }); 22 | * ``` 23 | */ 24 | export class GraphQLDatabaseLoader { 25 | private readonly _queryManager: GraphQLQueryManager; 26 | 27 | /** 28 | * Creates a new GraphQLDatabaseLoader instance. Needs a TypeORM connection 29 | * in order to execute queries 30 | * 31 | * @param connection - A {@link https://typeorm.io/#/connection | TypeORM Database connection} 32 | * @param options - Configuration options that will be used by this loader instance 33 | */ 34 | constructor(connection: Connection, options: LoaderOptions = {}) { 35 | this._queryManager = new GraphQLQueryManager(connection, options); 36 | } 37 | 38 | /** 39 | * Specify a TypeORM entity that you would like to load. This method will 40 | * return a QueryBuilder for the entity you provide similar to how a TypeORM `createQueryBuilder` 41 | * method works 42 | * 43 | * @example 44 | * ```typescript 45 | * const userLoader = context.loader.loadEntity(User, "users") 46 | * ``` 47 | * 48 | * @param entity - The TypeORM entity you will be loading for this query. 49 | * @param alias - The alias you would like to give the select table 50 | */ 51 | public loadEntity( 52 | entity: T, 53 | alias?: string 54 | ): GraphQLQueryBuilder { 55 | return new GraphQLQueryBuilder(this._queryManager, entity, alias); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/GraphQLQueryBuilder.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLResolveInfo } from "graphql"; 2 | import { 3 | EjectQueryCallback, 4 | GraphQLEntityFields, 5 | QueryPagination, 6 | QueryPredicates, 7 | SearchOptions, 8 | WhereArgument, 9 | WhereExpression, 10 | } from "./types"; 11 | import { GraphQLQueryManager } from "./GraphQLQueryManager"; 12 | import { BaseEntity, ObjectLiteral, OrderByCondition } from "typeorm"; 13 | import { GraphQLInfoParser } from "./lib/GraphQLInfoParser"; 14 | 15 | export class GraphQLQueryBuilder { 16 | private _info: GraphQLEntityFields | null = null; 17 | private _andWhereExpressions: Array = []; 18 | private _orWhereExpressions: Array = []; 19 | private _searchExpressions: Array = []; 20 | private _order: OrderByCondition = {}; 21 | private _selectFields: Array> = []; 22 | private _pagination?: QueryPagination; 23 | private _parser: GraphQLInfoParser = new GraphQLInfoParser(); 24 | private _context: any; 25 | private _ejectQueryCallback: EjectQueryCallback | null = null; 26 | 27 | /** 28 | * Internal Constructor 29 | * @hidden 30 | * @param _manager 31 | * @param _entity 32 | * @param _alias 33 | */ 34 | constructor( 35 | private _manager: GraphQLQueryManager, 36 | private _entity: Function | string, 37 | private _alias?: string 38 | ) {} 39 | 40 | /** 41 | * Provide the query builder with the GraphQL Query info you would like to resolve 42 | * It is required to call this method before you can invoke any of the `load*` methods 43 | * 44 | * @example 45 | * ```typescript 46 | * function resolver(obj, args, context, info) { 47 | * return context 48 | * .loader 49 | * .loadEntity(User, "user") 50 | * // Pass the GraphQLResolveInfo to the loader 51 | * // before trying to load the data 52 | * .info(info) 53 | * .where("user.id = :id", {id: args.id}) 54 | * .loadOne() 55 | * } 56 | * ``` 57 | * 58 | * @param info - The GraphQLResolveInfo object your resolver function will receive 59 | * @param fieldName - The path to a child field you would like to resolve, e.g. `edges.node`. [Commonly used with pagination](https://gitlab.com/Mando75/typeorm-graphql-loader/-/blob/master/md/pagination.md) 60 | * @returns GraphQLQueryBuilder 61 | */ 62 | public info( 63 | info: GraphQLResolveInfo, 64 | fieldName?: string 65 | ): GraphQLQueryBuilder { 66 | this._info = this._parser.parseResolveInfoModels(info, fieldName); 67 | return this; 68 | } 69 | 70 | /** 71 | * Provide the query builder a where condition. Multiple conditions can be added 72 | * by re-invoking the method (they get added to a list). Any where conditions added 73 | * via this method will be grouped in an AND expression 74 | * 75 | * This method uses the TypeORM SelectQueryBuilder where syntax. For more information, 76 | * see the {@link https://typeorm.io/#/select-query-builder/adding-where-expression|TypeORM docs} 77 | * 78 | * @example 79 | * ```typescript 80 | * function resolver(obj, args, context, info) { 81 | * return context 82 | * .loader 83 | * .loadEntity(User, "users") 84 | * .info(info) 85 | * .where("users.id = :myId", {myId: args.id}) 86 | * .loadOne() 87 | * } 88 | *``` 89 | * @param where - the {@link WhereArgument} you would like applied to the query 90 | * @param params - An optional parameter you can use to bind values for your where condition. See {@link https://github.com/typeorm/typeorm/blob/master/docs/select-query-builder.md#adding-where-expression|TypeORM docs} 91 | * @returns GraphQLQueryBuilder 92 | */ 93 | public where( 94 | where: WhereArgument, 95 | params?: ObjectLiteral 96 | ): GraphQLQueryBuilder { 97 | if (typeof where === "string") { 98 | this._andWhereExpressions.push({ 99 | condition: where, 100 | params, 101 | }); 102 | } else { 103 | this._andWhereExpressions.push(where); 104 | } 105 | return this; 106 | } 107 | 108 | /** 109 | * Provide the query builder with an OR WHERE condition. Multiple conditions can be added 110 | * by re-invoking the method (they get added to a list). Any where conditions added via this 111 | * method will be grouped in an OR expression. Should only be used after an initial WHERE condition. 112 | * 113 | * Like the {@link GraphQLQueryBuilder.where|where} method, it uses the SelectQueryBuilder where syntax 114 | * 115 | * @example 116 | * ```typescript 117 | * loader 118 | * .loadEntity(Book, "books") 119 | * .info(info) 120 | * .where("books.authorId = :authorId", {authorId: args.authorId}) 121 | * .orWhere("books.isPublicDomain = :includePublicDomain", {includePublicDomain: args.publicDomain}) 122 | * .loadMany() 123 | *``` 124 | * @param where - the condition you would like applied to the query 125 | * @param params - An optional parameter you can use to bind values for your where condition. See {@link https://github.com/typeorm/typeorm/blob/master/docs/select-query-builder.md#adding-where-expression|TypeORM docs} 126 | * @returns GraphQLQueryBuilder 127 | */ 128 | public orWhere( 129 | where: WhereArgument, 130 | params?: ObjectLiteral 131 | ): GraphQLQueryBuilder { 132 | if (typeof where === "string") { 133 | this._orWhereExpressions.push({ condition: where, params }); 134 | } else { 135 | this._orWhereExpressions.push(where); 136 | } 137 | return this; 138 | } 139 | 140 | /** 141 | * Provides an easy way to perform searches without needing to manually write SQL WHERE 142 | * conditions for each search instance 143 | * 144 | * The method accepts a searchOptions parameter that allows 145 | * you to define which columns should be searched, the type 146 | * of search, and other SQL options. 147 | * 148 | * If you need search columns to be concatenated, place the 149 | * columns to combine in a sub-array as seen with the name 150 | * columns in the example below. These columns will be concatenated 151 | * together with an empty string separator in the SQL query 152 | * 153 | * @example 154 | * ```typescript 155 | * function resolver(obj, args, context, info) { 156 | * const searchOptions: SearchOptions = { 157 | * searchColumns: ['email', ['firstName', 'lastName']] 158 | * searchText: args.searchText 159 | * // optional 160 | * searchMethod: LoaderSearchMethod.STARTS_WITH 161 | * // optional 162 | * caseSensitive: false 163 | * } 164 | * 165 | * return context 166 | * .loader 167 | * .loadEntity(User) 168 | * .info(info) 169 | * .search(searchOptions) 170 | * .loadMany() 171 | * } 172 | * ``` 173 | * 174 | * 175 | * @param searchOptions - An {@link SearchOptions}that allows you to specify which columns should be searched and what to search for 176 | * @returns GraphQLQueryBuilder 177 | */ 178 | public search(searchOptions: SearchOptions): GraphQLQueryBuilder { 179 | this._searchExpressions.push(searchOptions); 180 | return this; 181 | } 182 | 183 | /** 184 | * Adds a SQL ORDER statement to the query. 185 | * See the {@link https://github.com/typeorm/typeorm/blob/master/docs/select-query-builder.md#adding-order-by-expression | TypeORM documentation for details} 186 | * If called multiple times, the Order By conditions will be merged 187 | * 188 | * @example 189 | * ```typescript 190 | * function resolver(obj, args, context, info) { 191 | * return context 192 | * .loader 193 | * .loadEntity(User, 'user') 194 | * .info(info) 195 | * .order({'user.createdAt': 'ASC'}) 196 | * .loadMany() 197 | * } 198 | * ``` 199 | * @param order 200 | * @returns GraphQLQueryBuilder 201 | */ 202 | public order(order: OrderByCondition): GraphQLQueryBuilder { 203 | this._order = { ...this._order, ...order }; 204 | return this; 205 | } 206 | 207 | /** 208 | * Manually specify fields you always want to be selected. This is 209 | * useful if you have custom resolvers for other fields on you GraphQL 210 | * type that may require data that isn't guaranteed to be in a query. 211 | * 212 | * @example 213 | * ```typescript 214 | * function resolver(obj, args, context, info) { 215 | * return context 216 | * .loader 217 | * .loadEntity(User, "user") 218 | * .info(info) 219 | * .where("user.id = :id", {id: args.id}) 220 | * // include the email and firstName fields 221 | * // regardless of whether or not they were included in the query 222 | * .selectFields(['email', 'firstName']) 223 | * .loadOne() 224 | * } 225 | * ``` 226 | * @param fields 227 | * @deprecated Use new `ConfigureLoader` decorator to require fields 228 | */ 229 | public selectFields(fields: string | Array): GraphQLQueryBuilder { 230 | this._selectFields.push(fields); 231 | return this; 232 | } 233 | 234 | /** 235 | * Allows you to paginate the query using offset and limit. 236 | * If used, should be paired with {@link GraphQLQueryBuilder.loadPaginated|loadPaginated}, the other 237 | * loaders will ignore these options. 238 | * 239 | * @example 240 | * ```typescript 241 | * function resolver(obj, args, context, info) { 242 | * const searchOptions: SearchOptions = { 243 | * searchColumns: ["email"], 244 | * searchText: args.searchText 245 | * } 246 | * 247 | * return context 248 | * .loader 249 | * .loadEntity(User) 250 | * .info(info) 251 | * .search(searchOptions) 252 | * .paginate({offset: args.offset, limit: args.limit}) 253 | * .loadPaginated() 254 | * } 255 | * ``` 256 | * @param pagination 257 | */ 258 | public paginate(pagination: QueryPagination): GraphQLQueryBuilder { 259 | this._pagination = pagination; 260 | return this; 261 | } 262 | 263 | /** 264 | * Allows you to pass a user defined context to the loader. This context will 265 | * be passed to the decorator predicates at resolve time. 266 | * 267 | * @example 268 | * ```typescript 269 | * function resolve(obj, args, context, info) { 270 | * return context 271 | * .loader 272 | * .loadEntity(User, "user") 273 | * .info(info) 274 | * .where("user.id = :id", { id: args.id }) 275 | * // this method accepts any value for the context 276 | * .context({ requireRelation: true, ignoreField: false }) 277 | * .loadOne() 278 | * } 279 | * ``` 280 | * 281 | * @param context 282 | */ 283 | public context(context: K): GraphQLQueryBuilder { 284 | this._context = context; 285 | return this; 286 | } 287 | 288 | /** 289 | * Receives a callback that can be used to modify the TypeORM SelectQueryBuilder instance 290 | * before the loader executes the database query. 291 | * 292 | * Please note that this callback is run AFTER the loader has already applied all provided conditions to the query builder 293 | * (where conditions, pagination, order, etc). Be aware, as changes you make to the ejected query builder could conflict 294 | * with loader applied settings. Some tips to avoid potential conflicts: 295 | * 296 | * - If you are using the eject callback to apply where conditions, move all of your where conditions to the callback. 297 | * This will prevent potential conflicts between where conditions applied via the loader wrapper being overwritten 298 | * by conditions applied via the eject callback. Keep all your where conditions in one place. 299 | * 300 | * - For most cases, if you plan on joining tables in the callback, be sure to [join WITHOUT selecting](https://typeorm.io/#/select-query-builder/join-without-selection). 301 | * This is to prevent issues with selecting data from the same table twice, and is generally more performant. 302 | * If you are wanting a relation or column to always be joined and selected, see the {@link ConfigureLoader} Decorator 303 | * 304 | * @example 305 | * ```typescript 306 | * function resolve(obj, args, context, info) { 307 | * return context 308 | * .loader 309 | * .loadEntity(User, "user") 310 | * .info(info) 311 | * .ejectQueryBuilder(qb => { 312 | * return qb.innerJoin("user.group", "group") 313 | * .where("group.name = :groupName", { groupName: args.groupName }) 314 | * }) 315 | * .loadMany() 316 | * } 317 | * ``` 318 | * 319 | * @param cb 320 | */ 321 | public ejectQueryBuilder(cb: EjectQueryCallback): GraphQLQueryBuilder { 322 | this._ejectQueryCallback = cb; 323 | return this; 324 | } 325 | 326 | /** 327 | * Load one record from the database. 328 | * This record will include all relations and fields requested 329 | * in the GraphQL query that exist on the TypeORM entities. 330 | * If you have not provided the query builder 331 | * with an info object, this method will raise an error 332 | * 333 | * @example 334 | * ```typescript 335 | * function resolve(obj, args, context, info) { 336 | * return context 337 | * .loader 338 | * .loadEntity(User, "user") 339 | * .info(info) 340 | * .where("user.id = :id", {id: args.id}) 341 | * .loadOne() 342 | * } 343 | * ``` 344 | * @throws Error Missing info argument 345 | */ 346 | public async loadOne(): Promise | undefined> { 347 | return this._genericLoad(false, false); 348 | } 349 | 350 | /** 351 | * Load multiple records from the database. 352 | * These records will include all the relations and fields 353 | * requested in the GraphQL query that exist on the TypeORM entities. 354 | * If you have not provided the query builder with an info object, 355 | * this method will raise an error. 356 | * @example 357 | * ```typescript 358 | * function resolve(obj, args, context, info) { 359 | * return context 360 | * .loader 361 | * .loadEntity(User, "user") 362 | * .info(info) 363 | * .where("user.id = :id", {id: args.id}) 364 | * .loadOne() 365 | * } 366 | * ``` 367 | * @throws Error Missing info argument 368 | */ 369 | public async loadMany(): Promise[]> { 370 | return this._genericLoad(true, false); 371 | } 372 | 373 | /** 374 | * Loads a paginated set of records via offset and limit (see {@link GraphQLQueryBuilder.paginate}) 375 | * Will return the set of records and overall record count (ignores limit). This count can be 376 | * used to build the next offset. Please note that the loader will not do any offset/limit calculations 377 | * for you, it will only apply the values you give it. 378 | * See [Pagination Advice](https://gitlab.com/Mando75/typeorm-graphql-loader/-/blob/master/md/pagination.md) for more info 379 | */ 380 | public async loadPaginated(): Promise<[InstanceType[], number]> { 381 | if (!this._pagination) { 382 | throw new Error( 383 | "Must provide pagination object before calling load paginated" 384 | ); 385 | } 386 | return this._genericLoad(true, true); 387 | } 388 | 389 | /** 390 | * A generic loader to handle duplicate logic 391 | * from all the load methods. 392 | * Makes sure all the options are passed to the manager 393 | * @param many 394 | * @param paginate 395 | */ 396 | private async _genericLoad( 397 | many: U, 398 | paginate: V 399 | ): Promise< 400 | V extends true 401 | ? [InstanceType[], number] 402 | : U extends true 403 | ? InstanceType[] 404 | : InstanceType | undefined 405 | > { 406 | // we need to validate an info object 407 | this._validateInfo(this._info); 408 | // Check if this query is already in the cache 409 | 410 | const cacheAlias = 411 | this._alias ?? 412 | (typeof this._entity === "function" ? this._entity.name : this._entity); 413 | 414 | const { fields, found, key, item } = this._manager.processQueryMeta( 415 | this._info, 416 | this._andWhereExpressions, 417 | cacheAlias 418 | ); 419 | 420 | // if it is we can just return it 421 | if (found && item) { 422 | return item; 423 | } 424 | 425 | // Otherwise build an executor and and add it to the cache 426 | const executor = ( 427 | resolve: (value?: any) => void, 428 | reject: (reason?: any) => void 429 | ) => { 430 | this._manager.addQueueItem({ 431 | many, 432 | key, 433 | fields, 434 | predicates: this._getQueryPredicates(), 435 | resolve, 436 | reject, 437 | entity: this._entity, 438 | pagination: paginate ? this._pagination : undefined, 439 | alias: this._alias, 440 | context: this._context, 441 | ejectQueryCallback: this._ejectQueryCallback ?? ((qb) => qb), 442 | }); 443 | }; 444 | 445 | const promise = new Promise(executor); 446 | 447 | this._manager.addCacheItem(key, promise); 448 | return promise; 449 | } 450 | 451 | /** 452 | * Throw an error if the info object has not been defined for this query 453 | */ 454 | private _validateInfo( 455 | info?: GraphQLEntityFields | null 456 | ): asserts info is GraphQLEntityFields { 457 | if (!this._info) { 458 | throw new Error( 459 | "Missing GraphQL Resolve info. Please invoke `.info()` before calling this method" 460 | ); 461 | } 462 | } 463 | 464 | /** 465 | * Takes all the query options and bundles them 466 | * together into a single object 467 | * @private 468 | */ 469 | private _getQueryPredicates(): QueryPredicates { 470 | return { 471 | search: this._searchExpressions, 472 | andWhere: this._andWhereExpressions, 473 | orWhere: this._orWhereExpressions, 474 | order: this._order, 475 | selectFields: this._selectFields.flat(), 476 | }; 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /src/GraphQLQueryManager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLEntityFields, 3 | LoaderOptions, 4 | QueryMeta, 5 | QueryPagination, 6 | QueueItem, 7 | SearchOptions, 8 | WhereExpression, 9 | } from "./types"; 10 | import { LoaderSearchMethod } from "./enums/LoaderSearchMethod"; 11 | import * as crypto from "crypto"; 12 | import { 13 | Brackets, 14 | Connection, 15 | EntityManager, 16 | OrderByCondition, 17 | SelectQueryBuilder, 18 | } from "typeorm"; 19 | import { GraphQLQueryResolver } from "./GraphQLQueryResolver"; 20 | import { Formatter } from "./lib/Formatter"; 21 | import { LoaderNamingStrategyEnum } from "./enums/LoaderNamingStrategy"; 22 | 23 | /** 24 | * The query manager for the loader. Is an internal class 25 | * that should not be used by anything except the loader 26 | * @hidden 27 | */ 28 | export class GraphQLQueryManager { 29 | private _queue: QueueItem[] = []; 30 | private _cache: Map> = new Map(); 31 | private _immediate?: NodeJS.Immediate; 32 | private readonly _defaultLoaderSearchMethod: LoaderSearchMethod; 33 | private _resolver: GraphQLQueryResolver; 34 | private _formatter: Formatter; 35 | 36 | constructor(private _connection: Connection, options: LoaderOptions = {}) { 37 | const { defaultSearchMethod } = options; 38 | this._defaultLoaderSearchMethod = 39 | defaultSearchMethod ?? LoaderSearchMethod.ANY_POSITION; 40 | 41 | this._resolver = new GraphQLQueryResolver(options); 42 | this._formatter = new Formatter( 43 | options.namingStrategy ?? LoaderNamingStrategyEnum.CAMELCASE 44 | ); 45 | } 46 | 47 | /** 48 | * Helper method to generate a TypeORM query builder 49 | * @param entityManager 50 | * @param name 51 | * @param alias 52 | */ 53 | private static createTypeORMQueryBuilder( 54 | entityManager: EntityManager, 55 | name: string, 56 | alias: string 57 | ): SelectQueryBuilder<{}> { 58 | return entityManager 59 | .getRepository<{}>(name) 60 | .createQueryBuilder(alias) 61 | .select([]); 62 | } 63 | 64 | /** 65 | * Takes a condition and formats into a type that TypeORM can 66 | * read 67 | * @param where 68 | * @private 69 | */ 70 | private static _breakDownWhereExpression(where: WhereExpression) { 71 | if (where instanceof Brackets) { 72 | return { where: where, params: undefined }; 73 | } else { 74 | const asExpression = where; 75 | return { where: asExpression.condition, params: asExpression.params }; 76 | } 77 | } 78 | 79 | /** 80 | * Looks up a query in the cache and returns 81 | * the existing promise if found. 82 | * @param fields 83 | * @param where 84 | * @param alias 85 | */ 86 | public processQueryMeta( 87 | fields: GraphQLEntityFields | null, 88 | where: Array, 89 | alias: string 90 | ): QueryMeta { 91 | // Create a new md5 hash function 92 | const hash = crypto.createHash("md5"); 93 | 94 | // Use the query parameters to generate a new hash for caching 95 | const key = hash 96 | .update(JSON.stringify([where, fields, alias])) 97 | .digest() 98 | .toString("hex"); 99 | 100 | // If this key already exists in the cache, just return the found value 101 | if (this._cache.has(key)) { 102 | return { 103 | fields, 104 | key: "", 105 | item: this._cache.get(key), 106 | found: true, 107 | }; 108 | } 109 | 110 | // Cancel any scheduled immediates so we can add more 111 | // items to the queue 112 | if (this._immediate) { 113 | clearImmediate(this._immediate); 114 | } 115 | 116 | // return the new cache key 117 | return { 118 | fields, 119 | key, 120 | found: false, 121 | }; 122 | } 123 | 124 | /** 125 | * Pushes a new item to the queue and sets a new immediate 126 | * @param item 127 | */ 128 | public addQueueItem(item: QueueItem) { 129 | this._queue.push(item); 130 | this._setImmediate(); 131 | } 132 | 133 | /** 134 | * Adds a new promise to the cache. It can now be looked up by processQueryMeta 135 | * if an identical request comes through 136 | * @param key 137 | * @param value 138 | */ 139 | public addCacheItem(key: string, value: Promise) { 140 | this._cache.set(key, value); 141 | } 142 | 143 | /** 144 | * Helper to set an immediate that will process the queue 145 | * @private 146 | */ 147 | private _setImmediate() { 148 | this._immediate = setImmediate(() => this._processQueue()); 149 | } 150 | 151 | /** 152 | * Where the magic happens 153 | * Goes through the queue and resoles each query 154 | * @private 155 | */ 156 | private async _processQueue(): Promise { 157 | // Clear and capture the current queue 158 | const queue = this._queue.splice(0, this._queue.length); 159 | const queryRunner = this._connection.createQueryRunner("slave"); 160 | 161 | try { 162 | await queryRunner.connect(); 163 | await Promise.all(queue.map(this._resolveQueueItem(queryRunner.manager))); 164 | } catch (e) { 165 | queue.forEach((q) => { 166 | q.reject(e); 167 | this._cache.delete(q.key); 168 | }); 169 | } 170 | 171 | await queryRunner.release(); 172 | } 173 | 174 | /** 175 | * Returns a closure that will be used to resolve 176 | * a query from the cache 177 | * @param entityManager 178 | */ 179 | private _resolveQueueItem(entityManager: EntityManager) { 180 | return async (item: QueueItem) => { 181 | const name = 182 | typeof item.entity == "string" ? item.entity : item.entity.name; 183 | 184 | const alias = item.alias ?? name; 185 | 186 | let queryBuilder: SelectQueryBuilder<{}> = GraphQLQueryManager.createTypeORMQueryBuilder( 187 | entityManager, 188 | name, 189 | alias 190 | ); 191 | queryBuilder = this._resolver.createQuery( 192 | name, 193 | item.fields, 194 | entityManager.connection, 195 | queryBuilder, 196 | alias, 197 | item.context 198 | ); 199 | queryBuilder = this._addSelectFields( 200 | queryBuilder, 201 | alias, 202 | item.predicates.selectFields 203 | ); 204 | queryBuilder = this._addAndWhereConditions( 205 | queryBuilder, 206 | item.predicates.andWhere 207 | ); 208 | queryBuilder = this._addOrWhereConditions( 209 | queryBuilder, 210 | item.predicates.orWhere 211 | ); 212 | queryBuilder = this._addSearchConditions( 213 | queryBuilder, 214 | alias, 215 | item.predicates.search 216 | ); 217 | queryBuilder = this._addOrderByCondition( 218 | queryBuilder, 219 | item.predicates.order 220 | ); 221 | 222 | queryBuilder = this._addPagination(queryBuilder, item.pagination); 223 | 224 | queryBuilder = item.ejectQueryCallback(queryBuilder); 225 | 226 | let promise; 227 | if (item.pagination) { 228 | promise = queryBuilder.getManyAndCount(); 229 | } else if (item.many) { 230 | promise = queryBuilder.getMany(); 231 | } else { 232 | promise = queryBuilder.getOne(); 233 | } 234 | return promise 235 | .then(item.resolve, item.reject) 236 | .finally(() => this._cache.delete(item.key)); 237 | }; 238 | } 239 | 240 | /** 241 | * Given a set of conditions, ANDs them onto the SQL WHERE expression 242 | * via the TypeORM QueryBuilder. 243 | * Will handle the initial where statement as per TypeORM style 244 | * @param qb 245 | * @param conditions 246 | * @private 247 | */ 248 | private _addAndWhereConditions( 249 | qb: SelectQueryBuilder<{}>, 250 | conditions: Array 251 | ): SelectQueryBuilder<{}> { 252 | const initialWhere = conditions.shift(); 253 | if (!initialWhere) return qb; 254 | 255 | const { where, params } = GraphQLQueryManager._breakDownWhereExpression( 256 | initialWhere 257 | ); 258 | qb = qb.where(where, params); 259 | 260 | conditions.forEach((condition) => { 261 | const { where, params } = GraphQLQueryManager._breakDownWhereExpression( 262 | condition 263 | ); 264 | qb = qb.andWhere(where, params); 265 | }); 266 | return qb; 267 | } 268 | 269 | /** 270 | * Given a set of conditions, ORs them onto the SQL WHERE expression 271 | * via the TypeORM QueryBuilder 272 | * @param qb 273 | * @param conditions 274 | * @private 275 | */ 276 | private _addOrWhereConditions( 277 | qb: SelectQueryBuilder<{}>, 278 | conditions: Array 279 | ): SelectQueryBuilder<{}> { 280 | conditions.forEach((condition) => { 281 | const { where, params } = GraphQLQueryManager._breakDownWhereExpression( 282 | condition 283 | ); 284 | qb = qb.orWhere(where, params); 285 | }); 286 | return qb; 287 | } 288 | 289 | /** 290 | * Given a list of search conditions, adds them to the query builder. 291 | * If multiple sets of search conditions are passed, the will be ANDed together 292 | * @param qb 293 | * @param alias 294 | * @param searchConditions 295 | * @private 296 | */ 297 | private _addSearchConditions( 298 | qb: SelectQueryBuilder<{}>, 299 | alias: string, 300 | searchConditions: Array 301 | ): SelectQueryBuilder<{}> { 302 | // Add an andWhere for each formatted search condition 303 | this._formatSearchConditions(searchConditions, alias).forEach( 304 | ({ query, params }) => { 305 | qb = qb.andWhere(query, params); 306 | } 307 | ); 308 | return qb; 309 | } 310 | 311 | /** 312 | * Maps over a list of given search conditions and formats them into 313 | * a query and param object to be added to a query builder. 314 | * @param conditions 315 | * @param alias 316 | * @private 317 | */ 318 | private _formatSearchConditions( 319 | conditions: Array, 320 | alias: string 321 | ) { 322 | return conditions.map( 323 | ({ searchColumns, searchMethod, searchText, caseSensitive }) => { 324 | // Determine which search method we should use (can be customized per request) 325 | const method = searchMethod || this._defaultLoaderSearchMethod; 326 | // Generates a list of 'column LIKE :searchText' in accordance with the 327 | // SearchOptions type definition 328 | const likeQueryStrings = this._formatter.formatSearchColumns( 329 | searchColumns, 330 | alias, 331 | caseSensitive 332 | ); 333 | // Depending on our search method, we need to place our wild card 334 | // in a different part of the string. This handles that. 335 | const searchTextParam = this._formatter.getSearchMethodMapping( 336 | method, 337 | searchText 338 | ); 339 | // Returns this structure so they can be safely added 340 | // to the query builder without providing for SQL injection 341 | return { 342 | query: `(${likeQueryStrings.join(" OR ")})`, 343 | params: { searchText: searchTextParam }, 344 | }; 345 | } 346 | ); 347 | } 348 | 349 | /** 350 | * Adds pagination to the query builder 351 | * @param queryBuilder 352 | * @param pagination 353 | * @private 354 | */ 355 | private _addPagination( 356 | queryBuilder: SelectQueryBuilder<{}>, 357 | pagination: QueryPagination | undefined 358 | ): SelectQueryBuilder<{}> { 359 | if (pagination) { 360 | queryBuilder = queryBuilder.offset(pagination.offset); 361 | queryBuilder = queryBuilder.limit(pagination.limit); 362 | } 363 | return queryBuilder; 364 | } 365 | 366 | /** 367 | * Adds OrderBy condition to the query builder 368 | * @param queryBuilder 369 | * @param order 370 | * @private 371 | */ 372 | private _addOrderByCondition( 373 | queryBuilder: SelectQueryBuilder<{}>, 374 | order: OrderByCondition 375 | ): SelectQueryBuilder<{}> { 376 | return queryBuilder.orderBy(order); 377 | } 378 | 379 | /** 380 | * makes sure given fields are selected 381 | * by the query builder 382 | * @param queryBuilder 383 | * @param alias 384 | * @param selectFields 385 | * @private 386 | */ 387 | private _addSelectFields( 388 | queryBuilder: SelectQueryBuilder<{}>, 389 | alias: string, 390 | selectFields: Array 391 | ): SelectQueryBuilder<{}> { 392 | selectFields.forEach((field) => { 393 | queryBuilder = queryBuilder.addSelect( 394 | this._formatter.columnSelection(alias, field), 395 | this._formatter.aliasField(alias, field) 396 | ); 397 | }); 398 | return queryBuilder; 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /src/GraphQLQueryResolver.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLEntityFields, LoaderOptions } from "./types"; 2 | import { LoaderNamingStrategyEnum } from "./enums/LoaderNamingStrategy"; 3 | import { Connection, EntityMetadata, SelectQueryBuilder } from "typeorm"; 4 | import { Formatter } from "./lib/Formatter"; 5 | import { ColumnMetadata } from "typeorm/metadata/ColumnMetadata"; 6 | import { EmbeddedMetadata } from "typeorm/metadata/EmbeddedMetadata"; 7 | import { 8 | getGraphQLFieldNames, 9 | getLoaderIgnoredFields, 10 | getLoaderRequiredFields, 11 | getSQLJoinAliases, 12 | resolvePredicate, 13 | } from "./ConfigureLoader"; 14 | import * as crypto from "crypto"; 15 | import { 16 | requestedEmbeddedFieldsFilter, 17 | requestedFieldsFilter, 18 | requestedRelationFilter, 19 | } from "./lib/filters"; 20 | 21 | /** 22 | * Internal only class 23 | * Used for recursively traversing the GraphQL request and adding 24 | * the required selects and joins 25 | * @hidden 26 | */ 27 | export class GraphQLQueryResolver { 28 | private readonly _primaryKeyColumn?: string; 29 | private readonly _namingStrategy: LoaderNamingStrategyEnum; 30 | private _formatter: Formatter; 31 | private readonly _maxDepth: number; 32 | 33 | constructor({ 34 | primaryKeyColumn, 35 | namingStrategy, 36 | maxQueryDepth, 37 | }: LoaderOptions) { 38 | this._namingStrategy = namingStrategy ?? LoaderNamingStrategyEnum.CAMELCASE; 39 | this._primaryKeyColumn = primaryKeyColumn; 40 | this._formatter = new Formatter(this._namingStrategy); 41 | this._maxDepth = maxQueryDepth ?? Infinity; 42 | } 43 | 44 | private static _generateChildHash( 45 | alias: string, 46 | propertyName: string, 47 | length = 0 48 | ): string { 49 | const hash = crypto.createHash("md5"); 50 | hash.update(`${alias}__${propertyName}`); 51 | 52 | const output = hash.digest("hex"); 53 | 54 | if (length != 0) { 55 | return output.slice(0, length); 56 | } 57 | 58 | return output; 59 | } 60 | 61 | /** 62 | * Given a model and queryBuilder, will add the selected fields and 63 | * relations required by a graphql field selection 64 | * @param model 65 | * @param selection 66 | * @param connection 67 | * @param queryBuilder 68 | * @param alias 69 | * @param context 70 | * @param depth 71 | */ 72 | public createQuery( 73 | model: Function | string, 74 | selection: GraphQLEntityFields | null, 75 | connection: Connection, 76 | queryBuilder: SelectQueryBuilder<{}>, 77 | alias: string, 78 | context: any, 79 | depth = 0 80 | ): SelectQueryBuilder<{}> { 81 | const meta = connection.getMetadata(model); 82 | if (selection) { 83 | queryBuilder = this._selectFields( 84 | queryBuilder, 85 | selection, 86 | meta, 87 | alias, 88 | context 89 | ); 90 | 91 | queryBuilder = this._selectEmbeddedFields( 92 | queryBuilder, 93 | selection, 94 | meta, 95 | alias, 96 | context 97 | ); 98 | 99 | queryBuilder = this._selectRequiredFields( 100 | queryBuilder, 101 | selection, 102 | alias, 103 | meta, 104 | context 105 | ); 106 | 107 | if (depth < this._maxDepth) { 108 | queryBuilder = this._selectRelations( 109 | queryBuilder, 110 | selection, 111 | meta, 112 | alias, 113 | context, 114 | connection, 115 | depth 116 | ); 117 | } 118 | } 119 | return queryBuilder; 120 | } 121 | 122 | /** 123 | * Given a list of EmbeddedField metadata and the current selection set, 124 | * will find any GraphQL fields that map to embedded entities on the current 125 | * TypeORM model and add them to the SelectQuery 126 | * @param queryBuilder 127 | * @param selection 128 | * @param meta 129 | * @param alias 130 | * @param context 131 | * @private 132 | */ 133 | private _selectEmbeddedFields( 134 | queryBuilder: SelectQueryBuilder<{}>, 135 | selection: GraphQLEntityFields, 136 | meta: EntityMetadata, 137 | alias: string, 138 | context: any 139 | ) { 140 | const graphQLFieldNames = getGraphQLFieldNames(meta.target); 141 | const ignoredFields = getLoaderIgnoredFields(meta.target); 142 | 143 | const embeddedFieldsToSelect: Array> = []; 144 | meta.embeddeds 145 | .filter( 146 | requestedEmbeddedFieldsFilter( 147 | ignoredFields, 148 | graphQLFieldNames, 149 | selection, 150 | context 151 | ) 152 | ) 153 | .forEach((field) => { 154 | // This is the name of the embedded entity on the TypeORM model 155 | const embeddedFieldName = 156 | graphQLFieldNames.get(field.propertyName) ?? field.propertyName; 157 | 158 | if (selection.hasOwnProperty(embeddedFieldName)) { 159 | const embeddedSelection = selection[embeddedFieldName]; 160 | // Extract the column names from the embedded field 161 | // so we can compare it to what was requested in the GraphQL query 162 | const embeddedFieldColumnNames = field.columns.map( 163 | (column) => column.propertyName 164 | ); 165 | // Filter out any columns that weren't requested in GQL 166 | // and format them in a way that TypeORM can understand. 167 | // The query builder api requires we query like so: 168 | // .addSelect('table.embeddedField.embeddedColumn') 169 | embeddedFieldsToSelect.push( 170 | embeddedFieldColumnNames 171 | .filter((columnName) => { 172 | const embeddedGraphQLFieldNames = getGraphQLFieldNames( 173 | field.type 174 | ); 175 | const graphQLName = 176 | embeddedGraphQLFieldNames.get(columnName) ?? columnName; 177 | return embeddedSelection.children.hasOwnProperty(graphQLName); 178 | }) 179 | .map((columnName) => `${field.propertyName}.${columnName}`) 180 | ); 181 | } 182 | }); 183 | 184 | // Now add each embedded select statement on to the query builder 185 | embeddedFieldsToSelect.flat().forEach((field) => { 186 | queryBuilder = queryBuilder.addSelect( 187 | this._formatter.columnSelection(alias, field) 188 | ); 189 | }); 190 | return queryBuilder; 191 | } 192 | 193 | /** 194 | * Given a set of fields, adds them as a select to the 195 | * query builder if they exist on the entity. 196 | * @param queryBuilder 197 | * @param selection 198 | * @param meta 199 | * @param alias 200 | * @param context 201 | * @private 202 | */ 203 | private _selectFields( 204 | queryBuilder: SelectQueryBuilder<{}>, 205 | selection: GraphQLEntityFields, 206 | meta: EntityMetadata, 207 | alias: string, 208 | context: any 209 | ): SelectQueryBuilder<{}> { 210 | const ignoredFields = getLoaderIgnoredFields(meta.target); 211 | const graphQLFieldNames = getGraphQLFieldNames(meta.target); 212 | 213 | const requestedFields = meta.columns.filter( 214 | requestedFieldsFilter( 215 | ignoredFields, 216 | graphQLFieldNames, 217 | selection, 218 | context 219 | ) 220 | ); 221 | 222 | // TODO Remove in 2.0 223 | // Ensure we select the primary key column 224 | queryBuilder = this._selectPrimaryKey(queryBuilder, requestedFields, alias); 225 | 226 | // Add a select for each field that was requested in the query 227 | requestedFields.forEach((field) => { 228 | // Make sure we account for embedded types 229 | const propertyName: string = field.propertyName; 230 | const databaseName: string = field.databaseName; 231 | queryBuilder = queryBuilder.addSelect( 232 | this._formatter.columnSelection(alias, propertyName), 233 | this._formatter.aliasField(alias, databaseName) 234 | ); 235 | }); 236 | return queryBuilder; 237 | } 238 | 239 | /** 240 | * Ensures that the primary key of each entity is selected. 241 | * This is to ensure that joins work properly 242 | * @param qb 243 | * @param fields 244 | * @param alias 245 | * @private 246 | * @deprecated The loader now uses the entity metadata to grab the primary key 247 | */ 248 | private _selectPrimaryKey( 249 | qb: SelectQueryBuilder<{}>, 250 | fields: Array, 251 | alias: string 252 | ): SelectQueryBuilder<{}> { 253 | /** 254 | * The query builder will automatically include the primary key column 255 | * in it's selection. To avoid a breaking change, we'll still select a column 256 | * if the user provides it, but this will be removed in the next major version. 257 | */ 258 | if (!this._primaryKeyColumn) { 259 | return qb; 260 | } 261 | 262 | // Did they already include the primary key column in their query? 263 | const queriedPrimaryKey = fields.find( 264 | (field) => field.propertyName === this._primaryKeyColumn 265 | ); 266 | 267 | // This will have already been selected 268 | if (queriedPrimaryKey?.isPrimary) { 269 | return qb; 270 | } 271 | 272 | if (!queriedPrimaryKey) { 273 | // if not, add it so joins don't break 274 | return qb.addSelect( 275 | this._formatter.columnSelection(alias, this._primaryKeyColumn), 276 | this._formatter.aliasField(alias, this._primaryKeyColumn) 277 | ); 278 | } else { 279 | return qb; 280 | } 281 | } 282 | 283 | /** 284 | * Joins any relations required to resolve the GraphQL selection. 285 | * will recursively call createQuery for each relation joined with 286 | * the subselection of fields required for that branch of the request. 287 | * @param queryBuilder 288 | * @param selection 289 | * @param alias 290 | * @param context 291 | * @param meta 292 | * @param connection 293 | * @param depth 294 | * @private 295 | */ 296 | private _selectRelations( 297 | queryBuilder: SelectQueryBuilder<{}>, 298 | selection: GraphQLEntityFields, 299 | meta: EntityMetadata, 300 | alias: string, 301 | context: any, 302 | connection: Connection, 303 | depth: number 304 | ): SelectQueryBuilder<{}> { 305 | const relations = meta.relations; 306 | const ignoredFields = getLoaderIgnoredFields(meta.target); 307 | const requiredFields = getLoaderRequiredFields(meta.target); 308 | const graphQLFieldNames = getGraphQLFieldNames(meta.target); 309 | const sqlJoinAliases = getSQLJoinAliases(meta.target); 310 | 311 | relations 312 | .filter( 313 | requestedRelationFilter( 314 | ignoredFields, 315 | requiredFields, 316 | graphQLFieldNames, 317 | selection, 318 | context 319 | ) 320 | ) 321 | .forEach((relation) => { 322 | // Join each relation that was queried 323 | const relationGraphQLName = 324 | graphQLFieldNames.get(relation.propertyName) ?? relation.propertyName; 325 | 326 | const childAlias = 327 | // Check if custom alias was given 328 | sqlJoinAliases.get(relation.propertyName) ?? 329 | // fallback to auto generated hash 330 | GraphQLQueryResolver._generateChildHash( 331 | alias, 332 | relation.propertyName, 333 | 10 334 | ); 335 | 336 | if ( 337 | resolvePredicate( 338 | requiredFields.get(relation.propertyName), 339 | context, 340 | selection 341 | ) 342 | ) { 343 | queryBuilder = queryBuilder.leftJoinAndSelect( 344 | this._formatter.columnSelection(alias, relation.propertyName), 345 | childAlias 346 | ); 347 | } else { 348 | // Join, but don't select the full relation 349 | queryBuilder = queryBuilder.leftJoin( 350 | this._formatter.columnSelection(alias, relation.propertyName), 351 | childAlias 352 | ); 353 | } 354 | // Recursively call createQuery to select and join any subfields 355 | // from this relation 356 | queryBuilder = this.createQuery( 357 | relation.inverseEntityMetadata.target, 358 | selection[relationGraphQLName]?.children, 359 | connection, 360 | queryBuilder, 361 | childAlias, 362 | context, 363 | depth + 1 364 | ); 365 | }); 366 | return queryBuilder; 367 | } 368 | 369 | /** 370 | * Attaches fields marked as required via the ConfigureLoader decorator 371 | * to the query builder. 372 | * @param queryBuilder 373 | * @param children 374 | * @param alias 375 | * @param meta 376 | * @param context 377 | * @private 378 | */ 379 | private _selectRequiredFields( 380 | queryBuilder: SelectQueryBuilder<{}>, 381 | children: GraphQLEntityFields, 382 | alias: string, 383 | meta: EntityMetadata, 384 | context: any 385 | ): SelectQueryBuilder<{}> { 386 | const requiredFields = getLoaderRequiredFields(meta.target); 387 | 388 | // We will use columns to attach properties and relations 389 | const columns = meta.columns.filter((col) => { 390 | const predicate = requiredFields.get(col.propertyName); 391 | return ( 392 | !col.relationMetadata && resolvePredicate(predicate, context, children) 393 | ); 394 | }); 395 | 396 | // Used to attach embedded columns 397 | const embeds = meta.embeddeds.filter((embed) => { 398 | const predicate = requiredFields.get(embed.propertyName); 399 | return resolvePredicate(predicate, context, children); 400 | }); 401 | 402 | queryBuilder = this._selectRequiredColumns(queryBuilder, columns, alias); 403 | queryBuilder = this._selectRequiredEmbeds(queryBuilder, embeds, alias); 404 | return queryBuilder; 405 | } 406 | 407 | /** 408 | * Selects columns depending on their column type. 409 | * Properties are selected, relations are joined. 410 | * @param queryBuilder 411 | * @param columns 412 | * @param alias 413 | * @private 414 | */ 415 | private _selectRequiredColumns( 416 | queryBuilder: SelectQueryBuilder<{}>, 417 | columns: Array, 418 | alias: string 419 | ): SelectQueryBuilder<{}> { 420 | columns.forEach((col) => { 421 | const { propertyName, databaseName } = col; 422 | // If relation metadata is present, this is a joinable column 423 | if (!col.relationMetadata) { 424 | // Otherwise we can assume this column is property and safe to select 425 | queryBuilder = queryBuilder.addSelect( 426 | this._formatter.columnSelection(alias, propertyName), 427 | this._formatter.aliasField(alias, databaseName) 428 | ); 429 | } 430 | }); 431 | return queryBuilder; 432 | } 433 | 434 | /** 435 | * Select the required embeds. Embedded entities are a bit clunky to work 436 | * with inside the query builder API, so we handle that junk here. 437 | * @param queryBuilder 438 | * @param embeds 439 | * @param alias 440 | * @private 441 | */ 442 | private _selectRequiredEmbeds( 443 | queryBuilder: SelectQueryBuilder<{}>, 444 | embeds: Array, 445 | alias: string 446 | ): SelectQueryBuilder<{}> { 447 | embeds.forEach((embed) => { 448 | // Select embed 449 | const { propertyName: embedName, columns: embedColumns } = embed; 450 | 451 | embedColumns.forEach(({ propertyName }) => { 452 | queryBuilder.addSelect( 453 | this._formatter.columnSelection(alias, `${embedName}.${propertyName}`) 454 | ); 455 | }); 456 | }); 457 | return queryBuilder; 458 | } 459 | } 460 | -------------------------------------------------------------------------------- /src/__tests__/basic.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { graphql } from "graphql"; 3 | import { startup, TestHelpers } from "./util/testStartup"; 4 | import { Author, Book, Publisher } from "./entity"; 5 | 6 | const deepEqualInAnyOrder = require("deep-equal-in-any-order"); 7 | 8 | chai.use(deepEqualInAnyOrder); 9 | const { expect } = chai; 10 | 11 | describe("Basic GraphQL queries", () => { 12 | let helpers: TestHelpers; 13 | 14 | before(async () => { 15 | helpers = await startup("basic", { logging: false }); 16 | }); 17 | 18 | describe("querying a single entity", () => { 19 | it("can query a single entity one layer deep", async () => { 20 | const { connection, schema, loader } = helpers; 21 | const author = await connection.getRepository(Author).findOne(); 22 | const query = ` 23 | query authorById($id: Int!) { 24 | authorById(id: $id) { 25 | id 26 | firstName 27 | lastName 28 | email 29 | } 30 | } 31 | `; 32 | const vars = { id: author?.id }; 33 | 34 | const result = await graphql( 35 | schema, 36 | query, 37 | {}, 38 | { 39 | loader, 40 | }, 41 | vars 42 | ); 43 | 44 | const expected = { 45 | id: author?.id, 46 | firstName: author?.firstName, 47 | lastName: author?.lastName, 48 | email: author?.email, 49 | }; 50 | expect(result).to.not.have.key("errors"); 51 | expect(result.data!.authorById).to.deep.equal(expected); 52 | }); 53 | 54 | it("can query fields that have custom column names", async () => { 55 | const { connection, schema, loader } = helpers; 56 | const author = await connection.getRepository(Author).findOne(); 57 | const query = ` 58 | query authorById($id: Int!) { 59 | authorById(id: $id) { 60 | id 61 | firstName 62 | lastName 63 | email 64 | phone 65 | } 66 | } 67 | `; 68 | const vars = { id: author?.id }; 69 | 70 | const result = await graphql( 71 | schema, 72 | query, 73 | {}, 74 | { 75 | loader, 76 | }, 77 | vars 78 | ); 79 | 80 | const expected = { 81 | id: author?.id, 82 | firstName: author?.firstName, 83 | lastName: author?.lastName, 84 | email: author?.email, 85 | phone: author?.phone, 86 | }; 87 | expect(result).to.not.have.key("errors"); 88 | expect(result.data!.authorById).to.deep.equal(expected); 89 | }); 90 | 91 | it("can query a single entity multiple layers deep", async () => { 92 | const { connection, schema, loader } = helpers; 93 | const author = await connection 94 | .getRepository(Author) 95 | .findOne({ relations: ["books", "books.publisher"] }); 96 | const query = ` 97 | query authorById($id: Int!) { 98 | authorById(id: $id) { 99 | id 100 | firstName 101 | lastName 102 | email 103 | books { 104 | id 105 | title 106 | summary 107 | publisher { 108 | id 109 | } 110 | } 111 | } 112 | } 113 | `; 114 | const vars = { id: author?.id }; 115 | 116 | const result = await graphql( 117 | schema, 118 | query, 119 | {}, 120 | { 121 | loader, 122 | }, 123 | vars 124 | ); 125 | 126 | const expected = { 127 | id: author?.id, 128 | firstName: author?.firstName, 129 | lastName: author?.lastName, 130 | email: author?.email, 131 | books: author?.books.map((book) => ({ 132 | id: book.id, 133 | title: book.title, 134 | summary: book.summary, 135 | publisher: { 136 | id: book.publisher.id, 137 | }, 138 | })), 139 | }; 140 | expect(result).to.not.have.key("errors"); 141 | expect(result.data!.authorById).to.deep.equal(expected); 142 | }); 143 | 144 | it("can resolve a query that contains fragments", async () => { 145 | const { connection, schema, loader } = helpers; 146 | const author = await connection 147 | .getRepository(Author) 148 | .findOne({ relations: ["books", "books.publisher"] }); 149 | const query = ` 150 | fragment bookFragment on Book { 151 | title 152 | summary 153 | publisher { 154 | id 155 | } 156 | } 157 | fragment authorFragment on Author { 158 | firstName 159 | lastName 160 | email 161 | books { 162 | ...bookFragment 163 | } 164 | } 165 | query authorById($id: Int!) { 166 | authorById(id: $id) { 167 | ...authorFragment 168 | } 169 | } 170 | `; 171 | const vars = { id: author?.id }; 172 | 173 | const result = await graphql( 174 | schema, 175 | query, 176 | {}, 177 | { 178 | loader, 179 | }, 180 | vars 181 | ); 182 | 183 | const expected = { 184 | firstName: author?.firstName, 185 | lastName: author?.lastName, 186 | email: author?.email, 187 | books: author?.books.map((book) => ({ 188 | title: book.title, 189 | summary: book.summary, 190 | publisher: { 191 | id: book.publisher.id, 192 | }, 193 | })), 194 | }; 195 | expect(result).to.not.have.key("errors"); 196 | expect(result.data!.authorById).to.deep.equal(expected); 197 | }); 198 | 199 | it("can resolve a query that contains fields with arguments", async () => { 200 | const { connection, schema, loader } = helpers; 201 | const author = await connection 202 | .getRepository(Author) 203 | .findOne({ relations: ["books", "books.publisher"] }); 204 | 205 | const query = ` 206 | query booksByAuthorId($id: Int!) { 207 | booksByAuthorId(authorId: $id) { 208 | id 209 | title 210 | transformedTitle(transform: "UPPERCASE") 211 | } 212 | } 213 | `; 214 | const vars = { id: author?.id }; 215 | 216 | const result = await graphql( 217 | schema, 218 | query, 219 | {}, 220 | { 221 | loader, 222 | }, 223 | vars 224 | ); 225 | 226 | const expected = author!.books 227 | .filter((book) => book.isPublished) 228 | .map((book) => ({ 229 | id: book.id, 230 | title: book.title, 231 | transformedTitle: book.title.toUpperCase(), 232 | })); 233 | expect(result).to.not.have.key("errors"); 234 | expect(result.data!.booksByAuthorId).to.deep.equal(expected); 235 | }); 236 | 237 | it("can resolve a mutation that contains multiple return types (union)", async () => { 238 | const { connection, schema, loader } = helpers; 239 | const bookCount = await connection.getRepository(Book).count(); 240 | const author = await connection.getRepository(Author).findOne(); 241 | const publisher = await connection.getRepository(Publisher).findOne(); 242 | 243 | const query = ` 244 | fragment bookFragment on Book { 245 | title 246 | summary 247 | publisher { 248 | id 249 | } 250 | author { 251 | id 252 | } 253 | } 254 | mutation createBook($authorId: Int!, $publisherId: Int!, $summary: String!, $title: String!) { 255 | createBook(authorId: $authorId, publisherId: $publisherId, summary: $summary, title: $title) { 256 | ... on BookCreateSuccess { 257 | data { 258 | ...bookFragment 259 | } 260 | } 261 | ... on BookCreateError { 262 | message 263 | } 264 | } 265 | } 266 | `; 267 | const vars = { 268 | authorId: author?.id, 269 | publisherId: publisher?.id, 270 | title: "Typescript Rules", 271 | summary: 272 | 'A book of 300 pages only containing the phrase "Typescript Rules"', 273 | }; 274 | 275 | const result = await graphql( 276 | schema, 277 | query, 278 | {}, 279 | { 280 | loader, 281 | connection: helpers.connection, 282 | }, 283 | vars 284 | ); 285 | 286 | const expected = { 287 | data: { 288 | title: vars.title, 289 | summary: vars.summary, 290 | author: { 291 | id: vars.authorId, 292 | }, 293 | publisher: { 294 | id: vars.publisherId, 295 | }, 296 | }, 297 | }; 298 | 299 | expect(result).to.not.have.key("errors"); 300 | expect(bookCount + 1).to.be.equal( 301 | await connection.getRepository(Book).count() 302 | ); 303 | expect(result.data!.createBook).to.deep.equal(expected); 304 | }); 305 | 306 | it("can resolve a mutation that contains multiple return types (union) and nested fragments", async () => { 307 | const { connection, schema, loader } = helpers; 308 | const bookCount = await connection.getRepository(Book).count(); 309 | const author = await connection.getRepository(Author).findOne(); 310 | const publisher = await connection.getRepository(Publisher).findOne(); 311 | 312 | const query = ` 313 | fragment bookFragment on Book { 314 | title 315 | summary 316 | publisher { 317 | id 318 | } 319 | author { 320 | id 321 | } 322 | } 323 | fragment bookCreateSuccess on BookCreateSuccess { 324 | data { 325 | ...bookFragment 326 | } 327 | } 328 | mutation createBook($authorId: Int!, $publisherId: Int!, $summary: String!, $title: String!) { 329 | createBook(authorId: $authorId, publisherId: $publisherId, summary: $summary, title: $title) { 330 | ... on BookCreateSuccess { 331 | ...bookCreateSuccess 332 | } 333 | ... on BookCreateError { 334 | message 335 | } 336 | } 337 | } 338 | `; 339 | const vars = { 340 | authorId: author?.id, 341 | publisherId: publisher?.id, 342 | title: "Typescript Rules", 343 | summary: 344 | 'A book of 300 pages only containing the phrase "Typescript Rules"', 345 | }; 346 | 347 | const result = await graphql( 348 | schema, 349 | query, 350 | {}, 351 | { 352 | loader, 353 | connection: helpers.connection, 354 | }, 355 | vars 356 | ); 357 | 358 | const expected = { 359 | data: { 360 | title: vars.title, 361 | summary: vars.summary, 362 | author: { 363 | id: vars.authorId, 364 | }, 365 | publisher: { 366 | id: vars.publisherId, 367 | }, 368 | }, 369 | }; 370 | 371 | expect(result).to.not.have.key("errors"); 372 | expect(bookCount + 1).to.be.equal( 373 | await connection.getRepository(Book).count() 374 | ); 375 | expect(result.data!.createBook).to.deep.equal(expected); 376 | }); 377 | }); 378 | 379 | describe("querying multiple entities", () => { 380 | it("can query a single level on multiple entities", async () => { 381 | const { connection, schema, loader } = helpers; 382 | const books = await connection 383 | .getRepository(Book) 384 | .find({ where: { author: { id: 1 }, isPublished: true } }); 385 | 386 | const query = ` 387 | query booksByAuthorId($authorId: Int!) { 388 | booksByAuthorId(authorId: $authorId) { 389 | id 390 | title 391 | summary 392 | } 393 | } 394 | `; 395 | 396 | const vars = { authorId: 1 }; 397 | const result = await graphql(schema, query, {}, { loader }, vars); 398 | 399 | const expected: Array> = books.map(({ id, title, summary }) => ({ id, title, summary })); 402 | 403 | expect(result).to.not.have.key("errors"); 404 | expect(result.data!.booksByAuthorId).to.deep.equal(expected); 405 | }); 406 | 407 | it("can query multiple levels on multiple entities", async () => { 408 | const { connection, schema, loader } = helpers; 409 | const books = await connection.getRepository(Book).find({ 410 | where: { author: { id: 1 }, isPublished: true }, 411 | relations: ["author", "publisher", "reviews"], 412 | }); 413 | 414 | const query = ` 415 | query booksByAuthorId($authorId: Int!) { 416 | booksByAuthorId(authorId: $authorId) { 417 | id 418 | title 419 | summary 420 | author { 421 | id 422 | firstName 423 | lastName 424 | } 425 | publisher { 426 | name 427 | } 428 | reviews { 429 | rating 430 | } 431 | } 432 | } 433 | `; 434 | 435 | const vars = { authorId: 1 }; 436 | const result = await graphql(schema, query, {}, { loader }, vars); 437 | 438 | const expected = books.map( 439 | ({ id, title, summary, author, publisher, reviews }) => ({ 440 | id, 441 | title, 442 | summary, 443 | author: { 444 | id: 1, 445 | firstName: author.firstName, 446 | lastName: author.lastName, 447 | }, 448 | publisher: { 449 | name: publisher.name, 450 | }, 451 | reviews: reviews.map((review) => ({ rating: review.rating })), 452 | }) 453 | ); 454 | 455 | expect(result).to.not.have.key("errors"); 456 | // Can't be bothered to overwrite the module and get it working with mocha 457 | // @ts-ignore 458 | expect(result.data!.booksByAuthorId).to.deep.equalInAnyOrder(expected); 459 | }); 460 | }); 461 | }); 462 | -------------------------------------------------------------------------------- /src/__tests__/builderOptions.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { graphql } from "graphql"; 3 | import { startup, TestHelpers } from "./util/testStartup"; 4 | import { Author, Book, Publisher, Review } from "./entity"; 5 | 6 | chai.use(require("deep-equal-in-any-order")); 7 | const { expect } = chai; 8 | 9 | describe("Query Builder options", () => { 10 | let helpers: TestHelpers; 11 | 12 | before(async () => { 13 | helpers = await startup("options", { logging: false }); 14 | }); 15 | 16 | it("caches the same query to prevent duplicate calls", async () => { 17 | const { loader, connection, schema } = helpers; 18 | const author = await connection.getRepository(Author).findOne(); 19 | const query = ` 20 | query authorById($id: Int!) { 21 | first: authorById(id: $id) { 22 | id 23 | firstName 24 | lastName 25 | } 26 | second: authorById(id: $id) { 27 | id 28 | firstName 29 | lastName 30 | } 31 | } 32 | `; 33 | const vars = { id: author?.id }; 34 | // Enable logging 35 | // Check that only a single database call happens between the 36 | // two console.warns 37 | console.warn("START GQL QUERY"); 38 | const result = await graphql(schema, query, {}, { loader }, vars); 39 | console.warn("END GQL QUERY"); 40 | 41 | const expectedAuthor = { 42 | id: author?.id, 43 | firstName: author?.firstName, 44 | lastName: author?.lastName, 45 | }; 46 | const expected = { 47 | first: expectedAuthor, 48 | second: expectedAuthor, 49 | }; 50 | 51 | expect(result).to.not.have.key("errors"); 52 | expect(result.data).to.deep.equal(expected); 53 | }); 54 | 55 | it("respects the selectFields option", async () => { 56 | const { schema, loader, connection } = helpers; 57 | const query = ` 58 | query getPaginatedReviews($offset: Int!, $limit: Int!) { 59 | paginatedReviews(offset: $offset, limit: $limit) { 60 | reviews { 61 | id 62 | title 63 | body 64 | reviewDate 65 | reviewerName 66 | } 67 | offset 68 | hasMore 69 | maxRating 70 | minRating 71 | } 72 | } 73 | `; 74 | 75 | const vars = { offset: 0, limit: 10 }; 76 | const result = await graphql(schema, query, {}, { loader }, vars); 77 | const reviews = await connection 78 | .getRepository(Review) 79 | .createQueryBuilder("review") 80 | .orderBy({ rating: "DESC" }) 81 | .limit(10) 82 | .getMany(); 83 | 84 | const expected = { 85 | hasMore: true, 86 | offset: 10, 87 | minRating: Math.min(...reviews.map((review) => review.rating)), 88 | maxRating: Math.max(...reviews.map((review) => review.rating)), 89 | reviews: reviews.map(({ id, title, body, reviewDate, reviewerName }) => ({ 90 | id, 91 | title, 92 | body, 93 | reviewDate, 94 | reviewerName, 95 | })), 96 | }; 97 | 98 | expect(result).to.not.have.key("errors"); 99 | expect(result.data?.paginatedReviews).to.deep.equal(expected); 100 | }); 101 | 102 | it("can apply OR WHERE conditions with strings", async () => { 103 | const { connection, schema, loader } = helpers; 104 | const query = ` 105 | query orWhere($authorId: Int!, $publisherId: Int!) { 106 | booksByAuthorOrPublisher(authorId: $authorId, publisherId: $publisherId) { 107 | id 108 | title 109 | } 110 | } 111 | `; 112 | const author = await connection.getRepository(Author).findOne(); 113 | const publisher = await connection.getRepository(Publisher).findOne(); 114 | const books = await connection 115 | .getRepository(Book) 116 | .createQueryBuilder("book") 117 | .where("book.authorId = :authorId", { authorId: author?.id }) 118 | .orWhere("book.publisherId = :publisherId", { 119 | publisherId: publisher?.id, 120 | }) 121 | .getMany(); 122 | 123 | const vars = { authorId: author?.id, publisherId: publisher?.id }; 124 | 125 | const result = await graphql(schema, query, {}, { loader }, vars); 126 | const expected = books.map(({ id, title }) => ({ id, title })); 127 | expect(result).to.not.have.key("errors"); 128 | expect(result.data?.booksByAuthorOrPublisher).to.deep.equal(expected); 129 | }); 130 | 131 | it("can apply OR WHERE conditions with brackets", async () => { 132 | const { connection, schema, loader } = helpers; 133 | const query = ` 134 | query orWhere($authorId: Int!, $publisherId: Int!) { 135 | booksByAuthorOrPublisher(authorId: $authorId, publisherId: $publisherId, useBrackets: true) { 136 | id 137 | title 138 | } 139 | } 140 | `; 141 | const author = await connection.getRepository(Author).findOne(); 142 | const publisher = await connection.getRepository(Publisher).findOne(); 143 | const books = await connection 144 | .getRepository(Book) 145 | .createQueryBuilder("book") 146 | .where("book.authorId = :authorId", { authorId: author?.id }) 147 | .orWhere("book.publisherId = :publisherId", { 148 | publisherId: publisher?.id, 149 | }) 150 | .getMany(); 151 | 152 | const vars = { authorId: author?.id, publisherId: publisher?.id }; 153 | 154 | const result = await graphql(schema, query, {}, { loader }, vars); 155 | const expected = books.map(({ id, title }) => ({ id, title })); 156 | expect(result).to.not.have.key("errors"); 157 | expect(result.data?.booksByAuthorOrPublisher).to.deep.equal(expected); 158 | }); 159 | }); 160 | 161 | describe("Depth limiting", () => { 162 | let helpers: TestHelpers; 163 | before(async () => { 164 | helpers = await startup("max_depth", { 165 | logging: false, 166 | loaderOptions: { maxQueryDepth: 2 }, 167 | }); 168 | }); 169 | 170 | it("does not load relations more than max depth", async () => { 171 | const { loader, connection, schema } = helpers; 172 | const author = await connection.getRepository(Author).findOne(); 173 | const query = ` 174 | query authorById($id: Int!) { 175 | authorById(id: $id) { 176 | id 177 | firstName 178 | lastName 179 | books { 180 | id 181 | publisher { 182 | id 183 | books { 184 | id 185 | } 186 | } 187 | } 188 | } 189 | } 190 | `; 191 | const vars = { id: author?.id }; 192 | const result = await graphql(schema, query, {}, { loader }, vars); 193 | 194 | expect(result).to.not.have.key("errors"); 195 | expect(result.data?.authorById?.books?.publisher?.books).to.not.be.ok; 196 | }); 197 | }); 198 | 199 | describe("Primary Key Backwards compatibility", () => { 200 | let helpers: TestHelpers; 201 | 202 | before(async () => { 203 | helpers = await startup("deprecated_primary_key", { 204 | logging: false, 205 | loaderOptions: { primaryKeyColumn: "rating" }, 206 | }); 207 | }); 208 | 209 | it("is backwards compatible with primary key option", async () => { 210 | const { schema, loader, connection } = helpers; 211 | const query = ` 212 | query getPaginatedReviews($offset: Int!, $limit: Int!) { 213 | deprecatedPrimaryKey(offset: $offset, limit: $limit) { 214 | reviews { 215 | id 216 | title 217 | body 218 | reviewDate 219 | reviewerName 220 | } 221 | offset 222 | hasMore 223 | maxRating 224 | minRating 225 | } 226 | } 227 | `; 228 | 229 | const vars = { offset: 0, limit: 10 }; 230 | const result = await graphql( 231 | schema, 232 | query, 233 | {}, 234 | { loader, connection }, 235 | vars 236 | ); 237 | const reviews = await helpers.connection 238 | .getRepository(Review) 239 | .createQueryBuilder("review") 240 | .orderBy({ rating: "DESC" }) 241 | .limit(10) 242 | .getMany(); 243 | 244 | const expected = { 245 | hasMore: true, 246 | offset: 10, 247 | minRating: Math.min(...reviews.map((review) => review.rating)), 248 | maxRating: Math.max(...reviews.map((review) => review.rating)), 249 | reviews: reviews.map(({ id, title, body, reviewDate, reviewerName }) => ({ 250 | id, 251 | title, 252 | body, 253 | reviewDate, 254 | reviewerName, 255 | })), 256 | }; 257 | 258 | expect(result).to.not.have.key("errors"); 259 | expect(result.data?.deprecatedPrimaryKey).to.deep.equal(expected); 260 | }); 261 | }); 262 | 263 | describe("ejectQueryBuilder", () => { 264 | let helpers: TestHelpers; 265 | 266 | before(async () => { 267 | helpers = await startup("eject_builder", { 268 | logging: false, 269 | }); 270 | }); 271 | 272 | it("can successfully execute a query that had a custom eject callback", async () => { 273 | const { connection, schema, loader } = helpers; 274 | const publisher = await connection 275 | .getRepository(Publisher) 276 | .findOne({ relations: ["books"] }); 277 | 278 | const query = ` 279 | query publisherByBookTitle($bookTitle: String!) { 280 | publisherByBookTitle(bookTitle: $bookTitle) { 281 | id 282 | name 283 | books { 284 | id 285 | title 286 | } 287 | } 288 | } 289 | `; 290 | const vars = { bookTitle: publisher?.books?.[0].title }; 291 | 292 | const result = await graphql(schema, query, {}, { loader }, vars); 293 | 294 | const expected = { 295 | id: publisher?.id, 296 | name: publisher?.name, 297 | books: publisher?.books.map(({ id, title }) => ({ id, title })), 298 | }; 299 | 300 | expect(result).to.not.have.key("errors"); 301 | // @ts-ignore 302 | expect(result.data!.publisherByBookTitle).to.deep.equalInAnyOrder(expected); 303 | }); 304 | }); 305 | -------------------------------------------------------------------------------- /src/__tests__/decoratorUtils.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { startup, TestHelpers } from "./util/testStartup"; 3 | import { DecoratorTest } from "./entity"; 4 | import { 5 | getLoaderIgnoredFields, 6 | getLoaderRequiredFields, 7 | resolvePredicate, 8 | } from "../ConfigureLoader"; 9 | import { GraphQLEntityFields } from "../types"; 10 | 11 | const spies = require("chai-spies"); 12 | chai.use(spies); 13 | 14 | const { expect } = chai; 15 | 16 | describe("Decorator Utilities", () => { 17 | let helpers: TestHelpers; 18 | const customizedFields: Array = [ 19 | "testField", 20 | "testRelation", 21 | "testEmbed", 22 | "testRemappedField", 23 | "testRemappedRelation", 24 | "testRemappedEmbed", 25 | ]; 26 | const untouchedFields: Array = [ 27 | "id", 28 | "createdAt", 29 | "updatedAt", 30 | ]; 31 | 32 | before(async () => { 33 | helpers = await startup("decorator_utils", { logging: false }); 34 | }); 35 | 36 | describe("getLoaderRequiredFields", () => { 37 | it("returns all the fields that have custom require logic", () => { 38 | const meta = helpers.connection.getMetadata(DecoratorTest); 39 | getLoaderRequiredFields(meta.target).forEach((_, key) => { 40 | expect(customizedFields).to.include(key); 41 | }); 42 | }); 43 | 44 | it("excludes all the fields that have no custom require logic", () => { 45 | const meta = helpers.connection.getMetadata(DecoratorTest); 46 | getLoaderRequiredFields(meta.target).forEach((_, key) => { 47 | expect(untouchedFields).to.not.include(key); 48 | }); 49 | }); 50 | }); 51 | 52 | describe("getLoaderIgnoredFields", () => { 53 | it("returns all the fields that have custom ignore logic", () => { 54 | const meta = helpers.connection.getMetadata(DecoratorTest); 55 | getLoaderIgnoredFields(meta.target).forEach((_, key) => { 56 | expect(customizedFields).to.include(key); 57 | }); 58 | }); 59 | 60 | it("excludes all the fields that have no custom ignore logic", () => { 61 | const meta = helpers.connection.getMetadata(DecoratorTest); 62 | getLoaderIgnoredFields(meta.target).forEach((_, key) => { 63 | expect(untouchedFields).to.not.include(key); 64 | }); 65 | }); 66 | }); 67 | 68 | describe("resolvePredicate", () => { 69 | type TestContext = { key: boolean }; 70 | const context: TestContext = { key: true }; 71 | const selection: GraphQLEntityFields = { testChild: { children: {} } }; 72 | const queriedFields = ["testChild"]; 73 | 74 | it("correctly calls a predicate function", () => { 75 | const spy = chai.spy(() => true); 76 | const resolved = resolvePredicate(spy, context, selection); 77 | expect(spy).to.have.been.called(); 78 | expect(resolved).to.be.true; 79 | }); 80 | 81 | it("correctly returns just a boolean", () => { 82 | expect(resolvePredicate(true, context, selection)).to.be.true; 83 | expect(resolvePredicate(false, context, selection)).to.be.false; 84 | }); 85 | 86 | it("calls the predicate function with the correct params", () => { 87 | const spy = chai.spy(() => false); 88 | const resolved = resolvePredicate(spy, context, selection); 89 | expect(spy).to.have.been.called.with(context, queriedFields, selection); 90 | expect(resolved).to.be.false; 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/__tests__/decorators.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { startup, TestHelpers } from "./util/testStartup"; 3 | import "reflect-metadata"; 4 | import { Author, DecoratorTest } from "./entity"; 5 | import { graphql } from "graphql"; 6 | 7 | const { expect } = chai; 8 | 9 | describe("ConfigureLoader", () => { 10 | let helpers: TestHelpers; 11 | let dt: DecoratorTest | undefined; 12 | 13 | before(async () => { 14 | helpers = await startup("configure_loader", { logging: false }); 15 | dt = await helpers.connection 16 | .getRepository(DecoratorTest) 17 | .findOne({ relations: ["testRelation", "testRemappedRelation"] }); 18 | }); 19 | 20 | it("Can successfully execute a query against an entity with decorators", async () => { 21 | const { schema, loader } = helpers; 22 | 23 | const query = ` 24 | query DecoratorTest($dtId: Int!) { 25 | decoratorTests(dtId: $dtId) { 26 | id 27 | testField 28 | testRelation { 29 | id 30 | } 31 | testEmbed { 32 | street 33 | } 34 | } 35 | } 36 | `; 37 | const vars = { dtId: dt?.id }; 38 | const result = await graphql(schema, query, {}, { loader }, vars); 39 | 40 | const expected = { 41 | id: dt?.id, 42 | testField: dt?.testField, 43 | testRelation: { 44 | id: dt?.testRelation.id, 45 | }, 46 | testEmbed: { 47 | street: dt?.testEmbed.street, 48 | }, 49 | }; 50 | 51 | expect(result.errors).to.be.undefined; 52 | expect(result.data?.decoratorTests).to.deep.equal(expected); 53 | }); 54 | 55 | describe("requiring fields", () => { 56 | it("loads a required field even when not requested", async () => { 57 | const { schema, loader } = helpers; 58 | 59 | const query = ` 60 | query DecoratorTest($dtId: Int!, $requireField: Boolean) { 61 | decoratorTests(dtId: $dtId, requireField: $requireField) { 62 | id 63 | testRelation { 64 | id 65 | } 66 | testEmbed { 67 | street 68 | } 69 | } 70 | } 71 | `; 72 | const vars = { dtId: dt?.id, requireField: true }; 73 | // The resolver will throw an error if a required field is missing 74 | // in the record response 75 | const result = await graphql(schema, query, {}, { loader }, vars); 76 | 77 | const expected = { 78 | id: dt?.id, 79 | testRelation: { 80 | id: dt?.testRelation.id, 81 | }, 82 | testEmbed: { 83 | street: dt?.testEmbed.street, 84 | }, 85 | }; 86 | 87 | expect(result.errors).to.be.undefined; 88 | expect(result.data?.decoratorTests).to.deep.equal(expected); 89 | }); 90 | 91 | it("loads a required relation even when not requested", async () => { 92 | const { schema, loader } = helpers; 93 | 94 | const query = ` 95 | query DecoratorTest($dtId: Int!, $requireRelation: Boolean) { 96 | decoratorTests(dtId: $dtId, requireRelation: $requireRelation) { 97 | id 98 | testField 99 | testEmbed { 100 | street 101 | } 102 | } 103 | } 104 | `; 105 | 106 | const vars = { dtId: dt?.id, requireRelation: true }; 107 | 108 | // The resolver will throw an error if a required relation is missing 109 | // in the record response 110 | const result = await graphql(schema, query, {}, { loader }, vars); 111 | 112 | const expected = { 113 | id: dt?.id, 114 | testField: dt?.testField, 115 | testEmbed: { 116 | street: dt?.testEmbed.street, 117 | }, 118 | }; 119 | 120 | expect(result.errors).to.be.undefined; 121 | expect(result.data?.decoratorTests).to.deep.equal(expected); 122 | }); 123 | 124 | it("loads a required embedded field even when not requested", async () => { 125 | const { schema, loader } = helpers; 126 | 127 | const query = ` 128 | query DecoratorTest($dtId: Int!, $requireEmbed: Boolean) { 129 | decoratorTests(dtId: $dtId, requireEmbed: $requireEmbed) { 130 | id 131 | testField 132 | testRelation { 133 | id 134 | } 135 | } 136 | } 137 | `; 138 | 139 | const vars = { dtId: dt?.id, requireEmbed: true }; 140 | const result = await graphql(schema, query, {}, { loader }, vars); 141 | 142 | const expected = { 143 | id: dt?.id, 144 | testField: dt?.testField, 145 | testRelation: { 146 | id: dt?.testRelation.id, 147 | }, 148 | }; 149 | 150 | expect(result.errors).to.be.undefined; 151 | expect(result.data?.decoratorTests).to.deep.equal(expected); 152 | }); 153 | }); 154 | 155 | describe("ignoring", () => { 156 | it("ignores fields correctly", async () => { 157 | const { schema, loader } = helpers; 158 | 159 | const query = ` 160 | query DecoratorTest($dtId: Int!, $ignoreField: Boolean) { 161 | decoratorTests(dtId: $dtId, ignoreField: $ignoreField) { 162 | id 163 | testField 164 | testRelation { 165 | id 166 | } 167 | testEmbed { 168 | street 169 | } 170 | } 171 | } 172 | `; 173 | const vars = { dtId: dt?.id, ignoreField: true }; 174 | const result = await graphql(schema, query, {}, { loader }, vars); 175 | 176 | const expected = { 177 | id: dt?.id, 178 | testField: null, 179 | testRelation: { 180 | id: dt?.testRelation.id, 181 | }, 182 | testEmbed: { 183 | street: dt?.testEmbed.street, 184 | }, 185 | }; 186 | expect(result.errors).to.be.undefined; 187 | expect(result.data?.decoratorTests).to.deep.equal(expected); 188 | }); 189 | 190 | it("ignores relations correctly", async () => { 191 | const { schema, loader } = helpers; 192 | 193 | const query = ` 194 | query DecoratorTest($dtId: Int!, $ignoreRelation: Boolean) { 195 | decoratorTests(dtId: $dtId, ignoreRelation: $ignoreRelation) { 196 | id 197 | testField 198 | testRelation { 199 | id 200 | } 201 | testEmbed { 202 | street 203 | } 204 | } 205 | } 206 | `; 207 | const vars = { dtId: dt?.id, ignoreRelation: true }; 208 | const result = await graphql(schema, query, {}, { loader }, vars); 209 | 210 | const expected = { 211 | id: dt?.id, 212 | testField: dt?.testField, 213 | // Ignored is a non-nullable column on the db. 214 | // even so, the field should be ignored in the query 215 | // and return null. 216 | testRelation: null, 217 | testEmbed: { 218 | street: dt?.testEmbed.street, 219 | }, 220 | }; 221 | 222 | expect(result.errors).to.be.undefined; 223 | expect(result.data?.decoratorTests).to.deep.equal(expected); 224 | }); 225 | 226 | it("ignores embedded fields correctly", async () => { 227 | const { schema, loader } = helpers; 228 | 229 | const query = ` 230 | query DecoratorTest($dtId: Int!, $ignoreEmbed: Boolean) { 231 | decoratorTests(dtId: $dtId, ignoreEmbed: $ignoreEmbed) { 232 | id 233 | testField 234 | testRelation { 235 | id 236 | } 237 | testEmbed { 238 | street 239 | city 240 | } 241 | } 242 | } 243 | `; 244 | const vars = { dtId: dt?.id, ignoreEmbed: true }; 245 | const result = await graphql(schema, query, {}, { loader }, vars); 246 | 247 | const expected = { 248 | id: dt?.id, 249 | testField: dt?.testField, 250 | testRelation: { 251 | id: dt?.testRelation.id, 252 | }, 253 | // Ignored is a non-nullable column on the db. 254 | // even so, the field should be ignored in the query 255 | // and return null. 256 | testEmbed: null, 257 | }; 258 | 259 | expect(result.errors).to.be.undefined; 260 | expect(result.data?.decoratorTests).to.deep.equal(expected); 261 | }); 262 | }); 263 | 264 | describe("remap graphql field names", () => { 265 | it("can remap a field name", async () => { 266 | const { schema, loader } = helpers; 267 | 268 | const query = ` 269 | query DecoratorTest($dtId: Int!) { 270 | decoratorTests(dtId: $dtId) { 271 | id 272 | remappedField 273 | } 274 | } 275 | `; 276 | 277 | const vars = { dtId: dt?.id }; 278 | const result = await graphql(schema, query, {}, { loader }, vars); 279 | 280 | const expected = { 281 | id: dt?.id, 282 | remappedField: dt?.testRemappedField, 283 | }; 284 | 285 | expect(result.errors).to.be.undefined; 286 | expect(result.data?.decoratorTests).to.deep.equal(expected); 287 | }); 288 | 289 | it("can remap a relation name", async () => { 290 | const { schema, loader } = helpers; 291 | 292 | const query = ` 293 | query DecoratorTest($dtId: Int!) { 294 | decoratorTests(dtId: $dtId) { 295 | id 296 | remappedRelation { 297 | id 298 | firstName 299 | } 300 | } 301 | } 302 | `; 303 | 304 | const vars = { dtId: dt?.id }; 305 | const result = await graphql(schema, query, {}, { loader }, vars); 306 | 307 | const expected = { 308 | id: dt?.id, 309 | remappedRelation: { 310 | id: dt?.testRemappedRelation.id, 311 | firstName: dt?.testRemappedRelation.firstName, 312 | }, 313 | }; 314 | 315 | expect(result.errors).to.be.undefined; 316 | expect(result.data?.decoratorTests).to.deep.equal(expected); 317 | }); 318 | 319 | it("can remap an embed name", async () => { 320 | const { schema, loader } = helpers; 321 | 322 | const query = ` 323 | query DecoratorTest($dtId: Int!) { 324 | decoratorTests(dtId: $dtId) { 325 | id 326 | remappedEmbed { 327 | street 328 | city 329 | } 330 | } 331 | } 332 | `; 333 | 334 | const vars = { dtId: dt?.id }; 335 | const result = await graphql(schema, query, {}, { loader }, vars); 336 | 337 | const expected = { 338 | id: dt?.id, 339 | remappedEmbed: { 340 | street: dt?.testRemappedEmbed.street, 341 | city: dt?.testRemappedEmbed.city, 342 | }, 343 | }; 344 | 345 | expect(result.errors).to.be.undefined; 346 | expect(result.data?.decoratorTests).to.deep.equal(expected); 347 | }); 348 | 349 | it("can remap an embed property name", async () => { 350 | const { schema, loader } = helpers; 351 | 352 | const query = ` 353 | query DecoratorTest($dtId: Int!) { 354 | decoratorTests(dtId: $dtId) { 355 | id 356 | remappedEmbed { 357 | street 358 | city 359 | unitNumber 360 | } 361 | } 362 | } 363 | `; 364 | 365 | const vars = { dtId: dt?.id }; 366 | const result = await graphql(schema, query, {}, { loader }, vars); 367 | 368 | const expected = { 369 | id: dt?.id, 370 | remappedEmbed: { 371 | street: dt?.testRemappedEmbed.street, 372 | city: dt?.testRemappedEmbed.city, 373 | unitNumber: dt?.testRemappedEmbed.street2, 374 | }, 375 | }; 376 | 377 | expect(result.errors).to.be.undefined; 378 | expect(result.data?.decoratorTests).to.deep.equal(expected); 379 | }); 380 | }); 381 | 382 | describe("user defined join alias", () => { 383 | it("can successfully query on a user defined alias", async () => { 384 | const { schema, loader, connection } = helpers; 385 | const relation = await connection.getRepository(Author).findOne(); 386 | const entity = await connection 387 | .getRepository(DecoratorTest) 388 | .createQueryBuilder("dt") 389 | .where("dt.testRelationId = :relationId", { relationId: relation?.id }) 390 | .getOne(); 391 | 392 | const query = ` 393 | query CustomSQLAlias($relationId: Int!) { 394 | customSQLAlias(relationId: $relationId) { 395 | id 396 | createdAt 397 | updatedAt 398 | testRelation { 399 | id 400 | } 401 | } 402 | } 403 | `; 404 | 405 | const vars = { relationId: relation?.id }; 406 | 407 | const expected = { 408 | id: entity?.id, 409 | createdAt: entity?.createdAt.toISOString(), 410 | updatedAt: entity?.updatedAt.toISOString(), 411 | testRelation: { 412 | id: relation?.id, 413 | }, 414 | }; 415 | 416 | const result = await graphql(schema, query, {}, { loader }, vars); 417 | 418 | expect(result.errors).to.be.undefined; 419 | expect(result.data?.customSQLAlias).to.deep.equal(expected); 420 | }); 421 | }); 422 | }); 423 | -------------------------------------------------------------------------------- /src/__tests__/embeddedEntities.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { graphql } from "graphql"; 3 | import { startup, TestHelpers } from "./util/testStartup"; 4 | import { Author } from "./entity"; 5 | 6 | const deepEqualInAnyOrder = require("deep-equal-in-any-order"); 7 | 8 | chai.use(deepEqualInAnyOrder); 9 | const { expect } = chai; 10 | 11 | describe("Querying embedded entities", () => { 12 | let helpers: TestHelpers; 13 | 14 | before(async () => { 15 | helpers = await startup("embedded_entities", { logging: false }); 16 | }); 17 | 18 | it("can query embedded fields on an entity", async () => { 19 | const { connection, schema, loader } = helpers; 20 | const author = await connection.getRepository(Author).findOne(); 21 | 22 | const query = ` 23 | query authorById($id: Int!) { 24 | authorById(id: $id) { 25 | id 26 | firstName 27 | lastName 28 | email 29 | address { 30 | street 31 | city 32 | state 33 | zip 34 | } 35 | } 36 | } 37 | `; 38 | 39 | const vars = { id: author?.id }; 40 | 41 | const result = await graphql(schema, query, {}, { loader }, vars); 42 | 43 | const expected = { 44 | id: author?.id, 45 | firstName: author?.firstName, 46 | lastName: author?.lastName, 47 | email: author?.email, 48 | address: { 49 | street: author?.address.street, 50 | city: author?.address.city, 51 | state: author?.address.state, 52 | zip: author?.address.zip, 53 | }, 54 | }; 55 | 56 | expect(result).to.not.have.key("errors"); 57 | expect(result.data!.authorById).to.deep.equal(expected); 58 | }); 59 | 60 | it("can query multiple embedded fields on a nested entity", async () => { 61 | const { connection, schema, loader } = helpers; 62 | const author = await connection 63 | .getRepository(Author) 64 | .findOne({ relations: ["books", "books.publisher"] }); 65 | 66 | const query = ` 67 | query booksByAuthorId($id: Int!) { 68 | booksByAuthorId(authorId: $id) { 69 | id 70 | publisher { 71 | address { 72 | street 73 | city 74 | state 75 | } 76 | poBox { 77 | street 78 | zip 79 | } 80 | } 81 | } 82 | } 83 | `; 84 | 85 | const vars = { id: author?.id }; 86 | const result = await graphql(schema, query, {}, { loader }, vars); 87 | 88 | const expected = author?.books 89 | .filter((book) => book.isPublished) 90 | .map((book) => ({ 91 | id: book.id, 92 | publisher: { 93 | address: { 94 | street: book.publisher.address.street, 95 | city: book.publisher.address.city, 96 | state: book.publisher.address.state, 97 | }, 98 | poBox: { 99 | street: book.publisher.poBox.street, 100 | zip: book.publisher.poBox.zip, 101 | }, 102 | }, 103 | })); 104 | 105 | expect(result).to.not.have.key("errors"); 106 | // Can't be bothered to overwrite the module and get it working with mocha 107 | // @ts-ignore 108 | expect(result.data!.booksByAuthorId).to.deep.equalInAnyOrder(expected); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/__tests__/entity/Address.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "typeorm"; 2 | import { Field, ObjectType } from "type-graphql"; 3 | import { ConfigureLoader } from "../../ConfigureLoader"; 4 | 5 | @ObjectType() 6 | export class Address { 7 | @Field() 8 | @Column() 9 | street!: string; 10 | 11 | @Column() 12 | @ConfigureLoader({ graphQLName: "unitNumber" }) 13 | street2!: string; 14 | 15 | @Field() 16 | @Column() 17 | city!: string; 18 | 19 | @Field() 20 | @Column() 21 | state!: string; 22 | 23 | // testing custom column names 24 | @Field() 25 | @Column({ name: "postCode" }) 26 | zip!: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/__tests__/entity/Author.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | OneToMany, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from "typeorm"; 10 | import { Book } from "./Book"; 11 | import { Field, Int, ObjectType } from "type-graphql"; 12 | import { Address } from "./Address"; 13 | 14 | @ObjectType() 15 | @Entity() 16 | export class Author extends BaseEntity { 17 | @Field((type) => Int) 18 | @PrimaryGeneratedColumn() 19 | id!: number; 20 | 21 | @Field((type) => Address) 22 | @Column((type) => Address) 23 | address!: Address; 24 | 25 | @Field() 26 | @Column("varchar") 27 | email!: string; 28 | 29 | @Field() 30 | @Column("varchar") 31 | firstName!: string; 32 | 33 | @Field() 34 | @Column("varchar") 35 | lastName!: string; 36 | 37 | @Field() 38 | @Column({ name: "mobilePhone" }) 39 | phone!: string; 40 | 41 | @Field((type) => [Book]) 42 | @OneToMany((type) => Book, (book) => book.author) 43 | books!: Book[]; 44 | 45 | @Field() 46 | @CreateDateColumn() 47 | createdAt!: Date; 48 | 49 | @Field() 50 | @UpdateDateColumn() 51 | updatedAt!: Date; 52 | } 53 | -------------------------------------------------------------------------------- /src/__tests__/entity/Book.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | ManyToOne, 7 | OneToMany, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from "typeorm"; 11 | import { Author } from "./Author"; 12 | import { Publisher } from "./Publisher"; 13 | import { Review } from "./Review"; 14 | import { createUnionType, Field, Int, ObjectType } from "type-graphql"; 15 | 16 | @ObjectType() 17 | @Entity() 18 | export class Book extends BaseEntity { 19 | @Field((type) => Int) 20 | @PrimaryGeneratedColumn() 21 | id!: number; 22 | 23 | @Field((type) => Boolean) 24 | @Column("boolean") 25 | isPublished!: boolean; 26 | 27 | @Field() 28 | @Column("varchar") 29 | title!: string; 30 | 31 | @Field() 32 | @Column("text") 33 | summary!: string; 34 | 35 | @Field((type) => String) 36 | @Column("date") 37 | publishedDate!: Date; 38 | 39 | @Field((type) => Author) 40 | @ManyToOne((type) => Author, (author) => author.books) 41 | author!: Author; 42 | 43 | @Field((type) => Publisher) 44 | @ManyToOne((type) => Publisher, (publisher) => publisher.books) 45 | publisher!: Publisher; 46 | 47 | @Field((type) => [Review]) 48 | @OneToMany((t) => Review, (review) => review.book) 49 | reviews!: Review[]; 50 | 51 | @Field() 52 | @CreateDateColumn() 53 | createdAt!: Date; 54 | 55 | @Field() 56 | @UpdateDateColumn() 57 | updatedAt!: Date; 58 | } 59 | 60 | @ObjectType() 61 | export class BookCreateSuccess { 62 | @Field((data) => Book) 63 | public readonly data: Book; 64 | 65 | constructor(data: Book) { 66 | this.data = data; 67 | } 68 | } 69 | 70 | @ObjectType() 71 | export class BookCreateError { 72 | @Field((message) => String) 73 | public readonly message: string; 74 | 75 | constructor(message: string) { 76 | this.message = message; 77 | } 78 | } 79 | 80 | export const BookCreateResultType = createUnionType({ 81 | name: "BookCreateResult", // the name of the GraphQL union 82 | types: () => [BookCreateSuccess, BookCreateError] as const, // function that returns tuple of object types classes 83 | }); 84 | -------------------------------------------------------------------------------- /src/__tests__/entity/DecoratorTest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | JoinColumn, 7 | OneToOne, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from "typeorm"; 11 | import { Field, Int, ObjectType } from "type-graphql"; 12 | import { ConfigureLoader } from "../../"; 13 | import { Author } from "./Author"; 14 | import { Address } from "./Address"; 15 | import { DecoratorContext } from "../util/DecoratorContext"; 16 | 17 | @ObjectType() 18 | @Entity() 19 | export class DecoratorTest extends BaseEntity { 20 | @Field((type) => Int) 21 | @PrimaryGeneratedColumn() 22 | id!: number; 23 | 24 | @Field({ nullable: true }) 25 | @Column("varchar", { nullable: false }) 26 | @ConfigureLoader({ 27 | ignore: (context: DecoratorContext) => context.ignoreField, 28 | required: (context: DecoratorContext) => context.requireField, 29 | }) 30 | testField?: string; 31 | 32 | @Column("varchar", { nullable: false }) 33 | @ConfigureLoader({ 34 | graphQLName: "remappedField", 35 | }) 36 | testRemappedField!: string; 37 | 38 | @Field((type) => Address, { nullable: true }) 39 | @Column((type) => Address) 40 | @ConfigureLoader({ 41 | ignore: (context: DecoratorContext) => context.ignoreEmbed, 42 | required: (context: DecoratorContext) => context.requireEmbed, 43 | }) 44 | testEmbed!: Address; 45 | 46 | @Column((type) => Address) 47 | @ConfigureLoader({ 48 | graphQLName: "remappedEmbed", 49 | }) 50 | testRemappedEmbed!: Address; 51 | 52 | @OneToOne((type) => Author) 53 | @JoinColumn() 54 | @Field((type) => Author, { nullable: true }) 55 | @ConfigureLoader({ 56 | ignore: (context: DecoratorContext) => context.ignoreRelation, 57 | required: (context: DecoratorContext) => context.requireRelation, 58 | sqlJoinAlias: "user_named_alias", 59 | }) 60 | testRelation!: Author; 61 | 62 | @OneToOne((type) => Author) 63 | @JoinColumn() 64 | @ConfigureLoader({ 65 | graphQLName: "remappedRelation", 66 | }) 67 | testRemappedRelation!: Author; 68 | 69 | @Field() 70 | @CreateDateColumn() 71 | createdAt!: Date; 72 | 73 | @Field() 74 | @UpdateDateColumn() 75 | updatedAt!: Date; 76 | } 77 | -------------------------------------------------------------------------------- /src/__tests__/entity/PaginatedReviews.ts: -------------------------------------------------------------------------------- 1 | import { Review } from "./Review"; 2 | import { Field, Int, ObjectType } from "type-graphql"; 3 | 4 | @ObjectType() 5 | export class PaginatedReviews { 6 | @Field((type) => [Review]) 7 | public readonly reviews: Review[]; 8 | 9 | @Field((type) => Int) 10 | public readonly offset: number; 11 | 12 | @Field() 13 | public readonly hasMore: boolean; 14 | 15 | constructor(reviews: Review[], offset: number, hasMore: boolean) { 16 | this.reviews = reviews; 17 | this.offset = offset; 18 | this.hasMore = hasMore; 19 | } 20 | 21 | @Field() 22 | public maxRating(): number { 23 | return Math.max(...this.reviews.map((review) => review.rating)); 24 | } 25 | 26 | @Field() 27 | public minRating(): number { 28 | return Math.min(...this.reviews.map((review) => review.rating)); 29 | } 30 | } 31 | 32 | @ObjectType() 33 | export class ReviewConnection { 34 | @Field((type) => Int) 35 | public readonly totalCount: number; 36 | 37 | @Field((type) => [ReviewEdge]) 38 | public readonly edges: ReviewEdge[]; 39 | 40 | constructor(totalCount: number, records: Review[]) { 41 | this.totalCount = totalCount; 42 | this.edges = records.map( 43 | (review) => new ReviewEdge(review, review.id.toString()) 44 | ); 45 | } 46 | } 47 | 48 | @ObjectType() 49 | export class ReviewEdge { 50 | @Field((type) => Review) 51 | public readonly node: Review; 52 | 53 | @Field() 54 | public readonly cursor: string; 55 | 56 | constructor(node: Review, cursor: string) { 57 | this.node = node; 58 | this.cursor = cursor; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/__tests__/entity/Publisher.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | OneToMany, 6 | PrimaryGeneratedColumn, 7 | } from "typeorm"; 8 | import { Book } from "./Book"; 9 | import { Field, Int, ObjectType } from "type-graphql"; 10 | import { Address } from "./Address"; 11 | 12 | @ObjectType() 13 | @Entity() 14 | export class Publisher extends BaseEntity { 15 | @Field((type) => Int) 16 | @PrimaryGeneratedColumn() 17 | id!: number; 18 | 19 | @Field() 20 | @Column("varchar") 21 | name!: string; 22 | 23 | @Field((type) => Address) 24 | @Column((type) => Address) 25 | address!: Address; 26 | 27 | @Field((type) => Address) 28 | @Column((type) => Address) 29 | poBox!: Address; 30 | 31 | @Field((type) => [Book], { nullable: true }) 32 | @OneToMany((type) => Book, (book) => book.publisher) 33 | books!: Book[]; 34 | } 35 | -------------------------------------------------------------------------------- /src/__tests__/entity/Review.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | } from "typeorm"; 8 | import { Book } from "./Book"; 9 | import { Field, Int, ObjectType } from "type-graphql"; 10 | 11 | @ObjectType() 12 | @Entity() 13 | export class Review extends BaseEntity { 14 | @Field((type) => Int) 15 | @PrimaryGeneratedColumn() 16 | id!: number; 17 | 18 | @Field() 19 | @Column("varchar") 20 | title!: string; 21 | 22 | @Field() 23 | @Column("text") 24 | body!: string; 25 | 26 | @Field((type) => String) 27 | @Column("date") 28 | reviewDate!: Date; 29 | 30 | @Field() 31 | @Column("int") 32 | rating!: number; 33 | 34 | @Field() 35 | @Column("varchar") 36 | reviewerName!: string; 37 | 38 | @Field((type) => Book) 39 | @ManyToOne((type) => Book, (book) => book.reviews) 40 | book!: Book; 41 | } 42 | -------------------------------------------------------------------------------- /src/__tests__/entity/index.ts: -------------------------------------------------------------------------------- 1 | export { Author } from "./Author"; 2 | 3 | export { Book } from "./Book"; 4 | 5 | export { Publisher } from "./Publisher"; 6 | 7 | export { Review } from "./Review"; 8 | 9 | export { DecoratorTest } from "./DecoratorTest"; 10 | -------------------------------------------------------------------------------- /src/__tests__/pagination.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { graphql } from "graphql"; 3 | import { startup, TestHelpers } from "./util/testStartup"; 4 | import { Review } from "./entity"; 5 | import { ReviewConnection } from "./entity/PaginatedReviews"; 6 | 7 | chai.use(require("deep-equal-in-any-order")); 8 | const { expect } = chai; 9 | 10 | describe("Pagination", () => { 11 | let helpers: TestHelpers; 12 | let reviews: Review[]; 13 | 14 | before(async () => { 15 | helpers = await startup("pagination", { logging: false }); 16 | reviews = await helpers.connection 17 | .getRepository(Review) 18 | .createQueryBuilder("review") 19 | .leftJoinAndSelect("review.book", "book") 20 | .orderBy({ rating: "DESC" }) 21 | .getMany(); 22 | }); 23 | 24 | it("can perform a simple paginated query", async () => { 25 | const { schema, loader } = helpers; 26 | const query = ` 27 | query getPaginatedReviews($offset: Int!, $limit: Int!) { 28 | paginatedReviews(offset: $offset, limit: $limit) { 29 | reviews { 30 | id 31 | title 32 | body 33 | reviewDate 34 | rating 35 | reviewerName 36 | } 37 | offset 38 | hasMore 39 | } 40 | } 41 | `; 42 | 43 | const vars = { offset: 0, limit: 10 }; 44 | const result = await graphql(schema, query, {}, { loader }, vars); 45 | 46 | const firstTenReviews = reviews.slice(0, 10); 47 | 48 | const expected = { 49 | hasMore: true, 50 | offset: 10, 51 | reviews: firstTenReviews.map( 52 | ({ id, title, body, reviewDate, rating, reviewerName }) => ({ 53 | id, 54 | title, 55 | body, 56 | reviewDate, 57 | rating, 58 | reviewerName, 59 | }) 60 | ), 61 | }; 62 | 63 | expect(result).to.not.have.key("errors"); 64 | expect(result.data?.paginatedReviews).to.deep.equal(expected); 65 | }); 66 | 67 | it("can perform a simple paginated query while selecting relations", async () => { 68 | const { schema, loader } = helpers; 69 | const query = ` 70 | query getPaginatedReviews($offset: Int!, $limit: Int!) { 71 | paginatedReviews(offset: $offset, limit: $limit) { 72 | reviews { 73 | id 74 | title 75 | body 76 | reviewDate 77 | rating 78 | reviewerName 79 | book { 80 | id 81 | title 82 | } 83 | } 84 | offset 85 | hasMore 86 | } 87 | } 88 | `; 89 | 90 | const vars = { offset: 0, limit: 10 }; 91 | const result = await graphql(schema, query, {}, { loader }, vars); 92 | 93 | const firstTenReviews = reviews.slice(0, 10); 94 | 95 | const expected = { 96 | hasMore: true, 97 | offset: 10, 98 | reviews: firstTenReviews.map( 99 | ({ id, title, body, reviewDate, rating, reviewerName, book }) => ({ 100 | id, 101 | title, 102 | body, 103 | reviewDate, 104 | rating, 105 | reviewerName, 106 | book: { 107 | id: book.id, 108 | title: book.title, 109 | }, 110 | }) 111 | ), 112 | }; 113 | 114 | expect(result).to.not.have.key("errors"); 115 | expect(result.data?.paginatedReviews).to.deep.equal(expected); 116 | }); 117 | 118 | it("can paginate through an entire record base", async () => { 119 | const { schema, loader } = helpers; 120 | const query = ` 121 | query getPaginatedReviews($offset: Int!, $limit: Int!) { 122 | paginatedReviews(offset: $offset, limit: $limit) { 123 | reviews { 124 | id 125 | title 126 | body 127 | reviewDate 128 | rating 129 | reviewerName 130 | book { 131 | id 132 | title 133 | } 134 | } 135 | offset 136 | hasMore 137 | } 138 | } 139 | `; 140 | let hasMore = true; 141 | let vars = { offset: 0, limit: 10 }; 142 | let queriedReviews: Array = []; 143 | while (hasMore) { 144 | const result = await graphql(schema, query, {}, { loader }, vars); 145 | expect(result).to.not.have.key("errors"); 146 | expect(result.data).to.have.key("paginatedReviews"); 147 | hasMore = result.data?.paginatedReviews?.hasMore; 148 | vars = { ...vars, offset: result.data?.paginatedReviews?.offset }; 149 | queriedReviews = queriedReviews.concat( 150 | result.data?.paginatedReviews?.reviews 151 | ); 152 | } 153 | 154 | const expected = reviews.map( 155 | ({ id, title, body, reviewDate, rating, reviewerName, book }) => ({ 156 | id, 157 | title, 158 | body, 159 | reviewDate, 160 | rating, 161 | reviewerName, 162 | book: { 163 | id: book.id, 164 | title: book.title, 165 | }, 166 | }) 167 | ); 168 | 169 | expect(queriedReviews).to.deep.equal(expected); 170 | }); 171 | 172 | it("can query nested items from the info object", async () => { 173 | const { connection, schema, loader } = helpers; 174 | const query = ` 175 | query nestedInfoFields { 176 | reviewConnection { 177 | totalCount 178 | edges { 179 | cursor 180 | node { 181 | id 182 | title 183 | body 184 | reviewerName 185 | rating 186 | reviewDate 187 | } 188 | } 189 | } 190 | } 191 | `; 192 | 193 | const result = await graphql(schema, query, {}, { loader }); 194 | const [reviews, count] = await connection 195 | .getRepository(Review) 196 | .createQueryBuilder("review") 197 | .limit(15) 198 | .getManyAndCount(); 199 | const expected = new ReviewConnection(count, reviews); 200 | 201 | expect(result).to.not.have.key("errors"); 202 | expect(result.data).to.have.key("reviewConnection"); 203 | // @ts-ignore 204 | expect(result.data!.reviewConnection).to.deep.equalInAnyOrder(expected); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /src/__tests__/resolvers/AddressResolver.ts: -------------------------------------------------------------------------------- 1 | import { FieldResolver, Resolver, Root } from "type-graphql"; 2 | import { Address } from "../entity/Address"; 3 | 4 | @Resolver(Address) 5 | export class AddressResolver { 6 | @FieldResolver() 7 | unitNumber(@Root() parent: Address): string { 8 | return parent.street2; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/__tests__/resolvers/AuthorResolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arg, 3 | Ctx, 4 | Info, 5 | Int, 6 | Query, 7 | registerEnumType, 8 | Resolver, 9 | } from "type-graphql"; 10 | import { Author } from "../entity"; 11 | import { GraphQLDatabaseLoader } from "../../GraphQLDatabaseLoader"; 12 | import { GraphQLResolveInfo } from "graphql"; 13 | import { LoaderSearchMethod } from "../.."; 14 | 15 | registerEnumType(LoaderSearchMethod, { 16 | name: "LoaderSearchMethod", 17 | }); 18 | 19 | @Resolver(Author) 20 | export class AuthorResolver { 21 | @Query((returns) => Author) 22 | async authorById( 23 | @Arg("id", (type) => Int) id: number, 24 | @Ctx("loader") loader: GraphQLDatabaseLoader, 25 | @Info() info: GraphQLResolveInfo 26 | ) { 27 | const author = await loader 28 | .loadEntity(Author) 29 | .where("author.id = :id", { id }) 30 | .info(info) 31 | .loadOne(); 32 | 33 | if (!author) { 34 | throw new AuthorNotFoundError(); 35 | } 36 | return author; 37 | } 38 | 39 | @Query((returns) => [Author]) 40 | searchAuthors( 41 | @Ctx("loader") loader: GraphQLDatabaseLoader, 42 | @Info() info: GraphQLResolveInfo, 43 | @Arg("searchText", (type) => String) searchText: string, 44 | @Arg("searchMethod", (type) => LoaderSearchMethod, { nullable: true }) 45 | searchMethod?: LoaderSearchMethod, 46 | @Arg("caseSensitive", (type) => Boolean, { nullable: true }) 47 | caseSensitive?: boolean, 48 | @Arg("searchCombinedName", (type) => Boolean, { nullable: true }) 49 | searchCombinedName?: boolean 50 | ) { 51 | const searchColumns = searchCombinedName 52 | ? ["email", ["firstName", "lastName"]] 53 | : ["email", "firstName", "lastName"]; 54 | return loader 55 | .loadEntity(Author) 56 | .info(info) 57 | .search({ 58 | searchText, 59 | searchColumns, 60 | searchMethod, 61 | caseSensitive, 62 | }) 63 | .loadMany(); 64 | } 65 | } 66 | 67 | class AuthorNotFoundError extends Error {} 68 | -------------------------------------------------------------------------------- /src/__tests__/resolvers/BookResolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arg, 3 | Ctx, 4 | FieldResolver, 5 | Info, 6 | Int, 7 | Mutation, 8 | Query, 9 | Resolver, 10 | Root, 11 | } from "type-graphql"; 12 | import { Author, Book, Publisher } from "../entity"; 13 | import { GraphQLDatabaseLoader } from "../../GraphQLDatabaseLoader"; 14 | import { GraphQLResolveInfo } from "graphql"; 15 | import { 16 | BookCreateError, 17 | BookCreateResultType, 18 | BookCreateSuccess, 19 | } from "../entity/Book"; 20 | import { Brackets, Connection } from "typeorm"; 21 | 22 | enum Transform { 23 | LOWERCASE = "LOWERCASE", 24 | } 25 | 26 | @Resolver(Book) 27 | export class BookResolver { 28 | @FieldResolver((returns) => String) 29 | async transformedTitle( 30 | @Arg("transform", (type) => String) transform: Transform, 31 | @Root() book: Book 32 | ) { 33 | return transform === Transform.LOWERCASE 34 | ? book.title.toLowerCase() 35 | : book.title.toUpperCase(); 36 | } 37 | 38 | @Query((returns) => [Book]) 39 | async booksByAuthorId( 40 | @Arg("authorId", (type) => Int) authorId: number, 41 | @Ctx("loader") loader: GraphQLDatabaseLoader, 42 | @Info() info: GraphQLResolveInfo 43 | ) { 44 | return loader 45 | .loadEntity(Book, "book") 46 | .where("book.authorId = :authorId", { authorId }) 47 | .where("book.isPublished IS TRUE") 48 | .info(info) 49 | .loadMany(); 50 | } 51 | 52 | @Query((returns) => [Book]) 53 | async booksByAuthorOrPublisher( 54 | @Arg("publisherId", (type) => Int) publisherId: number, 55 | @Arg("authorId", (type) => Int) authorId: number, 56 | @Arg("useBrackets", { nullable: true, defaultValue: false }) 57 | useBrackets: boolean = false, 58 | @Ctx("loader") loader: GraphQLDatabaseLoader, 59 | @Info() info: GraphQLResolveInfo 60 | ) { 61 | const orWhere = useBrackets 62 | ? new Brackets((qb) => 63 | qb.orWhere("books.authorId = :authorId", { authorId }) 64 | ) 65 | : "books.authorId = :authorId"; 66 | return loader 67 | .loadEntity(Book, "books") 68 | .where("books.publisherId = :publisherId", { publisherId }) 69 | .orWhere(orWhere, { authorId }) 70 | .info(info) 71 | .loadMany(); 72 | } 73 | 74 | @Mutation((returns) => BookCreateResultType) 75 | async createBook( 76 | @Arg("title", (type) => String) title: string, 77 | @Arg("summary", (type) => String) summary: string, 78 | @Arg("authorId", (type) => Int) authorId: number, 79 | @Arg("publisherId", (type) => Int) publisherId: number, 80 | @Ctx("loader") loader: GraphQLDatabaseLoader, 81 | @Ctx("connection") connection: Connection, 82 | @Info() info: GraphQLResolveInfo 83 | ): Promise { 84 | let book = new Book(); 85 | 86 | book.author = new Author(); 87 | book.author.id = authorId; 88 | book.publisher = new Publisher(); 89 | book.publisher.id = publisherId; 90 | 91 | book.title = title; 92 | book.createdAt = new Date(); 93 | book.updatedAt = new Date(); 94 | book.publishedDate = new Date(); 95 | book.isPublished = true; 96 | book.summary = summary; 97 | 98 | try { 99 | book = await connection.getRepository(Book).save(book); 100 | } catch (e) { 101 | return new BookCreateError("Error creating book: " + e); 102 | } 103 | 104 | return new BookCreateSuccess( 105 | (await loader 106 | .loadEntity(Book, "book") 107 | .where("book.id = :id", { 108 | id: book.id, 109 | }) 110 | .info(info, "BookCreateSuccess.data") 111 | .loadOne())! 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/__tests__/resolvers/DecoratorTestResolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arg, 3 | Ctx, 4 | FieldResolver, 5 | Info, 6 | Int, 7 | Query, 8 | Resolver, 9 | Root, 10 | } from "type-graphql"; 11 | import { Author, DecoratorTest } from "../entity"; 12 | import { GraphQLDatabaseLoader } from "../../GraphQLDatabaseLoader"; 13 | import { GraphQLResolveInfo } from "graphql"; 14 | import { DecoratorContext } from "../util/DecoratorContext"; 15 | import { Address } from "../entity/Address"; 16 | 17 | @Resolver(DecoratorTest) 18 | export class DecoratorTestResolver { 19 | @Query((returns) => DecoratorTest) 20 | async decoratorTests( 21 | @Arg("dtId", (type) => Int) dtId: number, 22 | @Arg("ignoreField", { nullable: true, defaultValue: false }) 23 | ignoreField: boolean, 24 | @Arg("requireField", { nullable: true, defaultValue: false }) 25 | requireField: boolean, 26 | @Arg("ignoreRelation", { nullable: true, defaultValue: false }) 27 | ignoreRelation: boolean, 28 | @Arg("requireRelation", { nullable: true, defaultValue: false }) 29 | requireRelation: boolean, 30 | @Arg("requireEmbed", { nullable: true, defaultValue: false }) 31 | requireEmbed: boolean, 32 | @Arg("ignoreEmbed", { nullable: true, defaultValue: false }) 33 | ignoreEmbed: boolean, 34 | @Ctx("loader") loader: GraphQLDatabaseLoader, 35 | @Info() info: GraphQLResolveInfo 36 | ) { 37 | const record = await loader 38 | .loadEntity(DecoratorTest, "dt") 39 | .info(info) 40 | .context({ 41 | ignoreRelation, 42 | ignoreEmbed, 43 | ignoreField, 44 | requireRelation, 45 | requireField, 46 | requireEmbed, 47 | }) 48 | .where("dt.id = :id", { id: dtId }) 49 | .loadOne(); 50 | 51 | if (ignoreField && record?.testField) { 52 | throw new Error( 53 | "Validation Failed: Ignored Field is present in response" 54 | ); 55 | } 56 | 57 | if (ignoreRelation && record?.testRelation) { 58 | throw new Error( 59 | "Validation Failed: Ignored Relation is present in response" 60 | ); 61 | } 62 | 63 | if (requireField && !record?.testField) { 64 | throw new Error( 65 | "Validation Failed: Required Field is missing in response" 66 | ); 67 | } 68 | 69 | if (requireRelation && !record?.testRelation) { 70 | throw new Error( 71 | "Validation Failed: Required Relation is missing in response" 72 | ); 73 | } 74 | 75 | if (requireEmbed && !record?.testEmbed) { 76 | throw new Error( 77 | "Validation Failed: Required Embed is missing in response" 78 | ); 79 | } 80 | 81 | if (ignoreEmbed && record?.testEmbed) { 82 | throw new Error( 83 | "Validation Failed: Ignored embed is present in response" 84 | ); 85 | } 86 | 87 | return record; 88 | } 89 | 90 | @Query((type) => DecoratorTest) 91 | customSQLAlias( 92 | @Arg("relationId", (type) => Int) relationId: number, 93 | @Ctx("loader") loader: GraphQLDatabaseLoader, 94 | @Info() info: GraphQLResolveInfo 95 | ) { 96 | return loader 97 | .loadEntity(DecoratorTest, "dt") 98 | .info(info) 99 | .context({ requireRelation: true }) 100 | .ejectQueryBuilder((qb) => 101 | qb.where("user_named_alias.id = :relationId", { relationId }) 102 | ) 103 | .loadOne(); 104 | } 105 | 106 | @FieldResolver() 107 | remappedField(@Root() parent: DecoratorTest): string { 108 | return parent.testRemappedField; 109 | } 110 | 111 | @FieldResolver() 112 | remappedEmbed(@Root() parent: DecoratorTest): Address { 113 | return parent.testRemappedEmbed; 114 | } 115 | 116 | @FieldResolver() 117 | remappedRelation(@Root() parent: DecoratorTest): Author { 118 | return parent.testRemappedRelation; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/__tests__/resolvers/PublisherResolver.ts: -------------------------------------------------------------------------------- 1 | import { Arg, Ctx, Info, Query, Resolver } from "type-graphql"; 2 | import { Publisher } from "../entity"; 3 | import { GraphQLDatabaseLoader } from "../../GraphQLDatabaseLoader"; 4 | import { GraphQLResolveInfo } from "graphql"; 5 | 6 | @Resolver(Publisher) 7 | export class PublisherResolver { 8 | @Query((returns) => Publisher) 9 | async publisherByBookTitle( 10 | @Arg("bookTitle") bookTitle: string, 11 | @Ctx("loader") loader: GraphQLDatabaseLoader, 12 | @Info() info: GraphQLResolveInfo 13 | ) { 14 | return loader 15 | .loadEntity(Publisher, "publisher") 16 | .ejectQueryBuilder((qb) => { 17 | qb.innerJoin("publisher.books", "book").where( 18 | "book.title = :bookTitle", 19 | { bookTitle } 20 | ); 21 | return qb; 22 | }) 23 | .info(info) 24 | .loadOne(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/__tests__/resolvers/ReviewResolver.ts: -------------------------------------------------------------------------------- 1 | import { Arg, Ctx, Info, Int, Query, Resolver } from "type-graphql"; 2 | import { PaginatedReviews, ReviewConnection } from "../entity/PaginatedReviews"; 3 | import { GraphQLDatabaseLoader } from "../../GraphQLDatabaseLoader"; 4 | import { GraphQLResolveInfo } from "graphql"; 5 | import { Review } from "../entity"; 6 | 7 | @Resolver(PaginatedReviews) 8 | export class ReviewResolver { 9 | @Query((returns) => PaginatedReviews) 10 | async paginatedReviews( 11 | @Arg("offset", (type) => Int) offset: number, 12 | @Arg("limit", (type) => Int) limit: number, 13 | @Ctx("loader") loader: GraphQLDatabaseLoader, 14 | @Info() info: GraphQLResolveInfo 15 | ): Promise { 16 | const [reviews, count] = await loader 17 | .loadEntity(Review) 18 | .info(info, "reviews") 19 | .paginate({ offset, limit }) 20 | .selectFields(["rating"]) 21 | .order({ rating: "DESC" }) 22 | .loadPaginated(); 23 | 24 | const nextOffset = offset + limit; 25 | const recordsLeft = count - nextOffset; 26 | const newOffset = recordsLeft < 1 ? count : nextOffset; 27 | return new PaginatedReviews(reviews, newOffset, newOffset !== count); 28 | } 29 | 30 | @Query((returns) => ReviewConnection) 31 | async reviewConnection( 32 | @Ctx("loader") loader: GraphQLDatabaseLoader, 33 | @Info() info: GraphQLResolveInfo 34 | ): Promise { 35 | const [reviews, count] = await loader 36 | .loadEntity(Review) 37 | .info(info, "edges.node") 38 | .selectFields(["rating"]) 39 | .paginate({ offset: 0, limit: 15 }) 40 | .loadPaginated(); 41 | return new ReviewConnection(count, reviews); 42 | } 43 | 44 | @Query((returns) => PaginatedReviews, { 45 | deprecationReason: 46 | "Only to test backwards compatibility with primary key option", 47 | }) 48 | async deprecatedPrimaryKey( 49 | @Arg("offset", (type) => Int) offset: number, 50 | @Arg("limit", (type) => Int) limit: number, 51 | @Ctx("loader") loader: GraphQLDatabaseLoader, 52 | @Info() info: GraphQLResolveInfo 53 | ): Promise { 54 | const [reviews, count] = await loader 55 | .loadEntity(Review) 56 | .info(info, "reviews") 57 | .paginate({ offset, limit }) 58 | .order({ rating: "DESC" }) 59 | .loadPaginated(); 60 | 61 | const nextOffset = offset + limit; 62 | const recordsLeft = count - nextOffset; 63 | const newOffset = recordsLeft < 1 ? count : nextOffset; 64 | return new PaginatedReviews(reviews, newOffset, newOffset !== count); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/__tests__/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | export { AuthorResolver } from "./AuthorResolver"; 2 | export { BookResolver } from "./BookResolver"; 3 | export { ReviewResolver } from "./ReviewResolver"; 4 | export { DecoratorTestResolver } from "./DecoratorTestResolver"; 5 | export { PublisherResolver } from "./PublisherResolver"; 6 | -------------------------------------------------------------------------------- /src/__tests__/searching.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from "chai"; 2 | import { graphql } from "graphql"; 3 | import { startup, TestHelpers } from "./util/testStartup"; 4 | import { Author } from "./entity"; 5 | import { Seeder } from "./util/Seeder"; 6 | 7 | chai.should(); 8 | chai.use(require("chai-things")); 9 | const { expect } = chai; 10 | 11 | const TEST_AUTHOR_EMAIL = "testingsearchemail@testingsearch.com"; 12 | const TEST_AUTHOR_FIRST_NAME = "testingSearchFirstName"; 13 | const TEST_AUTHOR_LAST_NAME = "testingSearchLastName"; 14 | const TEST_AUTHOR_PHONE = "123-456-7890"; 15 | 16 | describe("Search queries", () => { 17 | let helpers: TestHelpers; 18 | let author: Author; 19 | 20 | before(async () => { 21 | helpers = await startup("searching", { logging: false }); 22 | author = helpers.connection.getRepository(Author).create({ 23 | email: TEST_AUTHOR_EMAIL, 24 | firstName: TEST_AUTHOR_FIRST_NAME, 25 | lastName: TEST_AUTHOR_LAST_NAME, 26 | address: Seeder.addressFactory(), 27 | phone: TEST_AUTHOR_PHONE, 28 | }); 29 | author = await helpers.connection.createEntityManager().save(author); 30 | }); 31 | 32 | it("can perform a search with the default settings", async () => { 33 | const { schema, loader } = helpers; 34 | const query = ` 35 | query searchAuthorsByEmail($email: String!) { 36 | searchAuthors(searchText: $email) { 37 | id 38 | firstName 39 | lastName 40 | } 41 | } 42 | `; 43 | const vars = { email: author.email.slice(0, 5) }; 44 | 45 | const result = await graphql(schema, query, {}, { loader }, vars); 46 | 47 | const expected = { 48 | id: author.id, 49 | firstName: author.firstName, 50 | lastName: author.lastName, 51 | }; 52 | 53 | expect(result).to.not.have.key("errors"); 54 | result.data?.searchAuthors.should.include.something.that.deep.equals( 55 | expected 56 | ); 57 | }); 58 | 59 | it("can perform a STARTS_WITH search", async () => { 60 | const { schema, loader } = helpers; 61 | const query = ` 62 | query searchAuthorsByFirstName($firstName: String!) { 63 | searchAuthors(searchText: $firstName, searchMethod: STARTS_WITH) { 64 | id 65 | firstName 66 | lastName 67 | } 68 | } 69 | `; 70 | 71 | const vars = { firstName: author.firstName.slice(0, 3) }; 72 | 73 | const expected = { 74 | id: author.id, 75 | firstName: author.firstName, 76 | lastName: author.lastName, 77 | }; 78 | 79 | const result = await graphql(schema, query, {}, { loader }, vars); 80 | 81 | expect(result).to.not.have.key("errors"); 82 | result.data?.searchAuthors.should.include.something.that.deep.equals( 83 | expected 84 | ); 85 | }); 86 | 87 | it("can perform an ENDS_WITH search", async () => { 88 | const { schema, loader } = helpers; 89 | const query = ` 90 | query searchAuthorsByFirstName($firstName: String!) { 91 | searchAuthors(searchText: $firstName, searchMethod: ENDS_WITH) { 92 | id 93 | firstName 94 | lastName 95 | } 96 | } 97 | `; 98 | 99 | const vars = { 100 | firstName: author.firstName.slice(3, author.firstName.length), 101 | }; 102 | 103 | const expected = { 104 | id: author.id, 105 | firstName: author.firstName, 106 | lastName: author.lastName, 107 | }; 108 | 109 | const result = await graphql(schema, query, {}, { loader }, vars); 110 | 111 | expect(result).to.not.have.key("errors"); 112 | result.data?.searchAuthors.should.include.something.that.deep.equals( 113 | expected 114 | ); 115 | }); 116 | 117 | it("can perform a search on combined columns", async () => { 118 | const { schema, loader } = helpers; 119 | const query = ` 120 | query searchAuthorsByFirstName($firstName: String!) { 121 | searchAuthors(searchText: $firstName, searchMethod: STARTS_WITH, searchCombinedName: true) { 122 | id 123 | firstName 124 | lastName 125 | } 126 | } 127 | `; 128 | 129 | const vars = { 130 | firstName: author.firstName + " " + author.lastName.slice(0, 3), 131 | }; 132 | 133 | const expected = { 134 | id: author.id, 135 | firstName: author.firstName, 136 | lastName: author.lastName, 137 | }; 138 | 139 | const result = await graphql(schema, query, {}, { loader }, vars); 140 | 141 | expect(result).to.not.have.key("errors"); 142 | result.data?.searchAuthors.should.include.something.that.deep.equals( 143 | expected 144 | ); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/__tests__/util/DecoratorContext.ts: -------------------------------------------------------------------------------- 1 | export interface DecoratorContext { 2 | requireField: boolean; 3 | requireEmbed: boolean; 4 | requireRelation: boolean; 5 | ignoreField: boolean; 6 | ignoreEmbed: boolean; 7 | ignoreRelation: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/__tests__/util/Seeder.ts: -------------------------------------------------------------------------------- 1 | import { Connection, EntityManager } from "typeorm"; 2 | import { Author, Book, DecoratorTest, Publisher, Review } from "../entity"; 3 | import * as faker from "faker"; 4 | 5 | export class Seeder { 6 | private readonly NUM_AUTHORS = 10; 7 | private readonly NUM_PUBLISHERS = 3; 8 | private readonly NUM_BOOKS = 50; 9 | private readonly NUM_REVIEWS = 100; 10 | private readonly NUM_DECORATOR_TESTS = 10; 11 | 12 | constructor(private conn: Connection) {} 13 | 14 | public static addressFactory() { 15 | return { 16 | street: faker.address.streetAddress(), 17 | street2: faker.address.secondaryAddress(), 18 | city: faker.address.city(), 19 | state: faker.address.state(), 20 | zip: faker.address.zipCode(), 21 | }; 22 | } 23 | 24 | async seed() { 25 | await this.conn.transaction(async (entityManager) => { 26 | const authors = await this.seedAuthors(entityManager); 27 | const publishers = await this.seedPublishers(entityManager); 28 | const books = await this.seedBooks(entityManager, authors, publishers); 29 | await this.seedReviews(entityManager, books); 30 | await this.seedDecoratorTests(entityManager, authors); 31 | }); 32 | } 33 | 34 | private async seedAuthors(manager: EntityManager) { 35 | const authors: Array> = []; 36 | for (let i = 0; i < this.NUM_AUTHORS; i++) { 37 | const author: Partial = { 38 | firstName: faker.name.firstName(), 39 | lastName: faker.name.lastName(), 40 | email: faker.internet.email(), 41 | address: Seeder.addressFactory(), 42 | phone: faker.phone.phoneNumber(), 43 | }; 44 | authors.push(author); 45 | } 46 | await manager 47 | .createQueryBuilder() 48 | .insert() 49 | .into(Author) 50 | .values(authors) 51 | .execute(); 52 | 53 | return await manager.getRepository(Author).find(); 54 | } 55 | 56 | private async seedPublishers(manager: EntityManager) { 57 | const publishers: Array> = []; 58 | for (let i = 0; i < this.NUM_PUBLISHERS; i++) { 59 | const publisher: Partial = { 60 | name: faker.company.companyName(), 61 | address: Seeder.addressFactory(), 62 | poBox: Seeder.addressFactory(), 63 | }; 64 | publishers.push(publisher); 65 | } 66 | await manager 67 | .createQueryBuilder() 68 | .insert() 69 | .into(Publisher) 70 | .values(publishers) 71 | .execute(); 72 | return await manager.getRepository(Publisher).find(); 73 | } 74 | 75 | private async seedBooks( 76 | manager: EntityManager, 77 | authors: Author[], 78 | publishers: Publisher[] 79 | ) { 80 | const books: Array> = []; 81 | for (let i = 1; i <= this.NUM_BOOKS; i++) { 82 | const book: Partial = { 83 | title: faker.lorem.words(3), 84 | summary: faker.lorem.paragraph(2), 85 | publishedDate: faker.date.past(), 86 | author: authors[i % this.NUM_AUTHORS], 87 | isPublished: faker.datatype.number(10) <= 5, 88 | publisher: publishers[i % this.NUM_PUBLISHERS], 89 | }; 90 | books.push(book); 91 | } 92 | 93 | await manager 94 | .createQueryBuilder() 95 | .insert() 96 | .into(Book) 97 | .values(books) 98 | .execute(); 99 | return await manager.getRepository(Book).find(); 100 | } 101 | 102 | private async seedReviews(manager: EntityManager, books: Book[]) { 103 | const reviews: Array> = []; 104 | for (let i = 1; i <= this.NUM_REVIEWS; i++) { 105 | const review: Partial = { 106 | title: faker.lorem.words(3), 107 | body: faker.lorem.paragraph(5), 108 | reviewDate: faker.date.past(), 109 | rating: faker.datatype.number({ min: 0, max: 10 }), 110 | reviewerName: faker.name.firstName() + " " + faker.name.lastName(), 111 | book: books[i % this.NUM_BOOKS], 112 | }; 113 | reviews.push(review); 114 | } 115 | 116 | await manager 117 | .createQueryBuilder() 118 | .insert() 119 | .into(Review) 120 | .values(reviews) 121 | .execute(); 122 | } 123 | 124 | private async seedDecoratorTests( 125 | manager: EntityManager, 126 | authors: Array 127 | ) { 128 | const decoratorTests: Array> = []; 129 | for (let i = 1; i <= this.NUM_DECORATOR_TESTS; i++) { 130 | const dt: Partial = { 131 | testField: faker.lorem.words(1), 132 | testRelation: authors[i % this.NUM_AUTHORS], 133 | testEmbed: Seeder.addressFactory(), 134 | testRemappedField: faker.lorem.words(1), 135 | testRemappedEmbed: Seeder.addressFactory(), 136 | testRemappedRelation: authors[i % this.NUM_AUTHORS], 137 | }; 138 | decoratorTests.push(dt); 139 | } 140 | 141 | await manager 142 | .createQueryBuilder() 143 | .insert() 144 | .into(DecoratorTest) 145 | .values(decoratorTests) 146 | .execute(); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/__tests__/util/testStartup.ts: -------------------------------------------------------------------------------- 1 | import { Author, Book, DecoratorTest, Publisher, Review } from "../entity"; 2 | import { 3 | AuthorResolver, 4 | BookResolver, 5 | DecoratorTestResolver, 6 | PublisherResolver, 7 | ReviewResolver, 8 | } from "../resolvers"; 9 | import { Connection, createConnection } from "typeorm"; 10 | import { Seeder } from "./Seeder"; 11 | import { GraphQLDatabaseLoader } from "../../GraphQLDatabaseLoader"; 12 | import { LoaderOptions } from "../../types"; 13 | import { buildSchema } from "type-graphql"; 14 | import { GraphQLSchema, printSchema } from "graphql"; 15 | import * as fs from "fs"; 16 | import { AddressResolver } from "../resolvers/AddressResolver"; 17 | 18 | export interface TestHelpers { 19 | schema: GraphQLSchema; 20 | loader: GraphQLDatabaseLoader; 21 | connection: Connection; 22 | } 23 | 24 | export interface StartupOptions { 25 | loaderOptions?: LoaderOptions; 26 | logging?: boolean; 27 | } 28 | 29 | export async function startup( 30 | testName: string, 31 | options?: StartupOptions 32 | ): Promise { 33 | const connection = await createConnection({ 34 | name: testName, 35 | type: "sqlite", 36 | database: `:memory:`, 37 | synchronize: true, 38 | dropSchema: true, 39 | entities: [Author, Book, Publisher, Review, DecoratorTest], 40 | logging: !!options?.logging, 41 | }); 42 | 43 | const seeder = new Seeder(connection); 44 | await seeder.seed(); 45 | 46 | const loader = new GraphQLDatabaseLoader(connection, options?.loaderOptions); 47 | const schema = await buildSchema({ 48 | resolvers: [ 49 | AddressResolver, 50 | AuthorResolver, 51 | BookResolver, 52 | ReviewResolver, 53 | DecoratorTestResolver, 54 | PublisherResolver, 55 | ], 56 | }); 57 | 58 | fs.writeFile("testSchema.graphql", printSchema(schema), (err) => { 59 | if (err) { 60 | console.error(err); 61 | } 62 | }); 63 | 64 | return { schema, loader, connection }; 65 | } 66 | -------------------------------------------------------------------------------- /src/enums/LoaderNamingStrategy.ts: -------------------------------------------------------------------------------- 1 | export enum LoaderNamingStrategyEnum { 2 | /** 3 | * SQL Table columns formatted as firstName. 4 | */ 5 | CAMELCASE, 6 | /** 7 | * SQL Table columns formatted as first_name 8 | */ 9 | SNAKECASE, 10 | } 11 | -------------------------------------------------------------------------------- /src/enums/LoaderSearchMethod.ts: -------------------------------------------------------------------------------- 1 | export enum LoaderSearchMethod { 2 | /** 3 | * Uses `LIKE '%mysearch%'` 4 | */ 5 | ANY_POSITION, 6 | /** 7 | * Uses `LIKE 'mysearch%'` 8 | */ 9 | STARTS_WITH, 10 | /** 11 | * Uses `LIKE '%mysearch'` 12 | */ 13 | ENDS_WITH, 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLDatabaseLoader } from "./GraphQLDatabaseLoader"; 2 | 3 | export { 4 | WhereArgument, 5 | WhereExpression, 6 | LoaderOptions, 7 | SearchOptions, 8 | QueryPagination, 9 | FieldConfigurationPredicate, 10 | LoaderFieldConfiguration, 11 | EjectQueryCallback, 12 | GraphQLEntityFields, 13 | GraphQLFieldArgs, 14 | } from "./types"; 15 | export { GraphQLQueryBuilder } from "./GraphQLQueryBuilder"; 16 | export { LoaderNamingStrategyEnum } from "./enums/LoaderNamingStrategy"; 17 | export { LoaderSearchMethod } from "./enums/LoaderSearchMethod"; 18 | export { ConfigureLoader } from "./ConfigureLoader"; 19 | 20 | export { GraphQLDatabaseLoader }; 21 | export default GraphQLDatabaseLoader; 22 | -------------------------------------------------------------------------------- /src/lib/Formatter.ts: -------------------------------------------------------------------------------- 1 | import { LoaderNamingStrategyEnum, LoaderSearchMethod } from ".."; 2 | import { snakeCase } from "typeorm/util/StringUtils"; 3 | 4 | /** 5 | * A helper class for formatting various sql strings used by the loader 6 | * @hidden 7 | */ 8 | export class Formatter { 9 | private readonly _searchMethodMapping = new Map( 10 | [ 11 | [LoaderSearchMethod.ANY_POSITION, (text: string) => `%${text}%`], 12 | [LoaderSearchMethod.STARTS_WITH, (text: string) => `${text}%`], 13 | [LoaderSearchMethod.ENDS_WITH, (text: string) => `%${text}`], 14 | ] 15 | ); 16 | 17 | constructor(private _namingStrategy: LoaderNamingStrategyEnum) {} 18 | 19 | public columnSelection(alias: string, field: string): string { 20 | return `${alias}.${field}`; 21 | } 22 | 23 | public aliasField(alias: string, field: string): string { 24 | switch (this._namingStrategy) { 25 | case LoaderNamingStrategyEnum.SNAKECASE: 26 | return `${alias}_${snakeCase(field)}`; 27 | case LoaderNamingStrategyEnum.CAMELCASE: 28 | return `${alias}_${field}`; 29 | default: 30 | return `${alias}_${field}`; 31 | } 32 | } 33 | 34 | public getSearchMethodMapping( 35 | method: LoaderSearchMethod, 36 | searchText: string 37 | ): Function { 38 | return this._searchMethodMapping.get(method)!(searchText); 39 | } 40 | 41 | /** 42 | * Formats search columns for case sensitivity and joined search columns 43 | * @param searchColumns 44 | * @param alias 45 | * @param caseSensitive 46 | * @private 47 | */ 48 | public formatSearchColumns( 49 | searchColumns: Array>, 50 | alias: string, 51 | caseSensitive: boolean | undefined 52 | ) { 53 | return searchColumns.map((field) => { 54 | // straightforward, add a like field 55 | if (typeof field === "string") { 56 | const formattedColumnName = this.columnSelection(alias, field); 57 | return caseSensitive 58 | ? `${formattedColumnName} LIKE :searchText` 59 | : `LOWER(${formattedColumnName}) LIKE LOWER(:searchText)`; 60 | } else { 61 | // Indicates it is an array of columns we want to combine into a single 62 | // search 63 | const joinedFields = field 64 | .map((item) => this.columnSelection(alias, item)) 65 | .join(" || ' ' || "); 66 | return caseSensitive 67 | ? `${joinedFields} LIKE :searchText` 68 | : `LOWER(${joinedFields}) LIKE LOWER(:searchText)`; 69 | } 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/GraphQLInfoParser.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLResolveInfo } from "graphql"; 2 | import { 3 | FieldsByTypeName, 4 | parseResolveInfo, 5 | ResolveTree, 6 | } from "graphql-parse-resolve-info"; 7 | import { GraphQLEntityFields } from "../types"; 8 | 9 | export class GraphQLInfoParser { 10 | public parseResolveInfoModels( 11 | info: GraphQLResolveInfo, 12 | fieldName?: string 13 | ): GraphQLEntityFields { 14 | const data: ResolveTree = parseResolveInfo(info); 15 | if (data?.fieldsByTypeName) 16 | return this.recursiveInfoParser(data, true, fieldName?.split(".")); 17 | 18 | return {}; 19 | } 20 | 21 | private recursiveInfoParser( 22 | data: ResolveTree, 23 | root: boolean, 24 | fieldNames?: string[] 25 | ): GraphQLEntityFields { 26 | let result: GraphQLEntityFields = {}; 27 | // Gets definition for all models present in the tree 28 | const requestedFieldsByTypeName: FieldsByTypeName = data.fieldsByTypeName; 29 | const requestedFieldCount = Object.keys(requestedFieldsByTypeName).length; 30 | 31 | if (requestedFieldCount === 0) return {}; 32 | 33 | const path = fieldNames?.shift(); 34 | if (path) { 35 | // If this is the first step (we are processing the root of the tree) then 36 | // we should use the path value to discriminate a union type. 37 | // We need to check if this is the first call to the function 38 | // because, if there is only one returning field it is not 39 | // necessary to check the path 40 | if (root && requestedFieldCount > 1) { 41 | const subpath = fieldNames?.shift(); 42 | if (!subpath) throw new Error("Invalid path. Missing subpath"); 43 | 44 | return this.recursiveInfoParser( 45 | requestedFieldsByTypeName[path][subpath], 46 | false, 47 | fieldNames 48 | ); 49 | } 50 | } 51 | 52 | Object.values(requestedFieldsByTypeName).forEach((childFields) => { 53 | if (path) { 54 | result = this.recursiveInfoParser(childFields[path], false, fieldNames); 55 | } else { 56 | Object.entries(childFields).forEach(([fieldName, field]) => { 57 | result[fieldName] = { 58 | children: Object.keys(field).length 59 | ? this.recursiveInfoParser(field, false, fieldNames) 60 | : {}, 61 | arguments: field.args, 62 | }; 63 | }); 64 | } 65 | }); 66 | 67 | return result; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/filters.ts: -------------------------------------------------------------------------------- 1 | import { ColumnMetadata } from "typeorm/metadata/ColumnMetadata"; 2 | import { GraphQLEntityFields, RequireOrIgnoreSettings } from "../types"; 3 | import { resolvePredicate } from "../ConfigureLoader"; 4 | import { EmbeddedMetadata } from "typeorm/metadata/EmbeddedMetadata"; 5 | import { RelationMetadata } from "typeorm/metadata/RelationMetadata"; 6 | 7 | /** 8 | * A filter function used to extract the column metadata 9 | * of columns requested in the GraphQL query taking into 10 | * account ignored and remapped fields. 11 | * @hidden 12 | * @param ignoredFields 13 | * @param graphQLFieldNames 14 | * @param selection 15 | * @param context 16 | */ 17 | export const requestedFieldsFilter = ( 18 | ignoredFields: RequireOrIgnoreSettings, 19 | graphQLFieldNames: Map, 20 | selection: GraphQLEntityFields, 21 | context: any 22 | ) => (column: ColumnMetadata) => { 23 | // Handle remapping of graphql -> typeorm field 24 | const fieldName = 25 | graphQLFieldNames.get(column.propertyName) ?? column.propertyName; 26 | 27 | // Ensure field is not ignored and that it is in the selection 28 | return ( 29 | !resolvePredicate( 30 | ignoredFields.get(column.propertyName), 31 | context, 32 | selection 33 | ) && 34 | (column.isPrimary || selection.hasOwnProperty(fieldName)) 35 | ); 36 | }; 37 | 38 | /** 39 | * A filter function used to extract the embedded metadata 40 | * for embedded fields requested in a GraphQL query taking into 41 | * account ignore, and remapped fields 42 | * @hidden 43 | * @param ignoredFields 44 | * @param graphQLFieldNames 45 | * @param selection 46 | * @param context 47 | */ 48 | export const requestedEmbeddedFieldsFilter = ( 49 | ignoredFields: RequireOrIgnoreSettings, 50 | graphQLFieldNames: Map, 51 | selection: GraphQLEntityFields, 52 | context: any 53 | ) => (embed: EmbeddedMetadata) => { 54 | const fieldName = 55 | graphQLFieldNames.get(embed.propertyName) ?? embed.propertyName; 56 | 57 | return ( 58 | !resolvePredicate( 59 | ignoredFields.get(embed.propertyName), 60 | context, 61 | selection 62 | ) && selection.hasOwnProperty(fieldName) 63 | ); 64 | }; 65 | 66 | /** 67 | * A filter function used to extract the relation metadata 68 | * for relations requested in a GraphQL query taking into 69 | * account ignored, required, and remapped fields. 70 | * @hidden 71 | * @param ignoredFields 72 | * @param requiredFields 73 | * @param graphQLFieldNames 74 | * @param selection 75 | * @param context 76 | */ 77 | export const requestedRelationFilter = ( 78 | ignoredFields: RequireOrIgnoreSettings, 79 | requiredFields: RequireOrIgnoreSettings, 80 | graphQLFieldNames: Map, 81 | selection: GraphQLEntityFields, 82 | context: any 83 | ) => (relation: RelationMetadata) => { 84 | const fieldName = 85 | graphQLFieldNames.get(relation.propertyName) ?? relation.propertyName; 86 | // Pass on ignored relations 87 | return ( 88 | !resolvePredicate( 89 | ignoredFields.get(relation.propertyName), 90 | context, 91 | selection 92 | ) && 93 | // check first to see if it was queried for 94 | (selection.hasOwnProperty(fieldName) || 95 | // or if the field has been marked as required 96 | resolvePredicate( 97 | requiredFields.get(relation.propertyName), 98 | context, 99 | selection 100 | )) 101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Brackets, 3 | ObjectLiteral, 4 | OrderByCondition, 5 | SelectQueryBuilder, 6 | } from "typeorm"; 7 | import { LoaderNamingStrategyEnum } from "./enums/LoaderNamingStrategy"; 8 | import { LoaderSearchMethod } from "./enums/LoaderSearchMethod"; 9 | 10 | export type WhereArgument = string | Brackets; 11 | 12 | /** 13 | * @hidden 14 | */ 15 | export type WhereExpression = LoaderWhereExpression | Brackets; 16 | 17 | /** 18 | * @hidden 19 | */ 20 | export interface LoaderWhereExpression { 21 | condition: string; 22 | params?: ObjectLiteral; 23 | } 24 | 25 | export interface LoaderOptions { 26 | /** 27 | * Include if you are using one of the supported TypeORM custom naming strategies 28 | */ 29 | namingStrategy?: LoaderNamingStrategyEnum; 30 | /** 31 | * This column will always be loaded for every relation by the query builder. 32 | * 33 | * @deprecated The loader now automatically finds and selects the primary column from the entity metadata 34 | * so this is no longer necessary. To avoid breaking the API, the query builder will still 35 | * select this column for every relation, but the option will be removed in a future major version. 36 | */ 37 | primaryKeyColumn?: string; 38 | /** 39 | * Use this search method by default unless overwritten in a query option. Defaults to any position 40 | */ 41 | defaultSearchMethod?: LoaderSearchMethod; 42 | /** 43 | * Allows you to set a maximum query depth. This can be useful 44 | * in preventing malicious queries from locking up your database. 45 | * Defaults to Infinity 46 | */ 47 | maxQueryDepth?: number; 48 | } 49 | 50 | export interface SearchOptions { 51 | /** 52 | * The database columns to be searched 53 | * If columns need to be joined in an or, pass them in as a nested array. 54 | * e.g. ["email", ["firstName", "lastName"]] 55 | * This will produce a query like the following: 56 | * `WHERE email LIKE :searchText 57 | * OR firstName || ' ' || lastName LIKE :searchText 58 | */ 59 | searchColumns: Array>; 60 | /** 61 | * The text to be searched for 62 | */ 63 | searchText: string; 64 | /** 65 | * Optionally specify a search method. If not provided, default will be used (see LoaderOptions) 66 | */ 67 | searchMethod?: LoaderSearchMethod; 68 | /** 69 | * Whether the query is case sensitive. Default to false. Uses SQL LOWER to perform comparison 70 | */ 71 | caseSensitive?: boolean; 72 | } 73 | 74 | export interface QueryPagination { 75 | /** 76 | * the max number of records to return 77 | */ 78 | limit: number; 79 | /** 80 | * the offset from where to return records 81 | */ 82 | offset: number; 83 | } 84 | 85 | /** 86 | * This function will be called for each field at query resolution. The function will receive 87 | * whatever value was passed in the {@link GraphQLQueryBuilder.context|context} method, a string list 88 | * of queried fields from that entity, as well as the full selection object (GraphQL arguments and children) of 89 | * the current entity 90 | * 91 | * @example 92 | * ```typescript 93 | * 94 | * const requireUserPredicate = (context, queriedFields, selection) => { 95 | * return context.requireUser || queriedFields.includes('userId') || selection.userId 96 | * } 97 | * 98 | * @Entity() 99 | * class Author extends BaseEntity { 100 | * 101 | * // This relation will not be fetched if context value is false 102 | * @ConfigureLoader({ignore: (context) => context.ignoreBooks}) 103 | * @OneToMany() 104 | * books: [Book] 105 | * 106 | * // This relation will be joined if the predicate returns true 107 | * @ConfigureLoader({required: requireUserPredicate}) 108 | * @OneToOne() 109 | * user: User 110 | * 111 | * userId () { 112 | * return this.user.id 113 | * } 114 | * } 115 | * 116 | * ``` 117 | */ 118 | export type FieldConfigurationPredicate = ( 119 | context: any, 120 | queriedFields: Array, 121 | selection: GraphQLEntityFields 122 | ) => boolean; 123 | 124 | export interface LoaderFieldConfiguration { 125 | /** 126 | * When a field or relation is ignored, the loader will 127 | * never fetch it from the database, even if a matching field 128 | * name is present in the GraphQL Query. 129 | * 130 | * This is useful if you have a field that exists in both your 131 | * GraphQL schema and on your entity, but you want the field 132 | * to have custom resolve logic in order to implement 133 | * things like pagination or sorting. Ignoring the field will 134 | * improve dataloader performance as it helps prevent 135 | * over-fetching from the database. 136 | * 137 | * Please note that if a field is ignored, the entire sub-graph 138 | * will be ignored as well. 139 | */ 140 | ignore?: boolean | FieldConfigurationPredicate; 141 | 142 | /** 143 | * When a field or relation is required, the loader will always 144 | * fetch it from the database, regardless of whether or not it 145 | * was included in the GraphQL Query. 146 | * 147 | * This is useful if your entity relies on particular fields for 148 | * computed values or relations. Because the loader typically 149 | * only fetches the fields from the database that were requested 150 | * in the GraphQL query, using this option is a good way to ensure 151 | * any computed properties work the way you expect them to. 152 | * 153 | * Important note: 154 | * When requiring a relation, the loader will perform a 155 | * `leftJoinAndSelect` on the relation. This loads all the 156 | * relation's fields (essentially doing a `SELECT *` on the 157 | * relation). It does not currently perform any recursive requires 158 | * for the joined relation. 159 | */ 160 | required?: boolean | FieldConfigurationPredicate; 161 | 162 | /** 163 | * The name of this field in the GraphQL schema. If the given graphQLName 164 | * is found in a query, the loader will select this field. Please note that the 165 | * loader DOES NOT change the name on the returned TypeORM entity. You will need to provide 166 | * a field resolver to perform that mapping in your schema. This is behavior is to ensure 167 | * that the loader always returns a valid TypeORM entity. 168 | * 169 | * @example 170 | * ```typescript 171 | * // Entity Class 172 | * @Entity() 173 | * class User extends BaseEntity { 174 | * 175 | * @ConfigureLoader({ graphQLName: 'username' }) 176 | * @Column() 177 | * email: string 178 | * } 179 | * 180 | * // Resolvers 181 | * 182 | * const resolvers = { 183 | * Query: { 184 | * userQuery (root, args, context, info) { 185 | * return context.loader 186 | * .loadEntity(User, 'user') 187 | * .info(info) 188 | * .where('user.id = :id', { id: args.id }) 189 | * .loadOne() 190 | * } 191 | * }, 192 | * User: { 193 | * username (root: User) { 194 | * // If username is queried, the loader will 195 | * // include a select for the email column 196 | * // which can be resolved here. 197 | * return root.email 198 | * } 199 | * } 200 | * } 201 | * ``` 202 | */ 203 | graphQLName?: string; 204 | 205 | /** 206 | * Manually specify the alias of a table during the SQL join. 207 | * This is useful if you are wishing to add custom select/where logic 208 | * to an ejected query. Please note that custom aliases will be applied to _relations only_, 209 | * and will not affect how the loader selects columns or embedded entities. 210 | * 211 | * @example 212 | * ```typescript 213 | * // Entity Class 214 | * @Entity() 215 | * class User extends BaseEntity { 216 | * 217 | * @OneToOne() 218 | * @JoinColumn() 219 | * @ConfigureLoader({ sqlJoinAlias: "user_group", required: context => context.requireGroup }) 220 | * @Field(type => Group) 221 | * group: Group; 222 | * } 223 | * 224 | * // Resolvers 225 | * 226 | * const resolvers = { 227 | * Query: { 228 | * userByGroupIdQuery (root, args, context, info) { 229 | * return context.loader 230 | * .loadEntity(User, 'user') 231 | * .info(info) 232 | * .context({ requireGroup: true }) 233 | * .ejectQueryBuilder(qb => qb.where("user_group.id = :groupId", { groupId: args.groupId })) 234 | * .loadMany() 235 | * } 236 | * }, 237 | * } 238 | * ``` 239 | */ 240 | sqlJoinAlias?: string; 241 | } 242 | 243 | /** 244 | * @hidden 245 | */ 246 | export type RequireOrIgnoreSettings = Map< 247 | string, 248 | boolean | FieldConfigurationPredicate | undefined 249 | >; 250 | 251 | export type EjectQueryCallback = ( 252 | qb: SelectQueryBuilder 253 | ) => SelectQueryBuilder; 254 | 255 | /** 256 | * @hidden 257 | */ 258 | export interface QueryPredicates { 259 | andWhere: Array; 260 | orWhere: Array; 261 | search: Array; 262 | order: OrderByCondition; 263 | selectFields: Array; 264 | } 265 | 266 | /** 267 | * @hidden 268 | */ 269 | export interface QueueItem { 270 | many: boolean; 271 | key: string; 272 | fields: GraphQLEntityFields | null; 273 | predicates: QueryPredicates; 274 | resolve: (value?: any) => any; 275 | reject: (reason: any) => void; 276 | entity: Function | string; 277 | pagination?: QueryPagination; 278 | alias?: string; 279 | context?: any; 280 | ejectQueryCallback: EjectQueryCallback; 281 | } 282 | 283 | /** 284 | * @hidden 285 | */ 286 | export interface QueryMeta { 287 | key: string; 288 | fields: GraphQLEntityFields | null; 289 | found: boolean; 290 | item?: Promise; 291 | } 292 | 293 | export interface GraphQLFieldArgs { 294 | [key: string]: any; 295 | } 296 | 297 | /** 298 | * An object containing the requested entity fields 299 | * and arguments for a graphql query 300 | */ 301 | export type GraphQLEntityFields = { 302 | [field: string]: { 303 | children: GraphQLEntityFields; 304 | arguments?: GraphQLFieldArgs; 305 | }; 306 | }; 307 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "lib": ["esnext", "esnext.asynciterable"], /* Specify library files to be included in the compilation: */ 7 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 8 | "sourceMap": true, /* Generates corresponding '.map' file. */ 9 | "outDir": "dist/", /* Redirect output structure to the directory. */ 10 | "rootDir": "src/", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 11 | "removeComments": true, /* Do not emit comments to output. */ 12 | "typeRoots": [ 13 | "src/types", "node_modules/@types"], 14 | 15 | /* Strict Type-Checking Options */ 16 | "strict": true, /* Enable all strict type-checking options. */ 17 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 18 | "strictNullChecks": true, /* Enable strict null checks. */ 19 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 20 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 21 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 22 | 23 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 24 | 25 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 26 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 27 | }, 28 | "include": ["src", ".eslintrc.js"] 29 | } 30 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/index.ts"], 3 | "out": "public", 4 | "excludeInternal": true, 5 | "excludePrivate": true 6 | } 7 | --------------------------------------------------------------------------------