From 0237448382703d31265b3caf1abb9d71afddba9f Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Fri, 15 May 2026 15:12:44 +0200 Subject: [PATCH 1/4] Make dry-initializer usable from non-main Ractors after finalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `Dry::Initializer#finalize` (and `Config#finalize`) as the single public seal entry point — the previous `finalize!` is gone, and the old internal regen step is now a private `Config#compile`. After `finalize`, the class is callable from any Ractor: * `__dry_initializer_config__` and `klass.dry_initializer` are rewritten from bmethods into `class_eval`'d `def`s that read a shareable `DRY_INITIALIZER_CONFIG` constant. * The Config (with its definitions and default Procs) is deeply frozen via `Ractor.make_shareable`. Further `param`/`option` calls raise `FrozenError`. Pre-finalize, class-level Config storage no longer relies on writing `@dry_initializer` ivars (forbidden in non-main Ractors): both `DSL#extended` and `Dry::Initializer#inherited` install a `define_singleton_method` on the class, which `finalize` later swaps for the Ractor-safe `def`. Other changes pulled along to keep the path clean: * `Config#children` derives live from `Class#subclasses` instead of an owned `Set` — same immediate-only semantics, no mutation. * `Mixin::Local` is removed entirely; `Config#mixin` uses `Module#set_temporary_name` to keep the per-class mixin printing as `Dry::Initializer::Mixin::Local[]` in `ancestors`/`inspect`. * `Dispatchers.@pipeline` is eagerly initialized at load time and `Ractor.make_shareable`d so the default pipeline is readable from any Ractor. Custom dispatchers still work but opt out of Ractor compatibility unless registered with shareable Procs. * `spec/ractor_spec.rb` and `spec/inspect_spec.rb` cover the new surface end-to-end. --- lib/dry/initializer.rb | 13 +- lib/dry/initializer/config.rb | 79 ++++++++-- lib/dry/initializer/dispatchers.rb | 18 ++- lib/dry/initializer/dsl.rb | 2 +- lib/dry/initializer/mixin.rb | 1 - lib/dry/initializer/mixin/local.rb | 25 --- spec/custom_dispatchers_spec.rb | 11 ++ spec/inspect_spec.rb | 51 +++++++ spec/ractor_spec.rb | 237 +++++++++++++++++++++++++++++ 9 files changed, 388 insertions(+), 49 deletions(-) delete mode 100644 lib/dry/initializer/mixin/local.rb create mode 100644 spec/inspect_spec.rb create mode 100644 spec/ractor_spec.rb diff --git a/lib/dry/initializer.rb b/lib/dry/initializer.rb index baaeb0b..8576def 100644 --- a/lib/dry/initializer.rb +++ b/lib/dry/initializer.rb @@ -47,13 +47,22 @@ def option(name, type = nil, **opts, &block) self end + # Seal the initializer config: freeze the definitions, reject + # further `param`/`option` calls, and (where supported) make the + # config Ractor-shareable. + # @see Dry::Initializer::Config#finalize + # @return [self] + def finalize + dry_initializer.finalize + self + end + private def inherited(klass) super config = Config.new(klass, null: dry_initializer.null) - klass.send(:instance_variable_set, :@dry_initializer, config) - dry_initializer.children << config + klass.define_singleton_method(:dry_initializer) { config } end require_relative "initializer/struct" diff --git a/lib/dry/initializer/config.rb b/lib/dry/initializer/config.rb index 12f8eb5..f9666ab 100644 --- a/lib/dry/initializer/config.rb +++ b/lib/dry/initializer/config.rb @@ -6,6 +6,10 @@ module Initializer # Gem-related configuration of some class # class Config + # Name of the constant {#finalize} sets on the mixin to hold this + # Config. Long enough to not collide with user-defined constants. + CONFIG_CONST = :DRY_INITIALIZER_CONFIG + # @!attribute [r] null # @return [Dry::Initializer::UNDEFINED, nil] value of unassigned variable @@ -26,7 +30,7 @@ class Config def mixin @mixin ||= Module.new.tap do |mod| initializer = self - mod.extend(Mixin::Local) + mod.set_temporary_name("Dry::Initializer::Mixin::Local[#{extended_class&.inspect}]") mod.define_method(:__dry_initializer_config__) do initializer end @@ -34,10 +38,15 @@ def mixin end end - # List of configs of all subclasses of the [#extended_class] + # Configs of {#extended_class}'s direct (one-level) subclasses. + # Deeper descendants are reached transitively by recursing into each + # child's own `#children` (as {#compile} does). + # # @return [Array] def children - @children ||= Set.new + return [] unless extended_class + + extended_class.subclasses.map(&:dry_initializer) end # List of definitions for initializer params @@ -104,13 +113,40 @@ def code Builders::Initializer[self] end - # Finalizes config + # Seal the config. After finalize the class is usable from non-main + # Ractors and the Config (with its definitions) is deeply frozen — + # further `param`/`option` calls raise `FrozenError`. + # # @return [self] def finalize - @definitions = final_definitions - check_order_of_params - mixin.class_eval(code, "#{__FILE__}:#{__LINE__} class_eval") - children.each(&:finalize) + return self if @finalized + + compile + + mixin.const_set(CONFIG_CONST, self) unless mixin.const_defined?(CONFIG_CONST, false) + mixin.send(:undef_method, :__dry_initializer_config__) \ + if mixin.private_method_defined?(:__dry_initializer_config__) + mixin.module_eval(<<~RUBY, __FILE__, __LINE__ + 1) + private def __dry_initializer_config__ + #{CONFIG_CONST} + end + RUBY + + # Replace the bmethod `klass.dry_initializer` from `DSL#extended` + # / `Dry::Initializer#inherited` with a `def` reading the mixin + # constant, so it's callable from any Ractor. + if extended_class + extended_class.singleton_class.send(:undef_method, :dry_initializer) \ + if extended_class.singleton_class.method_defined?(:dry_initializer, false) + extended_class.class_eval(<<~RUBY, __FILE__, __LINE__ + 1) + def self.dry_initializer + #{CONFIG_CONST} + end + RUBY + end + + @finalized = true + Ractor.make_shareable(self) if defined?(Ractor) self end @@ -128,30 +164,43 @@ def inch private + # Rebuild the generated initializer on the mixin and recurse into + # children. Called on every DSL change. Private — it makes no + # Ractor-readiness guarantees; only {#finalize} does. + # @return [self] + def compile + @definitions = final_definitions + check_order_of_params + mixin.class_eval(code, "#{__FILE__}:#{__LINE__} class_eval") + children.each(&:compile) + self + end + def initialize(extended_class = nil, null: UNDEFINED) - @extended_class = extended_class.tap { |klass| klass&.include mixin } + @extended_class = extended_class sklass = extended_class&.superclass @parent = sklass.dry_initializer if sklass.is_a? Dry::Initializer @null = null || parent&.null @definitions = {} - finalize + extended_class&.include(mixin) + compile end def add_definition(option, name, type, block, **opts) opts = { parent: extended_class, - option: option, - null: null, + option:, + null:, source: name, - type: type, - block: block, + type:, + block:, **opts } options = Dispatchers.call(**opts) definition = Definition.new(**options) definitions[definition.source] = definition - finalize + compile mixin.class_eval definition.code end diff --git a/lib/dry/initializer/dispatchers.rb b/lib/dry/initializer/dispatchers.rb index 03ec268..584d3be 100644 --- a/lib/dry/initializer/dispatchers.rb +++ b/lib/dry/initializer/dispatchers.rb @@ -106,12 +106,20 @@ def call(**options) require_relative "dispatchers/wrap_type" def pipeline - @pipeline ||= [ - PrepareSource, PrepareTarget, PrepareIvar, PrepareReader, - PrepareDefault, PrepareOptional, - UnwrapType, CheckType, BuildNestedType, WrapType - ] + @pipeline ||= begin + list = [ + PrepareSource, PrepareTarget, PrepareIvar, PrepareReader, + PrepareDefault, PrepareOptional, + UnwrapType, CheckType, BuildNestedType, WrapType + ] + defined?(Ractor) ? Ractor.make_shareable(list) : list.freeze + end end + + # Eagerly initialize so that the first read from a non-main Ractor + # doesn't trip the class-ivar-set restriction. Done at load time so + # the assignment happens in the main Ractor. + pipeline end end end diff --git a/lib/dry/initializer/dsl.rb b/lib/dry/initializer/dsl.rb index 6130eb0..42e9bf7 100644 --- a/lib/dry/initializer/dsl.rb +++ b/lib/dry/initializer/dsl.rb @@ -35,7 +35,7 @@ def define(procedure = nil, &block) def extended(klass) config = Config.new(klass, null: null) - klass.send :instance_variable_set, :@dry_initializer, config + klass.define_singleton_method(:dry_initializer) { config } klass.include Mixin::Root end diff --git a/lib/dry/initializer/mixin.rb b/lib/dry/initializer/mixin.rb index 225314c..5b7276c 100644 --- a/lib/dry/initializer/mixin.rb +++ b/lib/dry/initializer/mixin.rb @@ -14,7 +14,6 @@ def self.extended(klass) end require_relative "mixin/root" - require_relative "mixin/local" end end end diff --git a/lib/dry/initializer/mixin/local.rb b/lib/dry/initializer/mixin/local.rb deleted file mode 100644 index 30176f5..0000000 --- a/lib/dry/initializer/mixin/local.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Dry - module Initializer - module Mixin - # @private - module Local - attr_reader :klass - - def inspect - "Dry::Initializer::Mixin::Local[#{klass}]" - end - alias_method :to_s, :inspect - alias_method :to_str, :inspect - - private - - def included(klass) - @klass = klass - super - end - end - end - end -end diff --git a/spec/custom_dispatchers_spec.rb b/spec/custom_dispatchers_spec.rb index 9563b38..69f1e84 100644 --- a/spec/custom_dispatchers_spec.rb +++ b/spec/custom_dispatchers_spec.rb @@ -8,6 +8,17 @@ Dry::Initializer::Dispatchers << dispatcher end + # The Dispatcher's pipeline is process-global, so reset it after each + # example. Re-trigger the eager initialization so subsequent tests (notably + # ractor_spec) get a shareable default pipeline back — clearing to + # nil alone would leave the lazy `||=` to fire from whichever Ractor + # touches it next, and class-ivar writes from non-main Ractors are + # forbidden. + after do + Dry::Initializer::Dispatchers.instance_variable_set(:@pipeline, nil) + Dry::Initializer::Dispatchers.send(:pipeline) + end + context "with extend syntax" do before do class Test::Foo diff --git a/spec/inspect_spec.rb b/spec/inspect_spec.rb new file mode 100644 index 0000000..e6ebd1b --- /dev/null +++ b/spec/inspect_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# The per-class mixin that `extend Dry::Initializer` installs is an +# anonymous module. By default it would print as `#` in +# ancestors output, backtraces, and `inspect` — noisy and uninformative. +# These tests pin down the user-visible effect: that mixin renders as +# `Dry::Initializer::Mixin::Local[]` instead. + +RSpec.describe "Dry::Initializer mixin inspection" do + before do + class Test::Foo + extend Dry::Initializer + param :foo + end + end + + let(:mixin) { Test::Foo.dry_initializer.mixin } + + it "shows up in the class's ancestors with a class-named label" do + expect(Test::Foo.ancestors.map(&:to_s)) + .to include("Dry::Initializer::Mixin::Local[Test::Foo]") + end + + it "renders the same string from inspect, to_s, and string interpolation" do + expected = "Dry::Initializer::Mixin::Local[Test::Foo]" + + expect(mixin.inspect).to eq(expected) + expect(mixin.to_s).to eq(expected) + expect("includes #{mixin}").to eq("includes #{expected}") + end + + it "uses each class's own name when more than one class extends Dry::Initializer" do + class Test::Bar + extend Dry::Initializer + param :bar + end + + foo_mixin = Test::Foo.dry_initializer.mixin + bar_mixin = Test::Bar.dry_initializer.mixin + + expect(foo_mixin.to_s).to eq("Dry::Initializer::Mixin::Local[Test::Foo]") + expect(bar_mixin.to_s).to eq("Dry::Initializer::Mixin::Local[Test::Bar]") + end + + it "falls back to the class's inspect for anonymous classes" do + klass = Class.new { extend Dry::Initializer; param :foo } + + expect(klass.dry_initializer.mixin.to_s) + .to match(/\ADry::Initializer::Mixin::Local\[#\]\z/) + end +end diff --git a/spec/ractor_spec.rb b/spec/ractor_spec.rb new file mode 100644 index 0000000..a86e571 --- /dev/null +++ b/spec/ractor_spec.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +# Ractor compatibility tests for dry-initializer. +# +# Purpose: pin down which usage patterns work across Ractor boundaries +# today, and document the exact exception each remaining failure raises +# — so the supported surface is explicit and regressions are visible. +# +# The supported pattern is: define a class in the main Ractor, call +# `finalize` on it, then instantiate or subclass it from any Ractor. +# Non-finalized classes are not expected to cross Ractor boundaries — +# that's a precondition, the same shape as other Ractor preconditions +# (you make_shareable what you want to send), not a bug to track here. + +RSpec.describe Dry::Initializer, "Ractor compatibility" do + before do + skip "Ractor not available" unless defined?(Ractor) + skip "Ractor tests gated to Ruby 4.0+" if RUBY_VERSION < "4" + end + + # Several tests deliberately let a Ractor crash. Suppress the + # `Thread#report_on_exception` chatter so the spec output stays clean. + around do |example| + previous = Thread.report_on_exception + Thread.report_on_exception = false + example.run + ensure + Thread.report_on_exception = previous + end + + # `Ractor#value` (and `#take` on older Rubies) wraps a remote raise in + # `Ractor::RemoteError`, with the original exception accessible via + # `#cause`. Walk the chain so matchers don't depend on the wrapper. + def cause_chain(error) + chain = [] + while error + chain << error + error = error.cause + end + chain + end + + def caused_by?(error, klass, message_includes: nil) + cause_chain(error).any? do |e| + e.is_a?(klass) && + (message_includes.nil? || e.message.to_s.include?(message_includes)) + end + end + + describe "calling .new on a main-Ractor class from a non-main Ractor" do + context "without finalize, with a default/type/rename" do + before do + class Test::Foo + extend Dry::Initializer + param :foo, default: -> { "default" } + option :bar, ->(v) { v.to_s.upcase }, optional: true + end + end + + it "raises the bmethod un-shareable-Proc error" do + ractor = Ractor.new { Test::Foo.new } + + expect { ractor.value }.to raise_error do |error| + expect( + caused_by?(error, RuntimeError, message_includes: "un-shareable Proc") + ).to(be(true), "expected un-shareable-Proc RuntimeError, got #{error.class}: #{error.message}") + end + end + end + + context "without finalize, with only plain params/options" do + before do + class Test::Foo + extend Dry::Initializer + param :foo + end + end + + # The generated `__dry_initializer_initialize__` for plain + # declarations doesn't reference `__dry_initializer_config__`, so + # the bmethod is never invoked. This is incidental: relying on it + # means losing defaults, coercion, and renames. Documented here so + # a regression in the code generator is visible. + it "incidentally succeeds" do + result = Ractor.new { Test::Foo.new("hi").foo }.value + expect(result).to eq("hi") + end + end + + context "after finalize" do + before do + class Test::Foo + extend Dry::Initializer + param :foo + option :bar, default: -> { "default" } + option :baz, ->(v) { v.to_s.upcase }, optional: true + finalize + end + end + + it "instantiates and reads back the param" do + result = Ractor.new { Test::Foo.new("hello").foo }.value + expect(result).to eq("hello") + end + + it "applies a default-valued option" do + result = Ractor.new { Test::Foo.new("hello").bar }.value + expect(result).to eq("default") + end + + it "applies type coercion to options" do + result = Ractor.new { Test::Foo.new("x", baz: "yo").baz }.value + expect(result).to eq("YO") + end + + it "exposes attributes via dry_initializer.public_attributes" do + result = Ractor.new do + instance = Test::Foo.new("a", baz: "b") + Test::Foo.dry_initializer.public_attributes(instance) + end.value + + expect(result).to eq(foo: "a", bar: "default", baz: "B") + end + end + + context "after finalize, with a default that references another param" do + before do + class Test::Greeter + extend Dry::Initializer + param :name + option :upcased_name, default: -> { name.upcase } + finalize + end + end + + # The default is `instance_exec`'d so `self` inside the Proc is the + # instance — `name` resolves to the reader method, not a captured + # local. Inline class-body defaults are made shareable by finalize's + # `Ractor.make_shareable`, so this composes cleanly with cross-Ractor + # instantiation. + it "resolves the reference at .new time from a non-main Ractor" do + result = Ractor.new { Test::Greeter.new("alice").upcased_name }.value + expect(result).to eq("ALICE") + end + end + end + + describe "Dry::Initializer#finalize" do + let(:klass) do + Class.new do + extend Dry::Initializer + param :foo + option :bar, default: -> { 1 } + end + end + + it "returns the class itself" do + expect(klass.finalize).to be(klass) + end + + it "is idempotent" do + klass.finalize + expect { klass.finalize }.not_to raise_error + end + + it "rejects further param definitions with FrozenError" do + klass.finalize + expect { klass.param :added }.to raise_error(FrozenError) + end + + it "rejects further option definitions with FrozenError" do + klass.finalize + expect { klass.option :added_opt }.to raise_error(FrozenError) + end + + it "leaves main-Ractor instantiation working" do + klass.finalize + instance = klass.new("hi") + expect(instance.foo).to eq("hi") + expect(instance.bar).to eq(1) + end + + it "makes the config Ractor-shareable" do + klass.finalize + expect(Ractor.shareable?(klass.dry_initializer)).to be(true) + end + end + + describe "subclassing" do + before do + class Test::Parent + extend Dry::Initializer + param :foo + finalize + end + end + + context "in the main Ractor, after the parent is finalized" do + # The new subclass starts with its own non-finalized Config and + # can be finalized independently. + it "succeeds and yields an independent, non-finalized child" do + child = Class.new(Test::Parent) { param :bar } + + expect(child.new("a", "b").foo).to eq("a") + expect(child.dry_initializer).not_to be(Test::Parent.dry_initializer) + expect(Test::Parent.subclasses).to include(child) + end + end + + context "in a non-main Ractor, with a finalized main-Ractor parent" do + it "succeeds and produces a usable subclass" do + result = Ractor.new do + child = Class.new(Test::Parent) + child.new("hello").foo + end.value + + expect(result).to eq("hello") + end + end + end + + describe "defining a class inside a non-main Ractor" do + it "succeeds and produces a usable class" do + result = Ractor.new do + klass = Class.new do + extend Dry::Initializer + param :name, ->(v) { v.to_s.upcase } + option :count, default: -> { 1 } + end + instance = klass.new("alice", count: 5) + [instance.name, instance.count] + end.value + + expect(result).to eq(["ALICE", 5]) + end + end +end From cd41b098a9119fdcb77e7345a014dd27b7606bde Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Tue, 12 May 2026 20:24:05 +0200 Subject: [PATCH 2/4] Update CHANGELOG.md --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 532c75a..bb88682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve ## [Unreleased] +### Added + +- `Dry::Initializer#finalize` seals the config so the class is usable from non-main Ractors. After `finalize` the definitions are deeply frozen — further `param`/`option` calls raise `FrozenError`. (@flash-gordon) + ### Changed -- Set minimum Ruby version to 3.2 (@timriley) +- Set minimum Ruby version to 3.3 (@timriley) ## [3.2.0] - 2025-01-01 From f305ac12ec49a3b1c6ead1a5193f946cd43ce160 Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Tue, 12 May 2026 20:25:48 +0200 Subject: [PATCH 3/4] Bump version to 3.3.0 --- lib/dry/initializer/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dry/initializer/version.rb b/lib/dry/initializer/version.rb index ebe2f2f..ad33c87 100644 --- a/lib/dry/initializer/version.rb +++ b/lib/dry/initializer/version.rb @@ -2,6 +2,6 @@ module Dry module Initializer - VERSION = "3.2.0" + VERSION = "3.3.0" end end From 82c9d0d59e75648dce388a27dd05e52af4dbee8f Mon Sep 17 00:00:00 2001 From: Nikita Shilnikov Date: Fri, 22 May 2026 17:01:35 +0200 Subject: [PATCH 4/4] Handle edge case of extending a class with existing subclasses --- lib/dry/initializer/config.rb | 23 +++++++++++--- lib/dry/initializer/dsl.rb | 17 +++++++++- spec/pre_existing_subclasses_spec.rb | 47 ++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 spec/pre_existing_subclasses_spec.rb diff --git a/lib/dry/initializer/config.rb b/lib/dry/initializer/config.rb index f9666ab..d025425 100644 --- a/lib/dry/initializer/config.rb +++ b/lib/dry/initializer/config.rb @@ -42,11 +42,21 @@ def mixin # Deeper descendants are reached transitively by recursing into each # child's own `#children` (as {#compile} does). # + # Skips subclasses that don't have their own `dry_initializer` + # singleton method yet — they'd inherit ours and we'd recurse into + # ourselves. `DSL#extended` and `Dry::Initializer#inherited` install + # those singletons; this guard keeps `#children` correct in between + # (e.g. while the parent's own Config is still being built). + # # @return [Array] def children return [] unless extended_class - extended_class.subclasses.map(&:dry_initializer) + extended_class.subclasses.filter_map do |klass| + next unless klass.singleton_class.method_defined?(:dry_initializer, false) + + klass.dry_initializer + end end # List of definitions for initializer params @@ -162,20 +172,23 @@ def inch lines.join("\n") end - private + protected # Rebuild the generated initializer on the mixin and recurse into - # children. Called on every DSL change. Private — it makes no - # Ractor-readiness guarantees; only {#finalize} does. + # children. Called on every DSL change. Protected — it makes no + # Ractor-readiness guarantees (only {#finalize} does), but the + # recompile chain crosses sibling Config instances. # @return [self] def compile @definitions = final_definitions check_order_of_params mixin.class_eval(code, "#{__FILE__}:#{__LINE__} class_eval") - children.each(&:compile) + children.each { |child| child.compile } self end + private + def initialize(extended_class = nil, null: UNDEFINED) @extended_class = extended_class sklass = extended_class&.superclass diff --git a/lib/dry/initializer/dsl.rb b/lib/dry/initializer/dsl.rb index 42e9bf7..a5ae991 100644 --- a/lib/dry/initializer/dsl.rb +++ b/lib/dry/initializer/dsl.rb @@ -34,12 +34,27 @@ def define(procedure = nil, &block) private def extended(klass) - config = Config.new(klass, null: null) + null_value = null + config = Config.new(klass, null: null_value) klass.define_singleton_method(:dry_initializer) { config } klass.include Mixin::Root + # `Dry::Initializer#inherited` only fires for classes subclassed + # *after* the extend. Pre-existing subclasses would otherwise + # inherit `klass`'s singleton `dry_initializer` and resolve to + # the parent's Config — so give each one its own Config now. + klass.subclasses.each { |sub| DSL.install_subclass_config(sub, null_value) } end class << self + # @api private + def install_subclass_config(klass, null_value) + return if klass.singleton_class.method_defined?(:dry_initializer, false) + + config = Config.new(klass, null: null_value) + klass.define_singleton_method(:dry_initializer) { config } + klass.subclasses.each { |sub| install_subclass_config(sub, null_value) } + end + private def extended(mod) diff --git a/spec/pre_existing_subclasses_spec.rb b/spec/pre_existing_subclasses_spec.rb new file mode 100644 index 0000000..c16a129 --- /dev/null +++ b/spec/pre_existing_subclasses_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Regression: when `extend Dry::Initializer` is applied to a class that +# already has subclasses, those subclasses must not share the parent's +# Config via inherited singleton methods. + +RSpec.describe "extending a class that already has subclasses" do + before do + class Test::Parent; end + class Test::Child < Test::Parent; end + class Test::Grandchild < Test::Child; end + end + + context "after extending the parent" do + before { Test::Parent.extend(Dry::Initializer) } + + it "gives each existing subclass its own Config" do + expect(Test::Child.dry_initializer) + .not_to be(Test::Parent.dry_initializer) + expect(Test::Grandchild.dry_initializer) + .not_to be(Test::Parent.dry_initializer) + expect(Test::Grandchild.dry_initializer) + .not_to be(Test::Child.dry_initializer) + end + + it "does not include the parent itself in its own children" do + children = Test::Parent.dry_initializer.children + expect(children).not_to include(Test::Parent.dry_initializer) + end + + it "exposes existing subclasses as children" do + expect(Test::Parent.dry_initializer.children) + .to include(Test::Child.dry_initializer) + end + + it "lets adding a param to the parent flow into the existing subclass" do + Test::Parent.param :foo + expect(Test::Child.new("hi").foo).to eq("hi") + end + + it "does not pollute the parent when adding a param to the existing subclass" do + Test::Child.param :bar + expect(Test::Parent.dry_initializer.definitions).not_to have_key(:bar) + expect(Test::Child.dry_initializer.definitions).to have_key(:bar) + end + end +end