safe_ostruct 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b350ef819ecad8545a838c5e3a1c1840fbbe7f64c8455a56a0afa304e4e1d7e
4
- data.tar.gz: ff69a99e6fba10bf86c54cc360e4ae41c31add767ccd3e080eaa325a754c88b5
3
+ metadata.gz: 9b0df898c8251bc9194d8c10a6b1a757ca50fc4933e864a8a4fc1120ea59a957
4
+ data.tar.gz: 5373099a228a7bd3ac7b4ec60946a5c4fdd30bb5a8baf4923aeb8aaca73335c6
5
5
  SHA512:
6
- metadata.gz: 68b2240672f66147033bc97efcc01b0a06747cf8b89d327236ac445fd1b72005cf40ed759a1127922134e70cb5934771fb4663d9fd4292d6910b5dae8c53a955
7
- data.tar.gz: 959d453d08c5ee503883c6279f9c747dff73fe73368c3ef1c9a720356c5532b9064b17f0005b2aaf36b656fa505bbcc73d766fbbca6497b34e8cc812c9772815
6
+ metadata.gz: 67d7be1d36a08b01eb72ba30d1cd02a571d4781744a37356d500a8248bf4144a3a86c29ef643be07b0f31f9f8b6ce0b4d7806ebdf832c3eb75a80c19050d2406
7
+ data.tar.gz: 3d8f958423503e3999db6ccb5e361be2d91b38a474cd3e24a7b22e1e2e6c2f143aa28d9eb0413253ed93b2a4bce3004460e4aea63c4b51b875990ed8ae2a2646
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.0] - 2026-07-27
4
+
5
+ Performance rework: attribute access now runs at Struct speed (within ~10% of raw Hash access, up from ~6x slower in 1.x).
6
+
7
+ - `SafeOstruct.new` returns instances of cached per-key-set shape classes with real `attr_accessor` methods; `method_missing` remains only as a fallback for dynamically added and undefined attributes.
8
+ - Added OpenStruct API parity: `==`, `eql?`, `hash`, `dig`, `each_pair`, `inspect`, `to_s`.
9
+ - Breaking: `to_h` returns a fresh hash instead of the live internal hash.
10
+ - Breaking: `respond_to?` is now true for shape attributes and their setters, even after `delete_field`.
11
+ - Breaking: `.class` of an instance is an anonymous shape subclass; use `is_a?(SafeOstruct)` for type checks.
12
+ - Breaking: `Marshal.dump` is not supported (anonymous classes); serialize via `to_h`.
13
+ - The shape cache is capped at 1000 classes per base class; beyond that, instances degrade gracefully to `method_missing`-based access.
14
+
3
15
  ## [1.0.0] - 2026-07-27
4
16
 
5
17
  - Initial release.
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # SafeOstruct
2
2
 
3
- A lightweight, dependency-free `OpenStruct` alternative backed by a plain symbol-keyed `Hash`. Published as the `safe_ostruct` gem.
3
+ A fast, dependency-free `OpenStruct` alternative. Published as the `safe_ostruct` gem.
4
4
 
5
- SafeOstruct gives you the ergonomics of `OpenStruct` (dynamic attributes, method-style access) without its cost: it never defines singleton methods, so instantiation stays fast and Ruby's method caches stay intact. Undefined attributes return `nil` instead of raising `NoMethodError`, which makes it a natural fit for API response objects, test doubles, and data with optional fields.
5
+ SafeOstruct gives you the ergonomics of `OpenStruct` (dynamic attributes, method-style access) at near-Hash speed. Attribute reads and writes are real methods on cached per-key-set "shape" classes, not `method_missing` dispatch, so they run at Struct speed (within ~10% of raw Hash access). Undefined attributes return `nil` instead of raising `NoMethodError`, which makes it a natural fit for API response objects, test doubles, and data with optional fields.
6
6
 
7
7
  ## Installation
8
8
 
@@ -21,7 +21,7 @@ gem install safe_ostruct
21
21
  ## Usage
22
22
 
