删除 nested object 后如何触发 count 更新
结论
不要在 after_save 中再次调用 save。这样会重复触发 after_save,最终导致 stack level too deep。
处理 nested attributes 时,通常可以继续使用 before_save,但不要用下面这种直接查询数据库的方式计算:
payment_items.sum(:total_payout_cents)
应该根据内存中的关联对象求和,同时排除已经标记为删除的记录:
before_save :update_payment_total
private
def update_payment_total
self.amount_cents = payment_items
.reject(&:marked_for_destruction?)
.sum { |item| item.total_payout_cents.to_i }
end
这样,无论 payment items 是新增、修改,还是通过 _destroy 删除,都能正确参与计算。计算结果也会和 Payment 在同一次保存事务中写入。
为什么原来的计算不准确
下面这行代码通常会生成 SQL,汇总数据库中当前已有的记录:
payment_items.sum(:total_payout_cents)
执行 before_save 时,通过 nested attributes 删除的记录往往只被标记为待删除:
item.marked_for_destruction? # => true
这些记录此时可能还在数据库中,所以 SQL 的 SUM 仍会将它们计入总额。与此同时,尚未写入数据库的新记录,以及尚未保存的金额修改,也可能无法通过数据库聚合得到。
改用 Ruby 块求和,会读取当前请求处理后的内存状态:
payment_items.sum { |item| item.total_payout_cents.to_i }
再用 marked_for_destruction? 排除即将删除的项目,就能算出本次保存完成后应有的总额。
完整示例
假设模型使用 accepts_nested_attributes_for:
class Payment < ApplicationRecord
has_many :payment_items, dependent: :destroy
accepts_nested_attributes_for :payment_items,
allow_destroy: true
before_save :update_payment_total
private
def update_payment_total
self.amount_cents = payment_items
.reject(&:marked_for_destruction?)
.sum { |item| item.total_payout_cents.to_i }
end
end
提交 nested attributes 时,可以通过 _destroy 标记要删除的记录:
{
payment: {
payment_items_attributes: {
"0" => {
id: "123",
_destroy: "1"
}
}
}
}
启用 allow_destroy: true 后,对应对象会在保存过程中被标记为删除。上面的 callback 在计算总额时会忽略该对象。
如果必须基于数据库中已经保存的数据计算
如果业务要求等所有关联记录提交后,再通过数据库聚合计算总额,可以使用 after_commit,并通过跳过 callback 的方式更新字段:
class Payment < ApplicationRecord
has_many :payment_items, dependent: :destroy
after_commit :refresh_payment_total, on: %i[create update]
private
def refresh_payment_total
update_column(
:amount_cents,
payment_items.sum(:total_payout_cents)
)
end
end
update_column 不会再次运行模型的保存 callback,因此不会造成递归。
但这种写法会多执行一次数据库更新,而且总额更新和 Payment 的首次保存不在同一个事务中。对于大多数 nested attributes 场景,前面的 before_save 配合 marked_for_destruction? 更合适。
注意事项
- 不要在
after_save中直接调用save、save!、update或update_attributes,否则会再次触发after_save。 - 金额字段可能为
nil时,可以用to_i将其按0处理。如果nil属于非法数据,应该添加 validation,而不是静默转换。 - 如果
PaymentItem可以绕过 Payment 单独创建、修改或删除,Payment 上的 callback 不一定会执行。此时可以通过服务对象统一处理修改,或者在PaymentItem的after_commit中重新计算所属 Payment。 - 如果项目数量很大,将全部记录加载到内存中可能带来性能问题。这时可以在事务完成后使用数据库聚合,但要接受第二次更新以及并发一致性方面的取舍。
备注:内容仅供参考。
