Ruby, JavaScript, Sass, iOS. Stinky Cheese & Beer Advocate. Working at CustomInk and loving it!

Jack has_many :things

Jack Has Many Things I am Jack's sofa, stereo and wardrobe... I make Jack's life complete. I reside in a ActiveRecord table called "things" and Jack is the only one that has the key. This is Jack's life, and it's ending one minute at a time.

As rails developers, we have done this simple relationship over and over again. I'm sure the has_many association is by far the most common in app/db design. It gives a single resource quick and easy access to others, but as your application grows, and depression sets in, we have to open up.

It's cheaper than a movie, and there's free coffee.

Jack With Bob I am talking about groups. Not the underground ones that carve us out of wood, but ones where we share with those around us. This is healing. The time has come to for our objects to do the same, but how?

The problem is that the ActiveRecord has_many association is scoped to an individual. No matter what conditions are tacked on, they are always pwned by the proxy owner. The things they own have ended up owning you! Sure we could model some groupable schema and go through it, ActiveRecord is beautiful that way. But what about our hard work in all those existing has_many associations and scopes?

I felt like destroying something beautiful.

The solution is on everyone's face, it is on the tip of everyone's tongue. I just gave it a name. It is called GroupedScope and it will fundamentally change the constructed SQL for the has_many associations you want to share. The best part, it will leave those associations untouched for continued use. GroupedScope even works with your existing association extensions and any scopes. Let's have a session.

First we need to install the gem. So bundle up in your Gemfile.

gem 'grouped_scope', '~> 3.1.0'

Now let's open up our people schema and the Person model, so Jack can share. First we add a group_id column to the people table.

class PeopleCanChooseAGroup < ActiveRecord::Migration
  def up
    add_column :people, :group_id, :integer
  end
  def down
    remove_column :people, :group_id
  end
end

Next we declare grouped_scope on a few associations.

class Person < ActiveRecord::Base
  has_many :things
  has_many :acquaintances
  has_many :problems
  grouped_scope :things, :problems
end

@jack = Person.find_by_name('Jack')
@bob  = Person.find_by_name('Bob')

@jack.update_attribute :group_id, 1
@bob.update_attribute :group_id, 1

So now every Person object in our app is now ready to share their :things and their :problems. I have also just arbitrarily put Jack and Bob into the same group. Declaring grouped_scope in the model generates a new group instance method that will allow us to either reflect on the group or delegate to the associations we have declared as now haveing grouped scope.

@jack.group   # => [#<Person id: 1, name: "Jack", group_id: 1>, #<Person id: 2, name: "Bob", group_id: 1>]

We all started seeing things differently.

The object returned by the group method, is an instance of GroupedScope::SelfGrouping. It is far cooler than you think. It looks and acts as an enumerable array, but in reality it is a ActiveRecord::Relation object that can delegate to generated grouped association reflections which mimic their originals. Essentially giving the group access to all associated objects of its members, in this case their :things and :problems. Did I loose you?

@jack.problems.size   # => 6
@bob.problems.size    # => 2

@bob.group.problems        # => [#<Problem...>,#<Problem...>,#<Problem...>,#<Problem...>,....]
@bob.group.problems.size   # => 8
@jack.group.problems.size  # => 8

Without going into the detail of Jack's and Bob's problems, we can see that within the group, they are all shared. This is what the GroupedScope gem is really all about. It allows existing has_many associations to be called on the group which changes the SQL generated to be owned by the group, essentially from id = 1 to id IN (SELECT id FROM people WHERE group_id = 1).

The way it accomplishes this is pretty sweet. GroupedScope creates reflections that use a custom association scope method which uses a little Arel magic to build predicate conditions. Since it copies all your existing association reflection options, it can be really smart by maintaining all the logic in the existing association. So options like :class_name, :foreign_key, :though, and :extend will just work! It also lets you chain scopes existing scopes or use your custom association extensions on the original associations. Here is very contrived example:

class Person < ActiveRecord::Base
  has_many :mental_issues, :class_name => "MentalState", :foreign_key => :name do
    def dangerous
      where(:snap_tolerance => 10)
    end
  end
  grouped_scope :mental_issues
end

class MentalState < ActiveRecord::Base
  scope :treatable_by, lambda { |doctor| where(:doctor_id = doctor.id) }
end

@jack.group.mental_issues.dangerous.treatable_by(@doctor) # => [#<MentalState...>,#<MentalState...>]

This is probably one of those "cry for help" things.

Marla The GroupedScope gem is never quite done. It is however well tested and can do exactly all that I have outlined in ActiveRecord 3.1.0. I even have older gem versions that track our legacy 2-3-stable git branch.

Also, you may have noticed that GroupedScope does not try to solve what your group business logic may look like. This is intentional and left to the end user to implement a Group object. This object would be the primary key owner of the group_id column in your models that declare grouped scope. You would also need to declare some sort of belongs_to association to your custom group model too.

Lastly, if we you see something you might like in features or notice a bug, open a issue on our Github project page.

I'd like to thank the Academy.