require 'json' require 'yaml' require_relative 'blobject/version' # Wraps a hash to provide arbitrarily nested object-style attribute access class Blobject # filter :to_ary else Blobject#to_ary returns a # blobject which is not cool, especially if you are puts. ProhibitedNames = [:to_ary] # pass an optional hash of values to preload # you can also pass a block, the new Blobject will be yield def initialize hash = {} @hash = Hash.new hash.each do |key, value| key = key.to_sym unless key.is_a? Symbol @hash[key] = value end @hash.each do |name, node| @hash[name] = self.class.send(:__blobjectify__, node) end yield self if block_given? end def empty? @hash.empty? end # delegates to the internal Hash def inspect @hash.inspect end # access the internal hash. be careful, this is _not_ a copy def hash @hash end # creates a recursive copy of the internal hash def to_hash h = hash.dup @hash.each do |name, node| h[name] = node.to_hash if node.respond_to? :to_hash end h end # method_missing is only called the first time an attribute is used. successive calls use # memoized getters, setters and checkers def method_missing method, *params, &block __tag_and_raise__ NoMethodError.new(method) if ProhibitedNames.include?(method) case # assignment in conditionals is usually a bad smell, here it helps minimize regex matching when (name = method[/^\w+$/, 0]) && params.length == 0 # the call is an attribute reader return self.class.new.freeze if frozen? and not @hash.has_key?(method) self.class.send :__define_attribute__, name return send(method) if @hash.has_key? method # close the scope for storing call chain parent = self nested_blobject = self.class.new store_in_parent = lambda do parent.send "#{name}=", nested_blobject nested_blobject.send :remove_instance_variable, :@store_in_parent end nested_blobject.instance_variable_set :@store_in_parent, store_in_parent return nested_blobject when (name = method[/^(\w+)=$/, 1]) && params.length == 1 # the call is an attribute writer self.class.send :__define_attribute__, name return send method, params.first when (name = method[/^(\w+)\?$/, 1]) && params.length == 0 # the call is an attribute checker self.class.send :__define_attribute__, name return send method end super end def respond_to? method return true if self.methods.include?(method) return false if ProhibitedNames.include?(method) method = method.to_s [/^(\w+)=$/, /^(\w+)\?$/, /^\w+$/].any? do |r| r.match(method) end end # compares Blobjects to Blobjects or Hashes def == other return @hash == other.hash if other.class <= Blobject return @hash == other if other.class <= Hash super end # hash-like access to the Blobject's attributes def [] name send name end # hash-like attribtue setter def []= name, value send "#{name.to_s}=", value end # freeze a Blobject to prevent it being modified def freeze @hash.freeze super end def freeze_r self.class.send(:__freeze_r__, self) freeze end # returns a hash which can be serialized as json. # this is for use in rails controllers: `render json: blobject` def as_json *args return hash.as_json(*args) if hash.respond_to? :as_json to_hash end # serialize the Blobject as a json string def to_json *args as_json.to_json *args end # serialize the Blobject as a yaml string def to_yaml as_yaml.to_yaml end # get a Blobject from a json string # if the yaml string describes an array, an array will be returned def self.from_json json __blobjectify__(JSON.parse(json)) end # get a Blobject from a yaml string # if the yaml string describes an array, an array will be returned def self.from_yaml yaml __blobjectify__(YAML.load(yaml)) end private # to avoid naming collisions private method names are prefixed and suffix with double unerscores (__) # Used to tag and reraise errors from a Blobject # Refer to "Tagging exceptions with modules" on p97 in Exceptional Ruby by Avdi Grimm # errors from this library can be handled with rescue Blobject::Error module Error; end def __tag_and_raise__ e raise e rescue e.extend Blobject::Error raise e end class << self private def __freeze_r__ object case object when Array return object.each do |e| e.freeze __freeze_r__(e) end when Hash return object.each do |k, v| v.freeze __freeze_r__(v) end when Blobject object.freeze __freeze_r__ object.hash else object.freeze end end def __blobjectify__ object array = object if object.is_a? Array hash = object if object.is_a? Hash return array.map{|a| __blobjectify__(a)} if array return Blobject.new(hash) if hash return object end def __define_attribute__ name __tag_and_raise__ NameError.new("invalid attribute name #{name}") unless name =~ /^\w+$/ name = name.to_sym methods = self.instance_methods setter_name = (name.to_s + '=').to_sym unless methods.include? setter_name self.send :define_method, setter_name do |value| begin value = self.class.send(:__blobjectify__, value) if value.is_a?(Hash) or value.is_a?(Array) @hash[name] = value rescue ex __tag_and_raise__(ex) end @store_in_parent.call unless @store_in_parent.nil? end end unless methods.include? name self.send :define_method, name do value = @hash[name] if value.nil? value = self.class.new @hash[name] = value unless frozen? end value end end checker_name = (name.to_s + '?').to_sym unless methods.include? checker_name self.send :define_method, checker_name do @hash.key?(name) end end name end end end