PAYATHON 2026

dependent: :nullify 能否清空多个 foreign key?

支付老李

结论

销毁 User 时,可以同时清空 Payment 中的多个 foreign key,但每个 foreign key 都要对应一个名称不同的 has_many association。

如果两次都定义为 has_many :payments,后一个定义会覆盖前一个。两个 dependent: :nullify callback 最后都可能指向后定义的关联,结果只有一个 foreign key 被清空,另一个仍然引用即将删除的 User,从而触发 PG::ForeignKeyViolation。

正确的关联定义

seller 和 buyer 应使用不同的关联名称,再通过 class_name 指定它们关联的都是 Payment:

class User < ApplicationRecord
  has_many :sold_payments,
             class_name: "Payment",
             foreign_key: :seller_id,
             inverse_of: :seller,
             dependent: :nullify

  has_many :bought_payments,
             class_name: "Payment",
             foreign_key: :buyer_id,
             inverse_of: :buyer,
             dependent: :nullify
end

Payment 中也要分别定义两个 belongs_to:

class Payment < ApplicationRecord
  belongs_to :seller,
             class_name: "User",
             foreign_key: :seller_id,
             inverse_of: :sold_payments,
             optional: true

  belongs_to :buyer,
             class_name: "User",
             foreign_key: :buyer_id,
             inverse_of: :bought_payments,
             optional: true
end

执行下面的代码时:

user.destroy

Rails 会分别处理这两个字段:

payments.seller_id = NULL
payments.buyer_id  = NULL

完成后再删除该 User。

为什么同名 association 会出错

下面的写法存在名称冲突:

class User < ApplicationRecord
  has_many :payments,
           class_name: "Payment",
           foreign_key: :seller_id,
           dependent: :nullify

  has_many :payments,
           class_name: "Payment",
           foreign_key: :buyer_id,
           dependent: :nullify
end

两条关联都叫 payments。Active Record 根据 association 名称保存和查找关联配置,因此第二个定义会取代第一个定义对应的 reflection。这样一来,代码中后定义的 foreign_key 通常能被清空,另一个 foreign key 则会保留原值。

例如,要删除的用户仍被 payments.seller_id 引用时,PostgreSQL 会拒绝执行删除操作:

PG::ForeignKeyViolation

问题不在于 dependent: :nullify 只能处理一个 foreign key,而在于两个 association 使用了相同的名称。

数据库字段必须允许 NULL

使用 dependent: :nullify 的前提是 seller_id 和 buyer_id 都能写入 NULL。迁移中不能为这两个字段设置 null: false:

class AllowNullUserReferencesOnPayments < ActiveRecord::Migration[7.0]
  def change
    change_column_null :payments, :seller_id, true
    change_column_null :payments, :buyer_id, true
  end
end

migration 的版本号应与项目当前使用的 Rails 版本一致。

用户删除后,这些关联可能为空,因此对应的 belongs_to 通常也要设置:

optional: true

这样,模型语义与数据库约束才能保持一致。

测试示例

测试时应同时检查两个 foreign key,不能只检查 association:

RSpec.describe User do
  it "nullifies seller_id and buyer_id when the user is destroyed" do
    user = create(:user)
    payment = create(
      :payment,
      seller: user,
      buyer: user
    )

    user.destroy!

    payment.reload

    expect(payment.seller_id).to be_nil
    expect(payment.buyer_id).to be_nil
  end
end

还可以分别测试用户只作为 seller 或 buyer 的情况:

expect { seller.destroy! }
  .to change { payment.reload.seller_id }
  .from(seller.id)
  .to(nil)
expect { buyer.destroy! }
  .to change { payment.reload.buyer_id }
  .from(buyer.id)
  .to(nil)

也可以交给数据库处理

如果删除操作不一定通过 Active Record 执行,或者希望数据库始终保证这一行为,可以为两个 foreign key 都配置 ON DELETE SET NULL:

class SetNullOnUserDeletion < ActiveRecord::Migration[7.0]
  def change
    remove_foreign_key :payments, column: :seller_id
    remove_foreign_key :payments, column: :buyer_id

    add_foreign_key :payments,
                    :users,
                    column: :seller_id,
                    on_delete: :nullify

    add_foreign_key :payments,
                    :users,
                    column: :buyer_id,
                    on_delete: :nullify
  end
end

采用这种方案时,两列仍然必须允许写入 NULL。数据库外键动作与模型层的 dependent: :nullify 在职责上有所重叠,因此项目中应明确由 Rails callback 还是数据库负责,避免配置含义不清。

修复的重点是不要重复定义 has_many :payments,而要使用 sold_payments、bought_payments 这类不同的 association 名称。

备注:内容仅供参考。