Module: Philiprehberger::DeepFreeze

Defined in:
lib/philiprehberger/deep_freeze.rb,
lib/philiprehberger/deep_freeze/version.rb

Constant Summary collapse

VERSION =
'0.7.0'

Class Method Summary collapse

Class Method Details

.deep_clone(obj, except: []) ⇒ Object

Create a fully independent deep copy of the object, then deep-freeze it. The original is never mutated.

Parameters:

  • obj (Object)

    the source object graph

  • except (Array<Symbol, Object>) (defaults to: [])

    Hash keys / Struct member names to leave unfrozen in the copy

Returns:

  • (Object)

    a deeply frozen copy



74
75
76
77
78
# File 'lib/philiprehberger/deep_freeze.rb', line 74

def deep_clone(obj, except: [])
  copy = deep_dup(obj)
  deep_freeze(copy, except: except)
  copy
end

.deep_diff(a, b, path: []) ⇒ Hash

Deep structural equality check that descends into nested Hash, Array, Set, Struct, and Data objects. Unlike ‘==`, this method compares structural content rather than frozen-state or object identity, making it safe to compare a deeply frozen graph against an unfrozen copy.

Parameters:

  • a (Object)

    first object

  • b (Object)

    second object

  • path (Array) (defaults to: [])

    internal path accumulator for diff keys

Returns:

  • (Hash)

    a hash mapping each differing path to ‘{ left:, right: }`; empty when the graphs are equal



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/philiprehberger/deep_freeze.rb', line 295

def deep_diff(a, b, path: [])
  return {} if a.equal?(b)

  unless a.instance_of?(b.class)
    return { path => { left: a, right: b } }
  end

  if defined?(Data) && a.is_a?(Data)
    return diff_members(a, b, a.class.members, path)
  end

  case a
  when Hash then diff_hashes(a, b, path)
  when Array then diff_arrays(a, b, path)
  when Struct then diff_members(a, b, a.members, path)
  else
    a == b ? {} : { path => { left: a, right: b } }
  end
end

.deep_dup(obj, seen: nil) ⇒ Object

Recursively duplicate an object graph, producing a fully independent, unfrozen copy. Descends into Hash, Array, Set, Struct, and Data. Returns the original value for immutable primitives (Integer, Symbol, nil, true, false, Range, Regexp) since duplicating them yields no benefit.

Parameters:

  • obj (Object)

    the object graph to duplicate

  • seen (Hash, nil) (defaults to: nil)

    internal original-to-copy map for circular reference detection

