From 5b8da5f45906c4e0f515f7e66b2b8dd2b3d13d0c Mon Sep 17 00:00:00 2001 From: Cris Nahine Date: Tue, 23 Jun 2026 10:21:44 +0800 Subject: [PATCH] Replace OpenStruct with hash-backed Context (v4.0.0) OpenStruct is deprecated on Ruby 3.2+ and leaves the default gems in Ruby 4.0. Reimplement Interactor::Context as a plain hash-backed object and drop the ostruct runtime dependency. - Context inherits from Object and stores attributes in an @table hash with method_missing accessors. All documented public API is preserved: build, [], []=, to_h, ==, success?, failure?, fail!, called!, rollback!, _called, deconstruct_keys, and dynamic getters/setters. - respond_to_missing? reports true only for setters and already-set keys, so the context never advertises implicit conversions (to_ary/to_hash/to_str) that method_missing would answer with nil. - initialize_copy gives dup/clone an independent store instead of aliasing the original's hash. - A reader called with arguments raises ArgumentError, matching a real zero-arity accessor. - Bump the version to 4.0.0. Breaking: Context is no longer is_a?(OpenStruct) and OpenStruct-only methods (each_pair, marshal_dump, marshal_load) are gone. - Fix the Lint/IdentityComparison warning in Interactor#run. 118 examples, 0 failures; standardrb clean. --- CHANGELOG.md | 13 ++++ interactor.gemspec | 3 +- lib/interactor.rb | 4 +- lib/interactor/context.rb | 111 +++++++++++++++++++++++++++++++- spec/interactor/context_spec.rb | 53 ++++++++++++++- 5 files changed, 175 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4399e86..cc0f347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/interactor.gemspec b/interactor.gemspec index 97535c6..90ea6f0 100644 --- a/interactor.gemspec +++ b/interactor.gemspec @@ -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" @@ -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 diff --git a/lib/interactor.rb b/lib/interactor.rb index 307234c..c76ca17 100644 --- a/lib/interactor.rb +++ b/lib/interactor.rb @@ -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 diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index 1402e60..73ab511 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -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 @@ -28,7 +26,7 @@ module Interactor # # => "baz" # context # # => # - 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 @@ -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. # @@ -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 diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index d990479..a421fd2 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -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") @@ -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