Class: SafeOstruct

Inherits:
Object
  • Object
show all
Defined in:
lib/safe_ostruct.rb,
lib/safe_ostruct/version.rb

Overview

SafeOstruct

SafeOstruct is a fast, flexible struct-like class with an OpenStruct-style interface. It handles missing methods gracefully by returning nil instead of raising a NoMethodError for undefined attributes or methods.

For speed, SafeOstruct.new returns an instance of a cached "shape" subclass generated per unique key set. Shape classes carry real attr_accessor methods backed by instance variables, so attribute reads and writes are ordinary inline-cached method calls (Struct-like speed) rather than method_missing dispatch. method_missing remains as a fallback for attributes added after creation and always returns nil for anything undefined.

Key features:

  • Define attributes dynamically with keyword arguments.
  • Handle missing methods by returning nil instead of raising NoMethodError.
  • Support both symbol and string access for attributes.
  • Dynamically add or update attributes using method assignment or hash-style assignment.
  • Remove attributes dynamically using delete_field.
  • OpenStruct API parity: ==, eql?, hash, dig, each_pair, inspect.

Example usage:

obj = SafeOstruct.new(name: 'John Doe')

obj.name           # => 'John Doe'
obj[:name]         # => 'John Doe'
obj['name']        # => 'John Doe'

obj.address = '123 Main St'
obj.age            # => nil (undefined attributes return nil)

obj.delete_field(:address)
obj.to_h           # => { name: 'John Doe' }

Storage model: attribute values live in instance variables (@name), except keys that are not valid identifiers (e.g. "foo-bar") or that start with __, which live in an internal overflow hash and are reachable via [], to_h, each_pair, and delete_field. Keys that collide with existing methods (e.g. :to_h, :class) are stored but only reachable hash-style.

Constant Summary collapse

VALID_ATTRIBUTE_NAME =

Attribute names that can be stored as instance variables.

/\A[a-zA-Z_][a-zA-Z0-9_]*\z/
SHAPE_CACHE_LIMIT =

Maximum number of shape classes cached per base class. Beyond this, instances are built on the base class itself (correct but slower), so arbitrary key sets (e.g. from JSON) cannot create classes unboundedly.

1000
VERSION =
"2.0.0".freeze

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(**kwargs) ⇒ SafeOstruct

Direct construction path (used when the shape cache is bypassed).

Parameters:

  • kwargs (Hash)

    the attributes to initialize the struct with



168
169
170
# File 'lib/safe_ostruct.rb', line 168

def initialize(**kwargs)
  __init_attributes__(kwargs) unless kwargs.empty?
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(method_name, *args, &_block) ⇒ Object?

Handles attributes added after creation and reads of undefined attributes, which return nil instead of raising NoMethodError.

Parameters:

  • method_name (Symbol)

    the name of the missing method

  • args (Array)

    the arguments passed to the missing method

Returns:

  • (Object, nil)

    the value of the attribute, or nil if undefined



178
179
180
181
182
183
184
# File 'lib/safe_ostruct.rb', line 178

def method_missing(method_name, *args, &_block)
  if method_name.end_with?("=")
    self[method_name[0...-1]] = args.first
  else
    self[method_name]
  end
end

Class Attribute Details

.__base__Class? (readonly)

Returns the user-visible class a shape was generated from.

Returns:

  • (Class, nil)

    the user-visible class a shape was generated from



74
75
76
# File 'lib/safe_ostruct.rb', line 74

def __base__
  @__base__
end

.__overflow_keys__Array<Symbol> (readonly)

Returns frozen list of shape keys stored in the overflow hash.

Returns:

  • (Array<Symbol>)

    frozen list of shape keys stored in the overflow hash



71
72
73
# File 'lib/safe_ostruct.rb', line 71

def __overflow_keys__
  @__overflow_keys__
end

Class Method Details

.inherited(subclass) ⇒ Object



76
77
78
79
80
81
82
83
# File 'lib/safe_ostruct.rb', line 76

def inherited(subclass)
  super
  subclass.instance_variable_set(:@__shape__, false)
  subclass.instance_variable_set(:@__shapes__, {})
  subclass.instance_variable_set(:@__shapes_lock__, Mutex.new)
  subclass.instance_variable_set(:@__overflow_keys__, [].freeze)
  subclass.instance_variable_set(:@__base__, nil)
end

.new(**kwargs) ⇒ SafeOstruct

Builds an instance of the cached shape class for the given key set. On shape classes this constructs normally.

Parameters:

  • kwargs (Hash)

    the attributes to initialize the struct with

Returns:



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/safe_ostruct.rb', line 90

def new(**kwargs)
  return super if @__shape__

  keys = kwargs.keys
  unless keys.all?(Symbol)
    kwargs = kwargs.transform_keys(&:to_sym)
    keys = kwargs.keys
  end
  shape = __shape_for__(keys)
  if shape
    instance = shape.allocate
    instance.__send__(:__shape_init__, kwargs)
  else
    instance = allocate
    instance.__send__(:__init_attributes__, kwargs)
  end
  instance
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Two SafeOstructs are equal when their attributes are equal, regardless of which shape class backs them.

