Adding association management methods to Rails models
If you’re a Rails developer, you’re probably familiar with updating associated records through the association_name_ids=
setter method. That strategy works well, but often leads to cluttered controller methods for managing record associations.
In a current project, I found myself wanting a more convenient interface for add, removing, and resetting record associations. So here is my implementation, where I added connect_*
, disconnect_*
, reconnect_*
to relevant models. With them implemented, the following API calls do exactly what you’d expect 🙃
// Add a new record to a has_many collection (without removing existing ones)
api.put('/authors/1', {
"item": {
"connect_posts": [1, 8]
}
})
// Remove a record from a has_many collection (leaving all others be)
api.put('/authors/1', {
"item": {
"disconnect_posts": [9]
}
})
// Remove all records from a has_many collection and replace with new ones.
api.put('/authors/1', {
"item": {
"reconnect_posts": [2,3]
}
})
// Perform multiple operations in one call.
api.put('/authors/1', {
"item": {
"manage": {
"connect": [1, 8],
"disconnect": [9],
"reconnect": [2,3]
}
}
})
Creating an ActiveSupport::Concern
Concerns in Rails allow us to cleanly extend our modules and classes. Instead of hardcoding these methods into our different models, we can use Concerns and Ruby’s metaprogramming capabilities to define these utility methods dynamically based on the model’s associations.
# app/models/concerns/association_manager.rb
module AssociationManager
extend ActiveSupport::Concern
class_methods do
# The class method used in our Models to register the association
# managers. Merges options, allowing dev to specify that only
# specific manage methods be added
def manage(*associations, **options)
merged_options = { only: %i[connect reconnect disconnect manage].freeze }.merge(options)
associations.each { |association_name| define_manage_methods(association_name, merged_options) }
end
private
# Defines each method for the named association
def define_manage_methods(assoc_name, options)
assoc = reflect_on_association(assoc_name)
options[:only].each do |method|
send("define_#{method}", assoc)
end
end
# 👇 Are all methods dedicated to defining each manager method.
# Ruby's metaprogramming lets us define these methods on the fly.
#
# What's cool about the manage method is that it calls itself the
# other manager methods that are getting defined.
#
# Within each method it handles querying and filtering the record_ids
# necesary for the update.
def define_manage(assoc)
define_method("manage_#{assoc.plural_name}=") do |options|
options.to_a.each do |entry|
method, r_ids = entry
send("#{method}_#{assoc.name}=", r_ids)
end
end
end
# Creates a new union of the existing associations with the new records
# and sends result to record_ids setter.
def define_connect(assoc)
define_method("connect_#{assoc.name}=") do |r_ids|
send "#{assoc.source_reflection_name}_ids=", send("#{assoc.source_reflection_name}_ids").union(r_ids)
end
end
# Replaces existing associations with the new record ids by
# sending new ids to record_ids setter.
def define_reconnect(assoc)
define_method("reconnect_#{assoc.name}=") do |r_ids|
send "#{assoc.source_reflection_name}_ids=", r_ids
end
end
# Creates a new difference of the existing associations and new records
# and sends result to record_ids setter.
def define_disconnect(assoc)
define_method("disconnect_#{assoc.name}=") do |r_ids|
send "#{assoc.source_reflection_name}_ids=", send("#{assoc.source_reflection_name}_ids").difference(r_ids)
end
end
end
end
Adding AssociationManager to a Model
By including the AssociationManager
concern in a model with a has_many
association, we’re able to then register it using the module’s manage
class method.
class Post < ApplicationRecord
include AssociationManager
has_many :comments
manage :comments, only: [:connect, :disconnect]
end
class Comment < ApplicationRecord
include AssociationManager
has_many :posts
manage :posts
end
...joins table implied...
Testing the Implementation
If you love it, go ahead an write some tests for the module! However, I just added some tests to my model.
require 'rails_helper'
Rspec.describe Post do
# other tests and stuff
describe 'has manage methods' do
let(:comment) { create(:comment) }
let(:post) { create(:post, :with_comments) }
it 'impliments the connect_comments method' do
post.connect_comments = [comment.id]
expect(post.comments).to include(comment)
expect(post.comments.count).to be > 1
end
it 'impliments the disconnect_comments method' do
post.comments << create_list(:comment, 3).push(comment)
post.disconnect_comments = [comment.id]
expect(post.comments).not_to include(comment)
end
it 'impliments the reconnect_comments method' do
post.comments << create_list(:comment, 3).push(comment)
post.reconnect_comments = [comment.id]
expect(post.comments).to eq([comment])
end
end
end
Wrap Up
The `AssociationManager` is allows for clean controller methods and powerful API association management. That’s a win win in my book!