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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +168 -1
  3. data/README.md +46 -2
  4. data/docs/authorization.md +116 -57
  5. data/docs/best-practices.md +16 -3
  6. data/docs/caching.md +161 -0
  7. data/docs/configuration.md +83 -4
  8. data/docs/custom-directives.md +5 -1
  9. data/docs/directives.md +117 -38
  10. data/docs/getting-started.md +84 -0
  11. data/docs/mutations.md +260 -0
  12. data/docs/performance.md +138 -0
  13. data/docs/relationships.md +80 -33
  14. data/docs/resolvers.md +179 -0
  15. data/docs/validation.md +132 -0
  16. data/lib/generators/lighthouse/builder_generator.rb +30 -0
  17. data/lib/generators/lighthouse/class_generator.rb +61 -0
  18. data/lib/generators/lighthouse/install/install_generator.rb +127 -0
  19. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
  22. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
  24. data/lib/generators/lighthouse/mutation_generator.rb +30 -0
  25. data/lib/generators/lighthouse/query_generator.rb +29 -0
  26. data/lib/generators/lighthouse/validator_generator.rb +36 -0
  27. data/lib/lighthouse/cache_control.rb +126 -0
  28. data/lib/lighthouse/caching.rb +68 -0
  29. data/lib/lighthouse/config.rb +85 -19
  30. data/lib/lighthouse/contracts/arg_builder.rb +8 -12
  31. data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
  32. data/lib/lighthouse/contracts/directive.rb +10 -14
  33. data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
  34. data/lib/lighthouse/contracts/field_middleware.rb +7 -11
  35. data/lib/lighthouse/contracts/field_resolver.rb +9 -13
  36. data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
  37. data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
  38. data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
  39. data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
  40. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
  41. data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
  42. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
  43. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
  44. data/lib/lighthouse/directive_resolvers/base.rb +142 -86
  45. data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
  46. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
  47. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
  48. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
  49. data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
  50. data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
  51. data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
  52. data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
  53. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
  54. data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
  55. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
  56. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
  57. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
  58. data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
  59. data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
  60. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
  61. data/lib/lighthouse/directives/arguments/base.rb +23 -31
  62. data/lib/lighthouse/directives/arguments/filters.rb +113 -93
  63. data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
  64. data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
  66. data/lib/lighthouse/directives/registry.rb +210 -141
  67. data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
  68. data/lib/lighthouse/errors.rb +27 -0
  69. data/lib/lighthouse/federation_directives.rb +53 -0
  70. data/lib/lighthouse/field_resolver_stack.rb +56 -0
  71. data/lib/lighthouse/generated_resolvers.rb +82 -0
  72. data/lib/lighthouse/graphql/version.rb +1 -1
  73. data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
  74. data/lib/lighthouse/ide_helper.rb +98 -0
  75. data/lib/lighthouse/namespaces.rb +21 -0
  76. data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
  77. data/lib/lighthouse/nested_mutations/executor.rb +149 -0
  78. data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
  79. data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
  80. data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
  81. data/lib/lighthouse/nested_mutations.rb +210 -0
  82. data/lib/lighthouse/pagination.rb +221 -0
  83. data/lib/lighthouse/persisted_queries.rb +167 -0
  84. data/lib/lighthouse/railtie.rb +26 -0
  85. data/lib/lighthouse/rb_lighthouse.rb +50 -29
  86. data/lib/lighthouse/reference_resolver.rb +13 -15
  87. data/lib/lighthouse/relation_batch.rb +156 -0
  88. data/lib/lighthouse/resolver_dispatch.rb +26 -0
  89. data/lib/lighthouse/schema_cache.rb +120 -0
  90. data/lib/lighthouse/schema_factory.rb +27 -37
  91. data/lib/lighthouse/schema_generator.rb +196 -239
  92. data/lib/lighthouse/schema_implementation.rb +51 -48
  93. data/lib/lighthouse/sdl_loader.rb +17 -20
  94. data/lib/lighthouse/support/arguments.rb +119 -0
  95. data/lib/lighthouse/support/authorization.rb +18 -19
  96. data/lib/lighthouse/support/batching.rb +16 -0
  97. data/lib/lighthouse/support/directive_args.rb +26 -24
  98. data/lib/lighthouse/support/model_resolver.rb +56 -56
  99. data/lib/lighthouse/support/naming.rb +55 -59
  100. data/lib/lighthouse/tasks/lighthouse.rake +33 -0
  101. data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
  102. metadata +52 -64
  103. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
  104. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
  105. data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
  106. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
  107. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
  108. 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.
@@ -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.
@@ -12,27 +12,47 @@ end
12
12
 
13
13
  ```graphql
14
14
  type Post {
15
- author: User! @belongsTo(relation: "author")
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
- Resolving relationships naively triggers one query per parent record. Lighthouse's
23
- relationship directives load through graphql-ruby's **Dataloader**, batching all
24
- sibling loads into a single query. Enable it on your schema:
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
- class AppSchema < BaseAppSchema
28
- use GraphQL::Dataloader
29
- end
41
+ AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
42
+ options: { using: { GraphQL::Dataloader => {} } }
43
+ )
30
44
  ```
31
45
 
32
- With the Dataloader in place, a query for 100 posts and their authors issues two
33
- queries, not 101.
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
- ## One-to-many — `@hasMany`
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
- `@hasMany` returns a **connection** type, so it supports pagination arguments and
44
- `edges { node { ... } }` / `nodes { ... }` selections out of the box. The inverse
45
- is `@belongsTo`.
63
+ ```graphql
64
+ { user(id: 1) { posts { title } } }
65
+ ```
46
66
 
47
- ## One-to-one — `@hasOne`
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
- profile: Profile @hasOne
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
- type Post {
59
- author: User! @belongsTo(relation: "author")
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
- ## Many-to-many — `@belongsToMany`
87
+ `defaultCount` and `maxCount` work as on `@paginate`.
64
88
 
65
- ```graphql
66
- type User {
67
- roles: [Role!]! @belongsToMany
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 equals the field name (snake_cased). When they
74
- differ, pass `relation:`:
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
- ## Filtering a relationship
123
+ ## Scopes and filters
83
124
 
84
- Relationship fields accept the same [argument filters](filtering-and-ordering.md)
85
- and [`@whereConditions`](filtering-and-ordering.md#complex-where-conditions) as
86
- root queries, so you can scope the related records:
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
- status: String @eq
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).