lighthouse-graphql 0.1.1 → 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 +154 -1
  3. data/README.md +42 -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 +51 -63
  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/caching.md ADDED
@@ -0,0 +1,161 @@
1
+ # Caching
2
+
3
+ Two independent layers, matching PHP Lighthouse:
4
+
5
+ - **Application cache.** `@cache` stores a field's resolved value in the Rails
6
+ cache (`Rails.cache`), so later requests skip the resolver.
7
+ - **HTTP cache.** `@cacheControl` computes the `Cache-Control` header a response
8
+ may carry, so browsers, CDNs and proxies can reuse it.
9
+
10
+ ## `@cache`
11
+
12
+ ```graphql
13
+ directive @cache(maxAge: Int, private: Boolean = false) on FIELD_DEFINITION
14
+ ```
15
+
16
+ ```graphql
17
+ type Query {
18
+ highestKnownPrime: Int! @field(resolver: "Primes") @cache
19
+ temperature: Int! @cache(maxAge: 300)
20
+ myTodos: [Todo!]! @cache(private: true)
21
+ }
22
+ ```
23
+
24
+ - **`maxAge`** is in seconds. Without it, an entry lives until `@clearCache`
25
+ invalidates it or the store evicts it.
26
+ - **`private: true`** keeps one entry per authenticated user (by `id`). Guests
27
+ share one.
28
+ - **The key** covers the parent type, the parent's key, the field and its
29
+ arguments. The parent's key is the field marked `@cacheKey`, else its first
30
+ `ID` field; root types have none.
31
+
32
+ ```graphql
33
+ type GithubProfile {
34
+ username: String @cacheKey
35
+ repos: [Repository!]! @cache # one entry per username
36
+ }
37
+ ```
38
+
39
+ Lists and relations are stored as arrays. A value the store cannot serialize,
40
+ such as a Relay connection object, is reported through
41
+ [`on_error`](configuration.md#on_error) and returned uncached. So cache list
42
+ fields, not connection fields.
43
+
44
+ The store is [`Config.cache_store`](configuration.md#cache_store), `Rails.cache`
45
+ by default. Note that Rails uses a `:null_store` in development and test unless
46
+ you enable caching (`bin/rails dev:cache`).
47
+
48
+ A cached value is still authorized and validated on every request: `@cache` runs
49
+ inside `@guard`, `@can*` and `@validator`, never around them.
50
+
51
+ ## `@clearCache`
52
+
53
+ ```graphql
54
+ directive @clearCache(type: String!, idSource: ClearCacheIdSource, field: String) repeatable on FIELD_DEFINITION
55
+
56
+ input ClearCacheIdSource {
57
+ argument: String # path of an argument, e.g. "input.id"
58
+ field: String # path in the result, e.g. "*.id"
59
+ }
60
+ ```
61
+
62
+ Put it on a mutation. It invalidates entries once the mutation has resolved
63
+ successfully; a failed mutation clears nothing.
64
+
65
+ ```graphql
66
+ type Mutation {
67
+ # Every cached field of every Post.
68
+ importPosts: Boolean @clearCache(type: "Post")
69
+
70
+ # The cached fields of the post whose id the argument holds.
71
+ updatePost(input: UpdatePostInput! @spread): Post! @update
72
+ @clearCache(type: "Post", idSource: { argument: "input.id" })
73
+
74
+ # One field of each post the mutation returned.
75
+ retitlePosts(ids: [ID!]!, title: String!): [Post!]!
76
+ @clearCache(type: "Post", idSource: { field: "*.id" }, field: "title")
77
+
78
+ # A root field.
79
+ publishPost(id: ID!): Post @clearCache(type: "Query", field: "latestPosts")
80
+ }
81
+ ```
82
+
83
+ ### How invalidation works
84
+
85
+ PHP Lighthouse clears entries through cache tags. Rails cache stores have no tags,
86
+ and `delete_matched` is not available everywhere (Solid Cache and Memcached lack
87
+ it). So Lighthouse **versions** keys instead:
88
+
89
+ 1. Each entry's key embeds four version tokens: the type, the type and id, the
90
+ type and field, and the type, id and field.
91
+ 2. `@clearCache` replaces the token it targets, so the entries built on the old
92
+ one are never read again. They expire or are evicted on their own.
93
+ 3. If a token is evicted, a new one is written, which causes misses, never stale
94
+ reads.
95
+
96
+ This works on any store, and clearing a type also clears its per-id entries.
97
+ Each cached read costs one `read_multi` for the tokens.
98
+
99
+ ## `@cacheControl`
100
+
101
+ ```graphql
102
+ directive @cacheControl(maxAge: Int! = 0, scope: CacheControlScope! = PUBLIC, inheritMaxAge: Boolean! = false)
103
+ on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
104
+ ```
105
+
106
+ Lighthouse walks the fields a query selects, fragments included, and derives one
107
+ header. These are PHP Lighthouse's rules, after Apollo's:
108
+
109
+ - `max-age` is the **lowest** `maxAge` among the contributing fields. 0 gives
110
+ `no-cache`.
111
+ - The response is `private` if any selected field is `PRIVATE`.
112
+ - **Defaults:**
113
+ - A root field without the directive is `maxAge: 0, PRIVATE`.
114
+ - Any other field returning an object, interface or union is 0, unless its
115
+ type carries `@cacheControl`.
116
+ - Scalar fields inherit their parent and don't contribute.
117
+ - A directive on the field overrides one on its type, and its omitted arguments
118
+ take the directive's defaults (0, `PUBLIC`).
119
+ - `inheritMaxAge: true` takes the parent field's `maxAge`.
120
+ - Mutations are never cacheable.
121
+
122
+ ```graphql
123
+ type Query {
124
+ publicCompanies: [Company!]! @cacheControl(maxAge: 15)
125
+ me: User! @guard @cacheControl(maxAge: 5, scope: PRIVATE)
126
+ }
127
+
128
+ type Company @cacheControl(maxAge: 40) {
129
+ users: [User!]! @hasMany @cacheControl(maxAge: 25)
130
+ }
131
+ ```
132
+
133
+ `{ publicCompanies { users { id } } }` gets `max-age=15, public`;
134
+ `{ me { id } }` gets `max-age=5, private`.
135
+
136
+ ### Sending the header
137
+
138
+ The controller from `rails generate lighthouse:install` already does this:
139
+
140
+ ```ruby
141
+ result = AppSchema.execute(params[:query], ...)
142
+ Lighthouse::GraphQL::CacheControl.apply(response, result) # no-op without @cacheControl
143
+ render json: result
144
+ ```
145
+
146
+ `Lighthouse::GraphQL::CacheControl.header(result)` returns the value without
147
+ setting it.
148
+
149
+ Things to know:
150
+
151
+ - Shared caches only store `GET` responses, and the generated route is
152
+ `POST /graphql`. Add `get '/graphql', to: 'graphql#execute'` and send cacheable
153
+ queries over `GET`, ideally as [persisted queries](performance.md#persisted-queries),
154
+ whose hash fits in a URL.
155
+ - The pagination types Lighthouse generates (`paginatorInfo`, `data`, `pageInfo`,
156
+ `edges`, `node`) inherit their parent field's `maxAge`, as in PHP Lighthouse,
157
+ so a paginated field is as cacheable as its `@cacheControl` says.
158
+ - With a `public` max-age, Rack's ETag middleware adds an `ETag`, so clients can
159
+ revalidate with `If-None-Match`.
160
+ - Rails normalizes `no-cache, private` to `no-cache`. `no-cache` already forces
161
+ every cache to revalidate, so nothing is stored stale.
@@ -10,6 +10,7 @@ require 'lighthouse-graphql'
10
10
 
11
11
  Lighthouse.configure do |config|
12
12
  config.sdl_folder = Rails.root.join('graphql').to_s
13
+ config.namespaces = { queries: ['Queries'], mutations: ['Mutations'] }
13
14
  config.resolver_namespaces = ['App::Graphql', 'Resolvers']
14
15
  config.on_error = ->(error, _context) { Rails.error.report(error) }
15
16
  end
@@ -23,12 +24,28 @@ Where Lighthouse loads `*.graphql` files from. Defaults to `RAILS_ROOT/graphql`
23
24
  in a Rails app, `nil` otherwise (pass SDL explicitly via `SchemaFactory` when not
24
25
  on Rails).
25
26
 
27
+ ### `namespaces`
28
+
29
+ Where each kind of class a schema names is looked up, by kind — they have
30
+ different contracts, so they live apart ([resolvers.md](resolvers.md)):
31
+
32
+ | Kind | Default | Holds |
33
+ | --- | --- | --- |
34
+ | `queries` | `['Queries']` | `GraphQL::Schema::Resolver` classes for `Query` fields |
35
+ | `mutations` | `['Mutations']` | `GraphQL::Schema::Mutation` classes for `Mutation` fields |
36
+ | `builders` | `['Builders']` | `@builder` classes |
37
+ | `validators` | `['Validators']` | `@validator` contracts |
38
+
39
+ ```ruby
40
+ config.namespaces = { queries: ['Api::Queries'] } # the other kinds keep their defaults
41
+ ```
42
+
26
43
  ### `resolver_namespaces`
27
44
 
28
- An ordered list of namespaces tried when resolving a **class name** referenced by
29
- a directive argument — `@field(resolver: "ContactPerson")`, `@all(model: "User")`,
30
- `@builder(class: "ContactsBuilder")`. Fully-qualified names (containing `::`) are
31
- used as-is. Defaults to `['App::Graphql']`.
45
+ An ordered list of namespaces tried for **models** named by a directive
46
+ (`@all(model: "User")`) and for `@field(resolver:)` on types other than `Query`
47
+ and `Mutation`. Fully-qualified names (containing `::`) are used as-is. Defaults
48
+ to `['App::Graphql']`.
32
49
 
33
50
  ```ruby
34
51
  config.resolver_namespaces = ['App::Graphql', 'Resolvers']
@@ -48,6 +65,52 @@ reporter in production:
48
65
  config.on_error = ->(error, _ctx) { Sentry.capture_exception(error) }
49
66
  ```
50
67
 
68
+ ### `cache_store`
69
+
70
+ The store behind [`@cache` / `@clearCache`](caching.md). Defaults to
71
+ `Rails.cache` (an in-memory store outside Rails).
72
+
73
+ ```ruby
74
+ config.cache_store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV['REDIS_URL'])
75
+ ```
76
+
77
+ ### `pagination_default_count`, `pagination_max_count`
78
+
79
+ Defaults for paginated fields: the `first` used when a client leaves it out (so
80
+ it is no longer required), and the largest `first` allowed. A directive's
81
+ `defaultCount` / `maxCount` override them; `maxCount: null` lifts the cap.
82
+
83
+ ```ruby
84
+ config.pagination_default_count = 25
85
+ config.pagination_max_count = 100
86
+ ```
87
+
88
+ ### `schema_cache`, `schema_cache_path`, `watch_sdl`, `persisted_queries*`
89
+
90
+ Options for large schemas and high traffic, all off by default. See
91
+ [performance.md](performance.md).
92
+
93
+ ```ruby
94
+ config.schema_cache = Rails.env.production? # cache the built schema in tmp/cache
95
+ config.watch_sdl = Rails.env.development? # reload when a .graphql file changes
96
+ config.persisted_queries = true # Apollo's Automatic Persisted Queries
97
+ config.persisted_queries_store = Rails.cache # where APQ keeps queries (default: cache_store)
98
+ config.persisted_queries_ttl = 1.week.to_i # how long they live there
99
+ config.persisted_queries_manifest = Rails.root.join('persisted-queries.json') # trusted operations
100
+ config.persisted_queries_only = Rails.env.production? # run nothing else
101
+ ```
102
+
103
+ ### `transactional_mutations`
104
+
105
+ Whether `@create` / `@update` / `@createMany` / `@updateMany` run in a database
106
+ transaction. Defaults to `true`.
107
+
108
+ ### `authenticated_user`, `policy_user`, `authorizer`
109
+
110
+ The authentication and authorization seams behind `@guard` and the `@can*`
111
+ family. The authorizer is `->(user, record, ability, args = [])`, Pundit by
112
+ default. See [authorization.md](authorization.md#configuration).
113
+
51
114
  ## Extending the registry
52
115
 
53
116
  Register app-defined directives (see [custom-directives.md](custom-directives.md))
@@ -57,6 +120,22 @@ from the same initializer:
57
120
  Lighthouse::DirectiveRegistry.register(App::Graphql::Directives::StartsWith)
58
121
  ```
59
122
 
123
+ Directives implemented inside another class — no class of their own to
124
+ register — contribute their SDL definition with `register_definition`, so it is
125
+ injected into the schema and written by `bin/rails lighthouse:ide_helper`:
126
+
127
+ ```ruby
128
+ Lighthouse::DirectiveRegistry.default.register_definition(
129
+ 'tenant', 'directive @tenant(column: String) on FIELD_DEFINITION'
130
+ )
131
+ ```
132
+
133
+ Types a definition refers to (an enum argument, say) are registered with
134
+ `register_type_definition('TenantMode', 'enum TenantMode { STRICT LOOSE }')`, or
135
+ returned by the directive class's `type_definitions` (`{ name => sdl }`). They
136
+ are injected only alongside a directive that uses them, and only if the schema
137
+ doesn't define them itself.
138
+
60
139
  ## Federation reference resolvers
61
140
 
62
141
  Custom federation entity resolvers are registered with
@@ -40,8 +40,12 @@ def self.directive_name = 'upcase'
40
40
  def self.definition = 'directive @upcase on FIELD_DEFINITION'
41
41
  ```
42
42
 
43
+ If the definition refers to types of its own (an enum argument, say), return
44
+ them from `def self.type_definitions = { 'Mode' => 'enum Mode { A B }' }`.
45
+
43
46
  Lighthouse injects that `definition` into the schema automatically, so apps never
44
- hand-declare your directive.
47
+ hand-declare your directive, and `bin/rails lighthouse:ide_helper` writes it out
48
+ for the IDE (see [IDE support](getting-started.md#ide-support)).
45
49
 
46
50
  ## Example 1 — an argument filter
47
51
 
data/docs/directives.md CHANGED
@@ -24,21 +24,27 @@ type Query {
24
24
  }
25
25
  ```
26
26
 
27
- The model defaults to the field's return type (`User`). Override with
28
- `@all(model: "Account")`. Combine with [argument filters](filtering-and-ordering.md)
29
- and [`@orderBy`](#orderby) to constrain the result.
27
+ It returns a plain list. The model defaults to the field's return type (`User`).
28
+ Override it with `@all(model: "Account")`, and narrow it with model scopes:
29
+ `@all(scopes: ["active"])`. Combine with
30
+ [argument filters](filtering-and-ordering.md) and [`@orderBy`](#orderby) to
31
+ constrain the result.
30
32
 
31
33
  ### `@find`
32
34
 
33
- Return a single record, typically by id. Pair it with an argument filter such as
34
- [`@eq`](#eq) so Lighthouse knows what to match on.
35
+ Return the single record the field's argument filters select. More than one match
36
+ is an error (`The query returned more than one result.`); none returns `null`.
35
37
 
36
38
  ```graphql
37
39
  type Query {
38
40
  user(id: ID! @eq): User @find
41
+ userByEmail(email: String! @eq): User @find
39
42
  }
40
43
  ```
41
44
 
45
+ A lookup by `id` alone is batched through the Dataloader. An `id` argument without
46
+ a filter directive still selects by primary key. `scopes:` applies model scopes.
47
+
42
48
  ### `@first`
43
49
 
44
50
  Like `@all`, but returns the **first** matching record instead of a list. Useful
@@ -46,31 +52,43 @@ with filters to fetch one record by criteria.
46
52
 
47
53
  ```graphql
48
54
  type Query {
49
- latestPost(authorId: ID! @eq): Post @first
55
+ latestPost(authorId: ID! @eq): Post @first(scopes: ["published"])
50
56
  }
51
57
  ```
52
58
 
53
59
  ### `@paginate`
54
60
 
55
- Return a paginated list. Lighthouse rewrites the field to a Paginator type and
56
- adds pagination arguments.
61
+ Return one page of a model's records. Lighthouse rewrites the field and adds the
62
+ pagination arguments, exactly as PHP Lighthouse does:
63
+
64
+ | `type:` | Arguments | Returns |
65
+ | --- | --- | --- |
66
+ | `PAGINATOR` (default) | `first: Int!`, `page: Int` | `PostPaginator! { paginatorInfo: PaginatorInfo!, data: [Post!]! }` |
67
+ | `SIMPLE` | `first: Int!`, `page: Int` | `PostSimplePaginator! { paginatorInfo: SimplePaginatorInfo!, data }`, with no total, so one query fewer |
68
+ | `CONNECTION` | `first: Int!`, `after: String` | `PostConnection! { pageInfo: PageInfo!, edges: [PostEdge!]! }`, Relay style |
57
69
 
58
70
  ```graphql
59
71
  type Query {
60
- posts: [Post!]! @paginate
72
+ posts(title: String @like): [Post!]! @paginate(defaultCount: 20, maxCount: 100)
61
73
  }
62
74
  ```
63
75
 
64
- becomes, in effect:
65
-
66
76
  ```graphql
67
- type Query {
68
- posts(first: Int, after: String): PostConnection
69
- }
77
+ { posts(first: 10, page: 2) { paginatorInfo { total lastPage hasMorePages } data { title } } }
70
78
  ```
71
79
 
72
- Use `@paginate(type: "CONNECTION")` for Relay-style cursor connections, or the
73
- default offset-based paginator otherwise.
80
+ - **The query.** It is the model's (`model:`, else the return type), narrowed by
81
+ `scopes:`, [`@builder`](#builder) and the field's argument filters. On a nested
82
+ field it pages the parent's association of the same name: `User.posts @paginate`
83
+ pages the user's posts.
84
+ - **`first`.** It defaults to `defaultCount`, else
85
+ [`Config.pagination_default_count`](configuration.md#pagination_default_count--pagination_max_count).
86
+ Without either, clients must pass it.
87
+ - **`maxCount`.** It caps `first`, overriding `Config.pagination_max_count`;
88
+ `maxCount: null` means unlimited.
89
+ - **The total** is only counted when the client selects `paginatorInfo` or
90
+ `pageInfo`.
91
+ - **Cursors** are base64 of the item's position.
74
92
 
75
93
  ### `@count`
76
94
 
@@ -79,6 +97,7 @@ Return the number of records — of a model, or of a relation on the parent obje
79
97
  ```graphql
80
98
  type Query {
81
99
  userCount: Int! @count(model: "User")
100
+ activeUserCount: Int! @count(model: "User", scopes: ["active"])
82
101
  }
83
102
 
84
103
  type Account {
@@ -92,7 +111,7 @@ Compute an aggregate (`SUM`, `AVG`, `MIN`, `MAX`, `COUNT`) over a column.
92
111
 
93
112
  ```graphql
94
113
  type Account {
95
- totalRevenue: Float @aggregate(relation: "orders", column: "amount", function: SUM)
114
+ totalRevenue: Float @aggregate(relation: "orders", column: "amount", function: "SUM")
96
115
  }
97
116
  ```
98
117
 
@@ -105,10 +124,11 @@ See [relationships.md](relationships.md) for the full guide.
105
124
 
106
125
  | Directive | Association |
107
126
  | --- | --- |
108
- | `@hasMany` | one-to-many (returns a connection) |
127
+ | `@hasMany` | one-to-many: a list, or paginated with `type:` |
109
128
  | `@hasOne` | one-to-one |
110
129
  | `@belongsTo` | inverse of has-one/has-many |
111
- | `@belongsToMany` | many-to-many |
130
+ | `@belongsToMany` | many-to-many: a list, or paginated with `type:` |
131
+ | `@morphTo`, `@morphOne`, `@morphMany`, `@morphToMany` | the polymorphic variants |
112
132
 
113
133
  ```graphql
114
134
  type User {
@@ -128,8 +148,11 @@ The relation name defaults to the field name (snake_cased). Override with
128
148
 
129
149
  ### `@field`
130
150
 
131
- Delegate a field to a custom resolver class — your escape hatch for anything the
132
- built-in directives don't cover.
151
+ Delegate a field to a class — your escape hatch for anything the built-in
152
+ directives don't cover. On `Query` and `Mutation` you usually don't need it:
153
+ a field without a resolving directive resolves to `Queries::<FieldName>` /
154
+ `Mutations::<FieldName>`, graphql-ruby resolver and mutation classes. See
155
+ [resolvers.md](resolvers.md).
133
156
 
134
157
  ```graphql
135
158
  type Contact {
@@ -137,10 +160,11 @@ type Contact {
137
160
  }
138
161
  ```
139
162
 
140
- The resolver is resolved against your configured
141
- [`resolver_namespaces`](configuration.md). It may be a class with a class-level
142
- `call`, an instance `#call`, or an instance `#resolve`, each receiving
143
- `(object, args, context)`:
163
+ On `Query` and `Mutation` the name is looked up in the queries / mutations
164
+ namespaces, elsewhere in [`resolver_namespaces`](configuration.md). The class may
165
+ be a `GraphQL::Schema::Resolver` (graphql-ruby's contract), or a plain callable —
166
+ a class-level `call`, an instance `#call`, or an instance `#resolve`, each
167
+ receiving `(object, args, context)`:
144
168
 
145
169
  ```ruby
146
170
  module Resolvers
@@ -189,25 +213,27 @@ Require an authenticated user before the field resolves; otherwise raise
189
213
 
190
214
  ```graphql
191
215
  type Query {
192
- me: User @field(resolver: "Resolvers::CurrentUser") @guard
216
+ me: User @guard
193
217
  }
194
218
  ```
195
219
 
196
- ### `@can`
220
+ ### `@canFind`, `@canModel`, `@canQuery`, `@canResolved`, `@canRoot`
197
221
 
198
- Authorize a field with a policy ability (Pundit by default; configurable). By
199
- default it authorizes the resolved record. `repeatable`.
222
+ Authorize a field with a policy ability (Pundit by default; configurable) — each
223
+ directive authorizes something different: the models an argument identifies, the
224
+ model class, the models the field's filters select, the resolved value, or the
225
+ parent object. All `repeatable`; shared arguments `ability`, `injectArgs`, `args`,
226
+ `action`, `returnValue`.
200
227
 
201
228
  ```graphql
202
- type Query {
203
- contact(id: ID!): Contact
204
- @field(resolver: "Resolvers::ContactResolver")
205
- @can(ability: "show")
229
+ type Mutation {
230
+ createPost(input: CreatePostInput! @spread): Post @create @canModel(ability: "create")
231
+ updatePost(id: ID!, title: String): Post @update @canFind(ability: "update", find: "id")
206
232
  }
207
233
  ```
208
234
 
209
- `@can(ability:, find:, model:, root:)` — see [authorization.md](authorization.md)
210
- for the `find`/`model`/`root` modes.
235
+ See [authorization.md](authorization.md). (`@can` was removed in favour of this
236
+ family — it has a migration table.)
211
237
 
212
238
  ### `@auth`
213
239
 
@@ -231,13 +257,16 @@ Apply a custom method to the query builder before the field resolves. Repeatable
231
257
 
232
258
  ```graphql
233
259
  type Query {
234
- contacts: [Contact!]! @paginate @builder(class: "Resolvers::ContactsBuilder")
260
+ contacts: [Contact!]! @paginate @builder(class: "Unassigned")
235
261
  }
236
262
  ```
237
263
 
264
+ Builders are looked up in their own namespace, `Builders::` (app/graphql/builders)
265
+ — they are not resolvers. See [resolvers.md](resolvers.md#builders).
266
+
238
267
  ```ruby
239
- module Resolvers
240
- class ContactsBuilder
268
+ module Builders
269
+ class Unassigned
241
270
  def self.call(relation, args, _ctx)
242
271
  args[:unassigned] ? relation.left_joins(:assignments).where(assignments: { id: nil }) : relation
243
272
  end
@@ -260,6 +289,7 @@ Full guide: [filtering-and-ordering.md](filtering-and-ordering.md).
260
289
  | `@in` | `column IN (values)` |
261
290
  | `@notIn` | `column NOT IN (values)` |
262
291
  | `@like` | `column LIKE %value%` |
292
+ | `@whereKey` | `primary key = value` (`IN` for a list) |
263
293
  | `@where(operator:)` | generic operator (`GT`, `GTE`, `IN`, `IS_NULL`, `CONTAINS`, …) |
264
294
  | `@orderBy` | sort by one or more columns |
265
295
 
@@ -290,6 +320,55 @@ type Query {
290
320
 
291
321
  ---
292
322
 
323
+ ## Mutation directives
324
+
325
+ `@create`, `@createMany`, `@update`, `@updateMany`, `@upsert` and `@upsertMany`
326
+ save the model a field returns through ActiveRecord, with its validations, in a
327
+ transaction. Input on its associations runs as
328
+ [nested mutations](mutations.md#nested-mutations). `@delete` destroys the records
329
+ its filter arguments (`@whereKey`, `@eq`, …) select. `@spread` flattens an input
330
+ argument into the attributes, `@rename` maps an argument to another attribute,
331
+ and `@inject(context:, name:)` sets server-side attributes.
332
+
333
+ ```graphql
334
+ type Mutation {
335
+ createPost(input: CreatePostInput! @spread): Post!
336
+ @create @inject(context: "current_user.id", name: "author_id")
337
+ updatePost(id: ID!, title: String): Post! @update
338
+ }
339
+ ```
340
+
341
+ See [mutations.md](mutations.md).
342
+
343
+ ---
344
+
345
+ ## Validation
346
+
347
+ `@validator` runs a contract (Dry::Validation by default) before a field
348
+ resolves — on a field, or on an input type wherever it is used. Model validations
349
+ need no directive. See [validation.md](validation.md).
350
+
351
+ ```graphql
352
+ input CreateUserInput @validator { email: String password: String }
353
+ ```
354
+
355
+ ---
356
+
357
+ ## Caching
358
+
359
+ `@cache(maxAge:, private:)` keeps a field's value in `Rails.cache`, keyed with the
360
+ help of `@cacheKey`; `@clearCache(type:, idSource:, field:)` invalidates it from a
361
+ mutation. `@cacheControl(maxAge:, scope:, inheritMaxAge:)` computes the response's
362
+ HTTP `Cache-Control` header. See [caching.md](caching.md).
363
+
364
+ ```graphql
365
+ type Query {
366
+ temperature: Int! @cache(maxAge: 300) @cacheControl(maxAge: 300)
367
+ }
368
+ ```
369
+
370
+ ---
371
+
293
372
  ## Adding your own
294
373
 
295
374
  Every directive above is a small class registered with
@@ -18,6 +18,39 @@ Runtime dependencies (`graphql ~> 2.5`, `activesupport`) come with the gem. If y
18
18
  want federation, add `apollo-federation` to your app — it is intentionally **not**
19
19
  a hard dependency of Lighthouse so non-federated apps stay lean.
20
20
 
21
+ ## The install generator
22
+
23
+ ```shell
24
+ bin/rails generate lighthouse:install
25
+ ```
26
+
27
+ does steps 1–5 below for you:
28
+
29
+ | File | What it is |
30
+ | --- | --- |
31
+ | `config/initializers/lighthouse.rb` | Every [configuration](configuration.md) option, commented out at its default |
32
+ | `graphql/schema.graphql` | A starter schema (`user` via `@find`, `users` via `@paginate`) that assumes a `User` model — skipped when the folder already holds `.graphql` files |
33
+ | `app/graphql/app_schema.rb` | The executable schema, with `GraphQL::Dataloader` enabled |
34
+ | `app/controllers/graphql_controller.rb` | The endpoint, plus a `post '/graphql'` route |
35
+ | `graphql.config.yml` | IDE configuration — see [IDE support](#ide-support) |
36
+
37
+ | Option | Effect |
38
+ | --- | --- |
39
+ | `--schema=ApiSchema` | Name of the schema constant (default `AppSchema`) |
40
+ | `--sdl-folder=app/schema` | Where the `.graphql` files live (default `graphql`); sets `config.sdl_folder` |
41
+ | `--skip-controller` | No controller or route — for apps that already serve GraphQL |
42
+ | `--skip-ide` | No `graphql.config.yml` or `.gitignore` entries |
43
+
44
+ Rerunning it is safe: the route and `.gitignore` entries are only added once, and
45
+ an existing schema class or controller is kept (pass `--force` to replace it).
46
+
47
+ It coexists with graphql-ruby's own tooling. The generators live under
48
+ `lighthouse:` and the rake task is `lighthouse:ide_helper`, next to graphql-ruby's
49
+ `graphql:*` generators and `graphql:schema:*` tasks. `graphql:install` writes the
50
+ same controller and route; whichever runs second finds the route already in place,
51
+ and Lighthouse keeps a controller graphql-ruby generated. In that case, have the
52
+ controller call `AppSchema.execute(...)` to serve the Lighthouse schema.
53
+
21
54
  ## 1. Write your schema (SDL)
22
55
 
23
56
  By default Lighthouse loads every `*.graphql` file under `RAILS_ROOT/graphql`,
@@ -124,6 +157,47 @@ end
124
157
 
125
158
  See [configuration.md](configuration.md) for all options.
126
159
 
160
+ ## IDE support
161
+
162
+ Your IDE validates and autocompletes `.graphql` files against the schema it can
163
+ see. Lighthouse's directive definitions and the types its manipulation phase
164
+ generates live in Ruby, so without help the IDE flags `@paginate` as unknown and
165
+ can't resolve `UserPaginator`. PHP Lighthouse solves this with an IDE helper
166
+ command; so does Lighthouse for Rails:
167
+
168
+ ```shell
169
+ bin/rails lighthouse:ide_helper # writes to Rails.root
170
+ OUTPUT=tmp/ide bin/rails lighthouse:ide_helper
171
+ ```
172
+
173
+ It writes two files:
174
+
175
+ - **`schema-directives.graphql`** — a definition for every registered directive,
176
+ built-in or [your own](custom-directives.md), except those your SDL already
177
+ declares (so the IDE never sees a duplicate).
178
+ - **`programmatic-types.graphql`** — the types Lighthouse adds to the executable
179
+ schema: `<Type>Paginator`, `<Type>SimplePaginator`, `<Type>Connection`/`Edge`,
180
+ `PaginatorInfo`, `SimplePaginatorInfo`, `PageInfo`, the `whereConditions`
181
+ inputs and enums, and the `_` placeholder scalar.
182
+
183
+ Point your IDE's [graphql-config](https://the-guild.dev/graphql/config) at them
184
+ next to your SDL — the install generator writes this `graphql.config.yml`:
185
+
186
+ ```yaml
187
+ schema:
188
+ - graphql/**/*.graphql
189
+ - schema-directives.graphql
190
+ - programmatic-types.graphql
191
+ ```
192
+
193
+ It works with the GraphQL plugin for JetBrains IDEs and the GraphQL extension for
194
+ VS Code. The files are derived from your schema, so the generator adds them to
195
+ `.gitignore`; rerun the task after changing the schema or registering a directive.
196
+
197
+ > The IDE reads fields as you wrote them: `users: [User!]! @paginate` still looks
198
+ > like a list to it, although clients query `users { data { … } }`. Field
199
+ > rewrites are not something an extra file can express.
200
+
127
201
  ## Casing: camelCase ↔ snake_case
128
202
 
129
203
  GraphQL conventionally uses `camelCase` field names; Rails uses `snake_case`
@@ -144,9 +218,19 @@ type User {
144
218
  }
145
219
  ```
146
220
 
221
+ ## Custom queries and mutations
222
+
223
+ When a field needs Ruby, write a graphql-ruby resolver or mutation class named
224
+ after it — `Query.latestPosts` resolves to `Queries::LatestPosts`
225
+ (`app/graphql/queries/latest_posts.rb`), `Mutation.publishPost` to
226
+ `Mutations::PublishPost`. `bin/rails generate lighthouse:query LatestPosts`
227
+ scaffolds one. See [resolvers.md](resolvers.md).
228
+
147
229
  ## Next steps
148
230
 
149
231
  - [Directives reference](directives.md) — the full vocabulary.
232
+ - [Queries, mutations & builders](resolvers.md), [Mutations](mutations.md),
233
+ [Validation](validation.md), [Caching](caching.md).
150
234
  - [Relationships](relationships.md) — wiring associations.
151
235
  - [Filtering & ordering](filtering-and-ordering.md) — query constraints.
152
236
  - [Apollo Federation](federation.md) — become a subgraph.