Rails Dependent Associations
I often find that the dependent parameter gets forgotten on projects. This is easy to do if you're new to Rails. When dependent is not set, nothing terrible happens at first… but once you get further into a project you will start seeing this:
2.1.2 :013 > Comment.first.post.title
Comment Load (0.6ms) SELECT "comments".* FROM "comments" ORDER BY "comments"."id" ASC LIMIT 1
Post Load (0.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 LIMIT 1 [["id", 1]]
NoMethodError: undefined method `title' for nil:NilClass
The cause of the error is that the parent record no longer exists, but the foreign key still references the parent object.
To stop this from happening you can use the dependent option with has_many
and has_one
.
Dependent Options
Dependent simply tells destroy what to do with child records. The available dependent options are as follows:
Destroy
The destroy option is the most common in a Rails app, as it is the most likely scenario. For example, when you delete a post, you probably want to remove the comments as well.
class Post < ActiveRecord::Base
has_many :comments, dependent: :destroy
end
class Comment < ActiveRecord::Base
belongs_to :post
after_destroy { puts "Callback Fired" }
end
Now when you delete the post the comments are also deleted. Any callback's that are defined will also be fired.
2.1.2 :004 > p.destroy
(0.1ms) BEGIN
Comment Load (0.3ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 18]]
SQL (0.2ms) DELETE FROM "comments" WHERE "comments"."id" = $1 [["id", 26]]
Callback Fired
SQL (0.2ms) DELETE FROM "comments" WHERE "comments"."id" = $1 [["id", 27]]
Callback Fired
SQL (0.2ms) DELETE FROM "posts" WHERE "posts"."id" = $1 [["id", 18]]
(1.6ms) COMMIT
=> #<Post id: 18, title: "Post 101", body: nil, created_at: "2015-02-07 16:42:13", updated_at: "2015-02-07 16:42:13">
The destroy option should be a default in Rails because of the ethos of convention over configuration. Currently what happens is the child record doesn't have its foreign key nullified, which is the next option we will look at.
I don't think we could change this default now as it would cause unexpected behaviors when updating to a new Rails version.
Nullify
Nullify is the opposite to destroy, it means the child record is not destroyed after Active Record removes the parent. Curiously, not that many developers seem to know that this option exist.
class Post < ActiveRecord::Base
has_many :comments, dependent: :nullify
end
Now when you delete the post the comment is not deleted. Instead the foreign key is set to Null.
.1.2 :003 > p.destroy
(0.2ms) BEGIN
Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 3]]
SQL (0.3ms) DELETE FROM "comments" WHERE "comments"."id" = $1 [["id", 3]]
SQL (0.2ms) DELETE FROM "posts" WHERE "posts"."id" = $1 [["id", 3]]
(1.7ms) COMMIT
=> #<Post id: 3, title: "Post 101", body: nil, created_at: "2015-01-31 13:01:43", updated_at: "2015-01-31 13:01:43">
This is better than leaving the foreign key set but will still cause an error if you try to access the post.
2.1.2 :006 > Comment.find(4).post.title
Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" = $1 LIMIT 1 [["id", 4]]
NoMethodError: undefined method `title' for nil:NilClass
Delete All
The delete all option is the same as destroy but no callbacks will be executed.
class Comment < ActiveRecord::Base
belongs_to :post
after_destroy { puts "Callback Fired" }
end
class Post < ActiveRecord::Base
has_many :comments, dependent: :delete_all
end
2.1.2 :011 > p.destroy
(0.2ms) BEGIN
SQL (0.3ms) DELETE FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 11]]
SQL (0.3ms) DELETE FROM "posts" WHERE "posts"."id" = $1 [["id", 11]]
(1.4ms) COMMIT
=> #<Post id: 11, title: "Post 101", body: nil, created_at: "2015-02-04 08:48:18", updated_at: "2015-02-04 08:48:18">
Restrict with Exception
This is an option that I have to admit I’ve never seen or used before writing this article. Restrict with exception raises an exception when destroy is called on a parent record with children. The parent will only be deleted if the parent doesn't have associated children.
I can imagine it to be useful if you wanted to protect against posts with comments being deleted.
class Post < ActiveRecord::Base
has_many :comments, dependent: :restrict_with_exception
end
2.1.2 :003 > p.destroy
(0.2ms) BEGIN
(2.3ms) ROLLBACK
ActiveRecord::DeleteRestrictionError: Cannot delete record because of dependent comments
As you can see this raises an ActiveRecord::DeleteRestrictionError exception if a comment exists.
Restrict with Error
The last option is restrict with error which sets an error on the parent record if the parent has child records associated.
class Post < ActiveRecord::Base
has_many :comments, dependent: :restrict_with_error
end
2.1.2 :003 > p.destroy
(0.2ms) BEGIN
(0.2ms) ROLLBACK
=> false
2.1.2 :004 > p.errors
=> #<ActiveModel::Errors:0x000001016a9828 @base=#<Post id: 6, title: "Post 101", body: nil, created_at: "2015-01-31 13:19:36", updated_at: "2015-01-31 13:19:36">, @messages={:base=>["Cannot delete record because dependent comments exist"]}>
Active Record has added an error to the errors attribute in the same way as a validation error would be added. I think this would work well with validations as you can display this error to the admin trying to delete the post. Restrict with error could be a replacement to doing a custom validation rule in less code. The downside you would lose the if/unless functionality available in validation.
I know that the dependent parameter is not the most exciting thing to read about but knowing the less known features in a framework can save you time when working on your next amazing feature.
Alex