diff --git a/lib/interactor.rb b/lib/interactor.rb index 2423630..6e0babd 100644 --- a/lib/interactor.rb +++ b/lib/interactor.rb @@ -75,6 +75,14 @@ def call(context = {}) def call!(context = {}) new(context).tap(&:run!).context end + + def context_class + @context_class || Interactor::Context + end + + def context_class=(klass) + @context_class = klass + end end # Internal: Initialize an Interactor. @@ -91,7 +99,7 @@ def call!(context = {}) # MyInteractor.new # # => #> def initialize(context = {}) - @context = Context.build(context) + @context = self.class.context_class.build(context) end # Internal: Invoke an interactor instance along with all defined hooks. The diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index cb269d2..0d4c8f0 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -29,153 +29,173 @@ module Interactor # context # # => # class Context < OpenStruct - # Internal: Initialize an Interactor::Context or preserve an existing one. - # If the argument given is an Interactor::Context, the argument is returned. - # Otherwise, a new Interactor::Context is initialized from the provided - # hash. - # - # The "build" method is used during interactor initialization. - # - # context - A Hash whose key/value pairs are used in initializing a new - # Interactor::Context object. If an existing Interactor::Context - # is given, it is simply returned. (default: {}) - # - # Examples - # - # context = Interactor::Context.build(foo: "bar") - # # => # - # context.object_id - # # => 2170969340 - # context = Interactor::Context.build(context) - # # => # - # context.object_id - # # => 2170969340 - # - # Returns the Interactor::Context. - def self.build(context = {}) - context.is_a?(Context) ? context : new(context) - end + # Public: The default mixin any Context implementation should use + module Mixin + def self.included(receiver) + receiver.extend ClassMethods + receiver.send :include, InstanceMethods + end - # Public: Whether the Interactor::Context is successful. By default, a new - # context is successful and only changes when explicitly failed. - # - # The "success?" method is the inverse of the "failure?" method. - # - # Examples - # - # context = Interactor::Context.new - # # => # - # context.success? - # # => true - # context.fail! - # # => Interactor::Failure: # - # context.success? - # # => false - # - # Returns true by default or false if failed. - def success? - !failure? - end + # Internal: Context class methods. + module ClassMethods + # Internal: Initialize an Interactor::Context or preserve an existing + # one. + # If the argument given is an Interactor::Context, the argument is + # returned. Otherwise, a new Interactor::Context is initialized from + # the provided hash. + # + # The "build" method is used during interactor initialization. + # + # context - A Hash whose key/value pairs are used in initializing a new + # Interactor::Context object. If an existing + # Interactor::Context is given, it is simply returned. + # (default: {}) + # + # Examples + # + # context = Interactor::Context.build(foo: "bar") + # # => # + # context.object_id + # # => 2170969340 + # context = Interactor::Context.build(context) + # # => # + # context.object_id + # # => 2170969340 + # + # Returns the Interactor::Context. + def build(context = {}) + context.is_a?(self) ? context : new(context) + end + end - # Public: Whether the Interactor::Context has failed. By default, a new - # context is successful and only changes when explicitly failed. - # - # The "failure?" method is the inverse of the "success?" method. - # - # Examples - # - # context = Interactor::Context.new - # # => # - # context.failure? - # # => false - # context.fail! - # # => Interactor::Failure: # - # context.failure? - # # => true - # - # Returns false by default or true if failed. - def failure? - @failure || false - end + # Internal: Context instance methods. + module InstanceMethods + # Public: Whether the Interactor::Context is successful. By default, a + # new context is successful and only changes when explicitly failed. + # + # The "success?" method is the inverse of the "failure?" method. + # + # Examples + # + # context = Interactor::Context.new + # # => # + # context.success? + # # => true + # context.fail! + # # => Interactor::Failure: # + # context.success? + # # => false + # + # Returns true by default or false if failed. + def success? + !failure? + end - # Public: Fail the Interactor::Context. Failing a context raises an error - # that may be rescued by the calling interactor. The context is also flagged - # as having failed. - # - # Optionally the caller may provide a hash of key/value pairs to be merged - # into the context before failure. - # - # context - A Hash whose key/value pairs are merged into the existing - # Interactor::Context instance. (default: {}) - # - # Examples - # - # context = Interactor::Context.new - # # => # - # context.fail! - # # => Interactor::Failure: # - # context.fail! rescue false - # # => false - # context.fail!(foo: "baz") - # # => Interactor::Failure: # - # - # Raises Interactor::Failure initialized with the Interactor::Context. - def fail!(context = {}) - context.each { |key, value| modifiable[key.to_sym] = value } - @failure = true - raise Failure, self - end + # Public: Whether the Interactor::Context has failed. By default, a new + # context is successful and only changes when explicitly failed. + # + # The "failure?" method is the inverse of the "success?" method. + # + # Examples + # + # context = Interactor::Context.new + # # => # + # context.failure? + # # => false + # context.fail! + # # => Interactor::Failure: # + # context.failure? + # # => true + # + # Returns false by default or true if failed. + def failure? + @failure || false + end - # Internal: Track that an Interactor has been called. The "called!" method - # is used by the interactor being invoked with this context. After an - # interactor is successfully called, the interactor instance is tracked in - # the context for the purpose of potential future rollback. - # - # interactor - An Interactor instance that has been successfully called. - # - # Returns nothing. - def called!(interactor) - _called << interactor - end + # Public: Fail the Interactor::Context. Failing a context raises an + # error that may be rescued by the calling interactor. The context is + # also flagged as having failed. + # + # Optionally the caller may provide a hash of key/value pairs to be + # merged into the context before failure. + # + # context - A Hash whose key/value pairs are merged into the existing + # Interactor::Context instance. (default: {}) + # + # Examples + # + # context = Interactor::Context.new + # # => # + # context.fail! + # # => Interactor::Failure: # + # context.fail! rescue false + # # => false + # context.fail!(foo: "baz") + # # => Interactor::Failure: # + # + # Raises Interactor::Failure initialized with the Interactor::Context. + def fail!(context = {}) + context.each { |key, value| send("#{key}=", value) } + @failure = true + raise Failure, self + end - # Public: Roll back the Interactor::Context. Any interactors to which this - # context has been passed and which have been successfully called are asked - # to roll themselves back by invoking their "rollback" instance methods. - # - # Examples - # - # context = MyInteractor.call(foo: "bar") - # # => # - # context.rollback! - # # => true - # context - # # => # - # - # Returns true if rolled back successfully or false if already rolled back. - def rollback! - return false if @rolled_back - _called.reverse_each(&:rollback) - @rolled_back = true - end + # Internal: Track that an Interactor has been called. The "called!" + # method is used by the interactor being invoked with this context. + # After an interactor is successfully called, the interactor instance is + # tracked in the context for the purpose of potential future rollback. + # + # interactor - An Interactor instance that has been successfully called. + # + # Returns nothing. + def called!(interactor) + _called << interactor + end - # Internal: An Array of successfully called Interactor instances invoked - # against this Interactor::Context instance. - # - # Examples - # - # context = Interactor::Context.new - # # => # - # context._called - # # => [] - # - # context = MyInteractor.call(foo: "bar") - # # => # - # context._called - # # => [#>] - # - # Returns an Array of Interactor instances or an empty Array. - def _called - @called ||= [] + # Public: Roll back the Interactor::Context. Any interactors to which + # this context has been passed and which have been successfully called + # are asked to roll themselves back by invoking their "rollback" + # instance methods. + # + # Examples + # + # context = MyInteractor.call(foo: "bar") + # # => # + # context.rollback! + # # => true + # context + # # => # + # + # Returns true if rolled back successfully or false if already rolled + # back. + def rollback! + return false if @rolled_back + _called.reverse_each(&:rollback) + @rolled_back = true + end + + # Internal: An Array of successfully called Interactor instances invoked + # against this Interactor::Context instance. + # + # Examples + # + # context = Interactor::Context.new + # # => # + # context._called + # # => [] + # + # context = MyInteractor.call(foo: "bar") + # # => # + # context._called + # # => [#>] + # + # Returns an Array of Interactor instances or an empty Array. + def _called + @called ||= [] + end + end end + + include Mixin end end diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index eb8b905..37c659e 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -1,25 +1,18 @@ module Interactor - describe Context do + shared_examples "context" do describe ".build" do it "converts the given hash to a context" do - context = Context.build(foo: "bar") + context = context_class.build(foo: "bar") - expect(context).to be_a(Context) + expect(context).to be_a(context_class) expect(context.foo).to eq("bar") end - it "builds an empty context if no hash is given" do - context = Context.build - - expect(context).to be_a(Context) - expect(context.send(:table)).to eq({}) - end - it "doesn't affect the original hash" do hash = { foo: "bar" } - context = Context.build(hash) + context = context_class.build(hash) - expect(context).to be_a(Context) + expect(context).to be_a(context_class) expect { context.foo = "baz" }.not_to change { @@ -28,10 +21,10 @@ module Interactor end it "preserves an already built context" do - context1 = Context.build(foo: "bar") - context2 = Context.build(context1) + context1 = context_class.build(foo: "bar") + context2 = context_class.build(context1) - expect(context2).to be_a(Context) + expect(context2).to be_a(context_class) expect { context2.foo = "baz" }.to change { @@ -41,7 +34,7 @@ module Interactor end describe "#success?" do - let(:context) { Context.build } + let(:context) { context_class.build } it "is true by default" do expect(context.success?).to eq(true) @@ -49,7 +42,7 @@ module Interactor end describe "#failure?" do - let(:context) { Context.build } + let(:context) { context_class.build } it "is false by default" do expect(context.failure?).to eq(false) @@ -57,7 +50,7 @@ module Interactor end describe "#fail!" do - let(:context) { Context.build(foo: "bar") } + let(:context) { context_class.build(foo: "bar") } it "sets success to false" do expect { @@ -125,7 +118,7 @@ module Interactor end describe "#called!" do - let(:context) { Context.build } + let(:context) { context_class.build } let(:instance1) { double(:instance1) } let(:instance2) { double(:instance2) } @@ -140,7 +133,7 @@ module Interactor end describe "#rollback!" do - let(:context) { Context.build } + let(:context) { context_class.build } let(:instance1) { double(:instance1) } let(:instance2) { double(:instance2) } @@ -165,11 +158,51 @@ module Interactor end describe "#_called" do - let(:context) { Context.build } + let(:context) { context_class.build } it "is empty by default" do expect(context._called).to eq([]) end end end + + describe Context do + it_behaves_like "context" do + let(:context_class) { Context } + + it "builds an empty context if no hash is given" do + context = context_class.build + + expect(context).to be_a(context_class) + expect(context.send(:table)).to eq({}) + end + end + end + + describe "Overwriting Context" do + it_behaves_like "context" do + let(:context_class) do + Class.new do + include Context::Mixin + + attr_accessor :foo + + def initialize(foo: nil) + @foo = foo + end + + def to_h + { foo: foo } + end + end + end + + it "builds the default context if no hash is given" do + context = context_class.build + + expect(context).to be_a(context_class) + expect(context.to_h).to eq(foo: nil) + end + end + end end