Returns:

  • (Object)

    an independent unfrozen copy (or ‘obj` itself if immutable by design)



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/philiprehberger/deep_freeze.rb', line 158

def deep_dup(obj, seen: nil)
  seen ||= {}
  return seen[obj.object_id] if seen.key?(obj.object_id)

  if defined?(Data) && obj.is_a?(Data)
    duped_attrs = {}
    obj.class.members.each do |member|
      duped_attrs[member] = deep_dup(obj.send(member), seen: seen)
    end
    result = obj.class.new(**duped_attrs)
    seen[obj.object_id] = result
    return result
  end

  case obj
  when Hash
    copy = {}
    seen[obj.object_id] = copy
    obj.each do |key, value|
      copy[deep_dup(key, seen: seen)] = deep_dup(value, seen: seen)
    end
    copy
  when Array
    copy = []
    seen[obj.object_id] = copy
    obj.each { |item| copy << deep_dup(item, seen: seen) }
    copy
  when Set
    copy = Set.new
    seen[obj.object_id] = copy
    obj.each { |item| copy.add(deep_dup(item, seen: seen)) }
    copy
  when String
    copy = obj.dup
    seen[obj.object_id] = copy
    copy
  when Numeric, Symbol, TrueClass, FalseClass, NilClass
    obj
  when Range, Regexp
    obj
  else
    copy = obj.dup
    seen[obj.object_id] = copy
    copy
  end
end

.deep_equal?(a, b) ⇒ Boolean

Structural equality across nested Hash, Array, Set, Struct, and Data graphs — ignores frozen state and object identity.

Parameters:

  • a (Object)

    first object

  • b (Object)

    second object

Returns:

  • (Boolean)

    true if the two graphs are structurally equal



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/philiprehberger/deep_freeze.rb', line 354

def deep_equal?(a, b)
  return true if a.equal?(b)
  return false unless a.instance_of?(b.class)

  if defined?(Data) && a.is_a?(Data)
    return a.class.members.all? { |m| deep_equal?(a.send(m), b.send(m)) }
  end

  case a
  when Hash
    return false unless a.size == b.size

    a.all? { |k, v| b.key?(k) && deep_equal?(v, b[k]) }
  when Array
    return false unless a.size == b.size

    a.each_with_index.all? { |item, i| deep_equal?(item, b[i]) }
  when Set
    return false unless a.size == b.size

    a.all? { |item| b.any? { |other| deep_equal?(item, other) } }
  when Struct
    a.each_pair.all? { |key, value| deep_equal?(value, b[key]) }
  else
    a == b
  end
end

.deep_freeze(obj, except: [], seen: nil) ⇒ Object

Recursively freeze an object and all nested objects it references. Descends into Hash, Array, Set, Struct, and Data (Ruby 3.2+) graphs and freezes every reachable value. Safe against circular references via a visited-set.

Parameters:

  • obj (Object)

    object graph to freeze in place

  • except (Array<Symbol, Object>) (defaults to: [])

    Hash keys / Struct member names to leave unfrozen

  • seen (Set, nil) (defaults to: nil)

    internal visited-set for circular reference detection

Returns:

  • (Object)

    the same object (now frozen), or a new Data instance with frozen members



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/philiprehberger/deep_freeze.rb', line 17

def deep_freeze(obj, except: [], seen: nil)
  seen ||= Set.new
  return obj if obj.frozen? || seen.include?(obj.object_id)

  seen.add(obj.object_id)

  if defined?(Data) && obj.is_a?(Data)
    frozen_attrs = {}
    obj.class.members.each do |member|
      frozen_attrs[member] = deep_freeze(obj.send(member), except: except, seen: seen)
    end
    return obj.class.new(**frozen_attrs)
  end

  case obj
  when Hash
    obj.each do |key, value|
      next if except.include?(key)

      deep_freeze(key, except: except, seen: seen)
      deep_freeze(value, except: except, seen: seen)
    end
  when Array
    obj.each { |item| deep_freeze(item, except: except, seen: seen) }
  when Set
    obj.each { |item| deep_freeze(item, except: except, seen: seen) }
  when Struct
    obj.each_pair do |key, value|
      next if except.include?(key)

      deep_freeze(value, except: except, seen: seen)
    end
  when String
  end
  obj.freeze

  obj
end

.deep_freeze_all(*objects, except: []) ⇒ Array<Object>

Freeze multiple object graphs with a shared visited-set so that references shared across the objects are detected as circular.

Parameters:

  • objects (Array<Object>)

    the object graphs to freeze

  • except (Array<Symbol, Object>) (defaults to: [])

    Hash keys / Struct member names to leave unfrozen

Returns:

  • (Array<Object>)

    the input objects (each now deeply frozen)



62
63
64
65
66
# File 'lib/philiprehberger/deep_freeze.rb', line 62

def deep_freeze_all(*objects, except: [])
  seen = Set.new
  objects.each { |obj| deep_freeze(obj, except: except, seen: seen) }
  objects
end

.deep_frozen?(obj, except: [], seen: nil) ⇒ Boolean

Return whether the object and every nested value reachable from it is frozen. Descends into Hash, Array, Set, Struct, and Data graphs, and handles circular references via a visited-set.

Parameters:

  • obj (Object)

    the object graph to check

  • except (Array<Symbol, Object>) (defaults to: [])

    Hash keys / Struct member names to ignore when testing frozen state

  • seen (Set, nil) (defaults to: nil)

    internal visited-set for circular reference detection

Returns:

  • (Boolean)

    true if obj and all nested values (outside ‘except`) are frozen



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/philiprehberger/deep_freeze.rb', line 116

def deep_frozen?(obj, except: [], seen: nil)
  seen ||= Set.new
  return true if seen.include?(obj.object_id)
  return false unless obj.frozen?

  seen.add(obj.object_id)

  if defined?(Data) && obj.is_a?(Data)
    return obj.class.members.all? { |m| deep_frozen?(obj.send(m), except: except, seen: seen) }
  end

  case obj
  when Hash
    obj.each do |key, value|
      next if except.include?(key)

      return false unless deep_frozen?(key, except: except, seen: seen)
      return false unless deep_frozen?(value, except: except, seen: seen)
    end
  when Array
    obj.each { |item| return false unless deep_frozen?(item, except: except, seen: seen) }
  when Set
    obj.each { |item| return false unless deep_frozen?(item, except: except, seen: seen) }
  when Struct
    obj.each_pair do |key, value|
      next if except.include?(key)

      return false unless deep_frozen?(value, except: except, seen: seen)
    end
  end

  true
end

.deep_merge(a, b) {|key, a_val, b_val| ... } ⇒ Hash

Deeply merge two hashes, recursing into nested hashes. When both values for a key are hashes, merges them recursively. Otherwise b’s value wins (unless a block is given, which resolves conflicts). Returns a new deeply frozen hash.

Parameters:

  • a (Hash)

    the base hash

  • b (Hash)

    the overriding hash

