├── .gitignore ├── .rspec ├── lib ├── opt_struct │ ├── version.rb │ ├── module_methods.rb │ ├── instance_methods.rb │ └── class_methods.rb └── opt_struct.rb ├── Gemfile ├── spec ├── spec_helper.rb ├── module_spec.rb ├── naming_spec.rb ├── class_methods_spec.rb ├── class_spec.rb ├── instance_spec.rb ├── block_spec.rb ├── defaults_spec.rb └── inheritance_spec.rb ├── Rakefile ├── opt_struct.gemspec ├── .github └── workflows │ └── ruby.yml ├── Gemfile.lock ├── alternatives.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | .ruby-version 3 | .tool-versions 4 | doc 5 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format progress 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/opt_struct/version.rb: -------------------------------------------------------------------------------- 1 | module OptStruct 2 | VERSION = "1.7.0" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "ostruct" 4 | gem "pry" 5 | gem "sdoc" 6 | gem "rake" 7 | gem "rspec" 8 | 9 | gemspec 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "pry" 3 | require "opt_struct" 4 | 5 | RSpec.configure do |config| 6 | config.expect_with(:rspec) { |c| c.syntax = :expect } 7 | config.default_formatter = "doc" if config.files_to_run.one? 8 | end 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "sdoc" 3 | require "pry" 4 | require "opt_struct" 5 | 6 | require "bundler/gem_tasks" 7 | require "rspec/core/rake_task" 8 | require "rdoc/task" 9 | 10 | RSpec::Core::RakeTask.new(:spec) 11 | task :default => :spec 12 | 13 | RDoc::Task.new do |t| 14 | t.rdoc_dir = "doc" 15 | t.rdoc_files.include("README.md", "lib/**/*.rb") 16 | t.options << "--format=sdoc" 17 | t.template = "rails" 18 | end 19 | 20 | task :console do 21 | Pry.start 22 | end 23 | -------------------------------------------------------------------------------- /opt_struct.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'opt_struct/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "opt_struct" 7 | spec.version = OptStruct::VERSION 8 | spec.authors = ["Carl Zulauf"] 9 | spec.email = ["carl@linkleaf.com"] 10 | 11 | spec.summary = %q{The Option Struct} 12 | spec.description = %q{Struct with support for keyword params and mixin support} 13 | spec.homepage = "https://github.com/carlzulauf/opt_struct" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split("\n").grep(/^lib/) 17 | spec.files += %w(README.md opt_struct.gemspec) 18 | spec.require_paths = ["lib"] 19 | end 20 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | ruby: ['2.7', '3.0', '3.1', '3.2', '3.3', head, jruby, truffleruby] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Ruby ${{ matrix.ruby }} 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | # attempt to use default bundler for ruby version 28 | # without this the version in Gemfile.lock is used, which is too new for ruby 2.7 29 | bundler: default 30 | - name: Install dependencies 31 | run: bundle install 32 | - name: Run tests 33 | run: bundle exec rake 34 | -------------------------------------------------------------------------------- /lib/opt_struct/module_methods.rb: -------------------------------------------------------------------------------- 1 | module OptStruct 2 | module ModuleMethods 3 | 4 | def included(klass) 5 | OptStruct._inject_struct(klass, self) 6 | super(klass) 7 | end 8 | 9 | def prepended(klass) 10 | OptStruct._inject_struct(klass, self) 11 | super(klass) 12 | end 13 | 14 | # These methods are meant to duplicate the macro methods in ClassMethods 15 | # When they are called in a module the action is deferred by adding a block to the struct chain 16 | %i( 17 | required 18 | option_reader 19 | option_writer 20 | option_accessor 21 | option 22 | options 23 | expect_arguments 24 | ).each do |class_method| 25 | define_method(class_method) do |*args, **options| 26 | @_opt_structs ||= [] 27 | @_opt_structs << [[], {}, -> { send(class_method, *args, **options) }] 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/module_spec.rb: -------------------------------------------------------------------------------- 1 | describe "OptStruct module usage" do 2 | context "required keyword" do 3 | class WithRequiredKeyword 4 | include OptStruct 5 | required :foo 6 | end 7 | 8 | it "raises an ArgumentError when missing" do 9 | expect{ WithRequiredKeyword.new }.to raise_error(ArgumentError) 10 | end 11 | 12 | it "initializes and provides an accessor when satisfied" do 13 | value = WithRequiredKeyword.new(foo: "bar") 14 | expect(value.foo).to eq("bar") 15 | end 16 | end 17 | 18 | context ".build options" do 19 | class WithBuildOptions 20 | include OptStruct.build(:foo, bar: nil) 21 | end 22 | 23 | it "raises an ArgumentError when required argument is missing" do 24 | expect{ WithBuildOptions.new }.to raise_error(ArgumentError) 25 | end 26 | 27 | it "provides accessors for arguments and keywords" do 28 | value = WithBuildOptions.new("something", bar: "foo") 29 | expect(value.foo).to eq("something") 30 | expect(value.bar).to eq("foo") 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | opt_struct (1.7.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | coderay (1.1.3) 10 | diff-lcs (1.5.1) 11 | method_source (1.1.0) 12 | ostruct (0.6.0) 13 | pry (0.14.2) 14 | coderay (~> 1.1) 15 | method_source (~> 1.0) 16 | psych (5.1.2) 17 | stringio 18 | rake (13.2.1) 19 | rdoc (6.7.0) 20 | psych (>= 4.0.0) 21 | rspec (3.13.0) 22 | rspec-core (~> 3.13.0) 23 | rspec-expectations (~> 3.13.0) 24 | rspec-mocks (~> 3.13.0) 25 | rspec-core (3.13.1) 26 | rspec-support (~> 3.13.0) 27 | rspec-expectations (3.13.3) 28 | diff-lcs (>= 1.2.0, < 2.0) 29 | rspec-support (~> 3.13.0) 30 | rspec-mocks (3.13.2) 31 | diff-lcs (>= 1.2.0, < 2.0) 32 | rspec-support (~> 3.13.0) 33 | rspec-support (3.13.1) 34 | sdoc (2.6.1) 35 | rdoc (>= 5.0) 36 | stringio (3.1.1) 37 | 38 | PLATFORMS 39 | ruby 40 | x86_64-linux 41 | 42 | DEPENDENCIES 43 | opt_struct! 44 | ostruct 45 | pry 46 | rake 47 | rspec 48 | sdoc 49 | 50 | BUNDLED WITH 51 | 2.5.16 52 | -------------------------------------------------------------------------------- /spec/naming_spec.rb: -------------------------------------------------------------------------------- 1 | class WeirdNamesStruct < OptStruct.new(:FOO) 2 | options :Capitalized, :cameLized, :"⛔", :end, :for 3 | end 4 | 5 | describe "naming" do 6 | subject { WeirdNamesStruct } 7 | 8 | it "allows all of the weird getter names to be called" do 9 | a = subject.new("bar", 456) 10 | expect(a.FOO).to eq("bar") 11 | # expect(a.send("123")).to eq(456) 12 | expect(a.Capitalized).to be_nil 13 | expect(a.cameLized).to be_nil 14 | expect(a.⛔).to be_nil 15 | expect(a.end).to be_nil 16 | expect(a.for).to be_nil 17 | # expect(a.send("-")).to be_nil 18 | # expect(a.send("=")).to be_nil 19 | # expect(a.send("with space")).to be_nil 20 | end 21 | 22 | it "allows all of the weird setter names to be called" do 23 | a = subject.new("bar", 456) 24 | 25 | a.FOO = "foo" 26 | expect(a.FOO).to eq("foo") 27 | 28 | # a.send("123=", 789) 29 | # expect(a.send("123")).to eq(789) 30 | 31 | a.Capitalized = true 32 | expect(a.Capitalized).to eq(true) 33 | 34 | a.cameLized = true 35 | expect(a.cameLized).to eq(true) 36 | 37 | a.⛔ = true 38 | expect(a.⛔).to eq(true) 39 | 40 | a.end = true 41 | expect(a.end).to eq(true) 42 | 43 | a.for = true 44 | expect(a.for).to eq(true) 45 | 46 | # a.send("-=", true) 47 | # expect(a.send("-")).to eq(true) 48 | # 49 | # a.send("==", true) 50 | # expect(a.send("=")).to eq(true) 51 | # 52 | # a.send("with space=", true) 53 | # expect(a.send("with space")).to eq(true) 54 | end 55 | 56 | it "throws an argument error when an invalid keyword is used" do 57 | expect { OptStruct.new(:class) }.to raise_error(ArgumentError) 58 | expect { OptStruct.new(:options) }.to raise_error(ArgumentError) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/class_methods_spec.rb: -------------------------------------------------------------------------------- 1 | describe "Class methods added by OptStruct" do 2 | subject do 3 | OptStruct.new(:pos1, :pos2, opt1: :default, opt2: :default) do 4 | required :opt3 5 | option :opt4, default: :default 6 | end 7 | end 8 | 9 | describe "required_keys" do 10 | it "returns the keys for required options" do 11 | expect(subject.required_keys).to eq(%i[pos1 pos2 opt3]) 12 | end 13 | end 14 | 15 | describe "defaults" do 16 | it "returns a Hash containing current defaults, indexed by their keys" do 17 | expect(subject.defaults).to eq({ opt1: :default, opt2: :default, opt4: :default }) 18 | end 19 | end 20 | 21 | describe "expected_arguments" do 22 | it "returns the names of the positional arguments the struct expects" do 23 | expect(subject.expected_arguments).to eq(%i[pos1 pos2]) 24 | end 25 | end 26 | 27 | describe "defined_keys" do 28 | it "returns the names of all options explicitly defined" do 29 | expect(subject.defined_keys).to eq(%i[pos1 pos2 opt1 opt2 opt3 opt4]) 30 | end 31 | end 32 | 33 | describe "option" do 34 | subject { OptStruct.new } 35 | 36 | context "with a valid option" do 37 | it "adds option to list of defined keys" do 38 | expect { subject.option :valid }.to \ 39 | change { subject.defined_keys }. 40 | from([]). 41 | to([:valid]) 42 | end 43 | end 44 | 45 | context "with an option from the list of reserved words" do 46 | it "raises an ArgumentError" do 47 | expect { subject.option :fetch }.to raise_error(ArgumentError) 48 | expect { subject.option :check_required_keys }.to raise_error(ArgumentError) 49 | expect { subject.option :options }.to raise_error(ArgumentError) 50 | expect { subject.option :run_callback }.to raise_error(ArgumentError) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/opt_struct.rb: -------------------------------------------------------------------------------- 1 | require "opt_struct/class_methods" 2 | require "opt_struct/module_methods" 3 | require "opt_struct/instance_methods" 4 | 5 | module OptStruct 6 | 7 | RESERVED_WORDS = [ 8 | :class, :options, 9 | *OptStruct::InstanceMethods.instance_methods, 10 | *OptStruct::InstanceMethods.protected_instance_methods, 11 | *OptStruct::InstanceMethods.private_instance_methods, 12 | ].freeze 13 | 14 | # list of class instance variables defined/tracked by opt_struct 15 | CLASS_IVARS = %i[ 16 | @defined_keys @required_keys @expected_arguments @defaults 17 | @_callbacks @_opt_structs 18 | ] 19 | 20 | # Default value object allows us to distinguish unspecified defaults from nil 21 | DEFAULT = Object.new 22 | 23 | def self._inject_struct(target, source, args = [], **defaults, &callback) 24 | existing = source.instance_variable_get(:@_opt_structs) 25 | structs = Array(existing).dup 26 | 27 | if args.any? || defaults.any? || block_given? 28 | structs << [args, defaults, callback] 29 | end 30 | 31 | target.instance_variable_set(:@_opt_structs, structs) if existing || structs.any? 32 | 33 | if target.is_a?(Class) 34 | target.instance_exec do 35 | extend ClassMethods 36 | attr_reader :options 37 | include InstanceMethods 38 | end 39 | structs.each do |s_args, s_defaults, s_callback| 40 | target.expect_arguments *s_args if s_args.any? 41 | target.options **s_defaults if s_defaults.any? 42 | target.class_exec(&s_callback) if s_callback 43 | end 44 | else 45 | target.singleton_class.prepend ModuleMethods 46 | end 47 | target 48 | end 49 | 50 | def self.included(klass) 51 | _inject_struct(klass, self) 52 | super(klass) 53 | end 54 | 55 | def self.prepended(klass) 56 | _inject_struct(klass, self) 57 | super(klass) 58 | end 59 | 60 | def self.new(*args, **defaults, &callback) 61 | _inject_struct(Class.new, self, args.map(&:to_sym), **defaults, &callback) 62 | end 63 | 64 | def self.build(*args, **defaults, &callback) 65 | _inject_struct(Module.new, self, args.map(&:to_sym), **defaults, &callback) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/class_spec.rb: -------------------------------------------------------------------------------- 1 | describe "OptStruct class usage spec" do 2 | context "two arguments and defaults" do 3 | subject do 4 | OptStruct.new(:one, :two, foo: "bar") 5 | end 6 | 7 | it "is initializable with only required args" do 8 | value = subject.new :foo, :bar 9 | expect(value.foo).to eq("bar") 10 | expect(value.one).to eq(:foo) 11 | end 12 | 13 | it "is not initializable without required args" do 14 | expect { subject.new }.to raise_error(ArgumentError) 15 | end 16 | 17 | it "can satisfy required args from a hash" do 18 | value = subject.new(one: "foo", two: "bar", foo: "oof") 19 | expect(value.one).to eq("foo") 20 | expect(value.two).to eq("bar") 21 | expect(value.options).to eq(one: "foo", two: "bar", foo: "oof") 22 | end 23 | 24 | it "allows options to be grabbed via fetch" do 25 | value = subject.new(one: "foo", two: "bar", foo: "oof") 26 | expect(value.fetch(:foo)).to eq("oof") 27 | expect{value.fetch(:bar)}.to raise_error(KeyError) 28 | expect(value.fetch(:bar, :default)).to eq(:default) 29 | expect(value.fetch(:bar){:default}).to eq(:default) 30 | end 31 | end 32 | 33 | context "argument with a matching default" do 34 | subject do 35 | OptStruct.new(:one, one: "foo") 36 | end 37 | 38 | it "causes the argument to not be required" do 39 | expect(subject.new.one).to eq("foo") 40 | end 41 | end 42 | 43 | context "wtih more complex test struct" do 44 | class TestStruct < OptStruct.new(foo: "bar") 45 | required :yin 46 | option :bar, default: "foo" 47 | end 48 | 49 | subject { TestStruct.new(yin: "yang") } 50 | 51 | it "throws argument error when missing required key" do 52 | expect { TestStruct.new }.to raise_error(ArgumentError) 53 | end 54 | 55 | it "uses default passed to .new" do 56 | expect(subject.foo).to eq("bar") 57 | end 58 | 59 | it "sets up option accessors for required keys" do 60 | expect(subject.yin).to eq("yang") 61 | subject.yin = "foo" 62 | expect(subject.options[:yin]).to eq("foo") 63 | end 64 | 65 | it "uses :default for key passed to .option" do 66 | expect(subject.bar).to eq("foo") 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/opt_struct/instance_methods.rb: -------------------------------------------------------------------------------- 1 | module OptStruct 2 | module InstanceMethods 3 | def initialize(*arguments, **options) 4 | with_init_callbacks do 5 | @options = options 6 | assign_arguments(arguments) 7 | assign_defaults 8 | check_required_keys 9 | end 10 | end 11 | 12 | def fetch(*a, &b) 13 | options.fetch(*a, &b) 14 | end 15 | 16 | def defaults 17 | self.class.defaults 18 | end 19 | 20 | def ==(other) 21 | options == other.options 22 | end 23 | 24 | private 25 | 26 | def check_required_keys 27 | missing = self.class.required_keys.reject { |key| options.key?(key) } 28 | if missing.any? 29 | raise ArgumentError, "missing required keywords: #{missing.inspect}" 30 | end 31 | end 32 | 33 | def assign_defaults 34 | defaults.each do |key, default_value| 35 | next if options.key?(key) 36 | options[key] = 37 | case default_value 38 | when Proc 39 | instance_exec(&default_value) 40 | when Symbol 41 | respond_to?(default_value, true) ? send(default_value) : default_value 42 | else 43 | default_value 44 | end 45 | end 46 | end 47 | 48 | def assign_arguments(args) 49 | self.class.expected_arguments.each_with_index do |key, i| 50 | options[key] = args[i] if args.length > i 51 | end 52 | end 53 | 54 | def with_init_callbacks(&init_block) 55 | callbacks = self.class.all_callbacks 56 | return yield if callbacks.nil? || callbacks.empty? 57 | 58 | around, before, after = [:around_init, :before_init, :init].map do |type| 59 | callbacks.fetch(type) { [] } 60 | end 61 | 62 | if around.any? 63 | init = proc { run_befores_and_afters(before, after, &init_block) } 64 | init = around.reduce(init) do |chain, callback| 65 | proc { run_callback(callback, &chain) } 66 | end 67 | instance_exec(&init) 68 | else 69 | run_befores_and_afters(before, after, &init_block) 70 | end 71 | end 72 | 73 | def run_befores_and_afters(before, after) 74 | before.each { |cb| run_callback(cb) } 75 | yield 76 | after.each { |cb| run_callback(cb) } 77 | end 78 | 79 | def run_callback(callback, &to_yield) 80 | case callback 81 | when Symbol, String 82 | send(callback, &to_yield) 83 | when Proc 84 | instance_exec(to_yield, &callback) 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/instance_spec.rb: -------------------------------------------------------------------------------- 1 | describe "OptStruct instance methods usage" do 2 | InstanceableClass = OptStruct.new(:arity_arg) do 3 | option :required_arg, required: true 4 | option :optional_arg 5 | 6 | option :private_arg, private: true, default: "yas" 7 | required :private_required_arg, private: true 8 | 9 | def arity_up 10 | options[:arity_arg].upcase 11 | end 12 | 13 | def required_up 14 | options[:required_arg].upcase 15 | end 16 | 17 | def optional_up 18 | (options[:optional_arg] || "default").upcase 19 | end 20 | 21 | def private_up 22 | private_arg.upcase 23 | end 24 | end 25 | 26 | subject { InstanceableClass.new(arity_arg: "yaaa", required_arg: "yara", private_required_arg: "yeee") } 27 | 28 | it "allows use of #options to access required args" do 29 | expect(subject.required_up).to eq("YARA") 30 | end 31 | 32 | it "allows use of #options to access arity arguments" do 33 | expect(subject.arity_up).to eq("YAAA") 34 | end 35 | 36 | it "allows use of #options.fetch to provide default for optional args" do 37 | expect(subject.optional_up).to eq("DEFAULT") 38 | end 39 | 40 | it "does not allow access to private argument publicly" do 41 | expect { subject.private_arg }.to raise_error(NoMethodError) 42 | end 43 | 44 | it "allows access to private argument through #options" do 45 | expect(subject.options[:private_arg]).to eq("yas") 46 | end 47 | 48 | it "allows access to private argument internally" do 49 | expect(subject.private_up).to eq("YAS") 50 | end 51 | 52 | context "with optional_arg supplied" do 53 | subject do 54 | InstanceableClass.new( 55 | arity_arg: "yaaa", 56 | required_arg: "yara", 57 | private_required_arg: "yeee", 58 | optional_arg: "yaoa", 59 | ) 60 | end 61 | 62 | it "allows use of #options.fetch to safely access optional arguments" do 63 | expect(subject.optional_up).to eq("YAOA") 64 | end 65 | end 66 | 67 | context "with required option missing" do 68 | subject { InstanceableClass.new(arity_arg: 1, private_required_arg: 1) } 69 | 70 | it "raises an ArgumentError" do 71 | # binding.pry 72 | expect { subject }.to raise_error(ArgumentError) 73 | end 74 | end 75 | 76 | context "with comparable structs" do 77 | let(:same) { InstanceableClass.new(arity_arg: "yaaa", required_arg: "yara", private_required_arg: "yeee") } 78 | let(:different) { InstanceableClass.new(arity_arg: "naaa", required_arg: "nara", private_required_arg: "neee") } 79 | 80 | it "compares as equal to struct with the same values" do 81 | expect( subject == same ).to eq(true) 82 | end 83 | it "compares as not equal to struct with different values" do 84 | expect( subject == different ).to eq(false) 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/block_spec.rb: -------------------------------------------------------------------------------- 1 | describe "OptStruct block usage" do 2 | PersonClass = OptStruct.new do 3 | required :first_name 4 | option :last_name 5 | option :ssn, private: true, required: true 6 | 7 | attr_reader :age 8 | 9 | init do 10 | @age = 0 11 | end 12 | 13 | def name 14 | [first_name, last_name].compact.join(" ") 15 | end 16 | 17 | def last_four 18 | ssn.scan(/\d/).last(4).join 19 | end 20 | end 21 | 22 | LoadsOfCallbacks = OptStruct.new do 23 | before_init { order << 2 } 24 | init { order << 3 } 25 | after_init { order << 4 } 26 | around_init { |i| order << 1; i.call; order << 5 } 27 | around_init :more_order 28 | 29 | def order 30 | @order ||= [] 31 | end 32 | 33 | def more_order 34 | order << 0 35 | yield 36 | order << 6 37 | end 38 | end 39 | 40 | CarModule = OptStruct.build do 41 | required :make, :model 42 | options :year, transmission: "Automatic" 43 | 44 | def name 45 | [year, make, model].compact.join(" ") 46 | end 47 | end 48 | 49 | class CarClass 50 | include CarModule 51 | end 52 | 53 | describe "with various callbacks" do 54 | subject { LoadsOfCallbacks.new } 55 | 56 | it "executes callbacks in a predictible order" do 57 | expect(subject.order).to eq((0..6).to_a) 58 | end 59 | end 60 | 61 | describe "with .new" do 62 | describe ".new" do 63 | subject { PersonClass } 64 | 65 | it "throws error when required keys missing" do 66 | expect{ subject.new }.to raise_error(ArgumentError) 67 | end 68 | 69 | it "allows initialization when required keys are satisfied" do 70 | value = subject.new(first_name: "Trish", ssn: "123-45-6789") 71 | expect(value).to be_a(subject) 72 | end 73 | end 74 | 75 | describe "instance" do 76 | subject { PersonClass.new(first_name: "Trish", last_name: "Smith", ssn: "123-45-6789") } 77 | 78 | it "contains methods defined in block" do 79 | expect(subject.name).to eq("Trish Smith") 80 | expect(subject.last_four).to eq("6789") 81 | end 82 | 83 | it "executes the init block" do 84 | expect(subject.age).to eq(0) 85 | end 86 | end 87 | end 88 | 89 | describe "with .build" do 90 | subject { CarClass } 91 | 92 | it "throws error when required keys missing" do 93 | expect{ subject.new }.to raise_error(ArgumentError) 94 | end 95 | 96 | it "adds block methods to instance methods" do 97 | car1 = subject.new(make: "Infiniti", model: "G37", year: 2012) 98 | expect(car1.name).to eq("2012 Infiniti G37") 99 | car2 = subject.new(model: "WRX", make: "Subaru") 100 | expect(car2.name).to eq("Subaru WRX") 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/opt_struct/class_methods.rb: -------------------------------------------------------------------------------- 1 | module OptStruct 2 | module ClassMethods 3 | def inherited(subclass) 4 | # intersection of defined vars and the ones we care about 5 | (instance_variables & OptStruct::CLASS_IVARS).each do |ivar| 6 | # copy each to the child class 7 | value = 8 | case ivar 9 | when :@_callbacks # Hash that we need to duplicate deeper 10 | instance_variable_get(ivar).transform_values(&:dup) 11 | else 12 | instance_variable_get(ivar).dup 13 | end 14 | subclass.send(:instance_variable_set, ivar, value) 15 | end 16 | super(subclass) 17 | end 18 | 19 | def defined_keys 20 | @defined_keys ||= [] 21 | end 22 | 23 | def required_keys 24 | @required_keys ||= [] 25 | end 26 | 27 | def expected_arguments 28 | @expected_arguments ||= [] 29 | end 30 | 31 | def defaults 32 | @defaults ||= {} 33 | end 34 | 35 | def required(*keys, **options) 36 | required_keys.concat keys 37 | option_accessor *keys, **options 38 | end 39 | 40 | def option_reader(*keys, **opts) 41 | keys.each do |key| 42 | define_method(key) { options[key] } 43 | private key if opts[:private] 44 | end 45 | end 46 | 47 | def option_writer(*keys, **opts) 48 | keys.each do |key| 49 | meth = "#{key}=".to_sym 50 | define_method(meth) { |value| options[key] = value } 51 | private meth if opts[:private] 52 | end 53 | end 54 | 55 | def option_accessor(*keys, **options) 56 | check_reserved_words(keys) 57 | defined_keys.concat keys 58 | option_reader *keys, **options 59 | option_writer *keys, **options 60 | end 61 | 62 | def option(key, default = OptStruct::DEFAULT, required: false, **options) 63 | default = options[:default] if options.key?(:default) 64 | defaults[key] = default unless default == OptStruct::DEFAULT 65 | required_keys << key if required 66 | option_accessor key, **options 67 | end 68 | 69 | def options(*keys, **keys_defaults) 70 | option_accessor *keys if keys.any? 71 | if keys_defaults.any? 72 | defaults.merge!(keys_defaults) 73 | option_accessor *(keys_defaults.keys - expected_arguments) 74 | end 75 | end 76 | 77 | def expect_arguments(*arguments) 78 | required(*arguments) 79 | expected_arguments.concat(arguments) 80 | end 81 | alias_method :expect_argument, :expect_arguments 82 | 83 | def init(meth = nil, &blk) 84 | add_callback(:init, meth || blk) 85 | end 86 | alias_method :after_init, :init 87 | 88 | def before_init(meth = nil, &blk) 89 | add_callback(:before_init, meth || blk) 90 | end 91 | 92 | def around_init(meth = nil, &blk) 93 | add_callback(:around_init, meth || blk) 94 | end 95 | 96 | def add_callback(name, callback) 97 | @_callbacks ||= {} 98 | @_callbacks[name] ||= [] 99 | @_callbacks[name] << callback 100 | end 101 | 102 | def all_callbacks 103 | @_callbacks 104 | end 105 | 106 | private 107 | 108 | def check_reserved_words(words) 109 | Array(words).each do |word| 110 | if OptStruct::RESERVED_WORDS.member?(word) 111 | raise ArgumentError, "Use of reserved word is not permitted: #{word.inspect}" 112 | end 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/defaults_spec.rb: -------------------------------------------------------------------------------- 1 | class DefaultSymbolMethodExists < OptStruct.new 2 | option :foo, default: :bar 3 | 4 | def bar 5 | "test" 6 | end 7 | end 8 | 9 | class DefaultSymbolMethodPrivate < OptStruct.new 10 | option :foo, default: :bar 11 | 12 | private 13 | 14 | def bar 15 | "test" 16 | end 17 | end 18 | 19 | class DefaultSymbolMethodDoesNotExist < OptStruct.new 20 | option :foo, default: :bar 21 | end 22 | 23 | class DefaultProc < OptStruct.new 24 | option :foo, default: -> { "bar" } 25 | end 26 | 27 | class DefaultLambda < OptStruct.new 28 | option :foo, default: lambda { "bar" } 29 | end 30 | 31 | class DefaultProcAndSymbolUsingOptions < OptStruct.new 32 | options foo: :bar, yin: -> { "yang" } 33 | 34 | def bar 35 | "test" 36 | end 37 | end 38 | 39 | class DefaultProcWithInstanceReference < OptStruct.new 40 | option :foo, default: -> { a_method } 41 | 42 | def a_method 43 | "bar" 44 | end 45 | end 46 | 47 | class DefaultProcWithChangingDefault < OptStruct.new 48 | @@id = 1 49 | 50 | option :foo, default: -> { auto_id } 51 | 52 | def auto_id 53 | @@id += 1 54 | end 55 | end 56 | 57 | class OptionsWithNilDefaults < OptStruct.new 58 | option :implicit_nil 59 | option :explicit_nil, nil 60 | option :nil_block, -> { nil } 61 | option :nil_method, :returns_nil 62 | options :opt_list1, :opt_list2, opt_list_nil: nil 63 | 64 | def returns_nil 65 | nil 66 | end 67 | end 68 | 69 | describe "OptStruct default values" do 70 | describe "using a symbol" do 71 | it "defaults to method return value when method exists" do 72 | expect(DefaultSymbolMethodExists.new.foo).to eq("test") 73 | expect(DefaultSymbolMethodExists.new.options[:foo]).to eq("test") 74 | end 75 | 76 | it "defaults to method return value when method is private" do 77 | expect(DefaultSymbolMethodPrivate.new.foo).to eq("test") 78 | expect(DefaultSymbolMethodPrivate.new.options[:foo]).to eq("test") 79 | end 80 | 81 | it "defaults to symbol if method does not exist" do 82 | expect(DefaultSymbolMethodDoesNotExist.new.foo).to eq(:bar) 83 | expect(DefaultSymbolMethodDoesNotExist.new.options[:foo]).to eq(:bar) 84 | end 85 | 86 | context "matching a method with nil return value" do 87 | it "initializes the option with a nil value" do 88 | expect(OptionsWithNilDefaults.new.nil_method).to eq(nil) 89 | expect(OptionsWithNilDefaults.new.options.key?(:nil_method)).to eq(true) 90 | end 91 | end 92 | end 93 | 94 | describe "using a proc" do 95 | it "calls the proc" do 96 | expect(DefaultProc.new.foo).to eq("bar") 97 | expect(DefaultProc.new.options[:foo]).to eq("bar") 98 | end 99 | 100 | it "executes in the context of the instance object" do 101 | expect(DefaultProcWithInstanceReference.new.foo).to eq("bar") 102 | expect(DefaultProcWithInstanceReference.new.options[:foo]).to eq("bar") 103 | end 104 | 105 | it "freshly evaluates for every instance" do 106 | expect(DefaultProcWithChangingDefault.new.foo).to eq(2) 107 | expect(DefaultProcWithChangingDefault.new.options[:foo]).to eq(3) 108 | expect(DefaultProcWithChangingDefault.new.foo).to eq(4) 109 | end 110 | 111 | it "evaluates only once per instance" do 112 | instance = DefaultProcWithChangingDefault.new 113 | value = instance.foo 114 | expect(instance.foo).to eq(value) 115 | expect(instance.fetch(:foo)).to eq(value) 116 | expect(instance.options[:foo]).to eq(value) 117 | end 118 | 119 | context "with a nil return value" do 120 | it "initializes the option with nil" do 121 | expect(OptionsWithNilDefaults.new.nil_block).to eq(nil) 122 | expect(OptionsWithNilDefaults.new.options.key?(:nil_block)).to eq(true) 123 | end 124 | end 125 | end 126 | 127 | describe "using a lambda" do 128 | it "calls the lambda" do 129 | expect(DefaultLambda.new.foo).to eq("bar") 130 | expect(DefaultLambda.new.fetch(:foo)).to eq("bar") 131 | end 132 | end 133 | 134 | describe "using options syntax" do 135 | it "evaluates a proc" do 136 | expect(DefaultProcAndSymbolUsingOptions.new.yin).to eq("yang") 137 | expect(DefaultProcAndSymbolUsingOptions.new.options[:yin]).to eq("yang") 138 | end 139 | 140 | it "evaluates a method via symbol" do 141 | expect(DefaultProcAndSymbolUsingOptions.new.foo).to eq("test") 142 | expect(DefaultProcAndSymbolUsingOptions.new.options[:foo]).to eq("test") 143 | end 144 | 145 | it "initializes values with nil defaults" do 146 | expect(OptionsWithNilDefaults.new.opt_list_nil).to eq(nil) 147 | expect(OptionsWithNilDefaults.new.options.key?(:opt_list_nil)).to eq(true) 148 | end 149 | 150 | it "does not initialize values with no default provided" do 151 | expect(OptionsWithNilDefaults.new.opt_list1).to eq(nil) 152 | expect(OptionsWithNilDefaults.new.opt_list2).to eq(nil) 153 | expect(OptionsWithNilDefaults.new.options.key?(:opt_list1)).to eq(false) 154 | expect(OptionsWithNilDefaults.new.options.key?(:opt_list2)).to eq(false) 155 | end 156 | end 157 | 158 | describe "using option syntax" do 159 | context "with no explicit default" do 160 | it "returns nil from option accessor" do 161 | expect(OptionsWithNilDefaults.new.implicit_nil).to eq(nil) 162 | end 163 | 164 | it "does not initialize the option in the options hash" do 165 | expect(OptionsWithNilDefaults.new.options.key?(:implicit_nil)).to eq(false) 166 | end 167 | end 168 | 169 | context "with explicit default nil as second argument" do 170 | it "returns nil from the option accessor" do 171 | expect(OptionsWithNilDefaults.new.explicit_nil).to eq(nil) 172 | end 173 | 174 | it "initializes the option in the options hash" do 175 | expect(OptionsWithNilDefaults.new.options.key?(:explicit_nil)).to eq(true) 176 | end 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /alternatives.md: -------------------------------------------------------------------------------- 1 | # Alternative Gems 2 | 3 | Before I create this gem I should check to see if something out there solves the problem just as well or close enough to obviate the need for this. 4 | 5 | ### Searching for [hash_struct](https://rubygems.org/search?utf8=%E2%9C%93&query=hash_struct) on rubygems. 6 | 7 | ## [hash_struct](https://rubygems.org/gems/hash_struct) 8 | 9 | Single file implementation: https://github.com/botanicus/hash_struct/blob/master/lib/hash_struct.rb 10 | 11 | Very simple. Arguments passed to `HashStruct.new` become `attr_accessor`. Reverse inheritance: expects modules to be mixed into HashStruct or descendant. Guess it's sort of like a plugin system. 12 | 13 | ⛔ 14 | 15 | ## [hash_with_struct_access](https://rubygems.org/gems/hash_with_struct_access) 16 | 17 | Recursively expose simple hash keys as methods. Sort of like Hashie. Implementation is much simpler than expected. Extends `Hash`. 18 | 19 | Uses method_missing and explicitly freezes hashes it touches. 20 | 21 | ⛔ 22 | 23 | ## [hash_initialized_struct](https://rubygems.org/gems/hash_initialized_struct) 24 | 25 | Same interface as Struct, but resulting class takes hash in initializer. All keys are required. Any extra keys cause errors. 26 | 27 | Extreme strictness is not very struct-like. No values are required on a struct. 28 | 29 | For some reason uses a class, but treats it like a module by aliasing `.new`. 30 | 31 | ⛔ 32 | 33 | ### Moving on to some logical choices 34 | 35 | ## [virtus](https://github.com/solnic/virtus.git) 36 | 37 | Large, complex dependency with many superfluous features including type casting. 38 | 39 | If it can achieve the API goal without generating heavy-weight objects it may be worth a try. 40 | 41 | It looks like it would take as much work to make virtus match the desired API as it would to create the API from scratch. 42 | 43 | Author admits it mixes concerns and wants users to move replace it with three gems. One of them is `dry-struct`. Checking that out. 44 | 45 | ⛔ 46 | 47 | ## [dry-struct](http://dry-rb.org/gems/dry-struct/) 48 | 49 | Uses `dry-types` and seems awfully concerned with type enforcement. 50 | 51 | Ability to coerce values is super nice and it has a simpler interface than Virtus for this chore. 52 | 53 | Starring this gem. Solves a different problem than OptStruct intends to solve, but appears to solve it rather well. 54 | 55 | ⛔ 56 | 57 | ### Searching for [struct](https://rubygems.org/search?utf8=%E2%9C%93&query=struct) on rubygems. 58 | 59 | ## [key_struct](https://github.com/ronen/key_struct) 60 | 61 | Like the use of `[]` to build the struct. 62 | 63 | Converts hash used to initialize into instance variables and relies on regular accessors. 64 | 65 | Has the ability to re-produce the incoming hash. 66 | 67 | Fairly small and simple implementation. Allows for default values to be set. 68 | 69 | Instances are comparable. Nice. 70 | 71 | No ability to to define non-keyword arguments. No module-like usage. 72 | 73 | ⛔ 74 | 75 | ## [attribute_struct](https://rubygems.org/gems/attribute_struct) 76 | 77 | More like a builder tool than a struct. Bare words used within are converted to hash keys, and can be nested, allowing you to create complex hash structures easily. 78 | 79 | Nice, but unrelated to the concerns of OptStruct. 80 | 81 | Another starred gem. 82 | 83 | ⛔ 84 | 85 | ## [object_struct](https://rubygems.org/gems/object_struct) 86 | 87 | Abandoned. Pulled from github. 88 | 89 | ⛔ 90 | 91 | ## [immutable-struct](https://github.com/stitchfix/immutable-struct) 92 | 93 | Interesting, but not sure how I feel about enforcing immutability. 94 | 95 | API is more flexible than some others, but takes the approach of mostly matching Struct while accepting a hash as the only initializing argument. 96 | 97 | Array coercion syntax is particularly weird. Might be an interesting approach for defining optional arguments, however. 98 | 99 | ⛔ 100 | 101 | ## [immutable_struct](https://github.com/iconara/immutable_struct) 102 | 103 | Matches Struct API, but removes setters. Resulting struct can be initialized with arguments or with a hash with keys that match the argument names. 104 | 105 | Parameters aren't required, but can be made required using `.strict` toggle. 106 | 107 | No module usage. No defaults. Immutability isn't necessary for this use case, and probably not even desired. OptStruct might want to support immutability as an optional feature. 108 | 109 | ⛔ 110 | 111 | ## [method_struct](https://github.com/basecrm/method_struct) 112 | 113 | Advertised as a refactoring tool. 114 | 115 | It's kind of a nice way to setup interactors. `new+perform` just becomes `call`. Class level method passes down to `call` on the instance method with arguments available as getters. 116 | 117 | Maybe a special subclass of OptStruct could allow this `call/call` or `perform/perform` shortcut for interactor/action type classes. 118 | 119 | Good example of providing a `do` interface to build the class. Forgot Struct allows this. Should be supported. 120 | 121 | ⛔ 122 | 123 | ## [classy_struct](https://github.com/amikula/classy_struct) 124 | 125 | Kind of like OpenStruct, but more efficient as searched for keys are made methods on the class, so future instances already have the accessors. 126 | 127 | ⛔ 128 | 129 | ## [better_struct](https://github.com/exAspArk/better_struct) 130 | 131 | Another OpenStruct alternative claiming to be faster. This one also attempts to normalize non-underscore style string keys and some other fancy stuff. 132 | 133 | ⛔ 134 | 135 | ## [finer_struct](https://github.com/notahat/finer_struct) 136 | 137 | Sort of like OpenStruct with optional argument enforcement and immutability. 138 | 139 | Had my hopes up when the author wrote about solving the needs of Struct and OpenStruct. Really doesn't do that. 140 | 141 | ⛔ 142 | 143 | ## [closed_struct](https://rubygems.org/gems/closed_struct) 144 | 145 | Like an angry less forgiving OpenStruct. No thanks. 146 | 147 | ⛔ 148 | 149 | ## [type_struct](https://github.com/ksss/type_struct) 150 | 151 | Struct with type enforcement. Meh. 152 | 153 | ⛔ 154 | 155 | ## [simple_struct](https://github.com/deadlyicon/simple_struct) 156 | 157 | Ditches Enumerable for an even lighter weight Struct than the stdlib. 158 | 159 | ⛔ 160 | 161 | ## Conclusion 162 | 163 | Some of these come close and provide important lessons. However, none do quite what I'd like from OptStruct. 164 | -------------------------------------------------------------------------------- /spec/inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | class ParentClassStruct < OptStruct.new 2 | options :yin, :yang 3 | end 4 | 5 | class WithMoreOptions < ParentClassStruct 6 | options :x, :y 7 | end 8 | 9 | class WithEvenMoreOptions < WithMoreOptions 10 | options :a, :b 11 | end 12 | 13 | class WithNewDefaults < ParentClassStruct 14 | option :yin, default: "foo" 15 | options yang: "bar" 16 | end 17 | 18 | class ParentModuleStruct 19 | include OptStruct 20 | options :yin, :yang 21 | end 22 | 23 | class MWithMoreOptions < ParentModuleStruct 24 | options :x, :y 25 | end 26 | class MWithNewDefaults < ParentModuleStruct 27 | option :yin, default: "foo" 28 | options yang: "bar" 29 | end 30 | 31 | module AmazingConfigBehavior 32 | include OptStruct.build(:foo, :bar) 33 | option :x 34 | end 35 | 36 | class AmazingConfigStruct 37 | include AmazingConfigBehavior 38 | options yin: :yang 39 | end 40 | 41 | class AmazingPrependStruct 42 | prepend AmazingConfigBehavior 43 | options yin: :yang 44 | end 45 | 46 | module BehaviorWithIncluded 47 | include OptStruct 48 | options x: 0, y: 0 49 | 50 | def self.included(klass) 51 | klass.instance_variable_set(:@triggered, true) 52 | end 53 | end 54 | 55 | class StructWithIncluded 56 | include BehaviorWithIncluded 57 | 58 | options foo: "bar" 59 | 60 | def self.triggered? 61 | @triggered 62 | end 63 | end 64 | 65 | class BaseClassWithInheritedHook 66 | def self.inherited(child) 67 | child.instance_variable_set(:@hook_ran, true) 68 | child.define_method(:hook_ran?) { self.class.instance_variable_get(:@hook_ran) } 69 | end 70 | end 71 | 72 | class ChildExpectingInheritedBehavior < BaseClassWithInheritedHook 73 | include OptStruct 74 | 75 | option :opt_structed, default: true 76 | 77 | def call 78 | hook_ran? 79 | end 80 | end 81 | 82 | class SubchildExpectingBehavior < ChildExpectingInheritedBehavior 83 | option :still_opt_structed, default: -> { :yes } 84 | end 85 | 86 | $breaker = true 87 | 88 | class BaseClassWithInit 89 | include OptStruct 90 | 91 | option :opt1 92 | init { self.opt1 = :value1 } 93 | end 94 | 95 | class Child1WithInit < BaseClassWithInit 96 | option :opt2 97 | init { self.opt2 = :value2 } 98 | end 99 | 100 | class Child2WithInit < BaseClassWithInit 101 | option :opt3 102 | init { self.opt3 = :value3 } 103 | end 104 | 105 | describe "inheritance" do 106 | context "when inherited with stacking callbacks" do 107 | let(:parent) { BaseClassWithInit.new } 108 | let(:child1) { Child1WithInit.new } 109 | let(:child2) { Child2WithInit.new } 110 | 111 | it "parent only has opt1 and it's set" do 112 | expect(parent.opt1).to eq(:value1) 113 | expect(parent.respond_to?(:opt2)).to eq(false) 114 | expect(parent.respond_to?(:opt3)).to eq(false) 115 | end 116 | 117 | it "child1 has opt1 and opt2 present, but not opt3" do 118 | expect(child1.opt1).to eq(:value1) 119 | expect(child1.opt2).to eq(:value2) 120 | expect(child1.respond_to?(:opt3)).to eq(false) 121 | end 122 | 123 | it "child2 has opt1 and opt3 present, but not opt2" do 124 | expect(child2.opt1).to eq(:value1) 125 | expect(child2.respond_to?(:opt2)).to eq(false) 126 | expect(child2.opt3).to eq(:value3) 127 | end 128 | end 129 | context "when included in a class expecting inherited behavior from parent" do 130 | let(:parent) { BaseClassWithInheritedHook } 131 | let(:child) { SubchildExpectingBehavior } 132 | 133 | subject { child.new } 134 | 135 | it "doesn't break the existing inherited behavior" do 136 | expect(subject.()).to eq(true) 137 | end 138 | 139 | it "continues to behave like an opt struct" do 140 | expect(subject.opt_structed).to eq(true) 141 | expect(subject.still_opt_structed).to eq(:yes) 142 | end 143 | end 144 | 145 | context "with more options" do 146 | subject { WithMoreOptions } 147 | 148 | it "has the features of the parent" do 149 | a = subject.new(yin: 1, yang: 2) 150 | expect([a.yin, a.yang]).to eq([1, 2]) 151 | end 152 | 153 | it "has the features of the child" do 154 | a = subject.new(x: 1, y: 2) 155 | expect([a.x, a.y]).to eq([1, 2]) 156 | end 157 | end 158 | 159 | context "with even more options" do 160 | subject { WithEvenMoreOptions } 161 | let(:parent) { WithMoreOptions } 162 | 163 | it "has the features of the parent" do 164 | expect(subject.new(x: 1).x).to eq(1) 165 | end 166 | 167 | it "doesn't add features to the parent" do 168 | expect { parent.new.a }.to raise_error(NoMethodError) 169 | end 170 | 171 | it "has features to the child" do 172 | expect(subject.new(a: 1).a).to eq(1) 173 | end 174 | end 175 | 176 | context "with new defaults" do 177 | subject { WithNewDefaults } 178 | 179 | it "has the features of the parent" do 180 | a = subject.new(yin: 1, yang: 2) 181 | expect([a.yin, a.yang]).to eq([1, 2]) 182 | end 183 | 184 | it "has the features of the child" do 185 | a = subject.new 186 | expect([a.yin, a.yang]).to eq(%w{foo bar}) 187 | end 188 | end 189 | 190 | context "build with more options" do 191 | subject { MWithMoreOptions } 192 | 193 | it "has the features of the parent" do 194 | a = subject.new(yin: 1, yang: 2) 195 | expect([a.yin, a.yang]).to eq([1, 2]) 196 | end 197 | 198 | it "has the features of the child" do 199 | a = subject.new(x: 1, y: 2) 200 | expect([a.x, a.y]).to eq([1, 2]) 201 | end 202 | end 203 | 204 | context "build with new defaults" do 205 | subject { MWithNewDefaults } 206 | 207 | it "has the features of the parent" do 208 | a = subject.new(yin: 1, yang: 2) 209 | expect([a.yin, a.yang]).to eq([1, 2]) 210 | end 211 | 212 | it "has the features of the child" do 213 | a = subject.new 214 | expect([a.yin, a.yang]).to eq(%w{foo bar}) 215 | end 216 | end 217 | 218 | context "module in module" do 219 | subject { AmazingConfigStruct } 220 | it "has the features defined in module" do 221 | a = subject.new(1, 2) 222 | expect([a.foo, a.bar]).to eq([1, 2]) 223 | 224 | b = subject.new(foo: 1, bar: 2, x: 3) 225 | expect([b.foo, b.bar, b.x]).to eq([1, 2, 3]) 226 | end 227 | 228 | it "has the features defiend in class" do 229 | a = subject.new(1, 2, yin: 3) 230 | expect(a.yin).to eq(3) 231 | end 232 | end 233 | 234 | context "module in module, prepended" do 235 | subject { AmazingPrependStruct } 236 | it "has the features defined in module" do 237 | a = subject.new(1, 2) 238 | expect([a.foo, a.bar]).to eq([1, 2]) 239 | 240 | b = subject.new(foo: 1, bar: 2, x: 3) 241 | expect([b.foo, b.bar, b.x]).to eq([1, 2, 3]) 242 | end 243 | 244 | it "has the features defiend in class" do 245 | a = subject.new(1, 2, yin: 3) 246 | expect(a.yin).to eq(3) 247 | end 248 | end 249 | 250 | context "module with included" do 251 | subject { StructWithIncluded } 252 | 253 | it "triggers the custom included behavior" do 254 | expect(subject.triggered?).to eq(true) 255 | end 256 | 257 | it "sets up the defaults correctly" do 258 | a = subject.new 259 | expect([a.x, a.y, a.foo]).to eq([0,0,"bar"]) 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Opt Struct 2 | 3 | A struct around a hash. Great for encapsulating actions with complex configuration, like interactor/action classes. 4 | 5 | ```ruby 6 | gem "opt_struct" 7 | ``` 8 | 9 | # Examples 10 | 11 | ## Creating an OptStruct 12 | 13 | ```ruby 14 | class User < OptStruct.new 15 | required :email, :name 16 | option :role, default: "member" 17 | 18 | def formatted_email 19 | %{"#{name}" <#{email}>} 20 | end 21 | end 22 | ``` 23 | 24 | ## Using an OptStruct 25 | 26 | ```ruby 27 | user = User.new(email: "admin@user.com", name: "Ms. Admin", role: "admin") 28 | 29 | # option accessors are available 30 | user.name 31 | # => "Ms. Admin" 32 | user.formatted_email 33 | # => "\"Ms. Admin\" " 34 | user.name = "Amber Admin" 35 | # => "Amber Admin" 36 | 37 | # values are also accessible through the `#options` Hash 38 | user.options 39 | # => {:email=>"admin@user.com", :name=>"Amber Admin", :role=>"admin"} 40 | user.options.fetch(:role) 41 | # => "admin" 42 | ``` 43 | 44 | # Documentation 45 | 46 | ## Use As Inheritable Class 47 | 48 | `OptStruct.new` returns an instance of `Class` that can be inherited or initialized directly. 49 | 50 | The following are functionally equivalent 51 | 52 | ```ruby 53 | class User < OptStruct.new 54 | required :email 55 | option :name 56 | end 57 | ``` 58 | 59 | ```ruby 60 | User = OptStruct.new do 61 | required :email 62 | option :name 63 | end 64 | ``` 65 | 66 | `OptStruct` classes can safely have descendants with their own isolated options. 67 | 68 | ```ruby 69 | class AdminUser < User 70 | required :token 71 | end 72 | 73 | User.new(email: "regular@user.com") 74 | # => #"regular@user.com"}> 75 | 76 | AdminUser.new(email: "admin@user.com") 77 | # ArgumentError: missing required keywords: [:token] 78 | 79 | AdminUser.new(email: "admin@user.com", token: "a2236843f0227af2") 80 | # => #"admin@user.com", :token=>"..."}> 81 | ``` 82 | 83 | ## Use As Mixin Module 84 | 85 | `OptStruct.build` returns an instance of `Module` that can be included into a class or another module. 86 | 87 | The following are functionally equivalent 88 | 89 | ```ruby 90 | module Visitable 91 | include OptStruct.build 92 | options :expected_at, :arrived_at, :departed_at 93 | end 94 | 95 | class AuditLog 96 | include Visitable 97 | end 98 | ``` 99 | 100 | ```ruby 101 | Visitable = OptStruct.build { options :expected_at, :arrived_at, :departed_at } 102 | 103 | class AuditLog 104 | include Visitable 105 | end 106 | ``` 107 | 108 | These examples result in an `AuditLog` class with identical behavior, but no explicit `Visitable` module. 109 | 110 | ```ruby 111 | class AuditLog 112 | include OptStruct.build 113 | options :expected_at, :arrived_at, :departed_at 114 | end 115 | ``` 116 | 117 | ```ruby 118 | class AuditLog 119 | include(OptStruct.build do 120 | options :expected_at, :arrived_at, :departed_at 121 | end) 122 | end 123 | ``` 124 | 125 | ## Optional Arguments 126 | 127 | Optional arguments are simply accessor methods for values expected to be in the `#options` Hash. Optional arguments can be defined in multiple ways. 128 | 129 | All of the examples in this section are functionally equivalent. 130 | 131 | ```ruby 132 | class User < OptStruct.new 133 | option :email 134 | option :role, default: "member" 135 | end 136 | ``` 137 | 138 | ```ruby 139 | class User < OptStruct.new 140 | options :email, role: "member" 141 | end 142 | ``` 143 | 144 | ```ruby 145 | class User < OptStruct.new 146 | options email: nil, role: "member" 147 | end 148 | ``` 149 | 150 | Passing a Hash to `.new` or `.build` is equivalent to passing the same hash to `options` 151 | 152 | ```ruby 153 | User = OptStruct.new(email: nil, role: "member") 154 | ``` 155 | 156 | Default blocks can also be used and are late evaluated within the struct instance. 157 | 158 | ```ruby 159 | class User < OptStruct.new 160 | option :email, default: -> { nil } 161 | option :role, -> { "member" } 162 | end 163 | ``` 164 | 165 | ```ruby 166 | class User < OptStruct.new 167 | options :email, role: -> { "member" } 168 | end 169 | ``` 170 | 171 | ```ruby 172 | class User < OptStruct.new 173 | option :email, nil 174 | option :role, -> { default_role } 175 | 176 | private 177 | 178 | def default_role 179 | "member" 180 | end 181 | end 182 | ``` 183 | 184 | Default symbols are treated as method calls if the struct `#respond_to?` the method. 185 | 186 | ```ruby 187 | class User < OptStruct.new 188 | options :email, :role => :default_role 189 | 190 | def default_role 191 | "member" 192 | end 193 | end 194 | ``` 195 | 196 | ## Required Arguments 197 | 198 | Required arguments are just like optional arguments, except they are also added to the `.required_keys` collection, which is checked when an OptStruct is initialized. If the `#options` Hash does not contain all `.required_keys` then an `ArgumentError` is raised. 199 | 200 | The following examples are functionally equivalent. 201 | 202 | ```ruby 203 | class Student < OptStruct.new 204 | required :name 205 | end 206 | ``` 207 | 208 | ```ruby 209 | class Student < OptStruct.new 210 | option :name, required: true 211 | end 212 | ``` 213 | 214 | ```ruby 215 | class Student < OptStruct.new 216 | option :name 217 | required_keys << :name 218 | end 219 | ``` 220 | 221 | ### Expected Arguments 222 | 223 | OptStructs can accept non-keyword arguments if the struct knows to expect them. 224 | 225 | For code like this to work... 226 | 227 | ```ruby 228 | user = User.new("admin@user.com", "admin") 229 | user.email # => "admin@user.com" 230 | user.role # => "admin" 231 | ``` 232 | 233 | ... the OptStruct needs to have some `.expected_arguments`. 234 | 235 | The following `User` class examples are functionally equivalent and allow the code above to function. 236 | 237 | ```ruby 238 | User = OptStruct.new(:email, :role) 239 | ``` 240 | 241 | ```ruby 242 | class User < OptStruct.new(:email) 243 | expect_argument :role 244 | end 245 | ``` 246 | 247 | ```ruby 248 | class User 249 | include OptStruct.build(:email, :role) 250 | end 251 | ``` 252 | 253 | ```ruby 254 | class User 255 | include OptStruct.build 256 | expect_arguments :email, :role 257 | end 258 | ``` 259 | 260 | ```ruby 261 | class User < OptStruct.new(:email) 262 | expected_arguments << :role 263 | end 264 | ``` 265 | 266 | Expected arguments are similar to required arguments, except they are in `.expected_arguments` collection, which is checked when an OptStruct is initialized. 267 | 268 | Expected arguments can also be supplied using keywords. An `ArgumentError` is only raised if the expected argument is not in the list of arguments passed to `OptStruct#new` **and** the argument is not present in the optional Hash passed to `OptStruct#new`. 269 | 270 | The following examples will initialize any of the `User` class examples above without error. 271 | 272 | ```ruby 273 | User.new(email: "example@user.com", role: "member") 274 | User.new("example@user.com", role: "member") 275 | User.new(role: "member", email: "example@user.com") 276 | ``` 277 | 278 | ## The `#options` Hash 279 | 280 | All OptStruct arguments are read from and stored in a single `Hash` instance. This Hash can be accessed directly using the `options` method. 281 | 282 | ```ruby 283 | Person = OptStruct.new(:name) 284 | Person.new(name: "John", age: 32).options 285 | # => {:name=>"John", :age=>32} 286 | ``` 287 | 288 | Feel free to write your own accessor methods for things like dependent options or other complex/private behavior. 289 | 290 | ```ruby 291 | class Person < OptStruct.new 292 | option :given_name 293 | option :family_name 294 | 295 | def name 296 | options.fetch(:name) { "#{given_name} #{family_name}" } 297 | end 298 | end 299 | ``` 300 | 301 | ## On Initialization 302 | 303 | All of the following examples are functionally equivalent. 304 | 305 | OptStruct classes are initialized in an `initialize` method (in `OptStruct::InstanceMethods`) like most classes. Also, like most classes, you can override `initialize` as long as you remember to call `super` properly to retain `OptStruct` functionality. 306 | 307 | ```ruby 308 | class UserReportBuilder < OptStruct.new(:user) 309 | attr_reader :report 310 | 311 | def initialize(*) 312 | super 313 | @report = [] 314 | end 315 | end 316 | ``` 317 | 318 | `OptStruct` also provides initialization callbacks to make hooking into and customizing the initialization of OptStruct classes easier, less brittle, and require less code. 319 | 320 | ```ruby 321 | class UserReportBuilder < OptStruct.new(:user) 322 | attr_reader :report 323 | init { @report = [] } 324 | end 325 | ``` 326 | 327 | ```ruby 328 | class UserReportBuilder < OptStruct.new(:user) 329 | attr_reader :report 330 | 331 | around_init do |instance| 332 | instance.call 333 | @report = [] 334 | end 335 | end 336 | ``` 337 | 338 | Available callbacks 339 | 340 | * `around_init` 341 | * `before_init` 342 | * `init` 343 | * `after_init` 344 | 345 | ## Inheritance, Expanded 346 | 347 | See `spec/inheritance_spec.rb` for examples of just how crazy you can get. 348 | --------------------------------------------------------------------------------