23
23
  ```ruby
24
- require "safe_ostruct" # `require "safe_ostruct"` works too
24
+ require "safe_ostruct"
25
25
 
26
26
  obj = SafeOstruct.new(name: "John Doe")
27
27
 
@@ -42,8 +42,14 @@ obj[:age] # => nil
42
42
  obj.delete_field(:address)
43
43
  obj.address # => nil
44
44
 
45
- # Convert to a hash
45
+ # Convert to a hash (a fresh copy, safe to mutate)
46
46
  obj.to_h # => { name: "John Doe", city: "Springfield" }
47
+
48
+ # OpenStruct API parity
49
+ obj == SafeOstruct.new(name: "John Doe", city: "Springfield") # => true
50
+ obj.dig(:name) # => "John Doe"
51
+ obj.each_pair { |key, value| ... }
52
+ obj.inspect # => #<SafeOstruct name="John Doe", city="Springfield">
47
53
  ```
48
54
 
49
55
  It also works as a `JSON.parse` object class for dot-notation access to parsed JSON:
@@ -65,32 +71,43 @@ class ResponseResult < SafeOstruct
65
71
  end
66
72
  ```
67
73
 
74
+ ## How it works
75
+
76
+ `SafeOstruct.new` returns an instance of a cached subclass generated per unique key set (a "shape"). The shape class carries real `attr_accessor` methods backed by instance variables, so `obj.name` is an ordinary inline-cached method call instead of `method_missing` dispatch. Creating many structs with the same keys reuses one class, so the method cache stays intact (the classic OpenStruct problem). The shape cache is capped at `SafeOstruct::SHAPE_CACHE_LIMIT` (1000) classes per base class; beyond that, instances degrade gracefully to `method_missing`-based access, so arbitrary key sets (e.g. from JSON) cannot create classes unboundedly.
77
+
78
+ `method_missing` remains as a fallback for attributes added after creation and for undefined attributes, which always return `nil`.
79
+
68
80
  ## Behavior notes
69
81
 
70
- These behaviors are intentional and covered by the test suite. They will not change without a major version bump.
82
+ These behaviors are intentional and covered by the test suite.
71
83
 
72
84
  - **Every undefined method returns `nil`.** `obj.anything` returns `nil` rather than raising `NoMethodError`. This includes typos, so prefer SafeOstruct where absent-means-nil is the semantics you want, and avoid it where a typo should fail loudly.
73
- - **`respond_to?` only reflects currently set attributes.** `obj.respond_to?(:missing)` is `false` even though `obj.missing` returns `nil`, and `respond_to?(:attr=)` is `false` even though assignment works. Duck-type checks via `respond_to?` will only see attributes that have been set.
74
- - **`to_h` returns the live internal hash, not a copy.** Mutating the returned hash mutates the struct. Call `obj.to_h.dup` if you need an independent copy.
85
+ - **`to_h` returns a fresh hash.** Mutating the returned hash does not affect the struct.
86
+ - **`respond_to?` is true for shape attributes** (getters and setters, even after `delete_field`), and true for dynamically added attributes while they hold a value. It stays false for attributes that were never set.
87
+ - **`.class` is an anonymous shape subclass.** Use `is_a?(SafeOstruct)` (or your own base class) for type checks, not `.class ==`. Structs with equal attributes are `==` regardless of shape.
88
+ - **Keys that are not valid method names** (e.g. `"foo-bar"`) or that collide with existing methods (e.g. `:to_h`, `:class`) are stored and fully accessible hash-style (`obj[:"foo-bar"]`), just not via dot access. Keys starting with `__` are reserved for internal storage.
89
+ - **Marshal is not supported** because shape classes are anonymous. Serialize via `obj.to_h` instead.
90
+ - **Subclass instance variables appear in `to_h`.** Attribute storage is instance variables, so a subclass that memoizes internal state into an ivar will see it in `to_h`; prefix internal ivars with `__` to exclude them.
75
91
 
76
92
  ## Performance
77
93
 
78
- SafeOstruct exists for a specific trade: it is dramatically cheaper than `OpenStruct` to create, at the cost of slower per-attribute access (every read and write goes through `method_missing`). For the common lifecycle of value objects (create once, read a handful of attributes), creation cost dominates and SafeOstruct wins comfortably. If you create an object once and read the same attribute millions of times, use `Struct` or `Data` instead.
79
-
80
- Results from `benchmark/ips.rb` (Ruby 3.2.2, x86_64-linux, higher is better):
94
+ Attribute access runs at Struct speed because shape attributes are real `attr_accessor` methods. Results from `benchmark/ips.rb` (Ruby 3.2.2, x86_64-linux, YJIT disabled, higher is better):
81
95
 
82
96
  | Operation | Hash | Struct | SafeOstruct | OpenStruct |
83
97
  |---|---|---|---|---|
84
- | Instantiation (3 attributes) | 13.9M i/s | 10.5M i/s | 3.2M i/s | 0.16M i/s |
85
- | Defined attribute read | 61.1M i/s | 57.7M i/s | 9.2M i/s | 27.9M i/s |
86
- | Undefined attribute read | n/a | n/a | 9.2M i/s | 7.3M i/s |
87
- | Attribute write | 41.4M i/s | n/a | 4.8M i/s | 21.5M i/s |
98
+ | Defined attribute read | 61.0M i/s | 58.3M i/s | 55.6M i/s | 28.1M i/s |
99
+ | Attribute write | 42.1M i/s | n/a | 53.0M i/s | 20.0M i/s |
100
+ | Instantiation (3 attributes) | 13.6M i/s | 10.1M i/s | 2.0M i/s | 0.16M i/s |
101
+ | Undefined attribute read | n/a | n/a | 6.2M i/s | 7.2M i/s |
102
+ | Dynamically added attribute read | n/a | n/a | 6.9M i/s | n/a |
103
+ | Hash-style read (`[]`) | 56.7M i/s | n/a | 20.0M i/s | 28.9M i/s |
88
104
 
89
105
  Highlights:
90
106
 
91
- - **Instantiation is ~20x faster than OpenStruct.** OpenStruct defines singleton methods per instance, which is slow and invalidates method caches; SafeOstruct just stores a hash.
92
- - **Reads of defined attributes are ~3x slower than OpenStruct** (9.2M vs 27.9M i/s), because OpenStruct's lazily defined accessor is a real method while SafeOstruct always dispatches through `method_missing`.
93
- - Direct `Hash` or `Struct` access is roughly 6x faster than SafeOstruct. When attribute names are fixed and known up front, prefer those.
107
+ - **Reads are within 10% of raw Hash access** and effectively at Struct speed. Writes are faster than `Hash#[]=`.
108
+ - **Instantiation is ~12x faster than OpenStruct**, which defines singleton methods per instance. It is slower than a bare Struct because of the shape-cache lookup; the cost is repaid after about two attribute accesses per object.
109
+ - Attributes added after creation (and undefined reads) go through the `method_missing` fallback at ~6-7M i/s, comparable to OpenStruct's lazily defined accessors.
110
+ - YJIT narrows the remaining gaps further.
94
111
 
