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
@@ -12,27 +12,47 @@ end
12
12
 
13
13
  ```graphql
14
14
  type Post {
15
- author: User! @belongsTo(relation: "author")
15
+ author: User! @belongsTo
16
16
  comments: [Comment!]! @hasMany
17
17
  }
18
18
  ```
19
19
 
20
+ | Directive | ActiveRecord association |
21
+ | --- | --- |
22
+ | `@belongsTo` | `belongs_to` |
23
+ | `@hasOne` | `has_one` |
24
+ | `@hasMany` | `has_many` |
25
+ | `@belongsToMany` | `has_many :through`, `has_and_belongs_to_many` |
26
+ | `@morphTo` | `belongs_to …, polymorphic: true` |
27
+ | `@morphOne` / `@morphMany` | `has_one` / `has_many …, as:` |
28
+ | `@morphToMany` | `has_many :through` a polymorphic join |
29
+
30
+ The `morph*` names are PHP Lighthouse's. ActiveRecord resolves polymorphic
31
+ associations the same way as any other, so they behave exactly like their
32
+ non-polymorphic counterparts. Use whichever reads better.
33
+
20
34
  ## Avoiding the N+1 problem
21
35
 
22
- Resolving relationships naively triggers one query per parent record. Lighthouse's
23
- relationship directives load through graphql-ruby's **Dataloader**, batching all
24
- sibling loads into a single query. Enable it on your schema:
36
+ The relationship directives load through graphql-ruby's **Dataloader** and
37
+ ActiveRecord's preloader, one query per association per batch. Enable the
38
+ Dataloader on your schema (the install generator does):
25
39
 
26
40
  ```ruby
27
- class AppSchema < BaseAppSchema
28
- use GraphQL::Dataloader
29
- end
41
+ AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
42
+ options: { using: { GraphQL::Dataloader => {} } }
43
+ )
30
44
  ```
31
45
 
32
- With the Dataloader in place, a query for 100 posts and their authors issues two
33
- queries, not 101.
46
+ A query for 100 posts and their authors then issues two queries, not 101. A
47
+ many-to-many or `:through` association adds one query for the join table. Without
48
+ the Dataloader, the directives still work, but they load one parent at a time.
49
+
50
+ A field with `scopes:` or a filter argument the client supplied runs its own query
51
+ per parent, because each parent's query differs.
52
+
53
+ ## Lists, and paginating them
34
54
 
35
- ## One-to-many — `@hasMany`
55
+ Collection directives return a **plain list**, as in PHP Lighthouse:
36
56
 
37
57
  ```graphql
38
58
  type User {
@@ -40,38 +60,59 @@ type User {
40
60
  }
41
61
  ```
42
62
 
43
- `@hasMany` returns a **connection** type, so it supports pagination arguments and
44
- `edges { node { ... } }` / `nodes { ... }` selections out of the box. The inverse
45
- is `@belongsTo`.
63
+ ```graphql
64
+ { user(id: 1) { posts { title } } }
65
+ ```
46
66
 
