Цель данного обзора показать пару приемов использования метода #merge из модуля ActiveRecord::SpawnMethods, предназначенного для объединения скоупов.
relation_destination.merge(relation_source) добавляет sql-выражение из relation_source в итоговый relation_destination. Приведу пару примеров.
Допустим мы хотим получить список всех оплаченных заказов, но только для клиентов с голубыми глазами. В общем случае это могло бы быть так:
Order.paid.joins(:user).where(:users => { :eye_color => 'blue' })
# => SELECT "orders".*
FROM "orders"
INNER JOIN "users" ON "users"."id" = "orders"."user_id"
WHERE "orders"."status" = 3 AND "users"."eye_color" = 'blue'
Но в таком подходе есть проблема: мы должны знать все тонкости выборки этих самых клиентов с голубыми глазами. И возможно, что таковыми должны быть не только клиенты с признаком eye_color => 'blue', но и со статусом равным 1. В таком случае, чтобы разместить логику «голубых глаз» в едином удобном месте логично сделать scope в модели User:
class User < ActiveRecord::Base
STATUS = { :visible => 1 }
has_many :orders
scope :blue_eye, where(:eye_color => 'blue', :status => STATUS[:visible])
end
Теперь мы можем изменить первый вариант на:
Order.paid.where(:user_id => User.blue_eye.pluck(:id))
# => SELECT id FROM "users"
WHERE "users"."eye_color" = 'blue' AND "users"."status" = 1
# => SELECT "orders".* FROM "orders"
WHERE "orders"."status" = 3 AND "orders"."user_id" IN (2, 3)
Таким образом мы избавились от первоначальной проблемы. Но возник другой момент: такая запись генерирует 2 запроса к БД (к пользователям и к заказам). При этом id'шники пользователей будут переданы во второй sql-запрос. И если пользователей с голубыми глазами будет много (сотни/тысячи), то sql-команда получится очень длинной и в большинстве случаев далеко не оптимальной (разбор плана и т.д.).
И вот на помощь к нам приходит Relation#merge:
Order.paid.joins(:user).merge(User.blue_eye)
# => SELECT "orders".*
FROM "orders" INNER JOIN "users" ON "users"."id" = "orders"."user_id"
WHERE "orders"."status" = 3 AND "users"."eye_color" = 'blue'
AND "users"."status" = 1
Такая цепочка вернет нужные нам заказы за один запрос с использованием SQL оператора JOIN. На мой взгляд, это выглядит достаточно лаконично, причём как с точки зрения AR, так и точки зрения результирующего sql-запроса. Если по какой-то причине мы изменим логику получения клиентов с голубыми глазами, то достаточно будет изменить scope в модели User и все другие запросы получат новые условия.
Давайте теперь станем более социально-ориентированными и сделаем выборку не просто по всем пользователям, а по друзьям. Цель: получить список самых популярных статей среди друзей подтвердивших статус дружбы. Для этого можно воспользоваться вот такими вот моделями:
class Friendship < ActiveRecord::Base
STATUS = {:verified => 5}
belongs_to :user
belongs_to :friend, :class_name => 'User',
:conditions => { :friendships => { :status => STATUS[:verified] } }
end
class Article < ActiveRecord::Base
belongs_to :user
end
class User < ActiveRecord::Base
STATUS = { :visible => 1 }
has_many :friendships, :dependent => :destroy
has_many :friends, :through => :friendships,
:conditions => { :status => STATUS[:visible] }
has_many :articles
end
Несколько вариантов достижения поставленной цели:
1) Все условия в одном вызове:
user = User.first
Article.joins(:user).
joins('INNER JOIN friendships on users.id = friendships.friend_id').
where(:friendships =>
{ :user_id => user, :status => Friendship::STATUS[:verified]}).
where( :users => {:status => User::STATUS[:visible]}).
order('articles.rating desc')
# => SELECT "articles".*
FROM "articles" INNER JOIN "users" ON "users"."id" = "articles"."user_id"
INNER JOIN friendships on users.id = friendships.friend_id
WHERE "friendships"."user_id" = 1 AND "friendships"."status" = 5
AND "users"."status" = 1
ORDER BY articles.rating desc
2) 2 sql-запроса, идентификаторы пользователей передаются списком в функцию IN:
user = User.first
Article.where(:user_id => user.friends).
order('articles.rating desc')
# => SELECT "users".*
FROM "users" INNER JOIN "friendships"
ON "users"."id" = "friendships"."friend_id"
WHERE "friendships"."user_id" = 1
AND ("friendships"."status" = 5 AND "users"."status" = 1)
# => SELECT "articles".* FROM "articles"
WHERE "articles"."user_id" IN (2, 3)
ORDER BY articles.rating desc
3) В один запрос с JOIN:
user = User.first
Article.joins(:user).merge(user.friends.scoped).
order('articles.rating desc')
# => SELECT "articles".*
FROM "articles" INNER JOIN "users" ON "users"."id" = "articles"."user_id"
INNER JOIN "friendships" ON "users"."id" = "friendships"."friend_id"
WHERE "friendships"."user_id" = 1
AND ("friendships"."status" = 5 AND "users"."status" = 1)
ORDER BY articles.rating desc
Пример с Relation#merge демонстрирует как получить sql-запрос с объединением по друзьям. Немаловажно то, что в запрос включены все те условия, которые мы задали в ассоциациях.
На этом можно было бы и закончить данный обзор, но остался ещё один подводный камень: в Rails 3.0.x такой красивый merge к сожалению изменит SELECT "articles".* FROM на SELECT "users".* FROM, что в результате вернет пользователей, а не статьи. Чтобы это побороть, можно добавить метод экземпляра в модель User:
def join_friends
Friendship.scoped.
joins('INNER JOIN friendships on friendships.friend_id = users.id').
where(:user_id => id, :status => Friendship::STATUS[:verified]).
where(:users => {:status => STATUS[:visible]})
end
Тогда популярные статьи друзей можно получить так:
user = User.first
Article.joins(:user).merge(user.join_friends).
order('articles.rating desc')
# => SELECT "articles".*
FROM "articles" INNER JOIN "users" ON "users"."id" = "articles"."user_id"
INNER JOIN friendships on friendships.friend_id = users.id
WHERE "friendships"."user_id" = 1 AND "friendships"."status" = 5
AND "users"."status" = 1
ORDER BY articles.rating desc
Вот и все, спасибо за внимание!
Полезные ссылки:
отличный доклад. вытянул для себя очень много полезного.