読者です 読者をやめる 読者になる 読者になる

DataMapperのクエリチェインが使えない(場合の)話

dm-core-1.2.0の話です。

DataMapperのクエリチェイン(正式名称しらない。。)は特定の条件下(DataMapper::Collection#union(|, +))で利用できない理由と、回避策を調べた。 長いです。

class User
  include DataMapper::Resource
  
  property :id, Serial
  property :name, String
  property :active, Boolean
  
  def self.active
    all(active: true)
  end

  def self.name_start_with(prefix)
    all(:name.like => "#{prefix}%")
  end
end

# DataMapperはこんなことができる(クエリチェイン)
User.active.name_start_with('a')
# SELECT "id", "name", "active"
# FROM "users"
# WHERE ("active" = 't' AND "name" LIKE 'a%')
# ORDER BY "id"

# orを使いたい場合はこんな感じ
a = (User.name_start_with('a') | User.name_start_with('b'))
# SELECT "id", "name", "active"
# FROM "users"
# WHERE ("name" LIKE 'a%' OR "name" LIKE 'b%')
# ORDER BY "id"
# likeの条件は維持される

# が、調子に乗ると、、
b = (User.name_start_with('a') | User.name_start_with('b')).active
# SELECT "id", "name", "active"
# FROM "users"
# WHERE "active" = 't'
# ORDER BY "id"
# likeの条件が落ちる!!

c = (User.name_start_with('a') | User.name_start_with('b')).all(active: true)
# SELECT "id", "name", "active"
# FROM "users"
# WHERE (("name" LIKE 'a%' OR "name" LIKE 'b%') AND "active" = 't')
# ORDER BY "id"
# likeの条件は維持される

d = (User.name_start_with('a').active | User.name_start_with('a').active)
# SELECT "id", "name", "active"
# FROM "users"
# WHERE (("name" LIKE 'a%' OR "name" LIKE 'b%') AND "active" = 't')
# ORDER BY "id"
# likeの条件は維持される

actives = User.active
e = (actives.name_start_with('a') | actives.name_start_with('b'))
# SELECT "id", "name", "active"
# FROM "users"
# WHERE (("active" = 't' AND "name" LIKE 'a%') OR
#        ("active" = 't' AND "name" LIKE 'b%'))
# ORDER BY "id"
# likeの条件は維持される

なんで?

まずactiveを呼び出す処理を確認する。 activeはUserに定義しているのでDataMapper::Collectionには存在しない。DataMapper::Collection#method_missingでModelクラスに処理を移譲している。

    def method_missing(method, *args, &block)
      relationships = self.relationships

      if model.respond_to?(method)
        delegate_to_model(method, *args, &block)
      elsif relationship = relationships[method] || relationships[DataMapper::Inflector.singularize(method.to_s).to_sym]
        delegate_to_relationship(relationship, *args)
      else
        super
      end
    end

    def delegate_to_model(method, *args, &block)
      model = self.model
      model.send(:with_scope, query) do
        model.send(method, *args, &block)
      end
    end

DataMapper::Model#with_scopeはDataMapper::Model::Scopeに定義されていて、実装は次の通り。

      def with_scope(query)
        options = if query.kind_of?(Hash)
          query
        else
          query.options
        end

        # merge the current scope with the passed in query
        with_exclusive_scope(self.query.merge(options)) { |*block_args| yield(*block_args) }
      end

with_scopeに渡されるqueryは次の結果である。

(User.name_start_with('a') | User.name_start_with('b')).query

このため、クエリチェインを利用した場合、次のquery.optionsしか利用されない。

(User.name_start_with('a') | User.name_start_with('b')).query.options

次に(a),(b),(c)のケースについてDataMapper::Collectionのqueryとquery.optionsの中身を確認してみる。

a = (User.name_start_with('a') | User.name_start_with('b')).query
# => #<DataMapper::Query
# @repository=:default
# @model=User
# @fields=[
#   #<DataMapper::Property::Serial @model=User @name=:id>,
#   #<DataMapper::Property::String @model=User @name=:name>,
#   #<DataMapper::Property::Boolean @model=User @name=:active>
# ]
# @links=[]
# @conditions=(name LIKE "a%" OR name LIKE "b%")
# @order=[
#   #<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>
# ]
# @limit=nil
# @offset=0
# @reload=false
# @unique=false
# >
a = (User.name_start_with('a') | User.name_start_with('b')).query.options
# => {:fields=>
#  [#<DataMapper::Property::Serial @model=User @name=:id>,
#   #<DataMapper::Property::String @model=User @name=:name>,
#   #<DataMapper::Property::Boolean @model=User @name=:active>],
# :order=>
#  [#<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>],
# :unique=>false,
# :add_reversed=>false,
# :reload=>false}

b = (User.name_start_with('a') | User.name_start_with('b')).active.query
# => #<DataMapper::Query
# @repository=:default
# @model=User
# @fields=[
#   #<DataMapper::Property::Serial @model=User @name=:id>,
#   #<DataMapper::Property::String @model=User @name=:name>,
#   #<DataMapper::Property::Boolean @model=User @name=:active>
# ]
# @links=[]
# @conditions=(active = true)
# @order=[
#   #<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>
# ]
# @limit=nil
# @offset=0
# @reload=false
# @unique=false
# >
b = (User.name_start_with('a') | User.name_start_with('b')).active.query.options
# {:fields=>
#   [#<DataMapper::Property::Serial @model=User @name=:id>,
#    #<DataMapper::Property::String @model=User @name=:name>,
#    #<DataMapper::Property::Boolean @model=User @name=:active>],
#  :order=>
#   [#<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>],
#  :unique=>false,
#  :add_reversed=>false,
#  :reload=>false,
#  :active=>true}

c = (User.name_start_with('a') | User.name_start_with('b')).all(active: true).query
# => #<DataMapper::Query
# @repository=:default
# @model=User
# @fields=[
#   #<DataMapper::Property::Serial @model=User @name=:id>,
#   #<DataMapper::Property::String @model=User @name=:name>,
#   #<DataMapper::Property::Boolean @model=User @name=:active>
# ]
# @links=[]
# @conditions=((name LIKE "a%" OR name LIKE "b%") AND active = true)
# @order=[
#   #<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>
# ]
# @limit=nil
# @offset=0
# @reload=false
# @unique=false
# >
c = (User.name_start_with('a') | User.name_start_with('b')).all(active: true).query.options
# {:fields=>
#   [#<DataMapper::Property::Serial @model=User @name=:id>,
#    #<DataMapper::Property::String @model=User @name=:name>,
#    #<DataMapper::Property::Boolean @model=User @name=:active>],
#  :order=>
#   [#<DataMapper::Query::Direction @target=#<DataMapper::Property::Serial @model=User @name=:id> @operator=:asc>],
#  :unique=>false,
#  :add_reversed=>false,
#  :reload=>false,
#  :active=>true

(b)と(c)で等価なqueryになるべきだが、@conditionsの中身が異なることがわかる。 また、(a)、(b)、(c)のどのquery.optionsにもorの条件が含まれないことがわかる。

という事で、query.optionsにorの条件が含まれないため、orを含むクエリの場合、条件が壊れてしまうことがわかった。

回避策

クエリチェイン使わなければ、意図したSQLになる。

((User.name_start_with('a') | User.name_start_with('b')) & User.active)
# SELECT "id", "name", "active"
# FROM "users"
# WHERE (("name" LIKE 'a%' OR "name" LIKE 'b%') AND "active" = 't')
# ORDER BY "id"

結論

  1. 一度、DataMapper::Collection#union(|, +)を利用すると、それ以降クエリチェインは(クエリが壊れるため)利用できない。

  2. クエリチェインは利用できないがDataMapper::Collection#intersection(&)を利用することで回避できる。

# これは使えない。
(User.name_start_with('a') | User.name_start_with('b')).active

# こっちはOK
((User.name_start_with('a') | User.name_start_with('b')) & User.active)

# これもOK (allは使える)
(User.name_start_with('a') | User.name_start_with('b')).all(active: true)

# allに渡す条件次第ではこれも使える(冗長な書き方)
(User.name_start_with('a') | User.name_start_with('b')).all(User.active.query.options)