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}" 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