├── .gitignore ├── .rspec ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── benchmark.rb ├── console └── setup ├── dynamic_class.gemspec ├── lib ├── dynamic_class.rb └── dynamic_class │ └── version.rb └── spec ├── dynamic_class_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.1.8 4 | - 2.2.4 5 | - 2.3.0 6 | before_install: gem install bundler -v 1.11.2 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at ariel.caplan@mail.yu.edu. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ 50 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in dynamic_class.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 amcaplan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/dynamic_class.svg)](https://badge.fury.io/rb/dynamic_class) 2 | [![Build Status](https://travis-ci.org/amcaplan/dynamic_class.svg?branch=master)](https://travis-ci.org/amcaplan/dynamic_class) 3 | [![Code Climate](https://codeclimate.com/github/amcaplan/dynamic_class.png)](https://codeclimate.com/github/amcaplan/dynamic_class) 4 | 5 | # DynamicClass 6 | 7 | Many developers use `OpenStruct` as a convenient way of consuming APIs through 8 | a nifty data object. But the performance penalty is pretty awful. 9 | 10 | `DynamicClass` offers a better solution, optimizing for the case where you 11 | need to create objects with the same set of properties every time, but you 12 | can't define the needed keys until runtime. `DynamicClass` works by defining 13 | instance methods on the class every time it encounters a new propery. 14 | 15 | Let's see it in action: 16 | 17 | ``` ruby 18 | Animal = DynamicClass.new do 19 | def speak 20 | "The #{type} makes a #{sound} sound!" 21 | end 22 | end 23 | 24 | dog = Animal.new(type: 'dog', sound: 'woof') 25 | # => # 26 | dog.speak 27 | # => The dog makes a woof sound! 28 | dog.ears = 'droopy' 29 | dog[:nose] = ['cold', 'wet'] 30 | dog['tail'] = 'waggable' 31 | dog 32 | # => # 33 | dog.type 34 | # => "dog" 35 | dog.tail 36 | # => "waggable" 37 | 38 | cat = Animal.new 39 | # => # 40 | cat.to_h 41 | # => {:type=>nil, :sound=>nil, :ears=>nil, :nose=>nil, :tail=>nil} 42 | # The class has been changed by the dog! 43 | ``` 44 | 45 | Because methods are defined on the class (unlike `OpenStruct` which defines 46 | methods on the object's singleton class), there is no need to define a method 47 | more than once. This means that, past the first time a property is added, 48 | the cost of setting a property drops. 49 | 50 | The results are pretty astounding. Here are the results of the benchmark in 51 | `bin/benchmark.rb` (including a few other `OpenStruct`-like solutions for 52 | comparison), run on Ruby 2.3.1; the final benchmark is most representative of 53 | the average case: 54 | 55 | ``` 56 | Initialization benchmark 57 | 58 | Warming up -------------------------------------- 59 | OpenStruct 84.801k i/100ms 60 | PersistentOpenStruct 74.901k i/100ms 61 | OpenFastStruct 81.303k i/100ms 62 | DynamicClass 97.024k i/100ms 63 | RegularClass 211.767k i/100ms 64 | Calculating ------------------------------------- 65 | OpenStruct 1.104M (± 5.8%) i/s - 5.512M in 5.011886s 66 | PersistentOpenStruct 941.181k (± 5.2%) i/s - 4.719M in 5.027485s 67 | OpenFastStruct 1.020M (± 6.0%) i/s - 5.122M in 5.040500s 68 | DynamicClass 1.309M (± 4.0%) i/s - 6.598M in 5.049905s 69 | RegularClass 4.170M (± 4.0%) i/s - 20.965M in 5.036315s 70 | 71 | Comparison: 72 | RegularClass: 4169602.6 i/s 73 | DynamicClass: 1308644.3 i/s - 3.19x slower 74 | OpenStruct: 1103594.3 i/s - 3.78x slower 75 | OpenFastStruct: 1019939.5 i/s - 4.09x slower 76 | PersistentOpenStruct: 941180.6 i/s - 4.43x slower 77 | 78 | 79 | 80 | Assignment Benchmark 81 | 82 | Warming up -------------------------------------- 83 | OpenStruct 216.147k i/100ms 84 | PersistentOpenStruct 210.657k i/100ms 85 | OpenFastStruct 101.072k i/100ms 86 | DynamicClass 311.870k i/100ms 87 | RegularClass 312.066k i/100ms 88 | Calculating ------------------------------------- 89 | OpenStruct 4.505M (± 5.0%) i/s - 22.479M in 5.003206s 90 | PersistentOpenStruct 4.515M (± 5.0%) i/s - 22.540M in 5.005895s 91 | OpenFastStruct 1.383M (± 3.5%) i/s - 6.974M in 5.048792s 92 | DynamicClass 11.138M (± 5.0%) i/s - 55.825M in 5.026293s 93 | RegularClass 11.069M (± 5.8%) i/s - 55.236M in 5.009156s 94 | 95 | Comparison: 96 | DynamicClass: 11137717.4 i/s 97 | RegularClass: 11068826.7 i/s - same-ish: difference falls within error 98 | PersistentOpenStruct: 4514966.3 i/s - 2.47x slower 99 | OpenStruct: 4505071.4 i/s - 2.47x slower 100 | OpenFastStruct: 1383122.4 i/s - 8.05x slower 101 | 102 | 103 | 104 | Access Benchmark 105 | 106 | Warming up -------------------------------------- 107 | OpenStruct 259.543k i/100ms 108 | PersistentOpenStruct 255.894k i/100ms 109 | OpenFastStruct 225.799k i/100ms 110 | DynamicClass 313.455k i/100ms 111 | RegularClass 313.982k i/100ms 112 | Calculating ------------------------------------- 113 | OpenStruct 6.744M (± 5.0%) i/s - 33.741M in 5.016060s 114 | PersistentOpenStruct 6.863M (± 5.2%) i/s - 34.290M in 5.011129s 115 | OpenFastStruct 4.717M (± 4.5%) i/s - 23.709M in 5.036478s 116 | DynamicClass 11.467M (± 5.9%) i/s - 57.362M in 5.021761s 117 | RegularClass 11.395M (± 6.6%) i/s - 56.831M in 5.011823s 118 | 119 | Comparison: 120 | DynamicClass: 11467320.5 i/s 121 | RegularClass: 11395421.4 i/s - same-ish: difference falls within error 122 | PersistentOpenStruct: 6862609.3 i/s - 1.67x slower 123 | OpenStruct: 6744325.9 i/s - 1.70x slower 124 | OpenFastStruct: 4717334.0 i/s - 2.43x slower 125 | 126 | 127 | 128 | All-Together Benchmark 129 | 130 | Warming up -------------------------------------- 131 | OpenStruct 13.929k i/100ms 132 | PersistentOpenStruct 64.546k i/100ms 133 | OpenFastStruct 45.014k i/100ms 134 | DynamicClass 96.783k i/100ms 135 | RegularClass 197.149k i/100ms 136 | Calculating ------------------------------------- 137 | OpenStruct 147.361k (± 4.8%) i/s - 738.237k in 5.021813s 138 | PersistentOpenStruct 766.793k (± 5.8%) i/s - 3.873M in 5.069128s 139 | OpenFastStruct 525.565k (± 4.1%) i/s - 2.656M in 5.062072s 140 | DynamicClass 1.251M (± 4.0%) i/s - 6.291M in 5.038697s 141 | RegularClass 3.758M (± 4.1%) i/s - 18.926M in 5.046044s 142 | 143 | Comparison: 144 | RegularClass: 3757567.8 i/s 145 | DynamicClass: 1250634.2 i/s - 3.00x slower 146 | PersistentOpenStruct: 766792.7 i/s - 4.90x slower 147 | OpenFastStruct: 525565.1 i/s - 7.15x slower 148 | OpenStruct: 147361.4 i/s - 25.50x slower 149 | ``` 150 | 151 | `DynamicClass` is still behind plain old Ruby classes, but it's the best out of 152 | the pack when it comes to `OpenStruct` and friends. 153 | 154 | ## WARNING! 155 | 156 | This class should only be used to consume trusted APIs, or for similar purposes. 157 | It should never be used to take in user input. This will open you up to a memory 158 | leak DoS attack, since every new key becomes a new method defined on the class, 159 | and is never erased. 160 | 161 | ## Installation 162 | 163 | Add this line to your application's Gemfile: 164 | 165 | ```ruby 166 | gem 'dynamic_class' 167 | ``` 168 | 169 | And then execute: 170 | 171 | $ bundle 172 | 173 | Or install it yourself as: 174 | 175 | $ gem install dynamic_class 176 | 177 | ## Development 178 | 179 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 180 | `rake spec` to run the tests. You can run the benchmark using `rake benchmark`. 181 | You can also run `bin/console` for an interactive prompt that will allow you to 182 | experiment. 183 | 184 | To install this gem onto your local machine, run `bundle exec rake install`. 185 | 186 | ## Contributing 187 | 188 | Bug reports and pull requests are welcome. This project is intended to be a 189 | safe, welcoming space for collaboration, and contributors are expected to adhere 190 | to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 191 | 192 | For functionality changes or bug fixes, please include tests. For performance 193 | enhancements, please run the benchmarks and include results in your pull 194 | request. 195 | 196 | ## License 197 | 198 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 199 | 200 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | desc 'Run Benchmarking Examples' 7 | task :benchmark do 8 | require './bin/benchmark' 9 | end 10 | 11 | task :default => :spec 12 | -------------------------------------------------------------------------------- /bin/benchmark.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark/ips' 2 | require 'dynamic_class' 3 | require 'ostruct' 4 | require 'persistent_open_struct' 5 | require 'ofstruct' 6 | 7 | class RegularClass 8 | attr_accessor :foo 9 | 10 | def initialize(args) 11 | @foo = args[:foo] 12 | end 13 | end 14 | 15 | MyDynamicClass = DynamicClass.new 16 | 17 | puts "Initialization benchmark\n\n" 18 | 19 | Benchmark.ips do |x| 20 | input_hash = { foo: :bar } 21 | 22 | x.report('OpenStruct') do 23 | OpenStruct.new(input_hash) 24 | end 25 | 26 | x.report('PersistentOpenStruct') do 27 | PersistentOpenStruct.new(input_hash) 28 | end 29 | 30 | x.report('OpenFastStruct') do 31 | OpenFastStruct.new(input_hash) 32 | end 33 | 34 | x.report('DynamicClass') do 35 | MyDynamicClass.new(input_hash) 36 | end 37 | 38 | x.report('RegularClass') do 39 | RegularClass.new(input_hash) 40 | end 41 | 42 | x.compare! 43 | end 44 | 45 | puts "\n\nAssignment Benchmark\n\n" 46 | 47 | Benchmark.ips do |x| 48 | os = OpenStruct.new(foo: :bar) 49 | pos = PersistentOpenStruct.new(foo: :bar) 50 | ofs = OpenFastStruct.new(foo: :bar) 51 | dc = MyDynamicClass.new(foo: :bar) 52 | rgc = RegularClass.new(foo: :bar) 53 | 54 | x.report('OpenStruct') do 55 | os.foo = :bar 56 | end 57 | 58 | x.report('PersistentOpenStruct') do 59 | pos.foo = :bar 60 | end 61 | 62 | x.report('OpenFastStruct') do 63 | ofs.foo = :bar 64 | end 65 | 66 | x.report('DynamicClass') do 67 | dc.foo = :bar 68 | end 69 | 70 | x.report('RegularClass') do 71 | rgc.foo = :bar 72 | end 73 | 74 | x.compare! 75 | end 76 | 77 | puts "\n\nAccess Benchmark\n\n" 78 | 79 | Benchmark.ips do |x| 80 | os = OpenStruct.new(foo: :bar) 81 | pos = PersistentOpenStruct.new(foo: :bar) 82 | ofs = OpenFastStruct.new(foo: :bar) 83 | dc = MyDynamicClass.new(foo: :bar) 84 | rgc = RegularClass.new(foo: :bar) 85 | 86 | x.report('OpenStruct') do 87 | os.foo 88 | end 89 | 90 | x.report('PersistentOpenStruct') do 91 | pos.foo 92 | end 93 | 94 | x.report('OpenFastStruct') do 95 | ofs.foo 96 | end 97 | 98 | x.report('DynamicClass') do 99 | dc.foo 100 | end 101 | 102 | x.report('RegularClass') do 103 | rgc.foo 104 | end 105 | 106 | x.compare! 107 | end 108 | 109 | puts "\n\nAll-Together Benchmark\n\n" 110 | 111 | Benchmark.ips do |x| 112 | input_hash = { foo: :bar } 113 | 114 | x.report('OpenStruct') do 115 | os = OpenStruct.new(input_hash) 116 | os.foo = :bar 117 | os.foo 118 | end 119 | 120 | x.report('PersistentOpenStruct') do 121 | pos = PersistentOpenStruct.new(input_hash) 122 | pos.foo = :bar 123 | pos.foo 124 | end 125 | 126 | x.report('OpenFastStruct') do 127 | ofs = OpenFastStruct.new(input_hash) 128 | ofs.foo = :bar 129 | ofs.foo 130 | end 131 | 132 | x.report('DynamicClass') do 133 | dc = MyDynamicClass.new(input_hash) 134 | dc.foo = :bar 135 | dc.foo 136 | end 137 | 138 | x.report('RegularClass') do 139 | rgc = RegularClass.new(input_hash) 140 | rgc.foo = :bar 141 | rgc.foo 142 | end 143 | 144 | x.compare! 145 | end 146 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "dynamic_class" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /dynamic_class.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'dynamic_class/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "dynamic_class" 8 | spec.version = DynamicClass::VERSION 9 | spec.authors = ["amcaplan"] 10 | spec.email = ["ariel.caplan@mail.yu.edu"] 11 | 12 | spec.summary = %q{Create classes that define themselves... eventually.} 13 | spec.description = %q{Specifically designed as an OpenStruct-like tool for consuming APIs, dynamic_class lets your classes define their own getters and setters at runtime based on the data instances receive at instantiation.} 14 | spec.homepage = "https://github.com/amcaplan/dynamic_class" 15 | spec.license = "MIT" 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "exe" 18 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.required_ruby_version = '>= 2.1.0' 22 | 23 | spec.add_development_dependency "bundler", "~> 1.11" 24 | spec.add_development_dependency "rake", "~> 10.0" 25 | spec.add_development_dependency "rspec", "~> 3.0" 26 | spec.add_development_dependency "ofstruct", "~> 0.2" 27 | spec.add_development_dependency "persistent_open_struct", "~> 0.0" 28 | spec.add_development_dependency "benchmark-ips", "~> 2.3" 29 | end 30 | -------------------------------------------------------------------------------- /lib/dynamic_class.rb: -------------------------------------------------------------------------------- 1 | require 'dynamic_class/version' 2 | require 'set' 3 | 4 | module DynamicClass 5 | def self.new(&block) 6 | ::Class.new(::DynamicClass::Class).tap do |klass| 7 | klass.class_exec(&block) if block_given? 8 | end 9 | end 10 | 11 | class Class 12 | class << self 13 | def attributes 14 | @attributes ||= Set.new 15 | end 16 | 17 | # Always revert to original #to_h in case the parent class has already 18 | # redefined #to_h. 19 | def inherited(subclass) 20 | subclass.class_eval <<-RUBY 21 | def to_h 22 | {} 23 | end 24 | RUBY 25 | end 26 | 27 | def mutex 28 | @mutex ||= Mutex.new 29 | end 30 | 31 | def add_methods!(key) 32 | class_exec do 33 | mutex.synchronize do 34 | attr_writer key unless method_defined?("#{key}=") 35 | attr_reader key unless method_defined?("#{key}") 36 | attributes << key 37 | 38 | # I'm pretty sure this is safe, because attempting to add an attribute 39 | # that isn't a valid instance variable name will raise an error. Please 40 | # contact the maintainer if you find a situation where this could be a 41 | # security problem. 42 | # 43 | # The reason to use class_eval here is because, based on benchmarking, 44 | # this defines the fastest version of #to_h possible. 45 | class_eval <<-RUBY 46 | def to_h 47 | { 48 | #{ 49 | attributes.map { |attribute| 50 | "#{attribute.inspect} => #{attribute}" 51 | }.join(",\n") 52 | } 53 | } 54 | end 55 | RUBY 56 | end 57 | end 58 | end 59 | end 60 | 61 | def initialize(attributes = {}) 62 | attributes.each_pair do |key, value| 63 | __send__(:"#{key}=", value) 64 | end 65 | end 66 | 67 | def to_h 68 | {} 69 | end 70 | 71 | def []=(key, value) 72 | key = key.to_sym 73 | instance_variable_set(:"@#{key}", value) 74 | self.class.add_methods!(key) unless self.class.attributes.include?(key) 75 | end 76 | 77 | def [](key) 78 | instance_variable_get(:"@#{key}") 79 | end 80 | 81 | def each_pair 82 | return to_enum(__method__) { self.class.attributes.size } unless block_given? 83 | to_h.each_pair do |key, value| 84 | yield key, value 85 | end 86 | end 87 | 88 | def method_missing(mid, *args) 89 | len = args.length 90 | if (mname = mid[/.*(?==\z)/m]) 91 | if len != 1 92 | raise ArgumentError, "wrong number of arguments (#{len} for 1)", caller(1) 93 | end 94 | self[mname] = args.first 95 | elsif len == 0 96 | self[mid] 97 | else 98 | raise ArgumentError, "wrong number of arguments (#{len} for 0)", caller(1) 99 | end 100 | end 101 | 102 | def delete_field(key) 103 | instance_variable_set(:"@#{key}", nil) 104 | end 105 | 106 | def ==(other) 107 | other.is_a?(self.class) && to_h == other.to_h 108 | end 109 | 110 | def eql?(other) 111 | other.is_a?(self.class) && to_h.eql?(other.to_h) 112 | end 113 | 114 | def hash 115 | to_h.hash 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/dynamic_class/version.rb: -------------------------------------------------------------------------------- 1 | module DynamicClass 2 | VERSION = "0.2.3" 3 | end 4 | -------------------------------------------------------------------------------- /spec/dynamic_class_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'ostruct' 3 | 4 | describe DynamicClass do 5 | subject { klass.new(data) } 6 | 7 | let(:klass) { described_class.new } 8 | let(:data) {{}} # default, overridden in many tests 9 | 10 | def expect_subject_responds(attribute) 11 | expect(subject).to respond_to(attribute) 12 | expect(subject).to respond_to(:"#{attribute}=") 13 | end 14 | 15 | def expect_subject_does_not_respond(attribute) 16 | expect(subject).not_to respond_to(attribute) 17 | expect(subject).not_to respond_to(:"#{attribute}=") 18 | end 19 | 20 | 21 | describe 'setting and getting values' do 22 | context 'setting at initialization' do 23 | let(:data) {{ foo: 'bar' }} 24 | 25 | it 'retrieves via getter method' do 26 | expect(subject.foo).to eq('bar') 27 | end 28 | 29 | it 'retrieves via [] method with Symbol' do 30 | expect(subject[:foo]).to eq('bar') 31 | end 32 | 33 | it 'retrieves via [] method with String' do 34 | expect(subject['foo']).to eq('bar') 35 | end 36 | end 37 | 38 | context 'setting after initialization' do 39 | let(:value) { double(:value) } 40 | 41 | context 'setting with attr= methods' do 42 | it 'returns the input value' do 43 | expect(subject.foo = value).to eq(value) 44 | end 45 | 46 | it 'sets the value' do 47 | subject.foo = value 48 | expect(subject.foo).to eq(value) 49 | end 50 | end 51 | 52 | context 'setting with []= method' do 53 | it 'returns the input value' do 54 | expect(subject[:foo] = value).to eq(value) 55 | end 56 | 57 | it 'sets the value' do 58 | subject[:foo] = value 59 | expect(subject.foo).to eq(value) 60 | end 61 | end 62 | end 63 | end 64 | 65 | describe 'append-only method signature' do 66 | it 'does not respond to getters and setters by default' do 67 | expect_subject_does_not_respond(:foo) 68 | end 69 | 70 | it 'appends getters and setters in response to setting a value at initialization' do 71 | klass.new(foo: 'bar') 72 | expect_subject_responds(:foo) 73 | end 74 | 75 | describe 'appending getters and setters in response to setting a value after initialization' do 76 | it 'appends in response to a = method' do 77 | subject.foo = 'bar' 78 | expect_subject_responds(:foo) 79 | end 80 | 81 | it 'appends in response to the []= method with a Symbol' do 82 | subject[:foo] = 'bar' 83 | expect_subject_responds(:foo) 84 | end 85 | 86 | it 'appends in response to the []= method with a String' do 87 | subject['foo'] = 'bar' 88 | expect_subject_responds(:foo) 89 | end 90 | end 91 | 92 | it 'does not append getters and setters in response to getting an attribute' do 93 | subject.foo 94 | expect_subject_does_not_respond(:foo) 95 | end 96 | 97 | it 'does not remove getters and setters when a field is deleted' do 98 | subject.foo = 'bar' 99 | subject.delete_field(:foo) 100 | expect_subject_responds(:foo) 101 | end 102 | 103 | describe 'not overwriting existing methods' do 104 | let(:klass) { 105 | DynamicClass.new do 106 | def bar=(value) 107 | @bar = value + 4 108 | end 109 | 110 | def foo 111 | @foo + 3 112 | end 113 | end 114 | } 115 | 116 | context 'on attributes set at initialization' do 117 | let(:data) {{ foo: 0, bar: 0 }} 118 | 119 | it 'uses the explicitly defined setter' do 120 | expect(subject.bar).to eq(4) 121 | end 122 | 123 | it 'uses the explicitly defined getter' do 124 | expect(subject.foo).to eq(3) 125 | end 126 | end 127 | 128 | context 'on attributes set after initialization' do 129 | before do 130 | subject.foo = 0 131 | subject.bar = 0 132 | end 133 | 134 | it 'uses the explicitly defined setter' do 135 | expect(subject.bar).to eq(4) 136 | end 137 | 138 | it 'uses the explicitly defined getter' do 139 | expect(subject.foo).to eq(3) 140 | end 141 | end 142 | end 143 | end 144 | 145 | describe 'equality testing' do 146 | let(:object1) { klass.new(data1) } 147 | let(:object2) { klass.new(data2) } 148 | 149 | context 'both objects are empty' do 150 | let(:data1) {{}} 151 | let(:data2) {{}} 152 | 153 | it 'considers the objects equal' do 154 | expect(object1).to eq(object2) 155 | end 156 | end 157 | 158 | context 'one object is empty' do 159 | let(:data1) {{}} 160 | let(:data2) {{ a: 'foo' }} 161 | 162 | it 'considers the objects unequal' do 163 | expect(object1).not_to eq(object2) 164 | end 165 | end 166 | 167 | context 'both objects hold the same data' do 168 | let(:data1) {{ a: 'foo' }} 169 | let(:data2) {{ a: 'foo' }} 170 | 171 | it 'considers the objects equal' do 172 | expect(object1).to eq(object2) 173 | end 174 | end 175 | 176 | context 'both objects have the same keys but different values' do 177 | let(:data1) {{ a: 'foo' }} 178 | let(:data2) {{ a: 'bar' }} 179 | 180 | it 'considers the objects unequal' do 181 | expect(object1).not_to eq(object2) 182 | end 183 | end 184 | 185 | context 'both objects have the same values but different keys' do 186 | let(:data1) {{ a: 'foo' }} 187 | let(:data2) {{ b: 'foo' }} 188 | 189 | it 'considers the objects unequal' do 190 | expect(object1).not_to eq(object2) 191 | end 192 | end 193 | end 194 | 195 | describe 'inputting and outputting hashes and hash-like objects' do 196 | let(:data) {{name: "John Smith", age: 70, pension: 300}} 197 | 198 | it 'outputs a hash equal to its input hash' do 199 | expect(subject.to_h).to eq(data) 200 | end 201 | 202 | it "accepts another #{described_class} instance instead of a hash on initialization" do 203 | expect(klass.new(subject).to_h).to eq(data) 204 | end 205 | 206 | it 'accepts a Struct instead of a hash on initialization' do 207 | struct = Struct.new(*data.keys).new(*data.values) 208 | expect(klass.new(struct).to_h).to eq(data) 209 | end 210 | 211 | it 'accepts an OpenStruct instead of a hash on initialization' do 212 | open_struct = OpenStruct.new(data) 213 | expect(klass.new(open_struct).to_h).to eq(data) 214 | end 215 | end 216 | 217 | describe 'accessing keys and values with #each_pair' do 218 | let(:data) {{ name: "John Smith", age: 70, pension: 300 }} 219 | 220 | it 'iterates through each pair' do 221 | pairs = data.each_pair 222 | subject.each_pair do |key, value| 223 | expect([key, value]).to eq(pairs.next) 224 | end 225 | expect { pairs.next }.to raise_error(StopIteration) # all values were seen 226 | end 227 | 228 | it 'returns the iterated hash' do 229 | expect(subject.each_pair{}).to eq(data) 230 | end 231 | end 232 | 233 | describe 'computing hash value' do 234 | let(:data) {{ name: "John Smith", age: 70, pension: 300 }} 235 | 236 | it 'uses the hash value of the internally built hash' do 237 | expect(subject.hash).to eq(data.hash) 238 | end 239 | end 240 | 241 | describe 'raising errors' do 242 | context 'including an argument on a getter which has not yet been created' do 243 | it 'raises an ArgumentError' do 244 | expect { subject.foo(true) }.to raise_error(ArgumentError) 245 | end 246 | end 247 | 248 | context 'including an extra argument on a setter which has not yet been created' do 249 | it 'raises an ArgumentError' do 250 | expect { subject.send(:foo=, 'bar', 'bar') }.to raise_error(ArgumentError) 251 | end 252 | end 253 | end 254 | 255 | describe 'relationship between instances of the same DynamicClass class' do 256 | it 'makes the same methods available to other instances of the same class' do 257 | expect_subject_does_not_respond(:foo) 258 | klass.new(foo: 'bar') 259 | expect_subject_responds(:foo) 260 | end 261 | end 262 | 263 | describe 'relationship between instances of different DynamicClass classes' do 264 | context 'the classes have no relationship' do 265 | let(:klass2) { DynamicClass.new } 266 | 267 | before do 268 | klass2.new(foo: 'bar') 269 | expect(klass2.new).to respond_to(:foo) 270 | end 271 | 272 | it 'does not impact an unrelated class' do 273 | expect_subject_does_not_respond(:foo) 274 | end 275 | end 276 | 277 | context 'one class inherits from the other' do 278 | let(:klass2) { Class.new(klass) } 279 | let(:child) { klass2.new } 280 | 281 | context 'an attribute is added to the parent' do 282 | before do 283 | subject.foo = 'bar' 284 | expect_subject_responds(:foo) 285 | end 286 | 287 | it 'inherits methods from a parent' do 288 | expect(child).to respond_to(:foo) 289 | expect(child).to respond_to(:foo=) 290 | expect(subject.to_h).to have_key(:foo) 291 | end 292 | 293 | it 'does not include attributes from the parent in output hash' do 294 | expect(child.to_h).not_to have_key(:foo) 295 | end 296 | end 297 | 298 | context 'an attribute is added to the child' do 299 | before do 300 | child.foo = 'bar' 301 | expect(child).to respond_to(:foo) 302 | expect(child).to respond_to(:foo=) 303 | expect(child.to_h).to have_key(:foo) 304 | end 305 | 306 | it 'does not pass methods up to the parent' do 307 | expect_subject_does_not_respond(:foo) 308 | end 309 | 310 | it 'does not include attributes from the child in output hash' do 311 | expect(subject.to_h).not_to have_key(:foo) 312 | end 313 | end 314 | end 315 | end 316 | 317 | describe 'freezing' do 318 | context 'subject is frozen' do 319 | before do 320 | subject.foo = 'bar' 321 | subject.freeze 322 | end 323 | 324 | it 'raises a RuntimeError when adding a new value' do 325 | expect { subject.baz = 'quux' }.to raise_error(RuntimeError) 326 | end 327 | 328 | it 'raises a RuntimeError when modifying a value' do 329 | expect { subject.foo = 'quux' }.to raise_error(RuntimeError) 330 | end 331 | end 332 | end 333 | 334 | describe 'custom class code' do 335 | context 'when a block is not specified' do 336 | let(:klass) { DynamicClass.new } 337 | 338 | it 'does not raise an error' do 339 | expect { klass.new }.not_to raise_error 340 | end 341 | end 342 | 343 | context 'when a block is specified' do 344 | let(:klass) { 345 | DynamicClass.new do 346 | def four 347 | 4 348 | end 349 | end 350 | } 351 | 352 | it 'responds to methods defined in the passed-in block' do 353 | expect(subject.four).to eq(4) 354 | end 355 | end 356 | end 357 | 358 | describe 'thread-safety' do 359 | context 'adding keys in multiple threads' do 360 | before do 361 | 500.times.map { |i| 362 | Thread.new do 363 | subject["foo#{i}"] = i 364 | end 365 | }.each(&:join) 366 | end 367 | 368 | it 'adds all the keys appropriately' do 369 | (0...500).each do |i| 370 | expect(subject).to respond_to(:"foo#{i}") 371 | end 372 | end 373 | 374 | it 'updates #to_h properly' do 375 | keys = (0...500).map { |i| :"foo#{i}" } 376 | expect(subject.to_h).to include(*keys) 377 | end 378 | end 379 | end 380 | end 381 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'dynamic_class' 3 | --------------------------------------------------------------------------------