lighthouse-graphql 0.2.0 → 0.3.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/README.md +16 -1
  4. data/docs/caching.md +1 -1
  5. data/docs/configuration.md +68 -0
  6. data/docs/custom-directives.md +11 -1
  7. data/docs/directives.md +306 -0
  8. data/docs/events.md +201 -0
  9. data/docs/getting-started.md +38 -9
  10. data/docs/motivation.md +184 -0
  11. data/docs/mutations.md +15 -0
  12. data/docs/resolvers.md +13 -1
  13. data/docs/subscriptions.md +423 -0
  14. data/lib/generators/lighthouse/builder_generator.rb +1 -1
  15. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
  16. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +31 -3
  17. data/lib/generators/lighthouse/subscription_generator.rb +95 -0
  18. data/lib/lighthouse/broadcast_job.rb +14 -0
  19. data/lib/lighthouse/broadcasting.rb +143 -0
  20. data/lib/lighthouse/cache_control.rb +5 -2
  21. data/lib/lighthouse/config.rb +53 -12
  22. data/lib/lighthouse/controller.rb +131 -0
  23. data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
  24. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
  25. data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
  26. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
  27. data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
  28. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +68 -0
  29. data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
  30. data/lib/lighthouse/directives/registry.rb +14 -6
  31. data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
  32. data/lib/lighthouse/events/execution_trace.rb +51 -0
  33. data/lib/lighthouse/events/lifecycle.rb +146 -0
  34. data/lib/lighthouse/events/schema_build.rb +44 -0
  35. data/lib/lighthouse/events.rb +76 -0
  36. data/lib/lighthouse/feature_directive.rb +137 -0
  37. data/lib/lighthouse/field_resolver_stack.rb +22 -2
  38. data/lib/lighthouse/graphql/version.rb +1 -1
  39. data/lib/lighthouse/namespaces.rb +1 -0
  40. data/lib/lighthouse/persisted_queries/batch.rb +34 -0
  41. data/lib/lighthouse/persisted_queries.rb +17 -4
  42. data/lib/lighthouse/rb_lighthouse.rb +20 -0
  43. data/lib/lighthouse/schema_cache.rb +12 -5
  44. data/lib/lighthouse/schema_generator.rb +27 -2
  45. data/lib/lighthouse/subscription.rb +120 -0
  46. data/lib/lighthouse/support/soft_deletes.rb +164 -0
  47. data/lib/lighthouse/tasks/lighthouse.rake +10 -0
  48. data/sig/lighthouse/controller.rbs +30 -0
  49. data/sig/lighthouse/directives.rbs +158 -0
  50. data/sig/lighthouse/events.rbs +122 -0
  51. data/sig/lighthouse/graphql.rbs +111 -0
  52. data/sig/lighthouse.rbs +193 -0
  53. metadata +29 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3be850a4eb57c4d4fc6bd41103d5bb783482530f3fc0f3584aa00314de4e936
4
- data.tar.gz: 75ae5604d85d1549f159dc17a98932d7c4274074ea26b9a9ade38b4f8d8d6af6
3
+ metadata.gz: 44b6e89e140483d01ad4cb9c945cfc29d6f025e05798ed4e5e11868060b86885
4
+ data.tar.gz: cac88d1820f171e4cbe6dfaa51b737c405cacf6ec3c08ed4ce8694eb2039aa18
5
5
  SHA512:
