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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +43 -17
- data/lib/safe_ostruct/version.rb +1 -1
- data/lib/safe_ostruct.rb +250 -49
- metadata +8 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b0df898c8251bc9194d8c10a6b1a757ca50fc4933e864a8a4fc1120ea59a957
|
|
4
|
+
data.tar.gz: 5373099a228a7bd3ac7b4ec60946a5c4fdd30bb5a8baf4923aeb8aaca73335c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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)
|
|
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"
|
|
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.
|
|
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
|
-
- **`
|
|
74
|
-
- **`
|
|
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
|
-
|
|
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
|
-
|
|
|
85
|
-
|
|
|
86
|
-
|
|
|
87
|
-
|
|
|
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
|
-
- **
|
|
92
|
-
- **
|
|
93
|
-
-
|
|
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
|
data/lib/safe_ostruct/version.rb
CHANGED
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
11
|
-
#
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# in
|
|
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
|
-
#
|
|
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
|
-
|
|
169
|
+
__init_attributes__(kwargs) unless kwargs.empty?
|
|
58
170
|
end
|
|
59
171
|
|
|
60
|
-
# Handles
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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]
|
|
217
|
+
# @return [Boolean]
|
|
98
218
|
def respond_to_missing?(method_name, include_private = false)
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
instead of
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
56
|
+
summary: A fast OpenStruct alternative that returns nil for undefined attributes
|
|
57
57
|
test_files: []
|