Соединитесь за одним столом дважды с условиями
Существуют ситуации, когда ActiveRecord устанавливает имя таблицы псевдонимов, если существует несколько объединений с одной и той же таблицей. Я застрял в ситуации, когда эти объединения содержат области действия (используя 'слияние').
У меня есть отношения многие ко многим:
Модели table_name:
users
Вторая модель table_name:
posts
Присоединиться к имени таблицы:
access_levels
Пост имеет много пользователей через access_levels и наоборот.
И модель User, и модель Post имеют одно и то же отношение:
has_many :access_levels, -> { merge(AccessLevel.valid) }
Область видимости внутри модели AccessLevel выглядит следующим образом:
# v1
scope :valid, -> {
where("(valid_from IS NULL OR valid_from < :now) AND (valid_until IS NULL OR valid_until > :now)", :now => Time.zone.now)
}
# v2
# scope :valid, -> {
# where("(#{table_name}.valid_from IS NULL OR #{table_name}.valid_from < :now) AND (#{table_name}.valid_until IS NULL OR #{table_name}.valid_until > :now)", :now => Time.zone.now)
# }
Я хотел бы позвонить вот так:
Post.joins(:access_levels).joins(:users).where (...)
ActiveRecord создает псевдоним для второго соединения ('access_levels_users'). Я хочу сослаться на это имя таблицы внутри "действительной" области видимости модели AccessLevel.
V1, очевидно, генерирует PG::AmbiguousColumn
-Ошибка.
V2 приводит к префиксу обоих условий access_levels.
, что семантически неправильно.
Вот как я генерирую запрос: (упрощенно)
# inside of a policy
scope = Post.
joins(:access_levels).
where("access_levels.level" => 1, "access_levels.user_id" => current_user.id)
# inside of my controller
scope.joins(:users).select([
Post.arel_table[Arel.star],
"hstore(array_agg(users.id::text), array_agg(users.email::text)) user_names"
]).distinct.group("posts.id")
Сгенерированный запрос выглядит так (используя valid
сфера v2 сверху):
SELECT "posts".*, hstore(array_agg(users.id::text), array_agg(users.email::text)) user_names
FROM "posts"
INNER JOIN "access_levels" ON "access_levels"."post_id" = "posts"."id" AND (("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-07-24 05:38:09.274104') AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-07-24 05:38:09.274132'))
INNER JOIN "users" ON "users"."id" = "access_levels"."user_id"
INNER JOIN "access_levels" "access_levels_posts" ON "access_levels_posts"."post_id" = "posts"."id" AND (("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-07-24 05:38:09.274675') AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-07-24 05:38:09.274688'))
WHERE "posts"."deleted_at" IS NULL AND "access_levels"."level" = 4 AND "access_levels"."user_id" = 1 GROUP BY posts.id
ActiveRecord устанавливает собственный псевдоним access_levels_posts для второго соединения таблицы access_levels. Проблема в том, что объединены valid
-scope префикс столбца с "access_levels" вместо "access_levels_posts". Я также попытался использовать arel для создания области видимости:
# v3
scope :valid, -> {
where arel_table[:valid_from].eq(nil).or(arel_table[:valid_from].lt(Time.zone.now)).and(
arel_table[:valid_until].eq(nil).or(arel_table[:valid_until].gt(Time.zone.now))
)
}
Полученный запрос остается прежним.
3 ответа
Тем временем я смог решить свою проблему. Я опубликую свое решение, чтобы помочь другим, у которых есть подобные проблемы.
Преамбула: Это долгий путь к земле обетованной;)
Я сделаю настройку как можно короче:
#
# Setup
#
class Post < ActiveRecord::Base
has_many :access_levels, -> { merge(AccessLevel.valid) }
has_many :users, :through => :access_levels
end
class AccessLevel < ActiveRecord::Base
belongs_to :post
belongs_to :user
scope :valid, -> {
where arel_table[:valid_from].eq(nil).or(arel_table[:valid_from].lt(Time.zone.now)).and(
arel_table[:valid_until].eq(nil).or(arel_table[:valid_until].gt(Time.zone.now))
)
}
enum :level => [:publisher, :subscriber]
end
class User < ActiveRecord::Base
has_many :access_levels, -> { merge(AccessLevel.valid) }
has_many :users, :through => :access_levels
end
Первоначальной целью было назвать что-то вроде этого (чтобы добавить дополнительные условия и т. Д.):
Post.joins(:users).joins(:access_levels)
Это приводит к семантически неправильному запросу:
SELECT "posts".* FROM "posts"
INNER JOIN "access_levels"
ON "access_levels"."post_id" = "posts"."id"
AND (("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-09-15 20:42:46.835548')
AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-09-15 20:42:46.835688'))
INNER JOIN "users"
ON "users"."id" = "access_levels"."user_id"
INNER JOIN "access_levels" "access_levels_posts"
ON "access_levels_posts"."post_id" = "posts"."id"
AND (("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-09-15 20:42:46.836090')
AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-09-15 20:42:46.836163'))
Второе соединение использует псевдоним, но условие не использует этот псевдоним.
Арель на помощь!
Я построил все следующие соединения с пустым арлом вместо того, чтобы доверять ActiveRecord. К сожалению, кажется, что объединение обоих не всегда работает, как ожидалось. Но, по крайней мере, это так работает. В этом примере я использую внешние объединения, так что в любом случае мне придется создавать их самостоятельно. Кроме того, все эти запросы хранятся внутри политик (с использованием Pundit). Таким образом, они легко тестируются, и нет никакого жирного контроллера или какой-либо избыточности. Так что я в порядке с дополнительным кодом.
#
# Our starting point ;)
#
scope = Post
#
# Rebuild `scope.joins(:users)` or `scope.joins(:access_levels => :user)`
# No magic here.
#
join = Post.arel_table.join(AccessLevel.arel_table, Arel::Nodes::OuterJoin).on(
Post.arel_table[:id].eq(AccessLevel.arel_table[:post_id]).
and(AccessLevel.valid.where_values)
).join_sources
scope = scope.joins(join)
join = AccessLevel.arel_table.join(User.arel_table, Arel::Nodes::OuterJoin).on(
AccessLevel.arel_table[:user_id].eq(User.arel_table[:id])
).join_sources
scope = scope.joins(join)
#
# Now let's join the access_levels table for a second time while reusing the AccessLevel.valid scope.
# To accomplish that, we temporarily swap AccessLevel.table_name
#
table_alias = 'al' # This will be the alias
temporary_table_name = AccessLevel.table_name # We want to restore the original table_name later
AccessLevel.table_name = table_alias # Set the alias as the table_name
valid_clause = AccessLevel.valid.where_values # Store the condition with our temporarily table_name
AccessLevel.table_name = temporary_table_name # Restore the original table_name
#
# We're now able to use the table_alias combined with our valid_clause
#
join = Post.arel_table.join(AccessLevel.arel_table.alias(table_alias), Arel::Nodes::OuterJoin).on(
Post.arel_table[:id].eq(AccessLevel.arel_table.alias(table_alias)[:post_id]).
and(valid_clause)
).join_sources
scope = scope.joins(join)
После всей крови, пота и слез вот наш итоговый запрос:
SELECT "posts".* FROM "posts"
LEFT OUTER JOIN "access_levels"
ON "posts"."id" = "access_levels"."post_id"
AND ("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-09-15 20:35:34.420077')
AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-09-15 20:35:34.420189')
LEFT OUTER JOIN "users"
ON "access_levels"."user_id" = "users"."id"
LEFT OUTER JOIN "access_levels" "al"
ON "posts"."id" = "al"."post_id"
AND ("al"."valid_from" IS NULL OR "al"."valid_from" < '2014-09-15 20:35:41.678492')
AND ("al"."valid_until" IS NULL OR "al"."valid_until" > '2014-09-15 20:35:41.678603')
Все условия теперь используют правильный псевдоним!
Посмотрев ближе на эту проблему по похожему вопросу, я нашел более простое и понятное (на мой взгляд) решение этого вопроса. Я вставляю здесь соответствующие фрагменты моего ответа на другой вопрос для полноты, а также ваш охват.
Дело было в том, чтобы найти способ получить доступ к текущему arel_table
объект, с его table_aliases
если они используются, внутри области действия в момент ее выполнения. С помощью этой таблицы вы сможете узнать, используется ли область в JOIN
с псевдонимом имени таблицы (несколько объединений в одной таблице), или, с другой стороны, в области нет псевдонима для имени таблицы.
# based on your v2
scope :valid, -> {
where("(#{current_table_from_scope}.valid_from IS NULL OR
#{current_table_from_scope}.valid_from < :now) AND
(#{current_table_from_scope}.valid_until IS NULL OR
#{current_table_from_scope}.valid_until > :now)",
:now => Time.zone.now)
}
def self.current_table_from_scope
current_table = current_scope.arel.source.left
case current_table
when Arel::Table
current_table.name
when Arel::Nodes::TableAlias
current_table.right
else
fail
end
end
я использую current_scope
в качестве базового объекта для поиска таблицы arel, вместо предыдущих попыток использования self.class.arel_table
или даже relation.arel_table
, Я зову source
на этом объекте, чтобы получить Arel::SelectManager
что в свою очередь даст вам текущую таблицу на #left
, На данный момент есть два варианта: что у вас там есть Arel::Table
(без псевдонима, имя таблицы включено #name
) или что у вас есть Arel::Nodes::TableAlias
с псевдонимом на его #right
,
Если вам интересно, вот несколько ссылок, которые я использовал в будущем:
- На аналогичный вопрос по SO ответил тонна кода, которую вы могли бы использовать вместо своей красивой и лаконичной Способности.
- Этот вопрос Rails и этот другой.
Я сталкивался с этим вопросом, когда искал такие вещи. Я знаю, что это поздний ответ, но если здесь кто-то споткнется, возможно, это поможет. Это работает в Rails 4.2.2, возможно, этого нельзя было сделать, когда был задан вопрос.
Этот ответ был вдохновлен ответом @dgilperez, но немного упрощен. Также используя правильную область. Итак, вот оно.
class Post < ActiveRecord::Base
# the scope of the used association must be used
has_many :access_levels, -> { merge(AccessLevel.valid(current_scope)) }
has_many :users, :through => :access_levels
end
class AccessLevel < ActiveRecord::Base
belongs_to :post
belongs_to :user
# have an optional parameter for another scope than the scope of this class
scope :valid, ->(cur_scope = nil) {
# 'current_scope.table' is the same as 'current_scope.arel.source.left',
# and there is no need to investigate if it's an alias or not.
ar_table = cur_scope && cur_scope.table || arel_table
now = Time.zone.now
where(
ar_table[:valid_from].eq(nil).or(ar_table[:valid_from].lt(now)).and(
ar_table[:valid_until].eq(nil).or(ar_table[:valid_until].gt(now)))
)
}
enum :level => [:publisher, :subscriber]
end
class User < ActiveRecord::Base
# the scope of the used association must be used
has_many :access_levels, -> { merge(AccessLevel.valid(current_scope)) }
has_many :users, :through => :access_levels
end
И не нужно иметь его в двух соединениях
Post.joins(:users, :access_levels).first
Я видел, что вы также перешли на использование OUTER JOIN, вы можете получить это с помощью:
Post.includes(:users, :access_levels).references(:users, :access_levels).first
Но будьте осторожны, используя includes
не всегда использует один запрос SQL.