仕事では 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
しかし。 混ぜ合わせるのでなく、もっともっと互いに独立した関係にできないか?
検索すれば、クエリオプジェクトというパタンがすぐにヒットします。
これを 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)
悪くなさそうです。
調べればもっと洗練された実装が見つかると思うのですが、自身の理解の最初の一歩としては悪くない感じです。