├── .github └── workflows │ └── build.yml ├── .rspec ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── benchmark ├── double_ofstruct_ostruct.rb └── hash_ofstruct_ostruct.rb ├── lib └── ofstruct.rb ├── ofstruct.gemspec └── spec ├── lib └── ofstruct_spec.rb └── spec_helper.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: ruby/setup-ruby@v1 9 | with: 10 | ruby-version: '3.3.2' 11 | bundler-cache: true 12 | 13 | - run: bundle install 14 | 15 | - run: bundle exec rspec 16 | 17 | - run: bundle exec rubocop 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rake 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | TargetRubyVersion: 3.0 7 | NewCops: enable 8 | 9 | RSpec/ExampleLength: 10 | Enabled: false 11 | 12 | RSpec/NestedGroups: 13 | Enabled: false 14 | 15 | RSpec/SpecFilePathFormat: 16 | Enabled: false 17 | 18 | Style/Documentation: 19 | Enabled: false 20 | 21 | Style/MissingRespondToMissing: 22 | Enabled: false 23 | 24 | Style/OpenStructUse: 25 | Exclude: 26 | - "benchmark/*.rb" 27 | 28 | Style/StringLiterals: 29 | EnforcedStyle: double_quotes 30 | 31 | Style/TrailingCommaInHashLiteral: 32 | EnforcedStyleForMultiline: consistent_comma 33 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | group :development, :test do 8 | gem "benchmark-ips" 9 | gem "guard-rspec" 10 | gem "rake" 11 | gem "rspec" 12 | gem "rubocop" 13 | gem "rubocop-rake" 14 | gem "rubocop-rspec" 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ofstruct (0.3.2) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | ast (2.4.2) 10 | benchmark-ips (2.14.0) 11 | coderay (1.1.3) 12 | diff-lcs (1.5.1) 13 | ffi (1.17.0) 14 | formatador (1.1.0) 15 | guard (2.19.0) 16 | formatador (>= 0.2.4) 17 | listen (>= 2.7, < 4.0) 18 | lumberjack (>= 1.0.12, < 2.0) 19 | nenv (~> 0.1) 20 | notiffany (~> 0.0) 21 | pry (>= 0.13.0) 22 | shellany (~> 0.0) 23 | thor (>= 0.18.1) 24 | guard-compat (1.2.1) 25 | guard-rspec (4.7.3) 26 | guard (~> 2.1) 27 | guard-compat (~> 1.1) 28 | rspec (>= 2.99.0, < 4.0) 29 | json (2.7.5) 30 | language_server-protocol (3.17.0.3) 31 | listen (3.9.0) 32 | rb-fsevent (~> 0.10, >= 0.10.3) 33 | rb-inotify (~> 0.9, >= 0.9.10) 34 | lumberjack (1.2.10) 35 | method_source (1.1.0) 36 | nenv (0.3.0) 37 | notiffany (0.1.3) 38 | nenv (~> 0.1) 39 | shellany (~> 0.0) 40 | parallel (1.26.3) 41 | parser (3.3.5.0) 42 | ast (~> 2.4.1) 43 | racc 44 | pry (0.14.2) 45 | coderay (~> 1.1) 46 | method_source (~> 1.0) 47 | racc (1.8.1) 48 | rainbow (3.1.1) 49 | rake (13.2.1) 50 | rb-fsevent (0.11.2) 51 | rb-inotify (0.11.1) 52 | ffi (~> 1.0) 53 | regexp_parser (2.9.2) 54 | rspec (3.13.0) 55 | rspec-core (~> 3.13.0) 56 | rspec-expectations (~> 3.13.0) 57 | rspec-mocks (~> 3.13.0) 58 | rspec-core (3.13.2) 59 | rspec-support (~> 3.13.0) 60 | rspec-expectations (3.13.3) 61 | diff-lcs (>= 1.2.0, < 2.0) 62 | rspec-support (~> 3.13.0) 63 | rspec-mocks (3.13.2) 64 | diff-lcs (>= 1.2.0, < 2.0) 65 | rspec-support (~> 3.13.0) 66 | rspec-support (3.13.1) 67 | rubocop (1.68.0) 68 | json (~> 2.3) 69 | language_server-protocol (>= 3.17.0) 70 | parallel (~> 1.10) 71 | parser (>= 3.3.0.2) 72 | rainbow (>= 2.2.2, < 4.0) 73 | regexp_parser (>= 2.4, < 3.0) 74 | rubocop-ast (>= 1.32.2, < 2.0) 75 | ruby-progressbar (~> 1.7) 76 | unicode-display_width (>= 2.4.0, < 3.0) 77 | rubocop-ast (1.33.0) 78 | parser (>= 3.3.1.0) 79 | rubocop-rake (0.6.0) 80 | rubocop (~> 1.0) 81 | rubocop-rspec (3.2.0) 82 | rubocop (~> 1.61) 83 | ruby-progressbar (1.13.0) 84 | shellany (0.0.1) 85 | thor (1.3.2) 86 | unicode-display_width (2.6.0) 87 | 88 | PLATFORMS 89 | ruby 90 | 91 | DEPENDENCIES 92 | benchmark-ips 93 | guard-rspec 94 | ofstruct! 95 | rake 96 | rspec 97 | rubocop 98 | rubocop-rake 99 | rubocop-rspec 100 | 101 | BUNDLED WITH 102 | 2.5.11 103 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :rspec, cmd: "bundle exec rspec" do 4 | require "guard/rspec/dsl" 5 | dsl = Guard::RSpec::Dsl.new(self) 6 | 7 | # RSpec files 8 | rspec = dsl.rspec 9 | watch(rspec.spec_helper) { rspec.spec_dir } 10 | watch(rspec.spec_support) { rspec.spec_dir } 11 | watch(rspec.spec_files) 12 | 13 | # Ruby files 14 | ruby = dsl.ruby 15 | dsl.watch_spec_files_for(ruby.lib_files) 16 | end 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Arturo Herrero, http://arturoherrero.com/ 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenFastStruct 2 | 3 | [![Code Climate](https://codeclimate.com/github/arturoherrero/ofstruct/badges/gpa.svg)](https://codeclimate.com/github/arturoherrero/ofstruct) 4 | [![Build Status](https://github.com/arturoherrero/ofstruct/workflows/Build/badge.svg?branch=master)](https://github.com/arturoherrero/ofstruct/actions) 5 | 6 | OpenStruct allows the creation of data objects with arbitrary attributes. 7 | 8 | **OpenFastStruct** is a data structure, similar* to an OpenStruct, that allows the 9 | definition of arbitrary attributes with their accompanying values. It benchmarks 10 | ~3x slower than a Hash, but **it's ~4x faster than OpenStruct**. 11 | 12 | *An OpenFastStruct is not exactly like an OpenStruct, these are the main 13 | differences between them: 14 | - OpenFastStruct doesn't allow hash access like `person[:name]`. 15 | - OpenFastStruct doesn't provide marshalling. 16 | - OpenFastStruct allows infinite chaining of attributes [example](#black-hole-object). 17 | 18 | 19 | ## Installation 20 | 21 | Install the gem: 22 | 23 | $ gem install ofstruct 24 | 25 | Use the gem in a project managed with Bundler adding it into the Gemfile: 26 | 27 | gem "ofstruct" 28 | 29 | 30 | ## Examples 31 | 32 | ### Basic usage 33 | 34 | ```ruby 35 | require "ofstruct" 36 | 37 | person = OpenFastStruct.new 38 | person.name = "John Smith" 39 | person.age = 70 40 | 41 | puts person.name # -> "John Smith" 42 | puts person.age # -> 70 43 | puts person.address # -> # 44 | ``` 45 | 46 | ### Initialize and update from a Hash 47 | 48 | An OpenFastStruct employs a Hash internally to store the methods and values and 49 | can even be initialized or updated with one: 50 | 51 | ```ruby 52 | require "ofstruct" 53 | 54 | person = OpenFastStruct.new(:name => "John Smith") 55 | puts person.name # -> "John Smith" 56 | 57 | person.update(:name => "David Smith", :age => 70) 58 | puts person.name # -> "David Smith" 59 | puts person.age # -> 70 60 | ``` 61 | 62 | ### Remove attributes 63 | 64 | Removing the presence of a method requires the execution the `#delete_field` 65 | method as setting the property value to a new empty OpenFastStruct. 66 | 67 | ```ruby 68 | require "ofstruct" 69 | 70 | person = OpenFastStruct.new 71 | person.name = "John Smith" 72 | person.delete_field(:name) 73 | puts person.name # -> # 74 | ``` 75 | 76 | ### *Black hole* object 77 | 78 | An OpenFastStruct instance is a *black hole* object that supports infinite 79 | chaining of attributes. 80 | 81 | ```ruby 82 | require "ofstruct" 83 | 84 | person = OpenFastStruct.new 85 | person.address.number = 4 86 | puts person.address.number # -> 4 87 | ``` 88 | 89 | 90 | ## Benchmarks 91 | 92 | Probably you heard that you should never, ever use OpenStruct because the 93 | performance penalty is prohibitive. You can use OpenFastStruct instead! 94 | 95 | #### Comparation between Hash, OpenFastStruct and OpenStruct: 96 | 97 | ``` 98 | $ ruby benchmark/hash_ofstruct_ostruct.rb 99 | Calculating ------------------------------------- 100 | Hash 25.518k i/100ms 101 | OpenFastStruct 10.527k i/100ms 102 | OpenStruct 3.236k i/100ms 103 | ------------------------------------------------- 104 | Hash 487.517k (±11.9%) i/s - 2.399M 105 | OpenFastStruct 159.952k (± 4.0%) i/s - 800.052k 106 | OpenStruct 45.602k (± 4.7%) i/s - 229.756k 107 | 108 | Comparison: 109 | Hash: 487516.9 i/s 110 | OpenFastStruct: 159952.4 i/s - 3.05x slower 111 | OpenStruct: 45601.6 i/s - 10.69x slower 112 | ``` 113 | 114 | #### Comparation between RSpec Stubs, OpenFastStruct and OpenStruct: 115 | 116 | ``` 117 | $ ruby benchmark/double_ofstruct_ostruct.rb 118 | Calculating ------------------------------------- 119 | RSpec Stubs 103.000 i/100ms 120 | OpenFastStruct 108.000 i/100ms 121 | OpenStruct 45.000 i/100ms 122 | ------------------------------------------------- 123 | RSpec Stubs 297.809 (±17.1%) i/s - 1.545k in 5.346697s 124 | OpenFastStruct 262.381 (±12.2%) i/s - 1.404k in 5.430345s 125 | OpenStruct 185.150 (± 7.0%) i/s - 945.000 126 | 127 | Comparison: 128 | RSpec Stubs: 297.8 i/s 129 | OpenFastStruct: 262.4 i/s - 1.14x slower 130 | OpenStruct: 185.2 i/s - 1.61x slower 131 | ``` 132 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rspec/core/rake_task" 4 | require "rubocop/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | RuboCop::RakeTask.new 9 | 10 | task default: :spec 11 | -------------------------------------------------------------------------------- /benchmark/double_ofstruct_ostruct.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "benchmark/ips" 4 | require "ostruct" 5 | require "rspec" 6 | require_relative "../lib/ofstruct" 7 | 8 | Benchmark.ips do |x| 9 | x.report "RSpec Stubs" do 10 | RSpec.describe "RSpec Stubs" do 11 | let(:user) { double(name: "John") } 12 | 13 | it "test" do 14 | expect(user.name).to eq("John") 15 | end 16 | end 17 | end 18 | 19 | x.report "OpenFastStruct" do 20 | RSpec.describe "OpenFastStruct" do 21 | let(:user) { OpenFastStruct.new(name: "John") } 22 | 23 | it "test" do 24 | expect(user.name).to eq("John") 25 | end 26 | end 27 | end 28 | 29 | x.report "OpenStruct" do 30 | RSpec.describe "OpenFastStruct" do 31 | let(:user) { OpenStruct.new(name: "John") } 32 | 33 | it "test" do 34 | expect(user.name).to eq("John") 35 | end 36 | end 37 | end 38 | 39 | x.compare! 40 | end 41 | -------------------------------------------------------------------------------- /benchmark/hash_ofstruct_ostruct.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "benchmark/ips" 4 | require "ostruct" 5 | require_relative "../lib/ofstruct" 6 | 7 | Benchmark.ips do |x| 8 | x.report "Hash" do 9 | object = { name: "John" } 10 | object[:name] 11 | object[:surname] = "Smith" 12 | object[:surname] 13 | end 14 | 15 | x.report "OpenFastStruct" do 16 | object = OpenFastStruct.new(name: "John") 17 | object.name 18 | object.surname = "Smith" 19 | object.surname 20 | end 21 | 22 | x.report "OpenStruct" do 23 | object = OpenStruct.new(name: "John") 24 | object.name 25 | object.surname = "Smith" 26 | object.surname 27 | end 28 | 29 | x.compare! 30 | end 31 | -------------------------------------------------------------------------------- /lib/ofstruct.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class OpenFastStruct 4 | def initialize(args = {}) 5 | @members = {} 6 | update(args) 7 | end 8 | 9 | def delete_field(key) 10 | assign(key, self.class.new) 11 | end 12 | 13 | def each_pair 14 | @members.each_pair 15 | end 16 | 17 | def update(args) 18 | ensure_hash!(args) 19 | args.each { |key, value| assign(key, value) } 20 | end 21 | 22 | def to_h 23 | @members.merge(@members) do |_, value| 24 | case value 25 | when Array 26 | value.map(&:to_h) 27 | when self.class 28 | value.to_h 29 | else 30 | value 31 | end 32 | end 33 | end 34 | 35 | def inspect 36 | "#<#{self.class}#{@members.map { |key, value| " :#{key}=#{value.inspect}" }.join}>" 37 | end 38 | alias to_s inspect 39 | 40 | def to_ary 41 | nil 42 | end 43 | 44 | def ==(other) 45 | other.is_a?(self.class) && to_h == other.to_h 46 | end 47 | 48 | private 49 | 50 | def ensure_hash!(args) 51 | raise ArgumentError unless args.is_a?(Hash) 52 | end 53 | 54 | def method_missing(name, *args) 55 | @members.fetch(name) do 56 | if name[-1] == "=" 57 | assign(name[0..-2], args.first) 58 | else 59 | delete_field(name) 60 | end 61 | end 62 | end 63 | 64 | def assign(key, value) 65 | @members[key.to_sym] = process(value) 66 | end 67 | 68 | def process(data) 69 | case data 70 | when Hash 71 | self.class.new(data) 72 | when Array 73 | data.map { |element| process(element) } 74 | else 75 | data 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /ofstruct.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.platform = Gem::Platform::RUBY 5 | spec.name = "ofstruct" 6 | spec.version = "0.3.2" 7 | spec.summary = "OpenFastStruct" 8 | spec.description = "OpenFastStruct is a data structure, similar to an OpenStruct but faster." 9 | spec.author = "Arturo Herrero" 10 | spec.email = "arturo.herrero@gmail.com" 11 | spec.homepage = "https://github.com/arturoherrero/ofstruct" 12 | spec.license = "MIT" 13 | spec.metadata = { "rubygems_mfa_required" => "true" } 14 | 15 | spec.required_ruby_version = ">= 3.0" 16 | 17 | spec.files = Dir["{lib}/**/*", "LICENSE", "README.md"] 18 | spec.require_paths = ["lib"] 19 | end 20 | -------------------------------------------------------------------------------- /spec/lib/ofstruct_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ofstruct" 4 | 5 | RSpec.describe OpenFastStruct do 6 | subject(:ofstruct) { described_class.new } 7 | 8 | it "can be instantiated with no arguments" do 9 | expect(ofstruct).to be_a(described_class) 10 | end 11 | 12 | it "works as an accessor" do 13 | ofstruct.name = "John" 14 | expect(ofstruct.name).to eq("John") 15 | end 16 | 17 | it "works as a black hole accessor" do 18 | ofstruct.user.name = "John" 19 | expect(ofstruct.user.name).to eq("John") 20 | end 21 | 22 | it "works with unexisting values" do 23 | expect(ofstruct.name).to be_a(described_class) 24 | end 25 | 26 | it "equals to other with the same attributes" do 27 | ofstruct.user.name = "John" 28 | other_ofstruct = described_class.new 29 | other_ofstruct.user.name = "John" 30 | expect(ofstruct).to eq(other_ofstruct) 31 | end 32 | 33 | context "when instantiated from arguments" do 34 | subject(:ofstruct) { described_class.new(args) } 35 | 36 | context "without a hash" do 37 | let(:args) { double } 38 | 39 | it "raises an exception" do 40 | expect { ofstruct }.to raise_error(ArgumentError) 41 | end 42 | end 43 | 44 | context "with a hash" do 45 | let(:symbol_args) { { name: "John" } } 46 | let(:string_args) { { "name" => "John" } } 47 | let(:nested_args) { { person: { name: "John" } } } 48 | let(:array_args) { { list: [{ name: "John" }, { name: "Doe" }] } } 49 | 50 | context "with symbol keys" do 51 | let(:args) { symbol_args } 52 | 53 | it "works as a reader" do 54 | expect(ofstruct.name).to eq("John") 55 | end 56 | 57 | it "works as an accessor" do 58 | ofstruct.name = "John Smith" 59 | expect(ofstruct.name).to eq("John Smith") 60 | end 61 | 62 | describe "#delete_field" do 63 | it "removes the named key" do 64 | ofstruct.delete_field(:name) 65 | expect(ofstruct.name).to be_a(described_class) 66 | end 67 | end 68 | 69 | describe "#each_pair" do 70 | it "yields all keys with the values" do 71 | expect(ofstruct.each_pair.to_a).to eq([[:name, "John"]]) 72 | end 73 | 74 | it "returns an enumerator" do 75 | expect(ofstruct.each_pair).to be_a(Enumerator) 76 | end 77 | end 78 | 79 | describe "#update" do 80 | it "overwrite previous keys" do 81 | ofstruct.update(name: "John Smith") 82 | expect(ofstruct.name).to eq("John Smith") 83 | end 84 | 85 | it "adds new keys" do 86 | ofstruct.update(surname: "Smith") 87 | expect(ofstruct.surname).to eq("Smith") 88 | end 89 | 90 | context "without hash" do 91 | it "raises an exception" do 92 | expect { ofstruct.update(double) }.to raise_error(ArgumentError) 93 | end 94 | end 95 | end 96 | 97 | describe "#to_h" do 98 | it "converts to a hash" do 99 | expect(ofstruct.to_h).to eq(args) 100 | end 101 | 102 | it "converts to a hash with chained attributes" do 103 | ofstruct.address.street = "Sunset Boulevar" 104 | ofstruct.address.number = 4 105 | 106 | expect(ofstruct.to_h).to eq( 107 | { 108 | name: "John", 109 | address: { 110 | street: "Sunset Boulevar", 111 | number: 4, 112 | }, 113 | } 114 | ) 115 | end 116 | end 117 | 118 | describe "#inspect" do 119 | it "returns the human-readable representation" do 120 | expect(ofstruct.inspect).to eq('#') 121 | end 122 | end 123 | end 124 | 125 | context "with string keys" do 126 | let(:args) { string_args } 127 | 128 | it "works as a reader" do 129 | expect(ofstruct.name).to eq("John") 130 | end 131 | 132 | describe "#to_h" do 133 | it "converts to a hash with keys as symbols" do 134 | expect(ofstruct.to_h).to eq(symbol_args) 135 | end 136 | end 137 | end 138 | 139 | context "with a nested hash" do 140 | let(:args) { nested_args } 141 | 142 | it "works as a reader" do 143 | expect(ofstruct.person.name).to eq("John") 144 | end 145 | 146 | it "converts to a hash" do 147 | expect(ofstruct.to_h).to eq(args) 148 | end 149 | end 150 | 151 | context "with a array of hashes" do 152 | let(:args) { array_args } 153 | 154 | it "works as a reader" do 155 | expect(ofstruct.list.map(&:name)).to include("John", "Doe") 156 | end 157 | 158 | it "converts to a hash" do 159 | expect(ofstruct.to_h).to eq(args) 160 | end 161 | end 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.expect_with :rspec do |expectations| 5 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 6 | end 7 | 8 | config.mock_with :rspec do |mocks| 9 | mocks.verify_partial_doubles = true 10 | end 11 | 12 | config.disable_monkey_patching! 13 | 14 | config.default_formatter = "doc" if config.files_to_run.one? 15 | 16 | config.order = :random 17 | Kernel.srand config.seed 18 | end 19 | --------------------------------------------------------------------------------