Дважды присоединитесь к одной таблице с условиями
Бывают ситуации, когда ActiveRecord задает имя таблицы псевдонимов, если существует несколько объединений с одной и той же таблицей. Я застрял в ситуации, когда эти объединения содержат области (используя "merge" ).
У меня есть отношение "многие ко многим":
Модели table_name: users
Вторая модель table_name: posts
Присоединить имя таблицы: access_levels
Post имеет много пользователей через access_levels и наоборот.
Обе модели пользователя и модель 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)
# }
Я бы назвал sth следующим:
Post.joins(:access_levels).joins(:users).where (...)
ActiveRecord создает псевдоним для второго соединения ('access_levels_users'). Я хочу ссылаться на это имя таблицы внутри допустимой области модели AccessLevel.
V1, очевидно, генерирует a PG::AmbiguousColumn
-Error.
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". Я также попытался использовать isl для создания области:
# 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))
)
}
Полученный запрос остается тем же.
Ответы
Ответ 1
Я смог решить мою собственную проблему тем временем. Я отправлю свое решение, чтобы помочь другим, у которых есть похожие проблемы.
Преамбула: это долгий путь к земле обетования;)
Я установлю как можно короче:
#
# 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'))
Второе соединение использует псевдоним - но условие не использует этот псевдоним.
Арели на помощь!
Я создал все следующие соединения с bare-isl вместо того, чтобы доверять 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 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')
Все условия теперь используют правильный псевдоним!
Ответ 2
После более близкого рассмотрения этой проблемы на аналогичном вопросе здесь я придумал более простое и чистое (на мой взгляд) решение этого вопроса. Я вставляю здесь соответствующие бит моего ответа на другой вопрос для полноты, а также ваш объем.
Суть заключалась в том, чтобы найти способ доступа к текущему объекту arel_table
с его table_aliases
, если они используются, внутри области действия на момент ее выполнения. С этой таблицей вы сможете узнать, используется ли область в JOIN
, которая имеет имя таблицы aliased (несколько соединений в одной таблице), или, с другой стороны, область не имеет псевдонимов для таблицы имя.
# 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
в качестве базового объекта для поиска таблицы isl вместо прежних попыток использования self.class.arel_table
или даже relation.arel_table
. Я вызываю source
на этом объекте, чтобы получить Arel::SelectManager
, который, в свою очередь, даст вам текущую таблицу на #left
, В данный момент есть два варианта: у вас есть Arel::Table
(нет псевдонима, имя таблицы находится на #name
) или у вас есть Arel::Nodes::TableAlias
с псевдонимом на #right
.
Если вам интересно, вот некоторые ссылки, которые я использовал в будущем:
Ответ 3
Я столкнулся с этим вопросом при поиске таких вещей. Я знаю это поздний ответ, но если кто-то еще споткнется здесь, возможно, это может быть для некоторой помощи. Это работает в 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 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 JOINs, вы можете получить это с помощью:
Post.includes(:users, :access_levels).references(:users, :access_levels).first
Но помните, что использование includes
не всегда использует один запрос SQL.