エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

クエリをオブジェクトにして ActiveRecord から分離する

仕事では Ruby on Rails を主戦場としているわけですが。 最近、クエリメソッドについて考えています。

Rails のクエリメソッドといえば「スコープ」を定義するのが常套手段です。

# app/models/user.rb
class User < ApplicationRecord
  scope :created_at_between, -> (from, to) { where(created_at: from..to) }
end
User.created_at_between('2024-11-01', '2024-11-30')

ここで、クエリメソッドがクラスと一体になっていることが便利なのか否か、このところ考えをめぐらせています。

たとえば、同じクエリメソッドを別のクラスで利用したいばあいはどのように実装するのがよいのか。

Book.created_at_between('2024-11-01', '2024-11-30')

Ruby らしい解としては、モジュールに分離して必要なクラスが mixin するのがよさそうです。

# app/models/concerns/created_at_between.rb
module CreatedAtBetween
  def created_at_between(from, to)
    where(created_at: from..to)
  end
end
# app/models/book.rb
class Book < ApplicationRecord
  extend CreatedAtBetween
end

しかし。 混ぜ合わせるのでなく、もっともっと互いに独立した関係にできないか?

検索すれば、クエリオプジェクトというパタンがすぐにヒットします。

martinfowler.com

これを Ruby に当てはめてみようと思います。

実装は、たとえばこんな感じ。

# app/models/created_at_between.rb
class CreatedAtBetween
  def initialize(from, to)
    @from = from
    @to = to
  end

  def range
    @from..@to
  end

  def apply(query)
    query.where(created_at: range)
  end
end

こんな感じで使えます。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(User)

#apply には ActiveRecord のクラスだけでなくリレーションも渡せます。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(User.limit(3))

クエリの内容が適合すれば、ActiveRecord の種類は問いません。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(Book)

別のクエリオブジェクトも作ってみましょう。

# app/models/order_by.rb
class OrderBy
  def initialize(key, direction)
    @key = key
    @direction = direction
  end

  def apply(query)
    query.order(@key => @direction)
  end
end
OrderBy.new(:age, :asc).apply(User)

#apply の結果もまたクエリなので、#reduce などで畳み込むこともできます。

criteria = [
  CreatedAtBetween.new('2024-11-01', '2024-11-30'),
  OrderBy.new(:age, :asc)
]

criteria.reduce(User) { |query, criterion| criterion.apply(query) }

複数のクエリオブジェクトを集約して、畳み込むためのインタフェースを提供するクラスも考えることができそうです。

# app/models/query.rb
class Query
  def initialize(criteria = [], criterion = nil)
    @criteria = [*criteria, *Array(criterion)]
  end

  def created_at_between(from, to)
    Query.new(@criteria, CreatedAtBetween.new(from, to))
  end

  def order_by(key, direction = 'asc')
    Query.new(@criteria, OrderBy.new(key, direction))
  end

  def apply(query)
    @criteria.reduce(query) do |query, criterion|
      criterion.apply(query)
    end
  end
end

メソッドの戻り値も同じクラスのオブジェクトなので、メソッドをチェインして呼び出せませす。

Query.new
  .created_at_between('2024-11-01', '2024-11-30')
  .order_by(:age, :asc)
  .apply(User)

メソッドを呼び出してもオブジェクトの状態は変わらず、新しいオブジェクトを作成して返すので、途中までのクエリを共用できます。

query = Query.new.created_at_between('2024-11-01', '2024-11-30')

query.order_by(:age, :asc).apply(User)
query.order_by(:title, :asc).apply(Book)

なんとなく ActiveRecord とクエリの分離ができましたが、このままだと ActiveRecord がただのデータの塊のようで Ruby っぽい感じがしません。

小さいメソッドを追加して外から見える呼び出しの方向を変えてみます。

# app/models/user.rb
class User < ApplicationRecord
  def self.match(query)
    query.apply(self)
  end
end
query = Query.new.created_at_between('2024-11-01', '2024-11-30').order_by(:age, :asc)

User.match(query)

ActiveRecord に操作が移って Ruby っぽくなったと思います。

もちろんリレーションからも利用できます。

User.where('name LIKE ?', '%A%').match(query)

さらにこのメソッドをスーパークラスで定義すれば、どの ActiveRecord からも利用できるようになるはずです。

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  def self.match(query)
    query.apply(self)
  end
end
query = Query.new
          .created_at_between('2024-11-01', '2024-11-30')
          .order_by(:created_at, :desc)

User.match(query)
Book.match(query)

悪くなさそうです。

調べればもっと洗練された実装が見つかると思うのですが、自身の理解の最初の一歩としては悪くない感じです。