Yields:

  • (key, a_val, b_val)

    conflict resolver; only invoked for leaf conflicts

Yield Returns:

  • (Object)

    the merged value for ‘key`

Returns:

  • (Hash)

    a new frozen hash with ‘a` and `b` deeply merged



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/philiprehberger/deep_freeze.rb', line 325

def deep_merge(a, b, &block)
  result = a.each_with_object({}) do |(key, a_val), merged|
    if b.key?(key)
      b_val = b[key]
      merged[key] = if a_val.is_a?(Hash) && b_val.is_a?(Hash)
                      deep_merge(a_val, b_val, &block)
                    elsif block
                      yield(key, a_val, b_val)
                    else
                      b_val
                    end
    else
      merged[key] = a_val
    end
  end

  b.each do |key, b_val|
    result[key] = b_val unless result.key?(key)
  end

  deep_freeze(deep_dup(result))
end

.deep_thaw(obj, except: [], seen: nil) ⇒ Object

Recursively unfreeze an object graph. Returns an unfrozen copy for frozen containers (produced via ‘dup`) and recurses into their children. Immutable primitives (Integer, Symbol, nil, true, false, Range, Regexp) are returned as-is.

The ‘except:` option here has the opposite semantics of `deep_freeze`: values at the listed Hash keys / Struct member names are left frozen.

Parameters:

  • obj (Object)

    the object graph to thaw

  • except (Array<Symbol, Object>) (defaults to: [])

    Hash keys / Struct member names to leave frozen

  • seen (Hash, nil) (defaults to: nil)

    internal original-to-copy map for circular reference detection

Returns:

  • (Object)

    an unfrozen copy of the graph (or ‘obj` itself if already immutable)



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/philiprehberger/deep_freeze.rb', line 217

def deep_thaw(obj, except: [], seen: nil)
  seen ||= {}
  return seen[obj.object_id] if seen.key?(obj.object_id)

  case obj
  when Numeric, Symbol, TrueClass, FalseClass, NilClass, Range, Regexp
    return obj
  end

  if defined?(Data) && obj.is_a?(Data)
    thawed_attrs = {}
    obj.class.members.each do |member|
      thawed_attrs[member] = deep_thaw(obj.send(member), except: except, seen: seen)
    end
    result = obj.class.new(**thawed_attrs)
    seen[obj.object_id] = result
    return result
  end

  case obj
  when Hash
    copy = obj.frozen? ? obj.dup : obj
    seen[obj.object_id] = copy
    entries = obj.to_a
    copy.clear
    entries.each do |key, value|
      if except.include?(key)
        copy[key] = value
      else
        copy[deep_thaw(key, except: except, seen: seen)] = deep_thaw(value, except: except, seen: seen)
      end
    end
    copy
  when Array
    copy = obj.frozen? ? obj.dup : obj
    seen[obj.object_id] = copy
    obj.each_with_index do |item, i|
      copy[i] = deep_thaw(item, except: except, seen: seen)
    end
    copy
  when Set
    copy = obj.frozen? ? obj.dup : obj
    seen[obj.object_id] = copy
    thawed_items = obj.map { |item| deep_thaw(item, except: except, seen: seen) }
    copy.clear
    thawed_items.each { |item| copy.add(item) }
    copy
  when Struct
    copy = obj.frozen? ? obj.dup : obj
    seen[obj.object_id] = copy
    obj.each_pair do |key, value|
      copy[key] = if except.include?(key)
                    value
                  else
                    deep_thaw(value, except: except, seen: seen)
                  end
    end
    copy
  when String
    copy = obj.frozen? ? obj.dup : obj
    seen[obj.object_id] = copy
    copy
  else
    copy = obj.frozen? ? obj.dup : obj
    seen[obj.object_id] = copy
    copy
  end
end

.freeze_hash_keys(hash, seen: nil) ⇒ Object

Recursively freeze only the keys of every Hash reachable from the input, leaving the values mutable. Descends through Arrays and Sets to find nested Hashes.

Parameters:

  • hash (Object)

    object graph whose Hash keys should be frozen

  • seen (Set, nil) (defaults to: nil)

    internal visited-set for circular reference detection

Returns:

  • (Object)

    the original object (with its Hash keys frozen in place)



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/philiprehberger/deep_freeze.rb', line 87

def freeze_hash_keys(hash, seen: nil)
  seen ||= Set.new
  return hash if seen.include?(hash.object_id)

  seen.add(hash.object_id)

  case hash
  when Hash
    hash.each do |key, value|
      deep_freeze_key(key, seen)
      freeze_hash_keys(value, seen: seen)
    end
  when Array
    hash.each { |item| freeze_hash_keys(item, seen: seen) }
  when Set
    hash.each { |item| freeze_hash_keys(item, seen: seen) }
  end

  hash
end