95
112
  Run the benchmark yourself:
96
113
 
@@ -98,6 +115,15 @@ Run the benchmark yourself:
98
115
  bundle exec ruby benchmark/ips.rb
99
116
  ```
100
117
 
118
+ ## Migrating from 1.x
119
+
120
+ Version 2.0.0 changed the storage model from a single internal hash to shape classes. Interface-compatible, with these semantic differences:
121
+
122
+ - `to_h` returns a fresh hash instead of the live internal hash; code that mutated the struct through `to_h` must use `[]=` instead.
123
+ - `respond_to?` now reflects shape accessors (true for initial attributes and their setters, even after `delete_field`).
124
+ - `.class` is an anonymous shape subclass rather than `SafeOstruct` itself; `is_a?(SafeOstruct)` still holds.
125
+ - `Marshal.dump` no longer works; use `to_h`.
126
+
101
127
  ## Development
102
128
 
103
129
  ```bash
@@ -1,3 +1,3 @@
1
1
  class SafeOstruct
2
- VERSION = "1.0.0".freeze
2
+ VERSION = "2.0.0".freeze
3
3
  end
data/lib/safe_ostruct.rb CHANGED
@@ -2,13 +2,17 @@ require_relative "safe_ostruct/version"
2
2
 
3
3
  # SafeOstruct
4
4
  #
5
- # SafeOstruct is a flexible struct-like class that allows dynamic initialization of attributes
6
- # using keyword arguments. It handles missing methods gracefully by returning `nil`
7
- # instead of raising a `NoMethodError` for undefined attributes or methods.
8
- # This class use Symbolic-Hash and its underlying data structure.
5
+ # SafeOstruct is a fast, flexible struct-like class with an OpenStruct-style
6
+ # interface. It handles missing methods gracefully by returning `nil` instead
7
+ # of raising a `NoMethodError` for undefined attributes or methods.
9
8
  #
