From 92b9419e2a19b0e46c6ba01ba70ea7b0952a2eb5 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 17 Aug 2017 22:29:48 -0400 Subject: [PATCH 01/33] feat(Query::Result) add a first-class result object --- lib/graphql/execution/multiplex.rb | 6 ++-- lib/graphql/query.rb | 9 +++--- lib/graphql/query/result.rb | 52 ++++++++++++++++++++++++++++++ spec/graphql/query/result_spec.rb | 29 +++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 lib/graphql/query/result.rb create mode 100644 spec/graphql/query/result_spec.rb diff --git a/lib/graphql/execution/multiplex.rb b/lib/graphql/execution/multiplex.rb index 36e4dcbd689..e001bf8d2d7 100644 --- a/lib/graphql/execution/multiplex.rb +++ b/lib/graphql/execution/multiplex.rb @@ -77,6 +77,8 @@ def run_as_multiplex(queries) results.each_with_index.map do |data_result, idx| query = queries[idx] finish_query(data_result, query) + # Get the Query::Result, not the Hash + query.result end end @@ -109,7 +111,7 @@ def begin_query(query) # @return [Hash] final result of this query, including all values and errors def finish_query(data_result, query) # Assign the result so that it can be accessed in instrumentation - query.result = if data_result.equal?(NO_OPERATION) + query.result_values = if data_result.equal?(NO_OPERATION) if !query.valid? { "errors" => query.static_errors.map(&:to_h) } else @@ -129,7 +131,7 @@ def finish_query(data_result, query) # use the old `query_execution_strategy` etc to run this query def run_one_legacy(schema, query) - query.result = if !query.valid? + query.result_values = if !query.valid? all_errors = query.validation_errors + query.analysis_errors + query.context.errors if all_errors.any? { "errors" => all_errors.map(&:to_h) } diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 1bdfbf525e5..415f16c6782 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -5,6 +5,7 @@ require "graphql/query/executor" require "graphql/query/literal_input" require "graphql/query/null_context" +require "graphql/query/result" require "graphql/query/serial_execution" require "graphql/query/variables" require "graphql/query/input_validation_result" @@ -98,17 +99,17 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n @max_depth = max_depth || schema.max_depth @max_complexity = max_complexity || schema.max_complexity - @result = nil + @result_values = nil @executed = false end # @api private - def result=(result_hash) + def result_values=(result_hash) if @executed raise "Invariant: Can't reassign result" else @executed = true - @result = result_hash + @result_values = result_hash end end @@ -128,7 +129,7 @@ def result Execution::Multiplex.run_queries(@schema, [self]) } end - @result + @result ||= Query::Result.new(query: self, values: @result_values) end def static_errors diff --git a/lib/graphql/query/result.rb b/lib/graphql/query/result.rb new file mode 100644 index 00000000000..ef1a6e66456 --- /dev/null +++ b/lib/graphql/query/result.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module GraphQL + class Query + # A result from {Schema#execute}. + # It provides the requested data and + # access to the {Query} and {Query::Context}. + class Result + extend GraphQL::Delegate + + def initialize(query:, values:) + @query = query + @to_h = values + end + + # @return [GraphQL::Query] The query that was executed + attr_reader :query + + # @return [Hash] The resulting hash of "data" and/or "errors" + attr_reader :to_h + + def_delegators :@query, :context, :mutation?, :query? + + def_delegators :@to_h, :[], :keys, :values + + # Delegate any hash-like method to the underlying hash. + def method_missing(method_name, *args, &block) + if @to_h.respond_to?(method_name) + @to_h.public_send(method_name, *args, &block) + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + @to_h.respond_to?(method_name) || super + end + + def inspect + "#" + end + + def ==(other) + if other.is_a?(Hash) + @to_h == other + else + super + end + end + end + end +end diff --git a/spec/graphql/query/result_spec.rb b/spec/graphql/query/result_spec.rb new file mode 100644 index 00000000000..84e4bff48fb --- /dev/null +++ b/spec/graphql/query/result_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Query::Result do + let(:query_string) { '{ __type(name: "Cheese") { name } }' } + let(:schema) { Dummy::Schema } + let(:result) { schema.execute(query_string, context: { a: :b }) } + + it "exposes hash-like methods" do + assert_equal "Cheese", result["data"]["__type"]["name"] + refute result.key?("errors") + assert_equal ["data"], result.keys + end + + it "is equal with hashes" do + hash_result = {"data" => { "__type" => { "name" => "Cheese" } } } + assert_equal hash_result, result + end + + it "tells the kind of operation" do + assert result.query? + refute result.mutation? + end + + it "exposes the context" do + assert_instance_of GraphQL::Query::Context, result.context + assert_equal({a: :b}, result.context.to_h) + end +end From 722d38426e296c81f02dd7627b2dba437f29155b Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 18 Aug 2017 09:28:52 -0400 Subject: [PATCH 02/33] fix(Query::Result) implement result <=> result equality; delegate json methods to hash --- lib/graphql/query/result.rb | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/graphql/query/result.rb b/lib/graphql/query/result.rb index ef1a6e66456..083c049f919 100644 --- a/lib/graphql/query/result.rb +++ b/lib/graphql/query/result.rb @@ -21,7 +21,7 @@ def initialize(query:, values:) def_delegators :@query, :context, :mutation?, :query? - def_delegators :@to_h, :[], :keys, :values + def_delegators :@to_h, :[], :keys, :values, :to_json, :as_json # Delegate any hash-like method to the underlying hash. def method_missing(method_name, *args, &block) @@ -40,9 +40,20 @@ def inspect "#" end + # A result is equal to another object when: + # + # - The other object is a Hash whose value matches `result.to_h` + # - The other object is a Result whose value matches `result.to_h` + # + # (The query is ignored for comparing result equality.) + # + # @return [Boolean] def ==(other) - if other.is_a?(Hash) + case other + when Hash @to_h == other + when Query::Result + @to_h == other.to_h else super end From fa76dc8265431b4874b40e1cf2e0f42a277b832b Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 11 Apr 2017 10:32:37 -0400 Subject: [PATCH 03/33] feat(Subscriptions) add in-memory subscription --- lib/graphql.rb | 1 + lib/graphql/subscriptions.rb | 16 +++ lib/graphql/subscriptions/instrumentation.rb | 49 ++++++++ spec/graphql/subscriptions_spec.rb | 120 +++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 lib/graphql/subscriptions.rb create mode 100644 lib/graphql/subscriptions/instrumentation.rb create mode 100644 spec/graphql/subscriptions_spec.rb diff --git a/lib/graphql.rb b/lib/graphql.rb index ff2f26c239a..523293ef0db 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -113,3 +113,4 @@ def self.scan_with_ragel(query_string) require "graphql/compatibility" require "graphql/function" require "graphql/filter" +require "graphql/subscriptions" diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb new file mode 100644 index 00000000000..4c24ebc1780 --- /dev/null +++ b/lib/graphql/subscriptions.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +require "graphql/subscriptions/instrumentation" + +module GraphQL + module Subscriptions + module_function + + def use(defn, subscriber:) + schema = defn.target + instrumentation = Subscriptions::Instrumentation.new(schema: schema, subscriber: subscriber) + defn.instrument(:field, instrumentation) + defn.instrument(:query, instrumentation) + nil + end + end +end diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb new file mode 100644 index 00000000000..28f29bd0e18 --- /dev/null +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +module GraphQL + module Subscriptions + class Instrumentation + def initialize(schema:, subscriber:) + @subscriber = subscriber + @schema = schema + end + + def instrument(type, field) + if type == @schema.subscription + # This is a root field of `subscription` + subscribing_resolve_proc = SubscriptionRegistrationResolve.new(field.resolve_proc) + field.redefine(resolve: subscribing_resolve_proc) + else + field + end + end + + def before_query(query) + if query.context[:resubscribe] != false + query.context[:subscriber] = @subscriber.new + end + end + + def after_query(query) + end + + private + + class SubscriptionRegistrationResolve + def initialize(inner_proc) + @inner_proc = inner_proc + end + + # Wrap the proc with subscription registration logic + def call(obj, args, ctx) + subscriber = ctx[:subscriber] + if subscriber + # `Subscriber#register` has some side-effects to register the subscription + subscriber.register(obj, args, ctx) + end + # call the resolve function: + @inner_proc.call(obj, args, ctx) + end + end + end + end +end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb new file mode 100644 index 00000000000..4c9ffde1f2c --- /dev/null +++ b/spec/graphql/subscriptions_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true +require "spec_helper" + +class InMemorySubscriber + class << self + def subscriptions + @subscriptions ||= Hash.new { |h, k| h[k] = [] } + end + + def clear + @subscriptions = nil + end + + def trigger(field, args) + k = key(field, args) + subs = subscriptions[k] + subs.each(&:trigger) + end + + def key(field, args) + "#{field}(#{JSON.dump(args.to_h)})" + end + end + + def register(obj, args, ctx) + sub_key = self.class.key(ctx.field.name, args) + subscription = Subscription.new(ctx) + self.class.subscriptions[sub_key] << subscription + end + + class Subscription + attr_reader :ctx + + def initialize(ctx) + @ctx = ctx + end + + def trigger + payloads = ctx[:payloads] + schema = ctx.schema + res = schema.execute( + document: ctx.query.document, + context: {payloads: payloads, resubscribe: false}, + root_value: ctx[:root], + ) + # This is like "broadcast" + payloads.push(res) + end + end + + class Payload + attr_reader :str + + def initialize + @str = "Update" + @counter = 0 + end + + def int + @counter += 1 + end + end +end + + +describe GraphQL::Subscriptions do + let(:root_object) { OpenStruct.new(payload: InMemorySubscriber::Payload.new) } + let(:schema) { + payload_type = GraphQL::ObjectType.define do + name "Payload" + field :str, !types.String + field :int, !types.Int + end + + subscription_type = GraphQL::ObjectType.define do + name "Subscription" + field :payload, payload_type do + argument :id, !types.ID + end + end + + query_type = subscription_type.redefine(name: "Query") + + GraphQL::Schema.define do + query(query_type) + subscription(subscription_type) + use(GraphQL::Subscriptions, subscriber: InMemorySubscriber) + end + } + + describe "pushing updates" do + before do + InMemorySubscriber.clear + end + + it "sends updated data" do + query_str = <<-GRAPHQL + subscription { + payload(id: "1") { str, int } + } + GRAPHQL + + payloads = [] + res = schema.execute(query_str, context: { payloads: payloads, root: root_object }, root_value: root_object) + + # Initial Result: + assert_equal [], payloads + assert_equal({"str" => "Update", "int" => 1}, res["data"]["payload"]) + + # Hit: + InMemorySubscriber.trigger("payload", id: "1") + InMemorySubscriber.trigger("payload", id: "1") + # Miss: + InMemorySubscriber.trigger("payload", id: "2") + + assert_equal({"str" => "Update", "int" => 2}, payloads[0]["data"]["payload"]) + assert_equal({"str" => "Update", "int" => 3}, payloads[1]["data"]["payload"]) + end + end +end From 37dadc23e9dc42dc98ea5cff9476fdcf88fcc4fa Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 12 Apr 2017 21:48:05 -0400 Subject: [PATCH 04/33] Add singleton subscriber; add multi-socket test --- lib/graphql/schema.rb | 3 + lib/graphql/subscriptions.rb | 8 +- lib/graphql/subscriptions/instrumentation.rb | 3 +- spec/graphql/subscriptions_spec.rb | 140 ++++++++++++------- 4 files changed, 101 insertions(+), 53 deletions(-) diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index d61347bab58..a55351e9680 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -81,6 +81,9 @@ class Schema :cursor_encoder, :raise_definition_error + # Singleton instance of the provided subscriber class, if there is one. + attr_accessor :subscriber + # @return [MiddlewareChain] MiddlewareChain which is applied to fields during execution attr_accessor :middleware diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 4c24ebc1780..f7cc5e70b95 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -5,9 +5,13 @@ module GraphQL module Subscriptions module_function - def use(defn, subscriber:) + def use(defn, subscriber_class:, options: {}) schema = defn.target - instrumentation = Subscriptions::Instrumentation.new(schema: schema, subscriber: subscriber) + schema.subscriber = subscriber_class.new(options.merge(schema: schema)) + instrumentation = Subscriptions::Instrumentation.new( + schema: schema, + subscriber: schema.subscriber, + ) defn.instrument(:field, instrumentation) defn.instrument(:query, instrumentation) nil diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index 28f29bd0e18..b4cb113f959 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -19,7 +19,8 @@ def instrument(type, field) def before_query(query) if query.context[:resubscribe] != false - query.context[:subscriber] = @subscriber.new + query.context[:subscriber] = @subscriber + @subscriber.register_query(query) end end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 4c9ffde1f2c..ef28d5b8950 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -1,53 +1,81 @@ # frozen_string_literal: true require "spec_helper" -class InMemorySubscriber - class << self +class InMemoryBackend + # Here's the required API for a subscriber: + class Subscriber + def initialize(schema:, database:) + @database = database + @schema = schema + end + + def register_query(query) + end + + def register(obj, args, ctx) + @database.add(ctx.field.name, args, ctx) + end + + def trigger(event, args, object) + subs = @database.fetch(event, args) + subs.each { |ctx| + res = @schema.execute( + document: ctx.query.document, + # TODO this won't work IRL: + variables: args, + context: {resubscribe: false}, + root_value: object, + ) + # This is like "broadcast" + socket = Socket.open(ctx[:socket_id]) + socket.write(res) + } + end + end + + # Subscription management database + class Database def subscriptions @subscriptions ||= Hash.new { |h, k| h[k] = [] } end - def clear - @subscriptions = nil + def fetch(field, args) + subscriptions[key(field, args)] end - def trigger(field, args) - k = key(field, args) - subs = subscriptions[k] - subs.each(&:trigger) + def add(field, args, sub) + subscriptions[key(field, args)] << sub end + private + def key(field, args) "#{field}(#{JSON.dump(args.to_h)})" end end - def register(obj, args, ctx) - sub_key = self.class.key(ctx.field.name, args) - subscription = Subscription.new(ctx) - self.class.subscriptions[sub_key] << subscription - end + # Pretend its a websocket: + class Socket + def self.open(id) + @sockets[id] + end - class Subscription - attr_reader :ctx + def self.clear + @sockets = Hash.new { |h, k| h[k] = self.new } + end + + attr_reader :deliveries - def initialize(ctx) - @ctx = ctx + def initialize + @deliveries = [] end - def trigger - payloads = ctx[:payloads] - schema = ctx.schema - res = schema.execute( - document: ctx.query.document, - context: {payloads: payloads, resubscribe: false}, - root_value: ctx[:root], - ) - # This is like "broadcast" - payloads.push(res) + def write(response) + @deliveries << response end end + # Just a random stateful object for tracking what happens: class Payload attr_reader :str @@ -64,7 +92,11 @@ def int describe GraphQL::Subscriptions do - let(:root_object) { OpenStruct.new(payload: InMemorySubscriber::Payload.new) } + before do + InMemoryBackend::Socket.clear + end + + let(:root_object) { OpenStruct.new(payload: InMemoryBackend::Payload.new) } let(:schema) { payload_type = GraphQL::ObjectType.define do name "Payload" @@ -84,37 +116,45 @@ def int GraphQL::Schema.define do query(query_type) subscription(subscription_type) - use(GraphQL::Subscriptions, subscriber: InMemorySubscriber) + use GraphQL::Subscriptions, + subscriber_class: InMemoryBackend::Subscriber, + options: { + database: InMemoryBackend::Database.new, + } end } describe "pushing updates" do - before do - InMemorySubscriber.clear - end - it "sends updated data" do query_str = <<-GRAPHQL - subscription { - payload(id: "1") { str, int } + subscription ($id: ID!){ + payload(id: $id) { str, int } } GRAPHQL - payloads = [] - res = schema.execute(query_str, context: { payloads: payloads, root: root_object }, root_value: root_object) - - # Initial Result: - assert_equal [], payloads - assert_equal({"str" => "Update", "int" => 1}, res["data"]["payload"]) - - # Hit: - InMemorySubscriber.trigger("payload", id: "1") - InMemorySubscriber.trigger("payload", id: "1") - # Miss: - InMemorySubscriber.trigger("payload", id: "2") - - assert_equal({"str" => "Update", "int" => 2}, payloads[0]["data"]["payload"]) - assert_equal({"str" => "Update", "int" => 3}, payloads[1]["data"]["payload"]) + # Initial subscriptions + res_1 = schema.execute(query_str, context: { socket_id: "1" }, variables: { "id" => "100" }, root_value: root_object) + res_2 = schema.execute(query_str, context: { socket_id: "2" }, variables: { "id" => "200" }, root_value: root_object) + + # Initial response, no broadcasts et + assert_equal({"str" => "Update", "int" => 1}, res_1["data"]["payload"]) + assert_equal({"str" => "Update", "int" => 2}, res_2["data"]["payload"]) + socket_1 = InMemoryBackend::Socket.open("1") + socket_2 = InMemoryBackend::Socket.open("2") + assert_equal [], socket_1.deliveries + assert_equal [], socket_2.deliveries + + # Application stuff happens. + # The application signals graphql via `subscriber.trigger`: + schema.subscriber.trigger("payload", {"id" => "100"}, root_object) + schema.subscriber.trigger("payload", {"id" => "200"}, root_object) + schema.subscriber.trigger("payload", {"id" => "100"}, root_object) + schema.subscriber.trigger("payload", {"id" => "300"}, nil) + + # Let's see what GraphQL sent over the wire: + assert_equal({"str" => "Update", "int" => 3}, socket_1.deliveries[0]["data"]["payload"]) + assert_equal({"str" => "Update", "int" => 4}, socket_2.deliveries[0]["data"]["payload"]) + assert_equal({"str" => "Update", "int" => 5}, socket_1.deliveries[1]["data"]["payload"]) end end end From 342169b061bdba5302b7dd287abbc25b5d174ba7 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 14 Apr 2017 07:05:20 -0700 Subject: [PATCH 05/33] Hardcode skipping other subscription fields during a subscription update --- lib/graphql/execution/execute.rb | 11 ++++-- lib/graphql/query.rb | 13 +++++-- lib/graphql/subscriptions/instrumentation.rb | 11 ++++-- spec/graphql/subscriptions_spec.rb | 38 ++++++++++++-------- 4 files changed, 52 insertions(+), 21 deletions(-) diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb index b241d118bcd..d07b9f83246 100644 --- a/lib/graphql/execution/execute.rb +++ b/lib/graphql/execution/execute.rb @@ -21,7 +21,8 @@ def execute(ast_operation, root_type, query) root_type, query.irep_selection, query.context, - mutation: query.mutation? + mutation: query.mutation?, + subscription: query.subscription?, ) GraphQL::Execution::Lazy.resolve(result) @@ -33,10 +34,16 @@ def execute(ast_operation, root_type, query) module ExecutionFunctions module_function - def resolve_selection(object, current_type, selection, query_ctx, mutation: false ) + def resolve_selection(object, current_type, selection, query_ctx, mutation: false, subscription: false ) selection_result = SelectionResult.new selection.typed_children[current_type].each do |name, subselection| + # Can't `break` because technically multiple fields could match + # TODO: make sure it matches the _right_ root field in the + # case that there are multiple with the same name but different arguments + if subscription && query_ctx.query.subscription_name && name != query_ctx.query.subscription_name + next + end field_result = resolve_field( selection_result, subselection, diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 415f16c6782..0e25839d526 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -48,6 +48,9 @@ def selected_operation_name selected_operation.name end + # @return [String, nil] the triggered event, if this query is a subscription update + attr_reader :subscription_name + # Prepare query `query_string` on `schema` # @param schema [GraphQL::Schema] # @param query_string [String] @@ -59,9 +62,10 @@ 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_name: 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) + @subscription_name = subscription_name @context = Context.new(query: self, values: context) @root_value = root_value @fragments = nil @@ -94,7 +98,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 @@ -225,6 +228,10 @@ def merge_filters(only: nil, except: nil) nil end + def subscription? + @subscription + end + private def find_operation(operations, operation_name) @@ -274,6 +281,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) @@ -286,6 +294,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 diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index b4cb113f959..91752e74341 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -18,7 +18,8 @@ def instrument(type, field) end def before_query(query) - if query.context[:resubscribe] != false + # It's a subscription, but it's not an update: + if query.subscription? && !query.subscription_name query.context[:subscriber] = @subscriber @subscriber.register_query(query) end @@ -40,9 +41,13 @@ def call(obj, args, ctx) if subscriber # `Subscriber#register` has some side-effects to register the subscription subscriber.register(obj, args, ctx) + nil + elsif ctx.field.name == ctx.query.subscription_name + # The root object is _already_ the subscription update: + obj + else + nil end - # call the resolve function: - @inner_proc.call(obj, args, ctx) end end end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index ef28d5b8950..09e8de33dd0 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -13,6 +13,8 @@ def register_query(query) end def register(obj, args, ctx) + # The `ctx` is functioning as subscription data. + # IRL you'd have some other model that persisted the subscription @database.add(ctx.field.name, args, ctx) end @@ -21,9 +23,8 @@ def trigger(event, args, object) subs.each { |ctx| res = @schema.execute( document: ctx.query.document, - # TODO this won't work IRL: - variables: args, - context: {resubscribe: false}, + variables: ctx.query.provided_variables, + subscription_name: event, root_value: object, ) # This is like "broadcast" @@ -96,7 +97,12 @@ def int InMemoryBackend::Socket.clear end - let(:root_object) { OpenStruct.new(payload: InMemoryBackend::Payload.new) } + let(:root_object) { + OpenStruct.new( + payload: InMemoryBackend::Payload.new, + otherPayload: InMemoryBackend::Payload.new, + ) + } let(:schema) { payload_type = GraphQL::ObjectType.define do name "Payload" @@ -106,7 +112,10 @@ def int subscription_type = GraphQL::ObjectType.define do name "Subscription" - field :payload, payload_type do + field :payload, !payload_type do + argument :id, !types.ID + end + field :otherPayload, !payload_type do argument :id, !types.ID end end @@ -129,6 +138,7 @@ def int query_str = <<-GRAPHQL subscription ($id: ID!){ payload(id: $id) { str, int } + otherPayload(id: "900") { int } } GRAPHQL @@ -136,9 +146,9 @@ def int res_1 = schema.execute(query_str, context: { socket_id: "1" }, variables: { "id" => "100" }, root_value: root_object) res_2 = schema.execute(query_str, context: { socket_id: "2" }, variables: { "id" => "200" }, root_value: root_object) - # Initial response, no broadcasts et - assert_equal({"str" => "Update", "int" => 1}, res_1["data"]["payload"]) - assert_equal({"str" => "Update", "int" => 2}, res_2["data"]["payload"]) + # Initial response is nil, no broadcasts yet + assert_equal(nil, res_1["data"]) + assert_equal(nil, res_2["data"]) socket_1 = InMemoryBackend::Socket.open("1") socket_2 = InMemoryBackend::Socket.open("2") assert_equal [], socket_1.deliveries @@ -146,15 +156,15 @@ def int # Application stuff happens. # The application signals graphql via `subscriber.trigger`: - schema.subscriber.trigger("payload", {"id" => "100"}, root_object) - schema.subscriber.trigger("payload", {"id" => "200"}, root_object) - schema.subscriber.trigger("payload", {"id" => "100"}, root_object) + schema.subscriber.trigger("payload", {"id" => "100"}, root_object.payload) + schema.subscriber.trigger("payload", {"id" => "200"}, root_object.payload) + schema.subscriber.trigger("payload", {"id" => "100"}, root_object.payload) schema.subscriber.trigger("payload", {"id" => "300"}, nil) # Let's see what GraphQL sent over the wire: - assert_equal({"str" => "Update", "int" => 3}, socket_1.deliveries[0]["data"]["payload"]) - assert_equal({"str" => "Update", "int" => 4}, socket_2.deliveries[0]["data"]["payload"]) - assert_equal({"str" => "Update", "int" => 5}, socket_1.deliveries[1]["data"]["payload"]) + assert_equal({"str" => "Update", "int" => 1}, socket_1.deliveries[0]["data"]["payload"]) + assert_equal({"str" => "Update", "int" => 2}, socket_2.deliveries[0]["data"]["payload"]) + assert_equal({"str" => "Update", "int" => 3}, socket_1.deliveries[1]["data"]["payload"]) end end end From bf0161658f709b1da01e93a2633c3f5157ef6373 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sat, 15 Apr 2017 05:30:07 -0700 Subject: [PATCH 06/33] refactor(Subscriptions) pass all subscription data to user code at once --- lib/graphql/subscriptions/instrumentation.rb | 14 ++++++++------ spec/graphql/subscriptions_spec.rb | 17 ++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index 91752e74341..16cb0ddc0b4 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -20,12 +20,15 @@ def instrument(type, field) def before_query(query) # It's a subscription, but it's not an update: if query.subscription? && !query.subscription_name - query.context[:subscriber] = @subscriber - @subscriber.register_query(query) + query.context[:subscriptions] = [] end end def after_query(query) + subscriptions = query.context[:subscriptions] + if subscriptions + @subscriber.register(query, subscriptions) + end end private @@ -37,10 +40,9 @@ def initialize(inner_proc) # Wrap the proc with subscription registration logic def call(obj, args, ctx) - subscriber = ctx[:subscriber] - if subscriber - # `Subscriber#register` has some side-effects to register the subscription - subscriber.register(obj, args, ctx) + subscriptions = ctx[:subscriptions] + if subscriptions + subscriptions << [args, ctx] nil elsif ctx.field.name == ctx.query.subscription_name # The root object is _already_ the subscription update: diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 09e8de33dd0..f89a2dc6155 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -4,18 +4,17 @@ class InMemoryBackend # Here's the required API for a subscriber: class Subscriber - def initialize(schema:, database:) - @database = database + def initialize(schema:, **options) + @database = options.fetch(:database) @schema = schema end - def register_query(query) - end - - def register(obj, args, ctx) - # The `ctx` is functioning as subscription data. - # IRL you'd have some other model that persisted the subscription - @database.add(ctx.field.name, args, ctx) + def register(query, subscriptions) + subscriptions.each do |(args, ctx)| + # The `ctx` is functioning as subscription data. + # IRL you'd have some other model that persisted the subscription + @database.add(ctx.field.name, args, ctx) + end end def trigger(event, args, object) From b30eac9a4b5049bb5f0a2a4fc951e9c5cb4c014a Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sun, 16 Apr 2017 20:09:12 -0400 Subject: [PATCH 07/33] feat(Subscriptions::Event) improve subscription data API --- lib/graphql/query.rb | 3 ++ lib/graphql/subscriptions.rb | 1 + lib/graphql/subscriptions/event.rb | 13 +++++++++ lib/graphql/subscriptions/instrumentation.rb | 8 ++++-- spec/graphql/subscriptions_spec.rb | 29 +++++++++++++++++--- 5 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 lib/graphql/subscriptions/event.rb diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 0e25839d526..03cc4313388 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -51,6 +51,9 @@ def selected_operation_name # @return [String, nil] the triggered event, if this query is a subscription update attr_reader :subscription_name + # @return [String, nil] + attr_reader :operation_name + # Prepare query `query_string` on `schema` # @param schema [GraphQL::Schema] # @param query_string [String] diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index f7cc5e70b95..1c043b16baa 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require "graphql/subscriptions/event" require "graphql/subscriptions/instrumentation" module GraphQL diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb new file mode 100644 index 00000000000..2f48f5699f7 --- /dev/null +++ b/lib/graphql/subscriptions/event.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +module GraphQL + module Subscriptions + class Event + attr_reader :name, :arguments, :context + def initialize(name:, arguments:, context:) + @name = name + @arguments = arguments + @context = context + end + end + end +end diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index 16cb0ddc0b4..7c34fa50046 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -26,7 +26,7 @@ def before_query(query) def after_query(query) subscriptions = query.context[:subscriptions] - if subscriptions + if subscriptions && subscriptions.any? @subscriber.register(query, subscriptions) end end @@ -42,7 +42,11 @@ def initialize(inner_proc) def call(obj, args, ctx) subscriptions = ctx[:subscriptions] if subscriptions - subscriptions << [args, ctx] + subscriptions << Subscriptions::Event.new( + name: ctx.field.name, + arguments: args, + context: ctx, + ) nil elsif ctx.field.name == ctx.query.subscription_name # The root object is _already_ the subscription update: diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index f89a2dc6155..99bbb0fb241 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -10,10 +10,10 @@ def initialize(schema:, **options) end def register(query, subscriptions) - subscriptions.each do |(args, ctx)| + subscriptions.each do |ev| # The `ctx` is functioning as subscription data. # IRL you'd have some other model that persisted the subscription - @database.add(ctx.field.name, args, ctx) + @database.add(ev.name, ev.arguments, ev.context) end end @@ -102,6 +102,7 @@ def int otherPayload: InMemoryBackend::Payload.new, ) } + let(:database) { InMemoryBackend::Database.new } let(:schema) { payload_type = GraphQL::ObjectType.define do name "Payload" @@ -120,14 +121,14 @@ def int end query_type = subscription_type.redefine(name: "Query") - + db = database GraphQL::Schema.define do query(query_type) subscription(subscription_type) use GraphQL::Subscriptions, subscriber_class: InMemoryBackend::Subscriber, options: { - database: InMemoryBackend::Database.new, + database: db, } end } @@ -166,4 +167,24 @@ def int assert_equal({"str" => "Update", "int" => 3}, socket_1.deliveries[1]["data"]["payload"]) end end + + describe "subscribing" do + it "doesn't call the subscriber for invalid queries" do + query_str = <<-GRAPHQL + subscription ($id: ID){ + payload(id: $id) { str, int } + } + GRAPHQL + + res = schema.execute(query_str, context: { socket_id: "1" }, variables: { "id" => "100" }, root_value: root_object) + assert_equal true, res.key?("errors") + assert_equal 0, database.subscriptions.size + end + end + + describe "trigger" do + it "coerces args somehow?" + it "pushes errors" + it "handles errors during trigger somehow?" + end end From a900bb7659fa540e045aaafdbe4963bc411835e7 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sun, 16 Apr 2017 22:57:48 -0400 Subject: [PATCH 08/33] refactor(Subscriptions) simplify store/transport API --- lib/graphql/query/context.rb | 2 + lib/graphql/subscriptions.rb | 9 ++- lib/graphql/subscriptions/event.rb | 7 +- lib/graphql/subscriptions/subscriber.rb | 45 +++++++++++++ spec/graphql/subscriptions_spec.rb | 86 ++++++++++--------------- 5 files changed, 94 insertions(+), 55 deletions(-) create mode 100644 lib/graphql/subscriptions/subscriber.rb diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index e05dc77067d..9cd91afa020 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -5,6 +5,7 @@ class Query # It delegates `[]` to the hash that's passed to `GraphQL::Query#initialize`. class Context extend GraphQL::Delegate + attr_reader :execution_strategy # `strategy` is required by GraphQL::Batch alias_method :strategy, :execution_strategy @@ -58,6 +59,7 @@ def initialize(query:, values:) # @!method []=(key, value) # Reassign `key` to the hash passed to {Schema#execute} as `context:` + # @return [GraphQL::Schema::Warden] def warden @warden ||= @query.warden diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 1c043b16baa..0b3f586356c 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -1,14 +1,19 @@ # frozen_string_literal: true require "graphql/subscriptions/event" require "graphql/subscriptions/instrumentation" +require "graphql/subscriptions/subscriber" module GraphQL module Subscriptions module_function - def use(defn, subscriber_class:, options: {}) + def use(defn, store:, transports:) schema = defn.target - schema.subscriber = subscriber_class.new(options.merge(schema: schema)) + schema.subscriber = Subscriptions::Subscriber.new( + schema: schema, + store: store, + transports: transports, + ) instrumentation = Subscriptions::Instrumentation.new( schema: schema, subscriber: schema.subscriber, diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index 2f48f5699f7..7518745ee96 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -2,11 +2,16 @@ module GraphQL module Subscriptions class Event - attr_reader :name, :arguments, :context + attr_reader :name, :arguments, :context, :key def initialize(name:, arguments:, context:) @name = name @arguments = arguments @context = context + @key = self.class.serialize(name, arguments) + end + + def self.serialize(name, arguments) + "#{name}(#{JSON.dump(arguments.to_h)})" end end end diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb new file mode 100644 index 00000000000..454ec71b7e0 --- /dev/null +++ b/lib/graphql/subscriptions/subscriber.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +module GraphQL + module Subscriptions + class Subscriber + extend Forwardable + + def initialize(schema:, store:, transports:) + @schema = schema + @store = store + @transports = transports + end + + def_delegators :@store, :register, :each_subscription + + def trigger(event, args, object) + event_key = Subscriptions::Event.serialize(event, args) + @store.each_subscription(event_key) do |query_data| + query_string = query_data.fetch(:query_string) + variables = query_data.fetch(:variables) + context = query_data.fetch(:context) + operation_name = query_data.fetch(:operation_name) + + result = @schema.execute(query_string, { + context: context, + subscription_name: event, + operation_name: operation_name, + variables: variables, + root_value: object, + }) + + transport_key = query_data.fetch(:transport) + channel = query_data.fetch(:channel) + transport = @transports.fetch(transport_key) + transport.deliver(channel, result) + end + end + + private + + def serialize_event(event, args) + "#{event}(#{JSON.dump(args.to_h)})" + end + end + end +end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 99bbb0fb241..7b1308abe31 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -2,60 +2,46 @@ require "spec_helper" class InMemoryBackend - # Here's the required API for a subscriber: - class Subscriber - def initialize(schema:, **options) - @database = options.fetch(:database) - @schema = schema + # Store API + class Database + def initialize + @subscriptions = Hash.new { |h, k| h[k] = [] } end - def register(query, subscriptions) - subscriptions.each do |ev| - # The `ctx` is functioning as subscription data. + def register(query, events) + events.each do |ev| + # The `context` is functioning as subscription data. # IRL you'd have some other model that persisted the subscription - @database.add(ev.name, ev.arguments, ev.context) + @subscriptions[ev.key] << ev.context end end - def trigger(event, args, object) - subs = @database.fetch(event, args) - subs.each { |ctx| - res = @schema.execute( - document: ctx.query.document, - variables: ctx.query.provided_variables, - subscription_name: event, - root_value: object, - ) - # This is like "broadcast" - socket = Socket.open(ctx[:socket_id]) - socket.write(res) + def each_subscription(key) + @subscriptions[key].map { |ctx| + query = ctx.query + yield({ + query_string: query.query_string, + operation_name: query.operation_name, + variables: query.provided_variables, + context: {}, + channel: ctx[:socket], + transport: :socket, + }) } end - end - - # Subscription management database - class Database - def subscriptions - @subscriptions ||= Hash.new { |h, k| h[k] = [] } - end - - def fetch(field, args) - subscriptions[key(field, args)] - end - def add(field, args, sub) - subscriptions[key(field, args)] << sub - end - - private - - def key(field, args) - "#{field}(#{JSON.dump(args.to_h)})" + # Just for testing: + def size + @subscriptions.size end end - # Pretend its a websocket: class Socket + # Transport API: + def self.deliver(channel, result) + open(channel).deliveries << result + end + def self.open(id) @sockets[id] end @@ -69,10 +55,6 @@ def self.clear def initialize @deliveries = [] end - - def write(response) - @deliveries << response - end end # Just a random stateful object for tracking what happens: @@ -126,9 +108,9 @@ def int query(query_type) subscription(subscription_type) use GraphQL::Subscriptions, - subscriber_class: InMemoryBackend::Subscriber, - options: { - database: db, + store: db, + transports: { + socket: InMemoryBackend::Socket } end } @@ -143,8 +125,8 @@ def int GRAPHQL # Initial subscriptions - res_1 = schema.execute(query_str, context: { socket_id: "1" }, variables: { "id" => "100" }, root_value: root_object) - res_2 = schema.execute(query_str, context: { socket_id: "2" }, variables: { "id" => "200" }, root_value: root_object) + res_1 = schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "100" }, root_value: root_object) + res_2 = schema.execute(query_str, context: { socket: "2" }, variables: { "id" => "200" }, root_value: root_object) # Initial response is nil, no broadcasts yet assert_equal(nil, res_1["data"]) @@ -176,9 +158,9 @@ def int } GRAPHQL - res = schema.execute(query_str, context: { socket_id: "1" }, variables: { "id" => "100" }, root_value: root_object) + res = schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "100" }, root_value: root_object) assert_equal true, res.key?("errors") - assert_equal 0, database.subscriptions.size + assert_equal 0, database.size end end From 4adc15a402f185c27bd7eeb2f5f7f9d2c0aa3604 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sun, 16 Apr 2017 23:19:21 -0400 Subject: [PATCH 09/33] fix(Subscriptions) properly distinguish between subscriptions with the same name --- lib/graphql/execution/execute.rb | 9 +-------- lib/graphql/internal_representation/node.rb | 7 +++++++ lib/graphql/query.rb | 10 +++++++--- lib/graphql/subscriptions/instrumentation.rb | 10 ++++++---- lib/graphql/subscriptions/subscriber.rb | 2 +- spec/graphql/subscriptions_spec.rb | 14 +++++--------- 6 files changed, 27 insertions(+), 25 deletions(-) diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb index d07b9f83246..015ff3d4a8b 100644 --- a/lib/graphql/execution/execute.rb +++ b/lib/graphql/execution/execute.rb @@ -22,7 +22,6 @@ def execute(ast_operation, root_type, query) query.irep_selection, query.context, mutation: query.mutation?, - subscription: query.subscription?, ) GraphQL::Execution::Lazy.resolve(result) @@ -34,16 +33,10 @@ def execute(ast_operation, root_type, query) module ExecutionFunctions module_function - def resolve_selection(object, current_type, selection, query_ctx, mutation: false, subscription: false ) + def resolve_selection(object, current_type, selection, query_ctx, mutation: false) selection_result = SelectionResult.new selection.typed_children[current_type].each do |name, subselection| - # Can't `break` because technically multiple fields could match - # TODO: make sure it matches the _right_ root field in the - # case that there are multiple with the same name but different arguments - if subscription && query_ctx.query.subscription_name && name != query_ctx.query.subscription_name - next - end field_result = resolve_field( selection_result, subselection, diff --git a/lib/graphql/internal_representation/node.rb b/lib/graphql/internal_representation/node.rb index e0d4479f51a..56e8a46cd28 100644 --- a/lib/graphql/internal_representation/node.rb +++ b/lib/graphql/internal_representation/node.rb @@ -145,6 +145,13 @@ def deep_merge_node(new_parent, scope: nil, merge_self: true) # @return [GraphQL::Query] attr_reader :query + def subscription_key + @subscription_key ||= Subscriptions::Event.serialize( + definition_name, + @query.arguments_for(self, definition), + ) + end + protected attr_writer :owner_type, :parent diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 03cc4313388..b50b31ceef3 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -49,7 +49,7 @@ def selected_operation_name end # @return [String, nil] the triggered event, if this query is a subscription update - attr_reader :subscription_name + attr_reader :subscription_key # @return [String, nil] attr_reader :operation_name @@ -65,10 +65,10 @@ 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, subscription_name: nil, 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_key: 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) - @subscription_name = subscription_name + @subscription_key = subscription_key @context = Context.new(query: self, values: context) @root_value = root_value @fragments = nil @@ -109,6 +109,10 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n @executed = false end + def subscription_update? + @subscription && @subscription_key + end + # @api private def result_values=(result_hash) if @executed diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index 7c34fa50046..cd7b7033605 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -18,8 +18,7 @@ def instrument(type, field) end def before_query(query) - # It's a subscription, but it's not an update: - if query.subscription? && !query.subscription_name + if query.subscription? && !query.subscription_update? query.context[:subscriptions] = [] end end @@ -48,11 +47,14 @@ def call(obj, args, ctx) context: ctx, ) nil - elsif ctx.field.name == ctx.query.subscription_name + elsif ctx.irep_node.subscription_key == ctx.query.subscription_key # The root object is _already_ the subscription update: obj else - nil + # It should only: + # - Register the selection (first condition) + # - Pass `obj` to the child selection (second condition) + raise "An unselected subscription field should never be called" end end end diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index 454ec71b7e0..3a4082c14b1 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -22,7 +22,7 @@ def trigger(event, args, object) result = @schema.execute(query_string, { context: context, - subscription_name: event, + subscription_key: event_key, operation_name: operation_name, variables: variables, root_value: object, diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 7b1308abe31..a05ba0aa36b 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -81,7 +81,6 @@ def int let(:root_object) { OpenStruct.new( payload: InMemoryBackend::Payload.new, - otherPayload: InMemoryBackend::Payload.new, ) } let(:database) { InMemoryBackend::Database.new } @@ -97,9 +96,6 @@ def int field :payload, !payload_type do argument :id, !types.ID end - field :otherPayload, !payload_type do - argument :id, !types.ID - end end query_type = subscription_type.redefine(name: "Query") @@ -119,8 +115,8 @@ def int it "sends updated data" do query_str = <<-GRAPHQL subscription ($id: ID!){ - payload(id: $id) { str, int } - otherPayload(id: "900") { int } + firstPayload: payload(id: $id) { str, int } + otherPayload: payload(id: "900") { int } } GRAPHQL @@ -144,9 +140,9 @@ def int schema.subscriber.trigger("payload", {"id" => "300"}, nil) # Let's see what GraphQL sent over the wire: - assert_equal({"str" => "Update", "int" => 1}, socket_1.deliveries[0]["data"]["payload"]) - assert_equal({"str" => "Update", "int" => 2}, socket_2.deliveries[0]["data"]["payload"]) - assert_equal({"str" => "Update", "int" => 3}, socket_1.deliveries[1]["data"]["payload"]) + assert_equal({"str" => "Update", "int" => 1}, socket_1.deliveries[0]["data"]["firstPayload"]) + assert_equal({"str" => "Update", "int" => 2}, socket_2.deliveries[0]["data"]["firstPayload"]) + assert_equal({"str" => "Update", "int" => 3}, socket_1.deliveries[1]["data"]["firstPayload"]) end end From 0916d01d2f8f8003389ec35116cd0d32ce0568ca Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 17 Apr 2017 10:33:34 -0400 Subject: [PATCH 10/33] refactor(Subscriptions) pass ctx to deliver --- lib/graphql/subscriptions/subscriber.rb | 23 ++++++++++++++--------- spec/graphql/subscriptions_spec.rb | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index 3a4082c14b1..2c919913975 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -10,7 +10,7 @@ def initialize(schema:, store:, transports:) @transports = transports end - def_delegators :@store, :register, :each_subscription + def_delegators :@store, :register, :delete, :each_subscription def trigger(event, args, object) event_key = Subscriptions::Event.serialize(event, args) @@ -20,18 +20,23 @@ def trigger(event, args, object) context = query_data.fetch(:context) operation_name = query_data.fetch(:operation_name) - result = @schema.execute(query_string, { - context: context, - subscription_key: event_key, - operation_name: operation_name, - variables: variables, - root_value: object, - }) + query = GraphQL::Query.new( + @schema, + query_string, + { + context: context, + subscription_key: event_key, + operation_name: operation_name, + variables: variables, + root_value: object, + } + ) + result = query.result transport_key = query_data.fetch(:transport) channel = query_data.fetch(:channel) transport = @transports.fetch(transport_key) - transport.deliver(channel, result) + transport.deliver(channel, result, query.context) end end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index a05ba0aa36b..2ae9048d2c1 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -38,7 +38,7 @@ def size class Socket # Transport API: - def self.deliver(channel, result) + def self.deliver(channel, result, ctx) open(channel).deliveries << result end From 35bf2ced1d47593a681d50347b822c41894f5798 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 17 Apr 2017 22:43:14 -0400 Subject: [PATCH 11/33] Add yardoc --- lib/graphql/subscriptions.rb | 21 +++++++++++++++++++- lib/graphql/subscriptions/event.rb | 19 +++++++++++++++++- lib/graphql/subscriptions/instrumentation.rb | 21 +++++++++++++------- lib/graphql/subscriptions/subscriber.rb | 21 ++++++++++++++------ 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 0b3f586356c..4cf5d2237fb 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -4,9 +4,28 @@ require "graphql/subscriptions/subscriber" module GraphQL + # A plugin for attaching subscription behavior to the schema + # @example + # MySchema = GraphQL::Schema.define do + # use GraphQL::Subscriptions, + # store: MyDatabaseStorage.new, + # transports: { + # "apns" => ApnsTransport.new, + # "websocket" => WebsocketTransport.new, + # } + # end module Subscriptions module_function - + # Accept some application objects: + # - `store` for registering subscription state + # - named `transpors` for delivering payload + # + # Apply special behavior to subscription root fields with instrumentation. + # + # Prepare `MySchema.subscriber` for receiving triggers from the application. + # + # @param store [<#register(query, events), #each_subscription(event_key, &block)>] + # @param transports [Hash <#deliver(channel, result, ctx)>] def use(defn, store:, transports:) schema = defn.target schema.subscriber = Subscriptions::Subscriber.new( diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index 7518745ee96..0f2642dcd92 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -1,8 +1,24 @@ # frozen_string_literal: true module GraphQL module Subscriptions + # This thing can be: + # - Subscribed to by `subscription { ... }` + # - Triggered by `MySchema.subscriber.trigger(name, arguments, obj)` + # + # An array of `Event`s are passed to `store.register(query, events)`. class Event - attr_reader :name, :arguments, :context, :key + # @return [String] Corresponds to the Subscription root field name + attr_reader :name + + # @return [GraphQL::Query::Arguments] + attr_reader :arguments + + # @return [GraphQL::Query::Context] + attr_reader :context + + # @return [String] An opaque string which identifies this event, derived from `name` and `arguments` + attr_reader :key + def initialize(name:, arguments:, context:) @name = name @arguments = arguments @@ -10,6 +26,7 @@ def initialize(name:, arguments:, context:) @key = self.class.serialize(name, arguments) end + # @return [String] an identifier for this unit of subscription def self.serialize(name, arguments) "#{name}(#{JSON.dump(arguments.to_h)})" end diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index cd7b7033605..9f67d614792 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true module GraphQL module Subscriptions + # Wrap the root fields of the subscription type with special logic for: + # - Registering the subscription during the first execution + # - Evaluating the triggered portion(s) of the subscription during later execution class Instrumentation def initialize(schema:, subscriber:) @subscriber = subscriber @@ -17,16 +20,18 @@ def instrument(type, field) end end + # If needed, prepare to gather events which this query subscribes to def before_query(query) if query.subscription? && !query.subscription_update? - query.context[:subscriptions] = [] + query.context[:events] = [] end end + # After checking the root fields, pass the gathered events to the store def after_query(query) - subscriptions = query.context[:subscriptions] - if subscriptions && subscriptions.any? - @subscriber.register(query, subscriptions) + events = query.context[:events] + if events && events.any? + @subscriber.register(query, events) end end @@ -39,9 +44,11 @@ def initialize(inner_proc) # Wrap the proc with subscription registration logic def call(obj, args, ctx) - subscriptions = ctx[:subscriptions] - if subscriptions - subscriptions << Subscriptions::Event.new( + events = ctx[:events] + if events + # This is the first execution, so gather an Event + # for the backend to register: + events << Subscriptions::Event.new( name: ctx.field.name, arguments: args, context: ctx, diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index 2c919913975..0539d2948d5 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true module GraphQL module Subscriptions + # Hang along with the schema and: + # + # - Coordinate access to the application-provided store + # - Receive `trigger`s from the application + # - Respond to them by: + # - loading data from the store + # - evaluating the subscription + # - sending the result over the specified application-provided transport class Subscriber extend Forwardable @@ -12,6 +20,13 @@ def initialize(schema:, store:, transports:) def_delegators :@store, :register, :delete, :each_subscription + # Fetch subscription matching this field + arguments pair + # and evaluate them with `object` as underlying value. + # + # Results will be sent to the transport specified by the store. + # + # TODO handle raised errors during loading & delivering. + # Subscription deliveries should be isolated. def trigger(event, args, object) event_key = Subscriptions::Event.serialize(event, args) @store.each_subscription(event_key) do |query_data| @@ -39,12 +54,6 @@ def trigger(event, args, object) transport.deliver(channel, result, query.context) end end - - private - - def serialize_event(event, args) - "#{event}(#{JSON.dump(args.to_h)})" - end end end end From 3b10f9614319f48d7682a3bad8acd83d673f7053 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 20 Apr 2017 20:28:09 -0400 Subject: [PATCH 12/33] feat(Subscription) add queue api --- lib/graphql/subscriptions.rb | 5 +- lib/graphql/subscriptions/inline_queue.rb | 14 ++++ lib/graphql/subscriptions/instrumentation.rb | 2 +- lib/graphql/subscriptions/subscriber.rb | 66 ++++++++------- spec/graphql/subscriptions_spec.rb | 85 +++++++++++++++++--- 5 files changed, 125 insertions(+), 47 deletions(-) create mode 100644 lib/graphql/subscriptions/inline_queue.rb diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 4cf5d2237fb..e61273712d1 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true require "graphql/subscriptions/event" +require "graphql/subscriptions/inline_queue" require "graphql/subscriptions/instrumentation" require "graphql/subscriptions/subscriber" - module GraphQL # A plugin for attaching subscription behavior to the schema # @example @@ -26,10 +26,11 @@ module Subscriptions # # @param store [<#register(query, events), #each_subscription(event_key, &block)>] # @param transports [Hash <#deliver(channel, result, ctx)>] - def use(defn, store:, transports:) + def use(defn, store:, transports:, queue: InlineQueue) schema = defn.target schema.subscriber = Subscriptions::Subscriber.new( schema: schema, + queue: queue, store: store, transports: transports, ) diff --git a/lib/graphql/subscriptions/inline_queue.rb b/lib/graphql/subscriptions/inline_queue.rb new file mode 100644 index 00000000000..08f1066bf5c --- /dev/null +++ b/lib/graphql/subscriptions/inline_queue.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module GraphQL + module Subscriptions + # Run the query right away and push it over transport right away. + # This is the default if you don't provide a queue. + # @api private + module InlineQueue + module_function + def enqueue(schema, channel, event_key, object) + schema.subscriber.process(channel, event_key, object) + end + end + end +end diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index 9f67d614792..1d438f177d9 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -31,7 +31,7 @@ def before_query(query) def after_query(query) events = query.context[:events] if events && events.any? - @subscriber.register(query, events) + @subscriber.set(query, events) end end diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index 0539d2948d5..6d13bad8a80 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -12,47 +12,51 @@ module Subscriptions class Subscriber extend Forwardable - def initialize(schema:, store:, transports:) + def initialize(schema:, store:, queue: InlineQueue, transports:) @schema = schema @store = store + @queue = queue @transports = transports end - def_delegators :@store, :register, :delete, :each_subscription + def_delegators :@store, :set, :get, :delete, :each_channel - # Fetch subscription matching this field + arguments pair - # and evaluate them with `object` as underlying value. - # - # Results will be sent to the transport specified by the store. - # - # TODO handle raised errors during loading & delivering. - # Subscription deliveries should be isolated. + # Fetch subscriptions matching this field + arguments pair + # And pass them off to the queue. def trigger(event, args, object) event_key = Subscriptions::Event.serialize(event, args) - @store.each_subscription(event_key) do |query_data| - query_string = query_data.fetch(:query_string) - variables = query_data.fetch(:variables) - context = query_data.fetch(:context) - operation_name = query_data.fetch(:operation_name) + @store.each_channel(event_key) do |channel| + @queue.enqueue(@schema, channel, event_key, object) + end + end - query = GraphQL::Query.new( - @schema, - query_string, - { - context: context, - subscription_key: event_key, - operation_name: operation_name, - variables: variables, - root_value: object, - } - ) - result = query.result + # TODO rename this. + # It runs the query and delivers it. + # It's probably called in a background job, + # but the default is inline. + def process(channel, event_key, object) + query_data = @store.get(channel) + query_string = query_data.fetch(:query_string) + variables = query_data.fetch(:variables) + context = query_data.fetch(:context) + operation_name = query_data.fetch(:operation_name) - transport_key = query_data.fetch(:transport) - channel = query_data.fetch(:channel) - transport = @transports.fetch(transport_key) - transport.deliver(channel, result, query.context) - end + query = GraphQL::Query.new( + @schema, + query_string, + { + context: context, + subscription_key: event_key, + operation_name: operation_name, + variables: variables, + root_value: object, + } + ) + result = query.result + + transport_key = query_data.fetch(:transport) + transport = @transports.fetch(transport_key) + transport.deliver(channel, result, query.context) end end end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 2ae9048d2c1..cc63b089cd7 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -5,10 +5,12 @@ class InMemoryBackend # Store API class Database def initialize + @queries = {} @subscriptions = Hash.new { |h, k| h[k] = [] } end - def register(query, events) + def set(query, events) + @queries[query.context[:socket]] = query events.each do |ev| # The `context` is functioning as subscription data. # IRL you'd have some other model that persisted the subscription @@ -16,20 +18,32 @@ def register(query, events) end end - def each_subscription(key) - @subscriptions[key].map { |ctx| - query = ctx.query - yield({ - query_string: query.query_string, - operation_name: query.operation_name, - variables: query.provided_variables, - context: {}, - channel: ctx[:socket], - transport: :socket, - }) + def get(channel) + query = @queries[channel] + { + query_string: query.query_string, + operation_name: query.operation_name, + variables: query.provided_variables, + context: {}, + transport: :socket, } end + def each_channel(key) + @subscriptions[key].each do |ctx| + yield(ctx[:socket]) + end + end + + def delete(channel) + query = @queries.delete(channel) + if query + @subscriptions.each do |key, contexts| + contexts.delete(query.context) + end + end + end + # Just for testing: def size @subscriptions.size @@ -57,6 +71,23 @@ def initialize end end + class Queue + class << self + def pushes + @pushes ||= [] + end + + def clear + pushes.clear + end + + def enqueue(schema, channel, event_key, object) + pushes << channel + schema.subscriber.process(channel, event_key, object) + end + end + end + # Just a random stateful object for tracking what happens: class Payload attr_reader :str @@ -76,6 +107,7 @@ def int describe GraphQL::Subscriptions do before do InMemoryBackend::Socket.clear + InMemoryBackend::Queue.clear end let(:root_object) { @@ -105,6 +137,7 @@ def int subscription(subscription_type) use GraphQL::Subscriptions, store: db, + queue: InMemoryBackend::Queue, transports: { socket: InMemoryBackend::Socket } @@ -161,8 +194,34 @@ def int end describe "trigger" do + it "uses the provided queue" do + query_str = <<-GRAPHQL + subscription ($id: ID!){ + payload(id: $id) { str, int } + } + GRAPHQL + + schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "8" }, root_value: root_object) + schema.subscriber.trigger("payload", { "id" => "8"}, root_object.payload) + assert_equal ["1"], InMemoryBackend::Queue.pushes + end + + it "pushes errors" do + query_str = <<-GRAPHQL + subscription ($id: ID!){ + payload(id: $id) { str, int } + } + GRAPHQL + + schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "8" }, root_value: root_object) + schema.subscriber.trigger("payload", { "id" => "8"}, OpenStruct.new(str: nil, int: nil)) + socket = InMemoryBackend::Socket.open("1") + delivery = socket.deliveries.first + assert_equal nil, delivery.fetch("data") + assert_equal 1, delivery["errors"].length + end + it "coerces args somehow?" - it "pushes errors" it "handles errors during trigger somehow?" end end From 997c601a5a9b4366e69f7294e8c13740b7e7e7e3 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 20 Apr 2017 20:34:57 -0400 Subject: [PATCH 13/33] feat(Context#skip) add API for skipping fields from user code --- lib/graphql/execution/execute.rb | 32 ++++++++++++++++++++ lib/graphql/subscriptions/instrumentation.rb | 6 ++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb index 015ff3d4a8b..4c85b9b40ce 100644 --- a/lib/graphql/execution/execute.rb +++ b/lib/graphql/execution/execute.rb @@ -106,7 +106,39 @@ def resolve_field(owner, selection, parent_type, field, object, query_ctx) else result end +<<<<<<< HEAD end +======= + elsif value == SKIP + value + else + case field_type.kind + when GraphQL::TypeKinds::SCALAR + field_type.coerce_result(value, field_ctx) + when GraphQL::TypeKinds::ENUM + field_type.coerce_result(value, field_ctx) + when GraphQL::TypeKinds::LIST + inner_type = field_type.of_type + i = 0 + result = [] + value.each do |inner_value| + inner_ctx = field_ctx.spawn( + key: i, + selection: selection, + parent_type: parent_type, + field: field_defn, + ) + + inner_result = resolve_value( + owner, + parent_type, + field_defn, + inner_type, + inner_value, + selection, + inner_ctx, + ) +>>>>>>> feat(Context#skip) add API for skipping fields from user code def continue_resolve_field(owner, selection, parent_type, field, raw_value, field_ctx) if owner.invalid_null? diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index 1d438f177d9..c4710a79c9a 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -58,10 +58,8 @@ def call(obj, args, ctx) # The root object is _already_ the subscription update: obj else - # It should only: - # - Register the selection (first condition) - # - Pass `obj` to the child selection (second condition) - raise "An unselected subscription field should never be called" + # This is a subscription update, but this event wasn't triggered. + ctx.skip end end end From 80d22e195833d9a79ea0ddd58b075171bdc5932f Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 1 May 2017 20:54:14 -0400 Subject: [PATCH 14/33] Fix rebase --- lib/graphql/execution/execute.rb | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb index 4c85b9b40ce..015ff3d4a8b 100644 --- a/lib/graphql/execution/execute.rb +++ b/lib/graphql/execution/execute.rb @@ -106,39 +106,7 @@ def resolve_field(owner, selection, parent_type, field, object, query_ctx) else result end -<<<<<<< HEAD end -======= - elsif value == SKIP - value - else - case field_type.kind - when GraphQL::TypeKinds::SCALAR - field_type.coerce_result(value, field_ctx) - when GraphQL::TypeKinds::ENUM - field_type.coerce_result(value, field_ctx) - when GraphQL::TypeKinds::LIST - inner_type = field_type.of_type - i = 0 - result = [] - value.each do |inner_value| - inner_ctx = field_ctx.spawn( - key: i, - selection: selection, - parent_type: parent_type, - field: field_defn, - ) - - inner_result = resolve_value( - owner, - parent_type, - field_defn, - inner_type, - inner_value, - selection, - inner_ctx, - ) ->>>>>>> feat(Context#skip) add API for skipping fields from user code def continue_resolve_field(owner, selection, parent_type, field, raw_value, field_ctx) if owner.invalid_null? From a246fe63b86389d8ab28ff446d75be1cbd810118 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 2 May 2017 16:16:16 -0400 Subject: [PATCH 15/33] fix(Subscription) return ctx.skip for initial run --- lib/graphql/subscriptions/instrumentation.rb | 2 +- lib/graphql/subscriptions/subscriber.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index c4710a79c9a..9527b93eaa8 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -53,7 +53,7 @@ def call(obj, args, ctx) arguments: args, context: ctx, ) - nil + ctx.skip elsif ctx.irep_node.subscription_key == ctx.query.subscription_key # The root object is _already_ the subscription update: obj diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index 6d13bad8a80..cd1c42b8cbb 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -12,6 +12,7 @@ module Subscriptions class Subscriber extend Forwardable + attr_reader :store, :queue, :transports def initialize(schema:, store:, queue: InlineQueue, transports:) @schema = schema @store = store From b77fb737d3bb56e241960b55bc19dc0e01b292b1 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sat, 10 Jun 2017 15:36:43 -0400 Subject: [PATCH 16/33] Add execute hook, update for changes on master --- lib/graphql/execution/execute.rb | 1 + lib/graphql/execution/multiplex.rb | 2 +- lib/graphql/execution/selection_result.rb | 8 +++++ lib/graphql/query.rb | 4 +-- lib/graphql/subscriptions.rb | 2 ++ lib/graphql/subscriptions/inline_queue.rb | 9 +++-- lib/graphql/subscriptions/schema_execute.rb | 40 +++++++++++++++++++++ lib/graphql/subscriptions/subscriber.rb | 30 +++------------- 8 files changed, 66 insertions(+), 30 deletions(-) create mode 100644 lib/graphql/subscriptions/schema_execute.rb diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb index 015ff3d4a8b..46eba1ae74e 100644 --- a/lib/graphql/execution/execute.rb +++ b/lib/graphql/execution/execute.rb @@ -47,6 +47,7 @@ def resolve_selection(object, current_type, selection, query_ctx, mutation: fals ) if field_result.is_a?(Skip) + selection_result.skipped = true next end diff --git a/lib/graphql/execution/multiplex.rb b/lib/graphql/execution/multiplex.rb index e001bf8d2d7..a1551e39938 100644 --- a/lib/graphql/execution/multiplex.rb +++ b/lib/graphql/execution/multiplex.rb @@ -115,7 +115,7 @@ def finish_query(data_result, query) if !query.valid? { "errors" => query.static_errors.map(&:to_h) } else - {} + data_result end else result = { "data" => data_result.to_h } diff --git a/lib/graphql/execution/selection_result.rb b/lib/graphql/execution/selection_result.rb index c51bb39f1ec..ed45923533b 100644 --- a/lib/graphql/execution/selection_result.rb +++ b/lib/graphql/execution/selection_result.rb @@ -8,6 +8,7 @@ def initialize @storage = {} @owner = nil @invalid_null = false + @skipped = false end # @param key [String] The name for this value in the result @@ -29,10 +30,17 @@ def each end end + # TODO: is there a better way to return nil when nothing was entered? + def skipped=(was_skipped) + @skipped = was_skipped + end + # @return [Hash] A plain Hash representation of this result def to_h if @invalid_null nil + elsif @storage.empty? && @skipped + nil else flatten(self) end diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index b50b31ceef3..06a228925c5 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -110,7 +110,7 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n end def subscription_update? - @subscription && @subscription_key + @subscription_key && subscription? end # @api private @@ -236,7 +236,7 @@ def merge_filters(only: nil, except: nil) end def subscription? - @subscription + with_prepared_ast { @subscription } end private diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index e61273712d1..599501f895d 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -2,6 +2,7 @@ require "graphql/subscriptions/event" require "graphql/subscriptions/inline_queue" require "graphql/subscriptions/instrumentation" +require "graphql/subscriptions/schema_execute" require "graphql/subscriptions/subscriber" module GraphQL # A plugin for attaching subscription behavior to the schema @@ -26,6 +27,7 @@ module Subscriptions # # @param store [<#register(query, events), #each_subscription(event_key, &block)>] # @param transports [Hash <#deliver(channel, result, ctx)>] + # @param queue [<#enqueue(...)>] def use(defn, store:, transports:, queue: InlineQueue) schema = defn.target schema.subscriber = Subscriptions::Subscriber.new( diff --git a/lib/graphql/subscriptions/inline_queue.rb b/lib/graphql/subscriptions/inline_queue.rb index 08f1066bf5c..d8b4e8dca62 100644 --- a/lib/graphql/subscriptions/inline_queue.rb +++ b/lib/graphql/subscriptions/inline_queue.rb @@ -6,8 +6,13 @@ module Subscriptions # @api private module InlineQueue module_function - def enqueue(schema, channel, event_key, object) - schema.subscriber.process(channel, event_key, object) + # @param subscriber [GraphQL::Subscriptions::Subscriber] + # @param channel [String] + # @param event_key [String] + # @param object [Object] + # @return [void] + def enqueue(subscriber, channel, event_key, object) + subscriber.process(channel, event_key, object) end end end diff --git a/lib/graphql/subscriptions/schema_execute.rb b/lib/graphql/subscriptions/schema_execute.rb new file mode 100644 index 00000000000..100f72664e5 --- /dev/null +++ b/lib/graphql/subscriptions/schema_execute.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +module GraphQL + module Subscriptions + module SchemaExecute + # @param subscriber [GraphQL::Subscriptions::Subscriber] + # @param channel [String] + # @param object [Object] + # @return [void] + def self.call(subscriber, channel, event_key, object) + # Lookup the saved data for this subscription + query_data = subscriber.get(channel) + # Fetch the required keys from the saved data + query_string = query_data.fetch(:query_string) + variables = query_data.fetch(:variables) + context = query_data.fetch(:context) + operation_name = query_data.fetch(:operation_name) + + # Re-evaluate the saved query + query = GraphQL::Query.new( + subscriber.schema, + query_string, + { + context: context, + subscription_key: event_key, + operation_name: operation_name, + variables: variables, + root_value: object, + } + ) + result = query.result + + # Find the transport for this subscription + transport_key = query_data.fetch(:transport) + transport = subscriber.transports.fetch(transport_key) + # Deliver the payload over the transport + transport.deliver(channel, result, query.context) + end + end + end +end diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index cd1c42b8cbb..34e077571fc 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -10,14 +10,15 @@ module Subscriptions # - evaluating the subscription # - sending the result over the specified application-provided transport class Subscriber - extend Forwardable + extend GraphQL::Delegate - attr_reader :store, :queue, :transports - def initialize(schema:, store:, queue: InlineQueue, transports:) + attr_reader :store, :queue, :transports, :schema + def initialize(schema:, store:, queue: InlineQueue, execute: SchemaExecute, transports:) @schema = schema @store = store @queue = queue @transports = transports + @execute = execute end def_delegators :@store, :set, :get, :delete, :each_channel @@ -36,28 +37,7 @@ def trigger(event, args, object) # It's probably called in a background job, # but the default is inline. def process(channel, event_key, object) - query_data = @store.get(channel) - query_string = query_data.fetch(:query_string) - variables = query_data.fetch(:variables) - context = query_data.fetch(:context) - operation_name = query_data.fetch(:operation_name) - - query = GraphQL::Query.new( - @schema, - query_string, - { - context: context, - subscription_key: event_key, - operation_name: operation_name, - variables: variables, - root_value: object, - } - ) - result = query.result - - transport_key = query_data.fetch(:transport) - transport = @transports.fetch(transport_key) - transport.deliver(channel, result, query.context) + @execute.call(self, channel, event_key, object) end end end From ab3772ef0be3e03e9c4b787a0ca366b153c9ddc8 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sat, 10 Jun 2017 15:44:35 -0400 Subject: [PATCH 17/33] update tests to use from_definition --- spec/graphql/subscriptions_spec.rb | 75 ++++++++++++++++-------------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index cc63b089cd7..58aff6a6131 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -3,8 +3,9 @@ class InMemoryBackend # Store API - class Database - def initialize + module Database + module_function + def clear @queries = {} @subscriptions = Hash.new { |h, k| h[k] = [] } end @@ -101,13 +102,38 @@ def int @counter += 1 end end + + SchemaDefinition = <<-GRAPHQL + type Subscription { + payload(id: ID!): Payload! + } + + type Payload { + str: String! + int: Int! + } + + type Query { + dummy: Int + } + GRAPHQL + + Schema = GraphQL::Schema.from_definition(SchemaDefinition).redefine do + use GraphQL::Subscriptions, + store: InMemoryBackend::Database, + queue: InMemoryBackend::Queue, + transports: { + socket: InMemoryBackend::Socket + } + end end describe GraphQL::Subscriptions do before do - InMemoryBackend::Socket.clear - InMemoryBackend::Queue.clear + socket.clear + queue.clear + database.clear end let(:root_object) { @@ -115,34 +141,11 @@ def int payload: InMemoryBackend::Payload.new, ) } - let(:database) { InMemoryBackend::Database.new } - let(:schema) { - payload_type = GraphQL::ObjectType.define do - name "Payload" - field :str, !types.String - field :int, !types.Int - end - - subscription_type = GraphQL::ObjectType.define do - name "Subscription" - field :payload, !payload_type do - argument :id, !types.ID - end - end - query_type = subscription_type.redefine(name: "Query") - db = database - GraphQL::Schema.define do - query(query_type) - subscription(subscription_type) - use GraphQL::Subscriptions, - store: db, - queue: InMemoryBackend::Queue, - transports: { - socket: InMemoryBackend::Socket - } - end - } + let(:database) { InMemoryBackend::Database } + let(:socket) { InMemoryBackend::Socket } + let(:queue) { InMemoryBackend::Queue } + let(:schema) { InMemoryBackend::Schema } describe "pushing updates" do it "sends updated data" do @@ -160,8 +163,8 @@ def int # Initial response is nil, no broadcasts yet assert_equal(nil, res_1["data"]) assert_equal(nil, res_2["data"]) - socket_1 = InMemoryBackend::Socket.open("1") - socket_2 = InMemoryBackend::Socket.open("2") + socket_1 = socket.open("1") + socket_2 = socket.open("2") assert_equal [], socket_1.deliveries assert_equal [], socket_2.deliveries @@ -203,7 +206,7 @@ def int schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "8" }, root_value: root_object) schema.subscriber.trigger("payload", { "id" => "8"}, root_object.payload) - assert_equal ["1"], InMemoryBackend::Queue.pushes + assert_equal ["1"], queue.pushes end it "pushes errors" do @@ -215,8 +218,8 @@ def int schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "8" }, root_value: root_object) schema.subscriber.trigger("payload", { "id" => "8"}, OpenStruct.new(str: nil, int: nil)) - socket = InMemoryBackend::Socket.open("1") - delivery = socket.deliveries.first + socket_1 = socket.open("1") + delivery = socket_1.deliveries.first assert_equal nil, delivery.fetch("data") assert_equal 1, delivery["errors"].length end From 3223574e7542b41755fb0d4f83054769b06b7281 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sat, 10 Jun 2017 22:07:20 -0400 Subject: [PATCH 18/33] feat(Subscriptions) normalize & coerce args for trigger --- lib/graphql/internal_representation/node.rb | 1 + lib/graphql/query/literal_input.rb | 41 ++++++++++++--- lib/graphql/subscriptions/event.rb | 20 +++++-- lib/graphql/subscriptions/subscriber.rb | 7 ++- spec/graphql/subscriptions_spec.rb | 58 ++++++++++++++++++++- 5 files changed, 114 insertions(+), 13 deletions(-) diff --git a/lib/graphql/internal_representation/node.rb b/lib/graphql/internal_representation/node.rb index 56e8a46cd28..ca21ef9ad83 100644 --- a/lib/graphql/internal_representation/node.rb +++ b/lib/graphql/internal_representation/node.rb @@ -149,6 +149,7 @@ def subscription_key @subscription_key ||= Subscriptions::Event.serialize( definition_name, @query.arguments_for(self, definition), + definition, ) end diff --git a/lib/graphql/query/literal_input.rb b/lib/graphql/query/literal_input.rb index 3beff11afbe..d81d2ceecde 100644 --- a/lib/graphql/query/literal_input.rb +++ b/lib/graphql/query/literal_input.rb @@ -14,9 +14,22 @@ def self.coerce(type, ast_node, variables) else case type when GraphQL::ScalarType - type.coerce_input(ast_node, variables.context) + # TODO smell + # This gets used for plain values during subscriber.trigger + if variables + type.coerce_input(ast_node, variables.context) + else + type.coerce_isolated_input(ast_node) + end when GraphQL::EnumType - type.coerce_input(ast_node.name, variables.context) + # TODO smell + # This gets used for plain values sometimes + v = ast_node.is_a?(GraphQL::Language::Nodes::Enum) ? ast_node.name : ast_node + if variables + type.coerce_input(v, variables.context) + else + type.coerce_isolated_input(v) + end when GraphQL::NonNullType LiteralInput.coerce(type.of_type, ast_node, variables) when GraphQL::ListType @@ -43,7 +56,14 @@ def self.from_arguments(ast_arguments, argument_defns, variables) # Variables is nil when making .defaults_for context = variables ? variables.context : nil values_hash = {} - indexed_arguments = ast_arguments.each_with_object({}) { |a, memo| memo[a.name] = a } + indexed_arguments = case ast_arguments + when Hash + ast_arguments + when Array + ast_arguments.each_with_object({}) { |a, memo| memo[a.name] = a } + else + raise ArgumentError, "Unexpected ast_arguments: #{ast_arguments}" + end argument_defns.each do |arg_name, arg_defn| ast_arg = indexed_arguments[arg_name] @@ -51,12 +71,17 @@ def self.from_arguments(ast_arguments, argument_defns, variables) # If the value is a variable, # only add a value if the variable is actually present. # Otherwise, coerce the value in the AST, prepare the value and add it. - if ast_arg - value_is_a_variable = ast_arg.value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) + # + # TODO: since indexed_arguments can come from a plain Ruby hash, + # have to check for `false` or `nil` as hash values. This is getting smelly :S + if indexed_arguments.key?(arg_name) + arg_value = ast_arg.is_a?(GraphQL::Language::Nodes::Argument) ? ast_arg.value : ast_arg + + value_is_a_variable = arg_value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) - if (!value_is_a_variable || (value_is_a_variable && variables.key?(ast_arg.value.name))) + if (!value_is_a_variable || (value_is_a_variable && variables.key?(arg_value.name))) - value = coerce(arg_defn.type, ast_arg.value, variables) + value = coerce(arg_defn.type, arg_value, variables) value = arg_defn.prepare(value, context) if value.is_a?(GraphQL::ExecutionError) @@ -64,7 +89,7 @@ def self.from_arguments(ast_arguments, argument_defns, variables) raise value end - values_hash[ast_arg.name] = value + values_hash[arg_name] = value end end diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index 0f2642dcd92..83c25438282 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -23,12 +23,26 @@ def initialize(name:, arguments:, context:) @name = name @arguments = arguments @context = context - @key = self.class.serialize(name, arguments) + @key = self.class.serialize(name, arguments, @context.field) end # @return [String] an identifier for this unit of subscription - def self.serialize(name, arguments) - "#{name}(#{JSON.dump(arguments.to_h)})" + def self.serialize(name, arguments, field) + normalized_args = case arguments + when GraphQL::Query::Arguments + arguments + when Hash + GraphQL::Query::LiteralInput.from_arguments( + arguments, + field.arguments, + nil, + ) + else + raise ArgumentError, "Unexpected arguments: #{arguments}, must be Hash or GraphQL::Arguments" + end + + sorted_h = normalized_args.to_h.sort.to_h + "#{name}(#{JSON.dump(sorted_h)})" end end end diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index 34e077571fc..dd8cae2b86c 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -26,7 +26,12 @@ def initialize(schema:, store:, queue: InlineQueue, execute: SchemaExecute, tran # Fetch subscriptions matching this field + arguments pair # And pass them off to the queue. def trigger(event, args, object) - event_key = Subscriptions::Event.serialize(event, args) + field = @schema.get_field("Subscription", event) + if !field + raise "No subscription matching trigger: #{event}" + end + + event_key = Subscriptions::Event.serialize(event, args, field) @store.each_channel(event_key) do |channel| @queue.enqueue(@schema, channel, event_key, object) end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 58aff6a6131..b96a8477b38 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -49,6 +49,10 @@ def delete(channel) def size @subscriptions.size end + + def subscriptions + @subscriptions + end end class Socket @@ -106,6 +110,8 @@ def int SchemaDefinition = <<-GRAPHQL type Subscription { payload(id: ID!): Payload! + event(type: PayloadType = ONE, userId: ID!): Payload + myEvent(type: PayloadType): Payload } type Payload { @@ -113,6 +119,13 @@ def int int: Int! } + # Arbitrary "kinds" of payloads which may be + # subscribed to separately + enum PayloadType { + ONE + TWO + } + type Query { dummy: Int } @@ -224,7 +237,50 @@ def int assert_equal 1, delivery["errors"].length end - it "coerces args somehow?" + it "coerces args" do + query_str = <<-GRAPHQL + subscription($type: PayloadType) { + e1: event(userId: "3", type: $type) { int } + } + GRAPHQL + + # Subscribe with explicit `TYPE` + schema.execute(query_str, context: { socket: "1" }, variables: { "type" => "ONE" }, root_value: root_object) + # Subscribe with default `TYPE` + schema.execute(query_str, context: { socket: "2" }, root_value: root_object) + # Subscribe with non-matching `TYPE` + schema.execute(query_str, context: { socket: "3" }, variables: { "type" => "TWO" }, root_value: root_object) + # Subscribe with explicit null + schema.execute(query_str, context: { socket: "4" }, variables: { "type" => nil }, root_value: root_object) + + # Trigger the subscription with coerceable args, different orders: + schema.subscriber.trigger("event", {"userId" => 3, "type" => "ONE"}, OpenStruct.new(str: "", int: 1)) + schema.subscriber.trigger("event", {"type" => "ONE", "userId" => "3"}, OpenStruct.new(str: "", int: 2)) + # This is a non-trigger + schema.subscriber.trigger("event", {"userId" => "3", "type" => "TWO"}, OpenStruct.new(str: "", int: 3)) + # These get default value of ONE + schema.subscriber.trigger("event", {"userId" => "3"}, OpenStruct.new(str: "", int: 4)) + # Trigger with null updates subscribers to null + schema.subscriber.trigger("event", {"userId" => 3, "type" => nil}, OpenStruct.new(str: "", int: 5)) + + socket_1 = socket.open("1") + assert_equal [1,2,4], socket_1.deliveries.map { |d| d["data"]["e1"]["int"] } + + # Same as socket_1 + socket_2 = socket.open("2") + assert_equal [1,2,4], socket_2.deliveries.map { |d| d["data"]["e1"]["int"] } + + # Received the "non-trigger" + socket_3 = socket.open("3") + assert_equal [3], socket_3.deliveries.map { |d| d["data"]["e1"]["int"] } + + # Received the trigger with null + socket_4 = socket.open("4") + assert_equal [5], socket_4.deliveries.map { |d| d["data"]["e1"]["int"] } + end + + it "coerces input objects" + it "allows context-scoped subscriptions somehow?" it "handles errors during trigger somehow?" end end From 680a2e1ce02e771be7a6dfc156f726da3b802fd5 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sun, 11 Jun 2017 09:34:11 -0400 Subject: [PATCH 19/33] Test triggering with input objects --- lib/graphql/query/literal_input.rb | 4 +++- spec/graphql/subscriptions_spec.rb | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/graphql/query/literal_input.rb b/lib/graphql/query/literal_input.rb index d81d2ceecde..a52d94c3b0e 100644 --- a/lib/graphql/query/literal_input.rb +++ b/lib/graphql/query/literal_input.rb @@ -39,7 +39,9 @@ def self.coerce(type, ast_node, variables) [LiteralInput.coerce(type.of_type, ast_node, variables)] end when GraphQL::InputObjectType - from_arguments(ast_node.arguments, type.arguments, variables) + # TODO smell: handling AST vs handling plain Ruby + next_args = ast_node.is_a?(Hash) ? ast_node : ast_node.arguments + from_arguments(next_args, type.arguments, variables) end end end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index b96a8477b38..4a6d6fc01d6 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -110,7 +110,7 @@ def int SchemaDefinition = <<-GRAPHQL type Subscription { payload(id: ID!): Payload! - event(type: PayloadType = ONE, userId: ID!): Payload + event(stream: StreamInput): Payload myEvent(type: PayloadType): Payload } @@ -119,6 +119,11 @@ def int int: Int! } + input StreamInput { + userId: ID! + type: PayloadType = ONE + } + # Arbitrary "kinds" of payloads which may be # subscribed to separately enum PayloadType { @@ -240,7 +245,7 @@ def int it "coerces args" do query_str = <<-GRAPHQL subscription($type: PayloadType) { - e1: event(userId: "3", type: $type) { int } + e1: event(stream: { userId: "3", type: $type }) { int } } GRAPHQL @@ -254,14 +259,14 @@ def int schema.execute(query_str, context: { socket: "4" }, variables: { "type" => nil }, root_value: root_object) # Trigger the subscription with coerceable args, different orders: - schema.subscriber.trigger("event", {"userId" => 3, "type" => "ONE"}, OpenStruct.new(str: "", int: 1)) - schema.subscriber.trigger("event", {"type" => "ONE", "userId" => "3"}, OpenStruct.new(str: "", int: 2)) + schema.subscriber.trigger("event", { "stream" => {"userId" => 3, "type" => "ONE"} }, OpenStruct.new(str: "", int: 1)) + schema.subscriber.trigger("event", { "stream" => {"type" => "ONE", "userId" => "3"} }, OpenStruct.new(str: "", int: 2)) # This is a non-trigger - schema.subscriber.trigger("event", {"userId" => "3", "type" => "TWO"}, OpenStruct.new(str: "", int: 3)) + schema.subscriber.trigger("event", { "stream" => {"userId" => "3", "type" => "TWO"} }, OpenStruct.new(str: "", int: 3)) # These get default value of ONE - schema.subscriber.trigger("event", {"userId" => "3"}, OpenStruct.new(str: "", int: 4)) + schema.subscriber.trigger("event", { "stream" => {"userId" => "3"} }, OpenStruct.new(str: "", int: 4)) # Trigger with null updates subscribers to null - schema.subscriber.trigger("event", {"userId" => 3, "type" => nil}, OpenStruct.new(str: "", int: 5)) + schema.subscriber.trigger("event", { "stream" => {"userId" => 3, "type" => nil} }, OpenStruct.new(str: "", int: 5)) socket_1 = socket.open("1") assert_equal [1,2,4], socket_1.deliveries.map { |d| d["data"]["e1"]["int"] } @@ -279,7 +284,6 @@ def int assert_equal [5], socket_4.deliveries.map { |d| d["data"]["e1"]["int"] } end - it "coerces input objects" it "allows context-scoped subscriptions somehow?" it "handles errors during trigger somehow?" end From 566e1469da576fa1d91d21f5b26e15969d25a826 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sun, 11 Jun 2017 14:48:28 -0400 Subject: [PATCH 20/33] Add context-based subscription scoping --- lib/graphql/field.rb | 6 ++- lib/graphql/internal_representation/node.rb | 18 +++++--- lib/graphql/subscriptions/event.rb | 12 ++++-- lib/graphql/subscriptions/subscriber.rb | 4 +- spec/graphql/subscriptions_spec.rb | 48 ++++++++++++++++----- 5 files changed, 66 insertions(+), 22 deletions(-) diff --git a/lib/graphql/field.rb b/lib/graphql/field.rb index 122bd0aa590..e5e064a8cbb 100644 --- a/lib/graphql/field.rb +++ b/lib/graphql/field.rb @@ -129,6 +129,7 @@ class Field :edge_class, :relay_node_field, :relay_nodes_field, + :subscription_scope, argument: GraphQL::Define::AssignArgument ensure_defined( @@ -136,7 +137,7 @@ class Field :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 @@ -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 diff --git a/lib/graphql/internal_representation/node.rb b/lib/graphql/internal_representation/node.rb index ca21ef9ad83..92128160e81 100644 --- a/lib/graphql/internal_representation/node.rb +++ b/lib/graphql/internal_representation/node.rb @@ -146,11 +146,19 @@ def deep_merge_node(new_parent, scope: nil, merge_self: true) attr_reader :query def subscription_key - @subscription_key ||= Subscriptions::Event.serialize( - definition_name, - @query.arguments_for(self, definition), - definition, - ) + @subscription_key ||= 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 diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index 83c25438282..0fc4d28b568 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -23,11 +23,17 @@ def initialize(name:, arguments:, context:) @name = name @arguments = arguments @context = context - @key = self.class.serialize(name, arguments, @context.field) + field = context.field + scope_val = if field.subscription_scope + context[field.subscription_scope] + else + nil + end + @key = self.class.serialize(name, arguments, field, scope: scope_val) end # @return [String] an identifier for this unit of subscription - def self.serialize(name, arguments, field) + def self.serialize(name, arguments, field, scope:) normalized_args = case arguments when GraphQL::Query::Arguments arguments @@ -42,7 +48,7 @@ def self.serialize(name, arguments, field) end sorted_h = normalized_args.to_h.sort.to_h - "#{name}(#{JSON.dump(sorted_h)})" + JSON.dump([scope, name, sorted_h]) end end end diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index dd8cae2b86c..1ea0989d635 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -25,13 +25,13 @@ def initialize(schema:, store:, queue: InlineQueue, execute: SchemaExecute, tran # Fetch subscriptions matching this field + arguments pair # And pass them off to the queue. - def trigger(event, args, object) + def trigger(event, args, object, scope: nil) field = @schema.get_field("Subscription", event) if !field raise "No subscription matching trigger: #{event}" end - event_key = Subscriptions::Event.serialize(event, args, field) + event_key = Subscriptions::Event.serialize(event, args, field, scope: scope) @store.each_channel(event_key) do |channel| @queue.enqueue(@schema, channel, event_key, object) end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 4a6d6fc01d6..3d49f8d09f0 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -25,7 +25,7 @@ def get(channel) query_string: query.query_string, operation_name: query.operation_name, variables: query.provided_variables, - context: {}, + context: { me: query.context[:me] }, transport: :socket, } end @@ -58,7 +58,7 @@ def subscriptions class Socket # Transport API: def self.deliver(channel, result, ctx) - open(channel).deliveries << result + deliveries(channel) << result end def self.open(id) @@ -74,6 +74,10 @@ def self.clear def initialize @deliveries = [] end + + def self.deliveries(id) + @sockets[id].deliveries + end end class Queue @@ -144,6 +148,9 @@ def int socket: InMemoryBackend::Socket } end + + # TODO don't hack this + Schema.get_field("Subscription", "myEvent").subscription_scope = :me end @@ -268,23 +275,42 @@ def int # Trigger with null updates subscribers to null schema.subscriber.trigger("event", { "stream" => {"userId" => 3, "type" => nil} }, OpenStruct.new(str: "", int: 5)) - socket_1 = socket.open("1") - assert_equal [1,2,4], socket_1.deliveries.map { |d| d["data"]["e1"]["int"] } + assert_equal [1,2,4], socket.deliveries("1").map { |d| d["data"]["e1"]["int"] } # Same as socket_1 - socket_2 = socket.open("2") - assert_equal [1,2,4], socket_2.deliveries.map { |d| d["data"]["e1"]["int"] } + assert_equal [1,2,4], socket.deliveries("2").map { |d| d["data"]["e1"]["int"] } # Received the "non-trigger" - socket_3 = socket.open("3") - assert_equal [3], socket_3.deliveries.map { |d| d["data"]["e1"]["int"] } + assert_equal [3], socket.deliveries("3").map { |d| d["data"]["e1"]["int"] } # Received the trigger with null - socket_4 = socket.open("4") - assert_equal [5], socket_4.deliveries.map { |d| d["data"]["e1"]["int"] } + assert_equal [5], socket.deliveries("4").map { |d| d["data"]["e1"]["int"] } + end + + it "allows context-scoped subscriptions" do + query_str = <<-GRAPHQL + subscription($type: PayloadType) { + myEvent(type: $type) { int } + } + GRAPHQL + + # Subscriptions for user 1 + schema.execute(query_str, context: { socket: "1", me: "1" }, variables: { "type" => "ONE" }, root_value: root_object) + schema.execute(query_str, context: { socket: "2", me: "1" }, variables: { "type" => "TWO" }, root_value: root_object) + # Subscription for user 2 + schema.execute(query_str, context: { socket: "3", me: "2" }, variables: { "type" => "ONE" }, root_value: root_object) + + schema.subscriber.trigger("myEvent", { "type" => "ONE" }, OpenStruct.new(str: "", int: 1), scope: "1") + schema.subscriber.trigger("myEvent", { "type" => "TWO" }, OpenStruct.new(str: "", int: 2), scope: "1") + schema.subscriber.trigger("myEvent", { "type" => "ONE" }, OpenStruct.new(str: "", int: 3), scope: "2") + + # Delivered to user 1 + assert_equal [1], socket.deliveries("1").map { |d| d["data"]["myEvent"]["int"] } + assert_equal [2], socket.deliveries("2").map { |d| d["data"]["myEvent"]["int"] } + # Delivered to user 2 + assert_equal [3], socket.deliveries("3").map { |d| d["data"]["myEvent"]["int"] } end - it "allows context-scoped subscriptions somehow?" it "handles errors during trigger somehow?" end end From 44133232bdba83957927b810e5bc034ec4d2b07c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sun, 11 Jun 2017 15:01:41 -0400 Subject: [PATCH 21/33] Describe error handling --- spec/graphql/subscriptions_spec.rb | 39 +++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 3d49f8d09f0..6704daf0c70 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -311,6 +311,43 @@ def int assert_equal [3], socket.deliveries("3").map { |d| d["data"]["myEvent"]["int"] } end - it "handles errors during trigger somehow?" + describe "errors" do + class ErrorPayload + def int + raise "Boom!" + end + + def str + raise GraphQL::ExecutionError.new("This is handled") + end + end + + it "lets unhandled errors crash "do + query_str = <<-GRAPHQL + subscription($type: PayloadType) { + myEvent(type: $type) { int } + } + GRAPHQL + + schema.execute(query_str, context: { socket: "1", me: "1" }, variables: { "type" => "ONE" }, root_value: root_object) + err = assert_raises(RuntimeError) { + schema.subscriber.trigger("myEvent", { "type" => "ONE" }, ErrorPayload.new, scope: "1") + } + assert_equal "Boom!", err.message + end + end + + it "sends query errors to the subscriber" do + query_str = <<-GRAPHQL + subscription($type: PayloadType) { + myEvent(type: $type) { str } + } + GRAPHQL + + schema.execute(query_str, context: { socket: "1", me: "1" }, variables: { "type" => "ONE" }, root_value: root_object) + schema.subscriber.trigger("myEvent", { "type" => "ONE" }, ErrorPayload.new, scope: "1") + res = socket.deliveries("1").first + assert_equal "This is handled", res["errors"][0]["message"] + end end end From ed4a943448a727a2de4010f23e45664a2a5ac066 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 12 Jun 2017 17:29:54 -0400 Subject: [PATCH 22/33] Fix queue --- lib/graphql/subscriptions/inline_queue.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/graphql/subscriptions/inline_queue.rb b/lib/graphql/subscriptions/inline_queue.rb index d8b4e8dca62..b58fadcc2fd 100644 --- a/lib/graphql/subscriptions/inline_queue.rb +++ b/lib/graphql/subscriptions/inline_queue.rb @@ -6,13 +6,13 @@ module Subscriptions # @api private module InlineQueue module_function - # @param subscriber [GraphQL::Subscriptions::Subscriber] + # @param schema [GraphQL::Schema] # @param channel [String] # @param event_key [String] # @param object [Object] # @return [void] - def enqueue(subscriber, channel, event_key, object) - subscriber.process(channel, event_key, object) + def enqueue(schema, channel, event_key, object) + schema.subscriber.process(channel, event_key, object) end end end From 7d9b54356e05e7b099eb190a20048876fbd52c1b Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 4 Aug 2017 08:35:34 -0400 Subject: [PATCH 23/33] Add overview doc --- guides/guides.html | 1 + guides/subscriptions/overview.md | 47 +++++++++++++++++++++++ guides/subscriptions/queue.md | 9 +++++ guides/subscriptions/store.md | 9 +++++ guides/subscriptions/subscription_type.md | 40 +++++++++++++++++++ guides/subscriptions/transport.md | 9 +++++ guides/subscriptions/triggers.md | 25 ++++++++++++ lib/graphql/subscriptions/event.rb | 11 ++---- lib/graphql/subscriptions/inline_queue.rb | 22 +++++++++-- lib/graphql/subscriptions/memory_store.rb | 15 ++++++++ lib/graphql/subscriptions/subscriber.rb | 23 +++++++---- spec/graphql/subscriptions_spec.rb | 36 ++++++++++------- 12 files changed, 214 insertions(+), 33 deletions(-) create mode 100644 guides/subscriptions/overview.md create mode 100644 guides/subscriptions/queue.md create mode 100644 guides/subscriptions/store.md create mode 100644 guides/subscriptions/subscription_type.md create mode 100644 guides/subscriptions/transport.md create mode 100644 guides/subscriptions/triggers.md create mode 100644 lib/graphql/subscriptions/memory_store.rb diff --git a/guides/guides.html b/guides/guides.html index 2dea73dc1c6..5d4e57d2544 100644 --- a/guides/guides.html +++ b/guides/guides.html @@ -6,6 +6,7 @@ - name: Types - name: Fields - name: Relay + - name: Subscriptions - name: GraphQL Pro - name: GraphQL Pro - OperationStore - name: Other diff --git a/guides/subscriptions/overview.md b/guides/subscriptions/overview.md new file mode 100644 index 00000000000..65432c056e1 --- /dev/null +++ b/guides/subscriptions/overview.md @@ -0,0 +1,47 @@ +--- +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 __Store__ manages subscriber state (_who_ subscribed to _what_) +- The __Queue__ runs subscription queries after events happen (eg, ActiveJob) +- The __Transport__ delivers updates to clients + +### 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" %}. + +### Store + +As clients subscribe and unsubscribe, you must keep track of their subscription status. The _Store_ manages this state. + +Read more in the {% internal_link "Store guide","subscriptions/store" %} + +### Queue + +After a trigger, clients must be updated with new data. The _Queue_ evaluates GraphQL queries and delivers the result to clients. + +Read more in the {% internal_link "Queue guide","subscriptions/transport" %} + +### Transport + +Clients must receive data somehow. A _Transport_ is a way to send data to a client (eg, websocket, native push notification, or webhook). + +Read more in the {% internal_link "Transport guide","subscriptions/transport" %} diff --git a/guides/subscriptions/queue.md b/guides/subscriptions/queue.md new file mode 100644 index 00000000000..b7c8631017a --- /dev/null +++ b/guides/subscriptions/queue.md @@ -0,0 +1,9 @@ +--- +layout: guide +search: true +section: Subscriptions +title: Queue +desc: Running queries in response to triggers +index: 4 +experimental: true +--- diff --git a/guides/subscriptions/store.md b/guides/subscriptions/store.md new file mode 100644 index 00000000000..3e3ac69e9d0 --- /dev/null +++ b/guides/subscriptions/store.md @@ -0,0 +1,9 @@ +--- +layout: guide +search: true +section: Subscriptions +title: Store +desc: Subscription state management +index: 3 +experimental: true +--- diff --git a/guides/subscriptions/subscription_type.md b/guides/subscriptions/subscription_type.md new file mode 100644 index 00000000000..b10c008ae07 --- /dev/null +++ b/guides/subscriptions/subscription_type.md @@ -0,0 +1,40 @@ +--- +layout: guide +search: true +section: Subscriptions +title: Subscription Type +desc: The root type for subscriptions +index: 1 +experimental: true +--- + +To enable subscriptions: + +- Define `SubscriptionType` +- Add a `subscription` type to your schema +- Hook up the module with `use(GraphQL::Subscription, options)` + +For example: + +```ruby +# app/graphql/types/subscription_type.rb +Types::SubscriptionType = GraphQL::ObjectType.define do + name "Subscription" + field :postAdded, !Types::PostType, "A post was published to the blog" + # ... +end +``` + +And: + +```ruby +# app/graphql/my_schema.rb +MySchema = GraphQL::Schema.define do + query(Types::QueryType) + # ... + subscription(Types::SubscriptionType) + use GraphQL::Subscriptions, { + # options, see below ... + } +end +``` diff --git a/guides/subscriptions/transport.md b/guides/subscriptions/transport.md new file mode 100644 index 00000000000..5c751885760 --- /dev/null +++ b/guides/subscriptions/transport.md @@ -0,0 +1,9 @@ +--- +layout: guide +search: true +section: Subscriptions +title: Transport +desc: Delivering updates to clients +index: 5 +experimental: true +--- diff --git a/guides/subscriptions/triggers.md b/guides/subscriptions/triggers.md new file mode 100644 index 00000000000..ed2b828d6c1 --- /dev/null +++ b/guides/subscriptions/triggers.md @@ -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. diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index 0fc4d28b568..9b4e999a074 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -19,16 +19,13 @@ class Event # @return [String] An opaque string which identifies this event, derived from `name` and `arguments` attr_reader :key - def initialize(name:, arguments:, context:) + def initialize(name:, arguments:, field: nil, context: nil, scope: nil) @name = name @arguments = arguments @context = context - field = context.field - scope_val = if field.subscription_scope - context[field.subscription_scope] - else - nil - end + field ||= context.field + scope_val = scope || (context && field.subscription_scope && context[field.subscription_scope]) + @key = self.class.serialize(name, arguments, field, scope: scope_val) end diff --git a/lib/graphql/subscriptions/inline_queue.rb b/lib/graphql/subscriptions/inline_queue.rb index b58fadcc2fd..285fbe809b1 100644 --- a/lib/graphql/subscriptions/inline_queue.rb +++ b/lib/graphql/subscriptions/inline_queue.rb @@ -1,18 +1,32 @@ # frozen_string_literal: true +# test_via: ../subscriptions.rb module GraphQL module Subscriptions # Run the query right away and push it over transport right away. # This is the default if you don't provide a queue. # @api private - module InlineQueue - module_function + class InlineQueue + def initialize(schema:, store:) + @schema = schema + @store = store + end + # @param schema [GraphQL::Schema] # @param channel [String] # @param event_key [String] # @param object [Object] # @return [void] - def enqueue(schema, channel, event_key, object) - schema.subscriber.process(channel, event_key, object) + def enqueue(channel, event_key, object) + @schema.subscriber.process(channel, event_key, object) + end + + # @param event [GraphQL::Subscriptions::Event] + # @return [void] + def enqueue_all(event, object) + event_key = event.key + @store.each_channel(event_key) do |channel| + enqueue(channel, event_key, object) + end end end end diff --git a/lib/graphql/subscriptions/memory_store.rb b/lib/graphql/subscriptions/memory_store.rb new file mode 100644 index 00000000000..143d2b147a4 --- /dev/null +++ b/lib/graphql/subscriptions/memory_store.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +module GraphQL + module Subscriptions + # This "in-memory" database + # will only work for a single server, + # like your development environment. + # + # In case of a crash, restart or redeploy, + # it loses all state. + # @api private + class MemoryStore + + end + end +end diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index 1ea0989d635..f9c354aff0d 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -9,6 +9,10 @@ module Subscriptions # - loading data from the store # - evaluating the subscription # - sending the result over the specified application-provided transport + # + # TODO: + # - add generator for installing + # - better api than `schema.subscriber` class Subscriber extend GraphQL::Delegate @@ -16,7 +20,7 @@ class Subscriber def initialize(schema:, store:, queue: InlineQueue, execute: SchemaExecute, transports:) @schema = schema @store = store - @queue = queue + @queue = queue.new(schema: schema, store: store) @transports = transports @execute = execute end @@ -25,16 +29,19 @@ def initialize(schema:, store:, queue: InlineQueue, execute: SchemaExecute, tran # Fetch subscriptions matching this field + arguments pair # And pass them off to the queue. - def trigger(event, args, object, scope: nil) - field = @schema.get_field("Subscription", event) + def trigger(event_name, args, object, scope: nil) + field = @schema.get_field("Subscription", event_name) if !field - raise "No subscription matching trigger: #{event}" + raise "No subscription matching trigger: #{event_name}" end - event_key = Subscriptions::Event.serialize(event, args, field, scope: scope) - @store.each_channel(event_key) do |channel| - @queue.enqueue(@schema, channel, event_key, object) - end + event = Subscriptions::Event.new( + name: event_name, + arguments: args, + field: field, + scope: scope, + ) + @queue.enqueue_all(event, object) end # TODO rename this. diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 6704daf0c70..0637da2471e 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -3,8 +3,7 @@ class InMemoryBackend # Store API - module Database - module_function + class Database def clear @queries = {} @subscriptions = Hash.new { |h, k| h[k] = [] } @@ -81,18 +80,27 @@ def self.deliveries(id) end class Queue - class << self - def pushes - @pushes ||= [] - end + attr_reader :pushes - def clear - pushes.clear - end + def initialize(schema:, store:) + @schema = schema + @store = store + @pushes = [] + end + + def clear + pushes.clear + end + + def enqueue(channel, event_key, object) + pushes << channel + @schema.subscriber.process(channel, event_key, object) + end - def enqueue(schema, channel, event_key, object) - pushes << channel - schema.subscriber.process(channel, event_key, object) + def enqueue_all(event, object) + event_key = event.key + @store.each_channel(event_key) do |channel| + enqueue(channel, event_key, object) end end end @@ -149,7 +157,7 @@ def int } end - # TODO don't hack this + # TODO don't hack this (no way to add metadata from IDL parser right now) Schema.get_field("Subscription", "myEvent").subscription_scope = :me end @@ -169,7 +177,7 @@ def int let(:database) { InMemoryBackend::Database } let(:socket) { InMemoryBackend::Socket } - let(:queue) { InMemoryBackend::Queue } + let(:queue) { schema.subscriber.queue } let(:schema) { InMemoryBackend::Schema } describe "pushing updates" do From b4673aeed6dbe60975edd6eabaac0e5f245ebc04 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 7 Aug 2017 21:27:55 -0500 Subject: [PATCH 24/33] Add subscription type docs --- guides/subscriptions/subscription_type.md | 69 ++++++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/guides/subscriptions/subscription_type.md b/guides/subscriptions/subscription_type.md index b10c008ae07..342899d8986 100644 --- a/guides/subscriptions/subscription_type.md +++ b/guides/subscriptions/subscription_type.md @@ -8,33 +8,86 @@ index: 1 experimental: true --- -To enable subscriptions: +`Subscription` is the entry point for all subscriptions in a GraphQL system. Each field corresponds to an event which may be subscribed to: -- Define `SubscriptionType` -- Add a `subscription` type to your schema -- Hook up the module with `use(GraphQL::Subscription, options)` +```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 +} +``` -For example: +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 :postAdded, !Types::PostType, "A post was published to the blog" + field :postWasPublished, !Types::PostType, "A post was published to the blog" # ... end ``` -And: +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 +``` + +And hook up the {{ "GraphQL::Subscriptions" | api_doc }} plugin: + +```ruby +# app/graphql/my_schema.rb +MySchema = GraphQL::Schema.define do + # ... use GraphQL::Subscriptions, { - # options, see below ... + # options, see below } end ``` + +## Plugin Options + +Plugin options correspond to the parts of the subscription system: + +- `queue:` provides a {% internal_link "Queue implementation", "subscriptions/queue" %} +- `store:` provides a {% internal_link "Store implementation", "subscriptions/store" %} +- `transports:` provides one or more {% internal_link "Transport implementations", "subscriptions/transport" %} + +For example: + +```ruby +use GraphQL::Subscriptions, { + queue: MyApp::Subscriptions::Queue, + store: MyApp::Subscriptions::Store, + transports: { + "action_cable" => MyApp::Subscriptions::ActionCableTransport, + } +} +``` + +`GraphQL::Subscriptions` will use these objects to manage subscription lifecycle. From e06b96251840e25905fc93e97ae19a5bd3955b7a Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 7 Aug 2017 21:31:54 -0500 Subject: [PATCH 25/33] fix tests --- lib/graphql/subscriptions/subscriber.rb | 1 + spec/graphql/subscriptions_spec.rb | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb index f9c354aff0d..914c71d7894 100644 --- a/lib/graphql/subscriptions/subscriber.rb +++ b/lib/graphql/subscriptions/subscriber.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +# test_via: ../subscriptions.rb module GraphQL module Subscriptions # Hang along with the schema and: diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 0637da2471e..7bcea1f0bae 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -3,7 +3,8 @@ class InMemoryBackend # Store API - class Database + module Database + module_function def clear @queries = {} @subscriptions = Hash.new { |h, k| h[k] = [] } From b977ced44d25b03308e56732b4bbbd9b1fe8f930 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 9 Aug 2017 08:58:32 -0500 Subject: [PATCH 26/33] Merge all implmentatoin functions into a single class --- lib/graphql/subscriptions.rb | 70 ++++---- lib/graphql/subscriptions/event.rb | 2 +- lib/graphql/subscriptions/implementation.rb | 73 +++++++++ lib/graphql/subscriptions/inline_queue.rb | 33 ---- lib/graphql/subscriptions/instrumentation.rb | 8 +- lib/graphql/subscriptions/memory_store.rb | 15 -- lib/graphql/subscriptions/schema_execute.rb | 40 ----- lib/graphql/subscriptions/subscriber.rb | 57 ------- spec/graphql/subscriptions_spec.rb | 158 ++++++++----------- 9 files changed, 175 insertions(+), 281 deletions(-) create mode 100644 lib/graphql/subscriptions/implementation.rb delete mode 100644 lib/graphql/subscriptions/inline_queue.rb delete mode 100644 lib/graphql/subscriptions/memory_store.rb delete mode 100644 lib/graphql/subscriptions/schema_execute.rb delete mode 100644 lib/graphql/subscriptions/subscriber.rb diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 599501f895d..a1e64019743 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -1,48 +1,44 @@ # frozen_string_literal: true require "graphql/subscriptions/event" -require "graphql/subscriptions/inline_queue" +require "graphql/subscriptions/implementation" require "graphql/subscriptions/instrumentation" -require "graphql/subscriptions/schema_execute" -require "graphql/subscriptions/subscriber" + module GraphQL - # A plugin for attaching subscription behavior to the schema - # @example - # MySchema = GraphQL::Schema.define do - # use GraphQL::Subscriptions, - # store: MyDatabaseStorage.new, - # transports: { - # "apns" => ApnsTransport.new, - # "websocket" => WebsocketTransport.new, - # } - # end - module Subscriptions - module_function - # Accept some application objects: - # - `store` for registering subscription state - # - named `transpors` for delivering payload - # - # Apply special behavior to subscription root fields with instrumentation. - # - # Prepare `MySchema.subscriber` for receiving triggers from the application. - # - # @param store [<#register(query, events), #each_subscription(event_key, &block)>] - # @param transports [Hash <#deliver(channel, result, ctx)>] - # @param queue [<#enqueue(...)>] - def use(defn, store:, transports:, queue: InlineQueue) + class Subscriptions + attr_reader :implementation + + def self.use(defn, options = {}) schema = defn.target - schema.subscriber = Subscriptions::Subscriber.new( - schema: schema, - queue: queue, - store: store, - transports: transports, - ) - instrumentation = Subscriptions::Instrumentation.new( - schema: schema, - subscriber: schema.subscriber, - ) + options[:schema] = schema + options[:implementation] ||= Subscriptions::Implementation + schema.subscriber = self.new(options) + instrumentation = Subscriptions::Instrumentation.new(schema: schema) defn.instrument(:field, instrumentation) defn.instrument(:query, instrumentation) nil end + + def initialize(kwargs) + @schema = kwargs[:schema] + implementation_class = kwargs.delete(:implementation) + @implementation = implementation_class.new(kwargs) + end + + # Fetch subscriptions matching this field + arguments pair + # And pass them off to the queue. + def trigger(event_name, args, object, scope: nil) + field = @schema.get_field("Subscription", event_name) + if !field + raise "No subscription matching trigger: #{event_name}" + end + + event = Subscriptions::Event.new( + name: event_name, + arguments: args, + field: field, + scope: scope, + ) + @implementation.enqueue_all(event, object) + end end end diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index 9b4e999a074..ee66fcf0dca 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true module GraphQL - module Subscriptions + class Subscriptions # This thing can be: # - Subscribed to by `subscription { ... }` # - Triggered by `MySchema.subscriber.trigger(name, arguments, obj)` diff --git a/lib/graphql/subscriptions/implementation.rb b/lib/graphql/subscriptions/implementation.rb new file mode 100644 index 00000000000..0ff6b3d4e2c --- /dev/null +++ b/lib/graphql/subscriptions/implementation.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true +# test_via: ../subscriptions.rb +module GraphQL + class Subscriptions + class Implementation + def initialize(schema:, **rest) + @schema = schema + end + + def execute(channel, event_key, object) + # Lookup the saved data for this subscription + query_data = get_subscription(channel) + # Fetch the required keys from the saved data + query_string = query_data.fetch(:query_string) + variables = query_data.fetch(:variables) + context = query_data.fetch(:context) + operation_name = query_data.fetch(:operation_name) + + # Re-evaluate the saved query + query = GraphQL::Query.new( + @schema, + query_string, + { + context: context, + subscription_key: event_key, + operation_name: operation_name, + variables: variables, + root_value: object, + } + ) + result = query.result + + deliver(channel, result, query.context) + end + + # Event `event` occurred on `object`, + # Update all subscribers. + # @param event [Subscriptions::Event] + # @param object [Object] + def enqueue_all(event, object) + event_key = event.key + each_channel(event_key) do |channel| + enqueue(channel, event_key, object) + end + end + + def enqueue(channel, event_key, object) + execute(channel, event_key, object) + end + + # Get each channel subscribed to `event_key` and yield them + # @param event_key [String] + # @yieldparam channel [String] + # @return [void] + def each_channel(event_key) + raise NotImplementedError + end + + def get_subscription(channel) + raise NotImplementedError + end + + # Deliver the payload to the channel + def deliver(channel, result, context) + raise NotImplementedError + end + + def subscribed(query, events) + raise NotImplementedError + end + end + end +end diff --git a/lib/graphql/subscriptions/inline_queue.rb b/lib/graphql/subscriptions/inline_queue.rb deleted file mode 100644 index 285fbe809b1..00000000000 --- a/lib/graphql/subscriptions/inline_queue.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true -# test_via: ../subscriptions.rb -module GraphQL - module Subscriptions - # Run the query right away and push it over transport right away. - # This is the default if you don't provide a queue. - # @api private - class InlineQueue - def initialize(schema:, store:) - @schema = schema - @store = store - end - - # @param schema [GraphQL::Schema] - # @param channel [String] - # @param event_key [String] - # @param object [Object] - # @return [void] - def enqueue(channel, event_key, object) - @schema.subscriber.process(channel, event_key, object) - end - - # @param event [GraphQL::Subscriptions::Event] - # @return [void] - def enqueue_all(event, object) - event_key = event.key - @store.each_channel(event_key) do |channel| - enqueue(channel, event_key, object) - end - end - end - end -end diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index 9527b93eaa8..86b1af1db13 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true +# test_via: ../subscriptions.rb module GraphQL - module Subscriptions + class Subscriptions # Wrap the root fields of the subscription type with special logic for: # - Registering the subscription during the first execution # - Evaluating the triggered portion(s) of the subscription during later execution class Instrumentation - def initialize(schema:, subscriber:) - @subscriber = subscriber + def initialize(schema:) @schema = schema end @@ -31,7 +31,7 @@ def before_query(query) def after_query(query) events = query.context[:events] if events && events.any? - @subscriber.set(query, events) + @schema.subscriber.implementation.subscribed(query, events) end end diff --git a/lib/graphql/subscriptions/memory_store.rb b/lib/graphql/subscriptions/memory_store.rb deleted file mode 100644 index 143d2b147a4..00000000000 --- a/lib/graphql/subscriptions/memory_store.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Subscriptions - # This "in-memory" database - # will only work for a single server, - # like your development environment. - # - # In case of a crash, restart or redeploy, - # it loses all state. - # @api private - class MemoryStore - - end - end -end diff --git a/lib/graphql/subscriptions/schema_execute.rb b/lib/graphql/subscriptions/schema_execute.rb deleted file mode 100644 index 100f72664e5..00000000000 --- a/lib/graphql/subscriptions/schema_execute.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Subscriptions - module SchemaExecute - # @param subscriber [GraphQL::Subscriptions::Subscriber] - # @param channel [String] - # @param object [Object] - # @return [void] - def self.call(subscriber, channel, event_key, object) - # Lookup the saved data for this subscription - query_data = subscriber.get(channel) - # Fetch the required keys from the saved data - query_string = query_data.fetch(:query_string) - variables = query_data.fetch(:variables) - context = query_data.fetch(:context) - operation_name = query_data.fetch(:operation_name) - - # Re-evaluate the saved query - query = GraphQL::Query.new( - subscriber.schema, - query_string, - { - context: context, - subscription_key: event_key, - operation_name: operation_name, - variables: variables, - root_value: object, - } - ) - result = query.result - - # Find the transport for this subscription - transport_key = query_data.fetch(:transport) - transport = subscriber.transports.fetch(transport_key) - # Deliver the payload over the transport - transport.deliver(channel, result, query.context) - end - end - end -end diff --git a/lib/graphql/subscriptions/subscriber.rb b/lib/graphql/subscriptions/subscriber.rb deleted file mode 100644 index 914c71d7894..00000000000 --- a/lib/graphql/subscriptions/subscriber.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true -# test_via: ../subscriptions.rb -module GraphQL - module Subscriptions - # Hang along with the schema and: - # - # - Coordinate access to the application-provided store - # - Receive `trigger`s from the application - # - Respond to them by: - # - loading data from the store - # - evaluating the subscription - # - sending the result over the specified application-provided transport - # - # TODO: - # - add generator for installing - # - better api than `schema.subscriber` - class Subscriber - extend GraphQL::Delegate - - attr_reader :store, :queue, :transports, :schema - def initialize(schema:, store:, queue: InlineQueue, execute: SchemaExecute, transports:) - @schema = schema - @store = store - @queue = queue.new(schema: schema, store: store) - @transports = transports - @execute = execute - end - - def_delegators :@store, :set, :get, :delete, :each_channel - - # Fetch subscriptions matching this field + arguments pair - # And pass them off to the queue. - def trigger(event_name, args, object, scope: nil) - field = @schema.get_field("Subscription", event_name) - if !field - raise "No subscription matching trigger: #{event_name}" - end - - event = Subscriptions::Event.new( - name: event_name, - arguments: args, - field: field, - scope: scope, - ) - @queue.enqueue_all(event, object) - end - - # TODO rename this. - # It runs the query and delivers it. - # It's probably called in a background job, - # but the default is inline. - def process(channel, event_key, object) - @execute.call(self, channel, event_key, object) - end - end - end -end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 7bcea1f0bae..2daa6be0fce 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -2,15 +2,19 @@ require "spec_helper" class InMemoryBackend - # Store API - module Database - module_function - def clear + class Subscriptions < GraphQL::Subscriptions::Implementation + attr_reader :deliveries, :pushes, :extra + + def initialize(schema:, extra:) + super + @extra = extra @queries = {} @subscriptions = Hash.new { |h, k| h[k] = [] } + @deliveries = Hash.new { |h, k| h[k] = [] } + @pushes = [] end - def set(query, events) + def subscribed(query, events) @queries[query.context[:socket]] = query events.each do |ev| # The `context` is functioning as subscription data. @@ -19,7 +23,13 @@ def set(query, events) end end - def get(channel) + def each_channel(key) + @subscriptions[key].each do |ctx| + yield(ctx[:socket]) + end + end + + def get_subscription(channel) query = @queries[channel] { query_string: query.query_string, @@ -30,13 +40,7 @@ def get(channel) } end - def each_channel(key) - @subscriptions[key].each do |ctx| - yield(ctx[:socket]) - end - end - - def delete(channel) + def delete_subscription(channel) query = @queries.delete(channel) if query @subscriptions.each do |key, contexts| @@ -45,67 +49,38 @@ def delete(channel) end end - # Just for testing: - def size - @subscriptions.size - end - - def subscriptions - @subscriptions - end - end - - class Socket - # Transport API: - def self.deliver(channel, result, ctx) - deliveries(channel) << result - end - - def self.open(id) - @sockets[id] + def deliver(channel, result, ctx) + @deliveries[channel] << result end - def self.clear - @sockets = Hash.new { |h, k| h[k] = self.new } - end - - attr_reader :deliveries - - def initialize - @deliveries = [] + def enqueue(channel, event_key, object) + @pushes << channel + execute(channel, event_key, object) end - def self.deliveries(id) - @sockets[id].deliveries - end - end - - class Queue - attr_reader :pushes - - def initialize(schema:, store:) - @schema = schema - @store = store - @pushes = [] + def enqueue_all(event, object) + event_key = event.key + each_channel(event_key) do |channel| + enqueue(channel, event_key, object) + end end - def clear - pushes.clear + # Just for testing: + def reset + @queries.clear + @subscriptions.clear + @deliveries.clear + @pushes.clear end - def enqueue(channel, event_key, object) - pushes << channel - @schema.subscriber.process(channel, event_key, object) + def size + @subscriptions.size end - def enqueue_all(event, object) - event_key = event.key - @store.each_channel(event_key) do |channel| - enqueue(channel, event_key, object) - end + def subscriptions + @subscriptions end end - # Just a random stateful object for tracking what happens: class Payload attr_reader :str @@ -151,23 +126,17 @@ def int Schema = GraphQL::Schema.from_definition(SchemaDefinition).redefine do use GraphQL::Subscriptions, - store: InMemoryBackend::Database, - queue: InMemoryBackend::Queue, - transports: { - socket: InMemoryBackend::Socket - } + implementation: Subscriptions, + extra: 123 end # TODO don't hack this (no way to add metadata from IDL parser right now) Schema.get_field("Subscription", "myEvent").subscription_scope = :me end - describe GraphQL::Subscriptions do before do - socket.clear - queue.clear - database.clear + schema.subscriber.implementation.reset end let(:root_object) { @@ -176,11 +145,9 @@ def int ) } - let(:database) { InMemoryBackend::Database } - let(:socket) { InMemoryBackend::Socket } - let(:queue) { schema.subscriber.queue } let(:schema) { InMemoryBackend::Schema } - + let(:implementation) { schema.subscriber.implementation } + let(:deliveries) { implementation.deliveries } describe "pushing updates" do it "sends updated data" do query_str = <<-GRAPHQL @@ -197,10 +164,8 @@ def int # Initial response is nil, no broadcasts yet assert_equal(nil, res_1["data"]) assert_equal(nil, res_2["data"]) - socket_1 = socket.open("1") - socket_2 = socket.open("2") - assert_equal [], socket_1.deliveries - assert_equal [], socket_2.deliveries + assert_equal [], deliveries["1"] + assert_equal [], deliveries["2"] # Application stuff happens. # The application signals graphql via `subscriber.trigger`: @@ -210,9 +175,9 @@ def int schema.subscriber.trigger("payload", {"id" => "300"}, nil) # Let's see what GraphQL sent over the wire: - assert_equal({"str" => "Update", "int" => 1}, socket_1.deliveries[0]["data"]["firstPayload"]) - assert_equal({"str" => "Update", "int" => 2}, socket_2.deliveries[0]["data"]["firstPayload"]) - assert_equal({"str" => "Update", "int" => 3}, socket_1.deliveries[1]["data"]["firstPayload"]) + assert_equal({"str" => "Update", "int" => 1}, deliveries["1"][0]["data"]["firstPayload"]) + assert_equal({"str" => "Update", "int" => 2}, deliveries["2"][0]["data"]["firstPayload"]) + assert_equal({"str" => "Update", "int" => 3}, deliveries["1"][1]["data"]["firstPayload"]) end end @@ -226,7 +191,7 @@ def int res = schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "100" }, root_value: root_object) assert_equal true, res.key?("errors") - assert_equal 0, database.size + assert_equal 0, implementation.size end end @@ -240,7 +205,7 @@ def int schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "8" }, root_value: root_object) schema.subscriber.trigger("payload", { "id" => "8"}, root_object.payload) - assert_equal ["1"], queue.pushes + assert_equal ["1"], implementation.pushes end it "pushes errors" do @@ -252,8 +217,7 @@ def int schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "8" }, root_value: root_object) schema.subscriber.trigger("payload", { "id" => "8"}, OpenStruct.new(str: nil, int: nil)) - socket_1 = socket.open("1") - delivery = socket_1.deliveries.first + delivery = deliveries["1"].first assert_equal nil, delivery.fetch("data") assert_equal 1, delivery["errors"].length end @@ -284,16 +248,16 @@ def int # Trigger with null updates subscribers to null schema.subscriber.trigger("event", { "stream" => {"userId" => 3, "type" => nil} }, OpenStruct.new(str: "", int: 5)) - assert_equal [1,2,4], socket.deliveries("1").map { |d| d["data"]["e1"]["int"] } + assert_equal [1,2,4], deliveries["1"].map { |d| d["data"]["e1"]["int"] } # Same as socket_1 - assert_equal [1,2,4], socket.deliveries("2").map { |d| d["data"]["e1"]["int"] } + assert_equal [1,2,4], deliveries["2"].map { |d| d["data"]["e1"]["int"] } # Received the "non-trigger" - assert_equal [3], socket.deliveries("3").map { |d| d["data"]["e1"]["int"] } + assert_equal [3], deliveries["3"].map { |d| d["data"]["e1"]["int"] } # Received the trigger with null - assert_equal [5], socket.deliveries("4").map { |d| d["data"]["e1"]["int"] } + assert_equal [5], deliveries["4"].map { |d| d["data"]["e1"]["int"] } end it "allows context-scoped subscriptions" do @@ -314,10 +278,10 @@ def int schema.subscriber.trigger("myEvent", { "type" => "ONE" }, OpenStruct.new(str: "", int: 3), scope: "2") # Delivered to user 1 - assert_equal [1], socket.deliveries("1").map { |d| d["data"]["myEvent"]["int"] } - assert_equal [2], socket.deliveries("2").map { |d| d["data"]["myEvent"]["int"] } + assert_equal [1], deliveries["1"].map { |d| d["data"]["myEvent"]["int"] } + assert_equal [2], deliveries["2"].map { |d| d["data"]["myEvent"]["int"] } # Delivered to user 2 - assert_equal [3], socket.deliveries("3").map { |d| d["data"]["myEvent"]["int"] } + assert_equal [3], deliveries["3"].map { |d| d["data"]["myEvent"]["int"] } end describe "errors" do @@ -355,8 +319,14 @@ def str schema.execute(query_str, context: { socket: "1", me: "1" }, variables: { "type" => "ONE" }, root_value: root_object) schema.subscriber.trigger("myEvent", { "type" => "ONE" }, ErrorPayload.new, scope: "1") - res = socket.deliveries("1").first + res = deliveries["1"].first assert_equal "This is handled", res["errors"][0]["message"] end end + + describe "implementation" do + it "is initialized with keywords" do + assert_equal 123, schema.subscriber.implementation.extra + end + end end From 8895d8d59e9ea11b8f6e936b91ac0951054c87f5 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 11 Aug 2017 15:54:30 -0400 Subject: [PATCH 27/33] Add docs to implementation --- lib/graphql/subscriptions.rb | 4 +- lib/graphql/subscriptions/implementation.rb | 61 +++++++++++++------ lib/graphql/subscriptions/instrumentation.rb | 2 +- lib/graphql/subscriptions/memory_storage.rb | 31 ++++++++++ .../subscriptions/memory_storage_spec.rb | 10 +++ spec/graphql/subscriptions_spec.rb | 19 ++---- 6 files changed, 92 insertions(+), 35 deletions(-) create mode 100644 lib/graphql/subscriptions/memory_storage.rb create mode 100644 spec/graphql/subscriptions/memory_storage_spec.rb diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index a1e64019743..84cb3f5f97a 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -2,9 +2,11 @@ require "graphql/subscriptions/event" require "graphql/subscriptions/implementation" require "graphql/subscriptions/instrumentation" +require "graphql/subscriptions/memory_storage" module GraphQL class Subscriptions + # @return [GraphQL::Subscriptions::Implementation] A long-lived instance of the user-provided implementation class attr_reader :implementation def self.use(defn, options = {}) @@ -38,7 +40,7 @@ def trigger(event_name, args, object, scope: nil) field: field, scope: scope, ) - @implementation.enqueue_all(event, object) + @implementation.execute_all(event, object) end end end diff --git a/lib/graphql/subscriptions/implementation.rb b/lib/graphql/subscriptions/implementation.rb index 0ff6b3d4e2c..f1c26b7cbf0 100644 --- a/lib/graphql/subscriptions/implementation.rb +++ b/lib/graphql/subscriptions/implementation.rb @@ -7,9 +7,20 @@ def initialize(schema:, **rest) @schema = schema end - def execute(channel, event_key, object) + # `event` was triggered on `object`, and `subscription_id` was subscribed, + # so it should be updated. + # + # Load `subscription_id`'s GraphQL data, re-evaluate the query, and deliver the result. + # + # This is where a queue may be inserted to push updates in the background. + # + # @param subscription_id [String] + # @param event [GraphQL::Subscriptions::Event] The event which was triggered + # @param object [Object] The value for the subscription field + # @return [void] + def execute(subscription_id, event, object) # Lookup the saved data for this subscription - query_data = get_subscription(channel) + query_data = read_subscription(subscription_id) # Fetch the required keys from the saved data query_string = query_data.fetch(:query_string) variables = query_data.fetch(:variables) @@ -22,7 +33,7 @@ def execute(channel, event_key, object) query_string, { context: context, - subscription_key: event_key, + subscription_key: event.key, operation_name: operation_name, variables: variables, root_value: object, @@ -30,42 +41,52 @@ def execute(channel, event_key, object) ) result = query.result - deliver(channel, result, query.context) + deliver(subscription_id, result, query.context) end # Event `event` occurred on `object`, # Update all subscribers. # @param event [Subscriptions::Event] # @param object [Object] - def enqueue_all(event, object) - event_key = event.key - each_channel(event_key) do |channel| - enqueue(channel, event_key, object) + # @return [void] + def execute_all(event, object) + each_subscription_id(event) do |subscription_id| + execute(subscription_id, event, object) end end - def enqueue(channel, event_key, object) - execute(channel, event_key, object) - end - - # Get each channel subscribed to `event_key` and yield them - # @param event_key [String] - # @yieldparam channel [String] + # Get each `subscription_id` subscribed to `event_key` and yield them + # @param event [GraphQL::Subscriptions::Event] + # @yieldparam subscription_id [String] # @return [void] - def each_channel(event_key) + def each_subscription_id(event) raise NotImplementedError end - def get_subscription(channel) + # The system wants to send an update to this subscription. + # Read its data and return it. + # @param subscription_id [String] + # @return [Hash] Containing required keys + def read_subscription(subscription_id) raise NotImplementedError end - # Deliver the payload to the channel - def deliver(channel, result, context) + # A subscription query was re-evaluated, returning `result`. + # The result should be send to `subscription_id`. + # @param subscription_id [String] + # @param result [Hash] + # @param context [GraphQL::Query::Context] + # @return [void] + def deliver(subscription_id, result, context) raise NotImplementedError end - def subscribed(query, events) + # `query` was executed and found subscriptions to `events`. + # Update the database to reflect this new state. + # @param query [GraphQL::Query] + # @param events [Array] + # @return [void] + def write_subscription(query, events) raise NotImplementedError end end diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index 86b1af1db13..da43d5448b0 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -31,7 +31,7 @@ def before_query(query) def after_query(query) events = query.context[:events] if events && events.any? - @schema.subscriber.implementation.subscribed(query, events) + @schema.subscriber.implementation.write_subscription(query, events) end end diff --git a/lib/graphql/subscriptions/memory_storage.rb b/lib/graphql/subscriptions/memory_storage.rb new file mode 100644 index 00000000000..3899533650a --- /dev/null +++ b/lib/graphql/subscriptions/memory_storage.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +require "securerandom" + +module GraphQL + class Subscriptions + class MemoryStorage + def initialize(*args) + super + # TODO thread-safety + @event_subscriber_ids = Hash.new { |h,k| h[k] = [] } + @subscribers_by_id = {} + end + + def each_subscription_id(event) + @event_subscriber_ids.each { |sub_id| yield(sub_id) } + end + + def read_subscription(subscription_id) + @subscribers_by_id[subscription_id] + end + + def write_subscription(query, events) + subscription_id = query.context[:subscription_id] ||= SecureRandom.uuid + @subscribers_by_id[subscription_id] = query + events.each do |event| + @event_subscriber_ids[event.key] << subscription_ids + end + end + end + end +end diff --git a/spec/graphql/subscriptions/memory_storage_spec.rb b/spec/graphql/subscriptions/memory_storage_spec.rb new file mode 100644 index 00000000000..d671146a27f --- /dev/null +++ b/spec/graphql/subscriptions/memory_storage_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +describe GraphQL::Subscriptions::MemoryStorage do + describe "state management" do + focus + it "manages subscriptions" do + skip + end + end +end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 2daa6be0fce..514b5c51788 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -14,7 +14,7 @@ def initialize(schema:, extra:) @pushes = [] end - def subscribed(query, events) + def write_subscription(query, events) @queries[query.context[:socket]] = query events.each do |ev| # The `context` is functioning as subscription data. @@ -23,13 +23,13 @@ def subscribed(query, events) end end - def each_channel(key) - @subscriptions[key].each do |ctx| + def each_subscription_id(event) + @subscriptions[event.key].each do |ctx| yield(ctx[:socket]) end end - def get_subscription(channel) + def read_subscription(channel) query = @queries[channel] { query_string: query.query_string, @@ -53,16 +53,9 @@ def deliver(channel, result, ctx) @deliveries[channel] << result end - def enqueue(channel, event_key, object) + def execute(channel, event, object) @pushes << channel - execute(channel, event_key, object) - end - - def enqueue_all(event, object) - event_key = event.key - each_channel(event_key) do |channel| - enqueue(channel, event_key, object) - end + super end # Just for testing: From b6ca9e9945fc5730f18a408e9d5ff991006db003 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 15 Aug 2017 08:42:28 -0400 Subject: [PATCH 28/33] Merge implementation into subscriptions class; rename Schema#subscriber -> Schema#subscriptions --- Guardfile | 25 +++-- lib/graphql/schema.rb | 5 +- lib/graphql/subscriptions.rb | 104 ++++++++++++++++-- .../action_cable_subscriptions.rb | 0 lib/graphql/subscriptions/implementation.rb | 94 ---------------- lib/graphql/subscriptions/instrumentation.rb | 2 +- lib/graphql/subscriptions/memory_storage.rb | 31 ------ .../subscriptions/memory_storage_spec.rb | 10 -- spec/graphql/subscriptions_spec.rb | 51 +++++---- 9 files changed, 137 insertions(+), 185 deletions(-) create mode 100644 lib/graphql/subscriptions/action_cable_subscriptions.rb delete mode 100644 lib/graphql/subscriptions/implementation.rb delete mode 100644 lib/graphql/subscriptions/memory_storage.rb delete mode 100644 spec/graphql/subscriptions/memory_storage_spec.rb diff --git a/Guardfile b/Guardfile index 0145708f5cb..52493fa8fa0 100644 --- a/Guardfile +++ b/Guardfile @@ -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 diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index a55351e9680..25576057a8f 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -81,8 +81,9 @@ class Schema :cursor_encoder, :raise_definition_error - # Singleton instance of the provided subscriber class, if there is one. - attr_accessor :subscriber + # Single, long-lived instance of the provided subscriptions class, if there is one. + # @return [GraphQL::Subscriptions] + attr_accessor :subscriptions # @return [MiddlewareChain] MiddlewareChain which is applied to fields during execution attr_accessor :middleware diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 84cb3f5f97a..9bac6fbe942 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -1,19 +1,13 @@ # frozen_string_literal: true require "graphql/subscriptions/event" -require "graphql/subscriptions/implementation" require "graphql/subscriptions/instrumentation" -require "graphql/subscriptions/memory_storage" module GraphQL class Subscriptions - # @return [GraphQL::Subscriptions::Implementation] A long-lived instance of the user-provided implementation class - attr_reader :implementation - def self.use(defn, options = {}) schema = defn.target options[:schema] = schema - options[:implementation] ||= Subscriptions::Implementation - schema.subscriber = self.new(options) + schema.subscriptions = self.new(options) instrumentation = Subscriptions::Instrumentation.new(schema: schema) defn.instrument(:field, instrumentation) defn.instrument(:query, instrumentation) @@ -22,12 +16,15 @@ def self.use(defn, options = {}) def initialize(kwargs) @schema = kwargs[:schema] - implementation_class = kwargs.delete(:implementation) - @implementation = implementation_class.new(kwargs) end # Fetch subscriptions matching this field + arguments pair # And pass them off to the queue. + # @param event_name [String] + # @param args [Hash] + # @param object [Object] + # @param scope [Symbol, String] + # @return [void] def trigger(event_name, args, object, scope: nil) field = @schema.get_field("Subscription", event_name) if !field @@ -40,7 +37,94 @@ def trigger(event_name, args, object, scope: nil) field: field, scope: scope, ) - @implementation.execute_all(event, object) + execute_all(event, object) + end + + def initialize(schema:, **rest) + @schema = schema + end + + # `event` was triggered on `object`, and `subscription_id` was subscribed, + # so it should be updated. + # + # Load `subscription_id`'s GraphQL data, re-evaluate the query, and deliver the result. + # + # This is where a queue may be inserted to push updates in the background. + # + # @param subscription_id [String] + # @param event [GraphQL::Subscriptions::Event] The event which was triggered + # @param object [Object] The value for the subscription field + # @return [void] + def execute(subscription_id, event, object) + # Lookup the saved data for this subscription + query_data = read_subscription(subscription_id) + # Fetch the required keys from the saved data + query_string = query_data.fetch(:query_string) + variables = query_data.fetch(:variables) + context = query_data.fetch(:context) + operation_name = query_data.fetch(:operation_name) + + # Re-evaluate the saved query + query = GraphQL::Query.new( + @schema, + query_string, + { + context: context, + subscription_key: event.key, + operation_name: operation_name, + variables: variables, + root_value: object, + } + ) + result = query.result + + deliver(subscription_id, result, query.context) + end + + # Event `event` occurred on `object`, + # Update all subscribers. + # @param event [Subscriptions::Event] + # @param object [Object] + # @return [void] + def execute_all(event, object) + each_subscription_id(event) do |subscription_id| + execute(subscription_id, event, object) + end + end + + # Get each `subscription_id` subscribed to `event_key` and yield them + # @param event [GraphQL::Subscriptions::Event] + # @yieldparam subscription_id [String] + # @return [void] + def each_subscription_id(event) + raise NotImplementedError + end + + # The system wants to send an update to this subscription. + # Read its data and return it. + # @param subscription_id [String] + # @return [Hash] Containing required keys + def read_subscription(subscription_id) + raise NotImplementedError + end + + # A subscription query was re-evaluated, returning `result`. + # The result should be send to `subscription_id`. + # @param subscription_id [String] + # @param result [Hash] + # @param context [GraphQL::Query::Context] + # @return [void] + def deliver(subscription_id, result, context) + raise NotImplementedError + end + + # `query` was executed and found subscriptions to `events`. + # Update the database to reflect this new state. + # @param query [GraphQL::Query] + # @param events [Array] + # @return [void] + def write_subscription(query, events) + raise NotImplementedError end end end diff --git a/lib/graphql/subscriptions/action_cable_subscriptions.rb b/lib/graphql/subscriptions/action_cable_subscriptions.rb new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/graphql/subscriptions/implementation.rb b/lib/graphql/subscriptions/implementation.rb deleted file mode 100644 index f1c26b7cbf0..00000000000 --- a/lib/graphql/subscriptions/implementation.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true -# test_via: ../subscriptions.rb -module GraphQL - class Subscriptions - class Implementation - def initialize(schema:, **rest) - @schema = schema - end - - # `event` was triggered on `object`, and `subscription_id` was subscribed, - # so it should be updated. - # - # Load `subscription_id`'s GraphQL data, re-evaluate the query, and deliver the result. - # - # This is where a queue may be inserted to push updates in the background. - # - # @param subscription_id [String] - # @param event [GraphQL::Subscriptions::Event] The event which was triggered - # @param object [Object] The value for the subscription field - # @return [void] - def execute(subscription_id, event, object) - # Lookup the saved data for this subscription - query_data = read_subscription(subscription_id) - # Fetch the required keys from the saved data - query_string = query_data.fetch(:query_string) - variables = query_data.fetch(:variables) - context = query_data.fetch(:context) - operation_name = query_data.fetch(:operation_name) - - # Re-evaluate the saved query - query = GraphQL::Query.new( - @schema, - query_string, - { - context: context, - subscription_key: event.key, - operation_name: operation_name, - variables: variables, - root_value: object, - } - ) - result = query.result - - deliver(subscription_id, result, query.context) - end - - # Event `event` occurred on `object`, - # Update all subscribers. - # @param event [Subscriptions::Event] - # @param object [Object] - # @return [void] - def execute_all(event, object) - each_subscription_id(event) do |subscription_id| - execute(subscription_id, event, object) - end - end - - # Get each `subscription_id` subscribed to `event_key` and yield them - # @param event [GraphQL::Subscriptions::Event] - # @yieldparam subscription_id [String] - # @return [void] - def each_subscription_id(event) - raise NotImplementedError - end - - # The system wants to send an update to this subscription. - # Read its data and return it. - # @param subscription_id [String] - # @return [Hash] Containing required keys - def read_subscription(subscription_id) - raise NotImplementedError - end - - # A subscription query was re-evaluated, returning `result`. - # The result should be send to `subscription_id`. - # @param subscription_id [String] - # @param result [Hash] - # @param context [GraphQL::Query::Context] - # @return [void] - def deliver(subscription_id, result, context) - raise NotImplementedError - end - - # `query` was executed and found subscriptions to `events`. - # Update the database to reflect this new state. - # @param query [GraphQL::Query] - # @param events [Array] - # @return [void] - def write_subscription(query, events) - raise NotImplementedError - end - end - end -end diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index da43d5448b0..dc38eb594a1 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -31,7 +31,7 @@ def before_query(query) def after_query(query) events = query.context[:events] if events && events.any? - @schema.subscriber.implementation.write_subscription(query, events) + @schema.subscriptions.write_subscription(query, events) end end diff --git a/lib/graphql/subscriptions/memory_storage.rb b/lib/graphql/subscriptions/memory_storage.rb deleted file mode 100644 index 3899533650a..00000000000 --- a/lib/graphql/subscriptions/memory_storage.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true -require "securerandom" - -module GraphQL - class Subscriptions - class MemoryStorage - def initialize(*args) - super - # TODO thread-safety - @event_subscriber_ids = Hash.new { |h,k| h[k] = [] } - @subscribers_by_id = {} - end - - def each_subscription_id(event) - @event_subscriber_ids.each { |sub_id| yield(sub_id) } - end - - def read_subscription(subscription_id) - @subscribers_by_id[subscription_id] - end - - def write_subscription(query, events) - subscription_id = query.context[:subscription_id] ||= SecureRandom.uuid - @subscribers_by_id[subscription_id] = query - events.each do |event| - @event_subscriber_ids[event.key] << subscription_ids - end - end - end - end -end diff --git a/spec/graphql/subscriptions/memory_storage_spec.rb b/spec/graphql/subscriptions/memory_storage_spec.rb deleted file mode 100644 index d671146a27f..00000000000 --- a/spec/graphql/subscriptions/memory_storage_spec.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -describe GraphQL::Subscriptions::MemoryStorage do - describe "state management" do - focus - it "manages subscriptions" do - skip - end - end -end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 514b5c51788..e9f276a1908 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" class InMemoryBackend - class Subscriptions < GraphQL::Subscriptions::Implementation + class Subscriptions < GraphQL::Subscriptions attr_reader :deliveries, :pushes, :extra def initialize(schema:, extra:) @@ -118,8 +118,7 @@ def int GRAPHQL Schema = GraphQL::Schema.from_definition(SchemaDefinition).redefine do - use GraphQL::Subscriptions, - implementation: Subscriptions, + use InMemoryBackend::Subscriptions, extra: 123 end @@ -129,7 +128,7 @@ def int describe GraphQL::Subscriptions do before do - schema.subscriber.implementation.reset + schema.subscriptions.reset end let(:root_object) { @@ -139,7 +138,7 @@ def int } let(:schema) { InMemoryBackend::Schema } - let(:implementation) { schema.subscriber.implementation } + let(:implementation) { schema.subscriptions } let(:deliveries) { implementation.deliveries } describe "pushing updates" do it "sends updated data" do @@ -161,11 +160,11 @@ def int assert_equal [], deliveries["2"] # Application stuff happens. - # The application signals graphql via `subscriber.trigger`: - schema.subscriber.trigger("payload", {"id" => "100"}, root_object.payload) - schema.subscriber.trigger("payload", {"id" => "200"}, root_object.payload) - schema.subscriber.trigger("payload", {"id" => "100"}, root_object.payload) - schema.subscriber.trigger("payload", {"id" => "300"}, nil) + # The application signals graphql via `subscriptions.trigger`: + schema.subscriptions.trigger("payload", {"id" => "100"}, root_object.payload) + schema.subscriptions.trigger("payload", {"id" => "200"}, root_object.payload) + schema.subscriptions.trigger("payload", {"id" => "100"}, root_object.payload) + schema.subscriptions.trigger("payload", {"id" => "300"}, nil) # Let's see what GraphQL sent over the wire: assert_equal({"str" => "Update", "int" => 1}, deliveries["1"][0]["data"]["firstPayload"]) @@ -175,7 +174,7 @@ def int end describe "subscribing" do - it "doesn't call the subscriber for invalid queries" do + it "doesn't call the subscriptions for invalid queries" do query_str = <<-GRAPHQL subscription ($id: ID){ payload(id: $id) { str, int } @@ -197,7 +196,7 @@ def int GRAPHQL schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "8" }, root_value: root_object) - schema.subscriber.trigger("payload", { "id" => "8"}, root_object.payload) + schema.subscriptions.trigger("payload", { "id" => "8"}, root_object.payload) assert_equal ["1"], implementation.pushes end @@ -209,7 +208,7 @@ def int GRAPHQL schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "8" }, root_value: root_object) - schema.subscriber.trigger("payload", { "id" => "8"}, OpenStruct.new(str: nil, int: nil)) + schema.subscriptions.trigger("payload", { "id" => "8"}, OpenStruct.new(str: nil, int: nil)) delivery = deliveries["1"].first assert_equal nil, delivery.fetch("data") assert_equal 1, delivery["errors"].length @@ -232,14 +231,14 @@ def int schema.execute(query_str, context: { socket: "4" }, variables: { "type" => nil }, root_value: root_object) # Trigger the subscription with coerceable args, different orders: - schema.subscriber.trigger("event", { "stream" => {"userId" => 3, "type" => "ONE"} }, OpenStruct.new(str: "", int: 1)) - schema.subscriber.trigger("event", { "stream" => {"type" => "ONE", "userId" => "3"} }, OpenStruct.new(str: "", int: 2)) + schema.subscriptions.trigger("event", { "stream" => {"userId" => 3, "type" => "ONE"} }, OpenStruct.new(str: "", int: 1)) + schema.subscriptions.trigger("event", { "stream" => {"type" => "ONE", "userId" => "3"} }, OpenStruct.new(str: "", int: 2)) # This is a non-trigger - schema.subscriber.trigger("event", { "stream" => {"userId" => "3", "type" => "TWO"} }, OpenStruct.new(str: "", int: 3)) + schema.subscriptions.trigger("event", { "stream" => {"userId" => "3", "type" => "TWO"} }, OpenStruct.new(str: "", int: 3)) # These get default value of ONE - schema.subscriber.trigger("event", { "stream" => {"userId" => "3"} }, OpenStruct.new(str: "", int: 4)) - # Trigger with null updates subscribers to null - schema.subscriber.trigger("event", { "stream" => {"userId" => 3, "type" => nil} }, OpenStruct.new(str: "", int: 5)) + schema.subscriptions.trigger("event", { "stream" => {"userId" => "3"} }, OpenStruct.new(str: "", int: 4)) + # Trigger with null updates subscriptionss to null + schema.subscriptions.trigger("event", { "stream" => {"userId" => 3, "type" => nil} }, OpenStruct.new(str: "", int: 5)) assert_equal [1,2,4], deliveries["1"].map { |d| d["data"]["e1"]["int"] } @@ -266,9 +265,9 @@ def int # Subscription for user 2 schema.execute(query_str, context: { socket: "3", me: "2" }, variables: { "type" => "ONE" }, root_value: root_object) - schema.subscriber.trigger("myEvent", { "type" => "ONE" }, OpenStruct.new(str: "", int: 1), scope: "1") - schema.subscriber.trigger("myEvent", { "type" => "TWO" }, OpenStruct.new(str: "", int: 2), scope: "1") - schema.subscriber.trigger("myEvent", { "type" => "ONE" }, OpenStruct.new(str: "", int: 3), scope: "2") + schema.subscriptions.trigger("myEvent", { "type" => "ONE" }, OpenStruct.new(str: "", int: 1), scope: "1") + schema.subscriptions.trigger("myEvent", { "type" => "TWO" }, OpenStruct.new(str: "", int: 2), scope: "1") + schema.subscriptions.trigger("myEvent", { "type" => "ONE" }, OpenStruct.new(str: "", int: 3), scope: "2") # Delivered to user 1 assert_equal [1], deliveries["1"].map { |d| d["data"]["myEvent"]["int"] } @@ -297,13 +296,13 @@ def str schema.execute(query_str, context: { socket: "1", me: "1" }, variables: { "type" => "ONE" }, root_value: root_object) err = assert_raises(RuntimeError) { - schema.subscriber.trigger("myEvent", { "type" => "ONE" }, ErrorPayload.new, scope: "1") + schema.subscriptions.trigger("myEvent", { "type" => "ONE" }, ErrorPayload.new, scope: "1") } assert_equal "Boom!", err.message end end - it "sends query errors to the subscriber" do + it "sends query errors to the subscriptions" do query_str = <<-GRAPHQL subscription($type: PayloadType) { myEvent(type: $type) { str } @@ -311,7 +310,7 @@ def str GRAPHQL schema.execute(query_str, context: { socket: "1", me: "1" }, variables: { "type" => "ONE" }, root_value: root_object) - schema.subscriber.trigger("myEvent", { "type" => "ONE" }, ErrorPayload.new, scope: "1") + schema.subscriptions.trigger("myEvent", { "type" => "ONE" }, ErrorPayload.new, scope: "1") res = deliveries["1"].first assert_equal "This is handled", res["errors"][0]["message"] end @@ -319,7 +318,7 @@ def str describe "implementation" do it "is initialized with keywords" do - assert_equal 123, schema.subscriber.implementation.extra + assert_equal 123, schema.subscriptions.extra end end end From faeea889aa9188d8e3706d84e902f5a14bfbea96 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 16 Aug 2017 15:00:00 -0400 Subject: [PATCH 29/33] feat(Subscriptions::ActionCableSubscriptions) Add a basic action cable implementation --- lib/graphql/subscriptions.rb | 6 +- .../action_cable_subscriptions.rb | 127 ++++++++++++++++++ lib/graphql/subscriptions/instrumentation.rb | 8 +- 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 9bac6fbe942..848adcd8391 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true require "graphql/subscriptions/event" require "graphql/subscriptions/instrumentation" +if defined?(ActionCable) + require "graphql/subscriptions/action_cable_subscriptions" +end module GraphQL class Subscriptions @@ -63,7 +66,7 @@ def execute(subscription_id, event, object) variables = query_data.fetch(:variables) context = query_data.fetch(:context) operation_name = query_data.fetch(:operation_name) - + p "Running query #{operation_name.inspect} on #{object.inspect}" # Re-evaluate the saved query query = GraphQL::Query.new( @schema, @@ -77,7 +80,6 @@ def execute(subscription_id, event, object) } ) result = query.result - deliver(subscription_id, result, query.context) end diff --git a/lib/graphql/subscriptions/action_cable_subscriptions.rb b/lib/graphql/subscriptions/action_cable_subscriptions.rb index e69de29bb2d..112a962ccae 100644 --- a/lib/graphql/subscriptions/action_cable_subscriptions.rb +++ b/lib/graphql/subscriptions/action_cable_subscriptions.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true +require "securerandom" + +module GraphQL + class Subscriptions + # A subscriptions implementation that sends data + # as ActionCable broadcastings. + # + # Experimental, some things to keep in mind: + # + # - No queueing system; ActiveJob should be added + # - Take care to reload context when re-delivering the subscription. (see {Query#subscription_update?}) + # + # @example Adding ActionCableSubscriptions to your schema + # MySchema = GraphQL::Schema.define do + # # ... + # use GraphQL::Subscriptions::ActionCableSubscriptions + # end + # + # @example Implementing a channel for GraphQL Subscriptions + # class GraphqlChannel < ApplicationCable::Channel + # def subscribed + # @subscription_ids = [] + # end + # + # def execute(data) + # query = data["query"] + # variables = ensure_hash(data["variables"]) + # operation_name = data["operationName"] + # context = { + # current_user: current_user, + # # Make sure the channel is in the context + # channel: self, + # } + # + # exec_options = { + # query: query, + # context: context, + # variables: variables, + # operation_name: operation_name + # } + # + # # TODO: Add a `Result` class to make this not janky. + # query = GraphQL::Query.new(MySchema, exec_options) + # MySchema.execute(exec_options) + # + # payload = { + # result: query.subscription? ? nil : result, + # more: query.subscription?, + # } + # + # # Track the subscription here so we can remove it + # # on unsubscribe. + # if context[:subscription_id] + # @subscription_ids << context[:subscription_id] + # end + # + # transmit(payload) + # end + # + # def unsubscribed + # @subscription_ids.each { |sid| + # CardsSchema.subscriptions.delete_subscription(sid) + # } + # end + # end + # + class ActionCableSubscriptions < GraphQL::Subscriptions + SUBSCRIPTION_PREFIX = "graphql-subscription:" + EVENT_PREFIX = "graphql-event:" + def initialize(**rest) + # A per-process map of subscriptions to deliver. + # This is provided by Rails, so let's use it + @subscriptions = Concurrent::Map.new + super + end + + # An event was triggered; Push the data over ActionCable. + # Subscribers will re-evaluate locally. + # TODO: this method name is a smell + def execute_all(event, object) + ActionCable.server.broadcast(EVENT_PREFIX + event.key, object) + end + + # This subscription was re-evaluated. + # Send it to the specific stream where this client was waiting. + def deliver(subscription_id, result, context) + payload = { result: result, more: true } + ActionCable.server.broadcast(SUBSCRIPTION_PREFIX + subscription_id, payload) + end + + # A query was run where these events were subscribed to. + # Store them in memory in _this_ ActionCable frontend. + # It will receive notifications when events come in + # and re-evaluate the query locally. + def write_subscription(query, events) + channel = query.context[:channel] + subscription_id = query.context[:subscription_id] ||= SecureRandom.uuid + channel.stream_from(SUBSCRIPTION_PREFIX + subscription_id) + @subscriptions[subscription_id] = query + events.each do |event| + event_key = event.key + channel.stream_from(EVENT_PREFIX + event_key, coder: ActiveSupport::JSON) do |message| + execute(subscription_id, event, message) + nil + end + end + end + + # Return the query from "storage" (in memory) + def read_subscription(subscription_id) + query = @subscriptions[subscription_id] + { + query_string: query.query_string, + variables: query.provided_variables, + context: query.context.to_h, + operation_name: query.operation_name, + } + end + + # The channel was closed, forget about it. + def delete_subscription(subscription_id) + @subscriptions.delete(subscription_id) + end + end + end +end diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index dc38eb594a1..94deba9b893 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -23,13 +23,13 @@ def instrument(type, field) # If needed, prepare to gather events which this query subscribes to def before_query(query) if query.subscription? && !query.subscription_update? - query.context[:events] = [] + query.context.namespace(:subscriptions)[:events] = [] end end # After checking the root fields, pass the gathered events to the store def after_query(query) - events = query.context[:events] + events = query.context.namespace(:subscriptions)[:events] if events && events.any? @schema.subscriptions.write_subscription(query, events) end @@ -44,7 +44,7 @@ def initialize(inner_proc) # Wrap the proc with subscription registration logic def call(obj, args, ctx) - events = ctx[:events] + events = ctx.namespace(:subscriptions)[:events] if events # This is the first execution, so gather an Event # for the backend to register: @@ -56,7 +56,7 @@ def call(obj, args, ctx) ctx.skip elsif ctx.irep_node.subscription_key == ctx.query.subscription_key # The root object is _already_ the subscription update: - obj + @inner_proc.call(obj, args, ctx) else # This is a subscription update, but this event wasn't triggered. ctx.skip From 5af1597d8c7bffb3893031301bdf9fb963097196 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 17 Aug 2017 22:00:11 -0400 Subject: [PATCH 30/33] Update docs for new Subscriptions API --- .../action_cable_implementation.md | 15 ++++++++ guides/subscriptions/implementation.md | 24 +++++++++++++ guides/subscriptions/overview.md | 24 ++++--------- guides/subscriptions/queue.md | 9 ----- guides/subscriptions/store.md | 9 ----- guides/subscriptions/subscription_type.md | 34 +------------------ guides/subscriptions/transport.md | 9 ----- 7 files changed, 47 insertions(+), 77 deletions(-) create mode 100644 guides/subscriptions/action_cable_implementation.md create mode 100644 guides/subscriptions/implementation.md delete mode 100644 guides/subscriptions/queue.md delete mode 100644 guides/subscriptions/store.md delete mode 100644 guides/subscriptions/transport.md diff --git a/guides/subscriptions/action_cable_implementation.md b/guides/subscriptions/action_cable_implementation.md new file mode 100644 index 00000000000..a0d2be49f94 --- /dev/null +++ b/guides/subscriptions/action_cable_implementation.md @@ -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 [nowhere](#) (TODO). diff --git a/guides/subscriptions/implementation.md b/guides/subscriptions/implementation.md new file mode 100644 index 00000000000..751e695c7e5 --- /dev/null +++ b/guides/subscriptions/implementation.md @@ -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? diff --git a/guides/subscriptions/overview.md b/guides/subscriptions/overview.md index 65432c056e1..0c65a353cd8 100644 --- a/guides/subscriptions/overview.md +++ b/guides/subscriptions/overview.md @@ -12,9 +12,7 @@ _Subscriptions_ allow GraphQL clients to observe specific events and receive upd - The __Subscription type__ is the entry point for subscription queries - __Triggers__ begin the update process -- The __Store__ manages subscriber state (_who_ subscribed to _what_) -- The __Queue__ runs subscription queries after events happen (eg, ActiveJob) -- The __Transport__ delivers updates to clients +- The __Implementation__ provides application-specific methods for executing & delivering updates. ### Subscription Type @@ -28,20 +26,12 @@ After an event occurs in our application, _triggers_ begin the update process by Read more in the {% internal_link "Triggers guide","subscriptions/triggers" %}. -### Store +### Implementation -As clients subscribe and unsubscribe, you must keep track of their subscription status. The _Store_ manages this state. +Besides the GraphQL component, your application must provide some subscription-related plumbing, for example: -Read more in the {% internal_link "Store guide","subscriptions/store" %} +- __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? -### Queue - -After a trigger, clients must be updated with new data. The _Queue_ evaluates GraphQL queries and delivers the result to clients. - -Read more in the {% internal_link "Queue guide","subscriptions/transport" %} - -### Transport - -Clients must receive data somehow. A _Transport_ is a way to send data to a client (eg, websocket, native push notification, or webhook). - -Read more in the {% internal_link "Transport guide","subscriptions/transport" %} +Read more in the {% internal_link "Implementation guide", "subscriptions/implementation" %} or check out the {% internal_link "ActionCable implementation", "subscriptions/action_cable_implementation" %}. diff --git a/guides/subscriptions/queue.md b/guides/subscriptions/queue.md deleted file mode 100644 index b7c8631017a..00000000000 --- a/guides/subscriptions/queue.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -layout: guide -search: true -section: Subscriptions -title: Queue -desc: Running queries in response to triggers -index: 4 -experimental: true ---- diff --git a/guides/subscriptions/store.md b/guides/subscriptions/store.md deleted file mode 100644 index 3e3ac69e9d0..00000000000 --- a/guides/subscriptions/store.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -layout: guide -search: true -section: Subscriptions -title: Store -desc: Subscription state management -index: 3 -experimental: true ---- diff --git a/guides/subscriptions/subscription_type.md b/guides/subscriptions/subscription_type.md index 342899d8986..42794effd8f 100644 --- a/guides/subscriptions/subscription_type.md +++ b/guides/subscriptions/subscription_type.md @@ -58,36 +58,4 @@ MySchema = GraphQL::Schema.define do end ``` -And hook up the {{ "GraphQL::Subscriptions" | api_doc }} plugin: - -```ruby -# app/graphql/my_schema.rb -MySchema = GraphQL::Schema.define do - # ... - use GraphQL::Subscriptions, { - # options, see below - } -end -``` - -## Plugin Options - -Plugin options correspond to the parts of the subscription system: - -- `queue:` provides a {% internal_link "Queue implementation", "subscriptions/queue" %} -- `store:` provides a {% internal_link "Store implementation", "subscriptions/store" %} -- `transports:` provides one or more {% internal_link "Transport implementations", "subscriptions/transport" %} - -For example: - -```ruby -use GraphQL::Subscriptions, { - queue: MyApp::Subscriptions::Queue, - store: MyApp::Subscriptions::Store, - transports: { - "action_cable" => MyApp::Subscriptions::ActionCableTransport, - } -} -``` - -`GraphQL::Subscriptions` will use these objects to manage subscription lifecycle. +See {% internal_link "Implementing Subscriptions","subscriptions/implementation" %} for more about actually delivering updates. diff --git a/guides/subscriptions/transport.md b/guides/subscriptions/transport.md deleted file mode 100644 index 5c751885760..00000000000 --- a/guides/subscriptions/transport.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -layout: guide -search: true -section: Subscriptions -title: Transport -desc: Delivering updates to clients -index: 5 -experimental: true ---- From dce218f213a5e9c0046024ace4a826cb55c0d765 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sun, 20 Aug 2017 08:33:45 -0400 Subject: [PATCH 31/33] Update to use Query::Result --- lib/graphql/subscriptions.rb | 9 +++------ .../action_cable_subscriptions.rb | 18 +++++++----------- spec/graphql/subscriptions_spec.rb | 11 +++++++++-- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 848adcd8391..957541818f9 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -66,12 +66,10 @@ def execute(subscription_id, event, object) variables = query_data.fetch(:variables) context = query_data.fetch(:context) operation_name = query_data.fetch(:operation_name) - p "Running query #{operation_name.inspect} on #{object.inspect}" # Re-evaluate the saved query - query = GraphQL::Query.new( - @schema, - query_string, + result = @schema.execute( { + query: query_string, context: context, subscription_key: event.key, operation_name: operation_name, @@ -79,8 +77,7 @@ def execute(subscription_id, event, object) root_value: object, } ) - result = query.result - deliver(subscription_id, result, query.context) + deliver(subscription_id, result) end # Event `event` occurred on `object`, diff --git a/lib/graphql/subscriptions/action_cable_subscriptions.rb b/lib/graphql/subscriptions/action_cable_subscriptions.rb index 112a962ccae..a23d8a117c4 100644 --- a/lib/graphql/subscriptions/action_cable_subscriptions.rb +++ b/lib/graphql/subscriptions/action_cable_subscriptions.rb @@ -33,25 +33,21 @@ class Subscriptions # channel: self, # } # - # exec_options = { + # result = MySchema.execute({ # query: query, # context: context, # variables: variables, # operation_name: operation_name - # } - # - # # TODO: Add a `Result` class to make this not janky. - # query = GraphQL::Query.new(MySchema, exec_options) - # MySchema.execute(exec_options) + # }) # # payload = { - # result: query.subscription? ? nil : result, - # more: query.subscription?, + # result: result.to_h, + # more: result.subscription?, # } # # # Track the subscription here so we can remove it # # on unsubscribe. - # if context[:subscription_id] + # if result.context[:subscription_id] # @subscription_ids << context[:subscription_id] # end # @@ -84,8 +80,8 @@ def execute_all(event, object) # This subscription was re-evaluated. # Send it to the specific stream where this client was waiting. - def deliver(subscription_id, result, context) - payload = { result: result, more: true } + def deliver(subscription_id, result) + payload = { result: result.to_h, more: true } ActionCable.server.broadcast(SUBSCRIPTION_PREFIX + subscription_id, payload) end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index e9f276a1908..50f2aaa424f 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -49,7 +49,7 @@ def delete_subscription(channel) end end - def deliver(channel, result, ctx) + def deliver(channel, result) @deliveries[channel] << result end @@ -117,7 +117,14 @@ def int } GRAPHQL - Schema = GraphQL::Schema.from_definition(SchemaDefinition).redefine do + Resolvers = { + "Subscription" => { + "payload" => ->(o,a,c) { o }, + "myEvent" => ->(o,a,c) { o }, + "event" => ->(o,a,c) { o }, + }, + } + Schema = GraphQL::Schema.from_definition(SchemaDefinition, default_resolve: Resolvers).redefine do use InMemoryBackend::Subscriptions, extra: 123 end From 66ee59b32bedd75558e0f1c3d944e6a04c0f0424 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 24 Aug 2017 08:30:09 +0200 Subject: [PATCH 32/33] Add Query::Result#subscription? --- lib/graphql/query/result.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/graphql/query/result.rb b/lib/graphql/query/result.rb index 083c049f919..d26f96c8e19 100644 --- a/lib/graphql/query/result.rb +++ b/lib/graphql/query/result.rb @@ -19,7 +19,7 @@ def initialize(query:, values:) # @return [Hash] The resulting hash of "data" and/or "errors" attr_reader :to_h - def_delegators :@query, :context, :mutation?, :query? + def_delegators :@query, :context, :mutation?, :query?, :subscription? def_delegators :@to_h, :[], :keys, :values, :to_json, :as_json From 10a7b1a13aa348ebe64be78311aafa1fae3c262f Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 13 Sep 2017 11:00:49 -0400 Subject: [PATCH 33/33] refactor(Event) rename key -> topic --- guides/subscriptions/action_cable_implementation.md | 2 +- lib/graphql/internal_representation/node.rb | 4 ++-- lib/graphql/query.rb | 8 ++++---- lib/graphql/subscriptions.rb | 4 ++-- lib/graphql/subscriptions/action_cable_subscriptions.rb | 5 ++--- lib/graphql/subscriptions/event.rb | 4 ++-- lib/graphql/subscriptions/instrumentation.rb | 2 +- spec/graphql/subscriptions_spec.rb | 4 ++-- 8 files changed, 16 insertions(+), 17 deletions(-) diff --git a/guides/subscriptions/action_cable_implementation.md b/guides/subscriptions/action_cable_implementation.md index a0d2be49f94..e4830843991 100644 --- a/guides/subscriptions/action_cable_implementation.md +++ b/guides/subscriptions/action_cable_implementation.md @@ -12,4 +12,4 @@ experimental: true To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}. -A client is available [nowhere](#) (TODO). +A client is available [in graphql-ruby-client](https://github.com/rmosolgo/graphql-ruby-client). diff --git a/lib/graphql/internal_representation/node.rb b/lib/graphql/internal_representation/node.rb index 92128160e81..889c26d20f4 100644 --- a/lib/graphql/internal_representation/node.rb +++ b/lib/graphql/internal_representation/node.rb @@ -145,8 +145,8 @@ def deep_merge_node(new_parent, scope: nil, merge_self: true) # @return [GraphQL::Query] attr_reader :query - def subscription_key - @subscription_key ||= begin + def subscription_topic + @subscription_topic ||= begin scope = if definition.subscription_scope @query.context[definition.subscription_scope] else diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 06a228925c5..de710760091 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -49,7 +49,7 @@ def selected_operation_name end # @return [String, nil] the triggered event, if this query is a subscription update - attr_reader :subscription_key + attr_reader :subscription_topic # @return [String, nil] attr_reader :operation_name @@ -65,10 +65,10 @@ 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, subscription_key: nil, 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) - @subscription_key = subscription_key + @subscription_topic = subscription_topic @context = Context.new(query: self, values: context) @root_value = root_value @fragments = nil @@ -110,7 +110,7 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n end def subscription_update? - @subscription_key && subscription? + @subscription_topic && subscription? end # @api private diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 957541818f9..a0b5a76bcc2 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -71,7 +71,7 @@ def execute(subscription_id, event, object) { query: query_string, context: context, - subscription_key: event.key, + subscription_topic: event.topic, operation_name: operation_name, variables: variables, root_value: object, @@ -91,7 +91,7 @@ def execute_all(event, object) end end - # Get each `subscription_id` subscribed to `event_key` and yield them + # Get each `subscription_id` subscribed to `event.topic` and yield them # @param event [GraphQL::Subscriptions::Event] # @yieldparam subscription_id [String] # @return [void] diff --git a/lib/graphql/subscriptions/action_cable_subscriptions.rb b/lib/graphql/subscriptions/action_cable_subscriptions.rb index a23d8a117c4..7343121c70d 100644 --- a/lib/graphql/subscriptions/action_cable_subscriptions.rb +++ b/lib/graphql/subscriptions/action_cable_subscriptions.rb @@ -75,7 +75,7 @@ def initialize(**rest) # Subscribers will re-evaluate locally. # TODO: this method name is a smell def execute_all(event, object) - ActionCable.server.broadcast(EVENT_PREFIX + event.key, object) + ActionCable.server.broadcast(EVENT_PREFIX + event.topic, object) end # This subscription was re-evaluated. @@ -95,8 +95,7 @@ def write_subscription(query, events) channel.stream_from(SUBSCRIPTION_PREFIX + subscription_id) @subscriptions[subscription_id] = query events.each do |event| - event_key = event.key - channel.stream_from(EVENT_PREFIX + event_key, coder: ActiveSupport::JSON) do |message| + channel.stream_from(EVENT_PREFIX + event.topic, coder: ActiveSupport::JSON) do |message| execute(subscription_id, event, message) nil end diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index ee66fcf0dca..c51b7220fee 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -17,7 +17,7 @@ class Event attr_reader :context # @return [String] An opaque string which identifies this event, derived from `name` and `arguments` - attr_reader :key + attr_reader :topic def initialize(name:, arguments:, field: nil, context: nil, scope: nil) @name = name @@ -26,7 +26,7 @@ def initialize(name:, arguments:, field: nil, context: nil, scope: nil) field ||= context.field scope_val = scope || (context && field.subscription_scope && context[field.subscription_scope]) - @key = self.class.serialize(name, arguments, field, scope: scope_val) + @topic = self.class.serialize(name, arguments, field, scope: scope_val) end # @return [String] an identifier for this unit of subscription diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb index 94deba9b893..4d0ae85548f 100644 --- a/lib/graphql/subscriptions/instrumentation.rb +++ b/lib/graphql/subscriptions/instrumentation.rb @@ -54,7 +54,7 @@ def call(obj, args, ctx) context: ctx, ) ctx.skip - elsif ctx.irep_node.subscription_key == ctx.query.subscription_key + elsif ctx.irep_node.subscription_topic == ctx.query.subscription_topic # The root object is _already_ the subscription update: @inner_proc.call(obj, args, ctx) else diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 50f2aaa424f..3bf867ad508 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -19,12 +19,12 @@ def write_subscription(query, events) events.each do |ev| # The `context` is functioning as subscription data. # IRL you'd have some other model that persisted the subscription - @subscriptions[ev.key] << ev.context + @subscriptions[ev.topic] << ev.context end end def each_subscription_id(event) - @subscriptions[event.key].each do |ctx| + @subscriptions[event.topic].each do |ctx| yield(ctx[:socket]) end end