47
- ## One-to-one — `@hasOne`
67
+ To paginate, pass `type:`, which takes the same values as
68
+ [`@paginate`](directives.md#paginate): `PAGINATOR`, `SIMPLE` or `CONNECTION`.
69
+ Lighthouse rewrites the field and adds the pagination arguments:
48
70
 
49
71
  ```graphql
50
72
  type User {
51
- profile: Profile @hasOne
73
+ posts: [Post!]! @hasMany(type: PAGINATOR, defaultCount: 10)
74
+ feed: [Post!]! @hasMany(relation: "posts", type: CONNECTION)
52
75
  }
53
76
  ```
54
77
 
55
- ## Inverse — `@belongsTo`
56
-
57
78
  ```graphql
58
- type Post {
59
- author: User! @belongsTo(relation: "author")
79
+ {
80
+ user(id: 1) {
81
+ posts(first: 5, page: 2) { paginatorInfo { total lastPage } data { title } }
82
+ feed(first: 5, after: "NQ==") { pageInfo { hasNextPage endCursor } edges { node { title } } }
83
+ }
60
84
  }
61
85
  ```
62
86
 
63
- ## Many-to-many — `@belongsToMany`
87
+ `defaultCount` and `maxCount` work as on `@paginate`.
64
88
 
65
- ```graphql
66
- type User {
67
- roles: [Role!]! @belongsToMany
68
- }
69
- ```
89
+ ### Paginating many parents at once
90
+
91
+ With the Dataloader, a paginated relation loads the pages of **every parent** of
92
+ a level together, as PHP Lighthouse does: `{ users { posts(first: 5) { … } } }`
93
+ costs one query for the pages, plus one for the totals if the client selects
94
+ `paginatorInfo` or `pageInfo` — however many users there are. The same goes for
95
+ `@paginate` on a nested field.
96
+
97
+ - **Pages.** A window function numbers each parent's rows in the field's order
98
+ (`@orderBy`, else the primary key), and one query keeps the rows of the
99
+ requested page: `ROW_NUMBER() OVER (PARTITION BY posts.user_id ORDER BY …)`.
100
+ PHP Lighthouse uses a `UNION ALL` of one query per parent instead; a window
101
+ function keeps the SQL the same size for 3 parents or 3,000.
102
+ - **Totals.** One `GROUP BY` counts every parent's related records, with the same
103
+ scopes and filters as the page. The count only runs when a total is asked for,
104
+ and never for `SIMPLE`, which fetches one extra row instead.
105
+
106
+ This needs window functions: PostgreSQL, MySQL 8+, MariaDB 10.2+ or SQLite 3.25+.
107
+ A relation falls back to one query per parent on other databases, without the
108
+ Dataloader, and when its rows can't be numbered per parent: a composite key, an
109
+ association scope that reads the owner (`has_many :x, ->(owner) { … }`), or
110
+ `DISTINCT`. The pages are the same either way.
70
111
 
71
112
  ## Renaming relations
72
113
 
73
- By default the relation name equals the field name (snake_cased). When they
74
- differ, pass `relation:`:
114
+ By default the relation name is the field name, snake_cased. When they differ,
115
+ pass `relation:`:
75
116
 
76
117
  ```graphql
77
118
  type Post {
@@ -79,17 +120,23 @@ type Post {
79
120
  }
80
121
  ```
81
122
 
82
- ## Filtering a relationship
123
+ ## Scopes and filters
83
124
 
84
- Relationship fields accept the same [argument filters](filtering-and-ordering.md)
85
- and [`@whereConditions`](filtering-and-ordering.md#complex-where-conditions) as
86
- root queries, so you can scope the related records:
125
+ `scopes:` applies model scopes to the association. Relationship fields also
126
+ accept the same [argument filters](filtering-and-ordering.md) and
127
+ [`@whereConditions`](filtering-and-ordering.md#complex-where-conditions) as root
128
+ queries:
87
129
 
88
130
  ```graphql
89
131
  type User {
90
132
  publishedPosts(
91
- status: String @eq
133
+ title: String @like
92
134
  orderBy: [OrderByClause!] @orderBy
93
- ): [Post!]! @hasMany(relation: "posts")
135
+ ): [Post!]! @hasMany(relation: "posts", scopes: ["published"])
94
136
  }
95
137
  ```
138
+
139
+ ## Writing relationships
140
+
141
+ Creating, connecting, syncing or deleting related records from a mutation's input
142
+ is covered by [nested mutations](mutations.md#nested-mutations).
data/docs/resolvers.md ADDED
@@ -0,0 +1,189 @@
1
+ # Queries, Mutations and Builders
2
+
3
+ Most fields need no Ruby: a directive (`@all`, `@find`, `@paginate`, `@create`,
4
+ …) or the default attribute reader resolves them. For the rest, Lighthouse looks
5
+ up a class by name. There are three kinds of class, and they are not
6
+ interchangeable. Each kind has its own contract and its own folder:
7
+
8
+ | Kind | Folder / namespace | Contract | Used by |
9
+ | --- | --- | --- | --- |
10
+ | Query | `app/graphql/queries` · `Queries::` | `GraphQL::Schema::Resolver` | `Query` fields |
11
+ | Mutation | `app/graphql/mutations` · `Mutations::` | `GraphQL::Schema::Mutation` | `Mutation` fields |
12
+ | Builder | `app/graphql/builders` · `Builders::` | `call(relation, args, context)` → relation | `@builder` |
13
+
14
+ Validators (`app/graphql/validators`) are the fourth kind. They are covered in
15
+ [validation.md](validation.md).
16
+
17
+ ## Queries and mutations: graphql-ruby's contract
18
+
19
+ A query or mutation class is a plain graphql-ruby
20
+ [resolver](https://graphql-ruby.org/fields/resolvers.html) or
21
+ [mutation](https://graphql-ruby.org/mutations/mutation_classes.html), so everything
22
+ you know from graphql-ruby applies:
23
+
24
+ - the field's arguments arrive as `snake_case` keyword arguments to `resolve`;
25
+ - `object` and `context` are available;
26
+ - `ready?` and `authorized?` run before `resolve`.
27
+
28
+ The SDL already declares the field's arguments and return type, so the class
29
+ declares neither.
30
+
31
+ ```graphql
32
+ type Query {
33
+ latestPosts(limit: Int): [Post!]!
34
+ }
35
+
36
+ type Mutation {
37
+ publishPost(id: ID!): Post
38
+ }
39
+ ```
40
+
41
+ ```ruby
42
+ # app/graphql/queries/latest_posts.rb
43
+ module Queries
44
+ class LatestPosts < GraphQL::Schema::Resolver
45
+ def resolve(limit: 10)
46
+ Post.order(created_at: :desc).limit(limit)
47
+ end
48
+ end
49
+ end
50
+
51
+ # app/graphql/mutations/publish_post.rb
52
+ module Mutations
53
+ class PublishPost < GraphQL::Schema::Mutation
54
+ def authorized?(id:)
55
+ context[:current_user]&.admin? || raise(GraphQL::ExecutionError, 'Admins only.')
56
+ end
57
+
58
+ def resolve(id:)
59
+ Post.find(id).tap { |post| post.update!(published: true) }
60
+ end
61
+ end
62
+ end
63
+ ```
64
+
65
+ ### Resolution by convention
66
+
67
+ A field on `Query` or `Mutation` with **no resolving directive** resolves to the
68
+ class named after it, as in PHP Lighthouse:
69
+
70
+ - `Query.latestPosts` resolves to `Queries::LatestPosts`;
71
+ - `Mutation.publishPost` resolves to `Mutations::PublishPost`.
72
+
73
+ The class is looked up on each call, so code reloading works. Classes found by
74
+ convention must inherit from `GraphQL::Schema::Resolver` (or `Mutation`, which
75
+ is one). Otherwise Lighthouse raises a `Lighthouse::DefinitionError`. A root
76
+ field with no class is reported through [`on_error`](configuration.md#on_error):
77
+ it raises in test and logs elsewhere. Lighthouse never falls back to a top-level
78
+ constant, so a `post` field is never mistaken for the `Post` model.
79
+
80
+ graphql-ruby's `RelayClassicMutation` (what `rails g graphql:install` generates
81
+ `Mutations::BaseMutation` from) works too, as long as the SDL field takes the
82
+ `input:` argument it expects.
83
+
84
+ Wrapping directives (`@guard`, the `@can*` family, `@validator`, `@cache`,
85
+ `@clearCache`) are not resolvers, so a field carrying only those still resolves by
86
+ convention:
87
+
88
+ ```graphql
89
+ type Mutation {
90
+ publishPost(id: ID!): Post @guard @canFind(ability: "publish", find: "id")
91
+ }
92
+ ```
93
+
94
+ ### Naming a class explicitly
95
+
96
+ `@field(resolver:)` points a field at a class by name. On `Query` and `Mutation`
97
+ the name is looked up in the queries and mutations namespaces; on any other type,
98
+ in [`resolver_namespaces`](configuration.md#resolver_namespaces).
99
+
100
+ ```graphql
101
+ type Query {
102
+ feed: [Post!]! @field(resolver: "Feeds::Home") # Queries::Feeds::Home
103
+ }
104
+ ```
105
+
106
+ Besides graphql-ruby resolver classes, `@field` also accepts a plain callable:
107
+ anything with `call(object, args, context)`.
108
+
109
+ ### `@spread` and `@inject`
110
+
111
+ Both shape the keyword arguments a class receives. They do the same for
112
+ [`@create` / `@update`](mutations.md).
113
+
114
+ ```graphql
115
+ type Mutation {
116
+ createDraft(input: CreateDraftInput! @spread): Post
117
+ @inject(context: "current_user.id", name: "author_id")
118
+ }
119
+ ```
120
+
121
+ `resolve(title:, body:, author_id:)` receives the input's fields at the top level.
122
+ `author_id` comes from `context[:current_user].id`, so clients cannot set it.
123
+ `@inject` is repeatable, and `name:` may be a dot path into an input
124
+ (`"input.author_id"`).
125
+
126
+ ## Builders
127
+
128
+ A builder is not a resolver: it does not produce the field's value. It refines the
129
+ query of a field that a model directive resolves. It receives the relation, the
130
+ field's arguments and the context, and returns a relation:
131
+
132
+ ```graphql
133
+ type Query {
134
+ posts: [Post!]! @paginate @builder(class: "Published")
135
+ }
136
+ ```
137
+
138
+ ```ruby
139
+ # app/graphql/builders/published.rb
140
+ module Builders
141
+ class Published
142
+ def self.call(relation, args, context)
143
+ relation.where(published: true)
144
+ end
145
+ end
146
+ end
147
+ ```
148
+
149
+ `@builder` works with `@all`, `@paginate`, `@first`, `@count` and `@aggregate`.
150
+ It is repeatable, and `method:` names a method other than `call`. Builder classes
151
+ are looked up in the builders namespace only. Raising a `GraphQL::ExecutionError`
152
+ from a builder (e.g. a tenancy check) always reaches the client; it never falls
153
+ back to the unscoped relation.
154
+
155
+ The gem ships RBS signatures in `sig/`, so with Steep (or anything that reads
156
+ `rbs collection`) a builder can be checked against `Lighthouse::_Builder`:
157
+
158
+ ```rbs
159
+ # sig/builders/published.rbs
160
+ class Builders::Published
161
+ extend Lighthouse::_Builder
162
+ end
163
+ ```
164
+
165
+ ## Generators
166
+
167
+ ```shell
168
+ bin/rails generate lighthouse:query LatestPosts # app/graphql/queries/latest_posts.rb
169
+ bin/rails generate lighthouse:mutation PublishPost # app/graphql/mutations/publish_post.rb
170
+ bin/rails generate lighthouse:builder Published # app/graphql/builders/published.rb
171
+ bin/rails generate lighthouse:validator CreatePostInput # app/graphql/validators/create_post_input_validator.rb
172
+ ```
173
+
174
+ Each one writes the class with its contract, into the first namespace configured
175
+ for its kind (or `--namespace=…`). Namespaced names nest modules:
176
+ `lighthouse:mutation Blog::Publish` writes `Mutations::Blog::Publish`. These
177
+ generators do not collide with graphql-ruby's `graphql:*` generators. A
178
+ `lighthouse:mutation` and a `graphql:mutation` of the same name target the same
179
+ file, and Rails asks before overwriting it.
180
+
181
+ ## Changing the namespaces
182
+
183
+ ```ruby
184
+ Lighthouse.configure do |config|
185
+ config.namespaces = { queries: ['Api::Queries'], mutations: ['Api::Mutations'] }
186
+ end
187
+ ```
188
+
189
+ Kinds you leave out keep their defaults. Each kind takes a list, tried in order.
@@ -0,0 +1,132 @@
1
+ # Validation
2
+
3
+ Rails puts validation in the model, and so does Lighthouse: the
4
+ [mutation directives](mutations.md) save through ActiveRecord, so `validates`
5
+ rules apply with no configuration. Not every rule belongs to a model, though:
6
+ a password confirmation, a rule across two arguments, the arguments of a query, or
7
+ input that never reaches a model at all. For those, `@validator` runs a contract
8
+ class before the field resolves.
9
+
10
+ Both report errors the same way, in PHP Lighthouse's format: messages keyed by
11
+ argument path under `extensions.validation`.
12
+
13
+ ```json
14
+ {
15
+ "data": { "signUp": null },
16
+ "errors": [{
17
+ "message": "Validation failed for the field [signUp].",
18
+ "extensions": { "validation": { "passwordConfirmation": ["must match password"] } }
19
+ }]
20
+ }
21
+ ```
22
+
23
+ ## Model validations
24
+
25
+ Nothing to do. A failed `save` in `@create`, `@update`, `@createMany` or
26
+ `@updateMany` reports the model's `full_message`s, keyed by the argument each
27
+ attribute came from (see [mutations.md](mutations.md#validation-errors)).
28
+
29
+ ## `@validator`
30
+
31
+ ```graphql
32
+ directive @validator(class: String) on FIELD_DEFINITION | INPUT_OBJECT
33
+ ```
34
+
35
+ ### On a field
36
+
37
+ The contract receives all of the field's arguments:
38
+
39
+ ```graphql
40
+ type Mutation {
41
+ signUp(email: String!, password: String!, passwordConfirmation: String): User @validator
42
+ }
43
+ ```
44
+
45
+ ```ruby
46
+ # app/graphql/validators/mutation/sign_up_validator.rb
47
+ module Validators
48
+ module Mutation
49
+ class SignUpValidator < Dry::Validation::Contract
50
+ params do
51
+ required(:email).filled(:string)
52
+ required(:password).filled(:string, min_size?: 12)
53
+ optional(:password_confirmation).maybe(:string)
54
+ end
55
+
56
+ rule(:password_confirmation) do
57
+ key.failure('must match password') if value && value != values[:password]
58
+ end
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ The class defaults to `Validators::<ParentType>::<FieldName>Validator`. Name
65
+ another with `@validator(class: "SignUpRules")`; it is looked up in the validators
66
+ namespace.
67
+
68
+ ### On an input type
69
+
70
+ The contract receives that input's values. It runs **wherever the type is used**:
71
+ as an argument, nested inside another input, or as each item of a list. The
72
+ fields that take the input need no directive.
73
+
74
+ ```graphql
75
+ input AddressInput @validator {
76
+ street: String
77
+ zipCode: String
78
+ }
79
+
80
+ type Mutation {
81
+ ship(to: AddressInput!, billing: BillingInput): Shipment
82
+ shipMany(to: [AddressInput!]!): [Shipment!]!
83
+ }
84
+ ```
85
+
86
+ The class defaults to `Validators::<InputType>Validator`, here
87
+ `Validators::AddressInputValidator`. Errors are prefixed with the path to the
88
+ input: `to.zipCode`, `billing.address.street`, `to.1.street`.
89
+
90
+ ### Contracts
91
+
92
+ A contract is a [Dry::Validation](https://dry-rb.org/gems/dry-validation/)
93
+ contract:
94
+
95
+ - Values arrive with `snake_case` symbol keys, and the error keys are mapped back
96
+ to the GraphQL argument names.
97
+ - Declare `option :graphql_context, optional: true` to read the GraphQL context in
98
+ rules (e.g. `graphql_context[:current_user]`).
99
+ - `dry-validation` is an optional dependency. Add it to your Gemfile to use these
100
+ contracts.
101
+
102
+ Any other object works if its `call(values)` returns a Dry-style result, or a
103
+ `{ key => [messages] }` Hash (empty when valid):
104
+
105
+ ```ruby
106
+ module Validators
107
+ class NoSpam
108
+ def call(values)
109
+ values[:title].to_s.include?('spam') ? { title: ['looks like spam'] } : {}
110
+ end
111
+ end
112
+ end
113
+ ```
114
+
115
+ A missing class raises `Lighthouse::DefinitionError`. Lighthouse never silently
116
+ skips validation.
117
+
118
+ ### Order
119
+
120
+ Validation runs after authentication and authorization (`@guard`, `@can*`), so
121
+ anonymous clients cannot probe your rules, and before `@cache` and the resolver.
122
+ All contracts on a field run, and their errors are reported together.
123
+
124
+ Generate a contract with `bin/rails generate lighthouse:validator AddressInput`.
125
+
126
+ ## Why not `@rules`?
127
+
128
+ PHP Lighthouse's `@rules(apply: ["email", "max:280"])` embeds Laravel's
129
+ string-based rule language in the schema. Rails has no equivalent to port: its
130
+ validations live in models, and Dry contracts express the rest as Ruby with
131
+ proper types. So Lighthouse for Rails supports those two, and does not invent a
132
+ third rule syntax.
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'class_generator'
4
+
5
+ # `rails generate lighthouse:builder PublishedPosts` — a query builder for
6
+ # `@builder(class: "PublishedPosts")`.
7
+ class Lighthouse::Generators::BuilderGenerator < Lighthouse::Generators::ClassGenerator
8
+ desc 'Generate a query builder for @builder in app/graphql/builders.'
9
+
10
+ private
11
+
12
+ def kind = :builders
13
+
14
+ def body
15
+ <<~RUBY
16
+ # Applied by @builder(class: "#{class_name}") before the field resolves:
17
+ # receives the relation, the field's arguments and the GraphQL context, and
18
+ # returns the relation to use. Its type is Lighthouse::_Builder.
19
+ class #{class_basename}
20
+ def self.call(relation, args, context)
21
+ relation
22
+ end
23
+ end
24
+ RUBY
25
+ end
26
+
27
+ def usage
28
+ "Use it with @builder(class: \"#{class_name}\") on a field resolved by @all, @paginate, @first, @count or @aggregate."
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'lighthouse-graphql'
5
+
6
+ # Base for the generators that scaffold the classes a schema refers to by
7
+ # name — queries, mutations, builders and validators — each in its own
8
+ # namespace (Config.namespaces) and with its own contract.
9
+ class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
10
+ hide!
11
+
12
+ class_option :namespace, type: :string,
13
+ desc: 'Namespace to generate into (default: the first of Config.namespaces for this kind)'
14
+
15
+ def create_class
16
+ create_file File.join('app/graphql', *modules.map(&:underscore), "#{class_basename.underscore}.rb"), source
17
+ end
18
+
19
+ def show_usage
20
+ say_status :usage, usage, :blue if usage
21
+ end
22
+
23
+ private
24
+
25
+ # :queries, :mutations, :builders or :validators.
26
+ def kind
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def body
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def usage
35
+ nil
36
+ end
37
+
38
+ def class_basename
39
+ class_name.demodulize
40
+ end
41
+
42
+ def namespace_name
43
+ options[:namespace] || Lighthouse::Config.namespaces[kind].first
44
+ end
45
+
46
+ def modules
47
+ namespace_name.split('::') + class_name.split('::')[0...-1]
48
+ end
49
+
50
+ def full_class_name
51
+ (modules + [class_basename]).join('::')
52
+ end
53
+
54
+ def source
55
+ lines = ['# frozen_string_literal: true', '']
56
+ modules.each_with_index { |mod, depth| lines << "#{' ' * depth}module #{mod}" }
57
+ body.each_line { |line| lines << (line.strip.empty? ? '' : "#{' ' * modules.size}#{line.chomp}") }
58
+ modules.size.times.reverse_each { |depth| lines << "#{' ' * depth}end" }
59
+ "#{lines.join("\n")}\n"
60
+ end
61
+ end