Modularized Association Methods in Rails 3.2

— January 20, 2012 at 11:03 PST


Happy Friday! It's Rails 3.2 day! The official release announcement mentions a few of the big changes, but I'd like to take a moment to highlight a relatively small change I was responsible for, one that I hope may make your life a little easier.

From the ActiveRecord CHANGELOG:

Generated association methods are created within a separate module to allow overriding and
composition using `super`. For a class named `MyModel`, the module is named
`MyModel::GeneratedFeatureMethods`. It is included into the model class immediately after
the `generated_attributes_methods` module defined in ActiveModel, so association methods
override attribute methods of the same name. *Josh Susser*

The point of this change is to allow more flexibility in working with associations in your model classes. When you define an association, ActiveRecord automagically generates some methods for you to work with the association. For example, a has_many :patches association generates the methods patches and patches= (and a few others).

Previously, those association methods were inserted directly into your model class. This change moves those methods into their own module which is then included in your model class. Your model gets the same methods through inheritance, but also gets to override those methods and still call them using super. Let's take a look at two ways this makes things easier for you.

Sometimes you want to replace the standard generated association methods. That's always been easy to do simply by defining new methods in your model class. The only wrinkle was that you had to make sure you defined your method after you set up the association, or calling has_many would overwrite your method, since last writer wins. That was usually not a problem, but sometimes plugins or other monkey patching extensions could add an association after your model's class was defined, which wouldn't give you a chance to add your method afterwards. With this change, you don't have to worry about those order dependencies anymore. Since those methods are generated in their own module, the order doesn't matter. This is a pretty small issue all told and I doubt it affected many people, but it's still worth mentioning.

The real reason for this change is being able to compose your own methods with the standard generated methods. Before this change, you'd have to use alias_method_chain or some other fancy footwork to layer your own logic on top of the standard association functionality. Either that or you'd have to somehow duplicate the standard behavior in your own method. Ick. Now you can compose methods using inheritance and super, the way Alan Kay intended you to. Here's the example from the docs:

class Car < ActiveRecord::Base
  belongs_to :owner
  belongs_to :old_owner

  def owner=(new_owner)
    self.old_owner = self.owner
    super
  end
end

If you're familiar with ActiveRecord it's probably fairly obvious what's going on there, but I'll spell it out for the new kids. When you define the belongs_to :owner association, that generates a standard owner= method, and puts it in the module named Car::GeneratedFeatureMethods, which is the closest ancestor of class Car. If you're curious what this looks like, fire up the rails console and type Car.ancestors to see the class's inheritance chain. (Or use your own app and model, since that will be much easier than making up a new app just to see that one thing.)

In this Car class, you can see that changing owners keeps track of the old owner, so the new owner knows who to call when he can't figure out how to open the trunk. The generated owner= method does a fair amount of stuff including managing counter caches, running callbacks, setting inverse associations, etc. Skipping that could break a number of things, so after saving the old owner, you also want to run the generated method. Since it's in a module that Car inherits from, you only have to call super to get that to run. No muss, no fuss!

One more step towards simpler OOP in Rails! Thanks to my fellow Ruby Rogues Avdi Grimm and James Edward Gray II for complaining about the old state of things enough to motivate me to finally go fix this.

24 commentsactiverecord, associations, rails

