lighthouse-graphql 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +168 -1
- data/README.md +46 -2
- data/docs/authorization.md +116 -57
- data/docs/best-practices.md +16 -3
- data/docs/caching.md +161 -0
- data/docs/configuration.md +83 -4
- data/docs/custom-directives.md +5 -1
- data/docs/directives.md +117 -38
- data/docs/getting-started.md +84 -0
- data/docs/mutations.md +260 -0
- data/docs/performance.md +138 -0
- data/docs/relationships.md +80 -33
- data/docs/resolvers.md +179 -0
- data/docs/validation.md +132 -0
- data/lib/generators/lighthouse/builder_generator.rb +30 -0
- data/lib/generators/lighthouse/class_generator.rb +61 -0
- data/lib/generators/lighthouse/install/install_generator.rb +127 -0
- data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
- data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
- data/lib/generators/lighthouse/mutation_generator.rb +30 -0
- data/lib/generators/lighthouse/query_generator.rb +29 -0
- data/lib/generators/lighthouse/validator_generator.rb +36 -0
- data/lib/lighthouse/cache_control.rb +126 -0
- data/lib/lighthouse/caching.rb +68 -0
- data/lib/lighthouse/config.rb +85 -19
- data/lib/lighthouse/contracts/arg_builder.rb +8 -12
- data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/directive.rb +10 -14
- data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/field_middleware.rb +7 -11
- data/lib/lighthouse/contracts/field_resolver.rb +9 -13
- data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
- data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
- data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
- data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
- data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
- data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
- data/lib/lighthouse/directive_resolvers/base.rb +142 -86
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
- data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
- data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
- data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
- data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
- data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
- data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
- data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
- data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
- data/lib/lighthouse/directives/arguments/base.rb +23 -31
- data/lib/lighthouse/directives/arguments/filters.rb +113 -93
- data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
- data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
- data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
- data/lib/lighthouse/directives/registry.rb +210 -141
- data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
- data/lib/lighthouse/errors.rb +27 -0
- data/lib/lighthouse/federation_directives.rb +53 -0
- data/lib/lighthouse/field_resolver_stack.rb +56 -0
- data/lib/lighthouse/generated_resolvers.rb +82 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
- data/lib/lighthouse/ide_helper.rb +98 -0
- data/lib/lighthouse/namespaces.rb +21 -0
- data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
- data/lib/lighthouse/nested_mutations/executor.rb +149 -0
- data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
- data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
- data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
- data/lib/lighthouse/nested_mutations.rb +210 -0
- data/lib/lighthouse/pagination.rb +221 -0
- data/lib/lighthouse/persisted_queries.rb +167 -0
- data/lib/lighthouse/railtie.rb +26 -0
- data/lib/lighthouse/rb_lighthouse.rb +50 -29
- data/lib/lighthouse/reference_resolver.rb +13 -15
- data/lib/lighthouse/relation_batch.rb +156 -0
- data/lib/lighthouse/resolver_dispatch.rb +26 -0
- data/lib/lighthouse/schema_cache.rb +120 -0
- data/lib/lighthouse/schema_factory.rb +27 -37
- data/lib/lighthouse/schema_generator.rb +196 -239
- data/lib/lighthouse/schema_implementation.rb +51 -48
- data/lib/lighthouse/sdl_loader.rb +17 -20
- data/lib/lighthouse/support/arguments.rb +119 -0
- data/lib/lighthouse/support/authorization.rb +18 -19
- data/lib/lighthouse/support/batching.rb +16 -0
- data/lib/lighthouse/support/directive_args.rb +26 -24
- data/lib/lighthouse/support/model_resolver.rb +56 -56
- data/lib/lighthouse/support/naming.rb +55 -59
- data/lib/lighthouse/tasks/lighthouse.rake +33 -0
- data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
- metadata +52 -64
- data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
- data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
- data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
- data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
- data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
- data/lib/lighthouse/directives/relation_directive.rb +0 -332
data/docs/mutations.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# Mutations
|
|
2
|
+
|
|
3
|
+
Write a mutation as a [class in `app/graphql/mutations`](resolvers.md) when it
|
|
4
|
+
carries business logic. Plain create, update, upsert and delete need no Ruby:
|
|
5
|
+
`@create`, `@createMany`, `@update`, `@updateMany`, `@upsert`, `@upsertMany` and
|
|
6
|
+
`@delete` work on the model a field returns, through ActiveRecord. Nested input
|
|
7
|
+
saves its associations too. The model's validations, callbacks and associations
|
|
8
|
+
behave exactly as they do anywhere else in your app.
|
|
9
|
+
|
|
10
|
+
```graphql
|
|
11
|
+
type Mutation {
|
|
12
|
+
createPost(title: String!, body: String): Post! @create
|
|
13
|
+
updatePost(id: ID!, title: String, body: String): Post! @update
|
|
14
|
+
createPosts(inputs: [CreatePostInput!]!): [Post!]! @createMany
|
|
15
|
+
updatePosts(inputs: [UpdatePostInput!]!): [Post!]! @updateMany
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## How arguments become attributes
|
|
20
|
+
|
|
21
|
+
Each argument the client sends becomes one attribute, under its `snake_case`
|
|
22
|
+
name. Arguments the client leaves out are not assigned at all, so `@update` only
|
|
23
|
+
changes what was sent.
|
|
24
|
+
|
|
25
|
+
- **`@rename(attribute:)`** maps an argument (or input field) to a differently
|
|
26
|
+
named attribute:
|
|
27
|
+
```graphql
|
|
28
|
+
createPost(title: String!, content: String @rename(attribute: "body")): Post @create
|
|
29
|
+
```
|
|
30
|
+
- **`@spread`** merges an input object's fields into the arguments. This is the
|
|
31
|
+
usual shape for a single `input:` argument:
|
|
32
|
+
```graphql
|
|
33
|
+
createPost(input: CreatePostInput! @spread): Post @create
|
|
34
|
+
```
|
|
35
|
+
- **`@inject(context:, name:)`** sets a server-side attribute from the context,
|
|
36
|
+
so clients cannot choose it:
|
|
37
|
+
```graphql
|
|
38
|
+
createPost(title: String!): Post @create @inject(context: "current_user.id", name: "author_id")
|
|
39
|
+
```
|
|
40
|
+
- **`model:`** names the model when it is not the field's return type:
|
|
41
|
+
`@create(model: "Admin::Post")`.
|
|
42
|
+
|
|
43
|
+
## The directives
|
|
44
|
+
|
|
45
|
+
### `@create`
|
|
46
|
+
|
|
47
|
+
Builds the model from the arguments and saves it.
|
|
48
|
+
|
|
49
|
+
### `@createMany`
|
|
50
|
+
|
|
51
|
+
The field takes exactly one argument, a list of input objects. Lighthouse creates
|
|
52
|
+
one model per item.
|
|
53
|
+
|
|
54
|
+
### `@update`
|
|
55
|
+
|
|
56
|
+
Finds the model by its primary key and updates it. The key comes from the
|
|
57
|
+
argument named after the primary key (`id`), or the one renamed to it. A missing
|
|
58
|
+
record raises `Couldn't find Post with 'id'=…`, the same wording as Rails.
|
|
59
|
+
|
|
60
|
+
### `@updateMany`
|
|
61
|
+
|
|
62
|
+
Like `@createMany`, but each item carries its primary key. Without a key the
|
|
63
|
+
mutation fails with `Missing primary key for update.`
|
|
64
|
+
|
|
65
|
+
### `@upsert` / `@upsertMany`
|
|
66
|
+
|
|
67
|
+
Update the model the input identifies, or create it:
|
|
68
|
+
|
|
69
|
+
- With `identifyingColumns: ["email"]`, the model is matched on those columns.
|
|
70
|
+
All of them must be given and non-null.
|
|
71
|
+
- Otherwise it is matched on the primary key.
|
|
72
|
+
- When nothing matches, the model is created, with the given id if there is one.
|
|
73
|
+
|
|
74
|
+
```graphql
|
|
75
|
+
type Mutation {
|
|
76
|
+
upsertUser(email: String!, name: String!): User @upsert(identifyingColumns: ["email"])
|
|
77
|
+
upsertPosts(inputs: [UpsertPostInput!]!): [Post!]! @upsertMany
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `@delete`
|
|
82
|
+
|
|
83
|
+
Destroys the models the field's filter arguments select, running their callbacks,
|
|
84
|
+
and returns them: a list for a list field, else the first or `null`.
|
|
85
|
+
|
|
86
|
+
```graphql
|
|
87
|
+
type Mutation {
|
|
88
|
+
deletePost(id: ID! @whereKey): Post @delete
|
|
89
|
+
deletePosts(ids: [ID!]! @whereKey): [Post!]! @delete
|
|
90
|
+
deleteDrafts(authorId: ID! @eq): [Post!]! @delete(scopes: ["drafts"])
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Without a filter argument, it refuses to run, since it would delete every row:
|
|
95
|
+
`Would modify all models, use an argument to filter.` A model whose callbacks
|
|
96
|
+
abort the destroy is reported next to the data:
|
|
97
|
+
`Could not modify model Post with ID 1.`
|
|
98
|
+
|
|
99
|
+
## Validation errors
|
|
100
|
+
|
|
101
|
+
When a model does not save, the mutation fails with PHP Lighthouse's error
|
|
102
|
+
format. Messages are keyed by **argument path**, including the path inside a
|
|
103
|
+
spread input or the index of a list item:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"data": { "createPosts": null },
|
|
108
|
+
"errors": [{
|
|
109
|
+
"message": "Validation failed for the field [createPosts].",
|
|
110
|
+
"extensions": {
|
|
111
|
+
"validation": {
|
|
112
|
+
"inputs.1.title": ["Title can't be blank"],
|
|
113
|
+
"inputs.2.title": ["Title can't be blank"]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}]
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Messages are the models' `full_message`s, so they follow your locale files. A
|
|
121
|
+
`:base` error is keyed by the field name. For rules that don't belong to the
|
|
122
|
+
model, add a [`@validator`](validation.md). It runs before the model is saved.
|
|
123
|
+
|
|
124
|
+
## Transactions
|
|
125
|
+
|
|
126
|
+
Each mutation field runs in a database transaction. If one item of a
|
|
127
|
+
`@createMany` is invalid, the others are rolled back, and the response lists every
|
|
128
|
+
invalid item, not just the first. To turn this off:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
Lighthouse.configure { |config| config.transactional_mutations = false }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Combining with authorization and cache invalidation
|
|
135
|
+
|
|
136
|
+
```graphql
|
|
137
|
+
type Mutation {
|
|
138
|
+
createPost(input: CreatePostInput! @spread): Post!
|
|
139
|
+
@create
|
|
140
|
+
@guard
|
|
141
|
+
@canModel(ability: "create")
|
|
142
|
+
@clearCache(type: "Query", field: "latestPosts")
|
|
143
|
+
|
|
144
|
+
updatePost(input: UpdatePostInput! @spread): Post!
|
|
145
|
+
@update
|
|
146
|
+
@canFind(ability: "update", find: "input.id")
|
|
147
|
+
@clearCache(type: "Post", idSource: { argument: "input.id" })
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The order you write them in does not matter: authentication runs first, then
|
|
152
|
+
authorization, validation, and the save; `@clearCache` acts once the save has
|
|
153
|
+
succeeded. See [authorization.md](authorization.md) and [caching.md](caching.md).
|
|
154
|
+
|
|
155
|
+
## Nested mutations
|
|
156
|
+
|
|
157
|
+
An input field named after one of the model's associations carries **operations**
|
|
158
|
+
on that association, and its type declares which are allowed. This is PHP
|
|
159
|
+
Lighthouse's nested mutation API, run through ActiveRecord:
|
|
160
|
+
|
|
161
|
+
```graphql
|
|
162
|
+
type Mutation {
|
|
163
|
+
createPost(input: CreatePostInput! @spread): Post @create
|
|
164
|
+
updatePost(input: UpdatePostInput! @spread): Post @update
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
input CreatePostInput {
|
|
168
|
+
title: String!
|
|
169
|
+
author: AuthorBelongsTo # Post belongs_to :author
|
|
170
|
+
comments: CommentsHasMany # Post has_many :comments
|
|
171
|
+
tags: TagsBelongsToMany # Post has_many :tags, through: :taggings
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
input AuthorBelongsTo { connect: ID create: CreateUserInput update: UpdateUserInput upsert: UpsertUserInput }
|
|
175
|
+
input CommentsHasMany { create: [CreateCommentInput!] connect: [ID!] }
|
|
176
|
+
input TagsBelongsToMany { connect: [ID!] sync: [ID!] create: [CreateTagInput!] }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```graphql
|
|
180
|
+
mutation {
|
|
181
|
+
createPost(input: {
|
|
182
|
+
title: "Hello"
|
|
183
|
+
author: { connect: 1 }
|
|
184
|
+
comments: { create: [{ body: "First!" }] }
|
|
185
|
+
tags: { sync: [3, 4] }
|
|
186
|
+
}) { id author { name } comments { body } tags { name } }
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Operations by association
|
|
191
|
+
|
|
192
|
+
| Association | Operations |
|
|
193
|
+
| --- | --- |
|
|
194
|
+
| `belongs_to` | `connect: ID`, `create`, `update`, `upsert`, `disconnect: Boolean`, `delete: Boolean` |
|
|
195
|
+
| `belongs_to …, polymorphic: true` | `connect: { type: String!, id: ID! }`, `disconnect`, `delete` |
|
|
196
|
+
| `has_one` (also `as:`) | `create`, `update`, `upsert`, `delete: ID` |
|
|
197
|
+
| `has_many` (also `as:`) | `create`, `update`, `upsert`, `connect: [ID!]`, `disconnect: [ID!]`, `delete: [ID!]` |
|
|
198
|
+
| `has_many :through`, `has_and_belongs_to_many` | `sync`, `syncWithoutDetaching`, `create`, `update`, `upsert`, `connect`, `disconnect: [ID!]`, `delete: [ID!]` |
|
|
199
|
+
|
|
200
|
+
- `create`, `update` and `upsert` take input objects, or lists of them for
|
|
201
|
+
collections. Those inputs may nest further operations, to any depth.
|
|
202
|
+
- `null` operations are ignored, and `disconnect`/`delete` only act when `true`.
|
|
203
|
+
- A polymorphic `connect`'s `type` is a model name: `"Task"`, or an enum value
|
|
204
|
+
such as `TASK`.
|
|
205
|
+
- `has_one` refuses a second `create`: "Cannot create a related model: a Post
|
|
206
|
+
already exists for this Task. Use upsert to modify the existing model." An
|
|
207
|
+
`upsert` without an id updates the existing one.
|
|
208
|
+
- Operations run in PHP Lighthouse's fixed order, whatever order the client
|
|
209
|
+
writes them in.
|
|
210
|
+
|
|
211
|
+
### Pivot values
|
|
212
|
+
|
|
213
|
+
For a `has_many :through`, `connect`, `sync` and `syncWithoutDetaching` accept
|
|
214
|
+
inputs with an `id` plus columns of the join model:
|
|
215
|
+
|
|
216
|
+
```graphql
|
|
217
|
+
input UpdateUserRoles { sync: [RolePivot!] connect: [RolePivot!] }
|
|
218
|
+
input RolePivot { id: ID! expiresAt: String } # role id + role_assignments.expires_at
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`has_and_belongs_to_many` has no join model, so it takes plain ids only.
|
|
222
|
+
|
|
223
|
+
### When it saves, and what fails
|
|
224
|
+
|
|
225
|
+
Each record is saved **once**:
|
|
226
|
+
|
|
227
|
+
1. its attributes are assigned;
|
|
228
|
+
2. `belongs_to` operations set its foreign keys (creating or updating the parent
|
|
229
|
+
records first);
|
|
230
|
+
3. it is saved;
|
|
231
|
+
4. the operations on associations pointing back at it run.
|
|
232
|
+
|
|
233
|
+
Validation errors from any level are keyed by their full input path, e.g.
|
|
234
|
+
`input.author.create.name`. The whole mutation is one transaction, so a failure
|
|
235
|
+
anywhere rolls everything back.
|
|
236
|
+
|
|
237
|
+
### Operations as arguments
|
|
238
|
+
|
|
239
|
+
`@create`, `@update`, `@upsert` (with optional `identifyingColumns`) and `@delete`
|
|
240
|
+
on an argument or input field, with `relation:`, run that operation on the
|
|
241
|
+
association instead:
|
|
242
|
+
|
|
243
|
+
```graphql
|
|
244
|
+
input UpdateUserInput {
|
|
245
|
+
id: ID!
|
|
246
|
+
newTask: CreateTaskInput @create(relation: "tasks")
|
|
247
|
+
tasks: [UpsertTaskInput!] @upsert(relation: "tasks", identifyingColumns: ["name"])
|
|
248
|
+
deleteTasks: [ID!] @delete(relation: "tasks")
|
|
249
|
+
deleteAvatar: Boolean @delete(relation: "avatar")
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Permissions
|
|
254
|
+
|
|
255
|
+
As in PHP Lighthouse, nested `update`, `upsert`, `delete` and `disconnect` find
|
|
256
|
+
records by primary key **in the whole table**, not only among the parent's
|
|
257
|
+
current children. A nested `update` of another user's task moves the task to
|
|
258
|
+
this user. Authorize the mutation field accordingly, with `@can*` checks on the
|
|
259
|
+
field or validators that restrict the ids. There is no per-operation permission
|
|
260
|
+
mechanism.
|
data/docs/performance.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Performance at scale
|
|
2
|
+
|
|
3
|
+
A Rails process is long-lived: it builds the schema once at boot (before forking,
|
|
4
|
+
with Puma's `preload_app!`) and reuses it for every request. So PHP Lighthouse's
|
|
5
|
+
per-request schema and query caches mostly solve a problem Rails does not have.
|
|
6
|
+
Large schemas and high traffic still benefit from three options. **All are off
|
|
7
|
+
by default**; enable the ones your app needs.
|
|
8
|
+
|
|
9
|
+
| Option | What it does | Turn it on when |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| [`schema_cache`](#schema-cache) | Caches the built schema document in `tmp/cache` | The schema is large and boot time matters (autoscaling, serverless, CI) |
|
|
12
|
+
| [`watch_sdl`](#reloading-the-sdl-in-development) | Reloads the app when a `.graphql` file changes | Developing; typically `Rails.env.development?` |
|
|
13
|
+
| [`persisted_queries`](#persisted-queries) | Apollo's Automatic Persisted Queries, optionally an allowlist | Large queries, CDN caching of `GET`s, locking the API down to known operations |
|
|
14
|
+
|
|
15
|
+
## Schema cache
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Lighthouse.configure { |config| config.schema_cache = Rails.env.production? }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Building a schema parses the SDL, injects directive definitions, runs the
|
|
22
|
+
manipulators (pagination types, `whereConditions` inputs, …), parses the result
|
|
23
|
+
and builds the executable schema. The cache stores the parsed, manipulated
|
|
24
|
+
document, so a boot skips everything up to that last step.
|
|
25
|
+
|
|
26
|
+
Measured on synthetic schemas (one machine; orders of magnitude, not promises):
|
|
27
|
+
|
|
28
|
+
| Schema | Without cache | Cached boot |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| 150 models (≈1,000 types) | 260 ms | 145 ms |
|
|
31
|
+
| 600 models (≈4,300 types) | 1,060 ms | 560 ms |
|
|
32
|
+
|
|
33
|
+
It works the way Bootsnap does: a content-addressed file in
|
|
34
|
+
`tmp/cache/lighthouse` (`config.schema_cache_path` to move it).
|
|
35
|
+
|
|
36
|
+
- **The key.** It covers the SDL, the Lighthouse, graphql-ruby and Ruby versions,
|
|
37
|
+
the registered directive and type definitions, the manipulators' source and the
|
|
38
|
+
configuration they read. Change any of them and the next boot rebuilds, so a
|
|
39
|
+
stale schema cannot be served and there is nothing to clear by hand.
|
|
40
|
+
- **Writes.** They are atomic, so concurrent boots never read half a file. Only
|
|
41
|
+
the current entry is kept.
|
|
42
|
+
- **Errors.** An unreadable file is a cache miss, never an error.
|
|
43
|
+
|
|
44
|
+
To ship a warm cache, build it where the release is built (e.g. a Docker image
|
|
45
|
+
layer):
|
|
46
|
+
|
|
47
|
+
```shell
|
|
48
|
+
bin/rails lighthouse:cache # writes tmp/cache/lighthouse/schema-<key>.bin
|
|
49
|
+
bin/rails lighthouse:clear_cache # deletes it (never required: keys change on their own)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Unlike PHP Lighthouse's `lighthouse:cache`, forgetting to run it is harmless:
|
|
53
|
+
the first boot writes the cache.
|
|
54
|
+
|
|
55
|
+
## Reloading the SDL in development
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
Lighthouse.configure { |config| config.watch_sdl = Rails.env.development? }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Rails' reloader watches Ruby files, not `.graphql` ones, so without this an SDL
|
|
62
|
+
edit only shows up after restarting the server. With it, the SDL folder joins
|
|
63
|
+
`config.watchable_dirs`, and an edit reloads the app, and with it the schema, on
|
|
64
|
+
the next request, like a Ruby change. This relies on the schema constant living
|
|
65
|
+
in an autoloaded file, as the generated `app/graphql/app_schema.rb` does.
|
|
66
|
+
|
|
67
|
+
## Persisted queries
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
Lighthouse.configure do |config|
|
|
71
|
+
config.persisted_queries = true
|
|
72
|
+
config.persisted_queries_ttl = 1.week.to_i # optional; default: until the store evicts
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This is the [Automatic Persisted Queries](https://www.apollographql.com/docs/apollo-server/performance/apq/)
|
|
77
|
+
protocol, as Apollo Client (`createPersistedQueryLink`), urql and PHP
|
|
78
|
+
Lighthouse speak it:
|
|
79
|
+
|
|
80
|
+
1. The client sends `extensions: { persistedQuery: { version: 1, sha256Hash } }`
|
|
81
|
+
without the query.
|
|
82
|
+
2. A known hash runs. An unknown one answers `PersistedQueryNotFound`, and the
|
|
83
|
+
client retries with the query, which is checked against the hash and stored
|
|
84
|
+
in `config.persisted_queries_store` (default: `Rails.cache`).
|
|
85
|
+
3. With the feature off, clients get `PersistedQueryNotSupported` and simply
|
|
86
|
+
keep sending queries.
|
|
87
|
+
|
|
88
|
+
The generated controller runs requests through
|
|
89
|
+
`Lighthouse::GraphQL::PersistedQueries.execute`. A hash fits in a URL, so add a
|
|
90
|
+
`GET` route and CDNs can cache responses under the `Cache-Control` header that
|
|
91
|
+
[`@cacheControl`](caching.md#cachecontrol) computes. Rack's ETag middleware adds
|
|
92
|
+
an `ETag` too:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# config/routes.rb
|
|
96
|
+
post "/graphql", to: "graphql#execute"
|
|
97
|
+
get "/graphql", to: "graphql#execute"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Parsing and validating a query costs well under a millisecond, so this is about
|
|
101
|
+
bandwidth, CDN caching and control, not CPU.
|
|
102
|
+
|
|
103
|
+
### Trusted documents: a manifest and an allowlist
|
|
104
|
+
|
|
105
|
+
Large APIs often accept only the operations their own clients ship. Point
|
|
106
|
+
Lighthouse at a manifest generated by the client build, either Apollo's
|
|
107
|
+
persisted query manifest
|
|
108
|
+
(`{ "operations": [{ "id": "<sha256>", "body": "query …" }] }`) or a plain
|
|
109
|
+
`{ "<sha256>": "query …" }` object:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
config.persisted_queries_manifest = Rails.root.join('persisted-queries.json')
|
|
113
|
+
config.persisted_queries_only = Rails.env.production?
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- The manifest's operations are served from memory, so a cache eviction can
|
|
117
|
+
never drop them. The file is reloaded when it changes, and an id that is not
|
|
118
|
+
the SHA-256 of its body is rejected.
|
|
119
|
+
- With `persisted_queries_only`, nothing else runs, and arbitrary queries get
|
|
120
|
+
`Only persisted queries are allowed.` (`PERSISTED_QUERY_NOT_IN_LIST`).
|
|
121
|
+
|
|
122
|
+
Check in CI that a schema change does not break an operation already shipped to
|
|
123
|
+
clients:
|
|
124
|
+
|
|
125
|
+
```shell
|
|
126
|
+
bin/rails lighthouse:persisted_queries:verify SCHEMA=AppSchema
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## What is deliberately not cached
|
|
130
|
+
|
|
131
|
+
- **Query ASTs and validation results.** PHP Lighthouse caches them because PHP
|
|
132
|
+
starts from scratch on every request. Here, parsing and validating a sizeable
|
|
133
|
+
query takes about 0.2 ms and 0.6 ms. A shared store (Redis, Memcached) costs a
|
|
134
|
+
network round trip, which is more than parsing, and validation can depend on
|
|
135
|
+
the request's context, which a cache keyed by query text would skip.
|
|
136
|
+
- **Compiled Ruby.** Your app's code, and Lighthouse's, are already compiled
|
|
137
|
+
and cached by Bootsnap (instruction sequences) and YJIT. The SDL is not Ruby,
|
|
138
|
+
so there is nothing for them to compile.
|
data/docs/relationships.md
CHANGED
|
@@ -12,27 +12,47 @@ end
|
|
|
12
12
|
|
|
13
13
|
```graphql
|
|
14
14
|
type Post {
|
|
15
|
-
author: User! @belongsTo
|
|
15
|
+
author: User! @belongsTo
|
|
16
16
|
comments: [Comment!]! @hasMany
|
|
17
17
|
}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
| Directive | ActiveRecord association |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `@belongsTo` | `belongs_to` |
|
|
23
|
+
| `@hasOne` | `has_one` |
|
|
24
|
+
| `@hasMany` | `has_many` |
|
|
25
|
+
| `@belongsToMany` | `has_many :through`, `has_and_belongs_to_many` |
|
|
26
|
+
| `@morphTo` | `belongs_to …, polymorphic: true` |
|
|
27
|
+
| `@morphOne` / `@morphMany` | `has_one` / `has_many …, as:` |
|
|
28
|
+
| `@morphToMany` | `has_many :through` a polymorphic join |
|
|
29
|
+
|
|
30
|
+
The `morph*` names are PHP Lighthouse's. ActiveRecord resolves polymorphic
|
|
31
|
+
associations the same way as any other, so they behave exactly like their
|
|
32
|
+
non-polymorphic counterparts. Use whichever reads better.
|
|
33
|
+
|
|
20
34
|
## Avoiding the N+1 problem
|
|
21
35
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
The relationship directives load through graphql-ruby's **Dataloader** and
|
|
37
|
+
ActiveRecord's preloader, one query per association per batch. Enable the
|
|
38
|
+
Dataloader on your schema (the install generator does):
|
|
25
39
|
|
|
26
40
|
```ruby
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
|
|
42
|
+
options: { using: { GraphQL::Dataloader => {} } }
|
|
43
|
+
)
|
|
30
44
|
```
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
A query for 100 posts and their authors then issues two queries, not 101. A
|
|
47
|
+
many-to-many or `:through` association adds one query for the join table. Without
|
|
48
|
+
the Dataloader, the directives still work, but they load one parent at a time.
|
|
49
|
+
|
|
50
|
+
A field with `scopes:` or a filter argument the client supplied runs its own query
|
|
51
|
+
per parent, because each parent's query differs.
|
|
52
|
+
|
|
53
|
+
## Lists, and paginating them
|
|
34
54
|
|
|
35
|
-
|
|
55
|
+
Collection directives return a **plain list**, as in PHP Lighthouse:
|
|
36
56
|
|
|
37
57
|
```graphql
|
|
38
58
|
type User {
|
|
@@ -40,38 +60,59 @@ type User {
|
|
|
40
60
|
}
|
|
41
61
|
```
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
```graphql
|
|
64
|
+
{ user(id: 1) { posts { title } } }
|
|
65
|
+
```
|
|
46
66
|
|
|
47
|
-
|
|
67
|
+
To paginate, pass `type:`, which takes the same values as
|
|
68
|
+
[`@paginate`](directives.md#paginate): `PAGINATOR`, `SIMPLE` or `CONNECTION`.
|
|
69
|
+
Lighthouse rewrites the field and adds the pagination arguments:
|
|
48
70
|
|
|
49
71
|
```graphql
|
|
50
72
|
type User {
|
|
51
|
-
|
|
73
|
+
posts: [Post!]! @hasMany(type: PAGINATOR, defaultCount: 10)
|
|
74
|
+
feed: [Post!]! @hasMany(relation: "posts", type: CONNECTION)
|
|
52
75
|
}
|
|
53
76
|
```
|
|
54
77
|
|
|
55
|
-
## Inverse — `@belongsTo`
|
|
56
|
-
|
|
57
78
|
```graphql
|
|
58
|
-
|
|
59
|
-
|
|
79
|
+
{
|
|
80
|
+
user(id: 1) {
|
|
81
|
+
posts(first: 5, page: 2) { paginatorInfo { total lastPage } data { title } }
|
|
82
|
+
feed(first: 5, after: "NQ==") { pageInfo { hasNextPage endCursor } edges { node { title } } }
|
|
83
|
+
}
|
|
60
84
|
}
|
|
61
85
|
```
|
|
62
86
|
|
|
63
|
-
|
|
87
|
+
`defaultCount` and `maxCount` work as on `@paginate`.
|
|
64
88
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
89
|
+
### Paginating many parents at once
|
|
90
|
+
|
|
91
|
+
With the Dataloader, a paginated relation loads the pages of **every parent** of
|
|
92
|
+
a level together, as PHP Lighthouse does: `{ users { posts(first: 5) { … } } }`
|
|
93
|
+
costs one query for the pages, plus one for the totals if the client selects
|
|
94
|
+
`paginatorInfo` or `pageInfo` — however many users there are. The same goes for
|
|
95
|
+
`@paginate` on a nested field.
|
|
96
|
+
|
|
97
|
+
- **Pages.** A window function numbers each parent's rows in the field's order
|
|
98
|
+
(`@orderBy`, else the primary key), and one query keeps the rows of the
|
|
99
|
+
requested page: `ROW_NUMBER() OVER (PARTITION BY posts.user_id ORDER BY …)`.
|
|
100
|
+
PHP Lighthouse uses a `UNION ALL` of one query per parent instead; a window
|
|
101
|
+
function keeps the SQL the same size for 3 parents or 3,000.
|
|
102
|
+
- **Totals.** One `GROUP BY` counts every parent's related records, with the same
|
|
103
|
+
scopes and filters as the page. The count only runs when a total is asked for,
|
|
104
|
+
and never for `SIMPLE`, which fetches one extra row instead.
|
|
105
|
+
|
|
106
|
+
This needs window functions: PostgreSQL, MySQL 8+, MariaDB 10.2+ or SQLite 3.25+.
|
|
107
|
+
A relation falls back to one query per parent on other databases, without the
|
|
108
|
+
Dataloader, and when its rows can't be numbered per parent: a composite key, an
|
|
109
|
+
association scope that reads the owner (`has_many :x, ->(owner) { … }`), or
|
|
110
|
+
`DISTINCT`. The pages are the same either way.
|
|
70
111
|
|
|
71
112
|
## Renaming relations
|
|
72
113
|
|
|
73
|
-
By default the relation name
|
|
74
|
-
|
|
114
|
+
By default the relation name is the field name, snake_cased. When they differ,
|
|
115
|
+
pass `relation:`:
|
|
75
116
|
|
|
76
117
|
```graphql
|
|
77
118
|
type Post {
|
|
@@ -79,17 +120,23 @@ type Post {
|
|
|
79
120
|
}
|
|
80
121
|
```
|
|
81
122
|
|
|
82
|
-
##
|
|
123
|
+
## Scopes and filters
|
|
83
124
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
125
|
+
`scopes:` applies model scopes to the association. Relationship fields also
|
|
126
|
+
accept the same [argument filters](filtering-and-ordering.md) and
|
|
127
|
+
[`@whereConditions`](filtering-and-ordering.md#complex-where-conditions) as root
|
|
128
|
+
queries:
|
|
87
129
|
|
|
88
130
|
```graphql
|
|
89
131
|
type User {
|
|
90
132
|
publishedPosts(
|
|
91
|
-
|
|
133
|
+
title: String @like
|
|
92
134
|
orderBy: [OrderByClause!] @orderBy
|
|
93
|
-
): [Post!]! @hasMany(relation: "posts")
|
|
135
|
+
): [Post!]! @hasMany(relation: "posts", scopes: ["published"])
|
|
94
136
|
}
|
|
95
137
|
```
|
|
138
|
+
|
|
139
|
+
## Writing relationships
|
|
140
|
+
|
|
141
|
+
Creating, connecting, syncing or deleting related records from a mutation's input
|
|
142
|
+
is covered by [nested mutations](mutations.md#nested-mutations).
|