kdbanman
kdbanman

Reputation: 10567

Is there a lightweight way to lock down a set of keys on a Hash?

To be clear, I'm perfectly happy implementing this functionality as a custom class myself, but I want to make sure I'm not overlooking some bit of ruby or rails magic. I have googled every meaningful permutation of the keywords "ruby rails hash keys values immutable lock freeze". But no luck so far!


Problem: I need to give a Hash a set of keys, possibly at run time, and then lock the set of keys without locking their values. Something like the following:

to_lock = {}
to_lock[:name] = "Bill"
to_lock[:age] = 42

to_lock.freeze_keys     # <-- this is what I'm after, so that:

to_lock[:name] = "Bob"  # <-- this works fine,
to_lock[:height]        # <-- this returns nil, and
to_lock[:height] = 175  # <-- this throws some RuntimeError

Question: Is there a bit of ruby or rails tooling to allow this?


I know of Object#freeze and of Immutable::Hash, but both lock keys and values.

Sticking with out-of-the-box ruby, the use case could be mostly met by manipulating the methods or accessors of classes at runtime, as in this or this, then overriding #method_missing. But that feels quite a bit clunkier. Those techniques also don't really "lock" the set of methods or accessors, it's just awkward to add more. At that point it'd be better to simply write a class that exactly implements the snippet above and maintain it as needed.

Upvotes: 2

Views: 644

Answers (3)

jtzero
jtzero

Reputation: 2254

Sounds like a great use-case for the built-in Struct

irb(main):001:0> s = Struct.new(:name, :age).new('Bill', 175)
=> #<struct name="Bill", age=175>
irb(main):002:0> s.name = 'Bob'
=> "Bob"
irb(main):003:0> s.something_else
NoMethodError: undefined method `something_else' for #<struct name="Bob", age=175>
    from (irb):3
    from /home/jtzero/.rbenv/versions/2.3.0/bin/irb:11:in `<main>'

Upvotes: 1

Cary Swoveland
Cary Swoveland

Reputation: 110725

@meagar has offered an interesting solution, but has pointed out that it only works when attempting to add a key-value pair using Hash#[]. Moreover, it does not prevent keys from being deleted.

Here's another way, but it's rather kludgy, so I think you should probably be looking for a different way to skin your cat.

class Hash
  def frozen_keys_create
    self.merge(self) { |*_,v| [v] }.freeze
  end

  def frozen_keys_get_value(k)
    self[k].first
  end

  def frozen_keys_put_value(k, new_value)
    self[k].replace [new_value]
    self
  end

  def frozen_keys_to_unfrozen
    self.merge(self) { |*_,v| v.first }
  end
end

Now let's put them to use.

Create a frozen hash with each value wrapped in an array

sounds = { :cat=>"meow", :dog=>"woof" }.frozen_keys_create
  #=> {:cat=>["meow"], :dog=>["woof"]}
sounds.frozen?
  #=> true

This prevents keys from being added:

sounds[:pig] = "oink"
  #=> RuntimeError: can't modify frozen Hash
sounds.update(:pig=>"oink")
  #=> RuntimeError: can't modify frozen Hash

or deleted:

sounds.delete(:cat)
  #=> RuntimeError: can't modify frozen Hash
sounds.reject! { |k,_| k==:cat }
  #=> RuntimeError: can't modify frozen Hash

Get a value

sounds.frozen_keys_get_value(:cat)
  #=> "meow"

Change a value

sounds.frozen_keys_put_value(:dog, "oooooowwwww")
  #=> {:cat=>["meow"], :dog=>["oooooowwwww"]}

Convert to a hash whose keys are not frozen

new_sounds = sounds.frozen_keys_to_unfrozen
  #=> {:cat=>"meow", :dog=>"oooooowwwww"} 
new_sounds.frozen?
  #=> false 

Add and delete keys

Maybe even add (private, perhaps) methods to add or delete key(s) to override the desired behaviour.

class Hash
  def frozen_keys_add_key_value(k, value)
    frozen_keys_to_unfrozen.tap { |h| h[k] = value }.frozen_keys_create
  end

  def frozen_keys_delete_key(k)
    frozen_keys_to_unfrozen.reject! { |key| key == k }.frozen_keys_create
  end
end

sounds = { :cat=>"meow", :dog=>"woof" }.frozen_keys_create
  #=> {:cat=>["meow"], :dog=>["oooowwww"]}
new_sounds = sounds.frozen_keys_add_key_value(:pig, "oink")
  #=> {:cat=>["meow"], :dog=>["woof"], :pig=>["oink"]} 
new_sounds.frozen?
  #=> true 
newer_yet = new_sounds.frozen_keys_delete_key(:cat)
  #=> {:dog=>["woof"], :pig=>["oink"]} 
newer_yet.frozen?
  #=> true 

Upvotes: 1

user229044
user229044

Reputation: 239382

You can achieve this by defining a custom []= for your "to-lock" instance of a hash, after you've added the allowed keys:

x = { name: nil, age: nil }

def x.[]=(key, value)
  # blow up unless the key already exists in the hash
  raise 'no' unless keys.include?(key)
  super
end

x[:name] # nil
x[:name] = "Bob" # "Bob"

x[:size] # nil
x[:size] = "large" # raise no

Note that this won't prevent you from inadvertently adding keys using something like merge!.

Upvotes: 3

Related Questions