在条件下两次加入同一个表

时间:2014-07-23 21:55:57

标签: sql ruby-on-rails-4 rails-activerecord arel

如果存在多个具有相同表的联接,则有些情况下ActiveRecord会设置别名表名。我遇到了这些联接包含范围的情况(使用' merge')。

我有多对多的关系:

  

模型table_name:users

     

第二个模型table_name:posts

     

加入表名:access_levels

帖子通过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)
  # }

我想这样打电话:

Post.joins(:access_levels).joins(:users).where (...)

ActiveRecord为第二个联接创建别名(&#39; access_levels_users&#39;)。我想在&#39;有效的&#39;中引用这个表名。 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设置适当的别名&#39; access_levels_posts&#39;对于access_levels表的第二次连接。 问题是合并后的valid - 范围会在列前面添加&#39; access_levels&#39;而不是&#39; access_levels_posts&#39;。我还尝试使用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 个答案:

答案 0 :(得分:6)

similar question here上仔细研究这个问题之后,我想出了一个更简单,更清洁(我的眼睛)这个问题的解决方案。我在这里粘贴了我对另一个问题的答案的相关部分,以及完整性,以及你的范围。

关键是要找到一种方法来访问当前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,其{{1}上有别名}}。

如果您有兴趣,这里有一些我在路上使用的参考文献:

答案 1 :(得分: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'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请求。

答案 2 :(得分:2)

在此期间,我能够解决自己的问题。我会发布我的解决方案,以帮助其他有类似问题的人。

序言:这是承诺之地的漫长道路;)

我会尽量缩短设置:

#
# 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'))

第二个连接使用别名 - 但条件不使用此别名。

Arel救援!

我使用裸arel构建了以下所有连接,而不是信任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')

现在所有条件都使用了合适的别名!