lighthouse-graphql 0.4.0 → 0.5.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -1
  3. data/README.md +8 -5
  4. data/docs/configuration.md +26 -2
  5. data/docs/directives.md +287 -4
  6. data/docs/generators.md +82 -0
  7. data/docs/getting-started.md +26 -30
  8. data/docs/relationships.md +42 -0
  9. data/docs/relay.md +137 -0
  10. data/docs/resolvers.md +261 -22
  11. data/docs/security.md +17 -0
  12. data/docs/types.md +219 -0
  13. data/docs/uploads.md +80 -0
  14. data/lib/generators/lighthouse/base_classes.rb +32 -49
  15. data/lib/generators/lighthouse/install/install_generator.rb +83 -24
  16. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -6
  17. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -3
  18. data/lib/lighthouse/async_mutation_job.rb +17 -0
  19. data/lib/lighthouse/complexity.rb +5 -4
  20. data/lib/lighthouse/config.rb +13 -2
  21. data/lib/lighthouse/controller.rb +27 -9
  22. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  23. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  24. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  25. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  26. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  27. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  28. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  29. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  30. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  31. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  32. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  33. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +2 -1
  34. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  35. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  36. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  37. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
  38. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  39. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  40. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  41. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  42. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  43. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  44. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  45. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +2 -1
  46. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
  47. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
  48. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  49. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  50. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  51. data/lib/lighthouse/directives/definitions.rb +52 -0
  52. data/lib/lighthouse/directives/model_directive.rb +131 -0
  53. data/lib/lighthouse/directives/node_directive.rb +134 -0
  54. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  55. data/lib/lighthouse/directives/registry.rb +21 -21
  56. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  57. data/lib/lighthouse/enum_directive.rb +41 -0
  58. data/lib/lighthouse/field_resolver_stack.rb +53 -6
  59. data/lib/lighthouse/generated_resolvers.rb +13 -8
  60. data/lib/lighthouse/global_id.rb +31 -0
  61. data/lib/lighthouse/graphql/version.rb +1 -1
  62. data/lib/lighthouse/multipart_request.rb +43 -0
  63. data/lib/lighthouse/node_registry.rb +68 -0
  64. data/lib/lighthouse/pagination.rb +3 -0
  65. data/lib/lighthouse/rb_lighthouse.rb +33 -0
  66. data/lib/lighthouse/relation_counts.rb +29 -0
  67. data/lib/lighthouse/resolve_info.rb +80 -0
  68. data/lib/lighthouse/resolver_bases.rb +9 -2
  69. data/lib/lighthouse/resolver_dispatch.rb +36 -11
  70. data/lib/lighthouse/scalar_coercion.rb +72 -0
  71. data/lib/lighthouse/schema_cache.rb +2 -1
  72. data/lib/lighthouse/schema_generator.rb +11 -10
  73. data/lib/lighthouse/schema_implementation.rb +4 -8
  74. data/lib/lighthouse/support/environment.rb +13 -0
  75. data/lib/lighthouse/support/model_resolver.rb +7 -5
  76. data/lib/lighthouse/support/preload.rb +49 -0
  77. data/lib/lighthouse/type.rb +103 -0
  78. data/lib/lighthouse/type_resolution.rb +150 -0
  79. data/lib/lighthouse/upload.rb +26 -0
  80. data/lib/lighthouse/upload_store.rb +32 -0
  81. data/sig/lighthouse/directives.rbs +3 -2
  82. data/sig/lighthouse.rbs +18 -0
  83. metadata +38 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a15bb4d92716a08d5d248c5155b9cf336407a7988fed6e00c825b07bea4a206b
4
- data.tar.gz: 421fca84187c5030f38c2cdd07ed26f9909747944f788cfd9662e67b6e322e2d
3
+ metadata.gz: 5dd6b5839f6520df6489135c1a37382ce4f7889d136140f652443ad5a3cd1a81
4
+ data.tar.gz: 60fdb10b55abe72a1dc3df89705b904b424344e5bffa611e695a46fa8767ddc7
5
5
  SHA512:
6
- metadata.gz: 388112925823baa63d0a7a4436bb1df4511f4a4c13b01001dd72b351156b78982ca4eab136a131c9c25b997915bff26099fa2a485bc4df74bfef390138e37c3a
7
- data.tar.gz: e2f90e9654cfcdc2d26e084f68476ac59f56eb708683b3b4817cfc41006843363feb0f9a08b838bdcdd888395bc8f13ae29394d28b0bc37566c26dad63657cac
6
+ metadata.gz: 0f2aff4008f41238a6ebca9c598add10069e7f0c4c8c5578bc1041f3da50f36b859b9ce1c91a29576442562a42afca20845b8fb3704b19d374570381a3588262
7
+ data.tar.gz: b985fc24dfb2d0196c96f84cb0423d0b21e38659a0bc1cd4db13dc3818a8939fd2340006d85605cb5a50fde37f638d177ba90bf5af26e2cc3b9a3a0c2043986a
data/CHANGELOG.md CHANGED
@@ -6,6 +6,143 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-18
10
+
11
+ ### Added
12
+
13
+ - `Lighthouse::GraphQL::Type`, a concern that hands a graphql-ruby type class to Lighthouse, in
14
+ graphql-ruby's `app/graphql/types` structure. A field of an object type with no resolving
15
+ directive resolves with the method of its snake_case name on `Types::<Type>Type`, when that class
16
+ includes the concern and defines it (`Post.readingTime` → `Types::PostType#reading_time`). As in
17
+ graphql-ruby, the method has `object` (the parent), `context`, the `dataload*` helpers, and the
18
+ field's arguments as keywords. The SDL still declares the fields; the ones the class does not
19
+ define are read off the object as before. A type class that does not include the concern is left
20
+ alone. The class is looked up in the new `types` namespace (default `['Types']`), and must inherit
21
+ from `base_types[:object]` when one is given.
22
+ - `@model(class:)`, as PHP Lighthouse's: maps an object type to the ActiveRecord model of another
23
+ name. Every directive taking `model:` (`@all`, `@find`, `@paginate`, `@create`, `@update`, the
24
+ `@can*` family...) on a field returning the type gets it, unless it names its own; unions,
25
+ interfaces and `@node` resolve the model's records to the type; the schema does not build unless
26
+ it is a model; and a type class's methods only run on its records (another parent is reported
27
+ through `on_error`).
28
+ - Unions and interfaces resolve each value to its object type as PHP Lighthouse does: a Hash's
29
+ `__typename`, else the type named after the value's class or a superclass (STI), else the type
30
+ whose `@model` names its model. A union's type class (`Types::SearchResultType` including the
31
+ concern) can take over with graphql-ruby's `self.resolve_type(object, context)`, an interface's
32
+ with `definition_methods { def resolve_type }`, and `@union(resolveType:)` /
33
+ `@interface(resolveType:)` name a class in the SDL. A value nothing resolves, or one resolved
34
+ outside the union, raises `Lighthouse::DefinitionError` naming the ways out.
35
+ - Custom scalars read and write their values through their class: `Types::DateTimeType` (a
36
+ `GraphQL::Schema::Scalar` including the concern), or the class `@scalar(class:)` names, with
37
+ graphql-ruby's `coerce_input` / `coerce_result`. A `GraphQL::CoercionError` makes the argument
38
+ invalid with its message. A scalar with no class passes its values through, as before.
39
+ - `@method` passes the field's arguments to the method: as snake_case keywords when it takes
40
+ keywords, otherwise positionally in SDL order, as PHP Lighthouse does.
41
+ - `base_types[:union]`, `[:interface]` and `[:scalar]`, like `[:object]`, are the bases the type
42
+ classes of their kind must inherit from.
43
+ - `docs/types.md`: unions, interfaces and scalars.
44
+ - `@field(resolver: "Class@method")`, as PHP Lighthouse's: the method replaces `call` on a plain
45
+ class (as a class method, else an instance method), or `resolve` on a resolver class, whose
46
+ `ready?` / `authorized?` still run.
47
+ - `ResolveInfo`, PHP Lighthouse's `$resolveInfo`: `resolve_info` in `Resolvers::` / `Mutations::`
48
+ classes, classes `@field` names and type class methods, and a fourth argument for a plain callable
49
+ that takes one. It has the field, its parent type, object, arguments, context and path, and
50
+ `enhance_builder(relation, scopes: [])`, which applies the field's `@builder` classes and argument
51
+ directives (`@eq`, `@where`, `@orderBy`, `@whereConditions`, ...) to a query the class builds.
52
+ - `@with(relation:, scopes:)` and `@withCount(relation:, scopes:)` eager-load a relation, or its
53
+ count (kept on the parent as `<relation>_count`), before the field resolves; `@lazyLoad(relations:)`
54
+ eager-loads relations of the records a field returns. All three load across every parent in the
55
+ response at once, through the Dataloader, and take nested relations (`"posts.comments"`).
56
+ - Relay global object identification: `@globalId` encodes a field (base64 of `Type:id`) and decodes
57
+ an argument or input field (`decode: ARRAY | TYPE | ID`); `@node` makes a type implement `Node` and
58
+ adds `Query.node(id:)`, which finds the record through the type's model or `@node(resolver:)`. The
59
+ new `global_id` setting takes your own encoder. `docs/relay.md`.
60
+ - A union's or interface's class may answer `[type, value]`, as graphql-ruby allows.
61
+ - File uploads: the `Upload` scalar (Lighthouse's own for `scalar Upload`), the GraphQL multipart
62
+ request specification in `Lighthouse::GraphQL::Controller`, and `@upload(disk:, path:)`, which
63
+ stores the file through the new `upload_store` setting (an ActiveStorage blob by default) and
64
+ passes its signed id. `docs/uploads.md`.
65
+ - Filters: `@whereNull`, `@whereNotNull`, `@whereBetween`, `@whereNotBetween`, `@whereJsonContains`
66
+ (PostgreSQL, MySQL, SQLite), `@scope`, `@nest` (the filters of an input's fields) and `@limit`
67
+ (a LIMIT, or a per-parent cut of a relation's records); `@whereAuth(relation:)` keeps the
68
+ signed-in user's records.
69
+ - Argument directives: `@trim`, `@drop`, `@hash` (BCrypt) and `@bind(class:)`, which replaces an id
70
+ with its record(s) or fails validation. Rewriting ones (`@globalId`, `@trim`, `@drop`) run before
71
+ `@guard`, `@can*` and `@validator`; effectful ones (`@hash`, `@bind`, `@upload`) after them.
72
+ - `@builder` on an argument or input field, as PHP Lighthouse's: the method gets the argument's
73
+ value whenever the client sends it, `null` included. `method: "Class@method"` names the builder
74
+ without `class:`, and `value:` on a field passes a fixed value in place of the arguments.
75
+ - `@limit(builder: true)` puts the LIMIT on the field's query; `@limit` must be on an `Int` argument,
76
+ and is accepted on the field itself, as PHP Lighthouse allows.
77
+ - `@hasManyThrough` and `@hasOneThrough`, PHP Lighthouse's names, as aliases of `@hasMany` and
78
+ `@hasOne`, which resolve `has_many` / `has_one :through`.
79
+ - `ResolveInfo#lookahead`: graphql-ruby's Lookahead for a field resolved by a class (a `Resolvers::`
80
+ class, `@field`, a type class method). It never reaches the arguments, so `@cache` keys are unchanged.
81
+ - `@throttle` logs a warning when the cache store is a `:null_store`, which counts nothing.
82
+ - `@enum(value:)` for an enum value's internal value; `@namespaced` to group fields; `@throttle` to
83
+ rate-limit a field per user or IP (the controller concern now puts `:remote_ip` in the context);
84
+ `@async` to run a mutation in an ActiveJob and answer `true`; `@hide(env:)` / `@show(env:)` to leave
85
+ fields out by environment (the schema cache key now includes it).
86
+
87
+ ### Changed
88
+
89
+ - Generators build on graphql-ruby's instead of copying them. `lighthouse:install` runs
90
+ `graphql:install --no-relay --skip-graphiql --skip-query-logs` for the base classes every
91
+ graphql-ruby app has (`Types::Base*`, `Resolvers::BaseResolver`, `Mutations::BaseMutation`), then
92
+ replaces what it wrote that an SDL-first app does differently: the schema class (built from the
93
+ SDL, naming every base in `base_types`), the controller, no `Types::QueryType` /
94
+ `MutationType`, and a `BaseMutation` that is a `GraphQL::Schema::Mutation`. It writes
95
+ `Subscriptions::BaseSubscription` itself, graphql-ruby having none. Files the app had are kept.
96
+ - `docs/generators.md`: every generator, and which of graphql-ruby's fit a Lighthouse app
97
+ (`graphql:object`, `:scalar`, `:union`, `:interface` for type classes; `graphql:mutation` and
98
+ `graphql:detailed_trace` not as they are).
99
+
100
+ - `@field(resolver:)` on a field of a type other than `Query` and `Mutation` looks the class up in
101
+ the resolvers namespace first, nested names included: `@field(resolver: "Posts::TagsCount")`
102
+ resolves to `Resolvers::Posts::TagsCount`. Then it tries `resolver_namespaces`, as before, so
103
+ full names and classes outside `Resolvers::` resolve as they did. A class `Resolvers::X` now
104
+ wins over one that `resolver_namespaces` would find for the same name.
105
+
106
+ ### Fixed
107
+
108
+ - A wrapping directive (`@globalId`, `@guard`, `@cache`, ...) on a field of a type whose objects are
109
+ Hashes read nothing: the wrapped default reader only called methods. It now reads the Hash's
110
+ keys, as the default reader does.
111
+
112
+ - A field returning a union or an interface no longer raises `undefined method 'resolve_type' for
113
+ an instance of Proc`. Lighthouse's field dispatcher replaced the schema's default resolve with a
114
+ Proc, which graphql-ruby also asks for `resolve_type` and scalar coercion; it now hands both on.
115
+
116
+ - `@field(resolver:)` on a `Query` or `Mutation` field finds a nested name in the resolvers /
117
+ mutations namespaces, as documented: `"Feeds::Home"` resolves to `Resolvers::Feeds::Home`,
118
+ then to `Feeds::Home`. It used to try only `Feeds::Home`, so the field resolved to `null`.
119
+ The field's `complexity` is looked up the same way.
120
+
121
+ ### Documentation
122
+
123
+ - `docs/resolvers.md`: fields of types other than `Query` and `Mutation` resolved with `@field`,
124
+ whose class receives the parent record as `object`. The example app resolves `Post.tagsCount`
125
+ (batched through the dataloader), `Post.excerpt(length:)` and `Query.popularTags` this way.
126
+ It resolves `Post.readingTime` and the `ReadingTime` type, which is not a model, with
127
+ `Types::PostType` and `Types::ReadingTimeType`, `Query.search` (a union of `Post` and `User`)
128
+ and `Query.latestActivity` (an `Authored` interface) by the default, `DateTime` with
129
+ `Types::DateTimeType`, and `Post.summary(words:)` with `@method`. `Tag` is a Relay node with
130
+ global ids; `User.latestPostTitle` uses `@with`, `Tag.usage` `@withCount`, `User.posts`
131
+ `@lazyLoad`, `Query.counts` `@field(resolver: "Statistics@counts")`, and `Query.myPosts`
132
+ `resolve_info.enhance_builder`. `Mutation.updateAvatar` takes a multipart upload with `@upload`
133
+ (the example now loads ActiveStorage). `Mutation.schedulePublish` is `@async` on Solid Queue,
134
+ `Query.searchPosts` is `@throttle`d, and `Query.posts` filters `metadata` with `@whereJsonContains`;
135
+ the example's suite runs on PostgreSQL and MySQL too (`DATABASE_URL`, a new CI job), with Redis.
136
+
137
+ ### Removed
138
+
139
+ - `rails g lighthouse:type`: it was graphql-ruby's `graphql:object` (and `:scalar`, `:union`,
140
+ `:interface`) with an `include`. Generate the class with graphql-ruby, then add
141
+ `include Lighthouse::GraphQL::Type`.
142
+ - `lighthouse:query` and `lighthouse:mutation` no longer write `Resolvers::BaseResolver` /
143
+ `Mutations::BaseMutation` when they are missing: graphql-ruby's install writes them, which
144
+ `lighthouse:install` runs. They say so instead.
145
+
9
146
  ## [0.4.0] - 2026-10-17
10
147
 
11
148
  ### Added
@@ -324,7 +461,8 @@ had been running inside a private monorepo as a path gem before being extracted.
324
461
  - Config-driven extension seams: `Lighthouse.configure`, `resolver_namespaces`,
325
462
  `on_error`, and `DirectiveRegistry.register` for app-defined directives.
326
463
 
327
- [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.4.0...HEAD
464
+ [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.5.0...HEAD
465
+ [0.5.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.4.0...v0.5.0
328
466
  [0.4.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.3.0...v0.4.0
329
467
  [0.3.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.2.1...v0.3.0
330
468
  [0.2.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.2.0...v0.2.1
data/README.md CHANGED
@@ -78,10 +78,13 @@ Then let the generator set the app up:
78
78
  bin/rails generate lighthouse:install
79
79
  ```
80
80
 
81
- It creates the initializer, a starter schema in `graphql/schema.graphql`, the
82
- `AppSchema` class, a `GraphqlController` with its `POST /graphql` route, and a
83
- `graphql.config.yml` for your IDE. Run `bin/rails generate lighthouse:install --help`
84
- for its options (`--schema`, `--sdl-folder`, `--skip-controller`, `--skip-ide`).
81
+ It runs graphql-ruby's own `graphql:install` for the base classes every graphql-ruby
82
+ app has, then writes Lighthouse's on top: the initializer, a starter schema in
83
+ `graphql/schema.graphql`, the `AppSchema` class built from it, a `GraphqlController`
84
+ with its `POST /graphql` route, and a `graphql.config.yml` for your IDE. Run
85
+ `bin/rails generate lighthouse:install --help` for its options (`--schema`,
86
+ `--sdl-folder`, `--skip-controller`, `--skip-ide`), and see
87
+ [docs/generators.md](docs/generators.md) for every generator, graphql-ruby's included.
85
88
 
86
89
  ### IDE support
87
90
 
@@ -130,7 +133,7 @@ Prefer to wire it by hand? These are the steps the generator performs.
130
133
  options: {
131
134
  base_types: {
132
135
  resolver: Resolvers::BaseResolver, # graphql-ruby's base classes, which
133
- mutation: Mutations::BaseMutation, # rails g lighthouse:install writes
136
+ mutation: Mutations::BaseMutation, # rails g lighthouse:install has graphql-ruby write
134
137
  subscription: Subscriptions::BaseSubscription
135
138
  # object: Types::BaseObject, ...
136
139
  }
@@ -38,6 +38,7 @@ different contracts, so they live apart ([resolvers.md](resolvers.md)):
38
38
  | `events` | `['Events']` | Classes `@event(dispatch:)` names ([events.md](events.md)) |
39
39
  | `subscriptions` | `['Subscriptions']` | `Subscriptions::BaseSubscription` classes (a `GraphQL::Schema::Subscription`; the base includes `Lighthouse::GraphQL::Subscription`) for `Subscription` fields, and `@subscription(class:)` ([subscriptions.md](subscriptions.md)) |
40
40
  | `analyzers` | `['Analyzers']` | `GraphQL::Analysis::Analyzer` classes (optionally including `Lighthouse::GraphQL::ComplexityAnalyzer`) that `@complexity(resolver:)` names ([security.md](security.md)) |
41
+ | `types` | `['Types']` | `Types::<Name>Type` classes including `Lighthouse::GraphQL::Type`: an object type's, whose methods resolve the fields of the SDL type `<Name>` ([resolvers.md](resolvers.md#type-classes)); a union's or interface's, which picks its values' types; a scalar's, which coerces its values ([types.md](types.md)). Also where `@union`, `@interface` and `@scalar` look classes up |
41
42
 
42
43
  ```ruby
43
44
  config.namespaces = { queries: ['Api::Resolvers'] } # the other kinds keep their defaults
@@ -47,8 +48,9 @@ config.namespaces = { queries: ['Api::Resolvers'] } # the other kinds keep the
47
48
 
48
49
  An ordered list of namespaces tried for **models** named by a directive
49
50
  (`@all(model: "User")`) and for `@field(resolver:)` on types other than `Query`
50
- and `Mutation`. Fully-qualified names (containing `::`) are used as-is. Defaults
51
- to `['App::Graphql']`.
51
+ and `Mutation`, after the resolvers namespace
52
+ ([resolvers.md](resolvers.md#fields-of-other-types)). Fully-qualified names
53
+ (containing `::`) are used as-is. Defaults to `['App::Graphql']`.
52
54
 
53
55
  ```ruby
54
56
  config.resolver_namespaces = ['App::Graphql', 'Resolvers']
@@ -115,6 +117,28 @@ initializer.
115
117
  config.convert_empty_strings_to_null = true
116
118
  ```
117
119
 
120
+ ### `global_id`
121
+
122
+ Encodes and decodes Relay global ids for [`@globalId` and `@node`](relay.md): any
123
+ object with `encode(type, id)` returning the global id, and `decode(global_id)`
124
+ returning `[type, id]`. Defaults to `Lighthouse::GraphQL::GlobalId`, the base64 of
125
+ `Type:id`, as PHP Lighthouse's.
126
+
127
+ ```ruby
128
+ config.global_id = SignedGlobalIds
129
+ ```
130
+
131
+ ### `upload_store`
132
+
133
+ Stores the file an [`@upload`](uploads.md) argument carries: any object with
134
+ `call(file, disk:, path:, public:)`, whose return value the argument then holds.
135
+ Defaults to `Lighthouse::GraphQL::UploadStore`, which makes an ActiveStorage blob and
136
+ returns its signed id.
137
+
138
+ ```ruby
139
+ config.upload_store = ->(file, path: '/', **) { S3Uploader.put(file, prefix: path) }
140
+ ```
141
+
118
142
  ### `queue_broadcasts`, `broadcast_queue`
119
143
 
120
144
  Whether [`@broadcast`](directives.md#broadcast) triggers its subscription in an
data/docs/directives.md CHANGED
@@ -129,6 +129,7 @@ See [relationships.md](relationships.md) for the full guide.
129
129
  | `@belongsTo` | inverse of has-one/has-many |
130
130
  | `@belongsToMany` | many-to-many: a list, or paginated with `type:` |
131
131
  | `@morphTo`, `@morphOne`, `@morphMany`, `@morphToMany` | the polymorphic variants |
132
+ | `@hasManyThrough`, `@hasOneThrough` | PHP Lighthouse's names for `@hasMany` / `@hasOne` on a `has_many` / `has_one :through` |
132
133
 
133
134
  ```graphql
134
135
  type User {
@@ -142,6 +143,28 @@ type User {
142
143
  The relation name defaults to the field name (snake_cased). Override with
143
144
  `@hasMany(relation: "authored_posts")`.
144
145
 
146
+ ### `@with`, `@withCount` and `@lazyLoad`
147
+
148
+ Eager loading for fields the relationship directives do not resolve, each across
149
+ every parent in the response at once. See
150
+ [relationships.md](relationships.md#eager-loading-for-other-fields).
151
+
152
+ ```graphql
153
+ type User {
154
+ "@with loads the relation before the field resolves, for a method that reads it."
155
+ taskSummary: String! @with(relation: "tasks") @method(name: "task_summary")
156
+
157
+ "@withCount sets posts_count on the user; the field's resolver reads it."
158
+ activity: String! @withCount(relation: "posts", scopes: ["published"]) @method
159
+
160
+ "@lazyLoad loads relations of the records the field returns."
161
+ posts: [Post!]! @hasMany @lazyLoad(relations: ["tags", "comments.author"])
162
+ }
163
+ ```
164
+
165
+ `relation` / `relations` may be nested with dots. `scopes` narrow the loaded or
166
+ counted records. `@with`'s `relation` defaults to the field's name.
167
+
145
168
  ---
146
169
 
147
170
  ## Field directives
@@ -162,10 +185,16 @@ type Contact {
162
185
  ```
163
186
 
164
187
  On `Query` and `Mutation` the name is looked up in the queries / mutations
165
- namespaces, elsewhere in [`resolver_namespaces`](configuration.md). The class may
188
+ namespaces, a nested name too (`Feeds::Home` is `Resolvers::Feeds::Home`), and
189
+ then as written. On any other type it is looked up in the resolvers namespace the
190
+ same way (`Posts::TagsCount` is `Resolvers::Posts::TagsCount`), then in
191
+ [`resolver_namespaces`](configuration.md). There, the class's `object` is the parent: the
192
+ record the field belongs to (see
193
+ [resolvers.md](resolvers.md#fields-of-other-types)). The class may
166
194
  be a `GraphQL::Schema::Resolver` (graphql-ruby's contract), or a plain callable —
167
195
  a class-level `call`, an instance `#call`, or an instance `#resolve`, each
168
- receiving `(object, args, context)`:
196
+ receiving `(object, args, context)`, and the [`ResolveInfo`](resolvers.md#resolveinfo)
197
+ as a fourth argument when it takes one:
169
198
 
170
199
  ```ruby
171
200
  module Resolvers
@@ -177,6 +206,17 @@ module Resolvers
177
206
  end
178
207
  ```
179
208
 
209
+ A method after `@` replaces `call`, as PHP Lighthouse's `Class@method`: it is called
210
+ on the class when the class has it, else on an instance. On a
211
+ `GraphQL::Schema::Resolver` it replaces `resolve`, and `ready?` / `authorized?`
212
+ still run.
213
+
214
+ ```graphql
215
+ type Query {
216
+ usersTotal: Int @field(resolver: "Statistics@users_total") # Resolvers::Statistics.users_total
217
+ }
218
+ ```
219
+
180
220
  ### `@rename`
181
221
 
182
222
  Read a differently-named attribute off the parent object. This is the explicit
@@ -191,15 +231,157 @@ type User {
191
231
  ### `@method`
192
232
 
193
233
  Resolve a field by calling a method on the parent object (PHP-parity). Great for
194
- exposing a computed method or predicate without a custom resolver.
234
+ exposing a computed method or predicate without a custom resolver. `name:`
235
+ defaults to the field's snake_case name.
195
236
 
196
237
  ```graphql
197
238
  type Conversation {
198
239
  labels: [String!]! @method(name: "label_list")
199
240
  canReply: Boolean @method(name: "can_reply?")
241
+ summary(words: Int = 20): String! @method
200
242
  }
201
243
  ```
202
244
 
245
+ The field's arguments reach the method: as snake_case keywords when it takes
246
+ keywords (`def summary(words:)`), otherwise positionally, in the order the SDL
247
+ declares them, as PHP Lighthouse passes them (`def summary(words)`). The SDL's
248
+ defaults apply, and a method taking no parameters is called without them.
249
+
250
+ ## Relay
251
+
252
+ ### `@globalId` and `@node`
253
+
254
+ `@globalId` encodes a field's value as a Relay global id (base64 of `Type:id`), and
255
+ decodes an argument or input field (`decode: ARRAY | TYPE | ID`). `@node` on a type
256
+ makes it implement `Node` and adds `Query.node(id:)`, which finds the type's model
257
+ record, or calls `@node(resolver:)`. See [relay.md](relay.md).
258
+
259
+ ```graphql
260
+ type User @node {
261
+ id: ID! @globalId
262
+ }
263
+
264
+ type Query {
265
+ user(id: ID! @globalId(decode: ID) @eq): User @find
266
+ }
267
+ ```
268
+
269
+ ## Execution
270
+
271
+ ### `@namespaced`
272
+
273
+ A field that only groups others: it resolves to `true`, and the fields of its type
274
+ resolve with their own directives. On `Query` or `Mutation`.
275
+
276
+ ```graphql
277
+ type Query { post: PostQueries! @namespaced }
278
+ type PostQueries {
279
+ find(id: ID! @eq): Post @find
280
+ list: [Post!]! @paginate
281
+ }
282
+ ```
283
+
284
+ ### `@throttle`
285
+
286
+ Rate-limits a field per client: at most `maxAttempts` calls every `decayMinutes`
287
+ (default 60 a minute), counted in [`cache_store`](configuration.md#cache_store). A
288
+ client is the signed-in user, else the context's `:remote_ip`, which the controller
289
+ concern sets. Past the limit the field fails with `Rate limit for Query.x exceeded.
290
+ Try again later.` before anything else runs. `name:` makes fields count together,
291
+ `prefix:` separates groups.
292
+
293
+ ```graphql
294
+ type Mutation {
295
+ login(email: String!, password: String!): User @throttle(maxAttempts: 5)
296
+ }
297
+ ```
298
+
299
+ ### `@async`
300
+
301
+ Runs a mutation in a job and answers `true` at once. `@guard`, the `@can*` family,
302
+ `@validator` and the argument directives run in the request, so the client still
303
+ gets their errors; what resolves the field runs in
304
+ `Lighthouse::GraphQL::AsyncMutationJob` (ActiveJob), with the arguments and the
305
+ context values ActiveJob can serialize (records too, through GlobalID). The field
306
+ must be a `Mutation` field returning `Boolean`, and the schema a named constant.
307
+
308
+ ```graphql
309
+ type Mutation {
310
+ importFeed(url: String!): Boolean! @guard @async(queue: "imports")
311
+ }
312
+ ```
313
+
314
+ ### `@hide` and `@show`
315
+
316
+ Leave a field out of the schema by environment (`Rails.env`): `@hide(env:)` removes
317
+ it in the environments listed, `@show(env:)` keeps it only in those.
318
+
319
+ ```graphql
320
+ type Query {
321
+ debugInfo: String @show(env: ["development", "test"])
322
+ legacyFeed: [Post!]! @hide(env: ["production"]) @all
323
+ }
324
+ ```
325
+
326
+ ## Type directives
327
+
328
+ ### `@model`
329
+
330
+ Maps an object type to the ActiveRecord model whose records it resolves, when the
331
+ names differ, as PHP Lighthouse's. Every directive that takes `model:` (`@all`,
332
+ `@find`, `@first`, `@paginate`, `@create`, `@update`, `@upsert`, `@delete`, the
333
+ `@can*` family...) on a field returning the type gets it, unless it names its own; a
334
+ union or interface resolves the model's records to the type; `@node` finds them. The
335
+ schema does not build unless the class is an ActiveRecord model.
336
+
337
+ ```graphql
338
+ type Article @model(class: "Post") { id: ID! title: String! }
339
+
340
+ type Query {
341
+ articles: [Article!]! @paginate # pages through Post
342
+ article(id: ID! @eq): Article @find
343
+ }
344
+ ```
345
+
346
+ ### `@enum`
347
+
348
+ The internal value of an enum value: what a resolver gets for it in an argument,
349
+ and what a result answers with it for.
350
+
351
+ ```graphql
352
+ enum Role {
353
+ ADMIN @enum(value: "admin")
354
+ MEMBER @enum(value: "member")
355
+ }
356
+
357
+ type Query { users(role: Role @eq): [User!]! @all } # filters on "admin"
358
+ ```
359
+
360
+ They go on a union, an interface or a scalar, and name the class that resolves or
361
+ coerces its values. Without them, Lighthouse uses the type's class
362
+ (`Types::<Name>Type` including `Lighthouse::GraphQL::Type`), or its default. See
363
+ [types.md](types.md).
364
+
365
+ ### `@union` and `@interface`
366
+
367
+ Name the class that picks the object type of each value: `resolve_type(object,
368
+ context)` or `call(object, context)`, returning a type name or a type class.
369
+ Looked up in the types namespace (nested names too), then as written.
370
+
371
+ ```graphql
372
+ union SearchResult @union(resolveType: "SearchResultResolver") = Post | User
373
+ interface Authored @interface(resolveType: "AuthoredResolver") { id: ID! }
374
+ ```
375
+
376
+ ### `@scalar`
377
+
378
+ Names the class that reads and writes the scalar's values: `coerce_input(value,
379
+ context)` and `coerce_result(value, context)`, as a `GraphQL::Schema::Scalar`.
380
+
381
+ ```graphql
382
+ scalar DateTime @scalar(class: "Types::DateTimeType")
383
+ ```
384
+
203
385
  ---
204
386
 
205
387
  ## Authentication & Authorization
@@ -263,7 +445,40 @@ type Query {
263
445
  ```
264
446
 
265
447
  Builders are looked up in their own namespace, `Builders::` (app/graphql/builders)
266
- — they are not resolvers. See [resolvers.md](resolvers.md#builders).
448
+ — they are not resolvers. See [resolvers.md](resolvers.md#builders). The class is
449
+ `class:` with `method:` (default `call`), or PHP Lighthouse's `method: "Class@method"`.
450
+
451
+ On a field, the method gets `(relation, args, context)`, or the directive's
452
+ `value:` in place of the arguments. On an argument or an input field, it gets
453
+ `(relation, value, context)` whenever the client sends that argument, an explicit
454
+ `null` included, and not when it is left out:
455
+
456
+ ```graphql
457
+ type Query {
458
+ posts(since: DateTime @builder(method: "Recent@since")): [Post!]! @all
459
+ latest: [Post!]! @all @builder(class: "Recent", value: 5)
460
+ }
461
+ ```
462
+
463
+ ```ruby
464
+ class Builders::Recent
465
+ def self.call(relation, count, _context) = relation.order(created_at: :desc).limit(count)
466
+ def self.since(relation, time, _context) = time ? relation.where(created_at: time..) : relation
467
+ end
468
+ ```
469
+
470
+ ### `@whereAuth`
471
+
472
+ Keep only the records the signed-in user owns, through a relation of the model: a
473
+ `belongs_to`, a `has_many`, a `:through`. A guest gets none. It applies where
474
+ `@builder` does (`@all`, `@paginate`, `@first`, `@count`, `enhance_builder`).
475
+
476
+ ```graphql
477
+ type Query {
478
+ myPosts: [Post!]! @paginate @whereAuth(relation: "author")
479
+ myTeams: [Team!]! @all @whereAuth(relation: "members")
480
+ }
481
+ ```
267
482
 
268
483
  ```ruby
269
484
  module Builders
@@ -292,6 +507,10 @@ Full guide: [filtering-and-ordering.md](filtering-and-ordering.md).
292
507
  | `@like` | `column LIKE %value%` |
293
508
  | `@whereKey` | `primary key = value` (`IN` for a list) |
294
509
  | `@where(operator:)` | generic operator (`GT`, `GTE`, `IN`, `IS_NULL`, `CONTAINS`, …) |
510
+ | `@whereNull` / `@whereNotNull` | `true`: `column IS NULL` / `IS NOT NULL`; `false`: the opposite |
511
+ | `@whereBetween` / `@whereNotBetween` | `column BETWEEN` / `NOT BETWEEN` the input's two fields, in order |
512
+ | `@whereJsonContains` | a JSON column contains the value (every value of a list); `key: "meta->tags"` points into it |
513
+ | `@scope(name:)` | calls the model scope with the value (`name:` defaults to the argument's) |
295
514
  | `@orderBy` | sort by one or more columns |
296
515
 
297
516
  ```graphql
@@ -307,6 +526,41 @@ type Query {
307
526
 
308
527
  The column defaults to the snake_cased argument name; override with `key:`.
309
528
 
529
+ ```graphql
530
+ type Query {
531
+ posts(
532
+ unpublished: Boolean @whereNull(key: "published_at")
533
+ createdAt: DateRange @whereBetween(key: "created_at")
534
+ tagged: [String!] @whereJsonContains(key: "tags")
535
+ trending: Boolean @scope
536
+ filter: PostFilter @nest
537
+ limit: Int @limit
538
+ ): [Post!]! @all
539
+ }
540
+
541
+ input DateRange { from: DateTime! to: DateTime! }
542
+ input PostFilter { title: String @like, authorId: ID @eq(key: "author_id") }
543
+ ```
544
+
545
+ ### `@nest`
546
+
547
+ On an argument whose type is an input object, applies the filters of the input's
548
+ fields (at any depth, for those that carry `@nest` too), as if they were the
549
+ field's own arguments.
550
+
551
+ ### `@limit`
552
+
553
+ On an `Int` argument, caps how many results the field returns at the client's
554
+ value; `null` leaves them alone. A query not run yet gets a `LIMIT`; the records of
555
+ a relation, loaded for every parent at once, are cut per parent. A negative value is
556
+ an error.
557
+
558
+ `@limit(builder: true)` puts the `LIMIT` on the field's query instead, with the
559
+ other argument directives. Use it on root fields: on a relation batched for many
560
+ parents it would cut them all together, as PHP Lighthouse warns. PHP Lighthouse
561
+ also allows `@limit` on the field itself, where it only marks the field the argument
562
+ limits; it is accepted there and changes nothing.
563
+
310
564
  ### `@whereConditions` / `@whereHasConditions`
311
565
 
312
566
  Give clients a structured, dynamic `WHERE` builder (AND/OR/operators, and HAS for
@@ -319,6 +573,35 @@ type Query {
319
573
  }
320
574
  ```
321
575
 
576
+ ### `@trim`, `@hash`, `@drop`, `@bind` and `@upload`
577
+
578
+ Rewrite an argument's or input field's value before the field resolves, in the
579
+ order the SDL lists them:
580
+
581
+ | Directive | The resolver gets |
582
+ | --- | --- |
583
+ | `@trim` | the string without leading and trailing whitespace. On a field: every string it takes, nested inputs included |
584
+ | `@drop` | nothing: the argument is removed |
585
+ | `@hash` | the value hashed with BCrypt (the `bcrypt` gem, as `has_secure_password`) |
586
+ | `@bind(class:, column: "id", with: [], optional: false)` | the model record(s) the value identifies, eager-loading `with`; or what a class's `call(value, context)` returns |
587
+ | `@upload(disk:, path: "/")` | what [`upload_store`](configuration.md#upload_store) answers for the uploaded file: the signed id of an ActiveStorage blob, by default ([uploads.md](uploads.md)) |
588
+
589
+ ```graphql
590
+ type Mutation {
591
+ signUp(email: String! @trim, password: String! @hash, captcha: String @drop): User! @create
592
+ assign(task: ID! @bind(class: "Task"), user: ID! @bind(class: "User", with: ["roles"])): Task!
593
+ updateAvatar(avatar: Upload! @upload(path: "avatars")): User! @update @inject(context: "current_user.id", name: "id")
594
+ }
595
+ ```
596
+
597
+ `@bind` fails the field with a validation error on the argument (`The selected task
598
+ is invalid.`) when no record matches, unless `optional: true`, which passes `null`.
599
+
600
+ `@globalId`, `@trim` and `@drop` only rewrite values: they run first, so `@guard`,
601
+ the `@can*` family and `@validator` see the result. `@hash`, `@bind` and `@upload`
602
+ run once those have let the call through: a validator sees the password before it
603
+ is hashed, and nothing is stored or looked up for a refused call.
604
+
322
605
  ### `@convertEmptyStringsToNull`
323
606
 
324
607
  Replaces incoming empty strings `""` with `null`, before any resolver sees them —