10
- # Additionally, it supports dynamically adding and removing attributes. The object allows access
11
- # to its attributes using both methods and hash-style key access with symbols or strings.
9
+ # For speed, `SafeOstruct.new` returns an instance of a cached "shape" subclass
10
+ # generated per unique key set. Shape classes carry real `attr_accessor`
11
+ # methods backed by instance variables, so attribute reads and writes are
12
+ # ordinary inline-cached method calls (Struct-like speed) rather than
13
+ # `method_missing` dispatch. `method_missing` remains as a fallback for
14
+ # attributes added after creation and always returns `nil` for anything
15
+ # undefined.
12
16
  #
13
17
  # Key features:
14
18
  # - Define attributes dynamically with keyword arguments.
@@ -16,87 +20,208 @@ require_relative "safe_ostruct/version"
16
20
  # - Support both symbol and string access for attributes.
17
21
  # - Dynamically add or update attributes using method assignment or hash-style assignment.
18
22
  # - Remove attributes dynamically using `delete_field`.
23
+ # - OpenStruct API parity: `==`, `eql?`, `hash`, `dig`, `each_pair`, `inspect`.
19
24
  #
20
25
  # Example usage:
21
26
  # obj = SafeOstruct.new(name: 'John Doe')
22
27
  #
23
- # # Accessing defined attributes
24
28
  # obj.name # => 'John Doe'
25
29
  # obj[:name] # => 'John Doe'
26
30
  # obj['name'] # => 'John Doe'
27
31
  #
28
- # # Adding new attributes dynamically
29
32
  # obj.address = '123 Main St'
30
- # obj.address # => '123 Main St'
31
- # obj[:address] # => '123 Main St'
32
- # obj['address'] # => '123 Main St'
33
+ # obj.age # => nil (undefined attributes return nil)
33
34
  #
34
- # # Accessing an undefined attribute returns nil
35
- # obj.age # => nil
36
- # obj[:age] # => nil
37
- # obj['age'] # => nil
38
- #
39
- # # Deleting an attribute
40
35
  # obj.delete_field(:address)
41
- # obj.address # => nil
42
- # obj.respond_to?(:address) # => false
43
- #
44
- # # Converting to a hash
45
- # hash_representation = obj.to_h
46
- # # hash_representation => { name: 'John Doe' }
36
+ # obj.to_h # => { name: 'John Doe' }
47
37
  #
48
- # This class simplifies working with dynamic or optional attributes and allows for greater
49
- # flexibility in handling data structures, especially when attribute names may not be known
50
- # in advance or when optional fields are common.
38
+ # Storage model: attribute values live in instance variables (`@name`), except
39
+ # keys that are not valid identifiers (e.g. `"foo-bar"`) or that start with
40
+ # `__`, which live in an internal overflow hash and are reachable via `[]`,
41
+ # `to_h`, `each_pair`, and `delete_field`. Keys that collide with existing
42
+ # methods (e.g. `:to_h`, `:class`) are stored but only reachable hash-style.
51
43
  #
52
44
  class SafeOstruct
