Skip to content

Instantly share code, notes, and snippets.

@oboxodo
Last active September 15, 2024 15:31
Show Gist options
  • Save oboxodo/7952fd5e83b7ce77e397b5f1c4ba543a to your computer and use it in GitHub Desktop.
Save oboxodo/7952fd5e83b7ce77e397b5f1c4ba543a to your computer and use it in GitHub Desktop.

Avoid has_many/one + belongs_to circular dependency

This is based on the use of Shopify's packwerk.

Asuming we have a posting pack with a Post model and a commenting pack with a Comment model, and we want the commenting pack to have a declared dependency on the posting pack, then here's a pattern to avoid the has_many :comments usual declaration on the Post model while still being able to have very similar functionality to the dependent option to react/restrict when a post is destroyed.

If you need to fetch a post's comments, you can simply use Comments.where(post: @post) instead of the usual @post.comments. Same result. You can define a scope if you want to shorten it or something.

# packs/commenting/app/models/comment.rb
class Comment < ApplicationRecord
# This is a strong dependency we want to maintain and can't avoid
belongs_to :post
# Optionaly declare a scope for accessing all comments for a given post:
scope :for_post, ->(o) { where(post: o) }
end
# packs/commenting/config/initializer/commenting.rb
Rails.application.configure do
config.to_prepare do
# Instruct Post to let us know when a post is about to be deleted.
# Post itself remains agnostic about the existence of comments or not.
# But we use its public API to react about it attempting to be destroyed.
# This is all based on what Rails itself seems to be doing when handling
# the `dependent` option:
# https://github.com/rails/rails/blob/fc734f28e65ef8829a1a939ee6702c1f349a1d5a/activerecord/lib/active_record/associations/has_many_association.rb#L13
Post.before_destroy do |o|
# Instead of: Post.has_many :comments, dependent: :destroy
Comment.for_post(o).destroy_all
# Instead of: Post.has_many :comments, dependent: :delete_all
Comment.for_post(o).delete_all
# Instead of: Post.has_many :comments, dependent: :nullify
Comment.for_post(o).update_all(post_id: nil)
# Instead of: Post.has_many :comments, dependent: :restrict_with_exception
raise ActiveRecord::DeleteRestrictionError.new(:comments) if Comment.for_post(o).any?
# Instead of: Post.has_many :comments, dependent: :restrict_with_error
# (DISCLAIMER: I have yet to test this one as I haven't needed it just yet)
o.errors.add(:base, :'restrict_dependent_destroy.has_many', record: "comments") if Comment.for_post(o).any?
end
end
end
# packs/commenting/package.yml
enforce_dependencies: true
dependencies:
- packs/posting
# packs/posting/app/models/post.rb
class Post < ApplicationRecord
# Posts don't _really_ need to know the system supports commenting.
# Different ways to declare the unwanted dependency we want to avoid
# has_many :comments, dependent: :destroy
# has_many :comments, dependent: :delete_all
# has_many :comments, dependent: :nullify
# has_many :comments, dependent: :restrict_with_exception
# has_many :comments, dependent: :restrict_with_error
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment