├── .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 | [](https://codeclimate.com/github/arturoherrero/ofstruct)
4 | [](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 |
--------------------------------------------------------------------------------