結局書いた。堅いオブジェクト用DSL

http://d.hatena.ne.jp/yssk22/20080803#1217768127 の続き。なんだかんだで、クライアントから受け取るJSON(XML,form-url-encoded)を正しく解釈し、よきに計らってくれるものが必要。こんなドメインオブジェクトは必要でしょうか。私は必要でした(w

to_hash, from_hash でハッシュ互換になったところがポイント。これで、JSONでもXMLでも何でもこい、です(その代わり動作速度は微妙かと。。。。あとは、ActiveRecord で、暗黙の型変換が走るようなので*1、それにならって実装しましたが、object(:is_aオプションなしの場合はHash)と array の取り扱いは少々工夫?。ハッシュが渡されてもオブジェクトに復元可能であれば復元するように努力します。arrayも同様に配列に復元可能であれば復元しようと努力します。

あとで svn 上にコミットするけれど、とりあえずの使い方とソースをバックアップ代わりにべたばり。

すくなくとも、CouchObject::Persistableよりは自分の意図するとおりに動作するようにはしたつもり。次はCouchResource::Validations ですかね。


つかいかた。

>> class DomainObject
>>   include CouchResource::Struct
>>   class NestedDomainObject
>>      include CouchResource::Struct
>>      string :subtitle
>>   end
>>   string :title
>>   array :users
>>   object :meta_tags
>>   object :subobjects, :is_a => NestedDomainObject
>> end
=> nil
>> a = DomainObject.new
=> #<DomainObject:0x1123f94>
>> a.title = "hoge"
=> "hoge"
>> a.users = ["yssk22"]
=> ["yssk22"]
>> a.meta_tags = { :keyword => [:hoge, :fuga], :description => "hogehgoe" }
=> {:keyword=>[:hoge, :fuga], :description=>"hogehgoe"}
>> a.subobjects = DomainObject::NestedDomainObject.new
=> #<DomainObject::NestedDomainObject:0x5ba3b0>
>> a.subobjects.subtitle = "hoge"
=> "hoge"
>> a.to_hash
=> {"title"=>"hoge\n", "subobjects"=>{"class"=>"DomainObject::NestedDomainObject", "subtitle"=>"hoge"}, "class"=>"DomainObject", "users"=>["yssk22"], "meta_tags"=>{"description"=>"hogehgoe", "keyword"=>[:hoge, :fuga]}}
>> b = DomainObject.from_hash(a.to_hash)
=> #<DomainObject:0x12ede74 @attributes={"title"=>"hoge\n", "subobjects"=>#<DomainObject::NestedDomainObject:0x12e99dc @attributes={"subtitle"=>"hoge"}>, "users"=>["yssk22"], "meta_tags"=>{"description"=>"hogehgoe", "keyword"=>[:hoge, :fuga]}}>
>> b.title
=> "hoge\n"
>> 

ソース。plugin/couch_resource/lib/couch_resource/struct.rb に置くことにした。

require 'rubygems'
require 'active_support'
require 'json'
module CouchResource
  module Struct
    def self.included(base)
      base.send(:extend,  ClassMethods)
      base.send(:include, InstanceMethods)
    end

    module ClassMethods
      def string(name, option={})
        option[:is_a] = :string
        register_attribute_member(name, option)
        define_attribute_accessor(name, option)
      end

      def number(name, option={})
        option[:is_a] = :number
        register_attribute_member(name, option)
        define_attribute_accessor(name, option)
      end

      def boolean(name, option={})
        option[:is_a] = :boolean
        register_attribute_member(name, option)
        define_attribute_accessor(name, option)
        method = <<-EOS
          def #{name}?
            get_attribute(:#{name})
          end
EOS
        class_eval(method, __FILE__, __LINE__)
      end

      def array(name, option={})
        option[:is_a] = :array
        register_attribute_member(name, option)
        define_attribute_accessor(name, option)
      end

      def object(name, option={})
        unless option.has_key?(:is_a)
          option[:is_a] = :hash
        end
        register_attribute_member(name, option)
        define_attribute_accessor(name, option)
      end

      def from_hash(hash)
        hash ||= {}
        instance = self.new
        (read_inheritable_attribute(:attribute_members) || {}).each do |name, option|
          instance.set_attribute(name, hash[name])
        end
        instance
      end

      private
      def register_attribute_member(name, option = {})
        attribute_members = read_inheritable_attribute(:attribute_members)
        attribute_members ||= HashWithIndifferentAccess.new({})
        attribute_members[name] = option
        write_inheritable_attribute(:attribute_members, attribute_members)
      end

      def define_attribute_accessor(name, option={})
        define_attribute_read_accessor(name, option)
        define_attribute_write_accessor(name, option)
      end

      def define_attribute_read_accessor(name, option={})
        method = <<-EOS
          def #{name}
            get_attribute(:#{name})
          end
EOS
        class_eval(method, __FILE__, __LINE__)
      end

      def define_attribute_write_accessor(name, option={})
        method = <<-EOS
          def #{name}=(value)
            set_attribute(:#{name}, value)
          end
EOS
        class_eval(method, __FILE__, __LINE__)
      end
    end

    module InstanceMethods
      def set_attribute(name, value)
        attribute_members = self.class.read_inheritable_attribute(:attribute_members) || {}
        if attribute_members.has_key?(name)
          option = attribute_members[name]
          @attributes ||= HashWithIndifferentAccess.new({})
          if value.nil?
            @attributes[name] = nil
          else
            klass = option[:is_a]
            parsed_value = case klass
                        when :string, :number, :boolean, :array, :hash
                          self.send("parse_for_#{klass}_attributes", value)
                        else
                          self.send("parse_for_object_attributes", value, klass)
                        end
            @attributes[name] = parsed_value
          end
        end
      end

      def get_attribute(name)
        @attributes ||= HashWithIndifferentAccess.new({})
        @attributes[name]
      end

      def to_hash
        hash = HashWithIndifferentAccess.new({ :class => self.class.name })
        (self.class.read_inheritable_attribute(:attribute_members) || {}).each do |name, option|
          klass = option[:is_a]
          value = get_attribute(name)
          case klass
          when :string, :number, :boolean, :array, :hash
            hash[name] = value
          else
            if value
              hash[name] = value.to_hash
            else
              hash[name] = nil
            end
          end
        end
        hash
      end
      private
      def parse_for_string_attributes(value)
        value.is_a?(String) ? value : value.to_s
      end

      def parse_for_number_attributes(value)
        if value.is_a?(Numeric)
          value
        else
          v = value.to_s
          if v =~ /^\d+$/
            v.to_i
          else
            v.to_f
          end
        end
      end

      def parse_for_boolean_attributes(value)
        value && true
      end

      def parse_for_array_attributes(value)
        if value.is_a?(Array)
          value
        else
          if value.respond_to?(:to_a)
            value.to_a
          else
            [value]
          end
        end
      end

      def parse_for_hash_attributes(value)
        if value.is_a?(Hash)
          value
        else
          if value.respond_to?(:to_hash)
            value.to_hash
          else
            raise TypeError.new
          end
        end
      end

      def parse_for_object_attributes(value, klass)
        if value.is_a?(klass)
          value
        elsif value.is_a?(Hash)
          klass.from_hash(value)
        else
          raise TypeError.new
        end
      end
    end
  end
end

*1:"integer"の列に文字列を入れるとto_iが呼ばれて値がセットされる