diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 3a4835a..9d8ae51 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -17,7 +17,7 @@ jobs:
- '3.2.2'
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
diff --git a/.rubocop.yml b/.rubocop.yml
index 9997774..d95a718 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -22,6 +22,7 @@ Metrics/BlockLength:
Enabled: false
Metrics/MethodLength:
+ Max: 15
AllowedMethods: [initialize, initialize_with]
Metrics/AbcSize:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f12a44..1a10318 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,33 @@
## [Unreleased]
+* N/A
+
+## [0.2.0] - 2026-02-19
+
+### Changed
+- `initialize_with` readers are now always defined, even if a method with the same name already exists. This makes `foo` consistently return the init-arg value.
+
+### Breaking
+- Previously, if a method `#foo` existed (on the class or an ancestor), the gem skipped defining the reader and logged a warning; callers had to use `@foo` to access the init-arg. Now the reader is defined and overrides the existing method.
+
+### Added
+- Optional override warnings in Rails development/test, or when logger level is `DEBUG`.
+
+### Fixed
+- No warning on Rails reload when the existing method was originally defined by this gem.
+- No warning when a subclass re-declares an attribute already declared by an ancestor’s `initialize_with`.
+- Duplicate common mutable default values (`Array`, `Hash`, `Set`, `String`) per instance when the caller omits that keyword, preventing accidental cross-instance mutation. Copy is shallow; caller-provided values are not duplicated.
+
+
## [0.1.1] - 2025-05-02
-- Refactor internals
-- [BUGFIX] Only trigger `attr_reader` creation on initial class load (vs on every call to `#new`)
+### Changed
+- Refactor internals.
+
+### Fixed
+- Only define `attr_reader`s on initial class setup (avoid re-defining on each call to `.new`).
## [0.1.0] - 2025-03-05
-- Initial release
+
+### Added
+- Initial release.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index e5a6445..6a5ea08 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -39,7 +39,7 @@ This Code of Conduct applies within all community spaces, and also applies when
## Enforcement
-Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at kali@teamshares.com. All complaints will be reviewed and investigated promptly and fairly.
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at oss@teamshares.com. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
diff --git a/Gemfile b/Gemfile
index c65c53d..e932eec 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,7 +5,7 @@ source "https://rubygems.org"
# Specify your gem's dependencies in declarative_initialization.gemspec
gemspec
-gem "pry-byebug", "~> 3.11.0"
+gem "pry-byebug", "~> 3.12.0"
gem "rake", "~> 13.0"
gem "rspec", "~> 3.0"
gem "rubocop", "~> 1.21"
diff --git a/README.md b/README.md
index ab0cac9..4879d90 100644
--- a/README.md
+++ b/README.md
@@ -1,106 +1,175 @@
# DeclarativeInitialization
-Boilerplate slows down devs and irritates everyone, plus the added cruft makes it harder to scan for the actual logic in a given file.
+Declare a class’s keyword inputs once and get a keyword-only `initialize` with assignments, readers, and helpful argument errors.
-This is a small layer to support declarative initialization _specifically for simple keyword-based classes_.
+- **Keyword-only initializer**: rejects positional args and unknown keywords
+- **Assignments**: sets `@keyword` instance variables from declared inputs
+- **Readers**: defines `attr_reader` for each input (and a `block` reader)
+- **Defaults**: supports optional keywords with default values
+- **No dependencies**: plain Ruby (\(>= 3.0\))
-## Usage
+## When to use it
+
+Use this when you have small POROs that take keyword inputs and you’re tired of repeating the same initializer boilerplate:
+
+- **Service / command objects** that take dependencies and parameters
+- **Value objects** with a fixed set of attributes
+- **Configuration objects** with a handful of optional flags
+- **Components / presenters** that accept a stable set of inputs
+
+If you need complex inheritance initialization, multiple initializer “shapes”, or highly dynamic defaults, a handwritten `initialize` may be clearer.
-Given a standard ruby class like so:
+## Installation
+
+Add to your Gemfile:
```ruby
-class SomeObject
- def initialize(foo:, bar:, baz: "default value")
- @foo = foo
- @bar = bar
- @baz = baz
- end
+gem "declarative_initialization"
+```
+
+Then install:
+
+```bash
+bundle install
+```
+
+In non-Bundler contexts, require it directly:
+
+```ruby
+require "declarative_initialization"
+```
+
+## Quick start
- attr_reader :foo, :bar, :baz
+```ruby
+class UserGreeter
+ include DeclarativeInitialization
+
+ initialize_with :user
+
+ def call
+ "Hello, #{user.name}!"
+ end
end
+
+UserGreeter.new(user: current_user).call
+# => "Hello, Alice!"
+
+UserGreeter.new
+# ArgumentError: [UserGreeter] Missing keyword argument(s): user
+
+UserGreeter.new(user: current_user, extra: true)
+# ArgumentError: [UserGreeter] Unknown keyword argument(s): extra
```
-With this library it can be simplified to:
+## Usage
+
+### Required vs optional keywords (defaults)
+
+Declare required keywords as symbols, and optional keywords as keyword arguments:
```ruby
-class SomeObject
+class Search
include DeclarativeInitialization
- initialize_with :foo, :bar, baz: "default value"
+ initialize_with :query, limit: 10, order: :desc
+
+ def call
+ results = perform_search(query).take(limit)
+ order == :desc ? results.reverse : results
+ end
end
+
+Search.new(query: "ruby").call
+Search.new(query: "ruby", limit: 50).call
```
-## Quick note on naming
-The gem name is `declarative_initialization` because there's already a very outdated gem claiming the `initialize_with` name.
+### Post-initialize hook
-We've set up an alias, however, so you can do either `include DeclarativeInitialization` _or_ `include InitializeWith`.
+Pass a block to `initialize_with` to run code after assignments. The block runs in the instance context.
-FWIW in practice at Teamshares we just `include DeclarativeInitialization` in the base class for all our View Components.
+```ruby
+class Rectangle
+ include DeclarativeInitialization
+
+ initialize_with :width, :height do
+ raise ArgumentError, "Dimensions must be positive" if width <= 0 || height <= 0
+ @area = width * height
+ end
+
+ attr_reader :area
+end
+```
-### Custom logic
+### Capturing a block passed to `.new`
-Sometimes the existing `initialize` method also does other work, for instance setting initial values for additional instance variables that aren't passed in directly.
+If the caller passes a block to `.new`, it’s stored in `@block` and available via the `block` reader.
-We support that by passing an optional block to `initialize_with` -- for instance, in the example above if the original version also set `@bang = foo * bar`, we could support that by changing the updated version to:
+```ruby
+class Wrapper
+ include DeclarativeInitialization
- ```ruby
- initialize_with :foo, :bar, baz: "default value" do
- @bang = @foo * @bar
+ initialize_with :tag
+
+ def render
+ "<#{tag}>#{block&.call}#{tag}>"
end
- ```
+end
+
+Wrapper.new(tag: "div") { "Content" }.render
+# => "
Content
"
+```
+
+## Behavior notes / gotchas
-### Edge cases
+### Only keyword arguments are accepted
-* Accepting a block: this is handled automatically -- if a block was provided to the Foo.new call, it'll be made available as `@block`/`attr_reader :block`
+The generated initializer is keyword-only. Passing positional arguments raises an `ArgumentError`.
-* If a method with same name already exists, we log a warning and do not create the `attr_reader`. In that case you'll need to reference the instance variable directly.
+### Readers are public by default
- * Because of this, best practice when referencing variables in the post-initialize block is to use `@foo` rather than relying on the `foo` attr_reader
+Inputs are exposed with `attr_reader`. If you prefer private readers, make them private after the declaration:
-* Due to ruby syntax limitations, we do not support referencing other fields directly in the declaration:
+```ruby
+class Example
+ include DeclarativeInitialization
+ initialize_with :user, admin: false
- * Does _not_ work:
- ```ruby
- initialize_with :user, company: user.employer
- ```
- * Workaround:
- ```ruby
- initialize_with :user, company: nil do
- @company ||= @user.employer
- end
- ```
+ private :user, :admin
+end
+```
-* If using `initialize_with` on a subclass where the superclass defines `initialize`, we will _not_ automatically call `super`, because if we do we get this `RuntimeError`:
- > implicit argument passing of super from method defined by define_method() is not supported. Specify all arguments explicitly.
+### Defaults are literal values
-* If you need to call `super` from the block passed into `initialize_with` (unusual edge case, subclass requires different arguments than parent):
+Defaults are applied when the caller omits that keyword. For common mutable defaults (`Array`, `Hash`, `Set`, `String`), the value is duplicated per instance (shallow). If you need deeper setup (or derived values), use the post-initialize block.
- * Does _not_ work (due to `instance_exec` changing execution context but _not_ the method lookup chain):
- ```ruby
- initialize_with :foo do
- super(bar: 123)
- end
- ```
- * Workaround _possible_ (but really, probably more understandable to just fall back to manually writing `def initialize`):
- ```ruby
- initialize_with :foo do
- parent_initialize = method(:initialize).super_method
- parent_initialize.call(bar: 123)
- end
- ```
+### Method name conflicts
-* If you find yourself backed into a weird corner, just use a plain ole `def initialize`! This library is meant to make the easy cases less work, but there's no requirement that you must use it for every super complex case you run into. :)
+`initialize_with` defines readers for each declared input (and `block`). If a method with the same name already exists, it will be overridden.
-## Development
+In Rails development/test (or when your logger level allows it), the gem logs a warning when it overrides an existing method.
-After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
+### Referencing other inputs in defaults
-To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
+You can’t reference one declared input from another input’s default at declaration time:
-## Contributing
+```ruby
+initialize_with :user, account: user.account # user is not available here
+```
-Bug reports and pull requests are welcome on GitHub at https://github.com/teamshares/declarative_initialization. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/teamshares/declarative_initialization/blob/main/CODE_OF_CONDUCT.md).
+Use the post-initialize block instead:
-## Code of Conduct
+```ruby
+initialize_with :user, account: nil do
+ @account ||= user.account
+end
+```
+
+### Inheritance and `super`
+
+`initialize_with` generates an `initialize` method. If a subclass calls `initialize_with`, it replaces the parent initializer and **does not call `super`**. Prefer a single initializer per hierarchy, or avoid this gem for complex inheritance chains.
+
+## Contributing
-Everyone interacting in the DeclarativeInitialization project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/teamshares/declarative_initialization/blob/main/CODE_OF_CONDUCT.md).
+- **Source**: [teamshares/declarative_initialization](https://github.com/teamshares/declarative_initialization)
+- **Code of conduct**: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)
diff --git a/Rakefile b/Rakefile
index cca7175..10f9c36 100644
--- a/Rakefile
+++ b/Rakefile
@@ -10,3 +10,7 @@ require "rubocop/rake_task"
RuboCop::RakeTask.new
task default: %i[spec rubocop]
+
+# Require default to pass before release. This relies on the default gem release task
+# (from bundler/gem_tasks) depending on "build"; default runs before build, so before push.
+Rake::Task["build"].enhance([:default])
diff --git a/lib/declarative_initialization.rb b/lib/declarative_initialization.rb
index f838ae0..c33878d 100644
--- a/lib/declarative_initialization.rb
+++ b/lib/declarative_initialization.rb
@@ -1,23 +1,14 @@
# frozen_string_literal: true
require_relative "declarative_initialization/version"
+require_relative "declarative_initialization/internal"
require_relative "declarative_initialization/class_methods"
-require_relative "declarative_initialization/instance_methods"
module DeclarativeInitialization
def self.included(base)
- base.class_eval do
- include InstanceMethods
- extend ClassMethods
- end
+ base.extend ClassMethods
end
end
-# Set up an alias so you can also do `include InitializeWith`
-module InitializeWith
- def self.included(base)
- base.class_eval do
- include DeclarativeInitialization
- end
- end
-end
+# Alias so you can also do `include InitializeWith`
+InitializeWith = DeclarativeInitialization
diff --git a/lib/declarative_initialization/class_methods.rb b/lib/declarative_initialization/class_methods.rb
index 3726820..02647d8 100644
--- a/lib/declarative_initialization/class_methods.rb
+++ b/lib/declarative_initialization/class_methods.rb
@@ -1,74 +1,57 @@
# frozen_string_literal: true
-require "logger"
+require "set"
module DeclarativeInitialization
module ClassMethods
# Defines an initializer expecting the specified keyword arguments.
# @param args [Array] Required keyword arguments
- # @param kwargs [Hash] Optional keyword arguments (required, but have default values)
- # @param post_initialize_block [Proc] Block to execute after initialization (optional)
- def initialize_with(*args, **kwargs, &post_initialize_block)
+ # @param kwargs [Hash] Optional keyword arguments with default values
+ # @param post_initialize [Proc] Block to execute after initialization (optional)
+ def initialize_with(*args, **kwargs, &post_initialize)
declared = args + kwargs.keys
- _validate_arguments!(declared)
-
- _set_up_attribute_readers(declared)
- _set_up_block_reader
- _define_initializer(declared, kwargs, post_initialize_block)
+ Internal.validate_arguments!(self, declared)
+ declared.each { |key| _define_reader(key) }
+ _define_reader(:block, block_reader: true)
+ _define_generated_initializer(declared, kwargs, post_initialize)
end
private
- def _class_name
- name || "Anonymous Class"
+ def _declared_readers
+ @_declared_readers ||= Set.new
end
- def _logger
- @_logger ||= if defined?(Rails) && Rails.respond_to?(:logger)
- Rails.logger
- else
- logger = Logger.new($stdout)
- logger.level = Logger::WARN
- logger
- end
+ def _ancestor_declared_reader?(key)
+ ancestors.drop(1).any? do |ancestor|
+ ancestor.instance_variable_get(:@_declared_readers)&.include?(key)
+ end
end
- def _validate_arguments!(declared)
- return if declared.all? { |arg| arg.is_a?(Symbol) }
+ def _define_reader(key, block_reader: false)
+ return if _declared_readers.include?(key)
+ return if _ancestor_declared_reader?(key)
- raise ArgumentError, "[#{_class_name}] All arguments to #initialize_with must be symbols"
- end
-
- def _set_up_attribute_readers(declared)
- declared.each do |key|
- if method_defined?(key)
- _logger.warn "[#{_class_name}] Method ##{key} already exists -- skipping attr_reader generation"
- else
- attr_reader key
- end
- end
- end
+ Internal.warn_override(self, key, block_reader: block_reader) if method_defined?(key)
- def _set_up_block_reader
- if method_defined?(:block)
- _logger.warn "[#{_class_name}] Method #block already exists -- may NOT be able to reference a block " \
- "passed to #new as #block (use @block instead)"
- else
- attr_reader :block
- end
+ _declared_readers.add(key)
+ attr_reader key
end
- def _define_initializer(declared, defaults, post_initialize_block)
+ def _define_generated_initializer(declared, defaults, post_initialize)
define_method(:initialize) do |*given_args, **given_kwargs, &given_block|
- class_name = self.class.name || "Anonymous Class"
- _validate_initialization_arguments!(class_name, given_args, given_kwargs, declared, defaults)
+ Internal.validate_initialization_arguments!(self.class, given_args, given_kwargs, declared, defaults)
- declared.each do |key|
- instance_variable_set(:"@#{key}", given_kwargs.fetch(key, defaults[key]))
+ defaults.each do |key, value|
+ next if given_kwargs.key?(key)
+
+ instance_variable_set(:"@#{key}", Internal.copy_default(value))
end
+ given_kwargs.each { |key, value| instance_variable_set(:"@#{key}", value) }
+
instance_variable_set(:@block, given_block) if given_block
- instance_exec(&post_initialize_block) if post_initialize_block
+ instance_exec(&post_initialize) if post_initialize
end
end
end
diff --git a/lib/declarative_initialization/instance_methods.rb b/lib/declarative_initialization/instance_methods.rb
deleted file mode 100644
index 8acce38..0000000
--- a/lib/declarative_initialization/instance_methods.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module DeclarativeInitialization
- module InstanceMethods
- private
-
- def _validate_initialization_arguments!(class_name, given_args, given_kwargs, declared, defaults)
- raise ArgumentError, "[#{class_name}] Only keyword arguments are accepted" unless given_args.empty?
-
- missing = declared - given_kwargs.keys - defaults.keys
- extra = given_kwargs.keys - declared
-
- raise ArgumentError, "[#{class_name}] Missing keyword argument(s): #{missing.join(", ")}" unless missing.empty?
- raise ArgumentError, "[#{class_name}] Unknown keyword argument(s): #{extra.join(", ")}" unless extra.empty?
- end
- end
-end
diff --git a/lib/declarative_initialization/internal.rb b/lib/declarative_initialization/internal.rb
new file mode 100644
index 0000000..adcb82e
--- /dev/null
+++ b/lib/declarative_initialization/internal.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "logger"
+require "set"
+
+module DeclarativeInitialization
+ # Internal helpers that don't need to be injected into user classes.
+ # All methods are module functions - stateless and callable as Internal.method_name
+ module Internal
+ module_function
+
+ def logger
+ @logger ||= if defined?(Rails) && Rails.respond_to?(:logger)
+ Rails.logger
+ else
+ Logger.new($stdout).tap { |l| l.level = Logger::WARN }
+ end
+ end
+
+ def display_name(klass)
+ klass.name || "Anonymous Class"
+ end
+
+ def format_message(klass, message)
+ "[#{display_name(klass)}] #{message}"
+ end
+
+ def validate_arguments!(klass, declared)
+ return if declared.all?(Symbol)
+
+ raise ArgumentError, format_message(klass, "All arguments to #initialize_with must be symbols")
+ end
+
+ def validate_initialization_arguments!(klass, given_args, given_kwargs, declared, defaults)
+ raise ArgumentError, format_message(klass, "Only keyword arguments are accepted") unless given_args.empty?
+
+ missing = declared - given_kwargs.keys - defaults.keys
+ unless missing.empty?
+ raise ArgumentError, format_message(klass, "Missing keyword argument(s): #{missing.join(", ")}")
+ end
+
+ extra = given_kwargs.keys - declared
+ return if extra.empty?
+
+ raise ArgumentError, format_message(klass, "Unknown keyword argument(s): #{extra.join(", ")}")
+ end
+
+ def warn_override(klass, key, block_reader:)
+ return unless warn_override?
+
+ location = override_location(klass, key)
+ reader_type = block_reader ? "block" : "init-arg"
+ logger.warn format_message(klass,
+ "Method ##{key} already exists #{location} -- overriding with #{reader_type} reader")
+ end
+
+ def warn_override?
+ return true if defined?(Rails) && Rails.respond_to?(:env) && (Rails.env.development? || Rails.env.test?)
+
+ logger.level <= Logger::DEBUG
+ end
+
+ def override_location(klass, key)
+ return "on this class" if klass.method_defined?(key, false)
+
+ owner = klass.instance_method(key).owner
+ "in #{owner.name || "an anonymous ancestor"}"
+ end
+
+ # Defensive copy for common mutable default values.
+ #
+ # Defaults passed to `initialize_with` are created once at class definition
+ # time. Without copying, `[]` / `{}` / `Set.new` defaults can be shared across
+ # instances and accidentally mutated.
+ #
+ # This is intentionally shallow, and only for common core mutable types.
+ def copy_default(value)
+ return value if value.nil? || value.frozen?
+
+ case value
+ when Array, Hash, Set, String
+ value.dup
+ else
+ value
+ end
+ end
+ end
+end
diff --git a/lib/declarative_initialization/version.rb b/lib/declarative_initialization/version.rb
index 1ac1755..097f610 100644
--- a/lib/declarative_initialization/version.rb
+++ b/lib/declarative_initialization/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module DeclarativeInitialization
- VERSION = "0.1.1"
+ VERSION = "0.2.0"
end
diff --git a/spec/alias_spec.rb b/spec/alias_spec.rb
index 613e4ef..76bbbe7 100644
--- a/spec/alias_spec.rb
+++ b/spec/alias_spec.rb
@@ -4,6 +4,7 @@
let(:klass) do
Class.new do
include InitializeWith
+
initialize_with :foo, bar: "default value"
end
end
diff --git a/spec/basic_interface_spec.rb b/spec/basic_interface_spec.rb
index 14df614..24049a1 100644
--- a/spec/basic_interface_spec.rb
+++ b/spec/basic_interface_spec.rb
@@ -37,6 +37,7 @@
let(:base_klass) do
Class.new do
include DeclarativeInitialization
+
initialize_with :foo, bar: "default value"
end
end
diff --git a/spec/block_argument_spec.rb b/spec/block_argument_spec.rb
index 8ae65f0..36699e9 100644
--- a/spec/block_argument_spec.rb
+++ b/spec/block_argument_spec.rb
@@ -4,6 +4,7 @@
let(:klass) do
Class.new do
include DeclarativeInitialization
+
initialize_with :foo
end
end
diff --git a/spec/defaults_spec.rb b/spec/defaults_spec.rb
index 3f09fe4..0b2d42c 100644
--- a/spec/defaults_spec.rb
+++ b/spec/defaults_spec.rb
@@ -7,6 +7,7 @@
let(:klass) do
Class.new do
include DeclarativeInitialization
+
initialize_with :foo, bar: foo
end
end
@@ -18,6 +19,7 @@
let(:klass) do
Class.new do
include DeclarativeInitialization
+
initialize_with :foo, bar: nil do
@bar ||= foo
end
diff --git a/spec/downstream_inheritance_spec.rb b/spec/downstream_inheritance_spec.rb
index 28b0dbc..4e807e3 100644
--- a/spec/downstream_inheritance_spec.rb
+++ b/spec/downstream_inheritance_spec.rb
@@ -6,6 +6,7 @@
let(:base_klass) do
Class.new do
include DeclarativeInitialization
+
initialize_with :foo, bar: "default value"
end
end
diff --git a/spec/post_initialize_block_spec.rb b/spec/post_initialize_block_spec.rb
index 7f9a981..6003b60 100644
--- a/spec/post_initialize_block_spec.rb
+++ b/spec/post_initialize_block_spec.rb
@@ -4,6 +4,7 @@
let(:klass) do
Class.new do
include DeclarativeInitialization
+
initialize_with :foo, bar: "default value" do
@baz = foo
end
diff --git a/spec/reader_spec.rb b/spec/reader_spec.rb
index 2ca6122..49d54ad 100644
--- a/spec/reader_spec.rb
+++ b/spec/reader_spec.rb
@@ -7,6 +7,7 @@
let(:klass) do
Class.new do
include DeclarativeInitialization
+
initialize_with :foo
def foo = @foo * 100
@@ -20,6 +21,7 @@ def foo = @foo * 100
let(:klass) do
Class.new do
include DeclarativeInitialization
+
initialize_with :foo do
@foo *= 100
end
@@ -33,6 +35,7 @@ def foo = @foo * 100
let(:klass) do
Class.new do
include DeclarativeInitialization
+
initialize_with :foo do
@foo = foo * 100
end
@@ -42,27 +45,368 @@ def foo = @foo * 100
it { expect(subject.foo).to eq(100) }
end
- describe "if method already exists" do
+ describe "defaults" do
let(:klass) do
Class.new do
- def foo = "original"
include DeclarativeInitialization
+
+ initialize_with items: []
end
end
- let(:logger) { instance_double(Logger) }
+ it "duplicates mutable default values per instance" do
+ a = klass.new
+ b = klass.new
+
+ expect(a.items).to eq([])
+ expect(b.items).to eq([])
+ expect(a.items).not_to be(b.items)
+ end
+
+ it "does not duplicate caller-provided values" do
+ provided = []
+ instance = klass.new(items: provided)
+
+ expect(instance.items).to be(provided)
+ end
+ end
+
+ # =============================================================================
+ # OVERRIDE BEHAVIOR AND OPTIONAL WARNING COVERAGE
+ # =============================================================================
+ #
+ # Matrix of scenarios for attribute readers:
+ # OVERRIDE + OPTIONAL WARN cases:
+ # - User method on THIS class before initialize_with
+ # - User method on ANCESTOR (inherited, not our reader)
+ # NO-WARN + NO-OVERRIDE cases:
+ # - Reload (we defined it on this class)
+ # - Inherited from ancestor's initialize_with (our reader)
+ # - User method defined AFTER initialize_with (user's method wins)
+ # - No conflicting method
+ #
+ # Same matrix applies to :block reader
+ # =============================================================================
+
+ describe "override and warning scenarios" do
+ let(:logger) { instance_double(Logger, level: Logger::DEBUG) }
before do
- allow(klass).to receive(:_logger).and_return(logger)
- expect(logger).to receive(:warn).with(
- "[Anonymous Class] Method #foo already exists -- skipping attr_reader generation"
- )
- klass.initialize_with(:foo)
+ allow(DeclarativeInitialization::Internal).to receive(:logger).and_return(logger)
+ allow(DeclarativeInitialization::Internal).to receive(:warn_override?).and_return(true)
+ end
+
+ def attr_override_warning(key, location: "on this class")
+ "[Anonymous Class] Method ##{key} already exists #{location} -- overriding with init-arg reader"
+ end
+
+ def block_override_warning(location: "on this class")
+ "[Anonymous Class] Method #block already exists #{location} -- overriding with block reader"
+ end
+
+ # =========================================================================
+ # OVERRIDE + OPTIONAL WARN CASES
+ # =========================================================================
+
+ describe "user method on THIS class before initialize_with" do
+ let(:klass) do
+ Class.new do
+ def foo = "user-defined"
+ include DeclarativeInitialization
+ end
+ end
+
+ it "WARNS when overriding" do
+ expect(logger).to receive(:warn).with(attr_override_warning(:foo))
+ klass.initialize_with(:foo)
+ end
+
+ it "overrides user method, foo returns init-arg value" do
+ allow(logger).to receive(:warn)
+ klass.initialize_with(:foo)
+ instance = klass.new(foo: 123)
+ expect(instance.foo).to eq(123)
+ end
+ end
+
+ describe "user method on ANCESTOR (inherited)" do
+ let(:parent_klass) do
+ Class.new do
+ def foo = "parent custom"
+ end
+ end
+
+ let(:klass) { Class.new(parent_klass) { include DeclarativeInitialization } }
+
+ it "WARNS when overriding with ancestor class name (anonymous)" do
+ expect(logger).to receive(:warn).with(attr_override_warning(:foo, location: "in an anonymous ancestor"))
+ klass.initialize_with(:foo)
+ end
+
+ it "overrides inherited method, foo returns init-arg value" do
+ allow(logger).to receive(:warn)
+ klass.initialize_with(:foo)
+ instance = klass.new(foo: 123)
+ expect(instance.foo).to eq(123)
+ end
+ end
+
+ describe ":block reader with user #block on THIS class" do
+ let(:klass) do
+ Class.new do
+ def block = "user block"
+ include DeclarativeInitialization
+ end
+ end
+
+ it "WARNS when overriding with block-specific message" do
+ expect(logger).to receive(:warn).with(block_override_warning)
+ klass.initialize_with(:foo)
+ end
+
+ it "overrides user's #block, returns block passed to new" do
+ allow(logger).to receive(:warn)
+ klass.initialize_with(:foo)
+ my_block = proc { "test" }
+ instance = klass.new(foo: 1, &my_block)
+ expect(instance.block).to eq(my_block)
+ end
+ end
+
+ describe ":block reader with user #block on ANCESTOR" do
+ let(:parent_klass) do
+ Class.new do
+ def block = "parent block"
+ end
+ end
+
+ let(:klass) { Class.new(parent_klass) { include DeclarativeInitialization } }
+
+ it "WARNS when overriding with ancestor class name (anonymous)" do
+ expect(logger).to receive(:warn).with(block_override_warning(location: "in an anonymous ancestor"))
+ klass.initialize_with(:foo)
+ end
+
+ it "overrides inherited #block, returns block passed to new" do
+ allow(logger).to receive(:warn)
+ klass.initialize_with(:foo)
+ my_block = proc { "test" }
+ instance = klass.new(foo: 1, &my_block)
+ expect(instance.block).to eq(my_block)
+ end
+ end
+
+ describe "multiple attributes, some conflict" do
+ let(:klass) do
+ Class.new do
+ def bar = "user bar"
+ include DeclarativeInitialization
+ end
+ end
+
+ it "WARNS only for conflicting attribute" do
+ expect(logger).to receive(:warn).with(attr_override_warning(:bar)).once
+ klass.initialize_with(:foo, :bar, baz: "default")
+ end
+
+ it "overrides conflicting, all attributes return init-arg values" do
+ allow(logger).to receive(:warn)
+ klass.initialize_with(:foo, :bar, baz: "default")
+ instance = klass.new(foo: 1, bar: 2, baz: 3)
+ expect(instance.foo).to eq(1)
+ expect(instance.bar).to eq(2)
+ expect(instance.baz).to eq(3)
+ end
+ end
+
+ # =========================================================================
+ # NO-WARN CASES
+ # =========================================================================
+
+ describe "no conflicting method (normal case)" do
+ let(:klass) { Class.new { include DeclarativeInitialization } }
+
+ it "does NOT warn" do
+ expect(logger).not_to receive(:warn)
+ klass.initialize_with(:foo, :bar, baz: "default")
+ end
+
+ it "defines all readers" do
+ klass.initialize_with(:foo, :bar, baz: "default")
+ instance = klass.new(foo: 1, bar: 2)
+ expect(instance).to have_attributes(foo: 1, bar: 2, baz: "default")
+ expect(instance).to respond_to(:block)
+ end
end
- it "does not create attr_reader " do
- expect(subject.foo).to eq("original")
- expect(subject.instance_variable_get("@foo")).to eq(1)
+ describe "user method defined AFTER initialize_with" do
+ let(:klass) do
+ Class.new do
+ include DeclarativeInitialization
+
+ initialize_with :foo
+
+ def foo = "user-override-after"
+ end
+ end
+
+ it "does NOT warn (we define first)" do
+ expect(logger).not_to receive(:warn)
+ end
+
+ it "user's method takes precedence" do
+ instance = klass.new(foo: 123)
+ expect(instance.foo).to eq("user-override-after")
+ expect(instance.instance_variable_get("@foo")).to eq(123)
+ end
+ end
+
+ describe "reload: initialize_with called twice" do
+ let(:klass) { Class.new { include DeclarativeInitialization } }
+
+ it "does NOT warn on second call" do
+ expect(logger).not_to receive(:warn)
+ klass.initialize_with(:foo)
+ klass.initialize_with(:foo)
+ end
+
+ it "still works after reload" do
+ klass.initialize_with(:foo)
+ klass.initialize_with(:foo)
+ expect(klass.new(foo: 42).foo).to eq(42)
+ end
+
+ it "does NOT warn when adding attributes on reload" do
+ expect(logger).not_to receive(:warn)
+ klass.initialize_with(:foo)
+ klass.initialize_with(:foo, :bar)
+ end
+
+ it ":block reader also doesn't warn on reload" do
+ expect(logger).not_to receive(:warn)
+ klass.initialize_with(:foo)
+ klass.initialize_with(:foo)
+ my_block = proc { "test" }
+ expect(klass.new(foo: 1, &my_block).block).to eq(my_block)
+ end
+ end
+
+ describe "inherited from ancestor's initialize_with" do
+ let(:parent_klass) do
+ Class.new do
+ include DeclarativeInitialization
+
+ initialize_with :foo
+ end
+ end
+
+ let(:klass) { Class.new(parent_klass) }
+
+ it "does NOT warn when re-declaring parent's attribute" do
+ expect(logger).not_to receive(:warn)
+ klass.initialize_with(:foo, :bar)
+ end
+
+ it "child uses parent's reader" do
+ klass.initialize_with(:foo, :bar)
+ expect(klass.instance_method(:foo).owner).to eq(parent_klass)
+ end
+
+ it "works correctly" do
+ klass.initialize_with(:foo, :bar)
+ expect(klass.new(foo: 1, bar: 2)).to have_attributes(foo: 1, bar: 2)
+ end
+ end
+
+ describe "grandchild with ancestor chain using initialize_with" do
+ let(:grandparent_klass) do
+ Class.new do
+ include DeclarativeInitialization
+
+ initialize_with :foo
+ end
+ end
+
+ let(:parent_klass) do
+ Class.new(grandparent_klass) do
+ initialize_with :foo, :bar
+ end
+ end
+
+ let(:klass) { Class.new(parent_klass) }
+
+ it "does NOT warn when re-declaring ancestors' attributes" do
+ expect(logger).not_to receive(:warn)
+ klass.initialize_with(:foo, :bar, :baz)
+ end
+
+ it "works correctly with all attributes" do
+ klass.initialize_with(:foo, :bar, :baz)
+ expect(klass.new(foo: 1, bar: 2, baz: 3)).to have_attributes(foo: 1, bar: 2, baz: 3)
+ end
+ end
+
+ # =========================================================================
+ # MIXED CASES
+ # =========================================================================
+
+ describe "user method on named ancestor class" do
+ before do
+ stub_const("NamedParent", Class.new do
+ def foo = "named parent method"
+ end)
+ end
+
+ let(:klass) { Class.new(NamedParent) { include DeclarativeInitialization } }
+
+ it "WARNS when overriding with actual class name" do
+ expect(logger).to receive(:warn).with(attr_override_warning(:foo, location: "in NamedParent"))
+ klass.initialize_with(:foo)
+ end
+
+ it "overrides ancestor method, foo returns init-arg value" do
+ allow(logger).to receive(:warn)
+ klass.initialize_with(:foo)
+ instance = klass.new(foo: 123)
+ expect(instance.foo).to eq(123)
+ end
+ end
+
+ describe "grandparent user method, parent/child use initialize_with" do
+ let(:grandparent_klass) do
+ Class.new do
+ def foo = "grandparent custom"
+ end
+ end
+
+ let(:parent_klass) do
+ Class.new(grandparent_klass) do
+ include DeclarativeInitialization
+ end
+ end
+
+ let(:klass) { Class.new(parent_klass) }
+
+ it "parent WARNS when overriding with grandparent class name (anonymous)" do
+ expect(logger).to receive(:warn).with(attr_override_warning(:foo, location: "in an anonymous ancestor"))
+ parent_klass.initialize_with(:foo)
+ end
+
+ it "child does NOT warn because parent already has our reader" do
+ allow(logger).to receive(:warn)
+ parent_klass.initialize_with(:foo)
+ expect(logger).not_to receive(:warn)
+ klass.initialize_with(:foo, :bar)
+ end
+
+ it "parent's reader is used, init-arg value returned" do
+ allow(logger).to receive(:warn)
+ parent_klass.initialize_with(:foo)
+ klass.initialize_with(:foo, :bar)
+ instance = klass.new(foo: 1, bar: 2)
+ expect(instance.foo).to eq(1)
+ expect(instance.bar).to eq(2)
+ end
end
end
end