lighthouse-graphql 0.1.1 → 0.2.1

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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +175 -1
  3. data/README.md +47 -3
  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/motivation.md +184 -0
  12. data/docs/mutations.md +260 -0
  13. data/docs/performance.md +138 -0
  14. data/docs/relationships.md +80 -33
  15. data/docs/resolvers.md +189 -0
  16. data/docs/validation.md +132 -0
  17. data/lib/generators/lighthouse/builder_generator.rb +30 -0
  18. data/lib/generators/lighthouse/class_generator.rb +61 -0
  19. data/lib/generators/lighthouse/install/install_generator.rb +127 -0
  20. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
  21. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
  22. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
  23. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
  24. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
  25. data/lib/generators/lighthouse/mutation_generator.rb +30 -0
  26. data/lib/generators/lighthouse/query_generator.rb +29 -0
  27. data/lib/generators/lighthouse/validator_generator.rb +36 -0
  28. data/lib/lighthouse/cache_control.rb +126 -0
  29. data/lib/lighthouse/caching.rb +68 -0
  30. data/lib/lighthouse/config.rb +85 -19
  31. data/lib/lighthouse/contracts/arg_builder.rb +8 -12
  32. data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
  33. data/lib/lighthouse/contracts/directive.rb +10 -14
  34. data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
  35. data/lib/lighthouse/contracts/field_middleware.rb +7 -11
  36. data/lib/lighthouse/contracts/field_resolver.rb +9 -13
  37. data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
  38. data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
  39. data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
  40. data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
  41. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
  42. data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
  43. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
  44. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
  45. data/lib/lighthouse/directive_resolvers/base.rb +142 -86
  46. data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
  47. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
  48. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
  49. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
  50. data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
  51. data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
  52. data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
  53. data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
  54. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
  55. data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
  56. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
  57. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
  58. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
  59. data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
  60. data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
  61. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
  62. data/lib/lighthouse/directives/arguments/base.rb +23 -31
  63. data/lib/lighthouse/directives/arguments/filters.rb +113 -93
  64. data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
  65. data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
  66. data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
  67. data/lib/lighthouse/directives/registry.rb +210 -141
  68. data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
  69. data/lib/lighthouse/errors.rb +27 -0
  70. data/lib/lighthouse/federation_directives.rb +53 -0
  71. data/lib/lighthouse/field_resolver_stack.rb +56 -0
  72. data/lib/lighthouse/generated_resolvers.rb +82 -0
  73. data/lib/lighthouse/graphql/version.rb +1 -1
  74. data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
  75. data/lib/lighthouse/ide_helper.rb +98 -0
  76. data/lib/lighthouse/namespaces.rb +21 -0
  77. data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
  78. data/lib/lighthouse/nested_mutations/executor.rb +149 -0
  79. data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
  80. data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
  81. data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
  82. data/lib/lighthouse/nested_mutations.rb +210 -0
  83. data/lib/lighthouse/pagination.rb +221 -0
  84. data/lib/lighthouse/persisted_queries.rb +167 -0
  85. data/lib/lighthouse/railtie.rb +26 -0
  86. data/lib/lighthouse/rb_lighthouse.rb +50 -29
  87. data/lib/lighthouse/reference_resolver.rb +13 -15
  88. data/lib/lighthouse/relation_batch.rb +156 -0
  89. data/lib/lighthouse/resolver_dispatch.rb +26 -0
  90. data/lib/lighthouse/schema_cache.rb +120 -0
  91. data/lib/lighthouse/schema_factory.rb +27 -37
  92. data/lib/lighthouse/schema_generator.rb +196 -239
  93. data/lib/lighthouse/schema_implementation.rb +51 -48
  94. data/lib/lighthouse/sdl_loader.rb +17 -20
  95. data/lib/lighthouse/support/arguments.rb +119 -0
  96. data/lib/lighthouse/support/authorization.rb +18 -19
  97. data/lib/lighthouse/support/batching.rb +16 -0
  98. data/lib/lighthouse/support/directive_args.rb +26 -24
  99. data/lib/lighthouse/support/model_resolver.rb +56 -56
  100. data/lib/lighthouse/support/naming.rb +55 -59
  101. data/lib/lighthouse/tasks/lighthouse.rake +33 -0
  102. data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
  103. data/sig/lighthouse/directives.rbs +158 -0
  104. data/sig/lighthouse/graphql.rbs +78 -0
  105. data/sig/lighthouse.rbs +142 -0
  106. metadata +56 -64
  107. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
  108. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
  109. data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
  110. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
  111. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
  112. data/lib/lighthouse/directives/relation_directive.rb +0 -332
