有条件加入同一张表两次

Posted

技术标签:

【中文标题】有条件加入同一张表两次【英文标题】:Join the same table twice with conditions 【发布时间】:2014-09-15 07:13:46 【问题描述】:

如果同一个表有多个连接,ActiveRecord 会设置别名表名。我陷入了这些联接包含范围(使用“合并”)的情况。

我有一个多对多的关系:

模型表名:users

第二个模型表名: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-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 表的第二个连接设置一个适当的别名“access_levels_posts”。 问题是合并的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))
  )

结果查询保持不变。

【问题讨论】:

您的问题有点令人困惑,但我想我知道您想做什么。将valid 范围更改为joins(:user).where("(valid_from IS NULL OR valid_from &lt; :now) AND (valid_until IS NULL OR valid_until &gt; :now)", now: Time.zone.now).where(users: active: true, or: something ) 【参考方案1】:

在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

如果你有兴趣,这里有一些我在路上使用的参考资料:

一个similar question on SO,用大量代码回答,你可以用它来代替你美丽而简洁的能力。 这个Rails issue和这个other one。

【讨论】:

看起来是一个非常好的解决方案 - 我一定会试一试【参考方案2】:

我在搜索此类内容时遇到了这个问题。我知道这是一个迟到的答案,但如果其他人在这里绊倒,也许这可能会有所帮助。这在 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 请求。

【讨论】:

这太棒了!我想要表名(字符串)而不是实际的 Arel 表,所以我最终得到了(current_scope ? current_scope.table : arel_table).name【参考方案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'))

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

来救援了!

我已经使用裸 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')

现在所有条件都使用正确的别名!

【讨论】:

以上是关于有条件加入同一张表两次的主要内容,如果未能解决你的问题,请参考以下文章

在 2 个数据帧 Spark 中缓存同一张表两次

如何加入一个表两次

加入同一个表两次时分组

mysql加入2个表 - 但必须加入同一个表两次

加入表两次 - 在同一个表的两个不同列上

Sequelize:两次加入同一张表