You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Rails 5.2升级后新实例ActiveRecord关联未自动保存问题

问题:Rails 5.0升级到5.2后,关联对象创建顺序变更导致非空约束错误

升级背景

  • Rails版本:从5.0.7.2升级到5.2.8.1
  • Ruby版本:从2.5.0升级到2.7.7

原工作代码(Rails 5.0中正常运行)

def up
    Test.find_by(name: 'Rewarded_Ad_Goal').try(:destroy)
    choice_1565 = Choice.new(weight: 1, value: '0_Control')
    choice_1566 = Choice.new(weight: 1, value: '1_Alt')
    test = Test.create!(name: 'Rewarded_Ad_Goal', choices: [choice_1565, choice_1566], aasm_state: 'stopped')
end

Rails 5.2中触发的错误

irb(main):004:0> test = Ingot::Test.create!(name: 'Rewarded_Ad_Goal', choices: [choice_1565, choice_1566], aasm_state: 'stopped')
   (0.3ms)  BEGIN
  Ingot::Test Exists (0.4ms)  SELECT  1 AS one FROM "ingot_tests" WHERE "ingot_tests"."name" = $1 LIMIT $2  [["name", "Rewarded_Ad_Goal"], ["LIMIT", 1]]
  Ingot::Test Exists (0.4ms)  SELECT  1 AS one FROM "ingot_tests" WHERE "ingot_tests"."name" = $1 LIMIT $2  [["name", "Rewarded_Ad_Goal"], ["LIMIT", 1]]
  SQL (1.8ms)  INSERT INTO "ingot_tests" ("aasm_state", "name", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["aasm_state", "stopped"], ["name", "Rewarded_Ad_Goal"], ["created_at", "2023-04-10 19:17:33.630832"], ["updated_at", "2023-04-10 19:17:33.630832"]]
   (0.3ms)  ROLLBACK
Traceback (most recent call last):
        1: from (irb):4
ActiveRecord::NotNullViolation (PG::NotNullViolation: ERROR:  null value in column "default_choice_id" violates not-null constraint)
DETAIL:  Failing row contains (311, null, null, stopped, null, Rewarded_Ad_Goal, 2023-04-10 19:17:33.630832, 2023-04-10 19:17:33.630832).
: INSERT INTO "ingot_tests" ("aasm_state", "name", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"

相关模型定义

test.rb

class Test < ActiveRecord::Base
    include AASM
    include Contracts

    belongs_to :default_choice, class_name: 'Ingot::Choice'
    has_many :assignments, class_name: 'Ingot::Assignment', dependent: :delete_all
    has_many :choices, class_name: 'Ingot::Choice', dependent: :destroy, autosave: true
    has_many :filters, class_name: 'Ingot::Filter', dependent: :destroy

    accepts_nested_attributes_for :default_choice,
      allow_destroy: true,
      reject_if: :all_blank

    accepts_nested_attributes_for :choices,
      allow_destroy: true,
      reject_if: :all_blank

    accepts_nested_attributes_for :filters,
      allow_destroy: true,
      reject_if: :all_blank

    validates_length_of :choices, minimum: 2, message: 'are missing.  You must specify at least two alternatives.'
    validates_presence_of :default_choice
    validates_presence_of :name
    validates_uniqueness_of :name

    before_validation :set_default_choice,
      unless: :default_choice

    def set_default_choice # confirmed this gets called
      puts "Setting default choice to #{choices.first.id}" # <== nil
      puts "Setting default choice to #{choices.first.weight}"
      puts "Setting default choice to #{choices.first.value}"
      puts "Setting default choice to #{choices.first.test_id}" # <== nil
      self.default_choice ||= choices.first
    end
end

choice.rb

class Choice < ActiveRecord::Base
    belongs_to :test, class_name: 'Ingot::Test'
    has_many :assignments, class_name: 'Ingot::Assignment', dependent: :destroy

    validates_numericality_of :weight
    validates_presence_of :value
    validates_presence_of :weight

    before_validation :set_default_weight

    def test
      super || Test.where(default_choice_id: id).first
    end

    private

    def set_default_weight
      self.weight ||= 1
    end
end

问题分析与解决方案

核心原因:Rails 5.1+的关联保存顺序变更

在Rails 5.0中,创建父对象并关联子对象时,Rails会先保存子对象(获取id),再保存父对象,因此before_validation回调能拿到子对象的id。但从Rails 5.1开始,关联对象的保存顺序发生了关键变化:父对象会先完成验证并尝试保存,之后才处理子对象的保存。这就导致set_default_choice回调触发时,choices还只是内存中的未持久化对象,没有id和test_id,无法满足default_choice_id的非空约束。

解决办法

  1. 调整回调逻辑,手动提前保存子对象:
    修改set_default_choice方法,先保存所有子对象再关联默认选项:

    def set_default_choice
      choices.each(&:save!) if choices.any? # 先持久化子对象
      self.default_choice ||= choices.first
    end
    

    注意:需确保子对象的验证规则都能通过,否则会抛出保存异常。

  2. 改用嵌套属性创建关联对象:
    使用choices_attributes替代直接传入子对象实例,Rails会自动处理正确的保存顺序,确保default_choice能关联到已持久化的子对象:

    test = Test.create!(
      name: 'Rewarded_Ad_Goal',
      aasm_state: 'stopped',
      choices_attributes: [
        { weight: 1, value: '0_Control' },
        { weight: 1, value: '1_Alt' }
      ]
    )
    
  3. 临时调整数据库约束(不推荐长期使用):
    若允许临时修改数据库,可先将default_choice_id改为允许null,创建对象后再设置默认选项,最后改回非空约束。此方式需要额外迁移步骤,仅适合临时过渡。

内容的提问来源于stack exchange,提问作者PragmaticProgrammer

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.25 08:42:36