@@ -0,0 +1,184 @@
1
+ # Motivation
2
+
3
+ Lighthouse GraphQL Rails brings the ideas of [Lighthouse](https://lighthouse-php.com),
4
+ the GraphQL framework for Laravel, to Ruby on Rails. This page explains those ideas:
5
+ why the schema comes first, why behaviour lives in directives, and why that works so
6
+ well on top of an opinionated model layer like Active Record.
7
+
8
+ ## Where it comes from
9
+
10
+ Lighthouse ([nuwave/lighthouse](https://github.com/nuwave/lighthouse)) changed how many
11
+ Laravel teams build GraphQL APIs. Instead of a resolver class for every field, you write
12
+ the schema in GraphQL's own language and attach **directives** that say what a field
13
+ does: `@paginate` it, follow a `@hasMany` relationship, `@create` a model, check a policy
14
+ with `@canFind`. Lighthouse reads those directives and generates efficient queries against
15
+ Eloquent, Laravel's ORM.
16
+
17
+ The idea does not depend on PHP. It depends on two things: a schema written first, and a
18
+ model layer with strong conventions. Rails has the second in Active Record, and
19
+ [graphql-ruby](https://graphql-ruby.org) gives Ruby a mature GraphQL engine to build the
20
+ first on. Lighthouse GraphQL Rails puts them together. It keeps Lighthouse's directive
21
+ names, arguments and error formats wherever they make sense, so its documentation, its
22
+ examples and the experience of Laravel teams carry over.
23
+
24
+ ## The problem with writing every resolver
25
+
26
+ Most GraphQL fields in a Rails app do one of a handful of things: list records, find one,
27
+ paginate, follow an association, filter and sort, read an attribute, or create, update
28
+ and delete a record. Written by hand, each of these is a resolver. Each resolver is a
29
+ place where:
30
+
31
+ - **the API's shape hides in Ruby.** To know what `posts` accepts and returns, you read
32
+ a type class, its fields, their resolver methods and the scopes they call.
33
+ - **N+1 queries creep in.** Every association needs its own batch loader, and every
34
+ paginated association needs a separate count, page and cursor logic.
35
+ - **the same decisions are made again**, slightly differently each time: how pages are
36
+ numbered, what a cursor contains, how validation errors are reported, which filter
37
+ arguments are allowed.
38
+
39
+ With [graphql-ruby](https://graphql-ruby.org) alone, a list of published posts with their
40
+ author and comments, filterable by author, looks roughly like this:
41
+
42
+ ```ruby
43
+ class Types::PostType < Types::BaseObject
44
+ field :id, ID, null: false
45
+ field :title, String, null: false
46
+ field :author, Types::UserType, null: false
47
+ field :comments, Types::CommentType.connection_type, null: false
48
+
49
+ def author
50
+ dataloader.with(Sources::RecordById, User).load(object.author_id)
51
+ end
52
+
53
+ def comments
54
+ object.comments.order(:created_at) # one query per post
55
+ end
56
+ end
57
+
58
+ class Types::QueryType < Types::BaseObject
59
+ field :posts, Types::PostType.connection_type, null: false do
60
+ argument :author_id, ID, required: false
61
+ end
62
+
63
+ def posts(author_id: nil)
64
+ scope = Post.published.order(created_at: :desc)
65
+ author_id ? scope.where(author_id: author_id) : scope
66
+ end
67
+ end
68
+ ```
69
+
70
+ …plus the `Sources::RecordById` batch loader, and more code to batch the comments. With
71
+ Lighthouse, it is the schema:
72
+
73
+ ```graphql
74
+ type Query {
75
+ posts(authorId: ID @eq): [Post!]! @paginate(type: CONNECTION) @builder(class: "Published")
76
+ }
77
+
78
+ type Post {
79
+ id: ID!
80
+ title: String!
81
+ author: User! @belongsTo
82
+ comments: [Comment!]! @hasMany(type: CONNECTION, scopes: ["chronological"])
83
+ }
84
+ ```
85
+
86
+ The `author` of every post loads in one query, and so does every post's page of
87
+ comments, with one more for their totals if the client asks for `pageInfo { total }`. This isn't a
88
+ special case: it is how every relationship directive works.
89
+
90
+ ## Why the schema comes first
91
+
92
+ In a schema-first API, the `.graphql` files *are* the API's definition, not something
93
+ generated from Ruby classes:
94
+
95
+ - **It is a contract you can read.** Frontend developers, mobile developers and reviewers
96
+ read the same SDL that clients query. A pull request that changes the API shows the
97
+ change in the schema diff.
98
+ - **It is designed before it is implemented.** You can agree on the types and fields
99
+ first, as a team, and the server follows the design rather than leaking its internals.
100
+ - **Tools understand it directly.** IDE plugins, code generators and schema registries
101
+ read SDL. Lighthouse's `lighthouse:ide_helper` task adds its directives and generated
102
+ types so editors validate and autocomplete the schema.
103
+ - **It says what each field does.** `posts: [Post!]! @paginate @guard @cacheControl(maxAge: 60)`
104
+ tells a reader, without opening any Ruby, that the field is paginated, requires a
105
+ signed-in user and can be cached for a minute.
106
+
107
+ ## Why directives
108
+
109
+ Directives are a small vocabulary that composes. Each one does one job, and a field
110
+ combines them:
111
+
112
+ ```graphql
113
+ type Mutation {
114
+ updatePost(input: UpdatePostInput! @spread): Post!
115
+ @guard # require a user
116
+ @canFind(ability: "update", find: "input.id") # PostPolicy#update?
117
+ @update # save through Active Record, in a transaction
118
+ @clearCache(type: "Post", idSource: { argument: "input.id" })
119
+ }
120
+ ```
121
+
122
+ Because the behaviour is declared, Lighthouse controls the order it runs in
123
+ (authentication, then authorization, then validation, then the save, then cache
124
+ invalidation) and handles the details the same way everywhere: pagination types and
125
+ cursors, validation errors keyed by argument path, `Cache-Control` headers. That
126
+ consistency is what a hand-written resolver per field rarely achieves.
127
+
128
+ ## Why it fits Active Record
129
+
130
+ A directive can only be this short if something else knows the details. In Lighthouse,
131
+ that is the model layer. Active Record is opinionated, and every convention it imposes is
132
+ information Lighthouse can use:
133
+
134
+ | Active Record already knows | So Lighthouse can |
135
+ | --- | --- |
136
+ | Associations, their foreign keys, `:through` and polymorphic links | Resolve `@hasMany`, `@belongsTo`, `@belongsToMany` and `@morph*` from the name alone, batch them, and paginate many parents in one query |
137
+ | Scopes | Narrow any query with `scopes: ["published"]` |
138
+ | Validations | Report a failed `@create` or `@update` by argument path, with the model's own messages and locale |
139
+ | Transactions and callbacks | Run nested mutations (create a post, attach tags, add comments) atomically, with your callbacks |
140
+ | Naming conventions (`Post`, `posts`, `author_id`) | Map `Post` to its model, `authorId` to `author_id`, and a field to its association, with no configuration |
141
+
142
+ The schema says **what** the API offers; the models already say **how** the data
143
+ behaves. Nothing is described twice, and your domain logic stays where Rails puts it,
144
+ in models, scopes, validations and policies, instead of moving into GraphQL resolvers.
145
+ That is the same reason Lighthouse works so well on Eloquent: both are opinionated
146
+ Active Record ORMs, and conventions are what make a declarative layer on top possible.
147
+
148
+ ## Ruby where there is real logic
149
+
150
+ Schema-first doesn't mean schema-only. When a field does something no directive
151
+ describes, write Ruby, using graphql-ruby's own classes:
152
+
153
+ - A `Query` or `Mutation` field without a resolving directive resolves by convention to
154
+ `Queries::FieldName` (a `GraphQL::Schema::Resolver`) or `Mutations::FieldName` (a
155
+ `GraphQL::Schema::Mutation`). See [Queries, Mutations and Builders](resolvers.md).
156
+ - `@builder` classes adjust a query; `@validator` contracts check arguments.
157
+ - Your own directives plug in through the registry, with the same contracts as the
158
+ built-in ones. See [Custom Directives](custom-directives.md).
159
+
160
+ Lighthouse is built on graphql-ruby, not instead of it: its Dataloader, its execution
161
+ engine and its classes are all there when you need them.
162
+
163
+ ## From small apps to large ones
164
+
165
+ The same schema serves a side project and a busy API. When it grows, opt-in features keep
166
+ it fast without changing the schema: a cached schema build for quick boots, Apollo's
167
+ persisted queries for CDN caching, and an allowlist of trusted operations. They are all
168
+ off by default. See [Performance at scale](performance.md).
169
+
170
+ ## When something else fits better
171
+
172
+ Lighthouse is at its best when the API is close to your models. Consider plain
173
+ graphql-ruby, or using Lighthouse only for part of the schema, when:
174
+
175
+ - the API is mostly computed, aggregated or fetched from other services, rather than read
176
+ from and written to your database;
177
+ - your team prefers to define the schema in Ruby and generate the SDL from it
178
+ (code-first);
179
+ - types differ so much from your tables that most fields would need their own resolver.
180
+
181
+ Both approaches run on graphql-ruby, and a Lighthouse schema can call graphql-ruby
182
+ classes anywhere, so you can start with directives and write Ruby where it pays off.
183
+
184
+ Ready to try it? Continue with [Getting Started](getting-started.md).
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.