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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +175 -1
- data/README.md +47 -3
- 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/motivation.md +184 -0
- data/docs/mutations.md +260 -0
- data/docs/performance.md +138 -0
- data/docs/relationships.md +80 -33
- data/docs/resolvers.md +189 -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
- data/sig/lighthouse/directives.rbs +158 -0
- data/sig/lighthouse/graphql.rbs +78 -0
- data/sig/lighthouse.rbs +142 -0
- metadata +56 -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/motivation.md
ADDED
|
@@ -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.
|
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.
|