6
- metadata.gz: 62cafecf9c4deae7b974747a9de82c422ea1d58d50fa752d6c583fba20f6ff16b99db56496a9963d0740826803b89e730c3ede19fed937baf22fac7f954bd826
7
- data.tar.gz: fcd638c332e4f055afb9b28e7ac211162582878e578e5cb45c02a7d24dcf2ae70f7dd5342cdd477bc1f8a16b3cc1e6dd78e487791ee396044fefbe7aa95bea49
6
+ metadata.gz: b70da4e82689cc2808b00c514f4de0eafb60fedcff45d6f0fa28b560bc3dbe502e6d3b4c0a7615d33065f95fa82fc756332added5dd9f2ba9396ef6b38ff79ce
7
+ data.tar.gz: 8a30bc33a00599f3302196e3e24c881be56a68f94026ae1c0ab64246afa7b2114f6e723fba4f203fe51be03661bc6351f65fc12e3d4827624a6e04a2798b53fd
data/CHANGELOG.md CHANGED
@@ -6,6 +6,80 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-17
10
+
11
+ ### Added
12
+
13
+ - `Lighthouse::GraphQL::Controller`, a Rails controller concern: `include` it, name the schema
14
+ with `lighthouse_schema AppSchema`, and its `execute` action runs the operation through
15
+ persisted queries, batches a JSON array body, reads the variables, sets the Cache-Control
16
+ header and renders the result. Its private hooks (`graphql_context`, `graphql_variables`,
17
+ `graphql_schema`) adjust it, and `lighthouse_request { }` gives an action of your own the
18
+ request events. The generated `GraphqlController` and the example app now use it.
19
+ - Events, as PHP Lighthouse dispatches them (`docs/events.md`): `Lighthouse::Events.listen`
20
+ for `StartRequest`, `StartOperationOrOperations`, `BuildSchemaString` (its listeners return SDL to add),
21
+ `RegisterDirectiveNamespaces` (its listeners return namespaces of directive classes to register), `ManipulateAST`,
22
+ `StartExecution`, `BuildExtensionsResponse` (its listeners return an `ExtensionsResponse` for the result's
23
+ `extensions`), `ManipulateResult`, `EndExecution`, `EndOperationOrOperations`, `EndRequest`
24
+ and `ValidateSchema`. Each one is also instrumented through `ActiveSupport::Notifications`
25
+ as `<event>.lighthouse`.
26
+ - `@event(dispatch:)` dispatches an event class, constructed with the field's resolved value,
27
+ once the field resolves. Event classes are looked up in `Config.namespaces[:events]` (`Events`).
28
+ - `PersistedQueries.multiplex` runs a batch of operations.
29
+ - `bin/rails lighthouse:validate_schema` dispatches `ValidateSchema` and builds every type.
30
+ - `@feature(name:, when:)` shows a field or object type only while a feature flag is in that
31
+ state, checked per query through graphql-ruby's visibility. It uses Flipper by default;
32
+ `Config.feature_checker` and `Config.feature_actor` change that.
33
+ - `@convertEmptyStringsToNull` on an argument, input field or field, or on every field with
34
+ `Config.convert_empty_strings_to_null`, turns incoming `""` into `null`.
35
+ - Soft deletes: `@softDeletes` adds a `trashed: Trashed @trashed` argument (`ONLY`, `WITH`,
36
+ `WITHOUT`), and `@restore` and `@forceDelete` restore or permanently delete the soft-deleted models
37
+ their filters select. They work with paranoia, discard or a plain `deleted_at` column,
38
+ detected per model, or with an adapter set in `Config.soft_deletes`.
39
+ - `@withoutGlobalScopes(names:)` lifts a model's default scope conditions when the client passes
40
+ `true`. Each name is a `without_<name>` scope or a column.
41
+ - Subscriptions (`docs/subscriptions.md`), on graphql-ruby's subscriptions: `Subscription`
42
+ fields resolve to plain `GraphQL::Schema::Subscription` classes in `Subscriptions::` by convention,
43
+ or to the class `@subscription(class:)` names, or work with no class at all.
44
+ `@broadcast(subscription:, shouldQueue:)` triggers a subscription once a mutation succeeds,
45
+ inline or in an ActiveJob (`Config.queue_broadcasts`, `Config.broadcast_queue`), with the class's
46
+ `self.trigger_arguments(root, context)` and `trigger_scope`; a plain class with required arguments
47
+ and no `trigger_arguments` (which could never reach a subscriber) is reported as a
48
+ `Lighthouse::DefinitionError`. The optional concern `Lighthouse::GraphQL::Subscription` adds PHP
49
+ Lighthouse's `authorize?`, `filter?` and `resolve_payload` hooks. `rails generate lighthouse:subscription`
50
+ writes a `GraphQL::Schema::Subscription` (`--lighthouse` to include the concern). The example app
51
+ has real ActionCable subscriptions (`GraphqlChannel`) with channel and end-to-end delivery specs.
52
+ - The initializer `rails generate lighthouse:install` writes lists the new settings, and
53
+ `docs/custom-directives.md` shows registering a namespace of directives through
54
+ `RegisterDirectiveNamespaces`.
55
+
56
+ ### Fixed
57
+
58
+ - Schemas using `@cacheControl` (or `@feature`) build on graphql 2.5 again. Their arguments
59
+ with a default are now nullable (`maxAge: Int = 0`, not `Int! = 0`), because graphql 2.5 does not
60
+ fill in a directive argument's default and so refused every usage that left one out. CI now
61
+ also runs the suite on graphql 2.5.
62
+
63
+ ## [0.2.1] - 2026-10-17
64
+
65
+ ### Added
66
+
67
+ - RBS signatures in `sig/`, shipped with the gem, for the parts an app implements or calls:
68
+ `Lighthouse::_Builder` (what a `@builder` class answers) and `_Validator`, every
69
+ `Lighthouse::Config` setting with the callables it takes (`_ErrorHandler`, `_UserReader`,
70
+ `_Authorizer`), the directive extension points (`DirectiveRegistry`, the contracts,
71
+ `BaseDirectiveResolver`, `Directives::Arguments::Base`), the entrypoints (`get_schema`,
72
+ `SchemaFactory`, `PersistedQueries`, `CacheControl`, `SchemaCache`, `IdeHelper`) and the
73
+ errors. `rbs collection` (and so Steep) loads them from the installed gem, as it does for any
74
+ gem that ships `sig/`; nothing changes at runtime.
75
+ CI validates them and runs the suite with every Lighthouse method checked against its
76
+ signature, so they cannot drift from the code.
77
+ - A documentation site, built from `docs/` with VuePress as PHP Lighthouse builds its own,
78
+ and published to GitHub Pages on every push to `main`:
79
+ <https://vaz-innovation.github.io/lighthouse-graphql/>.
80
+ - `docs/motivation.md`: where Lighthouse comes from, why the schema comes first, and why
81
+ directives work so well on Active Record — and when something else fits better.
82
+
9
83
  ## [0.2.0] - 2026-10-17