Parameters:

  • other (Object)

Returns:

  • (Boolean)


284
285
286
# File 'lib/safe_ostruct.rb', line 284

def ==(other)
  other.is_a?(SafeOstruct) && to_h == other.to_h
end

#[](key) ⇒ Object?

Access attributes using [] with symbol or string.

Parameters:

  • key (Symbol, String)

    the key for the attribute to be retrieved

Returns:

  • (Object, nil)

    the value of the attribute, or nil if undefined



190
191
192
193
194
195
196
197
# File 'lib/safe_ostruct.rb', line 190

def [](key)
  key = key.to_sym unless key.is_a?(Symbol)
  if (ivar = IVAR_LOOKUP[key])
    instance_variable_get(ivar)
  elsif (overflow = @__overflow__)
    overflow[key]
  end
end

#[]=(key, value) ⇒ Object

Set attributes using [] with symbol or string.

Parameters:

  • key (Symbol, String)

    the key for the attribute to be set

  • value (Object)

    the value to assign to the attribute



203
204
205
206
207
208
209
210
# File 'lib/safe_ostruct.rb', line 203

def []=(key, value)
  key = key.to_sym unless key.is_a?(Symbol)
  if (ivar = IVAR_LOOKUP[key])
    instance_variable_set(ivar, value)
  else
    (@__overflow__ ||= {})[key] = value
  end
end

#delete_field(key) ⇒ Object?

Deletes an attribute from the struct.

Parameters:

  • key (Symbol, String)

    the key of the attribute to delete

Returns:

  • (Object, nil)

    the value of the deleted attribute, or nil if the attribute did not exist



231
232
233
234
235
236
237
238
# File 'lib/safe_ostruct.rb', line 231

def delete_field(key)
  key = key.to_sym unless key.is_a?(Symbol)
  if (ivar = IVAR_LOOKUP[key])
    remove_instance_variable(ivar) if instance_variable_defined?(ivar)
  else
    @__overflow__&.delete(key)
  end
end

#dig(key, *keys) ⇒ Object?

Extracts a nested value, like Hash#dig.

Parameters:

  • key (Symbol, String)

    the first key

  • keys (Array)

    further keys passed to dig on the value

Returns:

  • (Object, nil)


274
275
276
277
# File 'lib/safe_ostruct.rb', line 274

def dig(key, *keys)
  value = self[key]
  keys.empty? ? value : value&.dig(*keys)
end

#each_pair {|key, value| ... } ⇒ SafeOstruct, Enumerator

Yields each attribute name and value; returns an Enumerator without a block.

Yield Parameters:

  • key (Symbol)
  • value (Object)

Returns:



261
262
263
264
265
266
267
# File 'lib/safe_ostruct.rb', line 261

def each_pair(&block)
  pairs = to_h
  return pairs.each_pair unless block_given?

  pairs.each_pair(&block)
  self
end

#hashInteger

Returns hash code derived from the attributes.

Returns:

  • (Integer)

    hash code derived from the attributes



290
291
292
# File 'lib/safe_ostruct.rb', line 290

def hash
  to_h.hash
end

#inspectString Also known as: to_s

Returns readable representation, e.g. #.

Returns:

  • (String)

    readable representation, e.g. #



295
296
297
298
299
300
# File 'lib/safe_ostruct.rb', line 295

def inspect
  base = self.class.__base__ || self.class
  label = base.name || "SafeOstruct"
  attrs = to_h.map { |key, value| "#{key}=#{value.inspect}" }.join(", ")
  attrs.empty? ? "#<#{label}>" : "#<#{label} #{attrs}>"
end

#respond_to_missing?(method_name, include_private = false) ⇒ Boolean

True for attributes that currently hold a value. Shape attributes also respond via their real accessor methods.

Parameters:

  • method_name (Symbol)

    the name of the method being checked

  • include_private (Boolean) (defaults to: false)

    whether to include private methods in the check

Returns:

  • (Boolean)


218
219
220
221
222
223
224
225
# File 'lib/safe_ostruct.rb', line 218

def respond_to_missing?(method_name, include_private = false)
  if (ivar = IVAR_LOOKUP[method_name])
    instance_variable_defined?(ivar) || super
  else
    overflow = @__overflow__
    (overflow ? overflow.key?(method_name) : false) || super
  end
end

#to_hHash

Converts the attributes of the SafeOstruct into a hash.

Mutating the returned hash does not affect the struct.

Returns:

  • (Hash)

    a freshly built hash of the attributes with symbol keys.



244
245
246
247
248
249
250
251
252
253
254
# File 'lib/safe_ostruct.rb', line 244

def to_h
  hash = {}
  instance_variables.each do |ivar|
    next if ivar.start_with?("@__")

    hash[ivar[1..].to_sym] = instance_variable_get(ivar)
  end
  overflow = @__overflow__
  hash.merge!(overflow) if overflow && !overflow.empty?
  hash
end