diff --git a/.rubocop.yml b/.rubocop.yml index 1c90cf3..d5b4ba0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,6 +5,7 @@ AllCops: - "bin/**/*" - "Gemfile" - "Rakefile" + SuggestExtensions: false Metrics/BlockLength: Exclude: diff --git a/CHANGELOG.md b/CHANGELOG.md index fa347af..2149319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.2.0 + +- Add `OptionsDeclaration` mixin. + ## 0.1.1 ### Bug fixes diff --git a/Gemfile.lock b/Gemfile.lock index 07a0fd5..f14251a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,12 +2,23 @@ PATH remote: . specs: stimpack (0.1.0) + activesupport (~> 6.1) GEM remote: https://rubygems.org/ specs: + activesupport (6.1.3) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) ast (2.4.2) + concurrent-ruby (1.1.8) diff-lcs (1.4.4) + i18n (1.8.9) + concurrent-ruby (~> 1.0) + minitest (5.14.4) parallel (1.20.1) parser (3.0.0.0) ast (~> 2.4.1) @@ -40,7 +51,10 @@ GEM rubocop-ast (1.4.1) parser (>= 2.7.1.5) ruby-progressbar (1.11.0) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) unicode-display_width (2.0.0) + zeitwerk (2.4.2) PLATFORMS x86_64-darwin-19 diff --git a/README.md b/README.md index eab2182..b00f4ed 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ and behaviour. ## Table of Contents - [FunctionalObject](#functionalobject) +- [OptionsDeclaration](#optionsdeclaration) ## FunctionalObject @@ -42,3 +43,44 @@ we can now initialize and invoke an instance of `Foo` by calling: Foo.(bar: "Hello world!") #=> "Hello world!" ``` + +## OptionsDeclaration + +A mixin that introduces the concept of an `option`, and lets classes declare +a list options with various configuration options. Declaring an option will: + +1. Add a keyword argument to the class initializer. +2. Assign an instance variable on instantiation. +3. Create an attribute reader (private by default.) + +This lets us collect and condense what would otherwise be scattered throughout +the class definition. + +**Example:** + +Given the following options declaration: + +```ruby +class Foo + include Stimpack::OptionsDeclaration + + option :bar + option :baz, default: [] +end +``` + +we can now instantiate `Foo` as long as we provide the required options: + +```ruby +Foo.new(bar: "Hello!") +``` + +### Configuration options + +When declaring an option, the following configuration kets are available: + +| Configuration | Type | Default | Notes | +| --------------- | ------------ | ------- | ----- | +| `default` | `any` | `nil` | Can be a literal or a callable object. Arrays and hashes will not be shared across instances. | +| `required` | `boolean` | `true` | | +| `private_reader` | `boolean` | `true` | | diff --git a/lib/stimpack.rb b/lib/stimpack.rb index 5a8716f..fb374b5 100644 --- a/lib/stimpack.rb +++ b/lib/stimpack.rb @@ -3,6 +3,7 @@ require_relative "stimpack/version" require_relative "stimpack/functional_object" +require_relative "stimpack/options_declaration" module Stimpack class Error < StandardError; end diff --git a/lib/stimpack/options_declaration.rb b/lib/stimpack/options_declaration.rb new file mode 100644 index 0000000..f58dbf7 --- /dev/null +++ b/lib/stimpack/options_declaration.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +# TODO: Remove dependency on ActiveSupport. +# +require "active_support/core_ext/class/attribute" + +module Stimpack + # This mixin is used to augment classes with a DSL for declaring keyword + # arguments. It is used to cut down on noise for a common initialization + # pattern, which goes: + # + # 1. Declare parameters in `#initialize`. + # 2. Assign attributes to instance variables. + # 3. Add private reader methods. + # + # Example: + # + # # Before + # + # class AccruePoints + # def initialize(user:, amount:) + # @user = user + # @amount = amount + # end + # + # private + # + # attr_reader :user, :amount + # end + # + # # After + # + # class AccruePoints + # option :user + # option :amount + # end + # + module OptionsDeclaration + module ClassMethods + def self.extended(klass) + klass.class_eval do + # TODO: Remove dependency on ActiveSupport. + # + class_attribute :options_configuration, instance_accessor: false, default: {} + end + end + + # Declare a keyword argument for this class. + # + # Example: + # + # class AccruePoints + # option :user + # end + # + def option(*identifiers, required: true, default: nil, private_reader: true) # rubocop:disable Metrics/MethodLength + self.options_configuration = options_configuration.merge( + identifiers.map do |identifier| + [ + identifier.to_sym, + Option.new( + identifier.to_sym, + required: required, + default: default + ) + ] + end.to_h + ) + + identifiers.each do |identifier| + class_eval do + attr_reader identifier.to_sym + + private identifier.to_sym if private_reader + end + end + end + + def options + options_configuration.keys + end + + def required_options + options_configuration.select { |_, option| option.required? }.keys + end + + def optional_options + options_configuration.select { |_, option| option.optional? }.keys + end + end + + # Injects an initializer that assigns options and proxies the call to any + # custom initializer _without_ the declared options included in the call. + # + module OptionsInitializer + def initialize(*_args, **options) + assigner = OptionsAssigner.new(self, options) + assigner.assign_options! + yield self if block_given? + end + + class OptionsAssigner + def initialize(service, options) + @service = service + @options = options + end + + def assign_options! + check_for_missing_options! + + service.class.options_configuration.each_value { |o| assign_option(o) } + end + + private + + attr_reader :service, :options + + def check_for_missing_options! + raise(ArgumentError, <<~ERROR) unless missing_options.empty? + Missing required options: #{missing_options.join(', ')} + ERROR + end + + def assign_option(option) + assigned_value = options[option.name] + + service.instance_variable_set( + "@#{option.name}", + assigned_value.nil? ? option.default_value : assigned_value + ) + end + + def missing_options + required_options - options.keys + end + + def required_options + service.class.required_options + end + end + end + + def self.included(klass) + klass.extend(ClassMethods) + klass.include(OptionsInitializer) + end + + class Option + def initialize(name, required:, default:) + @name = name + @default = default + @required = required + end + + attr_reader :name, :default, :required + + def default_value + default.respond_to?(:call) ? default.() : default + end + + def required? + required && default.nil? + end + + def optional? + !required? + end + end + end +end diff --git a/spec/stimpack/options_declaration_spec.rb b/spec/stimpack/options_declaration_spec.rb new file mode 100644 index 0000000..b4081d2 --- /dev/null +++ b/spec/stimpack/options_declaration_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "stimpack/options_declaration" + +RSpec.describe Stimpack::OptionsDeclaration do + subject(:service) { klass } + + let(:klass) do + Class.new do + include Stimpack::OptionsDeclaration + + option :foo + option :bar, required: false + option :baz, private_reader: false + option :qux, default: "Foo" + option :quux, default: -> { "Bar" } + end + end + + describe ".option" do + it { expect(service.options_configuration.size).to eq(5) } + it { expect(service.options_configuration.values).to all(be_a(described_class::Option)) } + + describe "private_reader (option)" do + let(:public_instance_methods) { service.public_instance_methods(false) } + let(:private_instance_methods) { service.private_instance_methods(false) } + + it { expect(public_instance_methods).to contain_exactly(:baz) } + it { expect(private_instance_methods).to contain_exactly(:foo, :bar, :qux, :quux) } + end + end + + describe ".options" do + it { expect(service.options).to contain_exactly(:foo, :bar, :baz, :qux, :quux) } + end + + describe ".required_options" do + it { expect(service.required_options).to contain_exactly(:foo, :baz) } + end + + describe ".optional_options" do + it { expect(service.optional_options).to contain_exactly(:bar, :qux, :quux) } + end + + describe "#initialize" do + context "when not passing required options" do + it { expect { service.new }.to raise_error(ArgumentError) } + end + + context "when omitting only optional options" do + let(:options) do + { + foo: 1, + baz: 2 + } + end + + it { expect { service.new(**options) }.not_to raise_error } + end + + describe "argument assignment" do + let(:instance) { service.new(**options) } + + let(:options) do + { + foo: 1, + baz: 2 + } + end + + context "when option is assigned explicitly" do + it { expect(instance.send(:foo)).to eq(1) } + it { expect(instance.send(:baz)).to eq(2) } + end + + context "when option with default is assigned explicitly" do + before { options.merge!(qux: 3) } + + it { expect(instance.send(:qux)).to eq(3) } + end + + context "when optional option is assigned through omission" do + it { expect(instance.send(:bar)).to eq(nil) } + end + + context "when default option is assigned by omission" do + it { expect(instance.send(:qux)).to eq("Foo") } + it { expect(instance.send(:quux)).to eq("Bar") } + end + end + end + + describe "#initialize (multiple-layer inheritance)" do + let(:sub_klass) do + Class.new(klass) do + # Override option :foo and add 2 more options. + # + option :lorem, :ipsum, :foo, private_reader: false + + attr_accessor :hello, :world, :qux + + def initialize(hello, world:, **options) + super(**options) + @hello = hello + @world = world + end + end + end + + let(:instance) do + sub_klass.new("hello", world: "world", foo: 1, baz: 2, lorem: 3, ipsum: 4) do |instance| + instance.qux = instance.lorem + instance.ipsum + end + end + + it { expect(instance.hello).to eq("hello") } + it { expect(instance.world).to eq("world") } + it { expect(instance.foo).to eq(1) } + it { expect(instance.baz).to eq(2) } + it { expect(instance.lorem).to eq(3) } + it { expect(instance.ipsum).to eq(4) } + it { expect(instance.qux).to eq(7) } + end +end diff --git a/stimpack.gemspec b/stimpack.gemspec index 52b9827..923c59b 100644 --- a/stimpack.gemspec +++ b/stimpack.gemspec @@ -29,6 +29,10 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + # TODO: Drop ActiveSupport dependency. + # + spec.add_dependency "activesupport", "~> 6.1" + spec.add_development_dependency "rspec", "~> 3.10" spec.add_development_dependency "rubocop", "~> 1.11" end