10
84
 
11
85
  ### Added
@@ -208,7 +282,9 @@ had been running inside a private monorepo as a path gem before being extracted.
208
282
  - Config-driven extension seams: `Lighthouse.configure`, `resolver_namespaces`,
209
283
  `on_error`, and `DirectiveRegistry.register` for app-defined directives.
210
284
 
211
- [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.2.0...HEAD
285
+ [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.3.0...HEAD
286
+ [0.3.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.2.1...v0.3.0
287
+ [0.2.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.2.0...v0.2.1
212
288
  [0.2.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.1...v0.2.0
213
289
  [0.1.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.0...v0.1.1
214
290
  [0.1.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/releases/tag/v0.1.0
data/README.md CHANGED
@@ -39,7 +39,14 @@ paginate, walk a relationship, or read an attribute. Lighthouse — like its PHP
39
39
  inspiration — captures those patterns as **directives** you attach in the schema,
40
40
  and falls back to a sensible default reader for plain fields. You write Ruby only
41
41
  for the genuinely custom parts (`@field(resolver: "...")`), and your schema stays
42
- the single source of truth.
42
+ the single source of truth. [Motivation](docs/motivation.md) explains the idea in depth.
43
+
44
+ The same directives reach the rest of an API, too:
45
+ - real-time updates: `Subscription` fields on graphql-ruby's subscriptions, triggered
46
+ by a mutation's `@broadcast`;
47
+ - soft deletes (`@softDeletes`, `@restore`, `@forceDelete`) and feature flags (`@feature`);
48
+ - request lifecycle [events](docs/events.md);
49
+ - a controller concern that serves the schema in a few lines.
43
50
 
44
51
  ### Inspirations
45
52
 
@@ -62,6 +69,8 @@ gem 'lighthouse-graphql'
62
69
 
63
70
  It depends on `graphql ~> 2.5` and `activesupport`. For federation you also need
64
71
  `apollo-federation` in your app (it is an optional, app-provided dependency).
72
+ On graphql 2.5, keep the `json` gem below 3: graphql 2.5 calls `JSON.generate`
73
+ with an option json 3 removed (graphql 2.6 no longer does).
65
74
 
66
75
  Then let the generator set the app up:
67
76
 
@@ -142,8 +151,12 @@ are the library's end-to-end suite. See [example/README.md](example/README.md).
142
151
 
143
152
  ## Documentation
144
153
 
154
+ **Read it at [vaz-innovation.github.io/lighthouse-graphql](https://vaz-innovation.github.io/lighthouse-graphql/)**,
155
+ or as Markdown in [`docs/`](docs):
156
+
145
157
  | Guide | What it covers |
146
158
  | --- | --- |
159
+ | [Motivation](docs/motivation.md) | Where Lighthouse comes from, why the schema comes first, and why directives fit Active Record |
147
160
  | [Getting started](docs/getting-started.md) | Install, base types, building & serving the schema |
148
161
  | [Directives reference](docs/directives.md) | Every built-in directive, grouped by purpose |
149
162
  | [Relationships](docs/relationships.md) | `@hasMany`, `@belongsTo`, `@belongsToMany`, `@hasOne`, the `@morph*` variants, batching and `type:` pagination |
@@ -153,6 +166,8 @@ are the library's end-to-end suite. See [example/README.md](example/README.md).
153
166
  | [Validation](docs/validation.md) | Model validations, `@validator` with Dry::Validation contracts |
154
167
  | [Authentication & authorization](docs/authorization.md) | `@guard`, `@canFind` / `@canModel` / `@canQuery` / `@canResolved` / `@canRoot` (Pundit by default) |
155
168
  | [Performance at scale](docs/performance.md) | Opt-in schema cache, SDL reloading in development, persisted queries (APQ) and trusted-document allowlists |
169
+ | [Events](docs/events.md) | Request, schema-build and execution events, `@event`, the controller concern |
170
+ | [Subscriptions](docs/subscriptions.md) | `Subscription` fields, `@subscription`, `@broadcast`, filtering and queued broadcasts |
156
171
  | [Caching](docs/caching.md) | `@cache`, `@cacheKey`, `@clearCache` (Rails.cache), `@cacheControl` (HTTP) |
157
172
  | [Apollo Federation](docs/federation.md) | `@key`, `resolve_reference`, the `ReferenceResolver` registry |
158
173
  | [Custom directives](docs/custom-directives.md) | The contracts, the registry, writing your own directive |
data/docs/caching.md CHANGED
@@ -99,7 +99,7 @@ Each cached read costs one `read_multi` for the tokens.
99
99
  ## `@cacheControl`
100
100
 
101
101
  ```graphql
102
- directive @cacheControl(maxAge: Int! = 0, scope: CacheControlScope! = PUBLIC, inheritMaxAge: Boolean! = false)
102
+ directive @cacheControl(maxAge: Int = 0, scope: CacheControlScope = PUBLIC, inheritMaxAge: Boolean = false)
103
103
  on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
104
104
  ```
105
105
 
@@ -35,6 +35,8 @@ different contracts, so they live apart ([resolvers.md](resolvers.md)):
35
35
  | `mutations` | `['Mutations']` | `GraphQL::Schema::Mutation` classes for `Mutation` fields |
36
36
  | `builders` | `['Builders']` | `@builder` classes |
37
37
  | `validators` | `['Validators']` | `@validator` contracts |
38
+ | `events` | `['Events']` | Classes `@event(dispatch:)` names ([events.md](events.md)) |
39
+ | `subscriptions` | `['Subscriptions']` | `GraphQL::Schema::Subscription` classes (optionally including `Lighthouse::GraphQL::Subscription`) for `Subscription` fields, and `@subscription(class:)` ([subscriptions.md](subscriptions.md)) |
38
40
 
39
41
  ```ruby
40
42
  config.namespaces = { queries: ['Api::Queries'] } # the other kinds keep their defaults
@@ -100,17 +102,83 @@ config.persisted_queries_manifest = Rails.root.join('persisted-queries.json') #
100
102
  config.persisted_queries_only = Rails.env.production? # run nothing else
101
103
  ```
102
104
 
105
+ ### `convert_empty_strings_to_null`
106
+
107
+ Makes every field behave as if it carried
108
+ [`@convertEmptyStringsToNull`](directives.md#convertemptystringstonull): empty
109
+ strings sent for nullable inputs arrive as `nil`, non-null inputs unchanged.
110
+ Defaults to `false`. It is read when the schema is built, so set it in the
111
+ initializer.
112
+
113
+ ```ruby
114
+ config.convert_empty_strings_to_null = true
115
+ ```
116
+
117
+ ### `queue_broadcasts`, `broadcast_queue`
118
+
119
+ Whether [`@broadcast`](directives.md#broadcast) triggers its subscription in an
120
+ ActiveJob (`Lighthouse::GraphQL::BroadcastJob`, needs the `activejob` gem)
121
+ instead of inside the mutation's request, and the queue it goes to. A directive's
122
+ `shouldQueue:` overrides `queue_broadcasts`. Defaults: `false`, and `nil` (the
123
+ default queue of ActiveJob). See [subscriptions.md](subscriptions.md#queueing).
124
+
125
+ ```ruby
126
+ config.queue_broadcasts = true
127
+ config.broadcast_queue = :subscriptions
128
+ ```
129
+
103
130
  ### `transactional_mutations`
104
131
 
105
132
  Whether `@create` / `@update` / `@createMany` / `@updateMany` run in a database
106
133
  transaction. Defaults to `true`.
107
134
 
135
+ ### `soft_deletes`
136
+
137
+ What "trashed" means, for `@softDeletes`, `@trashed`, `@restore`, `@forceDelete`
138
+ and `@withoutGlobalScopes`. By default each model is inspected: paranoia
139
+ (`acts_as_paranoid`), then discard (`Discard::Model`), then a `deleted_at` column.
140
+ Set it for another column, or a scheme of your own:
141
+
142
+ ```ruby
143
+ # Another column, for models without a gem:
144
+ config.soft_deletes = Lighthouse::Support::SoftDeletes::Column.new(:archived_at)
145
+
146
+ # Any object answering these (relations in, relations out; records in, truthy out):
147
+ class SoftDeleteAdapter
148
+ def soft_deletes?(model) = model.include?(Archivable)
149
+ def only_trashed(relation) = relation.unscope(where: :archived_at).where.not(archived_at: nil)
150
+ def with_trashed(relation) = relation.unscope(where: :archived_at)
151
+ def without_trashed(relation) = relation.unscope(where: :archived_at).where(archived_at: nil)
152
+ def restore(record) = record.unarchive
153
+ def force_delete(record) = record.destroy
154
+ end
155
+ config.soft_deletes = SoftDeleteAdapter.new
156
+ ```
157
+
158
+ See [Soft deletes & global scopes](directives.md#soft-deletes-global-scopes).
159
+
108
160
  ### `authenticated_user`, `policy_user`, `authorizer`
109
161
 
110
162
  The authentication and authorization seams behind `@guard` and the `@can*`
111
163
  family. The authorizer is `->(user, record, ability, args = [])`, Pundit by
112
164
  default. See [authorization.md](authorization.md#configuration).
113
165
 
166
+ ### `feature_checker`, `feature_actor`
167
+
168
+ The seams behind [`@feature`](directives.md#feature). The checker is
169
+ `->(name, actor, ctx) { true/false }`, called with the feature's name as written
170
+ in the SDL; by default it asks Flipper, `Flipper.enabled?(name.to_sym, actor)`,
171
+ and raises a clear error when Flipper is not loaded. The actor is
172
+ `->(ctx) { actor }`; by default it is the authenticated user (a Flipper actor
173
+ answers `flipper_id`), and `nil` checks the feature globally.
174
+
175
+ ```ruby
176
+ Lighthouse.configure do |config|
177
+ config.feature_actor = ->(ctx) { ctx[:current_account] }
178
+ config.feature_checker = ->(name, account, _ctx) { account&.features&.include?(name) }
179
+ end
180
+ ```
181
+
114
182
  ## Extending the registry
115
183
 
116
184
  Register app-defined directives (see [custom-directives.md](custom-directives.md))
@@ -109,7 +109,17 @@ Lighthouse::DirectiveRegistry.register(App::Graphql::Directives::StartsWith)
109
109
  Lighthouse::DirectiveRegistry.register(App::Graphql::Directives::UpcaseDirective)
110
110
  ```
111
111
 
112
- It is now usable in your SDL — no other changes:
112
+ To register every directive in a namespace at once, as PHP Lighthouse locates
113
+ directives by namespace, listen to the
114
+ [`RegisterDirectiveNamespaces`](events.md) event. Each class there with a
115
+ `directive_name` that is a resolver or an argument builder gets registered when
116
+ the schema is built:
117
+
118
+ ```ruby
119
+ Lighthouse::Events.listen(Lighthouse::Events::RegisterDirectiveNamespaces) { 'App::Graphql::Directives' }
120
+ ```
121
+
122
+ Either way, it is now usable in your SDL — no other changes:
113
123
 
114
124
  ```graphql
115
125
  type Query {
data/docs/directives.md CHANGED
@@ -318,6 +318,33 @@ type Query {
318
318
  }
319
319
  ```
320
320
 
321
+ ### `@convertEmptyStringsToNull`
322
+
323
+ Replaces incoming empty strings `""` with `null`, before any resolver sees them —
324
+ directive resolvers (`@create`, `@all` filters, …), `@field`, `Queries::` /
325
+ `Mutations::` classes and `@validator` alike. On an argument or an input field it
326
+ converts that one input; on a field it converts every input of the field, nested
327
+ input objects and lists included. Non-null inputs (`String!`) pass unchanged when
328
+ the directive is only on the field: placing it on the non-null argument or input
329
+ field itself forces the conversion.
330
+
331
+ ```graphql
332
+ type Mutation {
333
+ createPost(title: String @convertEmptyStringsToNull, body: String): Post! @create
334
+ signUp(input: SignUpInput!): User! @create @convertEmptyStringsToNull
335
+ }
336
+
337
+ input SignUpInput {
338
+ name: String # "" becomes null on signUp, but not where no directive applies
339
+ nickname: String @convertEmptyStringsToNull # on every field taking SignUpInput
340
+ email: String! # unchanged; add the directive here to force it
341
+ }
342
+ ```
343
+
344
+ A directive on an input field applies wherever the input type is used; one on a
345
+ field affects only that field. To enable it for every field, set
346
+ [`convert_empty_strings_to_null`](configuration.md#convert_empty_strings_to_null).
347
+
321
348
  ---
322
349
 
323
350
  ## Mutation directives
@@ -342,6 +369,197 @@ See [mutations.md](mutations.md).
342
369
 
343
370
  ---
344
371
 
372
+ ## Soft deletes & global scopes
373
+
374
+ Rails has no built-in soft deletes or named global scopes, so these directives
375
+ work with what Rails apps use. Lighthouse detects, per model, in this order:
376
+
377
+ 1. **[paranoia](https://github.com/rubysherpas/paranoia)** (`acts_as_paranoid`):
378
+ a default scope hides trashed records.
379
+ 2. **[discard](https://github.com/jhawthorn/discard)** (`include Discard::Model`):
380
+ nothing is hidden, unless the model adds `default_scope -> { kept }`.
381
+ 3. **A plain `deleted_at` column**, with no gem (a default scope is optional).
382
+
383
+ An app with its own scheme, or another column, sets
384
+ [`Config.soft_deletes`](configuration.md#soft_deletes). A model that matches none
385
+ of them is an error when a client uses one of these directives on it
386
+ (`Model Widget does not support soft deletes …`).
387
+
388
+ ### `@softDeletes`
389
+
390
+ Lets clients filter trashed records. It adds the argument
391
+ `trashed: Trashed @trashed` to the field and defines the `Trashed` enum once
392
+ (unless your schema declares its own).
393
+
394
+ ```graphql
395
+ type Query {
396
+ tasks: [Task!]! @all @softDeletes
397
+ }
398
+ ```
399
+
400
+ becomes
401
+
402
+ ```graphql
403
+ type Query {
404
+ tasks(trashed: Trashed @trashed): [Task!]! @all
405
+ }
406
+
407
+ "Specify if you want to include or exclude trashed results from a query."
408
+ enum Trashed {
409
+ "Only return trashed results."
410
+ ONLY
411
+ "Return both trashed and non-trashed results."
412
+ WITH
413
+ "Only return non-trashed results."
414
+ WITHOUT
415
+ }
416
+ ```
417
+
418
+ ### `@trashed`
419
+
420
+ The argument builder `@softDeletes` adds; use it by hand to name the argument
421
+ yourself. It works wherever argument filters do: `@all`, `@paginate`, `@find`,
422
+ `@first`, `@count` and the relationship directives (`@hasMany`, …, including
423
+ paginated ones, which keep batching through the Dataloader).
424
+
425
+ ```graphql
426
+ type Query {
427
+ flights(trashed: Trashed @trashed, destination: String @eq): [Flight!]! @all
428
+ }
429
+ type Post {
430
+ comments(trashed: Trashed @trashed): [Comment!]! @hasMany
431
+ }
432
+ ```
433
+
434
+ | Value | Result |
435
+ | --- | --- |
436
+ | `ONLY` | only trashed records |
437
+ | `WITH` | trashed and non-trashed records |
438
+ | `WITHOUT` | only non-trashed records |
439
+ | *(omitted)* | **the model's own behaviour, untouched**: paranoia and a default scope hide trashed records, a bare `deleted_at` column or discard without a default scope show everything |
440
+
441
+ `ONLY`, `WITH` and `WITHOUT` lift the model's default scope on the soft-delete
442
+ column first, so they behave the same whatever the model does by default. That
443
+ is `unscope(where: column)`, which removes every condition on that column from
444
+ the query, including one added by an argument declared before `trashed`. Avoid a
445
+ separate filter on the soft-delete column itself.
446
+
447
+ ### `@forceDelete`
448
+
449
+ Permanently removes one or more soft deleted models. It works like
450
+ [`@delete`](mutations.md#delete): the filter arguments (`@whereKey`, `@eq`, …) are
451
+ required (`Would modify all models, use an argument to filter.`), the removed
452
+ models are returned (a list for a list field, else the first or `null`), and
453
+ `model:` and `scopes:` are honoured. It finds trashed models even when the model
454
+ hides them by default, and it removes live ones too. It runs in a transaction
455
+ (`Config.transactional_mutations`).
456
+
457
+ ```graphql
458
+ type Mutation {
459
+ forceDeletePost(id: ID! @whereKey): Post @forceDelete
460
+ forceDeletePosts(ids: [ID!]! @whereKey): [Post!]! @forceDelete
461
+ }
462
+ ```
463
+
464
+ paranoia models are removed with `really_destroy!`, discard and `deleted_at`
465
+ models with `destroy`. A model whose callbacks refuse is reported as
466
+ `Could not modify model Post with ID 1.`
467
+
468
+ ### `@restore`
469
+
470
+ Un-deletes one or more soft deleted models, with the same arguments, guard and
471
+ result as `@forceDelete`: paranoia's `restore`, discard's `undiscard`, or setting
472
+ the `deleted_at` column to `NULL`.
473
+
474
+ ```graphql
475
+ type Mutation {
476
+ restorePost(id: ID! @whereKey): Post @restore
477
+ }
478
+ ```
479
+
480
+ ### `@withoutGlobalScopes`
481
+
482
+ Omits global scopes from the query when the client passes `true`. Use it on a
483
+ `Boolean` argument; `false` and an omitted argument leave the scopes in place.
484
+
485
+ ```graphql
486
+ type Query {
487
+ posts(includeUnscheduled: Boolean @withoutGlobalScopes(names: ["scheduled"])): [Post!]! @all
488
+ }
489
+ ```
490
+
491
+ ActiveRecord has one anonymous `default_scope`, not scopes with names, so each
492
+ name is resolved like this (snake_cased; a trailing `Scope` is optional):
493
+
494
+ 1. A model scope **`without_<name>`**, if the model has one. You write it:
495
+ `scope :without_scheduled, -> { unscope(where: :schedule_at) }`.
496
+ 2. Else a **column** of that name: `unscope(where: column)`. Like `@trashed`, this
497
+ removes every condition on that column from the query, including ones added by
498
+ earlier arguments.
499
+ 3. Else a soft-delete name (`softDeletes`, `SoftDeletingScope`): trashed records
500
+ are included, as with `trashed: WITH`.
501
+
502
+ Any other name is an error: `Cannot omit the global scope …`.
503
+
504
+ ---
505
+
506
+ ## Subscriptions
507
+
508
+ Fields of the `Subscription` type resolve to a `Subscriptions::` class by
509
+ convention, like queries and mutations: a plain graphql-ruby
510
+ `GraphQL::Schema::Subscription` (`authorized?`, `subscribe`, `update`, ...).
511
+ `@subscription` names another one, and `@broadcast` triggers a subscription from
512
+ a mutation. The backend (ActionCable, ...) is the schema's; see
513
+ [subscriptions.md](subscriptions.md).
514
+
515
+ ### `@subscription`
516
+
517
+ ```graphql
518
+ directive @subscription(class: String!) on FIELD_DEFINITION
519
+ ```
520
+
521
+ Names the class a `Subscription` field resolves to, when it does not follow the
522
+ convention (`postUpdated` → `Subscriptions::PostUpdated`). The class inherits from
523
+ `GraphQL::Schema::Subscription` (which may `include Lighthouse::GraphQL::Subscription`,
524
+ the optional concern with PHP Lighthouse's `filter?` hooks) and is looked up in
525
+ `Config.namespaces[:subscriptions]`; a name with `::` is used as it is.
526
+
527
+ ```graphql
528
+ type Subscription {
529
+ postUpdated(author: ID!): Post @subscription(class: "Blog::PostUpdatedSubscription")
530
+ }
531
+ ```
532
+
533
+ ### `@broadcast`
534
+
535
+ ```graphql
536
+ directive @broadcast(subscription: String!, shouldQueue: Boolean) repeatable on FIELD_DEFINITION
537
+ ```
538
+
539
+ Once the field has resolved, triggers the `subscription` field of the
540
+ `Subscription` type with the result as its root, so its subscribers are updated,
541
+ each passing through the class's `update` (or `filter?`). The class's
542
+ `self.trigger_arguments(root, context)` says which subscribers' arguments the
543
+ trigger must equal: none are needed for a field without arguments, and a plain
544
+ class with a required argument and no `trigger_arguments` is reported as a
545
+ `Lighthouse::DefinitionError`. A failed mutation, or a `nil` result, broadcasts
546
+ nothing. An unknown subscription name is a
547
+ `Lighthouse::DefinitionError` when the schema is built. `shouldQueue` performs the
548
+ trigger in an ActiveJob; without it, `Config.queue_broadcasts` (default `false`)
549
+ decides.
550
+
551
+ ```graphql
552
+ type Mutation {
553
+ createPost(input: CreatePostInput! @spread): Post! @create @broadcast(subscription: "postCreated")
554
+ updatePost(input: UpdatePostInput! @spread): Post!
555
+ @update
556
+ @broadcast(subscription: "postUpdated", shouldQueue: false)
557
+ @broadcast(subscription: "postChanged")
558
+ }
559
+ ```
560
+
561
+ ---
562
+
345
563
  ## Validation
346
564
 
347
565
  `@validator` runs a contract (Dry::Validation by default) before a field
@@ -369,6 +587,94 @@ type Query {
369
587
 
370
588
  ---
371
589
 
590
+ ## Feature flags
591
+
592
+ ### `@feature`
593
+
594
+ Include a field or an object type in the schema depending on a feature flag,
595
+ backed by [Flipper](https://www.flippercloud.io/docs) (PHP Lighthouse's
596
+ `@feature` is backed by Laravel Pennant).
597
+
598
+ ```graphql
599
+ type Query {
600
+ newCheckout: Checkout @feature(name: "new_checkout")
601
+ legacyCheckout: Checkout @feature(name: "new_checkout", when: INACTIVE)
602
+ }
603
+
604
+ type Beta @feature(name: "beta") {
605
+ id: ID!
606
+ }
607
+ ```
608
+
609
+ | Argument | Meaning |
610
+ | --- | --- |
611
+ | `name: String!` | The feature to check. |
612
+ | `when: FeatureState = ACTIVE` | The state the feature must be in for the element to be included: `ACTIVE` or `INACTIVE`. |
613
+
614
+ Feature flags depend on who asks, while the schema is built once, so the element
615
+ stays in the schema and is hidden **per query**, through graphql-ruby's
616
+ [visibility](https://graphql-ruby.org/authorization/visibility.html). A hidden
617
+ field or type disappears from introspection, and a query selecting it fails
618
+ validation (`Field 'newCheckout' doesn't exist on type 'Query'`). The fields
619
+ returning a hidden type are hidden with it. `to_definition` prints the schema as
620
+ the features stand for no actor.
621
+
622
+ Each feature is checked at most once per query. By default Flipper is asked
623
+ whether the feature is enabled for the authenticated user (`Flipper.enabled?(:new_checkout, user)`;
624
+ the user answers `flipper_id`, which Flipper's ActiveRecord integration adds to
625
+ models); with no user, the global gate decides. Flipper is optional: add
626
+ `gem "flipper"` to your Gemfile, or configure your own check with
627
+ `feature_checker` and `feature_actor` (see [configuration.md](configuration.md#feature_checker-feature_actor)).
628
+
629
+ ```ruby
630
+ Flipper.enable_actor(:new_checkout, User.find(1))
631
+
632
+ # schema.execute(query, context: { current_user: user })
633
+ ```
634
+
635
+ ---
636
+
637
+ ## Events
638
+
639
+ `@event` dispatches an event once the field has resolved. The class is built with
640
+ the resolved value and handed to `Lighthouse::Events.dispatch`; the value is
641
+ returned unchanged, and nothing is dispatched when the field raises. It is
642
+ repeatable: each `@event` dispatches its own. See [events.md](events.md) for
643
+ listening, and for the events Lighthouse itself dispatches.
644
+
645
+ ```graphql
646
+ directive @event(dispatch: String!) repeatable on FIELD_DEFINITION
647
+ ```
648
+
649
+ ```graphql
650
+ type Mutation {
651
+ shipOrder(id: ID!): Order!
652
+ @update
653
+ @event(dispatch: "OrderShipped")
654
+ @event(dispatch: "Billing::InvoiceDue")
655
+ }
656
+ ```
657
+
658
+ ```ruby
659
+ class OrderShipped
660
+ attr_reader :order
661
+
662
+ def initialize(order) = @order = order
663
+ end
664
+
665
+ Lighthouse::Events.listen(OrderShipped) { |event| Mailer.shipped(event.order).deliver_later }
666
+ ```
667
+
668
+ The class is looked up in `Config.namespaces[:events]` (default `['Events']`,
669
+ so `Events::OrderShipped`, then `::OrderShipped`); a name with `::` is used
670
+ as-is. A name that finds no class fails the schema build with
671
+ `Lighthouse::DefinitionError`. `@event` runs inside `@guard`, `@can*`,
672
+ `@validator` and `@cache`: a rejected request, or a cache hit, dispatches
673
+ nothing. (`@canResolved` checks the resolved value, so there the event is
674
+ dispatched before the check.)
675
+
676
+ ---
677
+
372
678
  ## Adding your own
373
679
 
374
680
  Every directive above is a small class registered with