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
95 changes: 92 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,34 @@ def deconstruct_keys(keys)
failure: failure?
)
end

private

# Internal: The underlying hash store.
#
# Returns a Hash.
attr_reader :table

# Internal: Handle dynamic attribute accessors not defined on the class.
#
# 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
else
@table[name]
end
end

# Internal: All dynamic attribute methods are supported.
#
# Returns true.
def respond_to_missing?(name, include_private = false)
true
end
end
end
6 changes: 5 additions & 1 deletion spec/interactor/context_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
module Interactor
describe Context do
it "inherits directly from Object, not OpenStruct" do
expect(Context.superclass).to eq(Object)
end

describe ".build" do
it "converts the given hash to a context" do
context = Context.build(foo: "bar")
Expand All @@ -12,7 +16,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