require "set"
# Get us `SmartHash::Loose`. It's usually `Dir[]` in other gems, but we've only got 1 file at the moment.
require File.expand_path("../smart_hash/loose", __FILE__)
# == A smarter alternative to OpenStruct
#
# Major features:
#
# * You can access attributes as methods or keys.
# * Attribute access is strict by default.
# * You can use any attribute names.
# * Descends from `Hash` and inherits its rich feature set.
#
# See {rubydoc documentation}[http://rubydoc.info/github/dadooda/smart_hash/master/frames] for basic usage examples.
class SmartHash < Hash
# Attribute name regexp without delimiters.
ATTR_REGEXP = /[a-zA-Z_]\w*/
# Attribute names that are forbidden.
# Forbidden attrs cannot be manupulated as such and are handled as methods only.
FORBIDDEN_ATTRS = [:default, :default_proc, :strict]
# Gem version.
VERSION = "0.1.0"
# See #declare.
attr_reader :declared_attrs
# See #protect.
attr_reader :protected_attrs
# Strict mode. Default is true.
#
# person = SmartHash[]
# person.invalid_stuff # KeyError: key not found: :invalid_stuff
#
# person.strict = false
# person.invalid_stuff # => nil
attr_accessor :strict
def initialize(*args)
super
_smart_hash_init
end
# Alternative constructor.
#
# person = SmartHash[]
def self.[](*args)
super.tap do |_|
_.instance_eval do
_smart_hash_init
end
end
end
# Declare attributes. By declaring the attributes you ensure that there's no
# interference from existing methods.
#
# person = SmartHash[]
# person.declare(:size)
# person.size # KeyError: key not found: :size
#
# person.size = "XL"
# person.size # => "XL"
#
# See also #undeclare.
def declare(*attrs)
raise ArgumentError, "No attrs specified" if attrs.empty?
attrs.each do |attr|
(v = attr).is_a?(klass = Symbol) or raise ArgumentError, "#{klass} expected, #{v.class} (#{v.inspect}) given"
attr.to_s.match /\A#{ATTR_REGEXP}\z/ or raise ArgumentError, "Incorrect attribute name '#{attr}'"
@declared_attrs << attr # `Set` is returned.
end
end
# Protect attributes from being assigned.
#
# person = SmartHash[]
# person.name = "John"
# person.protect(:name)
#
# person.name = "Bob" # ArgumentError: Attribute 'name' is protected
#
# See also #unprotect.
def protect(*attrs)
raise ArgumentError, "No attrs specified" if attrs.empty?
attrs.each do |attr|
(v = attr).is_a?(klass = Symbol) or raise ArgumentError, "#{klass} expected, #{v.class} (#{v.inspect}) given"
attr.to_s.match /\A#{ATTR_REGEXP}\z/ or raise ArgumentError, "Incorrect attribute name '#{attr}'"
@protected_attrs << attr
end
end
def undeclare(*attrs)
raise ArgumentError, "No attrs specified" if attrs.empty?
attrs.each do |attr|
@declared_attrs.delete(attr) # `Set` is returned.
end
end
def unprotect(*attrs)
raise ArgumentError, "No attrs specified" if attrs.empty?
attrs.each do |attr|
@protected_attrs.delete(attr)
end
end
private
# Make private copies of methods we need.
[:fetch, :instance_eval].each do |method_name|
my_method_name = "_smart_hash_#{method_name}".to_sym
alias_method my_method_name, method_name
private my_method_name
end
# Common post-initialize routine.
def _smart_hash_init #:nodoc:
@declared_attrs = Set[]
@strict = true
# Protect only the bare minimum. Technically speaking, assigning ANYTHING that exists as a method is potentially dangerous
# or confusing. So it's fairly pointless to try to protect everything. If the person wants to screw everything up on purpose,
# he'll find a way to do it anyway.
@protected_attrs = Set[:inspect, :to_s]
# Suppress warnings.
vrb, $VERBOSE = $VERBOSE, nil
# Insert lookup routine for existing methods, such as size.
methods.map(&:to_s).each do |method_name|
# Install control routine on correct attribute access methods only.
# NOTE: Check longer REs first.
case method_name
when /\A(#{ATTR_REGEXP})=\z/
# Case "r.attr=".
attr = $1.to_sym
next if FORBIDDEN_ATTRS.include? attr
_smart_hash_instance_eval <<-EOT
def #{method_name}(value)
raise ArgumentError, "Attribute '#{attr}' is protected" if @protected_attrs.include? :#{attr}
self[:#{attr}] = value
end
EOT
when /\A#{ATTR_REGEXP}\z/
# Case "r.attr".
next if FORBIDDEN_ATTRS.include? attr
_smart_hash_instance_eval <<-EOT
def #{method_name}(*args)
if @declared_attrs.include?(:#{method_name}) or has_key?(:#{method_name})
if @strict
_smart_hash_fetch(:#{method_name})
else
self[:#{method_name}]
end
else
super
end
end
EOT
end # case
end # each
# Restore warnings.
$VERBOSE = vrb
end
def method_missing(method_name, *args)
# NOTE: No need to check for forbidden attrs here, since they exist as methods by definition.
case method_name
when /\A(.+)=\z/
# Case "r.attr=". Attribute assignment. Method name is pre-validated for us by Ruby.
attr = $1.to_sym
raise ArgumentError, "Attribute '#{attr}' is protected" if @protected_attrs.include? attr
self[attr] = args[0]
when /\A#{ATTR_REGEXP}\z/
# Case "r.attr".
if @strict
_smart_hash_fetch(method_name)
else
self[method_name]
end
else
super
end
end
end