From 1e74efe00d14e9baf9e05a2741da5c25e7b11b1a Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 12 Feb 2024 11:39:30 -0500 Subject: [PATCH 1/2] Add TriggerJob --- lib/graphql/subscriptions.rb | 32 +++++++-- lib/graphql/subscriptions/trigger_job.rb | 15 ++++ .../graphql/subscriptions/trigger_job_spec.rb | 72 +++++++++++++++++++ spec/graphql/subscriptions_spec.rb | 22 ++++++ 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 lib/graphql/subscriptions/trigger_job.rb create mode 100644 spec/graphql/subscriptions/trigger_job_spec.rb diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index e23e5d6de1..14ed3773a3 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -22,9 +22,7 @@ class SubscriptionScopeMissingError < GraphQL::Error end # @see {Subscriptions#initialize} for options, concrete implementations may add options. - def self.use(defn, options = {}) - schema = defn.is_a?(Class) ? defn : defn.target - + def self.use(schema, **options) if schema.subscriptions(inherited: false) raise ArgumentError, "Can't reinstall subscriptions. #{schema} is using #{schema.subscriptions}, can't also add #{self}" end @@ -37,18 +35,44 @@ def self.use(defn, options = {}) # @param schema [Class] the GraphQL schema this manager belongs to # @param validate_update [Boolean] If false, then validation is skipped when executing updates - def initialize(schema:, validate_update: true, broadcast: false, default_broadcastable: false, **rest) + def initialize(schema:, validate_update: true, broadcast: false, default_broadcastable: false, trigger_job: NOT_CONFIGURED, **rest) if broadcast schema.query_analyzer(Subscriptions::BroadcastAnalyzer) end @default_broadcastable = default_broadcastable @schema = schema @validate_update = validate_update + @trigger_job = if trigger_job == NOT_CONFIGURED + if defined?(ActiveJob::Base) + require "graphql/subscriptions/trigger_job" + trigger_job_class = Class.new(GraphQL::Subscriptions::TriggerJob) + trigger_job_class.subscriptions = self + # ActiveJob will need a constant reference to this class: + schema.const_set(:SubscriptionsTriggerJob, trigger_job_class) + trigger_job_class + else + nil + end + else + trigger_job + end end # @return [Boolean] Used when fields don't have `broadcastable:` explicitly set attr_reader :default_broadcastable + # This class is used to trigger with `.trigger_later`. + # When Rails is loaded, a Job class is automatically created. + # Pass `use ..., trigger_job: ...` to provide a custom class. + # @return [Class, nil] + attr_reader :trigger_job + + # Use {trigger_job} (`.perform_later`) to perform this trigger. + # @see trigger for arguments + def trigger_later(event_name, args, object, scope: nil, context: {}) + job_class = @trigger_job || raise(ArgumentError, "No `trigger_job` configured. Make sure Rails is loaded or provide a `trigger_job:` option to `use #{self.class}, ...`.") + job_class.perform_later(event_name, args, object, scope: scope, context: {}) + end # Fetch subscriptions matching this field + arguments pair # And pass them off to the queue. # @param event_name [String] diff --git a/lib/graphql/subscriptions/trigger_job.rb b/lib/graphql/subscriptions/trigger_job.rb new file mode 100644 index 0000000000..7dca0f1292 --- /dev/null +++ b/lib/graphql/subscriptions/trigger_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +module GraphQL + class Subscriptions + class TriggerJob < ActiveJob::Base + class << self + # @return [GraphQL::Subscriptions] + attr_accessor :subscriptions + end + + def perform(*args, **kwargs) + self.class.subscriptions.trigger(*args, **kwargs) + end + end + end +end diff --git a/spec/graphql/subscriptions/trigger_job_spec.rb b/spec/graphql/subscriptions/trigger_job_spec.rb new file mode 100644 index 0000000000..9a02fff0f9 --- /dev/null +++ b/spec/graphql/subscriptions/trigger_job_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true +require "spec_helper" + +describe "GraphQL::Subscriptions::TriggerJob" do + before do + skip "Requires Rails" unless testing_rails? + TriggerJobSchema.subscriptions.reset + end + + if defined?(ActiveJob) + include ActiveJob::TestHelper + ActiveJob::Base.logger = Logger.new(IO::NULL) + end + + class TriggerJobSchema < GraphQL::Schema + class InMemorySubscriptions < GraphQL::Subscriptions + attr_reader :write_subscription_events, :execute_all_events + + def initialize(...) + super + reset + end + + def write_subscription(_query, events) + @write_subscription_events.concat(events) + end + + def execute_all(event, _object) + @execute_all_events.push(event) + end + + def reset + @write_subscription_events = [] + @execute_all_events = [] + end + end + + class Subscription < GraphQL::Schema::Object + class Update < GraphQL::Schema::Subscription + field :news, String + + def resolve + object + { + news: (object && object[:news]) ? object[:news] : "Hello World" + } + end + end + + field :update, subscription: Update + end + subscription Subscription + use InMemorySubscriptions + end + + it "Creates a custom ActiveJob::Base subclass" do + assert_equal TriggerJobSchema::SubscriptionsTriggerJob, TriggerJobSchema.subscriptions.trigger_job + assert_equal GraphQL::Subscriptions::TriggerJob, TriggerJobSchema::SubscriptionsTriggerJob.superclass + assert_equal ActiveJob::Base, TriggerJobSchema::SubscriptionsTriggerJob.superclass.superclass + end + + it "runs .trigger in the background" do + res = TriggerJobSchema.execute("subscription { update { news } }") + assert_equal 1, TriggerJobSchema.subscriptions.write_subscription_events.size + assert_equal 0, TriggerJobSchema.subscriptions.execute_all_events.size + perform_enqueued_jobs do + TriggerJobSchema.subscriptions.trigger_later(:update, {}, { news: "Expect a week of sunshine" }) + end + assert_equal 1, TriggerJobSchema.subscriptions.execute_all_events.size + + end +end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 03c10a6b86..a5ba4231ee 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -1000,6 +1000,28 @@ def self.parse_error(err, context) res = schema.subscriptions.trigger("payload", { "id" => "8"}, OpenStruct.new(str: nil, int: nil)) assert res end + + it "raises an error when trigger_job isn't configured" do + assert_nil schema.subscriptions.trigger_job + err = assert_raises ArgumentError do + schema.subscriptions.trigger_later(:nothing, {}, :nothing) + end + assert_equal "No `trigger_job` configured. Make sure Rails is loaded or provide a `trigger_job:` option to `use InMemoryBackend::Subscriptions, ...`.", err.message + end + + it "doesn't raise an error when trigger_job is present" do + log = [] + trigger_job = Class.new do + define_singleton_method(:perform_later) do |*args, **kwargs| + log << [args, kwargs] + end + end + new_schema = Class.new(schema) do + use InMemoryBackend::Subscriptions, extra: 123, trigger_job: trigger_job + end + assert new_schema.subscriptions.trigger_later(:nothing, :nothing, :nothing) + assert_equal [[[:nothing, :nothing, :nothing], { scope: nil, context: {} }]], log + end end describe "Triggering with custom enum values" do From dd03664f0d387cf0f4ff65ed011749005b3bf3b4 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 12 Feb 2024 11:51:54 -0500 Subject: [PATCH 2/2] Add queue_as, fix test when Rails is loaded --- lib/graphql/subscriptions.rb | 5 ++- .../graphql/subscriptions/trigger_job_spec.rb | 6 ++++ spec/graphql/subscriptions_spec.rb | 34 ++++++++++--------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 14ed3773a3..72db7baa90 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -35,7 +35,7 @@ def self.use(schema, **options) # @param schema [Class] the GraphQL schema this manager belongs to # @param validate_update [Boolean] If false, then validation is skipped when executing updates - def initialize(schema:, validate_update: true, broadcast: false, default_broadcastable: false, trigger_job: NOT_CONFIGURED, **rest) + def initialize(schema:, validate_update: true, broadcast: false, default_broadcastable: false, trigger_job: NOT_CONFIGURED, trigger_job_queue_as: NOT_CONFIGURED, **rest) if broadcast schema.query_analyzer(Subscriptions::BroadcastAnalyzer) end @@ -47,6 +47,9 @@ def initialize(schema:, validate_update: true, broadcast: false, default_broadca require "graphql/subscriptions/trigger_job" trigger_job_class = Class.new(GraphQL::Subscriptions::TriggerJob) trigger_job_class.subscriptions = self + if trigger_job_queue_as != NOT_CONFIGURED + trigger_job_class.queue_as(trigger_job_queue_as) + end # ActiveJob will need a constant reference to this class: schema.const_set(:SubscriptionsTriggerJob, trigger_job_class) trigger_job_class diff --git a/spec/graphql/subscriptions/trigger_job_spec.rb b/spec/graphql/subscriptions/trigger_job_spec.rb index 9a02fff0f9..38a32655cd 100644 --- a/spec/graphql/subscriptions/trigger_job_spec.rb +++ b/spec/graphql/subscriptions/trigger_job_spec.rb @@ -57,6 +57,12 @@ def resolve assert_equal TriggerJobSchema::SubscriptionsTriggerJob, TriggerJobSchema.subscriptions.trigger_job assert_equal GraphQL::Subscriptions::TriggerJob, TriggerJobSchema::SubscriptionsTriggerJob.superclass assert_equal ActiveJob::Base, TriggerJobSchema::SubscriptionsTriggerJob.superclass.superclass + + custom_class = Class.new(TriggerJobSchema) do + use TriggerJobSchema::InMemorySubscriptions, trigger_job_queue_as: "graphql_subscriptions" + end + + assert_equal "graphql_subscriptions", custom_class::SubscriptionsTriggerJob.queue_name end it "runs .trigger in the background" do diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index a5ba4231ee..83af2c8828 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -1001,26 +1001,28 @@ def self.parse_error(err, context) assert res end - it "raises an error when trigger_job isn't configured" do - assert_nil schema.subscriptions.trigger_job - err = assert_raises ArgumentError do - schema.subscriptions.trigger_later(:nothing, {}, :nothing) + if !testing_rails? + it "raises an error when trigger_job isn't configured" do + assert_nil schema.subscriptions.trigger_job + err = assert_raises ArgumentError do + schema.subscriptions.trigger_later(:nothing, {}, :nothing) + end + assert_equal "No `trigger_job` configured. Make sure Rails is loaded or provide a `trigger_job:` option to `use InMemoryBackend::Subscriptions, ...`.", err.message end - assert_equal "No `trigger_job` configured. Make sure Rails is loaded or provide a `trigger_job:` option to `use InMemoryBackend::Subscriptions, ...`.", err.message - end - it "doesn't raise an error when trigger_job is present" do - log = [] - trigger_job = Class.new do - define_singleton_method(:perform_later) do |*args, **kwargs| - log << [args, kwargs] + it "doesn't raise an error when trigger_job is present" do + log = [] + trigger_job = Class.new do + define_singleton_method(:perform_later) do |*args, **kwargs| + log << [args, kwargs] + end end + new_schema = Class.new(schema) do + use InMemoryBackend::Subscriptions, extra: 123, trigger_job: trigger_job + end + assert new_schema.subscriptions.trigger_later(:nothing, :nothing, :nothing) + assert_equal [[[:nothing, :nothing, :nothing], { scope: nil, context: {} }]], log end - new_schema = Class.new(schema) do - use InMemoryBackend::Subscriptions, extra: 123, trigger_job: trigger_job - end - assert new_schema.subscriptions.trigger_later(:nothing, :nothing, :nothing) - assert_equal [[[:nothing, :nothing, :nothing], { scope: nil, context: {} }]], log end end