Err the Blog Atom Feed Icon
Err the Blog
Rubyisms and Railities
  • “Extend for Profit”
    – Chris on December 04, 2006

    Yes, for profit! This is exciting. You see, Famous Ryan just made a post about extending ActiveRecord associations. Right here. He uses a classic example, way classic: find_active.

    For reference, here it is (but really, read his post):

    class Organization < ActiveRecord::Base
      has_many :people do
        def find_active
          find(:all, :conditions => ["active = ?", true])
        end
      end
    end
    

    Ryan then shows us his new superpower: organization.people.find_active (where organization is something normal like Organization.find(:first)). Makes sense, right? You can just slap whatever methods you want onto an association to make them More Powerful™.

    So, that’s a pretty good use. Custom finder and all that. But we can get crazier than just finders.

    Take, oh I dunno, tags. Let’s say you have a normal blog which has and belongs to many tags. Comma separated tags.

    class Post < ActiveRecord::Base
      has_and_belongs_to_many :tags
    end
    

    Now I can do Post.find(:first).tags to get an array of tags. Nothing special. How would you get a string of tags in a view?

    Maybe:
    <%= @post.tags.map(&:name).join(', ') %>
    

    That’s one way. But as you and I are on this skinny controller fat model kick, we know the better solution is to add a method like tags_string to our Post model.

    class Post < ActiveRecord::Base
      has_and_belongs_to_many :tags
    
      def tags_string
        tags.map(&:name) * ', '
      end
    end
    

    Now, our view:

    <%= @post.tags_string %>
    

    Nice. A clean, comma separated list of tag names. The acts_as_taggable plugin includes a tags_list method by default which does this same thing (albeit space separated).

    But wait. Wait. What <%= %> does, behind the scenes, is call to_s on what’s passed to it. Knowing that, we can control how objects are <%= %>‘d by overriding their to_s. You’d never want to <%= @post.tags %> because it would just print a bunch of debug crap. Hrm… it sure reads nicely, though…

    What if we did this…

      class Post < ActiveRecord::Base
        has_and_belongs_to_many :tags do
          def to_s
            map(&:name) * ', '
          end
        end
      end
    
    Now we can just do this in our view to get a comma separated list of tags:
    <%= @post.tags %>
    
    Or, in script/console:
    >> @post.tags.class
    => Array
    >> @post.tags.size
    => 2
    >> @post.tags.to_s
    => "bikes, site"
    

    Hot. Extending associations ain’t just for finders no more.

    Update: Wow, sloppy. For the record it’s &:name, not :&name. Fixed it up.

  • Johannes, about 7 hours later:

    Ah, that’s awesome!

    But one question.. What is this: :&name

  • Silvano Stralla, about 6 hours later:

    Thank you! It seems to be a useful idea.

  • mort, about 6 hours later:

    Thaks for the post. Overriding to_s that way has a real “wow factor” into it, but don’t you feel that you’re moving into the model display logic that belongs in the view? If you want your tags semi-colon separated tomorrow you are gonna have to mess with the model and that’s pretty counter-intuitive. I’d rather go with a “display_tags(@post.tags)” kind of helper. What do you think?

  • Hendrik Mans, about 6 hours later:

    Now, doesn’t this completely fall down and crash once you want to display a comma separated list of linked tags? Now to_s would have to know about URLs, which is a no-no in the model. So you’d probably pass a Proc or something that spits out links. Crazy! Fat model and all, I think that kind of stuff still belongs into the V of our little MVC. Even if it means more partials and whatnot.

  • Tim Lucas, about 4 hours later:

    Burnin.

    Hendrik: Sure, if you need links etc then go right ahead and write a helper method, but I think I always end up needing to_s’s for most stuff in the app for various purposes (e.g. link ‘title’s, etc).

  • Daniel Morrison, about 3 hours later:

    Johannes, its how to use the to_proc method that’s a newish addition to Rails

    In the map example, it gives you a nice shortcut. Instead of writing:
    map {|tag| tag.name}
    You can just write:
    map(:&name)
  • Rube, about 1 hour later:

    I just don’t get extending ActiveRecord associations. I understand what its doing, but it seems like every example is something that should go in a model anyway. What if somewhere in your app you want to get a list of all the tags used on your site and then use your <%= %> trick? Wouldn’t you want your to_s def in the model? Same with find_active. I’m just having trouble envisioning a case where I would ONLY want new functionality available through a specific association (or a few specific associations).

    Also, shouldn’t tags_string be a function of tags instead of posts. That feels a little off to me.

  • Chris, 33 minutes later:

    mort: My model knows how to turn a string of tags into an array of tags by breaking on the comma (as opposed to on a semi-colon), so it’s okay if it knows how to rebuild a string of tags from an array. Maybe there needs to be a Tag.separator attribute which is set to comma? Yeah, there does.

    All: Keep your links out of the model, please. This to_s trick is just a shortcut for a really common scenario. Not every scenario. You still need view helpers. Listen to Tim.

    Rube: Yeah, find_active should probably go in the Tag model. I think if you do Post.find(:all).tags.find_active once you’ve defined the method on Tag the association will auto-scope the call to post_id for you, but that’s not the point. The point is you can. In this case, tags_string works on an array of tags—how can you put array logic into a model? You can’t, you need to put it on the array. The association array. to_s is right where it belongs.

  • Chris, 41 minutes later:

    Hey, here are two more ideas if you’re on board with Tag being in charge.

  • mort, about 4 hours later:

    Chris, thanks for answering. Actually my point was more about putting display logic in the model, same as Hendrick’s but he put it better. As Tim and you have made clear now we all basically agree. And anyway, reservations aside, overriding to_s that way is undoubtely nifty.

  • ckozus, 2 days later:

    Isn’t it tags.map(&:name), instead of tags.map(:&name) ?

  • Brandon Keepers, 5 days later:

    Ok, how about combining this post with your previous one

     has_many :people do
        def find_active(*args)
          args << :all if args.empty?
          with_scope :find => { :conditions => ["active = ?", true] } do
            find(*args)
          end
        end
      end
    

    Would allow you to do:

    # all active people
    Organization.people.find_active
    # all active over 21
    Organization.people.find_active(:all, :conditions => ['birthdate < ?', 21.years.ago])
    

    Unfortunately, with_scope appears to get ignored. Any ideas why?

  • Chris, 5 days later:

    Brandon: If you just define find_active on Person, you will be able to do organize.people.find_active(:all) just fine.

  • John Nunemaker, 6 days later:

    Minor detail but map(:&name) * ’, ’ should be map(&:title) * ’, ’ I think.

  • Daniel Morrison, 7 days later:

    John: Good catch on my typo.

  • Anon, 28 days later:

    Perhaps the map could be replaced with to_sentence(:skip_last_comma => true)

  • Sixteen people have commented.
    Chime in.
    Sorry, no more comments :(
This is Err, the weblog of PJ Hyett and Chris Wanstrath.
All original content copyright ©2006-2008 the aforementioned.