Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Ignore bundler lock file for gems
Gemfile.lock

/.bundle/
/.yardoc
/_yardoc/
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
72 changes: 0 additions & 72 deletions Gemfile.lock

This file was deleted.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion declarative_initialization.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 2 additions & 47 deletions lib/declarative_initialization.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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`
Expand Down
75 changes: 75 additions & 0 deletions lib/declarative_initialization/class_methods.rb
Original file line number Diff line number Diff line change
@@ -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<Symbol>] Required keyword arguments
# @param kwargs [Hash<Symbol, Object>] 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
17 changes: 17 additions & 0 deletions lib/declarative_initialization/instance_methods.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/declarative_initialization/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module DeclarativeInitialization
VERSION = "0.1.0"
VERSION = "0.1.1"
end
6 changes: 3 additions & 3 deletions spec/basic_interface_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion spec/block_argument_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/downstream_inheritance_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 25 additions & 10 deletions spec/reader_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading