Flexible Association Managers in Rails 7 APIs

Adding association management methods to Rails models

Photo by Transly Translation Agency on Unsplash

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!

Posted