53
- # Initializes the SafeOstruct with attributes provided as keyword arguments or a hash.
45
+ # Attribute names that can be stored as instance variables.
46
+ VALID_ATTRIBUTE_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
47
+
48
+ # Maximum number of shape classes cached per base class. Beyond this,
49
+ # instances are built on the base class itself (correct but slower),
50
+ # so arbitrary key sets (e.g. from JSON) cannot create classes unboundedly.
51
+ SHAPE_CACHE_LIMIT = 1000
52
+
53
+ # Memoized attribute-key to ivar-name mapping shared by all instances.
54
+ # Keys that cannot be stored as ivars map to nil (they use overflow
55
+ # storage). Grows by one entry per distinct attribute name ever used;
56
+ # the default proc runs under the GVL, so a race only recomputes.
57
+ IVAR_LOOKUP = Hash.new do |cache, key|
58
+ valid = VALID_ATTRIBUTE_NAME.match?(key) && !key.start_with?("__")
59
+ cache[key] = valid ? :"@#{key}" : nil
60
+ end
61
+ private_constant :IVAR_LOOKUP
62
+
63
+ @__shape__ = false
64
+ @__shapes__ = {}
65
+ @__shapes_lock__ = Mutex.new
66
+ @__overflow_keys__ = [].freeze
67
+ @__base__ = nil
68
+
69
+ class << self
70
+ # @return [Array<Symbol>] frozen list of shape keys stored in the overflow hash
71
+ attr_reader :__overflow_keys__
72
+
73
+ # @return [Class, nil] the user-visible class a shape was generated from
74
+ attr_reader :__base__
75
+
76
+ def inherited(subclass)
77
+ super
78
+ subclass.instance_variable_set(:@__shape__, false)
79
+ subclass.instance_variable_set(:@__shapes__, {})
80
+ subclass.instance_variable_set(:@__shapes_lock__, Mutex.new)
81
+ subclass.instance_variable_set(:@__overflow_keys__, [].freeze)
82
+ subclass.instance_variable_set(:@__base__, nil)
83
+ end
84
+
85
+ # Builds an instance of the cached shape class for the given key set.
86
+ # On shape classes this constructs normally.
87
+ #
88
+ # @param kwargs [Hash] the attributes to initialize the struct with
89
+ # @return [SafeOstruct]
90
+ def new(**kwargs)
91
+ return super if @__shape__
92
+
93
+ keys = kwargs.keys
94
+ unless keys.all?(Symbol)
95
+ kwargs = kwargs.transform_keys(&:to_sym)
96
+ keys = kwargs.keys
97
+ end
98
+ shape = __shape_for__(keys)
99
+ if shape
100
+ instance = shape.allocate
101
+ instance.__send__(:__shape_init__, kwargs)
102
+ else
103
+ instance = allocate
104
+ instance.__send__(:__init_attributes__, kwargs)
105
+ end
106
+ instance
107
+ end
108
+
109
+ private
110
+
111
+ def __shape_for__(keys)
112
+ cache = @__shapes__
113
+ shape = cache[keys]
114
+ return shape if shape
115
+ return nil if cache.size >= SHAPE_CACHE_LIMIT
116
+
117
+ @__shapes_lock__.synchronize do
118
+ cache[keys.freeze] ||= __create_shape__(keys)
119
+ end
120
+ end
121
+
122
+ def __create_shape__(keys)
123
+ ivar_keys = keys.select { |key| IVAR_LOOKUP[key] }
124
+ overflow_keys = keys - ivar_keys
125
+ accessor_keys = ivar_keys.reject do |key|
126
+ method_defined?(key) || private_method_defined?(key) || method_defined?(:"#{key}=")
127
+ end
128
+
129
+ shape = Class.new(self)
130
+ shape.instance_variable_set(:@__shape__, true)
131
+ shape.instance_variable_set(:@__base__, __base__ || self)
132
+ shape.instance_variable_set(:@__overflow_keys__, overflow_keys.freeze)
133
+ shape.send(:attr_accessor, *accessor_keys) unless accessor_keys.empty?
134
+
135
+ # Every interpolated key matched VALID_ATTRIBUTE_NAME, so the eval'd
136
+ # source can only contain identifier characters. The factory calls
137
+ # __shape_init__ with the kwargs hash positionally to avoid re-splat
138
+ # copies; initialize delegates to it for direct shape.new calls.
139
+ assignments = ivar_keys.map { |key| " @#{key} = attrs[:#{key}]" }
140
+ assignments << " __init_overflow__(attrs)" unless overflow_keys.empty?
141
+ shape.class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
142
+ # def initialize(**kwargs)
143
+ # __shape_init__(kwargs)
144
+ # end
145
+ #
146
+ # def __shape_init__(attrs)
147
+ # @name = attrs[:name]
148
+ # @email = attrs[:email]
149
+ # __init_overflow__(attrs) if any keys need overflow storage
150
+ # end
151
+ def initialize(**kwargs)
152
+ __shape_init__(kwargs)
153
+ end
154
+
155
+ private
156
+
157
+ def __shape_init__(attrs)
158
+ #{assignments.join("\n")}
159
+ end
160
+ RUBY
161
+ shape
162
+ end
163
+ end
164
+
165
+ # Direct construction path (used when the shape cache is bypassed).
54
166
  #
55
167
  # @param kwargs [Hash] the attributes to initialize the struct with
56
168
  def initialize(**kwargs)
57
- @attributes = kwargs.transform_keys(&:to_sym)
169
+ __init_attributes__(kwargs) unless kwargs.empty?
58
170
  end
59
171
 
60
- # Handles missing methods dynamically, including attribute assignment
172
+ # Handles attributes added after creation and reads of undefined
173
+ # attributes, which return `nil` instead of raising `NoMethodError`.
61
174
  #
62
175
  # @param method_name [Symbol] the name of the missing method
63
176
  # @param args [Array] the arguments passed to the missing method
64
- # @param block [Proc] an optional block passed to the missing method
65
177
  # @return [Object, nil] the value of the attribute, or nil if undefined
66
178
  def method_missing(method_name, *args, &_block)
67
179
  if method_name.end_with?("=")
68
- # Handle dynamic assignment
69
- attr_name = method_name[0...-1].to_sym
70
- @attributes[attr_name] = args.first
180
+ self[method_name[0...-1]] = args.first
71
181
  else
72
- # Handle dynamic retrieval, return nil if attribute does not exist
73
- @attributes[method_name]
182
+ self[method_name]
74
183
  end
75
184
  end
76
185
 
77
- # Access attributes using [] with symbol or string
186
+ # Access attributes using [] with symbol or string.
78
187
  #
79
188
  # @param key [Symbol, String] the key for the attribute to be retrieved
80
189
  # @return [Object, nil] the value of the attribute, or nil if undefined
81
190
  def [](key)
82
- @attributes[key.to_sym]
191
+ key = key.to_sym unless key.is_a?(Symbol)
192
+ if (ivar = IVAR_LOOKUP[key])
193
+ instance_variable_get(ivar)
194
+ elsif (overflow = @__overflow__)
195
+ overflow[key]
196
+ end
83
197
  end
84
198
 
85
- # Set attributes using [] with symbol or string
199
+ # Set attributes using [] with symbol or string.
86
200
  #
87
201
  # @param key [Symbol, String] the key for the attribute to be set
88
202
  # @param value [Object] the value to assign to the attribute
89
203
  def []=(key, value)
90
- @attributes[key.to_sym] = value
204
+ key = key.to_sym unless key.is_a?(Symbol)
205
+ if (ivar = IVAR_LOOKUP[key])
206
+ instance_variable_set(ivar, value)
207
+ else
208
+ (@__overflow__ ||= {})[key] = value
209
+ end
91
210
  end
92
211
 
93
- # Ensures SafeOstruct responds to methods for defined and dynamically assigned attributes.
212
+ # True for attributes that currently hold a value. Shape attributes also
213
+ # respond via their real accessor methods.
94
214
  #
95
215
  # @param method_name [Symbol] the name of the method being checked
96
216
  # @param include_private [Boolean] whether to include private methods in the check
97
- # @return [Boolean] true if the method is defined or dynamically assigned, otherwise false
217
+ # @return [Boolean]
98
218
  def respond_to_missing?(method_name, include_private = false)
99
- @attributes.key?(method_name) || super
219
+ if (ivar = IVAR_LOOKUP[method_name])
220
+ instance_variable_defined?(ivar) || super
221
+ else
222
+ overflow = @__overflow__
223
+ (overflow ? overflow.key?(method_name) : false) || super
224
+ end
100
225
  end
101
226
 
102
227
  # Deletes an attribute from the struct.
@@ -104,17 +229,93 @@ class SafeOstruct
104
229
  # @param key [Symbol, String] the key of the attribute to delete
105
230
  # @return [Object, nil] the value of the deleted attribute, or nil if the attribute did not exist
106
231
  def delete_field(key)
107
- @attributes.delete(key.to_sym)
232
+ key = key.to_sym unless key.is_a?(Symbol)
233
+ if (ivar = IVAR_LOOKUP[key])
234
+ remove_instance_variable(ivar) if instance_variable_defined?(ivar)
235
+ else
236
+ @__overflow__&.delete(key)
237
+ end
108
238
  end
109
239
 
110
240
  # Converts the attributes of the SafeOstruct into a hash.
111
241
  #
112
- # @return [Hash] a hash representation of the attributes, where keys are symbols
113
- # and values are the corresponding attribute values. This method provides a way
114
- # to easily access the internal state of the SafeOstruct as a standard hash,
115
- # facilitating serialization and interoperability with other Ruby classes and
116
- # methods that expect a hash input.
242
+ # @return [Hash] a freshly built hash of the attributes with symbol keys.
243
+ # Mutating the returned hash does not affect the struct.
117
244
  def to_h
