diff --git a/.gitignore b/.gitignore index b04a8c8..3e32e85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Ignore bundler lock file for gems +Gemfile.lock + /.bundle/ /.yardoc /_yardoc/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2ce94..0f12a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## [Unreleased] -## [0.1.0] - 2025-03-05 +## [0.1.1] - 2025-05-02 +- Refactor internals +- [BUGFIX] Only trigger `attr_reader` creation on initial class load (vs on every call to `#new`) + +## [0.1.0] - 2025-03-05 - Initial release diff --git a/Gemfile b/Gemfile index dae1364..c65c53d 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.10.1" +gem "pry-byebug", "~> 3.11.0" gem "rake", "~> 13.0" gem "rspec", "~> 3.0" gem "rubocop", "~> 1.21" diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 2832ce5..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,72 +0,0 @@ -PATH - remote: . - specs: - declarative_initialization (0.1.0) - -GEM - remote: https://rubygems.org/ - specs: - ast (2.4.2) - byebug (11.1.3) - coderay (1.1.3) - diff-lcs (1.5.1) - json (2.9.1) - language_server-protocol (3.17.0.4) - method_source (1.1.0) - parallel (1.26.3) - parser (3.3.7.1) - ast (~> 2.4.1) - racc - pry (0.14.2) - coderay (~> 1.1) - method_source (~> 1.0) - pry-byebug (3.10.1) - byebug (~> 11.0) - pry (>= 0.13, < 0.15) - racc (1.8.1) - rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.10.0) - rspec (3.13.0) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.2) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.2) - rubocop (1.70.0) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.36.2, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.38.0) - parser (>= 3.3.1.0) - ruby-progressbar (1.13.0) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - -PLATFORMS - arm64-darwin-23 - ruby - -DEPENDENCIES - declarative_initialization! - pry-byebug (~> 3.10.1) - rake (~> 13.0) - rspec (~> 3.0) - rubocop (~> 1.21) - -BUNDLED WITH - 2.5.6 diff --git a/README.md b/README.md index 6cb980e..ab0cac9 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ We support that by passing an optional block to `initialize_with` -- for instanc * 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. - * Because of this, best practice when referencing variables in the post-initialize block is use `@foo` rather than relying on the `foo` attr_reader + * 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 * Due to ruby syntax limitations, we do not support referencing other fields directly in the declaration: diff --git a/declarative_initialization.gemspec b/declarative_initialization.gemspec index f0c78d2..1372560 100644 --- a/declarative_initialization.gemspec +++ b/declarative_initialization.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |spec| spec.homepage = "https://github.com/teamshares/declarative_initialization" spec.required_ruby_version = ">= 3.0.0" - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/teamshares/declarative_initialization" diff --git a/lib/declarative_initialization.rb b/lib/declarative_initialization.rb index 16bdb72..f838ae0 100644 --- a/lib/declarative_initialization.rb +++ b/lib/declarative_initialization.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true require_relative "declarative_initialization/version" -require "logger" +require_relative "declarative_initialization/class_methods" +require_relative "declarative_initialization/instance_methods" module DeclarativeInitialization def self.included(base) @@ -10,52 +11,6 @@ def self.included(base) extend ClassMethods end end - - module ClassMethods - def initialize_with(*args, **kwargs, &post_initialize_block) - declared = args + kwargs.keys - raise ArgumentError, "initialize_with expects to receive symbols" unless declared.all? { |arg| arg.is_a?(Symbol) } - - defaults = kwargs - - define_method(:initialize) do |*given_args, **given_kwargs, &block| - class_name = self.class.name || "Anonymous Class" - raise ArgumentError, "[#{class_name}] Only accepts keyword arguments" unless given_args.empty? - - missing = declared - given_kwargs.keys - defaults.keys - extra = given_kwargs.keys - declared - - raise ArgumentError, "[#{class_name}] Missing keyword arguments: #{missing.join(", ")}" unless missing.empty? - raise ArgumentError, "[#{class_name}] Unknown keyword arguments: #{extra.join(", ")}" unless extra.empty? - - declared.each do |key| - instance_variable_set(:"@#{key}", given_kwargs.fetch(key, defaults[key])) - if respond_to?(key, true) - __logger.warn "Method ##{key} already exists on #{self.class.name}. Skipping attr_reader generation." - else - self.class.send(:attr_reader, key) - end - end - - if block # Automatically record any block passed to .new as an instance variable - instance_variable_set(:@block, block) - self.class.send(:attr_reader, :block) unless respond_to?(:block) - end - - instance_exec(&post_initialize_block) if post_initialize_block - end - end - end - - module InstanceMethods - def __logger - @__logger ||= begin - Rails.logger - rescue NameError - Logger.new($stdout) - end - end - end end # Set up an alias so you can also do `include InitializeWith` diff --git a/lib/declarative_initialization/class_methods.rb b/lib/declarative_initialization/class_methods.rb new file mode 100644 index 0000000..3726820 --- /dev/null +++ b/lib/declarative_initialization/class_methods.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "logger" + +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) + declared = args + kwargs.keys + _validate_arguments!(declared) + + _set_up_attribute_readers(declared) + _set_up_block_reader + _define_initializer(declared, kwargs, post_initialize_block) + end + + private + + def _class_name + name || "Anonymous Class" + end + + def _logger + @_logger ||= if defined?(Rails) && Rails.respond_to?(:logger) + Rails.logger + else + logger = Logger.new($stdout) + logger.level = Logger::WARN + logger + end + end + + def _validate_arguments!(declared) + return if declared.all? { |arg| arg.is_a?(Symbol) } + + 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 + + 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 + end + + def _define_initializer(declared, defaults, post_initialize_block) + 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) + + declared.each do |key| + instance_variable_set(:"@#{key}", given_kwargs.fetch(key, defaults[key])) + end + + instance_variable_set(:@block, given_block) if given_block + instance_exec(&post_initialize_block) if post_initialize_block + end + end + end +end diff --git a/lib/declarative_initialization/instance_methods.rb b/lib/declarative_initialization/instance_methods.rb new file mode 100644 index 0000000..8acce38 --- /dev/null +++ b/lib/declarative_initialization/instance_methods.rb @@ -0,0 +1,17 @@ +# 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/version.rb b/lib/declarative_initialization/version.rb index 22bb3bc..1ac1755 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.0" + VERSION = "0.1.1" end diff --git a/spec/basic_interface_spec.rb b/spec/basic_interface_spec.rb index 31dc1ba..14df614 100644 --- a/spec/basic_interface_spec.rb +++ b/spec/basic_interface_spec.rb @@ -18,19 +18,19 @@ describe "when called with missing required arguments" do subject { klass.new(bar: 1) } - it { expect { subject }.to raise_error(ArgumentError, "[Anonymous Class] Missing keyword arguments: foo") } + it { expect { subject }.to raise_error(ArgumentError, "[Anonymous Class] Missing keyword argument(s): foo") } end describe "when called with extra arguments" do subject { klass.new(foo: 1, bar: 2, baz: 3) } - it { expect { subject }.to raise_error(ArgumentError, "[Anonymous Class] Unknown keyword arguments: baz") } + it { expect { subject }.to raise_error(ArgumentError, "[Anonymous Class] Unknown keyword argument(s): baz") } end describe "when called with positional arguments" do subject { klass.new(1, 2) } - it { expect { subject }.to raise_error(ArgumentError, "[Anonymous Class] Only accepts keyword arguments") } + it { expect { subject }.to raise_error(ArgumentError, "[Anonymous Class] Only keyword arguments are accepted") } end end diff --git a/spec/block_argument_spec.rb b/spec/block_argument_spec.rb index 130fa1e..8ae65f0 100644 --- a/spec/block_argument_spec.rb +++ b/spec/block_argument_spec.rb @@ -11,7 +11,6 @@ it "doesn't record block unless given" do record = klass.new(foo: 1) expect(record.instance_variable_get("@block")).to be_nil - expect(record.respond_to?(:block)).to be false end it "does record block when given" do diff --git a/spec/downstream_inheritance_spec.rb b/spec/downstream_inheritance_spec.rb index ceedf8c..28b0dbc 100644 --- a/spec/downstream_inheritance_spec.rb +++ b/spec/downstream_inheritance_spec.rb @@ -19,6 +19,7 @@ it "overrides initialize_with from super" do expect { subject }.not_to raise_error + expect { klass.new(baz: 3, foo: 1, bar: 2) }.to raise_error(ArgumentError) end end diff --git a/spec/reader_spec.rb b/spec/reader_spec.rb index 8ea777b..2ca6122 100644 --- a/spec/reader_spec.rb +++ b/spec/reader_spec.rb @@ -13,16 +13,7 @@ def foo = @foo * 100 end end - let(:log_double) { instance_double(Logger) } - - before do - allow_any_instance_of(klass).to receive(:__logger).and_return(log_double) - end - - it "does not override existing readers" do - expect(log_double).to receive(:warn) - expect(subject.foo).to eq(100) - end + it { expect(subject.foo).to eq(100) } end describe "allows overriding the value read by the attr_reader" do @@ -50,4 +41,28 @@ def foo = @foo * 100 it { expect(subject.foo).to eq(100) } end + + describe "if method already exists" do + let(:klass) do + Class.new do + def foo = "original" + include DeclarativeInitialization + end + end + + let(:logger) { instance_double(Logger) } + + 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) + end + + it "does not create attr_reader " do + expect(subject.foo).to eq("original") + expect(subject.instance_variable_get("@foo")).to eq(1) + end + end end