Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
92b9419
feat(Query::Result) add a first-class result object
rmosolgo Aug 18, 2017
722d384
fix(Query::Result) implement result <=> result equality; delegate jso…
rmosolgo Aug 18, 2017
d5aa35d
Merge pull request #898 from rmosolgo/query-result
Aug 20, 2017
fa76dc8
feat(Subscriptions) add in-memory subscription
rmosolgo Apr 11, 2017
37dadc2
Add singleton subscriber; add multi-socket test
rmosolgo Apr 13, 2017
342169b
Hardcode skipping other subscription fields during a subscription update
rmosolgo Apr 14, 2017
bf01616
refactor(Subscriptions) pass all subscription data to user code at once
rmosolgo Apr 15, 2017
b30eac9
feat(Subscriptions::Event) improve subscription data API
rmosolgo Apr 17, 2017
a900bb7
refactor(Subscriptions) simplify store/transport API
rmosolgo Apr 17, 2017
4adc15a
fix(Subscriptions) properly distinguish between subscriptions with th…
rmosolgo Apr 17, 2017
0916d01
refactor(Subscriptions) pass ctx to deliver
rmosolgo Apr 17, 2017
35bf2ce
Add yardoc
rmosolgo Apr 18, 2017
3b10f96
feat(Subscription) add queue api
rmosolgo Apr 21, 2017
997c601
feat(Context#skip) add API for skipping fields from user code
rmosolgo Apr 21, 2017
80d22e1
Fix rebase
rmosolgo May 2, 2017
a246fe6
fix(Subscription) return ctx.skip for initial run
rmosolgo May 2, 2017
b77fb73
Add execute hook, update for changes on master
rmosolgo Jun 10, 2017
ab3772e
update tests to use from_definition
rmosolgo Jun 10, 2017
3223574
feat(Subscriptions) normalize & coerce args for trigger
rmosolgo Jun 11, 2017
680a2e1
Test triggering with input objects
rmosolgo Jun 11, 2017
566e146
Add context-based subscription scoping
rmosolgo Jun 11, 2017
4413323
Describe error handling
rmosolgo Jun 11, 2017
ed4a943
Fix queue
rmosolgo Jun 12, 2017
7d9b543
Add overview doc
rmosolgo Aug 4, 2017
b4673ae
Add subscription type docs
rmosolgo Aug 8, 2017
e06b962
fix tests
rmosolgo Aug 8, 2017
b977ced
Merge all implmentatoin functions into a single class
rmosolgo Aug 9, 2017
8895d8d
Add docs to implementation
rmosolgo Aug 11, 2017
b6ca9e9
Merge implementation into subscriptions class; rename Schema#subscrib…
rmosolgo Aug 15, 2017
faeea88
feat(Subscriptions::ActionCableSubscriptions) Add a basic action cabl…
rmosolgo Aug 16, 2017
5af1597
Update docs for new Subscriptions API
rmosolgo Aug 18, 2017
dce218f
Update to use Query::Result
rmosolgo Aug 20, 2017
66ee59b
Add Query::Result#subscription?
rmosolgo Aug 24, 2017
10a7b1a
refactor(Event) rename key -> topic
rmosolgo Sep 13, 2017
994d33a
Merge branch '1.7.x' into subscriptions
rmosolgo Sep 13, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions Guardfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,20 @@ guard :minitest do
to_run << matching_spec
end

# Find a `# test_via:` macro to automatically run another test
body = File.read(m[0])
test_via_match = body.match(/test_via: (.*)/)
if test_via_match
test_via_path = test_via_match[1]
companion_file = Pathname.new(m[0] + "/../" + test_via_path)
.cleanpath
.to_s
.sub(/.rb/, "_spec.rb")
.sub("lib/", "spec/")
to_run << companion_file
# If the file was deleted, it won't exist anymore
if File.exist?(m[0])
# Find a `# test_via:` macro to automatically run another test
body = File.read(m[0])
test_via_match = body.match(/test_via: (.*)/)
if test_via_match
test_via_path = test_via_match[1]
companion_file = Pathname.new(m[0] + "/../" + test_via_path)
.cleanpath
.to_s
.sub(/.rb/, "_spec.rb")
.sub("lib/", "spec/")
to_run << companion_file
end
end

# 0+ files
Expand Down
1 change: 1 addition & 0 deletions guides/guides.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- name: Types
- name: Fields
- name: Relay
- name: Subscriptions
- name: GraphQL Pro
- name: GraphQL Pro - OperationStore
- name: Other
Expand Down
15 changes: 15 additions & 0 deletions guides/subscriptions/action_cable_implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
layout: guide
search: true
section: Subscriptions
title: Action Cable Implementation
desc: GraphQL subscriptions over ActionCable
index: 4
experimental: true
---

[ActionCable](http://guides.rubyonrails.org/action_cable_overview.html) is a great platform for delivering GraphQL subscriptions on Rails 5+. It handles message passing (via `broadcast`) and transport (via `transmit` over a websocket).

To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}.

A client is available [in graphql-ruby-client](https://github.com/rmosolgo/graphql-ruby-client).
24 changes: 24 additions & 0 deletions guides/subscriptions/implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
layout: guide
search: true
section: Subscriptions
title: Implementation
desc: Subscription execution and delivery
index: 3
experimental: true
---

The {{ "GraphQL::Subscriptions" | api_doc }} plugin is a base class for implementing subscriptions.

Each method corresponds to a step in the subscription lifecycle. See the API docs for method-by-method documentation: {{ "GraphQL::Subscriptions" | api_doc }}.

Also, see the {% internal_link "ActionCable implementation guide", "subscriptions/action_cable_implementation" %} or {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }} docs for an example implementation.

## Considerations

Every Ruby application is different, so consider these points when implementing subscriptions:

- Is your application single-process or multiprocess? Single-process applications can store state in memory while multiprocess applications need a message broker to keep all processes up-to-date.
- What components of your application can be used for persistence and message passing?
- How will you deliver push updates to subscribed clients? (For example, websockets, ActionCable, Pusher, webhooks, or something else?)
- How will you handle [thundering herd](https://en.wikipedia.org/wiki/Thundering_herd_problem)s? When an event is triggered, how will you manage database access to update clients without swamping your system?
37 changes: 37 additions & 0 deletions guides/subscriptions/overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
layout: guide
search: true
section: Subscriptions
title: Overview
desc: Introduction to Subscriptions in GraphQL-Ruby
index: 0
experimental: true
---

_Subscriptions_ allow GraphQL clients to observe specific events and receive updates from the server when those events occur. This supports live updates, such as websocket pushes. Subscriptions introduce several new concepts:

- The __Subscription type__ is the entry point for subscription queries
- __Triggers__ begin the update process
- The __Implementation__ provides application-specific methods for executing & delivering updates.

### Subscription Type

`subscription` is an entry point to your GraphQL schema, like `query` or `mutation`. It is defined by your `SubscriptionType`, a root-level `ObjectType`.

Read more in the {% internal_link "Subscription Type guide", "subscriptions/subscription_type" %}.

### Triggers

After an event occurs in our application, _triggers_ begin the update process by sending a name and payload to GraphQL.

Read more in the {% internal_link "Triggers guide","subscriptions/triggers" %}.

### Implementation

Besides the GraphQL component, your application must provide some subscription-related plumbing, for example:

- __state management__: How does your application keep track of who is subscribed to what?
- __transport__: How does your application deliver payloads to clients?
- __queueing__: How does your application distribute the work of re-running subscription queries?

Read more in the {% internal_link "Implementation guide", "subscriptions/implementation" %} or check out the {% internal_link "ActionCable implementation", "subscriptions/action_cable_implementation" %}.
61 changes: 61 additions & 0 deletions guides/subscriptions/subscription_type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
layout: guide
search: true
section: Subscriptions
title: Subscription Type
desc: The root type for subscriptions
index: 1
experimental: true
---

`Subscription` is the entry point for all subscriptions in a GraphQL system. Each field corresponds to an event which may be subscribed to:

```graphql
type Subscription {
# Triggered whenever a post is added
postWasPublished: Post
# Triggered whenever a comment is added;
# to watch a certain post, provide a `postId`
commentWasPublished(postId: ID): Comment
}
```

This type is the root for `subscription` operations, for example:

```graphql
subscription {
postWasPublished {
# This data will be delivered whenever `postWasPublished`
# is triggered by the server:
title
author {
name
}
}
}
```

To add subscriptions to your system, define an `ObjectType` named `Subscription`:

```ruby
# app/graphql/types/subscription_type.rb
Types::SubscriptionType = GraphQL::ObjectType.define do
name "Subscription"
field :postWasPublished, !Types::PostType, "A post was published to the blog"
# ...
end
```

Then, add it as the subscription root with `subscription(...)`:

```ruby
# app/graphql/my_schema.rb
MySchema = GraphQL::Schema.define do
query(Types::QueryType)
# ...
# Add Subscription to
subscription(Types::SubscriptionType)
end
```

See {% internal_link "Implementing Subscriptions","subscriptions/implementation" %} for more about actually delivering updates.
25 changes: 25 additions & 0 deletions guides/subscriptions/triggers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
layout: guide
search: true
section: Subscriptions
title: Triggers
desc: Sending updates from your application to GraphQL
index: 2
experimental: true
---

From your application, you can push updates to GraphQL clients with `.trigger`.

Events are triggered _by name_, and the name must match fields on your {% internal_link "Subscription Type","subscriptions/subscription_type" %}

```ruby
# Update the system with the new blog post:
MySchema.subscriptions.trigger("postAdded", {}, new_post)
```

The arguments are:

- `name`, which corresponds to the field on subscription type
- `arguments`, which corresponds to the arguments on subscription type (for example, if you subscribe to comments on a certain post, the arguments would be `{postId: comment.post_id}`.)
- `object`, which will be the root object of the subscription update
- `scope:` (not shown) for implicitly scoping the clients who will receive updates.
1 change: 1 addition & 0 deletions lib/graphql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,6 @@ def self.scan_with_ragel(graphql_string)
require "graphql/compatibility"
require "graphql/function"
require "graphql/filter"
require "graphql/subscriptions"
require "graphql/parse_error"
require "graphql/tracing"
4 changes: 2 additions & 2 deletions lib/graphql/execution/execute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class PropagateNull
def execute(ast_operation, root_type, query)
result = resolve_root_selection(query)
lazy_resolve_root_selection(result, {query: query})
GraphQL::Execution::Flatten.call(result)
GraphQL::Execution::Flatten.call(query.context)
end

# @api private
Expand Down Expand Up @@ -178,7 +178,7 @@ def resolve_value(value, field_type, field_ctx)
nil
end
elsif value.is_a?(Skip)
value
field_ctx.value = value
else
case field_type.kind
when GraphQL::TypeKinds::SCALAR, GraphQL::TypeKinds::ENUM
Expand Down
2 changes: 2 additions & 0 deletions lib/graphql/execution/flatten.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ def flatten(obj)
when Query::Context::SharedMethods
if obj.invalid_null?
nil
elsif obj.skipped? && obj.value.empty?
nil
else
flatten(obj.value)
end
Expand Down
4 changes: 2 additions & 2 deletions lib/graphql/execution/multiplex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,12 @@ def finish_query(data_result, query)
if !query.valid?
{ "errors" => query.static_errors.map(&:to_h) }
else
{}
data_result
end
else
# Use `context.value` which was assigned during execution
result = {
"data" => Execution::Flatten.call(query.context.value)
"data" => Execution::Flatten.call(query.context)
}

if query.context.errors.any?
Expand Down
6 changes: 5 additions & 1 deletion lib/graphql/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,15 @@ class Field
:edge_class,
:relay_node_field,
:relay_nodes_field,
:subscription_scope,
argument: GraphQL::Define::AssignArgument

ensure_defined(
:name, :deprecation_reason, :description, :description=, :property, :hash_key,
:mutation, :arguments, :complexity, :function,
:resolve, :resolve=, :lazy_resolve, :lazy_resolve=, :lazy_resolve_proc, :resolve_proc,
:type, :type=, :name=, :property=, :hash_key=,
:relay_node_field, :relay_nodes_field, :edges?, :edge_class
:relay_node_field, :relay_nodes_field, :edges?, :edge_class, :subscription_scope
)

# @return [Boolean] True if this is the Relay find-by-id field
Expand Down Expand Up @@ -180,6 +181,9 @@ class Field

attr_writer :connection

# @return [nil, String] Prefix for subscription names from this field
attr_accessor :subscription_scope

# @return [Boolean]
def connection?
@connection
Expand Down
16 changes: 16 additions & 0 deletions lib/graphql/internal_representation/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@ def deep_merge_node(new_parent, scope: nil, merge_self: true)
# @return [GraphQL::Query]
attr_reader :query

def subscription_topic
@subscription_topic ||= begin
scope = if definition.subscription_scope
@query.context[definition.subscription_scope]
else
nil
end
Subscriptions::Event.serialize(
definition_name,
@query.arguments_for(self, definition),
definition,
scope: scope
)
end
end

protected

attr_writer :owner_type, :parent
Expand Down
20 changes: 18 additions & 2 deletions lib/graphql/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ def selected_operation_name
selected_operation.name
end

# @return [String, nil] the triggered event, if this query is a subscription update
attr_reader :subscription_topic

# @return [String, nil]
attr_reader :operation_name

# Prepare query `query_string` on `schema`
# @param schema [GraphQL::Schema]
# @param query_string [String]
Expand All @@ -60,10 +66,11 @@ def selected_operation_name
# @param max_complexity [Numeric] the maximum field complexity for this query (falls back to schema-level value)
# @param except [<#call(schema_member, context)>] If provided, objects will be hidden from the schema when `.call(schema_member, context)` returns truthy
# @param only [<#call(schema_member, context)>] If provided, objects will be hidden from the schema when `.call(schema_member, context)` returns false
def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: {}, validate: true, operation_name: nil, root_value: nil, max_depth: nil, max_complexity: nil, except: nil, only: nil)
def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: {}, validate: true, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: nil, max_complexity: nil, except: nil, only: nil)
@schema = schema
@filter = schema.default_filter.merge(except: except, only: only)
@context = Context.new(query: self, object: root_value, values: context)
@subscription_topic = subscription_topic
@root_value = root_value
@fragments = nil
@operations = nil
Expand Down Expand Up @@ -95,7 +102,6 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n
@mutation = false
@operation_name = operation_name
@prepared_ast = false

@validation_pipeline = nil
@max_depth = max_depth || schema.max_depth
@max_complexity = max_complexity || schema.max_complexity
Expand All @@ -104,6 +110,10 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n
@executed = false
end

def subscription_update?
@subscription_topic && subscription?
end

# @api private
def result_values=(result_hash)
if @executed
Expand Down Expand Up @@ -229,6 +239,10 @@ def merge_filters(only: nil, except: nil)
nil
end

def subscription?
with_prepared_ast { @subscription }
end

private

def find_operation(operations, operation_name)
Expand Down Expand Up @@ -278,6 +292,7 @@ def prepare_ast
# with no operations returns an empty hash
@ast_variables = []
@mutation = false
@subscription = false
operation_name_error = nil
if @operations.any?
@selected_operation = find_operation(@operations, @operation_name)
Expand All @@ -290,6 +305,7 @@ def prepare_ast
@ast_variables = @selected_operation.variables
@mutation = @selected_operation.operation_type == "mutation"
@query = @selected_operation.operation_type == "query"
@subscription = @selected_operation.operation_type == "subscription"
end
end

Expand Down
Loading