118
- @attributes
245
+ hash = {}
246
+ instance_variables.each do |ivar|
247
+ next if ivar.start_with?("@__")
248
+
249
+ hash[ivar[1..].to_sym] = instance_variable_get(ivar)
250
+ end
251
+ overflow = @__overflow__
252
+ hash.merge!(overflow) if overflow && !overflow.empty?
253
+ hash
254
+ end
255
+
256
+ # Yields each attribute name and value; returns an Enumerator without a block.
257
+ #
258
+ # @yieldparam key [Symbol]
259
+ # @yieldparam value [Object]
260
+ # @return [SafeOstruct, Enumerator]
261
+ def each_pair(&block)
262
+ pairs = to_h
263
+ return pairs.each_pair unless block_given?
264
+
265
+ pairs.each_pair(&block)
266
+ self
267
+ end
268
+
269
+ # Extracts a nested value, like Hash#dig.
270
+ #
271
+ # @param key [Symbol, String] the first key
272
+ # @param keys [Array] further keys passed to dig on the value
273
+ # @return [Object, nil]
274
+ def dig(key, *keys)
275
+ value = self[key]
276
+ keys.empty? ? value : value&.dig(*keys)
277
+ end
278
+
279
+ # Two SafeOstructs are equal when their attributes are equal, regardless
280
+ # of which shape class backs them.
281
+ #
282
+ # @param other [Object]
283
+ # @return [Boolean]
284
+ def ==(other)
285
+ other.is_a?(SafeOstruct) && to_h == other.to_h
286
+ end
287
+ alias eql? ==
288
+
289
+ # @return [Integer] hash code derived from the attributes
290
+ def hash
291
+ to_h.hash
292
+ end
293
+
294
+ # @return [String] readable representation, e.g. #<SafeOstruct name="John">
295
+ def inspect
296
+ base = self.class.__base__ || self.class
297
+ label = base.name || "SafeOstruct"
298
+ attrs = to_h.map { |key, value| "#{key}=#{value.inspect}" }.join(", ")
299
+ attrs.empty? ? "#<#{label}>" : "#<#{label} #{attrs}>"
300
+ end
301
+ alias to_s inspect
302
+
303
+ private
304
+
305
+ def __init_attributes__(kwargs)
306
+ kwargs.each do |key, value|
307
+ key = key.to_sym unless key.is_a?(Symbol)
308
+ if (ivar = IVAR_LOOKUP[key])
309
+ instance_variable_set(ivar, value)
310
+ else
311
+ (@__overflow__ ||= {})[key] = value
312
+ end
313
+ end
314
+ end
315
+
316
+ def __init_overflow__(kwargs)
317
+ overflow = {}
318
+ self.class.__overflow_keys__.each { |key| overflow[key] = kwargs[key] }
319
+ @__overflow__ = overflow
119
320
  end
120
321
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe_ostruct
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Waqas Ali
@@ -10,12 +10,12 @@ bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: SafeOstruct is a lightweight, dependency-free struct-like class backed
14
- by a plain symbol-keyed Hash. It supports dynamic attribute definition via keyword
15
- arguments, method-style and hash-style (symbol or string) access, and returns nil
16
- instead of raising NoMethodError for undefined attributes. Unlike OpenStruct, it
17
- never defines singleton methods, so instantiation stays fast and method caches stay
18
- intact.
13
+ description: SafeOstruct is a fast, dependency-free struct-like class with an OpenStruct-style
14
+ interface. Instances are built on cached per-key-set shape classes with real attr_accessor
15
+ methods, so attribute reads and writes run at Struct speed (within ~10% of raw Hash
16
+ access) instead of going through method_missing. It supports dynamic attributes,
17
+ method-style and hash-style (symbol or string) access, and returns nil instead of
18
+ raising NoMethodError for undefined attributes.
19
19
  email:
20
20
  - waqas@sendoso.com
21
21
  executables: []
@@ -53,5 +53,5 @@ requirements: []
53
53
  rubygems_version: 3.4.10
54
54
  signing_key:
55
55
  specification_version: 4
56
- summary: A hash-backed OpenStruct alternative that returns nil for undefined attributes
56
+ summary: A fast OpenStruct alternative that returns nil for undefined attributes
57
57
  test_files: []