require "set" # Load our stuff. # NOTE: Our includes are capable of being loaded in arbitrary order (for spec and stuff), hence the `< Hash` in each of them. [ "smart_hash/**/*.rb", ].each do |fmask| Dir[File.expand_path("../#{fmask}", __FILE__)].each do |fn| require fn end end # == A smarter alternative to OpenStruct # # Major features: # # * You can access attributes as methods or keys. # * Attribute access is strict by default. # * You can use <b>any</b> 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] # See #declare. attr_reader :declared_attrs # See #protect. attr_reader :protected_attrs # Strict mode. Default is <tt>true</tt>. # # 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 attributes specified" if attrs.empty? attrs.each do |attr| [attr, Symbol].tap {|v, klass| v.is_a?(klass) 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 attributes specified" if attrs.empty? attrs.each do |attr| [attr, Symbol].tap {|v, klass| v.is_a?(klass) 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 attributes specified" if attrs.empty? attrs.each do |attr| @declared_attrs.delete(attr) # `Set` is returned. end end def unprotect(*attrs) raise ArgumentError, "No attributes specified" if attrs.empty? attrs.each do |attr| @protected_attrs.delete(attr) end end private # Make private copies of methods we need. [:fetch].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: # At early stages of construction via `[]` half-ready instances might be accessed. # Do determine such situations we need a flag. @is_initialized = true @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] # Extend own class with dynamic methods if needed. if not self.class < DynamicMethods method_names = methods.map(&:to_s) # Skip methods matching forbidden attrs. method_names -= FORBIDDEN_ATTRS.map(&:to_s) + FORBIDDEN_ATTRS.map {|_| "#{_}="} # Collect pieces of code. pcs = [] method_names.each do |method_name| case method_name when /\A(#{ATTR_REGEXP})=\z/ # Assignment. attr = $1.to_sym # NOTE: See `@is_initialized` checks -- our code must take control only when the object is fully initialized. pcs << %{ def #{method_name}(value) if @is_initialized if @protected_attrs.include? :#{attr} raise ArgumentError, "Attribute is protected: #{attr}" else self[:#{attr}] = value end else super end end } # pcs << when /\A(#{ATTR_REGEXP})\z/ # Access. attr = $1.to_sym pcs << %{ def #{method_name}(*args) if @is_initialized and (@declared_attrs.include?(:#{attr}) or has_key?(:#{attr})) if @strict _smart_hash_fetch(:#{attr}) else self[:#{attr}] end else super end end } # pcs << end # case end # method_names.each # Suppress warnings. vrb, $VERBOSE = $VERBOSE, nil # Create dynamic methods. DynamicMethods.class_eval pcs.join("\n") # Restore warnings. $VERBOSE = vrb # Include dynamic methods. self.class.class_eval "include DynamicMethods" end 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/ # Assignment. Method name is pre-validated for us by Ruby. attr = $1.to_sym raise ArgumentError, "Attribute is protected: #{attr}" if @protected_attrs.include? attr self[attr] = args[0] when /\A(#{ATTR_REGEXP})\z/ # Access. attr = $1.to_sym if @strict _smart_hash_fetch(attr) else self[attr] end else super end end end