Comments
  1. Zach Dennis2012-01-20 11:13:56

    Very nice addition Josh! I've got a few association over-rides which can be cleaned up because of this!

  2. Eirik Dentz Sinclair2012-01-20 11:24:05

    This is something my colleagues and I have often complained about as well. Thank you very much for taking the time to clean this up.

  3. Rizwan Reza2012-01-20 11:26:03

    That'll be certainly handy. Great work!

  4. Mark Thomson2012-01-20 11:27:30

    V. cool. Though I'm a relative noob, I ran into a need like this just this morning (before I read this) and had to use alias_method to allow me to access the old method.

  5. Helmut Juskewycz2012-01-20 12:07:32

    Although I havent had the need for this change yet, it is a great change and a good improvement to the Rails structure.

    Thank you for the patch and for taking the time to write the blog post.

  6. Kurtis Rainbolt-Greene2012-01-20 13:04:31

    Bloody brilliant, great work!

  7. Pablo Herrero2012-01-20 14:00:56

    Very cool addition!

  8. egor2012-01-21 00:55:38

    may be super(new_owner)

  9. Josh Susser2012-01-21 08:49:10

    @egor: Calling super with no args in a method is a special way of saying "call super and use the same args this method was called with." You have to say super() to call super explicitly with no args.

  10. Peter Vandenabeele2012-01-21 14:27:49

    Thanks !

    Does this mean that all documentation for "Overwriting default accessors" can be updated from Rails 3.2 on ?

    E.g. http://api.rubyonrails.org/classes/ActiveRecord/Base.html

    def length=(minutes)

    write_attribute(:length, minutes.to_i * 60)

    end

    Would then become (?)

    def length=(minutes)

    super(minutes.to_i * 60)

    end

    and the "write_attribute" will be less needed ?

  11. Collin Miller2012-01-22 03:55:51

    :D SLAM DUNK

  12. Josh Susser2012-01-22 17:22:29

    @Peter: Rails already had modularized attribute accessor methods before 3.2. This new change is for association methods. But that's a good point about the accessor docs - they should probably be updated.

  13. postmodern2012-01-22 21:40:34

    DataMapper has also had this feature for years. Good to see ActiveRecord improving!

  14. Ben2012-01-23 04:55:40

    Sounds like a cool change. A part of me wonders though if the overriding of default behaviours such as this is a good thing.

    For example if Rails expects certain functionality out of ActiveRecord subclass accessors and mutators, you can get yourself into trouble by modifying that expected behaviour. While you could still previously do this, the roundabout way of accomplishing this would act as a sort of code smell warning.

    I'm newer to Ruby / Rails though so I can appreciate that for the more experienced having this control is a boon (the scenario above may also not really be an issue, not sure). Would appreciate your thoughts on whether this is good practice!

  15. Ben2012-01-23 04:57:28

    Er sorry, I see this specifically refers to association methods. I'd like to update my question on best practice of overriding default association methods then, not accessors / mutators.

  16. Pan Thomakos2012-01-25 11:41:46

    This is a great improvement that I think should be the de-facto way to implement these kinds of declaratively defined helper methods. I'm assuming you used something like an anonymous module to implement this? I wrote a blog post about this type of meta-programming a little while back.

    While I think this is very useful I often find it hard to ensure that this type of logic is properly consolidated. For example, someone might be able to change a car owner in more than one way:

    car.owner = owner
    car.owner_id = owner.id
    

    If the logic is only implemented in one place, and AR automatically exposes these methods, it's hard to cover all your bases. Wouldn't it be better to create a single access point? Maybe hiding owner_id as a private attribute?

  17. Stephen Touset2012-01-27 19:13:46

    It is included into the model class immediately after the generated_attributes_methods module defined in ActiveModel, so association methods override attribute methods of the same name.

    By this description, it oughtn't work. Included modules are lower in priority in the method lookup chain, so won't override methods that already exist in the object they're included into.

    You did test this, right? :)

  18. Josh Susser2012-01-27 19:32:41

    @Pan: similar technique, but it's a named module so you can tell what's going on when you look at MyModel.ancestors. I'm not going to speak much to your other point because that's a huge conversation, but basically I disagree. There are very few methods to override, and it's not a big deal.

    @Stephen: The inheritance chain looks like MyModel -> ::GeneratedFeatureMethods -> ::GeneratedAttributeMethods -> ActiveRecord::Base. The association methods used to go in MyModel, but now they go in GeneratedFeatureMethods. Make sense?

  19. Stephen Touset2012-01-27 20:17:53

    Yep. The way you wrote it,

    so association methods override attribute methods

    sounded like the methods in ::GeneratedFetureMethods were intended to overwrite attribute methods in MyModel.

  20. Jesse Storimer2012-02-02 08:26:24

    I can think of several times this would have been useful. Thanks for the addition!

  21. blj2012-02-14 13:45:29

    Thanks. Nice one.

  22. bjl2012-03-02 13:33:16

    Am thinking this change has broken a lazy initialization process I was using for a polymorphic one-to-one association. Previously:

    has_one subdomain, :as => :subdomainable

    def subdomain
    self.build_subdomain unless self.subdomain.present?
    self.subdomain
    end

    ... now after upgrading to Rails 3.2, this code generates a stack overflow from recursion.

    Any time I attempt to see if the subdomain exists within the subdomain method I generate a recursive stack overflow.

    Is there a better way to lazily-initialize a related object like this?

  23. Grant Hutchins 2012-03-03 07:40:30

    Wow, yesterday someone asked me about how to override an association setter and I forlornly said they probably couldn't call super. Then today I happen upon this article and you've fixed it for us before we even needed it. Thanks!

  24. Josh Susser2012-03-15 15:55:03

    @bjl: try changing self.subdomain to super.subdomain. It looks like you're doing infinite recursion.

Sorry, comments for this article are closed.