使用 Rails 6 ActiveRecord 进行完全外连接

Posted

技术标签:

【中文标题】使用 Rails 6 ActiveRecord 进行完全外连接【英文标题】:FULL OUTER JOIN with Rails 6 ActiveRecord 【发布时间】:2020-07-01 20:18:37 【问题描述】:

我正在为一个带有消息和通知的应用程序建模,如下所示(简化):

# db/schema.rb
    create_table :messages do |t|
      ...
    end

    create_table :notifications do |t|
      t.references :message, index: true, foreign_key: true, null: false
      t.references :recipient, index: true, foreign_key:  to_table: :users , null: false
      t.datetime :read_at
      ...
    end

型号:

class User < ApplicationRecord; end
class Message < ApplicationRecord; end
class Notification < ApplicationRecord
  belongs_to :message
  belongs_to :recipient, class_name: 'User'
  accepts_nested_attributes_for :message
end

在这个简化的例子中:

消息表保存实际消息(例如标题、文本、图像...) 通知表会跟踪哪个用户阅读了哪条消息 所有消息都针对所有用户(例如系统范围的“公告”)。概括为让消息针对特定用户。

我正在寻找最简洁(最类似于 Rails)和最高效的方式来加载用户的通知和相关消息,同时确保即使尚未在数据库中为此用户创建任何通知,也能加载所有消息。这是为了避免每次发布新消息时都需要在 DB 中创建与用户数量一样多的通知行。仅当用户已阅读消息(存储read_at 值)时,才会在通知表中添加一行。

我设法使用这个 SQL 来实现它:

  # user.rb

  def notifications
    unsanitised_sql = <<-SQL
      SELECT 
        :user_id AS recipient_id, m.id as message_id, n.read_at, n.created_at, n.updated_at,
        m.text, ...
      FROM (
        SELECT *
        FROM notifications 
        WHERE recipient_id = :user_id
      ) n
      FULL OUTER JOIN messages m
      ON (message_id = m.id)
    SQL

    ActiveRecord::Base
      .connection
      .select_all(ActiveRecord::Base.sanitize_sql [ unsanitised_sql,  user_id: id  ])
      .map do |row|

      Notification.new(
        recipient_id: row['recipient_id'],
        message_id: row['message_id'],
        read_at: row['read_at'],
        created_at: row['created_at'],
        updated_at: row['updated_at'],
        message_attributes: 
          id: row['message_id'],
          text: row['text'],
          ...
        
      )
    end
  end

例如,如果我有一个 id=1 的消息,两个 id=20 和 id=21 的用户,以及一个(id=30,message_id=1,receiver_id=20)的通知,用于用户 21 和消息 1我收到带有read_at=nil 的“虚拟”通知(因为用户还没有阅读它)和相关的消息数据????

> User.find(21).notifications

=> [#<Notification:0x00007fb5c64df138 id: nil, message_id: 1, recipient_id: 21, read_at: nil, created_at: nil, updated_at: nil>]

> User.find(21).notifications.first.message

=> #<Message:0x00007fd57c52b5a8 id: 1, text: ...>

但是:

    非常冗长,例如,每当向MessageNotification 添加新属性时,都需要更新代码; 我不确定性能(我认为这很好,因为所有内容都已在单个查询中加载,例如我认为不存在 N+1 问题); 最重要的是,我真的更愿意在User 上使用has_many :notifications 关联来实现相同的目的,或者如果不可能,使用自定义notifications 范围。这是为了避免急切加载,拥有更流畅的 API,能够加入其他可能的关系等。或者至少我想以某种方式改进语法,使其更像 Rails。

有什么想法吗?

【问题讨论】:

为什么不直接从消息中获取所有行并包含/急切加载带有用户 ID 条件的通知?包含和急切加载都进行外连接,因此即使在通知中没有找到行,它们也会从消息中返回行。如果您想要的是消息列表以及用户是否已阅读它们的指示,那感觉就像您从错误的方向开始。 听起来通知真的是read_receipts。通常,这些在大多数系统中的停留时间不会超过 30 天,这对性能有帮助吗? @max 谢谢,我尝试将has_many :notifications 添加到Message,但随后Message.left_outer_joins(:notifications).where(notifications: recipient_id: 21 ) 返回一个空结果。此外,从消息端加载是一个好主意,但我仍然没有完全决定建模。我可能会将消息信息保留在 Notification 模型中,并将 Message 重命名为 Announcement,例如 github.com/excid3/jumpstart/tree/master/app/models。这是为了更灵活地处理不针对所有用户而仅针对某些用户的消息。所以无论如何我都会对这两种情况的解决方案感兴趣。 @Anthony 谢谢,确实可以选择以这样一种方式加载数据会更好,即自动将超过 X 天的消息显示为已读,而无需创建相应的通知(或 @987654337 @正如你提议的那样称呼他们)。 我意识到包含/急切的加载并不能真正起作用,所以我会重新措辞。您想要的是选择消息中的行并选择notifications.read_at。有很多方法可以做到这一点,例如子查询:Message.left_joins(:notifications).select('messages.*', ['(SELECT 1 FROM notifications n WHERE id.message_id = messages.id AND n.user_id = ?) AS read_by_user', user.id])。在 Postgres 上,您可以使用横向连接。 【参考方案1】:

您可以获取属于该用户的通知。然后检索没有目标用户通知的消息并为他们构建通知。最后合并这两个集合。

class Message < ApplicationRecord
  has_many :notifications
end

class Notification < ApplicationRecord
  belongs_to :message
  belongs_to :recipient, class_name: 'User'
end

class User < ApplicationRecord
  has_many :notifications # you might need `inverse_of: :recipient`

  def all_notifications
    notifications.load + Message
      .where.not(id: notifications.pluck(:message_id)).pluck(:id)
      .map  |message_id| notifications.build(message_id: message_id) 
  end
end

遗憾的是,这仍然会产生一个数组结果。我不确定这是否有可能建立正常的关联。希望其他答案能证明我错了。

我想问你这是否会产生正确的结果?这个问题很复杂,我不确定我对它的理解是否 100% 正确。

【讨论】:

是的,这解决了第 1 点:代码更加简洁。它不能解决第 2 点:加载所有消息,然后分别加载每条消息的通知。也不是第 3 点:不在 ActiveRecord 关联级别上运行。结果确实看起来正确(与我自己的代码相同)。 我已经更新了答案,这应该在很大程度上解决了第 2 点。现在只执行了 2 个 SQL 语句。第一个获取已经存在的通知。第二个仅检索缺少消息通知的消息 ID。第 3 期仍然有效。

以上是关于使用 Rails 6 ActiveRecord 进行完全外连接的主要内容,如果未能解决你的问题,请参考以下文章

将未维护的 ActiveRecord 适配器强制转换为 Rails 版本 6

ActiveRecord Rails将created_at时间戳转换为查询所在的时区

脱离Rails单独使用ActiveRecord的几点需知

为啥 Rails 5 使用 ApplicationRecord 而不是 ActiveRecord::Base?

为此使用啥 Rails-ActiveRecord 关联?

Rails使用ActiveRecord Collection或Array更新,导致ActiveRecord :: RecordInvalid错误