├── .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 | [](https://badge.fury.io/js/%40mando75%2Ftypeorm-graphql-loader)
8 | 
9 | [](https://opensource.org/licenses/MIT)
10 | [](https://gitlab.com/Mando75/typeorm-graphql-loader/commits/master)
11 | [](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 |
--------------------------------------------------------------------------------