Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## 4.0.0 / 2026-03-06

* [BREAKING] Replace `OpenStruct` with a plain hash-backed `Context` implementation.
`Interactor::Context` no longer inherits from `OpenStruct`. All documented public
API (`context.foo`, `context.foo = value`, `[]`, `[]=`, `to_h`, `fail!`, `success?`,
`failure?`, `rollback!`, `deconstruct_keys`) is fully preserved.

**Migration:** If your code checks `context.is_a?(OpenStruct)`, or calls OpenStruct
methods not part of the Interactor API (`each_pair`, `marshal_dump`, etc.), update
those callsites. Otherwise, no changes are required.

* [ENHANCEMENT] Remove `ostruct` runtime dependency — no gem dependency required.

## 3.2.0 / 2025-07-10
* [BUGFIX] Raise failures from nested contexts [#170]
* [FEATURE] Add `ostruct` dependency to gemspec.
Expand Down
3 changes: 1 addition & 2 deletions interactor.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ require "English"

Gem::Specification.new do |spec|
spec.name = "interactor"
spec.version = "3.2.0"
spec.version = "4.0.0"

spec.author = "Collective Idea"
spec.email = "info@collectiveidea.com"
Expand All @@ -13,7 +13,6 @@ Gem::Specification.new do |spec|

spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)

spec.add_dependency "ostruct"
spec.add_development_dependency "bundler"
spec.add_development_dependency "rake"
end
4 changes: 1 addition & 3 deletions lib/interactor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,7 @@ def initialize(context = {})
def run
run!
rescue Failure => e
if context.object_id != e.context.object_id
raise
end
raise unless context.equal?(e.context)
end

# Internal: Invoke an Interactor instance along with all defined hooks. The
Expand Down
111 changes: 108 additions & 3 deletions lib/interactor/context.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
require "ostruct"

module Interactor
# Public: The object for tracking state of an Interactor's invocation. The
# context is used to initialize the interactor with the information required
Expand Down Expand Up @@ -28,7 +26,7 @@ module Interactor
# # => "baz"
# context
# # => #<Interactor::Context foo="baz" hello="world">
class Context < OpenStruct
class Context
# 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
Expand Down Expand Up @@ -60,6 +58,68 @@ def self.build(context = {})
end
end

# Internal: Initialize an Interactor::Context from a Hash of key/value
# pairs. Keys are stored as symbols.
#
# data - A Hash whose key/value pairs populate the context. (default: {})
#
# Returns nothing.
def initialize(data = {})
@table = {}
data.to_h.each { |k, v| @table[k.to_sym] = v }
end

# Internal: Read a value from the context by key.
#
# key - A Symbol or String key.
#
# Returns the stored value or nil.
def [](key)
@table[key.to_sym]
end

# Internal: Write a value into the context by key.
#
# key - A Symbol or String key.
# value - The value to store.
#
# Returns the value.
def []=(key, value)
@table[key.to_sym] = value
end

# Public: Return the context as a plain Hash.
#
# Returns a Hash.
def to_h
@table.dup
end

# Public: Equality check based on stored attributes.
#
# other - Another object to compare.
#
# Returns true if both are a Context with identical attributes.
def ==(other)
other.is_a?(self.class) && other.to_h == to_h
end

# Internal: Human-readable representation of the context.
#
# Returns a String.
def inspect
pairs = @table.map { |k, v| "#{k}=#{v.inspect}" }.join(", ")
pairs.empty? ? "#<#{self.class}>" : "#<#{self.class} #{pairs}>"
end

# Internal: String representation (used as the exception message in
# Interactor::Failure).
#
# Returns a String.
def to_s
inspect
end

# Public: Whether the Interactor::Context is successful. By default, a new
# context is successful and only changes when explicitly failed.
#
Expand Down Expand Up @@ -208,5 +268,50 @@ def deconstruct_keys(keys)
failure: failure?
)
end

private

# Internal: Give a duplicated or cloned context its own copy of the
# underlying store, so mutating the copy does not bleed into the original.
#
# other - The Interactor::Context being copied from.
#
# Returns nothing.
def initialize_copy(other)
super
@table = other.to_h
end

# Internal: Handle dynamic attribute accessors not defined on the class. A
# name ending in "=" stores a value; any other name reads one. A reader
# called with arguments raises, matching a real zero-arity accessor.
#
# name - A Symbol method name.
# args - Arguments passed to the method.
#
# Returns the stored value for a getter, or sets and returns the value for
# a setter.
def method_missing(name, *args)
if (key = name.to_s.chomp!("="))
@table[key.to_sym] = args.first
elsif args.empty?
@table[name]
else
raise ArgumentError, "wrong number of arguments (given #{args.length}, expected 0)"
end
end

# Internal: Report which dynamic accessors the context supports: any setter,
# and a reader for a key that has been set. Keeping this honest stops the
# context from claiming it responds to implicit conversions such as to_ary,
# to_hash, and to_str, which would otherwise be dispatched here and return
# nil where the caller expects an Array, Hash, or String.
#
# name - A Symbol method name.
#
# Returns true for a setter or an existing key, otherwise defers to super.
def respond_to_missing?(name, include_private = false)
name.to_s.end_with?("=") || @table.key?(name.to_sym) || super
end
end
end
53 changes: 52 additions & 1 deletion spec/interactor/context_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,56 @@
module Interactor
describe Context do
it "inherits directly from Object, not OpenStruct" do
expect(Context.superclass).to eq(Object)
end

describe "#respond_to?" do
let(:context) { Context.build(foo: "bar") }

it "responds to a set attribute" do
expect(context.respond_to?(:foo)).to eq(true)
end

it "does not respond to an unset attribute" do
expect(context.respond_to?(:bar)).to eq(false)
end

it "responds to any setter" do
expect(context.respond_to?(:bar=)).to eq(true)
end

it "does not advertise implicit conversions it cannot satisfy" do
expect(context.respond_to?(:to_ary)).to eq(false)
expect(context.respond_to?(:to_hash)).to eq(false)
expect(context.respond_to?(:to_str)).to eq(false)
end
end

describe "dynamic readers" do
let(:context) { Context.build(foo: "bar") }

it "returns nil for an unset attribute" do
expect(context.baz).to eq(nil)
end

it "raises when a reader is called with arguments" do
expect { context.foo("extra") }.to raise_error(ArgumentError)
end
end

describe "#dup" do
it "does not share state with the original" do
context = Context.build(foo: "bar")
copy = context.dup

expect {
copy.foo = "baz"
}.not_to change {
context.foo
}
end
end

describe ".build" do
it "converts the given hash to a context" do
context = Context.build(foo: "bar")
Expand All @@ -12,7 +63,7 @@ module Interactor
context = Context.build

expect(context).to be_a(Context)
expect(context.send(:table)).to eq({})
expect(context.to_h).to eq({})
end

it "doesn't affect the original hash" do
Expand Down