lighthouse-graphql 0.2.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +11 -0
- data/docs/caching.md +1 -1
- data/docs/configuration.md +68 -0
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +306 -0
- data/docs/events.md +201 -0
- data/docs/getting-started.md +38 -9
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +3 -1
- data/docs/subscriptions.md +423 -0
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +31 -3
- data/lib/generators/lighthouse/subscription_generator.rb +95 -0
- data/lib/lighthouse/broadcast_job.rb +14 -0
- data/lib/lighthouse/broadcasting.rb +143 -0
- data/lib/lighthouse/cache_control.rb +5 -2
- data/lib/lighthouse/config.rb +53 -12
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
- data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
- data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +68 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +14 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +51 -0
- data/lib/lighthouse/events/lifecycle.rb +146 -0
- data/lib/lighthouse/events/schema_build.rb +44 -0
- data/lib/lighthouse/events.rb +76 -0
- data/lib/lighthouse/feature_directive.rb +137 -0
- data/lib/lighthouse/field_resolver_stack.rb +22 -2
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/namespaces.rb +1 -0
- data/lib/lighthouse/persisted_queries/batch.rb +34 -0
- data/lib/lighthouse/persisted_queries.rb +17 -4
- data/lib/lighthouse/rb_lighthouse.rb +20 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_generator.rb +27 -2
- data/lib/lighthouse/subscription.rb +120 -0
- data/lib/lighthouse/support/soft_deletes.rb +164 -0
- data/lib/lighthouse/tasks/lighthouse.rake +10 -0
- data/sig/lighthouse/controller.rbs +30 -0
- data/sig/lighthouse/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +33 -0
- data/sig/lighthouse.rbs +52 -1
- metadata +24 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 44b6e89e140483d01ad4cb9c945cfc29d6f025e05798ed4e5e11868060b86885
|
|
4
|
+
data.tar.gz: cac88d1820f171e4cbe6dfaa51b737c405cacf6ec3c08ed4ce8694eb2039aa18
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b70da4e82689cc2808b00c514f4de0eafb60fedcff45d6f0fa28b560bc3dbe502e6d3b4c0a7615d33065f95fa82fc756332added5dd9f2ba9396ef6b38ff79ce
|
|
7
|
+
data.tar.gz: 8a30bc33a00599f3302196e3e24c881be56a68f94026ae1c0ab64246afa7b2114f6e723fba4f203fe51be03661bc6351f65fc12e3d4827624a6e04a2798b53fd
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,60 @@ 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
|
+
|
|
9
63
|
## [0.2.1] - 2026-10-17
|
|
10
64
|
|
|
11
65
|
### Added
|
|
@@ -228,7 +282,8 @@ had been running inside a private monorepo as a path gem before being extracted.
|
|
|
228
282
|
- Config-driven extension seams: `Lighthouse.configure`, `resolver_namespaces`,
|
|
229
283
|
`on_error`, and `DirectiveRegistry.register` for app-defined directives.
|
|
230
284
|
|
|
231
|
-
[Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.
|
|
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
|
|
232
287
|
[0.2.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.2.0...v0.2.1
|
|
233
288
|
[0.2.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.1...v0.2.0
|
|
234
289
|
[0.1.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.1.0...v0.1.1
|
data/README.md
CHANGED
|
@@ -41,6 +41,13 @@ and falls back to a sensible default reader for plain fields. You write Ruby onl
|
|
|
41
41
|
for the genuinely custom parts (`@field(resolver: "...")`), and your schema stays
|
|
42
42
|
the single source of truth. [Motivation](docs/motivation.md) explains the idea in depth.
|
|
43
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.
|
|
50
|
+
|
|
44
51
|
### Inspirations
|
|
45
52
|
|
|
46
53
|
- **[Lighthouse (PHP)](https://lighthouse-php.com)** — the directive vocabulary,
|
|
@@ -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
|
|
|
@@ -157,6 +166,8 @@ or as Markdown in [`docs/`](docs):
|
|
|
157
166
|
| [Validation](docs/validation.md) | Model validations, `@validator` with Dry::Validation contracts |
|
|
158
167
|
| [Authentication & authorization](docs/authorization.md) | `@guard`, `@canFind` / `@canModel` / `@canQuery` / `@canResolved` / `@canRoot` (Pundit by default) |
|
|
159
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 |
|
|
160
171
|
| [Caching](docs/caching.md) | `@cache`, `@cacheKey`, `@clearCache` (Rails.cache), `@cacheControl` (HTTP) |
|
|
161
172
|
| [Apollo Federation](docs/federation.md) | `@key`, `resolve_reference`, the `ReferenceResolver` registry |
|
|
162
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
|
|
102
|
+
directive @cacheControl(maxAge: Int = 0, scope: CacheControlScope = PUBLIC, inheritMaxAge: Boolean = false)
|
|
103
103
|
on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
|
|
104
104
|
```
|
|
105
105
|
|
data/docs/configuration.md
CHANGED
|
@@ -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))
|
data/docs/custom-directives.md
CHANGED
|
@@ -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
|
-
|
|
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
|