有条件加入同一张表两次
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 < :now) AND (valid_until IS NULL OR valid_until > :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')
现在所有条件都使用正确的别名!
【讨论】:
以上是关于有条件加入同一张表两次的主要内容,如果未能解决你的问题,请参考以下文章