Подтвердить что ты не робот

Дважды присоединитесь к одной таблице с условиями

Бывают ситуации, когда 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))
  )
}

Полученный запрос остается тем же.

4b9b3361

Ответ 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.