简体   繁体   中英

In Ruby on Rails, how do I save many ActiveRecord objects that have references to other instances of the same class?

It's a bit tricky to think of this in the abstract, so here's an example:

schema.rb

create_table "comments", force: :cascade do |t|
  t.string "text"
  t.bigint "post_id"
  t.bigint "author_id"
  t.bigint "parent_id"
  t.index ["parent_id"], name: "index_comments_on_parent_id"
end

comment.rb

class Comment < ApplicationRecord
  belongs_to :parent, class_name: 'Comment'
  belongs_to :author, class_name: 'User'
  belongs_to :post
end

comment_controller.rb

def update
  # params contains data for a chain of comments, each the child of the preceding one
  comment_data = params["_json"]
  comments = []
  comment_data.each do |cd|
    com = Comment.new(text: cd["text"], post_id: cd["post_id"], author_id: cd["user_id"])
    com.parent = comments.last
    comments.push(com)
  end

  comments.each { |com| com.save }

end

So then I try to send something through an HTTP post request:

> POST /comments HTTP/1.1
> Host: mywebsitebackend.net
> Content-Type: application/json

| [
|   {
|      "post_id" : 1,
|      "user_id" : 99,
|      "text" : "You suck!!!!!"
|   },
|   {
|      "post_id" : 1,
|      "user_id" : 1,
|      "text" : "Be respectful, or I'll block you."
|   },
|   {
|      "post_id" : 1,
|      "user_id" : 99,
|      "text" : "Typical *******, doesn't belive in free speech."
|   }
| ]

What I expect: the new comments to be saved to the underlying database, with properly referencing the structure (ie, parent is nil for the first comment, and the previous one for the other two).

What I get: the second comment is saved to the database, but without parent id. The third comment is saved correctly. The first one is lost entirely.

I don't even see how to get what I need even if I manually inspect the reference chain and make sure to save everything in the proper order; this example should have worked because the no-parent comment is saved first. Nor do I really want to delve into that: it'll get really complicated if the underlying structure is more complex, with multiple comment chains and multiple children per comment. Besides, aren't things like this what Ruby and ActiveRecord is supposed to abstract away?

So what am I doing wrong, and how do I get the data to save correctly when multiple new ActiveRecord objects of the same class are created, and some of them reference each other?

Rails version: 5.1.7

OS: macOS 10.15.7 (Catalina)

DB: PostgreSQL 12.4

What you are describing is called a self-referential assocation or a self join .

To setup a self referential assocation you just create a nullable foreign key column that points to the same table:

class CreateObservations < ActiveRecord::Migration[6.0]
  def change
    add_reference :comments, :parent, 
      null: true,
      foreign_key: { to_table: :comments }
  end
end

And an assocation that points back to the same class:

class Comment < ApplicationRecord
  belongs_to :parent, 
             class_name: 'Comment',
             optional: true,
             inverse_of: :children
             
  has_many :children,
             class_name: 'Comment',
             foreign_key: 'parent_id',
             inverse_of: :parent
end

If you don't make the column nullable and the belongs_to assocation optional you end up in a chicken vs egg scenario where you can't actually insert the first record in the table as it has nothing to refer to.

If you want to create a record and children at the same you use accepts_nested_attributes .

class Comment < ApplicationRecord
  belongs_to :parent, 
             class_name: 'Comment',
             optional: true,
             inverse_of: :children
             
  has_many :children,
             class_name: 'Comment',
             foreign_key: 'parent_id',
             inverse_of: :parent

  accepts_nested_attributes_for :children
end

This lets you create a reddit style thread of comments with:

Comment.create!(
  text: "You suck!!!!!", 
  user_id: 99, 
  children_attributes: [
    { 
      text: "Be respectful, or I'll block you.", 
      user_id: 1,
      children_attributes: [
        {
          text: "Typical *******, doesn't belive in free speech.",
          user_id: 99
        }
      ]
    }
  ]
])

As it automatically handles recursion. See the guides for how to to create forms for nested attributes and how to whitelist them in your controller. If the nested attributes contain an id the nested records will be updated instead of creating new records.

Dealing with the whole post_id issue can be done in different ways:

  • Use a callback to set the post from the parent comment. This is my least favorite.
  • Make the column nullable and get the parent by traversing up the tree.
  • Use a polymorphic assocation instead so that a comment can belong to either a comment or a post. Get the original post by traversing up the tree.

The problem seems to be that you are only saving the comments afterwards. Usually Rails only generates the id after the record is saved, so when you assigned the parent_id before you saved it was still nil. You could add all of this in a transaction, just to be sure. Something like this:

def update
  # params contains data for a chain of comments, each the child of the preceding one
  comment_data = params["_json"]
  comments = []
  Comment.transaction do
    comment_data.each do |cd|
      com = Comment.new(text: cd["text"], post_id: cd["post_id"], author_id: cd["user_id"])
      com.parent = comments.last if comments.present?
      com.save!
      comments.push(com)
    end
  end
end

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