简体   繁体   中英

Rails how to create two models that have a has_one dependency

I have two models the account model depends on the id of the user model. I need them to be saved in the same transaction and if the validation on one of them fails then the transaction should fail. The problem is that the user model is getting saved even if the account model is invalid. What is that I'm doing wrong ?

Also why is rails open another transaction after saving ?

And here is the code :

# == Schema Information
#
# Table name: users
#
#  id              :integer          not null, primary key
#  username        :string(255)
#  password_digest :string(255)
#  active          :boolean
#  created_at      :datetime
#  updated_at      :datetime
#

class User < ActiveRecord::Base
  has_secure_password

  has_one :account, dependent: :destroy, autosave: true

  validates :username, presence: true, uniqueness: true
end



# == Schema Information
#
# Table name: accounts
#
#  id              :integer          not null, primary key
#  name            :string(255)
#  first_name      :string(255)
#  last_name       :string(255)
#  user_id         :integer
#  account_type_id :integer
#  created_at      :datetime
#  updated_at      :datetime
#

class Account < ActiveRecord::Base
  belongs_to :user

  validates_presence_of :first_name, :last_name, if: Proc.new { |record| record.is_customer? }
  validates_presence_of :name, if: Proc.new { |record| record.is_business? }

  validates_presence_of :user_id, :account_type_id

  def is_customer?
    self.account_type_id == 1
  end

  def is_business?
    self.account_type_id == 2
  end

  def define_business
    self.account_type_id = 2
  end
end




def create
  @user = User.new user_params
  @user.create_account business_params

  @user.account.define_business

  respond_to do |format|
    if @user.save && @account.save
      set_current_account @user

      format.html { redirect_to business_dashboard_path }
      format.json { head :created, location: business_dashboard_path }
    else
      format.html { render :new }
      format.json { render json: { errors: @account.errors }, status: :unprocessable_entity }
    end
  end
end

Console output:

SQL (0.4ms)  INSERT INTO "users" ("created_at", "password_digest", "updated_at", "username") VALUES (?, ?, ?, ?)  [["created_at", Fri, 21 Mar 2014 21:10:15 UTC +00:00], ["password_digest", "$2a$10$93pKlgadnoetCpBCdSSRtenLDbWnribprhLVX.gZ6i35WcsI6UuRi"], ["updated_at", Fri, 21 Mar 2014 21:10:15 UTC +00:00], ["username", "business12@email.com"]]
(7.2ms)  commit transaction
(0.0ms)  begin transaction
(0.0ms)  rollback transaction

Rails wraps model operations in a transaction by default. If you execute:

@user.save && @account.save

you get two transactions, one for each call of the method save . If the first DB operation succeeds, the second is executed.

With this code, three things can possibly happen:

  1. @user is invalid, so @user.save fails and returns false in which case no record is created in the DB.
  2. @user is valid, so @user.save succeeds and returns true , but @account is invalid, so @account.save fails in which case 1 (user) record is created in the DB.
  3. @user is valid, so @user.save succeeds and returns true , and @account is valid as well in which case both records are created in the DB.

You want to eliminate #2. You cannot use @user.save && @account.save in your case. It does not execute both DB operations in one transaction.

Let me start by saying that in order to save two models in a single transaction, you have two options:

1. Use an explicit transaction

You could explicitly do both in a transaction like so:

User.transaction do
  @user.save!
  @account.save!
end

Note that bang methods are used here ( save! , not save ) because they raise exceptions in case of an invalid model which triggers a roll-back of the transaction.

This way, you end up with either 0 or 2 records in your DB. You could implement this as a convenience method in one of your models or in your controller.

2. Use callbacks

You could also use callbacks . This can only be done in a model, like so:

class User < ActiveRecord::Base
  has_secure_password

  has_one :account, dependent: :destroy, autosave: true

  after_create :create_account

  private

  def create_account
    # do something
  end
end

This triggers a method create_account in your user model (consider making it private) which is executed after the user is successfully created in the DB.

Given that your user model might not have all the relevant information it needs to create the account, option #1 (ie Account.transaction do ) seems like the better fit for you.

You're already using autosave: true which is correct but unfortunately you're using .create_account instead of build_account . The has_one Association Reference explains:

The build_association method returns a new object of the associated type. This object will be instantiated from the passed attributes, and the link through its foreign key will be set, but the associated object will not yet be saved.

Since you're using autosave: true on the User association, you could build (not create) the account and then only save the user, which then saves the associated account as well. The benefit of using this is you don't need to worry about transactions at all.

I've prepared a gist to demonstrate the difference between your code and mine. You can download both files and run them like so:

ruby what_you_have.rb
ruby what_you_need.rb

You'll notice the tests will fail on what_you_have.rb and pass on what_you_need.rb .

When using create_account and user.save && account.save , the tests always fail with an invalid user and a valid account. Why is that? Because with user.create_account , you've already saved a valid account in your database before attempting to save the user.

When using build_account and then only user.save , you tell ActiveRecord to save the user (given it's valid) and save the account along the way. If one of them is invalid, user.save does not save anything.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM