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 名称。
备注:内容仅供参考。
