Skip to content

Commit

Permalink
Merge pull request #1 from Kaligo/feature/options-declaration
Browse files Browse the repository at this point in the history
[Feature] Add OptionsDeclaration mixin
  • Loading branch information
Drenmi authored Mar 8, 2021
2 parents 57237bc + c23e80d commit bde3298
Show file tree
Hide file tree
Showing 8 changed files with 360 additions and 0 deletions.
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ AllCops:
- "bin/**/*"
- "Gemfile"
- "Rakefile"
SuggestExtensions: false

Metrics/BlockLength:
Exclude:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.2.0

- Add `OptionsDeclaration` mixin.

## 0.1.1

### Bug fixes
Expand Down
14 changes: 14 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and behaviour.
## Table of Contents

- [FunctionalObject](#functionalobject)
- [OptionsDeclaration](#optionsdeclaration)

## FunctionalObject

Expand Down Expand Up @@ -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` | |
1 change: 1 addition & 0 deletions lib/stimpack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require_relative "stimpack/version"

require_relative "stimpack/functional_object"
require_relative "stimpack/options_declaration"

module Stimpack
class Error < StandardError; end
Expand Down
170 changes: 170 additions & 0 deletions lib/stimpack/options_declaration.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit bde3298

Please sign in to comment.