Соединитесь за одним столом дважды с условиями

Существуют ситуации, когда 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,

Если вам интересно, вот несколько ссылок, которые я использовал в будущем:

Я сталкивался с этим вопросом, когда искал такие вещи. Я знаю, что это поздний ответ, но если здесь кто-то споткнется, возможно, это поможет. Это работает в 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.

Другие вопросы по тегам